diff --git a/src/app/insights/page-client.tsx b/src/app/insights/page-client.tsx index de0233ea3..fb3c162be 100644 --- a/src/app/insights/page-client.tsx +++ b/src/app/insights/page-client.tsx @@ -7,6 +7,7 @@ import Link from "next/link"; import { SlidersHorizontal, TrendingUp } from "lucide-react"; import { useAuth } from "@/hooks/use-auth"; +import { accountName } from "@/lib/account-name"; import { queryKeys } from "@/lib/query-keys"; import { apiGet } from "@/lib/api/api-fetch"; // Bare number — its own React- and label-map-free module, so importing it here @@ -419,10 +420,9 @@ export default function InsightsPageClient() { // boundary hydrates, and a greeting that personalises during the // hydration render disagrees with the name-less SSR text — React // #418. See `useMounted`. + const shownName = user ? accountName(user).trim() : ""; const heroGreetingName = - mounted && user?.username?.trim() && user.username.trim().length > 0 - ? user.username.split(/\s+/)[0] - : null; + mounted && shownName.length > 0 ? shownName.split(/\s+/)[0] : null; // The advisor read only fires while the briefing is available, so with it // unavailable these stay null: the hero keeps its fallback subtitle and the // trends row its built-in captions. diff --git a/src/components/dashboard/dashboard-header.tsx b/src/components/dashboard/dashboard-header.tsx index 1a7308bff..c4e70a63b 100644 --- a/src/components/dashboard/dashboard-header.tsx +++ b/src/components/dashboard/dashboard-header.tsx @@ -32,6 +32,7 @@ import { import { PageHeader } from "@/components/ui/page-header"; import { useTranslations } from "@/lib/i18n/context"; import { useAuth } from "@/hooks/use-auth"; +import { accountName } from "@/lib/account-name"; import { useMounted } from "@/hooks/use-mounted"; import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; import { hourInTz } from "@/lib/tz/format"; @@ -143,7 +144,7 @@ export function DashboardHeader({ const greeting = greetingLine({ greeting: timeGreeting, known: mounted && user != null, - name: user?.username?.trim() ? user.username : null, + name: user ? accountName(user).trim() || null : null, createdAt: user?.createdAt, }); const welcomeText = t(greeting.key, greeting.values); diff --git a/src/components/layout/__tests__/sidebar-nav.test.tsx b/src/components/layout/__tests__/sidebar-nav.test.tsx index c6d519608..a79eda4d1 100644 --- a/src/components/layout/__tests__/sidebar-nav.test.tsx +++ b/src/components/layout/__tests__/sidebar-nav.test.tsx @@ -18,6 +18,7 @@ const mockUserRef = { value: { id: "u1", username: "testuser", + displayName: null as string | null, email: "user@example.com", role: "USER" as "USER" | "ADMIN", avatarUrl: null, @@ -119,6 +120,25 @@ const GATED_HREFS = [ "/achievements", ] as const; +describe(" account identity", () => { + afterEach(() => { + mockUserRef.value = { ...mockUserRef.value, displayName: null }; + }); + + it("names the account by its display name when one is set", () => { + mockUserRef.value = { ...mockUserRef.value, displayName: "Jane Doe" }; + const html = render(); + expect(html).toContain(">Jane Doe

"); + expect(html).toContain(">JD"); + expect(html).not.toContain(">testuser

"); + }); + + it("falls back to the username without a display name", () => { + const html = render(); + expect(html).toContain(">testuser

"); + }); +}); + describe(" first-paint fail-closed module gate (v1.25.12)", () => { it("renders core destinations on first paint regardless of the module map", () => { const maps: Record[] = [ diff --git a/src/components/layout/sidebar-nav.tsx b/src/components/layout/sidebar-nav.tsx index ff2c62444..28062cbb8 100644 --- a/src/components/layout/sidebar-nav.tsx +++ b/src/components/layout/sidebar-nav.tsx @@ -40,6 +40,7 @@ import { import { cn } from "@/lib/utils"; import { Logo } from "@/components/ui/logo"; import { useAuth, useLogout } from "@/hooks/use-auth"; +import { accountName } from "@/lib/account-name"; import { useIsMobile } from "@/hooks/use-is-mobile"; import { useNavModules } from "@/hooks/use-nav-modules"; import { useMounted } from "@/hooks/use-mounted"; @@ -153,6 +154,7 @@ function SidebarUserSection({ collapsed }: { collapsed: boolean }) { const sharedRecord = user?.accountAccess?.active != null; if (!user) return null; + const name = accountName(user); const themeIcon = theme === "system" ? ( @@ -166,13 +168,13 @@ function SidebarUserSection({ collapsed }: { collapsed: boolean }) { const identityBlock = ( <> - {avatarUrl && } + {avatarUrl && } - {getInitials(user.username)} + {getInitials(name)}
-

{user.username}

+

{name}

{user.email && (

{user.email}

)} @@ -282,11 +284,9 @@ function SidebarUserSection({ collapsed }: { collapsed: boolean }) { {t("nav.userMenu")} @@ -311,13 +311,13 @@ function SidebarUserSection({ collapsed }: { collapsed: boolean }) { label={t("nav.accountSettings")} >
-

{user.username}

+

{name}

{user.email && (

{user.email} diff --git a/src/components/layout/top-bar.tsx b/src/components/layout/top-bar.tsx index 23988f100..4d499d1a2 100644 --- a/src/components/layout/top-bar.tsx +++ b/src/components/layout/top-bar.tsx @@ -26,6 +26,7 @@ import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Skeleton } from "@/components/ui/skeleton"; import Link from "next/link"; import { useAuth, useLogout } from "@/hooks/use-auth"; +import { accountName } from "@/lib/account-name"; import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; import { useTheme } from "@/components/providers"; import { useTranslations } from "@/lib/i18n/context"; @@ -143,13 +144,13 @@ export function TopBar() { {t("nav.userMenu")} - {user.username} + {accountName(user)} diff --git a/src/lib/__tests__/account-name.test.ts b/src/lib/__tests__/account-name.test.ts new file mode 100644 index 000000000..3d080ebb1 --- /dev/null +++ b/src/lib/__tests__/account-name.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; + +import { accountName } from "../account-name"; + +describe("accountName", () => { + it("names the account by its display name when one is set", () => { + expect(accountName({ username: "jdoe", displayName: "Jane" })).toBe("Jane"); + }); + + it("trims the display name it shows", () => { + expect(accountName({ username: "jdoe", displayName: " Jane " })).toBe( + "Jane", + ); + }); + + it("falls back to the username when the display name is empty or absent", () => { + expect(accountName({ username: "jdoe", displayName: " " })).toBe("jdoe"); + expect(accountName({ username: "jdoe", displayName: null })).toBe("jdoe"); + expect(accountName({ username: "jdoe" })).toBe("jdoe"); + }); +}); diff --git a/src/lib/account-name.ts b/src/lib/account-name.ts new file mode 100644 index 000000000..2340acaa6 --- /dev/null +++ b/src/lib/account-name.ts @@ -0,0 +1,13 @@ +/** + * The name the app shows and addresses an account by: the display name when + * one is set, the username otherwise. Greetings, the top bar and the sidebar + * all read it here, so the display name edited on the profile card reaches + * every surface that names the account. + */ +export function accountName(user: { + username: string; + displayName?: string | null; +}): string { + const displayName = user.displayName?.trim(); + return displayName ? displayName : user.username; +}