Merge pull request '#13 Show different main pages based on logged in user role' (#22) from feature/#13 into develop
Reviewed-on: Netzbegruenung/candymat#22
This commit is contained in:
commit
3490ca5a2c
|
@ -1,5 +1,6 @@
|
|||
import {ApolloClient, createHttpLink, InMemoryCache} from "@apollo/client";
|
||||
import {setContext} from "@apollo/client/link/context";
|
||||
import {getRawJsonWebToken} from "../jwt/jwt";
|
||||
|
||||
|
||||
const httpLink = createHttpLink({
|
||||
|
@ -7,7 +8,7 @@ const httpLink = createHttpLink({
|
|||
});
|
||||
|
||||
const authLink = setContext((_, { headers }) => {
|
||||
const token = localStorage.getItem('token');
|
||||
const token = getRawJsonWebToken();
|
||||
return token ? {
|
||||
headers: {
|
||||
...headers,
|
||||
|
|
|
@ -29,7 +29,7 @@ function CustomAppBar() {
|
|||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem('token')
|
||||
history.replace("/login")
|
||||
history.push("/login")
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
|
@ -42,7 +42,7 @@ function CustomAppBar() {
|
|||
<MenuIcon />
|
||||
</IconButton>
|
||||
<Typography variant="h6" className={classes.title}>
|
||||
Candymat Redaktion
|
||||
Candymat
|
||||
</Typography>
|
||||
<IconButton
|
||||
aria-label="account of current user"
|
||||
|
|
|
@ -0,0 +1,52 @@
|
|||
import React from 'react';
|
||||
import {render, screen} from '@testing-library/react'
|
||||
import {MockedProvider} from '@apollo/client/testing';
|
||||
import {MemoryRouter} from 'react-router-dom';
|
||||
import Main from "./Main";
|
||||
import {SnackbarProvider} from "notistack";
|
||||
|
||||
|
||||
function renderMainPage() {
|
||||
render(<MockedProvider><MemoryRouter><SnackbarProvider><Main/></SnackbarProvider></MemoryRouter></MockedProvider>);
|
||||
}
|
||||
|
||||
describe('The main page', () => {
|
||||
test('displays the editors page if an editor is logged in', () => {
|
||||
const editorToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiY2FuZHltYXRfZWRpdG9yIiwicGVyc29uX3Jvd19pZCI6MSwiZXhwIjoxNjA5NDEyMTY4LCJpYXQiOjE2MDkyMzkzNjgsImF1ZCI6InBvc3RncmFwaGlsZSIsImlzcyI6InBvc3RncmFwaGlsZSJ9.kxdxmDrQw0vzD4tiXPj2fu-Cr8n7aWMikxntZ1ObF6c";
|
||||
localStorage.setItem("token", editorToken)
|
||||
renderMainPage();
|
||||
|
||||
// it renders question and category lists
|
||||
const questionListHeadline = screen.queryByText(/Fragen/);
|
||||
const categoryListHeadline = screen.queryByText(/Kategorien/);
|
||||
expect(questionListHeadline).not.toBeNull();
|
||||
expect(categoryListHeadline).not.toBeNull();
|
||||
});
|
||||
|
||||
test('displays the candidates page if a candidate is logged in', () => {
|
||||
const candidateToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiY2FuZHltYXRfY2FuZGlkYXRlIiwicGVyc29uX3Jvd19pZCI6MiwiZXhwIjoxNjA5NDEyMTY4LCJpYXQiOjE2MDkyMzkzNjgsImF1ZCI6InBvc3RncmFwaGlsZSIsImlzcyI6InBvc3RncmFwaGlsZSJ9.i66MDTPVWwfAvOawY25WE9OPb5CQ9hidoUruP91ngcg";
|
||||
localStorage.setItem("token", candidateToken)
|
||||
renderMainPage();
|
||||
|
||||
const placeholder = screen.queryByText(/Under construction/);
|
||||
expect(placeholder).not.toBeNull();
|
||||
});
|
||||
|
||||
test('displays the user page if an normal user is logged in', () => {
|
||||
const userToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiY2FuZHltYXRfcGVyc29uIiwicGVyc29uX3Jvd19pZCI6MywiZXhwIjoxNjA5NDEyMTY4LCJpYXQiOjE2MDkyMzkzNjgsImF1ZCI6InBvc3RncmFwaGlsZSIsImlzcyI6InBvc3RncmFwaGlsZSJ9.RWo5USCmyn-OYjgYixq0y6qlObU9Rb0KdsxxvrtlW1o";
|
||||
localStorage.setItem("token", userToken)
|
||||
renderMainPage();
|
||||
|
||||
const placeholder = screen.queryByText(/nichts zu sehen/);
|
||||
expect(placeholder).not.toBeNull();
|
||||
});
|
||||
|
||||
test('displays a link to the loggin page if something is wrong with the token', () => {
|
||||
const invalidToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiY2FuZHOYjgYixq0y6qlObU9Rb0KdsxxvrtlW1o";
|
||||
localStorage.setItem("token", invalidToken)
|
||||
renderMainPage();
|
||||
|
||||
const placeholder = screen.queryByRole("link", {name: /Login Seite/});
|
||||
expect(placeholder).not.toBeNull();
|
||||
});
|
||||
});
|
|
@ -1,10 +1,12 @@
|
|||
import CustomAppBar from "./CustomAppBar";
|
||||
import {Container} from "@material-ui/core";
|
||||
import React from "react";
|
||||
import {makeStyles} from "@material-ui/core/styles";
|
||||
import {Copyright} from "./Copyright";
|
||||
import QuestionList from "./QuestionList";
|
||||
import CategoryList from "./CategoryList";
|
||||
import {MainPageEditor} from "./MainPageEditor";
|
||||
import {getJsonWebToken} from "../jwt/jwt";
|
||||
import {MainPageCandidate} from "./MainPageCandidate";
|
||||
import {MainPageUser} from "./MainPageUser";
|
||||
import {Link} from "react-router-dom";
|
||||
import {Container} from "@material-ui/core";
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
appBarSpacer: theme.mixins.toolbar,
|
||||
|
@ -13,32 +15,36 @@ const useStyles = makeStyles((theme) => ({
|
|||
height: '100vh',
|
||||
overflow: 'auto',
|
||||
},
|
||||
container: {
|
||||
invalidTokenContainer: {
|
||||
paddingTop: theme.spacing(4),
|
||||
paddingBottom: theme.spacing(4),
|
||||
flexDirection: 'column',
|
||||
},
|
||||
paper: {
|
||||
margin: 5,
|
||||
padding: theme.spacing(2),
|
||||
display: 'flex',
|
||||
overflow: 'auto',
|
||||
flexDirection: 'column',
|
||||
},
|
||||
}
|
||||
}));
|
||||
|
||||
function Main() {
|
||||
const classes = useStyles();
|
||||
const getMainPage = () => {
|
||||
switch (getJsonWebToken()?.role) {
|
||||
case "candymat_editor":
|
||||
return <MainPageEditor/>;
|
||||
case "candymat_candidate":
|
||||
return <MainPageCandidate/>;
|
||||
case "candymat_person":
|
||||
return <MainPageUser/>;
|
||||
default:
|
||||
localStorage.removeItem('token');
|
||||
return <Container className={classes.invalidTokenContainer}>
|
||||
Du bist nicht eingelogged oder dein Token ist ungültig. Logge dich erneut ein.<br/>
|
||||
Zur <Link to={"/login"}>Login Seite</Link>
|
||||
</Container>
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<CustomAppBar/>
|
||||
<main className={classes.content}>
|
||||
<div className={classes.appBarSpacer}/>
|
||||
<Container maxWidth="lg" className={classes.container}>
|
||||
<QuestionList/>
|
||||
<CategoryList/>
|
||||
<Copyright/>
|
||||
</Container>
|
||||
{getMainPage()}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
|
|
|
@ -0,0 +1,21 @@
|
|||
import {Container} from "@material-ui/core";
|
||||
import React from "react";
|
||||
import {makeStyles} from "@material-ui/core/styles";
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
container: {
|
||||
paddingTop: theme.spacing(4),
|
||||
paddingBottom: theme.spacing(4),
|
||||
flexDirection: 'column',
|
||||
},
|
||||
}));
|
||||
|
||||
export function MainPageCandidate() {
|
||||
const classes = useStyles();
|
||||
|
||||
return (
|
||||
<Container maxWidth="lg" className={classes.container}>
|
||||
Under construction
|
||||
</Container>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,26 @@
|
|||
import {Container} from "@material-ui/core";
|
||||
import QuestionList from "./QuestionList";
|
||||
import CategoryList from "./CategoryList";
|
||||
import {Copyright} from "./Copyright";
|
||||
import React from "react";
|
||||
import {makeStyles} from "@material-ui/core/styles";
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
container: {
|
||||
paddingTop: theme.spacing(4),
|
||||
paddingBottom: theme.spacing(4),
|
||||
flexDirection: 'column',
|
||||
},
|
||||
}));
|
||||
|
||||
export function MainPageEditor() {
|
||||
const classes = useStyles();
|
||||
|
||||
return (
|
||||
<Container maxWidth="lg" className={classes.container}>
|
||||
<QuestionList/>
|
||||
<CategoryList/>
|
||||
<Copyright/>
|
||||
</Container>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
import {Container} from "@material-ui/core";
|
||||
import React from "react";
|
||||
import {makeStyles} from "@material-ui/core/styles";
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
container: {
|
||||
paddingTop: theme.spacing(4),
|
||||
paddingBottom: theme.spacing(4),
|
||||
flexDirection: 'column',
|
||||
},
|
||||
}));
|
||||
|
||||
export function MainPageUser() {
|
||||
const classes = useStyles();
|
||||
|
||||
return (
|
||||
<Container maxWidth="lg" className={classes.container}>
|
||||
Sorry, für dich gibt es hier leider nichts zu sehen...
|
||||
</Container>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,36 @@
|
|||
import {parseJwt} from "./jwt";
|
||||
|
||||
describe("The parseJwt function", () => {
|
||||
test("parses a valid candymat jwt", () => {
|
||||
const validJwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiY2FuZHltYXRfZWRpdG9yIiwicGVyc29uX3Jvd19pZCI6MSwiZXhwIjoxNjA5NDEyMTY4LCJpYXQiOjE2MDkyMzkzNjgsImF1ZCI6InBvc3RncmFwaGlsZSIsImlzcyI6InBvc3RncmFwaGlsZSJ9.6VLDBS5_HwgWIf_MKkMCuj4EVBZkbSGm87aWt5grP2M";
|
||||
|
||||
const jwt = parseJwt(validJwt)
|
||||
|
||||
expect(jwt).not.toBeNull();
|
||||
expect(jwt?.person_row_id).toBe(1);
|
||||
expect(jwt?.role).toBe("candymat_editor");
|
||||
});
|
||||
|
||||
test("returns null if role claim is invalid", () => {
|
||||
const invalidRoleClaimJwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiY2FuZHllZGl0b3IiLCJwZXJzb25fcm93X2lkIjoxLCJleHAiOjE2MDk0MTIxNjgsImlhdCI6MTYwOTIzOTM2OCwiYXVkIjoicG9zdGdyYXBoaWxlIiwiaXNzIjoicG9zdGdyYXBoaWxlIn0._AVFTMqMkIuyrfQGTmWE-Qi-C72KCrZ3s_uVyfuEDco";
|
||||
|
||||
const jwt = parseJwt(invalidRoleClaimJwt);
|
||||
|
||||
expect(jwt).toBeNull();
|
||||
})
|
||||
|
||||
test("returns null if person_row_id is not a number", () => {
|
||||
const invalidRowIdClaimJwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiY2FuZHltYXRfZWRpdG9yIiwicGVyc29uX3Jvd19pZCI6IjEiLCJleHAiOjE2MDk0MTIxNjgsImlhdCI6MTYwOTIzOTM2OCwiYXVkIjoicG9zdGdyYXBoaWxlIiwiaXNzIjoicG9zdGdyYXBoaWxlIn0.NfXylzN44qrZA5DX0qxxU71vJ1o9gdunscnK6V193Fc";
|
||||
|
||||
const jwt = parseJwt(invalidRowIdClaimJwt);
|
||||
|
||||
expect(jwt).toBeNull();
|
||||
})
|
||||
|
||||
test("returns null if token is rubish.", () => {
|
||||
const brokenJwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eysssssssssssssssssssss.6VLDBS5_HwgWIf_MKkMCuj4EVBZkbSGm87aWt5grP2M";
|
||||
const jwt = parseJwt(brokenJwt);
|
||||
|
||||
expect(jwt).toBeNull();
|
||||
})
|
||||
});
|
|
@ -0,0 +1,48 @@
|
|||
export const getRawJsonWebToken = (): string | null => {
|
||||
return localStorage.getItem('token');
|
||||
}
|
||||
|
||||
export const getJsonWebToken = (): JwtPayload | null => {
|
||||
const rawToken = getRawJsonWebToken();
|
||||
return rawToken ? parseJwt(rawToken) : null
|
||||
}
|
||||
|
||||
export const parseJwt = (token: string): JwtPayload | null => {
|
||||
try {
|
||||
const base64Url = token.split('.')[1];
|
||||
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const jsonPayload = decodeURIComponent(
|
||||
atob(base64)
|
||||
.split('')
|
||||
.map(c => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
|
||||
.join('')
|
||||
);
|
||||
const jwtPayload = JSON.parse(jsonPayload);
|
||||
return isJwtPayloadValid(jwtPayload) ? jwtPayload : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export const isJwtPayloadValid = (jwtPayload: JwtPayload): boolean => {
|
||||
return claims.every(claim => Object.keys(jwtPayload).includes(claim))
|
||||
&& userRoles.includes(jwtPayload.role)
|
||||
&& typeof (jwtPayload.person_row_id) === 'number'
|
||||
&& typeof (jwtPayload.exp) === 'number'
|
||||
&& typeof (jwtPayload.iat) === 'number';
|
||||
}
|
||||
|
||||
const claims = ["role", "person_row_id", "exp", "iat", "aud", "iss"]
|
||||
const userRoles = ["candymat_editor", 'candymat_candidate', 'candymat_person']
|
||||
|
||||
interface JwtPayload {
|
||||
"role": UserRole,
|
||||
"person_row_id": number,
|
||||
"exp": number,
|
||||
"iat": number,
|
||||
"aud": "postgraphile",
|
||||
"iss": "postgraphile"
|
||||
}
|
||||
|
||||
type UserRole = "candymat_editor" | 'candymat_candidate' | 'candymat_person'
|
||||
|
Loading…
Reference in New Issue