You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Let a user change their own display name from the user dropdown, via a dialog — and make public.User.name the single source of truth for it.
Today User.name is set during sign-up (#240) and can never be changed afterwards. The dropdown (components/layouts/user-menu.tsx) offers only Profile, Theme and Log out.
Make public.User.name the only source of truth
The name is currently nominally held in two places — the Neon Auth account and public.User — but only one of them is actually used:
Nothing writes a name to Neon Auth.Require Full Name During Sign-Up #240's merged action (prisma/actions/profile.ts) writes only prisma.user.update({ data: { name } }), despite the issue text describing a write to both.
Exactly one read exists:resolveRealUser destructures name off the session at lib/auth/server.ts:18 and uses it solely to seed User.name at first provisioning — conditionally (...(name ? { name } : {})), and only on create, since the upsert passes update: {}.
For email-OTP sign-in that value is effectively always empty: signIn.emailOtp collects no name, which is why Require Full Name During Sign-Up #240 had to add a separate in-app name step.
Drop that read.public.User.name then becomes the unambiguous source of truth, drift is impossible by construction rather than by discipline, and this dialog is a plain single-table write with no sync step.
Consequence to accept deliberately: if OAuth or social sign-in is added later, the provider would supply a name that could have seeded the field for free. Those users will instead start with name: null and go through the same in-app name step as everyone else — one collection path for every user, regardless of sign-in method.
Application name snapshot
Decision (2026-08-19), supersedes the original "no snapshot, live propagation" requirement below:Application now snapshots the applicant's display name at submission time (Application.applicantName), so a later rename does not retroactively change the name shown on an already-submitted or already-decided application — only applications submitted after the rename use the new name. Agreed between @cielbellerose and @b-at-neu on PR #498.
The dialog must instead warn that the change applies going forward only: future applications will show the new name, but applications already submitted keep the name that was on file at the time they were submitted.
Original requirement (superseded)
The dialog must warn that changing the name updates it on every application the user has, including submitted and already-decided ones.
Reviewer-facing views join to User live rather than reading a snapshot — AdminApplicationListItem, ApplicationForReview and PositionApplicationListItem all select user: { name, email } (lib/types.ts). So a rename immediately changes the applicant name shown across the full history.
This propagation is intended. The warning is the mitigation, not a signal that the behaviour should change — do not introduce a per-application name snapshot as part of this work.
Copy should say plainly that it applies to past and submitted applications, not only future ones.
Acceptance criteria
Edit name item in the user dropdown, opening a dialog
Dialog pre-fills the current name and validates it is non-blank, with a sensible maximum length — reuse the existing nameSchema from Require Full Name During Sign-Up #240 rather than defining a second rule
Server action in prisma/actions/, 'use server', authenticated, zod-validated, scoped to the caller's own record — a user can only rename themselves
Returns { error } for a user-facing failure and throws for the unexpected, per .claude/docs/ENGINEERING.md §4
Success toast; revalidatePath so sidebar, mobile nav and dropdown all update
The dialog warns that the change applies to future applications only — already-submitted/decided applications keep the name that was on file at submission time (see "Application name snapshot" above)
The name read is removed from resolveRealUser, leaving public.User.name as the single source of truth
Uses the existing FormDialog primitive rather than a hand-rolled Dialog
Input has an associated label; the dialog traps focus and returns it to the trigger on close; the trigger meets the ~44px touch target on mobile
Works for a bypass user without erroring, or is hidden for them
Dependencies
Blocked by #437 — that issue changes how auth users are provisioned before OTP send, which touches the same provisioning path this ticket edits. Land #437 first so the resolveRealUser change is made against its final shape.
Add an Edit name item to UserMenu that opens a FormDialog pre-filled with the current name, warns that the rename applies going forward only, and submits to the existingsetUserName action — which already authenticates, parses nameSchema, scopes the write to user.id, and revalidates /profile plus the root layout.
Post-cycle-1 addition (2026-08-19):Application.applicantName now snapshots the display name at submission time (see "Application name snapshot" above), so this ticket also touches prisma/schema.prisma, prisma/actions/applications.ts (submitApplication), and the read paths that display an applicant's name — this was added after the plan below was written and isn't reflected in it.
Two of the ticket's premises have since shifted, and the plan follows the code as it stands:
The resolveRealUser name read is already gone. Better Auth now owns the User row (lib/auth/server.ts — "the session id is the row id"), so there is no provisioning upsert and no session name to seed from. That AC is satisfied by the auth migration; nothing to do.
The live second write is in name-field.tsx. It calls authClient.updateUser({ name }) after setUserName to "sync the Neon Auth account", but Better Auth's updateUser writes the samepublic.User row through the Prisma adapter — a redundant round-trip that can only fail (and does fail for a bypass user, producing a spurious "account sync failed" warning). Deleting it is what "single source of truth, no sync step" now means, and it keeps the login gate and the new dialog on one identical write path.
Changes
components/features/edit-name-dialog.tsx — new; client FormDialog wrapper: nameSchema resolver, labelled name input, warning callout, setUserName call + toasts. Sits in features/ alongside create-user-dialog.tsx, not in layouts/.
components/layouts/user-menu.tsx — render EditNameDialog inside DropdownMenuContent, its trigger a DropdownMenuItem; bump menu-item touch targets.
components/features/name-field.tsx — drop the authClient.updateUser sync, its toast.warning branch, and the now-unused authClient import; plain toast.success('Name saved').
Create EditNameDialog({ currentName }: { currentName: string | null }) — 'use client', named export, FormDialog with schema={nameSchema}, defaultValues={{ name: currentName ?? '' }}, title="Edit name", submitLabel="Save name"; FormDialog re-resets to defaultValues on every open, so the pre-fill stays current after a rename.
Body: FormField name="name" → FormItem/FormLabel "Full name"/FormControl+Input (placeholder="Jane Smith", autoComplete="name", autoFocus)/FormMessage, then <WarningCallout icon={TriangleAlert}> with the propagation copy below.
onSubmit: const result = await setUserName({ name: data.name }); if (result?.error) { toast.error(result.error); return false; }; toast.success('Name updated.'); return true; — no local try/catch, FormDialog already catches a throw and toasts the generic message.
In UserMenu, add the item between Profile and Theme (its own DropdownMenuSeparator group): <EditNameDialog currentName={name} trigger=…> where the trigger is <DropdownMenuItem onSelect={(e) => e.preventDefault()} className="cursor-pointer text-sm"><UserPen className="size-4" aria-hidden />Edit name</DropdownMenuItem> — preventDefault keeps the menu open so the dialog isn't unmounted with it (on mobile the menu lives inside the nav Sheet, where closing the tree would kill the dialog outright). Pass the trigger as the FormDialogtrigger prop; do not call onNavigate here — the sheet must stay mounted behind the dialog.
Touch targets: apply a shared min-h-11 md:min-h-0 to the menu's interactive items (Profile, Edit name, Theme sub-trigger, Log out) — the new item needs ~44px on mobile and a lone tall item in a short menu would look broken.
Delete the authClient.updateUser block in name-field.tsx (see Overview), leaving setUserName → toast.success('Name saved') → router.replace(redirectTo).
Verify no other caller relied on that sync: authClient usage is now only login-view.tsx's OTP calls.
Data & contracts
No Prisma or schema change. One action, already written and reused unmodified — setUserName(input: unknown): Promise<ErrorType | void> in prisma/actions/profile.ts:
Auth:await getCurrentUser() first (redirects when unauthenticated); the write is prisma.user.update({ where: { id: user.id } }) — no client-supplied id, so a user can only rename themselves.
Validation:nameSchema.safeParse(input) — trimmed, non-blank, ≤ NAME_MAX_LENGTH (100). Same schema drives the client resolver, so field-level failures surface inline on the input; the returned { error: 'Enter your full name.' } is only reachable if the client is bypassed, and is safe, actionable copy either way.
Throws: nothing user-facing — a DB failure propagates and FormDialog's catch toasts "Something went wrong. Please try again."
Revalidation:revalidatePath('/profile') + revalidatePath('/', 'layout') — the layout entry is what refreshes the sidebar, mobile nav and the dropdown label; nothing extra is needed for this ticket.
Bypass user:getCurrentUser returns the real User row behind the bypass cookie, so the update succeeds and the item stays visible — the only thing that ever failed for bypass was the authClient sync being removed here.
UX states
Trigger:Edit name with a UserPen icon, in the dropdown between Profile and Theme.
Dialog: title "Edit name", description "This is the name reviewers see on your applications."; single "Full name" input pre-filled with the current name; warning callout below it; footer "Save name".
Warning copy (warning callout, TriangleAlert) — updated 2026-08-19 per the snapshot decision above: "This updates your name going forward. Applications you've already submitted keep the name you used at the time."
Validation (inline, via the resolver): blank → "Enter your full name."; over 100 chars → "Name must be 100 characters or fewer."
Submitting: submit button disabled with a spinner (FormDialog); input keeps its value on failure and the dialog stays open.
Success: dialog closes, toast "Name updated.", sidebar/nav/dropdown label update from the layout revalidation. On mobile the nav sheet is still open behind and shows the new name.
No loading or empty state applies — the name arrives as a prop from the server-rendered layout; the dialog fetches nothing.
A11y:FormLabel binds to the input; Radix traps focus in the dialog, Escape closes it, and focus returns to the Edit name item; icons aria-hidden; items clear ~44px on mobile.
Testing
Sign in as a normal user → open the user dropdown (desktop sidebar) → Edit name is present and opens a dialog pre-filled with the current name.
Save a new name → toast "Name updated.", dialog closes, and the sidebar + dropdown label show the new name without a manual reload.
Reopen the dialog → it is pre-filled with the new name.
Clear the field and submit → inline "Enter your full name.", dialog stays open, no toast.
Submit a name with leading/trailing spaces → saved trimmed.
Keyboard only: Tab to the account trigger, Enter, arrow to Edit name, Enter → focus lands in the input; Escape closes the dialog and focus returns to the Edit name item.
Mobile viewport (~375px): open the hamburger sheet → account menu → Edit name; the dialog is usable over the sheet, items are comfortably tappable, and after saving the sheet shows the new name.
As an admin/manager, open an application the user submitted before the rename → it still shows the pre-rename name (frozen snapshot), including on a decided application. Submit a new application after the rename → it shows the new name.
Dev bypass user (/login/bypass): rename succeeds with only the success toast — no "account sync failed" warning.
Sign-up path regression: a user with no name signing in via OTP still lands on the /login name step, saving shows "Name saved", and they are redirected to redirectTo.
Risks / notes
Nested overlays.Dialog inside DropdownMenuContent inside (on mobile) Sheet relies on onSelectpreventDefault to keep the ancestors mounted. If Radix leaves the page inert after close, the fix is modal={false} on the DropdownMenu — not hoisting the dialog out, which would unmount it with the sheet.
Two ACs are already met by the Better Auth migration (resolveRealUser no longer reads a session name); the plan converts that AC into deleting the one remaining redundant write in name-field.tsx. Flag it in the PR body so review doesn't read the missing lib/auth/server.ts diff as an omission.
Out of scope: adding the same editor to /profile. (A per-application name snapshot is now in scope — see "Application name snapshot" above; it was originally forbidden here, superseded 2026-08-19.)
Goal
Let a user change their own display name from the user dropdown, via a dialog — and make
public.User.namethe single source of truth for it.Today
User.nameis set during sign-up (#240) and can never be changed afterwards. The dropdown (components/layouts/user-menu.tsx) offers only Profile, Theme and Log out.Make
public.User.namethe only source of truthThe name is currently nominally held in two places — the Neon Auth account and
public.User— but only one of them is actually used:prisma/actions/profile.ts) writes onlyprisma.user.update({ data: { name } }), despite the issue text describing a write to both.resolveRealUserdestructuresnameoff the session atlib/auth/server.ts:18and uses it solely to seedUser.nameat first provisioning — conditionally (...(name ? { name } : {})), and only on create, since the upsert passesupdate: {}.signIn.emailOtpcollects no name, which is why Require Full Name During Sign-Up #240 had to add a separate in-app name step.Drop that read.
public.User.namethen becomes the unambiguous source of truth, drift is impossible by construction rather than by discipline, and this dialog is a plain single-table write with no sync step.Consequence to accept deliberately: if OAuth or social sign-in is added later, the provider would supply a name that could have seeded the field for free. Those users will instead start with
name: nulland go through the same in-app name step as everyone else — one collection path for every user, regardless of sign-in method.Application name snapshot
Decision (2026-08-19), supersedes the original "no snapshot, live propagation" requirement below:
Applicationnow snapshots the applicant's display name at submission time (Application.applicantName), so a later rename does not retroactively change the name shown on an already-submitted or already-decided application — only applications submitted after the rename use the new name. Agreed between @cielbellerose and @b-at-neu on PR #498.The dialog must instead warn that the change applies going forward only: future applications will show the new name, but applications already submitted keep the name that was on file at the time they were submitted.
Original requirement (superseded)
The dialog must warn that changing the name updates it on every application the user has, including submitted and already-decided ones.
Reviewer-facing views join to
Userlive rather than reading a snapshot —AdminApplicationListItem,ApplicationForReviewandPositionApplicationListItemall selectuser: { name, email }(lib/types.ts). So a rename immediately changes the applicant name shown across the full history.This propagation is intended. The warning is the mitigation, not a signal that the behaviour should change — do not introduce a per-application name snapshot as part of this work.
Copy should say plainly that it applies to past and submitted applications, not only future ones.
Acceptance criteria
nameSchemafrom Require Full Name During Sign-Up #240 rather than defining a second ruleprisma/actions/,'use server', authenticated, zod-validated, scoped to the caller's own record — a user can only rename themselves{ error }for a user-facing failure and throws for the unexpected, per.claude/docs/ENGINEERING.md§4revalidatePathso sidebar, mobile nav and dropdown all updatenameread is removed fromresolveRealUser, leavingpublic.User.nameas the single source of truthFormDialogprimitive rather than a hand-rolledDialogDependencies
Blocked by #437 — that issue changes how auth users are provisioned before OTP send, which touches the same provisioning path this ticket edits. Land #437 first so the
resolveRealUserchange is made against its final shape.Related
nameSchemaandname-field.tsxshould be reused here)Implementation Plan
Overview
Add an Edit name item to
UserMenuthat opens aFormDialogpre-filled with the current name, warns that the rename applies going forward only, and submits to the existingsetUserNameaction — which already authenticates, parsesnameSchema, scopes the write touser.id, and revalidates/profileplus the root layout.Post-cycle-1 addition (2026-08-19):
Application.applicantNamenow snapshots the display name at submission time (see "Application name snapshot" above), so this ticket also touchesprisma/schema.prisma,prisma/actions/applications.ts(submitApplication), and the read paths that display an applicant's name — this was added after the plan below was written and isn't reflected in it.Two of the ticket's premises have since shifted, and the plan follows the code as it stands:
resolveRealUsername read is already gone. Better Auth now owns theUserrow (lib/auth/server.ts— "the session id is the row id"), so there is no provisioning upsert and no sessionnameto seed from. That AC is satisfied by the auth migration; nothing to do.name-field.tsx. It callsauthClient.updateUser({ name })aftersetUserNameto "sync the Neon Auth account", but Better Auth'supdateUserwrites the samepublic.Userrow through the Prisma adapter — a redundant round-trip that can only fail (and does fail for a bypass user, producing a spurious "account sync failed" warning). Deleting it is what "single source of truth, no sync step" now means, and it keeps the login gate and the new dialog on one identical write path.Changes
components/features/edit-name-dialog.tsx— new; clientFormDialogwrapper:nameSchemaresolver, labelled name input, warning callout,setUserNamecall + toasts. Sits infeatures/alongsidecreate-user-dialog.tsx, not inlayouts/.components/layouts/user-menu.tsx— renderEditNameDialoginsideDropdownMenuContent, its trigger aDropdownMenuItem; bump menu-item touch targets.components/features/name-field.tsx— drop theauthClient.updateUsersync, itstoast.warningbranch, and the now-unusedauthClientimport; plaintoast.success('Name saved').Unchanged and reused as-is:
prisma/actions/profile.ts(setUserName),lib/constants.ts(nameSchema,NAME_MAX_LENGTH),components/ui/form-dialog.tsx,components/ui/warning-callout.tsx.Implementation
EditNameDialog({ currentName }: { currentName: string | null })—'use client', named export,FormDialogwithschema={nameSchema},defaultValues={{ name: currentName ?? '' }},title="Edit name",submitLabel="Save name";FormDialogre-resets todefaultValueson every open, so the pre-fill stays current after a rename.FormField name="name"→FormItem/FormLabel "Full name"/FormControl+Input(placeholder="Jane Smith",autoComplete="name",autoFocus)/FormMessage, then<WarningCallout icon={TriangleAlert}>with the propagation copy below.onSubmit:const result = await setUserName({ name: data.name });if (result?.error) { toast.error(result.error); return false; };toast.success('Name updated.'); return true;— no localtry/catch,FormDialogalready catches a throw and toasts the generic message.UserMenu, add the item between Profile and Theme (its ownDropdownMenuSeparatorgroup):<EditNameDialog currentName={name} trigger=…>where the trigger is<DropdownMenuItem onSelect={(e) => e.preventDefault()} className="cursor-pointer text-sm"><UserPen className="size-4" aria-hidden />Edit name</DropdownMenuItem>—preventDefaultkeeps the menu open so the dialog isn't unmounted with it (on mobile the menu lives inside the navSheet, where closing the tree would kill the dialog outright). Pass the trigger as theFormDialogtriggerprop; do not callonNavigatehere — the sheet must stay mounted behind the dialog.min-h-11 md:min-h-0to the menu's interactive items (Profile, Edit name, Theme sub-trigger, Log out) — the new item needs ~44px on mobile and a lone tall item in a short menu would look broken.authClient.updateUserblock inname-field.tsx(see Overview), leavingsetUserName→toast.success('Name saved')→router.replace(redirectTo).authClientusage is now onlylogin-view.tsx's OTP calls.Data & contracts
No Prisma or schema change. One action, already written and reused unmodified —
setUserName(input: unknown): Promise<ErrorType | void>inprisma/actions/profile.ts:await getCurrentUser()first (redirects when unauthenticated); the write isprisma.user.update({ where: { id: user.id } })— no client-supplied id, so a user can only rename themselves.nameSchema.safeParse(input)— trimmed, non-blank, ≤NAME_MAX_LENGTH(100). Same schema drives the client resolver, so field-level failures surface inline on the input; the returned{ error: 'Enter your full name.' }is only reachable if the client is bypassed, and is safe, actionable copy either way.FormDialog's catch toasts "Something went wrong. Please try again."revalidatePath('/profile')+revalidatePath('/', 'layout')— the layout entry is what refreshes the sidebar, mobile nav and the dropdown label; nothing extra is needed for this ticket.getCurrentUserreturns the realUserrow behind the bypass cookie, so the update succeeds and the item stays visible — the only thing that ever failed for bypass was theauthClientsync being removed here.UX states
Edit namewith aUserPenicon, in the dropdown between Profile and Theme.TriangleAlert) — updated 2026-08-19 per the snapshot decision above: "This updates your name going forward. Applications you've already submitted keep the name you used at the time."FormDialog); input keeps its value on failure and the dialog stays open.FormDialog's catch).FormLabelbinds to the input; Radix traps focus in the dialog, Escape closes it, and focus returns to theEdit nameitem; iconsaria-hidden; items clear ~44px on mobile.Testing
/login/bypass): rename succeeds with only the success toast — no "account sync failed" warning./loginname step, saving shows "Name saved", and they are redirected toredirectTo.Risks / notes
DialoginsideDropdownMenuContentinside (on mobile)Sheetrelies ononSelectpreventDefaultto keep the ancestors mounted. If Radix leaves the page inert after close, the fix ismodal={false}on theDropdownMenu— not hoisting the dialog out, which would unmount it with the sheet.resolveRealUserno longer reads a session name); the plan converts that AC into deleting the one remaining redundant write inname-field.tsx. Flag it in the PR body so review doesn't read the missinglib/auth/server.tsdiff as an omission./profile. (A per-application name snapshot is now in scope — see "Application name snapshot" above; it was originally forbidden here, superseded 2026-08-19.)