From cd74db8f0807626afc44a9d3e7a1dc4dc6315218 Mon Sep 17 00:00:00 2001 From: Dominic R Date: Mon, 15 Jun 2026 13:58:56 -0400 Subject: [PATCH] web: refactor account switcher components Agent-thread: https://sdko.org/internal/thr/ak/019ecc63-edee-7db1-8f5b-5073ff5d562f A7k-product: product A7k-product-repo: 3 Co-authored-by: Agent --- web/src/admin/brands/BrandForm.ts | 44 ++++---- web/src/common/users.ts | 17 +-- .../components/ak-account-switcher-storage.ts | 103 ++++++++++++------ web/src/components/ak-account-switcher.ts | 49 +++++---- web/src/components/ak-nav-buttons.ts | 11 +- 5 files changed, 139 insertions(+), 85 deletions(-) diff --git a/web/src/admin/brands/BrandForm.ts b/web/src/admin/brands/BrandForm.ts index f26322d16d..d4cfed3e2b 100644 --- a/web/src/admin/brands/BrandForm.ts +++ b/web/src/admin/brands/BrandForm.ts @@ -103,6 +103,29 @@ export class BrandForm extends ModelForm { }); } + protected renderAccountSwitchFlowInput(): TemplateResult { + return html` + +

+ ${msg( + "Authentication flow used when switching between accounts signed in on the same browser. If left empty, the default authentication flow is used.", + { id: "brand.form.flow-account-switch.description" }, + )} +

