Skip to content

Commit

Permalink
Merge branch 'main' into user-update
Browse files Browse the repository at this point in the history
  • Loading branch information
cikzh authored Feb 10, 2025
2 parents 5936788 + 18917fc commit 1155d9f
Show file tree
Hide file tree
Showing 25 changed files with 711 additions and 39 deletions.
2 changes: 1 addition & 1 deletion frontend/app/component/navbar/NavBarLinks.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ function TopLevelManagementLinks() {
return (
<>
<NavLink to={"/elections#administrator"}>{t("election.title.plural")}</NavLink>
<NavLink to={"/users#administratorcoordinator"}>{t("users")}</NavLink>
<NavLink to={"/users#administratorcoordinator"}>{t("users.users")}</NavLink>
<NavLink to={"/workstations#administrator"}>{t("workstations.workstations")}</NavLink>
<NavLink to={"/logs#administratorcoordinator"}>{t("logs")}</NavLink>
</>
Expand Down
2 changes: 1 addition & 1 deletion frontend/app/module/DevHomePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ function DevLinks() {
))}
</ul>
<li>
<Link to={`/users#administratorcoordinator`}>{t("user.management")}</Link>
<Link to={`/users#administratorcoordinator`}>{t("users.management")}</Link>
</li>
<li>
<Link to={`/workstations#administrator`}>{t("workstations.manage")}</Link>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,23 +84,25 @@ export function PollingStationUpdatePage() {
onCancel={handleCancel}
/>

<Button
type="button"
variant="tertiary-destructive"
leftIcon={<IconTrash />}
onClick={toggleShowDeleteModal}
>
{t("polling_station.delete")}
</Button>
{showDeleteModal && (
<PollingStationDeleteModal
electionId={election.id}
pollingStationId={pollingStationId}
onCancel={toggleShowDeleteModal}
onError={handleDeleteError}
onDeleted={handleDeleted}
/>
)}
<div className="mt-lg">
<Button
type="button"
variant="tertiary-destructive"
leftIcon={<IconTrash />}
onClick={toggleShowDeleteModal}
>
{t("polling_station.delete")}
</Button>
{showDeleteModal && (
<PollingStationDeleteModal
electionId={election.id}
pollingStationId={pollingStationId}
onCancel={toggleShowDeleteModal}
onError={handleDeleteError}
onDeleted={handleDeleted}
/>
)}
</div>
</>
)}
</article>
Expand Down
21 changes: 14 additions & 7 deletions frontend/app/module/users/UserListPage.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { useUserListRequest } from "app/module/users/useUserListRequest";

import { t } from "@kiesraad/i18n";
import { Loader, PageTitle, Table } from "@kiesraad/ui";
import { IconPlus } from "@kiesraad/icon";
import { Button, Loader, PageTitle, Table, Toolbar } from "@kiesraad/ui";
import { formatDateTime } from "@kiesraad/util";

