Skip to content

Commit

Permalink
Dpstat 800 - header (#12)
Browse files Browse the repository at this point in the history
* Add header component
* Add UserProfile endpoint
  • Loading branch information
ssb-jnk authored Jan 23, 2024
1 parent 5ba41eb commit 159c395
Show file tree
Hide file tree
Showing 17 changed files with 328 additions and 134 deletions.
88 changes: 33 additions & 55 deletions package-lock.json

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,15 @@
"preview": "vite preview"
},
"dependencies": {
"@esbuild-plugins/node-globals-polyfill": "^0.2.3",
"@statisticsnorway/ssb-component-library": "^2.0.96",
"dotenv": "^16.3.1",
"express": "4.18.2",
"jsonwebtoken": "^9.0.2",
"jwks-rsa": "^3.1.0",
"lightship": "^9.0.3",
"nodemon": "^3.0.2",
"memory-cache": "^0.2.0",
"nodemon": "^3.0.3",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-responsive": "^9.0.2",
Expand Down
111 changes: 92 additions & 19 deletions server.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import ViteExpress from 'vite-express';
import { createLightship } from 'lightship';
import cache from 'memory-cache';
import express from 'express';
import jwt from 'jsonwebtoken';
import jwksClient from 'jwks-rsa';
Expand All @@ -21,58 +22,130 @@ const client = jwksClient({

app.post('/api/verify-token', (req, res) => {
if (!req.headers.authorization.startsWith("Bearer")) {
return res.status(401).json({ success: false, message: 'No token provided' });
return res.status(401).json({ message: 'No token provided' });
}

const token = req.headers.authorization.split("Bearer ")[1];

const decodedToken = jwt.decode(token, { complete: true });
if (!decodedToken) {
return res.status(400).json({ success: false, message: 'Invalid token format' });
return res.status(400).json({ message: 'Invalid token format' });
}

const kid = decodedToken.header.kid;
getPublicKeyFromKeycloak(kid)
.then(publicKey => {
jwt.verify(token, publicKey, { algorithms: ['RS256'] }, (err, decoded) => {
if (err) {
return res.status(401).json({ success: false, message: 'Invalid token' });
return res.status(401).json({ message: 'Invalid token' });
}
res.json({ success: true, user: decoded });
res.json({ user: decoded });
});
})
.catch(error => {
console.error(error);
res.status(500).json({ success: false, message: 'Server error', error: error.message });
res.status(500).json({ message: 'Server error', error: error.message });
});
});

app.get('/api/teams', (req, res) => {
if (!req.headers.authorization.startsWith("Bearer")) {
return res.status(401).json({ success: false, message: 'No token provided' });
return res.status(401).json({ message: 'No token provided' });
}

const token = req.headers.authorization.split("Bearer ")[1];
const url = `${process.env.VITE_DAPLA_TEAM_API_URL}/teams`;

fetch(url, {
fetch(url, getFetchOptions(token))
.then(response => {
if (!response.ok) {
throw new Error('Response not ok');
}
return response.json();
}).then(data => {
return res.json(data._embedded.teams);
}).catch(error => {
console.error(error);
res.status(500).json({ message: 'Server error', error: error.message });
});
});

app.get('/api/userProfile', async (req, res) => {
if (!req.headers.authorization || !req.headers.authorization.startsWith("Bearer")) {
return res.status(401).json({ message: 'No token provided' });
}

try {
const token = req.headers.authorization.split("Bearer ")[1];
const jwt = JSON.parse(atob(token.split('.')[1]));

const cacheKey = `userProfile-${jwt.email}`;
const cachedUserProfile = cache.get(cacheKey);
if (cachedUserProfile) {
return res.json(cachedUserProfile);
}

const [userProfile, photo, manager] = await Promise.all([
fetchUserProfile(token, jwt.email),
fetchPhoto(token, jwt.email),
fetchUserManager(token, jwt.email)
]);
const data = { ...userProfile, photo: photo, manager: { ...manager } };
cache.put(cacheKey, data, 3600000);

return res.json(data);
} catch (error) {
console.error(error);
return res.status(500).json({ message: 'Server error', error: error.message });
}
});

async function fetchUserProfile(token, email) {
const url = `${process.env.VITE_DAPLA_TEAM_API_URL}/users/${email}`;
const response = await fetch(url, getFetchOptions(token));

if (!response.ok) {
throw new Error('Failed to fetch user profile');
}

return response.json();
}

async function fetchUserManager(token) {
const jwt = JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString());
const email = jwt.email;
const url = `${process.env.VITE_DAPLA_TEAM_API_URL}/users/${email}/manager`;
const response = await fetch(url, getFetchOptions(token));

if (!response.ok) {
throw new Error('Failed to fetch user profile');
}

return response.json();
}

async function fetchPhoto(token, email) {
const url = `${process.env.VITE_DAPLA_TEAM_API_URL}/users/${email}/photo`;
const response = await fetch(url, getFetchOptions(token));

if (!response.ok) {
throw new Error('Failed to fetch photo');
}

const arrayBuffer = await response.arrayBuffer();
const photoBuffer = Buffer.from(arrayBuffer);
return photoBuffer.toString('base64');
}

function getFetchOptions(token) {
return {
method: "GET",
headers: {
"accept": "*/*",
"Authorization": `Bearer ${token}`,
}
}).then(response => {
if (!response.ok) {
throw new Error('Response not ok');
}
return response.json();
}).then(data => {
return res.json({ success: true, data: data._embedded.teams });
}).catch(error => {
console.error(error);
res.status(500).json({ success: false, message: 'Server error', error: error.message });
});
});
};
}

