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(); + }); +});