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