function getPublicKeyFromKeycloak(kid) {
return new Promise((resolve, reject) => {
Expand Down
10 changes: 7 additions & 3 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,13 @@ import Users from './pages/Users';
import Login from './pages/Login/Login';

import { Routes, Route, useLocation } from 'react-router-dom';
import { jwtRegex } from './utils/regex';

export default function App() {
const isLoggedIn = useLocation().pathname !== '/login';
const isLoggedIn = (
useLocation().pathname !== '/login' &&
localStorage.getItem('access_token') !== null &&
jwtRegex.test(localStorage.getItem('access_token') as string));

return (
<>
Expand All @@ -26,8 +30,8 @@ export default function App() {
*/
}
<Route path="/" element={<Home />} />
<Route path="/medlemmer" element={<Users />} />
<Route path="/medlemmer/test" element={<h1>Test</h1>} />
<Route path="/teammedlemmer" element={<Users />} />
<Route path="/teammedlemmer/:user" element={<h1>Test</h1>} />
</Route>
</Routes>
</main>
Expand Down
44 changes: 44 additions & 0 deletions src/api/UserApi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@

export interface User {
principalName: string
azureAdId: string
displayName: string
firstName: string
lastName: string
email: string
manager?: User
photo?: string
}


export const getUserProfile = async (accessToken: string): Promise<User> => {
return fetch('/api/userProfile', {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${accessToken}`
}
}).then(response => {
if (!response.ok) {
console.error('Request failed with status:', response.status);
throw new Error('Request failed');
}
return response.json();
}).then(data => data as User)
.catch(error => {
console.error('Error during fetching userProfile:', error);
throw error;
});
};

export const getUserProfileFallback = (accessToken: string): User => {
const jwt = JSON.parse(atob(accessToken.split('.')[1]));
return {
principalName: jwt.upn,
azureAdId: jwt.oid, // not the real azureAdId, this is actually keycloaks oid
displayName: jwt.name,
firstName: jwt.given_name,
lastName: jwt.family_name,
email: jwt.email
};
};
5 changes: 2 additions & 3 deletions src/api/VerifyKeycloakToken.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
export const verifyKeycloakToken = (token?: string): Promise<boolean> => {
const getAccessToken = localStorage.getItem('access_token');
const accessToken = localStorage.getItem('access_token');

return fetch('/api/verify-token', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token || getAccessToken}`
'Authorization': `Bearer ${token || accessToken}`
},

}).then(response => {
if (!response.ok) {
console.error('Token verification failed with status:', response.status);
Expand Down
10 changes: 2 additions & 8 deletions src/api/teamApi.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,3 @@
export interface TeamApiResponse {
success: boolean;
data: Team[];
}

export interface Team {
uniformName: string;
displayName: string;
Expand All @@ -18,8 +13,7 @@ interface Link {
templated?: boolean;
}


export const getAllTeams = (): Promise<TeamApiResponse> => {
export const getAllTeams = (): Promise<Team[]> => {
const accessToken = localStorage.getItem('access_token');

return fetch('/api/teams', {
Expand All @@ -34,7 +28,7 @@ export const getAllTeams = (): Promise<TeamApiResponse> => {
throw new Error('Request failed');
}
return response.json();
}).then(data => data as TeamApiResponse)
}).then(data => data as Team[])
.catch(error => {
console.error('Error during fetching teams:', error);
throw error;
Expand Down
26 changes: 26 additions & 0 deletions src/components/Avatar/Avatar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import styles from './avatar.module.scss';
import { useNavigate } from 'react-router-dom';

interface PageLayoutProps {
fullName: string,
image?: string,
}

export default function Avatar({ fullName }: PageLayoutProps) {
const navigate = useNavigate();
const handleClick = () => {
const path_to_go = encodeURI(`/teammedlemmer/${fullName}`);
navigate(path_to_go);
};

const userProfile = JSON.parse(localStorage.getItem('userProfile') || '{}');
const base64Image = userProfile?.photo;
const imageSrc = base64Image ? `data:image/png;base64,${base64Image}` : null;

return (
<div className={styles.avatar} onClick={handleClick}>
{imageSrc ? <img src={imageSrc} alt="User" /> :
<div className={styles.initials}>{`${userProfile.firstName[0]}${userProfile.lastName[0]}`}</div>}
</div>
);
}
30 changes: 30 additions & 0 deletions src/components/Avatar/avatar.module.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
@use '@statisticsnorway/ssb-component-library/src/style/variables' as variables;

.avatar {
width: 50px;
height: 50px;
margin: 0 1.5rem 0 0;
border: 1px solid variables.$ssb-dark-2;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background-color: #fff;
transition: transform 0.3s;
cursor: pointer;

.initials {
font-size: 1.5rem;
font-weight: 700;
color: variables.$ssb-dark-5;
}

img {
width: 50px;
height: 50px;
border-radius: 50%;
object-fit: cover;
border: 2px solid #ffffff;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
}
2 changes: 1 addition & 1 deletion src/components/Breadcrumb.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { useLocation } from 'react-router-dom';

export default function Breadcrumb() {
const location = useLocation();
const pathnames = location.pathname.split('/').filter(x => x);
const pathnames = location.pathname.split('/').filter(x => x).map(x => decodeURI(x));

const breadcrumbItems = pathnames.map((value, index) => {
const last = index === pathnames.length - 1;
Expand Down
27 changes: 23 additions & 4 deletions src/components/Header/Header.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,33 @@
import styles from './header.module.scss'

import { Link } from 'react-router-dom';
import { Link } from '@statisticsnorway/ssb-component-library';
import Avatar from '../Avatar/Avatar';
import { useNavigate } from 'react-router-dom';
import {
jwtRegex
} from '../../utils/regex';

export default function Header(props) {
export default function Header(props: { isLoggedIn: boolean }) {
const { isLoggedIn } = props

const token = localStorage.getItem('access_token');
let decoded_jwt;

if (token && jwtRegex.test(token))
decoded_jwt = JSON.parse(atob(token.split('.')[1]));

const navigate = useNavigate();
return (
<div className={styles.header}>
<span>Dapla ctrl</span>
{isLoggedIn && <Link to="/medlemmer">Medlemmer</Link>}
<h2 className={styles.title} onClick={() => navigate("/")}>Dapla ctrl</h2>
{isLoggedIn &&
<div className={styles.navigation}>
<Link href="/teammedlemmer">Teammedlemmer</Link>
{token && <Avatar
fullName={decoded_jwt.name}
/>}
</div>
}
</div>
)
}
Loading

0 comments on commit 159c395

Please sign in to comment.