diff --git a/frontend/app/component/navbar/NavBarLinks.tsx b/frontend/app/component/navbar/NavBarLinks.tsx index a95e56222..92c5946f8 100644 --- a/frontend/app/component/navbar/NavBarLinks.tsx +++ b/frontend/app/component/navbar/NavBarLinks.tsx @@ -70,7 +70,7 @@ function TopLevelManagementLinks() { return ( <> {t("election.title.plural")} - {t("users")} + {t("users.users")} {t("workstations.workstations")} {t("logs")} diff --git a/frontend/app/module/DevHomePage.tsx b/frontend/app/module/DevHomePage.tsx index 41f022324..be3163fe1 100644 --- a/frontend/app/module/DevHomePage.tsx +++ b/frontend/app/module/DevHomePage.tsx @@ -67,7 +67,7 @@ function DevLinks() { ))}
  • - {t("user.management")} + {t("users.management")}
  • {t("workstations.manage")} diff --git a/frontend/app/module/polling_stations/page/PollingStationUpdatePage.tsx b/frontend/app/module/polling_stations/page/PollingStationUpdatePage.tsx index da6ec202a..7fbc05717 100644 --- a/frontend/app/module/polling_stations/page/PollingStationUpdatePage.tsx +++ b/frontend/app/module/polling_stations/page/PollingStationUpdatePage.tsx @@ -84,23 +84,25 @@ export function PollingStationUpdatePage() { onCancel={handleCancel} /> - - {showDeleteModal && ( - - )} +
    + + {showDeleteModal && ( + + )} +
    )} diff --git a/frontend/app/module/users/UserListPage.tsx b/frontend/app/module/users/UserListPage.tsx index 99cf9423e..b8c93ab93 100644 --- a/frontend/app/module/users/UserListPage.tsx +++ b/frontend/app/module/users/UserListPage.tsx @@ -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() { @@ -19,27 +20,33 @@ export function UserListPage() { return ( <> - +
    -

    {t("user.management")}

    +

    {t("users.management")}

    + + + {t("users.add")} + + + - {t("user.username")} + {t("users.username")} {t("role")} - {t("user.fullname")} - {t("user.last_activity")} + {t("users.fullname")} + {t("users.last_activity")} {users.map((user) => ( {user.username} {t(user.role)} - {user.fullname ?? {t("user.not_used")}} + {user.fullname ?? {t("users.not_used")}} {user.last_activity_at ? formatDateTime(new Date(user.last_activity_at)) : "–"} diff --git a/frontend/app/module/users/UsersHomePage.tsx b/frontend/app/module/users/UsersHomePage.tsx deleted file mode 100644 index e69de29bb..000000000 diff --git a/frontend/app/module/users/create/UserCreateContext.tsx b/frontend/app/module/users/create/UserCreateContext.tsx new file mode 100644 index 000000000..0e7e1e865 --- /dev/null +++ b/frontend/app/module/users/create/UserCreateContext.tsx @@ -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(undefined); diff --git a/frontend/app/module/users/create/UserCreateContextProvider.tsx b/frontend/app/module/users/create/UserCreateContextProvider.tsx new file mode 100644 index 000000000..628649c95 --- /dev/null +++ b/frontend/app/module/users/create/UserCreateContextProvider.tsx @@ -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>({}); + + function updateUser(update: Partial) { + setUser((user) => ({ ...user, ...update })); + } + + const context: IUserCreateContext = { user, updateUser }; + return {children}; +} diff --git a/frontend/app/module/users/create/UserCreateDetailsPage.test.tsx b/frontend/app/module/users/create/UserCreateDetailsPage.test.tsx new file mode 100644 index 000000000..f3832f266 --- /dev/null +++ b/frontend/app/module/users/create/UserCreateDetailsPage.test.tsx @@ -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) { + return render( + + + , + ); +} + +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"); + }); +}); diff --git a/frontend/app/module/users/create/UserCreateDetailsPage.tsx b/frontend/app/module/users/create/UserCreateDetailsPage.tsx new file mode 100644 index 000000000..13e8b87ce --- /dev/null +++ b/frontend/app/module/users/create/UserCreateDetailsPage.tsx @@ -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; + +const MIN_PASSWORD_LENGTH = 12; + +export function UserCreateDetailsPage() { + const navigate = useNavigate(); + const { user, updateUser } = useUserCreateContext(); + const [validationErrors, setValidationErrors] = useState(null); + + if (!user.role || !user.type) { + return ; + } + + function handleSubmit(event: FormEvent) { + 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 ( + <> + +
    +
    +

    {t("users.add_role", { role: t(user.role) })}

    +
    +
    +
    +
    + + + + + {user.type === "fullname" && ( + + )} + + + + + + + + +
    + + ); +} diff --git a/frontend/app/module/users/create/UserCreateLayout.tsx b/frontend/app/module/users/create/UserCreateLayout.tsx new file mode 100644 index 000000000..c6f14ade5 --- /dev/null +++ b/frontend/app/module/users/create/UserCreateLayout.tsx @@ -0,0 +1,11 @@ +import { Outlet } from "react-router"; + +import { UserCreateContextProvider } from "./UserCreateContextProvider"; + +export function UserCreateLayout() { + return ( + + + + ); +} diff --git a/frontend/app/module/users/create/UserCreateRolePage.test.tsx b/frontend/app/module/users/create/UserCreateRolePage.test.tsx new file mode 100644 index 000000000..42d1ed7da --- /dev/null +++ b/frontend/app/module/users/create/UserCreateRolePage.test.tsx @@ -0,0 +1,80 @@ +import { screen } from "@testing-library/react"; +import { userEvent } from "@testing-library/user-event"; +import { describe, expect, test, vi } from "vitest"; + +import { UserCreateRolePage } 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()), + useNavigate: () => navigate, +})); + +function renderPage(context: Partial) { + return render( + + + , + ); +} + +describe("UserCreateRolePage", () => { + test("Shows initial form", async () => { + renderPage({ user: {} }); + + expect(await screen.findByRole("heading", { level: 1, name: "Gebruiker toevoegen" })).toBeInTheDocument(); + + expect(await screen.findByLabelText("Beheerder")).not.toBeChecked(); + expect(await screen.findByLabelText("Coördinator")).not.toBeChecked(); + expect(await screen.findByLabelText("Invoerder")).not.toBeChecked(); + }); + + test("Shows form previously selected", async () => { + renderPage({ user: { role: "typist" } }); + + expect(await screen.findByLabelText("Beheerder")).not.toBeChecked(); + expect(await screen.findByLabelText("Coördinator")).not.toBeChecked(); + expect(await screen.findByLabelText("Invoerder")).toBeChecked(); + }); + + test("Shows validation error when nothing selected", async () => { + const updateUser = vi.fn(); + renderPage({ user: {}, updateUser }); + + const user = userEvent.setup(); + + const submit = await screen.findByRole("button", { name: "Verder" }); + await user.click(submit); + + const errorMessage = screen.getByText(/Dit is een verplichte vraag./); + expect(errorMessage).toBeInTheDocument(); + + expect(updateUser).not.toHaveBeenCalled(); + expect(navigate).not.toHaveBeenCalled(); + }); + + test.each([ + ["Beheerder", { role: "administrator", type: "fullname" }, "/users/create/details"], + ["Coördinator", { role: "coordinator", type: "fullname" }, "/users/create/details"], + ["Invoerder", { role: "typist" }, "/users/create/type"], + ])("Continue after selection as %s", async (label: string, update: unknown, newPath: string) => { + const updateUser = vi.fn(); + renderPage({ user: {}, updateUser }); + + const user = userEvent.setup(); + + const role = await screen.findByLabelText(label); + await user.click(role); + + const submit = await screen.findByRole("button", { name: "Verder" }); + await user.click(submit); + + expect(updateUser).toHaveBeenCalledExactlyOnceWith(update); + expect(navigate).toHaveBeenCalledExactlyOnceWith(newPath); + }); +}); diff --git a/frontend/app/module/users/create/UserCreateRolePage.tsx b/frontend/app/module/users/create/UserCreateRolePage.tsx new file mode 100644 index 000000000..fef422959 --- /dev/null +++ b/frontend/app/module/users/create/UserCreateRolePage.tsx @@ -0,0 +1,89 @@ +import { FormEvent, useState } from "react"; +import { useNavigate } from "react-router"; + +import { useUserCreateContext } from "app/module/users/create/useUserCreateContext"; + +import { Role } from "@kiesraad/api"; +import { t } from "@kiesraad/i18n"; +import { Button, ChoiceList, Form, FormLayout, PageTitle } from "@kiesraad/ui"; + +export function UserCreateRolePage() { + const navigate = useNavigate(); + const [error, setError] = useState(""); + const { user, updateUser } = useUserCreateContext(); + + function handleSubmit(event: FormEvent) { + event.preventDefault(); + const formData = new FormData(event.currentTarget); + const role = formData.get("role") as Role | null; + + if (!role) { + setError(t("users.role_mandatory")); + return; + } + + if (role === "typist") { + updateUser({ role }); + void navigate("/users/create/type"); + } else { + updateUser({ role, type: "fullname" }); + void navigate("/users/create/details"); + } + } + + return ( + <> + +
    +
    +

    {t("users.add")}

    +
    +
    +
    +
    + + + + {t("users.role_title")} + {error && {error}} + + {t("users.role_administrator_hint")} + + + {t("users.role_coordinator_hint")} + + + {t("users.role_typist_hint")} + + + + {t("users.role_hint")} + + + + + +
    + + ); +} diff --git a/frontend/app/module/users/create/UserCreateTypePage.test.tsx b/frontend/app/module/users/create/UserCreateTypePage.test.tsx new file mode 100644 index 000000000..12c847328 --- /dev/null +++ b/frontend/app/module/users/create/UserCreateTypePage.test.tsx @@ -0,0 +1,83 @@ +import { screen } from "@testing-library/react"; +import { userEvent } from "@testing-library/user-event"; +import { describe, expect, test, vi } from "vitest"; + +import { UserCreateTypePage } 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) { + return render( + + + , + ); +} + +describe("UserCreateTypePage", () => { + test("Redirect to start when no role in context", () => { + renderPage({ user: {} }); + expect(navigate).toHaveBeenCalledExactlyOnceWith("/users/create"); + }); + + test("Shows initial form", async () => { + renderPage({ user: { role: "typist" } }); + + expect(await screen.findByRole("heading", { level: 1, name: "Invoerder toevoegen" })).toBeInTheDocument(); + + expect(await screen.findByLabelText(/Op naam/)).toBeChecked(); + expect(await screen.findByLabelText(/Anonieme gebruikersnaam/)).not.toBeChecked(); + }); + + test("Shows form previously selected", async () => { + renderPage({ user: { role: "typist", type: "anonymous" } }); + + expect(await screen.findByLabelText(/Op naam/)).not.toBeChecked(); + expect(await screen.findByLabelText(/Anonieme gebruikersnaam/)).toBeChecked(); + }); + + test("Continue after selecting fullname", async () => { + const updateUser = vi.fn(); + renderPage({ user: { role: "typist" }, updateUser }); + + const user = userEvent.setup(); + + const fullname = await screen.findByLabelText(/Op naam/); + await user.click(fullname); + + const submit = await screen.findByRole("button", { name: "Verder" }); + await user.click(submit); + + expect(updateUser).toHaveBeenCalledExactlyOnceWith({ type: "fullname" }); + expect(navigate).toHaveBeenCalledExactlyOnceWith("/users/create/details"); + }); + + test("Continue after selecting anonymous", async () => { + const updateUser = vi.fn(); + renderPage({ user: { role: "typist" }, updateUser }); + + const user = userEvent.setup(); + + const anonymous = await screen.findByLabelText(/Anonieme gebruikersnaam/); + await user.click(anonymous); + + const submit = await screen.findByRole("button", { name: "Verder" }); + await user.click(submit); + + expect(updateUser).toHaveBeenCalledExactlyOnceWith({ type: "anonymous" }); + expect(navigate).toHaveBeenCalledExactlyOnceWith("/users/create/details"); + }); +}); diff --git a/frontend/app/module/users/create/UserCreateTypePage.tsx b/frontend/app/module/users/create/UserCreateTypePage.tsx new file mode 100644 index 000000000..64e04e75e --- /dev/null +++ b/frontend/app/module/users/create/UserCreateTypePage.tsx @@ -0,0 +1,71 @@ +import { FormEvent } from "react"; +import { Navigate, useNavigate } from "react-router"; + +import { UserType } from "app/module/users/create/UserCreateContext"; +import { useUserCreateContext } from "app/module/users/create/useUserCreateContext"; + +import { t } from "@kiesraad/i18n"; +import { Button, ChoiceList, Form, FormLayout, PageTitle } from "@kiesraad/ui"; + +export function UserCreateTypePage() { + const navigate = useNavigate(); + const { user, updateUser } = useUserCreateContext(); + + if (!user.role) { + return ; + } + + // Preselect fullname if there was nothing selected yet + const fullnameChecked = user.type ? user.type === "fullname" : true; + + function handleSubmit(event: FormEvent) { + event.preventDefault(); + const formData = new FormData(event.currentTarget); + const type = formData.get("type") as UserType; + + updateUser({ type }); + void navigate("/users/create/details"); + } + + return ( + <> + +
    +
    +

    {t("users.add_role", { role: t(user.role) })}

    +
    +
    +
    +
    + + + + {t("users.type_title")} + + + + + {t("users.type_hint")} + + + + + +
    + + ); +} diff --git a/frontend/app/module/users/create/index.ts b/frontend/app/module/users/create/index.ts new file mode 100644 index 000000000..5b4c0b1c1 --- /dev/null +++ b/frontend/app/module/users/create/index.ts @@ -0,0 +1,4 @@ +export * from "./UserCreateDetailsPage"; +export * from "./UserCreateLayout"; +export * from "./UserCreateRolePage"; +export * from "./UserCreateTypePage"; diff --git a/frontend/app/module/users/create/useUserCreateContext.ts b/frontend/app/module/users/create/useUserCreateContext.ts new file mode 100644 index 000000000..9677df035 --- /dev/null +++ b/frontend/app/module/users/create/useUserCreateContext.ts @@ -0,0 +1,13 @@ +import { useContext } from "react"; + +import { UserCreateContext } from "./UserCreateContext"; + +export function useUserCreateContext() { + const context = useContext(UserCreateContext); + + if (!context) { + throw new Error("useUserCreateContext must be used within an UserCreateContextProvider"); + } + + return context; +} diff --git a/frontend/app/module/users/index.ts b/frontend/app/module/users/index.ts index 3a8edd09d..704f52d00 100644 --- a/frontend/app/module/users/index.ts +++ b/frontend/app/module/users/index.ts @@ -1 +1,2 @@ export * from "./UserListPage"; +export * from "./create"; diff --git a/frontend/app/routes.tsx b/frontend/app/routes.tsx index 7d218cafc..2cf28aec6 100644 --- a/frontend/app/routes.tsx +++ b/frontend/app/routes.tsx @@ -13,7 +13,13 @@ import { import { LogsHomePage } from "app/module/logs"; import { NotAvailableInMock } from "app/module/NotAvailableInMock"; import { PollingStationListPage, PollingStationsLayout } from "app/module/polling_stations"; -import { UserListPage } from "app/module/users"; +import { + UserCreateDetailsPage, + UserCreateLayout, + UserCreateRolePage, + UserCreateTypePage, + UserListPage, +} from "app/module/users"; import { WorkstationsHomePage } from "app/module/workstations"; import { t } from "@kiesraad/i18n"; @@ -84,6 +90,11 @@ export const routes = createRoutesFromElements( } /> } /> + }> + } /> + } /> + } /> + } /> diff --git a/frontend/lib/i18n/locales/nl/generic.json b/frontend/lib/i18n/locales/nl/generic.json index 799c60457..9c791efa8 100644 --- a/frontend/lib/i18n/locales/nl/generic.json +++ b/frontend/lib/i18n/locales/nl/generic.json @@ -9,6 +9,7 @@ "close_message": "Melding sluiten", "contains_error": "bevat een fout", "contains_warning": "bevat een waarschuwing", + "continue": "Verder", "coordinator": "Coördinator", "counted_number": "Geteld aantal", "date_locale": "nl-NL", @@ -52,7 +53,6 @@ "totals_list": "Totaal lijst {group_number}", "type": "Soort", "typist": "Invoerder", - "users": "Gebruikers", "version": "Versie", "vote_count": "Aantal stemmen", "you_are_here": "je bent hier" diff --git a/frontend/lib/i18n/locales/nl/nl.ts b/frontend/lib/i18n/locales/nl/nl.ts index f31708538..7bd4054f0 100644 --- a/frontend/lib/i18n/locales/nl/nl.ts +++ b/frontend/lib/i18n/locales/nl/nl.ts @@ -15,12 +15,12 @@ import polling_station_choice from "./polling_station_choice.json"; import recounted from "./recounted.json"; import status from "./status.json"; import user from "./user.json"; +import users from "./users.json"; import voters_and_votes from "./voters_and_votes.json"; import workstations from "./workstations.json"; const nl = { ...generic, - form_errors, candidates_votes, check_and_save, data_entry, @@ -30,12 +30,14 @@ const nl = { election_status, error, feedback, + form_errors, messages, - polling_station_choice, polling_station, + polling_station_choice, recounted, status, user, + users, voters_and_votes, workstations, }; diff --git a/frontend/lib/i18n/locales/nl/user.json b/frontend/lib/i18n/locales/nl/user.json index 286a12098..78eba0580 100644 --- a/frontend/lib/i18n/locales/nl/user.json +++ b/frontend/lib/i18n/locales/nl/user.json @@ -1,13 +1,9 @@ { "personalize_account": "Personaliseer je account", "username": "Gebruikersnaam", - "fullname": "Volledige naam", - "last_activity": "Laatste activiteit", - "not_used": "Nog niet gebruikt", "username_hint": "Je kan deze niet aanpassen. Log volgende keer weer met deze gebruikersnaam in.", "username_login_hint": "De naam op het briefje dat je van de coördinator hebt gekregen.", "username_default": "Gebruiker01", - "management": "Gebruikersbeheer", "name": "Jouw naam", "name_subtext": "(roepnaam + achternaam)", "name_hint": "Bijvoorbeeld Karel van Tellingen. Je naam wordt opgenomen in het verslag van deze invoersessie.", diff --git a/frontend/lib/i18n/locales/nl/users.json b/frontend/lib/i18n/locales/nl/users.json new file mode 100644 index 000000000..363b4762c --- /dev/null +++ b/frontend/lib/i18n/locales/nl/users.json @@ -0,0 +1,26 @@ +{ + "add": "Gebruiker toevoegen", + "add_role": "{role} toevoegen", + "details_title": "Details van het account", + "fullname": "Volledige naam", + "fullname_hint": "Deze is terug te zien in de logs", + "last_activity": "Laatste activiteit", + "management": "Gebruikersbeheer", + "not_used": "Nog niet gebruikt", + "role_administrator_hint": "Verkiezingen voorbereiden, gebruikers aanmaken en uitslagen downloaden", + "role_coordinator_hint": "Problemen en fouten tijdens het invoeren oplossen", + "role_hint": "Kies de rol van de nieuwe gebruiker. De rol kan na het aanmaken van de gebruiker niet meer aangepast worden.", + "role_mandatory": "Dit is een verplichte vraag. Maak een keuze uit de opties hieronder.", + "role_title": "Welke rol krijgt de nieuwe gebruiker?", + "role_typist_hint": "Tellingen invoeren", + "temporary_password": "Tijdelijk wachtwoord", + "temporary_password_hint": "Gebruik minimaal {min_length} karakters. Het wachtwoord is hoofdlettergevoelig. De gebruiker moet na eerste keer inloggen zelf een nieuw wachtwoord kiezen.", + "temporary_password_error_min_length": "Dit wachtwoord is niet lang genoeg. Gebruik minimaal {min_length} karakters", + "type_anonymous": "Anonieme gebruikersnaam (bijvoorbeeld 'Invoerder01')", + "type_fullname": "Op naam (bijvoorbeeld 'MariekeDeJager')", + "type_hint": "Gebruikers met een anoniem account moeten bij de eerste keer inloggen hun echte naam invoeren. Anonieme accounts zijn vooral handig als je veel invoerders verwacht, en nog niet precies weet wie wel en wie niet aanwezig zal zijn.", + "type_title": "Type account", + "username": "Gebruikersnaam", + "username_hint": "Dit is de naam waarmee de gebruiker kan inloggen. De invoer is niet hoofdlettergevoelig", + "users": "Gebruikers" +} diff --git a/frontend/lib/ui/Form/FormLayout.module.css b/frontend/lib/ui/Form/FormLayout.module.css index 193085f1a..0ce5e71e0 100644 --- a/frontend/lib/ui/Form/FormLayout.module.css +++ b/frontend/lib/ui/Form/FormLayout.module.css @@ -1,9 +1,13 @@ .form-layout { - padding-bottom: var(--space-xl); + margin-bottom: var(--space-md); +} + +.form-layout.medium-width { + max-width: 32rem; } .form-section { - padding-bottom: var(--space-md); + margin-bottom: var(--space-md); h2 { font-size: 1.5rem; diff --git a/frontend/lib/ui/Form/FormLayout.tsx b/frontend/lib/ui/Form/FormLayout.tsx index 3f5c2dfe0..6155fe5ab 100644 --- a/frontend/lib/ui/Form/FormLayout.tsx +++ b/frontend/lib/ui/Form/FormLayout.tsx @@ -1,15 +1,18 @@ import * as React from "react"; +import { cn } from "@kiesraad/util"; + import cls from "./FormLayout.module.css"; export interface FormLayoutProps { children: React.ReactNode; + width?: "medium"; disabled?: boolean; } -export function FormLayout({ children, disabled }: FormLayoutProps) { +export function FormLayout({ children, disabled, width }: FormLayoutProps) { return ( -
    +
    {children}
    diff --git a/frontend/lib/ui/Toolbar/Toolbar.module.css b/frontend/lib/ui/Toolbar/Toolbar.module.css index f143ab753..43c3204ee 100644 --- a/frontend/lib/ui/Toolbar/Toolbar.module.css +++ b/frontend/lib/ui/Toolbar/Toolbar.module.css @@ -1,7 +1,7 @@ .toolbar { display: flex; align-items: center; - padding-bottom: var(--space-sm); + padding-bottom: var(--space-lg); } .toolbar-section {