#20 Add UserManagement page
Connects to backend and gets all registered users by role. Enabled editors to see all registered users which wasn't possible.
This commit is contained in:
parent
765443ebc2
commit
e26a154518
|
@ -19,12 +19,14 @@ create table candymat_data_privat.person_account
|
||||||
email character varying(320) not null unique check (email ~* '^.+@.+\..+$'),
|
email character varying(320) not null unique check (email ~* '^.+@.+\..+$'),
|
||||||
password_hash character varying(256) not null
|
password_hash character varying(256) not null
|
||||||
);
|
);
|
||||||
|
|
||||||
alter table candymat_data.person
|
alter table candymat_data.person
|
||||||
enable row level security;
|
enable row level security;
|
||||||
create policy update_person on candymat_data.person for update to candymat_person
|
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);
|
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
|
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);
|
using (row_id = nullif(current_setting('jwt.claims.person_row_id', true), '')::integer);
|
||||||
|
|
||||||
-- The following enables viewing candidates and editors information for every person.
|
-- The following enables viewing candidates and editors information for every person.
|
||||||
-- This may be changed to only enable registered (and verified) persons.
|
-- This may be changed to only enable registered (and verified) persons.
|
||||||
create policy select_person_public
|
create policy select_person_public
|
||||||
|
@ -32,3 +34,10 @@ create policy select_person_public
|
||||||
for select
|
for select
|
||||||
to candymat_anonymous, candymat_person -- maybe change to candymat_person only in the future
|
to candymat_anonymous, candymat_person -- maybe change to candymat_person only in the future
|
||||||
using (role in ('candymat_editor', 'candymat_candidate'));
|
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);
|
||||||
|
|
130
redaktions-app/src/backend/queries/person.mock.ts
Normal file
130
redaktions-app/src/backend/queries/person.mock.ts
Normal file
|
@ -0,0 +1,130 @@
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
57
redaktions-app/src/backend/queries/person.ts
Normal file
57
redaktions-app/src/backend/queries/person.ts
Normal file
|
@ -0,0 +1,57 @@
|
||||||
|
import { gql } from "@apollo/client";
|
||||||
|
import { UserRole } from "../../jwt/jwt";
|
||||||
|
|
||||||
|
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: UserRole;
|
||||||
|
__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";
|
||||||
|
};
|
||||||
|
}
|
|
@ -9,6 +9,7 @@ import QuestionAnswerIcon from "@material-ui/icons/QuestionAnswer";
|
||||||
import PeopleIcon from "@material-ui/icons/People";
|
import PeopleIcon from "@material-ui/icons/People";
|
||||||
import { MenuOption } from "./MainMenu";
|
import { MenuOption } from "./MainMenu";
|
||||||
import { PersonRoutes } from "./Main";
|
import { PersonRoutes } from "./Main";
|
||||||
|
import { UserManagement } from "./UserManagement";
|
||||||
|
|
||||||
const useStyles = makeStyles((theme) => ({
|
const useStyles = makeStyles((theme) => ({
|
||||||
container: {
|
container: {
|
||||||
|
@ -47,7 +48,7 @@ export function HomePageEditor(): React.ReactElement {
|
||||||
<CategoryList />
|
<CategoryList />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path={editorRoutes.userManagement.path}>
|
<Route path={editorRoutes.userManagement.path}>
|
||||||
<div />
|
<UserManagement />
|
||||||
</Route>
|
</Route>
|
||||||
</Switch>
|
</Switch>
|
||||||
<Copyright />
|
<Copyright />
|
||||||
|
|
52
redaktions-app/src/components/PersonCard.tsx
Normal file
52
redaktions-app/src/components/PersonCard.tsx
Normal file
|
@ -0,0 +1,52 @@
|
||||||
|
import {
|
||||||
|
Avatar,
|
||||||
|
Card,
|
||||||
|
CardActionArea,
|
||||||
|
CardHeader,
|
||||||
|
Paper,
|
||||||
|
Typography,
|
||||||
|
} from "@material-ui/core";
|
||||||
|
import React from "react";
|
||||||
|
import { makeStyles } from "@material-ui/core/styles";
|
||||||
|
|
||||||
|
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),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
interface PersonCardProps {
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PersonCard(props: PersonCardProps): React.ReactElement {
|
||||||
|
const classes = useStyles();
|
||||||
|
|
||||||
|
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>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
60
redaktions-app/src/components/UserManagement.tsx
Normal file
60
redaktions-app/src/components/UserManagement.tsx
Normal file
|
@ -0,0 +1,60 @@
|
||||||
|
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),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
export function UserManagement(): 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 || ""}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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>
|
||||||
|
Kandidat:innen
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,67 @@
|
||||||
|
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(/Kandidat/);
|
||||||
|
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(/Kandidat/);
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
Loading…
Reference in a new issue