Compare commits

..

1 Commits

Author SHA1 Message Date
76c3c7d968 web/admin: Fix layout centering. Adjust theming. 2025-04-22 20:11:33 +02:00
15 changed files with 557 additions and 408 deletions

52
web/package-lock.json generated
View File

@ -25,6 +25,7 @@
"@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",
@ -65,7 +66,6 @@
"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,11 +2281,47 @@
"@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",
@ -3521,7 +3557,6 @@
"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"
} }
@ -10688,7 +10723,6 @@
"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"
} }
@ -11309,7 +11343,6 @@
"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"
@ -13787,8 +13820,7 @@
"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",
@ -18224,7 +18256,6 @@
"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",
@ -22342,7 +22373,6 @@
"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"
} }
@ -22694,12 +22724,6 @@
"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",

View File

@ -13,6 +13,7 @@
"@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",
@ -53,7 +54,6 @@
"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",

View File

@ -4,6 +4,7 @@ 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";
@ -11,6 +12,8 @@ import { WebsocketClient } from "@goauthentik/common/ws";
import { AuthenticatedInterface } from "@goauthentik/elements/Interface"; import { AuthenticatedInterface } from "@goauthentik/elements/Interface";
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";
@ -40,6 +43,8 @@ if (process.env.NODE_ENV === "development") {
@customElement("ak-interface-admin") @customElement("ak-interface-admin")
export class AdminInterface extends AuthenticatedInterface { export class AdminInterface extends AuthenticatedInterface {
//#region Properties
@property({ type: Boolean }) @property({ type: Boolean })
notificationDrawerOpen = getURLParam("notificationDrawerOpen", false); notificationDrawerOpen = getURLParam("notificationDrawerOpen", false);
@ -54,6 +59,17 @@ export class AdminInterface extends AuthenticatedInterface {
@query("ak-about-modal") @query("ak-about-modal")
aboutModal?: AboutModal; aboutModal?: AboutModal;
@state()
sidebarVisible = false;
#toggleSidebar = () => {
this.sidebarVisible = !this.sidebarVisible;
};
//#endregion
//#region Styles
static get styles(): CSSResult[] { static get styles(): CSSResult[] {
return [ return [
PFBase, PFBase,
@ -67,23 +83,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-admin-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,6 +114,10 @@ export class AdminInterface extends AuthenticatedInterface {
]; ];
} }
//#endregion
//#region Lifecycle
constructor() { constructor() {
super(); super();
this.ws = new WebsocketClient(); this.ws = new WebsocketClient();
@ -123,12 +150,26 @@ export class AdminInterface extends AuthenticatedInterface {
} }
} }
async connectedCallback(): Promise<void> {
super.connectedCallback();
window.addEventListener(EVENT_SIDEBAR_TOGGLE, this.#toggleSidebar);
}
disconnectedCallback(): void {
super.disconnectedCallback();
window.removeEventListener(EVENT_SIDEBAR_TOGGLE, this.#toggleSidebar);
}
render(): TemplateResult { render(): TemplateResult {
const sidebarClasses = { const sidebarClasses = {
"pf-m-light": this.activeTheme === UiThemeEnum.Light, "pf-m-light": this.activeTheme === UiThemeEnum.Light,
"pf-m-expanded": !this.sidebarVisible,
"pf-m-collapsed": this.sidebarVisible,
}; };
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 +177,16 @@ 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-enterprise-status interface="admin"></ak-enterprise-status>
</ak-page-navbar>
<ak-admin-sidebar <ak-admin-sidebar
class="pf-c-page__sidebar ${classMap(sidebarClasses)}" class="pf-c-page__sidebar
${classMap(sidebarClasses)}"
></ak-admin-sidebar> ></ak-admin-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">

View File

@ -1,4 +1,3 @@
import { EVENT_SIDEBAR_TOGGLE } from "@goauthentik/common/constants";
import { me } from "@goauthentik/common/users"; import { me } from "@goauthentik/common/users";
import { AKElement } from "@goauthentik/elements/Base"; import { AKElement } from "@goauthentik/elements/Base";
import { import {
@ -31,16 +30,9 @@ export class AkAdminSidebar extends WithCapabilitiesConfig(WithVersion(AKElement
me().then((user: SessionUser) => { me().then((user: SessionUser) => {
this.impersonation = user.original ? user.user.username : null; this.impersonation = user.original ? user.user.username : null;
}); });
this.toggleOpen = this.toggleOpen.bind(this);
this.checkWidth = this.checkWidth.bind(this); this.checkWidth = this.checkWidth.bind(this);
} }
// This has to be a bound method so the event listener can be removed on disconnection as
// needed.
toggleOpen() {
this.open = !this.open;
}
checkWidth() { checkWidth() {
// This works just fine, but it assumes that the `--ak-sidebar--minimum-auto-width` is in // 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. // REMs. If that changes, this code will have to be adjusted as well.
@ -52,7 +44,7 @@ export class AkAdminSidebar extends WithCapabilitiesConfig(WithVersion(AKElement
connectedCallback() { connectedCallback() {
super.connectedCallback(); super.connectedCallback();
window.addEventListener(EVENT_SIDEBAR_TOGGLE, this.toggleOpen);
window.addEventListener("resize", this.checkWidth); window.addEventListener("resize", this.checkWidth);
// After connecting to the DOM, we can now perform this check to see if the sidebar should // After connecting to the DOM, we can now perform this check to see if the sidebar should
// be open by default. // be open by default.
@ -63,7 +55,6 @@ export class AkAdminSidebar extends WithCapabilitiesConfig(WithVersion(AKElement
// connection, and removing them before disconnection. // connection, and removing them before disconnection.
disconnectedCallback() { disconnectedCallback() {
window.removeEventListener(EVENT_SIDEBAR_TOGGLE, this.toggleOpen);
window.removeEventListener("resize", this.checkWidth); window.removeEventListener("resize", this.checkWidth);
super.disconnectedCallback(); super.disconnectedCallback();
} }
@ -71,8 +62,9 @@ export class AkAdminSidebar extends WithCapabilitiesConfig(WithVersion(AKElement
render() { render() {
return html` return html`
<ak-sidebar <ak-sidebar
class="pf-c-page__sidebar ${this.open ? "pf-m-expanded" : "pf-m-collapsed"} ${this class="pf-c-page__sidebar
.activeTheme === UiThemeEnum.Light ${this.open ? "pf-m-expanded" : "pf-m-collapsed"} ${this.activeTheme ===
UiThemeEnum.Light
? "pf-m-light" ? "pf-m-light"
: ""}" : ""}"
> >
@ -81,19 +73,6 @@ export class AkAdminSidebar extends WithCapabilitiesConfig(WithVersion(AKElement
`; `;
} }
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 { renderSidebarItems(): TemplateResult {
// The second attribute type is of string[] to help with the 'activeWhen' control, which was // The second attribute type is of string[] to help with the 'activeWhen' control, which was
// commonplace and singular enough to merit its own handler. // commonplace and singular enough to merit its own handler.

View File

@ -94,10 +94,13 @@ export class AdminOverviewPage extends AdminOverviewBase {
} }
render(): TemplateResult { render(): TemplateResult {
const name = this.user?.user.name ?? this.user?.user.username; const username = this.user?.user.name || this.user?.user.username;
return html`<ak-page-header description=${msg("General system status")} ?hasIcon=${false}> return html` <ak-page-header
<span slot="header"> ${msg(str`Welcome, ${name || ""}.`)} </span> header=${msg(str`Welcome, ${username || ""}.`)}
description=${msg("General system status")}
?hasIcon=${false}
>
</ak-page-header> </ak-page-header>
<section class="pf-c-page__main-section"> <section class="pf-c-page__main-section">
<div class="pf-l-grid pf-m-gutter"> <div class="pf-l-grid pf-m-gutter">

View File

@ -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">

View File

@ -1,110 +1,26 @@
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"; import { render } from "@lit-labs/ssr";
import { collectResult } from "@lit-labs/ssr/lib/render-result.js";
import { TemplateResult, html } from "lit";
import { unsafeHTML } from "lit/directives/unsafe-html.js"; import { 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> {
* Render untrusted HTML to a string without escaping it. return await collectResult(render(input));
* }
* @returns {string} The rendered HTML string.
*/ export function purify(input: TemplateResult): TemplateResult {
export function renderStaticHTMLUnsafe(untrustedHTML: unknown): string { return html`${until(
const container = document.createElement("html"); (async () => {
render(untrustedHTML, container); const rendered = await renderStatic(input);
const purified = DOMPurify.sanitize(rendered);
const result = container.innerHTML; return html`${unsafeHTML(purified)}`;
})(),
return result; )}`;
} }

View File

@ -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) {

View File

@ -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>`;
} }
} }

View File

@ -10,15 +10,18 @@ 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,55 +84,212 @@ 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: 1279px) {
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;
}
`, `,
]; ];
} }
//#endregion
//#region Properties
@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()
me?: SessionUser;
@state()
uiConfig!: UIConfig;
//#endregion
//#endregion
//#region Methods
#toggleSidebar() {
this.open = !this.open;
this.dispatchEvent(
new CustomEvent(EVENT_SIDEBAR_TOGGLE, {
bubbles: true,
composed: true,
}),
);
}
//#region Constructor
constructor() { constructor() {
super(); super();
window.addEventListener(EVENT_WS_MESSAGE, () => { window.addEventListener(EVENT_WS_MESSAGE, () => {
@ -119,13 +297,23 @@ export class PageHeader extends WithBrandConfig(AKElement) {
}); });
} }
async firstUpdated() { connectedCallback(): void {
super.connectedCallback();
AKPageNavbar.elementRef = this;
}
disconnectedCallback(): void {
super.disconnectedCallback();
AKPageNavbar.elementRef = null;
}
public async firstUpdated() {
this.me = await me(); this.me = await me();
this.uiConfig = await uiConfig(); this.uiConfig = await uiConfig();
this.uiConfig.navbar.userDisplay = UserDisplay.none; this.uiConfig.navbar.userDisplay = UserDisplay.none;
} }
setTitle(header?: string) { #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") {
@ -141,65 +329,146 @@ export class PageHeader extends WithBrandConfig(AKElement) {
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);
} }
//#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>&nbsp;` ? 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.me}>
> <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;
} }
} }

View File

@ -35,10 +35,7 @@ export class Sidebar extends AKElement {
.pf-c-nav__section + .pf-c-nav__section { .pf-c-nav__section + .pf-c-nav__section {
--pf-c-nav__section--section--MarginTop: var(--pf-global--spacer--sm); --pf-c-nav__section--section--MarginTop: var(--pf-global--spacer--sm);
} }
.pf-c-nav__list .sidebar-brand {
max-height: 82px;
margin-bottom: -0.5rem;
}
nav { nav {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -70,7 +67,6 @@ export class Sidebar extends AKElement {
class="pf-c-nav ${this.activeTheme === UiThemeEnum.Light ? "pf-m-light" : ""}" class="pf-c-nav ${this.activeTheme === UiThemeEnum.Light ? "pf-m-light" : ""}"
aria-label=${msg("Global")} aria-label=${msg("Global")}
> >
<ak-sidebar-brand></ak-sidebar-brand>
<ul class="pf-c-nav__list"> <ul class="pf-c-nav__list">
<slot></slot> <slot></slot>
</ul> </ul>

View File

@ -1,4 +1,3 @@
import { EVENT_SIDEBAR_TOGGLE } from "@goauthentik/common/constants";
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 { themeImage } from "@goauthentik/elements/utils/images"; import { themeImage } from "@goauthentik/elements/utils/images";
@ -42,22 +41,16 @@ export class SidebarBrand extends WithBrandConfig(AKElement) {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
height: 114px; height: var(--ak-navbar-height);
min-height: 114px;
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);
} }
.pf-c-brand img { .pf-c-brand img {
padding: 0 0.5rem; padding: 0 0.5rem;
height: 42px; height: 42px;
} }
button.pf-c-button.sidebar-trigger {
background-color: transparent;
border-radius: 0px;
height: 100%;
color: var(--ak-dark-foreground);
}
`, `,
]; ];
} }
@ -70,32 +63,15 @@ export class SidebarBrand extends WithBrandConfig(AKElement) {
} }
render(): TemplateResult { render(): TemplateResult {
return html` ${window.innerWidth <= MIN_WIDTH return html` <a href="#/" class="pf-c-page__header-brand-link">
? html` <div class="pf-c-brand ak-brand">
<button <img
class="sidebar-trigger pf-c-button" src=${themeImage(this.brand?.brandingLogo ?? DefaultBrand.brandingLogo)}
@click=${() => { alt="${msg("authentik Logo")}"
this.dispatchEvent( loading="lazy"
new CustomEvent(EVENT_SIDEBAR_TOGGLE, { />
bubbles: true, </div>
composed: true, </a>`;
}),
);
}}
>
<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>`;
} }
} }

View File

@ -1,55 +0,0 @@
/**
* @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>`;
}

View File

@ -1,4 +1,4 @@
import { BrandedHTMLPolicy, sanitizeHTML } from "@goauthentik/common/purify"; import { purify } 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,6 +21,8 @@ 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() {
@ -31,21 +33,13 @@ export class BrandLinks extends AKElement {
links: FooterLink[] = []; links: FooterLink[] = [];
render() { render() {
const links = [...(this.links ?? [])]; const links = [...(this.links ?? []), poweredBy];
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) =>
const children = sanitizeHTML(BrandedHTMLPolicy, link.name); link.href
? purify(html`<li><a href="${link.href}">${link.name}</a></li>`)
if (link.href) { : html`<li><span>${link.name}</span></li>`,
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>`;
} }
} }

View File

@ -1,16 +1,15 @@
/// <reference types="@hcaptcha/types"/> ///<reference types="@hcaptcha/types"/>
/// <reference types="turnstile-types"/> import { renderStatic } from "@goauthentik/common/purify";
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";
@ -57,36 +56,40 @@ 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;
window.parent.postMessage({ const iframeTemplate = (captchaElement: TemplateResult, challengeUrl: string) =>
message: "resize", html`<!doctype html>
source: "goauthentik.io", <head>
context: "flow-executor", <html>
size: { height }, <body style="display:flex;flex-direction:row;justify-content:center;">
}); ${captchaElement}
}).observe(document.querySelector(".ak-captcha-container")); <script>
</script> new ResizeObserver((entries) => {
const height =
<script src=${challengeURL}></script> document.body.offsetHeight +
parseFloat(getComputedStyle(document.body).fontSize) * 2;
<script> window.parent.postMessage({
function callback(token) { message: "resize",
window.parent.postMessage({ source: "goauthentik.io",
message: "captcha", context: "flow-executor",
source: "goauthentik.io", size: { height },
context: "flow-executor", });
token, }).observe(document.querySelector(".ak-captcha-container"));
}); </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> {
@ -302,25 +305,11 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
} }
async renderFrame(captchaElement: TemplateResult) { async renderFrame(captchaElement: TemplateResult) {
const { contentDocument } = this.captchaFrame || {}; this.captchaFrame.contentWindow?.document.open();
this.captchaFrame.contentWindow?.document.write(
if (!contentDocument) { await renderStatic(iframeTemplate(captchaElement, this.challenge.jsUrl)),
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() {