Skip to content

Redesign user account page with separated password flow - #1444

Merged
joshunrau merged 8 commits into
mainfrom
updatePrefencesPage
Jul 28, 2026
Merged

Redesign user account page with separated password flow#1444
joshunrau merged 8 commits into
mainfrom
updatePrefencesPage

Conversation

@thomasbeaudry

Copy link
Copy Markdown
Collaborator

Summary

  • Rename "Preferences" to "Account" in the sidebar user dropup menu
  • Redesign the user page with a profile card showing the user icon, username, role, and a Change Password button
  • Separate password change into its own dialog with strength validation and confirmation
  • Reorder personal info fields: first name, last name, date of birth, sex, email, phone number
  • Remove the "Login Credentials" section; single "Personal Information" group
  • Add phone number validation requiring at least 7 digits
  • Add data-form-type="other" and data-lpignore="true" to prevent browser autofill

Test plan

  • Verify the sidebar dropup shows "Account" (en) / "Compte" (fr) instead of "Preferences"
  • Verify the profile card displays the user icon, username, and role
  • Verify the Change Password button opens a dialog with password + confirm fields and strength indicator
  • Verify submitting the profile form saves all fields except password
  • Verify phone numbers with fewer than 7 digits are rejected
  • Verify autofill does not populate the form with browser-saved data
  • Run pnpm lint and pnpm test — both pass (46 test files, 338 tests)

🤖 Generated with Claude Code

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>
@thomasbeaudry
thomasbeaudry requested a review from joshunrau as a code owner July 24, 2026 05:26

@gdevenyi gdevenyi left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  1. Unhandled rejection when a password change fails. void mutateAsync({...}).then(() => setIsPasswordDialogOpen(false)) has no .catch. mutateAsync rejects 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.

  2. The digit minimum only applies on this page. MIN_PHONE_DIGITS is enforced in user.tsx but not in admin/users/create.tsx, which still validates with bare PHONE_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.

  3. Hardcoded bg-sky-700 on the Change Password button alongside variant="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 its Select.Triggers — worth fixing the pattern in both.)

  4. 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 cover countPhoneDigits and PHONE_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

  1. permissionLabels: { [key: string]: string } over a closed enum — see inline.
  2. py-[0.633rem] is a magic value; w-60 is an arbitrary fixed width that will sit oddly with "Changer le mot de passe" vs "Change Password".
  3. 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.
  4. 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.
  5. <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.
  6. The profile onSubmit still 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.

Comment thread apps/web/src/routes/_app/user.tsx Outdated
data: { password: data.password },
id: currentUser!.id
})
.then(() => setIsPasswordDialogOpen(false));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread apps/web/src/utils/validation.ts Outdated
@@ -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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread apps/web/src/routes/_app/user.tsx Outdated
en: 'Standard User',
fr: 'Utilisateur standard'
})
const permissionLabels: { [key: string]: string } = {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread apps/web/src/routes/_app/user.tsx Outdated
</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"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.)

Comment thread apps/web/src/routes/_app/user.tsx Outdated
</p>
</div>
<Card className="mx-auto mt-4 max-w-3xl">
<Card.Header className="flex-row items-center justify-between py-[0.633rem]">

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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', () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 joshunrau left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 sets bg-sky-700 text-white hover:bg-sky-800 on top of variant="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-center is already in the button's base classes, and the fixed w-60 is tight for the French label.
  • user.tsx:119py-[0.633rem] is an unexplained magic number.
  • UserDropup.tsx:67data-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,179common.email and common.phoneNumber already exist in translations/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's mutateAsync(...).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-49Record<BasePermissionLevel, string> instead of { [key: string]: string } would remove the need for the typeof === 'string' guard. Inherited rather than introduced, but you're already in the file.

@joshunrau

Copy link
Copy Markdown
Collaborator

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 $PhoneNumber belongs — apps/web/src/utils/validation.ts or packages/schemas — is repo judgment, and the work reaches three route files plus a new e2e page object and spec in testing/.

thomasbeaudry and others added 3 commits July 28, 2026 01:51
…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>
@joshunrau

Copy link
Copy Markdown
Collaborator

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 ''; that was blocking saves for any user without an email.

Five things to tidy up, none of them blocking:

  • The password dialog's Dialog.Content has no Dialog.Description. Every other dialog in apps/web supplies one — FullscreenPreviewDialog.tsx uses an sr-only one where there's nothing meaningful to say. Without it Radix logs a warning and the dialog has no aria-describedby.
  • "Account"/"Compte" is now written inline in both user.tsx and UserDropup.tsx. Since it's used twice it should be a key, and translations/user.json is already there.
  • In apps/api/src/users/dto/update-user.dto.ts, the class still types email as string | undefined while its @ValidationSchema($UpdateUserData) now parses it to string | null | undefined. Prisma accepts null so nothing breaks today, but the type and the schema disagree, and the first data.email?.trim() written against that type would crash at runtime.
  • admin/users/create.tsx got the shared phone schema but its email is still the raw z.email() from $CreateUserData, so typing an address and then clearing it fails with zod's untranslated English message and blocks submit — the same action works fine on the other two forms. It also sends phoneNumber: '' instead of going through clearedIfBlank.
  • apps/api/src/users/dto/create-user.dto.ts still has its own phone regex with no minimum-digit rule, so the unified rule only holds in the browser; a direct API call can still create a user with a 3-digit number. That predates this PR, but it's the same rule you were consolidating, and packages/schemas looks like the right home for it.

If you hand this to Claude Code, Opus 5 is the right size — the items span apps/web, apps/api and packages/schemas, and two of them are design calls rather than mechanical edits.

Reviewed at commit 3bd731e.

thomasbeaudry and others added 2 commits July 28, 2026 14:04
# 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 joshunrau left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.save duplicates the core.save key that already exists with the identical English and French — including in admin/users/index.tsx, which this PR edits and which still uses core.save for the same button. Two keys for one string will drift the first time somebody adjusts the French; use core.save and drop the new one.
  • In apps/api/src/users/dto/update-user.dto.ts, the two redeclared fields spell out null | string by 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 $UpdateUserData again.
  • $User.phoneNumber deliberately still parses numbers stored before the 7-digit minimum, but $UpdateUserData validates 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.

thomasbeaudry and others added 2 commits July 28, 2026 19:26
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>
@thomasbeaudry

Copy link
Copy Markdown
Collaborator Author

Thanks — all three are addressed in f14b6247, on top of a merge with current main.

common.save — dropped. It was a second key for a string core.save already had; both call sites in user.tsx now use core.save, matching admin/users/index.tsx.

update-user.dto.ts — the two fields are now UpdateUserData['email'] and UpdateUserData['phoneNumber'], so the class can't fall behind $UpdateUserData.

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: omittedIfUnchanged keeps an untouched value out of the payload, and $PhoneNumber now takes the stored value so client-side validation lets it through too. Both halves are needed — otherwise the form refuses to submit, or the API rejects the value it just handed back.

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 $CreateUserData now enforces the minimum, so a legacy record can't be seeded through the API. The unit tests in apps/web/src/utils/__tests__/validation.test.ts cover it instead.

pnpm lint and pnpm test are clean (54 files, 407 passed) and CI is green on all five checks. I did not re-run the Playwright specs after your run at 5a7b88e7 — the only change touching them since is the payload edit above.

Ready for another look when you have a moment.

@joshunrau
joshunrau merged commit d668b50 into main Jul 28, 2026
5 checks passed
thomasbeaudry added a commit that referenced this pull request Jul 30, 2026
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>
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.

3 participants