Compare commits

...

6 Commits

Author SHA1 Message Date
Teffen Ellis
dea6e5d81a Fix typos. 2025-12-02 16:34:46 +01:00
Teffen Ellis
e9ebbc080c Clarify comment. 2025-12-02 16:34:46 +01:00
Teffen Ellis
be1052d428 Add additional regional codes. 2025-12-02 16:34:46 +01:00
Teffen Ellis
21cb17ca70 Remove comment. 2025-12-02 16:34:46 +01:00
Teffen Ellis
f646fa82fb Fix Han pattern. 2025-12-02 16:34:46 +01:00
Teffen Ellis
9982862826 web: Flesh out locale context. 2025-12-02 16:34:45 +01:00
29 changed files with 606 additions and 567 deletions

View File

@@ -1,9 +1,11 @@
{% load static %}
{% load i18n %}
{% load authentik_core %}
{% get_current_language as LANGUAGE_CODE %}
<!DOCTYPE html>
<html
lang="{{ LANGUAGE_CODE }}"
data-theme="{% if ui_theme == "dark" %}dark{% else %}light{% endif %}"
data-theme-choice="{% if ui_theme == "dark" %}dark{% elif ui_theme == "light" %}light{% else %}auto{% endif %}"
>
@@ -15,6 +17,7 @@
<title>{% block title %}{% trans title|default:brand.branding_title %}{% endblock %}</title>
<link rel="icon" href="{{ brand.branding_favicon_url }}">
<link rel="shortcut icon" href="{{ brand.branding_favicon_url }}">
{% block head_before %}
{% endblock %}

View File

@@ -36,39 +36,37 @@
<ak-skip-to-content></ak-skip-to-content>
<ak-message-container></ak-message-container>
<ak-locale-context>
<div class="pf-c-page__drawer">
<div class="pf-c-drawer pf-m-collapsed" id="flow-drawer">
<div class="pf-c-drawer__main">
<div class="pf-c-drawer__content">
<div class="pf-c-drawer__body">
<ak-flow-executor
slug="{{ flow.slug }}"
class="pf-c-login"
data-layout="{{ flow.layout|default:'stacked' }}"
>
{% include "base/placeholder.html" %}
<div class="pf-c-page__drawer">
<div class="pf-c-drawer pf-m-collapsed" id="flow-drawer">
<div class="pf-c-drawer__main">
<div class="pf-c-drawer__content">
<div class="pf-c-drawer__body">
<ak-flow-executor
slug="{{ flow.slug }}"
class="pf-c-login"
data-layout="{{ flow.layout|default:'stacked' }}"
>
{% include "base/placeholder.html" %}
<ak-brand-links
slot="footer"
exportparts="list:brand-links-list, list-item:brand-links-list-item"
role="contentinfo"
aria-label="{% trans 'Site footer' %}"
class="pf-c-login__footer {% if flow.layout == 'stacked' %}pf-m-dark{% endif %}"
></ak-brand-links>
</ak-flow-executor>
</div>
<ak-brand-links
slot="footer"
exportparts="list:brand-links-list, list-item:brand-links-list-item"
role="contentinfo"
aria-label="{% trans 'Site footer' %}"
class="pf-c-login__footer {% if flow.layout == 'stacked' %}pf-m-dark{% endif %}"
></ak-brand-links>
</ak-flow-executor>
</div>
<ak-flow-inspector
id="flow-inspector"
data-registration="lazy"
class="pf-c-drawer__panel pf-m-width-33"
slug="{{ flow.slug }}"
></ak-flow-inspector>
</div>
<ak-flow-inspector
id="flow-inspector"
data-registration="lazy"
class="pf-c-drawer__panel pf-m-width-33"
slug="{{ flow.slug }}"
></ak-flow-inspector>
</div>
</div>
</div>
</ak-locale-context>
</div>
{% endblock %}

View File

@@ -1,5 +1,4 @@
import "#admin/AdminInterface/AboutModal";
import "#elements/ak-locale-context/ak-locale-context";
import "#elements/banner/EnterpriseStatusBanner";
import "#elements/banner/VersionBanner";
import "#elements/messages/MessageContainer";
@@ -162,54 +161,52 @@ export class AdminInterface extends WithCapabilitiesConfig(WithSession(Authentic
"pf-m-collapsed": !drawerOpen,
};
return html` <ak-locale-context>
<div class="pf-c-page">
<ak-page-navbar ?open=${this.sidebarOpen}>
<ak-version-banner></ak-version-banner>
<ak-enterprise-status interface="admin"></ak-enterprise-status>
</ak-page-navbar>
return html`<div class="pf-c-page">
<ak-page-navbar ?open=${this.sidebarOpen}>
<ak-version-banner></ak-version-banner>
<ak-enterprise-status interface="admin"></ak-enterprise-status>
</ak-page-navbar>
<ak-sidebar ?hidden=${!this.sidebarOpen} class="${classMap(sidebarClasses)}"
>${renderSidebarItems(createAdminSidebarEntries())}
${this.can(CapabilitiesEnum.IsEnterprise)
? renderSidebarItems(createAdminSidebarEnterpriseEntries())
: nothing}
</ak-sidebar>
<ak-sidebar ?hidden=${!this.sidebarOpen} class="${classMap(sidebarClasses)}"
>${renderSidebarItems(createAdminSidebarEntries())}
${this.can(CapabilitiesEnum.IsEnterprise)
? renderSidebarItems(createAdminSidebarEnterpriseEntries())
: nothing}
</ak-sidebar>
<div class="pf-c-page__drawer">
<div class="pf-c-drawer ${classMap(drawerClasses)}">
<div class="pf-c-drawer__main">
<div class="pf-c-drawer__content">
<div class="pf-c-drawer__body">
<ak-router-outlet
role="presentation"
class="pf-c-page__main"
tabindex="-1"
id="main-content"
defaultUrl="/administration/overview"
.routes=${ROUTES}
>
</ak-router-outlet>
</div>
<div class="pf-c-page__drawer">
<div class="pf-c-drawer ${classMap(drawerClasses)}">
<div class="pf-c-drawer__main">
<div class="pf-c-drawer__content">
<div class="pf-c-drawer__body">
<ak-router-outlet
role="presentation"
class="pf-c-page__main"
tabindex="-1"
id="main-content"
defaultUrl="/administration/overview"
.routes=${ROUTES}
>
</ak-router-outlet>
</div>
<ak-notification-drawer
class="pf-c-drawer__panel pf-m-width-33 ${this
.notificationDrawerOpen
? ""
: "display-none"}"
?hidden=${!this.notificationDrawerOpen}
></ak-notification-drawer>
<ak-api-drawer
class="pf-c-drawer__panel pf-m-width-33 ${this.apiDrawerOpen
? ""
: "display-none"}"
?hidden=${!this.apiDrawerOpen}
></ak-api-drawer>
<ak-about-modal></ak-about-modal>
</div>
<ak-notification-drawer
class="pf-c-drawer__panel pf-m-width-33 ${this.notificationDrawerOpen
? ""
: "display-none"}"
?hidden=${!this.notificationDrawerOpen}
></ak-notification-drawer>
<ak-api-drawer
class="pf-c-drawer__panel pf-m-width-33 ${this.apiDrawerOpen
? ""
: "display-none"}"
?hidden=${!this.apiDrawerOpen}
></ak-api-drawer>
<ak-about-modal></ak-about-modal>
</div>
</div></div
></ak-locale-context>`;
</div>
</div>
</div>`;
}
}

