Generate passphrase and reset password page for user who has a generated password - #1512
Draft
david-roper wants to merge 11 commits into
Draft
Generate passphrase and reset password page for user who has a generated password#1512david-roper wants to merge 11 commits into
david-roper wants to merge 11 commits into
Conversation
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
generatePasswordcontrol (the sparkle icon beside the reveal toggle). Clicking it fills the fieldwith a five-word passphrase and reveals it, so the admin can read it back to its owner.
crypto.getRandomValueswith rejection sampling over a word listcommitted at
apps/web/src/utils/word-list.ts.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-yois the one published word omitted: it contains the-separator, which would make theword boundaries of a passphrase ambiguous. That leaves 1295 words, so five of them carry
51.7 bits of entropy.
confirmPasswordis mirrored through the form'ssubscribeprop rather than being retyped.Tracking it
mustResetPasswordis 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:
mustResetPasswordtruefalseThe forced reset
/auth/reset-password, a sibling of/auth/loginand outside_app._app/route.tsx's existingbeforeLoadredirects there when the flag is set. Because_appisthe 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.
sign-out, so nobody is trapped.
the flag lives on the JWT, so re-authenticating is what guarantees no stale token can leave a user
locked out.
updateSelfByIdclears the flag on any password write, so the existing change-password dialogon 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.createForPayloadnow returns early for a flagged token, granting one rule:read Userconditioned on the holder's own id, which is whatupdateSelfByIdis 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:
The four routes gated on
read Userstill pass the@RouteAccessguard, because it checks thesubject type and a conditional rule satisfies that. They are confined instead by
accessibleQueryapplying 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.
updateSelfByIdnowcompares against the stored hash and rejects a match with a new
PASSWORD_MATCHES_CURRENTcode,using the same
PASSWORD_ERROR_CODESmechanism as the three existing password errors — so the webclient localizes it with no new plumbing.
Dependency bump — please note
@douglasneuroinformatics/libui^6.11.2^6.16.0@douglasneuroinformatics/libui-form-types^1.1.0^1.4.0Both are required: the
generatePasswordfield option does not exist before libui 6.15.0, and6.16.0 declares a peer of
libui-form-types ^1.4.0, so bumping only one leaves the field typemissing. Permitted despite the release being recent because
minimumReleaseAgeExcludealready lists'@douglasneuroinformatics/*'.libuiis acatalog:dependency of seven workspaces —web,gateway,playground,outreach,react-core,serve-instrumentandstorybook— so all of them pick up five minorversions. Lint and the unit suite pass across every one, but only
webandgatewaywere exercisedin a browser; a reviewer should sanity-check playground, outreach and storybook visually.
Database
mustResetPassword Boolean?onUser. Optional rather than defaulted on purpose: Prisma appliesdefaults 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 pushonly). Existing users, the initial-setup admin, and thedemo seed users are all unflagged.
Testing
pnpm lintpnpm testpnpm test:e2e@smoke)New coverage:
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.
testing/src/specs/password-reset.spec.ts, 6 tests) — the generator filling both fieldswithout 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.tsis 1295 generated words. It is mechanical — the header recordsits provenance, licence, and the one omission.
apps/api/AGENTS.mdandapps/web/AGENTS.mdgained notes for two traps hit while building this:a single-segment route being shadowed by
@Get(':id')onceperfectionist/sort-classesreordershandlers, and libui's
subscribe.onChangeleavingTDatauninstantiated in itssetValuesparameter.
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