Revert-revert: Safari fixes (#14331)
* Reapply "web: Safari fixes merge branch (#14181)"
This reverts commit a41d45834c.
* web: Fix brand preference order. Adjust header height.
			
			
This commit is contained in:
		
							
								
								
									
										55
									
								
								web/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										55
									
								
								web/package-lock.json
									
									
									
										generated
									
									
									
								
							| @ -25,7 +25,6 @@ | |||||||
|                 "@formatjs/intl-listformat": "^7.5.7", |                 "@formatjs/intl-listformat": "^7.5.7", | ||||||
|                 "@fortawesome/fontawesome-free": "^6.6.0", |                 "@fortawesome/fontawesome-free": "^6.6.0", | ||||||
|                 "@goauthentik/api": "^2025.4.0-1746018955", |                 "@goauthentik/api": "^2025.4.0-1746018955", | ||||||
|                 "@lit-labs/ssr": "3.2.2", |  | ||||||
|                 "@lit/context": "^1.1.2", |                 "@lit/context": "^1.1.2", | ||||||
|                 "@lit/localize": "^0.12.2", |                 "@lit/localize": "^0.12.2", | ||||||
|                 "@lit/reactive-element": "^2.0.4", |                 "@lit/reactive-element": "^2.0.4", | ||||||
| @ -66,6 +65,7 @@ | |||||||
|                 "remark-gfm": "^4.0.1", |                 "remark-gfm": "^4.0.1", | ||||||
|                 "remark-mdx-frontmatter": "^5.0.0", |                 "remark-mdx-frontmatter": "^5.0.0", | ||||||
|                 "style-mod": "^4.1.2", |                 "style-mod": "^4.1.2", | ||||||
|  |                 "trusted-types": "^2.0.0", | ||||||
|                 "ts-pattern": "^5.4.0", |                 "ts-pattern": "^5.4.0", | ||||||
|                 "unist-util-visit": "^5.0.0", |                 "unist-util-visit": "^5.0.0", | ||||||
|                 "webcomponent-qr-code": "^1.2.0", |                 "webcomponent-qr-code": "^1.2.0", | ||||||
| @ -2314,50 +2314,11 @@ | |||||||
|                 "@lezer/lr": "^1.0.0" |                 "@lezer/lr": "^1.0.0" | ||||||
|             } |             } | ||||||
|         }, |         }, | ||||||
|         "node_modules/@lit-labs/ssr": { |  | ||||||
|             "version": "3.2.2", |  | ||||||
|             "resolved": "https://registry.npmjs.org/@lit-labs/ssr/-/ssr-3.2.2.tgz", |  | ||||||
|             "integrity": "sha512-He5TzeNPM9ECmVpgXRYmVlz0UA5YnzHlT43kyLi2Lu6mUidskqJVonk9W5K699+2DKhoXp8Ra4EJmHR6KrcW1Q==", |  | ||||||
|             "license": "BSD-3-Clause", |  | ||||||
|             "dependencies": { |  | ||||||
|                 "@lit-labs/ssr-client": "^1.1.7", |  | ||||||
|                 "@lit-labs/ssr-dom-shim": "^1.2.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==", |  | ||||||
|             "license": "BSD-3-Clause", |  | ||||||
|             "dependencies": { |  | ||||||
|                 "@lit/reactive-element": "^2.0.4", |  | ||||||
|                 "lit": "^3.1.2", |  | ||||||
|                 "lit-html": "^3.1.2" |  | ||||||
|             } |  | ||||||
|         }, |  | ||||||
|         "node_modules/@lit-labs/ssr-dom-shim": { |         "node_modules/@lit-labs/ssr-dom-shim": { | ||||||
|             "version": "1.2.1", |             "version": "1.2.1", | ||||||
|             "resolved": "https://registry.npmjs.org/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.2.1.tgz", |             "resolved": "https://registry.npmjs.org/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.2.1.tgz", | ||||||
|             "integrity": "sha512-wx4aBmgeGvFmOKucFKY+8VFJSYZxs9poN3SDNQFF6lT6NrQUnHiPB2PWz2sc4ieEcAaYYzN+1uWahEeTq2aRIQ==" |             "integrity": "sha512-wx4aBmgeGvFmOKucFKY+8VFJSYZxs9poN3SDNQFF6lT6NrQUnHiPB2PWz2sc4ieEcAaYYzN+1uWahEeTq2aRIQ==" | ||||||
|         }, |         }, | ||||||
|         "node_modules/@lit-labs/ssr/node_modules/@types/node": { |  | ||||||
|             "version": "16.18.114", |  | ||||||
|             "resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.114.tgz", |  | ||||||
|             "integrity": "sha512-7oAtnxrgkMNzyzT443UDWwzkmYew81F1ZSPm3/lsITJfW/WludaSOpegTvUG+UdapcbrtWOtY/E4LyTkhPGJ5Q==", |  | ||||||
|             "license": "MIT" |  | ||||||
|         }, |  | ||||||
|         "node_modules/@lit/context": { |         "node_modules/@lit/context": { | ||||||
|             "version": "1.1.2", |             "version": "1.1.2", | ||||||
|             "resolved": "https://registry.npmjs.org/@lit/context/-/context-1.1.2.tgz", |             "resolved": "https://registry.npmjs.org/@lit/context/-/context-1.1.2.tgz", | ||||||
| @ -2967,6 +2928,7 @@ | |||||||
|             "version": "0.3.0", |             "version": "0.3.0", | ||||||
|             "resolved": "https://registry.npmjs.org/@parse5/tools/-/tools-0.3.0.tgz", |             "resolved": "https://registry.npmjs.org/@parse5/tools/-/tools-0.3.0.tgz", | ||||||
|             "integrity": "sha512-zxRyTHkqb7WQMV8kTNBKWb1BeOFUKXBXTBWuxg9H9hfvQB3IwP6Iw2U75Ia5eyRxPNltmY7E8YAlz6zWwUnjKg==", |             "integrity": "sha512-zxRyTHkqb7WQMV8kTNBKWb1BeOFUKXBXTBWuxg9H9hfvQB3IwP6Iw2U75Ia5eyRxPNltmY7E8YAlz6zWwUnjKg==", | ||||||
|  |             "dev": true, | ||||||
|             "dependencies": { |             "dependencies": { | ||||||
|                 "parse5": "^7.0.0" |                 "parse5": "^7.0.0" | ||||||
|             } |             } | ||||||
| @ -10856,6 +10818,7 @@ | |||||||
|             "version": "4.0.1", |             "version": "4.0.1", | ||||||
|             "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", |             "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", | ||||||
|             "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", |             "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", | ||||||
|  |             "dev": true, | ||||||
|             "engines": { |             "engines": { | ||||||
|                 "node": ">= 12" |                 "node": ">= 12" | ||||||
|             } |             } | ||||||
| @ -11512,6 +11475,7 @@ | |||||||
|             "version": "5.17.1", |             "version": "5.17.1", | ||||||
|             "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.17.1.tgz", |             "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.17.1.tgz", | ||||||
|             "integrity": "sha512-LMHl3dXhTcfv8gM4kEzIUeTQ+7fpdA0l2tUf34BddXPkz2A5xJ5L/Pchd5BL6rdccM9QGvu0sWZzK1Z1t4wwyg==", |             "integrity": "sha512-LMHl3dXhTcfv8gM4kEzIUeTQ+7fpdA0l2tUf34BddXPkz2A5xJ5L/Pchd5BL6rdccM9QGvu0sWZzK1Z1t4wwyg==", | ||||||
|  |             "dev": true, | ||||||
|             "dependencies": { |             "dependencies": { | ||||||
|                 "graceful-fs": "^4.2.4", |                 "graceful-fs": "^4.2.4", | ||||||
|                 "tapable": "^2.2.0" |                 "tapable": "^2.2.0" | ||||||
| @ -13971,7 +13935,8 @@ | |||||||
|         "node_modules/graceful-fs": { |         "node_modules/graceful-fs": { | ||||||
|             "version": "4.2.11", |             "version": "4.2.11", | ||||||
|             "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", |             "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": { |         "node_modules/grapheme-splitter": { | ||||||
|             "version": "1.0.4", |             "version": "1.0.4", | ||||||
| @ -18725,6 +18690,7 @@ | |||||||
|             "version": "3.3.2", |             "version": "3.3.2", | ||||||
|             "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", |             "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", | ||||||
|             "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", |             "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", | ||||||
|  |             "dev": true, | ||||||
|             "dependencies": { |             "dependencies": { | ||||||
|                 "data-uri-to-buffer": "^4.0.0", |                 "data-uri-to-buffer": "^4.0.0", | ||||||
|                 "fetch-blob": "^3.1.4", |                 "fetch-blob": "^3.1.4", | ||||||
| @ -23061,6 +23027,7 @@ | |||||||
|             "version": "2.2.1", |             "version": "2.2.1", | ||||||
|             "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", |             "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", | ||||||
|             "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==", |             "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==", | ||||||
|  |             "dev": true, | ||||||
|             "engines": { |             "engines": { | ||||||
|                 "node": ">=6" |                 "node": ">=6" | ||||||
|             } |             } | ||||||
| @ -23461,6 +23428,12 @@ | |||||||
|                 "url": "https://github.com/sponsors/wooorm" |                 "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": { |         "node_modules/ts-api-utils": { | ||||||
|             "version": "1.3.0", |             "version": "1.3.0", | ||||||
|             "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.3.0.tgz", |             "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.3.0.tgz", | ||||||
|  | |||||||
| @ -13,7 +13,6 @@ | |||||||
|         "@formatjs/intl-listformat": "^7.5.7", |         "@formatjs/intl-listformat": "^7.5.7", | ||||||
|         "@fortawesome/fontawesome-free": "^6.6.0", |         "@fortawesome/fontawesome-free": "^6.6.0", | ||||||
|         "@goauthentik/api": "^2025.4.0-1746018955", |         "@goauthentik/api": "^2025.4.0-1746018955", | ||||||
|         "@lit-labs/ssr": "3.2.2", |  | ||||||
|         "@lit/context": "^1.1.2", |         "@lit/context": "^1.1.2", | ||||||
|         "@lit/localize": "^0.12.2", |         "@lit/localize": "^0.12.2", | ||||||
|         "@lit/reactive-element": "^2.0.4", |         "@lit/reactive-element": "^2.0.4", | ||||||
| @ -54,6 +53,7 @@ | |||||||
|         "remark-gfm": "^4.0.1", |         "remark-gfm": "^4.0.1", | ||||||
|         "remark-mdx-frontmatter": "^5.0.0", |         "remark-mdx-frontmatter": "^5.0.0", | ||||||
|         "style-mod": "^4.1.2", |         "style-mod": "^4.1.2", | ||||||
|  |         "trusted-types": "^2.0.0", | ||||||
|         "ts-pattern": "^5.4.0", |         "ts-pattern": "^5.4.0", | ||||||
|         "unist-util-visit": "^5.0.0", |         "unist-util-visit": "^5.0.0", | ||||||
|         "webcomponent-qr-code": "^1.2.0", |         "webcomponent-qr-code": "^1.2.0", | ||||||
|  | |||||||
| @ -1,11 +1,11 @@ | |||||||
| import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; | import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; | ||||||
| import { VERSION } from "@goauthentik/common/constants"; | import { VERSION } from "@goauthentik/common/constants"; | ||||||
| import { globalAK } from "@goauthentik/common/global"; | import { globalAK } from "@goauthentik/common/global"; | ||||||
|  | import { DefaultBrand } from "@goauthentik/common/ui/config"; | ||||||
| import "@goauthentik/elements/EmptyState"; | import "@goauthentik/elements/EmptyState"; | ||||||
| import { WithBrandConfig } from "@goauthentik/elements/Interface/brandProvider"; | import { WithBrandConfig } from "@goauthentik/elements/Interface/brandProvider"; | ||||||
| import { WithLicenseSummary } from "@goauthentik/elements/Interface/licenseSummaryProvider"; | import { WithLicenseSummary } from "@goauthentik/elements/Interface/licenseSummaryProvider"; | ||||||
| import { ModalButton } from "@goauthentik/elements/buttons/ModalButton"; | import { ModalButton } from "@goauthentik/elements/buttons/ModalButton"; | ||||||
| import { DefaultBrand } from "@goauthentik/elements/sidebar/SidebarBrand"; |  | ||||||
|  |  | ||||||
| import { msg } from "@lit/localize"; | import { msg } from "@lit/localize"; | ||||||
| import { TemplateResult, css, html } from "lit"; | import { TemplateResult, css, html } from "lit"; | ||||||
|  | |||||||
| @ -4,13 +4,17 @@ import { ROUTES } from "@goauthentik/admin/Routes"; | |||||||
| import { | import { | ||||||
|     EVENT_API_DRAWER_TOGGLE, |     EVENT_API_DRAWER_TOGGLE, | ||||||
|     EVENT_NOTIFICATION_DRAWER_TOGGLE, |     EVENT_NOTIFICATION_DRAWER_TOGGLE, | ||||||
|  |     EVENT_SIDEBAR_TOGGLE, | ||||||
| } from "@goauthentik/common/constants"; | } from "@goauthentik/common/constants"; | ||||||
| import { configureSentry } from "@goauthentik/common/sentry"; | import { configureSentry } from "@goauthentik/common/sentry"; | ||||||
| import { me } from "@goauthentik/common/users"; | import { me } from "@goauthentik/common/users"; | ||||||
| import { WebsocketClient } from "@goauthentik/common/ws"; | import { WebsocketClient } from "@goauthentik/common/ws"; | ||||||
| import { AuthenticatedInterface } from "@goauthentik/elements/Interface"; | import { AuthenticatedInterface } from "@goauthentik/elements/Interface"; | ||||||
|  | import { WithLicenseSummary } from "@goauthentik/elements/Interface/licenseSummaryProvider.js"; | ||||||
| import "@goauthentik/elements/ak-locale-context"; | import "@goauthentik/elements/ak-locale-context"; | ||||||
| import "@goauthentik/elements/banner/EnterpriseStatusBanner"; | import "@goauthentik/elements/banner/EnterpriseStatusBanner"; | ||||||
|  | import "@goauthentik/elements/banner/EnterpriseStatusBanner"; | ||||||
|  | import "@goauthentik/elements/banner/VersionBanner"; | ||||||
| import "@goauthentik/elements/banner/VersionBanner"; | import "@goauthentik/elements/banner/VersionBanner"; | ||||||
| import "@goauthentik/elements/messages/MessageContainer"; | import "@goauthentik/elements/messages/MessageContainer"; | ||||||
| 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/Sidebar"; | ||||||
| import "@goauthentik/elements/sidebar/SidebarItem"; | 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 { customElement, property, query, state } from "lit/decorators.js"; | ||||||
| import { classMap } from "lit/directives/class-map.js"; | import { classMap } from "lit/directives/class-map.js"; | ||||||
|  |  | ||||||
| import PFButton from "@patternfly/patternfly/components/Button/button.css"; | import PFButton from "@patternfly/patternfly/components/Button/button.css"; | ||||||
| import PFDrawer from "@patternfly/patternfly/components/Drawer/drawer.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 PFPage from "@patternfly/patternfly/components/Page/page.css"; | ||||||
| import PFBase from "@patternfly/patternfly/patternfly-base.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") { | if (process.env.NODE_ENV === "development") { | ||||||
|     await import("@goauthentik/esbuild-plugin-live-reload/client"); |     await import("@goauthentik/esbuild-plugin-live-reload/client"); | ||||||
| } | } | ||||||
|  |  | ||||||
| @customElement("ak-interface-admin") | @customElement("ak-interface-admin") | ||||||
| export class AdminInterface extends AuthenticatedInterface { | export class AdminInterface extends WithLicenseSummary(AuthenticatedInterface) { | ||||||
|  |     //#region Properties | ||||||
|  |  | ||||||
|     @property({ type: Boolean }) |     @property({ type: Boolean }) | ||||||
|     notificationDrawerOpen = getURLParam("notificationDrawerOpen", false); |     notificationDrawerOpen = getURLParam("notificationDrawerOpen", false); | ||||||
|  |  | ||||||
| @ -54,12 +65,29 @@ export class AdminInterface extends AuthenticatedInterface { | |||||||
|     @query("ak-about-modal") |     @query("ak-about-modal") | ||||||
|     aboutModal?: AboutModal; |     aboutModal?: AboutModal; | ||||||
|  |  | ||||||
|  |     @property({ type: Boolean, reflect: true }) | ||||||
|  |     public sidebarOpen: boolean; | ||||||
|  |  | ||||||
|  |     #toggleSidebar = () => { | ||||||
|  |         this.sidebarOpen = !this.sidebarOpen; | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     #sidebarMatcher: MediaQueryList; | ||||||
|  |     #sidebarListener = (event: MediaQueryListEvent) => { | ||||||
|  |         this.sidebarOpen = event.matches; | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     //#endregion | ||||||
|  |  | ||||||
|  |     //#region Styles | ||||||
|  |  | ||||||
|     static get styles(): CSSResult[] { |     static get styles(): CSSResult[] { | ||||||
|         return [ |         return [ | ||||||
|             PFBase, |             PFBase, | ||||||
|             PFPage, |             PFPage, | ||||||
|             PFButton, |             PFButton, | ||||||
|             PFDrawer, |             PFDrawer, | ||||||
|  |             PFNav, | ||||||
|             css` |             css` | ||||||
|                 .pf-c-page__main, |                 .pf-c-page__main, | ||||||
|                 .pf-c-drawer__content, |                 .pf-c-drawer__content, | ||||||
| @ -67,23 +95,30 @@ export class AdminInterface extends AuthenticatedInterface { | |||||||
|                     z-index: auto !important; |                     z-index: auto !important; | ||||||
|                     background-color: transparent; |                     background-color: transparent; | ||||||
|                 } |                 } | ||||||
|  |  | ||||||
|                 .display-none { |                 .display-none { | ||||||
|                     display: none; |                     display: none; | ||||||
|                 } |                 } | ||||||
|  |  | ||||||
|                 .pf-c-page { |                 .pf-c-page { | ||||||
|                     background-color: var(--pf-c-page--BackgroundColor) !important; |                     background-color: var(--pf-c-page--BackgroundColor) !important; | ||||||
|                 } |                 } | ||||||
|  |  | ||||||
|  |                 :host([theme="dark"]) { | ||||||
|                     /* Global page background colour */ |                     /* Global page background colour */ | ||||||
|                 :host([theme="dark"]) .pf-c-page { |                     .pf-c-page { | ||||||
|                         --pf-c-page--BackgroundColor: var(--ak-dark-background); |                         --pf-c-page--BackgroundColor: var(--ak-dark-background); | ||||||
|                     } |                     } | ||||||
|                 ak-enterprise-status, |                 } | ||||||
|                 ak-version-banner { |  | ||||||
|  |                 ak-page-navbar { | ||||||
|                     grid-area: header; |                     grid-area: header; | ||||||
|                 } |                 } | ||||||
|                 ak-admin-sidebar { |  | ||||||
|  |                 .ak-sidebar { | ||||||
|                     grid-area: nav; |                     grid-area: nav; | ||||||
|                 } |                 } | ||||||
|  |  | ||||||
|                 .pf-c-drawer__panel { |                 .pf-c-drawer__panel { | ||||||
|                     z-index: var(--pf-global--ZIndex--xl); |                     z-index: var(--pf-global--ZIndex--xl); | ||||||
|                 } |                 } | ||||||
| @ -91,10 +126,23 @@ export class AdminInterface extends AuthenticatedInterface { | |||||||
|         ]; |         ]; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     //#endregion | ||||||
|  |  | ||||||
|  |     //#region Lifecycle | ||||||
|  |  | ||||||
|     constructor() { |     constructor() { | ||||||
|         super(); |         super(); | ||||||
|         this.ws = new WebsocketClient(); |         this.ws = new WebsocketClient(); | ||||||
|  |  | ||||||
|  |         this.#sidebarMatcher = window.matchMedia("(min-width: 1200px)"); | ||||||
|  |         this.sidebarOpen = this.#sidebarMatcher.matches; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public connectedCallback() { | ||||||
|  |         super.connectedCallback(); | ||||||
|  |  | ||||||
|  |         window.addEventListener(EVENT_SIDEBAR_TOGGLE, this.#toggleSidebar); | ||||||
|  |  | ||||||
|         window.addEventListener(EVENT_NOTIFICATION_DRAWER_TOGGLE, () => { |         window.addEventListener(EVENT_NOTIFICATION_DRAWER_TOGGLE, () => { | ||||||
|             this.notificationDrawerOpen = !this.notificationDrawerOpen; |             this.notificationDrawerOpen = !this.notificationDrawerOpen; | ||||||
|             updateURLParams({ |             updateURLParams({ | ||||||
| @ -108,6 +156,14 @@ export class AdminInterface extends AuthenticatedInterface { | |||||||
|                 apiDrawerOpen: this.apiDrawerOpen, |                 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> { |     async firstUpdated(): Promise<void> { | ||||||
| @ -118,6 +174,7 @@ export class AdminInterface extends AuthenticatedInterface { | |||||||
|             this.user.user.isSuperuser || |             this.user.user.isSuperuser || | ||||||
|             // TODO: somehow add `access_admin_interface` to the API schema |             // TODO: somehow add `access_admin_interface` to the API schema | ||||||
|             this.user.user.systemPermissions.includes("access_admin_interface"); |             this.user.user.systemPermissions.includes("access_admin_interface"); | ||||||
|  |  | ||||||
|         if (!canAccessAdmin && this.user.user.pk > 0) { |         if (!canAccessAdmin && this.user.user.pk > 0) { | ||||||
|             window.location.assign("/if/user/"); |             window.location.assign("/if/user/"); | ||||||
|         } |         } | ||||||
| @ -125,10 +182,14 @@ export class AdminInterface extends AuthenticatedInterface { | |||||||
|  |  | ||||||
|     render(): TemplateResult { |     render(): TemplateResult { | ||||||
|         const sidebarClasses = { |         const sidebarClasses = { | ||||||
|  |             "pf-c-page__sidebar": true, | ||||||
|             "pf-m-light": this.activeTheme === UiThemeEnum.Light, |             "pf-m-light": this.activeTheme === UiThemeEnum.Light, | ||||||
|  |             "pf-m-expanded": this.sidebarOpen, | ||||||
|  |             "pf-m-collapsed": !this.sidebarOpen, | ||||||
|         }; |         }; | ||||||
|  |  | ||||||
|         const drawerOpen = this.notificationDrawerOpen || this.apiDrawerOpen; |         const drawerOpen = this.notificationDrawerOpen || this.apiDrawerOpen; | ||||||
|  |  | ||||||
|         const drawerClasses = { |         const drawerClasses = { | ||||||
|             "pf-m-expanded": drawerOpen, |             "pf-m-expanded": drawerOpen, | ||||||
|             "pf-m-collapsed": !drawerOpen, |             "pf-m-collapsed": !drawerOpen, | ||||||
| @ -136,11 +197,18 @@ export class AdminInterface extends AuthenticatedInterface { | |||||||
|  |  | ||||||
|         return html` <ak-locale-context> |         return html` <ak-locale-context> | ||||||
|             <div class="pf-c-page"> |             <div class="pf-c-page"> | ||||||
|                 <ak-enterprise-status interface="admin"></ak-enterprise-status> |                 <ak-page-navbar> | ||||||
|                     <ak-version-banner></ak-version-banner> |                     <ak-version-banner></ak-version-banner> | ||||||
|                 <ak-admin-sidebar |                     <ak-enterprise-status interface="admin"></ak-enterprise-status> | ||||||
|                     class="pf-c-page__sidebar ${classMap(sidebarClasses)}" |                 </ak-page-navbar> | ||||||
|                 ></ak-admin-sidebar> |  | ||||||
|  |                 <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-page__drawer"> | ||||||
|                     <div class="pf-c-drawer ${classMap(drawerClasses)}"> |                     <div class="pf-c-drawer ${classMap(drawerClasses)}"> | ||||||
|                         <div class="pf-c-drawer__main"> |                         <div class="pf-c-drawer__main"> | ||||||
|  | |||||||
| @ -1,132 +1,77 @@ | |||||||
| 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 { 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 { spread } from "@open-wc/lit-helpers"; | ||||||
|  |  | ||||||
| import { msg } from "@lit/localize"; | import { msg } from "@lit/localize"; | ||||||
| import { TemplateResult, html, nothing } from "lit"; | import { TemplateResult, html, nothing } from "lit"; | ||||||
| import { customElement, property, state } from "lit/decorators.js"; | import { repeat } from "lit/directives/repeat.js"; | ||||||
| import { map } from "lit/directives/map.js"; |  | ||||||
|  |  | ||||||
| import { UiThemeEnum } from "@goauthentik/api"; | // The second attribute type is of string[] to help with the 'activeWhen' control, which was | ||||||
| import type { SessionUser, UserSelf } from "@goauthentik/api"; | // commonplace and singular enough to merit its own handler. | ||||||
|  | type SidebarEntry = [ | ||||||
| @customElement("ak-admin-sidebar") |  | ||||||
| export class AkAdminSidebar extends WithCapabilitiesConfig(WithVersion(AKElement)) { |  | ||||||
|     @property({ type: Boolean, reflect: true }) |  | ||||||
|     open = true; |  | ||||||
|  |  | ||||||
|     @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); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     // 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, |     path: string | null, | ||||||
|     label: string, |     label: string, | ||||||
|     attributes?: Record<string, any> | string[] | null, // eslint-disable-line |     attributes?: Record<string, any> | string[] | null, // eslint-disable-line | ||||||
|     children?: SidebarEntry[], |     children?: SidebarEntry[], | ||||||
|         ]; | ]; | ||||||
|  |  | ||||||
|         // prettier-ignore | /** | ||||||
|         const sidebarContent: SidebarEntry[] = [ |  * Recursively renders a sidebar entry. | ||||||
|  |  */ | ||||||
|  | export function renderSidebarItem([ | ||||||
|  |     path, | ||||||
|  |     label, | ||||||
|  |     attributes, | ||||||
|  |     children, | ||||||
|  | ]: SidebarEntry): TemplateResult { | ||||||
|  |     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} | ||||||
|  |         ${children ? renderSidebarItems(children) : nothing} | ||||||
|  |     </ak-sidebar-item>`; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * 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 }, [ |     [null, msg("Dashboards"), { "?expanded": true }, [ | ||||||
|         ["/administration/overview", msg("Overview")], |         ["/administration/overview", msg("Overview")], | ||||||
|         ["/administration/dashboard/users", msg("User Statistics")], |         ["/administration/dashboard/users", msg("User Statistics")], | ||||||
|                 ["/administration/system-tasks", msg("System Tasks")]]], |         ["/administration/system-tasks", msg("System Tasks")]] | ||||||
|  |     ], | ||||||
|     [null, msg("Applications"), null, [ |     [null, msg("Applications"), null, [ | ||||||
|         ["/core/applications", msg("Applications"), [`^/core/applications/(?<slug>${SLUG_REGEX})$`]], |         ["/core/applications", msg("Applications"), [`^/core/applications/(?<slug>${SLUG_REGEX})$`]], | ||||||
|         ["/core/providers", msg("Providers"), [`^/core/providers/(?<id>${ID_REGEX})$`]], |         ["/core/providers", msg("Providers"), [`^/core/providers/(?<id>${ID_REGEX})$`]], | ||||||
|                 ["/outpost/outposts", msg("Outposts")]]], |         ["/outpost/outposts", msg("Outposts")]] | ||||||
|  |     ], | ||||||
|     [null, msg("Events"), null, [ |     [null, msg("Events"), null, [ | ||||||
|         ["/events/log", msg("Logs"), [`^/events/log/(?<id>${UUID_REGEX})$`]], |         ["/events/log", msg("Logs"), [`^/events/log/(?<id>${UUID_REGEX})$`]], | ||||||
|         ["/events/rules", msg("Notification Rules")], |         ["/events/rules", msg("Notification Rules")], | ||||||
|                 ["/events/transports", msg("Notification Transports")]]], |         ["/events/transports", msg("Notification Transports")]] | ||||||
|  |     ], | ||||||
|     [null, msg("Customization"), null, [ |     [null, msg("Customization"), null, [ | ||||||
|         ["/policy/policies", msg("Policies")], |         ["/policy/policies", msg("Policies")], | ||||||
|         ["/core/property-mappings", msg("Property Mappings")], |         ["/core/property-mappings", msg("Property Mappings")], | ||||||
|         ["/blueprints/instances", msg("Blueprints")], |         ["/blueprints/instances", msg("Blueprints")], | ||||||
|                 ["/policy/reputation", msg("Reputation scores")]]], |         ["/policy/reputation", msg("Reputation scores")]] | ||||||
|  |     ], | ||||||
|     [null, msg("Flows and Stages"), null, [ |     [null, msg("Flows and Stages"), null, [ | ||||||
|         ["/flow/flows", msg("Flows"), [`^/flow/flows/(?<slug>${SLUG_REGEX})$`]], |         ["/flow/flows", msg("Flows"), [`^/flow/flows/(?<slug>${SLUG_REGEX})$`]], | ||||||
|         ["/flow/stages", msg("Stages")], |         ["/flow/stages", msg("Stages")], | ||||||
|                 ["/flow/stages/prompts", msg("Prompts")]]], |         ["/flow/stages/prompts", msg("Prompts")]] | ||||||
|  |     ], | ||||||
|     [null, msg("Directory"), null, [ |     [null, msg("Directory"), null, [ | ||||||
|         ["/identity/users", msg("Users"), [`^/identity/users/(?<id>${ID_REGEX})$`]], |         ["/identity/users", msg("Users"), [`^/identity/users/(?<id>${ID_REGEX})$`]], | ||||||
|         ["/identity/groups", msg("Groups"), [`^/identity/groups/(?<id>${UUID_REGEX})$`]], |         ["/identity/groups", msg("Groups"), [`^/identity/groups/(?<id>${UUID_REGEX})$`]], | ||||||
| @ -134,53 +79,19 @@ export class AkAdminSidebar extends WithCapabilitiesConfig(WithVersion(AKElement | |||||||
|         ["/identity/initial-permissions", msg("Initial Permissions"), [`^/identity/initial-permissions/(?<id>${ID_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/sources", msg("Federation and Social login"), [`^/core/sources/(?<slug>${SLUG_REGEX})$`]], | ||||||
|         ["/core/tokens", msg("Tokens and App passwords")], |         ["/core/tokens", msg("Tokens and App passwords")], | ||||||
|                 ["/flow/stages/invitations", msg("Invitations")]]], |         ["/flow/stages/invitations", msg("Invitations")]] | ||||||
|  |     ], | ||||||
|     [null, msg("System"), null, [ |     [null, msg("System"), null, [ | ||||||
|         ["/core/brands", msg("Brands")], |         ["/core/brands", msg("Brands")], | ||||||
|         ["/crypto/certificates", msg("Certificates")], |         ["/crypto/certificates", msg("Certificates")], | ||||||
|         ["/outpost/integrations", msg("Outpost Integrations")], |         ["/outpost/integrations", msg("Outpost Integrations")], | ||||||
|                 ["/admin/settings", msg("Settings")]]], |         ["/admin/settings", msg("Settings")]] | ||||||
|         ]; |     ], | ||||||
|  | ]; | ||||||
|  |  | ||||||
|         // Typescript requires the type here to correctly type the recursive path | // prettier-ignore | ||||||
|         type SidebarRenderer = (_: SidebarEntry) => TemplateResult; | export const AdminSidebarEnterpriseEntries: readonly SidebarEntry[] = [ | ||||||
|  |     [null, msg("Enterprise"), null, [ | ||||||
|         const renderOneSidebarItem: SidebarRenderer = ([path, label, attributes, children]) => { |         ["/enterprise/licenses", msg("Licenses"), null] | ||||||
|             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; |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| declare global { |  | ||||||
|     interface HTMLElementTagNameMap { |  | ||||||
|         "ak-admin-sidebar": AkAdminSidebar; |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  | |||||||
| @ -94,10 +94,13 @@ export class AdminOverviewPage extends AdminOverviewBase { | |||||||
|     } |     } | ||||||
|  |  | ||||||
|     render(): TemplateResult { |     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}> |         return html` <ak-page-header | ||||||
|                 <span slot="header"> ${msg(str`Welcome, ${name || ""}.`)} </span> |                 header=${msg(str`Welcome, ${username || ""}.`)} | ||||||
|  |                 description=${msg("General system status")} | ||||||
|  |                 ?hasIcon=${false} | ||||||
|  |             > | ||||||
|             </ak-page-header> |             </ak-page-header> | ||||||
|             <section class="pf-c-page__main-section"> |             <section class="pf-c-page__main-section"> | ||||||
|                 <div class="pf-l-grid pf-m-gutter"> |                 <div class="pf-l-grid pf-m-gutter"> | ||||||
|  | |||||||
| @ -83,13 +83,10 @@ export class AdminSettingsPage extends AKElement { | |||||||
|     } |     } | ||||||
|  |  | ||||||
|     render() { |     render() { | ||||||
|         if (!this.settings) { |         if (!this.settings) return nothing; | ||||||
|             return nothing; |  | ||||||
|         } |  | ||||||
|         return html` |         return html` | ||||||
|             <ak-page-header icon="fa fa-cog" header="" description=""> |             <ak-page-header icon="fa fa-cog" header="${msg("System settings")}"> </ak-page-header> | ||||||
|                 <span slot="header"> ${msg("System settings")} </span> |  | ||||||
|             </ak-page-header> |  | ||||||
|             <section class="pf-c-page__main-section pf-m-no-padding-mobile pf-l-grid pf-m-gutter"> |             <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"> | ||||||
|                     <div class="pf-c-card__body"> |                     <div class="pf-c-card__body"> | ||||||
|  | |||||||
| @ -1,6 +1,7 @@ | |||||||
| import "@goauthentik/admin/common/ak-crypto-certificate-search"; | import "@goauthentik/admin/common/ak-crypto-certificate-search"; | ||||||
| import "@goauthentik/admin/common/ak-flow-search/ak-flow-search"; | import "@goauthentik/admin/common/ak-flow-search/ak-flow-search"; | ||||||
| import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; | import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; | ||||||
|  | import { DefaultBrand } from "@goauthentik/common/ui/config"; | ||||||
| import { first } from "@goauthentik/common/utils"; | import { first } from "@goauthentik/common/utils"; | ||||||
| import "@goauthentik/elements/CodeMirror"; | import "@goauthentik/elements/CodeMirror"; | ||||||
| import { CodeMirrorMode } from "@goauthentik/elements/CodeMirror"; | import { CodeMirrorMode } from "@goauthentik/elements/CodeMirror"; | ||||||
| @ -8,7 +9,6 @@ import "@goauthentik/elements/forms/FormGroup"; | |||||||
| import "@goauthentik/elements/forms/HorizontalFormElement"; | import "@goauthentik/elements/forms/HorizontalFormElement"; | ||||||
| import { ModelForm } from "@goauthentik/elements/forms/ModelForm"; | import { ModelForm } from "@goauthentik/elements/forms/ModelForm"; | ||||||
| import "@goauthentik/elements/forms/SearchSelect"; | import "@goauthentik/elements/forms/SearchSelect"; | ||||||
| import { DefaultBrand } from "@goauthentik/elements/sidebar/SidebarBrand"; |  | ||||||
| import YAML from "yaml"; | import YAML from "yaml"; | ||||||
|  |  | ||||||
| import { msg } from "@lit/localize"; | import { msg } from "@lit/localize"; | ||||||
|  | |||||||
| @ -1,26 +1,110 @@ | |||||||
| import type { Config as DOMPurifyConfig } from "dompurify"; | import type { Config as DOMPurifyConfig } from "dompurify"; | ||||||
| import DOMPurify from "dompurify"; | import DOMPurify from "dompurify"; | ||||||
|  | import { trustedTypes } from "trusted-types"; | ||||||
|  |  | ||||||
| import { render } from "@lit-labs/ssr"; | import { render } from "lit"; | ||||||
| import { collectResult } from "@lit-labs/ssr/lib/render-result.js"; |  | ||||||
| import { TemplateResult, html } from "lit"; |  | ||||||
| import { unsafeHTML } from "lit/directives/unsafe-html.js"; | 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 = { | export const DOM_PURIFY_STRICT = { | ||||||
|     ALLOWED_TAGS: ["#text"], |     ALLOWED_TAGS: ["#text"], | ||||||
| } as const satisfies DOMPurifyConfig; | } 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 { |     const result = container.innerHTML; | ||||||
|     return html`${until( |  | ||||||
|         (async () => { |     return result; | ||||||
|             const rendered = await renderStatic(input); |  | ||||||
|             const purified = DOMPurify.sanitize(rendered); |  | ||||||
|             return html`${unsafeHTML(purified)}`; |  | ||||||
|         })(), |  | ||||||
|     )}`; |  | ||||||
| } | } | ||||||
|  | |||||||
| @ -1,6 +1,7 @@ | |||||||
| import { config } from "@goauthentik/common/api/config"; | import { config } from "@goauthentik/common/api/config"; | ||||||
| import { VERSION } from "@goauthentik/common/constants"; | import { VERSION } from "@goauthentik/common/constants"; | ||||||
| import { me } from "@goauthentik/common/users"; | import { me } from "@goauthentik/common/users"; | ||||||
|  | import { readInterfaceRouteParam } from "@goauthentik/elements/router/utils"; | ||||||
| import { | import { | ||||||
|     ErrorEvent, |     ErrorEvent, | ||||||
|     EventHint, |     EventHint, | ||||||
| @ -68,7 +69,7 @@ export async function configureSentry(canDoPpi = false): Promise<Config> { | |||||||
|         }); |         }); | ||||||
|         setTag(TAG_SENTRY_CAPABILITIES, cfg.capabilities.join(",")); |         setTag(TAG_SENTRY_CAPABILITIES, cfg.capabilities.join(",")); | ||||||
|         if (window.location.pathname.includes("if/")) { |         if (window.location.pathname.includes("if/")) { | ||||||
|             setTag(TAG_SENTRY_COMPONENT, `web/${currentInterface()}`); |             setTag(TAG_SENTRY_COMPONENT, `web/${readInterfaceRouteParam()}`); | ||||||
|         } |         } | ||||||
|         if (cfg.capabilities.includes(CapabilitiesEnum.CanDebug)) { |         if (cfg.capabilities.includes(CapabilitiesEnum.CanDebug)) { | ||||||
|             const Spotlight = await import("@spotlightjs/spotlight"); |             const Spotlight = await import("@spotlightjs/spotlight"); | ||||||
| @ -86,13 +87,3 @@ export async function configureSentry(canDoPpi = false): Promise<Config> { | |||||||
|     } |     } | ||||||
|     return cfg; |     return cfg; | ||||||
| } | } | ||||||
|  |  | ||||||
| // Get the interface name from URL |  | ||||||
| export function currentInterface(): string { |  | ||||||
|     const pathMatches = window.location.pathname.match(/.+if\/(\w+)\//); |  | ||||||
|     let currentInterface = "unknown"; |  | ||||||
|     if (pathMatches && pathMatches.length >= 2) { |  | ||||||
|         currentInterface = pathMatches[1]; |  | ||||||
|     } |  | ||||||
|     return currentInterface.toLowerCase(); |  | ||||||
| } |  | ||||||
|  | |||||||
| @ -17,6 +17,13 @@ | |||||||
|  |  | ||||||
|     /* Minimum width after which the sidebar becomes automatic */ |     /* Minimum width after which the sidebar becomes automatic */ | ||||||
|     --ak-sidebar--minimum-auto-width: 80rem; |     --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) { | @supports selector(::-webkit-scrollbar) { | ||||||
|  | |||||||
							
								
								
									
										223
									
								
								web/src/common/stylesheets.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										223
									
								
								web/src/common/stylesheets.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,223 @@ | |||||||
|  | /** | ||||||
|  |  * @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; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Create a `CSSStyleSheet` from the given 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. | ||||||
|  |  * | ||||||
|  |  * @see {@linkcode removeStyleSheet} to remove a stylesheet from a given roots. | ||||||
|  |  */ | ||||||
|  | export function appendStyleSheet( | ||||||
|  |     styleParent: StyleSheetParent, | ||||||
|  |     ...insertions: CSSStyleSheet[] | ||||||
|  | ): void { | ||||||
|  |     insertions = Array.isArray(insertions) ? insertions : [insertions]; | ||||||
|  |  | ||||||
|  |     for (const styleSheetInsertion of insertions) { | ||||||
|  |         if (styleParent.adoptedStyleSheets.includes(styleSheetInsertion)) return; | ||||||
|  |  | ||||||
|  |         styleParent.adoptedStyleSheets = [...styleParent.adoptedStyleSheets, styleSheetInsertion]; | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Remove a stylesheet from the given roots, matching by referential equality. | ||||||
|  |  * | ||||||
|  |  * @see {@linkcode appendStyleSheet} to append a stylesheet to a given roots. | ||||||
|  |  */ | ||||||
|  | export function removeStyleSheet( | ||||||
|  |     styleParent: StyleSheetParent, | ||||||
|  |     ...removals: CSSStyleSheet[] | ||||||
|  | ): void { | ||||||
|  |     const nextAdoptedStyleSheets = styleParent.adoptedStyleSheets.filter( | ||||||
|  |         (styleSheet) => !removals.includes(styleSheet), | ||||||
|  |     ); | ||||||
|  |  | ||||||
|  |     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 | ||||||
| @ -1,7 +1,19 @@ | |||||||
| import { currentInterface } from "@goauthentik/common/sentry"; |  | ||||||
| import { me } from "@goauthentik/common/users"; | import { me } from "@goauthentik/common/users"; | ||||||
|  | import { isUserRoute } from "@goauthentik/elements/router/utils"; | ||||||
|  |  | ||||||
| import { UiThemeEnum, UserSelf } from "@goauthentik/api"; | import { UiThemeEnum, UserSelf } from "@goauthentik/api"; | ||||||
|  | import { CurrentBrand } from "@goauthentik/api"; | ||||||
|  |  | ||||||
|  | export const DefaultBrand = { | ||||||
|  |     brandingLogo: "/static/dist/assets/icons/icon_left_brand.svg", | ||||||
|  |     brandingFavicon: "/static/dist/assets/icons/icon.png", | ||||||
|  |     brandingTitle: "authentik", | ||||||
|  |     brandingCustomCss: "", | ||||||
|  |     uiFooterLinks: [], | ||||||
|  |     uiTheme: UiThemeEnum.Automatic, | ||||||
|  |     matchedDomain: "", | ||||||
|  |     defaultLocale: "", | ||||||
|  | } as const satisfies CurrentBrand; | ||||||
|  |  | ||||||
| export enum UserDisplay { | export enum UserDisplay { | ||||||
|     username = "username", |     username = "username", | ||||||
| @ -77,9 +89,7 @@ export class DefaultUIConfig implements UIConfig { | |||||||
|     }; |     }; | ||||||
|  |  | ||||||
|     constructor() { |     constructor() { | ||||||
|         if (currentInterface() === "user") { |         this.enabledFeatures.apiDrawer = !isUserRoute(); | ||||||
|             this.enabledFeatures.apiDrawer = false; |  | ||||||
|         } |  | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  |  | ||||||
|  | |||||||
| @ -95,7 +95,7 @@ export class NavigationButtons extends AKElement { | |||||||
|             ); |             ); | ||||||
|         }; |         }; | ||||||
|  |  | ||||||
|         return html`<div class="pf-c-page__header-tools-item pf-m-hidden pf-m-visible-on-lg"> |         return html`<div class="pf-c-page__header-tools-item pf-m-hidden pf-m-visible-on-xl"> | ||||||
|             <button class="pf-c-button pf-m-plain" type="button" @click=${onClick}> |             <button class="pf-c-button pf-m-plain" type="button" @click=${onClick}> | ||||||
|                 <pf-tooltip position="top" content=${msg("Open API drawer")}> |                 <pf-tooltip position="top" content=${msg("Open API drawer")}> | ||||||
|                     <i class="fas fa-code" aria-hidden="true"></i> |                     <i class="fas fa-code" aria-hidden="true"></i> | ||||||
| @ -116,7 +116,7 @@ export class NavigationButtons extends AKElement { | |||||||
|             ); |             ); | ||||||
|         }; |         }; | ||||||
|  |  | ||||||
|         return html`<div class="pf-c-page__header-tools-item pf-m-hidden pf-m-visible-on-lg"> |         return html`<div class="pf-c-page__header-tools-item pf-m-hidden pf-m-visible-on-xl"> | ||||||
|             <button |             <button | ||||||
|                 class="pf-c-button pf-m-plain" |                 class="pf-c-button pf-m-plain" | ||||||
|                 type="button" |                 type="button" | ||||||
| @ -156,9 +156,7 @@ export class NavigationButtons extends AKElement { | |||||||
|     } |     } | ||||||
|  |  | ||||||
|     renderImpersonation() { |     renderImpersonation() { | ||||||
|         if (!this.me?.original) { |         if (!this.me?.original) return nothing; | ||||||
|             return nothing; |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         const onClick = async () => { |         const onClick = async () => { | ||||||
|             await new CoreApi(DEFAULT_CONFIG).coreUsersImpersonateEndRetrieve(); |             await new CoreApi(DEFAULT_CONFIG).coreUsersImpersonateEndRetrieve(); | ||||||
| @ -175,6 +173,14 @@ export class NavigationButtons extends AKElement { | |||||||
|             </div>`; |             </div>`; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     renderAvatar() { | ||||||
|  |         return html`<img | ||||||
|  |             class="pf-c-page__header-tools-item pf-c-avatar pf-m-hidden pf-m-visible-on-xl" | ||||||
|  |             src=${ifDefined(this.me?.user.avatar)} | ||||||
|  |             alt="${msg("Avatar image")}" | ||||||
|  |         />`; | ||||||
|  |     } | ||||||
|  |  | ||||||
|     get userDisplayName() { |     get userDisplayName() { | ||||||
|         return match<UserDisplay | undefined, string | undefined>(this.uiConfig?.navbar.userDisplay) |         return match<UserDisplay | undefined, string | undefined>(this.uiConfig?.navbar.userDisplay) | ||||||
|             .with(UserDisplay.username, () => this.me?.user.username) |             .with(UserDisplay.username, () => this.me?.user.username) | ||||||
| @ -206,17 +212,13 @@ export class NavigationButtons extends AKElement { | |||||||
|             </div> |             </div> | ||||||
|             ${this.renderImpersonation()} |             ${this.renderImpersonation()} | ||||||
|             ${this.userDisplayName != "" |             ${this.userDisplayName != "" | ||||||
|                 ? html`<div class="pf-c-page__header-tools-group"> |                 ? html`<div class="pf-c-page__header-tools-group pf-m-hidden"> | ||||||
|                       <div class="pf-c-page__header-tools-item pf-m-hidden pf-m-visible-on-md"> |                       <div class="pf-c-page__header-tools-item pf-m-visible-on-2xl"> | ||||||
|                           ${this.userDisplayName} |                           ${this.userDisplayName} | ||||||
|                       </div> |                       </div> | ||||||
|                   </div>` |                   </div>` | ||||||
|                 : nothing} |                 : nothing} | ||||||
|             <img |             ${this.renderAvatar()} | ||||||
|                 class="pf-c-avatar" |  | ||||||
|                 src=${ifDefined(this.me?.user.avatar)} |  | ||||||
|                 alt="${msg("Avatar image")}" |  | ||||||
|             /> |  | ||||||
|         </div>`; |         </div>`; | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  | |||||||
| @ -1,165 +1,140 @@ | |||||||
| import { EVENT_THEME_CHANGE } from "@goauthentik/common/constants"; |  | ||||||
| import { globalAK } from "@goauthentik/common/global"; | import { globalAK } from "@goauthentik/common/global"; | ||||||
| import { UIConfig } from "@goauthentik/common/ui/config"; | import { | ||||||
| import { adaptCSS } from "@goauthentik/common/utils"; |     StyleSheetInit, | ||||||
| import { ensureCSSStyleSheet } from "@goauthentik/elements/utils/ensureCSSStyleSheet"; |     StyleSheetParent, | ||||||
|  |     appendStyleSheet, | ||||||
|  |     createStyleSheetUnsafe, | ||||||
|  |     removeStyleSheet, | ||||||
|  |     resolveStyleSheetParent, | ||||||
|  | } from "@goauthentik/common/stylesheets"; | ||||||
|  | import { | ||||||
|  |     CSSColorSchemeValue, | ||||||
|  |     ResolvedUITheme, | ||||||
|  |     UIThemeListener, | ||||||
|  |     createUIThemeEffect, | ||||||
|  |     formatColorScheme, | ||||||
|  |     resolveUITheme, | ||||||
|  | } from "@goauthentik/common/theme"; | ||||||
|  | import { type ThemedElement } from "@goauthentik/common/theme"; | ||||||
|  |  | ||||||
| import { localized } from "@lit/localize"; | 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 AKGlobal from "@goauthentik/common/styles/authentik.css"; | ||||||
| import OneDark from "@goauthentik/common/styles/one-dark.css"; | import OneDark from "@goauthentik/common/styles/one-dark.css"; | ||||||
| import ThemeDark from "@goauthentik/common/styles/theme-dark.css"; | import ThemeDark from "@goauthentik/common/styles/theme-dark.css"; | ||||||
|  |  | ||||||
| import { Config, CurrentBrand, UiThemeEnum } from "@goauthentik/api"; | import { UiThemeEnum } from "@goauthentik/api"; | ||||||
|  |  | ||||||
| type AkInterface = HTMLElement & { | // Re-export the theme helpers | ||||||
|     getTheme: () => Promise<UiThemeEnum>; | export { rootInterface } from "@goauthentik/common/theme"; | ||||||
|     brand?: CurrentBrand; |  | ||||||
|     uiConfig?: UIConfig; |  | ||||||
|     config?: Config; |  | ||||||
|     get activeTheme(): UiThemeEnum | undefined; |  | ||||||
| }; |  | ||||||
|  |  | ||||||
| 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); |  | ||||||
|  |  | ||||||
| @localized() | @localized() | ||||||
| export class AKElement extends LitElement { | export class AKElement extends LitElement implements ThemedElement { | ||||||
|     _mediaMatcher?: MediaQueryList; |     //#region Properties | ||||||
|     _mediaMatcherHandler?: (ev?: MediaQueryListEvent) => void; |  | ||||||
|     _activeTheme?: UiThemeEnum; |  | ||||||
|  |  | ||||||
|     get activeTheme(): UiThemeEnum | undefined { |     /** | ||||||
|         return this._activeTheme; |      * 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. | ||||||
|  |      */ | ||||||
|  |     @property({ | ||||||
|  |         attribute: "theme", | ||||||
|  |         type: String, | ||||||
|  |         reflect: true, | ||||||
|  |     }) | ||||||
|  |     public activeTheme: ResolvedUITheme; | ||||||
|  |  | ||||||
|  |     //#endregion | ||||||
|  |  | ||||||
|  |     //#region Private Properties | ||||||
|  |  | ||||||
|  |     readonly #preferredColorScheme: CSSColorSchemeValue; | ||||||
|  |  | ||||||
|  |     #customCSSStyleSheet: CSSStyleSheet | null; | ||||||
|  |     #darkThemeStyleSheet: CSSStyleSheet | null = null; | ||||||
|  |     #themeAbortController: AbortController | null = null; | ||||||
|  |  | ||||||
|  |     //#endregion | ||||||
|  |  | ||||||
|  |     //#region Lifecycle | ||||||
|  |  | ||||||
|  |     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); | ||||||
|  |         } | ||||||
|  |         return [styles, ...baseStyles].map(createStyleSheetUnsafe); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     constructor() { |     constructor() { | ||||||
|         super(); |         super(); | ||||||
|  |  | ||||||
|  |         const { brand } = globalAK(); | ||||||
|  |  | ||||||
|  |         this.#preferredColorScheme = formatColorScheme(brand.uiTheme); | ||||||
|  |         this.activeTheme = resolveUITheme(brand?.uiTheme); | ||||||
|  |  | ||||||
|  |         this.#customCSSStyleSheet = brand?.brandingCustomCss | ||||||
|  |             ? createStyleSheetUnsafe(brand.brandingCustomCss) | ||||||
|  |             : null; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     setInitialStyles(root: DocumentOrShadowRoot) { |     public disconnectedCallback(): void { | ||||||
|         const styleRoot: DocumentOrShadowRoot = ( |         super.disconnectedCallback(); | ||||||
|             "ShadyDOM" in window ? document : root |         this.#themeAbortController?.abort(); | ||||||
|         ) as DocumentOrShadowRoot; |  | ||||||
|         styleRoot.adoptedStyleSheets = adaptCSS([ |  | ||||||
|             ...styleRoot.adoptedStyleSheets, |  | ||||||
|             ensureCSSStyleSheet(AKGlobal), |  | ||||||
|             ensureCSSStyleSheet(OneDark), |  | ||||||
|         ]); |  | ||||||
|         this._initTheme(styleRoot); |  | ||||||
|         this._initCustomCSS(styleRoot); |  | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     protected createRenderRoot() { |     #styleRoot?: StyleSheetParent; | ||||||
|         this.fixElementStyles(); |  | ||||||
|         const root = super.createRenderRoot(); |  | ||||||
|         this.setInitialStyles(root as unknown as DocumentOrShadowRoot); |  | ||||||
|         return root; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     async getTheme(): Promise<UiThemeEnum> { |     #dispatchTheme: UIThemeListener = (nextUITheme) => { | ||||||
|         return rootInterface()?.getTheme() || UiThemeEnum.Automatic; |         if (!this.#styleRoot) return; | ||||||
|     } |  | ||||||
|  |  | ||||||
|     fixElementStyles() { |         if (nextUITheme === UiThemeEnum.Dark) { | ||||||
|         // Ensure all style sheets being passed are really style sheets. |             this.#darkThemeStyleSheet ||= createStyleSheetUnsafe(ThemeDark); | ||||||
|         (this.constructor as typeof ReactiveElement).elementStyles = ( |             appendStyleSheet(this.#styleRoot, this.#darkThemeStyleSheet); | ||||||
|             this.constructor as typeof ReactiveElement |             this.activeTheme = UiThemeEnum.Dark; | ||||||
|         ).elementStyles.map(ensureCSSStyleSheet); |         } else if (this.#darkThemeStyleSheet) { | ||||||
|  |             removeStyleSheet(this.#styleRoot, this.#darkThemeStyleSheet); | ||||||
|  |             this.#darkThemeStyleSheet = null; | ||||||
|  |             this.activeTheme = UiThemeEnum.Light; | ||||||
|         } |         } | ||||||
|  |  | ||||||
|     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); |     protected createRenderRoot(): HTMLElement | DocumentFragment { | ||||||
|             } |         const renderRoot = super.createRenderRoot(); | ||||||
|             return; |         this.#styleRoot = resolveStyleSheetParent(renderRoot); | ||||||
|         } else if (this._mediaMatcher && this._mediaMatcherHandler) { |  | ||||||
|             // Theme isn't automatic and we have a matcher configured, remove the matcher |         if (this.#customCSSStyleSheet) { | ||||||
|             // to prevent changes |             console.debug(`authentik/element[${this.tagName.toLowerCase()}]: Adding custom CSS`); | ||||||
|             this._mediaMatcher.removeEventListener("change", this._mediaMatcherHandler); |  | ||||||
|             this._mediaMatcher = undefined; |             appendStyleSheet(this.#styleRoot, this.#customCSSStyleSheet); | ||||||
|         } |  | ||||||
|         this._activateTheme(theme, root); |  | ||||||
|         } |         } | ||||||
|  |  | ||||||
|     static themeToStylesheet(theme?: UiThemeEnum): CSSStyleSheet | undefined { |         this.#themeAbortController = new AbortController(); | ||||||
|         if (theme === UiThemeEnum.Dark) { |  | ||||||
|             return _darkTheme; |  | ||||||
|         } |  | ||||||
|         return undefined; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     /** |         if (this.#preferredColorScheme === "dark") { | ||||||
|      * Directly activate a given theme, accepts multiple document/ShadowDOMs to apply the stylesheet |             this.#dispatchTheme(UiThemeEnum.Dark); | ||||||
|      * to. The stylesheets are applied to each DOM in order. Does nothing if the given theme is already active. |         } else if (this.#preferredColorScheme === "auto") { | ||||||
|      */ |             createUIThemeEffect(this.#dispatchTheme, { | ||||||
|     _activateTheme(theme: UiThemeEnum, ...roots: DocumentOrShadowRoot[]) { |                 signal: this.#themeAbortController.signal, | ||||||
|         if (theme === this._activeTheme) { |  | ||||||
|             return; |  | ||||||
|         } |  | ||||||
|         // 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, |  | ||||||
|             }), |  | ||||||
|         ); |  | ||||||
|         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(); |  | ||||||
|         } |         } | ||||||
|  |  | ||||||
|  |         return renderRoot; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     //#endregion | ||||||
| } | } | ||||||
|  | |||||||
| @ -1,5 +1,6 @@ | |||||||
| import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; | import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; | ||||||
| import { EVENT_REFRESH } from "@goauthentik/common/constants"; | import { EVENT_REFRESH } from "@goauthentik/common/constants"; | ||||||
|  | import { ThemedElement } from "@goauthentik/common/theme"; | ||||||
| import { authentikBrandContext } from "@goauthentik/elements/AuthentikContexts"; | import { authentikBrandContext } from "@goauthentik/elements/AuthentikContexts"; | ||||||
| import type { ReactiveElementHost } from "@goauthentik/elements/types.js"; | import type { ReactiveElementHost } from "@goauthentik/elements/types.js"; | ||||||
|  |  | ||||||
| @ -9,14 +10,12 @@ import type { ReactiveController } from "lit"; | |||||||
| import type { CurrentBrand } from "@goauthentik/api"; | import type { CurrentBrand } from "@goauthentik/api"; | ||||||
| import { CoreApi } from "@goauthentik/api"; | import { CoreApi } from "@goauthentik/api"; | ||||||
|  |  | ||||||
| import type { AkInterface } from "./Interface"; |  | ||||||
|  |  | ||||||
| export class BrandContextController implements ReactiveController { | export class BrandContextController implements ReactiveController { | ||||||
|     host!: ReactiveElementHost<AkInterface>; |     host!: ReactiveElementHost<ThemedElement>; | ||||||
|  |  | ||||||
|     context!: ContextProvider<{ __context__: CurrentBrand | undefined }>; |     context!: ContextProvider<{ __context__: CurrentBrand | undefined }>; | ||||||
|  |  | ||||||
|     constructor(host: ReactiveElementHost<AkInterface>) { |     constructor(host: ReactiveElementHost<ThemedElement>) { | ||||||
|         this.host = host; |         this.host = host; | ||||||
|         this.context = new ContextProvider(this.host, { |         this.context = new ContextProvider(this.host, { | ||||||
|             context: authentikBrandContext, |             context: authentikBrandContext, | ||||||
|  | |||||||
| @ -1,6 +1,7 @@ | |||||||
| import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; | import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; | ||||||
| import { EVENT_REFRESH } from "@goauthentik/common/constants"; | import { EVENT_REFRESH } from "@goauthentik/common/constants"; | ||||||
| import { globalAK } from "@goauthentik/common/global"; | import { globalAK } from "@goauthentik/common/global"; | ||||||
|  | import { ThemedElement } from "@goauthentik/common/theme"; | ||||||
| import { authentikConfigContext } from "@goauthentik/elements/AuthentikContexts"; | import { authentikConfigContext } from "@goauthentik/elements/AuthentikContexts"; | ||||||
| import type { ReactiveElementHost } from "@goauthentik/elements/types.js"; | import type { ReactiveElementHost } from "@goauthentik/elements/types.js"; | ||||||
|  |  | ||||||
| @ -10,14 +11,12 @@ import type { ReactiveController } from "lit"; | |||||||
| import type { Config } from "@goauthentik/api"; | import type { Config } from "@goauthentik/api"; | ||||||
| import { RootApi } from "@goauthentik/api"; | import { RootApi } from "@goauthentik/api"; | ||||||
|  |  | ||||||
| import type { AkInterface } from "./Interface"; |  | ||||||
|  |  | ||||||
| export class ConfigContextController implements ReactiveController { | export class ConfigContextController implements ReactiveController { | ||||||
|     host!: ReactiveElementHost<AkInterface>; |     host!: ReactiveElementHost<ThemedElement>; | ||||||
|  |  | ||||||
|     context!: ContextProvider<{ __context__: Config | undefined }>; |     context!: ContextProvider<{ __context__: Config | undefined }>; | ||||||
|  |  | ||||||
|     constructor(host: ReactiveElementHost<AkInterface>) { |     constructor(host: ReactiveElementHost<ThemedElement>) { | ||||||
|         this.host = host; |         this.host = host; | ||||||
|         this.context = new ContextProvider(this.host, { |         this.context = new ContextProvider(this.host, { | ||||||
|             context: authentikConfigContext, |             context: authentikConfigContext, | ||||||
|  | |||||||
| @ -1,107 +1,78 @@ | |||||||
| 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 } from "@goauthentik/elements/Base"; | ||||||
| import { VersionContextController } from "@goauthentik/elements/Interface/VersionContextController"; | import { VersionContextController } from "@goauthentik/elements/Interface/VersionContextController"; | ||||||
| import { ModalOrchestrationController } from "@goauthentik/elements/controllers/ModalOrchestrationController.js"; | import { ModalOrchestrationController } from "@goauthentik/elements/controllers/ModalOrchestrationController.js"; | ||||||
| import { ensureCSSStyleSheet } from "@goauthentik/elements/utils/ensureCSSStyleSheet"; |  | ||||||
|  |  | ||||||
| import { state } from "lit/decorators.js"; | import { state } from "lit/decorators.js"; | ||||||
|  |  | ||||||
| import PFBase from "@patternfly/patternfly/patternfly-base.css"; | import PFBase from "@patternfly/patternfly/patternfly-base.css"; | ||||||
|  |  | ||||||
| import type { Config, CurrentBrand, LicenseSummary, Version } from "@goauthentik/api"; | import type { Config, CurrentBrand, LicenseSummary, Version } from "@goauthentik/api"; | ||||||
| import { UiThemeEnum } from "@goauthentik/api"; |  | ||||||
|  |  | ||||||
| import { AKElement, rootInterface } from "../Base"; |  | ||||||
| import { BrandContextController } from "./BrandContextController"; | import { BrandContextController } from "./BrandContextController"; | ||||||
| import { ConfigContextController } from "./ConfigContextController"; | import { ConfigContextController } from "./ConfigContextController"; | ||||||
| import { EnterpriseContextController } from "./EnterpriseContextController"; | 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 configContext = Symbol("configContext"); | ||||||
| const modalController = Symbol("modalController"); | const modalController = Symbol("modalController"); | ||||||
| const versionContext = Symbol("versionContext"); | const versionContext = Symbol("versionContext"); | ||||||
|  |  | ||||||
| export class Interface extends AKElement implements AkInterface { | export abstract class Interface extends AKElement implements ThemedElement { | ||||||
|     [brandContext]!: BrandContextController; |     protected static readonly PFBaseStyleSheet = createStyleSheetUnsafe(PFBase); | ||||||
|  |  | ||||||
|     [configContext]!: ConfigContextController; |     [configContext]: ConfigContextController; | ||||||
|  |  | ||||||
|     [modalController]!: ModalOrchestrationController; |     [modalController]: ModalOrchestrationController; | ||||||
|  |  | ||||||
|     @state() |     @state() | ||||||
|     uiConfig?: UIConfig; |     public config?: Config; | ||||||
|  |  | ||||||
|     @state() |     @state() | ||||||
|     config?: Config; |     public brand?: CurrentBrand; | ||||||
|  |  | ||||||
|     @state() |  | ||||||
|     brand?: CurrentBrand; |  | ||||||
|  |  | ||||||
|     constructor() { |     constructor() { | ||||||
|         super(); |         super(); | ||||||
|         document.adoptedStyleSheets = [...document.adoptedStyleSheets, ensureCSSStyleSheet(PFBase)]; |         const styleParent = resolveStyleSheetParent(document); | ||||||
|         this._initContexts(); |  | ||||||
|         this.dataset.akInterfaceRoot = "true"; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     _initContexts() { |         this.dataset.akInterfaceRoot = this.tagName.toLowerCase(); | ||||||
|         this[brandContext] = new BrandContextController(this); |  | ||||||
|  |         appendStyleSheet(styleParent, Interface.PFBaseStyleSheet); | ||||||
|  |  | ||||||
|  |         this.addController(new BrandContextController(this)); | ||||||
|         this[configContext] = new ConfigContextController(this); |         this[configContext] = new ConfigContextController(this); | ||||||
|         this[modalController] = new ModalOrchestrationController(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; |     licenseSummary?: LicenseSummary; | ||||||
|     version?: Version; |     version?: Version; | ||||||
| }; | } | ||||||
|  |  | ||||||
| const enterpriseContext = Symbol("enterpriseContext"); | const enterpriseContext = Symbol("enterpriseContext"); | ||||||
|  |  | ||||||
| export class AuthenticatedInterface extends Interface { | export class AuthenticatedInterface extends Interface implements AkAuthenticatedInterface { | ||||||
|     [enterpriseContext]!: EnterpriseContextController; |     [enterpriseContext]!: EnterpriseContextController; | ||||||
|     [versionContext]!: VersionContextController; |     [versionContext]!: VersionContextController; | ||||||
|  |  | ||||||
|     @state() |     @state() | ||||||
|     licenseSummary?: LicenseSummary; |     public uiConfig?: UIConfig; | ||||||
|  |  | ||||||
|     @state() |     @state() | ||||||
|     version?: Version; |     public licenseSummary?: LicenseSummary; | ||||||
|  |  | ||||||
|  |     @state() | ||||||
|  |     public version?: Version; | ||||||
|  |  | ||||||
|     constructor() { |     constructor() { | ||||||
|         super(); |         super(); | ||||||
|     } |  | ||||||
|  |  | ||||||
|     _initContexts(): void { |  | ||||||
|         super._initContexts(); |  | ||||||
|         this[enterpriseContext] = new EnterpriseContextController(this); |         this[enterpriseContext] = new EnterpriseContextController(this); | ||||||
|         this[versionContext] = new VersionContextController(this); |         this[versionContext] = new VersionContextController(this); | ||||||
|     } |     } | ||||||
|  | |||||||
| @ -4,21 +4,24 @@ import { | |||||||
|     TITLE_DEFAULT, |     TITLE_DEFAULT, | ||||||
| } from "@goauthentik/common/constants"; | } from "@goauthentik/common/constants"; | ||||||
| import { globalAK } from "@goauthentik/common/global"; | import { globalAK } from "@goauthentik/common/global"; | ||||||
| import { currentInterface } from "@goauthentik/common/sentry"; | import { UIConfig, UserDisplay, getConfigForUser } from "@goauthentik/common/ui/config"; | ||||||
| import { UIConfig, UserDisplay, uiConfig } from "@goauthentik/common/ui/config"; | import { DefaultBrand } from "@goauthentik/common/ui/config"; | ||||||
| import { me } from "@goauthentik/common/users"; | import { me } from "@goauthentik/common/users"; | ||||||
| import "@goauthentik/components/ak-nav-buttons"; | import "@goauthentik/components/ak-nav-buttons"; | ||||||
| import { AKElement } from "@goauthentik/elements/Base"; | import { AKElement } from "@goauthentik/elements/Base"; | ||||||
| import { WithBrandConfig } from "@goauthentik/elements/Interface/brandProvider"; | import { WithBrandConfig } from "@goauthentik/elements/Interface/brandProvider"; | ||||||
|  | import { isAdminRoute } from "@goauthentik/elements/router/utils"; | ||||||
|  | import { themeImage } from "@goauthentik/elements/utils/images"; | ||||||
| import "@patternfly/elements/pf-tooltip/pf-tooltip.js"; | import "@patternfly/elements/pf-tooltip/pf-tooltip.js"; | ||||||
|  |  | ||||||
| import { msg } from "@lit/localize"; | 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 { customElement, property, state } from "lit/decorators.js"; | ||||||
|  |  | ||||||
| import PFAvatar from "@patternfly/patternfly/components/Avatar/avatar.css"; | import PFAvatar from "@patternfly/patternfly/components/Avatar/avatar.css"; | ||||||
| import PFButton from "@patternfly/patternfly/components/Button/button.css"; | import PFButton from "@patternfly/patternfly/components/Button/button.css"; | ||||||
| import PFContent from "@patternfly/patternfly/components/Content/content.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 PFDropdown from "@patternfly/patternfly/components/Dropdown/dropdown.css"; | ||||||
| import PFNotificationBadge from "@patternfly/patternfly/components/NotificationBadge/notification-badge.css"; | import PFNotificationBadge from "@patternfly/patternfly/components/NotificationBadge/notification-badge.css"; | ||||||
| import PFPage from "@patternfly/patternfly/components/Page/page.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"; | import { SessionUser } from "@goauthentik/api"; | ||||||
|  |  | ||||||
| @customElement("ak-page-header") | //#region Page Navbar | ||||||
| export class PageHeader extends WithBrandConfig(AKElement) { |  | ||||||
|     @property() |  | ||||||
|     icon?: string; |  | ||||||
|  |  | ||||||
|     @property({ type: Boolean }) | export interface PageNavbarDetails { | ||||||
|     iconImage = false; |     header?: string; | ||||||
|  |  | ||||||
|     @property() |  | ||||||
|     header = ""; |  | ||||||
|  |  | ||||||
|     @property() |  | ||||||
|     description?: 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() |     private static elementRef: AKPageNavbar | null = null; | ||||||
|     me?: SessionUser; |  | ||||||
|  |  | ||||||
|     @state() |     static readonly setNavbarDetails = (detail: Partial<PageNavbarDetails>): void => { | ||||||
|     uiConfig!: UIConfig; |         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[] { |     static get styles(): CSSResult[] { | ||||||
|         return [ |         return [ | ||||||
|             PFBase, |             PFBase, | ||||||
|             PFButton, |             PFButton, | ||||||
|             PFPage, |             PFPage, | ||||||
|  |             PFDrawer, | ||||||
|  |  | ||||||
|             PFNotificationBadge, |             PFNotificationBadge, | ||||||
|             PFContent, |             PFContent, | ||||||
|             PFAvatar, |             PFAvatar, | ||||||
| @ -63,127 +84,324 @@ export class PageHeader extends WithBrandConfig(AKElement) { | |||||||
|                     position: sticky; |                     position: sticky; | ||||||
|                     top: 0; |                     top: 0; | ||||||
|                     z-index: var(--pf-global--ZIndex--lg); |                     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 | ||||||
|  |                     ); | ||||||
|  |                     --host-navbar-height: var(--ak-c-page-header--height, 7.5rem); | ||||||
|                 } |                 } | ||||||
|                 .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: var(--pf-global--BorderWidth--sm); | ||||||
|                     border-bottom-style: solid; |                     border-bottom-style: solid; | ||||||
|                     border-bottom-color: var(--pf-global--BorderColor--100); |                     border-bottom-color: var(--pf-global--BorderColor--100); | ||||||
|  |                     background-color: var(--pf-c-page--BackgroundColor); | ||||||
|  |  | ||||||
|                     display: flex; |                     display: flex; | ||||||
|                     flex-direction: row; |                     flex-direction: row; | ||||||
|                     min-height: 114px; |  | ||||||
|                     max-height: 114px; |                     display: grid; | ||||||
|                     background-color: var(--pf-c-page--BackgroundColor); |                     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 (min-width: 426px) { | ||||||
|  |                         height: var(--host-navbar-height); | ||||||
|                     } |                     } | ||||||
|                 .pf-c-page__main-section.pf-m-light { |  | ||||||
|                     background-color: transparent; |                     @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 { |                 } | ||||||
|                     flex-grow: 1; |  | ||||||
|                     flex-shrink: 1; |                 .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; | ||||||
|  |                         margin-block-end: var(--pf-global--spacer--md); | ||||||
|  |  | ||||||
|  |                         display: box; | ||||||
|  |                         display: -webkit-box; | ||||||
|  |                         line-clamp: 2; | ||||||
|  |                         -webkit-line-clamp: 2; | ||||||
|  |                         box-orient: vertical; | ||||||
|  |                         -webkit-box-orient: vertical; | ||||||
|  |                         overflow: hidden; | ||||||
|  |  | ||||||
|  |                         @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); | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 .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; |                     display: flex; | ||||||
|                     flex-direction: column; |  | ||||||
|                     justify-content: center; |                     justify-content: center; | ||||||
|  |  | ||||||
|  |                     &.pf-m-collapsed { | ||||||
|  |                         display: none; | ||||||
|                     } |                     } | ||||||
|                 img.pf-icon { |  | ||||||
|                     max-height: 24px; |                     @media (max-width: 1199px) { | ||||||
|  |                         display: none; | ||||||
|                     } |                     } | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 .sidebar-trigger { | ||||||
|  |                     grid-area: toggle; | ||||||
|  |                     height: 100%; | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 .logo { | ||||||
|  |                     flex: 0 0 auto; | ||||||
|  |                     height: var(--ak-brand-logo-height); | ||||||
|  |  | ||||||
|  |                     & img { | ||||||
|  |                         height: 100%; | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |  | ||||||
|                 .sidebar-trigger, |                 .sidebar-trigger, | ||||||
|                 .notification-trigger { |                 .notification-trigger { | ||||||
|                     font-size: 24px; |                     font-size: 1.5rem; | ||||||
|                 } |                 } | ||||||
|  |  | ||||||
|                 .notification-trigger.has-notifications { |                 .notification-trigger.has-notifications { | ||||||
|                     color: var(--pf-global--active-color--100); |                     color: var(--pf-global--active-color--100); | ||||||
|                 } |                 } | ||||||
|  |  | ||||||
|  |                 .page-title { | ||||||
|  |                     display: flex; | ||||||
|  |                     gap: var(--pf-global--spacer--xs); | ||||||
|  |                 } | ||||||
|  |  | ||||||
|                 h1 { |                 h1 { | ||||||
|                     display: flex; |                     display: flex; | ||||||
|                     flex-direction: row; |                     flex-direction: row; | ||||||
|                     align-items: center !important; |                     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() { |     //#endregion | ||||||
|         super(); |  | ||||||
|         window.addEventListener(EVENT_WS_MESSAGE, () => { |  | ||||||
|             this.firstUpdated(); |  | ||||||
|         }); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     async firstUpdated() { |     //#region Properties | ||||||
|         this.me = await me(); |  | ||||||
|         this.uiConfig = await uiConfig(); |  | ||||||
|         this.uiConfig.navbar.userDisplay = UserDisplay.none; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     setTitle(header?: string) { |     @property({ type: String }) | ||||||
|         const currentIf = currentInterface(); |     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) { | ||||||
|         let title = this.brand?.brandingTitle || TITLE_DEFAULT; |         let title = this.brand?.brandingTitle || TITLE_DEFAULT; | ||||||
|         if (currentIf === "admin") { |  | ||||||
|  |         if (isAdminRoute()) { | ||||||
|             title = `${msg("Admin")} - ${title}`; |             title = `${msg("Admin")} - ${title}`; | ||||||
|         } |         } | ||||||
|         // Prepend the header to the title |         // Prepend the header to the title | ||||||
|         if (header !== undefined && header !== "") { |         if (header) { | ||||||
|             title = `${header} - ${title}`; |             title = `${header} - ${title}`; | ||||||
|         } |         } | ||||||
|         document.title = title; |         document.title = title; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     willUpdate() { |     #toggleSidebar() { | ||||||
|         // Always update title, even if there's no header value set, |         this.open = !this.open; | ||||||
|         // as in that case we still need to return to the generic title |  | ||||||
|         this.setTitle(this.header); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     renderIcon() { |  | ||||||
|         if (this.icon) { |  | ||||||
|             if (this.iconImage && !this.icon.startsWith("fa://")) { |  | ||||||
|                 return html`<img class="pf-icon" src="${this.icon}" alt="page icon" />`; |  | ||||||
|             } |  | ||||||
|             const icon = this.icon.replaceAll("fa://", "fa "); |  | ||||||
|             return html`<i class=${icon}></i>`; |  | ||||||
|         } |  | ||||||
|         return nothing; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     render(): TemplateResult { |  | ||||||
|         return html`<div class="bar"> |  | ||||||
|             <button |  | ||||||
|                 class="sidebar-trigger pf-c-button pf-m-plain" |  | ||||||
|                 @click=${() => { |  | ||||||
|         this.dispatchEvent( |         this.dispatchEvent( | ||||||
|             new CustomEvent(EVENT_SIDEBAR_TOGGLE, { |             new CustomEvent(EVENT_SIDEBAR_TOGGLE, { | ||||||
|                 bubbles: true, |                 bubbles: true, | ||||||
|                 composed: 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); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     //#endregion | ||||||
|  |  | ||||||
|  |     //#region Render | ||||||
|  |  | ||||||
|  |     renderIcon() { | ||||||
|  |         if (this.icon) { | ||||||
|  |             if (this.iconImage && !this.icon.startsWith("fa://")) { | ||||||
|  |                 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="accent-icon ${icon}"></i>`; | ||||||
|  |         } | ||||||
|  |         return nothing; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     render(): TemplateResult { | ||||||
|  |         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> |                     <i class="fas fa-bars"></i> | ||||||
|                 </button> |                 </button> | ||||||
|             <section class="pf-c-page__main-section pf-m-light"> |  | ||||||
|                 <div class="pf-c-content"> |                 <section | ||||||
|                     <h1> |                     class="items primary pf-c-content ${this.description ? "block-sibling" : ""}" | ||||||
|  |                 > | ||||||
|  |                     <h1 class="page-title"> | ||||||
|                         ${this.hasIcon |                         ${this.hasIcon | ||||||
|                             ? html`<slot name="icon">${this.renderIcon()}</slot> ` |                             ? html`<slot name="icon">${this.renderIcon()}</slot>` | ||||||
|                             : nothing} |                             : nothing} | ||||||
|                         <slot name="header">${this.header}</slot> |                         ${this.header} | ||||||
|                     </h1> |                     </h1> | ||||||
|                     ${this.description ? html`<p>${this.description}</p>` : html``} |  | ||||||
|                 </div> |  | ||||||
|                 </section> |                 </section> | ||||||
|             <div class="pf-c-page__header-tools"> |                 ${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"> |                     <div class="pf-c-page__header-tools-group"> | ||||||
|                     <ak-nav-buttons .uiConfig=${this.uiConfig} .me=${this.me}> |                         <ak-nav-buttons .uiConfig=${this.uiConfig} .me=${this.session}> | ||||||
|                             <a |                             <a | ||||||
|                                 class="pf-c-button pf-m-secondary pf-m-small pf-u-display-none pf-u-display-block-on-md" |                                 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/" |                                 href="${globalAK().api.base}if/user/" | ||||||
| @ -193,13 +411,76 @@ export class PageHeader extends WithBrandConfig(AKElement) { | |||||||
|                             </a> |                             </a> | ||||||
|                         </ak-nav-buttons> |                         </ak-nav-buttons> | ||||||
|                     </div> |                     </div> | ||||||
|             </div> |                 </section> | ||||||
|         </div>`; |             </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 { | declare global { | ||||||
|     interface HTMLElementTagNameMap { |     interface HTMLElementTagNameMap { | ||||||
|         "ak-page-header": PageHeader; |         "ak-page-header": AKPageHeader; | ||||||
|  |         "ak-page-navbar": AKPageNavbar; | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  | |||||||
							
								
								
									
										36
									
								
								web/src/elements/router/utils.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										36
									
								
								web/src/elements/router/utils.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,36 @@ | |||||||
|  | /** | ||||||
|  |  * @file Utilities for working with the client-side page router. | ||||||
|  |  */ | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * The name identifier for the current interface. | ||||||
|  |  */ | ||||||
|  | export type RouteInterfaceName = "user" | "admin" | "flow" | "unknown"; | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Read the current interface route parameter from the URL. | ||||||
|  |  * | ||||||
|  |  * @param location - The location object to read the pathname from. Defaults to `window.location`. | ||||||
|  |  * * @returns The name of the current interface, or "unknown" if not found. | ||||||
|  |  */ | ||||||
|  | export function readInterfaceRouteParam( | ||||||
|  |     location: Pick<URL, "pathname"> = window.location, | ||||||
|  | ): RouteInterfaceName { | ||||||
|  |     const [, currentInterface = "unknown"] = location.pathname.match(/.+if\/(\w+)\//) || []; | ||||||
|  |  | ||||||
|  |     return currentInterface.toLowerCase() as RouteInterfaceName; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Predicate to determine if the current route is for the admin interface. | ||||||
|  |  */ | ||||||
|  | export function isAdminRoute(location: Pick<URL, "pathname"> = window.location): boolean { | ||||||
|  |     return readInterfaceRouteParam(location) === "admin"; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Predicate to determine if the current route is for the user interface. | ||||||
|  |  */ | ||||||
|  | export function isUserRoute(location: Pick<URL, "pathname"> = window.location): boolean { | ||||||
|  |     return readInterfaceRouteParam(location) === "user"; | ||||||
|  | } | ||||||
| @ -1,5 +1,4 @@ | |||||||
| import { AKElement } from "@goauthentik/elements/Base"; | import { AKElement } from "@goauthentik/elements/Base"; | ||||||
| import "@goauthentik/elements/sidebar/SidebarBrand"; |  | ||||||
| import "@goauthentik/elements/sidebar/SidebarVersion"; | import "@goauthentik/elements/sidebar/SidebarVersion"; | ||||||
|  |  | ||||||
| import { msg } from "@lit/localize"; | import { msg } from "@lit/localize"; | ||||||
| @ -22,6 +21,7 @@ export class Sidebar extends AKElement { | |||||||
|             css` |             css` | ||||||
|                 :host { |                 :host { | ||||||
|                     z-index: 100; |                     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::after, | ||||||
|                 .pf-c-nav__link.pf-m-current:hover::after, |                 .pf-c-nav__link.pf-m-current:hover::after, | ||||||
| @ -35,10 +35,7 @@ export class Sidebar extends AKElement { | |||||||
|                 .pf-c-nav__section + .pf-c-nav__section { |                 .pf-c-nav__section + .pf-c-nav__section { | ||||||
|                     --pf-c-nav__section--section--MarginTop: var(--pf-global--spacer--sm); |                     --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 { |                 nav { | ||||||
|                     display: flex; |                     display: flex; | ||||||
|                     flex-direction: column; |                     flex-direction: column; | ||||||
| @ -70,7 +67,6 @@ export class Sidebar extends AKElement { | |||||||
|             class="pf-c-nav ${this.activeTheme === UiThemeEnum.Light ? "pf-m-light" : ""}" |             class="pf-c-nav ${this.activeTheme === UiThemeEnum.Light ? "pf-m-light" : ""}" | ||||||
|             aria-label=${msg("Global")} |             aria-label=${msg("Global")} | ||||||
|         > |         > | ||||||
|             <ak-sidebar-brand></ak-sidebar-brand> |  | ||||||
|             <ul class="pf-c-nav__list"> |             <ul class="pf-c-nav__list"> | ||||||
|                 <slot></slot> |                 <slot></slot> | ||||||
|             </ul> |             </ul> | ||||||
|  | |||||||
| @ -1,106 +0,0 @@ | |||||||
| import { EVENT_SIDEBAR_TOGGLE } from "@goauthentik/common/constants"; |  | ||||||
| import { globalAK } from "@goauthentik/common/global"; |  | ||||||
| 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, nothing } 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 |  | ||||||
| // is shown besides the content, and not overlaid. |  | ||||||
| export const MIN_WIDTH = 1200; |  | ||||||
|  |  | ||||||
| export const DefaultBrand: CurrentBrand = { |  | ||||||
|     brandingLogo: "/static/dist/assets/icons/icon_left_brand.svg", |  | ||||||
|     brandingFavicon: "/static/dist/assets/icons/icon.png", |  | ||||||
|     brandingTitle: "authentik", |  | ||||||
|     brandingCustomCss: "", |  | ||||||
|     uiFooterLinks: [], |  | ||||||
|     uiTheme: UiThemeEnum.Automatic, |  | ||||||
|     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 { |  | ||||||
|         const logoUrl = |  | ||||||
|             globalAK().brand.brandingLogo || this.brand?.brandingLogo || DefaultBrand.brandingLogo; |  | ||||||
|  |  | ||||||
|         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> |  | ||||||
|                   ` |  | ||||||
|                 : nothing} |  | ||||||
|             <a href="#/" class="pf-c-page__header-brand-link"> |  | ||||||
|                 <div class="pf-c-brand ak-brand"> |  | ||||||
|                     <img src=${themeImage(logoUrl)} alt="${msg("authentik Logo")}" loading="lazy" /> |  | ||||||
|                 </div> |  | ||||||
|             </a>`; |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| declare global { |  | ||||||
|     interface HTMLElementTagNameMap { |  | ||||||
|         "ak-sidebar-brand": SidebarBrand; |  | ||||||
|     } |  | ||||||
| } |  | ||||||
| @ -1,9 +1,9 @@ | |||||||
| import type { AdminInterface } from "@goauthentik/admin/AdminInterface/AdminInterface"; | import type { AdminInterface } from "@goauthentik/admin/AdminInterface/AdminInterface"; | ||||||
| import { globalAK } from "@goauthentik/common/global"; | import { globalAK } from "@goauthentik/common/global"; | ||||||
|  | import { DefaultBrand } from "@goauthentik/common/ui/config"; | ||||||
| import { AKElement, rootInterface } from "@goauthentik/elements/Base"; | import { AKElement, rootInterface } from "@goauthentik/elements/Base"; | ||||||
| import { WithLicenseSummary } from "@goauthentik/elements/Interface/licenseSummaryProvider"; | import { WithLicenseSummary } from "@goauthentik/elements/Interface/licenseSummaryProvider"; | ||||||
| import { WithVersion } from "@goauthentik/elements/Interface/versionProvider"; | import { WithVersion } from "@goauthentik/elements/Interface/versionProvider"; | ||||||
| import { DefaultBrand } from "@goauthentik/elements/sidebar/SidebarBrand"; |  | ||||||
|  |  | ||||||
| import { msg, str } from "@lit/localize"; | import { msg, str } from "@lit/localize"; | ||||||
| import { CSSResult, css, html, nothing } from "lit"; | import { CSSResult, css, html, nothing } from "lit"; | ||||||
|  | |||||||
| @ -1,19 +1,21 @@ | |||||||
|  | import { | ||||||
|  |     appendStyleSheet, | ||||||
|  |     assertAdoptableStyleSheetParent, | ||||||
|  |     createStyleSheetUnsafe, | ||||||
|  | } from "@goauthentik/common/stylesheets.js"; | ||||||
|  |  | ||||||
| import { TemplateResult, render as litRender } from "lit"; | import { TemplateResult, render as litRender } from "lit"; | ||||||
|  |  | ||||||
| import AKGlobal from "@goauthentik/common/styles/authentik.css"; | import AKGlobal from "@goauthentik/common/styles/authentik.css"; | ||||||
| import PFBase from "@patternfly/patternfly/patternfly-base.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 | // 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 | // to all elements under test.  Ensures they look right during testing, and that any | ||||||
| // CSS-based checks for visibility will return correct values. | // CSS-based checks for visibility will return correct values. | ||||||
|  |  | ||||||
| export const render = (body: TemplateResult) => { | export const render = (body: TemplateResult) => { | ||||||
|     document.adoptedStyleSheets = [ |     assertAdoptableStyleSheetParent(document); | ||||||
|         ...document.adoptedStyleSheets, |  | ||||||
|         ensureCSSStyleSheet(PFBase), |     appendStyleSheet(document, ...[PFBase, AKGlobal].map(createStyleSheetUnsafe)); | ||||||
|         ensureCSSStyleSheet(AKGlobal), |  | ||||||
|     ]; |  | ||||||
|     return litRender(body, document.body); |     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 { type LitElement, type ReactiveControllerHost, type TemplateResult, nothing } from "lit"; | ||||||
| import "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; | 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 { resolveUITheme } from "@goauthentik/common/theme"; | ||||||
|  | import { rootInterface } from "@goauthentik/elements/Base"; | ||||||
| import { UiThemeEnum } from "@goauthentik/api"; |  | ||||||
|  |  | ||||||
| export function themeImage(rawPath: string) { | export function themeImage(rawPath: string) { | ||||||
|     let enabledTheme = rootInterface()?.activeTheme; |     const enabledTheme = rootInterface()?.activeTheme || resolveUITheme(); | ||||||
|     if (!enabledTheme || enabledTheme === UiThemeEnum.Automatic) { |  | ||||||
|         enabledTheme = window.matchMedia(QUERY_MEDIA_COLOR_LIGHT).matches |  | ||||||
|             ? UiThemeEnum.Light |  | ||||||
|             : UiThemeEnum.Dark; |  | ||||||
|     } |  | ||||||
|     return rawPath.replaceAll("%(theme)s", enabledTheme); |     return rawPath.replaceAll("%(theme)s", enabledTheme); | ||||||
| } | } | ||||||
|  | |||||||
| @ -6,12 +6,12 @@ import { | |||||||
| } from "@goauthentik/common/constants"; | } from "@goauthentik/common/constants"; | ||||||
| import { globalAK } from "@goauthentik/common/global"; | import { globalAK } from "@goauthentik/common/global"; | ||||||
| import { configureSentry } from "@goauthentik/common/sentry"; | import { configureSentry } from "@goauthentik/common/sentry"; | ||||||
|  | import { DefaultBrand } from "@goauthentik/common/ui/config"; | ||||||
| import { first } from "@goauthentik/common/utils"; | import { first } from "@goauthentik/common/utils"; | ||||||
| import { WebsocketClient } from "@goauthentik/common/ws"; | import { WebsocketClient } from "@goauthentik/common/ws"; | ||||||
| import { Interface } from "@goauthentik/elements/Interface"; | import { Interface } from "@goauthentik/elements/Interface"; | ||||||
| import "@goauthentik/elements/LoadingOverlay"; | import "@goauthentik/elements/LoadingOverlay"; | ||||||
| import "@goauthentik/elements/ak-locale-context"; | import "@goauthentik/elements/ak-locale-context"; | ||||||
| import { DefaultBrand } from "@goauthentik/elements/sidebar/SidebarBrand"; |  | ||||||
| import { themeImage } from "@goauthentik/elements/utils/images"; | import { themeImage } from "@goauthentik/elements/utils/images"; | ||||||
| import "@goauthentik/flow/components/ak-brand-footer"; | import "@goauthentik/flow/components/ak-brand-footer"; | ||||||
| import "@goauthentik/flow/sources/apple/AppleLoginInit"; | import "@goauthentik/flow/sources/apple/AppleLoginInit"; | ||||||
| @ -46,7 +46,6 @@ import { | |||||||
|     FlowsApi, |     FlowsApi, | ||||||
|     ResponseError, |     ResponseError, | ||||||
|     ShellChallenge, |     ShellChallenge, | ||||||
|     UiThemeEnum, |  | ||||||
| } from "@goauthentik/api"; | } from "@goauthentik/api"; | ||||||
|  |  | ||||||
| @customElement("ak-flow-executor") | @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( |     async submit( | ||||||
|         payload?: FlowChallengeResponseRequest, |         payload?: FlowChallengeResponseRequest, | ||||||
|         options?: SubmitOptions, |         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 { AKElement } from "@goauthentik/elements/Base.js"; | ||||||
|  |  | ||||||
| import { msg } from "@lit/localize"; | 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") | @customElement("ak-brand-links") | ||||||
| export class BrandLinks extends AKElement { | export class BrandLinks extends AKElement { | ||||||
|     static get styles() { |     static get styles() { | ||||||
| @ -33,13 +31,21 @@ export class BrandLinks extends AKElement { | |||||||
|     links: FooterLink[] = []; |     links: FooterLink[] = []; | ||||||
|  |  | ||||||
|     render() { |     render() { | ||||||
|         const links = [...(this.links ?? []), poweredBy]; |         const links = [...(this.links ?? [])]; | ||||||
|  |  | ||||||
|         return html` <ul class="pf-c-list pf-m-inline"> |         return html` <ul class="pf-c-list pf-m-inline"> | ||||||
|             ${map(links, (link) => |             ${map(links, (link) => { | ||||||
|                 link.href |                 const children = sanitizeHTML(BrandedHTMLPolicy, link.name); | ||||||
|                     ? purify(html`<li><a href="${link.href}">${link.name}</a></li>`) |  | ||||||
|                     : html`<li><span>${link.name}</span></li>`, |                 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>`; |         </ul>`; | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  | |||||||
| @ -1,15 +1,16 @@ | |||||||
| ///<reference types="@hcaptcha/types"/> | /// <reference types="@hcaptcha/types"/> | ||||||
| import { renderStatic } from "@goauthentik/common/purify"; | /// <reference types="turnstile-types"/> | ||||||
|  | import { renderStaticHTMLUnsafe } from "@goauthentik/common/purify"; | ||||||
| import "@goauthentik/elements/EmptyState"; | import "@goauthentik/elements/EmptyState"; | ||||||
| import { akEmptyState } from "@goauthentik/elements/EmptyState"; | import { akEmptyState } from "@goauthentik/elements/EmptyState"; | ||||||
| import { bound } from "@goauthentik/elements/decorators/bound"; | import { bound } from "@goauthentik/elements/decorators/bound"; | ||||||
| import "@goauthentik/elements/forms/FormElement"; | import "@goauthentik/elements/forms/FormElement"; | ||||||
|  | import { createIFrameHTMLWrapper } from "@goauthentik/elements/utils/iframe"; | ||||||
| import { ListenerController } from "@goauthentik/elements/utils/listenerController.js"; | import { ListenerController } from "@goauthentik/elements/utils/listenerController.js"; | ||||||
| import { randomId } from "@goauthentik/elements/utils/randomId"; | import { randomId } from "@goauthentik/elements/utils/randomId"; | ||||||
| import "@goauthentik/flow/FormStatic"; | import "@goauthentik/flow/FormStatic"; | ||||||
| import { BaseStage } from "@goauthentik/flow/stages/base"; | import { BaseStage } from "@goauthentik/flow/stages/base"; | ||||||
| import { P, match } from "ts-pattern"; | import { P, match } from "ts-pattern"; | ||||||
| import type * as _ from "turnstile-types"; |  | ||||||
|  |  | ||||||
| import { msg } from "@lit/localize"; | import { msg } from "@lit/localize"; | ||||||
| import { CSSResult, PropertyValues, TemplateResult, css, html, nothing } from "lit"; | import { CSSResult, PropertyValues, TemplateResult, css, html, nothing } from "lit"; | ||||||
| @ -56,18 +57,14 @@ type CaptchaHandler = { | |||||||
| // a resize. Because the Captcha is itself in an iframe, the reported height is often off by some | // 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 | // margin, so adding 2rem of height to our container adds padding and prevents scroll bars or hidden | ||||||
| // rendering. | // rendering. | ||||||
|  | function iframeTemplate(children: TemplateResult, challengeURL: string): TemplateResult { | ||||||
| const iframeTemplate = (captchaElement: TemplateResult, challengeUrl: string) => |     return html` ${children} | ||||||
|     html`<!doctype html> |  | ||||||
|         <head> |  | ||||||
|             <html> |  | ||||||
|                 <body style="display:flex;flex-direction:row;justify-content:center;"> |  | ||||||
|                     ${captchaElement} |  | ||||||
|         <script> |         <script> | ||||||
|             new ResizeObserver((entries) => { |             new ResizeObserver((entries) => { | ||||||
|                 const height = |                 const height = | ||||||
|                     document.body.offsetHeight + |                     document.body.offsetHeight + | ||||||
|                     parseFloat(getComputedStyle(document.body).fontSize) * 2; |                     parseFloat(getComputedStyle(document.body).fontSize) * 2; | ||||||
|  |  | ||||||
|                 window.parent.postMessage({ |                 window.parent.postMessage({ | ||||||
|                     message: "resize", |                     message: "resize", | ||||||
|                     source: "goauthentik.io", |                     source: "goauthentik.io", | ||||||
| @ -76,20 +73,20 @@ const iframeTemplate = (captchaElement: TemplateResult, challengeUrl: string) => | |||||||
|                 }); |                 }); | ||||||
|             }).observe(document.querySelector(".ak-captcha-container")); |             }).observe(document.querySelector(".ak-captcha-container")); | ||||||
|         </script> |         </script> | ||||||
|                     <script src=${challengeUrl}></script> |  | ||||||
|  |         <script src=${challengeURL}></script> | ||||||
|  |  | ||||||
|         <script> |         <script> | ||||||
|             function callback(token) { |             function callback(token) { | ||||||
|                 window.parent.postMessage({ |                 window.parent.postMessage({ | ||||||
|                     message: "captcha", |                     message: "captcha", | ||||||
|                     source: "goauthentik.io", |                     source: "goauthentik.io", | ||||||
|                     context: "flow-executor", |                     context: "flow-executor", | ||||||
|                                 token: token, |                     token, | ||||||
|                 }); |                 }); | ||||||
|             } |             } | ||||||
|                     </script> |         </script>`; | ||||||
|                 </body> | } | ||||||
|             </html> |  | ||||||
|         </head>`; |  | ||||||
|  |  | ||||||
| @customElement("ak-stage-captcha") | @customElement("ak-stage-captcha") | ||||||
| export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeResponseRequest> { | export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeResponseRequest> { | ||||||
| @ -305,11 +302,25 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe | |||||||
|     } |     } | ||||||
|  |  | ||||||
|     async renderFrame(captchaElement: TemplateResult) { |     async renderFrame(captchaElement: TemplateResult) { | ||||||
|         this.captchaFrame.contentWindow?.document.open(); |         const { contentDocument } = this.captchaFrame || {}; | ||||||
|         this.captchaFrame.contentWindow?.document.write( |  | ||||||
|             await renderStatic(iframeTemplate(captchaElement, this.challenge.jsUrl)), |         if (!contentDocument) { | ||||||
|  |             console.debug( | ||||||
|  |                 "authentik/stages/captcha: unable to render captcha frame, no contentDocument", | ||||||
|             ); |             ); | ||||||
|         this.captchaFrame.contentWindow?.document.close(); |  | ||||||
|  |             return; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         contentDocument.open(); | ||||||
|  |  | ||||||
|  |         contentDocument.write( | ||||||
|  |             createIFrameHTMLWrapper( | ||||||
|  |                 renderStaticHTMLUnsafe(iframeTemplate(captchaElement, this.challenge.jsUrl)), | ||||||
|  |             ), | ||||||
|  |         ); | ||||||
|  |  | ||||||
|  |         contentDocument.close(); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     renderBody() { |     renderBody() { | ||||||
|  | |||||||
| @ -3,11 +3,10 @@ import "rapidoc"; | |||||||
|  |  | ||||||
| import { CSRFHeaderName } from "@goauthentik/common/api/config"; | import { CSRFHeaderName } from "@goauthentik/common/api/config"; | ||||||
| import { EVENT_THEME_CHANGE } from "@goauthentik/common/constants"; | import { EVENT_THEME_CHANGE } from "@goauthentik/common/constants"; | ||||||
| import { globalAK } from "@goauthentik/common/global"; |  | ||||||
| import { first, getCookie } from "@goauthentik/common/utils"; | import { first, getCookie } from "@goauthentik/common/utils"; | ||||||
| import { Interface } from "@goauthentik/elements/Interface"; | import { Interface } from "@goauthentik/elements/Interface"; | ||||||
| import "@goauthentik/elements/ak-locale-context"; | import "@goauthentik/elements/ak-locale-context"; | ||||||
| import { DefaultBrand } from "@goauthentik/elements/sidebar/SidebarBrand"; | import { DefaultBrand } from "@goauthentik/common/ui/config"; | ||||||
| import { themeImage } from "@goauthentik/elements/utils/images"; | import { themeImage } from "@goauthentik/elements/utils/images"; | ||||||
|  |  | ||||||
| import { msg } from "@lit/localize"; | import { msg } from "@lit/localize"; | ||||||
| @ -62,10 +61,6 @@ export class APIBrowser extends Interface { | |||||||
|         ); |         ); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     async getTheme(): Promise<UiThemeEnum> { |  | ||||||
|         return globalAK()?.brand.uiTheme || UiThemeEnum.Automatic; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     render(): TemplateResult { |     render(): TemplateResult { | ||||||
|         return html` |         return html` | ||||||
|             <ak-locale-context> |             <ak-locale-context> | ||||||
|  | |||||||
| @ -1,4 +1,3 @@ | |||||||
| import { globalAK } from "@goauthentik/common/global"; |  | ||||||
| import { Interface } from "@goauthentik/elements/Interface"; | import { Interface } from "@goauthentik/elements/Interface"; | ||||||
|  |  | ||||||
| import { msg } from "@lit/localize"; | 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 PFSpinner from "@patternfly/patternfly/components/Spinner/spinner.css"; | ||||||
| import PFBase from "@patternfly/patternfly/patternfly-base.css"; | import PFBase from "@patternfly/patternfly/patternfly-base.css"; | ||||||
|  |  | ||||||
| import { UiThemeEnum } from "@goauthentik/api"; |  | ||||||
|  |  | ||||||
| @customElement("ak-loading") | @customElement("ak-loading") | ||||||
| export class Loading extends Interface { | export class Loading extends Interface { | ||||||
|     static get styles(): CSSResult[] { |     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 |         // 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 |         // 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. |         // 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. |         // Stub function to avoid fetching custom CSS. | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     async getTheme(): Promise<UiThemeEnum> { |  | ||||||
|         return globalAK()?.brand.uiTheme || UiThemeEnum.Automatic; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     render(): TemplateResult { |     render(): TemplateResult { | ||||||
|         return html` <section |         return html` <section | ||||||
|             class="ak-static-page pf-c-page__main-section pf-m-no-padding-mobile pf-m-xl" |             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 { FlowExecutor } from "@goauthentik/flow/FlowExecutor"; | ||||||
|  |  | ||||||
| import { customElement, property } from "lit/decorators.js"; | import { customElement } from "lit/decorators.js"; | ||||||
|  |  | ||||||
| import { UiThemeEnum } from "@goauthentik/api"; |  | ||||||
|  |  | ||||||
| @customElement("ak-storybook-interface-flow") | @customElement("ak-storybook-interface-flow") | ||||||
| export class StoryFlowInterface extends FlowExecutor { | export class StoryFlowInterface extends FlowExecutor {} | ||||||
|     @property() |  | ||||||
|     storyTheme: UiThemeEnum = UiThemeEnum.Dark; |  | ||||||
|  |  | ||||||
|     async getTheme(): Promise<UiThemeEnum> { |  | ||||||
|         return this.storyTheme; |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| declare global { | declare global { | ||||||
|     interface HTMLElementTagNameMap { |     interface HTMLElementTagNameMap { | ||||||
|  | |||||||
| @ -1,18 +1,9 @@ | |||||||
| import { Interface } from "@goauthentik/elements/Interface"; | import { Interface } from "@goauthentik/elements/Interface"; | ||||||
|  |  | ||||||
| import { customElement, property } from "lit/decorators.js"; | import { customElement } from "lit/decorators.js"; | ||||||
|  |  | ||||||
| import { UiThemeEnum } from "@goauthentik/api"; |  | ||||||
|  |  | ||||||
| @customElement("ak-storybook-interface") | @customElement("ak-storybook-interface") | ||||||
| export class StoryInterface extends Interface { | export class StoryInterface extends Interface {} | ||||||
|     @property() |  | ||||||
|     storyTheme: UiThemeEnum = UiThemeEnum.Dark; |  | ||||||
|  |  | ||||||
|     async getTheme(): Promise<UiThemeEnum> { |  | ||||||
|         return this.storyTheme; |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| declare global { | declare global { | ||||||
|     interface HTMLElementTagNameMap { |     interface HTMLElementTagNameMap { | ||||||
|  | |||||||
| @ -6,7 +6,8 @@ import { | |||||||
| } from "@goauthentik/common/constants"; | } from "@goauthentik/common/constants"; | ||||||
| import { globalAK } from "@goauthentik/common/global"; | import { globalAK } from "@goauthentik/common/global"; | ||||||
| import { configureSentry } from "@goauthentik/common/sentry"; | import { configureSentry } from "@goauthentik/common/sentry"; | ||||||
| import { UIConfig } from "@goauthentik/common/ui/config"; | import { UIConfig, getConfigForUser } from "@goauthentik/common/ui/config"; | ||||||
|  | import { DefaultBrand } from "@goauthentik/common/ui/config"; | ||||||
| import { me } from "@goauthentik/common/users"; | import { me } from "@goauthentik/common/users"; | ||||||
| import { WebsocketClient } from "@goauthentik/common/ws"; | import { WebsocketClient } from "@goauthentik/common/ws"; | ||||||
| import "@goauthentik/components/ak-nav-buttons"; | import "@goauthentik/components/ak-nav-buttons"; | ||||||
| @ -21,7 +22,6 @@ import "@goauthentik/elements/notifications/NotificationDrawer"; | |||||||
| import { getURLParam, updateURLParams } from "@goauthentik/elements/router/RouteMatch"; | import { getURLParam, updateURLParams } from "@goauthentik/elements/router/RouteMatch"; | ||||||
| import "@goauthentik/elements/router/RouterOutlet"; | import "@goauthentik/elements/router/RouterOutlet"; | ||||||
| import "@goauthentik/elements/sidebar/Sidebar"; | import "@goauthentik/elements/sidebar/Sidebar"; | ||||||
| import { DefaultBrand } from "@goauthentik/elements/sidebar/SidebarBrand"; |  | ||||||
| import "@goauthentik/elements/sidebar/SidebarItem"; | import "@goauthentik/elements/sidebar/SidebarItem"; | ||||||
| import { themeImage } from "@goauthentik/elements/utils/images"; | import { themeImage } from "@goauthentik/elements/utils/images"; | ||||||
| import { ROUTES } from "@goauthentik/user/Routes"; | import { ROUTES } from "@goauthentik/user/Routes"; | ||||||
| @ -292,6 +292,7 @@ export class UserInterface extends AuthenticatedInterface { | |||||||
|  |  | ||||||
|     async connectedCallback() { |     async connectedCallback() { | ||||||
|         super.connectedCallback(); |         super.connectedCallback(); | ||||||
|  |  | ||||||
|         window.addEventListener(EVENT_NOTIFICATION_DRAWER_TOGGLE, this.toggleNotificationDrawer); |         window.addEventListener(EVENT_NOTIFICATION_DRAWER_TOGGLE, this.toggleNotificationDrawer); | ||||||
|         window.addEventListener(EVENT_API_DRAWER_TOGGLE, this.toggleApiDrawer); |         window.addEventListener(EVENT_API_DRAWER_TOGGLE, this.toggleApiDrawer); | ||||||
|         window.addEventListener(EVENT_WS_MESSAGE, this.fetchConfigurationDetails); |         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_NOTIFICATION_DRAWER_TOGGLE, this.toggleNotificationDrawer); | ||||||
|         window.removeEventListener(EVENT_API_DRAWER_TOGGLE, this.toggleApiDrawer); |         window.removeEventListener(EVENT_API_DRAWER_TOGGLE, this.toggleApiDrawer); | ||||||
|         window.removeEventListener(EVENT_WS_MESSAGE, this.fetchConfigurationDetails); |         window.removeEventListener(EVENT_WS_MESSAGE, this.fetchConfigurationDetails); | ||||||
|  |  | ||||||
|         super.disconnectedCallback(); |         super.disconnectedCallback(); | ||||||
|     } |     } | ||||||
|  |  | ||||||
| @ -319,8 +321,10 @@ export class UserInterface extends AuthenticatedInterface { | |||||||
|     } |     } | ||||||
|  |  | ||||||
|     fetchConfigurationDetails() { |     fetchConfigurationDetails() { | ||||||
|         me().then((me: SessionUser) => { |         me().then((session: SessionUser) => { | ||||||
|             this.me = me; |             this.me = session; | ||||||
|  |             this.uiConfig = getConfigForUser(session.user); | ||||||
|  |  | ||||||
|             new EventsApi(DEFAULT_CONFIG) |             new EventsApi(DEFAULT_CONFIG) | ||||||
|                 .eventsNotificationsList({ |                 .eventsNotificationsList({ | ||||||
|                     seen: false, |                     seen: false, | ||||||
| @ -334,12 +338,16 @@ export class UserInterface extends AuthenticatedInterface { | |||||||
|         }); |         }); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     get isFullyConfigured() { |     render() { | ||||||
|         return Boolean(this.uiConfig && this.me); |         if (!this.me) { | ||||||
|  |             console.debug(`authentik/user/UserInterface: waiting for user session to be available`); | ||||||
|  |  | ||||||
|  |             return nothing; | ||||||
|         } |         } | ||||||
|  |  | ||||||
|     render() { |         if (!this.uiConfig) { | ||||||
|         if (!this.isFullyConfigured) { |             console.debug(`authentik/user/UserInterface: waiting for UI config to be available`); | ||||||
|  |  | ||||||
|             return nothing; |             return nothing; | ||||||
|         } |         } | ||||||
|  |  | ||||||
|  | |||||||
		Reference in New Issue
	
	Block a user
	 Teffen Ellis
					Teffen Ellis