From d4a4d352e03c3c82047f16ad23b490fe1e184694 Mon Sep 17 00:00:00 2001 From: Isaac Vargas Date: Tue, 10 Nov 2026 10:09:33 -0800 Subject: [PATCH 1/9] docs(identity): spec amendment 1, station-only users Admins can create users with a name, roles and PIN but no email. They can only unlock stations, can't be Admin, and become regular users once an admin adds an email. Tracked in #170. --- SPEC.md | 2 +- docs/specs/SPEC-identity.md | 127 ++++++++++++++++++++++++++++++++++-- 2 files changed, 123 insertions(+), 6 deletions(-) diff --git a/SPEC.md b/SPEC.md index 494570c2..8fc0153b 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) spec approved | | `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/docs/specs/SPEC-identity.md b/docs/specs/SPEC-identity.md index 050b7552..f1033d63 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 **spec approved** · **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,44 @@ 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`). 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, keep `emailVerified: false`, 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 magic link.** It goes through the existing "existing active user" path, and Better Auth + marks the email verified. Whether Google/Discord then sign the user in or are refused depends on Better + Auth's account-linking rules for an existing user. The ticket checks this and writes it down here. Refusing + is acceptable: 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. + - Invitation contracts reject the `station.invalid` domain (`errors.invitations.reservedEmail`). +- **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 +263,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 +281,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 +302,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 +357,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 +397,23 @@ 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. + ## Open questions None. From 0f16dd657276f1a8e91069b362900a950f55d43d Mon Sep 17 00:00:00 2001 From: Isaac Vargas Date: Tue, 10 Nov 2026 10:11:33 -0800 Subject: [PATCH 2/9] docs(identity): mark amendment 1 planned with tickets #171-#174 --- SPEC.md | 2 +- docs/specs/SPEC-identity.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/SPEC.md b/SPEC.md index 8fc0153b..436972ee 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); A1 [#170](https://github.com/isaacvarg/woolrus/issues/170) | done; A1 (station-only users) spec approved | +| `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) planned, tickets #171–#174 | | `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/docs/specs/SPEC-identity.md b/docs/specs/SPEC-identity.md index f1033d63..c73dd15c 100644 --- a/docs/specs/SPEC-identity.md +++ b/docs/specs/SPEC-identity.md @@ -2,7 +2,7 @@ **Milestone:** M2 · **Depends on:** `settings` · **Used by:** every module (the `Actor`), `picking`/`packing`/`qa` (station actors on the floor), `operations` (email health) · **State:** done; amendment 1 (station-only users, -2026-11-10) is **spec approved** · **Issue:** [#21](https://github.com/isaacvarg/woolrus/issues/21); amendment 1: +2026-11-10) is **planned** (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 From 6de0a91bdf812316aa64afcae501d681c0dc18d7 Mon Sep 17 00:00:00 2001 From: Isaac Vargas Date: Tue, 10 Nov 2026 10:31:58 -0800 Subject: [PATCH 3/9] feat(identity): admins add station-only users who unlock with a PIN Amendment 1: a worker with no email gets a name, roles (never Admin) and a PIN. They hold a reserved @station.invalid placeholder, known only to isStationOnly in core; every API returns email: null + stationOnly instead. - core: createStationUser (idempotent, audited user.create without the PIN), updateUserRoles refuses Admin for them, listUsers search skips placeholders - contracts: CreateStationUserInput, users.createStationUser (POST /users), UserSummary/Me email nullable + stationOnly - web: Add station user dialog (shared PinField with Generate, then Issue badge), "Station only" chip, Edit roles without Admin; strings en + es - decisions.md: placeholder email over a nullable column, no Admin - tablet E2E: add, register station, unlock with the PIN, pick attributed Refs #171 --- SPEC.md | 2 +- apps/web/e2e/station-users.spec.ts | 103 ++++++++++ apps/web/src/app/(app)/layout.tsx | 4 +- apps/web/src/app/(app)/nav-footer.tsx | 2 +- .../app/(app)/users/credentials-dialogs.tsx | 95 ++++++---- .../app/(app)/users/station-user-dialog.tsx | 125 +++++++++++++ .../web/src/app/(app)/users/users-manager.tsx | 49 +++-- apps/web/src/server/router.ts | 7 +- docs/decisions.md | 8 +- docs/specs/SPEC-identity.md | 2 +- packages/contracts/src/me.ts | 3 +- packages/contracts/src/users.test.ts | 30 ++- packages/contracts/src/users.ts | 30 ++- packages/core/src/identity/no-secrets.test.ts | 25 ++- packages/core/src/identity/station-only.ts | 26 +++ packages/core/src/identity/users.test.ts | 176 +++++++++++++++++- packages/core/src/identity/users.ts | 82 +++++++- packages/core/src/index.ts | 1 + packages/i18n/src/messages/en.json | 11 +- packages/i18n/src/messages/es.json | 11 +- 20 files changed, 714 insertions(+), 78 deletions(-) create mode 100644 apps/web/e2e/station-users.spec.ts create mode 100644 apps/web/src/app/(app)/users/station-user-dialog.tsx create mode 100644 packages/core/src/identity/station-only.ts diff --git a/SPEC.md b/SPEC.md index 436972ee..2c1c64f4 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); A1 [#170](https://github.com/isaacvarg/woolrus/issues/170) | done; A1 (station-only users) planned, tickets #171–#174 | +| `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) in progress, tickets #171–#174 | | `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-users.spec.ts b/apps/web/e2e/station-users.spec.ts new file mode 100644 index 00000000..82c3b986 --- /dev/null +++ b/apps/web/e2e/station-users.spec.ts @@ -0,0 +1,103 @@ +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 }); +}); diff --git a/apps/web/src/app/(app)/layout.tsx b/apps/web/src/app/(app)/layout.tsx index 723355dc..463a0a4b 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, isStationOnly } 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 && ( 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..5f166480 --- /dev/null +++ b/apps/web/src/app/(app)/users/station-user-dialog.tsx @@ -0,0 +1,125 @@ +"use client"; + +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { Role, type UserSummary } from "@woolrus/contracts"; +import { useTranslations } from "next-intl"; +import { 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"); + +/** + * 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; + + return ( +

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

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

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

