Compare commits
4 Commits
main
...
admin-layo
Author | SHA1 | Date | |
---|---|---|---|
7b7ad9b63a | |||
00eff9871f | |||
f4ed74c0c7 | |||
3f81bde962 |
52
web/package-lock.json
generated
52
web/package-lock.json
generated
@ -25,7 +25,6 @@
|
|||||||
"@formatjs/intl-listformat": "^7.5.7",
|
"@formatjs/intl-listformat": "^7.5.7",
|
||||||
"@fortawesome/fontawesome-free": "^6.6.0",
|
"@fortawesome/fontawesome-free": "^6.6.0",
|
||||||
"@goauthentik/api": "^2025.2.4-1745325566",
|
"@goauthentik/api": "^2025.2.4-1745325566",
|
||||||
"@lit-labs/ssr": "^3.2.2",
|
|
||||||
"@lit/context": "^1.1.2",
|
"@lit/context": "^1.1.2",
|
||||||
"@lit/localize": "^0.12.2",
|
"@lit/localize": "^0.12.2",
|
||||||
"@lit/reactive-element": "^2.0.4",
|
"@lit/reactive-element": "^2.0.4",
|
||||||
@ -66,6 +65,7 @@
|
|||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"remark-mdx-frontmatter": "^5.0.0",
|
"remark-mdx-frontmatter": "^5.0.0",
|
||||||
"style-mod": "^4.1.2",
|
"style-mod": "^4.1.2",
|
||||||
|
"trusted-types": "^2.0.0",
|
||||||
"ts-pattern": "^5.4.0",
|
"ts-pattern": "^5.4.0",
|
||||||
"unist-util-visit": "^5.0.0",
|
"unist-util-visit": "^5.0.0",
|
||||||
"webcomponent-qr-code": "^1.2.0",
|
"webcomponent-qr-code": "^1.2.0",
|
||||||
@ -2281,47 +2281,11 @@
|
|||||||
"@lezer/lr": "^1.0.0"
|
"@lezer/lr": "^1.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@lit-labs/ssr": {
|
|
||||||
"version": "3.3.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@lit-labs/ssr/-/ssr-3.3.1.tgz",
|
|
||||||
"integrity": "sha512-JlF1PempxvzrGEpRFrF+Ki0MHzR3HA51SK8Zv0cFpW9p0bPW4k0FeCwrElCu371UEpXF7RcaE2wgYaE1az0XKg==",
|
|
||||||
"dependencies": {
|
|
||||||
"@lit-labs/ssr-client": "^1.1.7",
|
|
||||||
"@lit-labs/ssr-dom-shim": "^1.3.0",
|
|
||||||
"@lit/reactive-element": "^2.0.4",
|
|
||||||
"@parse5/tools": "^0.3.0",
|
|
||||||
"@types/node": "^16.0.0",
|
|
||||||
"enhanced-resolve": "^5.10.0",
|
|
||||||
"lit": "^3.1.2",
|
|
||||||
"lit-element": "^4.0.4",
|
|
||||||
"lit-html": "^3.1.2",
|
|
||||||
"node-fetch": "^3.2.8",
|
|
||||||
"parse5": "^7.1.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=13.9.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@lit-labs/ssr-client": {
|
|
||||||
"version": "1.1.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/@lit-labs/ssr-client/-/ssr-client-1.1.7.tgz",
|
|
||||||
"integrity": "sha512-VvqhY/iif3FHrlhkzEPsuX/7h/NqnfxLwVf0p8ghNIlKegRyRqgeaJevZ57s/u/LiFyKgqksRP5n+LmNvpxN+A==",
|
|
||||||
"dependencies": {
|
|
||||||
"@lit/reactive-element": "^2.0.4",
|
|
||||||
"lit": "^3.1.2",
|
|
||||||
"lit-html": "^3.1.2"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@lit-labs/ssr-dom-shim": {
|
"node_modules/@lit-labs/ssr-dom-shim": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.3.0.tgz",
|
||||||
"integrity": "sha512-nQIWonJ6eFAvUUrSlwyHDm/aE8PBDu5kRpL0vHMg6K8fK3Diq1xdPjTnsJSwxABhaZ+5eBi1btQB5ShUTKo4nQ=="
|
"integrity": "sha512-nQIWonJ6eFAvUUrSlwyHDm/aE8PBDu5kRpL0vHMg6K8fK3Diq1xdPjTnsJSwxABhaZ+5eBi1btQB5ShUTKo4nQ=="
|
||||||
},
|
},
|
||||||
"node_modules/@lit-labs/ssr/node_modules/@types/node": {
|
|
||||||
"version": "16.18.126",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.126.tgz",
|
|
||||||
"integrity": "sha512-OTcgaiwfGFBKacvfwuHzzn1KLxH/er8mluiy8/uM3sGXHaRe73RrSIj01jow9t4kJEW633Ov+cOexXeiApTyAw=="
|
|
||||||
},
|
|
||||||
"node_modules/@lit/context": {
|
"node_modules/@lit/context": {
|
||||||
"version": "1.1.5",
|
"version": "1.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/@lit/context/-/context-1.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/@lit/context/-/context-1.1.5.tgz",
|
||||||
@ -3557,6 +3521,7 @@
|
|||||||
"version": "0.3.0",
|
"version": "0.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/@parse5/tools/-/tools-0.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/@parse5/tools/-/tools-0.3.0.tgz",
|
||||||
"integrity": "sha512-zxRyTHkqb7WQMV8kTNBKWb1BeOFUKXBXTBWuxg9H9hfvQB3IwP6Iw2U75Ia5eyRxPNltmY7E8YAlz6zWwUnjKg==",
|
"integrity": "sha512-zxRyTHkqb7WQMV8kTNBKWb1BeOFUKXBXTBWuxg9H9hfvQB3IwP6Iw2U75Ia5eyRxPNltmY7E8YAlz6zWwUnjKg==",
|
||||||
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"parse5": "^7.0.0"
|
"parse5": "^7.0.0"
|
||||||
}
|
}
|
||||||
@ -10723,6 +10688,7 @@
|
|||||||
"version": "4.0.1",
|
"version": "4.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
|
||||||
"integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==",
|
"integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==",
|
||||||
|
"dev": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 12"
|
"node": ">= 12"
|
||||||
}
|
}
|
||||||
@ -11343,6 +11309,7 @@
|
|||||||
"version": "5.18.1",
|
"version": "5.18.1",
|
||||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.1.tgz",
|
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.1.tgz",
|
||||||
"integrity": "sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==",
|
"integrity": "sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==",
|
||||||
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"graceful-fs": "^4.2.4",
|
"graceful-fs": "^4.2.4",
|
||||||
"tapable": "^2.2.0"
|
"tapable": "^2.2.0"
|
||||||
@ -13820,7 +13787,8 @@
|
|||||||
"node_modules/graceful-fs": {
|
"node_modules/graceful-fs": {
|
||||||
"version": "4.2.11",
|
"version": "4.2.11",
|
||||||
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||||
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="
|
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
||||||
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/grapheme-splitter": {
|
"node_modules/grapheme-splitter": {
|
||||||
"version": "1.0.4",
|
"version": "1.0.4",
|
||||||
@ -18256,6 +18224,7 @@
|
|||||||
"version": "3.3.2",
|
"version": "3.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz",
|
||||||
"integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==",
|
"integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==",
|
||||||
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"data-uri-to-buffer": "^4.0.0",
|
"data-uri-to-buffer": "^4.0.0",
|
||||||
"fetch-blob": "^3.1.4",
|
"fetch-blob": "^3.1.4",
|
||||||
@ -22373,6 +22342,7 @@
|
|||||||
"version": "2.2.1",
|
"version": "2.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz",
|
||||||
"integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==",
|
"integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==",
|
||||||
|
"dev": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
@ -22724,6 +22694,12 @@
|
|||||||
"url": "https://github.com/sponsors/wooorm"
|
"url": "https://github.com/sponsors/wooorm"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/trusted-types": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/trusted-types/-/trusted-types-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-Eam+AUp6lg04YjmYkuLNhEJX+6ByocrKTpY/TtfRK/gV6OmxeN0OwkIasor28SUJ606snArpPLGtPMGbqdaaUA==",
|
||||||
|
"license": "W3C-20150513"
|
||||||
|
},
|
||||||
"node_modules/ts-api-utils": {
|
"node_modules/ts-api-utils": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz",
|
||||||
|
@ -13,7 +13,6 @@
|
|||||||
"@formatjs/intl-listformat": "^7.5.7",
|
"@formatjs/intl-listformat": "^7.5.7",
|
||||||
"@fortawesome/fontawesome-free": "^6.6.0",
|
"@fortawesome/fontawesome-free": "^6.6.0",
|
||||||
"@goauthentik/api": "^2025.2.4-1745325566",
|
"@goauthentik/api": "^2025.2.4-1745325566",
|
||||||
"@lit-labs/ssr": "^3.2.2",
|
|
||||||
"@lit/context": "^1.1.2",
|
"@lit/context": "^1.1.2",
|
||||||
"@lit/localize": "^0.12.2",
|
"@lit/localize": "^0.12.2",
|
||||||
"@lit/reactive-element": "^2.0.4",
|
"@lit/reactive-element": "^2.0.4",
|
||||||
@ -54,6 +53,7 @@
|
|||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"remark-mdx-frontmatter": "^5.0.0",
|
"remark-mdx-frontmatter": "^5.0.0",
|
||||||
"style-mod": "^4.1.2",
|
"style-mod": "^4.1.2",
|
||||||
|
"trusted-types": "^2.0.0",
|
||||||
"ts-pattern": "^5.4.0",
|
"ts-pattern": "^5.4.0",
|
||||||
"unist-util-visit": "^5.0.0",
|
"unist-util-visit": "^5.0.0",
|
||||||
"webcomponent-qr-code": "^1.2.0",
|
"webcomponent-qr-code": "^1.2.0",
|
||||||
|
@ -4,13 +4,17 @@ import { ROUTES } from "@goauthentik/admin/Routes";
|
|||||||
import {
|
import {
|
||||||
EVENT_API_DRAWER_TOGGLE,
|
EVENT_API_DRAWER_TOGGLE,
|
||||||
EVENT_NOTIFICATION_DRAWER_TOGGLE,
|
EVENT_NOTIFICATION_DRAWER_TOGGLE,
|
||||||
|
EVENT_SIDEBAR_TOGGLE,
|
||||||
} from "@goauthentik/common/constants";
|
} from "@goauthentik/common/constants";
|
||||||
import { configureSentry } from "@goauthentik/common/sentry";
|
import { configureSentry } from "@goauthentik/common/sentry";
|
||||||
import { me } from "@goauthentik/common/users";
|
import { me } from "@goauthentik/common/users";
|
||||||
import { WebsocketClient } from "@goauthentik/common/ws";
|
import { WebsocketClient } from "@goauthentik/common/ws";
|
||||||
import { AuthenticatedInterface } from "@goauthentik/elements/Interface";
|
import { AuthenticatedInterface } from "@goauthentik/elements/Interface";
|
||||||
|
import { WithLicenseSummary } from "@goauthentik/elements/Interface/licenseSummaryProvider.js";
|
||||||
import "@goauthentik/elements/ak-locale-context";
|
import "@goauthentik/elements/ak-locale-context";
|
||||||
import "@goauthentik/elements/banner/EnterpriseStatusBanner";
|
import "@goauthentik/elements/banner/EnterpriseStatusBanner";
|
||||||
|
import "@goauthentik/elements/banner/EnterpriseStatusBanner";
|
||||||
|
import "@goauthentik/elements/banner/VersionBanner";
|
||||||
import "@goauthentik/elements/banner/VersionBanner";
|
import "@goauthentik/elements/banner/VersionBanner";
|
||||||
import "@goauthentik/elements/messages/MessageContainer";
|
import "@goauthentik/elements/messages/MessageContainer";
|
||||||
import "@goauthentik/elements/messages/MessageContainer";
|
import "@goauthentik/elements/messages/MessageContainer";
|
||||||
@ -21,25 +25,32 @@ import "@goauthentik/elements/router/RouterOutlet";
|
|||||||
import "@goauthentik/elements/sidebar/Sidebar";
|
import "@goauthentik/elements/sidebar/Sidebar";
|
||||||
import "@goauthentik/elements/sidebar/SidebarItem";
|
import "@goauthentik/elements/sidebar/SidebarItem";
|
||||||
|
|
||||||
import { CSSResult, TemplateResult, css, html } from "lit";
|
import { CSSResult, TemplateResult, css, html, nothing } from "lit";
|
||||||
import { customElement, property, query, state } from "lit/decorators.js";
|
import { customElement, property, query, state } from "lit/decorators.js";
|
||||||
import { classMap } from "lit/directives/class-map.js";
|
import { classMap } from "lit/directives/class-map.js";
|
||||||
|
|
||||||
import PFButton from "@patternfly/patternfly/components/Button/button.css";
|
import PFButton from "@patternfly/patternfly/components/Button/button.css";
|
||||||
import PFDrawer from "@patternfly/patternfly/components/Drawer/drawer.css";
|
import PFDrawer from "@patternfly/patternfly/components/Drawer/drawer.css";
|
||||||
|
import PFNav from "@patternfly/patternfly/components/Nav/nav.css";
|
||||||
import PFPage from "@patternfly/patternfly/components/Page/page.css";
|
import PFPage from "@patternfly/patternfly/components/Page/page.css";
|
||||||
import PFBase from "@patternfly/patternfly/patternfly-base.css";
|
import PFBase from "@patternfly/patternfly/patternfly-base.css";
|
||||||
|
|
||||||
import { SessionUser, UiThemeEnum } from "@goauthentik/api";
|
import { LicenseSummaryStatusEnum, SessionUser, UiThemeEnum } from "@goauthentik/api";
|
||||||
|
|
||||||
import "./AdminSidebar";
|
import {
|
||||||
|
AdminSidebarEnterpriseEntries,
|
||||||
|
AdminSidebarEntries,
|
||||||
|
renderSidebarItems,
|
||||||
|
} from "./AdminSidebar.js";
|
||||||
|
|
||||||
if (process.env.NODE_ENV === "development") {
|
if (process.env.NODE_ENV === "development") {
|
||||||
await import("@goauthentik/esbuild-plugin-live-reload/client");
|
await import("@goauthentik/esbuild-plugin-live-reload/client");
|
||||||
}
|
}
|
||||||
|
|
||||||
@customElement("ak-interface-admin")
|
@customElement("ak-interface-admin")
|
||||||
export class AdminInterface extends AuthenticatedInterface {
|
export class AdminInterface extends WithLicenseSummary(AuthenticatedInterface) {
|
||||||
|
//#region Properties
|
||||||
|
|
||||||
@property({ type: Boolean })
|
@property({ type: Boolean })
|
||||||
notificationDrawerOpen = getURLParam("notificationDrawerOpen", false);
|
notificationDrawerOpen = getURLParam("notificationDrawerOpen", false);
|
||||||
|
|
||||||
@ -54,12 +65,29 @@ export class AdminInterface extends AuthenticatedInterface {
|
|||||||
@query("ak-about-modal")
|
@query("ak-about-modal")
|
||||||
aboutModal?: AboutModal;
|
aboutModal?: AboutModal;
|
||||||
|
|
||||||
|
@property({ type: Boolean, reflect: true })
|
||||||
|
public sidebarOpen = true;
|
||||||
|
|
||||||
|
#toggleSidebar = () => {
|
||||||
|
this.sidebarOpen = !this.sidebarOpen;
|
||||||
|
};
|
||||||
|
|
||||||
|
sidebarMatcher = window.matchMedia("(min-width: 1200px)");
|
||||||
|
#sidebarListener = (event: MediaQueryListEvent) => {
|
||||||
|
this.sidebarOpen = event.matches;
|
||||||
|
};
|
||||||
|
|
||||||
|
//#endregion
|
||||||
|
|
||||||
|
//#region Styles
|
||||||
|
|
||||||
static get styles(): CSSResult[] {
|
static get styles(): CSSResult[] {
|
||||||
return [
|
return [
|
||||||
PFBase,
|
PFBase,
|
||||||
PFPage,
|
PFPage,
|
||||||
PFButton,
|
PFButton,
|
||||||
PFDrawer,
|
PFDrawer,
|
||||||
|
PFNav,
|
||||||
css`
|
css`
|
||||||
.pf-c-page__main,
|
.pf-c-page__main,
|
||||||
.pf-c-drawer__content,
|
.pf-c-drawer__content,
|
||||||
@ -67,23 +95,30 @@ export class AdminInterface extends AuthenticatedInterface {
|
|||||||
z-index: auto !important;
|
z-index: auto !important;
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.display-none {
|
.display-none {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pf-c-page {
|
.pf-c-page {
|
||||||
background-color: var(--pf-c-page--BackgroundColor) !important;
|
background-color: var(--pf-c-page--BackgroundColor) !important;
|
||||||
}
|
}
|
||||||
/* Global page background colour */
|
|
||||||
:host([theme="dark"]) .pf-c-page {
|
:host([theme="dark"]) {
|
||||||
--pf-c-page--BackgroundColor: var(--ak-dark-background);
|
/* 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;
|
grid-area: header;
|
||||||
}
|
}
|
||||||
ak-admin-sidebar {
|
|
||||||
|
.ak-sidebar {
|
||||||
grid-area: nav;
|
grid-area: nav;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pf-c-drawer__panel {
|
.pf-c-drawer__panel {
|
||||||
z-index: var(--pf-global--ZIndex--xl);
|
z-index: var(--pf-global--ZIndex--xl);
|
||||||
}
|
}
|
||||||
@ -91,9 +126,19 @@ export class AdminInterface extends AuthenticatedInterface {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//#endregion
|
||||||
|
|
||||||
|
//#region Lifecycle
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
this.ws = new WebsocketClient();
|
this.ws = new WebsocketClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
public connectedCallback() {
|
||||||
|
super.connectedCallback();
|
||||||
|
|
||||||
|
window.addEventListener(EVENT_SIDEBAR_TOGGLE, this.#toggleSidebar);
|
||||||
|
|
||||||
window.addEventListener(EVENT_NOTIFICATION_DRAWER_TOGGLE, () => {
|
window.addEventListener(EVENT_NOTIFICATION_DRAWER_TOGGLE, () => {
|
||||||
this.notificationDrawerOpen = !this.notificationDrawerOpen;
|
this.notificationDrawerOpen = !this.notificationDrawerOpen;
|
||||||
@ -108,6 +153,14 @@ export class AdminInterface extends AuthenticatedInterface {
|
|||||||
apiDrawerOpen: this.apiDrawerOpen,
|
apiDrawerOpen: this.apiDrawerOpen,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.sidebarMatcher.addEventListener("change", this.#sidebarListener);
|
||||||
|
}
|
||||||
|
|
||||||
|
public disconnectedCallback(): void {
|
||||||
|
super.disconnectedCallback();
|
||||||
|
window.removeEventListener(EVENT_SIDEBAR_TOGGLE, this.#toggleSidebar);
|
||||||
|
this.sidebarMatcher.removeEventListener("change", this.#sidebarListener);
|
||||||
}
|
}
|
||||||
|
|
||||||
async firstUpdated(): Promise<void> {
|
async firstUpdated(): Promise<void> {
|
||||||
@ -118,6 +171,7 @@ export class AdminInterface extends AuthenticatedInterface {
|
|||||||
this.user.user.isSuperuser ||
|
this.user.user.isSuperuser ||
|
||||||
// TODO: somehow add `access_admin_interface` to the API schema
|
// TODO: somehow add `access_admin_interface` to the API schema
|
||||||
this.user.user.systemPermissions.includes("access_admin_interface");
|
this.user.user.systemPermissions.includes("access_admin_interface");
|
||||||
|
|
||||||
if (!canAccessAdmin && this.user.user.pk > 0) {
|
if (!canAccessAdmin && this.user.user.pk > 0) {
|
||||||
window.location.assign("/if/user/");
|
window.location.assign("/if/user/");
|
||||||
}
|
}
|
||||||
@ -125,10 +179,14 @@ export class AdminInterface extends AuthenticatedInterface {
|
|||||||
|
|
||||||
render(): TemplateResult {
|
render(): TemplateResult {
|
||||||
const sidebarClasses = {
|
const sidebarClasses = {
|
||||||
|
"pf-c-page__sidebar": true,
|
||||||
"pf-m-light": this.activeTheme === UiThemeEnum.Light,
|
"pf-m-light": this.activeTheme === UiThemeEnum.Light,
|
||||||
|
"pf-m-expanded": this.sidebarOpen,
|
||||||
|
"pf-m-collapsed": !this.sidebarOpen,
|
||||||
};
|
};
|
||||||
|
|
||||||
const drawerOpen = this.notificationDrawerOpen || this.apiDrawerOpen;
|
const drawerOpen = this.notificationDrawerOpen || this.apiDrawerOpen;
|
||||||
|
|
||||||
const drawerClasses = {
|
const drawerClasses = {
|
||||||
"pf-m-expanded": drawerOpen,
|
"pf-m-expanded": drawerOpen,
|
||||||
"pf-m-collapsed": !drawerOpen,
|
"pf-m-collapsed": !drawerOpen,
|
||||||
@ -136,11 +194,18 @@ export class AdminInterface extends AuthenticatedInterface {
|
|||||||
|
|
||||||
return html` <ak-locale-context>
|
return html` <ak-locale-context>
|
||||||
<div class="pf-c-page">
|
<div class="pf-c-page">
|
||||||
<ak-enterprise-status interface="admin"></ak-enterprise-status>
|
<ak-page-navbar>
|
||||||
<ak-version-banner></ak-version-banner>
|
<ak-version-banner></ak-version-banner>
|
||||||
<ak-admin-sidebar
|
<ak-enterprise-status interface="admin"></ak-enterprise-status>
|
||||||
class="pf-c-page__sidebar ${classMap(sidebarClasses)}"
|
</ak-page-navbar>
|
||||||
></ak-admin-sidebar>
|
|
||||||
|
<ak-sidebar class="${classMap(sidebarClasses)}">
|
||||||
|
${renderSidebarItems(AdminSidebarEntries)}
|
||||||
|
${this.licenseSummary?.status !== LicenseSummaryStatusEnum.Unlicensed
|
||||||
|
? renderSidebarItems(AdminSidebarEnterpriseEntries)
|
||||||
|
: nothing}
|
||||||
|
</ak-sidebar>
|
||||||
|
|
||||||
<div class="pf-c-page__drawer">
|
<div class="pf-c-page__drawer">
|
||||||
<div class="pf-c-drawer ${classMap(drawerClasses)}">
|
<div class="pf-c-drawer ${classMap(drawerClasses)}">
|
||||||
<div class="pf-c-drawer__main">
|
<div class="pf-c-drawer__main">
|
||||||
|
@ -1,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 { ID_REGEX, SLUG_REGEX, UUID_REGEX } from "@goauthentik/elements/router/Route";
|
||||||
import { getRootStyle } from "@goauthentik/elements/utils/getRootStyle";
|
|
||||||
import { spread } from "@open-wc/lit-helpers";
|
import { spread } from "@open-wc/lit-helpers";
|
||||||
|
|
||||||
import { msg } from "@lit/localize";
|
import { msg } from "@lit/localize";
|
||||||
import { TemplateResult, html, nothing } from "lit";
|
import { TemplateResult, html, nothing } from "lit";
|
||||||
import { customElement, property, state } from "lit/decorators.js";
|
import { repeat } from "lit/directives/repeat.js";
|
||||||
import { map } from "lit/directives/map.js";
|
|
||||||
|
|
||||||
import { UiThemeEnum } from "@goauthentik/api";
|
// The second attribute type is of string[] to help with the 'activeWhen' control, which was
|
||||||
import type { SessionUser, UserSelf } from "@goauthentik/api";
|
// commonplace and singular enough to merit its own handler.
|
||||||
|
type SidebarEntry = [
|
||||||
|
path: string | null,
|
||||||
|
label: string,
|
||||||
|
attributes?: Record<string, any> | string[] | null, // eslint-disable-line
|
||||||
|
children?: SidebarEntry[],
|
||||||
|
];
|
||||||
|
|
||||||
@customElement("ak-admin-sidebar")
|
/**
|
||||||
export class AkAdminSidebar extends WithCapabilitiesConfig(WithVersion(AKElement)) {
|
* Recursively renders a sidebar entry.
|
||||||
@property({ type: Boolean, reflect: true })
|
*/
|
||||||
open = true;
|
export function renderSidebarItem([
|
||||||
|
path,
|
||||||
|
label,
|
||||||
|
attributes,
|
||||||
|
children,
|
||||||
|
]: SidebarEntry): TemplateResult {
|
||||||
|
const properties = Array.isArray(attributes)
|
||||||
|
? { ".activeWhen": attributes }
|
||||||
|
: (attributes ?? {});
|
||||||
|
|
||||||
@state()
|
if (path) {
|
||||||
impersonation: UserSelf["username"] | null = null;
|
properties.path = path;
|
||||||
|
|
||||||
constructor() {
|
|
||||||
super();
|
|
||||||
me().then((user: SessionUser) => {
|
|
||||||
this.impersonation = user.original ? user.user.username : null;
|
|
||||||
});
|
|
||||||
this.toggleOpen = this.toggleOpen.bind(this);
|
|
||||||
this.checkWidth = this.checkWidth.bind(this);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// This has to be a bound method so the event listener can be removed on disconnection as
|
return html`<ak-sidebar-item ${spread(properties)}>
|
||||||
// needed.
|
${label ? html`<span slot="label">${label}</span>` : nothing}
|
||||||
toggleOpen() {
|
${children ? renderSidebarItems(children) : nothing}
|
||||||
this.open = !this.open;
|
</ak-sidebar-item>`;
|
||||||
}
|
|
||||||
|
|
||||||
checkWidth() {
|
|
||||||
// This works just fine, but it assumes that the `--ak-sidebar--minimum-auto-width` is in
|
|
||||||
// REMs. If that changes, this code will have to be adjusted as well.
|
|
||||||
const minWidth =
|
|
||||||
parseFloat(getRootStyle("--ak-sidebar--minimum-auto-width")) *
|
|
||||||
parseFloat(getRootStyle("font-size"));
|
|
||||||
this.open = window.innerWidth >= minWidth;
|
|
||||||
}
|
|
||||||
|
|
||||||
connectedCallback() {
|
|
||||||
super.connectedCallback();
|
|
||||||
window.addEventListener(EVENT_SIDEBAR_TOGGLE, this.toggleOpen);
|
|
||||||
window.addEventListener("resize", this.checkWidth);
|
|
||||||
// After connecting to the DOM, we can now perform this check to see if the sidebar should
|
|
||||||
// be open by default.
|
|
||||||
this.checkWidth();
|
|
||||||
}
|
|
||||||
|
|
||||||
// The symmetry (☟, ☝) here is critical in that you want to start adding these handlers after
|
|
||||||
// connection, and removing them before disconnection.
|
|
||||||
|
|
||||||
disconnectedCallback() {
|
|
||||||
window.removeEventListener(EVENT_SIDEBAR_TOGGLE, this.toggleOpen);
|
|
||||||
window.removeEventListener("resize", this.checkWidth);
|
|
||||||
super.disconnectedCallback();
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
return html`
|
|
||||||
<ak-sidebar
|
|
||||||
class="pf-c-page__sidebar ${this.open ? "pf-m-expanded" : "pf-m-collapsed"} ${this
|
|
||||||
.activeTheme === UiThemeEnum.Light
|
|
||||||
? "pf-m-light"
|
|
||||||
: ""}"
|
|
||||||
>
|
|
||||||
${this.renderSidebarItems()}
|
|
||||||
</ak-sidebar>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
updated() {
|
|
||||||
// This is permissible as`:host.classList` is not one of the properties Lit uses as a
|
|
||||||
// scheduling trigger. This sort of shenanigans can trigger an loop, in that it will trigger
|
|
||||||
// a browser reflow, which may trigger some other styling the application is monitoring,
|
|
||||||
// triggering a re-render which triggers a browser reflow, ad infinitum. But we've been
|
|
||||||
// living with that since jQuery, and it's both well-known and fortunately rare.
|
|
||||||
|
|
||||||
// eslint-disable-next-line wc/no-self-class
|
|
||||||
this.classList.remove("pf-m-expanded", "pf-m-collapsed");
|
|
||||||
// eslint-disable-next-line wc/no-self-class
|
|
||||||
this.classList.add(this.open ? "pf-m-expanded" : "pf-m-collapsed");
|
|
||||||
}
|
|
||||||
|
|
||||||
renderSidebarItems(): TemplateResult {
|
|
||||||
// The second attribute type is of string[] to help with the 'activeWhen' control, which was
|
|
||||||
// commonplace and singular enough to merit its own handler.
|
|
||||||
type SidebarEntry = [
|
|
||||||
path: string | null,
|
|
||||||
label: string,
|
|
||||||
attributes?: Record<string, any> | string[] | null, // eslint-disable-line
|
|
||||||
children?: SidebarEntry[],
|
|
||||||
];
|
|
||||||
|
|
||||||
// prettier-ignore
|
|
||||||
const sidebarContent: SidebarEntry[] = [
|
|
||||||
[null, msg("Dashboards"), { "?expanded": true }, [
|
|
||||||
["/administration/overview", msg("Overview")],
|
|
||||||
["/administration/dashboard/users", msg("User Statistics")],
|
|
||||||
["/administration/system-tasks", msg("System Tasks")]]],
|
|
||||||
[null, msg("Applications"), null, [
|
|
||||||
["/core/applications", msg("Applications"), [`^/core/applications/(?<slug>${SLUG_REGEX})$`]],
|
|
||||||
["/core/providers", msg("Providers"), [`^/core/providers/(?<id>${ID_REGEX})$`]],
|
|
||||||
["/outpost/outposts", msg("Outposts")]]],
|
|
||||||
[null, msg("Events"), null, [
|
|
||||||
["/events/log", msg("Logs"), [`^/events/log/(?<id>${UUID_REGEX})$`]],
|
|
||||||
["/events/rules", msg("Notification Rules")],
|
|
||||||
["/events/transports", msg("Notification Transports")]]],
|
|
||||||
[null, msg("Customization"), null, [
|
|
||||||
["/policy/policies", msg("Policies")],
|
|
||||||
["/core/property-mappings", msg("Property Mappings")],
|
|
||||||
["/blueprints/instances", msg("Blueprints")],
|
|
||||||
["/policy/reputation", msg("Reputation scores")]]],
|
|
||||||
[null, msg("Flows and Stages"), null, [
|
|
||||||
["/flow/flows", msg("Flows"), [`^/flow/flows/(?<slug>${SLUG_REGEX})$`]],
|
|
||||||
["/flow/stages", msg("Stages")],
|
|
||||||
["/flow/stages/prompts", msg("Prompts")]]],
|
|
||||||
[null, msg("Directory"), null, [
|
|
||||||
["/identity/users", msg("Users"), [`^/identity/users/(?<id>${ID_REGEX})$`]],
|
|
||||||
["/identity/groups", msg("Groups"), [`^/identity/groups/(?<id>${UUID_REGEX})$`]],
|
|
||||||
["/identity/roles", msg("Roles"), [`^/identity/roles/(?<id>${UUID_REGEX})$`]],
|
|
||||||
["/identity/initial-permissions", msg("Initial Permissions"), [`^/identity/initial-permissions/(?<id>${ID_REGEX})$`]],
|
|
||||||
["/core/sources", msg("Federation and Social login"), [`^/core/sources/(?<slug>${SLUG_REGEX})$`]],
|
|
||||||
["/core/tokens", msg("Tokens and App passwords")],
|
|
||||||
["/flow/stages/invitations", msg("Invitations")]]],
|
|
||||||
[null, msg("System"), null, [
|
|
||||||
["/core/brands", msg("Brands")],
|
|
||||||
["/crypto/certificates", msg("Certificates")],
|
|
||||||
["/outpost/integrations", msg("Outpost Integrations")],
|
|
||||||
["/admin/settings", msg("Settings")]]],
|
|
||||||
];
|
|
||||||
|
|
||||||
// Typescript requires the type here to correctly type the recursive path
|
|
||||||
type SidebarRenderer = (_: SidebarEntry) => TemplateResult;
|
|
||||||
|
|
||||||
const renderOneSidebarItem: SidebarRenderer = ([path, label, attributes, children]) => {
|
|
||||||
const properties = Array.isArray(attributes)
|
|
||||||
? { ".activeWhen": attributes }
|
|
||||||
: (attributes ?? {});
|
|
||||||
if (path) {
|
|
||||||
properties.path = path;
|
|
||||||
}
|
|
||||||
return html`<ak-sidebar-item ${spread(properties)}>
|
|
||||||
${label ? html`<span slot="label">${label}</span>` : nothing}
|
|
||||||
${map(children, renderOneSidebarItem)}
|
|
||||||
</ak-sidebar-item>`;
|
|
||||||
};
|
|
||||||
|
|
||||||
// prettier-ignore
|
|
||||||
return html`
|
|
||||||
${map(sidebarContent, renderOneSidebarItem)}
|
|
||||||
${this.renderEnterpriseMenu()}
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
renderEnterpriseMenu() {
|
|
||||||
return this.can(CapabilitiesEnum.IsEnterprise)
|
|
||||||
? html`
|
|
||||||
<ak-sidebar-item>
|
|
||||||
<span slot="label">${msg("Enterprise")}</span>
|
|
||||||
<ak-sidebar-item path="/enterprise/licenses">
|
|
||||||
<span slot="label">${msg("Licenses")}</span>
|
|
||||||
</ak-sidebar-item>
|
|
||||||
</ak-sidebar-item>
|
|
||||||
`
|
|
||||||
: nothing;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
/**
|
||||||
interface HTMLElementTagNameMap {
|
* Recursively renders a collection of sidebar entries.
|
||||||
"ak-admin-sidebar": AkAdminSidebar;
|
*/
|
||||||
}
|
export function renderSidebarItems(entries: readonly SidebarEntry[]) {
|
||||||
|
return repeat(entries, ([path, label]) => path || label, renderSidebarItem);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// prettier-ignore
|
||||||
|
export const AdminSidebarEntries: readonly SidebarEntry[] = [
|
||||||
|
[null, msg("Dashboards"), { "?expanded": true }, [
|
||||||
|
["/administration/overview", msg("Overview")],
|
||||||
|
["/administration/dashboard/users", msg("User Statistics")],
|
||||||
|
["/administration/system-tasks", msg("System Tasks")]]
|
||||||
|
],
|
||||||
|
[null, msg("Applications"), null, [
|
||||||
|
["/core/applications", msg("Applications"), [`^/core/applications/(?<slug>${SLUG_REGEX})$`]],
|
||||||
|
["/core/providers", msg("Providers"), [`^/core/providers/(?<id>${ID_REGEX})$`]],
|
||||||
|
["/outpost/outposts", msg("Outposts")]]
|
||||||
|
],
|
||||||
|
[null, msg("Events"), null, [
|
||||||
|
["/events/log", msg("Logs"), [`^/events/log/(?<id>${UUID_REGEX})$`]],
|
||||||
|
["/events/rules", msg("Notification Rules")],
|
||||||
|
["/events/transports", msg("Notification Transports")]]
|
||||||
|
],
|
||||||
|
[null, msg("Customization"), null, [
|
||||||
|
["/policy/policies", msg("Policies")],
|
||||||
|
["/core/property-mappings", msg("Property Mappings")],
|
||||||
|
["/blueprints/instances", msg("Blueprints")],
|
||||||
|
["/policy/reputation", msg("Reputation scores")]]
|
||||||
|
],
|
||||||
|
[null, msg("Flows and Stages"), null, [
|
||||||
|
["/flow/flows", msg("Flows"), [`^/flow/flows/(?<slug>${SLUG_REGEX})$`]],
|
||||||
|
["/flow/stages", msg("Stages")],
|
||||||
|
["/flow/stages/prompts", msg("Prompts")]]
|
||||||
|
],
|
||||||
|
[null, msg("Directory"), null, [
|
||||||
|
["/identity/users", msg("Users"), [`^/identity/users/(?<id>${ID_REGEX})$`]],
|
||||||
|
["/identity/groups", msg("Groups"), [`^/identity/groups/(?<id>${UUID_REGEX})$`]],
|
||||||
|
["/identity/roles", msg("Roles"), [`^/identity/roles/(?<id>${UUID_REGEX})$`]],
|
||||||
|
["/identity/initial-permissions", msg("Initial Permissions"), [`^/identity/initial-permissions/(?<id>${ID_REGEX})$`]],
|
||||||
|
["/core/sources", msg("Federation and Social login"), [`^/core/sources/(?<slug>${SLUG_REGEX})$`]],
|
||||||
|
["/core/tokens", msg("Tokens and App passwords")],
|
||||||
|
["/flow/stages/invitations", msg("Invitations")]]
|
||||||
|
],
|
||||||
|
[null, msg("System"), null, [
|
||||||
|
["/core/brands", msg("Brands")],
|
||||||
|
["/crypto/certificates", msg("Certificates")],
|
||||||
|
["/outpost/integrations", msg("Outpost Integrations")],
|
||||||
|
["/admin/settings", msg("Settings")]]
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
// prettier-ignore
|
||||||
|
export const AdminSidebarEnterpriseEntries: readonly SidebarEntry[] = [
|
||||||
|
[null, msg("Enterprise"), null, [
|
||||||
|
["/enterprise/licenses", msg("Licenses"), null]
|
||||||
|
],
|
||||||
|
]]
|
||||||
|
@ -58,9 +58,6 @@ export class AdminOverviewPage extends AdminOverviewBase {
|
|||||||
PFContent,
|
PFContent,
|
||||||
PFDivider,
|
PFDivider,
|
||||||
css`
|
css`
|
||||||
.pf-l-grid__item {
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
.pf-l-grid__item.big-graph-container {
|
.pf-l-grid__item.big-graph-container {
|
||||||
height: 35em;
|
height: 35em;
|
||||||
}
|
}
|
||||||
@ -74,6 +71,10 @@ export class AdminOverviewPage extends AdminOverviewBase {
|
|||||||
line-height: normal;
|
line-height: normal;
|
||||||
font-size: var(--pf-global--icon--FontSize--sm);
|
font-size: var(--pf-global--icon--FontSize--sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chart-item {
|
||||||
|
aspect-ratio: 2 / 1;
|
||||||
|
}
|
||||||
`,
|
`,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@ -94,22 +95,31 @@ export class AdminOverviewPage extends AdminOverviewBase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
render(): TemplateResult {
|
render(): TemplateResult {
|
||||||
const name = this.user?.user.name ?? this.user?.user.username;
|
const username = this.user?.user.name || this.user?.user.username;
|
||||||
|
|
||||||
return html`<ak-page-header description=${msg("General system status")} ?hasIcon=${false}>
|
return html` <ak-page-header
|
||||||
<span slot="header"> ${msg(str`Welcome, ${name || ""}.`)} </span>
|
header=${msg(str`Welcome, ${username || ""}.`)}
|
||||||
|
description=${msg("General system status")}
|
||||||
|
?hasIcon=${false}
|
||||||
|
>
|
||||||
</ak-page-header>
|
</ak-page-header>
|
||||||
<section class="pf-c-page__main-section">
|
<section class="pf-c-page__main-section">
|
||||||
<div class="pf-l-grid pf-m-gutter">
|
<div class="pf-l-grid pf-m-gutter">
|
||||||
<!-- row 1 -->
|
<div class="pf-l-grid__item pf-m-12-col pf-m-2-row pf-m-9-col-on-xl">
|
||||||
<div
|
<ak-recent-events pageSize="6"></ak-recent-events>
|
||||||
class="pf-l-grid__item pf-m-12-col pf-m-6-col-on-xl pf-m-6-col-on-2xl pf-l-grid pf-m-gutter"
|
</div>
|
||||||
>
|
<div class="pf-l-grid__item pf-m-12-col pf-m-6-col-on-sm pf-m-3-col-on-xl">
|
||||||
<div class="pf-l-grid__item pf-m-12-col pf-m-6-col-on-xl pf-m-4-col-on-2xl">
|
<ak-quick-actions-card .actions=${this.quickActions}>
|
||||||
<ak-quick-actions-card .actions=${this.quickActions}>
|
</ak-quick-actions-card>
|
||||||
</ak-quick-actions-card>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="pf-l-grid__item pf-m-12-col pf-m-6-col-on-xl pf-m-4-col-on-2xl">
|
<div class="pf-l-grid__item pf-m-12-col pf-m-6-col-on-sm pf-m-3-col-on-xl">
|
||||||
|
<ak-admin-status-version> </ak-admin-status-version>
|
||||||
|
</div>
|
||||||
|
<div class="pf-l-grid pf-l-grid__item pf-m-12-col pf-m-gutter">
|
||||||
|
${this.renderSecondaryRow()}
|
||||||
|
|
||||||
|
<div class="pf-l-grid__item pf-m-12-col pf-m-6-col-on-md chart-item">
|
||||||
<ak-aggregate-card
|
<ak-aggregate-card
|
||||||
icon="pf-icon pf-icon-zone"
|
icon="pf-icon pf-icon-zone"
|
||||||
header=${msg("Outpost status")}
|
header=${msg("Outpost status")}
|
||||||
@ -118,24 +128,13 @@ export class AdminOverviewPage extends AdminOverviewBase {
|
|||||||
<ak-admin-status-chart-outpost></ak-admin-status-chart-outpost>
|
<ak-admin-status-chart-outpost></ak-admin-status-chart-outpost>
|
||||||
</ak-aggregate-card>
|
</ak-aggregate-card>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div class="pf-l-grid__item pf-m-12-col pf-m-6-col-on-md chart-item">
|
||||||
class="pf-l-grid__item pf-m-12-col pf-m-12-col-on-xl pf-m-4-col-on-2xl"
|
|
||||||
>
|
|
||||||
<ak-aggregate-card icon="fa fa-sync-alt" header=${msg("Sync status")}>
|
<ak-aggregate-card icon="fa fa-sync-alt" header=${msg("Sync status")}>
|
||||||
<ak-admin-status-chart-sync></ak-admin-status-chart-sync>
|
<ak-admin-status-chart-sync></ak-admin-status-chart-sync>
|
||||||
</ak-aggregate-card>
|
</ak-aggregate-card>
|
||||||
</div>
|
</div>
|
||||||
<div class="pf-l-grid__item pf-m-12-col">
|
|
||||||
<hr class="pf-c-divider" />
|
|
||||||
</div>
|
|
||||||
${this.renderCards()}
|
|
||||||
</div>
|
|
||||||
<div class="pf-l-grid__item pf-m-12-col pf-m-6-col-on-xl">
|
|
||||||
<ak-recent-events pageSize="6"></ak-recent-events>
|
|
||||||
</div>
|
|
||||||
<div class="pf-l-grid__item pf-m-12-col">
|
|
||||||
<hr class="pf-c-divider" />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- row 3 -->
|
<!-- row 3 -->
|
||||||
<div
|
<div
|
||||||
class="pf-l-grid__item pf-m-12-col pf-m-6-col-on-xl pf-m-8-col-on-2xl big-graph-container"
|
class="pf-l-grid__item pf-m-12-col pf-m-6-col-on-xl pf-m-8-col-on-2xl big-graph-container"
|
||||||
@ -163,32 +162,34 @@ export class AdminOverviewPage extends AdminOverviewBase {
|
|||||||
</section>`;
|
</section>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
renderCards() {
|
renderSecondaryRow() {
|
||||||
const isEnterprise = this.hasEnterpriseLicense;
|
const isEnterprise = this.hasEnterpriseLicense;
|
||||||
|
const colSpan = isEnterprise ? 4 : 6;
|
||||||
|
|
||||||
const classes = {
|
const classes = {
|
||||||
"card-container": true,
|
"card-container": true,
|
||||||
"pf-l-grid__item": true,
|
"pf-l-grid__item": true,
|
||||||
"pf-m-6-col": true,
|
[`pf-m-12-col`]: true,
|
||||||
"pf-m-4-col-on-md": !isEnterprise,
|
[`pf-m-${colSpan}-col-on-md`]: true,
|
||||||
"pf-m-4-col-on-xl": !isEnterprise,
|
|
||||||
"pf-m-3-col-on-md": isEnterprise,
|
|
||||||
"pf-m-3-col-on-xl": isEnterprise,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return html`<div class=${classMap(classes)}>
|
return html`
|
||||||
|
<div class=${classMap(classes)}>
|
||||||
<ak-admin-status-system> </ak-admin-status-system>
|
<ak-admin-status-system> </ak-admin-status-system>
|
||||||
</div>
|
</div>
|
||||||
<div class=${classMap(classes)}>
|
|
||||||
<ak-admin-status-version> </ak-admin-status-version>
|
|
||||||
</div>
|
|
||||||
<div class=${classMap(classes)}>
|
<div class=${classMap(classes)}>
|
||||||
<ak-admin-status-card-workers> </ak-admin-status-card-workers>
|
<ak-admin-status-card-workers> </ak-admin-status-card-workers>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
${isEnterprise
|
${isEnterprise
|
||||||
? html` <div class=${classMap(classes)}>
|
? html`
|
||||||
<ak-admin-fips-status-system> </ak-admin-fips-status-system>
|
<div class=${classMap(classes)}>
|
||||||
</div>`
|
<ak-admin-fips-status-system> </ak-admin-fips-status-system>
|
||||||
: nothing} `;
|
</div>
|
||||||
|
`
|
||||||
|
: nothing}
|
||||||
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
renderActions() {
|
renderActions() {
|
||||||
|
@ -83,13 +83,10 @@ export class AdminSettingsPage extends AKElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
if (!this.settings) {
|
if (!this.settings) return nothing;
|
||||||
return nothing;
|
|
||||||
}
|
|
||||||
return html`
|
return html`
|
||||||
<ak-page-header icon="fa fa-cog" header="" description="">
|
<ak-page-header icon="fa fa-cog" header="${msg("System settings")}"> </ak-page-header>
|
||||||
<span slot="header"> ${msg("System settings")} </span>
|
|
||||||
</ak-page-header>
|
|
||||||
<section class="pf-c-page__main-section pf-m-no-padding-mobile pf-l-grid pf-m-gutter">
|
<section class="pf-c-page__main-section pf-m-no-padding-mobile pf-l-grid pf-m-gutter">
|
||||||
<div class="pf-c-card">
|
<div class="pf-c-card">
|
||||||
<div class="pf-c-card__body">
|
<div class="pf-c-card__body">
|
||||||
|
@ -1,26 +1,110 @@
|
|||||||
import type { Config as DOMPurifyConfig } from "dompurify";
|
import type { Config as DOMPurifyConfig } from "dompurify";
|
||||||
import DOMPurify from "dompurify";
|
import DOMPurify from "dompurify";
|
||||||
|
import { trustedTypes } from "trusted-types";
|
||||||
|
|
||||||
import { render } from "@lit-labs/ssr";
|
import { render } from "lit";
|
||||||
import { collectResult } from "@lit-labs/ssr/lib/render-result.js";
|
|
||||||
import { TemplateResult, html } from "lit";
|
|
||||||
import { unsafeHTML } from "lit/directives/unsafe-html.js";
|
import { unsafeHTML } from "lit/directives/unsafe-html.js";
|
||||||
import { until } from "lit/directives/until.js";
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trusted types policy that escapes HTML content in place.
|
||||||
|
*
|
||||||
|
* @see {@linkcode SanitizedTrustPolicy} to strip HTML content.
|
||||||
|
*
|
||||||
|
* @returns {TrustedHTML} All HTML content, escaped.
|
||||||
|
*/
|
||||||
|
export const EscapeTrustPolicy = trustedTypes.createPolicy("authentik-escape", {
|
||||||
|
createHTML: (untrustedHTML: string) => {
|
||||||
|
return DOMPurify.sanitize(untrustedHTML, {
|
||||||
|
RETURN_TRUSTED_TYPE: false,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trusted types policy, stripping all HTML content.
|
||||||
|
*
|
||||||
|
* @returns {TrustedHTML} Text content only, all HTML tags stripped.
|
||||||
|
*/
|
||||||
|
export const SanitizedTrustPolicy = trustedTypes.createPolicy("authentik-sanitize", {
|
||||||
|
createHTML: (untrustedHTML: string) => {
|
||||||
|
return DOMPurify.sanitize(untrustedHTML, {
|
||||||
|
RETURN_TRUSTED_TYPE: false,
|
||||||
|
ALLOWED_TAGS: ["#text"],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trusted types policy, allowing a minimal set of _safe_ HTML tags supplied by
|
||||||
|
* a trusted source, such as the brand API.
|
||||||
|
*/
|
||||||
|
export const BrandedHTMLPolicy = trustedTypes.createPolicy("authentik-restrict", {
|
||||||
|
createHTML: (untrustedHTML: string) => {
|
||||||
|
return DOMPurify.sanitize(untrustedHTML, {
|
||||||
|
RETURN_TRUSTED_TYPE: false,
|
||||||
|
FORBID_TAGS: [
|
||||||
|
"script",
|
||||||
|
"style",
|
||||||
|
"iframe",
|
||||||
|
"link",
|
||||||
|
"object",
|
||||||
|
"embed",
|
||||||
|
"applet",
|
||||||
|
"meta",
|
||||||
|
"base",
|
||||||
|
"form",
|
||||||
|
"input",
|
||||||
|
"textarea",
|
||||||
|
"select",
|
||||||
|
"button",
|
||||||
|
],
|
||||||
|
FORBID_ATTR: [
|
||||||
|
"onerror",
|
||||||
|
"onclick",
|
||||||
|
"onload",
|
||||||
|
"onmouseover",
|
||||||
|
"onmouseout",
|
||||||
|
"onmouseup",
|
||||||
|
"onmousedown",
|
||||||
|
"onfocus",
|
||||||
|
"onblur",
|
||||||
|
"onsubmit",
|
||||||
|
],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export type AuthentikTrustPolicy =
|
||||||
|
| typeof EscapeTrustPolicy
|
||||||
|
| typeof SanitizedTrustPolicy
|
||||||
|
| typeof BrandedHTMLPolicy;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitize an untrusted HTML string using a trusted types policy.
|
||||||
|
*/
|
||||||
|
export function sanitizeHTML(trustPolicy: AuthentikTrustPolicy, untrustedHTML: string) {
|
||||||
|
return unsafeHTML(trustPolicy.createHTML(untrustedHTML).toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DOMPurify configuration for strict sanitization.
|
||||||
|
*
|
||||||
|
* This configuration only allows text nodes and disallows all HTML tags.
|
||||||
|
*/
|
||||||
export const DOM_PURIFY_STRICT = {
|
export const DOM_PURIFY_STRICT = {
|
||||||
ALLOWED_TAGS: ["#text"],
|
ALLOWED_TAGS: ["#text"],
|
||||||
} as const satisfies DOMPurifyConfig;
|
} as const satisfies DOMPurifyConfig;
|
||||||
|
|
||||||
export async function renderStatic(input: TemplateResult): Promise<string> {
|
/**
|
||||||
return await collectResult(render(input));
|
* Render untrusted HTML to a string without escaping it.
|
||||||
}
|
*
|
||||||
|
* @returns {string} The rendered HTML string.
|
||||||
|
*/
|
||||||
|
export function renderStaticHTMLUnsafe(untrustedHTML: unknown): string {
|
||||||
|
const container = document.createElement("html");
|
||||||
|
render(untrustedHTML, container);
|
||||||
|
|
||||||
export function purify(input: TemplateResult): TemplateResult {
|
const result = container.innerHTML;
|
||||||
return html`${until(
|
|
||||||
(async () => {
|
return result;
|
||||||
const rendered = await renderStatic(input);
|
|
||||||
const purified = DOMPurify.sanitize(rendered);
|
|
||||||
return html`${unsafeHTML(purified)}`;
|
|
||||||
})(),
|
|
||||||
)}`;
|
|
||||||
}
|
}
|
||||||
|
@ -17,6 +17,13 @@
|
|||||||
|
|
||||||
/* Minimum width after which the sidebar becomes automatic */
|
/* Minimum width after which the sidebar becomes automatic */
|
||||||
--ak-sidebar--minimum-auto-width: 80rem;
|
--ak-sidebar--minimum-auto-width: 80rem;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The height of the navbar and branded sidebar.
|
||||||
|
* @todo This shouldn't be necessary. The sidebar can instead use a grid layout
|
||||||
|
* ensuring they share the same height.
|
||||||
|
*/
|
||||||
|
--ak-navbar--height: 7rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@supports selector(::-webkit-scrollbar) {
|
@supports selector(::-webkit-scrollbar) {
|
||||||
|
220
web/src/common/stylesheets.ts
Normal file
220
web/src/common/stylesheets.ts
Normal file
@ -0,0 +1,220 @@
|
|||||||
|
/**
|
||||||
|
* @file Stylesheet utilities.
|
||||||
|
*/
|
||||||
|
import { CSSResult, CSSResultOrNative, ReactiveElement, css } from "lit";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Elements containing adoptable stylesheets.
|
||||||
|
*/
|
||||||
|
export type StyleSheetParent = Pick<DocumentOrShadowRoot, "adoptedStyleSheets">;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type-predicate to determine if a given object has adoptable stylesheets.
|
||||||
|
*/
|
||||||
|
export function isAdoptableStyleSheetParent(input: unknown): input is StyleSheetParent {
|
||||||
|
// Sanity check - Does the input have the right shape?
|
||||||
|
|
||||||
|
if (!input || typeof input !== "object") return false;
|
||||||
|
|
||||||
|
if (!("adoptedStyleSheets" in input) || !input.adoptedStyleSheets) return false;
|
||||||
|
|
||||||
|
if (typeof input.adoptedStyleSheets !== "object") return false;
|
||||||
|
|
||||||
|
// We avoid `Array.isArray` because the adopted stylesheets property
|
||||||
|
// is defined as a proxied array.
|
||||||
|
// All we care about is that it's shaped like an array.
|
||||||
|
if (!("length" in input.adoptedStyleSheets)) return false;
|
||||||
|
|
||||||
|
if (typeof input.adoptedStyleSheets.length !== "number") return false;
|
||||||
|
|
||||||
|
// Finally is the array mutable?
|
||||||
|
return "push" in input.adoptedStyleSheets;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assert that the given input can adopt stylesheets.
|
||||||
|
*/
|
||||||
|
export function assertAdoptableStyleSheetParent<T>(
|
||||||
|
input: T,
|
||||||
|
): asserts input is T & StyleSheetParent {
|
||||||
|
if (isAdoptableStyleSheetParent(input)) return;
|
||||||
|
|
||||||
|
console.debug("Given input missing `adoptedStyleSheets`", input);
|
||||||
|
|
||||||
|
throw new TypeError("Assertion failed: `adoptedStyleSheets` missing in given input");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveStyleSheetParent<T extends HTMLElement | DocumentFragment | Document>(
|
||||||
|
renderRoot: T,
|
||||||
|
) {
|
||||||
|
const styleRoot = "ShadyDOM" in window ? document : renderRoot;
|
||||||
|
|
||||||
|
assertAdoptableStyleSheetParent(styleRoot);
|
||||||
|
|
||||||
|
return styleRoot;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StyleSheetInit = string | CSSResult | CSSStyleSheet;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given a source of CSS, create a `CSSStyleSheet`.
|
||||||
|
*
|
||||||
|
* @throw {@linkcode TypeError} if the input cannot be converted to a `CSSStyleSheet`
|
||||||
|
*
|
||||||
|
* @remarks
|
||||||
|
*
|
||||||
|
* Storybook's `build` does not currently have a coherent way of importing
|
||||||
|
* CSS-as-text into CSSStyleSheet.
|
||||||
|
*
|
||||||
|
* It works well when Storybook is running in `dev`, but in `build` it fails.
|
||||||
|
* Storied components will have to map their textual CSS imports.
|
||||||
|
*/
|
||||||
|
export function createStyleSheet(input: string): CSSResult {
|
||||||
|
const inputTemplate = [input] as unknown as TemplateStringsArray;
|
||||||
|
|
||||||
|
const result = css(inputTemplate, []);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given a source of CSS, create a `CSSStyleSheet`.
|
||||||
|
*
|
||||||
|
* @see {@linkcode createStyleSheet}
|
||||||
|
*/
|
||||||
|
export function normalizeCSSSource(css: string): CSSStyleSheet;
|
||||||
|
export function normalizeCSSSource(styleSheet: CSSStyleSheet): CSSStyleSheet;
|
||||||
|
export function normalizeCSSSource(cssResult: CSSResult): CSSResult;
|
||||||
|
export function normalizeCSSSource(input: StyleSheetInit): CSSResultOrNative;
|
||||||
|
export function normalizeCSSSource(input: StyleSheetInit): CSSResultOrNative {
|
||||||
|
if (typeof input === "string") return createStyleSheet(input);
|
||||||
|
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createStyleSheetUnsafe(input: StyleSheetInit): CSSStyleSheet {
|
||||||
|
const result = normalizeCSSSource(input);
|
||||||
|
if (result instanceof CSSStyleSheet) return result;
|
||||||
|
|
||||||
|
if (!result.styleSheet) {
|
||||||
|
console.debug(
|
||||||
|
"authentik/common/stylesheets: CSSResult missing styleSheet, returning empty",
|
||||||
|
{ result, input },
|
||||||
|
);
|
||||||
|
|
||||||
|
throw new TypeError("Expected a CSSStyleSheet");
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.styleSheet;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append stylesheet(s) to the given roots.
|
||||||
|
*/
|
||||||
|
export function appendStyleSheet(
|
||||||
|
insertions: CSSStyleSheet | Iterable<CSSStyleSheet>,
|
||||||
|
...styleParents: StyleSheetParent[]
|
||||||
|
): void {
|
||||||
|
insertions = Array.isArray(insertions) ? insertions : [insertions];
|
||||||
|
|
||||||
|
for (const nextStyleSheet of insertions) {
|
||||||
|
for (const styleParent of styleParents) {
|
||||||
|
if (styleParent.adoptedStyleSheets.includes(nextStyleSheet)) return;
|
||||||
|
|
||||||
|
styleParent.adoptedStyleSheets = [...styleParent.adoptedStyleSheets, nextStyleSheet];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a stylesheet from the given roots, matching by referential equality.
|
||||||
|
*/
|
||||||
|
export function removeStyleSheet(
|
||||||
|
currentStyleSheet: CSSStyleSheet,
|
||||||
|
...styleParents: StyleSheetParent[]
|
||||||
|
): void {
|
||||||
|
for (const styleParent of styleParents) {
|
||||||
|
const nextAdoptedStyleSheets = styleParent.adoptedStyleSheets.filter(
|
||||||
|
(styleSheet) => styleSheet !== currentStyleSheet,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (nextAdoptedStyleSheets.length === styleParent.adoptedStyleSheets.length) return;
|
||||||
|
|
||||||
|
styleParent.adoptedStyleSheets = nextAdoptedStyleSheets;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialize a stylesheet to a string.
|
||||||
|
*
|
||||||
|
* This is useful for debugging or inspecting the contents of a stylesheet.
|
||||||
|
*/
|
||||||
|
export function serializeStyleSheet(stylesheet: CSSStyleSheet): string {
|
||||||
|
return Array.from(stylesheet.cssRules || [], (rule) => rule.cssText || "").join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inspect the adopted stylesheets of a given style parent, serializing them to strings.
|
||||||
|
*/
|
||||||
|
export function inspectStyleSheets(styleParent: StyleSheetParent): string[] {
|
||||||
|
return styleParent.adoptedStyleSheets.map((styleSheet) => serializeStyleSheet(styleSheet));
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InspectedStyleSheetEntry {
|
||||||
|
tagName: string;
|
||||||
|
element: ReactiveElement;
|
||||||
|
styles: string[];
|
||||||
|
children?: InspectedStyleSheetEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively inspect the adopted stylesheets of a given style parent, serializing them to strings.
|
||||||
|
*/
|
||||||
|
export function inspectStyleSheetTree(element: ReactiveElement): InspectedStyleSheetEntry {
|
||||||
|
const styleParent = resolveStyleSheetParent(element.renderRoot);
|
||||||
|
const styles = inspectStyleSheets(styleParent);
|
||||||
|
const tagName = element.tagName.toLowerCase();
|
||||||
|
|
||||||
|
const treewalker = document.createTreeWalker(element.renderRoot, NodeFilter.SHOW_ELEMENT, {
|
||||||
|
acceptNode(node) {
|
||||||
|
if (node instanceof ReactiveElement) {
|
||||||
|
return NodeFilter.FILTER_ACCEPT;
|
||||||
|
}
|
||||||
|
return NodeFilter.FILTER_SKIP;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const children: InspectedStyleSheetEntry[] = [];
|
||||||
|
let currentNode: Node | null = treewalker.nextNode();
|
||||||
|
while (currentNode) {
|
||||||
|
const childElement = currentNode as ReactiveElement;
|
||||||
|
|
||||||
|
if (!isAdoptableStyleSheetParent(childElement.renderRoot)) {
|
||||||
|
currentNode = treewalker.nextNode();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const childStyles = inspectStyleSheets(childElement.renderRoot);
|
||||||
|
|
||||||
|
children.push({
|
||||||
|
tagName: childElement.tagName.toLowerCase(),
|
||||||
|
element: childElement,
|
||||||
|
styles: childStyles,
|
||||||
|
});
|
||||||
|
currentNode = treewalker.nextNode();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
tagName,
|
||||||
|
element,
|
||||||
|
styles,
|
||||||
|
children,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV === "development") {
|
||||||
|
Object.assign(window, {
|
||||||
|
inspectStyleSheetTree,
|
||||||
|
serializeStyleSheet,
|
||||||
|
inspectStyleSheets,
|
||||||
|
});
|
||||||
|
}
|
200
web/src/common/theme.ts
Normal file
200
web/src/common/theme.ts
Normal file
@ -0,0 +1,200 @@
|
|||||||
|
/**
|
||||||
|
* @file Theme utilities.
|
||||||
|
*/
|
||||||
|
import { UIConfig } from "@goauthentik/common/ui/config";
|
||||||
|
|
||||||
|
import { Config, CurrentBrand, UiThemeEnum } from "@goauthentik/api";
|
||||||
|
|
||||||
|
//#region Scheme Types
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valid CSS color scheme values.
|
||||||
|
*
|
||||||
|
* @link {@link https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-color-scheme | MDN}
|
||||||
|
*
|
||||||
|
* @category CSS
|
||||||
|
*/
|
||||||
|
export type CSSColorSchemeValue = "dark" | "light" | "auto";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A CSS color scheme value that can be preferred by the user, i.e. not `"auto"`.
|
||||||
|
*
|
||||||
|
* @category CSS
|
||||||
|
*/
|
||||||
|
export type ResolvedCSSColorSchemeValue = Exclude<CSSColorSchemeValue, "auto">;
|
||||||
|
|
||||||
|
//#endregion
|
||||||
|
|
||||||
|
//#region UI Theme Types
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A UI color scheme value that can be preferred by the user.
|
||||||
|
*
|
||||||
|
* i.e. not an lack of preference or unknown value.
|
||||||
|
*
|
||||||
|
* @category CSS
|
||||||
|
*/
|
||||||
|
export type ResolvedUITheme = typeof UiThemeEnum.Light | typeof UiThemeEnum.Dark;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A mapping of theme values to their respective inversion.
|
||||||
|
*
|
||||||
|
* @category CSS
|
||||||
|
*/
|
||||||
|
export const UIThemeInversion = {
|
||||||
|
dark: "light",
|
||||||
|
light: "dark",
|
||||||
|
} as const satisfies Record<ResolvedUITheme, ResolvedUITheme>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Either a valid CSS color scheme value, or a theme preference.
|
||||||
|
*/
|
||||||
|
export type UIThemeHint = CSSColorSchemeValue | UiThemeEnum;
|
||||||
|
|
||||||
|
//#endregion
|
||||||
|
|
||||||
|
//#region Scheme Functions
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an event target for the given color scheme.
|
||||||
|
*
|
||||||
|
* @param colorScheme The color scheme to target.
|
||||||
|
* @returns A {@linkcode MediaQueryList} that can be used to listen for changes to the color scheme.
|
||||||
|
*
|
||||||
|
* @see {@link https://developer.mozilla.org/en-US/docs/Web/API/MediaQueryList | MDN}
|
||||||
|
*
|
||||||
|
* @category CSS
|
||||||
|
*/
|
||||||
|
export function createColorSchemeTarget(colorScheme: ResolvedCSSColorSchemeValue): MediaQueryList {
|
||||||
|
return window.matchMedia(`(prefers-color-scheme: ${colorScheme})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats the given input into a valid CSS color scheme value.
|
||||||
|
*
|
||||||
|
* If the input is not provided, it defaults to "auto".
|
||||||
|
*
|
||||||
|
* @category CSS
|
||||||
|
*/
|
||||||
|
export function formatColorScheme(theme: ResolvedUITheme): ResolvedCSSColorSchemeValue;
|
||||||
|
export function formatColorScheme(
|
||||||
|
colorScheme: ResolvedCSSColorSchemeValue,
|
||||||
|
): ResolvedCSSColorSchemeValue;
|
||||||
|
export function formatColorScheme(hint?: UIThemeHint): CSSColorSchemeValue;
|
||||||
|
export function formatColorScheme(hint?: UIThemeHint): CSSColorSchemeValue {
|
||||||
|
if (!hint) return "auto";
|
||||||
|
|
||||||
|
switch (hint) {
|
||||||
|
case "dark":
|
||||||
|
case UiThemeEnum.Dark:
|
||||||
|
return "dark";
|
||||||
|
case "light":
|
||||||
|
case UiThemeEnum.Light:
|
||||||
|
return "light";
|
||||||
|
case "auto":
|
||||||
|
case UiThemeEnum.Automatic:
|
||||||
|
return "auto";
|
||||||
|
default:
|
||||||
|
console.warn(`Unknown color scheme hint: ${hint}. Defaulting to "auto".`);
|
||||||
|
return "auto";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//#endregion
|
||||||
|
|
||||||
|
//#region Theme Functions
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the current UI theme based on the user's preference or the provided color scheme.
|
||||||
|
*
|
||||||
|
* @param hint The color scheme hint to use.
|
||||||
|
*
|
||||||
|
* @category CSS
|
||||||
|
*/
|
||||||
|
export function resolveUITheme(
|
||||||
|
hint?: UIThemeHint,
|
||||||
|
defaultUITheme: ResolvedUITheme = UiThemeEnum.Light,
|
||||||
|
): ResolvedUITheme {
|
||||||
|
const colorScheme = formatColorScheme(hint);
|
||||||
|
|
||||||
|
if (colorScheme !== "auto") return colorScheme;
|
||||||
|
|
||||||
|
// Given that we don't know the user's preference,
|
||||||
|
// we can determine the theme based on whether the default theme is
|
||||||
|
// currently being overridden.
|
||||||
|
|
||||||
|
const colorSchemeInversion = formatColorScheme(UIThemeInversion[defaultUITheme]);
|
||||||
|
|
||||||
|
const mediaQueryList = createColorSchemeTarget(colorSchemeInversion);
|
||||||
|
|
||||||
|
return mediaQueryList.matches ? colorSchemeInversion : defaultUITheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Effect listener invoked when the color scheme changes.
|
||||||
|
*/
|
||||||
|
export type UIThemeListener = (currentUITheme: ResolvedUITheme) => void;
|
||||||
|
/**
|
||||||
|
* Create an effect that runs
|
||||||
|
*
|
||||||
|
* @returns A cleanup function that removes the effect.
|
||||||
|
*/
|
||||||
|
export function createUIThemeEffect(
|
||||||
|
effect: UIThemeListener,
|
||||||
|
listenerOptions?: AddEventListenerOptions,
|
||||||
|
): () => void {
|
||||||
|
const colorSchemeTarget = resolveUITheme();
|
||||||
|
const invertedColorSchemeTarget = UIThemeInversion[colorSchemeTarget];
|
||||||
|
|
||||||
|
let previousUITheme: ResolvedUITheme | undefined;
|
||||||
|
|
||||||
|
// First, wrap the effect to ensure we can abort it.
|
||||||
|
const changeListener = (event: MediaQueryListEvent) => {
|
||||||
|
if (listenerOptions?.signal?.aborted) return;
|
||||||
|
|
||||||
|
const currentUITheme = event.matches ? colorSchemeTarget : invertedColorSchemeTarget;
|
||||||
|
|
||||||
|
if (previousUITheme === currentUITheme) return;
|
||||||
|
|
||||||
|
previousUITheme = currentUITheme;
|
||||||
|
|
||||||
|
effect(currentUITheme);
|
||||||
|
};
|
||||||
|
|
||||||
|
const mediaQueryList = createColorSchemeTarget(colorSchemeTarget);
|
||||||
|
|
||||||
|
// Trigger the effect immediately.
|
||||||
|
effect(colorSchemeTarget);
|
||||||
|
|
||||||
|
// Listen for changes to the color scheme...
|
||||||
|
mediaQueryList.addEventListener("change", changeListener, listenerOptions);
|
||||||
|
|
||||||
|
// Finally, allow the caller to remove the effect.
|
||||||
|
const cleanup = () => {
|
||||||
|
mediaQueryList.removeEventListener("change", changeListener);
|
||||||
|
};
|
||||||
|
|
||||||
|
return cleanup;
|
||||||
|
}
|
||||||
|
|
||||||
|
//#endregion
|
||||||
|
|
||||||
|
//#region Theme Element
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An element that can be themed.
|
||||||
|
*/
|
||||||
|
export interface ThemedElement extends HTMLElement {
|
||||||
|
brand?: CurrentBrand;
|
||||||
|
uiConfig?: UIConfig;
|
||||||
|
config?: Config;
|
||||||
|
activeTheme: ResolvedUITheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rootInterface<T extends ThemedElement = ThemedElement>(): T | null {
|
||||||
|
const element = document.body.querySelector<T>("[data-ak-interface-root]");
|
||||||
|
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
|
||||||
|
//#endregion
|
@ -67,6 +67,12 @@ export class NavigationButtons extends AKElement {
|
|||||||
:host([theme="light"]) .pf-c-page__header-tools-group .pf-c-button {
|
:host([theme="light"]) .pf-c-page__header-tools-group .pf-c-button {
|
||||||
color: var(--ak-global--Color--100) !important;
|
color: var(--ak-global--Color--100) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.pf-c-avatar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
`,
|
`,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@ -156,9 +162,7 @@ export class NavigationButtons extends AKElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
renderImpersonation() {
|
renderImpersonation() {
|
||||||
if (!this.me?.original) {
|
if (!this.me?.original) return nothing;
|
||||||
return nothing;
|
|
||||||
}
|
|
||||||
|
|
||||||
const onClick = async () => {
|
const onClick = async () => {
|
||||||
await new CoreApi(DEFAULT_CONFIG).coreUsersImpersonateEndRetrieve();
|
await new CoreApi(DEFAULT_CONFIG).coreUsersImpersonateEndRetrieve();
|
||||||
@ -175,6 +179,14 @@ export class NavigationButtons extends AKElement {
|
|||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
renderAvatar() {
|
||||||
|
return html`<img
|
||||||
|
class="pf-c-avatar"
|
||||||
|
src=${ifDefined(this.me?.user.avatar)}
|
||||||
|
alt="${msg("Avatar image")}"
|
||||||
|
/>`;
|
||||||
|
}
|
||||||
|
|
||||||
get userDisplayName() {
|
get userDisplayName() {
|
||||||
return match<UserDisplay | undefined, string | undefined>(this.uiConfig?.navbar.userDisplay)
|
return match<UserDisplay | undefined, string | undefined>(this.uiConfig?.navbar.userDisplay)
|
||||||
.with(UserDisplay.username, () => this.me?.user.username)
|
.with(UserDisplay.username, () => this.me?.user.username)
|
||||||
@ -212,11 +224,7 @@ export class NavigationButtons extends AKElement {
|
|||||||
</div>
|
</div>
|
||||||
</div>`
|
</div>`
|
||||||
: nothing}
|
: nothing}
|
||||||
<img
|
${this.renderAvatar()}
|
||||||
class="pf-c-avatar"
|
|
||||||
src=${ifDefined(this.me?.user.avatar)}
|
|
||||||
alt="${msg("Avatar image")}"
|
|
||||||
/>
|
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,165 +1,125 @@
|
|||||||
import { EVENT_THEME_CHANGE } from "@goauthentik/common/constants";
|
|
||||||
import { globalAK } from "@goauthentik/common/global";
|
import { globalAK } from "@goauthentik/common/global";
|
||||||
import { UIConfig } from "@goauthentik/common/ui/config";
|
import {
|
||||||
import { adaptCSS } from "@goauthentik/common/utils";
|
StyleSheetInit,
|
||||||
import { ensureCSSStyleSheet } from "@goauthentik/elements/utils/ensureCSSStyleSheet";
|
StyleSheetParent,
|
||||||
|
appendStyleSheet,
|
||||||
|
createStyleSheetUnsafe,
|
||||||
|
removeStyleSheet,
|
||||||
|
resolveStyleSheetParent,
|
||||||
|
} from "@goauthentik/common/stylesheets";
|
||||||
|
import { ResolvedUITheme, createUIThemeEffect, resolveUITheme } from "@goauthentik/common/theme";
|
||||||
|
import { type ThemedElement } from "@goauthentik/common/theme";
|
||||||
|
|
||||||
import { localized } from "@lit/localize";
|
import { localized } from "@lit/localize";
|
||||||
import { LitElement, ReactiveElement } from "lit";
|
import { CSSResultGroup, CSSResultOrNative, LitElement } from "lit";
|
||||||
|
import { property } from "lit/decorators.js";
|
||||||
|
|
||||||
import AKGlobal from "@goauthentik/common/styles/authentik.css";
|
import AKGlobal from "@goauthentik/common/styles/authentik.css";
|
||||||
import OneDark from "@goauthentik/common/styles/one-dark.css";
|
import OneDark from "@goauthentik/common/styles/one-dark.css";
|
||||||
import ThemeDark from "@goauthentik/common/styles/theme-dark.css";
|
import ThemeDark from "@goauthentik/common/styles/theme-dark.css";
|
||||||
|
|
||||||
import { Config, CurrentBrand, UiThemeEnum } from "@goauthentik/api";
|
import { CurrentBrand, UiThemeEnum } from "@goauthentik/api";
|
||||||
|
|
||||||
type AkInterface = HTMLElement & {
|
// Re-export the theme helpers
|
||||||
getTheme: () => Promise<UiThemeEnum>;
|
export { rootInterface } from "@goauthentik/common/theme";
|
||||||
brand?: CurrentBrand;
|
|
||||||
uiConfig?: UIConfig;
|
|
||||||
config?: Config;
|
|
||||||
get activeTheme(): UiThemeEnum | undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const rootInterface = <T extends AkInterface>(): T | undefined =>
|
export interface AKElementInit {
|
||||||
(document.body.querySelector("[data-ak-interface-root]") as T) ?? undefined;
|
brand?: Partial<CurrentBrand>;
|
||||||
|
styleParents?: StyleSheetParent[];
|
||||||
export const QUERY_MEDIA_COLOR_LIGHT = "(prefers-color-scheme: light)";
|
}
|
||||||
|
|
||||||
// Ensure themes are converted to a static instance of CSS Stylesheet, otherwise the
|
|
||||||
// when changing themes we might not remove the correct css stylesheet instance.
|
|
||||||
const _darkTheme = ensureCSSStyleSheet(ThemeDark);
|
|
||||||
|
|
||||||
@localized()
|
@localized()
|
||||||
export class AKElement extends LitElement {
|
export class AKElement extends LitElement implements ThemedElement {
|
||||||
_mediaMatcher?: MediaQueryList;
|
|
||||||
_mediaMatcherHandler?: (ev?: MediaQueryListEvent) => void;
|
|
||||||
_activeTheme?: UiThemeEnum;
|
|
||||||
|
|
||||||
get activeTheme(): UiThemeEnum | undefined {
|
|
||||||
return this._activeTheme;
|
|
||||||
}
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
super();
|
|
||||||
}
|
|
||||||
|
|
||||||
setInitialStyles(root: DocumentOrShadowRoot) {
|
|
||||||
const styleRoot: DocumentOrShadowRoot = (
|
|
||||||
"ShadyDOM" in window ? document : root
|
|
||||||
) as DocumentOrShadowRoot;
|
|
||||||
styleRoot.adoptedStyleSheets = adaptCSS([
|
|
||||||
...styleRoot.adoptedStyleSheets,
|
|
||||||
ensureCSSStyleSheet(AKGlobal),
|
|
||||||
ensureCSSStyleSheet(OneDark),
|
|
||||||
]);
|
|
||||||
this._initTheme(styleRoot);
|
|
||||||
this._initCustomCSS(styleRoot);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected createRenderRoot() {
|
|
||||||
this.fixElementStyles();
|
|
||||||
const root = super.createRenderRoot();
|
|
||||||
this.setInitialStyles(root as unknown as DocumentOrShadowRoot);
|
|
||||||
return root;
|
|
||||||
}
|
|
||||||
|
|
||||||
async getTheme(): Promise<UiThemeEnum> {
|
|
||||||
return rootInterface()?.getTheme() || UiThemeEnum.Automatic;
|
|
||||||
}
|
|
||||||
|
|
||||||
fixElementStyles() {
|
|
||||||
// Ensure all style sheets being passed are really style sheets.
|
|
||||||
(this.constructor as typeof ReactiveElement).elementStyles = (
|
|
||||||
this.constructor as typeof ReactiveElement
|
|
||||||
).elementStyles.map(ensureCSSStyleSheet);
|
|
||||||
}
|
|
||||||
|
|
||||||
async _initTheme(root: DocumentOrShadowRoot): Promise<void> {
|
|
||||||
// Early activate theme based on media query to prevent light flash
|
|
||||||
// when dark is preferred
|
|
||||||
this._applyTheme(root, globalAK().brand.uiTheme);
|
|
||||||
this._applyTheme(root, await this.getTheme());
|
|
||||||
}
|
|
||||||
|
|
||||||
async _initCustomCSS(root: DocumentOrShadowRoot): Promise<void> {
|
|
||||||
const brand = globalAK().brand;
|
|
||||||
if (!brand) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const sheet = await new CSSStyleSheet().replace(brand.brandingCustomCss);
|
|
||||||
root.adoptedStyleSheets = [...root.adoptedStyleSheets, sheet];
|
|
||||||
}
|
|
||||||
|
|
||||||
_applyTheme(root: DocumentOrShadowRoot, theme?: UiThemeEnum): void {
|
|
||||||
if (!theme) {
|
|
||||||
theme = UiThemeEnum.Automatic;
|
|
||||||
}
|
|
||||||
if (theme === UiThemeEnum.Automatic) {
|
|
||||||
// Create a media matcher to automatically switch the theme depending on
|
|
||||||
// prefers-color-scheme
|
|
||||||
if (!this._mediaMatcher) {
|
|
||||||
this._mediaMatcher = window.matchMedia(QUERY_MEDIA_COLOR_LIGHT);
|
|
||||||
this._mediaMatcherHandler = (ev?: MediaQueryListEvent) => {
|
|
||||||
const theme =
|
|
||||||
ev?.matches || this._mediaMatcher?.matches
|
|
||||||
? UiThemeEnum.Light
|
|
||||||
: UiThemeEnum.Dark;
|
|
||||||
this._activateTheme(theme, root);
|
|
||||||
};
|
|
||||||
this._mediaMatcherHandler(undefined);
|
|
||||||
this._mediaMatcher.addEventListener("change", this._mediaMatcherHandler);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
} else if (this._mediaMatcher && this._mediaMatcherHandler) {
|
|
||||||
// Theme isn't automatic and we have a matcher configured, remove the matcher
|
|
||||||
// to prevent changes
|
|
||||||
this._mediaMatcher.removeEventListener("change", this._mediaMatcherHandler);
|
|
||||||
this._mediaMatcher = undefined;
|
|
||||||
}
|
|
||||||
this._activateTheme(theme, root);
|
|
||||||
}
|
|
||||||
|
|
||||||
static themeToStylesheet(theme?: UiThemeEnum): CSSStyleSheet | undefined {
|
|
||||||
if (theme === UiThemeEnum.Dark) {
|
|
||||||
return _darkTheme;
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Directly activate a given theme, accepts multiple document/ShadowDOMs to apply the stylesheet
|
* The resolved theme of the current element.
|
||||||
* to. The stylesheets are applied to each DOM in order. Does nothing if the given theme is already active.
|
*
|
||||||
|
* @remarks
|
||||||
|
*
|
||||||
|
* Unlike the browser's current color scheme, this is a value that can be
|
||||||
|
* resolved to a specific theme, i.e. dark or light.
|
||||||
*/
|
*/
|
||||||
_activateTheme(theme: UiThemeEnum, ...roots: DocumentOrShadowRoot[]) {
|
@property({
|
||||||
if (theme === this._activeTheme) {
|
attribute: "theme",
|
||||||
return;
|
type: String,
|
||||||
|
reflect: true,
|
||||||
|
})
|
||||||
|
public activeTheme: ResolvedUITheme;
|
||||||
|
|
||||||
|
protected static readonly DarkColorSchemeStyleSheet = createStyleSheetUnsafe(ThemeDark);
|
||||||
|
|
||||||
|
protected static finalizeStyles(styles?: CSSResultGroup): CSSResultOrNative[] {
|
||||||
|
// Ensure all style sheets being passed are really style sheets.
|
||||||
|
const baseStyles: StyleSheetInit[] = [AKGlobal, OneDark];
|
||||||
|
|
||||||
|
if (!styles) return baseStyles.map(createStyleSheetUnsafe);
|
||||||
|
|
||||||
|
if (Array.isArray(styles)) {
|
||||||
|
return [
|
||||||
|
//---
|
||||||
|
...(styles as unknown as CSSResultOrNative[]),
|
||||||
|
...baseStyles,
|
||||||
|
].flatMap(createStyleSheetUnsafe);
|
||||||
}
|
}
|
||||||
// Make sure we only get to this callback once we've picked a concise theme choice
|
return [styles, ...baseStyles].map(createStyleSheetUnsafe);
|
||||||
this.dispatchEvent(
|
}
|
||||||
new CustomEvent(EVENT_THEME_CHANGE, {
|
|
||||||
bubbles: true,
|
constructor(init?: AKElementInit) {
|
||||||
composed: true,
|
super();
|
||||||
detail: theme,
|
|
||||||
}),
|
const config = globalAK();
|
||||||
|
const { brand = config.brand, styleParents = [] } = init || {};
|
||||||
|
|
||||||
|
this.activeTheme = resolveUITheme(brand?.uiTheme);
|
||||||
|
this.#styleParents = styleParents;
|
||||||
|
|
||||||
|
this.#customCSSStyleSheet = brand?.brandingCustomCss
|
||||||
|
? createStyleSheetUnsafe(brand.brandingCustomCss)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
#styleParents: StyleSheetParent[] = [];
|
||||||
|
#customCSSStyleSheet: CSSStyleSheet | null;
|
||||||
|
|
||||||
|
#themeAbortController: AbortController | null = null;
|
||||||
|
|
||||||
|
public disconnectedCallback(): void {
|
||||||
|
super.disconnectedCallback();
|
||||||
|
this.#themeAbortController?.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected createRenderRoot(): HTMLElement | DocumentFragment {
|
||||||
|
const renderRoot = super.createRenderRoot();
|
||||||
|
|
||||||
|
const styleRoot = resolveStyleSheetParent(renderRoot);
|
||||||
|
const styleParents = Array.from(
|
||||||
|
new Set<StyleSheetParent>([styleRoot, ...this.#styleParents]),
|
||||||
);
|
);
|
||||||
this.setAttribute("theme", theme);
|
|
||||||
const stylesheet = AKElement.themeToStylesheet(theme);
|
if (this.#customCSSStyleSheet) {
|
||||||
const oldStylesheet = AKElement.themeToStylesheet(this._activeTheme);
|
console.debug(`authentik/element[${this.tagName.toLowerCase()}]: Adding custom CSS`);
|
||||||
roots.forEach((root) => {
|
|
||||||
if (stylesheet) {
|
styleRoot.adoptedStyleSheets = [
|
||||||
root.adoptedStyleSheets = [
|
...styleRoot.adoptedStyleSheets,
|
||||||
...root.adoptedStyleSheets,
|
this.#customCSSStyleSheet,
|
||||||
ensureCSSStyleSheet(stylesheet),
|
];
|
||||||
];
|
}
|
||||||
}
|
|
||||||
if (oldStylesheet) {
|
this.#themeAbortController = new AbortController();
|
||||||
root.adoptedStyleSheets = root.adoptedStyleSheets.filter(
|
|
||||||
(v) => v !== oldStylesheet,
|
createUIThemeEffect(
|
||||||
);
|
(currentUITheme) => {
|
||||||
}
|
if (currentUITheme === UiThemeEnum.Dark) {
|
||||||
});
|
appendStyleSheet(AKElement.DarkColorSchemeStyleSheet, ...styleParents);
|
||||||
this._activeTheme = theme;
|
} else {
|
||||||
this.requestUpdate();
|
removeStyleSheet(AKElement.DarkColorSchemeStyleSheet, ...styleParents);
|
||||||
|
}
|
||||||
|
this.activeTheme = currentUITheme;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
signal: this.#themeAbortController.signal,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return renderRoot;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
|
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
|
||||||
import { EVENT_REFRESH } from "@goauthentik/common/constants";
|
import { EVENT_REFRESH } from "@goauthentik/common/constants";
|
||||||
|
import { ThemedElement } from "@goauthentik/common/theme";
|
||||||
import { authentikBrandContext } from "@goauthentik/elements/AuthentikContexts";
|
import { authentikBrandContext } from "@goauthentik/elements/AuthentikContexts";
|
||||||
import type { ReactiveElementHost } from "@goauthentik/elements/types.js";
|
import type { ReactiveElementHost } from "@goauthentik/elements/types.js";
|
||||||
|
|
||||||
@ -9,14 +10,12 @@ import type { ReactiveController } from "lit";
|
|||||||
import type { CurrentBrand } from "@goauthentik/api";
|
import type { CurrentBrand } from "@goauthentik/api";
|
||||||
import { CoreApi } from "@goauthentik/api";
|
import { CoreApi } from "@goauthentik/api";
|
||||||
|
|
||||||
import type { AkInterface } from "./Interface";
|
|
||||||
|
|
||||||
export class BrandContextController implements ReactiveController {
|
export class BrandContextController implements ReactiveController {
|
||||||
host!: ReactiveElementHost<AkInterface>;
|
host!: ReactiveElementHost<ThemedElement>;
|
||||||
|
|
||||||
context!: ContextProvider<{ __context__: CurrentBrand | undefined }>;
|
context!: ContextProvider<{ __context__: CurrentBrand | undefined }>;
|
||||||
|
|
||||||
constructor(host: ReactiveElementHost<AkInterface>) {
|
constructor(host: ReactiveElementHost<ThemedElement>) {
|
||||||
this.host = host;
|
this.host = host;
|
||||||
this.context = new ContextProvider(this.host, {
|
this.context = new ContextProvider(this.host, {
|
||||||
context: authentikBrandContext,
|
context: authentikBrandContext,
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
|
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
|
||||||
import { EVENT_REFRESH } from "@goauthentik/common/constants";
|
import { EVENT_REFRESH } from "@goauthentik/common/constants";
|
||||||
import { globalAK } from "@goauthentik/common/global";
|
import { globalAK } from "@goauthentik/common/global";
|
||||||
|
import { ThemedElement } from "@goauthentik/common/theme";
|
||||||
import { authentikConfigContext } from "@goauthentik/elements/AuthentikContexts";
|
import { authentikConfigContext } from "@goauthentik/elements/AuthentikContexts";
|
||||||
import type { ReactiveElementHost } from "@goauthentik/elements/types.js";
|
import type { ReactiveElementHost } from "@goauthentik/elements/types.js";
|
||||||
|
|
||||||
@ -10,14 +11,12 @@ import type { ReactiveController } from "lit";
|
|||||||
import type { Config } from "@goauthentik/api";
|
import type { Config } from "@goauthentik/api";
|
||||||
import { RootApi } from "@goauthentik/api";
|
import { RootApi } from "@goauthentik/api";
|
||||||
|
|
||||||
import type { AkInterface } from "./Interface";
|
|
||||||
|
|
||||||
export class ConfigContextController implements ReactiveController {
|
export class ConfigContextController implements ReactiveController {
|
||||||
host!: ReactiveElementHost<AkInterface>;
|
host!: ReactiveElementHost<ThemedElement>;
|
||||||
|
|
||||||
context!: ContextProvider<{ __context__: Config | undefined }>;
|
context!: ContextProvider<{ __context__: Config | undefined }>;
|
||||||
|
|
||||||
constructor(host: ReactiveElementHost<AkInterface>) {
|
constructor(host: ReactiveElementHost<ThemedElement>) {
|
||||||
this.host = host;
|
this.host = host;
|
||||||
this.context = new ContextProvider(this.host, {
|
this.context = new ContextProvider(this.host, {
|
||||||
context: authentikConfigContext,
|
context: authentikConfigContext,
|
||||||
|
@ -1,107 +1,85 @@
|
|||||||
import { UIConfig, uiConfig } from "@goauthentik/common/ui/config";
|
import {
|
||||||
|
appendStyleSheet,
|
||||||
|
createStyleSheetUnsafe,
|
||||||
|
resolveStyleSheetParent,
|
||||||
|
} from "@goauthentik/common/stylesheets";
|
||||||
|
import { ThemedElement } from "@goauthentik/common/theme";
|
||||||
|
import { UIConfig } from "@goauthentik/common/ui/config";
|
||||||
|
import { AKElement, AKElementInit } from "@goauthentik/elements/Base";
|
||||||
import { VersionContextController } from "@goauthentik/elements/Interface/VersionContextController";
|
import { VersionContextController } from "@goauthentik/elements/Interface/VersionContextController";
|
||||||
import { ModalOrchestrationController } from "@goauthentik/elements/controllers/ModalOrchestrationController.js";
|
import { ModalOrchestrationController } from "@goauthentik/elements/controllers/ModalOrchestrationController.js";
|
||||||
import { ensureCSSStyleSheet } from "@goauthentik/elements/utils/ensureCSSStyleSheet";
|
|
||||||
|
|
||||||
import { state } from "lit/decorators.js";
|
import { state } from "lit/decorators.js";
|
||||||
|
|
||||||
import PFBase from "@patternfly/patternfly/patternfly-base.css";
|
import PFBase from "@patternfly/patternfly/patternfly-base.css";
|
||||||
|
|
||||||
import type { Config, CurrentBrand, LicenseSummary, Version } from "@goauthentik/api";
|
import type { Config, CurrentBrand, LicenseSummary, Version } from "@goauthentik/api";
|
||||||
import { UiThemeEnum } from "@goauthentik/api";
|
|
||||||
|
|
||||||
import { AKElement, rootInterface } from "../Base";
|
|
||||||
import { BrandContextController } from "./BrandContextController";
|
import { BrandContextController } from "./BrandContextController";
|
||||||
import { ConfigContextController } from "./ConfigContextController";
|
import { ConfigContextController } from "./ConfigContextController";
|
||||||
import { EnterpriseContextController } from "./EnterpriseContextController";
|
import { EnterpriseContextController } from "./EnterpriseContextController";
|
||||||
|
|
||||||
export type AkInterface = HTMLElement & {
|
|
||||||
getTheme: () => Promise<UiThemeEnum>;
|
|
||||||
brand?: CurrentBrand;
|
|
||||||
uiConfig?: UIConfig;
|
|
||||||
config?: Config;
|
|
||||||
};
|
|
||||||
|
|
||||||
const brandContext = Symbol("brandContext");
|
const brandContext = Symbol("brandContext");
|
||||||
const configContext = Symbol("configContext");
|
const configContext = Symbol("configContext");
|
||||||
const modalController = Symbol("modalController");
|
const modalController = Symbol("modalController");
|
||||||
const versionContext = Symbol("versionContext");
|
const versionContext = Symbol("versionContext");
|
||||||
|
|
||||||
export class Interface extends AKElement implements AkInterface {
|
export abstract class Interface extends AKElement implements ThemedElement {
|
||||||
[brandContext]!: BrandContextController;
|
protected static readonly PFBaseStyleSheet = createStyleSheetUnsafe(PFBase);
|
||||||
|
|
||||||
[configContext]!: ConfigContextController;
|
[brandContext]: BrandContextController;
|
||||||
|
|
||||||
[modalController]!: ModalOrchestrationController;
|
[configContext]: ConfigContextController;
|
||||||
|
|
||||||
|
[modalController]: ModalOrchestrationController;
|
||||||
|
|
||||||
@state()
|
@state()
|
||||||
uiConfig?: UIConfig;
|
public config?: Config;
|
||||||
|
|
||||||
@state()
|
@state()
|
||||||
config?: Config;
|
public brand?: CurrentBrand;
|
||||||
|
|
||||||
@state()
|
constructor({ styleParents = [], ...init }: AKElementInit = {}) {
|
||||||
brand?: CurrentBrand;
|
const styleParent = resolveStyleSheetParent(document);
|
||||||
|
|
||||||
constructor() {
|
super({
|
||||||
super();
|
...init,
|
||||||
document.adoptedStyleSheets = [...document.adoptedStyleSheets, ensureCSSStyleSheet(PFBase)];
|
styleParents: [styleParent, ...styleParents],
|
||||||
this._initContexts();
|
});
|
||||||
this.dataset.akInterfaceRoot = "true";
|
|
||||||
}
|
this.dataset.akInterfaceRoot = this.tagName.toLowerCase();
|
||||||
|
|
||||||
|
appendStyleSheet(Interface.PFBaseStyleSheet, styleParent);
|
||||||
|
|
||||||
_initContexts() {
|
|
||||||
this[brandContext] = new BrandContextController(this);
|
this[brandContext] = new BrandContextController(this);
|
||||||
this[configContext] = new ConfigContextController(this);
|
this[configContext] = new ConfigContextController(this);
|
||||||
this[modalController] = new ModalOrchestrationController(this);
|
this[modalController] = new ModalOrchestrationController(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
_activateTheme(theme: UiThemeEnum, ...roots: DocumentOrShadowRoot[]): void {
|
|
||||||
if (theme === this._activeTheme) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
console.debug(
|
|
||||||
`authentik/interface[${rootInterface()?.tagName.toLowerCase()}]: Enabling theme ${theme}`,
|
|
||||||
);
|
|
||||||
// Special case for root interfaces, as they need to modify the global document CSS too
|
|
||||||
// Instead of calling ._activateTheme() twice, we insert the root document in the call
|
|
||||||
// since multiple calls to ._activateTheme() would not do anything after the first call
|
|
||||||
// as the theme is already enabled.
|
|
||||||
roots.unshift(document as unknown as DocumentOrShadowRoot);
|
|
||||||
super._activateTheme(theme, ...roots);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getTheme(): Promise<UiThemeEnum> {
|
|
||||||
if (!this.uiConfig) {
|
|
||||||
this.uiConfig = await uiConfig();
|
|
||||||
}
|
|
||||||
return this.uiConfig.theme?.base || UiThemeEnum.Automatic;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AkAuthenticatedInterface = AkInterface & {
|
export interface AkAuthenticatedInterface extends ThemedElement {
|
||||||
licenseSummary?: LicenseSummary;
|
licenseSummary?: LicenseSummary;
|
||||||
version?: Version;
|
version?: Version;
|
||||||
};
|
}
|
||||||
|
|
||||||
const enterpriseContext = Symbol("enterpriseContext");
|
const enterpriseContext = Symbol("enterpriseContext");
|
||||||
|
|
||||||
export class AuthenticatedInterface extends Interface {
|
export class AuthenticatedInterface extends Interface implements AkAuthenticatedInterface {
|
||||||
[enterpriseContext]!: EnterpriseContextController;
|
[enterpriseContext]!: EnterpriseContextController;
|
||||||
[versionContext]!: VersionContextController;
|
[versionContext]!: VersionContextController;
|
||||||
|
|
||||||
@state()
|
@state()
|
||||||
licenseSummary?: LicenseSummary;
|
public uiConfig?: UIConfig;
|
||||||
|
|
||||||
@state()
|
@state()
|
||||||
version?: Version;
|
public licenseSummary?: LicenseSummary;
|
||||||
|
|
||||||
constructor() {
|
@state()
|
||||||
super();
|
public version?: Version;
|
||||||
}
|
|
||||||
|
constructor(init?: AKElementInit) {
|
||||||
|
super(init);
|
||||||
|
|
||||||
_initContexts(): void {
|
|
||||||
super._initContexts();
|
|
||||||
this[enterpriseContext] = new EnterpriseContextController(this);
|
this[enterpriseContext] = new EnterpriseContextController(this);
|
||||||
this[versionContext] = new VersionContextController(this);
|
this[versionContext] = new VersionContextController(this);
|
||||||
}
|
}
|
||||||
|
@ -5,20 +5,23 @@ import {
|
|||||||
} from "@goauthentik/common/constants";
|
} from "@goauthentik/common/constants";
|
||||||
import { globalAK } from "@goauthentik/common/global";
|
import { globalAK } from "@goauthentik/common/global";
|
||||||
import { currentInterface } from "@goauthentik/common/sentry";
|
import { currentInterface } from "@goauthentik/common/sentry";
|
||||||
import { UIConfig, UserDisplay, uiConfig } from "@goauthentik/common/ui/config";
|
import { UIConfig, UserDisplay, getConfigForUser } from "@goauthentik/common/ui/config";
|
||||||
import { me } from "@goauthentik/common/users";
|
import { me } from "@goauthentik/common/users";
|
||||||
import "@goauthentik/components/ak-nav-buttons";
|
import "@goauthentik/components/ak-nav-buttons";
|
||||||
import { AKElement } from "@goauthentik/elements/Base";
|
import { AKElement } from "@goauthentik/elements/Base";
|
||||||
import { WithBrandConfig } from "@goauthentik/elements/Interface/brandProvider";
|
import { WithBrandConfig } from "@goauthentik/elements/Interface/brandProvider";
|
||||||
|
import { DefaultBrand } from "@goauthentik/elements/sidebar/SidebarBrand";
|
||||||
|
import { themeImage } from "@goauthentik/elements/utils/images";
|
||||||
import "@patternfly/elements/pf-tooltip/pf-tooltip.js";
|
import "@patternfly/elements/pf-tooltip/pf-tooltip.js";
|
||||||
|
|
||||||
import { msg } from "@lit/localize";
|
import { msg } from "@lit/localize";
|
||||||
import { CSSResult, TemplateResult, css, html, nothing } from "lit";
|
import { CSSResult, LitElement, TemplateResult, css, html, nothing } from "lit";
|
||||||
import { customElement, property, state } from "lit/decorators.js";
|
import { customElement, property, state } from "lit/decorators.js";
|
||||||
|
|
||||||
import PFAvatar from "@patternfly/patternfly/components/Avatar/avatar.css";
|
import PFAvatar from "@patternfly/patternfly/components/Avatar/avatar.css";
|
||||||
import PFButton from "@patternfly/patternfly/components/Button/button.css";
|
import PFButton from "@patternfly/patternfly/components/Button/button.css";
|
||||||
import PFContent from "@patternfly/patternfly/components/Content/content.css";
|
import PFContent from "@patternfly/patternfly/components/Content/content.css";
|
||||||
|
import PFDrawer from "@patternfly/patternfly/components/Drawer/drawer.css";
|
||||||
import PFDropdown from "@patternfly/patternfly/components/Dropdown/dropdown.css";
|
import PFDropdown from "@patternfly/patternfly/components/Dropdown/dropdown.css";
|
||||||
import PFNotificationBadge from "@patternfly/patternfly/components/NotificationBadge/notification-badge.css";
|
import PFNotificationBadge from "@patternfly/patternfly/components/NotificationBadge/notification-badge.css";
|
||||||
import PFPage from "@patternfly/patternfly/components/Page/page.css";
|
import PFPage from "@patternfly/patternfly/components/Page/page.css";
|
||||||
@ -26,34 +29,52 @@ import PFBase from "@patternfly/patternfly/patternfly-base.css";
|
|||||||
|
|
||||||
import { SessionUser } from "@goauthentik/api";
|
import { SessionUser } from "@goauthentik/api";
|
||||||
|
|
||||||
@customElement("ak-page-header")
|
//#region Page Navbar
|
||||||
export class PageHeader extends WithBrandConfig(AKElement) {
|
|
||||||
@property()
|
|
||||||
icon?: string;
|
|
||||||
|
|
||||||
@property({ type: Boolean })
|
export interface PageNavbarDetails {
|
||||||
iconImage = false;
|
header?: string;
|
||||||
|
|
||||||
@property()
|
|
||||||
header = "";
|
|
||||||
|
|
||||||
@property()
|
|
||||||
description?: string;
|
description?: string;
|
||||||
|
icon?: string;
|
||||||
|
iconImage?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
@property({ type: Boolean })
|
/**
|
||||||
hasIcon = true;
|
* A global navbar component at the top of the page.
|
||||||
|
*
|
||||||
|
* Internally, this component listens for the `ak-page-header` event, which is
|
||||||
|
* dispatched by the `ak-page-header` component.
|
||||||
|
*/
|
||||||
|
@customElement("ak-page-navbar")
|
||||||
|
export class AKPageNavbar extends WithBrandConfig(AKElement) implements PageNavbarDetails {
|
||||||
|
//#region Static Properties
|
||||||
|
|
||||||
@state()
|
private static elementRef: AKPageNavbar | null = null;
|
||||||
me?: SessionUser;
|
|
||||||
|
|
||||||
@state()
|
static readonly setNavbarDetails = (detail: Partial<PageNavbarDetails>): void => {
|
||||||
uiConfig!: UIConfig;
|
const { elementRef } = AKPageNavbar;
|
||||||
|
if (!elementRef) {
|
||||||
|
console.debug(
|
||||||
|
`ak-page-header: Could not find ak-page-navbar, skipping event dispatch.`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { header, description, icon, iconImage } = detail;
|
||||||
|
|
||||||
|
elementRef.header = header;
|
||||||
|
elementRef.description = description;
|
||||||
|
elementRef.icon = icon;
|
||||||
|
elementRef.iconImage = iconImage || false;
|
||||||
|
elementRef.hasIcon = !!icon;
|
||||||
|
};
|
||||||
|
|
||||||
static get styles(): CSSResult[] {
|
static get styles(): CSSResult[] {
|
||||||
return [
|
return [
|
||||||
PFBase,
|
PFBase,
|
||||||
PFButton,
|
PFButton,
|
||||||
PFPage,
|
PFPage,
|
||||||
|
PFDrawer,
|
||||||
|
|
||||||
PFNotificationBadge,
|
PFNotificationBadge,
|
||||||
PFContent,
|
PFContent,
|
||||||
PFAvatar,
|
PFAvatar,
|
||||||
@ -63,143 +84,392 @@ export class PageHeader extends WithBrandConfig(AKElement) {
|
|||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: var(--pf-global--ZIndex--lg);
|
z-index: var(--pf-global--ZIndex--lg);
|
||||||
|
--pf-c-page__header-tools--MarginRight: 0;
|
||||||
|
--ak-brand-logo-height: var(--pf-global--FontSize--4xl, 2.25rem);
|
||||||
|
--ak-brand-background-color: var(
|
||||||
|
--pf-c-page__sidebar--m-light--BackgroundColor
|
||||||
|
);
|
||||||
}
|
}
|
||||||
.bar {
|
|
||||||
|
:host([theme="dark"]) {
|
||||||
|
--ak-brand-background-color: var(--pf-c-page__sidebar--BackgroundColor);
|
||||||
|
--pf-c-page__sidebar--BackgroundColor: var(--ak-dark-background-light);
|
||||||
|
color: var(--ak-dark-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
navbar {
|
||||||
border-bottom: var(--pf-global--BorderWidth--sm);
|
border-bottom: var(--pf-global--BorderWidth--sm);
|
||||||
border-bottom-style: solid;
|
border-bottom-style: solid;
|
||||||
border-bottom-color: var(--pf-global--BorderColor--100);
|
border-bottom-color: var(--pf-global--BorderColor--100);
|
||||||
|
background-color: var(--pf-c-page--BackgroundColor);
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
min-height: 114px;
|
min-height: 6rem;
|
||||||
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 (max-width: 768px) {
|
||||||
|
row-gap: var(--pf-global--spacer--xs);
|
||||||
|
|
||||||
|
align-items: center;
|
||||||
|
grid-template-areas:
|
||||||
|
"toggle primary secondary"
|
||||||
|
"toggle description description";
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.pf-c-page__main-section.pf-m-light {
|
|
||||||
background-color: transparent;
|
.items {
|
||||||
|
display: block;
|
||||||
|
|
||||||
|
&.primary {
|
||||||
|
grid-column: primary;
|
||||||
|
grid-row: primary / description;
|
||||||
|
|
||||||
|
align-content: center;
|
||||||
|
padding-block: var(--pf-global--spacer--md);
|
||||||
|
|
||||||
|
@media (min-width: 426px) {
|
||||||
|
&.block-sibling {
|
||||||
|
padding-block-end: 0;
|
||||||
|
grid-row: primary;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
padding-block: var(--pf-global--spacer--sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.accent-icon {
|
||||||
|
height: 1em;
|
||||||
|
width: 1em;
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.page-description {
|
||||||
|
grid-area: description;
|
||||||
|
padding-block-end: var(--pf-global--spacer--md);
|
||||||
|
|
||||||
|
@media (max-width: 425px) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 769px) {
|
||||||
|
text-wrap: balance;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.secondary {
|
||||||
|
grid-area: secondary;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
justify-self: end;
|
||||||
|
padding-block: var(--pf-global--spacer--sm);
|
||||||
|
padding-inline-end: var(--pf-global--spacer--sm);
|
||||||
|
|
||||||
|
@media (min-width: 769px) {
|
||||||
|
align-content: center;
|
||||||
|
padding-block: var(--pf-global--spacer--md);
|
||||||
|
padding-inline-end: var(--pf-global--spacer--xl);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.pf-c-page__main-section {
|
|
||||||
flex-grow: 1;
|
.brand {
|
||||||
flex-shrink: 1;
|
grid-area: brand;
|
||||||
|
background-color: var(--ak-brand-background-color);
|
||||||
|
height: 100%;
|
||||||
|
width: var(--pf-c-page__sidebar--Width);
|
||||||
|
align-items: center;
|
||||||
|
padding-inline: var(--pf-global--spacer--sm);
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
||||||
|
&.pf-m-collapsed {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@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,
|
.sidebar-trigger,
|
||||||
.notification-trigger {
|
.notification-trigger {
|
||||||
font-size: 24px;
|
font-size: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.notification-trigger.has-notifications {
|
.notification-trigger.has-notifications {
|
||||||
color: var(--pf-global--active-color--100);
|
color: var(--pf-global--active-color--100);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--pf-global--spacer--xs);
|
||||||
|
}
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: center !important;
|
align-items: center !important;
|
||||||
}
|
}
|
||||||
.pf-c-page__header-tools {
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.pf-c-page__header-tools-group {
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
:host([theme="dark"]) .pf-c-page__header-tools {
|
|
||||||
color: var(--ak-dark-foreground) !important;
|
|
||||||
}
|
|
||||||
`,
|
`,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor() {
|
//#endregion
|
||||||
super();
|
|
||||||
window.addEventListener(EVENT_WS_MESSAGE, () => {
|
|
||||||
this.firstUpdated();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async firstUpdated() {
|
//#region Properties
|
||||||
this.me = await me();
|
|
||||||
this.uiConfig = await uiConfig();
|
|
||||||
this.uiConfig.navbar.userDisplay = UserDisplay.none;
|
|
||||||
}
|
|
||||||
|
|
||||||
setTitle(header?: string) {
|
@property({ type: String })
|
||||||
|
icon?: string;
|
||||||
|
|
||||||
|
@property({ type: Boolean })
|
||||||
|
iconImage = false;
|
||||||
|
|
||||||
|
@property({ type: String })
|
||||||
|
header?: string;
|
||||||
|
|
||||||
|
@property({ type: String })
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@property({ type: Boolean })
|
||||||
|
hasIcon = true;
|
||||||
|
|
||||||
|
@property({ type: Boolean })
|
||||||
|
open = true;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
session?: SessionUser;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
uiConfig!: UIConfig;
|
||||||
|
|
||||||
|
//#endregion
|
||||||
|
|
||||||
|
//#region Private Methods
|
||||||
|
|
||||||
|
#setTitle(header?: string) {
|
||||||
const currentIf = currentInterface();
|
const currentIf = currentInterface();
|
||||||
let title = this.brand?.brandingTitle || TITLE_DEFAULT;
|
let title = this.brand?.brandingTitle || TITLE_DEFAULT;
|
||||||
|
|
||||||
if (currentIf === "admin") {
|
if (currentIf === "admin") {
|
||||||
title = `${msg("Admin")} - ${title}`;
|
title = `${msg("Admin")} - ${title}`;
|
||||||
}
|
}
|
||||||
// Prepend the header to the title
|
// Prepend the header to the title
|
||||||
if (header !== undefined && header !== "") {
|
if (header) {
|
||||||
title = `${header} - ${title}`;
|
title = `${header} - ${title}`;
|
||||||
}
|
}
|
||||||
document.title = title;
|
document.title = title;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#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() {
|
willUpdate() {
|
||||||
// Always update title, even if there's no header value set,
|
// Always update title, even if there's no header value set,
|
||||||
// as in that case we still need to return to the generic title
|
// 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() {
|
renderIcon() {
|
||||||
if (this.icon) {
|
if (this.icon) {
|
||||||
if (this.iconImage && !this.icon.startsWith("fa://")) {
|
if (this.iconImage && !this.icon.startsWith("fa://")) {
|
||||||
return html`<img class="pf-icon" src="${this.icon}" alt="page icon" />`;
|
return html`<img class="accent-icon pf-icon" src="${this.icon}" alt="page icon" />`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const icon = this.icon.replaceAll("fa://", "fa ");
|
const icon = this.icon.replaceAll("fa://", "fa ");
|
||||||
return html`<i class=${icon}></i>`;
|
|
||||||
|
return html`<i class="accent-icon ${icon}"></i>`;
|
||||||
}
|
}
|
||||||
return nothing;
|
return nothing;
|
||||||
}
|
}
|
||||||
|
|
||||||
render(): TemplateResult {
|
render(): TemplateResult {
|
||||||
return html`<div class="bar">
|
return html`<navbar aria-label="Main" class="navbar">
|
||||||
<button
|
<aside class="brand ${this.open ? "" : "pf-m-collapsed"}">
|
||||||
class="sidebar-trigger pf-c-button pf-m-plain"
|
<a href="#/">
|
||||||
@click=${() => {
|
<div class="logo">
|
||||||
this.dispatchEvent(
|
<img
|
||||||
new CustomEvent(EVENT_SIDEBAR_TOGGLE, {
|
src=${themeImage(
|
||||||
bubbles: true,
|
this.brand?.brandingLogo ?? DefaultBrand.brandingLogo,
|
||||||
composed: true,
|
)}
|
||||||
}),
|
alt="${msg("authentik Logo")}"
|
||||||
);
|
loading="lazy"
|
||||||
}}
|
/>
|
||||||
>
|
</div>
|
||||||
<i class="fas fa-bars"></i>
|
</a>
|
||||||
</button>
|
</aside>
|
||||||
<section class="pf-c-page__main-section pf-m-light">
|
<button
|
||||||
<div class="pf-c-content">
|
class="sidebar-trigger pf-c-button pf-m-plain"
|
||||||
<h1>
|
@click=${this.#toggleSidebar}
|
||||||
|
aria-label=${msg("Toggle sidebar")}
|
||||||
|
aria-expanded=${this.open ? "true" : "false"}
|
||||||
|
>
|
||||||
|
<i class="fas fa-bars"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<section
|
||||||
|
class="items primary pf-c-content ${this.description ? "block-sibling" : ""}"
|
||||||
|
>
|
||||||
|
<h1 class="page-title">
|
||||||
${this.hasIcon
|
${this.hasIcon
|
||||||
? html`<slot name="icon">${this.renderIcon()}</slot> `
|
? html`<slot name="icon">${this.renderIcon()}</slot>`
|
||||||
: nothing}
|
: nothing}
|
||||||
<slot name="header">${this.header}</slot>
|
${this.header}
|
||||||
</h1>
|
</h1>
|
||||||
${this.description ? html`<p>${this.description}</p>` : html``}
|
</section>
|
||||||
</div>
|
${this.description
|
||||||
</section>
|
? html`<section class="items page-description pf-c-content">
|
||||||
<div class="pf-c-page__header-tools">
|
<p>${this.description}</p>
|
||||||
<div class="pf-c-page__header-tools-group">
|
</section>`
|
||||||
<ak-nav-buttons .uiConfig=${this.uiConfig} .me=${this.me}>
|
: nothing}
|
||||||
<a
|
|
||||||
class="pf-c-button pf-m-secondary pf-m-small pf-u-display-none pf-u-display-block-on-md"
|
<section class="items secondary">
|
||||||
href="${globalAK().api.base}if/user/"
|
<div class="pf-c-page__header-tools-group">
|
||||||
slot="extra"
|
<ak-nav-buttons .uiConfig=${this.uiConfig} .me=${this.session}>
|
||||||
>
|
<a
|
||||||
${msg("User interface")}
|
class="pf-c-button pf-m-secondary pf-m-small pf-u-display-none pf-u-display-block-on-md"
|
||||||
</a>
|
href="${globalAK().api.base}if/user/"
|
||||||
</ak-nav-buttons>
|
slot="extra"
|
||||||
</div>
|
>
|
||||||
</div>
|
${msg("User interface")}
|
||||||
</div>`;
|
</a>
|
||||||
|
</ak-nav-buttons>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</navbar>
|
||||||
|
<slot></slot>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
//#endregion
|
||||||
|
}
|
||||||
|
|
||||||
|
//#endregion
|
||||||
|
|
||||||
|
//#region Page Header
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A page header component, used to display the page title and description.
|
||||||
|
*
|
||||||
|
* Internally, this component dispatches the `ak-page-header` event, which is
|
||||||
|
* listened to by the `ak-page-navbar` component.
|
||||||
|
*
|
||||||
|
* @singleton
|
||||||
|
*/
|
||||||
|
@customElement("ak-page-header")
|
||||||
|
export class AKPageHeader extends LitElement implements PageNavbarDetails {
|
||||||
|
@property({ type: String })
|
||||||
|
header?: string;
|
||||||
|
|
||||||
|
@property({ type: String })
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@property({ type: String })
|
||||||
|
icon?: string;
|
||||||
|
|
||||||
|
@property({ type: Boolean })
|
||||||
|
iconImage = false;
|
||||||
|
|
||||||
|
static get styles(): CSSResult[] {
|
||||||
|
return [
|
||||||
|
css`
|
||||||
|
:host {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
connectedCallback(): void {
|
||||||
|
super.connectedCallback();
|
||||||
|
|
||||||
|
AKPageNavbar.setNavbarDetails({
|
||||||
|
header: this.header,
|
||||||
|
description: this.description,
|
||||||
|
icon: this.icon,
|
||||||
|
iconImage: this.iconImage,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
updated(): void {
|
||||||
|
AKPageNavbar.setNavbarDetails({
|
||||||
|
header: this.header,
|
||||||
|
description: this.description,
|
||||||
|
icon: this.icon,
|
||||||
|
iconImage: this.iconImage,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//#endregion
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface HTMLElementTagNameMap {
|
interface HTMLElementTagNameMap {
|
||||||
"ak-page-header": PageHeader;
|
"ak-page-header": AKPageHeader;
|
||||||
|
"ak-page-navbar": AKPageNavbar;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -80,6 +80,7 @@ export class AggregateCard extends AKElement implements IAggregateCard {
|
|||||||
.center-value {
|
.center-value {
|
||||||
font-size: var(--pf-global--icon--FontSize--lg);
|
font-size: var(--pf-global--icon--FontSize--lg);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
place-content: center;
|
||||||
}
|
}
|
||||||
.subtext {
|
.subtext {
|
||||||
margin-top: var(--pf-global--spacer--sm);
|
margin-top: var(--pf-global--spacer--sm);
|
||||||
|
@ -22,6 +22,7 @@ export class Sidebar extends AKElement {
|
|||||||
css`
|
css`
|
||||||
:host {
|
:host {
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
|
--pf-c-page__sidebar--Transition: 0 !important;
|
||||||
}
|
}
|
||||||
.pf-c-nav__link.pf-m-current::after,
|
.pf-c-nav__link.pf-m-current::after,
|
||||||
.pf-c-nav__link.pf-m-current:hover::after,
|
.pf-c-nav__link.pf-m-current:hover::after,
|
||||||
@ -35,10 +36,7 @@ export class Sidebar extends AKElement {
|
|||||||
.pf-c-nav__section + .pf-c-nav__section {
|
.pf-c-nav__section + .pf-c-nav__section {
|
||||||
--pf-c-nav__section--section--MarginTop: var(--pf-global--spacer--sm);
|
--pf-c-nav__section--section--MarginTop: var(--pf-global--spacer--sm);
|
||||||
}
|
}
|
||||||
.pf-c-nav__list .sidebar-brand {
|
|
||||||
max-height: 82px;
|
|
||||||
margin-bottom: -0.5rem;
|
|
||||||
}
|
|
||||||
nav {
|
nav {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@ -70,7 +68,6 @@ export class Sidebar extends AKElement {
|
|||||||
class="pf-c-nav ${this.activeTheme === UiThemeEnum.Light ? "pf-m-light" : ""}"
|
class="pf-c-nav ${this.activeTheme === UiThemeEnum.Light ? "pf-m-light" : ""}"
|
||||||
aria-label=${msg("Global")}
|
aria-label=${msg("Global")}
|
||||||
>
|
>
|
||||||
<ak-sidebar-brand></ak-sidebar-brand>
|
|
||||||
<ul class="pf-c-nav__list">
|
<ul class="pf-c-nav__list">
|
||||||
<slot></slot>
|
<slot></slot>
|
||||||
</ul>
|
</ul>
|
||||||
|
@ -1,17 +1,3 @@
|
|||||||
import { EVENT_SIDEBAR_TOGGLE } from "@goauthentik/common/constants";
|
|
||||||
import { AKElement } from "@goauthentik/elements/Base";
|
|
||||||
import { WithBrandConfig } from "@goauthentik/elements/Interface/brandProvider";
|
|
||||||
import { themeImage } from "@goauthentik/elements/utils/images";
|
|
||||||
|
|
||||||
import { msg } from "@lit/localize";
|
|
||||||
import { CSSResult, TemplateResult, css, html } from "lit";
|
|
||||||
import { customElement } from "lit/decorators.js";
|
|
||||||
|
|
||||||
import PFButton from "@patternfly/patternfly/components/Button/button.css";
|
|
||||||
import PFPage from "@patternfly/patternfly/components/Page/page.css";
|
|
||||||
import PFGlobal from "@patternfly/patternfly/patternfly-base.css";
|
|
||||||
import PFBase from "@patternfly/patternfly/patternfly-base.css";
|
|
||||||
|
|
||||||
import { CurrentBrand, UiThemeEnum } from "@goauthentik/api";
|
import { CurrentBrand, UiThemeEnum } from "@goauthentik/api";
|
||||||
|
|
||||||
// If the viewport is wider than MIN_WIDTH, the sidebar
|
// If the viewport is wider than MIN_WIDTH, the sidebar
|
||||||
@ -28,79 +14,3 @@ export const DefaultBrand: CurrentBrand = {
|
|||||||
matchedDomain: "",
|
matchedDomain: "",
|
||||||
defaultLocale: "",
|
defaultLocale: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
@customElement("ak-sidebar-brand")
|
|
||||||
export class SidebarBrand extends WithBrandConfig(AKElement) {
|
|
||||||
static get styles(): CSSResult[] {
|
|
||||||
return [
|
|
||||||
PFBase,
|
|
||||||
PFGlobal,
|
|
||||||
PFPage,
|
|
||||||
PFButton,
|
|
||||||
css`
|
|
||||||
:host {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: center;
|
|
||||||
height: 114px;
|
|
||||||
min-height: 114px;
|
|
||||||
border-bottom: var(--pf-global--BorderWidth--sm);
|
|
||||||
border-bottom-style: solid;
|
|
||||||
border-bottom-color: var(--pf-global--BorderColor--100);
|
|
||||||
}
|
|
||||||
.pf-c-brand img {
|
|
||||||
padding: 0 0.5rem;
|
|
||||||
height: 42px;
|
|
||||||
}
|
|
||||||
button.pf-c-button.sidebar-trigger {
|
|
||||||
background-color: transparent;
|
|
||||||
border-radius: 0px;
|
|
||||||
height: 100%;
|
|
||||||
color: var(--ak-dark-foreground);
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
super();
|
|
||||||
window.addEventListener("resize", () => {
|
|
||||||
this.requestUpdate();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
render(): TemplateResult {
|
|
||||||
return html` ${window.innerWidth <= MIN_WIDTH
|
|
||||||
? html`
|
|
||||||
<button
|
|
||||||
class="sidebar-trigger pf-c-button"
|
|
||||||
@click=${() => {
|
|
||||||
this.dispatchEvent(
|
|
||||||
new CustomEvent(EVENT_SIDEBAR_TOGGLE, {
|
|
||||||
bubbles: true,
|
|
||||||
composed: true,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<i class="fas fa-bars"></i>
|
|
||||||
</button>
|
|
||||||
`
|
|
||||||
: html``}
|
|
||||||
<a href="#/" class="pf-c-page__header-brand-link">
|
|
||||||
<div class="pf-c-brand ak-brand">
|
|
||||||
<img
|
|
||||||
src=${themeImage(this.brand?.brandingLogo ?? DefaultBrand.brandingLogo)}
|
|
||||||
alt="${msg("authentik Logo")}"
|
|
||||||
loading="lazy"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</a>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
declare global {
|
|
||||||
interface HTMLElementTagNameMap {
|
|
||||||
"ak-sidebar-brand": SidebarBrand;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
@ -1,19 +1,20 @@
|
|||||||
|
import {
|
||||||
|
appendStyleSheet,
|
||||||
|
assertAdoptableStyleSheetParent,
|
||||||
|
} from "@goauthentik/common/stylesheets.js";
|
||||||
|
|
||||||
import { TemplateResult, render as litRender } from "lit";
|
import { TemplateResult, render as litRender } from "lit";
|
||||||
|
|
||||||
import AKGlobal from "@goauthentik/common/styles/authentik.css";
|
import AKGlobal from "@goauthentik/common/styles/authentik.css";
|
||||||
import PFBase from "@patternfly/patternfly/patternfly-base.css";
|
import PFBase from "@patternfly/patternfly/patternfly-base.css";
|
||||||
|
|
||||||
import { ensureCSSStyleSheet } from "../utils/ensureCSSStyleSheet.js";
|
|
||||||
|
|
||||||
// A special version of render that ensures our style sheets will always be available
|
// A special version of render that ensures our style sheets will always be available
|
||||||
// to all elements under test. Ensures they look right during testing, and that any
|
// to all elements under test. Ensures they look right during testing, and that any
|
||||||
// CSS-based checks for visibility will return correct values.
|
// CSS-based checks for visibility will return correct values.
|
||||||
|
|
||||||
export const render = (body: TemplateResult) => {
|
export const render = (body: TemplateResult) => {
|
||||||
document.adoptedStyleSheets = [
|
assertAdoptableStyleSheetParent(document);
|
||||||
...document.adoptedStyleSheets,
|
|
||||||
ensureCSSStyleSheet(PFBase),
|
appendStyleSheet([PFBase, AKGlobal], document);
|
||||||
ensureCSSStyleSheet(AKGlobal),
|
|
||||||
];
|
|
||||||
return litRender(body, document.body);
|
return litRender(body, document.body);
|
||||||
};
|
};
|
||||||
|
@ -1,9 +1,14 @@
|
|||||||
import { AKElement } from "@goauthentik/elements/Base";
|
|
||||||
|
|
||||||
import { type LitElement, type ReactiveControllerHost, type TemplateResult, nothing } from "lit";
|
import { type LitElement, type ReactiveControllerHost, type TemplateResult, nothing } from "lit";
|
||||||
import "lit";
|
import "lit";
|
||||||
|
|
||||||
export type ReactiveElementHost<T = AKElement> = Partial<ReactiveControllerHost> & T;
|
/**
|
||||||
|
* A custom element which may be used as a host for a ReactiveController.
|
||||||
|
*
|
||||||
|
* @remarks
|
||||||
|
*
|
||||||
|
* This type is derived from an internal type in Lit.
|
||||||
|
*/
|
||||||
|
export type ReactiveElementHost<T> = Partial<ReactiveControllerHost & T> & HTMLElement;
|
||||||
|
|
||||||
export type AbstractLitElementConstructor = abstract new (...args: never[]) => LitElement;
|
export type AbstractLitElementConstructor = abstract new (...args: never[]) => LitElement;
|
||||||
|
|
||||||
|
@ -1,35 +0,0 @@
|
|||||||
import { CSSResult, unsafeCSS } from "lit";
|
|
||||||
|
|
||||||
const supportsAdoptingStyleSheets: boolean =
|
|
||||||
window.ShadowRoot &&
|
|
||||||
(window.ShadyCSS === undefined || window.ShadyCSS.nativeShadow) &&
|
|
||||||
"adoptedStyleSheets" in Document.prototype &&
|
|
||||||
"replace" in CSSStyleSheet.prototype;
|
|
||||||
|
|
||||||
function stringToStylesheet(css: string) {
|
|
||||||
if (supportsAdoptingStyleSheets) {
|
|
||||||
const sheet = unsafeCSS(css).styleSheet;
|
|
||||||
if (sheet === undefined) {
|
|
||||||
throw new Error(
|
|
||||||
`CSS processing error: undefined stylesheet from string. Source: ${css}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return sheet;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sheet = new CSSStyleSheet();
|
|
||||||
sheet.replaceSync(css);
|
|
||||||
return sheet;
|
|
||||||
}
|
|
||||||
|
|
||||||
function cssResultToStylesheet(css: CSSResult) {
|
|
||||||
const sheet = css.styleSheet;
|
|
||||||
return sheet ? sheet : stringToStylesheet(css.toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ensureCSSStyleSheet = (css: string | CSSStyleSheet | CSSResult): CSSStyleSheet =>
|
|
||||||
css instanceof CSSResult
|
|
||||||
? cssResultToStylesheet(css)
|
|
||||||
: typeof css === "string"
|
|
||||||
? stringToStylesheet(css)
|
|
||||||
: css;
|
|
55
web/src/elements/utils/iframe.ts
Normal file
55
web/src/elements/utils/iframe.ts
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
/**
|
||||||
|
* @file IFrame Utilities
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface IFrameLoadResult {
|
||||||
|
contentWindow: Window;
|
||||||
|
contentDocument: Document;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pluckIFrameContent(iframe: HTMLIFrameElement) {
|
||||||
|
const contentWindow = iframe.contentWindow;
|
||||||
|
const contentDocument = iframe.contentDocument;
|
||||||
|
|
||||||
|
if (!contentWindow) {
|
||||||
|
throw new Error("Iframe contentWindow is not accessible");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!contentDocument) {
|
||||||
|
throw new Error("Iframe contentDocument is not accessible");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
contentWindow,
|
||||||
|
contentDocument,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveIFrameContent(iframe: HTMLIFrameElement): Promise<IFrameLoadResult> {
|
||||||
|
if (iframe.contentDocument?.readyState === "complete") {
|
||||||
|
return Promise.resolve(pluckIFrameContent(iframe));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
iframe.addEventListener("load", () => resolve(pluckIFrameContent(iframe)), { once: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a minimal HTML wrapper for an iframe.
|
||||||
|
*
|
||||||
|
* @deprecated Use the `contentDocument.body` directly instead.
|
||||||
|
*/
|
||||||
|
export function createIFrameHTMLWrapper(bodyContent: string): string {
|
||||||
|
const html = String.raw;
|
||||||
|
|
||||||
|
return html`<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
</head>
|
||||||
|
<body style="display:flex;flex-direction:row;justify-content:center;">
|
||||||
|
${bodyContent}
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
@ -1,13 +1,8 @@
|
|||||||
import { QUERY_MEDIA_COLOR_LIGHT, rootInterface } from "@goauthentik/elements/Base";
|
import { resolveUITheme } from "@goauthentik/common/theme";
|
||||||
|
import { rootInterface } from "@goauthentik/elements/Base";
|
||||||
import { UiThemeEnum } from "@goauthentik/api";
|
|
||||||
|
|
||||||
export function themeImage(rawPath: string) {
|
export function themeImage(rawPath: string) {
|
||||||
let enabledTheme = rootInterface()?.activeTheme;
|
const enabledTheme = rootInterface()?.activeTheme || resolveUITheme();
|
||||||
if (!enabledTheme || enabledTheme === UiThemeEnum.Automatic) {
|
|
||||||
enabledTheme = window.matchMedia(QUERY_MEDIA_COLOR_LIGHT).matches
|
|
||||||
? UiThemeEnum.Light
|
|
||||||
: UiThemeEnum.Dark;
|
|
||||||
}
|
|
||||||
return rawPath.replaceAll("%(theme)s", enabledTheme);
|
return rawPath.replaceAll("%(theme)s", enabledTheme);
|
||||||
}
|
}
|
||||||
|
@ -46,7 +46,6 @@ import {
|
|||||||
FlowsApi,
|
FlowsApi,
|
||||||
ResponseError,
|
ResponseError,
|
||||||
ShellChallenge,
|
ShellChallenge,
|
||||||
UiThemeEnum,
|
|
||||||
} from "@goauthentik/api";
|
} from "@goauthentik/api";
|
||||||
|
|
||||||
@customElement("ak-flow-executor")
|
@customElement("ak-flow-executor")
|
||||||
@ -200,10 +199,6 @@ export class FlowExecutor extends Interface implements StageHost {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async getTheme(): Promise<UiThemeEnum> {
|
|
||||||
return globalAK()?.brand.uiTheme || UiThemeEnum.Automatic;
|
|
||||||
}
|
|
||||||
|
|
||||||
async submit(
|
async submit(
|
||||||
payload?: FlowChallengeResponseRequest,
|
payload?: FlowChallengeResponseRequest,
|
||||||
options?: SubmitOptions,
|
options?: SubmitOptions,
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { purify } from "@goauthentik/common/purify";
|
import { BrandedHTMLPolicy, sanitizeHTML } from "@goauthentik/common/purify";
|
||||||
import { AKElement } from "@goauthentik/elements/Base.js";
|
import { AKElement } from "@goauthentik/elements/Base.js";
|
||||||
|
|
||||||
import { msg } from "@lit/localize";
|
import { msg } from "@lit/localize";
|
||||||
@ -21,8 +21,6 @@ const styles = css`
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const poweredBy: FooterLink = { name: msg("Powered by authentik"), href: null };
|
|
||||||
|
|
||||||
@customElement("ak-brand-links")
|
@customElement("ak-brand-links")
|
||||||
export class BrandLinks extends AKElement {
|
export class BrandLinks extends AKElement {
|
||||||
static get styles() {
|
static get styles() {
|
||||||
@ -33,13 +31,21 @@ export class BrandLinks extends AKElement {
|
|||||||
links: FooterLink[] = [];
|
links: FooterLink[] = [];
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const links = [...(this.links ?? []), poweredBy];
|
const links = [...(this.links ?? [])];
|
||||||
|
|
||||||
return html` <ul class="pf-c-list pf-m-inline">
|
return html` <ul class="pf-c-list pf-m-inline">
|
||||||
${map(links, (link) =>
|
${map(links, (link) => {
|
||||||
link.href
|
const children = sanitizeHTML(BrandedHTMLPolicy, link.name);
|
||||||
? purify(html`<li><a href="${link.href}">${link.name}</a></li>`)
|
|
||||||
: html`<li><span>${link.name}</span></li>`,
|
if (link.href) {
|
||||||
)}
|
return html`<li><a href="${link.href}">${children}</a></li>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return html`<li>
|
||||||
|
<span> ${children} </span>
|
||||||
|
</li>`;
|
||||||
|
})}
|
||||||
|
<li><span>${msg("Powered by authentik")}</span></li>
|
||||||
</ul>`;
|
</ul>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,15 +1,16 @@
|
|||||||
///<reference types="@hcaptcha/types"/>
|
/// <reference types="@hcaptcha/types"/>
|
||||||
import { renderStatic } from "@goauthentik/common/purify";
|
/// <reference types="turnstile-types"/>
|
||||||
|
import { renderStaticHTMLUnsafe } from "@goauthentik/common/purify";
|
||||||
import "@goauthentik/elements/EmptyState";
|
import "@goauthentik/elements/EmptyState";
|
||||||
import { akEmptyState } from "@goauthentik/elements/EmptyState";
|
import { akEmptyState } from "@goauthentik/elements/EmptyState";
|
||||||
import { bound } from "@goauthentik/elements/decorators/bound";
|
import { bound } from "@goauthentik/elements/decorators/bound";
|
||||||
import "@goauthentik/elements/forms/FormElement";
|
import "@goauthentik/elements/forms/FormElement";
|
||||||
|
import { createIFrameHTMLWrapper } from "@goauthentik/elements/utils/iframe";
|
||||||
import { ListenerController } from "@goauthentik/elements/utils/listenerController.js";
|
import { ListenerController } from "@goauthentik/elements/utils/listenerController.js";
|
||||||
import { randomId } from "@goauthentik/elements/utils/randomId";
|
import { randomId } from "@goauthentik/elements/utils/randomId";
|
||||||
import "@goauthentik/flow/FormStatic";
|
import "@goauthentik/flow/FormStatic";
|
||||||
import { BaseStage } from "@goauthentik/flow/stages/base";
|
import { BaseStage } from "@goauthentik/flow/stages/base";
|
||||||
import { P, match } from "ts-pattern";
|
import { P, match } from "ts-pattern";
|
||||||
import type * as _ from "turnstile-types";
|
|
||||||
|
|
||||||
import { msg } from "@lit/localize";
|
import { msg } from "@lit/localize";
|
||||||
import { CSSResult, PropertyValues, TemplateResult, css, html, nothing } from "lit";
|
import { CSSResult, PropertyValues, TemplateResult, css, html, nothing } from "lit";
|
||||||
@ -56,40 +57,36 @@ type CaptchaHandler = {
|
|||||||
// a resize. Because the Captcha is itself in an iframe, the reported height is often off by some
|
// a resize. Because the Captcha is itself in an iframe, the reported height is often off by some
|
||||||
// margin, so adding 2rem of height to our container adds padding and prevents scroll bars or hidden
|
// margin, so adding 2rem of height to our container adds padding and prevents scroll bars or hidden
|
||||||
// rendering.
|
// rendering.
|
||||||
|
function iframeTemplate(children: TemplateResult, challengeURL: string): TemplateResult {
|
||||||
|
return html` ${children}
|
||||||
|
<script>
|
||||||
|
new ResizeObserver((entries) => {
|
||||||
|
const height =
|
||||||
|
document.body.offsetHeight +
|
||||||
|
parseFloat(getComputedStyle(document.body).fontSize) * 2;
|
||||||
|
|
||||||
const iframeTemplate = (captchaElement: TemplateResult, challengeUrl: string) =>
|
window.parent.postMessage({
|
||||||
html`<!doctype html>
|
message: "resize",
|
||||||
<head>
|
source: "goauthentik.io",
|
||||||
<html>
|
context: "flow-executor",
|
||||||
<body style="display:flex;flex-direction:row;justify-content:center;">
|
size: { height },
|
||||||
${captchaElement}
|
});
|
||||||
<script>
|
}).observe(document.querySelector(".ak-captcha-container"));
|
||||||
new ResizeObserver((entries) => {
|
</script>
|
||||||
const height =
|
|
||||||
document.body.offsetHeight +
|
<script src=${challengeURL}></script>
|
||||||
parseFloat(getComputedStyle(document.body).fontSize) * 2;
|
|
||||||
window.parent.postMessage({
|
<script>
|
||||||
message: "resize",
|
function callback(token) {
|
||||||
source: "goauthentik.io",
|
window.parent.postMessage({
|
||||||
context: "flow-executor",
|
message: "captcha",
|
||||||
size: { height },
|
source: "goauthentik.io",
|
||||||
});
|
context: "flow-executor",
|
||||||
}).observe(document.querySelector(".ak-captcha-container"));
|
token,
|
||||||
</script>
|
});
|
||||||
<script src=${challengeUrl}></script>
|
}
|
||||||
<script>
|
</script>`;
|
||||||
function callback(token) {
|
}
|
||||||
window.parent.postMessage({
|
|
||||||
message: "captcha",
|
|
||||||
source: "goauthentik.io",
|
|
||||||
context: "flow-executor",
|
|
||||||
token: token,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
</head>`;
|
|
||||||
|
|
||||||
@customElement("ak-stage-captcha")
|
@customElement("ak-stage-captcha")
|
||||||
export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeResponseRequest> {
|
export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeResponseRequest> {
|
||||||
@ -305,11 +302,25 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
|
|||||||
}
|
}
|
||||||
|
|
||||||
async renderFrame(captchaElement: TemplateResult) {
|
async renderFrame(captchaElement: TemplateResult) {
|
||||||
this.captchaFrame.contentWindow?.document.open();
|
const { contentDocument } = this.captchaFrame || {};
|
||||||
this.captchaFrame.contentWindow?.document.write(
|
|
||||||
await renderStatic(iframeTemplate(captchaElement, this.challenge.jsUrl)),
|
if (!contentDocument) {
|
||||||
|
console.debug(
|
||||||
|
"authentik/stages/captcha: unable to render captcha frame, no contentDocument",
|
||||||
|
);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
contentDocument.open();
|
||||||
|
|
||||||
|
contentDocument.write(
|
||||||
|
createIFrameHTMLWrapper(
|
||||||
|
renderStaticHTMLUnsafe(iframeTemplate(captchaElement, this.challenge.jsUrl)),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
this.captchaFrame.contentWindow?.document.close();
|
|
||||||
|
contentDocument.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
renderBody() {
|
renderBody() {
|
||||||
|
@ -3,7 +3,6 @@ import "rapidoc";
|
|||||||
|
|
||||||
import { CSRFHeaderName } from "@goauthentik/common/api/config";
|
import { CSRFHeaderName } from "@goauthentik/common/api/config";
|
||||||
import { EVENT_THEME_CHANGE } from "@goauthentik/common/constants";
|
import { EVENT_THEME_CHANGE } from "@goauthentik/common/constants";
|
||||||
import { globalAK } from "@goauthentik/common/global";
|
|
||||||
import { first, getCookie } from "@goauthentik/common/utils";
|
import { first, getCookie } from "@goauthentik/common/utils";
|
||||||
import { Interface } from "@goauthentik/elements/Interface";
|
import { Interface } from "@goauthentik/elements/Interface";
|
||||||
import "@goauthentik/elements/ak-locale-context";
|
import "@goauthentik/elements/ak-locale-context";
|
||||||
@ -62,10 +61,6 @@ export class APIBrowser extends Interface {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getTheme(): Promise<UiThemeEnum> {
|
|
||||||
return globalAK()?.brand.uiTheme || UiThemeEnum.Automatic;
|
|
||||||
}
|
|
||||||
|
|
||||||
render(): TemplateResult {
|
render(): TemplateResult {
|
||||||
return html`
|
return html`
|
||||||
<ak-locale-context>
|
<ak-locale-context>
|
||||||
|
@ -1,4 +1,3 @@
|
|||||||
import { globalAK } from "@goauthentik/common/global";
|
|
||||||
import { Interface } from "@goauthentik/elements/Interface";
|
import { Interface } from "@goauthentik/elements/Interface";
|
||||||
|
|
||||||
import { msg } from "@lit/localize";
|
import { msg } from "@lit/localize";
|
||||||
@ -10,8 +9,6 @@ import PFPage from "@patternfly/patternfly/components/Page/page.css";
|
|||||||
import PFSpinner from "@patternfly/patternfly/components/Spinner/spinner.css";
|
import PFSpinner from "@patternfly/patternfly/components/Spinner/spinner.css";
|
||||||
import PFBase from "@patternfly/patternfly/patternfly-base.css";
|
import PFBase from "@patternfly/patternfly/patternfly-base.css";
|
||||||
|
|
||||||
import { UiThemeEnum } from "@goauthentik/api";
|
|
||||||
|
|
||||||
@customElement("ak-loading")
|
@customElement("ak-loading")
|
||||||
export class Loading extends Interface {
|
export class Loading extends Interface {
|
||||||
static get styles(): CSSResult[] {
|
static get styles(): CSSResult[] {
|
||||||
@ -28,7 +25,7 @@ export class Loading extends Interface {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
_initContexts(): void {
|
registerContexts(): void {
|
||||||
// Stub function to avoid making API requests for things we don't need. The `Interface` base class loads
|
// Stub function to avoid making API requests for things we don't need. The `Interface` base class loads
|
||||||
// a bunch of data that is used globally by various things, however this is an interface that is shown
|
// a bunch of data that is used globally by various things, however this is an interface that is shown
|
||||||
// very briefly and we don't need any of that data.
|
// very briefly and we don't need any of that data.
|
||||||
@ -38,10 +35,6 @@ export class Loading extends Interface {
|
|||||||
// Stub function to avoid fetching custom CSS.
|
// Stub function to avoid fetching custom CSS.
|
||||||
}
|
}
|
||||||
|
|
||||||
async getTheme(): Promise<UiThemeEnum> {
|
|
||||||
return globalAK()?.brand.uiTheme || UiThemeEnum.Automatic;
|
|
||||||
}
|
|
||||||
|
|
||||||
render(): TemplateResult {
|
render(): TemplateResult {
|
||||||
return html` <section
|
return html` <section
|
||||||
class="ak-static-page pf-c-page__main-section pf-m-no-padding-mobile pf-m-xl"
|
class="ak-static-page pf-c-page__main-section pf-m-no-padding-mobile pf-m-xl"
|
||||||
|
@ -1,18 +1,9 @@
|
|||||||
import { FlowExecutor } from "@goauthentik/flow/FlowExecutor";
|
import { FlowExecutor } from "@goauthentik/flow/FlowExecutor";
|
||||||
|
|
||||||
import { customElement, property } from "lit/decorators.js";
|
import { customElement } from "lit/decorators.js";
|
||||||
|
|
||||||
import { UiThemeEnum } from "@goauthentik/api";
|
|
||||||
|
|
||||||
@customElement("ak-storybook-interface-flow")
|
@customElement("ak-storybook-interface-flow")
|
||||||
export class StoryFlowInterface extends FlowExecutor {
|
export class StoryFlowInterface extends FlowExecutor {}
|
||||||
@property()
|
|
||||||
storyTheme: UiThemeEnum = UiThemeEnum.Dark;
|
|
||||||
|
|
||||||
async getTheme(): Promise<UiThemeEnum> {
|
|
||||||
return this.storyTheme;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface HTMLElementTagNameMap {
|
interface HTMLElementTagNameMap {
|
||||||
|
@ -1,18 +1,9 @@
|
|||||||
import { Interface } from "@goauthentik/elements/Interface";
|
import { Interface } from "@goauthentik/elements/Interface";
|
||||||
|
|
||||||
import { customElement, property } from "lit/decorators.js";
|
import { customElement } from "lit/decorators.js";
|
||||||
|
|
||||||
import { UiThemeEnum } from "@goauthentik/api";
|
|
||||||
|
|
||||||
@customElement("ak-storybook-interface")
|
@customElement("ak-storybook-interface")
|
||||||
export class StoryInterface extends Interface {
|
export class StoryInterface extends Interface {}
|
||||||
@property()
|
|
||||||
storyTheme: UiThemeEnum = UiThemeEnum.Dark;
|
|
||||||
|
|
||||||
async getTheme(): Promise<UiThemeEnum> {
|
|
||||||
return this.storyTheme;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface HTMLElementTagNameMap {
|
interface HTMLElementTagNameMap {
|
||||||
|
@ -6,7 +6,7 @@ import {
|
|||||||
} from "@goauthentik/common/constants";
|
} from "@goauthentik/common/constants";
|
||||||
import { globalAK } from "@goauthentik/common/global";
|
import { globalAK } from "@goauthentik/common/global";
|
||||||
import { configureSentry } from "@goauthentik/common/sentry";
|
import { configureSentry } from "@goauthentik/common/sentry";
|
||||||
import { UIConfig } from "@goauthentik/common/ui/config";
|
import { UIConfig, getConfigForUser } from "@goauthentik/common/ui/config";
|
||||||
import { me } from "@goauthentik/common/users";
|
import { me } from "@goauthentik/common/users";
|
||||||
import { WebsocketClient } from "@goauthentik/common/ws";
|
import { WebsocketClient } from "@goauthentik/common/ws";
|
||||||
import "@goauthentik/components/ak-nav-buttons";
|
import "@goauthentik/components/ak-nav-buttons";
|
||||||
@ -292,6 +292,7 @@ export class UserInterface extends AuthenticatedInterface {
|
|||||||
|
|
||||||
async connectedCallback() {
|
async connectedCallback() {
|
||||||
super.connectedCallback();
|
super.connectedCallback();
|
||||||
|
|
||||||
window.addEventListener(EVENT_NOTIFICATION_DRAWER_TOGGLE, this.toggleNotificationDrawer);
|
window.addEventListener(EVENT_NOTIFICATION_DRAWER_TOGGLE, this.toggleNotificationDrawer);
|
||||||
window.addEventListener(EVENT_API_DRAWER_TOGGLE, this.toggleApiDrawer);
|
window.addEventListener(EVENT_API_DRAWER_TOGGLE, this.toggleApiDrawer);
|
||||||
window.addEventListener(EVENT_WS_MESSAGE, this.fetchConfigurationDetails);
|
window.addEventListener(EVENT_WS_MESSAGE, this.fetchConfigurationDetails);
|
||||||
@ -301,6 +302,7 @@ export class UserInterface extends AuthenticatedInterface {
|
|||||||
window.removeEventListener(EVENT_NOTIFICATION_DRAWER_TOGGLE, this.toggleNotificationDrawer);
|
window.removeEventListener(EVENT_NOTIFICATION_DRAWER_TOGGLE, this.toggleNotificationDrawer);
|
||||||
window.removeEventListener(EVENT_API_DRAWER_TOGGLE, this.toggleApiDrawer);
|
window.removeEventListener(EVENT_API_DRAWER_TOGGLE, this.toggleApiDrawer);
|
||||||
window.removeEventListener(EVENT_WS_MESSAGE, this.fetchConfigurationDetails);
|
window.removeEventListener(EVENT_WS_MESSAGE, this.fetchConfigurationDetails);
|
||||||
|
|
||||||
super.disconnectedCallback();
|
super.disconnectedCallback();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -319,8 +321,10 @@ export class UserInterface extends AuthenticatedInterface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fetchConfigurationDetails() {
|
fetchConfigurationDetails() {
|
||||||
me().then((me: SessionUser) => {
|
me().then((session: SessionUser) => {
|
||||||
this.me = me;
|
this.me = session;
|
||||||
|
this.uiConfig = getConfigForUser(session.user);
|
||||||
|
|
||||||
new EventsApi(DEFAULT_CONFIG)
|
new EventsApi(DEFAULT_CONFIG)
|
||||||
.eventsNotificationsList({
|
.eventsNotificationsList({
|
||||||
seen: false,
|
seen: false,
|
||||||
@ -334,12 +338,16 @@ export class UserInterface extends AuthenticatedInterface {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
get isFullyConfigured() {
|
|
||||||
return Boolean(this.uiConfig && this.me);
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
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;
|
return nothing;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user