diff --git a/SPEC.md b/SPEC.md index 494570c2..a6747d86 100644 --- a/SPEC.md +++ b/SPEC.md @@ -65,7 +65,7 @@ States: `not started` → `spec draft` → `spec approved` → `planned` → `in | `settings` | M1 | [SPEC-settings](docs/specs/SPEC-settings.md) | [#18](https://github.com/isaacvarg/woolrus/issues/18) | done | | `store-sync` | M1 | [SPEC-store-sync](docs/specs/SPEC-store-sync.md) | [#19](https://github.com/isaacvarg/woolrus/issues/19) | done | | `workflow` | M1 | [SPEC-workflow](docs/specs/SPEC-workflow.md) | [#20](https://github.com/isaacvarg/woolrus/issues/20) | done | -| `identity` | M2 | [SPEC-identity](docs/specs/SPEC-identity.md) | [#21](https://github.com/isaacvarg/woolrus/issues/21) | done | +| `identity` | M2 | [SPEC-identity](docs/specs/SPEC-identity.md) | [#21](https://github.com/isaacvarg/woolrus/issues/21); A1 [#170](https://github.com/isaacvarg/woolrus/issues/170) | done; A1 (station-only users) done | | `notes` | M2 | [SPEC-notes](docs/specs/SPEC-notes.md) | [#22](https://github.com/isaacvarg/woolrus/issues/22) | done | | `picking` | M2 | [SPEC-picking](docs/specs/SPEC-picking.md) | [#23](https://github.com/isaacvarg/woolrus/issues/23) | done | | `packing` | M2 | [SPEC-packing](docs/specs/SPEC-packing.md) | [#24](https://github.com/isaacvarg/woolrus/issues/24) | done | diff --git a/apps/web/e2e/station-user-email.spec.ts b/apps/web/e2e/station-user-email.spec.ts new file mode 100644 index 00000000..e89745ff --- /dev/null +++ b/apps/web/e2e/station-user-email.spec.ts @@ -0,0 +1,65 @@ +import { expect, test } from "@playwright/test"; +import { hashPin, stationOnlyPlaceholder } from "@woolrus/core"; +import { createDb } from "@woolrus/db"; +import { followMagicLink, signInAs } from "./support/auth"; +import { findUserRow } from "./support/users"; + +// SPEC-identity amendment 1, success criterion 21: an admin adds an email to a station-only user, +// who then signs in personally by magic link as the same user, keeping their PIN. + +const db = createDb(); +// See users.spec.ts: /users renders slowly on a local database full of e2e users. +const slowRender = { timeout: 30_000 }; + +test("desktop: an admin adds an email to a station user, who then signs in as themselves", async ({ + page, + browser, +}, testInfo) => { + test.skip(testInfo.project.name !== "desktop", "one viewport is enough for a dialog"); + test.slow(); + const name = `Email Luis ${crypto.randomUUID().slice(0, 8)}`; + const pinHash = await hashPin("482916"); + const luis = await db.user.create({ + data: { name, email: stationOnlyPlaceholder(), roles: ["PACKER"], pinHash }, + }); + const email = `luis-${crypto.randomUUID()}@example.test`; + await signInAs(page, ["ADMIN"]); + + await page.goto("/users"); + const row = await findUserRow(page, name); + await expect(row.getByText("Station only")).toBeVisible(); + await row.getByRole("button", { name: `Actions for ${name}` }).click(slowRender); + await page.getByRole("menuitem", { name: "Add email" }).click(); + const dialog = page.getByRole("dialog", { name: `Add an email for ${name}` }); + + // A typo in the confirmation is caught before anything is sent. + await dialog.getByLabel("Email", { exact: true }).fill(email); + await dialog.getByLabel("Confirm email").fill(`x${email}`); + await dialog.getByRole("button", { name: "Add email" }).click(); + await expect(dialog.getByText("The two addresses don't match.")).toBeVisible(); + expect((await db.user.findUniqueOrThrow({ where: { id: luis.id } })).email).not.toBe(email); + + await dialog.getByLabel("Confirm email").fill(email); + await dialog.getByRole("button", { name: "Add email" }).click(); + await expect(page.getByText(`${name} can now sign in with ${email}.`)).toBeVisible(slowRender); + await expect(row).toContainText(email); + await expect(row.getByText("Station only")).toHaveCount(0); + + // Same row: id, roles and PIN kept. + expect(await db.user.findUniqueOrThrow({ where: { id: luis.id } })).toMatchObject({ + email, + roles: ["PACKER"], + pinHash, + }); + + // Luis follows a magic link to the new address and is signed in as the same user. + const luisPage = await (await browser.newContext()).newPage(); + await followMagicLink(luisPage, email); + await expect(luisPage).toHaveURL(/\/boxes$/); + const session = await db.session.findFirstOrThrow({ + where: { user: { email } }, + orderBy: { createdAt: "desc" }, + }); + expect(session.userId).toBe(luis.id); + expect((await db.user.findUniqueOrThrow({ where: { id: luis.id } })).emailVerified).toBe(true); +}); diff --git a/apps/web/e2e/station-users.spec.ts b/apps/web/e2e/station-users.spec.ts new file mode 100644 index 00000000..fde438cf --- /dev/null +++ b/apps/web/e2e/station-users.spec.ts @@ -0,0 +1,215 @@ +import { expect, test } from "@playwright/test"; +import { createDb } from "@woolrus/db"; +import { signInAs } from "./support/auth"; +import { createOrder, deleteCreatedOrders } from "./support/orders"; +import { chooseTile } from "./support/stations"; +import { findUserRow } from "./support/users"; + +// SPEC-identity amendment 1, success criterion 20: a station-only user, added by an admin with a +// generated PIN, unlocks a station the admin just registered and picks as themselves there. + +const db = createDb(); +// See users.spec.ts: /users renders slowly on a local database full of e2e users. +const slowRender = { timeout: 30_000 }; +const unique = () => crypto.randomUUID().slice(0, 8); + +test.afterAll(deleteCreatedOrders); + +test("tablet: an admin adds a station user who unlocks with the generated PIN and picks", async ({ + page, +}, testInfo) => { + test.skip(testInfo.project.name !== "tablet", "the tablet floor journey"); + test.slow(); + const customerName = `E2E ${crypto.randomUUID()}`; + const order = await createOrder({ customerName, quantity: 1 }); + await signInAs(page, ["ADMIN"]); + const name = `Station Luis ${unique()}`; + + // Add station user: name, Picker (Admin isn't offered), and a generated PIN. + await page.goto("/users"); + await page.getByRole("button", { name: "Add station user" }).click(slowRender); + const add = page.getByRole("dialog", { name: "Add station user" }); + await expect(add.getByRole("checkbox", { name: "Admin" })).toHaveCount(0); + await add.getByLabel("Name").fill(name); + await add.getByText("Picker", { exact: true }).click(); + await add.getByRole("button", { name: "Generate" }).click(); + const pin = (await add.getByRole("status").locator("strong").textContent()) ?? ""; + expect(pin).toMatch(/^\d{6}$/); + await expect(add.getByLabel("New PIN")).toHaveValue(pin); + await add.getByRole("button", { name: "Add", exact: true }).click(); + + // Created: the dialog offers Issue badge, which opens the print view for them. + await expect(add.getByText(`${name} can now unlock any station with their PIN.`)).toBeVisible(); + await add.getByRole("button", { name: "Issue badge" }).click(); + const badge = page.getByRole("dialog", { name: `Badge for ${name}` }); + await expect(badge.getByText(/^WRB-[0-9A-Z]{20}$/)).toBeVisible(slowRender); + await badge.getByRole("button", { name: "Close" }).click(); + + // Their row says Station only, never the placeholder, and Edit roles leaves out Admin. + const row = await findUserRow(page, name); + await expect(row.getByText("Station only")).toBeVisible(); + await expect(row).not.toContainText("station.invalid"); + await row.getByRole("button", { name: `Actions for ${name}` }).click(slowRender); + await page.getByRole("menuitem", { name: "Edit roles" }).click(); + const roles = page.getByRole("dialog", { name: `Roles for ${name}` }); + await expect(roles.getByRole("checkbox", { name: "Picker" })).toBeChecked(); + await expect(roles.getByRole("checkbox", { name: "Admin" })).toHaveCount(0); + await roles.getByRole("button", { name: "Cancel" }).click(); + + // The admin makes this tablet a station: the new user's tile is on the lock screen. + const stationName = `Station Users ${unique()}`; + await page.goto("/stations"); + await page.getByRole("button", { name: "Make this device a station" }).click(slowRender); + await page.getByRole("dialog").getByLabel("Name").fill(stationName); + await page.getByRole("dialog").getByRole("button", { name: "Make it a station" }).click(); + await expect(page).toHaveURL(/\/station$/); + await expect(page.getByRole("heading", { name: stationName })).toBeVisible(); + const station = await db.station.findUniqueOrThrow({ where: { name: stationName } }); + const user = await db.user.findFirstOrThrow({ where: { name } }); + + // They unlock with the PIN, and the station acts as them, with no email to show. + await chooseTile(page, name); + await page.getByLabel(`PIN for ${name}`).fill(pin); + await expect(page).not.toHaveURL(/\/station$/); + expect(await (await page.request.get("/api/v1/me")).json()).toMatchObject({ + id: user.id, + email: null, + roles: ["PICKER"], + stationId: station.id, + }); + + // Their pick records them and the station. + await page.goto("/queue/pending"); + await page.getByRole("searchbox", { name: "Search orders" }).fill(customerName); + await page + .getByRole("row", { name: new RegExp(order.number) }) + .getByRole("button", { name: "Begin Picking" }) + .click(); + await expect(page).toHaveURL(`/pick/${order.id}`); + const picked = page.waitForResponse(/\/rpc\/picking\/pickLine/); + await page + .getByTestId("pick-card") + .first() + .getByRole("button", { name: /^E2E item/, pressed: false }) + .click(); + await picked; + + expect( + await db.orderStageEvent.findFirst({ where: { orderId: order.id, toStatus: "PICKING" } }), + ).toMatchObject({ actorId: user.id, stationId: station.id }); + expect( + await db.auditEvent.findFirst({ where: { action: "pick.pickLine", actorId: user.id } }), + ).toMatchObject({ stationId: station.id }); +}); + +// Success criterion 22 and decision 13: a taken name warns, ignoring case and surrounding spaces, +// but never blocks. A deactivated user's name doesn't count. +test("desktop: adding a station user with a taken name warns but still adds them", async ({ + page, +}, testInfo) => { + test.skip(testInfo.project.name !== "desktop", "one viewport is enough for a dialog"); + const id = unique(); + const taken = `Dup Luis ${id}`; + const gone = `Gone Luis ${id}`; + await db.user.createMany({ + data: [ + { name: taken, email: `e2e-${crypto.randomUUID()}@example.test`, roles: ["PICKER"] }, + { + name: gone, + email: `e2e-${crypto.randomUUID()}@example.test`, + roles: ["PICKER"], + active: false, + }, + ], + }); + await signInAs(page, ["ADMIN"]); + await page.goto("/users"); + await page.getByRole("button", { name: "Add station user" }).click(slowRender); + const add = page.getByRole("dialog", { name: "Add station user" }); + const warning = add.getByText(/An active user is already called/); + + // A deactivated user's name: no warning. + const checked = page.waitForResponse( + (response) => + response.url().includes("/rpc/users/list") && !!response.request().postData()?.includes(gone), + ); + await add.getByLabel("Name").fill(gone); + await checked; + await expect(warning).toHaveCount(0); + + // An active user's name in another case, with spaces: the warning shows. + await add.getByLabel("Name").fill(` ${taken.toLowerCase()} `); + await expect(warning).toBeVisible(); + await expect(warning).toContainText(taken); + + // The admin can still add them. + await add.getByText("Picker", { exact: true }).click(); + await add.getByRole("button", { name: "Generate" }).click(); + await add.getByRole("button", { name: "Add", exact: true }).click(); + await expect(add.getByText("can now unlock any station with their PIN.")).toBeVisible(); + expect(await db.user.count({ where: { name: { equals: taken, mode: "insensitive" } } })).toBe(2); +}); + +// "Everything else is unchanged" (SPEC-identity amendment 1): clearing the PIN of a station-only +// user with no badge leaves them no way in, so it asks first, but it's still allowed. +test("desktop: clearing a badge-less station user's PIN warns first, then clears it", async ({ + page, +}, testInfo) => { + test.skip(testInfo.project.name !== "desktop", "one viewport is enough for a dialog"); + const name = `No Badge Luis ${unique()}`; + const user = await db.user.create({ + data: { + name, + email: `${crypto.randomUUID()}@station.invalid`, + roles: ["PICKER"], + pinHash: "not-a-real-hash", + }, + }); + await signInAs(page, ["ADMIN"]); + await page.goto("/users"); + const row = await findUserRow(page, name); + const clearPin = async () => { + await row.getByRole("button", { name: `Actions for ${name}` }).click(slowRender); + await page.getByRole("menuitem", { name: "Clear PIN" }).click(); + }; + const confirm = page.getByRole("dialog", { name: `Clear ${name}'s PIN?` }); + + // Cancel keeps the PIN. + await clearPin(); + await expect(confirm.getByText("can't unlock any station")).toBeVisible(); + await confirm.getByRole("button", { name: "Cancel" }).click(); + await expect(confirm).toHaveCount(0); + expect((await db.user.findUniqueOrThrow({ where: { id: user.id } })).pinHash).not.toBeNull(); + + // Confirming clears it. + await clearPin(); + await confirm.getByRole("button", { name: "Clear PIN" }).click(); + await expect(page.getByText(`PIN cleared for ${name}.`)).toBeVisible(); + expect((await db.user.findUniqueOrThrow({ where: { id: user.id } })).pinHash).toBeNull(); +}); + +// The same for the badge: revoking it from a station-only user with no PIN asks first. +test("desktop: revoking a PIN-less station user's badge warns first, then revokes it", async ({ + page, +}, testInfo) => { + test.skip(testInfo.project.name !== "desktop", "one viewport is enough for a dialog"); + const name = `No PIN Luis ${unique()}`; + const user = await db.user.create({ + data: { + name, + email: `${crypto.randomUUID()}@station.invalid`, + roles: ["PICKER"], + badgeCode: `e2e-${crypto.randomUUID()}`, + }, + }); + await signInAs(page, ["ADMIN"]); + await page.goto("/users"); + const row = await findUserRow(page, name); + await row.getByRole("button", { name: `Actions for ${name}` }).click(slowRender); + await page.getByRole("menuitem", { name: "Revoke badge" }).click(); + const confirm = page.getByRole("dialog", { name: `Revoke ${name}'s badge?` }); + await expect(confirm.getByText("can't unlock any station")).toBeVisible(); + await confirm.getByRole("button", { name: "Revoke badge" }).click(); + await expect(page.getByText(`Badge revoked for ${name}.`)).toBeVisible(); + expect((await db.user.findUniqueOrThrow({ where: { id: user.id } })).badgeCode).toBeNull(); +}); diff --git a/apps/web/src/app/(app)/layout.tsx b/apps/web/src/app/(app)/layout.tsx index 723355dc..c9a8ac0c 100644 --- a/apps/web/src/app/(app)/layout.tsx +++ b/apps/web/src/app/(app)/layout.tsx @@ -1,4 +1,4 @@ -import { can } from "@woolrus/core"; +import { can, publicEmail } from "@woolrus/core"; import { headers } from "next/headers"; import Link from "next/link"; import { redirect } from "next/navigation"; @@ -120,7 +120,7 @@ export default async function AppLayout({ children }: { children: React.ReactNod {user && ( void }) { + const t = useTranslations(); + const errorMessage = useErrorMessage(); + const fieldErrors = useFieldErrors(); + const queryClient = useQueryClient(); + const [email, setEmail] = useState(""); + const [confirm, setConfirm] = useState(""); + const [mismatch, setMismatch] = useState(false); + // One key per dialog: a retried submit (flaky Wi-Fi) can't send the email twice. + const [idempotencyKey] = useState(() => crypto.randomUUID()); + const add = useMutation({ + mutationFn: () => client.users.addEmail({ idempotencyKey, id: user.id, email }), + onSuccess: (updated) => { + toast.success(t("users.emailAdded", { name: updated.name, email: updated.email ?? "" })); + onClose(); + }, + onSettled: () => queryClient.invalidateQueries({ queryKey: orpc.users.key() }), + }); + const errors: Record = { + ...fieldErrors(add.error), + ...(mismatch ? { confirm: t("users.emailMismatch") } : {}), + }; + + return ( + !open && onClose()} + > + {(close) => ( +
{ + event.preventDefault(); + const matches = email.trim().toLowerCase() === confirm.trim().toLowerCase(); + setMismatch(!matches); + if (matches) add.mutate(); + }} + > +

{t("users.addEmailHelp", { name: user.name })}

+ + { + setConfirm(value); + setMismatch(false); + }} + /> + {add.error && !errors.email && ( +

+ {errorMessage(add.error)} +

+ )} +
+ + +
+ + )} +
+ ); +} diff --git a/apps/web/src/app/(app)/users/credentials-dialogs.tsx b/apps/web/src/app/(app)/users/credentials-dialogs.tsx index b630bb6e..fa8f4e8c 100644 --- a/apps/web/src/app/(app)/users/credentials-dialogs.tsx +++ b/apps/web/src/app/(app)/users/credentials-dialogs.tsx @@ -19,14 +19,69 @@ function useInvalidate() { return () => queryClient.invalidateQueries({ queryKey: orpc.users.key() }); } -/** Type a PIN, or Generate one that is shown once for the admin to hand over. */ +/** + * Type a PIN, or Generate one that is shown once for the admin to hand over. Shared by Set PIN and + * Add station user; the form around it shows `pin` field errors from the server. + */ +export function PinField({ + pin, + onChange, + holderName, +}: { + pin: string; + onChange: (pin: string) => void; + /** Who the generated PIN is for, in the hand-over notice. */ + holderName: string; +}) { + const t = useTranslations(); + const [generated, setGenerated] = useState(false); + return ( + <> +
+
+ { + onChange(value); + setGenerated(false); + }} + /> +
+ +
+ {generated && ( +

+ + {t("users.generatedPin", { name: holderName })}{" "} + {pin} + +

+ )} + + ); +} + export function PinDialog({ user, onClose }: { user: UserSummary; onClose: () => void }) { const t = useTranslations(); const errorMessage = useErrorMessage(); const fieldErrors = useFieldErrors(); const invalidate = useInvalidate(); const [pin, setPin] = useState(""); - const [generated, setGenerated] = useState(false); const save = useMutation({ mutationFn: () => client.users.setPin({ id: user.id, pin }), onSuccess: () => { @@ -51,41 +106,7 @@ export function PinDialog({ user, onClose }: { user: UserSummary; onClose: () => save.mutate(); }} > -
-
- { - setPin(value); - setGenerated(false); - }} - /> -
- -
- {generated && ( -

- - {t("users.generatedPin", { name: user.name })}{" "} - {pin} - -

- )} + {save.error && !errors.pin && (

{errorMessage(save.error)} diff --git a/apps/web/src/app/(app)/users/station-user-dialog.tsx b/apps/web/src/app/(app)/users/station-user-dialog.tsx new file mode 100644 index 00000000..d614187a --- /dev/null +++ b/apps/web/src/app/(app)/users/station-user-dialog.tsx @@ -0,0 +1,156 @@ +"use client"; + +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Role, USER_LIST_PAGE_MAX, type UserSummary } from "@woolrus/contracts"; +import { useTranslations } from "next-intl"; +import { useEffect, useState } from "react"; +import { Form } from "react-aria-components"; +import { Dialog } from "@/components/ui/dialog"; +import { Button, Checkbox, TextField } from "@/components/ui/fields"; +import { useErrorMessage, useFieldErrors } from "@/lib/errors"; +import { client, orpc } from "@/lib/orpc"; +import { PinField } from "./credentials-dialogs"; + +/** Station-only users can't be Admins (SPEC-identity amendment 1, decision 10). */ +export const stationRoles = Role.options.filter((role) => role !== "ADMIN"); + +const NAME_CHECK_DEBOUNCE_MS = 300; + +/** + * The active user who already has `name` (trimmed, any case), if any. Names needn't be unique, so + * this only feeds a warning (SPEC-identity amendment 1, decision 13). `users.list` matches part of + * the name; the exact comparison happens here. + */ +function useActiveNamesake(name: string) { + const [search, setSearch] = useState(""); + useEffect(() => { + const timer = setTimeout(() => setSearch(name.trim()), NAME_CHECK_DEBOUNCE_MS); + return () => clearTimeout(timer); + }, [name]); + const users = useQuery({ + ...orpc.users.list.queryOptions({ input: { search, pageSize: USER_LIST_PAGE_MAX } }), + enabled: search !== "", + }); + const wanted = search.toLowerCase(); + // Only while the debounced search still matches what's typed, so a stale warning never shows. + if (!wanted || name.trim().toLowerCase() !== wanted) return undefined; + return users.data?.users.find((user) => user.active && user.name.trim().toLowerCase() === wanted); +} + +/** + * Add a worker with no email: name, roles and a PIN (SPEC-identity amendment 1). Once created, + * the dialog offers Issue badge for them, since a badge is the other way onto a station. + */ +export function AddStationUserDialog({ + onClose, + onIssueBadge, +}: { + onClose: () => void; + onIssueBadge: (user: UserSummary) => void; +}) { + const t = useTranslations(); + const errorMessage = useErrorMessage(); + const fieldErrors = useFieldErrors(); + const queryClient = useQueryClient(); + const [name, setName] = useState(""); + const [selected, setSelected] = useState(new Set()); + const [pin, setPin] = useState(""); + // One key per dialog: a retried submit (flaky Wi-Fi) can't add the same person twice. + const [idempotencyKey] = useState(() => crypto.randomUUID()); + const create = useMutation({ + mutationFn: () => + client.users.createStationUser({ idempotencyKey, name, roles: [...selected], pin }), + onSettled: () => queryClient.invalidateQueries({ queryKey: orpc.users.key() }), + }); + const errors = fieldErrors(create.error); + const created = create.data; + const namesake = useActiveNamesake(created ? "" : name); + + return ( +

!open && onClose()} + > + {(close) => + created ? ( + <> +

+ {t("users.stationUserAdded", { name: created.name })} +

+
+ + +
+ + ) : ( +
{ + event.preventDefault(); + create.mutate(); + }} + > +

{t("users.stationOnlyHelp")}

+ +
+ {namesake && ( +

+ {t("users.duplicateNameWarning", { name: namesake.name })} +

+ )} +
+
+ {t("users.roles")} + {stationRoles.map((role) => ( + { + const next = new Set(selected); + if (on) next.add(role); + else next.delete(role); + setSelected(next); + }} + > + {t(`users.role.${role}`)} + + ))} +
+ + {create.error && !errors.name && !errors.pin && ( +

+ {errors.roles ?? errorMessage(create.error)} +

+ )} +
+ + +
+ + ) + } +
+ ); +} diff --git a/apps/web/src/app/(app)/users/users-manager.tsx b/apps/web/src/app/(app)/users/users-manager.tsx index 85ccb4c3..e1dd871e 100644 --- a/apps/web/src/app/(app)/users/users-manager.tsx +++ b/apps/web/src/app/(app)/users/users-manager.tsx @@ -11,9 +11,11 @@ import { Menu, MenuItem } from "@/components/ui/menu"; import { toast } from "@/components/ui/toast"; import { useErrorMessage, useFieldErrors } from "@/lib/errors"; import { client, orpc } from "@/lib/orpc"; +import { AddEmailDialog } from "./add-email-dialog"; import { BadgeDialog, PinDialog } from "./credentials-dialogs"; +import { AddStationUserDialog, stationRoles } from "./station-user-dialog"; -const roles = Role.options; +const allRoles = Role.options; const PAGE_SIZE = 50; const SEARCH_DEBOUNCE_MS = 250; @@ -46,6 +48,12 @@ export function UsersManager({ currentUserId }: { currentUserId: string }) { const [deactivating, setDeactivating] = useState(null); const [settingPin, setSettingPin] = useState(null); const [badge, setBadge] = useState(null); + const [addingStationUser, setAddingStationUser] = useState(false); + const [addingEmail, setAddingEmail] = useState(null); + // Clear PIN or Revoke badge that would leave a station-only user no way in, waiting to be confirmed. + const [lastWayIn, setLastWayIn] = useState<{ user: UserSummary; credential: Credential } | null>( + null, + ); const invalidate = useInvalidate(); const reactivate = useMutation({ mutationFn: (user: UserSummary) => client.users.reactivate({ id: user.id }), @@ -54,7 +62,10 @@ export function UsersManager({ currentUserId }: { currentUserId: string }) { }); const clearPin = useMutation({ mutationFn: (user: UserSummary) => client.users.clearPin({ id: user.id }), - onSuccess: (user) => toast.success(t("users.pinCleared", { name: user.name })), + onSuccess: (user) => { + toast.success(t("users.pinCleared", { name: user.name })); + setLastWayIn(null); + }, onError: (error) => toast.error(errorMessage(error)), onSettled: invalidate, }); @@ -66,7 +77,10 @@ export function UsersManager({ currentUserId }: { currentUserId: string }) { }); const revokeBadge = useMutation({ mutationFn: (user: UserSummary) => client.users.revokeBadge({ id: user.id }), - onSuccess: (user) => toast.success(t("users.badgeRevoked", { name: user.name })), + onSuccess: (user) => { + toast.success(t("users.badgeRevoked", { name: user.name })); + setLastWayIn(null); + }, onError: (error) => toast.error(errorMessage(error)), onSettled: invalidate, }); @@ -74,20 +88,23 @@ export function UsersManager({ currentUserId }: { currentUserId: string }) { return (
- - - - +
+ + + + + +
@@ -120,7 +137,11 @@ export function UsersManager({ currentUserId }: { currentUserId: string }) {
{user.name}
-
{user.email}
+ {user.stationOnly ? ( + {t("users.stationOnly")} + ) : ( +
{user.email}
+ )}
@@ -157,9 +178,21 @@ export function UsersManager({ currentUserId }: { currentUserId: string }) { {user.id !== currentUserId && ( setEditing(user)}>{t("users.editRoles")} + {/* Core refuses a deactivated user: their sign-in email would lead nowhere. */} + {user.stationOnly && user.active && ( + setAddingEmail(user)}> + {t("users.addEmail")} + + )} setSettingPin(user)}>{t("users.setPin")} {user.hasPin && ( - clearPin.mutate(user)}> + + isLastWayIn(user, "pin") + ? setLastWayIn({ user, credential: "pin" }) + : clearPin.mutate(user) + } + > {t("users.clearPin")} )} @@ -167,7 +200,13 @@ export function UsersManager({ currentUserId }: { currentUserId: string }) { {t("users.issueBadge")} {user.hasBadge && ( - revokeBadge.mutate(user)}> + + isLastWayIn(user, "badge") + ? setLastWayIn({ user, credential: "badge" }) + : revokeBadge.mutate(user) + } + > {t("users.revokeBadge")} )} @@ -205,8 +244,27 @@ export function UsersManager({ currentUserId }: { currentUserId: string }) { {deactivating && ( setDeactivating(null)} /> )} + {addingEmail && setAddingEmail(null)} />} + {lastWayIn && ( + + lastWayIn.credential === "pin" + ? clearPin.mutate(lastWayIn.user) + : revokeBadge.mutate(lastWayIn.user) + } + onClose={() => setLastWayIn(null)} + /> + )} {settingPin && setSettingPin(null)} />} {badge && setBadge(null)} />} + {addingStationUser && ( + setAddingStationUser(false)} + onIssueBadge={(user) => issueBadge.mutate(user)} + /> + )}
); } @@ -223,6 +281,8 @@ function RolesDialog({ user, onClose }: { user: UserSummary; onClose: () => void onSettled: invalidate, }); const rolesError = fieldErrors(update.error).roles; + // Station-only users can't be Admins (SPEC-identity amendment 1, decision 10). + const roles = user.stationOnly ? stationRoles : allRoles; return ( void ); } +type Credential = "pin" | "badge"; + +/** + * A station-only user unlocks stations with a PIN or a badge and has no other way in (SPEC-identity + * amendment 1). Removing the only one they hold is allowed, but asked about first. + */ +function isLastWayIn(user: UserSummary, credential: Credential): boolean { + if (!user.stationOnly) return false; + return credential === "pin" ? !user.hasBadge : !user.hasPin; +} + +const lastWayInText = { + pin: { title: "users.clearPinTitle", body: "users.clearPinNoWayIn", action: "users.clearPin" }, + badge: { + title: "users.revokeBadgeTitle", + body: "users.revokeBadgeNoWayIn", + action: "users.revokeBadge", + }, +} as const; + +function LastWayInDialog({ + user, + credential, + isPending, + onConfirm, + onClose, +}: { + user: UserSummary; + credential: Credential; + isPending: boolean; + onConfirm: () => void; + onClose: () => void; +}) { + const t = useTranslations(); + const text = lastWayInText[credential]; + + return ( + !open && onClose()} + > + {(close) => ( + <> +

{t(text.body, { name: user.name })}

+
+ + +
+ + )} +
+ ); +} + function DeactivateDialog({ user, onClose }: { user: UserSummary; onClose: () => void }) { const t = useTranslations(); const errorMessage = useErrorMessage(); diff --git a/apps/web/src/server/actor.ts b/apps/web/src/server/actor.ts index 480f03e5..01a868f5 100644 --- a/apps/web/src/server/actor.ts +++ b/apps/web/src/server/actor.ts @@ -1,4 +1,4 @@ -import { findStationByToken, stationActor, type UserActor } from "@woolrus/core"; +import { findStationByToken, isStationOnly, stationActor, type UserActor } from "@woolrus/core"; import { db, type Station, type User } from "@woolrus/db"; import { headers } from "next/headers"; import { cache } from "react"; @@ -28,7 +28,9 @@ export async function resolveActor(requestHeaders: Headers): Promise { - const user = await db.user.findUnique({ - where: { id: session.userId }, - select: { active: true }, - }); - if (user?.active) return; - log.info({ userId: session.userId }, "sign-in refused: user deactivated"); + const decision = await mayStartSession(db, session.userId); + if (decision.allowed) return; + log.info({ userId: session.userId, reason: decision.reason }, "sign-in refused"); return false; }, }, diff --git a/apps/web/src/server/router.ts b/apps/web/src/server/router.ts index 55687b52..23814872 100644 --- a/apps/web/src/server/router.ts +++ b/apps/web/src/server/router.ts @@ -1,5 +1,6 @@ import { addPackage, + addUserEmail, archiveBox, archiveLabAddress, archiveLabGroup, @@ -23,6 +24,7 @@ import { createNote, createPriorityRule, createShippingProviders, + createStationUser, customsProducts, deactivateUser, deleteBox, @@ -94,6 +96,7 @@ import { permissionsFor, pickLine, previewLabRun, + publicEmail, pushErrors, qaRemoveLot, qaReplaceLot, @@ -211,7 +214,7 @@ export const router = implementer.router({ get: api.me.get.handler(({ context: { user, actor, station, idleLockAt } }) => ({ id: user.id, name: user.name, - email: user.email, + email: publicEmail(user.email), image: user.image, roles: user.roles, permissions: permissionsFor(user.roles), @@ -530,6 +533,12 @@ export const router = implementer.router({ }, users: { list: api.users.list.handler(({ input, context }) => listUsers(db, context.actor, input)), + createStationUser: api.users.createStationUser.handler(({ input, context }) => + createStationUser(db, context.actor, input), + ), + addEmail: api.users.addEmail.handler(({ input, context }) => + addUserEmail(db, context.actor, input, { appUrl: env().BETTER_AUTH_URL }), + ), updateRoles: api.users.updateRoles.handler(({ input, context }) => updateUserRoles(db, context.actor, input), ), diff --git a/docs/decisions.md b/docs/decisions.md index 5db135d7..8c9d624a 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -72,7 +72,18 @@ Full model in `packages/db/prisma/schema/*.prisma`. Changes from the SRS's secti (or `PURCHASE_FAILED`, `VOID_REJECTED`); the `idempotencyKey` is recorded before the purchase call. - **Notes** have `visibility` (internal / customer) and remember `wooNoteId` so nothing is posted twice. - **Stations, PINs, badges:** `Station`, `StationActivation`, `User.pinHash/badgeCode`. Stations auto-lock after - `Settings.stationIdleLockMinutes` (default 10). + `Settings.stationIdleLockMinutes` (default 10). **Station-only users** (no email, unlock stations only; + SPEC-identity amendment 1, 2026-11-10) keep `User.email` required and unique, as Better Auth expects, by + holding a reserved placeholder, `@station.invalid` (`.invalid` can never receive mail, RFC 2606). + Chosen over a nullable column, which would touch every Better Auth read and its Prisma adapter for users who + never have a session. "Station-only" is derived from the address by one core helper, `isStationOnly`, never + a flag that could disagree, and the placeholder never leaves core (APIs return `email: null`). Station-only + users can't be **Admin**: that would make a 6-digit PIN the only credential for full admin access. + An admin can **add an email** to one, which makes them a regular user with the same id. The address is stored + with `emailVerified: true` (2026-11-10): Better Auth 1.7.8's magic-link verify returns 500 for an existing + unverified user, because its lock row in `verifications` gets a base64url id unless `generateId` is `"uuid"`, + and ours is `false` with uuid columns. A station-only user has no accounts or sessions for that step to revoke. + `generateId` and the schema stay as they are. - **WooCommerce payloads** are stored raw (`rawPayload` jsonb) so fields can be promoted to columns later. - **Settings** is a single row; secrets are stored AES-GCM-encrypted with `SETTINGS_ENCRYPTION_KEY`. - **Returns** (`ReturnRequest`, `ReturnItem`), **manifests**, **priority rules**, **webhook inbox** and diff --git a/docs/specs/SPEC-identity.md b/docs/specs/SPEC-identity.md index 050b7552..c2de270e 100644 --- a/docs/specs/SPEC-identity.md +++ b/docs/specs/SPEC-identity.md @@ -1,8 +1,14 @@ # Spec: `identity` **Milestone:** M2 · **Depends on:** `settings` · **Used by:** every module (the `Actor`), `picking`/`packing`/`qa` -(station actors on the floor), `operations` (email health) · **State:** done · -**Issue:** [#21](https://github.com/isaacvarg/woolrus/issues/21) +(station actors on the floor), `operations` (email health) · **State:** done; amendment 1 (station-only users, +2026-11-10) is **done** (tickets #171–#174) · **Issue:** [#21](https://github.com/isaacvarg/woolrus/issues/21); amendment 1: +[#170](https://github.com/isaacvarg/woolrus/issues/170) + +> **Amendment 1 (approved 2026-11-10): station-only users.** Some floor staff have no email address, or shouldn't +> need one to work. An admin can now create a user with just a name, roles and a PIN. That user can only unlock +> stations and never signs in personally, until an admin adds an email for them. Everything marked *(A1)* below +> belongs to this amendment. Project-wide commands, structure, code style, testing levels and boundaries are in [`../../SPEC.md`](../../SPEC.md); this spec only adds what is specific to `identity`. @@ -34,6 +40,11 @@ on the tablet. Every action is attributed to the right person and station. Any user can unlock any active station. The station only says *where* the action happened. +**Station-only users (A1)** have only the right-hand column: no email, no personal sign-in, no Better Auth +session. They unlock a station with their PIN or badge like anyone else. Since a station has to be registered by an +admin first, they can only work on devices the admin set up, which keeps NFR-SEC-02 ("sign-in limited to invited +or allow-listed users") intact: they can't sign in anywhere, only unlock. + ### User stories 1. As an admin, I invite `ana@…` as Picker + Packer. She gets an email, signs in with Google or a magic link, and @@ -50,6 +61,14 @@ Any user can unlock any active station. The station only says *where* the action 7. As anyone, I pick English or Spanish, light/dark/high-contrast, and collapse the sidebar. My choices follow me to every device and station. 8. As an admin, I see every station, who is on it now and when it was last used, and I can rename or revoke it. +9. *(A1)* As an admin, I choose **Add station user**, enter "Luis", tick Picker, and type or generate a PIN. Luis + now has a tile on every station and can unlock with that PIN right away. Nobody gets an email. I can issue + him a badge, change his roles, reset his PIN, or deactivate him, the same as anyone else. +10. *(A1)* As an admin, I can't give a station-only user the Admin role: admin powers need a person who can also + sign in personally. +11. *(A1)* As an admin, Luis now has an email address. I choose **Add email** on his row and enter it. He gets an + email telling him he can now sign in, and signs in with a magic link. He's the same user, with the same + history, PIN and badge, and he can now sign in personally like anyone else. ## Scope @@ -64,8 +83,13 @@ Any user can unlock any active station. The station only says *where* the action - Email: `EmailSender` adapter, Resend implementation, log implementation for dev, email settings + a "Send test email" action, invitation and magic-link templates in en/es, an `email` entry in `/api/health`. - User preferences (FR-SET-03) and log out. +- *(A1)* Station-only users: create one with name + roles + PIN; the same admin actions as for any other user; + marked "Station only" in the users list; no email, personal sign-in or Admin role. +- *(A1)* Adding an email to a station-only user, which makes them a regular user. **Out (owned elsewhere or later):** +- *(A1)* Going the other way: removing a regular user's email to make them station-only. +- *(A1)* Bulk import of station users (CSV). - Native Android station auth (bearer device token). The design keeps it possible: the station token is opaque and could later travel in a header. - Password sign-in, 2FA, SSO beyond Google/Discord. @@ -104,8 +128,10 @@ acting (ship review, 2026-10-20). | Function | Permission | Notes | |---|---|---| -| `listUsers(db, actor, { search?, page, pageSize })` | `user.manage` | Name order, 50 a page (max 100), search by name or email. Name, email, roles, active, `hasPin`, `hasBadge`, `pinLockedUntil`, last activity. Never `pinHash` or badge hash | -| `updateUserRoles(db, actor, { userId, roles })` | `user.manage` | ≥ 1 role. Audited with before/after | +| `listUsers(db, actor, { search?, page, pageSize })` | `user.manage` | Name order, 50 a page (max 100), search by name or email. Name, email, roles, active, `hasPin`, `hasBadge`, `pinLockedUntil`, last activity. Never `pinHash` or badge hash. *(A1)* `email` is `null` and `stationOnly: true` for station-only users; search never matches their placeholder | +| *(A1)* `createStationUser(db, actor, { name, roles, pin, idempotencyKey })` | `user.manage` | Below | +| *(A1)* `addUserEmail(db, actor, { id, email, idempotencyKey })` | `user.manage` | Below | +| `updateUserRoles(db, actor, { userId, roles })` | `user.manage` | ≥ 1 role. Audited with before/after. *(A1)* Refuses `ADMIN` for a station-only user (`errors.users.stationOnlyAdmin`) | | `deactivateUser(db, actor, { userId })` | `user.manage` | `active=false`, delete their Better Auth sessions, end their open activations (`MANUAL`), audited. Idempotent | | `reactivateUser(db, actor, { userId })` | `user.manage` | Audited | | `setPin(db, actor, { userId, pin })` | `user.manage` | Hash with scrypt (Node `crypto`, per-user salt), reset failed attempts/lock. Audited as `user.pin.set`, never the PIN | @@ -123,6 +149,52 @@ acting (ship review, 2026-10-20). that would leave no active Admin. An admin can't deactivate themselves or remove their own Admin role (`errors.users.self`). `ADMIN_EMAILS` still bootstraps an admin on first sign-in. +### Station-only users (A1) + +- **Storage:** no schema change. `User.email` stays required and unique, as Better Auth expects. A station-only + user gets a reserved placeholder, `@station.invalid`. `.invalid` is reserved (RFC 2606), so the + address can never exist or receive mail. `emailVerified` stays `false`. One core helper, + `isStationOnly(email)`, is the only place that knows the domain. Every rule below goes through it, and the + placeholder never leaves core: contracts return `email: null` + `stationOnly: true`. +- **`createStationUser({ name, roles, pin, idempotencyKey })`:** `name` 1–80 chars, trimmed. ≥ 1 role, never + `ADMIN` (`errors.users.stationOnlyAdmin`). `pin` follows the PIN rules (the dialog can **Generate** one, as for + Set PIN). In one transaction: create the user with the placeholder email, `active: true`, the PIN hashed as in + `setPin` (hashed before the transaction), and audit `user.create` with `{ stationOnly: true, roles }`. Never the + PIN. Returns the `UserSummary`. Names needn't be unique, as for invited users: the dialog warns when an + active user already has the same name (case-insensitive, trimmed) but doesn't block it (decision 13). +- **`addUserEmail({ id, email, idempotencyKey })`:** only for a station-only user (otherwise `CONFLICT`, + `errors.users.notStationOnly`) who is active (otherwise `CONFLICT`, `errors.users.addEmailInactive`: a + deactivated user can't sign in, and reactivating doesn't resend the email, so reactivate first). The email is lowercased and can't use the reserved domain. Refused with + `CONFLICT` if another user has it (`errors.users.alreadyExists`) or it has an open invitation + (`errors.users.invitationPending`: revoke it first, so one address never leads to two accounts). In one + transaction: replace the placeholder, set `emailVerified: true` (decision 15), audit `user.email.add` with + the address, and enqueue an `email.send` job with a new **`accountEnabled`** template (" set up sign-in + for you", linking to `/sign-in?email=
`, no expiry line, English like invitations). From then on the user is regular: + `stationOnly` is `false`, and Admin can be given. The user keeps their `id`, roles, PIN, badge and history. + - **First sign-in** is by the magic link in the `accountEnabled` email, through the existing "existing active + user" path. `sendMagicLink` and `mayStartSession` let them in once the placeholder is gone. + - **Google and Discord** (checked in Better Auth 1.7.8's `handleOAuthUserInfo`, #174): we set no + `account.accountLinking`, so linking is on, no provider is trusted, and `requireLocalEmailVerified` is + `true`. An existing user with no linked account is linked implicitly only when the provider reports the + address as verified *and* our user is `emailVerified`. Because `addUserEmail` sets `emailVerified: true`, + Google (`email_verified`) and Discord (`verified`) sign the user in straight away, linked to the same + `userId`, even before any magic link. A Discord account whose email isn't verified there is refused with + `account_not_linked`, which the sign-in page shows as the generic sign-in failure. The magic link always + works. + - A typo sends the email to the wrong address, the same risk as an invitation. The dialog asks for the address + twice. +- **No personal sign-in, enforced in core** (`sign-in.ts`), so the web hooks stay thin: + - `mayCreateAccount` refuses a station-only address, even if it appears in `ADMIN_EMAILS`. + - `sendMagicLink` sends nothing to a station-only address, the same silent path as for an unknown address. + - `session.create.before` refuses a session for a station-only user, whatever the method. This is what stops an + OAuth provider or account linking from reaching the row. + - `createInvitation` refuses the `station.invalid` domain (`errors.invitations.reservedEmail`). The check is in + core, not the contract, so `station-only.ts` stays the only code that knows the domain. +- **Everything else is unchanged:** deactivate/reactivate, set/clear PIN, issue/revoke badge, preferences, PIN + lockout, idle lock and attribution work the same, since they already go by `userId`. Clearing the PIN of a + user with no badge leaves them with no way in. The PIN dialog warns about it but allows it. +- **Last-admin guard** is unaffected: station-only users are never admins. + ### Invitations - `createInvitation({ email, roles, idempotencyKey })`: email is lowercased; ≥ 1 role; expires after **7 days**. @@ -199,6 +271,8 @@ Exactly 6 digits. Reject all-same (`000000`) and straight runs (`123456`, `65432 | Procedure | Route | |---|---| | `users.list` / `users.updateRoles` / `users.deactivate` / `users.reactivate` | `GET /users`, `PUT /users/{id}/roles`, `POST /users/{id}/deactivate`, `…/reactivate` | +| *(A1)* `users.createStationUser` | `POST /users` (idempotent via `Idempotency-Key`, like other creates) | +| *(A1)* `users.addEmail` | `PUT /users/{id}/email` | | `users.setPin` / `users.clearPin` | `PUT /users/{id}/pin`, `DELETE /users/{id}/pin` | | `users.issueBadge` / `users.revokeBadge` | `POST /users/{id}/badge`, `DELETE /users/{id}/badge` | | `invitations.list` / `create` / `resend` / `revoke` | `GET/POST /invitations`, `POST /invitations/{id}/resend`, `DELETE /invitations/{id}` | @@ -215,6 +289,10 @@ badge code once. `register` sets the cookie and never returns the token in the b - `/users` (nav: **Users**, `user.manage`): users table with role chips and status, a row menu (Edit roles, Set PIN, Issue badge, Deactivate/Reactivate), and an Invitations tab with **Invite user**. **Issue badge** opens a print view: name, a QR code of the badge code, and the code as text (decision 5). + *(A1)* The Users tab gets **Add station user** (name, role checkboxes without Admin, PIN with **Generate**). + Station-only rows show a "Station only" chip in place of the email, and Edit roles leaves out Admin for them. + After creating a user, the dialog offers **Issue badge** for them. The dialog shows the duplicate-name warning + inline. Station-only rows get **Add email** in the row menu (address + confirm). - `/stations` (nav: **Stations**, `station.manage`): list with name, current user, last seen, active. Rename, Revoke, and **Make this device a station**. - `/station`: full-screen, touch-first lock screen. Large name tiles (sorted by most recent use on this station), @@ -232,9 +310,15 @@ badge code once. `register` sets the cookie and never returns the token in the b One new dependency: `qrcode` (+ `@types/qrcode`) in `apps/web`, for badge QR codes (decision 5). +*(A1)* No schema change, migration or dependency. + ### Files expected to change -`packages/core/src/identity/**` (+ tests), `packages/core/src/adapters/email/**`, `packages/core/src/jobs.ts`, +*(A1)* `packages/core/src/identity/{users,sign-in,emails}.ts` (+ tests), `packages/contracts/src/{users,invitations}.ts`, +`apps/web/src/server/{auth,router}.ts`, `apps/web/src/app/(app)/users/**`, `packages/i18n/src/messages/{en,es}.json`, +`apps/web/e2e/identity.spec.ts` (or a new `station-users.spec.ts`), `docs/decisions.md`. + +Original scope: `packages/core/src/identity/**` (+ tests), `packages/core/src/adapters/email/**`, `packages/core/src/jobs.ts`, `packages/core/src/settings/{service,integration}.ts`, `packages/core/src/index.ts`, `packages/contracts/src/{users,invitations,stations,me,settings,index}.ts`, `packages/db/prisma/schema/{auth,system}.prisma` + a migration, `apps/web/src/server/{actor,auth,orpc,router}.ts`, `apps/web/src/app/{sign-in,station}/**`, @@ -281,6 +365,30 @@ One new dependency: `qrcode` (+ `@types/qrcode`) in `apps/web`, for badge QR cod limit and the lock screen returns. 15. Green: `pnpm lint && pnpm typecheck && pnpm test && pnpm build`, plus the Playwright suite. +*(A1)* + +16. **Create:** `users.createStationUser` with a name, Picker and a valid PIN creates an active user who shows + up in `station.status` tiles and can unlock with that PIN. It writes one `user.create` audit with no PIN or + hash in it. The same idempotency key twice creates one user. Admin allowed; Picker, Packer, QA, Shipper + `FORBIDDEN`. +17. **No Admin:** creating with `ADMIN`, or `updateUserRoles` adding `ADMIN` to a station-only user, fails with + `errors.users.stationOnlyAdmin`. +18. **No personal sign-in:** for a station-only user's placeholder, `sendMagicLink` sends nothing, + `mayCreateAccount` refuses even when the address is in `ADMIN_EMAILS`, and `session.create.before` refuses a + session for the user. `invitations.create` rejects any `@station.invalid` address. +19. **Never leaks:** no `users.*` output contains the placeholder (`email` is `null`, `stationOnly` is `true`), and + searching `users.list` for "station.invalid" finds nobody. Added to the no-secrets test. +20. **E2E (tablet):** an admin adds a station user with a generated PIN and registers the device as a station. + The new user's tile is on the lock screen, they unlock with the PIN, and their pick is attributed to them and + the station. +21. **Add email:** `users.addEmail` on a station-only user replaces the placeholder, and from then on + `stationOnly` is `false` and Admin can be given. It enqueues exactly one `accountEnabled` email in the same + transaction, and a magic link to the new address then signs in that same `userId`. It's refused for a regular + user, for an address another user has, for an address with an open invitation, and for the reserved domain. + Admin allowed; other roles `FORBIDDEN`. +22. **Duplicate-name warning:** the Add station user dialog shows the warning for an active user's name in a + different case and still lets the admin create the user (component or E2E test). + ## Decisions (2026-10-20) 1. **Email provider:** Resend, through its HTTP API (no SDK). Recorded in `docs/decisions.md` when this ships. @@ -297,6 +405,30 @@ One new dependency: `qrcode` (+ `@types/qrcode`) in `apps/web`, for badge QR cod 7. **Personal sessions** keep Better Auth's default: 7 days, extended with use. 8. **Invitation emails are in English.** No language picker. +### Amendment 1 (approved 2026-11-10) + +9. **Placeholder email, not a nullable column.** Better Auth's user model requires an email, and making it + nullable would affect every Better Auth read and its Prisma adapter for a user who never has a session. A + reserved `.invalid` address keeps the schema and Better Auth untouched. It's also derived, not flagged: no + `stationOnly` column that could disagree with the email. +10. **No Admin role for station-only users.** Decision 4 accepts admin powers from a PIN or badge for people who + also have a personal account. Station-only Admins would make a 6-digit PIN the only credential for full admin + access. Not allowed. +11. **A PIN is required at creation.** A user with no way in is useless; a badge can be added after. +12. Recorded in `docs/decisions.md` (data model, stations bullet) in the same PR as the code, per that file's rule. +13. **Duplicate names warn, don't block.** Two people really can share a name. The warning stops an admin from + creating the same person twice by mistake. +14. **Adding an email is in scope, one way only.** It keeps the same `userId`, so history, PIN and badge carry + over. It uses its own `accountEnabled` email rather than the invitation template, since there's nothing to + expire or accept. +15. **An added email is stored verified** (#174, 2026-11-10). The amendment first kept `emailVerified: false` + until the first magic link proved the address. Better Auth 1.7.8 can't do that here: for an existing + unverified user its magic-link verify runs `revokeUnprovenAccountAccess`, which reserves a `verifications` + row with a base64url id (a uuid only when `generateId` is `"uuid"`; ours is `false` with uuid columns), so + verify returns 500. A station-only user has no accounts or sessions for that step to revoke, so nothing is + lost. The cost: Google/Discord sign them in before any magic link, which a typo'd address already allows + through the magic link itself. `generateId` and the schema stay unchanged. + ## Open questions None. diff --git a/packages/contracts/src/me.ts b/packages/contracts/src/me.ts index a8ceb706..3d239a29 100644 --- a/packages/contracts/src/me.ts +++ b/packages/contracts/src/me.ts @@ -21,7 +21,8 @@ export type UpdatePreferencesInput = z.infer; export const Me = z.object({ id: Id, name: z.string(), - email: z.string(), + /** Null for a station-only user, who has no real address (SPEC-identity amendment 1). */ + email: z.string().nullable(), image: z.string().nullable(), roles: z.array(Role), permissions: z.array(z.string()), diff --git a/packages/contracts/src/users.test.ts b/packages/contracts/src/users.test.ts index 59e11d74..a36bf5bc 100644 --- a/packages/contracts/src/users.test.ts +++ b/packages/contracts/src/users.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from "vitest"; import { toFieldErrors } from "./base"; -import { generatePin, Pin, SetPinInput, UpdateUserRolesInput } from "./users"; +import { + CreateStationUserInput, + generatePin, + Pin, + SetPinInput, + UpdateUserRolesInput, +} from "./users"; const id = "0199a1c2-0000-7000-8000-000000000001"; @@ -51,3 +57,25 @@ describe("generatePin", () => { for (let i = 0; i < 500; i++) expect(Pin.safeParse(generatePin()).success).toBe(true); }); }); + +describe("CreateStationUserInput", () => { + const valid = { idempotencyKey: "key-12345", name: " Luis ", roles: ["PICKER"], pin: "482915" }; + + it("trims the name and deduplicates roles", () => { + expect( + CreateStationUserInput.parse({ ...valid, roles: ["PICKER", "PICKER", "QA"] }), + ).toMatchObject({ name: "Luis", roles: ["PICKER", "QA"] }); + }); + + it.each([ + [{ name: " " }, { name: "errors.field.required" }], + [{ name: "x".repeat(81) }, { name: "errors.field.tooBig" }], + [{ roles: [] }, { roles: "errors.users.rolesRequired" }], + [{ roles: ["PICKER", "ADMIN"] }, { roles: "errors.users.stationOnlyAdmin" }], + [{ pin: "123456" }, { pin: "errors.users.pinWeak" }], + ])("rejects %o with a translatable field error", (overrides, errors) => { + const result = CreateStationUserInput.safeParse({ ...valid, ...overrides }); + expect(result.success).toBe(false); + expect(toFieldErrors(result.error?.issues ?? [])).toEqual(errors); + }); +}); diff --git a/packages/contracts/src/users.ts b/packages/contracts/src/users.ts index dd360939..326084ad 100644 --- a/packages/contracts/src/users.ts +++ b/packages/contracts/src/users.ts @@ -1,12 +1,15 @@ import { z } from "zod"; -import { base, Id } from "./base"; +import { base, Id, IdempotencyKey } from "./base"; import { Role } from "./me"; /** A user as admins see them. Has flags for a PIN and badge, never their hashes. */ export const UserSummary = z.object({ id: Id, name: z.string(), - email: z.string(), + /** Null for a station-only user: their placeholder address never leaves core (amendment 1). */ + email: z.string().nullable(), + /** No email, no personal sign-in: unlocks stations only (SPEC-identity amendment 1). */ + stationOnly: z.boolean(), roles: z.array(Role), active: z.boolean(), hasPin: z.boolean(), @@ -59,6 +62,30 @@ export const IssuedBadge = z.object({ }); export type IssuedBadge = z.infer; +/** A worker with no email: name, roles (never Admin) and a PIN (SPEC-identity amendment 1). */ +export const CreateStationUserInput = z.object({ + idempotencyKey: IdempotencyKey, + name: z.string().trim().min(1).max(80), + roles: z + .array(Role) + .min(1, "errors.users.rolesRequired") + .refine((roles) => !roles.includes("ADMIN"), "errors.users.stationOnlyAdmin") + .transform((roles) => [...new Set(roles)]), + pin: Pin, +}); +export type CreateStationUserInput = z.infer; + +/** + * Turns a station-only user into a regular one (SPEC-identity amendment 1). Core refuses the + * reserved placeholder domain, so only `station-only.ts` knows it. + */ +export const AddUserEmailInput = z.object({ + idempotencyKey: IdempotencyKey, + id: Id, + email: z.string().trim().toLowerCase().pipe(z.email("errors.invitations.emailInvalid")), +}); +export type AddUserEmailInput = z.infer; + export const USER_LIST_PAGE_MAX = 100; export const UserListInput = z.object({ @@ -88,6 +115,26 @@ export const users = { .input(UserListInput) .output(UserListPage), + createStationUser: base + .route({ + method: "POST", + path: "/users", + tags: ["Users"], + summary: "Add a station-only user: name, roles and PIN, no email or personal sign-in", + }) + .input(CreateStationUserInput) + .output(UserSummary), + + addEmail: base + .route({ + method: "PUT", + path: "/users/{id}/email", + tags: ["Users"], + summary: "Add an email to a station-only user, making them a regular user who can sign in", + }) + .input(AddUserEmailInput) + .output(UserSummary), + updateRoles: base .route({ method: "PUT", diff --git a/packages/core/src/identity/add-email.test.ts b/packages/core/src/identity/add-email.test.ts new file mode 100644 index 00000000..e90c79a5 --- /dev/null +++ b/packages/core/src/identity/add-email.test.ts @@ -0,0 +1,263 @@ +import type { Db, Role } from "@woolrus/db"; +import { afterAll, beforeEach, describe, expect, it } from "vitest"; +import { createActor, resetDb, testDb } from "../../test/db"; +import type { EmailMessage } from "../adapters/email/sender"; +import { JOBS_SCHEMA, queues } from "../jobs"; +import { createInvitation, revokeInvitation } from "./invitations"; +import { mayStartSession, sendMagicLink } from "./sign-in"; +import { addUserEmail, createStationUser, updateUserRoles } from "./users"; + +// SPEC-identity amendment 1, success criterion 21: an admin adds an email to a station-only user, +// who then becomes a regular user with the same id, roles, PIN, badge and history. + +const db: Db = testDb(); +afterAll(() => db.$disconnect()); +beforeEach(() => resetDb(db)); + +const appUrl = "https://woolrus.example"; +const nonAdmins: Role[] = ["PICKER", "PACKER", "QA", "SHIPPER"]; +const unique = (name: string) => `${name}-${crypto.randomUUID()}@example.test`; +const key = () => crypto.randomUUID(); + +/** email.send jobs addressed to `to` (pg-boss's tables aren't reset between tests). */ +async function emailJobs(to: string) { + return db.$queryRawUnsafe< + { data: { template: string; params: Record; locale: string } }[] + >( + `SELECT data FROM ${JOBS_SCHEMA}.job WHERE name = $1 AND data->>'to' = $2 ORDER BY created_on`, + queues.emailSend, + to, + ); +} + +async function admin() { + const actor = await createActor(db, ["ADMIN"]); + await db.user.update({ where: { id: actor.userId }, data: { name: "Isa Admin" } }); + return actor; +} + +async function stationUser(actor: Awaited>) { + return createStationUser(db, actor, { + name: "Luis", + roles: ["PICKER"], + pin: "482916", + idempotencyKey: key(), + }); +} + +describe("addUserEmail", () => { + it("replaces the placeholder, keeps the user, and makes them a regular user", async () => { + const actor = await admin(); + const created = await stationUser(actor); + const before = await db.user.findUniqueOrThrow({ where: { id: created.id } }); + const email = unique("Luis").toUpperCase(); + + const updated = await addUserEmail( + db, + actor, + { id: created.id, email, idempotencyKey: key() }, + { appUrl }, + ); + + expect(updated).toMatchObject({ + id: created.id, + email: email.toLowerCase(), + stationOnly: false, + roles: ["PICKER"], + hasPin: true, + }); + const row = await db.user.findUniqueOrThrow({ where: { id: created.id } }); + expect(row).toMatchObject({ + email: email.toLowerCase(), + emailVerified: true, + pinHash: before.pinHash, + name: "Luis", + }); + expect( + await db.auditEvent.findFirst({ where: { entityId: created.id, action: "user.email.add" } }), + ).toMatchObject({ actorId: actor.userId, after: { email: email.toLowerCase() } }); + // Now a regular user: Admin can be given. + await expect( + updateUserRoles(db, actor, { id: created.id, roles: ["PICKER", "ADMIN"] }), + ).resolves.toMatchObject({ roles: ["PICKER", "ADMIN"], stationOnly: false }); + }); + + it("enqueues exactly one accountEnabled email, in English, linking to sign-in", async () => { + const actor = await admin(); + const created = await stationUser(actor); + const email = unique("luis"); + + await addUserEmail(db, actor, { id: created.id, email, idempotencyKey: key() }, { appUrl }); + + const jobs = await emailJobs(email); + expect(jobs).toHaveLength(1); + expect(jobs[0]?.data).toMatchObject({ + template: "accountEnabled", + locale: "en", + params: { + admin: "Isa Admin", + url: `${appUrl}/sign-in?email=${encodeURIComponent(email)}`, + }, + }); + }); + + it("is idempotent: a replay returns the same user and sends no second email", async () => { + const actor = await admin(); + const created = await stationUser(actor); + const email = unique("replay"); + const input = { id: created.id, email, idempotencyKey: key() }; + + const first = await addUserEmail(db, actor, input, { appUrl }); + const second = await addUserEmail(db, actor, input, { appUrl }); + + expect(second).toEqual(first); + expect(await emailJobs(email)).toHaveLength(1); + }); + + it("rolls the email job back with the change", async () => { + const actor = await admin(); + const created = await stationUser(actor); + const email = unique("rollback"); + // Fail the audit insert, which runs after the job is enqueued in the same transaction. + await db.$executeRawUnsafe(` + CREATE FUNCTION fail_email_audit() RETURNS trigger LANGUAGE plpgsql AS + $$ BEGIN RAISE EXCEPTION 'audit failed'; END $$`); + await db.$executeRawUnsafe(` + CREATE TRIGGER fail_email_audit BEFORE INSERT ON audit_events FOR EACH ROW + WHEN (NEW.after->>'email' = '${email}') EXECUTE FUNCTION fail_email_audit()`); + try { + await expect( + addUserEmail(db, actor, { id: created.id, email, idempotencyKey: key() }, { appUrl }), + ).rejects.toThrow(); + } finally { + await db.$executeRawUnsafe("DROP TRIGGER fail_email_audit ON audit_events"); + await db.$executeRawUnsafe("DROP FUNCTION fail_email_audit"); + } + expect(await db.user.count({ where: { email } })).toBe(0); + expect(await emailJobs(email)).toHaveLength(0); + expect((await db.user.findUniqueOrThrow({ where: { id: created.id } })).email).toMatch( + /@station\.invalid$/, + ); + }); + + it("then lets a magic link to the new address sign in the same user", async () => { + const actor = await admin(); + const created = await stationUser(actor); + const email = unique("signin"); + await addUserEmail(db, actor, { id: created.id, email, idempotencyKey: key() }, { appUrl }); + + const sent: EmailMessage[] = []; + const result = await sendMagicLink( + db, + { email, url: "https://woolrus.example/verify", fallbackLocale: "en" }, + { + adminEmails: [], + sender: async () => ({ kind: "resend", send: async (m) => void sent.push(m) }), + log: { info: () => {}, error: () => {} }, + }, + ); + expect(result).toBe("sent"); + expect(sent.map((m) => m.to)).toEqual([email]); + // Better Auth finds the user by this email, so the session it creates is for the same id. + expect((await db.user.findFirstOrThrow({ where: { email } })).id).toBe(created.id); + expect(await mayStartSession(db, created.id)).toEqual({ allowed: true }); + }); + + it("refuses a regular user", async () => { + const actor = await admin(); + const regular = await createActor(db, ["PICKER"]); + await expect( + addUserEmail( + db, + actor, + { id: regular.userId, email: unique("x"), idempotencyKey: key() }, + { appUrl }, + ), + ).rejects.toMatchObject({ kind: "CONFLICT", messageKey: "errors.users.notStationOnly" }); + }); + + it("refuses a deactivated station-only user, with no email sent", async () => { + const actor = await admin(); + const created = await stationUser(actor); + await db.user.update({ where: { id: created.id }, data: { active: false } }); + const email = unique("gone"); + await expect( + addUserEmail(db, actor, { id: created.id, email, idempotencyKey: key() }, { appUrl }), + ).rejects.toMatchObject({ kind: "CONFLICT", messageKey: "errors.users.addEmailInactive" }); + expect(await emailJobs(email)).toHaveLength(0); + expect((await db.user.findUniqueOrThrow({ where: { id: created.id } })).email).not.toBe(email); + }); + + it("refuses an address another user has, in any case", async () => { + const actor = await admin(); + const created = await stationUser(actor); + const other = await createActor(db, ["PACKER"]); + const { email } = await db.user.findUniqueOrThrow({ where: { id: other.userId } }); + await expect( + addUserEmail( + db, + actor, + { id: created.id, email: email.toUpperCase(), idempotencyKey: key() }, + { appUrl }, + ), + ).rejects.toMatchObject({ kind: "CONFLICT", messageKey: "errors.users.alreadyExists" }); + }); + + it("refuses an address with an open invitation, until it's revoked", async () => { + const actor = await admin(); + const created = await stationUser(actor); + const email = unique("invited"); + const invitation = await createInvitation( + db, + actor, + { email, roles: ["QA"], idempotencyKey: key() }, + { appUrl }, + ); + await expect( + addUserEmail(db, actor, { id: created.id, email, idempotencyKey: key() }, { appUrl }), + ).rejects.toMatchObject({ kind: "CONFLICT", messageKey: "errors.users.invitationPending" }); + + await revokeInvitation(db, actor, { id: invitation.id }); + await expect( + addUserEmail(db, actor, { id: created.id, email, idempotencyKey: key() }, { appUrl }), + ).resolves.toMatchObject({ email, stationOnly: false }); + }); + + it("refuses the reserved station-only domain", async () => { + const actor = await admin(); + const created = await stationUser(actor); + await expect( + addUserEmail( + db, + actor, + { id: created.id, email: "luis@STATION.invalid", idempotencyKey: key() }, + { appUrl }, + ), + ).rejects.toMatchObject({ + kind: "BAD_REQUEST", + messageKey: "errors.invitations.reservedEmail", + }); + }); + + it("returns NOT_FOUND for an unknown user", async () => { + const actor = await admin(); + await expect( + addUserEmail( + db, + actor, + { id: crypto.randomUUID(), email: unique("x"), idempotencyKey: key() }, + { appUrl }, + ), + ).rejects.toMatchObject({ kind: "NOT_FOUND" }); + }); + + it.each(nonAdmins)("is forbidden for %s", async (role) => { + const created = await stationUser(await admin()); + const actor = await createActor(db, [role]); + const email = unique("forbidden"); + await expect( + addUserEmail(db, actor, { id: created.id, email, idempotencyKey: key() }, { appUrl }), + ).rejects.toMatchObject({ kind: "FORBIDDEN" }); + expect(await db.user.count({ where: { email } })).toBe(0); + }); +}); diff --git a/packages/core/src/identity/emails.test.ts b/packages/core/src/identity/emails.test.ts index 977c456e..ddcdb599 100644 --- a/packages/core/src/identity/emails.test.ts +++ b/packages/core/src/identity/emails.test.ts @@ -17,4 +17,15 @@ describe("renderEmail", () => { expect(email.html).toContain(`Sign in to Woolrus`); expect(email.html).toContain("as Picker, Packer."); }); + + it("renders accountEnabled with the admin and a sign-in link, and no expiry", () => { + const url = "https://woolrus.example/sign-in?email=luis%40example.com"; + const email = renderEmail("accountEnabled", { admin: "Isa ", url }, "en"); + + expect(email.subject).toBe("Isa set up sign-in for you"); + expect(email.text).toContain(`Sign in to Woolrus: ${url}`); + expect(email.html).toContain("Isa <Admin> set up"); + expect(email.html).toContain(`Sign in to Woolrus`); + expect(email.text).not.toMatch(/valid for|expires/i); + }); }); diff --git a/packages/core/src/identity/emails.ts b/packages/core/src/identity/emails.ts index 52d7e345..756896e8 100644 --- a/packages/core/src/identity/emails.ts +++ b/packages/core/src/identity/emails.ts @@ -17,6 +17,12 @@ const templates = { paragraphs: ["emails.invitation.body", "emails.invitation.expiry"], action: "emails.invitation.action", }, + // A station-only user given an email (SPEC-identity amendment 1): nothing to accept or expire. + accountEnabled: { + subject: "emails.accountEnabled.subject", + paragraphs: ["emails.accountEnabled.body", "emails.accountEnabled.how"], + action: "emails.accountEnabled.action", + }, magicLink: { subject: "emails.magicLink.subject", paragraphs: ["emails.magicLink.body", "emails.magicLink.ignore"], diff --git a/packages/core/src/identity/invitations.test.ts b/packages/core/src/identity/invitations.test.ts index d02803e1..412166fe 100644 --- a/packages/core/src/identity/invitations.test.ts +++ b/packages/core/src/identity/invitations.test.ts @@ -9,6 +9,7 @@ import { revokeInvitation, } from "./invitations"; import { acceptInvitation, mayCreateAccount } from "./sign-in"; +import { stationOnlyPlaceholder } from "./station-only"; const db: Db = testDb(); afterAll(() => db.$disconnect()); @@ -96,6 +97,26 @@ describe("createInvitation", () => { expect(await emailJobs(email)).toHaveLength(0); }); + // SPEC-identity amendment 1, criterion 18: the station-only placeholder domain is reserved. + it("refuses any address on the station-only domain, in any case, with nothing written", async () => { + const actor = await admin(); + for (const email of [stationOnlyPlaceholder(), "luis@STATION.invalid"]) { + await expect( + createInvitation( + db, + actor, + { idempotencyKey: key(), email, roles: ["PICKER"] }, + { appUrl }, + ), + ).rejects.toMatchObject({ + kind: "BAD_REQUEST", + messageKey: "errors.invitations.reservedEmail", + }); + expect(await emailJobs(email.toLowerCase())).toHaveLength(0); + } + expect(await db.invitation.count()).toBe(0); + }); + it("refuses an email with a pending invitation", async () => { const actor = await admin(); const email = unique("cy"); diff --git a/packages/core/src/identity/invitations.ts b/packages/core/src/identity/invitations.ts index d42edb7a..0fbd054b 100644 --- a/packages/core/src/identity/invitations.ts +++ b/packages/core/src/identity/invitations.ts @@ -7,6 +7,7 @@ import { AppError, notFound } from "../errors"; import { withIdempotency } from "../idempotency"; import { queues } from "../jobs"; import { type Actor, assertCan } from "../permissions"; +import { isStationOnly } from "./station-only"; const VALID_FOR_MS = 7 * 24 * 60 * 60 * 1000; @@ -89,6 +90,9 @@ export async function createInvitation( const email = input.email.trim().toLowerCase(); const roles = [...new Set(input.roles)]; if (roles.length === 0) throw new AppError("BAD_REQUEST", "errors.users.rolesRequired"); + // The station-only placeholder domain (SPEC-identity amendment 1). Checked here, not in the + // contract, so `station-only.ts` stays the only code that knows the domain. + if (isStationOnly(email)) throw new AppError("BAD_REQUEST", "errors.invitations.reservedEmail"); try { // Only the id is stored for replays: the stored JSON would turn the dates into strings. const { id } = await db.$transaction((tx) => diff --git a/packages/core/src/identity/magic-link.test.ts b/packages/core/src/identity/magic-link.test.ts index 6ebca692..466a0dcd 100644 --- a/packages/core/src/identity/magic-link.test.ts +++ b/packages/core/src/identity/magic-link.test.ts @@ -4,7 +4,8 @@ import { afterAll, beforeEach, describe, expect, it } from "vitest"; import { createActor, resetDb, testDb } from "../../test/db"; import type { EmailMessage, EmailSender } from "../adapters/email/sender"; import { EmailError } from "../adapters/email/sender"; -import { mayCreateAccount, sendMagicLink } from "./sign-in"; +import { mayCreateAccount, mayStartSession, sendMagicLink } from "./sign-in"; +import { stationOnlyPlaceholder } from "./station-only"; // SPEC-identity "Sign-in hardening": magic links only go to people who may sign in, so our email // account can't be used to mail arbitrary addresses, and the response never says which is which. @@ -148,3 +149,77 @@ describe("mayCreateAccount (SRS §7.1 #3: any account got full access)", () => { } }); }); + +// SPEC-identity amendment 1: a station-only user unlocks stations by PIN and never signs in +// personally, whatever the method (criterion 18). +describe("station-only users can't sign in personally", () => { + async function stationOnlyUser(active = true) { + const actor = await createActor(db, ["PICKER"]); + return db.user.update({ + where: { id: actor.userId }, + data: { email: stationOnlyPlaceholder(), active }, + }); + } + + it("sendMagicLink sends nothing to a placeholder, the same silent path as a stranger", async () => { + const user = await stationOnlyUser(); + const { sent, sender } = fakeSender(); + const logs: unknown[] = []; + const strangerLogs: unknown[] = []; + + expect(await run(user.email.toUpperCase(), sender, logs)).toBe("skipped"); + expect(await run("stranger@example.test", sender, strangerLogs)).toBe("skipped"); + expect(sent).toHaveLength(0); + expect(logs).toEqual([{ email: user.email }]); + expect(strangerLogs).toEqual([{ email: "stranger@example.test" }]); + }); + + it("sendMagicLink sends nothing to a placeholder listed in ADMIN_EMAILS", async () => { + const email = stationOnlyPlaceholder(); + const { sent, sender } = fakeSender(); + const result = await sendMagicLink( + db, + { email, url, fallbackLocale: "en" }, + { + adminEmails: [email], + sender: async () => sender, + log: { info: () => {}, error: () => {} }, + }, + ); + + expect(result).toBe("skipped"); + expect(sent).toHaveLength(0); + }); + + it("mayCreateAccount refuses a placeholder, even in ADMIN_EMAILS or with an open invitation", async () => { + const email = stationOnlyPlaceholder(); + await db.invitation.create({ + data: { email, roles: ["PICKER"], expiresAt: new Date(Date.now() + 86_400_000) }, + }); + + expect(await mayCreateAccount(db, { email, emailVerified: true }, [email])).toBe(false); + expect( + await mayCreateAccount(db, { email: email.toUpperCase(), emailVerified: true }, []), + ).toBe(false); + }); + + it("mayStartSession refuses a station-only user, as it does a deactivated or unknown one", async () => { + const regular = await userWith({}); + const deactivated = await userWith({ active: false }); + const stationOnly = await stationOnlyUser(); + + expect(await mayStartSession(db, regular.id)).toEqual({ allowed: true }); + expect(await mayStartSession(db, deactivated.id)).toEqual({ + allowed: false, + reason: "deactivated", + }); + expect(await mayStartSession(db, stationOnly.id)).toEqual({ + allowed: false, + reason: "stationOnly", + }); + expect(await mayStartSession(db, crypto.randomUUID())).toEqual({ + allowed: false, + reason: "deactivated", + }); + }); +}); diff --git a/packages/core/src/identity/no-secrets.test.ts b/packages/core/src/identity/no-secrets.test.ts index 76b9e1ec..817bb324 100644 --- a/packages/core/src/identity/no-secrets.test.ts +++ b/packages/core/src/identity/no-secrets.test.ts @@ -14,12 +14,14 @@ import { unlockWithPin, } from "./station-session"; import { findStationByToken, listStations, registerStation } from "./stations"; -import { issueBadge, listUsers, setPin } from "./users"; +import { createStationUser, issueBadge, listUsers, setPin, updateUserRoles } from "./users"; // SPEC-identity success criterion 9: one session that sets a PIN, issues a badge, registers a // station and sets the Resend key. Every response and audit row is serialized and searched; none // may hold a PIN, PIN hash, badge hash, station token (or its hash) or the Resend key (or its // ciphertext). The badge code may appear in the issueBadge response only: it's printed from there. +// Amendment 1 (criterion 19): it also adds a station-only user, whose placeholder email must not +// appear in any users.* output either. const db: Db = testDb(); afterAll(() => db.$disconnect()); @@ -73,7 +75,23 @@ describe("no secrets out", () => { { appUrl: "http://localhost:3000" }, ); responses.invitations = await listInvitations(db, admin); + const stationUser = await createStationUser(db, admin, { + name: "Luis", + roles: ["PICKER"], + pin: "739104", + idempotencyKey: crypto.randomUUID(), + }); + responses.createStationUser = stationUser; + responses.stationUserRoles = await updateUserRoles(db, admin, { + id: stationUser.id, + roles: ["PICKER", "PACKER"], + }); + responses.stationUserPin = await setPin(db, admin, { id: stationUser.id, pin: "739104" }); + const stationBadge = await issueBadge(db, admin, { id: stationUser.id }); + responses.stationUserBadge = { ...stationBadge, code: undefined }; responses.users = await listUsers(db, admin); + responses.usersSearch = await listUsers(db, admin, { search: "station.invalid" }); + expect(responses.usersSearch).toMatchObject({ users: [], total: 0 }); responses.stations = await listStations(db, admin); // The floor: the station device, then the worker on it. @@ -89,6 +107,7 @@ describe("no secrets out", () => { const userRow = await db.user.findUniqueOrThrow({ where: { id: worker.userId } }); const stationRow = await db.station.findUniqueOrThrow({ where: { id: station.id } }); const settingsRow = await db.settings.findFirstOrThrow(); + const stationUserRow = await db.user.findUniqueOrThrow({ where: { id: stationUser.id } }); const secrets = { pin: PIN, pinHash: userRow.pinHash ?? fail(), @@ -97,6 +116,10 @@ describe("no secrets out", () => { stationTokenHash: stationRow.tokenHash, resendKey: RESEND_KEY, resendKeyCiphertext: settingsRow.resendApiKeyEnc ?? fail(), + stationPin: "739104", + stationPinHash: stationUserRow.pinHash ?? fail(), + placeholder: stationUserRow.email, + placeholderDomain: "station.invalid", }; const audit = await db.auditEvent.findMany(); diff --git a/packages/core/src/identity/sign-in.ts b/packages/core/src/identity/sign-in.ts index b7a0f369..db14c191 100644 --- a/packages/core/src/identity/sign-in.ts +++ b/packages/core/src/identity/sign-in.ts @@ -2,9 +2,11 @@ import type { Db, DbTx } from "@woolrus/db"; import type { Locale } from "@woolrus/i18n"; import type { EmailSender } from "../adapters/email/sender"; import { renderEmail } from "./emails"; +import { isStationOnly } from "./station-only"; -// Who may get an account (NFR-SEC-02, SRS §7.1 #3). Better Auth's user-create hooks in the web app -// call these, so the rule lives in core and is tested here. +// Who may get an account or a session (NFR-SEC-02, SRS §7.1 #3). Better Auth's hooks in the web app +// call these, so the rules live in core and are tested here. A station-only user (SPEC-identity +// amendment 1) never signs in personally: every path below refuses their placeholder address. /** An invitation that can still be accepted: not accepted, not revoked, not expired. */ export async function openInvitation(db: Db | DbTx, email: string, now = new Date()) { @@ -32,7 +34,7 @@ export async function mayCreateAccount( { email, emailVerified }: { email: string; emailVerified: boolean }, adminEmails: readonly string[], ): Promise { - if (!emailVerified) return false; + if (!emailVerified || isStationOnly(email)) return false; return isAdminEmail(email, adminEmails) || (await openInvitation(db, email)) !== null; } @@ -57,6 +59,26 @@ export async function acceptInvitation( }); } +export type SessionDecision = + | { allowed: true } + | { allowed: false; reason: "deactivated" | "stationOnly" }; + +/** + * Better Auth's `session.create.before`, for every sign-in method. A deactivated user can't start a + * session (a Google sign-in, or a link sent before deactivation), and neither can a station-only + * user: this is what stops an OAuth provider or account linking from reaching their row. An + * unknown user id is refused like a deactivated one. + */ +export async function mayStartSession(db: Db, userId: string): Promise { + const user = await db.user.findUnique({ + where: { id: userId }, + select: { active: true, email: true }, + }); + if (!user?.active) return { allowed: false, reason: "deactivated" }; + if (isStationOnly(user.email)) return { allowed: false, reason: "stationOnly" }; + return { allowed: true }; +} + export type MagicLinkLog = { info: (entry: { email: string }, message: string) => void; error: (entry: { email: string; err: unknown }, message: string) => void; @@ -83,13 +105,17 @@ export async function sendMagicLink( }, ): Promise<"sent" | "skipped" | "failed"> { const address = email.trim().toLowerCase(); + // A station-only placeholder can't receive mail, and its user signs in by PIN only. Refused after + // the same lookup as any address, so the timing doesn't single it out. const user = await db.user.findFirst({ where: { email: { equals: address, mode: "insensitive" } }, select: { active: true, locale: true }, }); - const allowed = user - ? user.active - : isAdminEmail(address, adminEmails) || (await openInvitation(db, address)) !== null; + const allowed = + !isStationOnly(address) && + (user + ? user.active + : isAdminEmail(address, adminEmails) || (await openInvitation(db, address)) !== null); if (!allowed) { log.info({ email: address }, "magic link not sent: no active user or open invitation"); return "skipped"; diff --git a/packages/core/src/identity/station-only.ts b/packages/core/src/identity/station-only.ts new file mode 100644 index 00000000..daf9f3d6 --- /dev/null +++ b/packages/core/src/identity/station-only.ts @@ -0,0 +1,31 @@ +/** + * Station-only users (SPEC-identity amendment 1) keep Better Auth's required, unique email by + * holding a placeholder on a reserved domain. `.invalid` is reserved by RFC 2606, so the address + * can never exist or receive mail. This file is the only code that knows the domain: everything + * else asks `isStationOnly`, and the placeholder never leaves core. + */ +const STATION_ONLY_DOMAIN = "station.invalid"; + +/** True for a station-only user's placeholder address (any case). */ +export function isStationOnly(email: string): boolean { + return email.toLowerCase().endsWith(`@${STATION_ONLY_DOMAIN}`); +} + +/** The address to show outside core: null for a placeholder, which never leaves core. */ +export function publicEmail(email: string): string | null { + return isStationOnly(email) ? null : email; +} + +/** A fresh placeholder for a new station-only user: `@station.invalid`. */ +export function stationOnlyPlaceholder(): string { + return `${crypto.randomUUID()}@${STATION_ONLY_DOMAIN}`; +} + +/** + * A Prisma string filter matching placeholders. Email searches exclude it (`NOT`), so searching for + * the domain, or any part of a placeholder, never finds a station-only user by it. + */ +export const stationOnlyEmailFilter = { + endsWith: `@${STATION_ONLY_DOMAIN}`, + mode: "insensitive", +} as const; diff --git a/packages/core/src/identity/users.test.ts b/packages/core/src/identity/users.test.ts index 829feed2..70b901f4 100644 --- a/packages/core/src/identity/users.test.ts +++ b/packages/core/src/identity/users.test.ts @@ -3,7 +3,16 @@ import { afterAll, beforeEach, describe, expect, it } from "vitest"; import { createActor, resetDb, testDb } from "../../test/db"; import { raceBehindLock } from "../../test/locks"; import type { UserActor } from "../permissions"; -import { deactivateUser, listUsers, reactivateUser, updateUserRoles } from "./users"; +import { isStationOnly, publicEmail } from "./station-only"; +import { stationStatus, unlockWithPin } from "./station-session"; +import { findStationByToken, registerStation } from "./stations"; +import { + createStationUser, + deactivateUser, + listUsers, + reactivateUser, + updateUserRoles, +} from "./users"; const db: Db = testDb(); afterAll(() => db.$disconnect()); @@ -254,6 +263,13 @@ describe("permissions", () => { () => updateUserRoles(db, actor, { id, roles: ["PACKER"] }), () => deactivateUser(db, actor, { id }), () => reactivateUser(db, actor, { id }), + () => + createStationUser(db, actor, { + name: "Luis", + roles: ["PICKER"], + pin: PIN, + idempotencyKey: crypto.randomUUID(), + }), ]) { await expect(call()).rejects.toMatchObject({ kind: "FORBIDDEN" }); } @@ -261,5 +277,170 @@ describe("permissions", () => { active: true, roles: ["PICKER"], }); + expect(await db.user.count({ where: { name: "Luis" } })).toBe(0); + }); +}); + +// --- Station-only users (SPEC-identity amendment 1, success criteria 16, 17, 19) --- + +const PIN = "482916"; + +function stationUserInput(overrides: Partial[2]> = {}) { + return { + name: "Luis", + roles: ["PICKER"] as Role[], + pin: PIN, + idempotencyKey: crypto.randomUUID(), + ...overrides, + }; +} + +describe("isStationOnly", () => { + it("is true only for the reserved station.invalid domain, in any case", () => { + expect(isStationOnly(`${crypto.randomUUID()}@station.invalid`)).toBe(true); + expect(isStationOnly("X@STATION.INVALID")).toBe(true); + expect(isStationOnly("luis@example.test")).toBe(false); + expect(isStationOnly("luis@station.invalid.example.test")).toBe(false); + expect(isStationOnly("luis@notstation.invalid")).toBe(false); + }); +}); + +describe("publicEmail", () => { + it("hides a placeholder and passes a real address through", () => { + expect(publicEmail(`${crypto.randomUUID()}@station.invalid`)).toBeNull(); + expect(publicEmail("luis@example.test")).toBe("luis@example.test"); + }); +}); + +describe("createStationUser", () => { + it("creates an active user with a PIN who gets a tile and unlocks with that PIN", async () => { + const admin = await createActor(db, ["ADMIN"]); + const created = await createStationUser(db, admin, stationUserInput({ name: " Luis " })); + + expect(created).toMatchObject({ + name: "Luis", + email: null, + stationOnly: true, + roles: ["PICKER"], + active: true, + hasPin: true, + hasBadge: false, + }); + const row = await db.user.findUniqueOrThrow({ where: { id: created.id } }); + expect(isStationOnly(row.email)).toBe(true); + expect(row.emailVerified).toBe(false); + + const { token } = await registerStation(db, admin, { name: "Pick 1" }); + const station = await findStationByToken(db, token); + if (!station) throw new Error("expected the station"); + const status = await stationStatus(db, station); + expect(status.tiles).toContainEqual({ id: created.id, name: "Luis", initials: "L" }); + await expect(unlockWithPin(db, station, { userId: created.id, pin: PIN })).resolves.toEqual({ + userName: "Luis", + }); + }); + + it("writes one user.create audit with stationOnly and roles, never the PIN or its hash", async () => { + const admin = await createActor(db, ["ADMIN"]); + const created = await createStationUser(db, admin, stationUserInput()); + const row = await db.user.findUniqueOrThrow({ where: { id: created.id } }); + + const audit = await db.auditEvent.findMany({ where: { entityId: created.id } }); + expect(audit).toHaveLength(1); + expect(audit[0]).toMatchObject({ + action: "user.create", + entityType: "User", + actorId: admin.userId, + after: { stationOnly: true, roles: ["PICKER"] }, + }); + const json = JSON.stringify(audit); + expect(json).not.toContain(PIN); + expect(json).not.toContain(row.pinHash ?? "no hash"); + expect(json).not.toContain(row.email); + }); + + it("creates one user for the same idempotency key twice", async () => { + const admin = await createActor(db, ["ADMIN"]); + const input = stationUserInput(); + const first = await createStationUser(db, admin, input); + const second = await createStationUser(db, admin, input); + expect(second.id).toBe(first.id); + expect(second.lastActiveAt).toBeNull(); + expect(await db.user.count({ where: { name: "Luis" } })).toBe(1); + expect(await db.auditEvent.count({ where: { action: "user.create" } })).toBe(1); + }); + + it("refuses the Admin role", async () => { + const admin = await createActor(db, ["ADMIN"]); + await expect( + createStationUser(db, admin, stationUserInput({ roles: ["PICKER", "ADMIN"] })), + ).rejects.toMatchObject({ kind: "BAD_REQUEST", messageKey: "errors.users.stationOnlyAdmin" }); + expect(await db.user.count({ where: { name: "Luis" } })).toBe(0); + }); + + it("validates the name, roles and PIN", async () => { + const admin = await createActor(db, ["ADMIN"]); + for (const overrides of [ + { name: " " }, + { name: "x".repeat(81) }, + { roles: [] as Role[] }, + { pin: "12345" }, + { pin: "123456" }, + ]) { + await expect(createStationUser(db, admin, stationUserInput(overrides))).rejects.toMatchObject( + { + kind: "BAD_REQUEST", + }, + ); + } + await expect( + createStationUser(db, admin, stationUserInput({ name: "x".repeat(80) })), + ).resolves.toMatchObject({ stationOnly: true }); + }); + + it("allows the same name as another user (the dialog only warns)", async () => { + const admin = await createActor(db, ["ADMIN"]); + await createStationUser(db, admin, stationUserInput()); + await createStationUser(db, admin, stationUserInput({ name: "luis" })); + expect(await db.user.count({ where: { name: { equals: "luis", mode: "insensitive" } } })).toBe( + 2, + ); + }); +}); + +describe("station-only users in the other users procedures", () => { + it("updateUserRoles refuses adding Admin to a station-only user, but allows other roles", async () => { + const admin = await createActor(db, ["ADMIN"]); + const { id } = await createStationUser(db, admin, stationUserInput()); + await expect( + updateUserRoles(db, admin, { id, roles: ["PICKER", "ADMIN"] }), + ).rejects.toMatchObject({ kind: "BAD_REQUEST", messageKey: "errors.users.stationOnlyAdmin" }); + await expect(updateUserRoles(db, admin, { id, roles: ["PACKER"] })).resolves.toMatchObject({ + roles: ["PACKER"], + email: null, + stationOnly: true, + }); + }); + + it("listUsers returns email null and never matches the placeholder in search", async () => { + const admin = await createActor(db, ["ADMIN"]); + const created = await createStationUser(db, admin, stationUserInput()); + const row = await db.user.findUniqueOrThrow({ where: { id: created.id } }); + + const all = await listUsers(db, admin); + expect(all.users.find((u) => u.id === created.id)).toMatchObject({ + email: null, + stationOnly: true, + }); + expect(all.users.find((u) => u.id === admin.userId)).toMatchObject({ stationOnly: false }); + expect(JSON.stringify(all)).not.toContain("station.invalid"); + + for (const search of ["station.invalid", "invalid", row.email.slice(0, 8), "@"]) { + const found = await listUsers(db, admin, { search }); + expect(found.users.map((u) => u.id)).not.toContain(created.id); + } + expect((await listUsers(db, admin, { search: "lui" })).users.map((u) => u.id)).toEqual([ + created.id, + ]); }); }); diff --git a/packages/core/src/identity/users.ts b/packages/core/src/identity/users.ts index 22414bd8..c2c7bf34 100644 --- a/packages/core/src/identity/users.ts +++ b/packages/core/src/identity/users.ts @@ -1,4 +1,6 @@ import { + type AddUserEmailInput, + type CreateStationUserInput, Pin, type UpdateUserRolesInput, USER_LIST_PAGE_MAX, @@ -6,11 +8,21 @@ import { type UserListPage, type UserSummary, } from "@woolrus/contracts"; -import type { Db, DbTx, Prisma, Role } from "@woolrus/db"; +import { type Db, type DbTx, Prisma, type Role } from "@woolrus/db"; import { recordAudit } from "../audit"; +import { enqueue } from "../enqueue"; import { AppError, notFound } from "../errors"; +import { withIdempotency } from "../idempotency"; +import { queues } from "../jobs"; import { type Actor, assertCan } from "../permissions"; import { hashBadgeCode, hashPin, newBadgeCode } from "./credentials"; +import { openInvitation } from "./sign-in"; +import { + isStationOnly, + publicEmail, + stationOnlyEmailFilter, + stationOnlyPlaceholder, +} from "./station-only"; const summarySelect = { id: true, @@ -35,7 +47,8 @@ function toUserSummary(user: UserRow): UserSummary { return { id: user.id, name: user.name, - email: user.email, + email: publicEmail(user.email), + stationOnly: isStationOnly(user.email), roles: user.roles, active: user.active, hasPin: user.pinHash !== null, @@ -87,7 +100,10 @@ export async function listUsers( ? { OR: [ { name: { contains: search, mode: "insensitive" } }, - { email: { contains: search, mode: "insensitive" } }, + { + email: { contains: search, mode: "insensitive" }, + NOT: { email: stationOnlyEmailFilter }, + }, ], } : {}; @@ -104,6 +120,141 @@ export async function listUsers( return { users: users.map(toUserSummary), total, page, pageSize }; } +const stationOnlyAdmin = () => new AppError("BAD_REQUEST", "errors.users.stationOnlyAdmin"); + +/** Validates as the contract does, so core doesn't rely on the route having parsed the input. */ +function parsePin(pin: string): void { + const parsed = Pin.safeParse(pin); + if (!parsed.success) { + const key = + parsed.error.issues[0]?.message === "errors.users.pinWeak" ? "pinWeak" : "pinFormat"; + throw new AppError("BAD_REQUEST", "errors.validation", { pin: `errors.users.${key}` }); + } +} + +/** + * A worker with no email (SPEC-identity amendment 1): a name, roles and a PIN, unlocking stations + * only. Never Admin (decision 10): a 6-digit PIN would then be the only credential for full admin + * powers. The PIN is required (decision 11) and hashed before the transaction, as in `setPin`. + * Names needn't be unique (decision 13); the dialog warns about duplicates. + */ +export async function createStationUser( + db: Db, + actor: Actor, + { idempotencyKey, ...input }: CreateStationUserInput, +): Promise { + assertCan(actor, "user.manage"); + const name = input.name.trim(); + if (name.length < 1 || name.length > 80) { + throw new AppError("BAD_REQUEST", "errors.validation", { + name: name.length ? "errors.field.tooBig" : "errors.field.required", + }); + } + const roles = [...new Set(input.roles)]; + if (roles.length === 0) throw new AppError("BAD_REQUEST", "errors.users.rolesRequired"); + if (roles.includes("ADMIN")) throw stationOnlyAdmin(); + parsePin(input.pin); + const pinHash = await hashPin(input.pin); + // Only the id is stored for replays: the stored JSON would turn the dates into strings. + const { id } = await db.$transaction((tx) => + withIdempotency(tx, idempotencyKey, "users.createStationUser", actor, async () => { + const user = await tx.user.create({ + data: { + name, + email: stationOnlyPlaceholder(), + emailVerified: false, + active: true, + roles, + pinHash, + }, + select: { id: true }, + }); + await recordAudit(tx, actor, { + entityType: "User", + entityId: user.id, + action: "user.create", + after: { stationOnly: true, roles }, + }); + return { id: user.id }; + }), + ); + return toUserSummary(await db.user.findUniqueOrThrow({ where: { id }, select: summarySelect })); +} + +/** + * Gives a station-only user a real email (SPEC-identity amendment 1, decision 14), so they become a + * regular user with the same id, roles, PIN, badge and history. `emailVerified` is set true (decision + * 15): Better Auth 1.7.8 fails the first magic link of an unverified existing user, and a + * station-only user has no accounts or sessions for that check to revoke. One address never leads to two accounts, so an address + * another user has, or one with an open invitation, is refused. The `accountEnabled` email is + * queued in the same transaction, so it exists only if the change commits. English, like + * invitations (decision 8). + */ +export async function addUserEmail( + db: Db, + actor: Actor, + { idempotencyKey, id, ...input }: AddUserEmailInput, + { appUrl }: { appUrl: string }, +): Promise { + assertCan(actor, "user.manage"); + // The contract already lowercases; core doesn't rely on it. + const email = input.email.trim().toLowerCase(); + if (isStationOnly(email)) throw new AppError("BAD_REQUEST", "errors.invitations.reservedEmail"); + try { + await db.$transaction((tx) => + withIdempotency(tx, idempotencyKey, "users.addEmail", actor, async () => { + await tx.$queryRaw`SELECT id FROM users WHERE id = ${id}::uuid FOR NO KEY UPDATE`; + const user = await tx.user.findUnique({ + where: { id }, + select: { email: true, active: true }, + }); + if (!user) throw notFound(); + if (!isStationOnly(user.email)) { + throw new AppError("CONFLICT", "errors.users.notStationOnly"); + } + // Their sign-in email would lead nowhere: a deactivated user can't sign in, and + // reactivating doesn't resend it. Reactivate first. + if (!user.active) throw new AppError("CONFLICT", "errors.users.addEmailInactive"); + if (await tx.user.findFirst({ where: { email: { equals: email, mode: "insensitive" } } })) { + throw new AppError("CONFLICT", "errors.users.alreadyExists"); + } + if (await openInvitation(tx, email)) { + throw new AppError("CONFLICT", "errors.users.invitationPending"); + } + await tx.user.update({ where: { id }, data: { email, emailVerified: true } }); + const admin = actor.userId + ? (await tx.user.findUnique({ where: { id: actor.userId }, select: { name: true } })) + ?.name + : undefined; + await enqueue(tx, queues.emailSend, { + to: email, + template: "accountEnabled", + params: { + admin: admin ?? "Woolrus", + url: `${appUrl}/sign-in?email=${encodeURIComponent(email)}`, + }, + locale: "en", + idempotencyKey: crypto.randomUUID(), + }); + await recordAudit(tx, actor, { + entityType: "User", + entityId: id, + action: "user.email.add", + after: { email }, + }); + return { id }; + }), + ); + } catch (error) { + // Someone took the address between the check and the update (a sign-up or another admin). + if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") { + throw new AppError("CONFLICT", "errors.users.alreadyExists"); + } + throw error; + } + return toUserSummary(await db.user.findUniqueOrThrow({ where: { id }, select: summarySelect })); +} + export async function updateUserRoles( db: Db, actor: Actor, @@ -114,6 +265,7 @@ export async function updateUserRoles( if (roles.length === 0) throw new AppError("BAD_REQUEST", "errors.users.rolesRequired"); return db.$transaction(async (tx) => { const user = await lockForUserChange(tx, id); + if (roles.includes("ADMIN") && isStationOnly(user.email)) throw stationOnlyAdmin(); const losesAdmin = user.roles.includes("ADMIN") && !roles.includes("ADMIN"); if (losesAdmin && id === actor.userId) throw new AppError("CONFLICT", "errors.users.self"); if (losesAdmin && user.active) await assertNotLastAdmin(tx, id); @@ -182,12 +334,7 @@ export async function setPin( { id, pin }: { id: string; pin: string }, ): Promise { assertCan(actor, "user.manage"); - const parsed = Pin.safeParse(pin); - if (!parsed.success) { - const key = - parsed.error.issues[0]?.message === "errors.users.pinWeak" ? "pinWeak" : "pinFormat"; - throw new AppError("BAD_REQUEST", "errors.validation", { pin: `errors.users.${key}` }); - } + parsePin(pin); const pinHash = await hashPin(pin); return db.$transaction(async (tx) => { await lockForUserChange(tx, id); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index f01ce904..00fb01e3 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -41,6 +41,8 @@ export * from "./identity/emails"; export * from "./identity/invitations"; export * from "./identity/preferences"; export * from "./identity/sign-in"; +// Not stationOnlyEmailFilter: only core searches by email. +export { isStationOnly, publicEmail, stationOnlyPlaceholder } from "./identity/station-only"; export * from "./identity/station-session"; export * from "./identity/stations"; export * from "./identity/users"; diff --git a/packages/core/src/workflow/people.test.ts b/packages/core/src/workflow/people.test.ts new file mode 100644 index 00000000..3c4aac6c --- /dev/null +++ b/packages/core/src/workflow/people.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "vitest"; +import { displayName } from "./people"; + +describe("displayName", () => { + it("is the trimmed name when there is one", () => { + expect(displayName({ name: " Luis ", email: "luis@example.test" })).toBe("Luis"); + }); + + it("falls back to a real email, never to a station-only placeholder", () => { + expect(displayName({ name: " ", email: "luis@example.test" })).toBe("luis@example.test"); + expect(displayName({ name: "", email: `${crypto.randomUUID()}@station.invalid` })).toBe(""); + }); +}); diff --git a/packages/core/src/workflow/people.ts b/packages/core/src/workflow/people.ts index 86213dca..802b3c53 100644 --- a/packages/core/src/workflow/people.ts +++ b/packages/core/src/workflow/people.ts @@ -1,4 +1,5 @@ import type { DbTx } from "@woolrus/db"; +import { publicEmail } from "../identity/station-only"; /** The user fields `displayName` needs, for a Prisma `select`. */ export const nameFields = { name: true, email: true } as const; @@ -8,7 +9,8 @@ export const nameFields = { name: true, email: true } as const; * creates users with an empty name). */ export function displayName(user: { name: string; email: string }): string { - return user.name.trim() || user.email; + // A station-only placeholder never shows (SPEC-identity amendment 1); their name is required anyway. + return user.name.trim() || publicEmail(user.email) || ""; } /** The claim holder's name for a `claimedByOther` conflict. */ diff --git a/packages/i18n/src/messages/en.json b/packages/i18n/src/messages/en.json index ad21e955..39b5bcbc 100644 --- a/packages/i18n/src/messages/en.json +++ b/packages/i18n/src/messages/en.json @@ -1356,13 +1356,18 @@ "self": "You can't deactivate yourself or remove your own Admin role.", "rolesRequired": "Choose at least one role.", "alreadyExists": "Someone with this email already has an account.", + "addEmailInactive": "This user is deactivated. Reactivate them first, then add their email.", + "notStationOnly": "This user already has an email.", + "invitationPending": "This email has an open invitation. Revoke it first, so the address doesn't end up with two accounts.", "pinFormat": "A PIN is exactly 6 digits.", - "pinWeak": "Too easy to guess: avoid repeated digits and runs like 123456." + "pinWeak": "Too easy to guess: avoid repeated digits and runs like 123456.", + "stationOnlyAdmin": "Station-only users can't be Admins: an Admin must also be able to sign in personally." }, "invitations": { "emailInvalid": "Enter a valid email address.", "pending": "This email already has a pending invitation. Resend or revoke it instead.", - "notOpen": "This invitation was already accepted or revoked." + "notOpen": "This invitation was already accepted or revoked.", + "reservedEmail": "This address is reserved for station users. Use the person's real email." }, "auth": { "deactivated": "Your account is deactivated. Ask an admin to reactivate it.", @@ -1466,6 +1471,12 @@ "subject": "Woolrus test email", "body": "This is a test email from Woolrus. If you're reading it, email is set up correctly." }, + "accountEnabled": { + "subject": "{admin} set up sign-in for you", + "body": "{admin} set up Woolrus sign-in for you with this address. Your roles, PIN and badge stay the same.", + "action": "Sign in to Woolrus", + "how": "The first time, sign in with an email link sent to this address." + }, "invitation": { "subject": "{inviter} invited you to Woolrus", "body": "{inviter} invited you to Woolrus, our warehouse workspace, as {roles}.", @@ -1495,6 +1506,12 @@ "actionsFor": "Actions for {name}", "editRoles": "Edit roles", "editRolesTitle": "Roles for {name}", + "addEmail": "Add email", + "addEmailTitle": "Add an email for {name}", + "addEmailHelp": "{name} becomes a regular user who can also sign in personally. Their roles, PIN, badge and history stay the same. We'll email them a sign-in link.", + "confirmEmail": "Confirm email", + "emailMismatch": "The two addresses don't match.", + "emailAdded": "{name} can now sign in with {email}.", "deactivate": "Deactivate", "reactivate": "Reactivate", "deactivateTitle": "Deactivate {name}?", @@ -1512,8 +1529,12 @@ }, "setPin": "Set PIN", "clearPin": "Clear PIN", + "clearPinTitle": "Clear {name}'s PIN?", + "clearPinNoWayIn": "{name} has no badge, so without a PIN they can't unlock any station. Set a new PIN or issue a badge afterwards to let them back in.", "issueBadge": "Issue badge", "revokeBadge": "Revoke badge", + "revokeBadgeTitle": "Revoke {name}'s badge?", + "revokeBadgeNoWayIn": "{name} has no PIN, so without a badge they can't unlock any station. Set a PIN or issue a new badge afterwards to let them back in.", "pinTitle": "PIN for {name}", "newPin": "New PIN", "pinHelp": "6 digits. Avoid repeated digits and runs like 123456.", @@ -1532,7 +1553,14 @@ "pagination": "Pages", "previousPage": "Previous page", "nextPage": "Next page", - "pageOf": "Page {page} of {pages}" + "pageOf": "Page {page} of {pages}", + "addStationUser": "Add station user", + "addStationUserTitle": "Add station user", + "stationOnly": "Station only", + "stationOnlyHelp": "For someone with no email: they unlock stations with a PIN or badge, and can't sign in personally or be an Admin.", + "stationUserAdded": "{name} can now unlock any station with their PIN.", + "duplicateNameWarning": "An active user is already called {name}. Add this one only if it's a different person.", + "add": "Add" }, "invitations": { "tab": "Invitations", diff --git a/packages/i18n/src/messages/es.json b/packages/i18n/src/messages/es.json index 521b803a..be0a6f13 100644 --- a/packages/i18n/src/messages/es.json +++ b/packages/i18n/src/messages/es.json @@ -1356,13 +1356,18 @@ "self": "No puedes desactivarte ni quitarte tu propio rol de Admin.", "rolesRequired": "Elige al menos un rol.", "alreadyExists": "Ya existe una cuenta con este correo.", + "addEmailInactive": "Este usuario está desactivado. Reactívalo primero y luego añade su correo.", + "notStationOnly": "Este usuario ya tiene un correo.", + "invitationPending": "Este correo tiene una invitación abierta. Revócala primero para que la dirección no termine con dos cuentas.", "pinFormat": "El PIN tiene exactamente 6 dígitos.", - "pinWeak": "Demasiado fácil de adivinar: evita dígitos repetidos y secuencias como 123456." + "pinWeak": "Demasiado fácil de adivinar: evita dígitos repetidos y secuencias como 123456.", + "stationOnlyAdmin": "Los usuarios solo de estación no pueden ser administradores: un administrador también debe poder iniciar sesión personalmente." }, "invitations": { "emailInvalid": "Introduce un correo electrónico válido.", "pending": "Este correo ya tiene una invitación pendiente. Reenvíala o revócala.", - "notOpen": "Esta invitación ya fue aceptada o revocada." + "notOpen": "Esta invitación ya fue aceptada o revocada.", + "reservedEmail": "Esta dirección está reservada para usuarios de estación. Usa el correo real de la persona." }, "auth": { "deactivated": "Tu cuenta está desactivada. Pide a un administrador que la reactive.", @@ -1466,6 +1471,12 @@ "subject": "Correo de prueba de Woolrus", "body": "Este es un correo de prueba de Woolrus. Si lo estás leyendo, el correo está bien configurado." }, + "accountEnabled": { + "subject": "{admin} configuró tu inicio de sesión", + "body": "{admin} configuró tu inicio de sesión en Woolrus con esta dirección. Tus roles, tu PIN y tu credencial no cambian.", + "action": "Iniciar sesión en Woolrus", + "how": "La primera vez, inicia sesión con un enlace enviado por correo a esta dirección." + }, "invitation": { "subject": "{inviter} te invitó a Woolrus", "body": "{inviter} te invitó a Woolrus, nuestro espacio de trabajo del almacén, como {roles}.", @@ -1495,6 +1506,12 @@ "actionsFor": "Acciones para {name}", "editRoles": "Editar roles", "editRolesTitle": "Roles de {name}", + "addEmail": "Añadir correo", + "addEmailTitle": "Añadir un correo para {name}", + "addEmailHelp": "{name} pasa a ser un usuario normal que también puede iniciar sesión personalmente. Sus roles, PIN, credencial e historial no cambian. Le enviaremos un enlace para iniciar sesión.", + "confirmEmail": "Confirmar correo", + "emailMismatch": "Las dos direcciones no coinciden.", + "emailAdded": "{name} ya puede iniciar sesión con {email}.", "deactivate": "Desactivar", "reactivate": "Reactivar", "deactivateTitle": "¿Desactivar a {name}?", @@ -1512,8 +1529,12 @@ }, "setPin": "Establecer PIN", "clearPin": "Borrar PIN", + "clearPinTitle": "¿Borrar el PIN de {name}?", + "clearPinNoWayIn": "{name} no tiene credencial, así que sin PIN no podrá desbloquear ninguna estación. Después, asigna un PIN nuevo o emite una credencial para que pueda volver a entrar.", "issueBadge": "Emitir credencial", "revokeBadge": "Revocar credencial", + "revokeBadgeTitle": "¿Revocar la credencial de {name}?", + "revokeBadgeNoWayIn": "{name} no tiene PIN, así que sin credencial no podrá desbloquear ninguna estación. Después, asigna un PIN o emite una credencial nueva para que pueda volver a entrar.", "pinTitle": "PIN de {name}", "newPin": "Nuevo PIN", "pinHelp": "6 dígitos. Evita dígitos repetidos y secuencias como 123456.", @@ -1532,7 +1553,14 @@ "pagination": "Páginas", "previousPage": "Página anterior", "nextPage": "Página siguiente", - "pageOf": "Página {page} de {pages}" + "pageOf": "Página {page} de {pages}", + "addStationUser": "Añadir usuario de estación", + "addStationUserTitle": "Añadir usuario de estación", + "stationOnly": "Solo estación", + "stationOnlyHelp": "Para alguien sin correo: desbloquea estaciones con un PIN o una credencial, y no puede iniciar sesión personalmente ni ser administrador.", + "stationUserAdded": "{name} ya puede desbloquear cualquier estación con su PIN.", + "duplicateNameWarning": "Ya hay un usuario activo llamado {name}. Añade este solo si es otra persona.", + "add": "Añadir" }, "invitations": { "tab": "Invitaciones",