Skip to content

feat(web): personal account settings at /account - #364

Merged
Makisuo merged 3 commits into
mainfrom
feat/account-settings
Aug 7, 2026
Merged

feat(web): personal account settings at /account#364
Makisuo merged 3 commits into
mainfrom
feat/account-settings

Conversation

@Makisuo

@Makisuo Makisuo commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

Adds a personal account settings surface, and fixes two silent UI bugs that building it surfaced.

/settings was entirely org-scoped and the Clerk user was only ever read (the sidebar avatar), so there was no way to change your own name, avatar, emails, password, 2FA, passkeys, connected accounts or sessions. Everything here runs through Clerk's client user.* methods — no new API routes, DB tables or domain schemas.

Two commits, independently revertable: the feature, then the pre-existing fixes.

/account

Section Backed by
Profile avatar dropzone + name + delete (setProfileImage / update / delete)
Email Addresses add + verify by code, set primary, remove
Password set vs change off passwordEnabled, sign-out-others toggle
Two-Factor Auth 3-step QR enrollment, backup codes, disable
Passkeys add / inline rename / remove, WebAuthn feature-detected
Connected Accounts Google + GitHub link / retry / disconnect
Active Sessions devices, locations, revoke

Decisions worth reviewing

  • Its own nav, not a wider settingsTabValues. useVisibleSettingsSections is org-permission machinery (admin checks, an Autumn customer round-trip) that account tabs must not wait on, and merging the unions would put unrenderable tabs into /settings' search schema. Sidebar chrome is shared via a new SettingsNavShell, so the two navs can't drift visually.
  • Hidden when isClerkAuthEnabled is false. Self-hosted mode has no user records — just a root pseudo-user.
  • Clerk types derived from the user resource (account-types.ts) rather than adding @clerk/types, which is not a declared dependency of this app and could skew from the installed @clerk/shared.
  • ClerkProvider now takes appearance. useReverification wraps every mutating call, which renders Clerk's own challenge modal; the provider had no appearance, so that modal appeared in Clerk's default light theme mid-flow. This is the one place Clerk UI shows up inside a hand-built page.
  • One new dependency: uqr for the TOTP QR — 3.9 kB gzip tree-shaken, zero deps, MIT. Clerk exports no QR component (only qrCodeRow/qrCodeContainer theming keys for its own prebuilt widget), and /account is code-split so this never enters the startup graph.

Deviations Clerk forces

  1. Connecting a provider is a full-page redirect, not a dialog — createExternalAccount returns a URL you must visit. Accounts can return unverified, so those rows get a Retry.
  2. Set-primary-email is user.update({ primaryEmailAddressId }), on the user, not the row.
  3. createPasskey() takes no name — add, then rename.
  4. Backup codes surface exactly once, inside verifyTOTP's result.
  5. user.getSessions() is imperative (no hook), so it uses the sanctioned useMountEffect.

⚠️ Clerk Dashboard prerequisites

Authenticator app, backup codes and passkeys each need their Dashboard toggle enabled. Those instance settings aren't readable from the client, so sections surface Clerk's own error rather than pre-gating — during testing "Set up authenticator" correctly returned "This feature is not enabled on this instance." Only deleteSelfEnabled is public and gates itself.

Pre-existing fixes (second commit)

Dialog padding. DialogContent carries no padding of its own — it comes from DialogHeader, DialogPanel and DialogFooter. A bare <div> between header and footer sits flush against the popup edge.

  • traces/advanced-filter-dialog: py-4 gave vertical padding only, so the SQL editor ran into both popup edges.
  • settings/organization-section: the delete-org confirm input was unpadded.

Left alone deliberately: destination-dialog, attribute-mappings, members, pricing-cards and scrape-targets already hand-pad with px-6, and spend-limit-dialog opts out (DialogPopup p-0 + bespoke px-5). Converting those would change their vertical rhythm and add a ScrollArea for no gain.

OTP labels. Base UI's <OTPField.Input> ignores aria-label on the first input — that slot carries the whole field's accessible name, which must come from a real <label>. It warns, but the failure is silent in practice: the box a screen-reader user types into first is announced as an unlabelled textbox. Both consumers (cli-login, account/code-field) now use Label htmlFor bound via useId.

Two sweeps (dialog-padding.test.tsx, otp-field-labels.test.tsx) walk every component so neither can come back. They include untracked files on purpose — a plain git ls-files hides brand-new code, which is exactly where these mistakes get made, so the sweep would pass vacuously on the files most likely to be wrong. Both were validated against deliberate regressions.

Testing

tsc --noEmit clean, oxlint clean on touched files, 789 tests / 91 files pass.

