Skip to content

Let Users Edit Their Name From the User Dropdown #443

Description

@b-at-neu

Goal

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.

Related


Implementation Plan

Overview

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 existing setUserName 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 same public.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.tsxnew; 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').

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

  • 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 FormDialog trigger 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 setUserNametoast.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.
  • Unexpected failure: dialog stays open, generic toast "Something went wrong. Please try again." (FormDialog's catch).
  • 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.
  • Paste 101+ characters and submit → inline max-length error; 100 characters saves.
  • 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 onSelect preventDefault 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.)

Metadata

Metadata

Assignees

Labels

claudeWill be worked on by ClaudeenhancementNew feature or requestpr openedPull request has been opened

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions