Compare commits
	
		
			1 Commits
		
	
	
		
			safari-loc
			...
			fix-shared
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 4f98c21f42 | 
							
								
								
									
										2
									
								
								go.mod
									
									
									
									
									
								
							
							
						
						
									
										2
									
								
								go.mod
									
									
									
									
									
								
							| @ -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
									
									
									
									
									
								
							
							
						
						
									
										4
									
								
								go.sum
									
									
									
									
									
								
							| @ -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= | ||||
|  | ||||
| @ -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
									
									
									
								
							
							
						
						
									
										52
									
								
								web/package-lock.json
									
									
									
										generated
									
									
									
								
							| @ -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", | ||||
|  | ||||
| @ -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", | ||||
|  | ||||
| @ -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 }, | ||||
|  | ||||
| @ -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"; | ||||
|  | ||||
| @ -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; | ||||
| } | ||||
|  | ||||
| @ -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 }, | ||||
|  | ||||
| @ -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"> | ||||
|  | ||||
| @ -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`); | ||||
|         } | ||||
|  | ||||
|         LocaleContext.singleton = this; | ||||
|  | ||||
|         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(this.#notifyApplication); | ||||
|             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() { | ||||
|         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>`; | ||||
|  | ||||
| @ -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; | ||||
|  | ||||
| /** | ||||
|  * 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({ | ||||
|         sourceLocale, | ||||
|         targetLocales, | ||||
|         loadLocale: (languageCode) => { | ||||
|             const localeDef = findLocaleDefinition(languageCode); | ||||
|  | ||||
|             if (!localeDef) { | ||||
|                 throw new Error(`Unrecognized locale: ${localeDef}`); | ||||
| export function initializeLocalization(): [LocaleGetter, LocaleSetter] { | ||||
|     if (getLocale && setLocale) { | ||||
|         return [getLocale, setLocale]; | ||||
|     } | ||||
|  | ||||
|             return localeDef.fetch(); | ||||
|     ({ 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(); | ||||
|         }, | ||||
|     }); | ||||
|     })); | ||||
|  | ||||
|     cachedLocaleState = [getLocale, setLocale]; | ||||
|  | ||||
|     return cachedLocaleState; | ||||
|     return [getLocale, setLocale]; | ||||
| } | ||||
|  | ||||
| export default initializeLocalization; | ||||
| export type { LocaleGetter, LocaleSetter }; | ||||
|  | ||||
| @ -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; | ||||
|  | ||||
| @ -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; | ||||
| } | ||||
| @ -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; | ||||
|  | ||||
| @ -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>; | ||||
| }; | ||||
|  | ||||
							
								
								
									
										55
									
								
								web/src/elements/utils/iframe.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										55
									
								
								web/src/elements/utils/iframe.ts
									
									
									
									
									
										Normal 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>`; | ||||
| } | ||||
| @ -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>`; | ||||
|     } | ||||
| } | ||||
|  | ||||
| @ -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,18 +57,14 @@ 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. | ||||
|  | ||||
| const iframeTemplate = (captchaElement: TemplateResult, challengeUrl: string) => | ||||
|     html`<!doctype html> | ||||
|         <head> | ||||
|             <html> | ||||
|                 <body style="display:flex;flex-direction:row;justify-content:center;"> | ||||
|                     ${captchaElement} | ||||
| 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; | ||||
|  | ||||
|                 window.parent.postMessage({ | ||||
|                     message: "resize", | ||||
|                     source: "goauthentik.io", | ||||
| @ -76,20 +73,20 @@ const iframeTemplate = (captchaElement: TemplateResult, challengeUrl: string) => | ||||
|                 }); | ||||
|             }).observe(document.querySelector(".ak-captcha-container")); | ||||
|         </script> | ||||
|                     <script src=${challengeUrl}></script> | ||||
|  | ||||
|         <script src=${challengeURL}></script> | ||||
|  | ||||
|         <script> | ||||
|             function callback(token) { | ||||
|                 window.parent.postMessage({ | ||||
|                     message: "captcha", | ||||
|                     source: "goauthentik.io", | ||||
|                     context: "flow-executor", | ||||
|                                 token: token, | ||||
|                     token, | ||||
|                 }); | ||||
|             } | ||||
|                     </script> | ||||
|                 </body> | ||||
|             </html> | ||||
|         </head>`; | ||||
|         </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", | ||||
|             ); | ||||
|         this.captchaFrame.contentWindow?.document.close(); | ||||
|  | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         contentDocument.open(); | ||||
|  | ||||
|         contentDocument.write( | ||||
|             createIFrameHTMLWrapper( | ||||
|                 renderStaticHTMLUnsafe(iframeTemplate(captchaElement, this.challenge.jsUrl)), | ||||
|             ), | ||||
|         ); | ||||
|  | ||||
|         contentDocument.close(); | ||||
|     } | ||||
|  | ||||
|     renderBody() { | ||||
|  | ||||
| @ -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> `, | ||||
|                 ); | ||||
|  | ||||
|  | ||||
| @ -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." | ||||
|  | ||||
|  | ||||
| @ -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: | ||||
|  | ||||
| @ -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" | ||||
|                 } | ||||
|               } | ||||
|             ], | ||||
|  | ||||
		Reference in New Issue
	
	Block a user
	