{t("users.stationOnlyHelp")}

+ +
+ {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..63fd3942 100644 --- a/apps/web/src/app/(app)/users/users-manager.tsx +++ b/apps/web/src/app/(app)/users/users-manager.tsx @@ -12,8 +12,9 @@ import { toast } from "@/components/ui/toast"; import { useErrorMessage, useFieldErrors } from "@/lib/errors"; import { client, orpc } from "@/lib/orpc"; 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 +47,7 @@ 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 invalidate = useInvalidate(); const reactivate = useMutation({ mutationFn: (user: UserSummary) => client.users.reactivate({ id: user.id }), @@ -74,20 +76,23 @@ export function UsersManager({ currentUserId }: { currentUserId: string }) { return (
- - - - +
+ + + + + +
@@ -120,7 +125,11 @@ export function UsersManager({ currentUserId }: { currentUserId: string }) {
{user.name}
-
{user.email}
+ {user.stationOnly ? ( + {t("users.stationOnly")} + ) : ( +
{user.email}
+ )}
@@ -207,6 +216,12 @@ export function UsersManager({ currentUserId }: { currentUserId: string }) { )} {settingPin && setSettingPin(null)} />} {badge && setBadge(null)} />} + {addingStationUser && ( + setAddingStationUser(false)} + onIssueBadge={(user) => issueBadge.mutate(user)} + /> + )}
); } @@ -223,6 +238,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 ( ({ id: user.id, name: user.name, - email: user.email, + email: isStationOnly(user.email) ? null : user.email, image: user.image, roles: user.roles, permissions: permissionsFor(user.roles), @@ -530,6 +532,9 @@ 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), + ), updateRoles: api.users.updateRoles.handler(({ input, context }) => updateUserRoles(db, context.actor, input), ), diff --git a/docs/decisions.md b/docs/decisions.md index 5db135d7..745f7e53 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -72,7 +72,13 @@ 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. - **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 c73dd15c..b70882ae 100644 --- a/docs/specs/SPEC-identity.md +++ b/docs/specs/SPEC-identity.md @@ -2,7 +2,7 @@ **Milestone:** M2 · **Depends on:** `settings` · **Used by:** every module (the `Actor`), `picking`/`packing`/`qa` (station actors on the floor), `operations` (email health) · **State:** done; amendment 1 (station-only users, -2026-11-10) is **planned** (tickets #171–#174) · **Issue:** [#21](https://github.com/isaacvarg/woolrus/issues/21); amendment 1: +2026-11-10) is **in progress** (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 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..4669c5c0 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,19 @@ 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; + export const USER_LIST_PAGE_MAX = 100; export const UserListInput = z.object({ @@ -88,6 +104,16 @@ 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), + updateRoles: base .route({ method: "PUT", 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/station-only.ts b/packages/core/src/identity/station-only.ts new file mode 100644 index 00000000..0377c2bf --- /dev/null +++ b/packages/core/src/identity/station-only.ts @@ -0,0 +1,26 @@ +/** + * 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}`); +} + +/** 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..ecdb2d65 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 } 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,163 @@ 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("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..156beb4e 100644 --- a/packages/core/src/identity/users.ts +++ b/packages/core/src/identity/users.ts @@ -1,4 +1,5 @@ import { + type CreateStationUserInput, Pin, type UpdateUserRolesInput, USER_LIST_PAGE_MAX, @@ -9,8 +10,10 @@ import { import type { Db, DbTx, Prisma, Role } from "@woolrus/db"; import { recordAudit } from "../audit"; import { AppError, notFound } from "../errors"; +import { withIdempotency } from "../idempotency"; import { type Actor, assertCan } from "../permissions"; import { hashBadgeCode, hashPin, newBadgeCode } from "./credentials"; +import { isStationOnly, stationOnlyEmailFilter, stationOnlyPlaceholder } from "./station-only"; const summarySelect = { id: true, @@ -29,13 +32,16 @@ type UserRow = Prisma.UserGetPayload<{ select: typeof summarySelect }>; /** Only flags leave this function: never `pinHash` or `badgeCode` (SPEC-identity, no secrets out). */ function toUserSummary(user: UserRow): UserSummary { + const stationOnly = isStationOnly(user.email); const times = [user.sessions[0]?.updatedAt, user.stationActivations[0]?.startedAt].filter( (t): t is Date => t !== undefined, ); return { id: user.id, name: user.name, - email: user.email, + // The placeholder never leaves core (SPEC-identity amendment 1). + email: stationOnly ? null : user.email, + stationOnly, roles: user.roles, active: user.active, hasPin: user.pinHash !== null, @@ -87,7 +93,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 +113,67 @@ 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 })); +} + export async function updateUserRoles( db: Db, actor: Actor, @@ -114,6 +184,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 +253,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..42a55523 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -41,6 +41,7 @@ export * from "./identity/emails"; export * from "./identity/invitations"; export * from "./identity/preferences"; export * from "./identity/sign-in"; +export * from "./identity/station-only"; export * from "./identity/station-session"; export * from "./identity/stations"; export * from "./identity/users"; diff --git a/packages/i18n/src/messages/en.json b/packages/i18n/src/messages/en.json index ad21e955..ce3bacb2 100644 --- a/packages/i18n/src/messages/en.json +++ b/packages/i18n/src/messages/en.json @@ -1357,7 +1357,8 @@ "rolesRequired": "Choose at least one role.", "alreadyExists": "Someone with this email already has an account.", "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.", @@ -1532,7 +1533,13 @@ "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.", + "add": "Add" }, "invitations": { "tab": "Invitations", diff --git a/packages/i18n/src/messages/es.json b/packages/i18n/src/messages/es.json index 521b803a..25446aef 100644 --- a/packages/i18n/src/messages/es.json +++ b/packages/i18n/src/messages/es.json @@ -1357,7 +1357,8 @@ "rolesRequired": "Elige al menos un rol.", "alreadyExists": "Ya existe una cuenta con este correo.", "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.", @@ -1532,7 +1533,13 @@ "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.", + "add": "Añadir" }, "invitations": { "tab": "Invitaciones", From 00586313897706ae7bd094ebf1c11912c8aa8e30 Mon Sep 17 00:00:00 2001 From: Isaac Vargas Date: Tue, 10 Nov 2026 10:43:36 -0800 Subject: [PATCH 4/9] feat(identity): warn before adding a station user with a taken name The Add station user dialog looks the name up with users.list (debounced) and warns when an active user already has it, ignoring case and surrounding spaces. It never blocks: two people can share a name (SPEC-identity decision 13). Refs #173 --- apps/web/e2e/station-users.spec.ts | 48 +++++++++++++++++++ .../app/(app)/users/station-user-dialog.tsx | 37 ++++++++++++-- packages/i18n/src/messages/en.json | 1 + packages/i18n/src/messages/es.json | 1 + 4 files changed, 84 insertions(+), 3 deletions(-) diff --git a/apps/web/e2e/station-users.spec.ts b/apps/web/e2e/station-users.spec.ts index 82c3b986..0ce67760 100644 --- a/apps/web/e2e/station-users.spec.ts +++ b/apps/web/e2e/station-users.spec.ts @@ -101,3 +101,51 @@ test("tablet: an admin adds a station user who unlocks with the generated PIN an 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); +}); diff --git a/apps/web/src/app/(app)/users/station-user-dialog.tsx b/apps/web/src/app/(app)/users/station-user-dialog.tsx index 5f166480..d614187a 100644 --- a/apps/web/src/app/(app)/users/station-user-dialog.tsx +++ b/apps/web/src/app/(app)/users/station-user-dialog.tsx @@ -1,9 +1,9 @@ "use client"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { Role, type UserSummary } from "@woolrus/contracts"; +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 { useState } from "react"; +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"; @@ -14,6 +14,29 @@ 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. @@ -41,6 +64,7 @@ export function AddStationUserDialog({ }); const errors = fieldErrors(create.error); const created = create.data; + const namesake = useActiveNamesake(created ? "" : name); return ( +
+ {namesake && ( +

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

+ )} +
{t("users.roles")} {stationRoles.map((role) => ( diff --git a/packages/i18n/src/messages/en.json b/packages/i18n/src/messages/en.json index ce3bacb2..45f61ca1 100644 --- a/packages/i18n/src/messages/en.json +++ b/packages/i18n/src/messages/en.json @@ -1539,6 +1539,7 @@ "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": { diff --git a/packages/i18n/src/messages/es.json b/packages/i18n/src/messages/es.json index 25446aef..60fcbb16 100644 --- a/packages/i18n/src/messages/es.json +++ b/packages/i18n/src/messages/es.json @@ -1539,6 +1539,7 @@ "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": { From 426bc76394a4d4812bae4f56027403bc531222c3 Mon Sep 17 00:00:00 2001 From: Isaac Vargas Date: Tue, 10 Nov 2026 10:45:50 -0800 Subject: [PATCH 5/9] feat(identity): station-only users can't sign in personally sendMagicLink and mayCreateAccount refuse the station-only placeholder (even in ADMIN_EMAILS), session.create.before goes through the new core mayStartSession, and createInvitation rejects the reserved domain with errors.invitations.reservedEmail. Refs #172 --- apps/web/src/server/auth.ts | 25 +++--- .../core/src/identity/invitations.test.ts | 21 +++++ packages/core/src/identity/invitations.ts | 4 + packages/core/src/identity/magic-link.test.ts | 77 ++++++++++++++++++- packages/core/src/identity/sign-in.ts | 38 +++++++-- packages/i18n/src/messages/en.json | 3 +- packages/i18n/src/messages/es.json | 3 +- 7 files changed, 151 insertions(+), 20 deletions(-) diff --git a/apps/web/src/server/auth.ts b/apps/web/src/server/auth.ts index 3b8e762a..75be0c27 100644 --- a/apps/web/src/server/auth.ts +++ b/apps/web/src/server/auth.ts @@ -1,4 +1,10 @@ -import { acceptInvitation, getEmailSender, mayCreateAccount, sendMagicLink } from "@woolrus/core"; +import { + acceptInvitation, + getEmailSender, + mayCreateAccount, + mayStartSession, + sendMagicLink, +} from "@woolrus/core"; import { db } from "@woolrus/db"; import { defaultLocale, isLocale, type Locale } from "@woolrus/i18n"; import { createLogger } from "@woolrus/logger"; @@ -76,17 +82,14 @@ function createAuth() { databaseHooks: { session: { create: { - // A deactivated user can't start a session (e.g. a Google sign-in, or a link sent - // before deactivation). Better Auth then redirects with `failed_to_create_session` - // (magic link) or `unable_to_create_session` (OAuth), which the sign-in page shows as - // errors.auth.deactivated. + // A deactivated or station-only user can't start a session, whatever the method (the + // rule is core's `mayStartSession`). Better Auth then redirects with + // `failed_to_create_session` (magic link) or `unable_to_create_session` (OAuth), which + // the sign-in page shows as errors.auth.deactivated. before: async (session) => { - 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/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/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/i18n/src/messages/en.json b/packages/i18n/src/messages/en.json index ce3bacb2..6300966c 100644 --- a/packages/i18n/src/messages/en.json +++ b/packages/i18n/src/messages/en.json @@ -1363,7 +1363,8 @@ "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.", diff --git a/packages/i18n/src/messages/es.json b/packages/i18n/src/messages/es.json index 25446aef..a07085d5 100644 --- a/packages/i18n/src/messages/es.json +++ b/packages/i18n/src/messages/es.json @@ -1363,7 +1363,8 @@ "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.", From 9227e51cc8c9b00912086522374bc9cdaeda96f2 Mon Sep 17 00:00:00 2001 From: Isaac Vargas Date: Tue, 10 Nov 2026 11:20:59 -0800 Subject: [PATCH 6/9] feat(identity): admins add an email to turn a station user into a regular user addUserEmail (users.addEmail, PUT /users/{id}/email) replaces a station-only user's placeholder, audits user.email.add and queues the new accountEnabled email in the same transaction. The user keeps their id, roles, PIN and badge. Add email in the row menu asks for the address twice (en + es). The address is stored verified (spec decision 15): Better Auth 1.7.8's magic-link verify returns 500 for an existing unverified user, because its lock row in verifications gets a non-uuid id with generateId: false. The spec now records how Google and Discord link for this user. Refs #174 --- apps/web/e2e/station-user-email.spec.ts | 65 +++++ .../src/app/(app)/users/add-email-dialog.tsx | 96 +++++++ .../web/src/app/(app)/users/users-manager.tsx | 8 + apps/web/src/server/router.ts | 4 + docs/decisions.md | 5 + docs/specs/SPEC-identity.md | 27 +- packages/contracts/src/users.ts | 21 ++ packages/core/src/identity/add-email.test.ts | 251 ++++++++++++++++++ packages/core/src/identity/emails.test.ts | 11 + packages/core/src/identity/emails.ts | 6 + packages/core/src/identity/users.ts | 74 +++++- packages/i18n/src/messages/en.json | 14 + packages/i18n/src/messages/es.json | 14 + 13 files changed, 588 insertions(+), 8 deletions(-) create mode 100644 apps/web/e2e/station-user-email.spec.ts create mode 100644 apps/web/src/app/(app)/users/add-email-dialog.tsx create mode 100644 packages/core/src/identity/add-email.test.ts 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/src/app/(app)/users/add-email-dialog.tsx b/apps/web/src/app/(app)/users/add-email-dialog.tsx new file mode 100644 index 00000000..6c643502 --- /dev/null +++ b/apps/web/src/app/(app)/users/add-email-dialog.tsx @@ -0,0 +1,96 @@ +"use client"; + +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import type { UserSummary } from "@woolrus/contracts"; +import { useTranslations } from "next-intl"; +import { useState } from "react"; +import { Form } from "react-aria-components"; +import { Dialog } from "@/components/ui/dialog"; +import { Button, TextField } from "@/components/ui/fields"; +import { toast } from "@/components/ui/toast"; +import { useErrorMessage, useFieldErrors } from "@/lib/errors"; +import { client, orpc } from "@/lib/orpc"; + +/** + * Add email, for a station-only user (SPEC-identity amendment 1): they become a regular user with + * the same id, roles, PIN and badge. The address is asked for twice: a typo would send their + * sign-in email to someone else. + */ +export function AddEmailDialog({ user, onClose }: { user: UserSummary; onClose: () => 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/users-manager.tsx b/apps/web/src/app/(app)/users/users-manager.tsx index 63fd3942..4faa4509 100644 --- a/apps/web/src/app/(app)/users/users-manager.tsx +++ b/apps/web/src/app/(app)/users/users-manager.tsx @@ -11,6 +11,7 @@ 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"; @@ -48,6 +49,7 @@ export function UsersManager({ currentUserId }: { currentUserId: string }) { const [settingPin, setSettingPin] = useState(null); const [badge, setBadge] = useState(null); const [addingStationUser, setAddingStationUser] = useState(false); + const [addingEmail, setAddingEmail] = useState(null); const invalidate = useInvalidate(); const reactivate = useMutation({ mutationFn: (user: UserSummary) => client.users.reactivate({ id: user.id }), @@ -166,6 +168,11 @@ export function UsersManager({ currentUserId }: { currentUserId: string }) { {user.id !== currentUserId && ( setEditing(user)}>{t("users.editRoles")} + {user.stationOnly && ( + setAddingEmail(user)}> + {t("users.addEmail")} + + )} setSettingPin(user)}>{t("users.setPin")} {user.hasPin && ( clearPin.mutate(user)}> @@ -214,6 +221,7 @@ export function UsersManager({ currentUserId }: { currentUserId: string }) { {deactivating && ( setDeactivating(null)} /> )} + {addingEmail && setAddingEmail(null)} />} {settingPin && setSettingPin(null)} />} {badge && setBadge(null)} />} {addingStationUser && ( diff --git a/apps/web/src/server/router.ts b/apps/web/src/server/router.ts index cc3b2d1b..3558508c 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, @@ -535,6 +536,9 @@ export const router = implementer.router({ 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 745f7e53..8c9d624a 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -79,6 +79,11 @@ Full model in `packages/db/prisma/schema/*.prisma`. Changes from the SRS's secti 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 b70882ae..742adcf9 100644 --- a/docs/specs/SPEC-identity.md +++ b/docs/specs/SPEC-identity.md @@ -166,14 +166,20 @@ that would leave no active Admin. An admin can't deactivate themselves or remove `errors.users.notStationOnly`). 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, keep `emailVerified: false`, 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: + 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 magic link.** It goes through the existing "existing active user" path, and Better Auth - marks the email verified. Whether Google/Discord then sign the user in or are refused depends on Better - Auth's account-linking rules for an existing user. The ticket checks this and writes it down here. Refusing - is acceptable: the magic link always works. + - **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: @@ -413,6 +419,13 @@ Original scope: `packages/core/src/identity/**` (+ tests), `packages/core/src/ad 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 diff --git a/packages/contracts/src/users.ts b/packages/contracts/src/users.ts index 4669c5c0..326084ad 100644 --- a/packages/contracts/src/users.ts +++ b/packages/contracts/src/users.ts @@ -75,6 +75,17 @@ export const CreateStationUserInput = z.object({ }); 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({ @@ -114,6 +125,16 @@ export const users = { .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..76025149 --- /dev/null +++ b/packages/core/src/identity/add-email.test.ts @@ -0,0 +1,251 @@ +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 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/users.ts b/packages/core/src/identity/users.ts index 156beb4e..27aefa94 100644 --- a/packages/core/src/identity/users.ts +++ b/packages/core/src/identity/users.ts @@ -1,4 +1,5 @@ import { + type AddUserEmailInput, type CreateStationUserInput, Pin, type UpdateUserRolesInput, @@ -7,12 +8,15 @@ 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, stationOnlyEmailFilter, stationOnlyPlaceholder } from "./station-only"; const summarySelect = { @@ -174,6 +178,74 @@ export async function createStationUser( 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 } }); + if (!user) throw notFound(); + if (!isStationOnly(user.email)) { + throw new AppError("CONFLICT", "errors.users.notStationOnly"); + } + 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, diff --git a/packages/i18n/src/messages/en.json b/packages/i18n/src/messages/en.json index 6300966c..378f9928 100644 --- a/packages/i18n/src/messages/en.json +++ b/packages/i18n/src/messages/en.json @@ -1356,6 +1356,8 @@ "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.", + "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.", "stationOnlyAdmin": "Station-only users can't be Admins: an Admin must also be able to sign in personally." @@ -1468,6 +1470,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}.", @@ -1497,6 +1505,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}?", diff --git a/packages/i18n/src/messages/es.json b/packages/i18n/src/messages/es.json index a07085d5..5ce00919 100644 --- a/packages/i18n/src/messages/es.json +++ b/packages/i18n/src/messages/es.json @@ -1356,6 +1356,8 @@ "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.", + "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.", "stationOnlyAdmin": "Los usuarios solo de estación no pueden ser administradores: un administrador también debe poder iniciar sesión personalmente." @@ -1468,6 +1470,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}.", @@ -1497,6 +1505,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}?", From 7751eb6750293d827fc49bb5cc6a4a1b534ec248 Mon Sep 17 00:00:00 2001 From: Isaac Vargas Date: Tue, 10 Nov 2026 11:41:43 -0800 Subject: [PATCH 7/9] fix(identity): confirm before removing a station user's last way in Review of amendment 1 (#171-#174): - Clear PIN on a station-only user with no badge, and Revoke badge on one with no PIN, now ask first (SPEC-identity: "the PIN dialog warns about it but allows it"). One LastWayInDialog covers both; strings en + es. - publicEmail(email) in core's station-only.ts is the one rule that hides the placeholder; toUserSummary, me.get and the app layout use it. - core exports isStationOnly, publicEmail and stationOnlyPlaceholder only; stationOnlyEmailFilter stays internal. Refs #170 --- apps/web/e2e/station-users.spec.ts | 64 +++++++++++ apps/web/src/app/(app)/layout.tsx | 4 +- .../web/src/app/(app)/users/users-manager.tsx | 101 +++++++++++++++++- apps/web/src/server/router.ts | 4 +- packages/core/src/identity/station-only.ts | 5 + packages/core/src/identity/users.test.ts | 9 +- packages/core/src/identity/users.ts | 13 ++- packages/core/src/index.ts | 3 +- packages/i18n/src/messages/en.json | 4 + packages/i18n/src/messages/es.json | 4 + 10 files changed, 196 insertions(+), 15 deletions(-) diff --git a/apps/web/e2e/station-users.spec.ts b/apps/web/e2e/station-users.spec.ts index 0ce67760..fde438cf 100644 --- a/apps/web/e2e/station-users.spec.ts +++ b/apps/web/e2e/station-users.spec.ts @@ -149,3 +149,67 @@ test("desktop: adding a station user with a taken name warns but still adds them 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 463a0a4b..c9a8ac0c 100644 --- a/apps/web/src/app/(app)/layout.tsx +++ b/apps/web/src/app/(app)/layout.tsx @@ -1,4 +1,4 @@ -import { can, isStationOnly } 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 && ( (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 }), @@ -58,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, }); @@ -70,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, }); @@ -175,7 +185,13 @@ export function UsersManager({ currentUserId }: { currentUserId: string }) { )} setSettingPin(user)}>{t("users.setPin")} {user.hasPin && ( - clearPin.mutate(user)}> + + isLastWayIn(user, "pin") + ? setLastWayIn({ user, credential: "pin" }) + : clearPin.mutate(user) + } + > {t("users.clearPin")} )} @@ -183,7 +199,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")} )} @@ -222,6 +244,18 @@ export function UsersManager({ currentUserId }: { currentUserId: string }) { 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 && ( @@ -298,6 +332,65 @@ function RolesDialog({ user, onClose }: { user: UserSummary; onClose: () => 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/router.ts b/apps/web/src/server/router.ts index 3558508c..23814872 100644 --- a/apps/web/src/server/router.ts +++ b/apps/web/src/server/router.ts @@ -50,7 +50,6 @@ import { getQaScreen, getSettings, getSyncStatus, - isStationOnly, issueAssignees, issueBadge, issueCounts, @@ -97,6 +96,7 @@ import { permissionsFor, pickLine, previewLabRun, + publicEmail, pushErrors, qaRemoveLot, qaReplaceLot, @@ -214,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: isStationOnly(user.email) ? null : user.email, + email: publicEmail(user.email), image: user.image, roles: user.roles, permissions: permissionsFor(user.roles), diff --git a/packages/core/src/identity/station-only.ts b/packages/core/src/identity/station-only.ts index 0377c2bf..daf9f3d6 100644 --- a/packages/core/src/identity/station-only.ts +++ b/packages/core/src/identity/station-only.ts @@ -11,6 +11,11 @@ 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}`; diff --git a/packages/core/src/identity/users.test.ts b/packages/core/src/identity/users.test.ts index ecdb2d65..70b901f4 100644 --- a/packages/core/src/identity/users.test.ts +++ b/packages/core/src/identity/users.test.ts @@ -3,7 +3,7 @@ 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 { isStationOnly } from "./station-only"; +import { isStationOnly, publicEmail } from "./station-only"; import { stationStatus, unlockWithPin } from "./station-session"; import { findStationByToken, registerStation } from "./stations"; import { @@ -305,6 +305,13 @@ describe("isStationOnly", () => { }); }); +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"]); diff --git a/packages/core/src/identity/users.ts b/packages/core/src/identity/users.ts index 27aefa94..2928e725 100644 --- a/packages/core/src/identity/users.ts +++ b/packages/core/src/identity/users.ts @@ -17,7 +17,12 @@ import { queues } from "../jobs"; import { type Actor, assertCan } from "../permissions"; import { hashBadgeCode, hashPin, newBadgeCode } from "./credentials"; import { openInvitation } from "./sign-in"; -import { isStationOnly, stationOnlyEmailFilter, stationOnlyPlaceholder } from "./station-only"; +import { + isStationOnly, + publicEmail, + stationOnlyEmailFilter, + stationOnlyPlaceholder, +} from "./station-only"; const summarySelect = { id: true, @@ -36,16 +41,14 @@ type UserRow = Prisma.UserGetPayload<{ select: typeof summarySelect }>; /** Only flags leave this function: never `pinHash` or `badgeCode` (SPEC-identity, no secrets out). */ function toUserSummary(user: UserRow): UserSummary { - const stationOnly = isStationOnly(user.email); const times = [user.sessions[0]?.updatedAt, user.stationActivations[0]?.startedAt].filter( (t): t is Date => t !== undefined, ); return { id: user.id, name: user.name, - // The placeholder never leaves core (SPEC-identity amendment 1). - email: stationOnly ? null : user.email, - stationOnly, + email: publicEmail(user.email), + stationOnly: isStationOnly(user.email), roles: user.roles, active: user.active, hasPin: user.pinHash !== null, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 42a55523..00fb01e3 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -41,7 +41,8 @@ export * from "./identity/emails"; export * from "./identity/invitations"; export * from "./identity/preferences"; export * from "./identity/sign-in"; -export * from "./identity/station-only"; +// 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/i18n/src/messages/en.json b/packages/i18n/src/messages/en.json index b230c3ad..20c41a20 100644 --- a/packages/i18n/src/messages/en.json +++ b/packages/i18n/src/messages/en.json @@ -1528,8 +1528,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.", diff --git a/packages/i18n/src/messages/es.json b/packages/i18n/src/messages/es.json index b377a2f6..98aef4a1 100644 --- a/packages/i18n/src/messages/es.json +++ b/packages/i18n/src/messages/es.json @@ -1528,8 +1528,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.", From 8746513330dcd71d1e4f9734c3c7b257a37dfbbd Mon Sep 17 00:00:00 2001 From: Isaac Vargas Date: Tue, 10 Nov 2026 12:04:06 -0800 Subject: [PATCH 8/9] fix(identity): ship review of station-only users From the /ship review of amendment 1 (code, security and test reviewers): - addUserEmail refuses a deactivated station-only user (CONFLICT, errors.users.addEmailInactive): their sign-in email would lead nowhere, and reactivating doesn't resend it. The row menu hides Add email for them. - resolveActor also refuses a personal session for a station-only user, so one that got past the sign-in hook some other way still can't act. - displayName falls back through publicEmail, so a placeholder can never show on workflow screens. - SPEC-identity: the reserved-domain check is in core createInvitation, not the contract; addUserEmail's deactivated rule. Refs #170 --- apps/web/src/app/(app)/users/users-manager.tsx | 3 ++- apps/web/src/server/actor.ts | 6 ++++-- docs/specs/SPEC-identity.md | 6 ++++-- packages/core/src/identity/add-email.test.ts | 12 ++++++++++++ packages/core/src/identity/users.ts | 8 +++++++- packages/core/src/workflow/people.test.ts | 13 +++++++++++++ packages/core/src/workflow/people.ts | 4 +++- packages/i18n/src/messages/en.json | 1 + packages/i18n/src/messages/es.json | 1 + 9 files changed, 47 insertions(+), 7 deletions(-) create mode 100644 packages/core/src/workflow/people.test.ts diff --git a/apps/web/src/app/(app)/users/users-manager.tsx b/apps/web/src/app/(app)/users/users-manager.tsx index 5257a132..e1dd871e 100644 --- a/apps/web/src/app/(app)/users/users-manager.tsx +++ b/apps/web/src/app/(app)/users/users-manager.tsx @@ -178,7 +178,8 @@ export function UsersManager({ currentUserId }: { currentUserId: string }) { {user.id !== currentUserId && ( setEditing(user)}>{t("users.editRoles")} - {user.stationOnly && ( + {/* Core refuses a deactivated user: their sign-in email would lead nowhere. */} + {user.stationOnly && user.active && ( setAddingEmail(user)}> {t("users.addEmail")} 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 { ).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); diff --git a/packages/core/src/identity/users.ts b/packages/core/src/identity/users.ts index 2928e725..c2c7bf34 100644 --- a/packages/core/src/identity/users.ts +++ b/packages/core/src/identity/users.ts @@ -204,11 +204,17 @@ export async function addUserEmail( 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 } }); + 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"); } 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 20c41a20..39b5bcbc 100644 --- a/packages/i18n/src/messages/en.json +++ b/packages/i18n/src/messages/en.json @@ -1356,6 +1356,7 @@ "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.", diff --git a/packages/i18n/src/messages/es.json b/packages/i18n/src/messages/es.json index 98aef4a1..be0a6f13 100644 --- a/packages/i18n/src/messages/es.json +++ b/packages/i18n/src/messages/es.json @@ -1356,6 +1356,7 @@ "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.", From 94c16a10b7a3f904562a5cd765fa8a82f658d2a5 Mon Sep 17 00:00:00 2001 From: Isaac Vargas Date: Tue, 10 Nov 2026 12:04:12 -0800 Subject: [PATCH 9/9] docs(identity): mark amendment 1 done Refs #170 --- SPEC.md | 2 +- docs/specs/SPEC-identity.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/SPEC.md b/SPEC.md index 2c1c64f4..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); A1 [#170](https://github.com/isaacvarg/woolrus/issues/170) | done; A1 (station-only users) in progress, tickets #171–#174 | +| `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/docs/specs/SPEC-identity.md b/docs/specs/SPEC-identity.md index f3339af0..c2de270e 100644 --- a/docs/specs/SPEC-identity.md +++ b/docs/specs/SPEC-identity.md @@ -2,7 +2,7 @@ **Milestone:** M2 · **Depends on:** `settings` · **Used by:** every module (the `Actor`), `picking`/`packing`/`qa` (station actors on the floor), `operations` (email health) · **State:** done; amendment 1 (station-only users, -2026-11-10) is **in progress** (tickets #171–#174) · **Issue:** [#21](https://github.com/isaacvarg/woolrus/issues/21); amendment 1: +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