From 152e407b539a728eaa3566516e94b39479b3398f Mon Sep 17 00:00:00 2001 From: GatewayJ <835269233@qq.com> Date: Fri, 11 Sep 2026 12:40:48 +0800 Subject: [PATCH] fix(account): display OIDC username with fallback Prefer the verified OIDC username in account surfaces, fall back to email when it is unavailable, and retain the existing access key display for older servers and non-OIDC sessions.\n\nReuse the existing accountinfo request for the user menu and cover the fallback order with regression tests. --- app/(dashboard)/account/page.tsx | 3 ++- components/user/dropdown.tsx | 8 +++++++- hooks/use-account.ts | 2 ++ lib/account-display.ts | 14 ++++++++++++++ tests/lib/account-display.test.ts | 25 +++++++++++++++++++++++++ tests/lib/account-surface.test.js | 9 +++++++++ tests/lib/ui-layout-source.test.js | 3 ++- 7 files changed, 61 insertions(+), 3 deletions(-) create mode 100644 lib/account-display.ts create mode 100644 tests/lib/account-display.test.ts diff --git a/app/(dashboard)/account/page.tsx b/app/(dashboard)/account/page.tsx index bbe80904..d96c5494 100644 --- a/app/(dashboard)/account/page.tsx +++ b/app/(dashboard)/account/page.tsx @@ -11,6 +11,7 @@ import { Skeleton } from "@/components/ui/skeleton" import { Page } from "@/components/page" import { PageHeader } from "@/components/page-header" import { useAccount, type AccountInfo } from "@/hooks/use-account" +import { resolveAccountDisplayName } from "@/lib/account-display" import { buildRoute } from "@/lib/routes" export default function AccountPage() { @@ -104,7 +105,7 @@ export default function AccountPage() { are facts to read, not objects to select. */}
{t("Username")}
-
{info.access_key}
+
{resolveAccountDisplayName(info)}
{t("Role")}
diff --git a/components/user/dropdown.tsx b/components/user/dropdown.tsx index 9f1929e6..32b82a5e 100644 --- a/components/user/dropdown.tsx +++ b/components/user/dropdown.tsx @@ -21,6 +21,7 @@ import { import { useAuth } from "@/contexts/auth-context" import { usePermissions } from "@/hooks/use-permissions" import { useSidebar } from "@/components/ui/sidebar" +import { resolveAccountDisplayName } from "@/lib/account-display" import { getThemeManifest } from "@/lib/theme/manifest" function resolveAvatarPath(path: string): string { @@ -68,7 +69,12 @@ export function UserDropdown() { } } - const accountName = (userInfo as { account_name?: string })?.account_name ?? "" + const accountIdentity = userInfo as { account_name?: string; username?: string; email?: string } | null + const accountName = resolveAccountDisplayName({ + access_key: accountIdentity?.account_name ?? "", + username: accountIdentity?.username, + email: accountIdentity?.email, + }) const roleLabel = isAdmin ? t("Administrator") : t("User") return ( diff --git a/hooks/use-account.ts b/hooks/use-account.ts index 0f0eccbe..a2378669 100644 --- a/hooks/use-account.ts +++ b/hooks/use-account.ts @@ -36,6 +36,8 @@ export interface AccountMfaSummary { */ export interface AccountInfo { access_key: string + username?: string + email?: string identity_type: IdentityType session_access_key?: string is_admin: boolean diff --git a/lib/account-display.ts b/lib/account-display.ts new file mode 100644 index 00000000..fabf84af --- /dev/null +++ b/lib/account-display.ts @@ -0,0 +1,14 @@ +export interface AccountDisplayIdentity { + access_key: string + username?: string + email?: string +} + +/** Resolve display-only identity metadata without changing the account principal. */ +export function resolveAccountDisplayName(identity: AccountDisplayIdentity): string { + return ( + [identity.username, identity.email, identity.access_key] + .find((value): value is string => typeof value === "string" && value.trim().length > 0) + ?.trim() ?? "" + ) +} diff --git a/tests/lib/account-display.test.ts b/tests/lib/account-display.test.ts new file mode 100644 index 00000000..07c6317d --- /dev/null +++ b/tests/lib/account-display.test.ts @@ -0,0 +1,25 @@ +import test from "node:test" +import assert from "node:assert/strict" +import { resolveAccountDisplayName } from "../../lib/account-display" + +test("account display name prefers the configured OIDC username", () => { + assert.equal( + resolveAccountDisplayName({ + access_key: "virtual-parent", + username: "j.bruijns@pay.nl", + email: "fallback@pay.nl", + }), + "j.bruijns@pay.nl", + ) +}) + +test("account display name falls back to email when username is absent or blank", () => { + assert.equal( + resolveAccountDisplayName({ access_key: "virtual-parent", username: " ", email: "fallback@pay.nl" }), + "fallback@pay.nl", + ) +}) + +test("account display name remains compatible with responses that only contain access_key", () => { + assert.equal(resolveAccountDisplayName({ access_key: "legacy-account" }), "legacy-account") +}) diff --git a/tests/lib/account-surface.test.js b/tests/lib/account-surface.test.js index a88156c2..4a05462d 100644 --- a/tests/lib/account-surface.test.js +++ b/tests/lib/account-surface.test.js @@ -31,6 +31,15 @@ test("the profile page distinguishes a failed read from an empty profile", () => assert.match(source, /
{ + const source = read("components/user/dropdown.tsx") + + assert.match(source, /account_name\?: string; username\?: string; email\?: string/) + assert.match(source, /resolveAccountDisplayName\(\{/) + assert.doesNotMatch(source, /getAccountInfo\(\)/) }) test("the profile page explains why a root identity cannot be edited here", () => { diff --git a/tests/lib/ui-layout-source.test.js b/tests/lib/ui-layout-source.test.js index e7b69a4d..07573be6 100644 --- a/tests/lib/ui-layout-source.test.js +++ b/tests/lib/ui-layout-source.test.js @@ -57,7 +57,8 @@ test("the account menu names the signed-in identity and its authority", () => { // A menu that opens on an avatar with no name answers neither "who am I" nor // "with what authority", which is the gap this menu existed with before. - assert.match(source, /const accountName = \(userInfo as \{ account_name\?: string \}\)\?\.account_name \?\? ""/) + assert.match(source, /const accountName = resolveAccountDisplayName\(\{/) + assert.match(source, /access_key: accountIdentity\?\.account_name \?\? ""/) assert.match(source, /const roleLabel = isAdmin \? t\("Administrator"\) : t\("User"\)/) assert.match(source, /\{accountName \|\| t\("Unknown user"\)\}/) assert.match(source, /t\("Profile"\)/)