Skip to content

Generate passphrase and reset password page for user who has a generated password - #1512

Draft
david-roper wants to merge 11 commits into
DouglasNeuroInformatics:mainfrom
david-roper:generate-password-change
Draft

Generate passphrase and reset password page for user who has a generated password#1512
david-roper wants to merge 11 commits into
DouglasNeuroInformatics:mainfrom
david-roper:generate-password-change

Conversation

@david-roper

@david-roper david-roper commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator

feat: generated passwords and a forced reset at first sign-in

Adds a passphrase generator to the admin user forms, and makes a user whose password was generated
for them choose their own before they can use the app.

Motivation

An administrator creating an account has to invent a password and relay it to its owner out of band.
That password is known to two people, and nothing previously required it to ever change. This adds a
one-click generator so the admin never invents one, and a forced reset so an admin-issued password
does not survive the user's first sign-in.

What changed

Generating a password

The password field on Add User and on the edit user sheet now renders libui's inline
generatePassword control (the sparkle icon beside the reveal toggle). Clicking it fills the field
with a five-word passphrase and reveals it, so the admin can read it back to its owner.

  • Generation runs in the browser: crypto.getRandomValues with rejection sampling over a word list
    committed at apps/web/src/utils/word-list.ts.
  • The list is the EFF short wordlist 2.0 (CC BY 3.0 US, attributed in the file header). Every
    word has a unique three-character prefix, so a misheard or mistyped word is recoverable — which
    matters when a passphrase is dictated over the phone.
  • yo-yo is the one published word omitted: it contains the - separator, which would make the
    word boundaries of a passphrase ambiguous. That leaves 1295 words, so five of them carry
    51.7 bits of entropy.
  • The field's control only writes its own value, so confirmPassword is mirrored through the form's
    subscribe prop rather than being retyped.

Tracking it

mustResetPassword is set when the password being submitted is still the one that was generated.
The value is compared rather than a bare "was the button clicked" flag, so an admin who generates a
passphrase and then types over it has chosen the password themselves and no reset is imposed.

One rule, applied identically on create and edit:

Action mustResetPassword
Generated and submitted as-is true
Typed by the admin false
Password field left blank (edit only) unchanged

The forced reset

  • New route /auth/reset-password, a sibling of /auth/login and outside _app.
  • _app/route.tsx's existing beforeLoad redirects there when the flag is set. Because _app is
    the sole parent of every in-app route, that single redirect covers all of them — no per-route
    exemption logic and no nav bar to suppress.
  • The page has no "current password" field (the user authenticated seconds earlier) and offers a
    sign-out, so nobody is trapped.
  • On success the user is signed out and signs in again with the new password. That is deliberate:
    the flag lives on the JWT, so re-authenticating is what guarantees no stale token can leave a user
    locked out.
  • updateSelfById clears the flag on any password write, so the existing change-password dialog
    on the account page clears it too.

Server-side enforcement

The redirect alone would be cosmetic — the user holds a valid token and could call the API directly.
AbilityFactory.createForPayload now returns early for a flagged token, granting one rule:
read User conditioned on the holder's own id, which is what updateSelfById is gated on.

There is no allowlist of exempt routes to keep up to date, so a route added later is refused without
anyone remembering to refuse it. Verified end-to-end:

GET /v1/subjects, /sessions, /groups, /assignments  ->  403
GET /v1/users                                       ->  200, containing only the caller

The four routes gated on read User still pass the @RouteAccess guard, because it checks the
subject type and a conditional rule satisfies that. They are confined instead by accessibleQuery
applying the rule's condition. This is the guard/row-scope split already documented in
apps/api/AGENTS.md, and the e2e asserts both halves.

Closing the obvious hole

Nothing previously stopped a user "changing" their password to the one they were just issued, which
would satisfy the reset while leaving the password known to whoever issued it. updateSelfById now
compares against the stored hash and rejects a match with a new PASSWORD_MATCHES_CURRENT code,
using the same PASSWORD_ERROR_CODES mechanism as the three existing password errors — so the web
client localizes it with no new plumbing.

Dependency bump — please note

Package From To
@douglasneuroinformatics/libui ^6.11.2 ^6.16.0
@douglasneuroinformatics/libui-form-types ^1.1.0 ^1.4.0

Both are required: the generatePassword field option does not exist before libui 6.15.0, and
6.16.0 declares a peer of libui-form-types ^1.4.0, so bumping only one leaves the field type
missing. Permitted despite the release being recent because minimumReleaseAgeExclude already lists
'@douglasneuroinformatics/*'.

libui is a catalog: dependency of seven workspaces — web, gateway, playground,
outreach, react-core, serve-instrument and storybook — so all of them pick up five minor
versions. Lint and the unit suite pass across every one, but only web and gateway were exercised
in a browser; a reviewer should sanity-check playground, outreach and storybook visually.

Database

mustResetPassword Boolean? on User. Optional rather than defaulted on purpose: Prisma applies
defaults on write for MongoDB, so a required column would fail to read back every user created
before the field existed. Absent/null is falsy and means "not forced".

No migration (MongoDB — prisma db push only). Existing users, the initial-setup admin, and the
demo seed users are all unflagged.

Testing

Suite Result
pnpm lint 33/33 tasks
pnpm test 741 passed, 1 skipped
pnpm test:e2e 155/155 passed (Chromium, plus Firefox @smoke)

New coverage:

  • Unit — passphrase shape, word list invariants (no separator inside a word, unique three-char
    prefixes, full range reached), the generated-vs-typed distinction, the narrowed ability's allow
    and deny cases, flag persistence on create, and the same-password rejection.
  • E2E (testing/src/specs/password-reset.spec.ts, 6 tests) — the generator filling both fields
    without clobbering other input; a flagged user being sent to the reset page and held there; the
    API refusing clinical data for that token; the issued password being rejected; and regaining the
    app after choosing a new one.

Reviewer notes

  • apps/web/src/utils/word-list.ts is 1295 generated words. It is mechanical — the header records
    its provenance, licence, and the one omission.
  • apps/api/AGENTS.md and apps/web/AGENTS.md gained notes for two traps hit while building this:
    a single-segment route being shadowed by @Get(':id') once perfectionist/sort-classes reorders
    handlers, and libui's subscribe.onChange leaving TData uninstantiated in its setValues
    parameter.

Out of scope

Passphrases are English only. There is no admin control to force a reset without changing a
password — generating one is the only way to flag a user. No pending-reset column in the users list,
no audit-log entry for the reset, and no password expiry. Mail is untouched: no password is ever put
in the welcome email.

#1509 still need to discuss whether having a words for passphrase stored in utils is safe or to use an external library instead

Closes issue #1311 #1511

assisted with opus 5.0

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant