Redesign user account page with separated password flow - #1444
Conversation
Rename "Preferences" to "Account" in sidebar dropup. Restructure the user page with a profile card (icon, username, role), reordered personal info fields, a dedicated Change Password dialog, phone number min-digit validation, and autofill prevention attributes. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
gdevenyi
left a comment
There was a problem hiding this comment.
Review — account page redesign
Separating the password change from the profile form is the right call, and it removes a genuinely awkward bit of the old code (the password && password === confirmPassword reconciliation inside onSubmit). The password schema is now unconditionally strict instead of if (ctx.value.password)-guarded, which is a real improvement. Field reordering and the "Preferences" → "Account" rename both make sense.
I checked the two things most likely to be silently broken and they're fine: libui's Form declares [key: \data-${string}`]: unknownand spreads...propsonto the
, so data-form-type/data-lpignoredo reach the DOM; and Tailwind 4.3's dynamic spacing scale makesh-18 w-18` valid.
Should fix
-
Unhandled rejection when a password change fails.
void mutateAsync({...}).then(() => setIsPasswordDialogOpen(false))has no.catch.mutateAsyncrejects on error, so a rejected password (breached, too weak server-side) produces an unhandled promise rejection. The axios interceptor still shows a toast, so it's not silent — but the dialog stays open with no local indication and the error escapes the promise chain. -
The digit minimum only applies on this page.
MIN_PHONE_DIGITSis enforced inuser.tsxbut not inadmin/users/create.tsx, which still validates with barePHONE_REGEX. An admin can create a user with a 5-digit phone number that the user then cannot save from their own account page without changing it. Same rule, two places — it should live with the regex. -
Hardcoded
bg-sky-700on the Change Password button alongsidevariant="primary". This instance supports admin-configured branding (customPrimaryColor, eight login themes), and this button opts out of all of it. (#1439 does the same on itsSelect.Triggers — worth fixing the pattern in both.) -
No tests for anything this PR actually changes. AGENTS.md requires unit tests and e2e tests in
testing/for new changes. The two new test files covercountPhoneDigitsandPHONE_REGEX; nothing covers the behaviour the PR is about — that the profile form no longer submits a password, that the dialog opens and closes on success, or that a sub-7-digit number is rejected through the schema. The test plan's six manual checkboxes are all unchecked.
Smaller
permissionLabels: { [key: string]: string }over a closed enum — see inline.py-[0.633rem]is a magic value;w-60is an arbitrary fixed width that will sit oddly with "Changer le mot de passe" vs "Change Password".it('should be 7')asserts a constant equals its literal — it can only fail when someone deliberately changes the constant, at which point they'll just update the test.- The user's name no longer appears anywhere except as form inputs — the old header showed
fullName, username and role. Deliberate? An "Account" page that never says who you are reads a little odd, especially for shared workstations. <Card>containing only a<Card.Header>, and a<div className="flex items-center gap-4">wrapping a single button — both are structure with nothing to do.- The profile
onSubmitstill filters out empty strings, so a user can't clear an email or phone number once set — pre-existing, but this PR is the natural place to fix it now that the password no longer needs the same filter.
Coordination
This is the only one of the five open PRs (#1439, #1441, #1442, #1443, #1444) that merges cleanly against all the others — no shared files. Nice.
| data: { password: data.password }, | ||
| id: currentUser!.id | ||
| }) | ||
| .then(() => setIsPasswordDialogOpen(false)); |
There was a problem hiding this comment.
mutateAsync rejects on failure and there's no .catch, so a rejected password change (server-side strength check, breached-password rejection, 5xx) becomes an unhandled promise rejection. The dialog also stays open with no local error state — the only feedback is the global axios error toast behind it.
mutate with callbacks avoids both:
onSubmit={(data) => {
updateSelfUserMutation.mutate(
{ data: { password: data.password }, id: currentUser!.id },
{ onSuccess: () => setIsPasswordDialogOpen(false) }
);
}}The profile form above has the same shape (void updateSelfUserMutation.mutateAsync(...) with no catch) — pre-existing, but worth fixing in the same pass since both are touched here.
| @@ -1 +1,7 @@ | |||
| export const PHONE_REGEX = new RegExp(/^(?=.{5,})\+?\(?\d{1,4}\)?[\s.-]?\d{1,4}[\s.-]?\d{1,9}$/); | |||
|
|
|||
| export const MIN_PHONE_DIGITS = 7; | |||
There was a problem hiding this comment.
The new minimum is enforced only in user.tsx. apps/web/src/routes/_app/admin/users/create.tsx still validates with z.union([z.literal(''), z.string().regex(PHONE_REGEX)]) and no digit check, so an admin can create a user with a 5-digit phone number that the user then can't save from their own account page — the field is pre-populated with a value their own form rejects.
Since PHONE_REGEX and MIN_PHONE_DIGITS are two halves of one rule, exporting the composed schema from here would make it impossible to apply one without the other:
export const $PhoneNumber = z.union([
z.literal(''),
z.string().regex(PHONE_REGEX).refine((v) => countPhoneDigits(v) >= MIN_PHONE_DIGITS)
]);(The message needs t(), so it'd take the translator as an argument — same shape the useMemo'd schema uses today.)
Worth noting the two rules overlap: PHONE_REGEX already carries a (?=.{5,}) lookahead. Two independent minimums that can disagree is exactly the "two artifacts that must agree" case AGENTS.md warns about — folding the digit count in and dropping the lookahead would leave one rule.
| en: 'Standard User', | ||
| fr: 'Utilisateur standard' | ||
| }) | ||
| const permissionLabels: { [key: string]: string } = { |
There was a problem hiding this comment.
The key set here is closed — it's $BasePermissionLevel ('ADMIN' | 'GROUP_MANAGER' | 'STANDARD'). AGENTS.md: "No loose records where a closed key set is known."
const permissionLabels: Record<BasePermissionLevel, string> = { ... };
const permissionLevel = userInfo.data.basePermissionLevel
? permissionLabels[userInfo.data.basePermissionLevel]
: undefined;That makes the typeof … === 'string' guard unnecessary (it isn't really checking anything — it can't distinguish a valid level from any other string) and turns a future fourth permission level into a compile error instead of a silently blank line in the profile card.
| </div> | ||
| <div className="flex items-center gap-4"> | ||
| <Button | ||
| className="flex w-60 items-center justify-center gap-2 bg-sky-700 text-white hover:bg-sky-800" |
There was a problem hiding this comment.
variant="primary" already resolves the themed primary colour, and bg-sky-700 … hover:bg-sky-800 overrides it with a fixed one. Instances can configure customPrimaryColor and pick among eight login themes ($BrandingConfig in schemas/setup), so on a branded deployment this is the one button that doesn't match anything around it. Dropping the colour classes and keeping the variant is enough.
w-60 (15rem) is also arbitrary for a button whose label is "Change Password" in English and "Changer le mot de passe" in French — the two want quite different widths, and a fixed one serves neither. w-fit with padding would.
(#1439 hardcodes the same bg-sky-700 family on its Select.Triggers — if you're touching this, worth aligning both.)
| </p> | ||
| </div> | ||
| <Card className="mx-auto mt-4 max-w-3xl"> | ||
| <Card.Header className="flex-row items-center justify-between py-[0.633rem]"> |
There was a problem hiding this comment.
py-[0.633rem] reads as a value tuned until one screenshot lined up. Nothing else in the app uses it, and the next person to change the icon size or title line-height won't know what it was compensating for. A scale value (py-2.5 = 0.625rem, within 0.13px of this) would behave identically and stay meaningful.
Structurally: this <Card> has only a Card.Header and no Card.Content, and the <div className="flex items-center gap-4"> on line 132 wraps a single <Button>. Both can go.
| }); | ||
|
|
||
| describe('MIN_PHONE_DIGITS', () => { | ||
| it('should be 7', () => { |
There was a problem hiding this comment.
This asserts a constant equals its own literal, so it can only fail when someone deliberately edits the constant — and then they'll edit this line too. It doesn't pin behaviour; the countPhoneDigits cases above already do that.
What's actually missing is a test of the rule: that a phone number with 6 digits is rejected and one with 7 is accepted, through whatever schema enforces it. That's the behaviour a user experiences, and right now nothing covers it.
Same for the rest of the PR — there's no test that the profile form no longer submits a password, or that the dialog closes only on a successful change. AGENTS.md also asks for an e2e spec in testing/ for new changes; testing/src/pages/_app/ has the page-object pattern ready for a user.page.ts.
joshunrau
left a comment
There was a problem hiding this comment.
Thanks — separating the password change into its own dialog is the right call, and the page reads much better for it. A few things need fixing before this lands.
The main one: the new "must contain at least 7 digits" message never actually reaches the user. Because the phone field is a z.union([z.literal(''), ...]), Zod discards the inner issue and reports a single invalid_union error, so the field just says "Invalid input" — in English, even in French. I reproduced this against the repo's Zod with "514-555", "12345" and "abcdefgh"; all three yield { code: 'invalid_union', path: ['phoneNumber'], message: 'Invalid input' }. Rewriting it as a single z.string() with a .check() that allows the empty string will let both the format error and the digit-count error carry their own translated message. (apps/web/src/routes/_app/user.tsx:58-77)
Related: the 7-digit rule only applies here. admin/users/create.tsx:233 and admin/users/index.tsx:61 still use PHONE_REGEX on its own, so an admin can save a phone number that the user is then blocked from re-saving on their own account. Worth exporting one shared $PhoneNumber schema from utils/validation.ts and using it in all three places.
On tests — the repo asks for a unit test and an e2e test per change. /user has no e2e page object or spec at all today, so the rename and the new dialog aren't covered anywhere. Could you add one in testing/ covering the dropup label, opening the dialog, rejecting a weak password, and a successful change closing the dialog? And in validation.test.ts:45-48, the MIN_PHONE_DIGITS should be 7 case just restates the constant — testing the schema's behaviour there would have caught the union problem.
Smaller items:
user.tsx:133— the Change Password button setsbg-sky-700 text-white hover:bg-sky-800on top ofvariant="primary", which overrides libui's theme colours via twMerge and makes it the only primary button in the app with its own palette.flex items-center justify-centeris already in the button's base classes, and the fixedw-60is tight for the French label.user.tsx:119—py-[0.633rem]is an unexplained magic number.UserDropup.tsx:67—data-testid="user-dropup-preferences"still says "preferences" after the rename. Nothing references it yet, so it's a free fix before the new e2e test depends on it. The gear icon on an "Account" entry is a bit off too.user.tsx:174,179—common.emailandcommon.phoneNumberalready exist intranslations/common.json; use the keys. "Change Password" (139, 215) and "Save" (200, 234) are each written inline twice, so they'd be better as keys as well.user.tsx:237-242— the password dialog'smutateAsync(...).then(...)has no.catch(), so a failed request leaves an unhandled rejection. The axios interceptor does surface a notification, so the user isn't left in the dark, but it should be handled where the dialog state lives.user.tsx:40-49—Record<BasePermissionLevel, string>instead of{ [key: string]: string }would remove the need for thetypeof === 'string'guard. Inherited rather than introduced, but you're already in the file.
|
Suggested model: Opus 5 Sizing the remaining work on this PR for whoever picks it up, human or agent. Redesigning the phone schema and deciding where the shared |
…tact details
A phone or email field declared as `z.union([z.literal(''), ...])` loses the
issues raised inside the union member: zod reports a single `invalid_union`, so
the field read "Invalid input", in English, instead of saying what was wrong.
Both are now one `z.string()` with a check that permits the blank string, built
from a shared `blankOr` helper in `utils/validation.ts`.
`PHONE_REGEX` is no longer exported, so the format rule and the digit minimum
cannot be applied apart from each other. The account page enforced both while
the two admin forms enforced only the format, so an admin could save a number
the user was then blocked from re-saving. Folding the digit count in also let
the regex's overlapping `(?=.{5,})` lookahead go, leaving one rule.
`$UpdateUserData` makes `email` and `phoneNumber` nullish so an update can clear
them, and both forms send null for a field left blank. Previously the account
form filtered empty values out of the payload, so an email once set could never
be removed, and the admin sheet sent `''`, which the API rejected.
Also from review: `mutate` with `onSuccess` in place of an uncaught
`mutateAsync().then()`, the themed primary button instead of a hardcoded
`bg-sky-700`, translation keys instead of strings written inline twice, a mapped
type over the closed permission-level union, and `user-dropup-account` for the
renamed menu entry.
Adds unit tests for both schemas and for the nullable update contract, and an
e2e page object and spec for /user covering the dropup label, the password
dialog, and clearing contact details.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…icateAs The suite runs fullyParallel against one shared database, so testing/AGENTS.md requires seeded data to be uniquely named. Both new specs seeded a fixed contact@example.org. Also records that authenticateAs now takes credentials as well as a role, so the fixtures table no longer disagrees with the fixture. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
This is a big improvement on the first round — the union fix in particular is done at the right level, by deleting the construct that caused the problem rather than patching the one field that showed it. I ran your new e2e specs against real servers and all thirteen pass, and lint and the unit tests are clean. Good catch on the admin sheet sending Five things to tidy up, none of them blocking:
If you hand this to Claude Code, Opus 5 is the right size — the items span Reviewed at commit 3bd731e. |
# Conflicts: # testing/AGENTS.md # testing/src/support/fixtures.ts
… page Addresses the five follow-up items from review on #1444: - Give the password dialog a `Dialog.Description`, so Radix stops warning and the dialog carries an `aria-describedby`. - Key "Account"/"Compte" as `user.account` now that it is used in two places. - Move the phone format and 7-digit minimum into `packages/schemas`, where both tiers read it. `apps/api` had its own regex with no digit rule, so a direct API call could store a number the account page then refused to save. The web helpers now map the returned error code to a translated message rather than restating the rule. - Redeclare `email`/`phoneNumber` on `UpdateUserDto`, which typed them as `string | undefined` while `$UpdateUserData` parses them to `string | null | undefined`. - Use the shared `$Email` on `admin/users/create.tsx`, so typing an address and then clearing it no longer fails with zod's untranslated message, and send blank contact details as absent rather than `''`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
joshunrau
left a comment
There was a problem hiding this comment.
This round lands it. The phone rule now has exactly one definition and both tiers read it, the create form no longer blocks on zod's untranslated message, and the dialog description is in place. I ran your two spec files against real api/gateway/web servers and all sixteen pass; lint and the unit tests are clean too.
Three small things left:
common.saveduplicates thecore.savekey that already exists with the identical English and French — including inadmin/users/index.tsx, which this PR edits and which still usescore.savefor the same button. Two keys for one string will drift the first time somebody adjusts the French; usecore.saveand drop the new one.- In
apps/api/src/users/dto/update-user.dto.ts, the two redeclared fields spell outnull | stringby hand. That is the same kind of hand-maintained agreement that caused the bug you just fixed — deriving them from the schema type (email?: UpdateUserData['email']) means the class cannot fall behind$UpdateUserDataagain. $User.phoneNumberdeliberately still parses numbers stored before the 7-digit minimum, but$UpdateUserDatavalidates with$PhoneNumber, and both the account form and the admin edit sheet resubmit the stored value on every save. So a user whose record holds a short number can't change their first name, and an admin can't edit that user at all, until the phone field is fixed. If that is what you want, fine — otherwise the write schema needs the same tolerance for a value that has not been touched.
If you hand this to Claude Code, Opus 5 is the right size — the items span apps/web, apps/api and packages/schemas, and the last one is a judgment call about existing data rather than a mechanical edit.
Reviewed at commit 5a7b88e.
Addresses the three items from review. `core.save` already carried the identical English and French, including in `admin/users/index.tsx`, so the `common.save` added here was a second key for one string waiting to drift. Dropped, and both call sites use `core.save`. `UpdateUserDto` derives its two redeclared fields from `UpdateUserData` rather than spelling out `null | string`, so the class cannot fall behind `$UpdateUserData` the way the phone rule fell behind itself. `$User.phoneNumber` still parses a number stored before the digit minimum, but `$UpdateUserData` validates with `$PhoneNumber` and both forms resubmitted the stored value on every save — so a user holding a short number could not change their own first name, and an admin could not edit that user at all. A PATCH should carry only what changed: `omittedIfUnchanged` drops an untouched value from the payload, and `$PhoneNumber` takes the stored value so client-side validation lets it through too. An edited number is still held to the rule, and the write schema is unchanged, so the API still rejects a short number. No e2e covers the legacy case: `$CreateUserData` now enforces the minimum, so such a record cannot be seeded through the API. The unit tests cover it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Thanks — all three are addressed in
The stored short number — fixed rather than accepted. Being unable to change your own first name because of a number you never touched is a regression for existing data, and an admin being locked out of that user entirely is worse. The fix treats the PATCH as carrying only what changed: Deliberately not a loosening of the write schema: an edited number is still held to the rule, and the API still rejects a short one. One gap worth naming — no e2e can cover this, because
Ready for another look when you have a moment. |
Reconciles the SMTP-only refactor with the two mail fixes pushed to the branch and with everything main gained since (#1441, #1444, #1477). The branch had patched the HTTP transport rather than removed it, so the conflicts were resolved toward the maintainer's decision 1: the HTTP client and SigV4 signer go, which makes both patches unnecessary rather than wrong. `isMailEnabled` no longer needs an HTTP branch, and the provider names come back out of `eslint.config.js` since no JSX mentions them any more. Kept from the other side: - #1441's `getDefaultAssignmentExpiry` replaces the hardcoded one-year default in the create-assignment form. - #1444's `$Email`/`$PhoneNumber`/`omittedIfBlank` validation helpers in the user-create form, replacing the old `PHONE_REGEX`. - The deduplicated welcome-email notification in `useCreateUserMutation`, now also opted out of the router error boundary so the call site's own password-error handling is the only path. - #1477's page objects and specs, unioned with the two added here. `SaveStatus.tsx` is restored from main: #1442 has landed and `admin/settings` now consumes it, so decision 4's "pick it back up once #1442 lands" applies. `activeLanguages` and `utils/languages.ts` are still absent from main, so those stay dropped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Summary
data-form-type="other"anddata-lpignore="true"to prevent browser autofillTest plan
pnpm lintandpnpm test— both pass (46 test files, 338 tests)🤖 Generated with Claude Code