View File

@@ -4,11 +4,10 @@ import {
LocaleMiddleware,
LoggingMiddleware,
} from "#common/api/middleware";
import { EVENT_LOCALE_REQUEST } from "#common/constants";
import { globalAK } from "#common/global";
import { SentryMiddleware } from "#common/sentry/middleware";
import { Config, Configuration, CoreApi, CurrentBrand, RootApi } from "@goauthentik/api";
import { Config, Configuration, CurrentBrand, RootApi } from "@goauthentik/api";
let globalConfigPromise: Promise<Config> | undefined = Promise.resolve(globalAK().config);
export function config(): Promise<Config> {
@@ -35,34 +34,6 @@ export function brandSetFavicon(brand: CurrentBrand) {
});
}
export function brandSetLocale(brand: CurrentBrand) {
if (brand.defaultLocale === "") {
return;
}
console.debug("authentik/locale: setting locale from brand default");
window.dispatchEvent(
new CustomEvent(EVENT_LOCALE_REQUEST, {
composed: true,
bubbles: true,
detail: { locale: brand.defaultLocale },
}),
);
}
let globalBrandPromise: Promise<CurrentBrand> | undefined = Promise.resolve(globalAK().brand);
export function brand(): Promise<CurrentBrand> {
if (!globalBrandPromise) {
globalBrandPromise = new CoreApi(DEFAULT_CONFIG)
.coreBrandsCurrentRetrieve()
.then((brand) => {
brandSetFavicon(brand);
brandSetLocale(brand);
return brand;
});
}
return globalBrandPromise;
}
export const DEFAULT_CONFIG = new Configuration({
basePath: `${globalAK().api.base}api/v3`,
middleware: [

View File

@@ -35,7 +35,6 @@ export const EVENT_API_DRAWER_TOGGLE = "ak-api-drawer-toggle";
export const EVENT_FLOW_INSPECTOR_TOGGLE = "ak-flow-inspector-toggle";
export const EVENT_WS_MESSAGE = "ak-ws-message";
export const EVENT_FLOW_ADVANCE = "ak-flow-advance";
export const EVENT_LOCALE_CHANGE = "ak-locale-change";
export const EVENT_LOCALE_REQUEST = "ak-locale-request";
export const EVENT_REQUEST_POST = "ak-request-post";
export const EVENT_MESSAGE = "ak-message";

View File

@@ -0,0 +1,147 @@
import { type allLocales, sourceLocale } from "../../../locale-codes.js";
import type { LocaleModule } from "@lit/localize";
import { msg } from "@lit/localize";
export type TargetLocale = (typeof allLocales)[number];
/**
* A dummy locale module representing the source locale (English).
*
* @remarks
* This is used to satisfy the return type of {@linkcode LocaleLoaderRecord}
* for the source locale, which does not need to be loaded.
*/
const sourceTargetModule: LocaleModule = {
templates: {},
};
/**
* A record mapping locale codes to their respective human-readable labels.
*
* @remarks
* These are thunked functions to allow for localization via `msg()`.
*/
export const LocaleLabelRecord: Record<TargetLocale, () => string> = {
[sourceLocale]: () => msg("English"),
pseudo_LOCALE: () => msg("Pseudolocale (for testing)"),
cs_CZ: () => msg("Czech"),
de_DE: () => msg("German"),
es_ES: () => msg("Spanish"),
fr_FR: () => msg("French"),
it_IT: () => msg("Italian"),
ja_JP: () => msg("Japanese"),
ko_KR: () => msg("Korean"),
nl_NL: () => msg("Dutch"),
pl_PL: () => msg("Polish"),
ru_RU: () => msg("Russian"),
tr_TR: () => msg("Turkish"),
zh_Hans: () => msg("Chinese (simplified)"),
zh_Hant: () => msg("Chinese (traditional)"),
};
/**
* A tuple representing a locale label and its corresponding code.
*/
export type LocaleOption = [label: string, code: TargetLocale];
/**
* Format the locale options for use in a user-facing element.
*
* @param locales locales argument for locale-sensitive sorting.
* @param collatorOptions Optional collator options for locale-sensitive sorting.
* @returns An array of locale options sorted by their labels.
*/
export function formatLocaleOptions(
locales?: Intl.LocalesArgument,
collatorOptions?: Intl.CollatorOptions,
): LocaleOption[] {
const options = Object.entries(LocaleLabelRecord)
.map(([code, label]) => {
return [label(), code];
})
.sort(([aLabel], [bLabel]) => aLabel.localeCompare(bLabel, locales, collatorOptions));
return options as LocaleOption[];
}
/**
* A record mapping locale codes to their respective module loaders.
*
* @remarks
* The `import` statements **must** reference a locale module path,
* as this is how ESBuild identifies which files to include in the build.
*/
export const LocaleLoaderRecord: Record<TargetLocale, () => Promise<LocaleModule>> = {
[sourceLocale]: () => Promise.resolve(sourceTargetModule),
pseudo_LOCALE: () => import("#locales/pseudo_LOCALE"),
cs_CZ: () => import("#locales/cs_CZ"),
de_DE: () => import("#locales/de_DE"),
es_ES: () => import("#locales/es_ES"),
fr_FR: () => import("#locales/fr_FR"),
it_IT: () => import("#locales/it_IT"),
ja_JP: () => import("#locales/ja_JP"),
ko_KR: () => import("#locales/ko_KR"),
nl_NL: () => import("#locales/nl_NL"),
pl_PL: () => import("#locales/pl_PL"),
ru_RU: () => import("#locales/ru_RU"),
tr_TR: () => import("#locales/tr_TR"),
zh_Hans: () => import("#locales/zh_Hans"),
zh_Hant: () => import("#locales/zh_Hant"),
};
/**
* A record mapping locale codes to their respective regex patterns.
*
* @remarks
* While this isn't too useful on its own, we use it to build the {@linkcode LocalePatternCodeMap}
* while ensuring that TypeScript can verify that all locale codes are covered.
*
* The matchers try to conform loosely to [RFC 5646](https://www.rfc-editor.org/rfc/rfc5646.txt),
* "Tags for the Identification of Languages."
* In practice, language tags have been seen using both hyphens and underscores.
*
* Chinese language (`zh` or _Zhongwen_) can have a subtag indicating script:
*
* - `Hans`: Simplified
* - `Hant`: Traditional
*
* Alternatively, the subtag can indicate a region with a predominant script.
* The fallback is simplified Chinese.
*/
export const LocalePatternRecord: Record<TargetLocale, RegExp> = {
[sourceLocale]: /^en([_-]|$)/i,
pseudo_LOCALE: /^pseudo/i,
cs_CZ: /^cs([_-]|$)/i,
de_DE: /^de([_-]|$)/i,
es_ES: /^es([_-]|$)/i,
fr_FR: /^fr([_-]|$)/i,
it_IT: /^it([_-]|$)/i,
ja_JP: /^ja([_-]|$)/i,
ko_KR: /^ko([_-]|$)/i,
nl_NL: /^nl([_-]|$)/i,
pl_PL: /^pl([_-]|$)/i,
ru_RU: /^ru([_-]|$)/i,
tr_TR: /^tr([_-]|$)/i,
/**
* Traditional Chinese.
*
* The region subtag is required.
*/
zh_Hant: /^zh[_-](TW|HK|MO|Hant)/i,
/**
* Simplified Chinese.
*
* The region subtag is optional.
*/
zh_Hans: /^zh([_-](CN|SG|MY|Hans)|$)/i,
};
/**
* A mapping of regex patterns to locale codes for matching user-supplied locale strings.
*
* @see {@linkcode LocalePatternRecord} for the source of this map.
*/
export const LocalePatternCodeMap = new Map<RegExp, TargetLocale>(
Object.entries(LocalePatternRecord).map(([code, pattern]) => [pattern, code as TargetLocale]),
);

View File

@@ -1,10 +0,0 @@
import type { LocaleModule } from "@lit/localize";
export type LocaleRow = [string, RegExp, () => string, () => Promise<LocaleModule>];
export type AkLocale = {
code: string;
match: RegExp;
label: () => string;
locale: () => Promise<LocaleModule>;
};

View File

@@ -0,0 +1,74 @@
import { sourceLocale } from "../../../locale-codes.js";
import { LocalePatternCodeMap, TargetLocale } from "#common/ui/locale/definitions";
export function getBestMatchLocale(locale: string): TargetLocale | null {
const [, localeCode] =
Iterator.from(LocalePatternCodeMap).find(([pattern]) => pattern.test(locale)) || [];
return localeCode ?? null;
}
/**
* Find the first supported locale from a list of candidates.
*
* @param candidates An array of locale strings to check.
* @returns The first supported locale code, or null if none found.
*
* @remarks
* This looks weird, but it's sensible: we have several candidates, and we want to find the first
* one that has a supported locale. Then, from *that*, we have to extract that first supported
* locale.
*/
export function findSupportedLocale(candidates: string[]): TargetLocale | null {
const candidate = candidates.find((candidate) => getBestMatchLocale(candidate));
return candidate ? getBestMatchLocale(candidate) : 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.
* @returns The best-matching supported locale code.
*
* @remarks
* The order of precedence is:
*
* 1. A `locale` URL parameter
* 2. A provided locale hint
* 3. The browser's navigator language
* 4. A provided fallback locale code
* 5. The source locale (English)
*/
export function autoDetectLanguage(localeHint?: string, fallbackLocaleCode?: string): TargetLocale {
let localeParam: string | null = null;
if (self.location) {
const searchParam = new URLSearchParams(self.location.search);
localeParam = searchParam.get("locale");
}
const candidates = [
localeParam,
localeHint,
self.navigator?.language,
fallbackLocaleCode,
].filter((item): item is string => !!item);
const firstSupportedLocale = findSupportedLocale(candidates);
if (!firstSupportedLocale) {
console.debug(`authentik/locale: Falling back to source locale`, {
sourceLocale,
localeHint,
fallbackLocaleCode,
candidates,
});
return sourceLocale;
}
return firstSupportedLocale;
}

View File

@@ -1,5 +1,4 @@
import { DEFAULT_CONFIG } from "#common/api/config";
import { EVENT_LOCALE_REQUEST } from "#common/constants";
import { isResponseErrorLike } from "#common/errors/network";
import { UIConfig, UserDisplay } from "#common/ui/config";
@@ -85,23 +84,6 @@ export async function me(requestInit?: RequestInit): Promise<SessionUser> {
return new CoreApi(DEFAULT_CONFIG)
.coreUsersMeRetrieve(requestInit)
.then((nextSession) => {
const locale: string | undefined = nextSession.user.settings.locale;
if (locale) {
console.debug(`authentik/locale: Activating user's configured locale '${locale}'`);
window.dispatchEvent(
new CustomEvent(EVENT_LOCALE_REQUEST, {
composed: true,
bubbles: true,
detail: { locale },
}),
);
}
return nextSession;
})
.catch(async (error: unknown) => {
if (isResponseErrorLike(error)) {
const { response } = error;

View File

@@ -4,6 +4,7 @@ import { applyDocumentTheme, createUIThemeEffect } from "#common/theme";
import { AKElement } from "#elements/Base";
import { BrandingContextController } from "#elements/controllers/BrandContextController";
import { ConfigContextController } from "#elements/controllers/ConfigContextController";
import { LocaleContextController } from "#elements/controllers/LocaleContextController";
import { ModalOrchestrationController } from "#elements/controllers/ModalOrchestrationController";
/**
@@ -13,10 +14,11 @@ export abstract class Interface extends AKElement {
constructor() {
super();
const { config, brand } = globalAK();
const { config, brand, locale } = globalAK();
createUIThemeEffect(applyDocumentTheme);
this.addController(new LocaleContextController(this, locale));
this.addController(new ConfigContextController(this, config));
this.addController(new BrandingContextController(this, brand));
this.addController(new ModalOrchestrationController());

View File

@@ -1,52 +0,0 @@
import "./ak-locale-context.js";
import { EVENT_LOCALE_REQUEST } from "#common/constants";
import { customEvent } from "#elements/utils/customEvents";
import { localized, msg } from "@lit/localize";
import { html, LitElement } from "lit";
import { customElement } from "lit/decorators.js";
export default {
title: "Elements / Shell / Locale Context",
};
@localized()
@customElement("ak-locale-demo-component")
export class AKLocaleDemoComponent extends LitElement {
render() {
return html`<span>${msg("Everything is ok.")}</span>`;
}
}
@localized()
@customElement("ak-locale-sensitive-demo-component")
export class AKLocaleSensitiveDemoComponent extends LitElement {
render() {
return html`<p>${msg("Everything is ok.")}</p>`;
}
}
export const InFrench = () =>
html`<div style="padding: 4em">
<ak-locale-context locale="fr"
><ak-locale-demo-component
>Everything is not ok.</ak-locale-demo-component
></ak-locale-context
>
</div>`;
export const SwitchingBackAndForth = () => {
let lang = "en";
window.setInterval(() => {
lang = lang === "en" ? "fr" : "en";
window.dispatchEvent(customEvent(EVENT_LOCALE_REQUEST, { locale: lang }));
}, 1000);
return html`<div style="padding: 4em">
<ak-locale-context locale="fr">
<ak-locale-sensitive-demo-component></ak-locale-sensitive-demo-component
></ak-locale-context>
</div>`;
};

View File

@@ -1,104 +0,0 @@
import type { LocaleGetter, LocaleSetter } from "./configureLocale.js";
import { initializeLocalization } from "./configureLocale.js";
import { autoDetectLanguage, DEFAULT_LOCALE, getBestMatchLocale } from "./helpers.js";
import { EVENT_LOCALE_CHANGE, EVENT_LOCALE_REQUEST } from "#common/constants";
import { globalAK } from "#common/global";
import { AKElement } from "#elements/Base";
import { customEvent } from "#elements/utils/customEvents";
import { customElement, property } from "lit/decorators.js";
/**
* A component to manage your locale settings.
*
* ## Details
*
* This component exists to take a locale setting from several different places, find the
* appropriate locale file in our catalog of locales, and set the lit-localization context
* appropriately. If that works, it sends off an event saying so.
*
* @element ak-locale-context
* @slot - The content which consumes this context
* @fires ak-locale-change - When a valid locale has been swapped in
*/
@customElement("ak-locale-context")
export class LocaleContext extends AKElement {
protected createRenderRoot(): HTMLElement | DocumentFragment {
return this;
}
/// @attribute The text representation of the current locale */
@property({ attribute: true, type: String, useDefault: true })
public locale = globalAK().locale || DEFAULT_LOCALE;
/// @attribute The URL parameter to look for (if any)
@property({ attribute: true, type: String, useDefault: true })
public param = "locale";
getLocale: LocaleGetter;
setLocale: LocaleSetter;
constructor(code = DEFAULT_LOCALE) {
super();
this.notifyApplication = this.notifyApplication.bind(this);
this.updateLocaleHandler = this.updateLocaleHandler.bind(this);
try {
const [getLocale, setLocale] = initializeLocalization();
this.getLocale = getLocale;
this.setLocale = setLocale;
this.setLocale(code).then(() => {
window.setTimeout(this.notifyApplication, 0);
});
} catch (e) {
throw new Error(`Developer error: Must have only one locale context per session: ${e}`);
}
}
connectedCallback() {
super.connectedCallback();
this.updateLocale();
window.addEventListener(EVENT_LOCALE_REQUEST, this.updateLocaleHandler as EventListener);
}
disconnectedCallback() {
window.removeEventListener(EVENT_LOCALE_REQUEST, this.updateLocaleHandler as EventListener);
super.disconnectedCallback();
}
updateLocaleHandler(ev: CustomEvent<{ locale: string }>) {
console.debug("authentik/locale: Locale update request received.");
this.updateLocale(ev.detail.locale);
}
updateLocale(requestedLocale: string | undefined = undefined) {
const localeRequest = autoDetectLanguage(requestedLocale, this.locale);
const locale = getBestMatchLocale(localeRequest);
if (!locale) {
console.warn(`authentik/locale: failed to find locale for code ${localeRequest}`);
return;
}
locale.locale().then(() => {
console.debug(`authentik/locale: Setting Locale to ${locale.label()} (${locale.code})`);
this.setLocale(locale.code).then(() => {
window.setTimeout(this.notifyApplication, 0);
});
});
}
notifyApplication() {
// You will almost never have cause to catch this event. Lit's own `@localized()` decorator
// works just fine for almost every use case.
this.dispatchEvent(customEvent(EVENT_LOCALE_CHANGE));
}
}
export default LocaleContext;
declare global {
interface HTMLElementTagNameMap {
"ak-locale-context": LocaleContext;
}
}

View File

@@ -1,39 +0,0 @@
import { sourceLocale, targetLocales } from "../../locale-codes.js";
import { getBestMatchLocale } from "./helpers.js";
import { configureLocalization } from "@lit/localize";
type LocaleGetter = ReturnType<typeof configureLocalization>["getLocale"];
type LocaleSetter = ReturnType<typeof configureLocalization>["setLocale"];
// Internal use only.
//
// This is where the lit-localization module is initialized with our loader, which associates our
// collection of locales with its getter and setter functions.
let getLocale: LocaleGetter | undefined = undefined;
let setLocale: LocaleSetter | undefined = undefined;
export function initializeLocalization(): [LocaleGetter, LocaleSetter] {
if (getLocale && setLocale) {
return [getLocale, setLocale];
}
({ getLocale, setLocale } = configureLocalization({
sourceLocale,
targetLocales,
loadLocale: async (locale: string) => {
const localeDef = getBestMatchLocale(locale);
if (!localeDef) {
console.warn(`Unrecognized locale: ${localeDef}`);
return Promise.reject("");
}
return localeDef.locale();
},
}));
return [getLocale, setLocale];
}
export default initializeLocalization;
export type { LocaleGetter, LocaleSetter };

View File

@@ -1,4 +0,0 @@
import { createContext } from "@lit/context";
export const localeContext = createContext<string>("locale");
export default localeContext;

View File

@@ -1,66 +0,0 @@
import { AkLocale, LocaleRow } from "./types.js";
import type { LocaleModule } from "@lit/localize";
import { msg } from "@lit/localize";
export const DEFAULT_FALLBACK = "en";
export const enLocale: LocaleModule = {
templates: {},
};
// NOTE: This table cannot be made any shorter, despite all the repetition of syntax. Bundlers look
// for the `import` #a *string target* for doing alias substitution, so putting
// the import in some sort of abstracting function doesn't work. The same is true for the `msg()`
// function, which `localize` uses to find strings for extraction and translation. Likewise,
// because this is a file-level table, the `msg()` must be thunked so that they're re-run when
// the user changes the locale.
// NOTE: The matchers try to conform loosely to [RFC
// 5646](https://www.rfc-editor.org/rfc/rfc5646.txt), "Tags for the Identification of Languages." In
// practice, language tags have been seen using both hyphens and underscores, and the Chinese
// language uses both "regional" and "script" suffixes. The regexes use the language and any region
// or script.
//
// Chinese locales usually (but not always) use the script rather than region suffix. The default
// (optional) fallback for Chinese (zh) is "Chinese (simplified)", which is why it has that odd
// regex syntax at the end which means "match zh as long as it's not followed by a [:word:] token";
// Traditional script and the Taiwanese are attempted first, and if neither matches, anything
// beginning with that generic "zh" is mapped to "Chinese (simplified)."
// - Code for Lit/Locale
// - Regex for matching user-supplied locale.
// - Text Label
// - Locale loader.
// prettier-ignore
const debug: LocaleRow = [
"pseudo_LOCALE", /^pseudo/i, () => msg("Pseudolocale (for testing)"), () => import("#locales/pseudo_LOCALE"),
];
// prettier-ignore
const LOCALE_TABLE: LocaleRow[] = [
["de_DE", /^de([_-]|$)/i, () => msg("German"), () => import("#locales/de_DE")],
["en", /^en([_-]|$)/i, () => msg("English"), () => Promise.resolve(enLocale)],
["es_ES", /^es([_-]|$)/i, () => msg("Spanish"), () => import("#locales/es_ES")],
["fr_FR", /^fr([_-]|$)/i, () => msg("French"), () => import("#locales/fr_FR")],
["it_IT", /^it([_-]|$)/i, () => msg("Italian"), () => import("#locales/it_IT")],
["ja_JP", /^ja([_-]|$)/i, () => msg("Japanese"), () => import("#locales/ja_JP")],
["ko_KR", /^ko([_-]|$)/i, () => msg("Korean"), () => import("#locales/ko_KR")],
["nl_NL", /^nl([_-]|$)/i, () => msg("Dutch"), () => import("#locales/nl_NL")],
["pl_PL", /^pl([_-]|$)/i, () => msg("Polish"), () => import("#locales/pl_PL")],
["ru_RU", /^ru([_-]|$)/i, () => msg("Russian"), () => import("#locales/ru_RU")],
["tr_TR", /^tr([_-]|$)/i, () => msg("Turkish"), () => import("#locales/tr_TR")],
["zh_Hans", /^zh(\b|_)/i, () => msg("Chinese (simplified)"), () => import("#locales/zh_Hans")],
["zh_Hant", /^zh[_-](HK|Hant)/i, () => msg("Chinese (traditional)"), () => import("#locales/zh_Hant")],
debug
];
export const LOCALES: AkLocale[] = LOCALE_TABLE.map(([code, match, label, locale]) => ({
code,
match,
label,
locale,
}));
export default LOCALES;

View File

@@ -1,70 +0,0 @@
import { enLocale, LOCALES as RAW_LOCALES } from "./definitions.js";
import { AkLocale } from "./types.js";
import { globalAK } from "#common/global";
export const DEFAULT_LOCALE = "en";
export const EVENT_REQUEST_LOCALE = "ak-request-locale";
const TOMBSTONE = "⛼⛼tombstone⛼⛼";
// NOTE: This is the definition of the LOCALES table that most of the code uses. The 'definitions'
// file is relatively pure, but here we establish that we want the English locale to loaded when an
// application is first instantiated.
export const LOCALES = RAW_LOCALES.map((locale) =>
locale.code === "en" ? { ...locale, locale: async () => enLocale } : locale,
);
export function getBestMatchLocale(locale: string): AkLocale | null {
return LOCALES.find((l) => l.match.test(locale)) || null;
}
// This looks weird, but it's sensible: we have several candidates, and we want to find the first
// one that has a supported locale. Then, from *that*, we have to extract that first supported
// locale.
export function findSupportedLocale(candidates: string[]): AkLocale | null {
const candidate = candidates.find((candidate: string) => getBestMatchLocale(candidate));
return candidate ? getBestMatchLocale(candidate) : null;
}
export function localeCodeFromUrl(param = "locale") {
const url = new URL(window.location.href);
return url.searchParams.get(param) || "";
}
// Get all locales we can, in order
// - Global authentik settings (contains user settings)
// - URL parameter
// - A requested code passed in, if any
// - Navigator
// - Fallback (en)
const isLocaleCandidate = (v: unknown): v is string =>
typeof v === "string" && v !== "" && v !== TOMBSTONE;
export function autoDetectLanguage(userReq = TOMBSTONE, brandReq = TOMBSTONE): string {
const localeCandidates: string[] = [
localeCodeFromUrl("locale"),
userReq,
window.navigator?.language ?? TOMBSTONE,
brandReq,
globalAK()?.locale ?? TOMBSTONE,
DEFAULT_LOCALE,
].filter(isLocaleCandidate);
const firstSupportedLocale = findSupportedLocale(localeCandidates);
if (!firstSupportedLocale) {
console.debug(
`authentik/locale: No locale found for '[${localeCandidates}.join(',')]', falling back to ${DEFAULT_LOCALE}`,
);
return DEFAULT_LOCALE;
}
return firstSupportedLocale.code;
}
export default autoDetectLanguage;

View File

@@ -1,4 +0,0 @@
import LocaleContext from "./ak-locale-context.js";
export { LocaleContext };
export default LocaleContext;

View File

@@ -1,15 +0,0 @@
import type { LocaleModule } from "@lit/localize";
export type LocaleRow = [
code: string,
pattern: RegExp,
label: () => string,
loader: () => Promise<LocaleModule>,
];
export type AkLocale = {
code: string;
match: RegExp;
label: () => string;
locale: () => Promise<LocaleModule>;
};

View File

@@ -0,0 +1,164 @@
import { sourceLocale, targetLocales } from "../../locale-codes.js";
import { LocaleLabelRecord, LocaleLoaderRecord, TargetLocale } from "#common/ui/locale/definitions";
import { autoDetectLanguage } from "#common/ui/locale/utils";
import { kAKLocale, LocaleContext, LocaleMixin } from "#elements/mixins/locale";
import type { ReactiveElementHost } from "#elements/types";
import { ContextProvider } from "@lit/context";
import { configureLocalization } from "@lit/localize";
import type { ReactiveController } from "lit";
/**
* A controller that provides the application configuration to the element.
*/
export class LocaleContextController implements ReactiveController {
protected static DocumentObserverInit: MutationObserverInit = {
attributes: true,
attributeFilter: ["lang"],
attributeOldValue: true,
};
#log = console.debug.bind(console, `authentik/controller/locale`);
/**
* Attempts to apply the given locale code.
* @param nextLocale A user or agent preferred locale code.
*/
#applyLocale(nextLocale: TargetLocale) {
const currentLocale = this.#context.value.getLocale();
const label = LocaleLabelRecord[nextLocale]();
if (currentLocale === nextLocale) {
this.#log("Skipping locale update, already set to:", label);
return;
}
this.#context.value.setLocale(nextLocale);
this.#host.locale = nextLocale;
this.#log("Applied locale:", label);
}
// #region Attribute Observation
/**
* Synchronizes changes to the document's `lang` attribute to the locale context.
*
* @remarks
* While we don't expect the document's `lang` attribute to change outside of
* this controller, we observe it to respect a possible external change,
* such as from the user agent's language settings, or a browser extension which
* modifies the attribute.
*/
#attributeListener = (mutations: MutationRecord[]) => {
for (const mutation of mutations) {
if (mutation.type !== "attributes" || mutation.attributeName !== "lang") {
continue;
}
const attribute = {
previous: mutation.oldValue,
current: document.documentElement.lang,
};
this.#log("Detected document `lang` attribute change", attribute);
if (attribute.previous === attribute.current) {
this.#log("Skipping locale update, `lang` unchanged", attribute);
continue;
}
const nextLocale = autoDetectLanguage(attribute.current);
this.#applyLocale(nextLocale);
return;
}
};
#documentObserver = new MutationObserver(this.#attributeListener);
#connectDocumentObserver() {
this.#documentObserver.observe(
document.documentElement,
LocaleContextController.DocumentObserverInit,
);
}
#disconnectDocumentObserver() {
this.#documentObserver.disconnect();
}
//#endregion
//#region Lifecycle
/**
* Loads the locale module for the given locale code.
*
* @param _locale The locale code to load.
*
* @remarks
* This is used by `@lit/localize` to dynamically load locale modules,
* as well synchronizing the document's `lang` attribute.
*/
#loadLocale = (_locale: string) => {
// TypeScript cannot infer the type here, but Lit Localize will only call this
// function with one of the `targetLocales`.
const locale = _locale as TargetLocale;
const label = LocaleLabelRecord[locale]();
this.#log(`Loading "${label}" module...`);
const loader = LocaleLoaderRecord[locale];
this.#log(`Updating \`lang\` attribute to: \`${locale}\``);
// Prevent observation while we update the `lang` attribute...
this.#disconnectDocumentObserver();
document.documentElement.lang = locale;
this.#connectDocumentObserver();
return loader();
};
#host: ReactiveElementHost<LocaleMixin>;
#context: ContextProvider<LocaleContext>;
/**
* @param host The host element.
* @param localeHint The initial locale code to set.
*/
constructor(host: ReactiveElementHost<LocaleMixin>, localeHint?: string) {
this.#host = host;
const contextValue = configureLocalization({
sourceLocale,
targetLocales,
loadLocale: this.#loadLocale,
});
this.#context = new ContextProvider(this.#host, {
context: LocaleContext,
initialValue: contextValue,
});
this.#host[kAKLocale] = contextValue;
const nextLocale = autoDetectLanguage(localeHint);
if (nextLocale !== sourceLocale) {
this.#applyLocale(nextLocale);
}
}
public hostDisconnected() {
this.#documentObserver.disconnect();
}
//#endregion
}

