 Marc 'risson' Schmitt
					Marc 'risson' Schmitt
				
			
				
					committed by
					
						 GitHub
						GitHub
					
				
			
			
				
	
			
			
			 GitHub
						GitHub
					
				
			
						parent
						
							cf160f800d
						
					
				
				
					commit
					337956672f
				
			| @ -1,110 +1,26 @@ | ||||
| import type { Config as DOMPurifyConfig } from "dompurify"; | ||||
| import DOMPurify from "dompurify"; | ||||
| import { trustedTypes } from "trusted-types"; | ||||
|  | ||||
| import { render } from "lit"; | ||||
| import { render } from "@lit-labs/ssr"; | ||||
| import { collectResult } from "@lit-labs/ssr/lib/render-result.js"; | ||||
| import { TemplateResult, html } 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; | ||||
|  | ||||
| /** | ||||
|  * 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); | ||||
|  | ||||
|     const result = container.innerHTML; | ||||
|  | ||||
|     return result; | ||||
| export async function renderStatic(input: TemplateResult): Promise<string> { | ||||
|     return await collectResult(render(input)); | ||||
| } | ||||
|  | ||||
| export function purify(input: TemplateResult): TemplateResult { | ||||
|     return html`${until( | ||||
|         (async () => { | ||||
|             const rendered = await renderStatic(input); | ||||
|             const purified = DOMPurify.sanitize(rendered); | ||||
|             return html`${unsafeHTML(purified)}`; | ||||
|         })(), | ||||
|     )}`; | ||||
| } | ||||
|  | ||||
| @ -17,13 +17,6 @@ | ||||
|  | ||||
|     /* Minimum width after which the sidebar becomes automatic */ | ||||
|     --ak-sidebar--minimum-auto-width: 80rem; | ||||
|  | ||||
|     /** | ||||
|      * The height of the navbar and branded sidebar. | ||||
|      * @todo This shouldn't be necessary. The sidebar can instead use a grid layout | ||||
|      * ensuring they share the same height. | ||||
|      */ | ||||
|     --ak-navbar--height: 7rem; | ||||
| } | ||||
|  | ||||
| @supports selector(::-webkit-scrollbar) { | ||||
|  | ||||
| @ -1,220 +0,0 @@ | ||||
| /** | ||||
|  * @file Stylesheet utilities. | ||||
|  */ | ||||
| import { CSSResult, CSSResultOrNative, ReactiveElement, css } from "lit"; | ||||
|  | ||||
| /** | ||||
|  * Elements containing adoptable stylesheets. | ||||
|  */ | ||||
| export type StyleSheetParent = Pick<DocumentOrShadowRoot, "adoptedStyleSheets">; | ||||
|  | ||||
| /** | ||||
|  * Type-predicate to determine if a given object has adoptable stylesheets. | ||||
|  */ | ||||
| export function isAdoptableStyleSheetParent(input: unknown): input is StyleSheetParent { | ||||
|     // Sanity check - Does the input have the right shape? | ||||
|  | ||||
|     if (!input || typeof input !== "object") return false; | ||||
|  | ||||
|     if (!("adoptedStyleSheets" in input) || !input.adoptedStyleSheets) return false; | ||||
|  | ||||
|     if (typeof input.adoptedStyleSheets !== "object") return false; | ||||
|  | ||||
|     // We avoid `Array.isArray` because the adopted stylesheets property | ||||
|     // is defined as a proxied array. | ||||
|     // All we care about is that it's shaped like an array. | ||||
|     if (!("length" in input.adoptedStyleSheets)) return false; | ||||
|  | ||||
|     if (typeof input.adoptedStyleSheets.length !== "number") return false; | ||||
|  | ||||
|     // Finally is the array mutable? | ||||
|     return "push" in input.adoptedStyleSheets; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Assert that the given input can adopt stylesheets. | ||||
|  */ | ||||
| export function assertAdoptableStyleSheetParent<T>( | ||||
|     input: T, | ||||
| ): asserts input is T & StyleSheetParent { | ||||
|     if (isAdoptableStyleSheetParent(input)) return; | ||||
|  | ||||
|     console.debug("Given input missing `adoptedStyleSheets`", input); | ||||
|  | ||||
|     throw new TypeError("Assertion failed: `adoptedStyleSheets` missing in given input"); | ||||
| } | ||||
|  | ||||
| export function resolveStyleSheetParent<T extends HTMLElement | DocumentFragment | Document>( | ||||
|     renderRoot: T, | ||||
| ) { | ||||
|     const styleRoot = "ShadyDOM" in window ? document : renderRoot; | ||||
|  | ||||
|     assertAdoptableStyleSheetParent(styleRoot); | ||||
|  | ||||
|     return styleRoot; | ||||
| } | ||||
|  | ||||
| export type StyleSheetInit = string | CSSResult | CSSStyleSheet; | ||||
|  | ||||
| /** | ||||
|  * Given a source of CSS, create a `CSSStyleSheet`. | ||||
|  * | ||||
|  * @throw {@linkcode TypeError} if the input cannot be converted to a `CSSStyleSheet` | ||||
|  * | ||||
|  * @remarks | ||||
|  * | ||||
|  * Storybook's `build` does not currently have a coherent way of importing | ||||
|  * CSS-as-text into CSSStyleSheet. | ||||
|  * | ||||
|  * It works well when Storybook is running in `dev`, but in `build` it fails. | ||||
|  * Storied components will have to map their textual CSS imports. | ||||
|  */ | ||||
| export function createStyleSheet(input: string): CSSResult { | ||||
|     const inputTemplate = [input] as unknown as TemplateStringsArray; | ||||
|  | ||||
|     const result = css(inputTemplate, []); | ||||
|  | ||||
|     return result; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Given a source of CSS, create a `CSSStyleSheet`. | ||||
|  * | ||||
|  * @see {@linkcode createStyleSheet} | ||||
|  */ | ||||
| export function normalizeCSSSource(css: string): CSSStyleSheet; | ||||
| export function normalizeCSSSource(styleSheet: CSSStyleSheet): CSSStyleSheet; | ||||
| export function normalizeCSSSource(cssResult: CSSResult): CSSResult; | ||||
| export function normalizeCSSSource(input: StyleSheetInit): CSSResultOrNative; | ||||
| export function normalizeCSSSource(input: StyleSheetInit): CSSResultOrNative { | ||||
|     if (typeof input === "string") return createStyleSheet(input); | ||||
|  | ||||
|     return input; | ||||
| } | ||||
|  | ||||
| export function createStyleSheetUnsafe(input: StyleSheetInit): CSSStyleSheet { | ||||
|     const result = normalizeCSSSource(input); | ||||
|     if (result instanceof CSSStyleSheet) return result; | ||||
|  | ||||
|     if (!result.styleSheet) { | ||||
|         console.debug( | ||||
|             "authentik/common/stylesheets: CSSResult missing styleSheet, returning empty", | ||||
|             { result, input }, | ||||
|         ); | ||||
|  | ||||
|         throw new TypeError("Expected a CSSStyleSheet"); | ||||
|     } | ||||
|  | ||||
|     return result.styleSheet; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Append stylesheet(s) to the given roots. | ||||
|  */ | ||||
| export function appendStyleSheet( | ||||
|     insertions: CSSStyleSheet | Iterable<CSSStyleSheet>, | ||||
|     ...styleParents: StyleSheetParent[] | ||||
| ): void { | ||||
|     insertions = Array.isArray(insertions) ? insertions : [insertions]; | ||||
|  | ||||
|     for (const nextStyleSheet of insertions) { | ||||
|         for (const styleParent of styleParents) { | ||||
|             if (styleParent.adoptedStyleSheets.includes(nextStyleSheet)) return; | ||||
|  | ||||
|             styleParent.adoptedStyleSheets = [...styleParent.adoptedStyleSheets, nextStyleSheet]; | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Remove a stylesheet from the given roots, matching by referential equality. | ||||
|  */ | ||||
| export function removeStyleSheet( | ||||
|     currentStyleSheet: CSSStyleSheet, | ||||
|     ...styleParents: StyleSheetParent[] | ||||
| ): void { | ||||
|     for (const styleParent of styleParents) { | ||||
|         const nextAdoptedStyleSheets = styleParent.adoptedStyleSheets.filter( | ||||
|             (styleSheet) => styleSheet !== currentStyleSheet, | ||||
|         ); | ||||
|  | ||||
|         if (nextAdoptedStyleSheets.length === styleParent.adoptedStyleSheets.length) return; | ||||
|  | ||||
|         styleParent.adoptedStyleSheets = nextAdoptedStyleSheets; | ||||
|     } | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Serialize a stylesheet to a string. | ||||
|  * | ||||
|  * This is useful for debugging or inspecting the contents of a stylesheet. | ||||
|  */ | ||||
| export function serializeStyleSheet(stylesheet: CSSStyleSheet): string { | ||||
|     return Array.from(stylesheet.cssRules || [], (rule) => rule.cssText || "").join("\n"); | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Inspect the adopted stylesheets of a given style parent, serializing them to strings. | ||||
|  */ | ||||
| export function inspectStyleSheets(styleParent: StyleSheetParent): string[] { | ||||
|     return styleParent.adoptedStyleSheets.map((styleSheet) => serializeStyleSheet(styleSheet)); | ||||
| } | ||||
|  | ||||
| interface InspectedStyleSheetEntry { | ||||
|     tagName: string; | ||||
|     element: ReactiveElement; | ||||
|     styles: string[]; | ||||
|     children?: InspectedStyleSheetEntry[]; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Recursively inspect the adopted stylesheets of a given style parent, serializing them to strings. | ||||
|  */ | ||||
| export function inspectStyleSheetTree(element: ReactiveElement): InspectedStyleSheetEntry { | ||||
|     const styleParent = resolveStyleSheetParent(element.renderRoot); | ||||
|     const styles = inspectStyleSheets(styleParent); | ||||
|     const tagName = element.tagName.toLowerCase(); | ||||
|  | ||||
|     const treewalker = document.createTreeWalker(element.renderRoot, NodeFilter.SHOW_ELEMENT, { | ||||
|         acceptNode(node) { | ||||
|             if (node instanceof ReactiveElement) { | ||||
|                 return NodeFilter.FILTER_ACCEPT; | ||||
|             } | ||||
|             return NodeFilter.FILTER_SKIP; | ||||
|         }, | ||||
|     }); | ||||
|     const children: InspectedStyleSheetEntry[] = []; | ||||
|     let currentNode: Node | null = treewalker.nextNode(); | ||||
|     while (currentNode) { | ||||
|         const childElement = currentNode as ReactiveElement; | ||||
|  | ||||
|         if (!isAdoptableStyleSheetParent(childElement.renderRoot)) { | ||||
|             currentNode = treewalker.nextNode(); | ||||
|             continue; | ||||
|         } | ||||
|  | ||||
|         const childStyles = inspectStyleSheets(childElement.renderRoot); | ||||
|  | ||||
|         children.push({ | ||||
|             tagName: childElement.tagName.toLowerCase(), | ||||
|             element: childElement, | ||||
|             styles: childStyles, | ||||
|         }); | ||||
|         currentNode = treewalker.nextNode(); | ||||
|     } | ||||
|  | ||||
|     return { | ||||
|         tagName, | ||||
|         element, | ||||
|         styles, | ||||
|         children, | ||||
|     }; | ||||
| } | ||||
|  | ||||
| if (process.env.NODE_ENV === "development") { | ||||
|     Object.assign(window, { | ||||
|         inspectStyleSheetTree, | ||||
|         serializeStyleSheet, | ||||
|         inspectStyleSheets, | ||||
|     }); | ||||
| } | ||||
| @ -1,200 +0,0 @@ | ||||
| /** | ||||
|  * @file Theme utilities. | ||||
|  */ | ||||
| import { UIConfig } from "@goauthentik/common/ui/config"; | ||||
|  | ||||
| import { Config, CurrentBrand, UiThemeEnum } from "@goauthentik/api"; | ||||
|  | ||||
| //#region Scheme Types | ||||
|  | ||||
| /** | ||||
|  * Valid CSS color scheme values. | ||||
|  * | ||||
|  * @link {@link https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-color-scheme | MDN} | ||||
|  * | ||||
|  * @category CSS | ||||
|  */ | ||||
| export type CSSColorSchemeValue = "dark" | "light" | "auto"; | ||||
|  | ||||
| /** | ||||
|  * A CSS color scheme value that can be preferred by the user, i.e. not `"auto"`. | ||||
|  * | ||||
|  * @category CSS | ||||
|  */ | ||||
| export type ResolvedCSSColorSchemeValue = Exclude<CSSColorSchemeValue, "auto">; | ||||
|  | ||||
| //#endregion | ||||
|  | ||||
| //#region UI Theme Types | ||||
|  | ||||
| /** | ||||
|  * A UI color scheme value that can be preferred by the user. | ||||
|  * | ||||
|  * i.e. not an lack of preference or unknown value. | ||||
|  * | ||||
|  * @category CSS | ||||
|  */ | ||||
| export type ResolvedUITheme = typeof UiThemeEnum.Light | typeof UiThemeEnum.Dark; | ||||
|  | ||||
| /** | ||||
|  * A mapping of theme values to their respective inversion. | ||||
|  * | ||||
|  * @category CSS | ||||
|  */ | ||||
| export const UIThemeInversion = { | ||||
|     dark: "light", | ||||
|     light: "dark", | ||||
| } as const satisfies Record<ResolvedUITheme, ResolvedUITheme>; | ||||
|  | ||||
| /** | ||||
|  * Either a valid CSS color scheme value, or a theme preference. | ||||
|  */ | ||||
| export type UIThemeHint = CSSColorSchemeValue | UiThemeEnum; | ||||
|  | ||||
| //#endregion | ||||
|  | ||||
| //#region Scheme Functions | ||||
|  | ||||
| /** | ||||
|  * Creates an event target for the given color scheme. | ||||
|  * | ||||
|  * @param colorScheme The color scheme to target. | ||||
|  * @returns A {@linkcode MediaQueryList} that can be used to listen for changes to the color scheme. | ||||
|  * | ||||
|  * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/MediaQueryList | MDN} | ||||
|  * | ||||
|  * @category CSS | ||||
|  */ | ||||
| export function createColorSchemeTarget(colorScheme: ResolvedCSSColorSchemeValue): MediaQueryList { | ||||
|     return window.matchMedia(`(prefers-color-scheme: ${colorScheme})`); | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Formats the given input into a valid CSS color scheme value. | ||||
|  * | ||||
|  * If the input is not provided, it defaults to "auto". | ||||
|  * | ||||
|  * @category CSS | ||||
|  */ | ||||
| export function formatColorScheme(theme: ResolvedUITheme): ResolvedCSSColorSchemeValue; | ||||
| export function formatColorScheme( | ||||
|     colorScheme: ResolvedCSSColorSchemeValue, | ||||
| ): ResolvedCSSColorSchemeValue; | ||||
| export function formatColorScheme(hint?: UIThemeHint): CSSColorSchemeValue; | ||||
| export function formatColorScheme(hint?: UIThemeHint): CSSColorSchemeValue { | ||||
|     if (!hint) return "auto"; | ||||
|  | ||||
|     switch (hint) { | ||||
|         case "dark": | ||||
|         case UiThemeEnum.Dark: | ||||
|             return "dark"; | ||||
|         case "light": | ||||
|         case UiThemeEnum.Light: | ||||
|             return "light"; | ||||
|         case "auto": | ||||
|         case UiThemeEnum.Automatic: | ||||
|             return "auto"; | ||||
|         default: | ||||
|             console.warn(`Unknown color scheme hint: ${hint}. Defaulting to "auto".`); | ||||
|             return "auto"; | ||||
|     } | ||||
| } | ||||
|  | ||||
| //#endregion | ||||
|  | ||||
| //#region Theme Functions | ||||
|  | ||||
| /** | ||||
|  * Resolve the current UI theme based on the user's preference or the provided color scheme. | ||||
|  * | ||||
|  * @param hint The color scheme hint to use. | ||||
|  * | ||||
|  * @category CSS | ||||
|  */ | ||||
| export function resolveUITheme( | ||||
|     hint?: UIThemeHint, | ||||
|     defaultUITheme: ResolvedUITheme = UiThemeEnum.Light, | ||||
| ): ResolvedUITheme { | ||||
|     const colorScheme = formatColorScheme(hint); | ||||
|  | ||||
|     if (colorScheme !== "auto") return colorScheme; | ||||
|  | ||||
|     // Given that we don't know the user's preference, | ||||
|     // we can determine the theme based on whether the default theme is | ||||
|     // currently being overridden. | ||||
|  | ||||
|     const colorSchemeInversion = formatColorScheme(UIThemeInversion[defaultUITheme]); | ||||
|  | ||||
|     const mediaQueryList = createColorSchemeTarget(colorSchemeInversion); | ||||
|  | ||||
|     return mediaQueryList.matches ? colorSchemeInversion : defaultUITheme; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Effect listener invoked when the color scheme changes. | ||||
|  */ | ||||
| export type UIThemeListener = (currentUITheme: ResolvedUITheme) => void; | ||||
| /** | ||||
|  * Create an effect that runs | ||||
|  * | ||||
|  * @returns A cleanup function that removes the effect. | ||||
|  */ | ||||
| export function createUIThemeEffect( | ||||
|     effect: UIThemeListener, | ||||
|     listenerOptions?: AddEventListenerOptions, | ||||
| ): () => void { | ||||
|     const colorSchemeTarget = resolveUITheme(); | ||||
|     const invertedColorSchemeTarget = UIThemeInversion[colorSchemeTarget]; | ||||
|  | ||||
|     let previousUITheme: ResolvedUITheme | undefined; | ||||
|  | ||||
|     // First, wrap the effect to ensure we can abort it. | ||||
|     const changeListener = (event: MediaQueryListEvent) => { | ||||
|         if (listenerOptions?.signal?.aborted) return; | ||||
|  | ||||
|         const currentUITheme = event.matches ? colorSchemeTarget : invertedColorSchemeTarget; | ||||
|  | ||||
|         if (previousUITheme === currentUITheme) return; | ||||
|  | ||||
|         previousUITheme = currentUITheme; | ||||
|  | ||||
|         effect(currentUITheme); | ||||
|     }; | ||||
|  | ||||
|     const mediaQueryList = createColorSchemeTarget(colorSchemeTarget); | ||||
|  | ||||
|     // Trigger the effect immediately. | ||||
|     effect(colorSchemeTarget); | ||||
|  | ||||
|     // Listen for changes to the color scheme... | ||||
|     mediaQueryList.addEventListener("change", changeListener, listenerOptions); | ||||
|  | ||||
|     // Finally, allow the caller to remove the effect. | ||||
|     const cleanup = () => { | ||||
|         mediaQueryList.removeEventListener("change", changeListener); | ||||
|     }; | ||||
|  | ||||
|     return cleanup; | ||||
| } | ||||
|  | ||||
| //#endregion | ||||
|  | ||||
| //#region Theme Element | ||||
|  | ||||
| /** | ||||
|  * An element that can be themed. | ||||
|  */ | ||||
| export interface ThemedElement extends HTMLElement { | ||||
|     brand?: CurrentBrand; | ||||
|     uiConfig?: UIConfig; | ||||
|     config?: Config; | ||||
|     activeTheme: ResolvedUITheme; | ||||
| } | ||||
|  | ||||
| export function rootInterface<T extends ThemedElement = ThemedElement>(): T | null { | ||||
|     const element = document.body.querySelector<T>("[data-ak-interface-root]"); | ||||
|  | ||||
|     return element; | ||||
| } | ||||
|  | ||||
| //#endregion | ||||
		Reference in New Issue
	
	Block a user