Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Dpstat 800 #12

Merged
merged 39 commits into from
Jan 23, 2024
Merged
Show file tree
Hide file tree
Changes from 32 commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
0360e9e
added dead simple keycloak auth with login
ssb-jnk Jan 12, 2024
29b33a1
update Input
ssb-jnk Jan 12, 2024
2ddd9a7
removed and updated some comments
ssb-jnk Jan 12, 2024
ad53e66
refactor and make code a little more readable
ssb-jnk Jan 12, 2024
d2839df
make so ProtectedRoute can group routes and protect them
ssb-jnk Jan 12, 2024
83993d7
add comment to review with team
ssb-jnk Jan 12, 2024
04892b4
testing api calls
ssb-jnk Jan 12, 2024
59eb764
cleaned up interface and make some improvements
ssb-jnk Jan 12, 2024
6e68772
added link to keycloak token
ssb-jnk Jan 13, 2024
336f1ef
adjusted to solve mergeconflicts
ssb-jnk Jan 13, 2024
cfb0f13
Merge branch 'main' into keycloak-auth
ssb-jnk Jan 13, 2024
caa023c
add example for how we could display errors loading data
ssb-jnk Jan 13, 2024
dc88d65
move '/' to protected
ssb-jnk Jan 15, 2024
d41e82f
make branch viewable for all
ssb-jnk Jan 15, 2024
0d42823
Merge branch 'main' into DPSTAT-800
ssb-jnk Jan 15, 2024
4e7c650
better secure components from rendering
ssb-jnk Jan 15, 2024
5f48dc2
remove logout and old login, and fix AccountMenu
ssb-jnk Jan 15, 2024
bf47a4d
remove logout references
ssb-jnk Jan 15, 2024
a966cb0
more work to polish things..
ssb-jnk Jan 15, 2024
c4397d1
.
ssb-jnk Jan 15, 2024
d93b213
changed name to Avatar
ssb-jnk Jan 16, 2024
c1eb2e7
to much stuff to handle..
ssb-jnk Jan 23, 2024
181b28d
merge conflict stuff
ssb-jnk Jan 23, 2024
e953e2e
Merge branch 'develop' into DPSTAT-800
ssb-jnk Jan 23, 2024
d9ca0d8
bump vite
ssb-jnk Jan 23, 2024
e56fb48
.
ssb-jnk Jan 23, 2024
ef74788
add nodemon
ssb-jnk Jan 23, 2024
414c82a
add react responsive
ssb-jnk Jan 23, 2024
55fc33f
add fallback
ssb-jnk Jan 23, 2024
3b9f3bd
add comment
ssb-jnk Jan 23, 2024
c1c9c8f
fix Home
ssb-jnk Jan 23, 2024
dc76c5a
remove & sign from css
ssb-jnk Jan 23, 2024
abc47b4
remove userProfile if not logged in
ssb-jnk Jan 23, 2024
fac97b9
rename onClick to handleClick
ssb-jnk Jan 23, 2024
5113713
rename avatar css
ssb-jnk Jan 23, 2024
7bdb3d5
change border of avatar
ssb-jnk Jan 23, 2024
2f78bdd
Linting: error Unexpected var, use let or const instead
ssb-jnk Jan 23, 2024
c4480bc
use const instead of var or let
ssb-jnk Jan 23, 2024
a0798bb
fix User interface and avoid use of var
ssb-jnk Jan 23, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
47 changes: 47 additions & 0 deletions src/api/UserProfile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@

export interface UserData {
principalName: string
azureAdId: string
displayName: string
firstName: string
lastName: string
email: string,
manager: any
ssb-jnk marked this conversation as resolved.
Show resolved Hide resolved
photo: string | null
}


export const getUserProfile = async (accessToken: string): Promise<UserData> => {

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 UserData)
.catch(error => {
console.error('Error during fetching userProfile:', error);
throw error;
});
};

export const getUserProfileFallback = (accessToken: string): UserData => {
var jwt = JSON.parse(atob(accessToken.split('.')[1]));
ssb-jnk marked this conversation as resolved.
Show resolved Hide resolved
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,
manager: null,
photo: null
};
};
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 onClick = () => {
ssb-jnk marked this conversation as resolved.
Show resolved Hide resolved
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={onClick}>
{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 {
ssb-jnk marked this conversation as resolved.
Show resolved Hide resolved
width: 50px;
height: 50px;
margin: 0 1.5rem 0 0;
border: 2px solid #000;
ssb-jnk marked this conversation as resolved.
Show resolved Hide resolved
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
25 changes: 21 additions & 4 deletions src/components/Header/Header.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,31 @@
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

let token = localStorage.getItem('access_token');
ssb-jnk marked this conversation as resolved.
Show resolved Hide resolved
if (token && jwtRegex.test(token))
var 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