From 61a26c02b7c56f77857c35eb4003bbb069fa2f71 Mon Sep 17 00:00:00 2001 From: Teffen Ellis <592134+GirlBossRush@users.noreply.github.com> Date: Fri, 2 May 2025 21:26:40 +0200 Subject: [PATCH] Revert-revert: Safari fixes (#14331) * Reapply "web: Safari fixes merge branch (#14181)" This reverts commit a41d45834c82755232a8d29181766628d39a4532. * web: Fix brand preference order. Adjust header height. --- web/package-lock.json | 55 +- web/package.json | 2 +- web/src/admin/AdminInterface/AboutModal.ts | 2 +- .../admin/AdminInterface/AdminInterface.ts | 98 +++- web/src/admin/AdminInterface/AdminSidebar.ts | 257 ++++------ .../admin/admin-overview/AdminOverviewPage.ts | 9 +- .../admin/admin-settings/AdminSettingsPage.ts | 9 +- web/src/admin/brands/BrandForm.ts | 2 +- web/src/common/purify.ts | 114 ++++- web/src/common/sentry.ts | 13 +- web/src/common/styles/authentik.css | 7 + web/src/common/stylesheets.ts | 223 ++++++++ web/src/common/theme.ts | 200 ++++++++ web/src/common/ui/config.ts | 18 +- web/src/components/ak-nav-buttons.ts | 26 +- web/src/elements/Base.ts | 253 +++++----- .../Interface/BrandContextController.ts | 7 +- .../Interface/ConfigContextController.ts | 7 +- web/src/elements/Interface/Interface.ts | 85 ++-- web/src/elements/PageHeader.ts | 475 ++++++++++++++---- web/src/elements/router/utils.ts | 36 ++ web/src/elements/sidebar/Sidebar.ts | 8 +- web/src/elements/sidebar/SidebarBrand.ts | 106 ---- web/src/elements/sidebar/SidebarVersion.ts | 2 +- web/src/elements/tests/utils.ts | 16 +- web/src/elements/types.ts | 11 +- web/src/elements/utils/ensureCSSStyleSheet.ts | 35 -- web/src/elements/utils/iframe.ts | 55 ++ web/src/elements/utils/images.ts | 13 +- web/src/flow/FlowExecutor.ts | 7 +- web/src/flow/components/ak-brand-footer.ts | 24 +- web/src/flow/stages/captcha/CaptchaStage.ts | 91 ++-- web/src/standalone/api-browser/index.ts | 7 +- web/src/standalone/loading/index.ts | 9 +- web/src/stories/flow-interface.ts | 13 +- web/src/stories/interface.ts | 13 +- web/src/user/UserInterface.ts | 26 +- 37 files changed, 1483 insertions(+), 851 deletions(-) create mode 100644 web/src/common/stylesheets.ts create mode 100644 web/src/common/theme.ts create mode 100644 web/src/elements/router/utils.ts delete mode 100644 web/src/elements/sidebar/SidebarBrand.ts delete mode 100644 web/src/elements/utils/ensureCSSStyleSheet.ts create mode 100644 web/src/elements/utils/iframe.ts diff --git a/web/package-lock.json b/web/package-lock.json index 1256361852..faed3230f7 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -25,7 +25,6 @@ "@formatjs/intl-listformat": "^7.5.7", "@fortawesome/fontawesome-free": "^6.6.0", "@goauthentik/api": "^2025.4.0-1746018955", - "@lit-labs/ssr": "3.2.2", "@lit/context": "^1.1.2", "@lit/localize": "^0.12.2", "@lit/reactive-element": "^2.0.4", @@ -66,6 +65,7 @@ "remark-gfm": "^4.0.1", "remark-mdx-frontmatter": "^5.0.0", "style-mod": "^4.1.2", + "trusted-types": "^2.0.0", "ts-pattern": "^5.4.0", "unist-util-visit": "^5.0.0", "webcomponent-qr-code": "^1.2.0", @@ -2314,50 +2314,11 @@ "@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": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.2.1.tgz", "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": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@lit/context/-/context-1.1.2.tgz", @@ -2967,6 +2928,7 @@ "version": "0.3.0", "resolved": "https://registry.npmjs.org/@parse5/tools/-/tools-0.3.0.tgz", "integrity": "sha512-zxRyTHkqb7WQMV8kTNBKWb1BeOFUKXBXTBWuxg9H9hfvQB3IwP6Iw2U75Ia5eyRxPNltmY7E8YAlz6zWwUnjKg==", + "dev": true, "dependencies": { "parse5": "^7.0.0" } @@ -10856,6 +10818,7 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "dev": true, "engines": { "node": ">= 12" } @@ -11512,6 +11475,7 @@ "version": "5.17.1", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.17.1.tgz", "integrity": "sha512-LMHl3dXhTcfv8gM4kEzIUeTQ+7fpdA0l2tUf34BddXPkz2A5xJ5L/Pchd5BL6rdccM9QGvu0sWZzK1Z1t4wwyg==", + "dev": true, "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" @@ -13971,7 +13935,8 @@ "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==" + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true }, "node_modules/grapheme-splitter": { "version": "1.0.4", @@ -18725,6 +18690,7 @@ "version": "3.3.2", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "dev": true, "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", @@ -23061,6 +23027,7 @@ "version": "2.2.1", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==", + "dev": true, "engines": { "node": ">=6" } @@ -23461,6 +23428,12 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/trusted-types": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/trusted-types/-/trusted-types-2.0.0.tgz", + "integrity": "sha512-Eam+AUp6lg04YjmYkuLNhEJX+6ByocrKTpY/TtfRK/gV6OmxeN0OwkIasor28SUJ606snArpPLGtPMGbqdaaUA==", + "license": "W3C-20150513" + }, "node_modules/ts-api-utils": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.3.0.tgz", diff --git a/web/package.json b/web/package.json index 195c2b6217..052323e4de 100644 --- a/web/package.json +++ b/web/package.json @@ -13,7 +13,6 @@ "@formatjs/intl-listformat": "^7.5.7", "@fortawesome/fontawesome-free": "^6.6.0", "@goauthentik/api": "^2025.4.0-1746018955", - "@lit-labs/ssr": "3.2.2", "@lit/context": "^1.1.2", "@lit/localize": "^0.12.2", "@lit/reactive-element": "^2.0.4", @@ -54,6 +53,7 @@ "remark-gfm": "^4.0.1", "remark-mdx-frontmatter": "^5.0.0", "style-mod": "^4.1.2", + "trusted-types": "^2.0.0", "ts-pattern": "^5.4.0", "unist-util-visit": "^5.0.0", "webcomponent-qr-code": "^1.2.0", diff --git a/web/src/admin/AdminInterface/AboutModal.ts b/web/src/admin/AdminInterface/AboutModal.ts index a519ee5359..4869038f97 100644 --- a/web/src/admin/AdminInterface/AboutModal.ts +++ b/web/src/admin/AdminInterface/AboutModal.ts @@ -1,11 +1,11 @@ import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { VERSION } from "@goauthentik/common/constants"; import { globalAK } from "@goauthentik/common/global"; +import { DefaultBrand } from "@goauthentik/common/ui/config"; import "@goauthentik/elements/EmptyState"; import { WithBrandConfig } from "@goauthentik/elements/Interface/brandProvider"; import { WithLicenseSummary } from "@goauthentik/elements/Interface/licenseSummaryProvider"; import { ModalButton } from "@goauthentik/elements/buttons/ModalButton"; -import { DefaultBrand } from "@goauthentik/elements/sidebar/SidebarBrand"; import { msg } from "@lit/localize"; import { TemplateResult, css, html } from "lit"; diff --git a/web/src/admin/AdminInterface/AdminInterface.ts b/web/src/admin/AdminInterface/AdminInterface.ts index dda28c5a37..e0c0583350 100644 --- a/web/src/admin/AdminInterface/AdminInterface.ts +++ b/web/src/admin/AdminInterface/AdminInterface.ts @@ -4,13 +4,17 @@ import { ROUTES } from "@goauthentik/admin/Routes"; import { EVENT_API_DRAWER_TOGGLE, EVENT_NOTIFICATION_DRAWER_TOGGLE, + EVENT_SIDEBAR_TOGGLE, } from "@goauthentik/common/constants"; import { configureSentry } from "@goauthentik/common/sentry"; import { me } from "@goauthentik/common/users"; import { WebsocketClient } from "@goauthentik/common/ws"; import { AuthenticatedInterface } from "@goauthentik/elements/Interface"; +import { WithLicenseSummary } from "@goauthentik/elements/Interface/licenseSummaryProvider.js"; import "@goauthentik/elements/ak-locale-context"; import "@goauthentik/elements/banner/EnterpriseStatusBanner"; +import "@goauthentik/elements/banner/EnterpriseStatusBanner"; +import "@goauthentik/elements/banner/VersionBanner"; import "@goauthentik/elements/banner/VersionBanner"; import "@goauthentik/elements/messages/MessageContainer"; import "@goauthentik/elements/messages/MessageContainer"; @@ -21,25 +25,32 @@ import "@goauthentik/elements/router/RouterOutlet"; import "@goauthentik/elements/sidebar/Sidebar"; import "@goauthentik/elements/sidebar/SidebarItem"; -import { CSSResult, TemplateResult, css, html } from "lit"; +import { CSSResult, TemplateResult, css, html, nothing } from "lit"; import { customElement, property, query, state } from "lit/decorators.js"; import { classMap } from "lit/directives/class-map.js"; import PFButton from "@patternfly/patternfly/components/Button/button.css"; import PFDrawer from "@patternfly/patternfly/components/Drawer/drawer.css"; +import PFNav from "@patternfly/patternfly/components/Nav/nav.css"; import PFPage from "@patternfly/patternfly/components/Page/page.css"; import PFBase from "@patternfly/patternfly/patternfly-base.css"; -import { SessionUser, UiThemeEnum } from "@goauthentik/api"; +import { LicenseSummaryStatusEnum, SessionUser, UiThemeEnum } from "@goauthentik/api"; -import "./AdminSidebar"; +import { + AdminSidebarEnterpriseEntries, + AdminSidebarEntries, + renderSidebarItems, +} from "./AdminSidebar.js"; if (process.env.NODE_ENV === "development") { await import("@goauthentik/esbuild-plugin-live-reload/client"); } @customElement("ak-interface-admin") -export class AdminInterface extends AuthenticatedInterface { +export class AdminInterface extends WithLicenseSummary(AuthenticatedInterface) { + //#region Properties + @property({ type: Boolean }) notificationDrawerOpen = getURLParam("notificationDrawerOpen", false); @@ -54,12 +65,29 @@ export class AdminInterface extends AuthenticatedInterface { @query("ak-about-modal") aboutModal?: AboutModal; + @property({ type: Boolean, reflect: true }) + public sidebarOpen: boolean; + + #toggleSidebar = () => { + this.sidebarOpen = !this.sidebarOpen; + }; + + #sidebarMatcher: MediaQueryList; + #sidebarListener = (event: MediaQueryListEvent) => { + this.sidebarOpen = event.matches; + }; + + //#endregion + + //#region Styles + static get styles(): CSSResult[] { return [ PFBase, PFPage, PFButton, PFDrawer, + PFNav, css` .pf-c-page__main, .pf-c-drawer__content, @@ -67,23 +95,30 @@ export class AdminInterface extends AuthenticatedInterface { z-index: auto !important; background-color: transparent; } + .display-none { display: none; } + .pf-c-page { background-color: var(--pf-c-page--BackgroundColor) !important; } - /* Global page background colour */ - :host([theme="dark"]) .pf-c-page { - --pf-c-page--BackgroundColor: var(--ak-dark-background); + + :host([theme="dark"]) { + /* Global page background colour */ + .pf-c-page { + --pf-c-page--BackgroundColor: var(--ak-dark-background); + } } - ak-enterprise-status, - ak-version-banner { + + ak-page-navbar { grid-area: header; } - ak-admin-sidebar { + + .ak-sidebar { grid-area: nav; } + .pf-c-drawer__panel { z-index: var(--pf-global--ZIndex--xl); } @@ -91,10 +126,23 @@ export class AdminInterface extends AuthenticatedInterface { ]; } + //#endregion + + //#region Lifecycle + constructor() { super(); 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, () => { this.notificationDrawerOpen = !this.notificationDrawerOpen; updateURLParams({ @@ -108,6 +156,14 @@ export class AdminInterface extends AuthenticatedInterface { apiDrawerOpen: this.apiDrawerOpen, }); }); + + this.#sidebarMatcher.addEventListener("change", this.#sidebarListener); + } + + public disconnectedCallback(): void { + super.disconnectedCallback(); + window.removeEventListener(EVENT_SIDEBAR_TOGGLE, this.#toggleSidebar); + this.#sidebarMatcher.removeEventListener("change", this.#sidebarListener); } async firstUpdated(): Promise { @@ -118,6 +174,7 @@ export class AdminInterface extends AuthenticatedInterface { this.user.user.isSuperuser || // TODO: somehow add `access_admin_interface` to the API schema this.user.user.systemPermissions.includes("access_admin_interface"); + if (!canAccessAdmin && this.user.user.pk > 0) { window.location.assign("/if/user/"); } @@ -125,10 +182,14 @@ export class AdminInterface extends AuthenticatedInterface { render(): TemplateResult { const sidebarClasses = { + "pf-c-page__sidebar": true, "pf-m-light": this.activeTheme === UiThemeEnum.Light, + "pf-m-expanded": this.sidebarOpen, + "pf-m-collapsed": !this.sidebarOpen, }; const drawerOpen = this.notificationDrawerOpen || this.apiDrawerOpen; + const drawerClasses = { "pf-m-expanded": drawerOpen, "pf-m-collapsed": !drawerOpen, @@ -136,11 +197,18 @@ export class AdminInterface extends AuthenticatedInterface { return html`
- - - + + + + + + + ${renderSidebarItems(AdminSidebarEntries)} + ${this.licenseSummary?.status !== LicenseSummaryStatusEnum.Unlicensed + ? renderSidebarItems(AdminSidebarEnterpriseEntries) + : nothing} + +
diff --git a/web/src/admin/AdminInterface/AdminSidebar.ts b/web/src/admin/AdminInterface/AdminSidebar.ts index 8ca4944259..3bb0449454 100644 --- a/web/src/admin/AdminInterface/AdminSidebar.ts +++ b/web/src/admin/AdminInterface/AdminSidebar.ts @@ -1,186 +1,97 @@ -import { EVENT_SIDEBAR_TOGGLE } from "@goauthentik/common/constants"; -import { me } from "@goauthentik/common/users"; -import { AKElement } from "@goauthentik/elements/Base"; -import { - CapabilitiesEnum, - WithCapabilitiesConfig, -} from "@goauthentik/elements/Interface/capabilitiesProvider"; -import { WithVersion } from "@goauthentik/elements/Interface/versionProvider"; import { ID_REGEX, SLUG_REGEX, UUID_REGEX } from "@goauthentik/elements/router/Route"; -import { getRootStyle } from "@goauthentik/elements/utils/getRootStyle"; import { spread } from "@open-wc/lit-helpers"; import { msg } from "@lit/localize"; import { TemplateResult, html, nothing } from "lit"; -import { customElement, property, state } from "lit/decorators.js"; -import { map } from "lit/directives/map.js"; +import { repeat } from "lit/directives/repeat.js"; -import { UiThemeEnum } from "@goauthentik/api"; -import type { SessionUser, UserSelf } from "@goauthentik/api"; +// The second attribute type is of string[] to help with the 'activeWhen' control, which was +// commonplace and singular enough to merit its own handler. +type SidebarEntry = [ + path: string | null, + label: string, + attributes?: Record | string[] | null, // eslint-disable-line + children?: SidebarEntry[], +]; -@customElement("ak-admin-sidebar") -export class AkAdminSidebar extends WithCapabilitiesConfig(WithVersion(AKElement)) { - @property({ type: Boolean, reflect: true }) - open = true; +/** + * Recursively renders a sidebar entry. + */ +export function renderSidebarItem([ + path, + label, + attributes, + children, +]: SidebarEntry): TemplateResult { + const properties = Array.isArray(attributes) + ? { ".activeWhen": attributes } + : (attributes ?? {}); - @state() - impersonation: UserSelf["username"] | null = null; - - constructor() { - super(); - me().then((user: SessionUser) => { - this.impersonation = user.original ? user.user.username : null; - }); - this.toggleOpen = this.toggleOpen.bind(this); - this.checkWidth = this.checkWidth.bind(this); + if (path) { + properties.path = path; } - // This has to be a bound method so the event listener can be removed on disconnection as - // needed. - toggleOpen() { - this.open = !this.open; - } - - checkWidth() { - // This works just fine, but it assumes that the `--ak-sidebar--minimum-auto-width` is in - // REMs. If that changes, this code will have to be adjusted as well. - const minWidth = - parseFloat(getRootStyle("--ak-sidebar--minimum-auto-width")) * - parseFloat(getRootStyle("font-size")); - this.open = window.innerWidth >= minWidth; - } - - connectedCallback() { - super.connectedCallback(); - window.addEventListener(EVENT_SIDEBAR_TOGGLE, this.toggleOpen); - window.addEventListener("resize", this.checkWidth); - // After connecting to the DOM, we can now perform this check to see if the sidebar should - // be open by default. - this.checkWidth(); - } - - // The symmetry (☟, ☝) here is critical in that you want to start adding these handlers after - // connection, and removing them before disconnection. - - disconnectedCallback() { - window.removeEventListener(EVENT_SIDEBAR_TOGGLE, this.toggleOpen); - window.removeEventListener("resize", this.checkWidth); - super.disconnectedCallback(); - } - - render() { - return html` - - ${this.renderSidebarItems()} - - `; - } - - updated() { - // This is permissible as`:host.classList` is not one of the properties Lit uses as a - // scheduling trigger. This sort of shenanigans can trigger an loop, in that it will trigger - // a browser reflow, which may trigger some other styling the application is monitoring, - // triggering a re-render which triggers a browser reflow, ad infinitum. But we've been - // living with that since jQuery, and it's both well-known and fortunately rare. - - // eslint-disable-next-line wc/no-self-class - this.classList.remove("pf-m-expanded", "pf-m-collapsed"); - // eslint-disable-next-line wc/no-self-class - this.classList.add(this.open ? "pf-m-expanded" : "pf-m-collapsed"); - } - - renderSidebarItems(): TemplateResult { - // The second attribute type is of string[] to help with the 'activeWhen' control, which was - // commonplace and singular enough to merit its own handler. - type SidebarEntry = [ - path: string | null, - label: string, - attributes?: Record | string[] | null, // eslint-disable-line - children?: SidebarEntry[], - ]; - - // prettier-ignore - const sidebarContent: SidebarEntry[] = [ - [null, msg("Dashboards"), { "?expanded": true }, [ - ["/administration/overview", msg("Overview")], - ["/administration/dashboard/users", msg("User Statistics")], - ["/administration/system-tasks", msg("System Tasks")]]], - [null, msg("Applications"), null, [ - ["/core/applications", msg("Applications"), [`^/core/applications/(?${SLUG_REGEX})$`]], - ["/core/providers", msg("Providers"), [`^/core/providers/(?${ID_REGEX})$`]], - ["/outpost/outposts", msg("Outposts")]]], - [null, msg("Events"), null, [ - ["/events/log", msg("Logs"), [`^/events/log/(?${UUID_REGEX})$`]], - ["/events/rules", msg("Notification Rules")], - ["/events/transports", msg("Notification Transports")]]], - [null, msg("Customization"), null, [ - ["/policy/policies", msg("Policies")], - ["/core/property-mappings", msg("Property Mappings")], - ["/blueprints/instances", msg("Blueprints")], - ["/policy/reputation", msg("Reputation scores")]]], - [null, msg("Flows and Stages"), null, [ - ["/flow/flows", msg("Flows"), [`^/flow/flows/(?${SLUG_REGEX})$`]], - ["/flow/stages", msg("Stages")], - ["/flow/stages/prompts", msg("Prompts")]]], - [null, msg("Directory"), null, [ - ["/identity/users", msg("Users"), [`^/identity/users/(?${ID_REGEX})$`]], - ["/identity/groups", msg("Groups"), [`^/identity/groups/(?${UUID_REGEX})$`]], - ["/identity/roles", msg("Roles"), [`^/identity/roles/(?${UUID_REGEX})$`]], - ["/identity/initial-permissions", msg("Initial Permissions"), [`^/identity/initial-permissions/(?${ID_REGEX})$`]], - ["/core/sources", msg("Federation and Social login"), [`^/core/sources/(?${SLUG_REGEX})$`]], - ["/core/tokens", msg("Tokens and App passwords")], - ["/flow/stages/invitations", msg("Invitations")]]], - [null, msg("System"), null, [ - ["/core/brands", msg("Brands")], - ["/crypto/certificates", msg("Certificates")], - ["/outpost/integrations", msg("Outpost Integrations")], - ["/admin/settings", msg("Settings")]]], - ]; - - // Typescript requires the type here to correctly type the recursive path - type SidebarRenderer = (_: SidebarEntry) => TemplateResult; - - const renderOneSidebarItem: SidebarRenderer = ([path, label, attributes, children]) => { - const properties = Array.isArray(attributes) - ? { ".activeWhen": attributes } - : (attributes ?? {}); - if (path) { - properties.path = path; - } - return html` - ${label ? html`${label}` : nothing} - ${map(children, renderOneSidebarItem)} - `; - }; - - // prettier-ignore - return html` - ${map(sidebarContent, renderOneSidebarItem)} - ${this.renderEnterpriseMenu()} - `; - } - - renderEnterpriseMenu() { - return this.can(CapabilitiesEnum.IsEnterprise) - ? html` - - ${msg("Enterprise")} - - ${msg("Licenses")} - - - ` - : nothing; - } + return html` + ${label ? html`${label}` : nothing} + ${children ? renderSidebarItems(children) : nothing} + `; } -declare global { - interface HTMLElementTagNameMap { - "ak-admin-sidebar": AkAdminSidebar; - } +/** + * Recursively renders a collection of sidebar entries. + */ +export function renderSidebarItems(entries: readonly SidebarEntry[]) { + return repeat(entries, ([path, label]) => path || label, renderSidebarItem); } + +// prettier-ignore +export const AdminSidebarEntries: readonly SidebarEntry[] = [ + [null, msg("Dashboards"), { "?expanded": true }, [ + ["/administration/overview", msg("Overview")], + ["/administration/dashboard/users", msg("User Statistics")], + ["/administration/system-tasks", msg("System Tasks")]] + ], + [null, msg("Applications"), null, [ + ["/core/applications", msg("Applications"), [`^/core/applications/(?${SLUG_REGEX})$`]], + ["/core/providers", msg("Providers"), [`^/core/providers/(?${ID_REGEX})$`]], + ["/outpost/outposts", msg("Outposts")]] + ], + [null, msg("Events"), null, [ + ["/events/log", msg("Logs"), [`^/events/log/(?${UUID_REGEX})$`]], + ["/events/rules", msg("Notification Rules")], + ["/events/transports", msg("Notification Transports")]] + ], + [null, msg("Customization"), null, [ + ["/policy/policies", msg("Policies")], + ["/core/property-mappings", msg("Property Mappings")], + ["/blueprints/instances", msg("Blueprints")], + ["/policy/reputation", msg("Reputation scores")]] + ], + [null, msg("Flows and Stages"), null, [ + ["/flow/flows", msg("Flows"), [`^/flow/flows/(?${SLUG_REGEX})$`]], + ["/flow/stages", msg("Stages")], + ["/flow/stages/prompts", msg("Prompts")]] + ], + [null, msg("Directory"), null, [ + ["/identity/users", msg("Users"), [`^/identity/users/(?${ID_REGEX})$`]], + ["/identity/groups", msg("Groups"), [`^/identity/groups/(?${UUID_REGEX})$`]], + ["/identity/roles", msg("Roles"), [`^/identity/roles/(?${UUID_REGEX})$`]], + ["/identity/initial-permissions", msg("Initial Permissions"), [`^/identity/initial-permissions/(?${ID_REGEX})$`]], + ["/core/sources", msg("Federation and Social login"), [`^/core/sources/(?${SLUG_REGEX})$`]], + ["/core/tokens", msg("Tokens and App passwords")], + ["/flow/stages/invitations", msg("Invitations")]] + ], + [null, msg("System"), null, [ + ["/core/brands", msg("Brands")], + ["/crypto/certificates", msg("Certificates")], + ["/outpost/integrations", msg("Outpost Integrations")], + ["/admin/settings", msg("Settings")]] + ], +]; + +// prettier-ignore +export const AdminSidebarEnterpriseEntries: readonly SidebarEntry[] = [ + [null, msg("Enterprise"), null, [ + ["/enterprise/licenses", msg("Licenses"), null] + ], +]] diff --git a/web/src/admin/admin-overview/AdminOverviewPage.ts b/web/src/admin/admin-overview/AdminOverviewPage.ts index d7a9e1737c..8a750f15fc 100644 --- a/web/src/admin/admin-overview/AdminOverviewPage.ts +++ b/web/src/admin/admin-overview/AdminOverviewPage.ts @@ -94,10 +94,13 @@ export class AdminOverviewPage extends AdminOverviewBase { } render(): TemplateResult { - const name = this.user?.user.name ?? this.user?.user.username; + const username = this.user?.user.name || this.user?.user.username; - return html` - ${msg(str`Welcome, ${name || ""}.`)} + return html`
diff --git a/web/src/admin/admin-settings/AdminSettingsPage.ts b/web/src/admin/admin-settings/AdminSettingsPage.ts index 7a2d3d2311..b1d1a34a42 100644 --- a/web/src/admin/admin-settings/AdminSettingsPage.ts +++ b/web/src/admin/admin-settings/AdminSettingsPage.ts @@ -83,13 +83,10 @@ export class AdminSettingsPage extends AKElement { } render() { - if (!this.settings) { - return nothing; - } + if (!this.settings) return nothing; + return html` - - ${msg("System settings")} - +
diff --git a/web/src/admin/brands/BrandForm.ts b/web/src/admin/brands/BrandForm.ts index c610ef975b..c956538996 100644 --- a/web/src/admin/brands/BrandForm.ts +++ b/web/src/admin/brands/BrandForm.ts @@ -1,6 +1,7 @@ import "@goauthentik/admin/common/ak-crypto-certificate-search"; import "@goauthentik/admin/common/ak-flow-search/ak-flow-search"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; +import { DefaultBrand } from "@goauthentik/common/ui/config"; import { first } from "@goauthentik/common/utils"; import "@goauthentik/elements/CodeMirror"; import { CodeMirrorMode } from "@goauthentik/elements/CodeMirror"; @@ -8,7 +9,6 @@ import "@goauthentik/elements/forms/FormGroup"; import "@goauthentik/elements/forms/HorizontalFormElement"; import { ModelForm } from "@goauthentik/elements/forms/ModelForm"; import "@goauthentik/elements/forms/SearchSelect"; -import { DefaultBrand } from "@goauthentik/elements/sidebar/SidebarBrand"; import YAML from "yaml"; import { msg } from "@lit/localize"; diff --git a/web/src/common/purify.ts b/web/src/common/purify.ts index 5da9810c8a..81ada057c3 100644 --- a/web/src/common/purify.ts +++ b/web/src/common/purify.ts @@ -1,26 +1,110 @@ import type { Config as DOMPurifyConfig } from "dompurify"; import DOMPurify from "dompurify"; +import { trustedTypes } from "trusted-types"; -import { render } from "@lit-labs/ssr"; -import { collectResult } from "@lit-labs/ssr/lib/render-result.js"; -import { TemplateResult, html } from "lit"; +import { render } from "lit"; import { unsafeHTML } from "lit/directives/unsafe-html.js"; -import { until } from "lit/directives/until.js"; +/** + * Trusted types policy that escapes HTML content in place. + * + * @see {@linkcode SanitizedTrustPolicy} to strip HTML content. + * + * @returns {TrustedHTML} All HTML content, escaped. + */ +export const EscapeTrustPolicy = trustedTypes.createPolicy("authentik-escape", { + createHTML: (untrustedHTML: string) => { + return DOMPurify.sanitize(untrustedHTML, { + RETURN_TRUSTED_TYPE: false, + }); + }, +}); + +/** + * Trusted types policy, stripping all HTML content. + * + * @returns {TrustedHTML} Text content only, all HTML tags stripped. + */ +export const SanitizedTrustPolicy = trustedTypes.createPolicy("authentik-sanitize", { + createHTML: (untrustedHTML: string) => { + return DOMPurify.sanitize(untrustedHTML, { + RETURN_TRUSTED_TYPE: false, + ALLOWED_TAGS: ["#text"], + }); + }, +}); + +/** + * Trusted types policy, allowing a minimal set of _safe_ HTML tags supplied by + * a trusted source, such as the brand API. + */ +export const BrandedHTMLPolicy = trustedTypes.createPolicy("authentik-restrict", { + createHTML: (untrustedHTML: string) => { + return DOMPurify.sanitize(untrustedHTML, { + RETURN_TRUSTED_TYPE: false, + FORBID_TAGS: [ + "script", + "style", + "iframe", + "link", + "object", + "embed", + "applet", + "meta", + "base", + "form", + "input", + "textarea", + "select", + "button", + ], + FORBID_ATTR: [ + "onerror", + "onclick", + "onload", + "onmouseover", + "onmouseout", + "onmouseup", + "onmousedown", + "onfocus", + "onblur", + "onsubmit", + ], + }); + }, +}); + +export type AuthentikTrustPolicy = + | typeof EscapeTrustPolicy + | typeof SanitizedTrustPolicy + | typeof BrandedHTMLPolicy; + +/** + * Sanitize an untrusted HTML string using a trusted types policy. + */ +export function sanitizeHTML(trustPolicy: AuthentikTrustPolicy, untrustedHTML: string) { + return unsafeHTML(trustPolicy.createHTML(untrustedHTML).toString()); +} + +/** + * DOMPurify configuration for strict sanitization. + * + * This configuration only allows text nodes and disallows all HTML tags. + */ export const DOM_PURIFY_STRICT = { ALLOWED_TAGS: ["#text"], } as const satisfies DOMPurifyConfig; -export async function renderStatic(input: TemplateResult): Promise { - return await collectResult(render(input)); -} +/** + * Render untrusted HTML to a string without escaping it. + * + * @returns {string} The rendered HTML string. + */ +export function renderStaticHTMLUnsafe(untrustedHTML: unknown): string { + const container = document.createElement("html"); + render(untrustedHTML, container); -export function purify(input: TemplateResult): TemplateResult { - return html`${until( - (async () => { - const rendered = await renderStatic(input); - const purified = DOMPurify.sanitize(rendered); - return html`${unsafeHTML(purified)}`; - })(), - )}`; + const result = container.innerHTML; + + return result; } diff --git a/web/src/common/sentry.ts b/web/src/common/sentry.ts index 3699ba5d6b..09c5c1cffd 100644 --- a/web/src/common/sentry.ts +++ b/web/src/common/sentry.ts @@ -1,6 +1,7 @@ import { config } from "@goauthentik/common/api/config"; import { VERSION } from "@goauthentik/common/constants"; import { me } from "@goauthentik/common/users"; +import { readInterfaceRouteParam } from "@goauthentik/elements/router/utils"; import { ErrorEvent, EventHint, @@ -68,7 +69,7 @@ export async function configureSentry(canDoPpi = false): Promise { }); setTag(TAG_SENTRY_CAPABILITIES, cfg.capabilities.join(",")); if (window.location.pathname.includes("if/")) { - setTag(TAG_SENTRY_COMPONENT, `web/${currentInterface()}`); + setTag(TAG_SENTRY_COMPONENT, `web/${readInterfaceRouteParam()}`); } if (cfg.capabilities.includes(CapabilitiesEnum.CanDebug)) { const Spotlight = await import("@spotlightjs/spotlight"); @@ -86,13 +87,3 @@ export async function configureSentry(canDoPpi = false): Promise { } 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(); -} diff --git a/web/src/common/styles/authentik.css b/web/src/common/styles/authentik.css index b8d99d9b9e..747c1e2d27 100644 --- a/web/src/common/styles/authentik.css +++ b/web/src/common/styles/authentik.css @@ -17,6 +17,13 @@ /* Minimum width after which the sidebar becomes automatic */ --ak-sidebar--minimum-auto-width: 80rem; + + /** + * The height of the navbar and branded sidebar. + * @todo This shouldn't be necessary. The sidebar can instead use a grid layout + * ensuring they share the same height. + */ + --ak-navbar--height: 7rem; } @supports selector(::-webkit-scrollbar) { diff --git a/web/src/common/stylesheets.ts b/web/src/common/stylesheets.ts new file mode 100644 index 0000000000..613b9bb418 --- /dev/null +++ b/web/src/common/stylesheets.ts @@ -0,0 +1,223 @@ +/** + * @file Stylesheet utilities. + */ +import { CSSResult, CSSResultOrNative, ReactiveElement, css } from "lit"; + +/** + * Elements containing adoptable stylesheets. + */ +export type StyleSheetParent = Pick; + +/** + * 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( + 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( + 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, + }); +} diff --git a/web/src/common/theme.ts b/web/src/common/theme.ts new file mode 100644 index 0000000000..c0ba4c6412 --- /dev/null +++ b/web/src/common/theme.ts @@ -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; + +//#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; + +/** + * 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 | null { + const element = document.body.querySelector("[data-ak-interface-root]"); + + return element; +} + +//#endregion diff --git a/web/src/common/ui/config.ts b/web/src/common/ui/config.ts index ccf5045302..2b0aca7ead 100644 --- a/web/src/common/ui/config.ts +++ b/web/src/common/ui/config.ts @@ -1,7 +1,19 @@ -import { currentInterface } from "@goauthentik/common/sentry"; import { me } from "@goauthentik/common/users"; +import { isUserRoute } from "@goauthentik/elements/router/utils"; 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 { username = "username", @@ -77,9 +89,7 @@ export class DefaultUIConfig implements UIConfig { }; constructor() { - if (currentInterface() === "user") { - this.enabledFeatures.apiDrawer = false; - } + this.enabledFeatures.apiDrawer = !isUserRoute(); } } diff --git a/web/src/components/ak-nav-buttons.ts b/web/src/components/ak-nav-buttons.ts index e5111b3220..4acb371c3f 100644 --- a/web/src/components/ak-nav-buttons.ts +++ b/web/src/components/ak-nav-buttons.ts @@ -95,7 +95,7 @@ export class NavigationButtons extends AKElement { ); }; - return html`
+ return html`
`; } + renderAvatar() { + return html`${msg(`; + } + get userDisplayName() { return match(this.uiConfig?.navbar.userDisplay) .with(UserDisplay.username, () => this.me?.user.username) @@ -206,17 +212,13 @@ export class NavigationButtons extends AKElement {
${this.renderImpersonation()} ${this.userDisplayName != "" - ? html`
-
+ ? html`
+
${this.userDisplayName}
` : nothing} - ${msg( + ${this.renderAvatar()}
`; } } diff --git a/web/src/elements/Base.ts b/web/src/elements/Base.ts index 658aa75f03..f8b08c4e3b 100644 --- a/web/src/elements/Base.ts +++ b/web/src/elements/Base.ts @@ -1,165 +1,140 @@ -import { EVENT_THEME_CHANGE } from "@goauthentik/common/constants"; import { globalAK } from "@goauthentik/common/global"; -import { UIConfig } from "@goauthentik/common/ui/config"; -import { adaptCSS } from "@goauthentik/common/utils"; -import { ensureCSSStyleSheet } from "@goauthentik/elements/utils/ensureCSSStyleSheet"; +import { + StyleSheetInit, + StyleSheetParent, + appendStyleSheet, + createStyleSheetUnsafe, + removeStyleSheet, + resolveStyleSheetParent, +} from "@goauthentik/common/stylesheets"; +import { + CSSColorSchemeValue, + ResolvedUITheme, + UIThemeListener, + createUIThemeEffect, + formatColorScheme, + resolveUITheme, +} from "@goauthentik/common/theme"; +import { type ThemedElement } from "@goauthentik/common/theme"; import { localized } from "@lit/localize"; -import { LitElement, ReactiveElement } from "lit"; +import { CSSResultGroup, CSSResultOrNative, LitElement } from "lit"; +import { property } from "lit/decorators.js"; import AKGlobal from "@goauthentik/common/styles/authentik.css"; import OneDark from "@goauthentik/common/styles/one-dark.css"; import ThemeDark from "@goauthentik/common/styles/theme-dark.css"; -import { Config, CurrentBrand, UiThemeEnum } from "@goauthentik/api"; +import { UiThemeEnum } from "@goauthentik/api"; -type AkInterface = HTMLElement & { - getTheme: () => Promise; - brand?: CurrentBrand; - uiConfig?: UIConfig; - config?: Config; - get activeTheme(): UiThemeEnum | undefined; -}; - -export const rootInterface = (): 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); +// Re-export the theme helpers +export { rootInterface } from "@goauthentik/common/theme"; @localized() -export class AKElement extends LitElement { - _mediaMatcher?: MediaQueryList; - _mediaMatcherHandler?: (ev?: MediaQueryListEvent) => void; - _activeTheme?: UiThemeEnum; +export class AKElement extends LitElement implements ThemedElement { + //#region Properties - 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() { 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) { - const styleRoot: DocumentOrShadowRoot = ( - "ShadyDOM" in window ? document : root - ) as DocumentOrShadowRoot; - styleRoot.adoptedStyleSheets = adaptCSS([ - ...styleRoot.adoptedStyleSheets, - ensureCSSStyleSheet(AKGlobal), - ensureCSSStyleSheet(OneDark), - ]); - this._initTheme(styleRoot); - this._initCustomCSS(styleRoot); + public disconnectedCallback(): void { + super.disconnectedCallback(); + this.#themeAbortController?.abort(); } - protected createRenderRoot() { - this.fixElementStyles(); - const root = super.createRenderRoot(); - this.setInitialStyles(root as unknown as DocumentOrShadowRoot); - return root; - } + #styleRoot?: StyleSheetParent; - async getTheme(): Promise { - return rootInterface()?.getTheme() || UiThemeEnum.Automatic; - } + #dispatchTheme: UIThemeListener = (nextUITheme) => { + if (!this.#styleRoot) return; - fixElementStyles() { - // Ensure all style sheets being passed are really style sheets. - (this.constructor as typeof ReactiveElement).elementStyles = ( - this.constructor as typeof ReactiveElement - ).elementStyles.map(ensureCSSStyleSheet); - } - - async _initTheme(root: DocumentOrShadowRoot): Promise { - // 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 { - const brand = globalAK().brand; - if (!brand) { - return; + if (nextUITheme === UiThemeEnum.Dark) { + this.#darkThemeStyleSheet ||= createStyleSheetUnsafe(ThemeDark); + appendStyleSheet(this.#styleRoot, this.#darkThemeStyleSheet); + this.activeTheme = UiThemeEnum.Dark; + } else if (this.#darkThemeStyleSheet) { + removeStyleSheet(this.#styleRoot, this.#darkThemeStyleSheet); + this.#darkThemeStyleSheet = null; + this.activeTheme = UiThemeEnum.Light; } - const sheet = await new CSSStyleSheet().replace(brand.brandingCustomCss); - root.adoptedStyleSheets = [...root.adoptedStyleSheets, sheet]; + }; + + protected createRenderRoot(): HTMLElement | DocumentFragment { + const renderRoot = super.createRenderRoot(); + this.#styleRoot = resolveStyleSheetParent(renderRoot); + + if (this.#customCSSStyleSheet) { + console.debug(`authentik/element[${this.tagName.toLowerCase()}]: Adding custom CSS`); + + appendStyleSheet(this.#styleRoot, this.#customCSSStyleSheet); + } + + this.#themeAbortController = new AbortController(); + + if (this.#preferredColorScheme === "dark") { + this.#dispatchTheme(UiThemeEnum.Dark); + } else if (this.#preferredColorScheme === "auto") { + createUIThemeEffect(this.#dispatchTheme, { + signal: this.#themeAbortController.signal, + }); + } + + return renderRoot; } - _applyTheme(root: DocumentOrShadowRoot, theme?: UiThemeEnum): void { - if (!theme) { - theme = UiThemeEnum.Automatic; - } - if (theme === UiThemeEnum.Automatic) { - // Create a media matcher to automatically switch the theme depending on - // prefers-color-scheme - if (!this._mediaMatcher) { - this._mediaMatcher = window.matchMedia(QUERY_MEDIA_COLOR_LIGHT); - this._mediaMatcherHandler = (ev?: MediaQueryListEvent) => { - const theme = - ev?.matches || this._mediaMatcher?.matches - ? UiThemeEnum.Light - : UiThemeEnum.Dark; - this._activateTheme(theme, root); - }; - this._mediaMatcherHandler(undefined); - this._mediaMatcher.addEventListener("change", this._mediaMatcherHandler); - } - return; - } else if (this._mediaMatcher && this._mediaMatcherHandler) { - // Theme isn't automatic and we have a matcher configured, remove the matcher - // to prevent changes - this._mediaMatcher.removeEventListener("change", this._mediaMatcherHandler); - this._mediaMatcher = undefined; - } - this._activateTheme(theme, root); - } - - static themeToStylesheet(theme?: UiThemeEnum): CSSStyleSheet | undefined { - if (theme === UiThemeEnum.Dark) { - return _darkTheme; - } - return undefined; - } - - /** - * Directly activate a given theme, accepts multiple document/ShadowDOMs to apply the stylesheet - * to. The stylesheets are applied to each DOM in order. Does nothing if the given theme is already active. - */ - _activateTheme(theme: UiThemeEnum, ...roots: DocumentOrShadowRoot[]) { - 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(); - } + //#endregion } diff --git a/web/src/elements/Interface/BrandContextController.ts b/web/src/elements/Interface/BrandContextController.ts index ac3106ed58..a896b4d41f 100644 --- a/web/src/elements/Interface/BrandContextController.ts +++ b/web/src/elements/Interface/BrandContextController.ts @@ -1,5 +1,6 @@ import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { EVENT_REFRESH } from "@goauthentik/common/constants"; +import { ThemedElement } from "@goauthentik/common/theme"; import { authentikBrandContext } from "@goauthentik/elements/AuthentikContexts"; import type { ReactiveElementHost } from "@goauthentik/elements/types.js"; @@ -9,14 +10,12 @@ import type { ReactiveController } from "lit"; import type { CurrentBrand } from "@goauthentik/api"; import { CoreApi } from "@goauthentik/api"; -import type { AkInterface } from "./Interface"; - export class BrandContextController implements ReactiveController { - host!: ReactiveElementHost; + host!: ReactiveElementHost; context!: ContextProvider<{ __context__: CurrentBrand | undefined }>; - constructor(host: ReactiveElementHost) { + constructor(host: ReactiveElementHost) { this.host = host; this.context = new ContextProvider(this.host, { context: authentikBrandContext, diff --git a/web/src/elements/Interface/ConfigContextController.ts b/web/src/elements/Interface/ConfigContextController.ts index c626a7a9c9..6caa1a1a20 100644 --- a/web/src/elements/Interface/ConfigContextController.ts +++ b/web/src/elements/Interface/ConfigContextController.ts @@ -1,6 +1,7 @@ import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { EVENT_REFRESH } from "@goauthentik/common/constants"; import { globalAK } from "@goauthentik/common/global"; +import { ThemedElement } from "@goauthentik/common/theme"; import { authentikConfigContext } from "@goauthentik/elements/AuthentikContexts"; import type { ReactiveElementHost } from "@goauthentik/elements/types.js"; @@ -10,14 +11,12 @@ import type { ReactiveController } from "lit"; import type { Config } from "@goauthentik/api"; import { RootApi } from "@goauthentik/api"; -import type { AkInterface } from "./Interface"; - export class ConfigContextController implements ReactiveController { - host!: ReactiveElementHost; + host!: ReactiveElementHost; context!: ContextProvider<{ __context__: Config | undefined }>; - constructor(host: ReactiveElementHost) { + constructor(host: ReactiveElementHost) { this.host = host; this.context = new ContextProvider(this.host, { context: authentikConfigContext, diff --git a/web/src/elements/Interface/Interface.ts b/web/src/elements/Interface/Interface.ts index fed91b5f11..b25e2b5d48 100644 --- a/web/src/elements/Interface/Interface.ts +++ b/web/src/elements/Interface/Interface.ts @@ -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 { ModalOrchestrationController } from "@goauthentik/elements/controllers/ModalOrchestrationController.js"; -import { ensureCSSStyleSheet } from "@goauthentik/elements/utils/ensureCSSStyleSheet"; import { state } from "lit/decorators.js"; import PFBase from "@patternfly/patternfly/patternfly-base.css"; import type { Config, CurrentBrand, LicenseSummary, Version } from "@goauthentik/api"; -import { UiThemeEnum } from "@goauthentik/api"; -import { AKElement, rootInterface } from "../Base"; import { BrandContextController } from "./BrandContextController"; import { ConfigContextController } from "./ConfigContextController"; import { EnterpriseContextController } from "./EnterpriseContextController"; -export type AkInterface = HTMLElement & { - getTheme: () => Promise; - brand?: CurrentBrand; - uiConfig?: UIConfig; - config?: Config; -}; - -const brandContext = Symbol("brandContext"); const configContext = Symbol("configContext"); const modalController = Symbol("modalController"); const versionContext = Symbol("versionContext"); -export class Interface extends AKElement implements AkInterface { - [brandContext]!: BrandContextController; +export abstract class Interface extends AKElement implements ThemedElement { + protected static readonly PFBaseStyleSheet = createStyleSheetUnsafe(PFBase); - [configContext]!: ConfigContextController; + [configContext]: ConfigContextController; - [modalController]!: ModalOrchestrationController; + [modalController]: ModalOrchestrationController; @state() - uiConfig?: UIConfig; + public config?: Config; @state() - config?: Config; - - @state() - brand?: CurrentBrand; + public brand?: CurrentBrand; constructor() { super(); - document.adoptedStyleSheets = [...document.adoptedStyleSheets, ensureCSSStyleSheet(PFBase)]; - this._initContexts(); - this.dataset.akInterfaceRoot = "true"; - } + const styleParent = resolveStyleSheetParent(document); - _initContexts() { - this[brandContext] = new BrandContextController(this); + this.dataset.akInterfaceRoot = this.tagName.toLowerCase(); + + appendStyleSheet(styleParent, Interface.PFBaseStyleSheet); + + this.addController(new BrandContextController(this)); this[configContext] = new ConfigContextController(this); this[modalController] = new ModalOrchestrationController(this); } - - _activateTheme(theme: UiThemeEnum, ...roots: DocumentOrShadowRoot[]): void { - if (theme === this._activeTheme) { - return; - } - console.debug( - `authentik/interface[${rootInterface()?.tagName.toLowerCase()}]: Enabling theme ${theme}`, - ); - // Special case for root interfaces, as they need to modify the global document CSS too - // Instead of calling ._activateTheme() twice, we insert the root document in the call - // since multiple calls to ._activateTheme() would not do anything after the first call - // as the theme is already enabled. - roots.unshift(document as unknown as DocumentOrShadowRoot); - super._activateTheme(theme, ...roots); - } - - async getTheme(): Promise { - if (!this.uiConfig) { - this.uiConfig = await uiConfig(); - } - return this.uiConfig.theme?.base || UiThemeEnum.Automatic; - } } -export type AkAuthenticatedInterface = AkInterface & { +export interface AkAuthenticatedInterface extends ThemedElement { licenseSummary?: LicenseSummary; version?: Version; -}; +} const enterpriseContext = Symbol("enterpriseContext"); -export class AuthenticatedInterface extends Interface { +export class AuthenticatedInterface extends Interface implements AkAuthenticatedInterface { [enterpriseContext]!: EnterpriseContextController; [versionContext]!: VersionContextController; @state() - licenseSummary?: LicenseSummary; + public uiConfig?: UIConfig; @state() - version?: Version; + public licenseSummary?: LicenseSummary; + + @state() + public version?: Version; constructor() { super(); - } - _initContexts(): void { - super._initContexts(); this[enterpriseContext] = new EnterpriseContextController(this); this[versionContext] = new VersionContextController(this); } diff --git a/web/src/elements/PageHeader.ts b/web/src/elements/PageHeader.ts index d0ec119cd5..796e3115e4 100644 --- a/web/src/elements/PageHeader.ts +++ b/web/src/elements/PageHeader.ts @@ -4,21 +4,24 @@ import { TITLE_DEFAULT, } from "@goauthentik/common/constants"; import { globalAK } from "@goauthentik/common/global"; -import { currentInterface } from "@goauthentik/common/sentry"; -import { UIConfig, UserDisplay, uiConfig } from "@goauthentik/common/ui/config"; +import { UIConfig, UserDisplay, getConfigForUser } from "@goauthentik/common/ui/config"; +import { DefaultBrand } from "@goauthentik/common/ui/config"; import { me } from "@goauthentik/common/users"; import "@goauthentik/components/ak-nav-buttons"; import { AKElement } from "@goauthentik/elements/Base"; import { WithBrandConfig } from "@goauthentik/elements/Interface/brandProvider"; +import { isAdminRoute } from "@goauthentik/elements/router/utils"; +import { themeImage } from "@goauthentik/elements/utils/images"; import "@patternfly/elements/pf-tooltip/pf-tooltip.js"; import { msg } from "@lit/localize"; -import { CSSResult, TemplateResult, css, html, nothing } from "lit"; +import { CSSResult, LitElement, TemplateResult, css, html, nothing } from "lit"; import { customElement, property, state } from "lit/decorators.js"; import PFAvatar from "@patternfly/patternfly/components/Avatar/avatar.css"; import PFButton from "@patternfly/patternfly/components/Button/button.css"; import PFContent from "@patternfly/patternfly/components/Content/content.css"; +import PFDrawer from "@patternfly/patternfly/components/Drawer/drawer.css"; import PFDropdown from "@patternfly/patternfly/components/Dropdown/dropdown.css"; import PFNotificationBadge from "@patternfly/patternfly/components/NotificationBadge/notification-badge.css"; import PFPage from "@patternfly/patternfly/components/Page/page.css"; @@ -26,34 +29,52 @@ import PFBase from "@patternfly/patternfly/patternfly-base.css"; import { SessionUser } from "@goauthentik/api"; -@customElement("ak-page-header") -export class PageHeader extends WithBrandConfig(AKElement) { - @property() - icon?: string; +//#region Page Navbar - @property({ type: Boolean }) - iconImage = false; - - @property() - header = ""; - - @property() +export interface PageNavbarDetails { + header?: string; description?: string; + icon?: string; + iconImage?: boolean; +} - @property({ type: Boolean }) - hasIcon = true; +/** + * A global navbar component at the top of the page. + * + * Internally, this component listens for the `ak-page-header` event, which is + * dispatched by the `ak-page-header` component. + */ +@customElement("ak-page-navbar") +export class AKPageNavbar extends WithBrandConfig(AKElement) implements PageNavbarDetails { + //#region Static Properties - @state() - me?: SessionUser; + private static elementRef: AKPageNavbar | null = null; - @state() - uiConfig!: UIConfig; + static readonly setNavbarDetails = (detail: Partial): void => { + const { elementRef } = AKPageNavbar; + if (!elementRef) { + console.debug( + `ak-page-header: Could not find ak-page-navbar, skipping event dispatch.`, + ); + return; + } + + const { header, description, icon, iconImage } = detail; + + elementRef.header = header; + elementRef.description = description; + elementRef.icon = icon; + elementRef.iconImage = iconImage || false; + elementRef.hasIcon = !!icon; + }; static get styles(): CSSResult[] { return [ PFBase, PFButton, PFPage, + PFDrawer, + PFNotificationBadge, PFContent, PFAvatar, @@ -63,143 +84,403 @@ export class PageHeader extends WithBrandConfig(AKElement) { position: sticky; top: 0; z-index: var(--pf-global--ZIndex--lg); + --pf-c-page__header-tools--MarginRight: 0; + --ak-brand-logo-height: var(--pf-global--FontSize--4xl, 2.25rem); + --ak-brand-background-color: var( + --pf-c-page__sidebar--m-light--BackgroundColor + ); + --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-style: solid; border-bottom-color: var(--pf-global--BorderColor--100); + background-color: var(--pf-c-page--BackgroundColor); + display: flex; flex-direction: row; - min-height: 114px; - max-height: 114px; - background-color: var(--pf-c-page--BackgroundColor); + + display: grid; + row-gap: var(--pf-global--spacer--sm); + column-gap: var(--pf-global--spacer--sm); + grid-template-columns: [brand] auto [toggle] auto [primary] 1fr [secondary] auto; + grid-template-rows: auto auto; + grid-template-areas: + "brand toggle primary secondary" + "brand toggle description secondary"; + + @media (min-width: 426px) { + height: var(--host-navbar-height); + } + + @media (max-width: 768px) { + row-gap: var(--pf-global--spacer--xs); + + align-items: center; + grid-template-areas: + "toggle primary secondary" + "toggle description description"; + justify-content: space-between; + width: 100%; + } } - .pf-c-page__main-section.pf-m-light { - background-color: transparent; + + .items { + display: block; + + &.primary { + grid-column: primary; + grid-row: primary / description; + + align-content: center; + padding-block: var(--pf-global--spacer--md); + + @media (min-width: 426px) { + &.block-sibling { + padding-block-end: 0; + grid-row: primary; + } + } + + @media (max-width: 768px) { + padding-block: var(--pf-global--spacer--sm); + } + + .accent-icon { + height: 1em; + width: 1em; + + @media (max-width: 768px) { + display: none; + } + } + } + + &.page-description { + grid-area: description; + 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); + } + } } - .pf-c-page__main-section { - flex-grow: 1; - flex-shrink: 1; + + .brand { + grid-area: brand; + background-color: var(--ak-brand-background-color); + height: 100%; + width: var(--pf-c-page__sidebar--Width); + align-items: center; + padding-inline: var(--pf-global--spacer--sm); + display: flex; - flex-direction: column; justify-content: center; + + &.pf-m-collapsed { + display: none; + } + + @media (max-width: 1199px) { + display: none; + } } - img.pf-icon { - max-height: 24px; + + .sidebar-trigger { + grid-area: toggle; + height: 100%; } + + .logo { + flex: 0 0 auto; + height: var(--ak-brand-logo-height); + + & img { + height: 100%; + } + } + .sidebar-trigger, .notification-trigger { - font-size: 24px; + font-size: 1.5rem; } + .notification-trigger.has-notifications { color: var(--pf-global--active-color--100); } + + .page-title { + display: flex; + gap: var(--pf-global--spacer--xs); + } + h1 { display: flex; flex-direction: row; align-items: center !important; } - .pf-c-page__header-tools { - flex-shrink: 0; - } - .pf-c-page__header-tools-group { - height: 100%; - } - :host([theme="dark"]) .pf-c-page__header-tools { - color: var(--ak-dark-foreground) !important; - } `, ]; } - constructor() { - super(); - window.addEventListener(EVENT_WS_MESSAGE, () => { - this.firstUpdated(); - }); - } + //#endregion - async firstUpdated() { - this.me = await me(); - this.uiConfig = await uiConfig(); - this.uiConfig.navbar.userDisplay = UserDisplay.none; - } + //#region Properties - setTitle(header?: string) { - const currentIf = currentInterface(); + @property({ type: String }) + icon?: string; + + @property({ type: Boolean }) + iconImage = false; + + @property({ type: String }) + header?: string; + + @property({ type: String }) + description?: string; + + @property({ type: Boolean }) + hasIcon = true; + + @property({ type: Boolean }) + open = true; + + @state() + session?: SessionUser; + + @state() + uiConfig!: UIConfig; + + //#endregion + + //#region Private Methods + + #setTitle(header?: string) { let title = this.brand?.brandingTitle || TITLE_DEFAULT; - if (currentIf === "admin") { + + if (isAdminRoute()) { title = `${msg("Admin")} - ${title}`; } // Prepend the header to the title - if (header !== undefined && header !== "") { + if (header) { title = `${header} - ${title}`; } document.title = title; } + #toggleSidebar() { + this.open = !this.open; + + this.dispatchEvent( + new CustomEvent(EVENT_SIDEBAR_TOGGLE, { + bubbles: true, + composed: true, + }), + ); + } + + //#endregion + + //#region Lifecycle + + public connectedCallback(): void { + super.connectedCallback(); + AKPageNavbar.elementRef = this; + + window.addEventListener(EVENT_WS_MESSAGE, () => { + this.firstUpdated(); + }); + } + + public disconnectedCallback(): void { + super.disconnectedCallback(); + AKPageNavbar.elementRef = null; + } + + public async firstUpdated() { + this.session = await me(); + this.uiConfig = getConfigForUser(this.session.user); + this.uiConfig.navbar.userDisplay = UserDisplay.none; + } + willUpdate() { // Always update title, even if there's no header value set, // as in that case we still need to return to the generic title - this.setTitle(this.header); + this.#setTitle(this.header); } + //#endregion + + //#region Render + renderIcon() { if (this.icon) { if (this.iconImage && !this.icon.startsWith("fa://")) { - return html`page icon`; + return html`page icon`; } + const icon = this.icon.replaceAll("fa://", "fa "); - return html``; + + return html``; } return nothing; } render(): TemplateResult { - return html`
- -
-
-

+ return html` + + + +
+

${this.hasIcon - ? html`${this.renderIcon()} ` + ? html`${this.renderIcon()}` : nothing} - ${this.header} + ${this.header}

- ${this.description ? html`

${this.description}

` : html``} -

-
- -
`; +
+ ${this.description + ? html`
+

${this.description}

+
` + : nothing} + +
+ +
+ + `; + } + + //#endregion +} + +//#endregion + +//#region Page Header + +/** + * A page header component, used to display the page title and description. + * + * Internally, this component dispatches the `ak-page-header` event, which is + * listened to by the `ak-page-navbar` component. + * + * @singleton + */ +@customElement("ak-page-header") +export class AKPageHeader extends LitElement implements PageNavbarDetails { + @property({ type: String }) + header?: string; + + @property({ type: String }) + description?: string; + + @property({ type: String }) + icon?: string; + + @property({ type: Boolean }) + iconImage = false; + + static get styles(): CSSResult[] { + return [ + css` + :host { + display: none; + } + `, + ]; + } + + connectedCallback(): void { + super.connectedCallback(); + + AKPageNavbar.setNavbarDetails({ + header: this.header, + description: this.description, + icon: this.icon, + iconImage: this.iconImage, + }); + } + + updated(): void { + AKPageNavbar.setNavbarDetails({ + header: this.header, + description: this.description, + icon: this.icon, + iconImage: this.iconImage, + }); } } +//#endregion + declare global { interface HTMLElementTagNameMap { - "ak-page-header": PageHeader; + "ak-page-header": AKPageHeader; + "ak-page-navbar": AKPageNavbar; } } diff --git a/web/src/elements/router/utils.ts b/web/src/elements/router/utils.ts new file mode 100644 index 0000000000..2632c60a11 --- /dev/null +++ b/web/src/elements/router/utils.ts @@ -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 = 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 = window.location): boolean { + return readInterfaceRouteParam(location) === "admin"; +} + +/** + * Predicate to determine if the current route is for the user interface. + */ +export function isUserRoute(location: Pick = window.location): boolean { + return readInterfaceRouteParam(location) === "user"; +} diff --git a/web/src/elements/sidebar/Sidebar.ts b/web/src/elements/sidebar/Sidebar.ts index 2fcfe3b6b5..95dfdc2111 100644 --- a/web/src/elements/sidebar/Sidebar.ts +++ b/web/src/elements/sidebar/Sidebar.ts @@ -1,5 +1,4 @@ import { AKElement } from "@goauthentik/elements/Base"; -import "@goauthentik/elements/sidebar/SidebarBrand"; import "@goauthentik/elements/sidebar/SidebarVersion"; import { msg } from "@lit/localize"; @@ -22,6 +21,7 @@ export class Sidebar extends AKElement { css` :host { z-index: 100; + --pf-c-page__sidebar--Transition: 0 !important; } .pf-c-nav__link.pf-m-current::after, .pf-c-nav__link.pf-m-current:hover::after, @@ -35,10 +35,7 @@ export class Sidebar extends AKElement { .pf-c-nav__section + .pf-c-nav__section { --pf-c-nav__section--section--MarginTop: var(--pf-global--spacer--sm); } - .pf-c-nav__list .sidebar-brand { - max-height: 82px; - margin-bottom: -0.5rem; - } + nav { display: flex; flex-direction: column; @@ -70,7 +67,6 @@ export class Sidebar extends AKElement { class="pf-c-nav ${this.activeTheme === UiThemeEnum.Light ? "pf-m-light" : ""}" aria-label=${msg("Global")} > -
diff --git a/web/src/elements/sidebar/SidebarBrand.ts b/web/src/elements/sidebar/SidebarBrand.ts deleted file mode 100644 index 1deededded..0000000000 --- a/web/src/elements/sidebar/SidebarBrand.ts +++ /dev/null @@ -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` - - ` - : nothing} - -
- ${msg( -
-
`; - } -} - -declare global { - interface HTMLElementTagNameMap { - "ak-sidebar-brand": SidebarBrand; - } -} diff --git a/web/src/elements/sidebar/SidebarVersion.ts b/web/src/elements/sidebar/SidebarVersion.ts index f5e0fbed05..35b1e2f253 100644 --- a/web/src/elements/sidebar/SidebarVersion.ts +++ b/web/src/elements/sidebar/SidebarVersion.ts @@ -1,9 +1,9 @@ import type { AdminInterface } from "@goauthentik/admin/AdminInterface/AdminInterface"; import { globalAK } from "@goauthentik/common/global"; +import { DefaultBrand } from "@goauthentik/common/ui/config"; import { AKElement, rootInterface } from "@goauthentik/elements/Base"; import { WithLicenseSummary } from "@goauthentik/elements/Interface/licenseSummaryProvider"; import { WithVersion } from "@goauthentik/elements/Interface/versionProvider"; -import { DefaultBrand } from "@goauthentik/elements/sidebar/SidebarBrand"; import { msg, str } from "@lit/localize"; import { CSSResult, css, html, nothing } from "lit"; diff --git a/web/src/elements/tests/utils.ts b/web/src/elements/tests/utils.ts index 9d818bd6ad..8dfd0bb1bc 100644 --- a/web/src/elements/tests/utils.ts +++ b/web/src/elements/tests/utils.ts @@ -1,19 +1,21 @@ +import { + appendStyleSheet, + assertAdoptableStyleSheetParent, + createStyleSheetUnsafe, +} from "@goauthentik/common/stylesheets.js"; + import { TemplateResult, render as litRender } from "lit"; import AKGlobal from "@goauthentik/common/styles/authentik.css"; import PFBase from "@patternfly/patternfly/patternfly-base.css"; -import { ensureCSSStyleSheet } from "../utils/ensureCSSStyleSheet.js"; - // A special version of render that ensures our style sheets will always be available // to all elements under test. Ensures they look right during testing, and that any // CSS-based checks for visibility will return correct values. export const render = (body: TemplateResult) => { - document.adoptedStyleSheets = [ - ...document.adoptedStyleSheets, - ensureCSSStyleSheet(PFBase), - ensureCSSStyleSheet(AKGlobal), - ]; + assertAdoptableStyleSheetParent(document); + + appendStyleSheet(document, ...[PFBase, AKGlobal].map(createStyleSheetUnsafe)); return litRender(body, document.body); }; diff --git a/web/src/elements/types.ts b/web/src/elements/types.ts index c579b534e4..30ed33b2e6 100644 --- a/web/src/elements/types.ts +++ b/web/src/elements/types.ts @@ -1,9 +1,14 @@ -import { AKElement } from "@goauthentik/elements/Base"; - import { type LitElement, type ReactiveControllerHost, type TemplateResult, nothing } from "lit"; import "lit"; -export type ReactiveElementHost = Partial & 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 = Partial & HTMLElement; export type AbstractLitElementConstructor = abstract new (...args: never[]) => LitElement; diff --git a/web/src/elements/utils/ensureCSSStyleSheet.ts b/web/src/elements/utils/ensureCSSStyleSheet.ts deleted file mode 100644 index d809ca69b6..0000000000 --- a/web/src/elements/utils/ensureCSSStyleSheet.ts +++ /dev/null @@ -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; diff --git a/web/src/elements/utils/iframe.ts b/web/src/elements/utils/iframe.ts new file mode 100644 index 0000000000..6c931e42a8 --- /dev/null +++ b/web/src/elements/utils/iframe.ts @@ -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 { + 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` + + + + + + ${bodyContent} + + `; +} diff --git a/web/src/elements/utils/images.ts b/web/src/elements/utils/images.ts index 573308a43d..a82c9589df 100644 --- a/web/src/elements/utils/images.ts +++ b/web/src/elements/utils/images.ts @@ -1,13 +1,8 @@ -import { QUERY_MEDIA_COLOR_LIGHT, rootInterface } from "@goauthentik/elements/Base"; - -import { UiThemeEnum } from "@goauthentik/api"; +import { resolveUITheme } from "@goauthentik/common/theme"; +import { rootInterface } from "@goauthentik/elements/Base"; export function themeImage(rawPath: string) { - let enabledTheme = rootInterface()?.activeTheme; - if (!enabledTheme || enabledTheme === UiThemeEnum.Automatic) { - enabledTheme = window.matchMedia(QUERY_MEDIA_COLOR_LIGHT).matches - ? UiThemeEnum.Light - : UiThemeEnum.Dark; - } + const enabledTheme = rootInterface()?.activeTheme || resolveUITheme(); + return rawPath.replaceAll("%(theme)s", enabledTheme); } diff --git a/web/src/flow/FlowExecutor.ts b/web/src/flow/FlowExecutor.ts index 6eb2e0ed85..29159b8409 100644 --- a/web/src/flow/FlowExecutor.ts +++ b/web/src/flow/FlowExecutor.ts @@ -6,12 +6,12 @@ import { } from "@goauthentik/common/constants"; import { globalAK } from "@goauthentik/common/global"; import { configureSentry } from "@goauthentik/common/sentry"; +import { DefaultBrand } from "@goauthentik/common/ui/config"; import { first } from "@goauthentik/common/utils"; import { WebsocketClient } from "@goauthentik/common/ws"; import { Interface } from "@goauthentik/elements/Interface"; import "@goauthentik/elements/LoadingOverlay"; import "@goauthentik/elements/ak-locale-context"; -import { DefaultBrand } from "@goauthentik/elements/sidebar/SidebarBrand"; import { themeImage } from "@goauthentik/elements/utils/images"; import "@goauthentik/flow/components/ak-brand-footer"; import "@goauthentik/flow/sources/apple/AppleLoginInit"; @@ -46,7 +46,6 @@ import { FlowsApi, ResponseError, ShellChallenge, - UiThemeEnum, } from "@goauthentik/api"; @customElement("ak-flow-executor") @@ -200,10 +199,6 @@ export class FlowExecutor extends Interface implements StageHost { }); } - async getTheme(): Promise { - return globalAK()?.brand.uiTheme || UiThemeEnum.Automatic; - } - async submit( payload?: FlowChallengeResponseRequest, options?: SubmitOptions, diff --git a/web/src/flow/components/ak-brand-footer.ts b/web/src/flow/components/ak-brand-footer.ts index a30cb4ddcc..6d64fb655e 100644 --- a/web/src/flow/components/ak-brand-footer.ts +++ b/web/src/flow/components/ak-brand-footer.ts @@ -1,4 +1,4 @@ -import { purify } from "@goauthentik/common/purify"; +import { BrandedHTMLPolicy, sanitizeHTML } from "@goauthentik/common/purify"; import { AKElement } from "@goauthentik/elements/Base.js"; import { msg } from "@lit/localize"; @@ -21,8 +21,6 @@ const styles = css` } `; -const poweredBy: FooterLink = { name: msg("Powered by authentik"), href: null }; - @customElement("ak-brand-links") export class BrandLinks extends AKElement { static get styles() { @@ -33,13 +31,21 @@ export class BrandLinks extends AKElement { links: FooterLink[] = []; render() { - const links = [...(this.links ?? []), poweredBy]; + const links = [...(this.links ?? [])]; + return html`
    - ${map(links, (link) => - link.href - ? purify(html`
  • ${link.name}
  • `) - : html`
  • ${link.name}
  • `, - )} + ${map(links, (link) => { + const children = sanitizeHTML(BrandedHTMLPolicy, link.name); + + if (link.href) { + return html`
  • ${children}
  • `; + } + + return html`
  • + ${children} +
  • `; + })} +
  • ${msg("Powered by authentik")}
`; } } diff --git a/web/src/flow/stages/captcha/CaptchaStage.ts b/web/src/flow/stages/captcha/CaptchaStage.ts index ec29889a95..df5ba30787 100644 --- a/web/src/flow/stages/captcha/CaptchaStage.ts +++ b/web/src/flow/stages/captcha/CaptchaStage.ts @@ -1,15 +1,16 @@ -/// -import { renderStatic } from "@goauthentik/common/purify"; +/// +/// +import { renderStaticHTMLUnsafe } from "@goauthentik/common/purify"; import "@goauthentik/elements/EmptyState"; import { akEmptyState } from "@goauthentik/elements/EmptyState"; import { bound } from "@goauthentik/elements/decorators/bound"; import "@goauthentik/elements/forms/FormElement"; +import { createIFrameHTMLWrapper } from "@goauthentik/elements/utils/iframe"; import { ListenerController } from "@goauthentik/elements/utils/listenerController.js"; import { randomId } from "@goauthentik/elements/utils/randomId"; import "@goauthentik/flow/FormStatic"; import { BaseStage } from "@goauthentik/flow/stages/base"; import { P, match } from "ts-pattern"; -import type * as _ from "turnstile-types"; import { msg } from "@lit/localize"; import { CSSResult, PropertyValues, TemplateResult, css, html, nothing } from "lit"; @@ -56,40 +57,36 @@ type CaptchaHandler = { // a resize. Because the Captcha is itself in an iframe, the reported height is often off by some // margin, so adding 2rem of height to our container adds padding and prevents scroll bars or hidden // rendering. +function iframeTemplate(children: TemplateResult, challengeURL: string): TemplateResult { + return html` ${children} + - - - - - `; + window.parent.postMessage({ + message: "resize", + source: "goauthentik.io", + context: "flow-executor", + size: { height }, + }); + }).observe(document.querySelector(".ak-captcha-container")); + + + + + `; +} @customElement("ak-stage-captcha") export class CaptchaStage extends BaseStage { @@ -305,11 +302,25 @@ export class CaptchaStage extends BaseStage { - return globalAK()?.brand.uiTheme || UiThemeEnum.Automatic; - } - render(): TemplateResult { return html` diff --git a/web/src/standalone/loading/index.ts b/web/src/standalone/loading/index.ts index 76cdff5a26..2f2c5c8a1e 100644 --- a/web/src/standalone/loading/index.ts +++ b/web/src/standalone/loading/index.ts @@ -1,4 +1,3 @@ -import { globalAK } from "@goauthentik/common/global"; import { Interface } from "@goauthentik/elements/Interface"; import { msg } from "@lit/localize"; @@ -10,8 +9,6 @@ import PFPage from "@patternfly/patternfly/components/Page/page.css"; import PFSpinner from "@patternfly/patternfly/components/Spinner/spinner.css"; import PFBase from "@patternfly/patternfly/patternfly-base.css"; -import { UiThemeEnum } from "@goauthentik/api"; - @customElement("ak-loading") export class Loading extends Interface { static get styles(): CSSResult[] { @@ -28,7 +25,7 @@ export class Loading extends Interface { ]; } - _initContexts(): void { + registerContexts(): void { // Stub function to avoid making API requests for things we don't need. The `Interface` base class loads // a bunch of data that is used globally by various things, however this is an interface that is shown // very briefly and we don't need any of that data. @@ -38,10 +35,6 @@ export class Loading extends Interface { // Stub function to avoid fetching custom CSS. } - async getTheme(): Promise { - return globalAK()?.brand.uiTheme || UiThemeEnum.Automatic; - } - render(): TemplateResult { return html`
{ - return this.storyTheme; - } -} +export class StoryFlowInterface extends FlowExecutor {} declare global { interface HTMLElementTagNameMap { diff --git a/web/src/stories/interface.ts b/web/src/stories/interface.ts index 0cc2a3bfcf..ffa6dc3175 100644 --- a/web/src/stories/interface.ts +++ b/web/src/stories/interface.ts @@ -1,18 +1,9 @@ import { Interface } from "@goauthentik/elements/Interface"; -import { customElement, property } from "lit/decorators.js"; - -import { UiThemeEnum } from "@goauthentik/api"; +import { customElement } from "lit/decorators.js"; @customElement("ak-storybook-interface") -export class StoryInterface extends Interface { - @property() - storyTheme: UiThemeEnum = UiThemeEnum.Dark; - - async getTheme(): Promise { - return this.storyTheme; - } -} +export class StoryInterface extends Interface {} declare global { interface HTMLElementTagNameMap { diff --git a/web/src/user/UserInterface.ts b/web/src/user/UserInterface.ts index c710a0cdcb..976dbf8baa 100644 --- a/web/src/user/UserInterface.ts +++ b/web/src/user/UserInterface.ts @@ -6,7 +6,8 @@ import { } from "@goauthentik/common/constants"; import { globalAK } from "@goauthentik/common/global"; import { configureSentry } from "@goauthentik/common/sentry"; -import { UIConfig } from "@goauthentik/common/ui/config"; +import { UIConfig, getConfigForUser } from "@goauthentik/common/ui/config"; +import { DefaultBrand } from "@goauthentik/common/ui/config"; import { me } from "@goauthentik/common/users"; import { WebsocketClient } from "@goauthentik/common/ws"; import "@goauthentik/components/ak-nav-buttons"; @@ -21,7 +22,6 @@ import "@goauthentik/elements/notifications/NotificationDrawer"; import { getURLParam, updateURLParams } from "@goauthentik/elements/router/RouteMatch"; import "@goauthentik/elements/router/RouterOutlet"; import "@goauthentik/elements/sidebar/Sidebar"; -import { DefaultBrand } from "@goauthentik/elements/sidebar/SidebarBrand"; import "@goauthentik/elements/sidebar/SidebarItem"; import { themeImage } from "@goauthentik/elements/utils/images"; import { ROUTES } from "@goauthentik/user/Routes"; @@ -292,6 +292,7 @@ export class UserInterface extends AuthenticatedInterface { async connectedCallback() { super.connectedCallback(); + window.addEventListener(EVENT_NOTIFICATION_DRAWER_TOGGLE, this.toggleNotificationDrawer); window.addEventListener(EVENT_API_DRAWER_TOGGLE, this.toggleApiDrawer); window.addEventListener(EVENT_WS_MESSAGE, this.fetchConfigurationDetails); @@ -301,6 +302,7 @@ export class UserInterface extends AuthenticatedInterface { window.removeEventListener(EVENT_NOTIFICATION_DRAWER_TOGGLE, this.toggleNotificationDrawer); window.removeEventListener(EVENT_API_DRAWER_TOGGLE, this.toggleApiDrawer); window.removeEventListener(EVENT_WS_MESSAGE, this.fetchConfigurationDetails); + super.disconnectedCallback(); } @@ -319,8 +321,10 @@ export class UserInterface extends AuthenticatedInterface { } fetchConfigurationDetails() { - me().then((me: SessionUser) => { - this.me = me; + me().then((session: SessionUser) => { + this.me = session; + this.uiConfig = getConfigForUser(session.user); + new EventsApi(DEFAULT_CONFIG) .eventsNotificationsList({ seen: false, @@ -334,12 +338,16 @@ export class UserInterface extends AuthenticatedInterface { }); } - get isFullyConfigured() { - return Boolean(this.uiConfig && this.me); - } - render() { - if (!this.isFullyConfigured) { + if (!this.me) { + console.debug(`authentik/user/UserInterface: waiting for user session to be available`); + + return nothing; + } + + if (!this.uiConfig) { + console.debug(`authentik/user/UserInterface: waiting for UI config to be available`); + return nothing; }