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 <gptagent@svc.sdko.net>
This commit is contained in:
Dominic R
2026-06-15 13:58:56 -04:00
parent 0922efcfcd
commit cd74db8f08
5 changed files with 139 additions and 85 deletions
+24 -20
View File
@@ -103,6 +103,29 @@ export class BrandForm extends ModelForm<Brand, string> {
});
}
protected renderAccountSwitchFlowInput(): TemplateResult {
return html`<ak-form-element-horizontal
label=${msg("Account switch flow", {
id: "brand.form.flow-account-switch.label",
})}
name="flowAccountSwitch"
>
<ak-flow-search
placeholder=${msg("Select an account switch flow...", {
id: "brand.form.flow-account-switch.placeholder",
})}
flowType=${FlowDesignationEnum.Authentication}
.currentFlow=${this.instance?.flowAccountSwitch}
></ak-flow-search>
<p class="pf-c-form__helper-text">
${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" },
)}
</p>
</ak-form-element-horizontal>`;
}
protected override renderForm(): TemplateResult {
const {
brandingTitle = "",
@@ -255,26 +278,7 @@ export class BrandForm extends ModelForm<Brand, string> {
)}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${msg("Account switch flow", {
id: "brand.form.flow-account-switch.label",
})}
name="flowAccountSwitch"
>
<ak-flow-search
placeholder=${msg("Select an account switch flow...", {
id: "brand.form.flow-account-switch.placeholder",
})}
flowType=${FlowDesignationEnum.Authentication}
.currentFlow=${this.instance?.flowAccountSwitch}
></ak-flow-search>
<p class="pf-c-form__helper-text">
${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" },
)}
</p>
</ak-form-element-horizontal>
${this.renderAccountSwitchFlowInput()}
<ak-form-element-horizontal
label=${msg("Invalidation Flow")}
name="flowInvalidation"
+9 -8
View File
@@ -31,14 +31,15 @@ export function formatUserDisplayName(user: UserLike | null, uiConfig?: UIConfig
return label || "";
}
export function formatUserSecondaryIdentifier(user: UserLike, label: string): string {
if (user.email && user.email !== label) {
return user.email;
}
if (user.username && user.username !== label) {
return user.username;
}
return "";
/**
* Pick a secondary identifier that distinguishes a user from the visible label.
*/
export function formatUserSecondaryIdentifier(user: UserLike, primaryLabel: string): string {
return (
[user.email, user.username].find(
(identifier) => identifier && identifier !== primaryLabel,
) ?? ""
);
}
const formatUnknownUserLabel = () =>
@@ -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<StoredAccountsPayload>());
}
function accountFromUser(user: UserSelf): BrowserLocalAccount {
export function writeStoredAccounts(accounts: BrowserLocalAccount[]): boolean {
return accountStorage().writeJSON({ accounts });
}
function accountFromUser(user: Readonly<UserSelf>): BrowserLocalAccount {
return {
uid: user.uid,
username: user.username,
@@ -60,6 +77,39 @@ function accountFromUser(user: UserSelf): BrowserLocalAccount {
};
}
function accountMatches(
account: BrowserLocalAccount,
knownUIDs: ReadonlySet<string>,
knownUsernames: ReadonlySet<string>,
): 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<UserSelf> | 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;
}
+28 -21
View File
@@ -29,20 +29,30 @@ export class AccountSwitcher extends WithSession(AKElement) {
protected override willUpdate(changed: PropertyValues<this>): 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<BrowserLocalAccount, "avatar">): 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`
<span class="pf-c-dropdown__menu-item-main" part="item">
${this.renderAvatar(account)}
<span part="labels">
<span part="name">${label}</span>
${description ? html`<span part="description">${description}</span>` : null}
</span>
${account.isCurrent
? html`<i class="fas fa-check" part="current-indicator" aria-hidden="true"></i>`
: null}
const content = html`<span class="pf-c-dropdown__menu-item-main" part="item">
${this.renderAvatar(account)}
<span part="labels">
<span part="name">${label}</span>
${description ? html`<span part="description">${description}</span>` : null}
</span>
`;
${account.isCurrent
? html`<i class="fas fa-check" part="current-indicator" aria-hidden="true"></i>`
: null}
</span>`;
if (account.isCurrent) {
return html`<li role="presentation">
@@ -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`<div part="container">
<ak-dropdown class="pf-c-dropdown" part="switcher">
@@ -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)}
<span part="toggle-label">${displayName}</span>
<i
class="fas fa-caret-down pf-c-dropdown__toggle-icon"
@@ -142,8 +149,8 @@ export class AccountSwitcher extends WithSession(AKElement) {
aria-labelledby="account-switcher-toggle"
tabindex="-1"
>
${accounts.map((account) => this.renderAccount(account))}
${accounts.length
${this.accounts.map((account) => this.renderAccount(account))}
${this.accounts.length
? html`<li class="pf-c-dropdown__separator" role="separator"></li>`
: null}
<li role="presentation">
+6 -5
View File
@@ -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))
</div>`;
}
renderImpersonation() {
protected renderImpersonation(): SlottedTemplateResult {
if (!this.impersonating) return nothing;
const onClick = async () => {
@@ -159,7 +160,7 @@ export class NavigationButtons extends WithNotifications(WithSession(AKElement))
</div>`;
}
render() {
render(): SlottedTemplateResult {
return html`<div role="presentation" class="pf-c-page__header-tools">
<div class="pf-c-page__header-tools-group">
${this.renderAPIDrawerTrigger()}