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
2 changes: 1 addition & 1 deletion web/src/admin/applications/wizard/ContextIdentity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,5 @@ import type { TypeCreate } from "@goauthentik/api";
import { createContext } from "@lit/context";

export const applicationWizardProvidersContext = createContext<TypeCreate[]>(
Symbol.for("ak-application-wizard-providers-context"),
Symbol("ak-application-wizard-providers-context"),
);
8 changes: 4 additions & 4 deletions web/src/common/api/middleware.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { AKRequestPostEvent, APIRequestInfo } from "#common/api/events";
import { autoDetectLanguage } from "#common/ui/locale/utils";
import { formatAcceptLanguageHeader } from "#common/ui/locale/utils";
import { getCookie } from "#common/utils";

import { ConsoleLogger, Logger } from "#logger/browser";
Expand Down Expand Up @@ -74,11 +74,11 @@ export class LocaleMiddleware implements Middleware, Disposable {
return;
}

this.#locale = event.detail.readyLocale;
this.#locale = formatAcceptLanguageHeader(event.detail.readyLocale);
};

constructor(localeHint?: string) {
this.#locale = autoDetectLanguage(localeHint);
constructor(languageTagHint: Intl.UnicodeBCP47LocaleIdentifier) {
this.#locale = formatAcceptLanguageHeader(languageTagHint);

window.addEventListener(LOCALE_STATUS_EVENT, this.#localeStatusListener);
}
Expand Down
65 changes: 35 additions & 30 deletions web/src/common/ui/locale/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,6 @@ import {
import { safeParseLocale } from "#common/ui/locale/utils";

import { msg, str } from "@lit/localize";
import { html } from "lit";
import { repeat } from "lit/directives/repeat.js";

/**
* Safely get a minimized locale ID, with fallback for older browsers.
Expand Down Expand Up @@ -198,34 +196,41 @@ export function formatLocaleDisplayNames(
return entries.sort(createIntlCollator(activeLocaleTag, collatorOptions));
}

export function renderLocaleDisplayNames(
entries: LocaleDisplay[],
activeLocaleTag: TargetLanguageTag | null,
export function formatRelativeLocaleDisplayName(
languageTag: TargetLanguageTag,
localizedDisplayName: string,
relativeDisplayName: string,
) {
return repeat(
entries,
([languageTag]) => languageTag,
([languageTag, localizedDisplayName, relativeDisplayName]) => {
const pseudo = languageTag === PseudoLanguageTag;

const same =
relativeDisplayName &&
normalizeDisplayName(relativeDisplayName) ===
normalizeDisplayName(localizedDisplayName);

let localizedMessage = localizedDisplayName;

if (!same && !pseudo) {
localizedMessage = msg(str`${relativeDisplayName} (${localizedDisplayName})`, {
id: "locale-option-localized-label",
desc: "Locale option label showing the localized language name along with the native language name in parentheses.",
});
}
const pseudo = languageTag === PseudoLanguageTag;

const same =
relativeDisplayName &&
normalizeDisplayName(relativeDisplayName) === normalizeDisplayName(localizedDisplayName);

if (same || pseudo) {
return localizedDisplayName;
}

return msg(str`${relativeDisplayName} (${localizedDisplayName})`, {
id: "locale-option-localized-label",
desc: "Locale option label showing the localized language name along with the native language name in parentheses.",
});
}

/**
* Format the display name for the auto-detect locale option.
*
* @param detectedLocale The detected locale display, if any.
*/
export function formatAutoDetectLocaleDisplayName(detectedLocale?: LocaleDisplay | null) {
const prefix = msg("Auto-detect", {
id: "locale-auto-detect-option",
desc: "Label for the auto-detect locale option in language selection dropdown",
});

if (!detectedLocale) {
return prefix;
}

return html`${pseudo ? html`<hr />` : null}
<option value=${languageTag} ?selected=${languageTag === activeLocaleTag}>
${localizedMessage}
</option>`;
},
);
return `${prefix} (${formatRelativeLocaleDisplayName(...detectedLocale)})`;
}
43 changes: 34 additions & 9 deletions web/src/common/ui/locale/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,8 +157,8 @@ export function getSessionLocale(): string | null {
/**
* Auto-detect the best locale to use from several sources.
*
* @param localeHint An optional locale code hint.
* @param fallbackLocaleCode An optional fallback locale code.
* @param languageTagHint An optional locale code hint.
* @param fallbackLanguageTag An optional fallback locale code.
* @returns The best-matching supported locale code.
*
* @remarks
Expand All @@ -172,8 +172,8 @@ export function getSessionLocale(): string | null {
* 6. The source locale (English)
*/
export function autoDetectLanguage(
localeHint?: string,
fallbackLocaleCode?: string,
languageTagHint?: Intl.UnicodeBCP47LocaleIdentifier,
fallbackLanguageTag?: Intl.UnicodeBCP47LocaleIdentifier,
): TargetLanguageTag {
let localeParam: string | null = null;

Expand All @@ -188,18 +188,18 @@ export function autoDetectLanguage(
const candidates = [
localeParam,
sessionLocale,
localeHint,
self.navigator?.language,
fallbackLocaleCode,
languageTagHint,
...(self.navigator?.languages || []),
fallbackLanguageTag,
].filter((item): item is string => !!item);

const firstSupportedLocale = findSupportedLocale(candidates);

if (!firstSupportedLocale) {
console.debug(`authentik/locale: Falling back to source locale`, {
SourceLanguageTag,
localeHint,
fallbackLocaleCode,
languageTagHint,
fallbackLanguageTag,
candidates,
});

Expand All @@ -208,3 +208,28 @@ export function autoDetectLanguage(

return firstSupportedLocale;
}

/**
* Given a locale code, format it for use in an `Accept-Language` header.
*/
export function formatAcceptLanguageHeader(languageTag: Intl.UnicodeBCP47LocaleIdentifier): string {
const [preferredLanguageTag, ...languageTags] = new Set([
languageTag,
...(self.navigator?.languages || []),
SourceLanguageTag,
"*",
]);

const fallbackCount = languageTags.length;

return [
preferredLanguageTag,
...languageTags.map((tag, idx) => {
const weight = ((fallbackCount - idx) / (fallbackCount + 1)).toFixed(
fallbackCount > 9 ? 2 : 1,
);

return `${tag};q=${weight}`;
}),
].join(", ");
}
2 changes: 1 addition & 1 deletion web/src/components/ak-wizard/WizardContexts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,5 @@ import type { WizardStepState } from "./types.js";
import { createContext } from "@lit/context";

export const wizardStepContext = createContext<WizardStepState>(
Symbol.for("authentik-wizard-step-labels"),
Symbol("authentik-wizard-step-labels"),
);
6 changes: 4 additions & 2 deletions web/src/elements/AuthenticatedInterface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,18 @@ import { NotificationsContextController } from "#elements/controllers/Notificati
import { SessionContextController } from "#elements/controllers/SessionContextController";
import { VersionContextController } from "#elements/controllers/VersionContextController";
import { Interface } from "#elements/Interface";
import { LicenseContext } from "#elements/mixins/license";
import { NotificationsContext } from "#elements/mixins/notifications";
import { SessionContext } from "#elements/mixins/session";
import { VersionContext } from "#elements/mixins/version";

export class AuthenticatedInterface extends Interface {
constructor() {
super();

this.addController(new LicenseContextController(this));
this.addController(new LicenseContextController(this), LicenseContext);
this.addController(new SessionContextController(this), SessionContext);
this.addController(new VersionContextController(this));
this.addController(new VersionContextController(this), VersionContext);
this.addController(new NotificationsContextController(this), NotificationsContext);
}
}
48 changes: 39 additions & 9 deletions web/src/elements/Interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import { ConfigContextController } from "#elements/controllers/ConfigContextCont
import { ContextControllerRegistry } from "#elements/controllers/ContextControllerRegistry";
import { LocaleContextController } from "#elements/controllers/LocaleContextController";
import { ModalOrchestrationController } from "#elements/controllers/ModalOrchestrationController";
import { ReactiveContextController } from "#elements/types";
import { ReactiveContextController } from "#elements/controllers/ReactiveContextController";
import { BrandingContext } from "#elements/mixins/branding";
import { AuthentikConfigContext } from "#elements/mixins/config";

import { Context, ContextType } from "@lit/context";
import { ReactiveController } from "lit";
Expand All @@ -16,6 +18,14 @@ import { ReactiveController } from "lit";
* The base interface element for the application.
*/
export abstract class Interface extends AKElement {
/**
* Private map of controllers to their registry keys.
*
* This is used to track which controllers have been registered,
* and to unregister them when removed.
*/
#registryKeys = new WeakMap<ReactiveController, ContextType<Context<unknown, unknown>>>();

constructor() {
super();

Expand All @@ -24,24 +34,44 @@ export abstract class Interface extends AKElement {
createUIThemeEffect(applyDocumentTheme);

this.addController(new LocaleContextController(this, locale));
this.addController(new ConfigContextController(this, config));
this.addController(new BrandingContextController(this, brand));
this.addController(new ConfigContextController(this, config), AuthentikConfigContext);
this.addController(new BrandingContextController(this, brand), BrandingContext);
this.addController(new ModalOrchestrationController());

this.dataset.testId = "interface-root";
}

public override addController(
controller: ReactiveController,
registryKey?: ContextType<Context<unknown, unknown>>,
): void {
super.addController(controller);
if (controller instanceof ReactiveContextController) {
if (!registryKey) {
throw new TypeError(
`ReactiveContextController (${controller.constructor.name}) requires a registry key.`,
);
}

if (registryKey) {
ContextControllerRegistry.set(registryKey, controller as ReactiveContextController);
if (this.#registryKeys.has(controller)) {
throw new Error(
`Controller (${controller.constructor.name}) is already registered.`,
);
}

this.#registryKeys.set(controller, registryKey);
ContextControllerRegistry.set(registryKey, controller);
}
super.addController(controller);
}

public connectedCallback(): void {
super.connectedCallback();
this.dataset.testId = "interface-root";
public override removeController(controller: ReactiveController): void {
super.removeController(controller);

const registryKey = this.#registryKeys.get(controller);

if (registryKey) {
ContextControllerRegistry.delete(registryKey);
this.#registryKeys.delete(controller);
}
}
}
32 changes: 31 additions & 1 deletion web/src/elements/controllers/ContextControllerRegistry.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,34 @@
import { type ContextControllerRegistryMap } from "#elements/types";

/**
* Check if the environment supports Symbol-keyed WeakMaps.
*
* @see {@link https://caniuse.com/mdn-javascript_builtins_weakmap_symbol_as_keys | Can I use}
*
* @todo Re-evaluate browser coverage after 2027-01-01
*/
function supportsSymbolKeyedWeakMap(): boolean {
const testKey = Symbol("test");
const wm = new WeakMap();

try {
wm.set(testKey, "value");
return wm.has(testKey);
} catch (_error) {
return false;
}
}

/**
* A constructor for either WeakMap or Map, depending on environment support.
*
* @remarks
*
* A preference for `WeakMap` is optional at the moment.
* However, if we ever support short-lived context controllers, such as
*/
const ContextControllerConstructor = supportsSymbolKeyedWeakMap() ? WeakMap : Map;

/**
* A registry of context controllers added to the Interface.
*
Expand All @@ -9,4 +38,5 @@ import { type ContextControllerRegistryMap } from "#elements/types";
*
* This is exported separately to avoid circular dependencies.
*/
export const ContextControllerRegistry = new WeakMap() as ContextControllerRegistryMap;
export const ContextControllerRegistry =
new ContextControllerConstructor() as ContextControllerRegistryMap;
5 changes: 3 additions & 2 deletions web/src/elements/locale/ak-locale-select.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { TargetLanguageTag } from "#common/ui/locale/definitions";
import { formatLocaleDisplayNames, renderLocaleDisplayNames } from "#common/ui/locale/format";
import { formatLocaleDisplayNames } from "#common/ui/locale/format";
import { setSessionLocale } from "#common/ui/locale/utils";

import { AKElement } from "#elements/Base";
import Styles from "#elements/locale/ak-locale-select.css";
import { LocaleOptions } from "#elements/locale/utils";
import { WithCapabilitiesConfig } from "#elements/mixins/capabilities";
import { WithLocale } from "#elements/mixins/locale";

Expand Down Expand Up @@ -159,7 +160,7 @@ export class AKLocaleSelect extends WithLocale(WithCapabilitiesConfig(AKElement)
class="pf-c-form-control ak-m-capitalize"
name="locale"
>
${renderLocaleDisplayNames(entries, activeLocaleTag)}
${LocaleOptions({ entries, activeLocaleTag })}
</select>`;
});
}
Expand Down
36 changes: 36 additions & 0 deletions web/src/elements/locale/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { PseudoLanguageTag, TargetLanguageTag } from "#common/ui/locale/definitions";
import { formatRelativeLocaleDisplayName, LocaleDisplay } from "#common/ui/locale/format";

import type { LitFC, SlottedTemplateResult } from "#elements/types";

import { html } from "lit";
import { repeat } from "lit/directives/repeat.js";

export interface LocaleOptionsProps {
entries: Iterable<LocaleDisplay>;
activeLocaleTag: TargetLanguageTag | null;
}

/**
* Render locale display name options for a select element.
*/
export const LocaleOptions: LitFC<LocaleOptionsProps> = ({ entries, activeLocaleTag }) => {
return repeat(
entries,
([languageTag]) => languageTag,
([languageTag, localizedDisplayName, relativeDisplayName]) => {
const pseudo = languageTag === PseudoLanguageTag;

const localizedMessage = formatRelativeLocaleDisplayName(
languageTag,
localizedDisplayName,
relativeDisplayName,
);

return html`${pseudo ? html`<hr />` : null}
<option value=${languageTag} ?selected=${languageTag === activeLocaleTag}>
${localizedMessage}
</option>`;
},
) as SlottedTemplateResult;
};
Loading
Loading