From e26a1545187c9d003767505d8c7e45fbb8518675 Mon Sep 17 00:00:00 2001 From: Christoph Lienhard Date: Tue, 30 Mar 2021 18:24:41 +0200 Subject: [PATCH] #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. --- backend/sql/02_create-user_tables.sql | 9 ++ .../src/backend/queries/person.mock.ts | 130 ++++++++++++++++++ redaktions-app/src/backend/queries/person.ts | 57 ++++++++ .../src/components/HomePageEditor.tsx | 3 +- redaktions-app/src/components/PersonCard.tsx | 52 +++++++ .../src/components/UserManagement.tsx | 60 ++++++++ .../user-management.integration.test.tsx | 67 +++++++++ 7 files changed, 377 insertions(+), 1 deletion(-) create mode 100644 redaktions-app/src/backend/queries/person.mock.ts create mode 100644 redaktions-app/src/backend/queries/person.ts create mode 100644 redaktions-app/src/components/PersonCard.tsx create mode 100644 redaktions-app/src/components/UserManagement.tsx create mode 100644 redaktions-app/src/integration-tests/user-management.integration.test.tsx diff --git a/backend/sql/02_create-user_tables.sql b/backend/sql/02_create-user_tables.sql index 9881748..8e75a40 100644 --- a/backend/sql/02_create-user_tables.sql +++ b/backend/sql/02_create-user_tables.sql @@ -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); diff --git a/redaktions-app/src/backend/queries/person.mock.ts b/redaktions-app/src/backend/queries/person.mock.ts new file mode 100644 index 0000000..23c6334 --- /dev/null +++ b/redaktions-app/src/backend/queries/person.mock.ts @@ -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 +> = [ + { + 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 +> = [ + { + request: { + query: GET_PERSONS_SORTED_BY_ROLE, + }, + result: { + data: getPersonsByRoleNoCandidatesData, + }, + }, +]; diff --git a/redaktions-app/src/backend/queries/person.ts b/redaktions-app/src/backend/queries/person.ts new file mode 100644 index 0000000..de3052f --- /dev/null +++ b/redaktions-app/src/backend/queries/person.ts @@ -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; + __typename: "PeopleConnection"; + }; + candidates: { + nodes: Array; + __typename: "PeopleConnection"; + }; + users: { + nodes: Array; + __typename: "PeopleConnection"; + }; +} diff --git a/redaktions-app/src/components/HomePageEditor.tsx b/redaktions-app/src/components/HomePageEditor.tsx index 40c5da6..c88525c 100644 --- a/redaktions-app/src/components/HomePageEditor.tsx +++ b/redaktions-app/src/components/HomePageEditor.tsx @@ -9,6 +9,7 @@ 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: { @@ -47,7 +48,7 @@ export function HomePageEditor(): React.ReactElement { -
+ diff --git a/redaktions-app/src/components/PersonCard.tsx b/redaktions-app/src/components/PersonCard.tsx new file mode 100644 index 0000000..0e1263b --- /dev/null +++ b/redaktions-app/src/components/PersonCard.tsx @@ -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 ( + + + {getInitials()} + + {fullName} + + ); +} diff --git a/redaktions-app/src/components/UserManagement.tsx b/redaktions-app/src/components/UserManagement.tsx new file mode 100644 index 0000000..b28297e --- /dev/null +++ b/redaktions-app/src/components/UserManagement.tsx @@ -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( + GET_PERSONS_SORTED_BY_ROLE + ).data; + + const convertPersonNodeToPersonCard = ( + person: BasicPersonResponse + ): JSX.Element => { + return ( + + ); + }; + + return ( +
+ + + Redaktion + + {persons?.editors.nodes.map(convertPersonNodeToPersonCard)} + + + + Kandidat:innen + + {persons?.candidates.nodes.map(convertPersonNodeToPersonCard)} + + + + Andere registrierte Personen + + {persons?.users.nodes.map(convertPersonNodeToPersonCard)} + +
+ ); +} diff --git a/redaktions-app/src/integration-tests/user-management.integration.test.tsx b/redaktions-app/src/integration-tests/user-management.integration.test.tsx new file mode 100644 index 0000000..2c49af7 --- /dev/null +++ b/redaktions-app/src/integration-tests/user-management.integration.test.tsx @@ -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) { + return render( + + + + + + + + ); +} + +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(); + }); +});