Skip to content
Closed
Show file tree
Hide file tree
Changes from 13 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
13 changes: 7 additions & 6 deletions web/src/admin/AdminInterface/AboutModal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { globalAK } from "#common/global";
import { ModalButton } from "#elements/buttons/ModalButton";
import { WithBrandConfig } from "#elements/mixins/branding";
import { WithLicenseSummary } from "#elements/mixins/license";
import { renderImage } from "#elements/utils/images";
import { ThemedImage } from "#elements/utils/images";

import { AdminApi, CapabilitiesEnum, LicenseSummaryStatusEnum } from "@goauthentik/api";

Expand Down Expand Up @@ -95,11 +95,12 @@ export class AboutModal extends WithLicenseSummary(WithBrandConfig(ModalButton))
aria-labelledby="modal-title"
>
<div class="pf-c-about-modal-box__brand">
${renderImage(
this.brandingFavicon,
msg("authentik Logo"),
"pf-c-about-modal-box__brand-image",
)}
${ThemedImage({
src: this.brandingFavicon,
alt: msg("authentik Logo"),
className: "pf-c-about-modal-box__brand-image",
theme: this.activeTheme,
})}
</div>
<div class="pf-c-about-modal-box__close">
<button class="pf-c-button pf-m-plain" type="button" @click=${this.close}>
Expand Down
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);
Comment on lines -77 to +81
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

e.g.

accept-language: ja-JP, en-US;q=0.8, en;q=0.5, *;q=0.3


window.addEventListener(LOCALE_STATUS_EVENT, this.#localeStatusListener);
}
Expand Down
2 changes: 1 addition & 1 deletion web/src/common/theme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -338,7 +338,7 @@ export function applyBackgroundImageProperty(
/**
* Returns the root interface element of the page.
*
* @todo Can this be handled with a Lit Mixin?
* @deprecated Use context controllers to access the interface root instead.
*/
export function rootInterface<T extends HTMLElement = HTMLElement>(): T {
const element = document.body.querySelector<T>("[data-test-id=interface-root]");
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,
) {
Comment on lines -201 to 203
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Split the render function from the formatter logic to re-use in the locale stage prompt component.

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.
Comment on lines -160 to +161
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consistent usage of "language tag" (a string) vs locale (a JavaScript Locale object)

* @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 || []),
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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(", ");
}
8 changes: 6 additions & 2 deletions web/src/components/ak-page-navbar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { AKElement } from "#elements/Base";
import { WithBrandConfig } from "#elements/mixins/branding";
import { WithSession } from "#elements/mixins/session";
import { isAdminRoute } from "#elements/router/utils";
import { renderImage } from "#elements/utils/images";
import { ThemedImage } from "#elements/utils/images";

import { msg } from "@lit/localize";
import { css, CSSResult, html, nothing, TemplateResult } from "lit";
Expand Down Expand Up @@ -377,7 +377,11 @@ export class AKPageNavbar
<aside role="presentation" class="brand ${this.open ? "" : "pf-m-collapsed"}">
<a aria-label="${msg("Home")}" href="#/">
<div class="logo">
${renderImage(this.brandingLogo, msg("authentik Logo"), "")}
${ThemedImage({
src: this.brandingLogo,
alt: msg("authentik Logo"),
theme: this.activeTheme,
})}
</div>
</a>
</aside>
Expand Down
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;
Loading
Loading