Driven end-to-end in the browser against the Clerk dev instance:

  • Full email lifecycle: add → reverification → verify with the test OTP → Verified badge → set as primary → restore original → remove. Account returned to its exact starting state, and the "can't delete your last verified address" guard re-engaged.
  • Profile name save round-tripped to Clerk and propagated to the sidebar (then restored).
  • Sessions listed 13 real sessions with exactly one non-revocable "This device" row.
  • Padding fixes confirmed via computed styles in both a Dialog and an AlertDialog; the traces dialog was measured specifically because DialogPanel adds a ScrollArea around a resizable textarea — no clipping, no overflow, resize: vertical preserved.
  • /integrations still renders correctly through the extracted nav shell.
  • Light and dark mode both checked.

Not exercisable locally: TOTP enrollment, passkey registration and OAuth linking, all blocked on the Dashboard toggles above. Those paths are covered by unit tests (including one asserting all backup codes render, since they're shown only once).

🤖 Generated with Claude Code


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

Makisuo added 3 commits August 8, 2026 00:14
Adds a hand-built account settings surface. `/settings` was entirely
org-scoped and the Clerk user was only ever read (the sidebar avatar), so
there was no way to change your own name, avatar, emails, password, 2FA,
passkeys, connected accounts or sessions.

Everything runs through Clerk's client `user.*` methods — no new API
routes, DB tables or domain schemas.

Sections: profile (avatar + name + delete), email addresses (add/verify by
code, set primary, remove), password (set vs change), TOTP (QR enrollment
+ backup codes), passkeys, connected accounts, active sessions.

Notes on the shape:

- `/account` gets its own nav rather than widening `settingsTabValues`.
  `useVisibleSettingsSections` is org-permission machinery (admin checks,
  an Autumn customer round-trip) that account tabs must not wait on, and
  merging the unions would put unrenderable tabs in /settings' search
  schema. The sidebar chrome is shared via SettingsNavShell.
- Hidden entirely when `isClerkAuthEnabled` is false; self-hosted mode has
  no user records, just a `root` pseudo-user.
- Clerk types are derived from the `user` resource (account-types.ts)
  because `@clerk/types` is not a declared dependency of this app.
- `useReverification` wraps every mutating call. That renders Clerk's own
  challenge modal, so ClerkProvider now takes `appearance` — it had none,
  and the modal would otherwise appear in Clerk's default light theme.
- `uqr` (3.9 kB gzip tree-shaken, zero deps) renders the TOTP QR. Clerk
  exports no QR component, and /account is code-split so it never enters
  the startup graph.

TOTP, backup codes and passkeys each need their Clerk Dashboard toggle
enabled. Those instance settings aren't readable from the client, so
sections surface Clerk's own error rather than pre-gating; only
`deleteSelfEnabled` is public and gates itself.
Two silent UI bugs found while building /account, fixed everywhere they
occur plus sweeps so they cannot come back.

Padding: `DialogContent` carries no padding of its own — it comes from
DialogHeader, DialogPanel and DialogFooter. A bare <div> between header and
footer therefore sits flush against the popup edge. `AlertDialog` has no
panel slot at all, so its bodies must pad themselves.

- traces/advanced-filter-dialog: `py-4` gave vertical padding only, so the
  SQL editor ran into both popup edges. Now DialogPanel.
- settings/organization-section: the delete-org confirm input was
  unpadded. Now `px-6 py-2`, matching members and attribute-mappings.

Left alone: destination-dialog, attribute-mappings, members, pricing-cards
and scrape-targets already hand-pad with px-6, and spend-limit-dialog
deliberately opts out (`DialogPopup p-0` with bespoke px-5). Swapping those
to DialogPanel would change their vertical rhythm and add a ScrollArea for
no gain.

OTP labels: Base UI's <OTPField.Input> ignores `aria-label` on the *first*
input — that slot carries the whole field's accessible name, which has to
come from a real <label>. It warns, but the failure is silent in practice:
the box a screen-reader user types into first is announced as an unlabelled
textbox. Both consumers (cli-login, account/code-field) now use
`Label htmlFor` bound to the first slot via useId.

The two sweeps walk every component, including untracked files — a plain
`git ls-files` hides brand-new code, which is exactly where this mistake
gets made, so the sweep would pass vacuously on the files most likely to be
wrong. Both were checked against deliberate regressions.
`bun add --filter` wrote uqr into the root manifest, so after moving the
dependency to apps/web/package.json by hand the lockfile still listed it
under the root workspace. That mismatch makes `bun install
--frozen-lockfile` re-resolve instead of installing from the lockfile, and
re-resolution trips bunfig's `minimumReleaseAge` on the pinned Effect betas
— which failed every CI job at the install step, including ones unrelated
to this branch.

Regenerated with the documented override
(`bun install --minimum-release-age=0`); the diff only moves uqr from the
root workspace to apps/web. `bun install --frozen-lockfile` now exits 0.
@Makisuo
Makisuo merged commit 0ebb589 into main Aug 7, 2026
32 of 34 checks passed
@Makisuo
Makisuo deleted the feat/account-settings branch August 7, 2026 22:37
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown

🍁 Maple PR preview

Warning

Preview cleanup could not be confirmed. The Alchemy teardown outcome was failure.

Final commit 36ddfbc · View workflow run

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