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"\)/)