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`