mirror of
https://github.com/goauthentik/authentik
synced 2026-05-05 22:52:42 +02:00
Compare commits
6 Commits
blueprints
...
locale-con
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dea6e5d81a | ||
|
|
e9ebbc080c | ||
|
|
be1052d428 | ||
|
|
21cb17ca70 | ||
|
|
f646fa82fb | ||
|
|
9982862826 |
@@ -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 %}
|
||||
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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";
|
||||
|
||||
147
web/src/common/ui/locale/definitions.ts
Normal file
147
web/src/common/ui/locale/definitions.ts
Normal 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]),
|
||||
);
|
||||
@@ -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>;
|
||||
};
|
||||
74
web/src/common/ui/locale/utils.ts
Normal file
74
web/src/common/ui/locale/utils.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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>`;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -1,4 +0,0 @@
|
||||
import { createContext } from "@lit/context";
|
||||
|
||||
export const localeContext = createContext<string>("locale");
|
||||
export default localeContext;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -1,4 +0,0 @@
|
||||
import LocaleContext from "./ak-locale-context.js";
|
||||
|
||||
export { LocaleContext };
|
||||
export default LocaleContext;
|
||||
@@ -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>;
|
||||
};
|
||||
164
web/src/elements/controllers/LocaleContextController.ts
Normal file
164
web/src/elements/controllers/LocaleContextController.ts
Normal 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
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
74
web/src/elements/mixins/locale.ts
Normal file
74
web/src/elements/mixins/locale.ts
Normal 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;
|
||||
},
|
||||
);
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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> `,
|
||||
);
|
||||
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
4
web/types/locale.d.ts
vendored
4
web/types/locale.d.ts
vendored
@@ -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 };
|
||||
|
||||
|
||||
Reference in New Issue
Block a user