Redesign user account page with separated password flow#1444
Redesign user account page with separated password flow#1444thomasbeaudry wants to merge 1 commit into
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.
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