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:
Christoph Lienhard 2020-12-31 13:16:47 +01:00
commit 3490ca5a2c
9 changed files with 233 additions and 22 deletions

View File

@ -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,

View File

@ -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"

View File

@ -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();
});
});

View File

@ -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>
)

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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();
})
});

View File

@ -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'