Compare commits

..

1 Commits

Author SHA1 Message Date
4f98c21f42 web: Fix issue where references to Lit SSR break page styles. 2025-04-22 20:11:56 +02:00
23 changed files with 445 additions and 394 deletions

2
go.mod
View File

@ -27,7 +27,7 @@ require (
github.com/spf13/cobra v1.9.1
github.com/stretchr/testify v1.10.0
github.com/wwt/guac v1.3.2
goauthentik.io/api/v3 v3.2025024.9
goauthentik.io/api/v3 v3.2025024.8
golang.org/x/exp v0.0.0-20230210204819-062eb4c674ab
golang.org/x/oauth2 v0.29.0
golang.org/x/sync v0.13.0

4
go.sum
View File

@ -290,8 +290,8 @@ go.opentelemetry.io/otel/trace v1.24.0 h1:CsKnnL4dUAr/0llH9FKuc698G04IrpWV0MQA/Y
go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
goauthentik.io/api/v3 v3.2025024.9 h1:i3tbkyotE32ZpJ729BsPWTuLQUdtZ54Li4aP1amZzsM=
goauthentik.io/api/v3 v3.2025024.9/go.mod h1:zz+mEZg8rY/7eEjkMGWJ2DnGqk+zqxuybGCGrR2O4Kw=
goauthentik.io/api/v3 v3.2025024.8 h1:2mG4CqGSsmZq2CtRehxpDjsER43U/JQSoTOn5VC1ui4=
goauthentik.io/api/v3 v3.2025024.8/go.mod h1:zz+mEZg8rY/7eEjkMGWJ2DnGqk+zqxuybGCGrR2O4Kw=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=

View File

@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-04-23 09:00+0000\n"
"POT-Creation-Date: 2025-04-22 13:40+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@ -1255,6 +1255,20 @@ msgstr ""
msgid "Reputation Scores"
msgstr ""
#: authentik/policies/templates/policies/buffer.html
msgid "Waiting for authentication..."
msgstr ""
#: authentik/policies/templates/policies/buffer.html
msgid ""
"You're already authenticating in another tab. This page will refresh once "
"authentication is completed."
msgstr ""
#: authentik/policies/templates/policies/buffer.html
msgid "Authenticate in this tab"
msgstr ""
#: authentik/policies/templates/policies/denied.html
msgid "Permission denied"
msgstr ""

52
web/package-lock.json generated
View File

@ -25,7 +25,6 @@
"@formatjs/intl-listformat": "^7.5.7",
"@fortawesome/fontawesome-free": "^6.6.0",
"@goauthentik/api": "^2025.2.4-1745325566",
"@lit-labs/ssr": "^3.2.2",
"@lit/context": "^1.1.2",
"@lit/localize": "^0.12.2",
"@lit/reactive-element": "^2.0.4",
@ -66,6 +65,7 @@
"remark-gfm": "^4.0.1",
"remark-mdx-frontmatter": "^5.0.0",
"style-mod": "^4.1.2",
"trusted-types": "^2.0.0",
"ts-pattern": "^5.4.0",
"unist-util-visit": "^5.0.0",
"webcomponent-qr-code": "^1.2.0",
@ -2281,47 +2281,11 @@
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lit-labs/ssr": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/@lit-labs/ssr/-/ssr-3.3.1.tgz",
"integrity": "sha512-JlF1PempxvzrGEpRFrF+Ki0MHzR3HA51SK8Zv0cFpW9p0bPW4k0FeCwrElCu371UEpXF7RcaE2wgYaE1az0XKg==",
"dependencies": {
"@lit-labs/ssr-client": "^1.1.7",
"@lit-labs/ssr-dom-shim": "^1.3.0",
"@lit/reactive-element": "^2.0.4",
"@parse5/tools": "^0.3.0",
"@types/node": "^16.0.0",
"enhanced-resolve": "^5.10.0",
"lit": "^3.1.2",
"lit-element": "^4.0.4",
"lit-html": "^3.1.2",
"node-fetch": "^3.2.8",
"parse5": "^7.1.1"
},
"engines": {
"node": ">=13.9.0"
}
},
"node_modules/@lit-labs/ssr-client": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@lit-labs/ssr-client/-/ssr-client-1.1.7.tgz",
"integrity": "sha512-VvqhY/iif3FHrlhkzEPsuX/7h/NqnfxLwVf0p8ghNIlKegRyRqgeaJevZ57s/u/LiFyKgqksRP5n+LmNvpxN+A==",
"dependencies": {
"@lit/reactive-element": "^2.0.4",
"lit": "^3.1.2",
"lit-html": "^3.1.2"
}
},
"node_modules/@lit-labs/ssr-dom-shim": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.3.0.tgz",
"integrity": "sha512-nQIWonJ6eFAvUUrSlwyHDm/aE8PBDu5kRpL0vHMg6K8fK3Diq1xdPjTnsJSwxABhaZ+5eBi1btQB5ShUTKo4nQ=="
},
"node_modules/@lit-labs/ssr/node_modules/@types/node": {
"version": "16.18.126",
"resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.126.tgz",
"integrity": "sha512-OTcgaiwfGFBKacvfwuHzzn1KLxH/er8mluiy8/uM3sGXHaRe73RrSIj01jow9t4kJEW633Ov+cOexXeiApTyAw=="
},
"node_modules/@lit/context": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@lit/context/-/context-1.1.5.tgz",
@ -3557,6 +3521,7 @@
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@parse5/tools/-/tools-0.3.0.tgz",
"integrity": "sha512-zxRyTHkqb7WQMV8kTNBKWb1BeOFUKXBXTBWuxg9H9hfvQB3IwP6Iw2U75Ia5eyRxPNltmY7E8YAlz6zWwUnjKg==",
"dev": true,
"dependencies": {
"parse5": "^7.0.0"
}
@ -10723,6 +10688,7 @@
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
"integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==",
"dev": true,
"engines": {
"node": ">= 12"
}
@ -11343,6 +11309,7 @@
"version": "5.18.1",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.1.tgz",
"integrity": "sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==",
"dev": true,
"dependencies": {
"graceful-fs": "^4.2.4",
"tapable": "^2.2.0"
@ -13820,7 +13787,8 @@
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"dev": true
},
"node_modules/grapheme-splitter": {
"version": "1.0.4",
@ -18256,6 +18224,7 @@
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz",
"integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==",
"dev": true,
"dependencies": {
"data-uri-to-buffer": "^4.0.0",
"fetch-blob": "^3.1.4",
@ -22373,6 +22342,7 @@
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz",
"integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==",
"dev": true,
"engines": {
"node": ">=6"
}
@ -22724,6 +22694,12 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/trusted-types": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/trusted-types/-/trusted-types-2.0.0.tgz",
"integrity": "sha512-Eam+AUp6lg04YjmYkuLNhEJX+6ByocrKTpY/TtfRK/gV6OmxeN0OwkIasor28SUJ606snArpPLGtPMGbqdaaUA==",
"license": "W3C-20150513"
},
"node_modules/ts-api-utils": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz",

View File

@ -13,7 +13,6 @@
"@formatjs/intl-listformat": "^7.5.7",
"@fortawesome/fontawesome-free": "^6.6.0",
"@goauthentik/api": "^2025.2.4-1745325566",
"@lit-labs/ssr": "^3.2.2",
"@lit/context": "^1.1.2",
"@lit/localize": "^0.12.2",
"@lit/reactive-element": "^2.0.4",
@ -54,6 +53,7 @@
"remark-gfm": "^4.0.1",
"remark-mdx-frontmatter": "^5.0.0",
"style-mod": "^4.1.2",
"trusted-types": "^2.0.0",
"ts-pattern": "^5.4.0",
"unist-util-visit": "^5.0.0",
"webcomponent-qr-code": "^1.2.0",

View File

@ -4,12 +4,8 @@ import {
EventMiddleware,
LoggingMiddleware,
} from "@goauthentik/common/api/middleware";
import { VERSION } from "@goauthentik/common/constants";
import { EVENT_LOCALE_REQUEST, VERSION } from "@goauthentik/common/constants";
import { globalAK } from "@goauthentik/common/global";
import {
EVENT_LOCALE_REQUEST,
LocaleContextEventDetail,
} from "@goauthentik/elements/ak-locale-context/events.js";
import { Config, Configuration, CoreApi, CurrentBrand, RootApi } from "@goauthentik/api";
@ -48,7 +44,7 @@ export function brandSetLocale(brand: CurrentBrand) {
}
console.debug("authentik/locale: setting locale from brand default");
window.dispatchEvent(
new CustomEvent<LocaleContextEventDetail>(EVENT_LOCALE_REQUEST, {
new CustomEvent(EVENT_LOCALE_REQUEST, {
composed: true,
bubbles: true,
detail: { locale: brand.defaultLocale },

View File

@ -14,6 +14,8 @@ export const EVENT_FLOW_INSPECTOR_TOGGLE = "ak-flow-inspector-toggle";
export const EVENT_SIDEBAR_TOGGLE = "ak-sidebar-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";
export const EVENT_THEME_CHANGE = "ak-theme-change";

View File

@ -1,26 +1,110 @@
import type { Config as DOMPurifyConfig } from "dompurify";
import DOMPurify from "dompurify";
import { trustedTypes } from "trusted-types";
import { render } from "@lit-labs/ssr";
import { collectResult } from "@lit-labs/ssr/lib/render-result.js";
import { TemplateResult, html } from "lit";
import { render } from "lit";
import { unsafeHTML } from "lit/directives/unsafe-html.js";
import { until } from "lit/directives/until.js";
/**
* Trusted types policy that escapes HTML content in place.
*
* @see {@linkcode SanitizedTrustPolicy} to strip HTML content.
*
* @returns {TrustedHTML} All HTML content, escaped.
*/
export const EscapeTrustPolicy = trustedTypes.createPolicy("authentik-escape", {
createHTML: (untrustedHTML: string) => {
return DOMPurify.sanitize(untrustedHTML, {
RETURN_TRUSTED_TYPE: false,
});
},
});
/**
* Trusted types policy, stripping all HTML content.
*
* @returns {TrustedHTML} Text content only, all HTML tags stripped.
*/
export const SanitizedTrustPolicy = trustedTypes.createPolicy("authentik-sanitize", {
createHTML: (untrustedHTML: string) => {
return DOMPurify.sanitize(untrustedHTML, {
RETURN_TRUSTED_TYPE: false,
ALLOWED_TAGS: ["#text"],
});
},
});
/**
* Trusted types policy, allowing a minimal set of _safe_ HTML tags supplied by
* a trusted source, such as the brand API.
*/
export const BrandedHTMLPolicy = trustedTypes.createPolicy("authentik-restrict", {
createHTML: (untrustedHTML: string) => {
return DOMPurify.sanitize(untrustedHTML, {
RETURN_TRUSTED_TYPE: false,
FORBID_TAGS: [
"script",
"style",
"iframe",
"link",
"object",
"embed",
"applet",
"meta",
"base",
"form",
"input",
"textarea",
"select",
"button",
],
FORBID_ATTR: [
"onerror",
"onclick",
"onload",
"onmouseover",
"onmouseout",
"onmouseup",
"onmousedown",
"onfocus",
"onblur",
"onsubmit",
],
});
},
});
export type AuthentikTrustPolicy =
| typeof EscapeTrustPolicy
| typeof SanitizedTrustPolicy
| typeof BrandedHTMLPolicy;
/**
* Sanitize an untrusted HTML string using a trusted types policy.
*/
export function sanitizeHTML(trustPolicy: AuthentikTrustPolicy, untrustedHTML: string) {
return unsafeHTML(trustPolicy.createHTML(untrustedHTML).toString());
}
/**
* DOMPurify configuration for strict sanitization.
*
* This configuration only allows text nodes and disallows all HTML tags.
*/
export const DOM_PURIFY_STRICT = {
ALLOWED_TAGS: ["#text"],
} as const satisfies DOMPurifyConfig;
export async function renderStatic(input: TemplateResult): Promise<string> {
return await collectResult(render(input));
}
/**
* Render untrusted HTML to a string without escaping it.
*
* @returns {string} The rendered HTML string.
*/
export function renderStaticHTMLUnsafe(untrustedHTML: unknown): string {
const container = document.createElement("html");
render(untrustedHTML, container);
export function purify(input: TemplateResult): TemplateResult {
return html`${until(
(async () => {
const rendered = await renderStatic(input);
const purified = DOMPurify.sanitize(rendered);
return html`${unsafeHTML(purified)}`;
})(),
)}`;
const result = container.innerHTML;
return result;
}

View File

@ -1,9 +1,6 @@
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { EVENT_LOCALE_REQUEST } from "@goauthentik/common/constants";
import { isResponseErrorLike } from "@goauthentik/common/errors/network";
import {
EVENT_LOCALE_REQUEST,
LocaleContextEventDetail,
} from "@goauthentik/elements/ak-locale-context/events.js";
import { CoreApi, SessionUser } from "@goauthentik/api";
@ -60,7 +57,7 @@ export async function me(): Promise<SessionUser> {
console.debug(`authentik/locale: Activating user's configured locale '${locale}'`);
window.dispatchEvent(
new CustomEvent<LocaleContextEventDetail>(EVENT_LOCALE_REQUEST, {
new CustomEvent(EVENT_LOCALE_REQUEST, {
composed: true,
bubbles: true,
detail: { locale },

View File

@ -1,9 +1,11 @@
import { EVENT_LOCALE_REQUEST } from "@goauthentik/common/constants";
import { customEvent } from "@goauthentik/elements/utils/customEvents";
import { localized, msg } from "@lit/localize";
import { LitElement, html } from "lit";
import { customElement } from "lit/decorators.js";
import "./ak-locale-context";
import { EVENT_LOCALE_REQUEST, LocaleContextEventDetail } from "./events.js";
export default {
title: "Elements / Shell / Locale Context",
@ -35,18 +37,10 @@ export const InFrench = () =>
</div>`;
export const SwitchingBackAndForth = () => {
let languageCode = "en";
let lang = "en";
window.setInterval(() => {
languageCode = languageCode === "en" ? "fr" : "en";
window.dispatchEvent(
new CustomEvent<LocaleContextEventDetail>(EVENT_LOCALE_REQUEST, {
composed: true,
bubbles: true,
detail: { locale: languageCode },
}),
);
lang = lang === "en" ? "fr" : "en";
window.dispatchEvent(customEvent(EVENT_LOCALE_REQUEST, { locale: lang }));
}, 1000);
return html`<div style="background: #fff; padding: 4em">

View File

@ -1,18 +1,19 @@
import { EVENT_LOCALE_CHANGE, EVENT_LOCALE_REQUEST } from "@goauthentik/common/constants";
import { AKElement } from "@goauthentik/elements/Base";
import { customEvent } from "@goauthentik/elements/utils/customEvents";
import { html } from "lit";
import { customElement, property } from "lit/decorators.js";
import { WithBrandConfig } from "../Interface/brandProvider";
import { initializeLocalization } from "./configureLocale.js";
import type { GetLocale, SetLocale } from "./configureLocale.js";
import { EVENT_LOCALE_CHANGE, EVENT_LOCALE_REQUEST, LocaleContextEventDetail } from "./events.js";
import { DEFAULT_LOCALE, autoDetectLanguage, findLocaleDefinition } from "./helpers.js";
import { initializeLocalization } from "./configureLocale";
import type { LocaleGetter, LocaleSetter } from "./configureLocale";
import { DEFAULT_LOCALE, autoDetectLanguage, getBestMatchLocale } from "./helpers";
/**
* A component to manage your locale settings.
*
* @remarks
* ## 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
@ -24,98 +25,70 @@ import { DEFAULT_LOCALE, autoDetectLanguage, findLocaleDefinition } from "./help
*/
@customElement("ak-locale-context")
export class LocaleContext extends WithBrandConfig(AKElement) {
protected static singleton: LocaleContext | null = null;
/**
* The text representation of the current locale
* @attribute
*/
/// @attribute The text representation of the current locale */
@property({ attribute: true, type: String })
public locale = DEFAULT_LOCALE;
locale = DEFAULT_LOCALE;
/**
* The URL parameter to look for (if any)
* @attribute
*/
/// @attribute The URL parameter to look for (if any)
@property({ attribute: true, type: String })
public param = "locale";
param = "locale";
protected readonly getLocale: GetLocale;
protected readonly setLocale: SetLocale;
getLocale: LocaleGetter;
setLocale: LocaleSetter;
constructor(code = DEFAULT_LOCALE) {
super();
if (LocaleContext.singleton) {
throw new Error(`Developer error: Must have only one locale context per session`);
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}`);
}
LocaleContext.singleton = this;
const [getLocale, setLocale] = initializeLocalization();
this.getLocale = getLocale;
this.setLocale = setLocale;
this.setLocale(code).then(this.#notifyApplication);
}
connectedCallback() {
this.#updateLocale();
window.addEventListener(EVENT_LOCALE_REQUEST, this.#localeUpdateListener as EventListener);
super.connectedCallback();
this.updateLocale();
window.addEventListener(EVENT_LOCALE_REQUEST, this.updateLocaleHandler as EventListener);
}
disconnectedCallback() {
LocaleContext.singleton = null;
window.removeEventListener(
EVENT_LOCALE_REQUEST,
this.#localeUpdateListener as EventListener,
);
window.removeEventListener(EVENT_LOCALE_REQUEST, this.updateLocaleHandler as EventListener);
super.disconnectedCallback();
}
#localeUpdateListener = (ev: CustomEvent<LocaleContextEventDetail>) => {
updateLocaleHandler(ev: CustomEvent<{ locale: string }>) {
console.debug("authentik/locale: Locale update request received.");
this.#updateLocale(ev.detail.locale);
};
#updateLocale(requestedLanguageCode?: string) {
const localeRequest = autoDetectLanguage(requestedLanguageCode, this.brand?.defaultLocale);
const locale = findLocaleDefinition(localeRequest);
this.updateLocale(ev.detail.locale);
}
updateLocale(requestedLocale: string | undefined = undefined) {
const localeRequest = autoDetectLanguage(requestedLocale, this.brand?.defaultLocale);
const locale = getBestMatchLocale(localeRequest);
if (!locale) {
console.warn(`authentik/locale: failed to find locale for code ${localeRequest}`);
return;
}
return locale.fetch().then(() => {
console.debug(
`authentik/locale: Setting Locale to ${locale.formatLabel()} (${locale.languageCode})`,
);
this.setLocale(locale.languageCode).then(this.#notifyApplication);
locale.locale().then(() => {
console.debug(`authentik/locale: Setting Locale to ${locale.label()} (${locale.code})`);
this.setLocale(locale.code).then(() => {
window.setTimeout(this.notifyApplication, 0);
});
});
}
#notifyFrameID = -1;
#notifyApplication = () => {
cancelAnimationFrame(this.#notifyFrameID);
requestAnimationFrame(() => {
// 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(
new CustomEvent(EVENT_LOCALE_CHANGE, {
bubbles: true,
composed: true,
}),
);
});
};
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));
}
render() {
return html`<slot></slot>`;

View File

@ -1,44 +1,39 @@
import { configureLocalization } from "@lit/localize";
import { sourceLocale, targetLocales } from "../../locale-codes.js";
import { findLocaleDefinition } from "./helpers.js";
import { sourceLocale, targetLocales } from "../../locale-codes";
import { getBestMatchLocale } from "./helpers";
export type ConfigureLocalizationResult = ReturnType<typeof configureLocalization>;
type LocaleGetter = ReturnType<typeof configureLocalization>["getLocale"];
type LocaleSetter = ReturnType<typeof configureLocalization>["setLocale"];
export type GetLocale = ConfigureLocalizationResult["getLocale"];
export type SetLocale = ConfigureLocalizationResult["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.
export type LocaleState = [GetLocale, SetLocale];
let getLocale: LocaleGetter | undefined = undefined;
let setLocale: LocaleSetter | undefined = undefined;
let cachedLocaleState: LocaleState | undefined = undefined;
export function initializeLocalization(): [LocaleGetter, LocaleSetter] {
if (getLocale && setLocale) {
return [getLocale, setLocale];
}
/**
* This is where the lit-localization module is initialized with our loader,
* which associates our collection of locales with its getter and setter functions.
*
* @returns A tuple of getter and setter functions.
* @internal
*/
export function initializeLocalization(): LocaleState {
if (cachedLocaleState) return cachedLocaleState;
const { getLocale, setLocale } = configureLocalization({
({ getLocale, setLocale } = configureLocalization({
sourceLocale,
targetLocales,
loadLocale: (languageCode) => {
const localeDef = findLocaleDefinition(languageCode);
loadLocale: async (locale: string) => {
const localeDef = getBestMatchLocale(locale);
if (!localeDef) {
throw new Error(`Unrecognized locale: ${localeDef}`);
console.warn(`Unrecognized locale: ${localeDef}`);
return Promise.reject("");
}
return localeDef.fetch();
return localeDef.locale();
},
});
}));
cachedLocaleState = [getLocale, setLocale];
return cachedLocaleState;
return [getLocale, setLocale];
}
export default initializeLocalization;
export type { LocaleGetter, LocaleSetter };

View File

@ -1,19 +1,15 @@
import * as EnglishLocaleModule from "@goauthentik/locales/en";
import * as _enLocale from "@goauthentik/locales/en";
import type { LocaleModule } from "@lit/localize";
import { msg } from "@lit/localize";
import { AKLocaleDefinition, LocaleRow } from "./types.js";
import { AkLocale, LocaleRow } from "./types";
/**
* The default ISO 639-1 language code.
*/
export const DEFAULT_LANGUAGE_CODE = "en";
export const DEFAULT_FALLBACK = "en";
/**
* The default English locale module.
*/
export const DefaultLocaleModule: LocaleModule = EnglishLocaleModule;
const enLocale: LocaleModule = _enLocale;
export { enLocale };
// NOTE: This table cannot be made any shorter, despite all the repetition of syntax. Bundlers look
// for the `await import` string as a *string target* for doing alias substitution, so putting
@ -39,44 +35,34 @@ export const DefaultLocaleModule: LocaleModule = EnglishLocaleModule;
// - Text Label
// - Locale loader.
// prettier-ignore
const debug: LocaleRow = [
"pseudo-LOCALE",
/^pseudo/i,
() => msg("Pseudolocale (for testing)"),
() => import("@goauthentik/locales/pseudo-LOCALE"),
"pseudo-LOCALE", /^pseudo/i, () => msg("Pseudolocale (for testing)"), async () => await import("@goauthentik/locales/pseudo-LOCALE"),
];
// prettier-ignore
const LOCALE_TABLE: readonly LocaleRow[] = [
// English loaded when the application is first instantiated.
["en", /^en([_-]|$)/i, () => msg("English"), () => Promise.resolve(DefaultLocaleModule)],
["de", /^de([_-]|$)/i, () => msg("German"), () => import("@goauthentik/locales/de")],
["es", /^es([_-]|$)/i, () => msg("Spanish"), () => import("@goauthentik/locales/es")],
["fr", /^fr([_-]|$)/i, () => msg("French"), () => import("@goauthentik/locales/fr")],
["it", /^it([_-]|$)/i, () => msg("Italian"), () => import("@goauthentik/locales/it")],
["ko", /^ko([_-]|$)/i, () => msg("Korean"), () => import("@goauthentik/locales/ko")],
["nl", /^nl([_-]|$)/i, () => msg("Dutch"), () => import("@goauthentik/locales/nl")],
["pl", /^pl([_-]|$)/i, () => msg("Polish"), () => import("@goauthentik/locales/pl")],
["ru", /^ru([_-]|$)/i, () => msg("Russian"), () => import("@goauthentik/locales/ru")],
["tr", /^tr([_-]|$)/i, () => msg("Turkish"), () => import("@goauthentik/locales/tr")],
["zh_TW", /^zh[_-]TW$/i, () => msg("Taiwanese Mandarin"), () => import("@goauthentik/locales/zh_TW")],
["zh-Hans", /^zh(\b|_)/i, () => msg("Chinese (simplified)"), () => import("@goauthentik/locales/zh-Hans")],
["zh-Hant", /^zh[_-](HK|Hant)/i, () => msg("Chinese (traditional)"), () => import("@goauthentik/locales/zh-Hant")],
debug,
const LOCALE_TABLE: LocaleRow[] = [
["de", /^de([_-]|$)/i, () => msg("German"), async () => await import("@goauthentik/locales/de")],
["en", /^en([_-]|$)/i, () => msg("English"), async () => await import("@goauthentik/locales/en")],
["es", /^es([_-]|$)/i, () => msg("Spanish"), async () => await import("@goauthentik/locales/es")],
["fr", /^fr([_-]|$)/i, () => msg("French"), async () => await import("@goauthentik/locales/fr")],
["it", /^it([_-]|$)/i, () => msg("Italian"), async () => await import("@goauthentik/locales/it")],
["ko", /^ko([_-]|$)/i, () => msg("Korean"), async () => await import("@goauthentik/locales/ko")],
["nl", /^nl([_-]|$)/i, () => msg("Dutch"), async () => await import("@goauthentik/locales/nl")],
["pl", /^pl([_-]|$)/i, () => msg("Polish"), async () => await import("@goauthentik/locales/pl")],
["ru", /^ru([_-]|$)/i, () => msg("Russian"), async () => await import("@goauthentik/locales/ru")],
["tr", /^tr([_-]|$)/i, () => msg("Turkish"), async () => await import("@goauthentik/locales/tr")],
["zh_TW", /^zh[_-]TW$/i, () => msg("Taiwanese Mandarin"), async () => await import("@goauthentik/locales/zh_TW")],
["zh-Hans", /^zh(\b|_)/i, () => msg("Chinese (simplified)"), async () => await import("@goauthentik/locales/zh-Hans")],
["zh-Hant", /^zh[_-](HK|Hant)/i, () => msg("Chinese (traditional)"), async () => await import("@goauthentik/locales/zh-Hant")],
debug
];
/**
* Available locales, identified by their ISO 639-1 language code.
*/
export const AKLocalDefinitions: readonly AKLocaleDefinition[] = LOCALE_TABLE.map(
([languageCode, pattern, formatLabel, fetch]) => {
return {
languageCode,
pattern,
formatLabel,
fetch,
};
},
);
export const LOCALES: AkLocale[] = LOCALE_TABLE.map(([code, match, label, locale]) => ({
code,
match,
label,
locale,
}));
export default AKLocalDefinitions;
export default LOCALES;

View File

@ -1,6 +0,0 @@
export const EVENT_LOCALE_REQUEST = "ak-locale-request";
export const EVENT_LOCALE_CHANGE = "ak-locale-change";
export interface LocaleContextEventDetail {
locale: string;
}

View File

@ -1,80 +1,59 @@
import { globalAK } from "@goauthentik/common/global";
import { AKLocalDefinitions } from "./definitions.js";
import { AKLocaleDefinition } from "./types.js";
import { LOCALES as RAW_LOCALES, enLocale } from "./definitions";
import { AkLocale } from "./types";
export const DEFAULT_LOCALE = "en";
export const EVENT_REQUEST_LOCALE = "ak-request-locale";
/**
* Find the locale definition for a given language code.
*/
export function findLocaleDefinition(languageCode: string): AKLocaleDefinition | null {
for (const locale of AKLocalDefinitions) {
if (locale.pattern.test(languageCode)) {
return locale;
}
}
const TOMBSTONE = "⛼⛼tombstone⛼⛼";
return null;
// 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 | undefined {
return LOCALES.find((l) => l.match.test(locale));
}
// 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[]): AKLocaleDefinition | null {
for (const candidate of candidates) {
const locale = findLocaleDefinition(candidate);
if (locale) return locale;
}
return null;
export function findSupportedLocale(candidates: string[]) {
const candidate = candidates.find((candidate: string) => getBestMatchLocale(candidate));
return candidate ? getBestMatchLocale(candidate) : undefined;
}
export function localeCodeFromURL(param = "locale") {
const searchParams = new URLSearchParams(window.location.search);
return searchParams.get(param);
export function localeCodeFromUrl(param = "locale") {
const url = new URL(window.location.href);
return url.searchParams.get(param) || "";
}
function isLocaleCodeCandidate(input: unknown): input is string {
if (typeof input !== "string") return false;
// 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)
return !!input;
}
const isLocaleCandidate = (v: unknown): v is string =>
typeof v === "string" && v !== "" && v !== TOMBSTONE;
/**
* Auto-detect the most appropriate locale.
*
* @remarks
*
* The order of precedence is:
*
* 1. URL parameter `locale`.
* 2. User's preferred locale, if any.
* 3. Browser's preferred locale, if any.
* 4. Brand's preferred locale, if any.
* 5. Default locale.
*
* @param requestedLanguageCode - The user's preferred locale, if any.
* @param brandLanguageCode - The brand's preferred locale, if any.
*
* @returns The most appropriate locale.
*/
export function autoDetectLanguage(
requestedLanguageCode?: string,
brandLanguageCode?: string,
): string {
const localeCandidates = [
localeCodeFromURL("locale"),
requestedLanguageCode,
window.navigator?.language,
brandLanguageCode,
globalAK()?.locale,
].filter(isLocaleCodeCandidate);
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);
@ -82,11 +61,10 @@ export function autoDetectLanguage(
console.debug(
`authentik/locale: No locale found for '[${localeCandidates}.join(',')]', falling back to ${DEFAULT_LOCALE}`,
);
return DEFAULT_LOCALE;
}
return firstSupportedLocale.languageCode;
return firstSupportedLocale.code;
}
export default autoDetectLanguage;

View File

@ -1,21 +1,10 @@
import type { LocaleModule } from "@lit/localize";
/**
* - ISO 639-1 code for the locale.
* - Pattern to match the user-supplied locale.
* - Human-readable label for the locale.
* - Locale loader.
*/
export type LocaleRow = [
languageCode: string,
pattern: RegExp,
formatLabel: () => string,
fetch: () => Promise<LocaleModule>,
];
export type LocaleRow = [string, RegExp, () => string, () => Promise<LocaleModule>];
export interface AKLocaleDefinition {
languageCode: string;
pattern: RegExp;
formatLabel(): string;
fetch(): Promise<LocaleModule>;
}
export type AkLocale = {
code: string;
match: RegExp;
label: () => string;
locale: () => Promise<LocaleModule>;
};

View File

@ -0,0 +1,55 @@
/**
* @file IFrame Utilities
*/
interface IFrameLoadResult {
contentWindow: Window;
contentDocument: Document;
}
export function pluckIFrameContent(iframe: HTMLIFrameElement) {
const contentWindow = iframe.contentWindow;
const contentDocument = iframe.contentDocument;
if (!contentWindow) {
throw new Error("Iframe contentWindow is not accessible");
}
if (!contentDocument) {
throw new Error("Iframe contentDocument is not accessible");
}
return {
contentWindow,
contentDocument,
};
}
export function resolveIFrameContent(iframe: HTMLIFrameElement): Promise<IFrameLoadResult> {
if (iframe.contentDocument?.readyState === "complete") {
return Promise.resolve(pluckIFrameContent(iframe));
}
return new Promise((resolve) => {
iframe.addEventListener("load", () => resolve(pluckIFrameContent(iframe)), { once: true });
});
}
/**
* Creates a minimal HTML wrapper for an iframe.
*
* @deprecated Use the `contentDocument.body` directly instead.
*/
export function createIFrameHTMLWrapper(bodyContent: string): string {
const html = String.raw;
return html`<!doctype html>
<html>
<head>
<meta charset="utf-8" />
</head>
<body style="display:flex;flex-direction:row;justify-content:center;">
${bodyContent}
</body>
</html>`;
}

View File

@ -1,4 +1,4 @@
import { purify } from "@goauthentik/common/purify";
import { BrandedHTMLPolicy, sanitizeHTML } from "@goauthentik/common/purify";
import { AKElement } from "@goauthentik/elements/Base.js";
import { msg } from "@lit/localize";
@ -21,8 +21,6 @@ const styles = css`
}
`;
const poweredBy: FooterLink = { name: msg("Powered by authentik"), href: null };
@customElement("ak-brand-links")
export class BrandLinks extends AKElement {
static get styles() {
@ -33,13 +31,21 @@ export class BrandLinks extends AKElement {
links: FooterLink[] = [];
render() {
const links = [...(this.links ?? []), poweredBy];
const links = [...(this.links ?? [])];
return html` <ul class="pf-c-list pf-m-inline">
${map(links, (link) =>
link.href
? purify(html`<li><a href="${link.href}">${link.name}</a></li>`)
: html`<li><span>${link.name}</span></li>`,
)}
${map(links, (link) => {
const children = sanitizeHTML(BrandedHTMLPolicy, link.name);
if (link.href) {
return html`<li><a href="${link.href}">${children}</a></li>`;
}
return html`<li>
<span> ${children} </span>
</li>`;
})}
<li><span>${msg("Powered by authentik")}</span></li>
</ul>`;
}
}

View File

@ -1,15 +1,16 @@
///<reference types="@hcaptcha/types"/>
import { renderStatic } from "@goauthentik/common/purify";
/// <reference types="@hcaptcha/types"/>
/// <reference types="turnstile-types"/>
import { renderStaticHTMLUnsafe } from "@goauthentik/common/purify";
import "@goauthentik/elements/EmptyState";
import { akEmptyState } from "@goauthentik/elements/EmptyState";
import { bound } from "@goauthentik/elements/decorators/bound";
import "@goauthentik/elements/forms/FormElement";
import { createIFrameHTMLWrapper } from "@goauthentik/elements/utils/iframe";
import { ListenerController } from "@goauthentik/elements/utils/listenerController.js";
import { randomId } from "@goauthentik/elements/utils/randomId";
import "@goauthentik/flow/FormStatic";
import { BaseStage } from "@goauthentik/flow/stages/base";
import { P, match } from "ts-pattern";
import type * as _ from "turnstile-types";
import { msg } from "@lit/localize";
import { CSSResult, PropertyValues, TemplateResult, css, html, nothing } from "lit";
@ -56,40 +57,36 @@ type CaptchaHandler = {
// a resize. Because the Captcha is itself in an iframe, the reported height is often off by some
// margin, so adding 2rem of height to our container adds padding and prevents scroll bars or hidden
// rendering.
function iframeTemplate(children: TemplateResult, challengeURL: string): TemplateResult {
return html` ${children}
<script>
new ResizeObserver((entries) => {
const height =
document.body.offsetHeight +
parseFloat(getComputedStyle(document.body).fontSize) * 2;
const iframeTemplate = (captchaElement: TemplateResult, challengeUrl: string) =>
html`<!doctype html>
<head>
<html>
<body style="display:flex;flex-direction:row;justify-content:center;">
${captchaElement}
<script>
new ResizeObserver((entries) => {
const height =
document.body.offsetHeight +
parseFloat(getComputedStyle(document.body).fontSize) * 2;
window.parent.postMessage({
message: "resize",
source: "goauthentik.io",
context: "flow-executor",
size: { height },
});
}).observe(document.querySelector(".ak-captcha-container"));
</script>
<script src=${challengeUrl}></script>
<script>
function callback(token) {
window.parent.postMessage({
message: "captcha",
source: "goauthentik.io",
context: "flow-executor",
token: token,
});
}
</script>
</body>
</html>
</head>`;
window.parent.postMessage({
message: "resize",
source: "goauthentik.io",
context: "flow-executor",
size: { height },
});
}).observe(document.querySelector(".ak-captcha-container"));
</script>
<script src=${challengeURL}></script>
<script>
function callback(token) {
window.parent.postMessage({
message: "captcha",
source: "goauthentik.io",
context: "flow-executor",
token,
});
}
</script>`;
}
@customElement("ak-stage-captcha")
export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeResponseRequest> {
@ -305,11 +302,25 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
}
async renderFrame(captchaElement: TemplateResult) {
this.captchaFrame.contentWindow?.document.open();
this.captchaFrame.contentWindow?.document.write(
await renderStatic(iframeTemplate(captchaElement, this.challenge.jsUrl)),
const { contentDocument } = this.captchaFrame || {};
if (!contentDocument) {
console.debug(
"authentik/stages/captcha: unable to render captcha frame, no contentDocument",
);
return;
}
contentDocument.open();
contentDocument.write(
createIFrameHTMLWrapper(
renderStaticHTMLUnsafe(iframeTemplate(captchaElement, this.challenge.jsUrl)),
),
);
this.captchaFrame.contentWindow?.document.close();
contentDocument.close();
}
renderBody() {

View File

@ -4,7 +4,7 @@ import {
CapabilitiesEnum,
WithCapabilitiesConfig,
} from "@goauthentik/elements/Interface/capabilitiesProvider";
import { AKLocalDefinitions } from "@goauthentik/elements/ak-locale-context/definitions";
import { LOCALES } from "@goauthentik/elements/ak-locale-context/definitions";
import "@goauthentik/elements/forms/FormElement";
import { BaseStage } from "@goauthentik/flow/stages/base";
@ -199,15 +199,15 @@ ${prompt.initialValue}</textarea
})}`;
case PromptTypeEnum.AkLocale: {
const locales = this.can(CapabilitiesEnum.CanDebug)
? AKLocalDefinitions
: AKLocalDefinitions.filter((locale) => locale.languageCode !== "debug");
? LOCALES
: LOCALES.filter((locale) => locale.code !== "debug");
const options = locales.map(
(locale) =>
html`<option
value=${locale.languageCode}
?selected=${locale.languageCode === prompt.initialValue}
value=${locale.code}
?selected=${locale.code === prompt.initialValue}
>
${locale.languageCode.toUpperCase()} - ${locale.formatLabel()}
${locale.code.toUpperCase()} - ${locale.label()}
</option> `,
);

View File

@ -146,6 +146,7 @@ When writing out steps in a procedural topic, avoid starting with "Once...". Ins
- Use _italic_ for:
- Variables or placeholders to indicate that the value should be replaced by the user (e.g., _your-domain.com_). Clearly indicate whether variables in code snippets need to be defined by the user, are system-provided, or generated.
- Emphasis, but sparingly, to avoid overuse. For example, you can use italics for important terms or concepts on first mention in a section.
- Use `code formatting` for:
@ -156,9 +157,11 @@ When writing out steps in a procedural topic, avoid starting with "Once...". Ins
- When handling URLs:
- For URLs entered as values or defined in fields, enclose any variables inside angle brackets (`< >`) to clearly indicate that these are placeholders that require user input.
- For URLs entered as values or defined in fields _italicize_ any variables within them to emphasize that placeholders require user input.
For example: `https://authentik.company/application/o/<slug>/.well-known/openid-configuration`
In Markdown, use this syntax: `<kbd>https://<em>company-domain</em>/source/oauth/callback/<em>source-slug</em></kbd>`
Rendered formatting: <kbd>https://<em>company-domain</em>/source/oauth/callback/<em>source-slug</em></kbd>
- When mentioning URLs in text or within procedural instructions, omit code formatting. For instance: "In your browser, go to https://example.com."

View File

@ -7,43 +7,41 @@ title: User properties and attributes
The User object has the following properties:
- `username`: User's username.
- `email`: User's email.
- `uid`: User's unique ID. Read-only.
- `name`: User's display name.
- `is_staff`: Boolean field defining if user is staff.
- `is_active`: Boolean field defining if user is active.
- `date_joined`: Date user joined/was created. Read-only.
- `password_change_date`: Date password was last changed. Read-only.
- `path`: User's path, see [Path](#path)
- `attributes`: Dynamic attributes, see [Attributes](#attributes)
- `group_attributes()`: Merged attributes of all groups the user is member of and the user's own attributes. Ready-only.
- `ak_groups`: This is a queryset of all the user's groups.
- `email` User's email.
- `uid` User's unique ID
- `name` User's display name.
- `is_staff` Boolean field if user is staff.
- `is_active` Boolean field if user is active.
- `date_joined` Date user joined/was created.
- `password_change_date` Date password was last changed.
- `path` User's path, see [Path](#path)
- `attributes` Dynamic attributes, see [Attributes](#attributes)
- `group_attributes()` Merged attributes of all groups the user is member of and the user's own attributes.
- `ak_groups` This is a queryset of all the user's groups.
You can do additional filtering like:
```python
user.ak_groups.filter(name__startswith='test')
```
For Django field lookups, see [here](https://docs.djangoproject.com/en/4.2/ref/models/querysets/#id4).
To get the name of all groups, you can use this command:
```python
[group.name for group in user.ak_groups.all()]
```
## Examples
These are examples of how User objects can be used within Policies and Property Mappings.
### List a user's group memberships
Use the following example to list all groups that a User object is a member of:
List all the User's group names:
```python
for group in user.ak_groups.all():
yield group.name
```
### List a user's group memberships and filter based on group name
Use the following example to list groups that a User object is a member of, but filter based on group name:
```python
user.ak_groups.filter(name__startswith='test')
```
:::info
For Django field lookups, see the [Django documentation](https://docs.djangoproject.com/en/stable/ref/models/querysets/#id4).
:::
## Path
Paths can be used to organize users into folders depending on which source created them or organizational structure. Paths may not start or end with a slash, but they can contain any other character as path segments. The paths are currently purely used for organization, it does not affect their permissions, group memberships, or anything else.
@ -89,7 +87,7 @@ This field is only used by the Proxy Provider.
Some applications can be configured to create new users using header information forwarded from authentik. You can forward additional header information by adding each header
underneath `additionalHeaders`:
#### Example
#### Example:
```yaml
additionalHeaders:

View File

@ -66,7 +66,7 @@ environment:
"client_id": "<Client ID>",
"secret": "<Client Secret>",
"settings": {
"server_url": "https://authentik.company/application/o/<slug>/.well-known/openid-configuration"
"server_url": "https://authentik.company/application/o/paperless/.well-known/openid-configuration"
}
}
],