feat(web): personal account settings at /account - #364
Merged
Conversation
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.
🍁 Maple PR previewWarning Preview cleanup could not be confirmed. The Alchemy teardown outcome was Final commit |
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.
Adds a personal account settings surface, and fixes two silent UI bugs that building it surfaced.
/settingswas 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 clientuser.*methods — no new API routes, DB tables or domain schemas.Two commits, independently revertable: the feature, then the pre-existing fixes.
/accountsetProfileImage/update/delete)passwordEnabled, sign-out-others toggleDecisions worth reviewing
settingsTabValues.useVisibleSettingsSectionsis 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 newSettingsNavShell, so the two navs can't drift visually.isClerkAuthEnabledis false. Self-hosted mode has no user records — just arootpseudo-user.userresource (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.ClerkProvidernow takesappearance.useReverificationwraps every mutating call, which renders Clerk's own challenge modal; the provider had noappearance, 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.uqrfor the TOTP QR — 3.9 kB gzip tree-shaken, zero deps, MIT. Clerk exports no QR component (onlyqrCodeRow/qrCodeContainertheming keys for its own prebuilt widget), and/accountis code-split so this never enters the startup graph.Deviations Clerk forces
createExternalAccountreturns a URL you must visit. Accounts can return unverified, so those rows get a Retry.user.update({ primaryEmailAddressId }), on the user, not the row.createPasskey()takes no name — add, then rename.verifyTOTP's result.user.getSessions()is imperative (no hook), so it uses the sanctioneduseMountEffect.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
deleteSelfEnabledis public and gates itself.Pre-existing fixes (second commit)
Dialog padding.
DialogContentcarries no padding of its own — it comes fromDialogHeader,DialogPanelandDialogFooter. A bare<div>between header and footer sits flush against the popup edge.traces/advanced-filter-dialog:py-4gave 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+ bespokepx-5). Converting those would change their vertical rhythm and add aScrollAreafor no gain.OTP labels. Base UI's
<OTPField.Input>ignoresaria-labelon 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 useLabel htmlForbound viauseId.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 plaingit ls-fileshides 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 --noEmitclean, oxlint clean on touched files, 789 tests / 91 files pass.Driven end-to-end in the browser against the Clerk dev instance:
Verifiedbadge → 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.Dialogand anAlertDialog; the traces dialog was measured specifically becauseDialogPaneladds aScrollAreaaround a resizable textarea — no clipping, no overflow,resize: verticalpreserved./integrationsstill renders correctly through the extracted nav shell.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
Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.