From 03cacc996cc48d2796d6e660051695fcbf007d71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 15:06:57 +0200 Subject: [PATCH] fix(profile): one display-name bound, checked after trimming The setup flow capped a display name at 50 characters while the profile took 80, and the profile checked its minimum before trimming, so a name of only spaces was stored as an empty string. Every surface now reads one bound (80), the profile trims before validating, and only spaces means no name on both paths: the profile clears it to null, the setup flow leaves it unset. --- docs/api/openapi.yaml | 6 +-- src/components/onboarding/baseline-fields.tsx | 3 +- .../access/managed-profile-create-form.tsx | 3 +- .../access/managed-profile-edit-form.tsx | 3 +- .../settings/account-section/index.tsx | 3 +- src/lib/auth/__tests__/profile-update.test.ts | 45 +++++++++++++++++++ src/lib/auth/profile-update.ts | 4 +- src/lib/openapi/routes/profile.ts | 10 ++++- src/lib/validations/display-name.ts | 20 +++++++++ src/lib/validations/managed-profiles.ts | 3 +- src/lib/validations/onboarding.ts | 4 +- 11 files changed, 93 insertions(+), 11 deletions(-) create mode 100644 src/lib/validations/display-name.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 691031f34..a996574f2 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -26651,9 +26651,10 @@ components: - OTHER - type: "null" displayName: + description: Trimmed before its bound is checked. Null, an empty string or only spaces clears it; an omitted field is + left untouched. anyOf: - type: string - minLength: 1 maxLength: 80 - type: "null" locale: @@ -33760,8 +33761,7 @@ components: maxLength: 64 displayName: type: string - minLength: 1 - maxLength: 50 + maxLength: 80 heightCm: type: number minimum: 50 diff --git a/src/components/onboarding/baseline-fields.tsx b/src/components/onboarding/baseline-fields.tsx index bd83c1e77..7d042aee9 100644 --- a/src/components/onboarding/baseline-fields.tsx +++ b/src/components/onboarding/baseline-fields.tsx @@ -1,5 +1,6 @@ "use client"; +import { DISPLAY_NAME_MAX_LENGTH } from "@/lib/validations/display-name"; import { Input } from "@/components/ui/input"; import { DateField } from "@/components/ui/date-field"; import { FieldGroup } from "@/components/ui/field-group"; @@ -98,7 +99,7 @@ export function BaselineFields({ onChange={(e) => onChange("displayName", e.target.value)} disabled={readOnlyDisplayName} autoComplete="nickname" - maxLength={50} + maxLength={DISPLAY_NAME_MAX_LENGTH} placeholder={t("onboarding.baseline.displayNamePlaceholder")} aria-invalid={errors.displayName ? true : undefined} aria-describedby={ diff --git a/src/components/settings/access/managed-profile-create-form.tsx b/src/components/settings/access/managed-profile-create-form.tsx index d87d38a08..2fd4841da 100644 --- a/src/components/settings/access/managed-profile-create-form.tsx +++ b/src/components/settings/access/managed-profile-create-form.tsx @@ -1,5 +1,6 @@ "use client"; +import { DISPLAY_NAME_MAX_LENGTH } from "@/lib/validations/display-name"; import { useState } from "react"; import { Button } from "@/components/ui/button"; @@ -176,7 +177,7 @@ export function ManagedProfileCreateForm({ }} placeholder={t("recordSharing.managed.namePlaceholder")} autoComplete="off" - maxLength={80} + maxLength={DISPLAY_NAME_MAX_LENGTH} aria-invalid={ displayName.length > 0 && nameIssue !== null ? "true" : undefined } diff --git a/src/components/settings/access/managed-profile-edit-form.tsx b/src/components/settings/access/managed-profile-edit-form.tsx index a4f4b4ffb..e6aed7d20 100644 --- a/src/components/settings/access/managed-profile-edit-form.tsx +++ b/src/components/settings/access/managed-profile-edit-form.tsx @@ -1,5 +1,6 @@ "use client"; +import { DISPLAY_NAME_MAX_LENGTH } from "@/lib/validations/display-name"; import { useState, type FormEvent } from "react"; import { Loader2 } from "lucide-react"; @@ -213,7 +214,7 @@ function ManagedProfileEditFields({ setError(null); }} autoComplete="off" - maxLength={80} + maxLength={DISPLAY_NAME_MAX_LENGTH} aria-invalid={nameIssue !== null ? "true" : undefined} /> ({ })); import { applyProfileUpdate as applyWithProof } from "../profile-update"; +import { DISPLAY_NAME_MAX_LENGTH } from "@/lib/validations/display-name"; +import { onboardingCompleteSchema } from "@/lib/validations/onboarding"; /** These suites are about validation and the email budget, not the proof: * every call is made as an owner who has just proved themselves. */ @@ -104,6 +106,49 @@ describe("applyProfileUpdate timezone validation", () => { }); }); +describe("applyProfileUpdate display name", () => { + const written = () => + ( + vi.mocked(prisma.user.update).mock.calls[0][0] as { + data: Record; + } + ).data; + + it("stores a name of only spaces as no name, never as an empty string", async () => { + const result = await applyProfileUpdate(USER_ID, { displayName: " " }); + expect(result.ok).toBe(true); + expect(written().displayName).toBeNull(); + }); + + it("trims a name before its bound, so padding does not push it over", async () => { + const name = "x".repeat(DISPLAY_NAME_MAX_LENGTH); + const result = await applyProfileUpdate(USER_ID, { + displayName: ` ${name} `, + }); + expect(result.ok).toBe(true); + expect(written().displayName).toBe(name); + }); + + it("refuses a name past the shared bound", async () => { + const result = await applyProfileUpdate(USER_ID, { + displayName: "x".repeat(DISPLAY_NAME_MAX_LENGTH + 1), + }); + expect(result.ok).toBe(false); + }); + + it("takes a name the setup flow accepts, at the same bound", () => { + const name = "x".repeat(DISPLAY_NAME_MAX_LENGTH); + expect( + onboardingCompleteSchema.shape.displayName.safeParse(name).success, + ).toBe(true); + expect( + onboardingCompleteSchema.shape.displayName.safeParse(`${name}x`).success, + ).toBe(false); + // Only spaces reaches the route as "", which it leaves unset. + expect(onboardingCompleteSchema.shape.displayName.parse(" ")).toBe(""); + }); +}); + describe("applyProfileUpdate insurer IK number", () => { it("accepts a valid 9-digit IKNR and writes it field-by-field", async () => { const result = await applyProfileUpdate(USER_ID, { diff --git a/src/lib/auth/profile-update.ts b/src/lib/auth/profile-update.ts index 210431ef6..6d2b43c0a 100644 --- a/src/lib/auth/profile-update.ts +++ b/src/lib/auth/profile-update.ts @@ -3,6 +3,7 @@ * and the iOS client hits `/api/user/profile` PATCH — both funnel through * `applyProfileUpdate` so we don't fork the validation/audit path. */ +import { optionalDisplayNameSchema } from "@/lib/validations/display-name"; import { prisma } from "@/lib/db"; import { auditLog } from "@/lib/auth/audit"; import { profileSchema } from "@/lib/validations/auth"; @@ -23,7 +24,8 @@ import { } from "@/lib/auth/existing-factor-proof"; const extendedProfileSchema = profileSchema.extend({ - displayName: z.string().min(1).max(80).nullable().optional(), + // Trimmed first; "" (or only spaces) clears the name to null below. + displayName: optionalDisplayNameSchema.nullable().optional(), locale: z .enum(["de", "en"], { error: "Locale must be de or en.", diff --git a/src/lib/openapi/routes/profile.ts b/src/lib/openapi/routes/profile.ts index 633a95404..b2a953afa 100644 --- a/src/lib/openapi/routes/profile.ts +++ b/src/lib/openapi/routes/profile.ts @@ -5,6 +5,7 @@ * Schemas come from `src/lib/validations/*` where shared with the * runtime request parsing, so the wire contract stays single-source. */ +import { DISPLAY_NAME_MAX_LENGTH } from "@/lib/validations/display-name"; import { aiUsageState } from "./ai-usage"; import { z } from "zod/v4"; import type { ZodOpenApiObject } from "zod-openapi"; @@ -544,7 +545,14 @@ export const profileUpdateRequest = z .describe( "Stored gender. Null or an empty string clears it; an omitted field is left untouched.", ), - displayName: z.string().min(1).max(80).nullable().optional(), + displayName: z + .string() + .max(DISPLAY_NAME_MAX_LENGTH) + .nullable() + .optional() + .describe( + "Trimmed before its bound is checked. Null, an empty string or only spaces clears it; an omitted field is left untouched.", + ), locale: z.enum(["de", "en"]).nullable().optional(), timezone: z.string().min(1).max(64).optional(), timeFormat: z diff --git a/src/lib/validations/display-name.ts b/src/lib/validations/display-name.ts new file mode 100644 index 000000000..b027ca182 --- /dev/null +++ b/src/lib/validations/display-name.ts @@ -0,0 +1,20 @@ +/** + * The one bound on a display name, for every surface that sets one: the setup + * flow, the profile (web and native), and a managed profile. The setup flow + * used to cap it at 50 while the profile took 80, so a name set later could + * not be typed into the setup form of another device. + */ +import { z } from "zod/v4"; + +export const DISPLAY_NAME_MAX_LENGTH = 80; + +/** + * An optional display name: trimmed BEFORE its bounds are checked, so a value + * of only spaces is the empty string, which every writer stores as "no name" + * (null). It used to be checked untrimmed and trimmed after, which stored an + * empty name for " ". + */ +export const optionalDisplayNameSchema = z + .string() + .trim() + .max(DISPLAY_NAME_MAX_LENGTH); diff --git a/src/lib/validations/managed-profiles.ts b/src/lib/validations/managed-profiles.ts index 88b6333d6..b267c1ec9 100644 --- a/src/lib/validations/managed-profiles.ts +++ b/src/lib/validations/managed-profiles.ts @@ -10,6 +10,7 @@ * refused rather than ignored — and the client types name exactly these fields * for the same reason (`src/lib/queries/use-managed-profiles.ts`). */ +import { DISPLAY_NAME_MAX_LENGTH } from "@/lib/validations/display-name"; import { z } from "zod/v4"; import { isValidTimezone } from "@/lib/tz/format"; @@ -30,7 +31,7 @@ const managedProfileGenderSchema = z.enum(["MALE", "FEMALE", "OTHER"]); /** The fields both bodies below share, with their bounds stated once. */ const managedProfileFields = { - displayName: z.string().trim().min(1).max(80), + displayName: z.string().trim().min(1).max(DISPLAY_NAME_MAX_LENGTH), dateOfBirth: z.iso.date().nullable(), locale: z.enum(["de", "en", "es", "fr", "it", "pl", "ko", "nl"]), timezone: z diff --git a/src/lib/validations/onboarding.ts b/src/lib/validations/onboarding.ts index 5d0f40460..eeef5851c 100644 --- a/src/lib/validations/onboarding.ts +++ b/src/lib/validations/onboarding.ts @@ -11,6 +11,7 @@ * Prisma-free by construction, so the generator script can pull it in * without dragging the server graph along. */ +import { optionalDisplayNameSchema } from "@/lib/validations/display-name"; import { z } from "zod/v4"; /** @@ -41,7 +42,8 @@ export const onboardingCompleteSchema = z.object({ .describe( "The managed record the setup answers were given FOR — the profile \"someone I look after\" created on the confirm screen. When present, the module map and the dashboard order derived from the caller's answers are applied to THAT record and its own setup row is written as finished; the caller's own record is stamped complete and its derivation latched without any module being touched. Honoured only for a managed profile the caller actively guards (otherwise 404 `onboarding.complete.recordNotFound`) and only when the answer to Q1 was `someone-else` (otherwise 422 `onboarding.complete.recordTargetMismatch`); nothing is stamped on a refusal. Absent with Q1 = `someone-else`, the flow completes without deriving anything anywhere.", ), - displayName: z.string().trim().min(1).max(50).optional(), + // Only spaces is no name: trimmed to "", which the route leaves unset. + displayName: optionalDisplayNameSchema.optional(), heightCm: z.number().min(50).max(300).optional(), dateOfBirth: z.string().optional(), // The same three values the profile schema stores. A narrower enum here