export function UserListPage() {
Expand All @@ -19,27 +20,33 @@ export function UserListPage() {

return (
<>
<PageTitle title={`${t("user.management")} - Abacus`} />
<PageTitle title={`${t("users.management")} - Abacus`} />
<header>
<section>
<h1>{t("user.management")}</h1>
<h1>{t("users.management")}</h1>
</section>
</header>
<main>
<article>
<Toolbar>
<Button.Link variant="secondary" size="sm" to={"./create"}>
<IconPlus /> {t("users.add")}
</Button.Link>
</Toolbar>

<Table id="users">
<Table.Header>
<Table.Column>{t("user.username")}</Table.Column>
<Table.Column>{t("users.username")}</Table.Column>
<Table.Column>{t("role")}</Table.Column>
<Table.Column>{t("user.fullname")}</Table.Column>
<Table.Column>{t("user.last_activity")}</Table.Column>
<Table.Column>{t("users.fullname")}</Table.Column>
<Table.Column>{t("users.last_activity")}</Table.Column>
</Table.Header>
<Table.Body className="fs-md">
{users.map((user) => (
<Table.LinkRow key={user.id} to={`${user.id}/update`}>
<Table.Cell>{user.username}</Table.Cell>
<Table.Cell>{t(user.role)}</Table.Cell>
<Table.Cell>{user.fullname ?? <span className="text-muted">{t("user.not_used")}</span>}</Table.Cell>
<Table.Cell>{user.fullname ?? <span className="text-muted">{t("users.not_used")}</span>}</Table.Cell>
<Table.Cell>
{user.last_activity_at ? formatDateTime(new Date(user.last_activity_at)) : "–"}
</Table.Cell>
Expand Down
Empty file.
20 changes: 20 additions & 0 deletions frontend/app/module/users/create/UserCreateContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { createContext } from "react";

import { Role } from "@kiesraad/api";

export type UserType = "fullname" | "anonymous";

export interface CreateUser {
role?: Role;
type?: UserType;
username?: string;
fullname?: string;
password?: string;
}

export interface IUserCreateContext {
user: CreateUser;
updateUser: (user: CreateUser) => void;
}

export const UserCreateContext = createContext<IUserCreateContext | undefined>(undefined);
17 changes: 17 additions & 0 deletions frontend/app/module/users/create/UserCreateContextProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import * as React from "react";
import { useState } from "react";

import { User } from "@kiesraad/api";

import { IUserCreateContext, UserCreateContext } from "./UserCreateContext";

export function UserCreateContextProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<Partial<User>>({});

function updateUser(update: Partial<User>) {
setUser((user) => ({ ...user, ...update }));
}

const context: IUserCreateContext = { user, updateUser };
return <UserCreateContext.Provider value={context}>{children}</UserCreateContext.Provider>;
}
113 changes: 113 additions & 0 deletions frontend/app/module/users/create/UserCreateDetailsPage.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { screen } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event";
import { describe, expect, test, vi } from "vitest";

import { UserCreateDetailsPage } from "app/module/users";

import { render } from "@kiesraad/test";

import { IUserCreateContext, UserCreateContext } from "./UserCreateContext";

const navigate = vi.fn();

vi.mock(import("react-router"), async (importOriginal) => ({
...(await importOriginal()),
Navigate: ({ to }) => {
navigate(to);
return null;
},
useNavigate: () => navigate,
}));

function renderPage(context: Partial<IUserCreateContext>) {
return render(
<UserCreateContext.Provider value={context as IUserCreateContext}>
<UserCreateDetailsPage />
</UserCreateContext.Provider>,
);
}

describe("UserCreateDetailsPage", () => {
test("Redirect to start when no role in context", () => {
renderPage({ user: {} });
expect(navigate).toHaveBeenCalledExactlyOnceWith("/users/create");
});

test("Show validation errors", async () => {
const updateUser = vi.fn();
renderPage({ user: { role: "coordinator", type: "fullname" }, updateUser });

const user = userEvent.setup();

const submit = await screen.findByRole("button", { name: "Opslaan" });
await user.click(submit);

const username = await screen.findByLabelText("Gebruikersnaam");
expect(username).toBeInvalid();
expect(username).toHaveAccessibleErrorMessage("Dit veld mag niet leeg zijn");

const fullname = await screen.findByLabelText("Volledige naam");
expect(fullname).toBeInvalid();
expect(fullname).toHaveAccessibleErrorMessage("Dit veld mag niet leeg zijn");

const password = await screen.findByLabelText("Tijdelijk wachtwoord");
expect(password).toBeInvalid();
expect(password).toHaveAccessibleErrorMessage("Dit veld mag niet leeg zijn");

await user.type(password, "mand");
await user.click(submit);

expect(password).toBeInvalid();
expect(password).toHaveAccessibleErrorMessage("Dit wachtwoord is niet lang genoeg. Gebruik minimaal 12 karakters");
});

test("Continue after filling in fullname fields", async () => {
const updateUser = vi.fn();
renderPage({ user: { role: "coordinator", type: "fullname" }, updateUser });

const user = userEvent.setup();

expect(await screen.findByRole("heading", { level: 1, name: "Coördinator toevoegen" })).toBeInTheDocument();

expect(await screen.findByLabelText("Gebruikersnaam")).toHaveValue("");
expect(await screen.findByLabelText("Volledige naam")).toHaveValue("");
expect(await screen.findByLabelText("Tijdelijk wachtwoord")).toHaveValue("");

await user.type(await screen.findByLabelText("Gebruikersnaam"), "NieuweGebruiker");
await user.type(await screen.findByLabelText("Volledige naam"), "Nieuwe Gebruiker");
await user.type(await screen.findByLabelText("Tijdelijk wachtwoord"), "Wachtwoord12");

const submit = await screen.findByRole("button", { name: "Opslaan" });
await user.click(submit);

expect(updateUser).toHaveBeenCalledExactlyOnceWith({
username: "NieuweGebruiker",
fullname: "Nieuwe Gebruiker",
password: "Wachtwoord12",
});
expect(navigate).toHaveBeenCalledExactlyOnceWith("/users");
});

test("Continue after filling in anonymous fields", async () => {
const updateUser = vi.fn();
renderPage({ user: { role: "typist", type: "anonymous" }, updateUser });

const user = userEvent.setup();

expect(await screen.findByLabelText("Gebruikersnaam")).toHaveValue("");
expect(screen.queryByLabelText("Volledige naam")).not.toBeInTheDocument();
expect(await screen.findByLabelText("Tijdelijk wachtwoord")).toHaveValue("");

await user.type(await screen.findByLabelText("Gebruikersnaam"), "NieuweGebruiker");
await user.type(await screen.findByLabelText("Tijdelijk wachtwoord"), "Wachtwoord12");

const submit = await screen.findByRole("button", { name: "Opslaan" });
await user.click(submit);

expect(updateUser).toHaveBeenCalledExactlyOnceWith({
username: "NieuweGebruiker",
password: "Wachtwoord12",
});
expect(navigate).toHaveBeenCalledExactlyOnceWith("/users");
});
});
119 changes: 119 additions & 0 deletions frontend/app/module/users/create/UserCreateDetailsPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { FormEvent, useState } from "react";
import { Navigate, useNavigate } from "react-router";

import { t } from "@kiesraad/i18n";
import { Button, Form, FormLayout, InputField, PageTitle } from "@kiesraad/ui";

import { useUserCreateContext } from "./useUserCreateContext";

interface UserDetails {
username: string;
fullname?: string;
password: string;
}

type ValidationErrors = Partial<UserDetails>;

const MIN_PASSWORD_LENGTH = 12;

export function UserCreateDetailsPage() {
const navigate = useNavigate();
const { user, updateUser } = useUserCreateContext();
const [validationErrors, setValidationErrors] = useState<ValidationErrors | null>(null);

if (!user.role || !user.type) {
return <Navigate to="/users/create" />;
}

function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const formData = new FormData(event.currentTarget);

const details: UserDetails = {
username: (formData.get("username") as string).trim(),
fullname: (formData.get("fullname") as string | undefined)?.trim(),
password: (formData.get("password") as string).trim(),
};

if (!validate(details)) {
return;
}

updateUser(details);

void navigate(`/users`);
}

function validate(details: UserDetails): boolean {
const errors: ValidationErrors = {};

const required = t("form_errors.FORM_VALIDATION_RESULT_REQUIRED");

if (details.username.length === 0) {
errors.username = required;
}

if (details.fullname !== undefined && details.fullname.length === 0) {
errors.fullname = required;
}

if (details.password.length === 0) {
errors.password = required;
} else if (details.password.length < MIN_PASSWORD_LENGTH) {
errors.password = t("users.temporary_password_error_min_length", { min_length: MIN_PASSWORD_LENGTH });
}

const isValid = Object.keys(errors).length === 0;
setValidationErrors(isValid ? null : errors);
return isValid;
}

return (
<>
<PageTitle title={`${t("users.add")} - Abacus`} />
<header>
<section>
<h1>{t("users.add_role", { role: t(user.role) })}</h1>
</section>
</header>
<main>
<Form onSubmit={handleSubmit}>
<FormLayout width="medium">
<FormLayout.Section title={t("users.details_title")}>
<InputField
id="username"
name="username"
label={t("users.username")}
hint={t("users.username_hint")}
error={validationErrors?.username}
/>

{user.type === "fullname" && (
<InputField
id="fullname"
name="fullname"
label={t("users.fullname")}
hint={t("users.fullname_hint")}
error={validationErrors?.fullname}
/>
)}

<InputField
id="password"
name="password"
label={t("users.temporary_password")}
hint={t("users.temporary_password_hint", { min_length: MIN_PASSWORD_LENGTH })}
error={validationErrors?.password}
/>
</FormLayout.Section>
</FormLayout>
<FormLayout.Controls>
<Button size="xl" type="submit">
{t("save")}
</Button>
</FormLayout.Controls>
</Form>
</main>
</>
);
}
11 changes: 11 additions & 0 deletions frontend/app/module/users/create/UserCreateLayout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { Outlet } from "react-router";

import { UserCreateContextProvider } from "./UserCreateContextProvider";

export function UserCreateLayout() {
return (
<UserCreateContextProvider>
<Outlet />
</UserCreateContextProvider>
);
}
Loading

0 comments on commit 1155d9f

Please sign in to comment.