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",
@@ -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<string> {
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<string, unknown>): Promise<Response> {
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();
});
});