Merge pull request 'feature/#20' (#24) from feature/#20 into develop

Reviewed-on: Netzbegruenung/candymat#24
This commit is contained in:
Christoph Lienhard 2021-06-13 12:58:50 +02:00
commit f66f8adecb
52 changed files with 34131 additions and 6801 deletions

View File

@ -19,12 +19,14 @@ create table candymat_data_privat.person_account
email character varying(320) not null unique check (email ~* '^.+@.+\..+$'),
password_hash character varying(256) not null
);
alter table candymat_data.person
enable row level security;
create policy update_person on candymat_data.person for update to candymat_person
with check (row_id = nullif(current_setting('jwt.claims.person_row_id', true), '')::integer);
create policy delete_person on candymat_data.person for delete to candymat_person
using (row_id = nullif(current_setting('jwt.claims.person_row_id', true), '')::integer);
-- The following enables viewing candidates and editors information for every person.
-- This may be changed to only enable registered (and verified) persons.
create policy select_person_public
@ -32,3 +34,10 @@ create policy select_person_public
for select
to candymat_anonymous, candymat_person -- maybe change to candymat_person only in the future
using (role in ('candymat_editor', 'candymat_candidate'));
-- Editors can see all registered persons in order to elevate their privileges
create policy select_person_editor
on candymat_data.person
for select
to candymat_editor
using (true);

View File