+
`; + } + protected override renderForm(): TemplateResult { const { brandingTitle = "", @@ -255,26 +278,7 @@ export class BrandForm extends ModelForm { )}

- - -

- ${msg( - "Authentication flow used when switching between accounts signed in on the same browser. If left empty, the default authentication flow is used.", - { id: "brand.form.flow-account-switch.description" }, - )} -

-
+ ${this.renderAccountSwitchFlowInput()} identifier && identifier !== primaryLabel, + ) ?? "" + ); } const formatUnknownUserLabel = () => diff --git a/web/src/components/ak-account-switcher-storage.ts b/web/src/components/ak-account-switcher-storage.ts index 7563708dc9..68fd61a155 100644 --- a/web/src/components/ak-account-switcher-storage.ts +++ b/web/src/components/ak-account-switcher-storage.ts @@ -1,3 +1,5 @@ +import { StorageAccessor } from "#common/storage"; + import type { UserSelf } from "@goauthentik/api"; const ACCOUNT_STORAGE_KEY = "authentik.accounts"; @@ -11,7 +13,17 @@ export interface BrowserLocalAccount { isCurrent: boolean; } -function coerceStoredAccount(value: unknown): BrowserLocalAccount | null { +interface StoredAccountsPayload { + accounts?: unknown; +} + +const stringOrEmpty = (value: unknown): string => (typeof value === "string" ? value : ""); + +function accountStorage(): StorageAccessor { + return StorageAccessor.local(ACCOUNT_STORAGE_KEY); +} + +export function coerceStoredAccount(value: unknown): BrowserLocalAccount | null { if (!value || typeof value !== "object") { return null; } @@ -21,35 +33,40 @@ function coerceStoredAccount(value: unknown): BrowserLocalAccount | null { } return { uid: account.uid, - username: typeof account.username === "string" ? account.username : "", - name: typeof account.name === "string" ? account.name : "", - email: typeof account.email === "string" ? account.email : "", - avatar: typeof account.avatar === "string" ? account.avatar : "", + username: stringOrEmpty(account.username), + name: stringOrEmpty(account.name), + email: stringOrEmpty(account.email), + avatar: stringOrEmpty(account.avatar), isCurrent: Boolean(account.isCurrent), }; } -export function readStoredAccounts(): BrowserLocalAccount[] { - try { - const stored = JSON.parse(localStorage.getItem(ACCOUNT_STORAGE_KEY) ?? "{}"); - if (!Array.isArray(stored.accounts)) { - return []; - } - return stored.accounts - .map((account: unknown) => coerceStoredAccount(account)) - .filter((account: BrowserLocalAccount | null): account is BrowserLocalAccount => - Boolean(account), - ); - } catch { +export function coerceStoredAccounts(value: unknown): BrowserLocalAccount[] { + if (!value || typeof value !== "object") { return []; } + + const { accounts } = value as StoredAccountsPayload; + if (!Array.isArray(accounts)) { + return []; + } + + return accounts + .map((account: unknown) => coerceStoredAccount(account)) + .filter((account: BrowserLocalAccount | null): account is BrowserLocalAccount => + Boolean(account), + ); } -export function writeStoredAccounts(accounts: BrowserLocalAccount[]): void { - localStorage.setItem(ACCOUNT_STORAGE_KEY, JSON.stringify({ accounts })); +export function readStoredAccounts(): BrowserLocalAccount[] { + return coerceStoredAccounts(accountStorage().readJSON()); } -function accountFromUser(user: UserSelf): BrowserLocalAccount { +export function writeStoredAccounts(accounts: BrowserLocalAccount[]): boolean { + return accountStorage().writeJSON({ accounts }); +} + +function accountFromUser(user: Readonly): BrowserLocalAccount { return { uid: user.uid, username: user.username, @@ -60,6 +77,39 @@ function accountFromUser(user: UserSelf): BrowserLocalAccount { }; } +function accountMatches( + account: BrowserLocalAccount, + knownUIDs: ReadonlySet, + knownUsernames: ReadonlySet, +): boolean { + return ( + knownUIDs.has(account.uid) || + (account.username !== "" && knownUsernames.has(account.username)) + ); +} + +export function mergeStoredAccounts( + currentAccount: BrowserLocalAccount, + storedAccounts: readonly BrowserLocalAccount[], +): BrowserLocalAccount[] { + const accounts = [currentAccount]; + const knownUIDs = new Set([currentAccount.uid]); + const knownUsernames = new Set(currentAccount.username ? [currentAccount.username] : []); + + for (const account of storedAccounts) { + if (accountMatches(account, knownUIDs, knownUsernames)) { + continue; + } + accounts.push({ ...account, isCurrent: false }); + knownUIDs.add(account.uid); + if (account.username) { + knownUsernames.add(account.username); + } + } + + return accounts; +} + /** * Merge the current user into the stored account list, persist it, and return it. * @@ -70,17 +120,8 @@ export function syncStoredAccounts(user: Readonly | null): BrowserLoca if (!user) { return readStoredAccounts(); } - const accounts = [accountFromUser(user)]; - for (const account of readStoredAccounts()) { - const known = accounts.some( - (existing) => - existing.uid === account.uid || - (account.username && existing.username === account.username), - ); - if (!known) { - accounts.push({ ...account, isCurrent: false }); - } - } + + const accounts = mergeStoredAccounts(accountFromUser(user), readStoredAccounts()); writeStoredAccounts(accounts); return accounts; } diff --git a/web/src/components/ak-account-switcher.ts b/web/src/components/ak-account-switcher.ts index 7b6b55173b..d1d0e0df6c 100644 --- a/web/src/components/ak-account-switcher.ts +++ b/web/src/components/ak-account-switcher.ts @@ -29,20 +29,30 @@ export class AccountSwitcher extends WithSession(AKElement) { protected override willUpdate(changed: PropertyValues): void { super.willUpdate(changed); - this.accounts = syncStoredAccounts(this.currentUser); + if (changed.has("session")) { + this.accounts = syncStoredAccounts(this.currentUser); + } } - protected nextQuery(): string { + protected get nextQuery(): string { const next = `${window.location.pathname}${window.location.search}${window.location.hash}`; return new URLSearchParams({ next }).toString(); } protected accountSwitchURL(account: BrowserLocalAccount): string { - return `${globalAK().api.base}account/switch/${account.uid}/?${this.nextQuery()}`; + return `${globalAK().api.base}account/switch/${account.uid}/?${this.nextQuery}`; } protected addAccountURL(): string { - return `${globalAK().api.base}flows/-/default/authentication/?${this.nextQuery()}`; + return `${globalAK().api.base}flows/-/default/authentication/?${this.nextQuery}`; + } + + protected get currentAccount(): BrowserLocalAccount | undefined { + return this.accounts.find((account) => account.isCurrent); + } + + protected accountLabel(account: BrowserLocalAccount): string { + return formatUserDisplayName(account, this.uiConfig) || account.username; } protected renderAvatar(account?: Pick): SlottedTemplateResult { @@ -57,20 +67,19 @@ export class AccountSwitcher extends WithSession(AKElement) { } protected renderAccount(account: BrowserLocalAccount): SlottedTemplateResult { - const label = formatUserDisplayName(account, this.uiConfig) || account.username; + const label = this.accountLabel(account); const description = formatUserSecondaryIdentifier(account, label); - const content = html` - - ${this.renderAvatar(account)} - - ${label} - ${description ? html`${description}` : null} - - ${account.isCurrent - ? html`` - : null} + + const content = html` + ${this.renderAvatar(account)} + + ${label} + ${description ? html`${description}` : null} - `; + ${account.isCurrent + ? html`` + : null} + `; if (account.isCurrent) { return html`
  • @@ -111,10 +120,8 @@ export class AccountSwitcher extends WithSession(AKElement) { if (!currentUser) { return null; } - const accounts = this.accounts; const displayName = formatUserDisplayName(currentUser, this.uiConfig) || currentUser.username; - const currentAccount = accounts.find((account) => account.isCurrent); return html`
    @@ -126,7 +133,7 @@ export class AccountSwitcher extends WithSession(AKElement) { aria-haspopup="menu" aria-controls="account-switcher-menu" > - ${this.renderAvatar(currentAccount)} + ${this.renderAvatar(this.currentAccount)} ${displayName} - ${accounts.map((account) => this.renderAccount(account))} - ${accounts.length + ${this.accounts.map((account) => this.renderAccount(account))} + ${this.accounts.length ? html`
  • ` : null}
  • diff --git a/web/src/components/ak-nav-buttons.ts b/web/src/components/ak-nav-buttons.ts index 7e004c0353..ec7ba4a66f 100644 --- a/web/src/components/ak-nav-buttons.ts +++ b/web/src/components/ak-nav-buttons.ts @@ -10,6 +10,7 @@ import { globalAK } from "#common/global"; import { AKElement } from "#elements/Base"; import { WithNotifications } from "#elements/mixins/notifications"; import { WithSession } from "#elements/mixins/session"; +import type { SlottedTemplateResult } from "#elements/types"; import Styles from "#components/ak-nav-button.css"; import { AKDrawerChangeEvent } from "#components/notifications/events"; @@ -38,7 +39,7 @@ export class NavigationButtons extends WithNotifications(WithSession(AKElement)) static styles = [PFDisplay, PFBrand, PFPage, PFButton, PFDrawer, PFNotificationBadge, Styles]; - protected renderAPIDrawerTrigger() { + protected renderAPIDrawerTrigger(): SlottedTemplateResult { const { apiDrawer } = this.uiConfig.enabledFeatures; return guard([apiDrawer], () => { @@ -78,7 +79,7 @@ export class NavigationButtons extends WithNotifications(WithSession(AKElement)) }); } - protected renderNotificationDrawerTrigger() { + protected renderNotificationDrawerTrigger(): SlottedTemplateResult { const { notificationDrawer } = this.uiConfig.enabledFeatures; const notificationCount = this.notificationCount; @@ -122,7 +123,7 @@ export class NavigationButtons extends WithNotifications(WithSession(AKElement)) }); } - renderSettings() { + protected renderSettings(): SlottedTemplateResult { if (!this.uiConfig?.enabledFeatures.settings) { return nothing; } @@ -141,7 +142,7 @@ export class NavigationButtons extends WithNotifications(WithSession(AKElement)) `; } - renderImpersonation() { + protected renderImpersonation(): SlottedTemplateResult { if (!this.impersonating) return nothing; const onClick = async () => { @@ -159,7 +160,7 @@ export class NavigationButtons extends WithNotifications(WithSession(AKElement)) `; } - render() { + render(): SlottedTemplateResult { return html`