mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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.
This commit is contained in:
1 parent
a264dee030
commit
03cacc996c
11 files changed
+93
-11
No files matched your search
@@ -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
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
<FieldGuidance
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { DISPLAY_NAME_MAX_LENGTH } from "@/lib/validations/display-name";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Languages, Loader2, Save, User } from "lucide-react";
|
||||
@@ -475,7 +476,7 @@ export function AccountSection() {
|
||||
clearFieldError("displayName");
|
||||
}}
|
||||
placeholder={t("onboarding.baseline.displayNamePlaceholder")}
|
||||
maxLength={80}
|
||||
maxLength={DISPLAY_NAME_MAX_LENGTH}
|
||||
autoComplete="nickname"
|
||||
enterKeyHint="next"
|
||||
aria-invalid={fieldErrors.displayName ? true : undefined}
|
||||
|
||||
@@ -17,6 +17,8 @@ vi.mock("@/lib/auth/audit", () => ({
|
||||
}));
|
||||
|
||||
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<string, unknown>;
|
||||
}
|
||||
).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, {
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user