fix(settings): let the display name change after the setup flow

The setup flow asks for the name the greeting and the reports use, and
the profile route has always accepted it, but no settings surface offered
the field and /api/auth/me did not publish it. A name chosen on the
first day stayed for good.

The account payload now carries `displayName`, the profile card shows the
field beside the username and saves it with the rest of the profile, and
an emptied field clears the name.
This commit is contained in:
Marc-André Bombeck committed 2026-10-21 12:36:15 +02:00
1 parent 6037ebd3d6
commit 1e606834d8
6 files changed
+299 -30

No files matched your search

+4
View File
@@ -391,6 +391,10 @@ export const GET = apiHandler(async () => {
// resolved `ai.capabilities.coach` and `modules.coach` below, which fold
// in the record's own opt-out, the operator's switches and the grant.
disableCoach: user.disableCoach ?? false,
// The name the app addresses the account by (dashboard greeting,
// reports). Set in the setup flow and edited on the profile card, which
// seeds its field from here.
displayName: user.displayName ?? null,
// v1.7.0 — health-record export identity fields. All optional.
fullName: user.fullName ?? null,
insurerName: user.insurerName ?? null,
@@ -0,0 +1,100 @@
/**
* The display name can be changed after the setup flow.
*
* The setup flow asks for the name the greeting and the reports use, and the
* profile route has always accepted `displayName` — but no settings surface
* offered it, and `/api/auth/me` did not even publish it, so a name chosen on
* the first day was the name for good. The profile card now carries the
* field, seeded from the account payload, and sends it with the save.
*
* Both ends are pinned: the route publishes the field (read from its response
* literal, the way `account-payload-consumer-guard` reads it), and the card
* renders it seeded and posts it.
*/
import { readFileSync } from "node:fs";
import { join, resolve } from "node:path";
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
let displayName: string | null = "Sam";
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
usePathname: () => "/settings/account",
useSearchParams: () => new URLSearchParams(),
}));
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({ data: null, isLoading: false, refetch: vi.fn() }),
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
useMutation: () => ({ mutate: vi.fn(), isPending: false }),
}));
vi.mock("@/hooks/use-mounted", () => ({ useMounted: () => true }));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: {
id: "u1",
username: "testuser",
displayName,
email: "user@example.com",
heightCm: null,
dateOfBirth: null,
gender: null,
role: "USER",
unitPreference: "metric",
},
isAuthenticated: true,
isLoading: false,
refetch: vi.fn(),
}),
}));
import { I18nProvider } from "@/lib/i18n/context";
import { AccountSection } from "@/components/settings/account-section";
function render(): string {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<AccountSection />
</I18nProvider>,
);
}
/** The `<input id="display-name" …>` tag, or null. */
function displayNameInput(html: string): string | null {
return /<input[^>]*id="display-name"[^>]*>/.exec(html)?.[0] ?? null;
}
describe("display name on the profile card", () => {
it("renders the field seeded with the stored name", () => {
displayName = "Sam";
const input = displayNameInput(render());
expect(input).not.toBeNull();
expect(input).toContain('value="Sam"');
});
it("renders it empty for an account that never set one", () => {
displayName = null;
const input = displayNameInput(render());
expect(input).not.toBeNull();
expect(input).toContain('value=""');
});
it("sends the field with the profile save", () => {
const source = readFileSync(resolve(__dirname, "../index.tsx"), "utf8");
expect(source).toMatch(
/displayName:\s*savedProfile\.displayName\s*\|\|\s*null/,
);
});
it("is published on the account payload the card seeds from", () => {
const route = readFileSync(
join(process.cwd(), "src/app/api/auth/me/route.ts"),
"utf8",
);
expect(route).toMatch(/\bdisplayName:\s*user\.displayName\s*\?\?\s*null/);
});
});
@@ -95,12 +95,16 @@ export function AccountSection() {
const [dateOfBirth, setDateOfBirth] = useState("");
const [gender, setGender] = useState<string>("");
const [timezone, setTimezone] = useState<string>(DEFAULT_TIMEZONE);
// The name the app addresses the account by; set in the setup flow, and
// until now not editable anywhere after it.
const [displayName, setDisplayName] = useState("");
// v1.7.0 — optional patient-identity fields for the health-record export.
const [fullName, setFullName] = useState("");
const [insurerName, setInsurerName] = useState("");
const [insuranceNumber, setInsuranceNumber] = useState("");
const [profileSeed, setProfileSeed] = useState({
email: "",
displayName: "",
height: EMPTY_HEIGHT_DRAFT,
dateOfBirth: "",
gender: "",
@@ -149,6 +153,7 @@ export function AccountSection() {
if (user && user.id !== seededUserId) {
const seed = {
email: user.email ?? "",
displayName: user.displayName ?? "",
height: heightAdapter.toDraft(user.heightCm),
dateOfBirth: user.dateOfBirth
? dateOnlyKey(new Date(user.dateOfBirth))
@@ -161,6 +166,7 @@ export function AccountSection() {
};
setSeededUserId(user.id);
setEmail(seed.email);
setDisplayName(seed.displayName);
setHeight(seed.height);
setDateOfBirth(seed.dateOfBirth);
setGender(seed.gender);
@@ -192,6 +198,7 @@ export function AccountSection() {
seed: profileSeed,
value: {
email,
displayName,
height,
dateOfBirth,
gender,
@@ -256,6 +263,7 @@ export function AccountSection() {
try {
const savedProfile = {
email: email.trim(),
displayName: displayName.trim(),
height,
dateOfBirth,
gender,
@@ -275,6 +283,9 @@ export function AccountSection() {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email: savedProfile.email || null,
// An emptied field clears the name; the greeting falls back to
// the username, as it does for an account that never set one.
displayName: savedProfile.displayName || null,
...(emailChanged && emailPassword
? { currentPassword: emailPassword }
: {}),
@@ -332,6 +343,7 @@ export function AccountSection() {
setSaveMsg({ key: "settings.profileSaved" });
setSaveMsgType("success");
setEmail(savedProfile.email);
setDisplayName(savedProfile.displayName);
setFullName(savedProfile.fullName);
setInsurerName(savedProfile.insurerName);
setInsuranceNumber(savedProfile.insuranceNumber);
@@ -449,45 +461,81 @@ export function AccountSection() {
autoComplete="username"
/>
</div>
{/* The name the greeting and the reports use. The setup flow
asks for it once; this is where it changes afterwards. */}
<div className="space-y-2">
<Label htmlFor="email">{t("auth.email")}</Label>
<Label htmlFor="display-name">
{t("onboarding.baseline.displayNameLabel")}
</Label>
<Input
id="email"
type="email"
value={email}
id="display-name"
value={displayName}
onChange={(e) => {
setEmail(e.target.value);
clearFieldError("email");
setDisplayName(e.target.value);
clearFieldError("displayName");
}}
placeholder={t("auth.emailPlaceholder")}
maxLength={320}
autoComplete="email"
placeholder={t("onboarding.baseline.displayNamePlaceholder")}
maxLength={80}
autoComplete="nickname"
enterKeyHint="next"
aria-invalid={fieldErrors.email ? true : undefined}
aria-describedby={fieldErrors.email ? "email-error" : undefined}
aria-invalid={fieldErrors.displayName ? true : undefined}
aria-describedby={
fieldErrors.displayName
? "display-name-hint display-name-error"
: "display-name-hint"
}
/>
<p
id="display-name-hint"
className="text-muted-foreground text-xs"
>
{t("onboarding.baseline.displayNameHint")}
</p>
<FieldError
id="display-name-error"
message={fieldErrors.displayName}
/>
<FieldError id="email-error" message={fieldErrors.email} />
{emailChanged && !emailNeedsFactor && (
<div className="space-y-2 pt-1">
<Label htmlFor="email-current-password">
{t("settings.emailChangePassword")}
</Label>
<Input
id="email-current-password"
type="password"
value={emailPassword}
onChange={(e) => {
setEmailPassword(e.target.value);
clearFieldError("email");
}}
autoComplete="current-password"
maxLength={512}
/>
</div>
)}
</div>
</div>
<div className="space-y-2">
<Label htmlFor="email">{t("auth.email")}</Label>
<Input
id="email"
type="email"
value={email}
onChange={(e) => {
setEmail(e.target.value);
clearFieldError("email");
}}
placeholder={t("auth.emailPlaceholder")}
maxLength={320}
autoComplete="email"
enterKeyHint="next"
aria-invalid={fieldErrors.email ? true : undefined}
aria-describedby={fieldErrors.email ? "email-error" : undefined}
/>
<FieldError id="email-error" message={fieldErrors.email} />
{emailChanged && !emailNeedsFactor && (
<div className="space-y-2 pt-1">
<Label htmlFor="email-current-password">
{t("settings.emailChangePassword")}
</Label>
<Input
id="email-current-password"
type="password"
value={emailPassword}
onChange={(e) => {
setEmailPassword(e.target.value);
clearFieldError("email");
}}
autoComplete="current-password"
maxLength={512}
/>
</div>
)}
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="gender">{t("settings.gender")}</Label>
+6
View File
@@ -157,6 +157,12 @@ export interface AuthUser {
* Defaults to `false` when the field is absent (a stale /me payload).
*/
disableCoach: boolean;
/**
* The name the app addresses the account by (dashboard greeting, reports),
* null when none was given. Edited on the profile card. Optional in the
* type so a stale /me payload coerces to "no display name".
*/
displayName?: string | null;
/**
* v1.7.0 — optional patient-identity fields used by the health-record
* export (PDF cover + FHIR Patient). All optional; `insuranceNumber`
+1
View File
@@ -37,6 +37,7 @@ export interface RejectedProfileField {
*/
export const PROFILE_FIELD_LABEL_KEYS: Record<string, string> = {
email: "auth.email",
displayName: "onboarding.baseline.displayNameLabel",
heightCm: "settings.height",
dateOfBirth: "settings.dateOfBirth",
gender: "settings.gender",