View File

@@ -1,9 +1,11 @@
import type { APIResult } from "#common/api/responses";
import { EVENT_WS_MESSAGE } from "#common/constants";
import { isCausedByAbortError, parseAPIResponseError } from "#common/errors/network";
import { autoDetectLanguage } from "#common/ui/locale/utils";
import { me } from "#common/users";
import { AKConfigMixin, kAKConfig } from "#elements/mixins/config";
import { kAKLocale, type LocaleMixin } from "#elements/mixins/locale";
import { SessionContext, SessionMixin } from "#elements/mixins/session";
import type { ReactiveElementHost } from "#elements/types";
@@ -23,7 +25,7 @@ export class SessionContextController implements ReactiveController {
#log = console.debug.bind(console, `authentik/controller/session`);
#abortController: null | AbortController = null;
#host: ReactiveElementHost<SessionMixin & AKConfigMixin>;
#host: ReactiveElementHost<LocaleMixin & SessionMixin & AKConfigMixin>;
#context: ContextProvider<SessionContext>;
constructor(
@@ -49,6 +51,14 @@ export class SessionContextController implements ReactiveController {
signal: this.#abortController.signal,
})
.then((session) => {
const localeHint: string | undefined = session.user.settings.locale;
if (localeHint) {
const locale = autoDetectLanguage(localeHint);
this.#log(`Activating user's configured locale '${locale}'`);
this.#host[kAKLocale]?.setLocale(locale);
}
const config = this.#host[kAKConfig];
if (config?.errorReporting.sendPii) {

View File

@@ -0,0 +1,74 @@
import { TargetLocale } from "#common/ui/locale/definitions";
import { createMixin } from "#elements/types";
import { consume, createContext } from "@lit/context";
import type { configureLocalization } from "@lit/localize";
export type LocaleContextValue = ReturnType<typeof configureLocalization>;
export const kAKLocale = Symbol("kAKLocale");
/**
* The Lit context for the application configuration.
*
* @category Context
* @see {@linkcode LocaleMixin}
* @see {@linkcode WithLocale}
*/
export const LocaleContext = createContext<LocaleContextValue>(
Symbol.for("authentik-locale-context"),
);
export type LocaleContext = typeof LocaleContext;
/**
* A consumer that provides session information to the element.
*
* @category Mixin
* @see {@linkcode WithLocale}
*/
export interface LocaleMixin {
/**
* The locale context value.
*
* @internal
*/
readonly [kAKLocale]: Readonly<LocaleContextValue>;
/**
* The current locale code.
*/
locale: TargetLocale;
}
/**
* A mixin that provides locale information to the element.
*
* @category Mixin
*/
export const WithLocale = createMixin<LocaleMixin>(
({
// ---
SuperClass,
subscribe = true,
}) => {
abstract class LocaleProvider extends SuperClass implements LocaleMixin {
@consume({
context: LocaleContext,
subscribe,
})
public [kAKLocale]!: LocaleContextValue;
public get locale(): TargetLocale {
return this[kAKLocale].getLocale() as TargetLocale;
}
public set locale(value: TargetLocale) {
this[kAKLocale].setLocale(value);
}
}
return LocaleProvider;
},
);

View File

@@ -1,5 +1,4 @@
import "#elements/LoadingOverlay";
import "#elements/ak-locale-context/ak-locale-context";
import "#flow/components/ak-brand-footer";
import "#flow/components/ak-flow-card";
import "#flow/sources/apple/AppleLoginInit";

View File

@@ -2,6 +2,7 @@ import { pluckErrorDetail } from "#common/errors/network";
import { AKElement } from "#elements/Base";
import { intersectionObserver } from "#elements/decorators/intersection-observer";
import { WithLocale } from "#elements/mixins/locale";
import { FocusTarget } from "#elements/utils/focus";
import { ContextualFlowInfo, CurrentBrand, ErrorDetail } from "@goauthentik/api";
@@ -56,7 +57,7 @@ export interface ResponseErrorsChallenge {
export abstract class BaseStage<
Tin extends FlowInfoChallenge & PendingUserChallenge & ResponseErrorsChallenge,
Tout,
> extends AKElement {
> extends WithLocale(AKElement) {
static shadowRootOptions: ShadowRootInit = {
...LitElement.shadowRootOptions,
delegatesFocus: true,

View File

@@ -3,7 +3,6 @@ import "#flow/components/ak-flow-card";
import { pluckErrorDetail } from "#common/errors/network";
import autoDetectLanguage from "#elements/ak-locale-context/helpers";
import { akEmptyState } from "#elements/EmptyState";
import { ifPresent } from "#elements/utils/attributes";
import { ListenerController } from "#elements/utils/listenerController";
@@ -134,8 +133,6 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
#captchaDocumentContainer?: HTMLDivElement;
#listenController = new ListenerController();
#locale: string | null = null;
//#endregion
//#region Getters/Setters
@@ -195,7 +192,7 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
sitekey: this.challenge.siteKey,
callback: this.onTokenChange,
size: "invisible",
hl: this.#locale ?? undefined,
hl: this.locale,
}),
);
});
@@ -230,7 +227,7 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
sitekey: this.challenge.siteKey,
callback: this.onTokenChange,
size: "invisible",
hl: this.#locale ?? undefined,
hl: this.locale,
}),
);
}
@@ -256,7 +253,7 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
data-theme="${this.activeTheme}"
data-callback="callback"
data-size="flexible"
data-language=${ifPresent(this.#locale)}
data-language=${ifPresent(this.locale)}
></div>`;
};
@@ -375,8 +372,6 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
window.addEventListener("message", this.#messageListener, {
signal: this.#listenController.signal,
});
this.#locale = autoDetectLanguage();
}
public disconnectedCallback(): void {

View File

@@ -1,7 +1,8 @@
import "#elements/Divider";
import "#flow/components/ak-flow-card";
import { LOCALES } from "#elements/ak-locale-context/definitions";
import { formatLocaleOptions } from "#common/ui/locale/definitions";
import { CapabilitiesEnum, WithCapabilitiesConfig } from "#elements/mixins/capabilities";
import { AKFormErrors } from "#components/ak-field-errors";
@@ -214,16 +215,16 @@ ${prompt.initialValue}</textarea
</div> `;
})}`;
case PromptTypeEnum.AkLocale: {
const locales = this.can(CapabilitiesEnum.CanDebug)
? LOCALES
: LOCALES.filter((locale) => locale.code !== "debug");
const options = locales.map(
(locale) =>
html`<option
value=${locale.code}
?selected=${locale.code === prompt.initialValue}
>
${locale.code.toUpperCase()} - ${locale.label()}
let localeOptions = formatLocaleOptions();
if (!this.can(CapabilitiesEnum.CanDebug)) {
localeOptions = localeOptions.filter(([, code]) => code !== "pseudo-LOCALE");
}
const options = localeOptions.map(
([label, code]) =>
html`<option value=${code} ?selected=${code === prompt.initialValue}>
${label}
</option> `,
);

