Compare commits
	
		
			4 Commits
		
	
	
		
			version/20
			...
			admin-layo
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 7b7ad9b63a | |||
| 00eff9871f | |||
| f4ed74c0c7 | |||
| 3f81bde962 | 
							
								
								
									
										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,13 +4,17 @@ import { ROUTES } from "@goauthentik/admin/Routes"; | ||||
| import { | ||||
|     EVENT_API_DRAWER_TOGGLE, | ||||
|     EVENT_NOTIFICATION_DRAWER_TOGGLE, | ||||
|     EVENT_SIDEBAR_TOGGLE, | ||||
| } from "@goauthentik/common/constants"; | ||||
| import { configureSentry } from "@goauthentik/common/sentry"; | ||||
| import { me } from "@goauthentik/common/users"; | ||||
| import { WebsocketClient } from "@goauthentik/common/ws"; | ||||
| import { AuthenticatedInterface } from "@goauthentik/elements/Interface"; | ||||
| import { WithLicenseSummary } from "@goauthentik/elements/Interface/licenseSummaryProvider.js"; | ||||
| import "@goauthentik/elements/ak-locale-context"; | ||||
| import "@goauthentik/elements/banner/EnterpriseStatusBanner"; | ||||
| import "@goauthentik/elements/banner/EnterpriseStatusBanner"; | ||||
| import "@goauthentik/elements/banner/VersionBanner"; | ||||
| import "@goauthentik/elements/banner/VersionBanner"; | ||||
| import "@goauthentik/elements/messages/MessageContainer"; | ||||
| import "@goauthentik/elements/messages/MessageContainer"; | ||||
| @ -21,25 +25,32 @@ import "@goauthentik/elements/router/RouterOutlet"; | ||||
| import "@goauthentik/elements/sidebar/Sidebar"; | ||||
| import "@goauthentik/elements/sidebar/SidebarItem"; | ||||
|  | ||||
| import { CSSResult, TemplateResult, css, html } from "lit"; | ||||
| import { CSSResult, TemplateResult, css, html, nothing } from "lit"; | ||||
| import { customElement, property, query, state } from "lit/decorators.js"; | ||||
| import { classMap } from "lit/directives/class-map.js"; | ||||
|  | ||||
| import PFButton from "@patternfly/patternfly/components/Button/button.css"; | ||||
| import PFDrawer from "@patternfly/patternfly/components/Drawer/drawer.css"; | ||||
| import PFNav from "@patternfly/patternfly/components/Nav/nav.css"; | ||||
| import PFPage from "@patternfly/patternfly/components/Page/page.css"; | ||||
| import PFBase from "@patternfly/patternfly/patternfly-base.css"; | ||||
|  | ||||
| import { SessionUser, UiThemeEnum } from "@goauthentik/api"; | ||||
| import { LicenseSummaryStatusEnum, SessionUser, UiThemeEnum } from "@goauthentik/api"; | ||||
|  | ||||
| import "./AdminSidebar"; | ||||
| import { | ||||
|     AdminSidebarEnterpriseEntries, | ||||
|     AdminSidebarEntries, | ||||
|     renderSidebarItems, | ||||
| } from "./AdminSidebar.js"; | ||||
|  | ||||
| if (process.env.NODE_ENV === "development") { | ||||
|     await import("@goauthentik/esbuild-plugin-live-reload/client"); | ||||
| } | ||||
|  | ||||
| @customElement("ak-interface-admin") | ||||
| export class AdminInterface extends AuthenticatedInterface { | ||||
| export class AdminInterface extends WithLicenseSummary(AuthenticatedInterface) { | ||||
|     //#region Properties | ||||
|  | ||||
|     @property({ type: Boolean }) | ||||
|     notificationDrawerOpen = getURLParam("notificationDrawerOpen", false); | ||||
|  | ||||
| @ -54,12 +65,29 @@ export class AdminInterface extends AuthenticatedInterface { | ||||
|     @query("ak-about-modal") | ||||
|     aboutModal?: AboutModal; | ||||
|  | ||||
|     @property({ type: Boolean, reflect: true }) | ||||
|     public sidebarOpen = true; | ||||
|  | ||||
|     #toggleSidebar = () => { | ||||
|         this.sidebarOpen = !this.sidebarOpen; | ||||
|     }; | ||||
|  | ||||
|     sidebarMatcher = window.matchMedia("(min-width: 1200px)"); | ||||
|     #sidebarListener = (event: MediaQueryListEvent) => { | ||||
|         this.sidebarOpen = event.matches; | ||||
|     }; | ||||
|  | ||||
|     //#endregion | ||||
|  | ||||
|     //#region Styles | ||||
|  | ||||
|     static get styles(): CSSResult[] { | ||||
|         return [ | ||||
|             PFBase, | ||||
|             PFPage, | ||||
|             PFButton, | ||||
|             PFDrawer, | ||||
|             PFNav, | ||||
|             css` | ||||
|                 .pf-c-page__main, | ||||
|                 .pf-c-drawer__content, | ||||
| @ -67,23 +95,30 @@ export class AdminInterface extends AuthenticatedInterface { | ||||
|                     z-index: auto !important; | ||||
|                     background-color: transparent; | ||||
|                 } | ||||
|  | ||||
|                 .display-none { | ||||
|                     display: none; | ||||
|                 } | ||||
|  | ||||
|                 .pf-c-page { | ||||
|                     background-color: var(--pf-c-page--BackgroundColor) !important; | ||||
|                 } | ||||
|                 /* Global page background colour */ | ||||
|                 :host([theme="dark"]) .pf-c-page { | ||||
|                     --pf-c-page--BackgroundColor: var(--ak-dark-background); | ||||
|  | ||||
|                 :host([theme="dark"]) { | ||||
|                     /* Global page background colour */ | ||||
|                     .pf-c-page { | ||||
|                         --pf-c-page--BackgroundColor: var(--ak-dark-background); | ||||
|                     } | ||||
|                 } | ||||
|                 ak-enterprise-status, | ||||
|                 ak-version-banner { | ||||
|  | ||||
|                 ak-page-navbar { | ||||
|                     grid-area: header; | ||||
|                 } | ||||
|                 ak-admin-sidebar { | ||||
|  | ||||
|                 .ak-sidebar { | ||||
|                     grid-area: nav; | ||||
|                 } | ||||
|  | ||||
|                 .pf-c-drawer__panel { | ||||
|                     z-index: var(--pf-global--ZIndex--xl); | ||||
|                 } | ||||
| @ -91,9 +126,19 @@ export class AdminInterface extends AuthenticatedInterface { | ||||
|         ]; | ||||
|     } | ||||
|  | ||||
|     //#endregion | ||||
|  | ||||
|     //#region Lifecycle | ||||
|  | ||||
|     constructor() { | ||||
|         super(); | ||||
|         this.ws = new WebsocketClient(); | ||||
|     } | ||||
|  | ||||
|     public connectedCallback() { | ||||
|         super.connectedCallback(); | ||||
|  | ||||
|         window.addEventListener(EVENT_SIDEBAR_TOGGLE, this.#toggleSidebar); | ||||
|  | ||||
|         window.addEventListener(EVENT_NOTIFICATION_DRAWER_TOGGLE, () => { | ||||
|             this.notificationDrawerOpen = !this.notificationDrawerOpen; | ||||
| @ -108,6 +153,14 @@ export class AdminInterface extends AuthenticatedInterface { | ||||
|                 apiDrawerOpen: this.apiDrawerOpen, | ||||
|             }); | ||||
|         }); | ||||
|  | ||||
|         this.sidebarMatcher.addEventListener("change", this.#sidebarListener); | ||||
|     } | ||||
|  | ||||
|     public disconnectedCallback(): void { | ||||
|         super.disconnectedCallback(); | ||||
|         window.removeEventListener(EVENT_SIDEBAR_TOGGLE, this.#toggleSidebar); | ||||
|         this.sidebarMatcher.removeEventListener("change", this.#sidebarListener); | ||||
|     } | ||||
|  | ||||
|     async firstUpdated(): Promise<void> { | ||||
| @ -118,6 +171,7 @@ export class AdminInterface extends AuthenticatedInterface { | ||||
|             this.user.user.isSuperuser || | ||||
|             // TODO: somehow add `access_admin_interface` to the API schema | ||||
|             this.user.user.systemPermissions.includes("access_admin_interface"); | ||||
|  | ||||
|         if (!canAccessAdmin && this.user.user.pk > 0) { | ||||
|             window.location.assign("/if/user/"); | ||||
|         } | ||||
| @ -125,10 +179,14 @@ export class AdminInterface extends AuthenticatedInterface { | ||||
|  | ||||
|     render(): TemplateResult { | ||||
|         const sidebarClasses = { | ||||
|             "pf-c-page__sidebar": true, | ||||
|             "pf-m-light": this.activeTheme === UiThemeEnum.Light, | ||||
|             "pf-m-expanded": this.sidebarOpen, | ||||
|             "pf-m-collapsed": !this.sidebarOpen, | ||||
|         }; | ||||
|  | ||||
|         const drawerOpen = this.notificationDrawerOpen || this.apiDrawerOpen; | ||||
|  | ||||
|         const drawerClasses = { | ||||
|             "pf-m-expanded": drawerOpen, | ||||
|             "pf-m-collapsed": !drawerOpen, | ||||
| @ -136,11 +194,18 @@ export class AdminInterface extends AuthenticatedInterface { | ||||
|  | ||||
|         return html` <ak-locale-context> | ||||
|             <div class="pf-c-page"> | ||||
|                 <ak-enterprise-status interface="admin"></ak-enterprise-status> | ||||
|                 <ak-version-banner></ak-version-banner> | ||||
|                 <ak-admin-sidebar | ||||
|                     class="pf-c-page__sidebar ${classMap(sidebarClasses)}" | ||||
|                 ></ak-admin-sidebar> | ||||
|                 <ak-page-navbar> | ||||
|                     <ak-version-banner></ak-version-banner> | ||||
|                     <ak-enterprise-status interface="admin"></ak-enterprise-status> | ||||
|                 </ak-page-navbar> | ||||
|  | ||||
|                 <ak-sidebar class="${classMap(sidebarClasses)}"> | ||||
|                     ${renderSidebarItems(AdminSidebarEntries)} | ||||
|                     ${this.licenseSummary?.status !== LicenseSummaryStatusEnum.Unlicensed | ||||
|                         ? renderSidebarItems(AdminSidebarEnterpriseEntries) | ||||
|                         : nothing} | ||||
|                 </ak-sidebar> | ||||
|  | ||||
|                 <div class="pf-c-page__drawer"> | ||||
|                     <div class="pf-c-drawer ${classMap(drawerClasses)}"> | ||||
|                         <div class="pf-c-drawer__main"> | ||||
|  | ||||
| @ -1,186 +1,97 @@ | ||||
| import { EVENT_SIDEBAR_TOGGLE } from "@goauthentik/common/constants"; | ||||
| import { me } from "@goauthentik/common/users"; | ||||
| import { AKElement } from "@goauthentik/elements/Base"; | ||||
| import { | ||||
|     CapabilitiesEnum, | ||||
|     WithCapabilitiesConfig, | ||||
| } from "@goauthentik/elements/Interface/capabilitiesProvider"; | ||||
| import { WithVersion } from "@goauthentik/elements/Interface/versionProvider"; | ||||
| import { ID_REGEX, SLUG_REGEX, UUID_REGEX } from "@goauthentik/elements/router/Route"; | ||||
| import { getRootStyle } from "@goauthentik/elements/utils/getRootStyle"; | ||||
| import { spread } from "@open-wc/lit-helpers"; | ||||
|  | ||||
| import { msg } from "@lit/localize"; | ||||
| import { TemplateResult, html, nothing } from "lit"; | ||||
| import { customElement, property, state } from "lit/decorators.js"; | ||||
| import { map } from "lit/directives/map.js"; | ||||
| import { repeat } from "lit/directives/repeat.js"; | ||||
|  | ||||
| import { UiThemeEnum } from "@goauthentik/api"; | ||||
| import type { SessionUser, UserSelf } from "@goauthentik/api"; | ||||
| // The second attribute type is of string[] to help with the 'activeWhen' control, which was | ||||
| // commonplace and singular enough to merit its own handler. | ||||
| type SidebarEntry = [ | ||||
|     path: string | null, | ||||
|     label: string, | ||||
|     attributes?: Record<string, any> | string[] | null, // eslint-disable-line | ||||
|     children?: SidebarEntry[], | ||||
| ]; | ||||
|  | ||||
| @customElement("ak-admin-sidebar") | ||||
| export class AkAdminSidebar extends WithCapabilitiesConfig(WithVersion(AKElement)) { | ||||
|     @property({ type: Boolean, reflect: true }) | ||||
|     open = true; | ||||
| /** | ||||
|  * Recursively renders a sidebar entry. | ||||
|  */ | ||||
| export function renderSidebarItem([ | ||||
|     path, | ||||
|     label, | ||||
|     attributes, | ||||
|     children, | ||||
| ]: SidebarEntry): TemplateResult { | ||||
|     const properties = Array.isArray(attributes) | ||||
|         ? { ".activeWhen": attributes } | ||||
|         : (attributes ?? {}); | ||||
|  | ||||
|     @state() | ||||
|     impersonation: UserSelf["username"] | null = null; | ||||
|  | ||||
|     constructor() { | ||||
|         super(); | ||||
|         me().then((user: SessionUser) => { | ||||
|             this.impersonation = user.original ? user.user.username : null; | ||||
|         }); | ||||
|         this.toggleOpen = this.toggleOpen.bind(this); | ||||
|         this.checkWidth = this.checkWidth.bind(this); | ||||
|     if (path) { | ||||
|         properties.path = path; | ||||
|     } | ||||
|  | ||||
|     // This has to be a bound method so the event listener can be removed on disconnection as | ||||
|     // needed. | ||||
|     toggleOpen() { | ||||
|         this.open = !this.open; | ||||
|     } | ||||
|  | ||||
|     checkWidth() { | ||||
|         // This works just fine, but it assumes that the `--ak-sidebar--minimum-auto-width` is in | ||||
|         // REMs. If that changes, this code will have to be adjusted as well. | ||||
|         const minWidth = | ||||
|             parseFloat(getRootStyle("--ak-sidebar--minimum-auto-width")) * | ||||
|             parseFloat(getRootStyle("font-size")); | ||||
|         this.open = window.innerWidth >= minWidth; | ||||
|     } | ||||
|  | ||||
|     connectedCallback() { | ||||
|         super.connectedCallback(); | ||||
|         window.addEventListener(EVENT_SIDEBAR_TOGGLE, this.toggleOpen); | ||||
|         window.addEventListener("resize", this.checkWidth); | ||||
|         // After connecting to the DOM, we can now perform this check to see if the sidebar should | ||||
|         // be open by default. | ||||
|         this.checkWidth(); | ||||
|     } | ||||
|  | ||||
|     // The symmetry (☟, ☝) here is critical in that you want to start adding these handlers after | ||||
|     // connection, and removing them before disconnection. | ||||
|  | ||||
|     disconnectedCallback() { | ||||
|         window.removeEventListener(EVENT_SIDEBAR_TOGGLE, this.toggleOpen); | ||||
|         window.removeEventListener("resize", this.checkWidth); | ||||
|         super.disconnectedCallback(); | ||||
|     } | ||||
|  | ||||
|     render() { | ||||
|         return html` | ||||
|             <ak-sidebar | ||||
|                 class="pf-c-page__sidebar ${this.open ? "pf-m-expanded" : "pf-m-collapsed"} ${this | ||||
|                     .activeTheme === UiThemeEnum.Light | ||||
|                     ? "pf-m-light" | ||||
|                     : ""}" | ||||
|             > | ||||
|                 ${this.renderSidebarItems()} | ||||
|             </ak-sidebar> | ||||
|         `; | ||||
|     } | ||||
|  | ||||
|     updated() { | ||||
|         // This is permissible as`:host.classList` is not one of the properties Lit uses as a | ||||
|         // scheduling trigger. This sort of shenanigans can trigger an loop, in that it will trigger | ||||
|         // a browser reflow, which may trigger some other styling the application is monitoring, | ||||
|         // triggering a re-render which triggers a browser reflow, ad infinitum. But we've been | ||||
|         // living with that since jQuery, and it's both well-known and fortunately rare. | ||||
|  | ||||
|         // eslint-disable-next-line wc/no-self-class | ||||
|         this.classList.remove("pf-m-expanded", "pf-m-collapsed"); | ||||
|         // eslint-disable-next-line wc/no-self-class | ||||
|         this.classList.add(this.open ? "pf-m-expanded" : "pf-m-collapsed"); | ||||
|     } | ||||
|  | ||||
|     renderSidebarItems(): TemplateResult { | ||||
|         // The second attribute type is of string[] to help with the 'activeWhen' control, which was | ||||
|         // commonplace and singular enough to merit its own handler. | ||||
|         type SidebarEntry = [ | ||||
|             path: string | null, | ||||
|             label: string, | ||||
|             attributes?: Record<string, any> | string[] | null, // eslint-disable-line | ||||
|             children?: SidebarEntry[], | ||||
|         ]; | ||||
|  | ||||
|         // prettier-ignore | ||||
|         const sidebarContent: SidebarEntry[] = [ | ||||
|             [null, msg("Dashboards"), { "?expanded": true }, [ | ||||
|                 ["/administration/overview", msg("Overview")], | ||||
|                 ["/administration/dashboard/users", msg("User Statistics")], | ||||
|                 ["/administration/system-tasks", msg("System Tasks")]]], | ||||
|             [null, msg("Applications"), null, [ | ||||
|                 ["/core/applications", msg("Applications"), [`^/core/applications/(?<slug>${SLUG_REGEX})$`]], | ||||
|                 ["/core/providers", msg("Providers"), [`^/core/providers/(?<id>${ID_REGEX})$`]], | ||||
|                 ["/outpost/outposts", msg("Outposts")]]], | ||||
|             [null, msg("Events"), null, [ | ||||
|                 ["/events/log", msg("Logs"), [`^/events/log/(?<id>${UUID_REGEX})$`]], | ||||
|                 ["/events/rules", msg("Notification Rules")], | ||||
|                 ["/events/transports", msg("Notification Transports")]]], | ||||
|             [null, msg("Customization"), null, [ | ||||
|                 ["/policy/policies", msg("Policies")], | ||||
|                 ["/core/property-mappings", msg("Property Mappings")], | ||||
|                 ["/blueprints/instances", msg("Blueprints")], | ||||
|                 ["/policy/reputation", msg("Reputation scores")]]], | ||||
|             [null, msg("Flows and Stages"), null, [ | ||||
|                 ["/flow/flows", msg("Flows"), [`^/flow/flows/(?<slug>${SLUG_REGEX})$`]], | ||||
|                 ["/flow/stages", msg("Stages")], | ||||
|                 ["/flow/stages/prompts", msg("Prompts")]]], | ||||
|             [null, msg("Directory"), null, [ | ||||
|                 ["/identity/users", msg("Users"), [`^/identity/users/(?<id>${ID_REGEX})$`]], | ||||
|                 ["/identity/groups", msg("Groups"), [`^/identity/groups/(?<id>${UUID_REGEX})$`]], | ||||
|                 ["/identity/roles", msg("Roles"), [`^/identity/roles/(?<id>${UUID_REGEX})$`]], | ||||
|                 ["/identity/initial-permissions", msg("Initial Permissions"), [`^/identity/initial-permissions/(?<id>${ID_REGEX})$`]], | ||||
|                 ["/core/sources", msg("Federation and Social login"), [`^/core/sources/(?<slug>${SLUG_REGEX})$`]], | ||||
|                 ["/core/tokens", msg("Tokens and App passwords")], | ||||
|                 ["/flow/stages/invitations", msg("Invitations")]]], | ||||
|             [null, msg("System"), null, [ | ||||
|                 ["/core/brands", msg("Brands")], | ||||
|                 ["/crypto/certificates", msg("Certificates")], | ||||
|                 ["/outpost/integrations", msg("Outpost Integrations")], | ||||
|                 ["/admin/settings", msg("Settings")]]], | ||||
|         ]; | ||||
|  | ||||
|         // Typescript requires the type here to correctly type the recursive path | ||||
|         type SidebarRenderer = (_: SidebarEntry) => TemplateResult; | ||||
|  | ||||
|         const renderOneSidebarItem: SidebarRenderer = ([path, label, attributes, children]) => { | ||||
|             const properties = Array.isArray(attributes) | ||||
|                 ? { ".activeWhen": attributes } | ||||
|                 : (attributes ?? {}); | ||||
|             if (path) { | ||||
|                 properties.path = path; | ||||
|             } | ||||
|             return html`<ak-sidebar-item ${spread(properties)}> | ||||
|                 ${label ? html`<span slot="label">${label}</span>` : nothing} | ||||
|                 ${map(children, renderOneSidebarItem)} | ||||
|             </ak-sidebar-item>`; | ||||
|         }; | ||||
|  | ||||
|         // prettier-ignore | ||||
|         return html` | ||||
|             ${map(sidebarContent, renderOneSidebarItem)} | ||||
|             ${this.renderEnterpriseMenu()} | ||||
|         `; | ||||
|     } | ||||
|  | ||||
|     renderEnterpriseMenu() { | ||||
|         return this.can(CapabilitiesEnum.IsEnterprise) | ||||
|             ? html` | ||||
|                   <ak-sidebar-item> | ||||
|                       <span slot="label">${msg("Enterprise")}</span> | ||||
|                       <ak-sidebar-item path="/enterprise/licenses"> | ||||
|                           <span slot="label">${msg("Licenses")}</span> | ||||
|                       </ak-sidebar-item> | ||||
|                   </ak-sidebar-item> | ||||
|               ` | ||||
|             : nothing; | ||||
|     } | ||||
|     return html`<ak-sidebar-item ${spread(properties)}> | ||||
|         ${label ? html`<span slot="label">${label}</span>` : nothing} | ||||
|         ${children ? renderSidebarItems(children) : nothing} | ||||
|     </ak-sidebar-item>`; | ||||
| } | ||||
|  | ||||
| declare global { | ||||
|     interface HTMLElementTagNameMap { | ||||
|         "ak-admin-sidebar": AkAdminSidebar; | ||||
|     } | ||||
| /** | ||||
|  * Recursively renders a collection of sidebar entries. | ||||
|  */ | ||||
| export function renderSidebarItems(entries: readonly SidebarEntry[]) { | ||||
|     return repeat(entries, ([path, label]) => path || label, renderSidebarItem); | ||||
| } | ||||
|  | ||||
| // prettier-ignore | ||||
| export const AdminSidebarEntries: readonly SidebarEntry[] = [ | ||||
|     [null, msg("Dashboards"), { "?expanded": true }, [ | ||||
|         ["/administration/overview", msg("Overview")], | ||||
|         ["/administration/dashboard/users", msg("User Statistics")], | ||||
|         ["/administration/system-tasks", msg("System Tasks")]] | ||||
|     ], | ||||
|     [null, msg("Applications"), null, [ | ||||
|         ["/core/applications", msg("Applications"), [`^/core/applications/(?<slug>${SLUG_REGEX})$`]], | ||||
|         ["/core/providers", msg("Providers"), [`^/core/providers/(?<id>${ID_REGEX})$`]], | ||||
|         ["/outpost/outposts", msg("Outposts")]] | ||||
|     ], | ||||
|     [null, msg("Events"), null, [ | ||||
|         ["/events/log", msg("Logs"), [`^/events/log/(?<id>${UUID_REGEX})$`]], | ||||
|         ["/events/rules", msg("Notification Rules")], | ||||
|         ["/events/transports", msg("Notification Transports")]] | ||||
|     ], | ||||
|     [null, msg("Customization"), null, [ | ||||
|         ["/policy/policies", msg("Policies")], | ||||
|         ["/core/property-mappings", msg("Property Mappings")], | ||||
|         ["/blueprints/instances", msg("Blueprints")], | ||||
|         ["/policy/reputation", msg("Reputation scores")]] | ||||
|     ], | ||||
|     [null, msg("Flows and Stages"), null, [ | ||||
|         ["/flow/flows", msg("Flows"), [`^/flow/flows/(?<slug>${SLUG_REGEX})$`]], | ||||
|         ["/flow/stages", msg("Stages")], | ||||
|         ["/flow/stages/prompts", msg("Prompts")]] | ||||
|     ], | ||||
|     [null, msg("Directory"), null, [ | ||||
|         ["/identity/users", msg("Users"), [`^/identity/users/(?<id>${ID_REGEX})$`]], | ||||
|         ["/identity/groups", msg("Groups"), [`^/identity/groups/(?<id>${UUID_REGEX})$`]], | ||||
|         ["/identity/roles", msg("Roles"), [`^/identity/roles/(?<id>${UUID_REGEX})$`]], | ||||
|         ["/identity/initial-permissions", msg("Initial Permissions"), [`^/identity/initial-permissions/(?<id>${ID_REGEX})$`]], | ||||
|         ["/core/sources", msg("Federation and Social login"), [`^/core/sources/(?<slug>${SLUG_REGEX})$`]], | ||||
|         ["/core/tokens", msg("Tokens and App passwords")], | ||||
|         ["/flow/stages/invitations", msg("Invitations")]] | ||||
|     ], | ||||
|     [null, msg("System"), null, [ | ||||
|         ["/core/brands", msg("Brands")], | ||||
|         ["/crypto/certificates", msg("Certificates")], | ||||
|         ["/outpost/integrations", msg("Outpost Integrations")], | ||||
|         ["/admin/settings", msg("Settings")]] | ||||
|     ], | ||||
| ]; | ||||
|  | ||||
| // prettier-ignore | ||||
| export const AdminSidebarEnterpriseEntries: readonly SidebarEntry[] = [ | ||||
|     [null, msg("Enterprise"), null, [ | ||||
|         ["/enterprise/licenses", msg("Licenses"), null] | ||||
|     ], | ||||
| ]] | ||||
|  | ||||
| @ -58,9 +58,6 @@ export class AdminOverviewPage extends AdminOverviewBase { | ||||
|             PFContent, | ||||
|             PFDivider, | ||||
|             css` | ||||
|                 .pf-l-grid__item { | ||||
|                     height: 100%; | ||||
|                 } | ||||
|                 .pf-l-grid__item.big-graph-container { | ||||
|                     height: 35em; | ||||
|                 } | ||||
| @ -74,6 +71,10 @@ export class AdminOverviewPage extends AdminOverviewBase { | ||||
|                     line-height: normal; | ||||
|                     font-size: var(--pf-global--icon--FontSize--sm); | ||||
|                 } | ||||
|  | ||||
|                 .chart-item { | ||||
|                     aspect-ratio: 2 / 1; | ||||
|                 } | ||||
|             `, | ||||
|         ]; | ||||
|     } | ||||
| @ -94,22 +95,31 @@ export class AdminOverviewPage extends AdminOverviewBase { | ||||
|     } | ||||
|  | ||||
|     render(): TemplateResult { | ||||
|         const name = this.user?.user.name ?? this.user?.user.username; | ||||
|         const username = this.user?.user.name || this.user?.user.username; | ||||
|  | ||||
|         return html`<ak-page-header description=${msg("General system status")} ?hasIcon=${false}> | ||||
|                 <span slot="header"> ${msg(str`Welcome, ${name || ""}.`)} </span> | ||||
|         return html` <ak-page-header | ||||
|                 header=${msg(str`Welcome, ${username || ""}.`)} | ||||
|                 description=${msg("General system status")} | ||||
|                 ?hasIcon=${false} | ||||
|             > | ||||
|             </ak-page-header> | ||||
|             <section class="pf-c-page__main-section"> | ||||
|                 <div class="pf-l-grid pf-m-gutter"> | ||||
|                     <!-- row 1 --> | ||||
|                     <div | ||||
|                         class="pf-l-grid__item pf-m-12-col pf-m-6-col-on-xl pf-m-6-col-on-2xl pf-l-grid pf-m-gutter" | ||||
|                     > | ||||
|                         <div class="pf-l-grid__item pf-m-12-col pf-m-6-col-on-xl pf-m-4-col-on-2xl"> | ||||
|                             <ak-quick-actions-card .actions=${this.quickActions}> | ||||
|                             </ak-quick-actions-card> | ||||
|                         </div> | ||||
|                         <div class="pf-l-grid__item pf-m-12-col pf-m-6-col-on-xl pf-m-4-col-on-2xl"> | ||||
|                     <div class="pf-l-grid__item pf-m-12-col pf-m-2-row pf-m-9-col-on-xl"> | ||||
|                         <ak-recent-events pageSize="6"></ak-recent-events> | ||||
|                     </div> | ||||
|                     <div class="pf-l-grid__item pf-m-12-col pf-m-6-col-on-sm pf-m-3-col-on-xl"> | ||||
|                         <ak-quick-actions-card .actions=${this.quickActions}> | ||||
|                         </ak-quick-actions-card> | ||||
|                     </div> | ||||
|  | ||||
|                     <div class="pf-l-grid__item pf-m-12-col pf-m-6-col-on-sm pf-m-3-col-on-xl"> | ||||
|                         <ak-admin-status-version> </ak-admin-status-version> | ||||
|                     </div> | ||||
|                     <div class="pf-l-grid pf-l-grid__item pf-m-12-col pf-m-gutter"> | ||||
|                         ${this.renderSecondaryRow()} | ||||
|  | ||||
|                         <div class="pf-l-grid__item pf-m-12-col pf-m-6-col-on-md chart-item"> | ||||
|                             <ak-aggregate-card | ||||
|                                 icon="pf-icon pf-icon-zone" | ||||
|                                 header=${msg("Outpost status")} | ||||
| @ -118,24 +128,13 @@ export class AdminOverviewPage extends AdminOverviewBase { | ||||
|                                 <ak-admin-status-chart-outpost></ak-admin-status-chart-outpost> | ||||
|                             </ak-aggregate-card> | ||||
|                         </div> | ||||
|                         <div | ||||
|                             class="pf-l-grid__item pf-m-12-col pf-m-12-col-on-xl pf-m-4-col-on-2xl" | ||||
|                         > | ||||
|                         <div class="pf-l-grid__item pf-m-12-col pf-m-6-col-on-md chart-item"> | ||||
|                             <ak-aggregate-card icon="fa fa-sync-alt" header=${msg("Sync status")}> | ||||
|                                 <ak-admin-status-chart-sync></ak-admin-status-chart-sync> | ||||
|                             </ak-aggregate-card> | ||||
|                         </div> | ||||
|                         <div class="pf-l-grid__item pf-m-12-col"> | ||||
|                             <hr class="pf-c-divider" /> | ||||
|                         </div> | ||||
|                         ${this.renderCards()} | ||||
|                     </div> | ||||
|                     <div class="pf-l-grid__item pf-m-12-col pf-m-6-col-on-xl"> | ||||
|                         <ak-recent-events pageSize="6"></ak-recent-events> | ||||
|                     </div> | ||||
|                     <div class="pf-l-grid__item pf-m-12-col"> | ||||
|                         <hr class="pf-c-divider" /> | ||||
|                     </div> | ||||
|  | ||||
|                     <!-- row 3 --> | ||||
|                     <div | ||||
|                         class="pf-l-grid__item pf-m-12-col pf-m-6-col-on-xl pf-m-8-col-on-2xl big-graph-container" | ||||
| @ -163,32 +162,34 @@ export class AdminOverviewPage extends AdminOverviewBase { | ||||
|             </section>`; | ||||
|     } | ||||
|  | ||||
|     renderCards() { | ||||
|     renderSecondaryRow() { | ||||
|         const isEnterprise = this.hasEnterpriseLicense; | ||||
|         const colSpan = isEnterprise ? 4 : 6; | ||||
|  | ||||
|         const classes = { | ||||
|             "card-container": true, | ||||
|             "pf-l-grid__item": true, | ||||
|             "pf-m-6-col": true, | ||||
|             "pf-m-4-col-on-md": !isEnterprise, | ||||
|             "pf-m-4-col-on-xl": !isEnterprise, | ||||
|             "pf-m-3-col-on-md": isEnterprise, | ||||
|             "pf-m-3-col-on-xl": isEnterprise, | ||||
|             [`pf-m-12-col`]: true, | ||||
|             [`pf-m-${colSpan}-col-on-md`]: true, | ||||
|         }; | ||||
|  | ||||
|         return html`<div class=${classMap(classes)}> | ||||
|         return html` | ||||
|             <div class=${classMap(classes)}> | ||||
|                 <ak-admin-status-system> </ak-admin-status-system> | ||||
|             </div> | ||||
|             <div class=${classMap(classes)}> | ||||
|                 <ak-admin-status-version> </ak-admin-status-version> | ||||
|             </div> | ||||
|  | ||||
|             <div class=${classMap(classes)}> | ||||
|                 <ak-admin-status-card-workers> </ak-admin-status-card-workers> | ||||
|             </div> | ||||
|  | ||||
|             ${isEnterprise | ||||
|                 ? html` <div class=${classMap(classes)}> | ||||
|                       <ak-admin-fips-status-system> </ak-admin-fips-status-system> | ||||
|                   </div>` | ||||
|                 : nothing} `; | ||||
|                 ? html` | ||||
|                       <div class=${classMap(classes)}> | ||||
|                           <ak-admin-fips-status-system> </ak-admin-fips-status-system> | ||||
|                       </div> | ||||
|                   ` | ||||
|                 : nothing} | ||||
|         `; | ||||
|     } | ||||
|  | ||||
|     renderActions() { | ||||
|  | ||||
| @ -83,13 +83,10 @@ export class AdminSettingsPage extends AKElement { | ||||
|     } | ||||
|  | ||||
|     render() { | ||||
|         if (!this.settings) { | ||||
|             return nothing; | ||||
|         } | ||||
|         if (!this.settings) return nothing; | ||||
|  | ||||
|         return html` | ||||
|             <ak-page-header icon="fa fa-cog" header="" description=""> | ||||
|                 <span slot="header"> ${msg("System settings")} </span> | ||||
|             </ak-page-header> | ||||
|             <ak-page-header icon="fa fa-cog" header="${msg("System settings")}"> </ak-page-header> | ||||
|             <section class="pf-c-page__main-section pf-m-no-padding-mobile pf-l-grid pf-m-gutter"> | ||||
|                 <div class="pf-c-card"> | ||||
|                     <div class="pf-c-card__body"> | ||||
|  | ||||
| @ -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; | ||||
| } | ||||
|  | ||||
| @ -17,6 +17,13 @@ | ||||
|  | ||||
|     /* 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) { | ||||
|  | ||||
							
								
								
									
										220
									
								
								web/src/common/stylesheets.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										220
									
								
								web/src/common/stylesheets.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,220 @@ | ||||
| /** | ||||
|  * @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, | ||||
|     }); | ||||
| } | ||||
							
								
								
									
										200
									
								
								web/src/common/theme.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										200
									
								
								web/src/common/theme.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,200 @@ | ||||
| /** | ||||
|  * @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 | ||||
| @ -67,6 +67,12 @@ export class NavigationButtons extends AKElement { | ||||
|                 :host([theme="light"]) .pf-c-page__header-tools-group .pf-c-button { | ||||
|                     color: var(--ak-global--Color--100) !important; | ||||
|                 } | ||||
|  | ||||
|                 @media (max-width: 768px) { | ||||
|                     .pf-c-avatar { | ||||
|                         display: none; | ||||
|                     } | ||||
|                 } | ||||
|             `, | ||||
|         ]; | ||||
|     } | ||||
| @ -156,9 +162,7 @@ export class NavigationButtons extends AKElement { | ||||
|     } | ||||
|  | ||||
|     renderImpersonation() { | ||||
|         if (!this.me?.original) { | ||||
|             return nothing; | ||||
|         } | ||||
|         if (!this.me?.original) return nothing; | ||||
|  | ||||
|         const onClick = async () => { | ||||
|             await new CoreApi(DEFAULT_CONFIG).coreUsersImpersonateEndRetrieve(); | ||||
| @ -175,6 +179,14 @@ export class NavigationButtons extends AKElement { | ||||
|             </div>`; | ||||
|     } | ||||
|  | ||||
|     renderAvatar() { | ||||
|         return html`<img | ||||
|             class="pf-c-avatar" | ||||
|             src=${ifDefined(this.me?.user.avatar)} | ||||
|             alt="${msg("Avatar image")}" | ||||
|         />`; | ||||
|     } | ||||
|  | ||||
|     get userDisplayName() { | ||||
|         return match<UserDisplay | undefined, string | undefined>(this.uiConfig?.navbar.userDisplay) | ||||
|             .with(UserDisplay.username, () => this.me?.user.username) | ||||
| @ -212,11 +224,7 @@ export class NavigationButtons extends AKElement { | ||||
|                       </div> | ||||
|                   </div>` | ||||
|                 : nothing} | ||||
|             <img | ||||
|                 class="pf-c-avatar" | ||||
|                 src=${ifDefined(this.me?.user.avatar)} | ||||
|                 alt="${msg("Avatar image")}" | ||||
|             /> | ||||
|             ${this.renderAvatar()} | ||||
|         </div>`; | ||||
|     } | ||||
| } | ||||
|  | ||||
| @ -1,165 +1,125 @@ | ||||
| import { EVENT_THEME_CHANGE } from "@goauthentik/common/constants"; | ||||
| import { globalAK } from "@goauthentik/common/global"; | ||||
| import { UIConfig } from "@goauthentik/common/ui/config"; | ||||
| import { adaptCSS } from "@goauthentik/common/utils"; | ||||
| import { ensureCSSStyleSheet } from "@goauthentik/elements/utils/ensureCSSStyleSheet"; | ||||
| import { | ||||
|     StyleSheetInit, | ||||
|     StyleSheetParent, | ||||
|     appendStyleSheet, | ||||
|     createStyleSheetUnsafe, | ||||
|     removeStyleSheet, | ||||
|     resolveStyleSheetParent, | ||||
| } from "@goauthentik/common/stylesheets"; | ||||
| import { ResolvedUITheme, createUIThemeEffect, resolveUITheme } from "@goauthentik/common/theme"; | ||||
| import { type ThemedElement } from "@goauthentik/common/theme"; | ||||
|  | ||||
| import { localized } from "@lit/localize"; | ||||
| import { LitElement, ReactiveElement } from "lit"; | ||||
| import { CSSResultGroup, CSSResultOrNative, LitElement } from "lit"; | ||||
| import { property } from "lit/decorators.js"; | ||||
|  | ||||
| import AKGlobal from "@goauthentik/common/styles/authentik.css"; | ||||
| import OneDark from "@goauthentik/common/styles/one-dark.css"; | ||||
| import ThemeDark from "@goauthentik/common/styles/theme-dark.css"; | ||||
|  | ||||
| import { Config, CurrentBrand, UiThemeEnum } from "@goauthentik/api"; | ||||
| import { CurrentBrand, UiThemeEnum } from "@goauthentik/api"; | ||||
|  | ||||
| type AkInterface = HTMLElement & { | ||||
|     getTheme: () => Promise<UiThemeEnum>; | ||||
|     brand?: CurrentBrand; | ||||
|     uiConfig?: UIConfig; | ||||
|     config?: Config; | ||||
|     get activeTheme(): UiThemeEnum | undefined; | ||||
| }; | ||||
| // Re-export the theme helpers | ||||
| export { rootInterface } from "@goauthentik/common/theme"; | ||||
|  | ||||
| export const rootInterface = <T extends AkInterface>(): T | undefined => | ||||
|     (document.body.querySelector("[data-ak-interface-root]") as T) ?? undefined; | ||||
|  | ||||
| export const QUERY_MEDIA_COLOR_LIGHT = "(prefers-color-scheme: light)"; | ||||
|  | ||||
| // Ensure themes are converted to a static instance of CSS Stylesheet, otherwise the | ||||
| // when changing themes we might not remove the correct css stylesheet instance. | ||||
| const _darkTheme = ensureCSSStyleSheet(ThemeDark); | ||||
| export interface AKElementInit { | ||||
|     brand?: Partial<CurrentBrand>; | ||||
|     styleParents?: StyleSheetParent[]; | ||||
| } | ||||
|  | ||||
| @localized() | ||||
| export class AKElement extends LitElement { | ||||
|     _mediaMatcher?: MediaQueryList; | ||||
|     _mediaMatcherHandler?: (ev?: MediaQueryListEvent) => void; | ||||
|     _activeTheme?: UiThemeEnum; | ||||
|  | ||||
|     get activeTheme(): UiThemeEnum | undefined { | ||||
|         return this._activeTheme; | ||||
|     } | ||||
|  | ||||
|     constructor() { | ||||
|         super(); | ||||
|     } | ||||
|  | ||||
|     setInitialStyles(root: DocumentOrShadowRoot) { | ||||
|         const styleRoot: DocumentOrShadowRoot = ( | ||||
|             "ShadyDOM" in window ? document : root | ||||
|         ) as DocumentOrShadowRoot; | ||||
|         styleRoot.adoptedStyleSheets = adaptCSS([ | ||||
|             ...styleRoot.adoptedStyleSheets, | ||||
|             ensureCSSStyleSheet(AKGlobal), | ||||
|             ensureCSSStyleSheet(OneDark), | ||||
|         ]); | ||||
|         this._initTheme(styleRoot); | ||||
|         this._initCustomCSS(styleRoot); | ||||
|     } | ||||
|  | ||||
|     protected createRenderRoot() { | ||||
|         this.fixElementStyles(); | ||||
|         const root = super.createRenderRoot(); | ||||
|         this.setInitialStyles(root as unknown as DocumentOrShadowRoot); | ||||
|         return root; | ||||
|     } | ||||
|  | ||||
|     async getTheme(): Promise<UiThemeEnum> { | ||||
|         return rootInterface()?.getTheme() || UiThemeEnum.Automatic; | ||||
|     } | ||||
|  | ||||
|     fixElementStyles() { | ||||
|         // Ensure all style sheets being passed are really style sheets. | ||||
|         (this.constructor as typeof ReactiveElement).elementStyles = ( | ||||
|             this.constructor as typeof ReactiveElement | ||||
|         ).elementStyles.map(ensureCSSStyleSheet); | ||||
|     } | ||||
|  | ||||
|     async _initTheme(root: DocumentOrShadowRoot): Promise<void> { | ||||
|         // Early activate theme based on media query to prevent light flash | ||||
|         // when dark is preferred | ||||
|         this._applyTheme(root, globalAK().brand.uiTheme); | ||||
|         this._applyTheme(root, await this.getTheme()); | ||||
|     } | ||||
|  | ||||
|     async _initCustomCSS(root: DocumentOrShadowRoot): Promise<void> { | ||||
|         const brand = globalAK().brand; | ||||
|         if (!brand) { | ||||
|             return; | ||||
|         } | ||||
|         const sheet = await new CSSStyleSheet().replace(brand.brandingCustomCss); | ||||
|         root.adoptedStyleSheets = [...root.adoptedStyleSheets, sheet]; | ||||
|     } | ||||
|  | ||||
|     _applyTheme(root: DocumentOrShadowRoot, theme?: UiThemeEnum): void { | ||||
|         if (!theme) { | ||||
|             theme = UiThemeEnum.Automatic; | ||||
|         } | ||||
|         if (theme === UiThemeEnum.Automatic) { | ||||
|             // Create a media matcher to automatically switch the theme depending on | ||||
|             // prefers-color-scheme | ||||
|             if (!this._mediaMatcher) { | ||||
|                 this._mediaMatcher = window.matchMedia(QUERY_MEDIA_COLOR_LIGHT); | ||||
|                 this._mediaMatcherHandler = (ev?: MediaQueryListEvent) => { | ||||
|                     const theme = | ||||
|                         ev?.matches || this._mediaMatcher?.matches | ||||
|                             ? UiThemeEnum.Light | ||||
|                             : UiThemeEnum.Dark; | ||||
|                     this._activateTheme(theme, root); | ||||
|                 }; | ||||
|                 this._mediaMatcherHandler(undefined); | ||||
|                 this._mediaMatcher.addEventListener("change", this._mediaMatcherHandler); | ||||
|             } | ||||
|             return; | ||||
|         } else if (this._mediaMatcher && this._mediaMatcherHandler) { | ||||
|             // Theme isn't automatic and we have a matcher configured, remove the matcher | ||||
|             // to prevent changes | ||||
|             this._mediaMatcher.removeEventListener("change", this._mediaMatcherHandler); | ||||
|             this._mediaMatcher = undefined; | ||||
|         } | ||||
|         this._activateTheme(theme, root); | ||||
|     } | ||||
|  | ||||
|     static themeToStylesheet(theme?: UiThemeEnum): CSSStyleSheet | undefined { | ||||
|         if (theme === UiThemeEnum.Dark) { | ||||
|             return _darkTheme; | ||||
|         } | ||||
|         return undefined; | ||||
|     } | ||||
|  | ||||
| export class AKElement extends LitElement implements ThemedElement { | ||||
|     /** | ||||
|      * Directly activate a given theme, accepts multiple document/ShadowDOMs to apply the stylesheet | ||||
|      * to. The stylesheets are applied to each DOM in order. Does nothing if the given theme is already active. | ||||
|      * The resolved theme of the current element. | ||||
|      * | ||||
|      * @remarks | ||||
|      * | ||||
|      * Unlike the browser's current color scheme, this is a value that can be | ||||
|      * resolved to a specific theme, i.e. dark or light. | ||||
|      */ | ||||
|     _activateTheme(theme: UiThemeEnum, ...roots: DocumentOrShadowRoot[]) { | ||||
|         if (theme === this._activeTheme) { | ||||
|             return; | ||||
|     @property({ | ||||
|         attribute: "theme", | ||||
|         type: String, | ||||
|         reflect: true, | ||||
|     }) | ||||
|     public activeTheme: ResolvedUITheme; | ||||
|  | ||||
|     protected static readonly DarkColorSchemeStyleSheet = createStyleSheetUnsafe(ThemeDark); | ||||
|  | ||||
|     protected static finalizeStyles(styles?: CSSResultGroup): CSSResultOrNative[] { | ||||
|         // Ensure all style sheets being passed are really style sheets. | ||||
|         const baseStyles: StyleSheetInit[] = [AKGlobal, OneDark]; | ||||
|  | ||||
|         if (!styles) return baseStyles.map(createStyleSheetUnsafe); | ||||
|  | ||||
|         if (Array.isArray(styles)) { | ||||
|             return [ | ||||
|                 //--- | ||||
|                 ...(styles as unknown as CSSResultOrNative[]), | ||||
|                 ...baseStyles, | ||||
|             ].flatMap(createStyleSheetUnsafe); | ||||
|         } | ||||
|         // Make sure we only get to this callback once we've picked a concise theme choice | ||||
|         this.dispatchEvent( | ||||
|             new CustomEvent(EVENT_THEME_CHANGE, { | ||||
|                 bubbles: true, | ||||
|                 composed: true, | ||||
|                 detail: theme, | ||||
|             }), | ||||
|         return [styles, ...baseStyles].map(createStyleSheetUnsafe); | ||||
|     } | ||||
|  | ||||
|     constructor(init?: AKElementInit) { | ||||
|         super(); | ||||
|  | ||||
|         const config = globalAK(); | ||||
|         const { brand = config.brand, styleParents = [] } = init || {}; | ||||
|  | ||||
|         this.activeTheme = resolveUITheme(brand?.uiTheme); | ||||
|         this.#styleParents = styleParents; | ||||
|  | ||||
|         this.#customCSSStyleSheet = brand?.brandingCustomCss | ||||
|             ? createStyleSheetUnsafe(brand.brandingCustomCss) | ||||
|             : null; | ||||
|     } | ||||
|  | ||||
|     #styleParents: StyleSheetParent[] = []; | ||||
|     #customCSSStyleSheet: CSSStyleSheet | null; | ||||
|  | ||||
|     #themeAbortController: AbortController | null = null; | ||||
|  | ||||
|     public disconnectedCallback(): void { | ||||
|         super.disconnectedCallback(); | ||||
|         this.#themeAbortController?.abort(); | ||||
|     } | ||||
|  | ||||
|     protected createRenderRoot(): HTMLElement | DocumentFragment { | ||||
|         const renderRoot = super.createRenderRoot(); | ||||
|  | ||||
|         const styleRoot = resolveStyleSheetParent(renderRoot); | ||||
|         const styleParents = Array.from( | ||||
|             new Set<StyleSheetParent>([styleRoot, ...this.#styleParents]), | ||||
|         ); | ||||
|         this.setAttribute("theme", theme); | ||||
|         const stylesheet = AKElement.themeToStylesheet(theme); | ||||
|         const oldStylesheet = AKElement.themeToStylesheet(this._activeTheme); | ||||
|         roots.forEach((root) => { | ||||
|             if (stylesheet) { | ||||
|                 root.adoptedStyleSheets = [ | ||||
|                     ...root.adoptedStyleSheets, | ||||
|                     ensureCSSStyleSheet(stylesheet), | ||||
|                 ]; | ||||
|             } | ||||
|             if (oldStylesheet) { | ||||
|                 root.adoptedStyleSheets = root.adoptedStyleSheets.filter( | ||||
|                     (v) => v !== oldStylesheet, | ||||
|                 ); | ||||
|             } | ||||
|         }); | ||||
|         this._activeTheme = theme; | ||||
|         this.requestUpdate(); | ||||
|  | ||||
|         if (this.#customCSSStyleSheet) { | ||||
|             console.debug(`authentik/element[${this.tagName.toLowerCase()}]: Adding custom CSS`); | ||||
|  | ||||
|             styleRoot.adoptedStyleSheets = [ | ||||
|                 ...styleRoot.adoptedStyleSheets, | ||||
|                 this.#customCSSStyleSheet, | ||||
|             ]; | ||||
|         } | ||||
|  | ||||
|         this.#themeAbortController = new AbortController(); | ||||
|  | ||||
|         createUIThemeEffect( | ||||
|             (currentUITheme) => { | ||||
|                 if (currentUITheme === UiThemeEnum.Dark) { | ||||
|                     appendStyleSheet(AKElement.DarkColorSchemeStyleSheet, ...styleParents); | ||||
|                 } else { | ||||
|                     removeStyleSheet(AKElement.DarkColorSchemeStyleSheet, ...styleParents); | ||||
|                 } | ||||
|                 this.activeTheme = currentUITheme; | ||||
|             }, | ||||
|             { | ||||
|                 signal: this.#themeAbortController.signal, | ||||
|             }, | ||||
|         ); | ||||
|  | ||||
|         return renderRoot; | ||||
|     } | ||||
| } | ||||
|  | ||||
| @ -1,5 +1,6 @@ | ||||
| import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; | ||||
| import { EVENT_REFRESH } from "@goauthentik/common/constants"; | ||||
| import { ThemedElement } from "@goauthentik/common/theme"; | ||||
| import { authentikBrandContext } from "@goauthentik/elements/AuthentikContexts"; | ||||
| import type { ReactiveElementHost } from "@goauthentik/elements/types.js"; | ||||
|  | ||||
| @ -9,14 +10,12 @@ import type { ReactiveController } from "lit"; | ||||
| import type { CurrentBrand } from "@goauthentik/api"; | ||||
| import { CoreApi } from "@goauthentik/api"; | ||||
|  | ||||
| import type { AkInterface } from "./Interface"; | ||||
|  | ||||
| export class BrandContextController implements ReactiveController { | ||||
|     host!: ReactiveElementHost<AkInterface>; | ||||
|     host!: ReactiveElementHost<ThemedElement>; | ||||
|  | ||||
|     context!: ContextProvider<{ __context__: CurrentBrand | undefined }>; | ||||
|  | ||||
|     constructor(host: ReactiveElementHost<AkInterface>) { | ||||
|     constructor(host: ReactiveElementHost<ThemedElement>) { | ||||
|         this.host = host; | ||||
|         this.context = new ContextProvider(this.host, { | ||||
|             context: authentikBrandContext, | ||||
|  | ||||
| @ -1,6 +1,7 @@ | ||||
| import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; | ||||
| import { EVENT_REFRESH } from "@goauthentik/common/constants"; | ||||
| import { globalAK } from "@goauthentik/common/global"; | ||||
| import { ThemedElement } from "@goauthentik/common/theme"; | ||||
| import { authentikConfigContext } from "@goauthentik/elements/AuthentikContexts"; | ||||
| import type { ReactiveElementHost } from "@goauthentik/elements/types.js"; | ||||
|  | ||||
| @ -10,14 +11,12 @@ import type { ReactiveController } from "lit"; | ||||
| import type { Config } from "@goauthentik/api"; | ||||
| import { RootApi } from "@goauthentik/api"; | ||||
|  | ||||
| import type { AkInterface } from "./Interface"; | ||||
|  | ||||
| export class ConfigContextController implements ReactiveController { | ||||
|     host!: ReactiveElementHost<AkInterface>; | ||||
|     host!: ReactiveElementHost<ThemedElement>; | ||||
|  | ||||
|     context!: ContextProvider<{ __context__: Config | undefined }>; | ||||
|  | ||||
|     constructor(host: ReactiveElementHost<AkInterface>) { | ||||
|     constructor(host: ReactiveElementHost<ThemedElement>) { | ||||
|         this.host = host; | ||||
|         this.context = new ContextProvider(this.host, { | ||||
|             context: authentikConfigContext, | ||||
|  | ||||
| @ -1,107 +1,85 @@ | ||||
| import { UIConfig, uiConfig } from "@goauthentik/common/ui/config"; | ||||
| import { | ||||
|     appendStyleSheet, | ||||
|     createStyleSheetUnsafe, | ||||
|     resolveStyleSheetParent, | ||||
| } from "@goauthentik/common/stylesheets"; | ||||
| import { ThemedElement } from "@goauthentik/common/theme"; | ||||
| import { UIConfig } from "@goauthentik/common/ui/config"; | ||||
| import { AKElement, AKElementInit } from "@goauthentik/elements/Base"; | ||||
| import { VersionContextController } from "@goauthentik/elements/Interface/VersionContextController"; | ||||
| import { ModalOrchestrationController } from "@goauthentik/elements/controllers/ModalOrchestrationController.js"; | ||||
| import { ensureCSSStyleSheet } from "@goauthentik/elements/utils/ensureCSSStyleSheet"; | ||||
|  | ||||
| import { state } from "lit/decorators.js"; | ||||
|  | ||||
| import PFBase from "@patternfly/patternfly/patternfly-base.css"; | ||||
|  | ||||
| import type { Config, CurrentBrand, LicenseSummary, Version } from "@goauthentik/api"; | ||||
| import { UiThemeEnum } from "@goauthentik/api"; | ||||
|  | ||||
| import { AKElement, rootInterface } from "../Base"; | ||||
| import { BrandContextController } from "./BrandContextController"; | ||||
| import { ConfigContextController } from "./ConfigContextController"; | ||||
| import { EnterpriseContextController } from "./EnterpriseContextController"; | ||||
|  | ||||
| export type AkInterface = HTMLElement & { | ||||
|     getTheme: () => Promise<UiThemeEnum>; | ||||
|     brand?: CurrentBrand; | ||||
|     uiConfig?: UIConfig; | ||||
|     config?: Config; | ||||
| }; | ||||
|  | ||||
| const brandContext = Symbol("brandContext"); | ||||
| const configContext = Symbol("configContext"); | ||||
| const modalController = Symbol("modalController"); | ||||
| const versionContext = Symbol("versionContext"); | ||||
|  | ||||
| export class Interface extends AKElement implements AkInterface { | ||||
|     [brandContext]!: BrandContextController; | ||||
| export abstract class Interface extends AKElement implements ThemedElement { | ||||
|     protected static readonly PFBaseStyleSheet = createStyleSheetUnsafe(PFBase); | ||||
|  | ||||
|     [configContext]!: ConfigContextController; | ||||
|     [brandContext]: BrandContextController; | ||||
|  | ||||
|     [modalController]!: ModalOrchestrationController; | ||||
|     [configContext]: ConfigContextController; | ||||
|  | ||||
|     [modalController]: ModalOrchestrationController; | ||||
|  | ||||
|     @state() | ||||
|     uiConfig?: UIConfig; | ||||
|     public config?: Config; | ||||
|  | ||||
|     @state() | ||||
|     config?: Config; | ||||
|     public brand?: CurrentBrand; | ||||
|  | ||||
|     @state() | ||||
|     brand?: CurrentBrand; | ||||
|     constructor({ styleParents = [], ...init }: AKElementInit = {}) { | ||||
|         const styleParent = resolveStyleSheetParent(document); | ||||
|  | ||||
|     constructor() { | ||||
|         super(); | ||||
|         document.adoptedStyleSheets = [...document.adoptedStyleSheets, ensureCSSStyleSheet(PFBase)]; | ||||
|         this._initContexts(); | ||||
|         this.dataset.akInterfaceRoot = "true"; | ||||
|     } | ||||
|         super({ | ||||
|             ...init, | ||||
|             styleParents: [styleParent, ...styleParents], | ||||
|         }); | ||||
|  | ||||
|         this.dataset.akInterfaceRoot = this.tagName.toLowerCase(); | ||||
|  | ||||
|         appendStyleSheet(Interface.PFBaseStyleSheet, styleParent); | ||||
|  | ||||
|     _initContexts() { | ||||
|         this[brandContext] = new BrandContextController(this); | ||||
|         this[configContext] = new ConfigContextController(this); | ||||
|         this[modalController] = new ModalOrchestrationController(this); | ||||
|     } | ||||
|  | ||||
|     _activateTheme(theme: UiThemeEnum, ...roots: DocumentOrShadowRoot[]): void { | ||||
|         if (theme === this._activeTheme) { | ||||
|             return; | ||||
|         } | ||||
|         console.debug( | ||||
|             `authentik/interface[${rootInterface()?.tagName.toLowerCase()}]: Enabling theme ${theme}`, | ||||
|         ); | ||||
|         // Special case for root interfaces, as they need to modify the global document CSS too | ||||
|         // Instead of calling ._activateTheme() twice, we insert the root document in the call | ||||
|         // since multiple calls to ._activateTheme() would not do anything after the first call | ||||
|         // as the theme is already enabled. | ||||
|         roots.unshift(document as unknown as DocumentOrShadowRoot); | ||||
|         super._activateTheme(theme, ...roots); | ||||
|     } | ||||
|  | ||||
|     async getTheme(): Promise<UiThemeEnum> { | ||||
|         if (!this.uiConfig) { | ||||
|             this.uiConfig = await uiConfig(); | ||||
|         } | ||||
|         return this.uiConfig.theme?.base || UiThemeEnum.Automatic; | ||||
|     } | ||||
| } | ||||
|  | ||||
| export type AkAuthenticatedInterface = AkInterface & { | ||||
| export interface AkAuthenticatedInterface extends ThemedElement { | ||||
|     licenseSummary?: LicenseSummary; | ||||
|     version?: Version; | ||||
| }; | ||||
| } | ||||
|  | ||||
| const enterpriseContext = Symbol("enterpriseContext"); | ||||
|  | ||||
| export class AuthenticatedInterface extends Interface { | ||||
| export class AuthenticatedInterface extends Interface implements AkAuthenticatedInterface { | ||||
|     [enterpriseContext]!: EnterpriseContextController; | ||||
|     [versionContext]!: VersionContextController; | ||||
|  | ||||
|     @state() | ||||
|     licenseSummary?: LicenseSummary; | ||||
|     public uiConfig?: UIConfig; | ||||
|  | ||||
|     @state() | ||||
|     version?: Version; | ||||
|     public licenseSummary?: LicenseSummary; | ||||
|  | ||||
|     constructor() { | ||||
|         super(); | ||||
|     } | ||||
|     @state() | ||||
|     public version?: Version; | ||||
|  | ||||
|     constructor(init?: AKElementInit) { | ||||
|         super(init); | ||||
|  | ||||
|     _initContexts(): void { | ||||
|         super._initContexts(); | ||||
|         this[enterpriseContext] = new EnterpriseContextController(this); | ||||
|         this[versionContext] = new VersionContextController(this); | ||||
|     } | ||||
|  | ||||
| @ -5,20 +5,23 @@ import { | ||||
| } from "@goauthentik/common/constants"; | ||||
| import { globalAK } from "@goauthentik/common/global"; | ||||
| import { currentInterface } from "@goauthentik/common/sentry"; | ||||
| import { UIConfig, UserDisplay, uiConfig } from "@goauthentik/common/ui/config"; | ||||
| import { UIConfig, UserDisplay, getConfigForUser } from "@goauthentik/common/ui/config"; | ||||
| import { me } from "@goauthentik/common/users"; | ||||
| import "@goauthentik/components/ak-nav-buttons"; | ||||
| import { AKElement } from "@goauthentik/elements/Base"; | ||||
| import { WithBrandConfig } from "@goauthentik/elements/Interface/brandProvider"; | ||||
| import { DefaultBrand } from "@goauthentik/elements/sidebar/SidebarBrand"; | ||||
| import { themeImage } from "@goauthentik/elements/utils/images"; | ||||
| import "@patternfly/elements/pf-tooltip/pf-tooltip.js"; | ||||
|  | ||||
| import { msg } from "@lit/localize"; | ||||
| import { CSSResult, TemplateResult, css, html, nothing } from "lit"; | ||||
| import { CSSResult, LitElement, TemplateResult, css, html, nothing } from "lit"; | ||||
| import { customElement, property, state } from "lit/decorators.js"; | ||||
|  | ||||
| import PFAvatar from "@patternfly/patternfly/components/Avatar/avatar.css"; | ||||
| import PFButton from "@patternfly/patternfly/components/Button/button.css"; | ||||
| import PFContent from "@patternfly/patternfly/components/Content/content.css"; | ||||
| import PFDrawer from "@patternfly/patternfly/components/Drawer/drawer.css"; | ||||
| import PFDropdown from "@patternfly/patternfly/components/Dropdown/dropdown.css"; | ||||
| import PFNotificationBadge from "@patternfly/patternfly/components/NotificationBadge/notification-badge.css"; | ||||
| import PFPage from "@patternfly/patternfly/components/Page/page.css"; | ||||
| @ -26,34 +29,52 @@ import PFBase from "@patternfly/patternfly/patternfly-base.css"; | ||||
|  | ||||
| import { SessionUser } from "@goauthentik/api"; | ||||
|  | ||||
| @customElement("ak-page-header") | ||||
| export class PageHeader extends WithBrandConfig(AKElement) { | ||||
|     @property() | ||||
|     icon?: string; | ||||
| //#region Page Navbar | ||||
|  | ||||
|     @property({ type: Boolean }) | ||||
|     iconImage = false; | ||||
|  | ||||
|     @property() | ||||
|     header = ""; | ||||
|  | ||||
|     @property() | ||||
| export interface PageNavbarDetails { | ||||
|     header?: string; | ||||
|     description?: string; | ||||
|     icon?: string; | ||||
|     iconImage?: boolean; | ||||
| } | ||||
|  | ||||
|     @property({ type: Boolean }) | ||||
|     hasIcon = true; | ||||
| /** | ||||
|  * A global navbar component at the top of the page. | ||||
|  * | ||||
|  * Internally, this component listens for the `ak-page-header` event, which is | ||||
|  * dispatched by the `ak-page-header` component. | ||||
|  */ | ||||
| @customElement("ak-page-navbar") | ||||
| export class AKPageNavbar extends WithBrandConfig(AKElement) implements PageNavbarDetails { | ||||
|     //#region Static Properties | ||||
|  | ||||
|     @state() | ||||
|     me?: SessionUser; | ||||
|     private static elementRef: AKPageNavbar | null = null; | ||||
|  | ||||
|     @state() | ||||
|     uiConfig!: UIConfig; | ||||
|     static readonly setNavbarDetails = (detail: Partial<PageNavbarDetails>): void => { | ||||
|         const { elementRef } = AKPageNavbar; | ||||
|         if (!elementRef) { | ||||
|             console.debug( | ||||
|                 `ak-page-header: Could not find ak-page-navbar, skipping event dispatch.`, | ||||
|             ); | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         const { header, description, icon, iconImage } = detail; | ||||
|  | ||||
|         elementRef.header = header; | ||||
|         elementRef.description = description; | ||||
|         elementRef.icon = icon; | ||||
|         elementRef.iconImage = iconImage || false; | ||||
|         elementRef.hasIcon = !!icon; | ||||
|     }; | ||||
|  | ||||
|     static get styles(): CSSResult[] { | ||||
|         return [ | ||||
|             PFBase, | ||||
|             PFButton, | ||||
|             PFPage, | ||||
|             PFDrawer, | ||||
|  | ||||
|             PFNotificationBadge, | ||||
|             PFContent, | ||||
|             PFAvatar, | ||||
| @ -63,143 +84,392 @@ export class PageHeader extends WithBrandConfig(AKElement) { | ||||
|                     position: sticky; | ||||
|                     top: 0; | ||||
|                     z-index: var(--pf-global--ZIndex--lg); | ||||
|                     --pf-c-page__header-tools--MarginRight: 0; | ||||
|                     --ak-brand-logo-height: var(--pf-global--FontSize--4xl, 2.25rem); | ||||
|                     --ak-brand-background-color: var( | ||||
|                         --pf-c-page__sidebar--m-light--BackgroundColor | ||||
|                     ); | ||||
|                 } | ||||
|                 .bar { | ||||
|  | ||||
|                 :host([theme="dark"]) { | ||||
|                     --ak-brand-background-color: var(--pf-c-page__sidebar--BackgroundColor); | ||||
|                     --pf-c-page__sidebar--BackgroundColor: var(--ak-dark-background-light); | ||||
|                     color: var(--ak-dark-foreground); | ||||
|                 } | ||||
|  | ||||
|                 navbar { | ||||
|                     border-bottom: var(--pf-global--BorderWidth--sm); | ||||
|                     border-bottom-style: solid; | ||||
|                     border-bottom-color: var(--pf-global--BorderColor--100); | ||||
|                     background-color: var(--pf-c-page--BackgroundColor); | ||||
|  | ||||
|                     display: flex; | ||||
|                     flex-direction: row; | ||||
|                     min-height: 114px; | ||||
|                     max-height: 114px; | ||||
|                     background-color: var(--pf-c-page--BackgroundColor); | ||||
|                     min-height: 6rem; | ||||
|  | ||||
|                     display: grid; | ||||
|                     row-gap: var(--pf-global--spacer--sm); | ||||
|                     column-gap: var(--pf-global--spacer--sm); | ||||
|                     grid-template-columns: [brand] auto [toggle] auto [primary] 1fr [secondary] auto; | ||||
|                     grid-template-rows: auto auto; | ||||
|                     grid-template-areas: | ||||
|                         "brand toggle primary secondary" | ||||
|                         "brand toggle description secondary"; | ||||
|  | ||||
|                     @media (max-width: 768px) { | ||||
|                         row-gap: var(--pf-global--spacer--xs); | ||||
|  | ||||
|                         align-items: center; | ||||
|                         grid-template-areas: | ||||
|                             "toggle primary secondary" | ||||
|                             "toggle description description"; | ||||
|                         justify-content: space-between; | ||||
|                         width: 100%; | ||||
|                     } | ||||
|                 } | ||||
|                 .pf-c-page__main-section.pf-m-light { | ||||
|                     background-color: transparent; | ||||
|  | ||||
|                 .items { | ||||
|                     display: block; | ||||
|  | ||||
|                     &.primary { | ||||
|                         grid-column: primary; | ||||
|                         grid-row: primary / description; | ||||
|  | ||||
|                         align-content: center; | ||||
|                         padding-block: var(--pf-global--spacer--md); | ||||
|  | ||||
|                         @media (min-width: 426px) { | ||||
|                             &.block-sibling { | ||||
|                                 padding-block-end: 0; | ||||
|                                 grid-row: primary; | ||||
|                             } | ||||
|                         } | ||||
|  | ||||
|                         @media (max-width: 768px) { | ||||
|                             padding-block: var(--pf-global--spacer--sm); | ||||
|                         } | ||||
|  | ||||
|                         .accent-icon { | ||||
|                             height: 1em; | ||||
|                             width: 1em; | ||||
|  | ||||
|                             @media (max-width: 768px) { | ||||
|                                 display: none; | ||||
|                             } | ||||
|                         } | ||||
|                     } | ||||
|  | ||||
|                     &.page-description { | ||||
|                         grid-area: description; | ||||
|                         padding-block-end: var(--pf-global--spacer--md); | ||||
|  | ||||
|                         @media (max-width: 425px) { | ||||
|                             display: none; | ||||
|                         } | ||||
|  | ||||
|                         @media (min-width: 769px) { | ||||
|                             text-wrap: balance; | ||||
|                         } | ||||
|                     } | ||||
|  | ||||
|                     &.secondary { | ||||
|                         grid-area: secondary; | ||||
|                         flex: 0 0 auto; | ||||
|                         justify-self: end; | ||||
|                         padding-block: var(--pf-global--spacer--sm); | ||||
|                         padding-inline-end: var(--pf-global--spacer--sm); | ||||
|  | ||||
|                         @media (min-width: 769px) { | ||||
|                             align-content: center; | ||||
|                             padding-block: var(--pf-global--spacer--md); | ||||
|                             padding-inline-end: var(--pf-global--spacer--xl); | ||||
|                         } | ||||
|                     } | ||||
|                 } | ||||
|                 .pf-c-page__main-section { | ||||
|                     flex-grow: 1; | ||||
|                     flex-shrink: 1; | ||||
|  | ||||
|                 .brand { | ||||
|                     grid-area: brand; | ||||
|                     background-color: var(--ak-brand-background-color); | ||||
|                     height: 100%; | ||||
|                     width: var(--pf-c-page__sidebar--Width); | ||||
|                     align-items: center; | ||||
|                     padding-inline: var(--pf-global--spacer--sm); | ||||
|  | ||||
|                     display: flex; | ||||
|                     flex-direction: column; | ||||
|                     justify-content: center; | ||||
|  | ||||
|                     &.pf-m-collapsed { | ||||
|                         display: none; | ||||
|                     } | ||||
|  | ||||
|                     @media (max-width: 1199px) { | ||||
|                         display: none; | ||||
|                     } | ||||
|                 } | ||||
|                 img.pf-icon { | ||||
|                     max-height: 24px; | ||||
|  | ||||
|                 .sidebar-trigger { | ||||
|                     grid-area: toggle; | ||||
|                     height: 100%; | ||||
|                 } | ||||
|  | ||||
|                 .logo { | ||||
|                     flex: 0 0 auto; | ||||
|                     height: var(--ak-brand-logo-height); | ||||
|  | ||||
|                     & img { | ||||
|                         height: 100%; | ||||
|                     } | ||||
|                 } | ||||
|  | ||||
|                 .sidebar-trigger, | ||||
|                 .notification-trigger { | ||||
|                     font-size: 24px; | ||||
|                     font-size: 1.5rem; | ||||
|                 } | ||||
|  | ||||
|                 .notification-trigger.has-notifications { | ||||
|                     color: var(--pf-global--active-color--100); | ||||
|                 } | ||||
|  | ||||
|                 .page-title { | ||||
|                     display: flex; | ||||
|                     gap: var(--pf-global--spacer--xs); | ||||
|                 } | ||||
|  | ||||
|                 h1 { | ||||
|                     display: flex; | ||||
|                     flex-direction: row; | ||||
|                     align-items: center !important; | ||||
|                 } | ||||
|                 .pf-c-page__header-tools { | ||||
|                     flex-shrink: 0; | ||||
|                 } | ||||
|                 .pf-c-page__header-tools-group { | ||||
|                     height: 100%; | ||||
|                 } | ||||
|                 :host([theme="dark"]) .pf-c-page__header-tools { | ||||
|                     color: var(--ak-dark-foreground) !important; | ||||
|                 } | ||||
|             `, | ||||
|         ]; | ||||
|     } | ||||
|  | ||||
|     constructor() { | ||||
|         super(); | ||||
|         window.addEventListener(EVENT_WS_MESSAGE, () => { | ||||
|             this.firstUpdated(); | ||||
|         }); | ||||
|     } | ||||
|     //#endregion | ||||
|  | ||||
|     async firstUpdated() { | ||||
|         this.me = await me(); | ||||
|         this.uiConfig = await uiConfig(); | ||||
|         this.uiConfig.navbar.userDisplay = UserDisplay.none; | ||||
|     } | ||||
|     //#region Properties | ||||
|  | ||||
|     setTitle(header?: string) { | ||||
|     @property({ type: String }) | ||||
|     icon?: string; | ||||
|  | ||||
|     @property({ type: Boolean }) | ||||
|     iconImage = false; | ||||
|  | ||||
|     @property({ type: String }) | ||||
|     header?: string; | ||||
|  | ||||
|     @property({ type: String }) | ||||
|     description?: string; | ||||
|  | ||||
|     @property({ type: Boolean }) | ||||
|     hasIcon = true; | ||||
|  | ||||
|     @property({ type: Boolean }) | ||||
|     open = true; | ||||
|  | ||||
|     @state() | ||||
|     session?: SessionUser; | ||||
|  | ||||
|     @state() | ||||
|     uiConfig!: UIConfig; | ||||
|  | ||||
|     //#endregion | ||||
|  | ||||
|     //#region Private Methods | ||||
|  | ||||
|     #setTitle(header?: string) { | ||||
|         const currentIf = currentInterface(); | ||||
|         let title = this.brand?.brandingTitle || TITLE_DEFAULT; | ||||
|  | ||||
|         if (currentIf === "admin") { | ||||
|             title = `${msg("Admin")} - ${title}`; | ||||
|         } | ||||
|         // Prepend the header to the title | ||||
|         if (header !== undefined && header !== "") { | ||||
|         if (header) { | ||||
|             title = `${header} - ${title}`; | ||||
|         } | ||||
|         document.title = title; | ||||
|     } | ||||
|  | ||||
|     #toggleSidebar() { | ||||
|         this.open = !this.open; | ||||
|  | ||||
|         this.dispatchEvent( | ||||
|             new CustomEvent(EVENT_SIDEBAR_TOGGLE, { | ||||
|                 bubbles: true, | ||||
|                 composed: true, | ||||
|             }), | ||||
|         ); | ||||
|     } | ||||
|  | ||||
|     //#endregion | ||||
|  | ||||
|     //#region Lifecycle | ||||
|  | ||||
|     public connectedCallback(): void { | ||||
|         super.connectedCallback(); | ||||
|         AKPageNavbar.elementRef = this; | ||||
|  | ||||
|         window.addEventListener(EVENT_WS_MESSAGE, () => { | ||||
|             this.firstUpdated(); | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     public disconnectedCallback(): void { | ||||
|         super.disconnectedCallback(); | ||||
|         AKPageNavbar.elementRef = null; | ||||
|     } | ||||
|  | ||||
|     public async firstUpdated() { | ||||
|         this.session = await me(); | ||||
|         this.uiConfig = getConfigForUser(this.session.user); | ||||
|         this.uiConfig.navbar.userDisplay = UserDisplay.none; | ||||
|     } | ||||
|  | ||||
|     willUpdate() { | ||||
|         // Always update title, even if there's no header value set, | ||||
|         // as in that case we still need to return to the generic title | ||||
|         this.setTitle(this.header); | ||||
|         this.#setTitle(this.header); | ||||
|     } | ||||
|  | ||||
|     //#endregion | ||||
|  | ||||
|     //#region Render | ||||
|  | ||||
|     renderIcon() { | ||||
|         if (this.icon) { | ||||
|             if (this.iconImage && !this.icon.startsWith("fa://")) { | ||||
|                 return html`<img class="pf-icon" src="${this.icon}" alt="page icon" />`; | ||||
|                 return html`<img class="accent-icon pf-icon" src="${this.icon}" alt="page icon" />`; | ||||
|             } | ||||
|  | ||||
|             const icon = this.icon.replaceAll("fa://", "fa "); | ||||
|             return html`<i class=${icon}></i>`; | ||||
|  | ||||
|             return html`<i class="accent-icon ${icon}"></i>`; | ||||
|         } | ||||
|         return nothing; | ||||
|     } | ||||
|  | ||||
|     render(): TemplateResult { | ||||
|         return html`<div class="bar"> | ||||
|             <button | ||||
|                 class="sidebar-trigger pf-c-button pf-m-plain" | ||||
|                 @click=${() => { | ||||
|                     this.dispatchEvent( | ||||
|                         new CustomEvent(EVENT_SIDEBAR_TOGGLE, { | ||||
|                             bubbles: true, | ||||
|                             composed: true, | ||||
|                         }), | ||||
|                     ); | ||||
|                 }} | ||||
|             > | ||||
|                 <i class="fas fa-bars"></i> | ||||
|             </button> | ||||
|             <section class="pf-c-page__main-section pf-m-light"> | ||||
|                 <div class="pf-c-content"> | ||||
|                     <h1> | ||||
|         return html`<navbar aria-label="Main" class="navbar"> | ||||
|                 <aside class="brand ${this.open ? "" : "pf-m-collapsed"}"> | ||||
|                     <a href="#/"> | ||||
|                         <div class="logo"> | ||||
|                             <img | ||||
|                                 src=${themeImage( | ||||
|                                     this.brand?.brandingLogo ?? DefaultBrand.brandingLogo, | ||||
|                                 )} | ||||
|                                 alt="${msg("authentik Logo")}" | ||||
|                                 loading="lazy" | ||||
|                             /> | ||||
|                         </div> | ||||
|                     </a> | ||||
|                 </aside> | ||||
|                 <button | ||||
|                     class="sidebar-trigger pf-c-button pf-m-plain" | ||||
|                     @click=${this.#toggleSidebar} | ||||
|                     aria-label=${msg("Toggle sidebar")} | ||||
|                     aria-expanded=${this.open ? "true" : "false"} | ||||
|                 > | ||||
|                     <i class="fas fa-bars"></i> | ||||
|                 </button> | ||||
|  | ||||
|                 <section | ||||
|                     class="items primary pf-c-content ${this.description ? "block-sibling" : ""}" | ||||
|                 > | ||||
|                     <h1 class="page-title"> | ||||
|                         ${this.hasIcon | ||||
|                             ? html`<slot name="icon">${this.renderIcon()}</slot> ` | ||||
|                             ? html`<slot name="icon">${this.renderIcon()}</slot>` | ||||
|                             : nothing} | ||||
|                         <slot name="header">${this.header}</slot> | ||||
|                         ${this.header} | ||||
|                     </h1> | ||||
|                     ${this.description ? html`<p>${this.description}</p>` : html``} | ||||
|                 </div> | ||||
|             </section> | ||||
|             <div class="pf-c-page__header-tools"> | ||||
|                 <div class="pf-c-page__header-tools-group"> | ||||
|                     <ak-nav-buttons .uiConfig=${this.uiConfig} .me=${this.me}> | ||||
|                         <a | ||||
|                             class="pf-c-button pf-m-secondary pf-m-small pf-u-display-none pf-u-display-block-on-md" | ||||
|                             href="${globalAK().api.base}if/user/" | ||||
|                             slot="extra" | ||||
|                         > | ||||
|                             ${msg("User interface")} | ||||
|                         </a> | ||||
|                     </ak-nav-buttons> | ||||
|                 </div> | ||||
|             </div> | ||||
|         </div>`; | ||||
|                 </section> | ||||
|                 ${this.description | ||||
|                     ? html`<section class="items page-description pf-c-content"> | ||||
|                           <p>${this.description}</p> | ||||
|                       </section>` | ||||
|                     : nothing} | ||||
|  | ||||
|                 <section class="items secondary"> | ||||
|                     <div class="pf-c-page__header-tools-group"> | ||||
|                         <ak-nav-buttons .uiConfig=${this.uiConfig} .me=${this.session}> | ||||
|                             <a | ||||
|                                 class="pf-c-button pf-m-secondary pf-m-small pf-u-display-none pf-u-display-block-on-md" | ||||
|                                 href="${globalAK().api.base}if/user/" | ||||
|                                 slot="extra" | ||||
|                             > | ||||
|                                 ${msg("User interface")} | ||||
|                             </a> | ||||
|                         </ak-nav-buttons> | ||||
|                     </div> | ||||
|                 </section> | ||||
|             </navbar> | ||||
|             <slot></slot>`; | ||||
|     } | ||||
|  | ||||
|     //#endregion | ||||
| } | ||||
|  | ||||
| //#endregion | ||||
|  | ||||
| //#region Page Header | ||||
|  | ||||
| /** | ||||
|  * A page header component, used to display the page title and description. | ||||
|  * | ||||
|  * Internally, this component dispatches the `ak-page-header` event, which is | ||||
|  * listened to by the `ak-page-navbar` component. | ||||
|  * | ||||
|  * @singleton | ||||
|  */ | ||||
| @customElement("ak-page-header") | ||||
| export class AKPageHeader extends LitElement implements PageNavbarDetails { | ||||
|     @property({ type: String }) | ||||
|     header?: string; | ||||
|  | ||||
|     @property({ type: String }) | ||||
|     description?: string; | ||||
|  | ||||
|     @property({ type: String }) | ||||
|     icon?: string; | ||||
|  | ||||
|     @property({ type: Boolean }) | ||||
|     iconImage = false; | ||||
|  | ||||
|     static get styles(): CSSResult[] { | ||||
|         return [ | ||||
|             css` | ||||
|                 :host { | ||||
|                     display: none; | ||||
|                 } | ||||
|             `, | ||||
|         ]; | ||||
|     } | ||||
|  | ||||
|     connectedCallback(): void { | ||||
|         super.connectedCallback(); | ||||
|  | ||||
|         AKPageNavbar.setNavbarDetails({ | ||||
|             header: this.header, | ||||
|             description: this.description, | ||||
|             icon: this.icon, | ||||
|             iconImage: this.iconImage, | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     updated(): void { | ||||
|         AKPageNavbar.setNavbarDetails({ | ||||
|             header: this.header, | ||||
|             description: this.description, | ||||
|             icon: this.icon, | ||||
|             iconImage: this.iconImage, | ||||
|         }); | ||||
|     } | ||||
| } | ||||
|  | ||||
| //#endregion | ||||
|  | ||||
| declare global { | ||||
|     interface HTMLElementTagNameMap { | ||||
|         "ak-page-header": PageHeader; | ||||
|         "ak-page-header": AKPageHeader; | ||||
|         "ak-page-navbar": AKPageNavbar; | ||||
|     } | ||||
| } | ||||
|  | ||||
| @ -80,6 +80,7 @@ export class AggregateCard extends AKElement implements IAggregateCard { | ||||
|                 .center-value { | ||||
|                     font-size: var(--pf-global--icon--FontSize--lg); | ||||
|                     text-align: center; | ||||
|                     place-content: center; | ||||
|                 } | ||||
|                 .subtext { | ||||
|                     margin-top: var(--pf-global--spacer--sm); | ||||
|  | ||||
| @ -22,6 +22,7 @@ export class Sidebar extends AKElement { | ||||
|             css` | ||||
|                 :host { | ||||
|                     z-index: 100; | ||||
|                     --pf-c-page__sidebar--Transition: 0 !important; | ||||
|                 } | ||||
|                 .pf-c-nav__link.pf-m-current::after, | ||||
|                 .pf-c-nav__link.pf-m-current:hover::after, | ||||
| @ -35,10 +36,7 @@ export class Sidebar extends AKElement { | ||||
|                 .pf-c-nav__section + .pf-c-nav__section { | ||||
|                     --pf-c-nav__section--section--MarginTop: var(--pf-global--spacer--sm); | ||||
|                 } | ||||
|                 .pf-c-nav__list .sidebar-brand { | ||||
|                     max-height: 82px; | ||||
|                     margin-bottom: -0.5rem; | ||||
|                 } | ||||
|  | ||||
|                 nav { | ||||
|                     display: flex; | ||||
|                     flex-direction: column; | ||||
| @ -70,7 +68,6 @@ export class Sidebar extends AKElement { | ||||
|             class="pf-c-nav ${this.activeTheme === UiThemeEnum.Light ? "pf-m-light" : ""}" | ||||
|             aria-label=${msg("Global")} | ||||
|         > | ||||
|             <ak-sidebar-brand></ak-sidebar-brand> | ||||
|             <ul class="pf-c-nav__list"> | ||||
|                 <slot></slot> | ||||
|             </ul> | ||||
|  | ||||
| @ -1,17 +1,3 @@ | ||||
| import { EVENT_SIDEBAR_TOGGLE } from "@goauthentik/common/constants"; | ||||
| import { AKElement } from "@goauthentik/elements/Base"; | ||||
| import { WithBrandConfig } from "@goauthentik/elements/Interface/brandProvider"; | ||||
| import { themeImage } from "@goauthentik/elements/utils/images"; | ||||
|  | ||||
| import { msg } from "@lit/localize"; | ||||
| import { CSSResult, TemplateResult, css, html } from "lit"; | ||||
| import { customElement } from "lit/decorators.js"; | ||||
|  | ||||
| import PFButton from "@patternfly/patternfly/components/Button/button.css"; | ||||
| import PFPage from "@patternfly/patternfly/components/Page/page.css"; | ||||
| import PFGlobal from "@patternfly/patternfly/patternfly-base.css"; | ||||
| import PFBase from "@patternfly/patternfly/patternfly-base.css"; | ||||
|  | ||||
| import { CurrentBrand, UiThemeEnum } from "@goauthentik/api"; | ||||
|  | ||||
| // If the viewport is wider than MIN_WIDTH, the sidebar | ||||
| @ -28,79 +14,3 @@ export const DefaultBrand: CurrentBrand = { | ||||
|     matchedDomain: "", | ||||
|     defaultLocale: "", | ||||
| }; | ||||
|  | ||||
| @customElement("ak-sidebar-brand") | ||||
| export class SidebarBrand extends WithBrandConfig(AKElement) { | ||||
|     static get styles(): CSSResult[] { | ||||
|         return [ | ||||
|             PFBase, | ||||
|             PFGlobal, | ||||
|             PFPage, | ||||
|             PFButton, | ||||
|             css` | ||||
|                 :host { | ||||
|                     display: flex; | ||||
|                     flex-direction: row; | ||||
|                     align-items: center; | ||||
|                     height: 114px; | ||||
|                     min-height: 114px; | ||||
|                     border-bottom: var(--pf-global--BorderWidth--sm); | ||||
|                     border-bottom-style: solid; | ||||
|                     border-bottom-color: var(--pf-global--BorderColor--100); | ||||
|                 } | ||||
|                 .pf-c-brand img { | ||||
|                     padding: 0 0.5rem; | ||||
|                     height: 42px; | ||||
|                 } | ||||
|                 button.pf-c-button.sidebar-trigger { | ||||
|                     background-color: transparent; | ||||
|                     border-radius: 0px; | ||||
|                     height: 100%; | ||||
|                     color: var(--ak-dark-foreground); | ||||
|                 } | ||||
|             `, | ||||
|         ]; | ||||
|     } | ||||
|  | ||||
|     constructor() { | ||||
|         super(); | ||||
|         window.addEventListener("resize", () => { | ||||
|             this.requestUpdate(); | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     render(): TemplateResult { | ||||
|         return html` ${window.innerWidth <= MIN_WIDTH | ||||
|                 ? html` | ||||
|                       <button | ||||
|                           class="sidebar-trigger pf-c-button" | ||||
|                           @click=${() => { | ||||
|                               this.dispatchEvent( | ||||
|                                   new CustomEvent(EVENT_SIDEBAR_TOGGLE, { | ||||
|                                       bubbles: true, | ||||
|                                       composed: true, | ||||
|                                   }), | ||||
|                               ); | ||||
|                           }} | ||||
|                       > | ||||
|                           <i class="fas fa-bars"></i> | ||||
|                       </button> | ||||
|                   ` | ||||
|                 : html``} | ||||
|             <a href="#/" class="pf-c-page__header-brand-link"> | ||||
|                 <div class="pf-c-brand ak-brand"> | ||||
|                     <img | ||||
|                         src=${themeImage(this.brand?.brandingLogo ?? DefaultBrand.brandingLogo)} | ||||
|                         alt="${msg("authentik Logo")}" | ||||
|                         loading="lazy" | ||||
|                     /> | ||||
|                 </div> | ||||
|             </a>`; | ||||
|     } | ||||
| } | ||||
|  | ||||
| declare global { | ||||
|     interface HTMLElementTagNameMap { | ||||
|         "ak-sidebar-brand": SidebarBrand; | ||||
|     } | ||||
| } | ||||
|  | ||||
| @ -1,19 +1,20 @@ | ||||
| import { | ||||
|     appendStyleSheet, | ||||
|     assertAdoptableStyleSheetParent, | ||||
| } from "@goauthentik/common/stylesheets.js"; | ||||
|  | ||||
| import { TemplateResult, render as litRender } from "lit"; | ||||
|  | ||||
| import AKGlobal from "@goauthentik/common/styles/authentik.css"; | ||||
| import PFBase from "@patternfly/patternfly/patternfly-base.css"; | ||||
|  | ||||
| import { ensureCSSStyleSheet } from "../utils/ensureCSSStyleSheet.js"; | ||||
|  | ||||
| // A special version of render that ensures our style sheets will always be available | ||||
| // to all elements under test.  Ensures they look right during testing, and that any | ||||
| // CSS-based checks for visibility will return correct values. | ||||
|  | ||||
| export const render = (body: TemplateResult) => { | ||||
|     document.adoptedStyleSheets = [ | ||||
|         ...document.adoptedStyleSheets, | ||||
|         ensureCSSStyleSheet(PFBase), | ||||
|         ensureCSSStyleSheet(AKGlobal), | ||||
|     ]; | ||||
|     assertAdoptableStyleSheetParent(document); | ||||
|  | ||||
|     appendStyleSheet([PFBase, AKGlobal], document); | ||||
|     return litRender(body, document.body); | ||||
| }; | ||||
|  | ||||
| @ -1,9 +1,14 @@ | ||||
| import { AKElement } from "@goauthentik/elements/Base"; | ||||
|  | ||||
| import { type LitElement, type ReactiveControllerHost, type TemplateResult, nothing } from "lit"; | ||||
| import "lit"; | ||||
|  | ||||
| export type ReactiveElementHost<T = AKElement> = Partial<ReactiveControllerHost> & T; | ||||
| /** | ||||
|  * A custom element which may be used as a host for a ReactiveController. | ||||
|  * | ||||
|  * @remarks | ||||
|  * | ||||
|  * This type is derived from an internal type in Lit. | ||||
|  */ | ||||
| export type ReactiveElementHost<T> = Partial<ReactiveControllerHost & T> & HTMLElement; | ||||
|  | ||||
| export type AbstractLitElementConstructor = abstract new (...args: never[]) => LitElement; | ||||
|  | ||||
|  | ||||
| @ -1,35 +0,0 @@ | ||||
| import { CSSResult, unsafeCSS } from "lit"; | ||||
|  | ||||
| const supportsAdoptingStyleSheets: boolean = | ||||
|     window.ShadowRoot && | ||||
|     (window.ShadyCSS === undefined || window.ShadyCSS.nativeShadow) && | ||||
|     "adoptedStyleSheets" in Document.prototype && | ||||
|     "replace" in CSSStyleSheet.prototype; | ||||
|  | ||||
| function stringToStylesheet(css: string) { | ||||
|     if (supportsAdoptingStyleSheets) { | ||||
|         const sheet = unsafeCSS(css).styleSheet; | ||||
|         if (sheet === undefined) { | ||||
|             throw new Error( | ||||
|                 `CSS processing error: undefined stylesheet from string.  Source: ${css}`, | ||||
|             ); | ||||
|         } | ||||
|         return sheet; | ||||
|     } | ||||
|  | ||||
|     const sheet = new CSSStyleSheet(); | ||||
|     sheet.replaceSync(css); | ||||
|     return sheet; | ||||
| } | ||||
|  | ||||
| function cssResultToStylesheet(css: CSSResult) { | ||||
|     const sheet = css.styleSheet; | ||||
|     return sheet ? sheet : stringToStylesheet(css.toString()); | ||||
| } | ||||
|  | ||||
| export const ensureCSSStyleSheet = (css: string | CSSStyleSheet | CSSResult): CSSStyleSheet => | ||||
|     css instanceof CSSResult | ||||
|         ? cssResultToStylesheet(css) | ||||
|         : typeof css === "string" | ||||
|           ? stringToStylesheet(css) | ||||
|           : css; | ||||
							
								
								
									
										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,13 +1,8 @@ | ||||
| import { QUERY_MEDIA_COLOR_LIGHT, rootInterface } from "@goauthentik/elements/Base"; | ||||
|  | ||||
| import { UiThemeEnum } from "@goauthentik/api"; | ||||
| import { resolveUITheme } from "@goauthentik/common/theme"; | ||||
| import { rootInterface } from "@goauthentik/elements/Base"; | ||||
|  | ||||
| export function themeImage(rawPath: string) { | ||||
|     let enabledTheme = rootInterface()?.activeTheme; | ||||
|     if (!enabledTheme || enabledTheme === UiThemeEnum.Automatic) { | ||||
|         enabledTheme = window.matchMedia(QUERY_MEDIA_COLOR_LIGHT).matches | ||||
|             ? UiThemeEnum.Light | ||||
|             : UiThemeEnum.Dark; | ||||
|     } | ||||
|     const enabledTheme = rootInterface()?.activeTheme || resolveUITheme(); | ||||
|  | ||||
|     return rawPath.replaceAll("%(theme)s", enabledTheme); | ||||
| } | ||||
|  | ||||
| @ -46,7 +46,6 @@ import { | ||||
|     FlowsApi, | ||||
|     ResponseError, | ||||
|     ShellChallenge, | ||||
|     UiThemeEnum, | ||||
| } from "@goauthentik/api"; | ||||
|  | ||||
| @customElement("ak-flow-executor") | ||||
| @ -200,10 +199,6 @@ export class FlowExecutor extends Interface implements StageHost { | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     async getTheme(): Promise<UiThemeEnum> { | ||||
|         return globalAK()?.brand.uiTheme || UiThemeEnum.Automatic; | ||||
|     } | ||||
|  | ||||
|     async submit( | ||||
|         payload?: FlowChallengeResponseRequest, | ||||
|         options?: SubmitOptions, | ||||
|  | ||||
| @ -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,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() { | ||||
|  | ||||
| @ -3,7 +3,6 @@ import "rapidoc"; | ||||
|  | ||||
| import { CSRFHeaderName } from "@goauthentik/common/api/config"; | ||||
| import { EVENT_THEME_CHANGE } from "@goauthentik/common/constants"; | ||||
| import { globalAK } from "@goauthentik/common/global"; | ||||
| import { first, getCookie } from "@goauthentik/common/utils"; | ||||
| import { Interface } from "@goauthentik/elements/Interface"; | ||||
| import "@goauthentik/elements/ak-locale-context"; | ||||
| @ -62,10 +61,6 @@ export class APIBrowser extends Interface { | ||||
|         ); | ||||
|     } | ||||
|  | ||||
|     async getTheme(): Promise<UiThemeEnum> { | ||||
|         return globalAK()?.brand.uiTheme || UiThemeEnum.Automatic; | ||||
|     } | ||||
|  | ||||
|     render(): TemplateResult { | ||||
|         return html` | ||||
|             <ak-locale-context> | ||||
|  | ||||
| @ -1,4 +1,3 @@ | ||||
| import { globalAK } from "@goauthentik/common/global"; | ||||
| import { Interface } from "@goauthentik/elements/Interface"; | ||||
|  | ||||
| import { msg } from "@lit/localize"; | ||||
| @ -10,8 +9,6 @@ import PFPage from "@patternfly/patternfly/components/Page/page.css"; | ||||
| import PFSpinner from "@patternfly/patternfly/components/Spinner/spinner.css"; | ||||
| import PFBase from "@patternfly/patternfly/patternfly-base.css"; | ||||
|  | ||||
| import { UiThemeEnum } from "@goauthentik/api"; | ||||
|  | ||||
| @customElement("ak-loading") | ||||
| export class Loading extends Interface { | ||||
|     static get styles(): CSSResult[] { | ||||
| @ -28,7 +25,7 @@ export class Loading extends Interface { | ||||
|         ]; | ||||
|     } | ||||
|  | ||||
|     _initContexts(): void { | ||||
|     registerContexts(): void { | ||||
|         // Stub function to avoid making API requests for things we don't need. The `Interface` base class loads | ||||
|         // a bunch of data that is used globally by various things, however this is an interface that is shown | ||||
|         // very briefly and we don't need any of that data. | ||||
| @ -38,10 +35,6 @@ export class Loading extends Interface { | ||||
|         // Stub function to avoid fetching custom CSS. | ||||
|     } | ||||
|  | ||||
|     async getTheme(): Promise<UiThemeEnum> { | ||||
|         return globalAK()?.brand.uiTheme || UiThemeEnum.Automatic; | ||||
|     } | ||||
|  | ||||
|     render(): TemplateResult { | ||||
|         return html` <section | ||||
|             class="ak-static-page pf-c-page__main-section pf-m-no-padding-mobile pf-m-xl" | ||||
|  | ||||
| @ -1,18 +1,9 @@ | ||||
| import { FlowExecutor } from "@goauthentik/flow/FlowExecutor"; | ||||
|  | ||||
| import { customElement, property } from "lit/decorators.js"; | ||||
|  | ||||
| import { UiThemeEnum } from "@goauthentik/api"; | ||||
| import { customElement } from "lit/decorators.js"; | ||||
|  | ||||
| @customElement("ak-storybook-interface-flow") | ||||
| export class StoryFlowInterface extends FlowExecutor { | ||||
|     @property() | ||||
|     storyTheme: UiThemeEnum = UiThemeEnum.Dark; | ||||
|  | ||||
|     async getTheme(): Promise<UiThemeEnum> { | ||||
|         return this.storyTheme; | ||||
|     } | ||||
| } | ||||
| export class StoryFlowInterface extends FlowExecutor {} | ||||
|  | ||||
| declare global { | ||||
|     interface HTMLElementTagNameMap { | ||||
|  | ||||
| @ -1,18 +1,9 @@ | ||||
| import { Interface } from "@goauthentik/elements/Interface"; | ||||
|  | ||||
| import { customElement, property } from "lit/decorators.js"; | ||||
|  | ||||
| import { UiThemeEnum } from "@goauthentik/api"; | ||||
| import { customElement } from "lit/decorators.js"; | ||||
|  | ||||
| @customElement("ak-storybook-interface") | ||||
| export class StoryInterface extends Interface { | ||||
|     @property() | ||||
|     storyTheme: UiThemeEnum = UiThemeEnum.Dark; | ||||
|  | ||||
|     async getTheme(): Promise<UiThemeEnum> { | ||||
|         return this.storyTheme; | ||||
|     } | ||||
| } | ||||
| export class StoryInterface extends Interface {} | ||||
|  | ||||
| declare global { | ||||
|     interface HTMLElementTagNameMap { | ||||
|  | ||||
| @ -6,7 +6,7 @@ import { | ||||
| } from "@goauthentik/common/constants"; | ||||
| import { globalAK } from "@goauthentik/common/global"; | ||||
| import { configureSentry } from "@goauthentik/common/sentry"; | ||||
| import { UIConfig } from "@goauthentik/common/ui/config"; | ||||
| import { UIConfig, getConfigForUser } from "@goauthentik/common/ui/config"; | ||||
| import { me } from "@goauthentik/common/users"; | ||||
| import { WebsocketClient } from "@goauthentik/common/ws"; | ||||
| import "@goauthentik/components/ak-nav-buttons"; | ||||
| @ -292,6 +292,7 @@ export class UserInterface extends AuthenticatedInterface { | ||||
|  | ||||
|     async connectedCallback() { | ||||
|         super.connectedCallback(); | ||||
|  | ||||
|         window.addEventListener(EVENT_NOTIFICATION_DRAWER_TOGGLE, this.toggleNotificationDrawer); | ||||
|         window.addEventListener(EVENT_API_DRAWER_TOGGLE, this.toggleApiDrawer); | ||||
|         window.addEventListener(EVENT_WS_MESSAGE, this.fetchConfigurationDetails); | ||||
| @ -301,6 +302,7 @@ export class UserInterface extends AuthenticatedInterface { | ||||
|         window.removeEventListener(EVENT_NOTIFICATION_DRAWER_TOGGLE, this.toggleNotificationDrawer); | ||||
|         window.removeEventListener(EVENT_API_DRAWER_TOGGLE, this.toggleApiDrawer); | ||||
|         window.removeEventListener(EVENT_WS_MESSAGE, this.fetchConfigurationDetails); | ||||
|  | ||||
|         super.disconnectedCallback(); | ||||
|     } | ||||
|  | ||||
| @ -319,8 +321,10 @@ export class UserInterface extends AuthenticatedInterface { | ||||
|     } | ||||
|  | ||||
|     fetchConfigurationDetails() { | ||||
|         me().then((me: SessionUser) => { | ||||
|             this.me = me; | ||||
|         me().then((session: SessionUser) => { | ||||
|             this.me = session; | ||||
|             this.uiConfig = getConfigForUser(session.user); | ||||
|  | ||||
|             new EventsApi(DEFAULT_CONFIG) | ||||
|                 .eventsNotificationsList({ | ||||
|                     seen: false, | ||||
| @ -334,12 +338,16 @@ export class UserInterface extends AuthenticatedInterface { | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     get isFullyConfigured() { | ||||
|         return Boolean(this.uiConfig && this.me); | ||||
|     } | ||||
|  | ||||
|     render() { | ||||
|         if (!this.isFullyConfigured) { | ||||
|         if (!this.me) { | ||||
|             console.debug(`authentik/user/UserInterface: waiting for user session to be available`); | ||||
|  | ||||
|             return nothing; | ||||
|         } | ||||
|  | ||||
|         if (!this.uiConfig) { | ||||
|             console.debug(`authentik/user/UserInterface: waiting for UI config to be available`); | ||||
|  | ||||
|             return nothing; | ||||
|         } | ||||
|  | ||||
|  | ||||
		Reference in New Issue
	
	Block a user
	