mirror of
https://github.com/goauthentik/authentik.git
synced 2026-06-17 19:09:11 +03:00
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:
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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()}
|
||||
|
||||
Reference in New Issue
Block a user