View File

@@ -1,6 +1,5 @@
// sort-imports-ignore
import "rapidoc";
import "#elements/ak-locale-context/index";
import styles from "./index.entrypoint.css";
@@ -61,51 +60,46 @@ export class APIBrowser extends WithBrandConfig(Interface) {
render(): TemplateResult {
return html`
<ak-locale-context>
<rapi-doc
part="rapi-doc"
spec-url=${ifDefined(this.schemaPath)}
heading-text=""
theme="light"
render-style="read"
default-schema-tab="schema"
primary-color="#fd4b2d"
nav-bg-color="#212427"
bg-color=${this.bgColor}
text-color=${this.textColor}
nav-text-color="#ffffff"
nav-hover-bg-color="#3c3f42"
nav-accent-color="#4f5255"
nav-hover-text-color="#ffffff"
use-path-in-nav-bar="true"
nav-item-spacing="relaxed"
allow-server-selection="false"
show-header="false"
allow-spec-url-load="false"
allow-spec-file-load="false"
show-method-in-nav-bar="as-colored-text"
@before-try=${(
e: CustomEvent<{
request: {
headers: Headers;
};
}>,
) => {
e.detail.request.headers.append(
CSRFHeaderName,
getCookie("authentik_csrf"),
);
}}
>
<div slot="nav-logo">
<img
alt="${msg("authentik Logo")}"
class="logo"
src="${themeImage(this.brandingLogo, this.activeTheme)}"
/>
</div>
</rapi-doc>
</ak-locale-context>
<rapi-doc
part="rapi-doc"
spec-url=${ifDefined(this.schemaPath)}
heading-text=""
theme="light"
render-style="read"
default-schema-tab="schema"
primary-color="#fd4b2d"
nav-bg-color="#212427"
bg-color=${this.bgColor}
text-color=${this.textColor}
nav-text-color="#ffffff"
nav-hover-bg-color="#3c3f42"
nav-accent-color="#4f5255"
nav-hover-text-color="#ffffff"
use-path-in-nav-bar="true"
nav-item-spacing="relaxed"
allow-server-selection="false"
show-header="false"
allow-spec-url-load="false"
allow-spec-file-load="false"
show-method-in-nav-bar="as-colored-text"
@before-try=${(
e: CustomEvent<{
request: {
headers: Headers;
};
}>,
) => {
e.detail.request.headers.append(CSRFHeaderName, getCookie("authentik_csrf"));
}}
>
<div slot="nav-logo">
<img
alt="${msg("authentik Logo")}"
class="logo"
src="${themeImage(this.brandingLogo, this.activeTheme)}"
/>
</div>
</rapi-doc>
`;
}
}