@ -1,6 +1,8 @@
create extension if not exists "pgcrypto";
-- Define JWT claim structure
drop type if exists candymat_data.jwt_token cascade;
create type candymat_data.jwt_token as
(
role text,
@ -8,6 +10,9 @@ create type candymat_data.jwt_token as
exp bigint
);
-- Function to get the currently logged-in person
drop function if exists candymat_data.current_person;
create function candymat_data.current_person(
) returns candymat_data.person as
$$
@ -17,6 +22,9 @@ where row_id = nullif(current_setting('jwt.claims.person_row_id', true), '')::in
$$ language sql stable;
grant execute on function candymat_data.current_person() to candymat_person;
-- Function to register a new user
drop function if exists candymat_data.register_person;
create function candymat_data.register_person(
first_name text,
last_name text,
@ -47,9 +55,11 @@ begin
end ;
$$ language plpgsql strict
security definer;
grant execute on function candymat_data.register_person(text, text, text, text) to candymat_anonymous;
-- Authenticate: Login for user
drop function if exists candymat_data.authenticate;
create function candymat_data.authenticate(
email text,
password text
@ -80,26 +90,24 @@ $$ language plpgsql strict
security definer;
grant execute on function candymat_data.authenticate(text, text) to candymat_anonymous, candymat_person;
-- Change role: Changes role for a given user. Only editors are allowed to use it.
drop function if exists candymat_data.change_role;
create function candymat_data.change_role(
person_row_id integer,
new_role candymat_data.role
)
returns table
(
first_name text,
last_name text,
role candymat_data.role
)
as
returns candymat_data.person as
$$
declare
person candymat_data.person;
begin
update candymat_data.person
set role = new_role
where candymat_data.person.row_id = $1;
where candymat_data.person.row_id = $1
returning * into person;
return query select candymat_data.person.first_name::text, candymat_data.person.last_name::text, new_role
from candymat_data.person
where person.row_id = person_row_id;
return person;
end;
$$ language plpgsql;
$$ language plpgsql strict security definer;
grant execute on function candymat_data.change_role(integer, candymat_data.role) to candymat_editor;

@ -1 +1 @@
Subproject commit d414b95c1c664adcd5149aee8eac4436b40d7dfb
Subproject commit 4e290449cb4b5821f5542d1ea6a9a629b99e6aa3

View File

@ -1,10 +1,11 @@
env:
browser: true
extends:
- 'eslint:recommended'
- 'plugin:react/recommended'
- 'plugin:@typescript-eslint/recommended'
parser: '@typescript-eslint/parser'
- "eslint:recommended"
- "plugin:react/recommended"
- "plugin:@typescript-eslint/recommended"
- "plugin:prettier/recommended"
parser: "@typescript-eslint/parser"
parserOptions:
ecmaFeatures:
jsx: true
@ -12,8 +13,11 @@ parserOptions:
sourceType: module
plugins:
- react
- '@typescript-eslint'
rules: {}
- "@typescript-eslint"
rules:
no-use-before-define: off
"@typescript-eslint/no-use-before-define":
- error
settings:
react:
version: detect

View File

@ -1,7 +1,7 @@
# Redaktions App
The "Redaktions App" or editor's app is the main gateway for editors and candidates to alter the database,
e.g. adding new questions (editors) and answering them (candidates).
The "Redaktions App" or editor's app is the main gateway for editors and candidates to alter the database, e.g. adding
new questions (editors) and answering them (candidates).
## Development
@ -18,8 +18,8 @@ The app is written in typescript and react and uses apollo to query the backend
```shell script
docker-compose up
```
which will start the whole setup including this app in a dockerfile.
However, rebuilding and restarting this image can be cumbersome and is not necessary in the development setup.
which will start the whole setup including this app in a dockerfile. However, rebuilding and restarting this image can
be cumbersome and is not necessary in the development setup.
- Instead run
```shell script
npm start
@ -38,13 +38,14 @@ Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
The page will reload if you make edits.<br>
You will also see any lint errors in the console.
Running the app without the backend server makes little sense.
Start it under [http://localhost:5000](http://localhost:5000) as specified in the Readme of the backend server (../backend)
Running the app without the backend server makes little sense. Start it
under [http://localhost:5000](http://localhost:5000) as specified in the Readme of the backend server (../backend)
##### `npm test`
Launches the test runner in the interactive watch mode.<br>
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more
information.
##### `npm run build`
@ -60,8 +61,13 @@ See the section about [deployment](https://facebook.github.io/create-react-app/d
**Note: this is a one-way operation. Once you `eject`, you cant go back!**
If you arent satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
If you arent satisfied with the build tool and configuration choices, you can `eject` at any time. This command will
remove the single build dependency from your project.
Instead, it will copy all the configuration files and the transitive dependencies (Webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point youre on your own.
Instead, it will copy all the configuration files and the transitive dependencies (Webpack, Babel, ESLint, etc) right
into your project so you have full control over them. All of the commands except `eject` will still work, but they will
point to the copied scripts so you can tweak them. At this point youre on your own.
You dont have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldnt feel obligated to use this feature. However we understand that this tool wouldnt be useful if you couldnt customize it when you are ready for it.
You dont have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you
shouldnt feel obligated to use this feature. However we understand that this tool wouldnt be useful if you couldnt
customize it when you are ready for it.

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 7.9 KiB

After

Width:  |  Height:  |  Size: 7.9 KiB

File diff suppressed because it is too large Load Diff

View File

@ -9,12 +9,12 @@
"@material-ui/lab": "^4.0.0-alpha.57",
"@testing-library/user-event": "^7.2.1",
"graphql": "^15.3.0",
"react": "^16.13.1",
"notistack": "^1.0.3",
"react": "^16.14.0",
"react-dom": "^16.13.1",
"react-router-dom": "^5.2.0",
"react-scripts": "^3.4.4",
"typescript": "^3.8",
"notistack": "^1.0.3"
"react-scripts": "^4.0.2",
"typescript": "^3.8"
},
"devDependencies": {
"@testing-library/jest-dom": "^5.11.4",
@ -26,12 +26,13 @@
"@types/react-router-dom": "^5.1.5",
"@typescript-eslint/eslint-plugin": "^4.12.0",
"@typescript-eslint/parser": "^4.12.0",
"eslint-config-prettier": "^7.1.0",
"eslint-config-prettier": "^7.2.0",
"eslint-plugin-prettier": "^3.4.0",
"eslint-plugin-react": "^7.22.0",
"husky": "^4.3.6",
"jest-environment-jsdom-sixteen": "^1.0.3",
"lint-staged": "^10.5.3",
"prettier": "2.2.1"
"prettier": "^2.3.0"
},
"scripts": {
"start": "react-scripts start",
@ -44,7 +45,7 @@
},
"husky": {
"hooks": {
"pre-commit": "lint-staged && npm test"
"pre-commit": "lint-staged"
}
},
"lint-staged": {

View File

@ -36,3 +36,7 @@
transform: rotate(360deg);
}
}
.MuiAppBar-colorPrimary {
background-color: green;
}

View File

@ -1,27 +1,10 @@
import "./App.css";
import React from "react";
import "./App.css";
import Main from "./components/Main";
import { Redirect, Route, RouteProps, Switch } from "react-router-dom";
import SignIn from "./components/SignIn";
import SignUp from "./components/SignUp";
function App(): React.ReactElement {
return (
<Switch>
<PrivateRoute exact path={"/"}>
<Main />
</PrivateRoute>
<NotLoggedInOnlyRoute path={"/login"}>
<SignIn />
</NotLoggedInOnlyRoute>
<NotLoggedInOnlyRoute path={"/signup"}>
<SignUp />
</NotLoggedInOnlyRoute>
</Switch>
);
}
export const isLoggedIn = (): boolean => !!localStorage.getItem("token");
import { getJsonWebToken, isLoggedIn } from "./jwt/jwt";
function PrivateRoute({ children, ...rest }: RouteProps) {
return (
@ -62,4 +45,24 @@ function NotLoggedInOnlyRoute({ children, ...rest }: RouteProps) {
);
}
function App(): React.ReactElement {
const jwt = getJsonWebToken();
return (
<Switch>
<NotLoggedInOnlyRoute path={"/login"}>
<SignIn />
</NotLoggedInOnlyRoute>
<NotLoggedInOnlyRoute path={"/signup"}>
<SignUp />
</NotLoggedInOnlyRoute>
<PrivateRoute>
{jwt && (
<Main userRole={jwt.role} loggedInUserRowId={jwt.person_row_id} />
)}
</PrivateRoute>
</Switch>
);
}
export default App;

View File

@ -1,11 +1,23 @@
import { ApolloClient, createHttpLink, InMemoryCache } from "@apollo/client";
import { setContext } from "@apollo/client/link/context";
import { getRawJsonWebToken } from "../jwt/jwt";
import { getRawJsonWebToken, logoutUser } from "../jwt/jwt";
import { onError } from "@apollo/client/link/error";
import { ServerError } from "@apollo/client/link/utils";
import { ServerParseError } from "@apollo/client/link/http";
const httpLink = createHttpLink({
uri: "http://localhost:5433/graphql",
});
const errorLink = onError(({ networkError }) => {
if (
networkError &&
(networkError as ServerError | ServerParseError).statusCode === 401
) {
logoutUser();
}
});
const authLink = setContext((_, { headers }) => {
const token = getRawJsonWebToken();
return token
@ -20,6 +32,6 @@ const authLink = setContext((_, { headers }) => {
export const client = new ApolloClient({
cache: new InMemoryCache(),
link: authLink.concat(httpLink),
link: errorLink.concat(authLink.concat(httpLink)),
connectToDevTools: true,
});

View File

@ -103,18 +103,17 @@ const getDeletedCategoryMock = (): DeleteCategoryPayload | null => {
: null;
};
export const deleteCategoryMock: Array<
MockedResponse<DeleteCategoryResponse>
> = [
{
request: {
query: DELETE_CATEGORY,
variables: deleteCategoryVariables,
},
result: {
data: {
deleteCategory: getDeletedCategoryMock(),
export const deleteCategoryMock: Array<MockedResponse<DeleteCategoryResponse>> =
[
{
request: {
query: DELETE_CATEGORY,
variables: deleteCategoryVariables,
},
result: {
data: {
deleteCategory: getDeletedCategoryMock(),
},
},
},
},
];
];

View File

@ -116,18 +116,17 @@ const getDeletedQuestionMock = (): DeleteQuestionPayload | null => {
: null;
};
export const deleteQuestionMock: Array<
MockedResponse<DeleteQuestionResponse>
> = [
{
request: {
query: DELETE_QUESTION,
variables: deleteQuestionVariables,
},
result: {
data: {
deleteQuestion: getDeletedQuestionMock(),
export const deleteQuestionMock: Array<MockedResponse<DeleteQuestionResponse>> =
[
{
request: {
query: DELETE_QUESTION,
variables: deleteQuestionVariables,
},
result: {
data: {
deleteQuestion: getDeletedQuestionMock(),
},
},
},
},
];
];

View File

@ -0,0 +1,26 @@
import { gql } from "@apollo/client";
import { UppercaseUserRole } from "../../jwt/jwt";
import { BasicPersonFragment, BasicPersonResponse } from "../queries/person";
export const CHANGE_ROLE = gql`
mutation ChangeRole($personRowId: Int!, $newRole: Role!) {
changeRole(input: { personRowId: $personRowId, newRole: $newRole }) {
person {
...BasicPersonFragment
}
}
}
${BasicPersonFragment}
`;
export interface ChangeRoleVariables {
personRowId: number;
newRole: UppercaseUserRole;
}
export interface ChangeRoleResponse {
__typename: "Mutation";
changeRole: {
person?: BasicPersonResponse;
};
}

View File

@ -0,0 +1,131 @@
import { MockedResponse } from "@apollo/client/testing";
import {
GET_PERSONS_SORTED_BY_ROLE,
GetPersonsSortedByRoleResponse,
} from "./person";
export const getPersonsByRoleAllFilledData: GetPersonsSortedByRoleResponse = {
editors: {
nodes: [
{
id: "WyJwZW9wbGUiLDFd",
rowId: 1,
firstName: "Erika",
lastName: "Mustermann",
role: "CANDYMAT_EDITOR",
__typename: "Person",
},
],
__typename: "PeopleConnection",
},
candidates: {
nodes: [
{
id: "WyJwZW9wbGUiLDJd",
rowId: 2,
firstName: "Max",
lastName: "Mustermann",
role: "CANDYMAT_CANDIDATE",
__typename: "Person",
},
{
id: "WyJwZW9wbGUiLDNd",
rowId: 3,
firstName: "Tricia",
lastName: "McMillan",
role: "CANDYMAT_CANDIDATE",
__typename: "Person",
},
],
__typename: "PeopleConnection",
},
users: {
nodes: [
{
id: "WyJwZW9wbGUiLDRd",
rowId: 4,
firstName: "Happy",
lastName: "User",
role: "CANDYMAT_PERSON",
__typename: "Person",
},
],
__typename: "PeopleConnection",
},
};
export const getPersonsSortedByRoleAllFilledMock: Array<
MockedResponse<GetPersonsSortedByRoleResponse>
> = [
{
request: {
query: GET_PERSONS_SORTED_BY_ROLE,
},
result: {
data: getPersonsByRoleAllFilledData,
},
},
];
export const getPersonsByRoleNoCandidatesData: GetPersonsSortedByRoleResponse =
{
editors: {
nodes: [
{
id: "WyJwZW9wbGUiLDFd",
rowId: 1,
firstName: "Erika",
lastName: "Mustermann",
role: "CANDYMAT_EDITOR",
__typename: "Person",
},
],
__typename: "PeopleConnection",
},
candidates: {
nodes: [],
__typename: "PeopleConnection",
},
users: {
nodes: [
{
id: "WyJwZW9wbGUiLDJd",
rowId: 2,
firstName: "Max",
lastName: "Mustermann",
role: "CANDYMAT_PERSON",
__typename: "Person",
},
{
id: "WyJwZW9wbGUiLDNd",
rowId: 3,
firstName: "Tricia",
lastName: "McMillan",
role: "CANDYMAT_PERSON",
__typename: "Person",
},
{
id: "WyJwZW9wbGUiLDRd",
rowId: 4,
firstName: "Happy",
lastName: "User",
role: "CANDYMAT_PERSON",
__typename: "Person",
},
],
__typename: "PeopleConnection",
},
};
export const getPersonsSortedByRoleNoCandidatesMock: Array<
MockedResponse<GetPersonsSortedByRoleResponse>
> = [
{
request: {
query: GET_PERSONS_SORTED_BY_ROLE,
},
result: {
data: getPersonsByRoleNoCandidatesData,
},
},
];

View File

@ -0,0 +1,57 @@
import { gql } from "@apollo/client";
import { UppercaseUserRole } from "../../jwt/jwt";
export const BasicPersonFragment = gql`
fragment BasicPersonFragment on Person {
id
rowId
firstName
lastName
role
}
`;
export interface BasicPersonResponse {
id: string;
rowId: number;
firstName: string | null;
lastName: string | null;
role: UppercaseUserRole;
__typename: "Person";
}
export const GET_PERSONS_SORTED_BY_ROLE = gql`
query AllPeople {
editors: allPeople(condition: { role: CANDYMAT_EDITOR }) {
nodes {
...BasicPersonFragment
}
}
candidates: allPeople(condition: { role: CANDYMAT_CANDIDATE }) {
nodes {
...BasicPersonFragment
}
}
users: allPeople(condition: { role: CANDYMAT_PERSON }) {
nodes {
...BasicPersonFragment
}
}
}
${BasicPersonFragment}
`;
export interface GetPersonsSortedByRoleResponse {
editors: {
nodes: Array<BasicPersonResponse>;
__typename: "PeopleConnection";
};
candidates: {
nodes: Array<BasicPersonResponse>;
__typename: "PeopleConnection";
};
users: {
nodes: Array<BasicPersonResponse>;
__typename: "PeopleConnection";
};
}

View File

@ -40,7 +40,7 @@ const useStyles = makeStyles((theme: Theme) =>
);
interface AccordionQuestionAnswerProps {
personRowId: number;
loggedInPersonRowId: number;
question: QuestionAnswerResponse;
}
@ -86,7 +86,7 @@ export default function AccordionQuestionAnswer(
</Typography>
<Divider />
<EditAnswerSection
personRowId={props.personRowId}
loggedInPersonRowId={props.loggedInPersonRowId}
question={props.question}
/>
</AccordionDetails>

View File

@ -36,7 +36,9 @@ interface AccordionWithEditProps {
title: string;
description: string | null;
subTitle?: string | null;
onEditButtonClick?(): void;
onDeleteButtonClick?(): void;
}

View File

@ -33,6 +33,22 @@ const useStyles = makeStyles((theme) => ({
},
}));
export const getIconForPosition = (
position: CandidatePosition,
props?: SvgIconProps
): JSX.Element => {
switch (position) {
case CandidatePosition.positive:
return <ThumbUpIcon {...props} />;
case CandidatePosition.neutral:
return <RadioButtonUncheckedIcon {...props} />;
case CandidatePosition.negative:
return <ThumbDown {...props} />;
case CandidatePosition.skipped:
return <CloseIcon {...props} />;
}
};
export function CandidatePositionLegend(): React.ReactElement {
const classes = useStyles();
@ -57,19 +73,3 @@ export function CandidatePositionLegend(): React.ReactElement {
</div>
);
}
export const getIconForPosition = (
position: CandidatePosition,
props?: SvgIconProps
): JSX.Element => {
switch (position) {
case CandidatePosition.positive:
return <ThumbUpIcon {...props} />;
case CandidatePosition.neutral:
return <RadioButtonUncheckedIcon {...props} />;
case CandidatePosition.negative:
return <ThumbDown {...props} />;
case CandidatePosition.skipped:
return <CloseIcon {...props} />;
}
};

View File

@ -28,9 +28,9 @@ const useStyles = makeStyles((theme) => ({
}));
export default function CategoryList(): React.ReactElement {
const categories = useQuery<GetAllCategoriesResponse, null>(
GET_ALL_CATEGORIES
).data?.allCategories.nodes;
const categories =
useQuery<GetAllCategoriesResponse, null>(GET_ALL_CATEGORIES).data
?.allCategories.nodes;
const classes = useStyles();
const handleAddClick = () => {

View File

@ -0,0 +1,107 @@
import React from "react";
import { IconButton, MenuItem } from "@material-ui/core";
import Menu from "@material-ui/core/Menu";
import EditIcon from "@material-ui/icons/Edit";
import { UPPERCASE_USER_ROLES, UppercaseUserRole } from "../jwt/jwt";
import { useMutation } from "@apollo/client";
import {
CHANGE_ROLE,
ChangeRoleResponse,
ChangeRoleVariables,
} from "../backend/mutations/userRole";
import { useSnackbar } from "notistack";
import { GET_PERSONS_SORTED_BY_ROLE } from "../backend/queries/person";
interface ChangeRoleMenuProps {
currentRole: UppercaseUserRole;
currentUserRowId: number;
disabled?: boolean;
}
export default function ChangeRoleMenu(
props: ChangeRoleMenuProps
): React.ReactElement {
const { enqueueSnackbar } = useSnackbar();
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
const open = Boolean(anchorEl);
const otherRoles = UPPERCASE_USER_ROLES.filter(
(role) => role != props.currentRole
);
const handleMenu = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget);
};
const handleClose = () => {
setAnchorEl(null);
};
const [changeRole] = useMutation<ChangeRoleResponse, ChangeRoleVariables>(
CHANGE_ROLE,
{
onCompleted() {
handleClose();
},
onError(e) {
console.error(e);
handleClose();
enqueueSnackbar("Ein Fehler ist aufgetreten, versuche es erneut.", {
variant: "error",
});
},
refetchQueries: [{ query: GET_PERSONS_SORTED_BY_ROLE }],
}
);
const displayRole = (role: UppercaseUserRole) => {
switch (role) {
case "CANDYMAT_CANDIDATE":
return "zu Kandidat:in machen";
case "CANDYMAT_EDITOR":
return "zu RedakteurIn machen";
case "CANDYMAT_PERSON":
return "zu Standard User machen";
}
};
return (
<React.Fragment>
<IconButton
aria-label="role of current user"
aria-controls="change-role"
aria-haspopup="true"
onClick={handleMenu}
color="inherit"
disabled={props.disabled}
>
<EditIcon />
</IconButton>
<Menu
id="change-role"
anchorEl={anchorEl}
anchorOrigin={{
vertical: "top",
horizontal: "right",
}}
keepMounted
transformOrigin={{
vertical: "top",
horizontal: "right",
}}
open={open}
onClose={handleClose}
>
{otherRoles.map((role) => (
<MenuItem
key={role}
onClick={() => {
changeRole({
variables: {
personRowId: props.currentUserRowId,
newRole: role,
},
});
}}
>
{displayRole(role)}
</MenuItem>
))}
</Menu>
</React.Fragment>
);
}

View File

@ -1,80 +1,71 @@
import React from "react";
import AppBar from "@material-ui/core/AppBar";
import { IconButton, MenuItem, Toolbar, Typography } from "@material-ui/core";
import {
createStyles,
IconButton,
Toolbar,
Typography,
} from "@material-ui/core";
import { makeStyles, Theme } from "@material-ui/core/styles";
import { ProfileMenu } from "./ProfileMenu";
import { mainMenuOpen, mainMenuWidth } from "./MainMenu";
import MenuIcon from "@material-ui/icons/Menu";
import Menu from "@material-ui/core/Menu";
import AccountCircle from "@material-ui/icons/AccountCircle";
import { makeStyles } from "@material-ui/core/styles";
import { useHistory } from "react-router-dom";
import { useReactiveVar } from "@apollo/client";
import clsx from "clsx";
const useStyles = makeStyles({
menuButton: {
marginRight: 16,
},
title: {
flexGrow: 1,
},
});
const useStyles = makeStyles((theme: Theme) =>
createStyles({
appBar: {
transition: theme.transitions.create(["margin", "width"], {
easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.leavingScreen,
}),
},
appBarShift: {
width: `calc(100% - ${mainMenuWidth}px)`,
marginLeft: mainMenuWidth,
transition: theme.transitions.create(["margin", "width"], {
easing: theme.transitions.easing.easeOut,
duration: theme.transitions.duration.enteringScreen,
}),
},
hide: {
display: "none",
},
menuButton: {
marginRight: theme.spacing(2),
},
title: {
flexGrow: 1,
},
})
);
function CustomAppBar(): React.ReactElement {
const classes = useStyles();
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
const open = Boolean(anchorEl);
const history = useHistory();
const open = useReactiveVar(mainMenuOpen);
const handleMenu = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget);
};
const handleLogout = () => {
localStorage.removeItem("token");
history.push("/login");
};
const handleClose = () => {
setAnchorEl(null);
};
return (
<AppBar>
<AppBar
position="fixed"
className={clsx(classes.appBar, {
[classes.appBarShift]: open,
})}
>
<Toolbar>
<IconButton
edge="start"
className={classes.menuButton}
className={clsx(classes.menuButton, open && classes.hide)}
color="inherit"
aria-label="menu"
onClick={() => mainMenuOpen(true)}
>
<MenuIcon />
</IconButton>
<Typography variant="h6" className={classes.title}>
Candymat
</Typography>
<IconButton
aria-label="account of current user"
aria-controls="menu-appbar"
aria-haspopup="true"
onClick={handleMenu}
color="inherit"
>
<AccountCircle />
</IconButton>
<Menu
id="menu-appbar"
anchorEl={anchorEl}
anchorOrigin={{
vertical: "top",
horizontal: "right",
}}
keepMounted
transformOrigin={{
vertical: "top",
horizontal: "right",
}}
open={open}
onClose={handleClose}
>
<MenuItem onClick={handleClose}>Profil</MenuItem>
<MenuItem onClick={handleLogout}>Logout</MenuItem>
</Menu>
<ProfileMenu />
</Toolbar>
</AppBar>
);

View File

@ -52,9 +52,9 @@ export default function DialogChangeQuestion(): React.ReactElement {
},
}
);
const categories = useQuery<GetAllCategoriesResponse, null>(
GET_ALL_CATEGORIES
).data?.allCategories.nodes;
const categories =
useQuery<GetAllCategoriesResponse, null>(GET_ALL_CATEGORIES).data
?.allCategories.nodes;
const [editQuestion, { loading: editLoading }] = useMutation<
EditQuestionResponse,

View File

@ -22,7 +22,7 @@ import ToggleButtonGroupAnswerPosition from "./ToggleButtonGroupAnswerPosition";
import EditAnswerText from "./EditAnswerText";
interface EditAnswerSectionProps {
personRowId: number;
loggedInPersonRowId: number;
question: QuestionAnswerResponse;
}
@ -35,7 +35,7 @@ export default function EditAnswerSection(
GetAnswerByQuestionAndPersonVariables
>(GET_ANSWER_BY_QUESTION_AND_PERSON, {
variables: {
personRowId: props.personRowId,
personRowId: props.loggedInPersonRowId,
questionRowId: props.question.rowId,
},
});
@ -92,7 +92,7 @@ export default function EditAnswerSection(
const savePosition = parsePosition(position);
const response = await addAnswer({
variables: {
personRowId: props.personRowId,
personRowId: props.loggedInPersonRowId,
questionRowId: props.question.rowId,
position: savePosition,
text: text,
@ -104,7 +104,7 @@ export default function EditAnswerSection(
id: "somethingIntermediate",
position: savePosition,
text: text || null,
personRowId: props.personRowId,
personRowId: props.loggedInPersonRowId,
questionRowId: props.question.rowId,
__typename: "Answer",
},

View File

@ -0,0 +1,50 @@
import { Container } from "@material-ui/core";
import React from "react";
import { makeStyles } from "@material-ui/core/styles";
import QuestionAnswersList from "./QuestionAnswerList";
import { Route, Switch } from "react-router-dom";
import { PersonRoutes } from "./Main";
import { MenuOption } from "./MainMenu";
import QuestionAnswerIcon from "@material-ui/icons/QuestionAnswer";
const useStyles = makeStyles((theme) => ({
container: {
paddingTop: theme.spacing(4),
paddingBottom: theme.spacing(4),
flexDirection: "column",
},
}));
interface CandidateRoutes extends PersonRoutes {
question: MenuOption;
}
export const candidateRoutes: CandidateRoutes = {
question: {
title: "Fragen beantworten",
path: "/",
icon: <QuestionAnswerIcon />,
},
};
interface HomePageCandidateProps {
loggedInPersonRowId: number;
}
export function HomePageCandidate(
props: HomePageCandidateProps
): React.ReactElement {
const classes = useStyles();
return (
<Container maxWidth="lg" className={classes.container}>
<Switch>
<Route exact path={candidateRoutes.question.path}>
<QuestionAnswersList
loggedInPersonRowId={props.loggedInPersonRowId}
/>
</Route>
</Switch>
</Container>
);
}

View File

@ -0,0 +1,61 @@
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";
import { Route, Switch } from "react-router-dom";
import QuestionAnswerIcon from "@material-ui/icons/QuestionAnswer";
import PeopleIcon from "@material-ui/icons/People";
import { MenuOption } from "./MainMenu";
import { PersonRoutes } from "./Main";
import { UserManagement } from "./UserManagement";
const useStyles = makeStyles((theme) => ({
container: {
paddingTop: theme.spacing(4),
paddingBottom: theme.spacing(4),
flexDirection: "column",
},
}));
interface EditorRoutes extends PersonRoutes {
question: MenuOption;
userManagement: MenuOption;
}
export const editorRoutes: EditorRoutes = {
question: {
title: "Fragen bearbeiten",
path: "/",
icon: <QuestionAnswerIcon />,
},
userManagement: {
title: "Benutzer verwalten",
path: "/benutzer",
icon: <PeopleIcon />,
},
};
interface HomePageEditorProps {
loggedInUserRowId: number;
}
export function HomePageEditor(props: HomePageEditorProps): React.ReactElement {
const classes = useStyles();
return (
<Container maxWidth="lg" className={classes.container}>
<Switch>
<Route exact path={editorRoutes.question.path}>
<QuestionList />
<CategoryList />
</Route>
<Route path={editorRoutes.userManagement.path}>
<UserManagement loggedInPersonRowId={props.loggedInUserRowId} />
</Route>
</Switch>
<Copyright />
</Container>
);
}

View File

@ -0,0 +1,41 @@
import { Container } from "@material-ui/core";
import React from "react";
import { makeStyles } from "@material-ui/core/styles";
import { Route, Switch } from "react-router-dom";
import { PersonRoutes } from "./Main";
import { MenuOption } from "./MainMenu";
import HomeIcon from "@material-ui/icons/Home";
const useStyles = makeStyles((theme) => ({
container: {
paddingTop: theme.spacing(4),
paddingBottom: theme.spacing(4),
flexDirection: "column",
},
}));
interface UserRoutes extends PersonRoutes {
home: MenuOption;
}
export const userRoutes: UserRoutes = {
home: {
title: "Home",
path: "/",
icon: <HomeIcon />,
},
};
export function HomePageUser(): React.ReactElement {
const classes = useStyles();
return (
<Container maxWidth="lg" className={classes.container}>
<Switch>
<Route exact path={userRoutes.home.path}>
Sorry, für dich gibt es hier leider nichts zu sehen...
</Route>
</Switch>
</Container>
);
}

View File

@ -1,65 +1,92 @@
import React from "react";
import { render, screen } from "@testing-library/react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { MockedProvider } from "@apollo/client/testing";
import { MemoryRouter } from "react-router-dom";
import Main from "./Main";
import { SnackbarProvider } from "notistack";
import { JwtPayload } from "../jwt/jwt";
import { queryAllMenuIconButtons } from "../integration-tests/test-helper";
function renderMainPage() {
function renderMainPage(jwt: JwtPayload) {
render(
<MockedProvider>
<MemoryRouter>
<SnackbarProvider>
<Main />
<Main userRole={jwt.role} loggedInUserRowId={jwt.person_row_id} />
</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();
const baseJwt: JwtPayload = {
aud: "postgraphile",
exp: 0,
iat: 0,
iss: "postgraphile",
role: "candymat_person",
person_row_id: 3,
};
describe("As an editor, the main page", () => {
const jwt: JwtPayload = {
...baseJwt,
role: "candymat_editor",
person_row_id: 1,
};
test("displays the editor's home page", () => {
renderMainPage(jwt);
// it renders question and category lists
const questionListHeadline = screen.queryByText(/Fragen/);
const questionListHeadline = screen.queryAllByText(/Fragen/);
const categoryListHeadline = screen.queryByText(/Kategorien/);
expect(questionListHeadline).not.toBeNull();
expect(questionListHeadline.length).toBeGreaterThan(0);
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();
test("has a menu with two entries", async () => {
renderMainPage(jwt);
const questionListHeadline = screen.queryByText(/Fragen/);
const menuButton = queryAllMenuIconButtons();
expect(menuButton).toHaveLength(1);
fireEvent.click(menuButton[0]);
// renders the two menu entries for an editor
await waitFor(() => {
expect(
screen.queryAllByRole("button", { name: /fragen|benutzer/i })
).toHaveLength(2);
});
});
});
describe("As a candidate, the main page", () => {
test("displays the candidate's home page ", () => {
const jwt: JwtPayload = {
...baseJwt,
role: "candymat_candidate",
person_row_id: 2,
};
renderMainPage(jwt);
const questionListHeadline = screen.queryAllByText(/Fragen/);
const categoryListHeadline = screen.queryByText(/Kategorien/);
expect(questionListHeadline).not.toBeNull();
expect(questionListHeadline.length).toBeGreaterThan(0);
expect(categoryListHeadline).toBeNull();
});
});
test("displays the user page if an normal user is logged in", () => {
const userToken =
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiY2FuZHltYXRfcGVyc29uIiwicGVyc29uX3Jvd19pZCI6MywiZXhwIjoxNjA5NDEyMTY4LCJpYXQiOjE2MDkyMzkzNjgsImF1ZCI6InBvc3RncmFwaGlsZSIsImlzcyI6InBvc3RncmFwaGlsZSJ9.RWo5USCmyn-OYjgYixq0y6qlObU9Rb0KdsxxvrtlW1o";
localStorage.setItem("token", userToken);
renderMainPage();
describe("As a simple user, the main page", () => {
test("displays the user's home page.", () => {
const jwt: JwtPayload = {
...baseJwt,
role: "candymat_person",
person_row_id: 3,
};
renderMainPage(jwt);
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,57 +1,89 @@
import CustomAppBar from "./CustomAppBar";
import React from "react";
import React, { ReactElement } from "react";
import { makeStyles } from "@material-ui/core/styles";
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";
import { editorRoutes, HomePageEditor } from "./HomePageEditor";
import { UserRole } from "../jwt/jwt";
import { candidateRoutes, HomePageCandidate } from "./HomePageCandidate";
import { HomePageUser, userRoutes } from "./HomePageUser";
import { MainMenu, mainMenuOpen, mainMenuWidth, MenuOption } from "./MainMenu";
import clsx from "clsx";
import { useReactiveVar } from "@apollo/client";
const useStyles = makeStyles((theme) => ({
appBarSpacer: theme.mixins.toolbar,
content: {
flexGrow: 1,
height: "100vh",
overflow: "auto",
padding: theme.spacing(3),
transition: theme.transitions.create("margin", {
easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.leavingScreen,
}),
marginLeft: -mainMenuWidth,
},
contentShift: {
transition: theme.transitions.create("margin", {
easing: theme.transitions.easing.easeOut,
duration: theme.transitions.duration.enteringScreen,
}),
marginLeft: 0,
},
invalidTokenContainer: {
paddingTop: theme.spacing(4),
paddingBottom: theme.spacing(4),
},
root: {
display: "flex",
},
}));
function Main(): React.ReactElement {
export interface PersonRoutes {
[id: string]: MenuOption;
}
interface MainProps {
userRole: UserRole;
loggedInUserRowId: number;
}
function Main(props: MainProps): ReactElement {
const classes = useStyles();
const getMainPage = () => {
const jwt = getJsonWebToken();
if (jwt) {
switch (jwt.role) {
case "candymat_editor":
return <MainPageEditor />;
case "candymat_candidate":
return <MainPageCandidate personRowId={jwt.person_row_id} />;
case "candymat_person":
return <MainPageUser />;
}
} else {
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>
);
const open = useReactiveVar(mainMenuOpen);
const getHomePage = (): ReactElement => {
switch (props.userRole) {
case "candymat_editor":
return <HomePageEditor loggedInUserRowId={props.loggedInUserRowId} />;
case "candymat_candidate":
return (
<HomePageCandidate loggedInPersonRowId={props.loggedInUserRowId} />
);
case "candymat_person":
return <HomePageUser />;
}
};
const getMenuOptions = (): Array<MenuOption> => {
switch (props.userRole) {
case "candymat_editor":
return Object.values(editorRoutes);
case "candymat_candidate":
return Object.values(candidateRoutes);
case "candymat_person":
return Object.values(userRoutes);
}
};
return (
<div>
<div className={classes.root}>
<CustomAppBar />
<main className={classes.content}>
<MainMenu options={getMenuOptions()} />
<main
className={clsx(classes.content, {
[classes.contentShift]: open,
})}
>
<div className={classes.appBarSpacer} />
{getMainPage()}
{getHomePage()}
</main>
</div>
);

View File

@ -0,0 +1,89 @@
import React, { ReactElement } from "react";
import { createStyles, IconButton } from "@material-ui/core";
import { makeStyles, Theme } from "@material-ui/core/styles";
import Drawer from "@material-ui/core/Drawer";
import List from "@material-ui/core/List";
import Divider from "@material-ui/core/Divider";
import ChevronLeftIcon from "@material-ui/icons/ChevronLeft";
import ListItem from "@material-ui/core/ListItem";
import ListItemIcon from "@material-ui/core/ListItemIcon";
import ListItemText from "@material-ui/core/ListItemText";
import { makeVar, useReactiveVar } from "@apollo/client";
import { useHistory } from "react-router-dom";
export const mainMenuWidth = 240;
const useStyles = makeStyles((theme: Theme) =>
createStyles({
drawer: {
width: mainMenuWidth,
flexShrink: 0,
},
drawerPaper: {
width: mainMenuWidth,
},
drawerHeader: {
display: "flex",
alignItems: "center",
padding: theme.spacing(0, 1),
// necessary for content to be below app bar
...theme.mixins.toolbar,
justifyContent: "flex-end",
},
})
);
export const mainMenuOpen = makeVar<boolean>(false);
export interface MenuOption {
title: string;
path: string;
icon: JSX.Element;
}
interface MainMenuProps {
options: Array<MenuOption>;
}
export function MainMenu(props: MainMenuProps): ReactElement {
const classes = useStyles();
const history = useHistory();
const open = useReactiveVar(mainMenuOpen);
const handleClose = () => {
mainMenuOpen(false);
};
return (
<React.Fragment>
<Drawer
className={classes.drawer}
variant="persistent"
anchor="left"
open={open}
classes={{
paper: classes.drawerPaper,
}}
>
<div className={classes.drawerHeader}>
<IconButton onClick={handleClose}>
<ChevronLeftIcon />
</IconButton>
</div>
<Divider />
<List>
{props.options.map((option) => (
<ListItem
button
key={option.title}
onClick={() => history.push(option.path)}
>
<ListItemIcon>{option.icon}</ListItemIcon>
<ListItemText primary={option.title} />
</ListItem>
))}
</List>
</Drawer>
</React.Fragment>
);
}

View File

@ -1,27 +0,0 @@
import { Container } from "@material-ui/core";
import React from "react";
import { makeStyles } from "@material-ui/core/styles";
import QuestionAnswersList from "./QuestionAnswerList";
const useStyles = makeStyles((theme) => ({
container: {
paddingTop: theme.spacing(4),
paddingBottom: theme.spacing(4),
flexDirection: "column",
},
}));
interface MainPageCandidateProps {
personRowId: number;
}
export function MainPageCandidate(
props: MainPageCandidateProps
): React.ReactElement {
const classes = useStyles();
return (
<Container maxWidth="lg" className={classes.container}>
<QuestionAnswersList personRowId={props.personRowId} />
</Container>
);
}

View File

@ -1,26 +0,0 @@
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(): React.ReactElement {
const classes = useStyles();
return (
<Container maxWidth="lg" className={classes.container}>
<QuestionList />
<CategoryList />
<Copyright />
</Container>
);
}

View File

@ -1,21 +0,0 @@
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(): React.ReactElement {
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,59 @@
import { Avatar, Paper, Typography } from "@material-ui/core";
import React from "react";
import { makeStyles } from "@material-ui/core/styles";
import ChangeRoleMenu from "./ChangeRoleMenu";
import { UppercaseUserRole } from "../jwt/jwt";
const useStyles = makeStyles((theme) => ({
root: {
width: "100%",
marginBottom: theme.spacing(1),
display: "flex",
flexDirection: "row",
alignItems: "center",
},
avatar: {
margin: theme.spacing(1),
},
title: {
marginLeft: theme.spacing(1),
width: "100%",
},
editButton: {
margin: theme.spacing(1),
},
}));
interface PersonCardProps {
firstName: string;
lastName: string;
userRole: UppercaseUserRole;
userRowId: number;
loggedInUserRowId: number;
}
export default function PersonCard(props: PersonCardProps): React.ReactElement {
const classes = useStyles();
const userRole = props.userRole;
const currentUserRowId = props.userRowId;
const getInitials = (): string => {
const initials =
(props.firstName.charAt(0) || "") + (props.lastName.charAt(0) || "");
return initials ? initials.toUpperCase() : "?";
};
const fullName = `${props.firstName} ${props.lastName}`.trim();
return (
<Paper className={classes.root}>
<Avatar className={classes.avatar} aria-label={fullName}>
{getInitials()}
</Avatar>
<Typography className={classes.title}>{fullName}</Typography>
<ChangeRoleMenu
currentUserRowId={currentUserRowId}
currentRole={userRole}
disabled={props.loggedInUserRowId === props.userRowId}
/>
</Paper>
);
}

View File

@ -0,0 +1,50 @@
import React from "react";
import { IconButton, MenuItem } from "@material-ui/core";
import AccountCircle from "@material-ui/icons/AccountCircle";
import Menu from "@material-ui/core/Menu";
import { logoutUser } from "../jwt/jwt";
export function ProfileMenu(): React.ReactElement {
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
const open = Boolean(anchorEl);
const handleMenu = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget);
};
const handleClose = () => {
setAnchorEl(null);
};
return (
<React.Fragment>
<IconButton
aria-label="account of current user"
aria-controls="menu-appbar"
aria-haspopup="true"
onClick={handleMenu}
color="inherit"
>
<AccountCircle />
</IconButton>
<Menu
id="menu-appbar"
anchorEl={anchorEl}
anchorOrigin={{
vertical: "top",
horizontal: "right",
}}
keepMounted
transformOrigin={{
vertical: "top",
horizontal: "right",
}}
open={open}
onClose={handleClose}
>
<MenuItem onClick={handleClose}>Profil</MenuItem>
<MenuItem onClick={logoutUser}>Logout</MenuItem>
</Menu>
</React.Fragment>
);
}

View File

@ -20,7 +20,7 @@ const useStyles = makeStyles((theme) => ({
}));
interface QuestionAnswerListProps {
personRowId: number;
loggedInPersonRowId: number;
}
export default function QuestionAnswersList(
@ -32,7 +32,7 @@ export default function QuestionAnswersList(
GetAllQuestionAnswersVariables
>(GET_ALL_QUESTION_ANSWERS, {
variables: {
personRowId: props.personRowId,
personRowId: props.loggedInPersonRowId,
},
}).data?.allQuestions.nodes;
@ -45,7 +45,7 @@ export default function QuestionAnswersList(
{questionAnswers?.map((question) => (
<AccordionQuestionAnswer
key={question.rowId}
personRowId={props.personRowId}
loggedInPersonRowId={props.loggedInPersonRowId}
question={question}
/>
))}

View File

@ -28,8 +28,9 @@ const useStyles = makeStyles((theme) => ({
}));
export default function QuestionList(): React.ReactElement {
const questions = useQuery<GetAllQuestionsResponse, null>(GET_ALL_QUESTIONS)
.data?.allQuestions.nodes;
const questions =
useQuery<GetAllQuestionsResponse, null>(GET_ALL_QUESTIONS).data
?.allQuestions.nodes;
const classes = useStyles();
const handleAddButtonClick = () => {

View File

@ -57,7 +57,7 @@ export default function SignIn(): React.ReactElement {
onCompleted(data) {
if (data.authenticate.jwtToken) {
localStorage.setItem("token", data.authenticate.jwtToken);
history.replace("/");
window.location.reload();
} else {
setError("Wrong username or password.");
}
@ -83,10 +83,9 @@ export default function SignIn(): React.ReactElement {
noValidate
onSubmit={(event) => {
event.preventDefault();
// fixme: logging?????
login({
variables: { email: email, password: password },
}).catch((error) => console.log(error));
}).catch((error) => setError(error));
}}
>
<TextField

View File

@ -8,23 +8,6 @@ export interface SignUpError {
passwordInvalid: boolean;
}
const parseErrorMessage = (error: ApolloError): string => {
let result = "Sign-up failed because of the following reason(s): ";
if (isEmailAlreadyUsed(error)) {
result += "The E-Mail is already in use. ";
}
if (isFirstNameInvalid(error)) {
result += "The provided 'First Name' is invalid. ";
}
if (isLastNameInvalid(error)) {
result += "The provided 'Last Name' is invalid. ";
}
if (isPasswordInvalid(error)) {
result += "The provided password is invalid. ";
}
return result;
};
const isEmailAlreadyUsed = (error: ApolloError): boolean => {
const errorMessage = error.message.toLowerCase();
@ -53,6 +36,23 @@ const isPasswordInvalid = (error: ApolloError): boolean => {
return errorMessage.includes("invalid") && errorMessage.includes("password");
};
const parseErrorMessage = (error: ApolloError): string => {
let result = "Sign-up failed because of the following reason(s): ";
if (isEmailAlreadyUsed(error)) {
result += "The E-Mail is already in use. ";
}
if (isFirstNameInvalid(error)) {
result += "The provided 'First Name' is invalid. ";
}
if (isLastNameInvalid(error)) {
result += "The provided 'Last Name' is invalid. ";
}
if (isPasswordInvalid(error)) {
result += "The provided password is invalid. ";
}
return result;
};
export const errorHandler = (
error: undefined | ApolloError
): undefined | SignUpError => {

View File

@ -0,0 +1,67 @@
import React from "react";
import { Paper, Typography } from "@material-ui/core";
import { makeStyles } from "@material-ui/core/styles";
import PersonCard from "./PersonCard";
import { useQuery } from "@apollo/client";
import {
BasicPersonResponse,
GET_PERSONS_SORTED_BY_ROLE,
GetPersonsSortedByRoleResponse,
} from "../backend/queries/person";
const useStyles = makeStyles((theme) => ({
root: {
width: "100%",
padding: theme.spacing(1),
marginBottom: theme.spacing(3),
},
}));
interface UserManagementProps {
loggedInPersonRowId: number;
}
export function UserManagement(props: UserManagementProps): React.ReactElement {
const classes = useStyles();
const persons = useQuery<GetPersonsSortedByRoleResponse>(
GET_PERSONS_SORTED_BY_ROLE
).data;
const convertPersonNodeToPersonCard = (
person: BasicPersonResponse
): JSX.Element => {
return (
<PersonCard
key={person.id}
firstName={person.firstName || ""}
lastName={person.lastName || ""}
userRole={person.role}
userRowId={person.rowId}
loggedInUserRowId={props.loggedInPersonRowId}
/>
);
};
return (
<div>
<Paper className={classes.root}>
<Typography component={"h2"} variant="h6" color="primary" gutterBottom>
Redaktion
</Typography>
{persons?.editors.nodes.map(convertPersonNodeToPersonCard)}
</Paper>
<Paper className={classes.root}>
<Typography component={"h2"} variant="h6" color="primary" gutterBottom>
KandidatInnen
</Typography>
{persons?.candidates.nodes.map(convertPersonNodeToPersonCard)}
</Paper>
<Paper className={classes.root}>
<Typography component={"h2"} variant="h6" color="primary" gutterBottom>
Andere registrierte Personen
</Typography>
{persons?.users.nodes.map(convertPersonNodeToPersonCard)}
</Paper>
</div>
);
}

View File

@ -1,13 +1,13 @@
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen",
"Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
sans-serif;
"Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New",
monospace;
monospace;
}

View File

@ -7,13 +7,27 @@ import { ApolloProvider } from "@apollo/client";
import { client } from "./backend/helper";
import { BrowserRouter as Router } from "react-router-dom";
import { SnackbarProvider } from "notistack";
import { createMuiTheme, ThemeProvider } from "@material-ui/core/styles";
const theme = createMuiTheme({
palette: {
primary: {
main: "#59ae2e",
},
secondary: {
main: "#e6007e",
},
},
});
ReactDOM.render(
<ApolloProvider client={client}>
<Router>
<SnackbarProvider maxSnack={3}>
<App />
</SnackbarProvider>
<ThemeProvider theme={theme}>
<SnackbarProvider maxSnack={3}>
<App />
</SnackbarProvider>
</ThemeProvider>
</Router>
</ApolloProvider>,
document.getElementById("root")

View File

@ -28,6 +28,111 @@ import {
} from "./test-helper";
import QuestionAnswersList from "../components/QuestionAnswerList";
function renderQuestionAnswerList(additionalMocks?: Array<MockedResponse>) {
const initialMocks = [
...getAllQuestionAnswersMock,
...getAnswerByQuestionAndPersonMock,
];
const allMocks = additionalMocks
? [...initialMocks, ...additionalMocks]
: initialMocks;
return render(
<MockedProvider mocks={allMocks}>
<MemoryRouter>
<SnackbarProvider>
<QuestionAnswersList loggedInPersonRowId={2} />
</SnackbarProvider>
</MemoryRouter>
</MockedProvider>
);
}
const waitForQuestionsToRender = async (): Promise<Array<HTMLElement>> => {
const numberOfAnswersInMockQuery = questionAnswersMock.length;
let questionAnswerCards: Array<HTMLElement> = [];
await waitFor(() => {
questionAnswerCards = screen.queryAllByRole("button", {
name: /Question [1-3]/,
});
expect(questionAnswerCards.length).toEqual(numberOfAnswersInMockQuery);
});
return questionAnswerCards;
};
const getSaveAnswerButton = (parent: HTMLElement) => {
return findByRole(parent, "button", { name: /speichern/i });
};
const getResetAnswerButton = (parent: HTMLElement) => {
return findByRole(parent, "button", { name: /zurücksetzen/i });
};
interface PositionIconButtons {
positive: HTMLElement;
neutral: HTMLElement;
negative: HTMLElement;
skipped: HTMLElement;
}
const getCandidatePositionButtons = async (
parent: HTMLElement
): Promise<PositionIconButtons> => {
const labelAboveButtons = await findByText(parent, /Deine Position/i);
const parentElement = labelAboveButtons.parentElement as HTMLElement;
const positive = queryAllIconButtons(
getPositivePositionPath(),
parentElement
)[0];
const neutral = queryAllIconButtons(
getNeutralPositionPath(),
parentElement
)[0];
const negative = queryAllIconButtons(
getNegativePositionPath(),
parentElement
)[0];
const skipped = queryAllIconButtons(
getSkippedPositionPath(),
parentElement
)[0];
expect(positive).toBeDefined();
expect(neutral).toBeDefined();
expect(negative).toBeDefined();
expect(skipped).toBeDefined();
return {
positive,
neutral,
negative,
skipped,
};
};
interface AccordionAnswerSection {
positionIconButtons: PositionIconButtons;
textField: HTMLElement;
saveButton: HTMLElement;
resetButton: HTMLElement;
}
const expandAccordionAndGetAnswerSection = async (
accordionExpandArea: HTMLElement
): Promise<AccordionAnswerSection | undefined> => {
fireEvent.click(accordionExpandArea);
let fullAccordion = accordionExpandArea.parentElement;
expect(fullAccordion).not.toBeNull();
fullAccordion = fullAccordion as HTMLElement;
const textField = await findByRole(fullAccordion, "textbox");
const saveButton = await getSaveAnswerButton(fullAccordion);
const resetButton = await getResetAnswerButton(fullAccordion);
const positionIconButtons = await getCandidatePositionButtons(fullAccordion);
return {
positionIconButtons,
textField,
saveButton,
resetButton,
};
};
describe("The AnswerList", () => {
test("displays the existing answers, but not the details of it", async () => {
renderQuestionAnswerList();
@ -145,108 +250,3 @@ describe("The AnswerList", () => {
);
});
});
function renderQuestionAnswerList(additionalMocks?: Array<MockedResponse>) {
const initialMocks = [
...getAllQuestionAnswersMock,
...getAnswerByQuestionAndPersonMock,
];
const allMocks = additionalMocks
? [...initialMocks, ...additionalMocks]
: initialMocks;
return render(
<MockedProvider mocks={allMocks}>
<MemoryRouter>
<SnackbarProvider>
<QuestionAnswersList personRowId={2} />
</SnackbarProvider>
</MemoryRouter>
</MockedProvider>
);
}
const waitForQuestionsToRender = async (): Promise<Array<HTMLElement>> => {
const numberOfAnswersInMockQuery = questionAnswersMock.length;
let questionAnswerCards: Array<HTMLElement> = [];
await waitFor(() => {
questionAnswerCards = screen.queryAllByRole("button", {
name: /Question [1-3]/,
});
expect(questionAnswerCards.length).toEqual(numberOfAnswersInMockQuery);
});
return questionAnswerCards;
};
const getSaveAnswerButton = (parent: HTMLElement) => {
return findByRole(parent, "button", { name: /speichern/i });
};
const getResetAnswerButton = (parent: HTMLElement) => {
return findByRole(parent, "button", { name: /zurücksetzen/i });
};
interface PositionIconButtons {
positive: HTMLElement;
neutral: HTMLElement;
negative: HTMLElement;
skipped: HTMLElement;
}
const getCandidatePositionButtons = async (
parent: HTMLElement
): Promise<PositionIconButtons> => {
const labelAboveButtons = await findByText(parent, /Deine Position/i);
const parentElement = labelAboveButtons.parentElement as HTMLElement;
const positive = queryAllIconButtons(
getPositivePositionPath(),
parentElement
)[0];
const neutral = queryAllIconButtons(
getNeutralPositionPath(),
parentElement
)[0];
const negative = queryAllIconButtons(
getNegativePositionPath(),
parentElement
)[0];
const skipped = queryAllIconButtons(
getSkippedPositionPath(),
parentElement
)[0];
expect(positive).toBeDefined();
expect(neutral).toBeDefined();
expect(negative).toBeDefined();
expect(skipped).toBeDefined();
return {
positive,
neutral,
negative,
skipped,
};
};
interface AccordionAnswerSection {
positionIconButtons: PositionIconButtons;
textField: HTMLElement;
saveButton: HTMLElement;
resetButton: HTMLElement;
}
const expandAccordionAndGetAnswerSection = async (
accordionExpandArea: HTMLElement
): Promise<AccordionAnswerSection | undefined> => {
fireEvent.click(accordionExpandArea);
let fullAccordion = accordionExpandArea.parentElement;
expect(fullAccordion).not.toBeNull();
fullAccordion = fullAccordion as HTMLElement;
const textField = await findByRole(fullAccordion, "textbox");
const saveButton = await getSaveAnswerButton(fullAccordion);
const resetButton = await getResetAnswerButton(fullAccordion);
const positionIconButtons = await getCandidatePositionButtons(fullAccordion);
return {
positionIconButtons,
textField,
saveButton,
resetButton,
};
};

View File

@ -20,7 +20,10 @@ beforeEach(() => localStorage.clear());
describe("The root path /", () => {
test("renders user's home page if they are logged in", () => {
localStorage.setItem("token", "asdfasdfasdf");
localStorage.setItem(
"token",
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiY2FuZHltYXRfZWRpdG9yIiwicGVyc29uX3Jvd19pZCI6MSwiZXhwIjoxNjEyMjEyODQyLCJpYXQiOjE2MTIwNDAwNDIsImF1ZCI6InBvc3RncmFwaGlsZSIsImlzcyI6InBvc3RncmFwaGlsZSJ9.8Z8iCKq-WHOCyKz4rdrjwcVy7sR5_9dHQZR-lJDLEg4"
);
renderAppAtUrl("/");
expect(() => screen.getByLabelText(/current user/)).not.toThrow();
@ -47,7 +50,10 @@ describe("The /login path", () => {
});
test("redirects to root / and the user's home page if the user is logged in", () => {
localStorage.setItem("token", "asdfasdfasdf");
localStorage.setItem(
"token",
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiY2FuZHltYXRfZWRpdG9yIiwicGVyc29uX3Jvd19pZCI6MSwiZXhwIjoxNjEyMjEyODQyLCJpYXQiOjE2MTIwNDAwNDIsImF1ZCI6InBvc3RncmFwaGlsZSIsImlzcyI6InBvc3RncmFwaGlsZSJ9.8Z8iCKq-WHOCyKz4rdrjwcVy7sR5_9dHQZR-lJDLEg4"
);
renderAppAtUrl("/login");
expect(() => screen.getByLabelText(/current user/)).not.toThrow();
@ -67,7 +73,10 @@ describe("The /signup path", () => {
});
test("redirects to root / and the user's home page if the user is logged in", () => {
localStorage.setItem("token", "asdfasdfasdf");
localStorage.setItem(
"token",
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiY2FuZHltYXRfZWRpdG9yIiwicGVyc29uX3Jvd19pZCI6MSwiZXhwIjoxNjEyMjEyODQyLCJpYXQiOjE2MTIwNDAwNDIsImF1ZCI6InBvc3RncmFwaGlsZSIsImlzcyI6InBvc3RncmFwaGlsZSJ9.8Z8iCKq-WHOCyKz4rdrjwcVy7sR5_9dHQZR-lJDLEg4"
);
renderAppAtUrl("/signup");
expect(() => screen.getByLabelText(/current user/)).not.toThrow();

View File

@ -20,6 +20,34 @@ import {
queryAllEditIconButtons,
} from "./test-helper";
function renderCategoryList(additionalMocks?: Array<MockedResponse>) {
const initialMocks = [...getAllCategoriesMock, ...getCategoryByIdMock];
const allMocks = additionalMocks
? [...initialMocks, ...additionalMocks]
: initialMocks;
return render(
<MockedProvider mocks={allMocks}>
<MemoryRouter>
<SnackbarProvider>
<CategoryList />
</SnackbarProvider>
</MemoryRouter>
</MockedProvider>
);
}
const waitForInitialCategoriesToRender = async (): Promise<
Array<HTMLElement>
> => {
const numberOfCategoriesInMockQuery = categoryNodesMock.length;
let categoryCards: Array<HTMLElement> = [];
await waitFor(() => {
categoryCards = screen.queryAllByRole("button", { name: /Category [1-2]/ });
expect(categoryCards.length).toEqual(numberOfCategoriesInMockQuery);
});
return categoryCards;
};
describe("The CategoryList", () => {
test("displays the existing categories, but not the details of it", async () => {
renderCategoryList();
@ -134,31 +162,3 @@ describe("The CategoryList", () => {
});
});
});
function renderCategoryList(additionalMocks?: Array<MockedResponse>) {
const initialMocks = [...getAllCategoriesMock, ...getCategoryByIdMock];
const allMocks = additionalMocks
? [...initialMocks, ...additionalMocks]
: initialMocks;
return render(
<MockedProvider mocks={allMocks}>
<MemoryRouter>
<SnackbarProvider>
<CategoryList />
</SnackbarProvider>
</MemoryRouter>
</MockedProvider>
);
}
const waitForInitialCategoriesToRender = async (): Promise<
Array<HTMLElement>
> => {
const numberOfCategoriesInMockQuery = categoryNodesMock.length;
let categoryCards: Array<HTMLElement> = [];
await waitFor(() => {
categoryCards = screen.queryAllByRole("button", { name: /Category [1-2]/ });
expect(categoryCards.length).toEqual(numberOfCategoriesInMockQuery);
});
return categoryCards;
};

View File

@ -21,6 +21,40 @@ import {
queryAllEditIconButtons,
} from "./test-helper";
function renderQuestionList(additionalMocks?: Array<MockedResponse>) {
const initialMocks = [
...getAllQuestionsMock,
...getQuestionByIdMock,
...getAllCategoriesMock,
];
const allMocks = additionalMocks
? [...initialMocks, ...additionalMocks]
: initialMocks;
return render(
<MockedProvider mocks={allMocks}>
<MemoryRouter>
<SnackbarProvider>
<QuestionList />
</SnackbarProvider>
</MemoryRouter>
</MockedProvider>
);
}
const waitForInitialQuestionsToRender = async (): Promise<
Array<HTMLElement>
> => {
const numberOfQuestionsInMockQuery = questionNodesMock.length;
let questionCards: Array<HTMLElement> = [];
await waitFor(() => {
questionCards = screen.queryAllByRole("button", {
name: /Question [1-3]\?/,
});
expect(questionCards.length).toEqual(numberOfQuestionsInMockQuery);
});
return questionCards;
};
describe("The QuestionList", () => {
test("displays the existing questions, but not the details of it", async () => {
renderQuestionList();
@ -138,37 +172,3 @@ describe("The QuestionList", () => {
});
});
});
function renderQuestionList(additionalMocks?: Array<MockedResponse>) {
const initialMocks = [
...getAllQuestionsMock,
...getQuestionByIdMock,
...getAllCategoriesMock,
];
const allMocks = additionalMocks
? [...initialMocks, ...additionalMocks]
: initialMocks;
return render(
<MockedProvider mocks={allMocks}>
<MemoryRouter>
<SnackbarProvider>
<QuestionList />
</SnackbarProvider>
</MemoryRouter>
</MockedProvider>
);
}
const waitForInitialQuestionsToRender = async (): Promise<
Array<HTMLElement>
> => {
const numberOfQuestionsInMockQuery = questionNodesMock.length;
let questionCards: Array<HTMLElement> = [];
await waitFor(() => {
questionCards = screen.queryAllByRole("button", {
name: /Question [1-3]\?/,
});
expect(questionCards.length).toEqual(numberOfQuestionsInMockQuery);
});
return questionCards;
};

View File

@ -5,17 +5,17 @@ import { MockedProvider } from "@apollo/client/testing";
import { MemoryRouter } from "react-router-dom";
import { loginMock } from "../backend/mutations/login.mock";
const mockHistoryReplace = jest.fn();
jest.mock("react-router-dom", () => ({
...jest.requireActual("react-router-dom"),
useHistory: () => ({
replace: mockHistoryReplace,
}),
}));
const mockLocationReload = jest.fn();
const { location } = window;
describe("SignIn page", () => {
beforeEach(() => mockHistoryReplace.mockReset());
beforeAll(() => {
delete (window as Partial<Window>).location;
window.location = { ...window.location, reload: mockLocationReload };
});
afterAll(() => (window.location = location));
beforeEach(() => mockLocationReload.mockReset());
test("initial state", () => {
render(
@ -36,7 +36,7 @@ describe("SignIn page", () => {
const button = screen.getByRole("button");
expect(button).not.toBeDisabled();
expect(button).toHaveTextContent("Sign In");
expect(mockHistoryReplace).not.toHaveBeenCalled();
expect(mockLocationReload).not.toHaveBeenCalled();
});
test("successful login", async () => {
@ -59,7 +59,7 @@ describe("SignIn page", () => {
fireEvent.click(button);
await waitFor(() => {
expect(mockHistoryReplace).toHaveBeenCalledWith("/");
expect(mockLocationReload).toHaveBeenCalled();
});
});
@ -89,7 +89,7 @@ describe("SignIn page", () => {
// it displays error text
const errorText = screen.getByText(/Wrong username or password/);
expect(errorText).toBeInTheDocument();
expect(mockHistoryReplace).not.toHaveBeenCalled();
expect(mockLocationReload).not.toHaveBeenCalled();
});
});
});

View File

@ -13,6 +13,7 @@ import {
CandidatePosition,
getIconForPosition,
} from "../components/CandidatePositionLegend";
import MenuIcon from "@material-ui/icons/Menu";
const memoizedGetIconPath = (icon: JSX.Element) => {
const cache: { path?: string } = {};
@ -32,6 +33,7 @@ const memoizedGetIconPath = (icon: JSX.Element) => {
};
const getEditIconPath = memoizedGetIconPath(<EditIcon />);
const getMenuIconPath = memoizedGetIconPath(<MenuIcon />);
const getDeleteIconPath = memoizedGetIconPath(<DeleteIcon />);
const getAddIconPath = memoizedGetIconPath(<AddIcon />);
export const getPositivePositionPath = memoizedGetIconPath(
@ -47,60 +49,36 @@ export const getSkippedPositionPath = memoizedGetIconPath(
getIconForPosition(CandidatePosition.skipped)
);
// sorry, I found no better way to find a specific icon button...
export const queryAllIconButtons = (
iconPath: string,
container?: HTMLElement
): HTMLElement[] => {
return (container
? queryAllByRole(container, "button")
: screen.queryAllByRole("button")
return (
container
? queryAllByRole(container, "button")
: screen.queryAllByRole("button")
).filter(
(button) =>
button.innerHTML.includes("svg") && button.innerHTML.includes(iconPath)
);
};
// sorry, I found no better way to find a specific icon button...
export const queryAllEditIconButtons = (
container?: HTMLElement
): Array<HTMLElement> => {
return (container
? queryAllByRole(container, "button")
: screen.queryAllByRole("button")
).filter(
(button) =>
button.innerHTML.includes("svg") &&
button.innerHTML.includes(getEditIconPath())
);
};
): Array<HTMLElement> => queryAllIconButtons(getEditIconPath(), container);
// sorry, I found no better way to find a specific icon button...
const queryAllDeleteIconButtons = (
container?: HTMLElement
): Array<HTMLElement> => {
return (container
? queryAllByRole(container, "button")
: screen.queryAllByRole("button")
).filter(
(button) =>
button.innerHTML.includes("svg") &&
button.innerHTML.includes(getDeleteIconPath())
);
};
): Array<HTMLElement> => queryAllIconButtons(getDeleteIconPath(), container);
// sorry, I found no better way to find a specific icon button...
export const queryAllAddIconButtons = (
container?: HTMLElement
): Array<HTMLElement> => {
return (container
? queryAllByRole(container, "button")
: screen.queryAllByRole("button")
).filter(
(button) =>
button.innerHTML.includes("svg") &&
button.innerHTML.includes(getAddIconPath())
);
};
): Array<HTMLElement> => queryAllIconButtons(getAddIconPath(), container);
export const queryAllMenuIconButtons = (
container?: HTMLElement
): Array<HTMLElement> => queryAllIconButtons(getMenuIconPath(), container);
export const expandAccordionAndGetIconButtons = async (
accordion: HTMLElement

View File

@ -0,0 +1,70 @@
import React from "react";
import { findByText, render, screen } from "@testing-library/react";
import { MockedProvider, MockedResponse } from "@apollo/client/testing";
import { MemoryRouter } from "react-router-dom";
import { SnackbarProvider } from "notistack";
import { UserManagement } from "../components/UserManagement";
import {
getPersonsSortedByRoleAllFilledMock,
getPersonsSortedByRoleNoCandidatesMock,
} from "../backend/queries/person.mock";
function renderUserManagementPage(mocks: Array<MockedResponse>) {
return render(
<MockedProvider mocks={mocks}>
<MemoryRouter>
<SnackbarProvider>
<UserManagement />
</SnackbarProvider>
</MemoryRouter>
</MockedProvider>
);
}
describe("The UserManagement page", () => {
test(" displays all registered persons sorted by role.", async () => {
renderUserManagementPage(getPersonsSortedByRoleAllFilledMock);
const editorsSectionTitle = await screen.findByText(/Redaktion/);
const editorsSection = editorsSectionTitle.parentElement as HTMLElement;
const editor = await findByText(editorsSection, /Erika Mustermann/);
expect(editor).not.toBeNull();
const candidatesSectionTitle = await screen.findByText(/KandidatInnen/);
const candidatesSection =
candidatesSectionTitle.parentElement as HTMLElement;
const candidate1 = await findByText(candidatesSection, /Max Mustermann/);
const candidate2 = await findByText(candidatesSection, /Tricia McMillan/);
expect(candidate1).not.toBeNull();
expect(candidate2).not.toBeNull();
/* const otherUsersSectionTitle = await screen.findByText(/Andere/);
const otherUsersSection = otherUsersSectionTitle.parentElement as HTMLElement;
const otherUser = await findByText(otherUsersSection, /Happy User/);
expect(otherUser).not.toBeNull(); */
});
test(" displays all registered persons sorted by role even though there are no candidates.", async () => {
renderUserManagementPage(getPersonsSortedByRoleNoCandidatesMock);
const editorsSectionTitle = await screen.findByText(/Redaktion/);
const editorsSection = editorsSectionTitle.parentElement as HTMLElement;
const editor = await findByText(editorsSection, /Erika Mustermann/);
expect(editor).not.toBeNull();
const candidatesSectionTitle = await screen.findByText("KandidatInnen");
const candidatesSection =
candidatesSectionTitle.parentElement as HTMLElement;
expect(candidatesSection.childElementCount).toBe(1);
const otherUsersSectionTitle = await screen.findByText(/Andere/);
const otherUsersSection =
otherUsersSectionTitle.parentElement as HTMLElement;
const otherUser1 = await findByText(otherUsersSection, /Max Mustermann/);
const otherUser2 = await findByText(otherUsersSection, /Tricia McMillan/);
const otherUser3 = await findByText(otherUsersSection, /Happy User/);
expect(otherUser1).not.toBeNull();
expect(otherUser2).not.toBeNull();
expect(otherUser3).not.toBeNull();
});
});

View File

@ -1,10 +1,53 @@
import { client } from "../backend/helper";
type Claim = "role" | "person_row_id" | "exp" | "iat" | "aud" | "iss";
export type UppercaseUserRole =
| "CANDYMAT_PERSON"
| "CANDYMAT_EDITOR"
| "CANDYMAT_CANDIDATE";
export type UserRole =
| "candymat_editor"
| "candymat_candidate"
| "candymat_person";
export interface JwtPayload {
role: UserRole;
person_row_id: number;
exp: number;
iat: number;
aud: "postgraphile";
iss: "postgraphile";
}
const CLAIMS: Claim[] = ["role", "person_row_id", "exp", "iat", "aud", "iss"];
export const UPPERCASE_USER_ROLES: UppercaseUserRole[] = [
"CANDYMAT_PERSON",
"CANDYMAT_EDITOR",
"CANDYMAT_CANDIDATE",
];
export const USER_ROLES: UserRole[] = [
"candymat_editor",
"candymat_candidate",
"candymat_person",
];
export const getRawJsonWebToken = (): string | null => {
return localStorage.getItem("token");
};
export const getJsonWebToken = (): JwtPayload | null => {
const rawToken = getRawJsonWebToken();
return rawToken ? parseJwt(rawToken) : null;
export const isJwtPayloadValid = (jwtPayload: unknown): boolean => {
const jwt = Object(jwtPayload);
return (
CLAIMS.every((claim) => Object.keys(jwt).includes(claim)) &&
USER_ROLES.includes(jwt.role) &&
typeof jwt.person_row_id === "number" &&
typeof jwt.exp === "number" &&
typeof jwt.iat === "number"
);
};
export const parseJwt = (token: string): JwtPayload | null => {
@ -24,26 +67,19 @@ export const parseJwt = (token: string): JwtPayload | 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"
);
export const getJsonWebToken = (): JwtPayload | null => {
const rawToken = getRawJsonWebToken();
return rawToken ? parseJwt(rawToken) : null;
};
const claims = ["role", "person_row_id", "exp", "iat", "aud", "iss"];
const userRoles = ["candymat_editor", "candymat_candidate", "candymat_person"];
export const logoutUser = async (): Promise<void> => {
try {
await client.cache.reset();
} catch (e) {
console.error(e);
}
localStorage.removeItem("token");
window.location.reload();
};
interface JwtPayload {
role: UserRole;
person_row_id: number;
exp: number;
iat: number;
aud: "postgraphile";
iss: "postgraphile";
}
type UserRole = "candymat_editor" | "candymat_candidate" | "candymat_person";
export const isLoggedIn = (): boolean => !!getJsonWebToken();

View File

@ -25,40 +25,6 @@ type Config = {
onUpdate?: (registration: ServiceWorkerRegistration) => void;
};
export function register(config?: Config): void {
if (process.env.NODE_ENV === "production" && "serviceWorker" in navigator) {
// The URL constructor is available in all browsers that support SW.
const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href);
if (publicUrl.origin !== window.location.origin) {
// Our service worker won't work if PUBLIC_URL is on a different origin
// from what our page is served on. This might happen if a CDN is used to
// serve assets; see https://github.com/facebook/create-react-app/issues/2374
return;
}
window.addEventListener("load", () => {
const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
if (isLocalhost) {
// This is running on localhost. Let's check if a service worker still exists or not.
checkValidServiceWorker(swUrl, config);
// Add some additional logging to localhost, pointing developers to the
// service worker/PWA documentation.
navigator.serviceWorker.ready.then(() => {
console.log(
"This web app is being served cache-first by a service " +
"worker. To learn more, visit https://bit.ly/CRA-PWA"
);
});
} else {
// Is not localhost. Just register service worker
registerValidSW(swUrl, config);
}
});
}
}
function registerValidSW(swUrl: string, config?: Config) {
navigator.serviceWorker
.register(swUrl)
@ -133,6 +99,40 @@ function checkValidServiceWorker(swUrl: string, config?: Config) {
});
}
export function register(config?: Config): void {
if (process.env.NODE_ENV === "production" && "serviceWorker" in navigator) {
// The URL constructor is available in all browsers that support SW.
const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href);
if (publicUrl.origin !== window.location.origin) {
// Our service worker won't work if PUBLIC_URL is on a different origin
// from what our page is served on. This might happen if a CDN is used to
// serve assets; see https://github.com/facebook/create-react-app/issues/2374
return;
}
window.addEventListener("load", () => {
const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
if (isLocalhost) {
// This is running on localhost. Let's check if a service worker still exists or not.
checkValidServiceWorker(swUrl, config);
// Add some additional logging to localhost, pointing developers to the
// service worker/PWA documentation.
navigator.serviceWorker.ready.then(() => {
console.log(
"This web app is being served cache-first by a service " +
"worker. To learn more, visit https://bit.ly/CRA-PWA"
);
});
} else {
// Is not localhost. Just register service worker
registerValidSW(swUrl, config);
}
});
}
}
export function unregister(): void {
if ("serviceWorker" in navigator) {
navigator.serviceWorker.ready

View File

@ -1,7 +1,11 @@
{
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
@ -13,7 +17,10 @@
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react"
"jsx": "react",
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
"include": [
"src"
]
}