From 1e606834d871a28e5044b541b8ea48526f3f7036 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 12:35:32 +0200 Subject: [PATCH] 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. --- src/app/api/auth/me/route.ts | 4 + .../__tests__/display-name-field.test.tsx | 100 ++++++++++++++++ .../settings/account-section/index.tsx | 108 ++++++++++++----- src/hooks/use-auth.ts | 6 + src/lib/profile/rejected-fields.ts | 1 + .../integration/profile-display-name.test.ts | 110 ++++++++++++++++++ 6 files changed, 299 insertions(+), 30 deletions(-) create mode 100644 src/components/settings/account-section/__tests__/display-name-field.test.tsx create mode 100644 tests/integration/profile-display-name.test.ts diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts index 09cebfdc0..ef1167630 100644 --- a/src/app/api/auth/me/route.ts +++ b/src/app/api/auth/me/route.ts @@ -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, diff --git a/src/components/settings/account-section/__tests__/display-name-field.test.tsx b/src/components/settings/account-section/__tests__/display-name-field.test.tsx new file mode 100644 index 000000000..b2aac482b --- /dev/null +++ b/src/components/settings/account-section/__tests__/display-name-field.test.tsx @@ -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( + + + , + ); +} + +/** The `` tag, or null. */ +function displayNameInput(html: string): string | null { + return /]*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/); + }); +}); diff --git a/src/components/settings/account-section/index.tsx b/src/components/settings/account-section/index.tsx index 68aec1b0b..cacf26e4c 100644 --- a/src/components/settings/account-section/index.tsx +++ b/src/components/settings/account-section/index.tsx @@ -95,12 +95,16 @@ export function AccountSection() { const [dateOfBirth, setDateOfBirth] = useState(""); const [gender, setGender] = useState(""); const [timezone, setTimezone] = useState(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" /> + {/* The name the greeting and the reports use. The setup flow + asks for it once; this is where it changes afterwards. */}
- + { - 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" + } + /> +

+ {t("onboarding.baseline.displayNameHint")} +

+ - - {emailChanged && !emailNeedsFactor && ( -
- - { - setEmailPassword(e.target.value); - clearFieldError("email"); - }} - autoComplete="current-password" - maxLength={512} - /> -
- )}
+
+ + { + 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} + /> + + {emailChanged && !emailNeedsFactor && ( +
+ + { + setEmailPassword(e.target.value); + clearFieldError("email"); + }} + autoComplete="current-password" + maxLength={512} + /> +
+ )} +
+
diff --git a/src/hooks/use-auth.ts b/src/hooks/use-auth.ts index 28eee182d..d80fdcbfd 100644 --- a/src/hooks/use-auth.ts +++ b/src/hooks/use-auth.ts @@ -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` diff --git a/src/lib/profile/rejected-fields.ts b/src/lib/profile/rejected-fields.ts index e18bcfff2..b0328440a 100644 --- a/src/lib/profile/rejected-fields.ts +++ b/src/lib/profile/rejected-fields.ts @@ -37,6 +37,7 @@ export interface RejectedProfileField { */ export const PROFILE_FIELD_LABEL_KEYS: Record = { email: "auth.email", + displayName: "onboarding.baseline.displayNameLabel", heightCm: "settings.height", dateOfBirth: "settings.dateOfBirth", gender: "settings.gender", diff --git a/tests/integration/profile-display-name.test.ts b/tests/integration/profile-display-name.test.ts new file mode 100644 index 000000000..42c2092ec --- /dev/null +++ b/tests/integration/profile-display-name.test.ts @@ -0,0 +1,110 @@ +/** + * The display name round-trips through the profile save and the account + * payload, against a real account row. + * + * The profile card seeds its display-name field from `GET /api/auth/me` and + * saves it through `PUT /api/auth/profile`. Until now `/me` did not publish + * the name at all, so the two ends could not meet; this pins the pipe: a save + * lands in the column and comes back on the payload, and an emptied field + * clears it. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +process.env.ENCRYPTION_KEY ??= + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => { + cookieJar.set(name, value); + }, + delete: (name: string) => { + cookieJar.delete(name); + }, + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +async function seed(displayName: string | null): Promise { + const prisma = getPrismaClient(); + const user = await prisma.user.create({ + data: { + username: "display-name", + email: "display-name@example.test", + displayName, + }, + }); + const session = await prisma.session.create({ + data: { userId: user.id, expiresAt: new Date(Date.now() + 60_000) }, + }); + cookieJar.set("healthlog_session", session.id); + return user.id; +} + +async function save(body: Record): Promise { + const { PUT } = await import("@/app/api/auth/profile/route"); + return PUT( + new NextRequest("http://localhost/api/auth/profile", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }), + ); +} + +async function me(): Promise<{ displayName?: string | null }> { + const { GET } = await import("@/app/api/auth/me/route"); + const res = await GET(); + expect(res.status).toBe(200); + return ((await res.json()) as { data: { displayName?: string | null } }).data; +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); +}); + +describe("display name: profile save and account payload", () => { + it("publishes the stored name on /api/auth/me", async () => { + await seed("Sam"); + expect((await me()).displayName).toBe("Sam"); + }); + + it("a save lands in the column and comes back on the payload", async () => { + const userId = await seed("Sam"); + + const res = await save({ displayName: " Alex " }); + expect(res.status).toBe(200); + + const row = await getPrismaClient().user.findUniqueOrThrow({ + where: { id: userId }, + select: { displayName: true }, + }); + expect(row.displayName).toBe("Alex"); + expect((await me()).displayName).toBe("Alex"); + }); + + it("an emptied field clears the name", async () => { + await seed("Sam"); + expect((await save({ displayName: null })).status).toBe(200); + expect((await me()).displayName).toBeNull(); + }); +});