Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion app/(dashboard)/account/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -104,7 +105,7 @@ export default function AccountPage() {
are facts to read, not objects to select. */}
<dl className="grid gap-x-8 gap-y-4 sm:grid-cols-[max-content_1fr]">
<dt className="text-sm text-muted-foreground">{t("Username")}</dt>
<dd className="font-mono break-all">{info.access_key}</dd>
<dd className="font-mono break-all">{resolveAccountDisplayName(info)}</dd>

<dt className="text-sm text-muted-foreground">{t("Role")}</dt>
<dd className="flex flex-wrap items-center gap-2">
Expand Down
8 changes: 7 additions & 1 deletion components/user/dropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 (
Expand Down
2 changes: 2 additions & 0 deletions hooks/use-account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions lib/account-display.ts
Original file line number Diff line number Diff line change
@@ -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() ?? ""
)
}
25 changes: 25 additions & 0 deletions tests/lib/account-display.test.ts
Original file line number Diff line number Diff line change
@@ -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")
})
9 changes: 9 additions & 0 deletions tests/lib/account-surface.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,15 @@ test("the profile page distinguishes a failed read from an empty profile", () =>
assert.match(source, /<dl /)
assert.match(source, /<dt /)
assert.match(source, /<dd /)
assert.match(source, /resolveAccountDisplayName\(info\)/)
})

test("the user menu prefers OIDC profile metadata without adding another account request", () => {
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", () => {
Expand Down
3 changes: 2 additions & 1 deletion tests/lib/ui-layout-source.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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"\)/)
Expand Down