View File

@@ -55,11 +55,6 @@
--ak-user-interface--slant-m-dark: var(--pf-global--BackgroundColor--100);
}
ak-locale-context {
display: flex;
flex-direction: column;
}
.pf-c-drawer__main {
min-height: calc(100vh - 76px);
max-height: calc(100vh - 76px);

View File

@@ -1,5 +1,4 @@
import "#components/ak-nav-buttons";
import "#elements/ak-locale-context/ak-locale-context";
import "#elements/banner/EnterpriseStatusBanner";
import "#elements/buttons/ActionButton/ak-action-button";
import "#elements/messages/MessageContainer";
@@ -124,8 +123,7 @@ class UserInterfacePresentation extends WithBrandConfig(WithSession(AKElement))
const backgroundStyles = this.uiConfig.theme.background;
return html`<ak-locale-context>
<ak-enterprise-status interface="user"></ak-enterprise-status>
return html`<ak-enterprise-status interface="user"></ak-enterprise-status>
<div class="pf-c-page">
<div class="background-wrapper" style=${ifPresent(backgroundStyles)}>
${!backgroundStyles
@@ -179,8 +177,7 @@ class UserInterfacePresentation extends WithBrandConfig(WithSession(AKElement))
</div>
</div>
</div>
</div>
</ak-locale-context>`;
</div>`;
}
}

View File

@@ -3,13 +3,13 @@
* not yet been generated, or are missing.
*
* ```sh
* npm run build:locales
* npm run build-locales
* ```
*/
declare module "#locales/*" {
/**
* If you see this, try running `npm run build:locales` to generate locale files.
* If you see this, try running `npm run build-locales` to generate locale files.
*/
type MissingLocale = symbol & { readonly __brand?: never };