From 8869df4b1df7e4f7c7b7df19db1583c1d2288626 Mon Sep 17 00:00:00 2001 From: Teffen Ellis Date: Wed, 16 Apr 2025 18:16:12 +0200 Subject: [PATCH] web/admin: Fix layout centering. Adjust theming. --- .../admin/AdminInterface/AdminInterface.ts | 62 ++- web/src/admin/AdminInterface/AdminSidebar.ts | 29 +- .../admin/admin-overview/AdminOverviewPage.ts | 9 +- .../admin/admin-settings/AdminSettingsPage.ts | 9 +- web/src/common/styles/authentik.css | 7 + web/src/components/ak-nav-buttons.ts | 24 +- web/src/elements/PageHeader.ts | 433 ++++++++++++++---- web/src/elements/sidebar/Sidebar.ts | 6 +- web/src/elements/sidebar/SidebarBrand.ts | 46 +- 9 files changed, 453 insertions(+), 172 deletions(-) diff --git a/web/src/admin/AdminInterface/AdminInterface.ts b/web/src/admin/AdminInterface/AdminInterface.ts index dda28c5a37..f12c9be553 100644 --- a/web/src/admin/AdminInterface/AdminInterface.ts +++ b/web/src/admin/AdminInterface/AdminInterface.ts @@ -4,6 +4,7 @@ import { ROUTES } from "@goauthentik/admin/Routes"; import { EVENT_API_DRAWER_TOGGLE, EVENT_NOTIFICATION_DRAWER_TOGGLE, + EVENT_SIDEBAR_TOGGLE, } from "@goauthentik/common/constants"; import { configureSentry } from "@goauthentik/common/sentry"; import { me } from "@goauthentik/common/users"; @@ -11,6 +12,8 @@ import { WebsocketClient } from "@goauthentik/common/ws"; import { AuthenticatedInterface } from "@goauthentik/elements/Interface"; import "@goauthentik/elements/ak-locale-context"; import "@goauthentik/elements/banner/EnterpriseStatusBanner"; +import "@goauthentik/elements/banner/EnterpriseStatusBanner"; +import "@goauthentik/elements/banner/VersionBanner"; import "@goauthentik/elements/banner/VersionBanner"; import "@goauthentik/elements/messages/MessageContainer"; import "@goauthentik/elements/messages/MessageContainer"; @@ -40,6 +43,8 @@ if (process.env.NODE_ENV === "development") { @customElement("ak-interface-admin") export class AdminInterface extends AuthenticatedInterface { + //#region Properties + @property({ type: Boolean }) notificationDrawerOpen = getURLParam("notificationDrawerOpen", false); @@ -54,6 +59,17 @@ export class AdminInterface extends AuthenticatedInterface { @query("ak-about-modal") aboutModal?: AboutModal; + @state() + sidebarVisible = false; + + #toggleSidebar = () => { + this.sidebarVisible = !this.sidebarVisible; + }; + + //#endregion + + //#region Styles + static get styles(): CSSResult[] { return [ PFBase, @@ -67,23 +83,30 @@ export class AdminInterface extends AuthenticatedInterface { z-index: auto !important; background-color: transparent; } + .display-none { display: none; } + .pf-c-page { background-color: var(--pf-c-page--BackgroundColor) !important; } - /* Global page background colour */ - :host([theme="dark"]) .pf-c-page { - --pf-c-page--BackgroundColor: var(--ak-dark-background); + + :host([theme="dark"]) { + /* Global page background colour */ + .pf-c-page { + --pf-c-page--BackgroundColor: var(--ak-dark-background); + } } - ak-enterprise-status, - ak-version-banner { + + ak-page-navbar { grid-area: header; } + ak-admin-sidebar { grid-area: nav; } + .pf-c-drawer__panel { z-index: var(--pf-global--ZIndex--xl); } @@ -91,6 +114,10 @@ export class AdminInterface extends AuthenticatedInterface { ]; } + //#endregion + + //#region Lifecycle + constructor() { super(); this.ws = new WebsocketClient(); @@ -123,12 +150,26 @@ export class AdminInterface extends AuthenticatedInterface { } } + async connectedCallback(): Promise { + super.connectedCallback(); + + window.addEventListener(EVENT_SIDEBAR_TOGGLE, this.#toggleSidebar); + } + + disconnectedCallback(): void { + super.disconnectedCallback(); + window.removeEventListener(EVENT_SIDEBAR_TOGGLE, this.#toggleSidebar); + } + render(): TemplateResult { const sidebarClasses = { "pf-m-light": this.activeTheme === UiThemeEnum.Light, + "pf-m-expanded": !this.sidebarVisible, + "pf-m-collapsed": this.sidebarVisible, }; const drawerOpen = this.notificationDrawerOpen || this.apiDrawerOpen; + const drawerClasses = { "pf-m-expanded": drawerOpen, "pf-m-collapsed": !drawerOpen, @@ -136,11 +177,16 @@ export class AdminInterface extends AuthenticatedInterface { return html`
- - + + + + + +
diff --git a/web/src/admin/AdminInterface/AdminSidebar.ts b/web/src/admin/AdminInterface/AdminSidebar.ts index 8ca4944259..7db9b7ec23 100644 --- a/web/src/admin/AdminInterface/AdminSidebar.ts +++ b/web/src/admin/AdminInterface/AdminSidebar.ts @@ -1,4 +1,3 @@ -import { EVENT_SIDEBAR_TOGGLE } from "@goauthentik/common/constants"; import { me } from "@goauthentik/common/users"; import { AKElement } from "@goauthentik/elements/Base"; import { @@ -31,16 +30,9 @@ export class AkAdminSidebar extends WithCapabilitiesConfig(WithVersion(AKElement me().then((user: SessionUser) => { this.impersonation = user.original ? user.user.username : null; }); - this.toggleOpen = this.toggleOpen.bind(this); this.checkWidth = this.checkWidth.bind(this); } - // This has to be a bound method so the event listener can be removed on disconnection as - // needed. - toggleOpen() { - this.open = !this.open; - } - checkWidth() { // This works just fine, but it assumes that the `--ak-sidebar--minimum-auto-width` is in // REMs. If that changes, this code will have to be adjusted as well. @@ -52,7 +44,7 @@ export class AkAdminSidebar extends WithCapabilitiesConfig(WithVersion(AKElement 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. @@ -63,7 +55,6 @@ export class AkAdminSidebar extends WithCapabilitiesConfig(WithVersion(AKElement // connection, and removing them before disconnection. disconnectedCallback() { - window.removeEventListener(EVENT_SIDEBAR_TOGGLE, this.toggleOpen); window.removeEventListener("resize", this.checkWidth); super.disconnectedCallback(); } @@ -71,8 +62,9 @@ export class AkAdminSidebar extends WithCapabilitiesConfig(WithVersion(AKElement render() { return html` @@ -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 { // The second attribute type is of string[] to help with the 'activeWhen' control, which was // commonplace and singular enough to merit its own handler. diff --git a/web/src/admin/admin-overview/AdminOverviewPage.ts b/web/src/admin/admin-overview/AdminOverviewPage.ts index d7a9e1737c..8a750f15fc 100644 --- a/web/src/admin/admin-overview/AdminOverviewPage.ts +++ b/web/src/admin/admin-overview/AdminOverviewPage.ts @@ -94,10 +94,13 @@ export class AdminOverviewPage extends AdminOverviewBase { } render(): TemplateResult { - const name = this.user?.user.name ?? this.user?.user.username; + const username = this.user?.user.name || this.user?.user.username; - return html` - ${msg(str`Welcome, ${name || ""}.`)} + return html`
diff --git a/web/src/admin/admin-settings/AdminSettingsPage.ts b/web/src/admin/admin-settings/AdminSettingsPage.ts index 7a2d3d2311..b1d1a34a42 100644 --- a/web/src/admin/admin-settings/AdminSettingsPage.ts +++ b/web/src/admin/admin-settings/AdminSettingsPage.ts @@ -83,13 +83,10 @@ export class AdminSettingsPage extends AKElement { } render() { - if (!this.settings) { - return nothing; - } + if (!this.settings) return nothing; + return html` - - ${msg("System settings")} - +
diff --git a/web/src/common/styles/authentik.css b/web/src/common/styles/authentik.css index b8d99d9b9e..747c1e2d27 100644 --- a/web/src/common/styles/authentik.css +++ b/web/src/common/styles/authentik.css @@ -17,6 +17,13 @@ /* Minimum width after which the sidebar becomes automatic */ --ak-sidebar--minimum-auto-width: 80rem; + + /** + * The height of the navbar and branded sidebar. + * @todo This shouldn't be necessary. The sidebar can instead use a grid layout + * ensuring they share the same height. + */ + --ak-navbar--height: 7rem; } @supports selector(::-webkit-scrollbar) { diff --git a/web/src/components/ak-nav-buttons.ts b/web/src/components/ak-nav-buttons.ts index e5111b3220..d314c6d0ce 100644 --- a/web/src/components/ak-nav-buttons.ts +++ b/web/src/components/ak-nav-buttons.ts @@ -67,6 +67,12 @@ export class NavigationButtons extends AKElement { :host([theme="light"]) .pf-c-page__header-tools-group .pf-c-button { color: var(--ak-global--Color--100) !important; } + + @media (max-width: 768px) { + .pf-c-avatar { + display: none; + } + } `, ]; } @@ -156,9 +162,7 @@ export class NavigationButtons extends AKElement { } renderImpersonation() { - if (!this.me?.original) { - return nothing; - } + if (!this.me?.original) return nothing; const onClick = async () => { await new CoreApi(DEFAULT_CONFIG).coreUsersImpersonateEndRetrieve(); @@ -175,6 +179,14 @@ export class NavigationButtons extends AKElement {
`; } + renderAvatar() { + return html`${msg(`; + } + get userDisplayName() { return match(this.uiConfig?.navbar.userDisplay) .with(UserDisplay.username, () => this.me?.user.username) @@ -212,11 +224,7 @@ export class NavigationButtons extends AKElement {
` : nothing} - ${msg( + ${this.renderAvatar()}
`; } } diff --git a/web/src/elements/PageHeader.ts b/web/src/elements/PageHeader.ts index d0ec119cd5..e4fd4742d8 100644 --- a/web/src/elements/PageHeader.ts +++ b/web/src/elements/PageHeader.ts @@ -10,15 +10,18 @@ import { me } from "@goauthentik/common/users"; import "@goauthentik/components/ak-nav-buttons"; import { AKElement } from "@goauthentik/elements/Base"; import { WithBrandConfig } from "@goauthentik/elements/Interface/brandProvider"; +import { DefaultBrand } from "@goauthentik/elements/sidebar/SidebarBrand"; +import { themeImage } from "@goauthentik/elements/utils/images"; import "@patternfly/elements/pf-tooltip/pf-tooltip.js"; import { msg } from "@lit/localize"; -import { CSSResult, TemplateResult, css, html, nothing } from "lit"; +import { CSSResult, LitElement, TemplateResult, css, html, nothing } from "lit"; import { customElement, property, state } from "lit/decorators.js"; import PFAvatar from "@patternfly/patternfly/components/Avatar/avatar.css"; import PFButton from "@patternfly/patternfly/components/Button/button.css"; import PFContent from "@patternfly/patternfly/components/Content/content.css"; +import PFDrawer from "@patternfly/patternfly/components/Drawer/drawer.css"; import PFDropdown from "@patternfly/patternfly/components/Dropdown/dropdown.css"; import PFNotificationBadge from "@patternfly/patternfly/components/NotificationBadge/notification-badge.css"; import PFPage from "@patternfly/patternfly/components/Page/page.css"; @@ -26,34 +29,52 @@ import PFBase from "@patternfly/patternfly/patternfly-base.css"; import { SessionUser } from "@goauthentik/api"; -@customElement("ak-page-header") -export class PageHeader extends WithBrandConfig(AKElement) { - @property() - icon?: string; +//#region Page Navbar - @property({ type: Boolean }) - iconImage = false; - - @property() - header = ""; - - @property() +export interface PageNavbarDetails { + header?: string; description?: string; + icon?: string; + iconImage?: boolean; +} - @property({ type: Boolean }) - hasIcon = true; +/** + * A global navbar component at the top of the page. + * + * Internally, this component listens for the `ak-page-header` event, which is + * dispatched by the `ak-page-header` component. + */ +@customElement("ak-page-navbar") +export class AKPageNavbar extends WithBrandConfig(AKElement) implements PageNavbarDetails { + //#region Static Properties - @state() - me?: SessionUser; + private static elementRef: AKPageNavbar | null = null; - @state() - uiConfig!: UIConfig; + static readonly setNavbarDetails = (detail: Partial): void => { + const { elementRef } = AKPageNavbar; + if (!elementRef) { + console.debug( + `ak-page-header: Could not find ak-page-navbar, skipping event dispatch.`, + ); + return; + } + + const { header, description, icon, iconImage } = detail; + + elementRef.header = header; + elementRef.description = description; + elementRef.icon = icon; + elementRef.iconImage = iconImage || false; + elementRef.hasIcon = !!icon; + }; static get styles(): CSSResult[] { return [ PFBase, PFButton, PFPage, + PFDrawer, + PFNotificationBadge, PFContent, PFAvatar, @@ -63,55 +84,212 @@ export class PageHeader extends WithBrandConfig(AKElement) { position: sticky; top: 0; z-index: var(--pf-global--ZIndex--lg); + --pf-c-page__header-tools--MarginRight: 0; + --ak-brand-logo-height: var(--pf-global--FontSize--4xl, 2.25rem); + --ak-brand-background-color: var( + --pf-c-page__sidebar--m-light--BackgroundColor + ); } - .bar { + + :host([theme="dark"]) { + --ak-brand-background-color: var(--pf-c-page__sidebar--BackgroundColor); + --pf-c-page__sidebar--BackgroundColor: var(--ak-dark-background-light); + color: var(--ak-dark-foreground); + } + + navbar { border-bottom: var(--pf-global--BorderWidth--sm); border-bottom-style: solid; border-bottom-color: var(--pf-global--BorderColor--100); + background-color: var(--pf-c-page--BackgroundColor); + display: flex; flex-direction: row; - min-height: 114px; - max-height: 114px; - background-color: var(--pf-c-page--BackgroundColor); + min-height: 6rem; + + display: grid; + row-gap: var(--pf-global--spacer--sm); + column-gap: var(--pf-global--spacer--sm); + grid-template-columns: [brand] auto [toggle] auto [primary] 1fr [secondary] auto; + grid-template-rows: auto auto; + grid-template-areas: + "brand toggle primary secondary" + "brand toggle description secondary"; + + @media (max-width: 768px) { + row-gap: var(--pf-global--spacer--xs); + + align-items: center; + grid-template-areas: + "toggle primary secondary" + "toggle description description"; + justify-content: space-between; + width: 100%; + } } - .pf-c-page__main-section.pf-m-light { - background-color: transparent; + + .items { + display: block; + + &.primary { + grid-column: primary; + grid-row: primary / description; + + align-content: center; + padding-block: var(--pf-global--spacer--md); + + @media (min-width: 426px) { + &.block-sibling { + padding-block-end: 0; + grid-row: primary; + } + } + + @media (max-width: 768px) { + padding-block: var(--pf-global--spacer--sm); + } + + .accent-icon { + height: 1em; + width: 1em; + + @media (max-width: 768px) { + display: none; + } + } + } + + &.page-description { + grid-area: description; + padding-block-end: var(--pf-global--spacer--md); + + @media (max-width: 425px) { + display: none; + } + + @media (min-width: 769px) { + text-wrap: balance; + } + } + + &.secondary { + grid-area: secondary; + flex: 0 0 auto; + justify-self: end; + padding-block: var(--pf-global--spacer--sm); + padding-inline-end: var(--pf-global--spacer--sm); + + @media (min-width: 769px) { + align-content: center; + padding-block: var(--pf-global--spacer--md); + padding-inline-end: var(--pf-global--spacer--xl); + } + } } - .pf-c-page__main-section { - flex-grow: 1; - flex-shrink: 1; + + .brand { + grid-area: brand; + background-color: var(--ak-brand-background-color); + height: 100%; + width: var(--pf-c-page__sidebar--Width); + align-items: center; + padding-inline: var(--pf-global--spacer--sm); + display: flex; - flex-direction: column; justify-content: center; + + &.pf-m-collapsed { + display: none; + } + + @media (max-width: 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, .notification-trigger { - font-size: 24px; + font-size: 1.5rem; } + .notification-trigger.has-notifications { color: var(--pf-global--active-color--100); } + + .page-title { + display: flex; + gap: var(--pf-global--spacer--xs); + } + h1 { display: flex; flex-direction: row; align-items: center !important; } - .pf-c-page__header-tools { - flex-shrink: 0; - } - .pf-c-page__header-tools-group { - height: 100%; - } - :host([theme="dark"]) .pf-c-page__header-tools { - color: var(--ak-dark-foreground) !important; - } `, ]; } + //#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() { super(); 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.uiConfig = await uiConfig(); this.uiConfig.navbar.userDisplay = UserDisplay.none; } - setTitle(header?: string) { + #setTitle(header?: string) { const currentIf = currentInterface(); let title = this.brand?.brandingTitle || TITLE_DEFAULT; if (currentIf === "admin") { @@ -141,65 +329,146 @@ export class PageHeader extends WithBrandConfig(AKElement) { willUpdate() { // Always update title, even if there's no header value set, // as in that case we still need to return to the generic title - this.setTitle(this.header); + this.#setTitle(this.header); } + //#region Render + renderIcon() { if (this.icon) { if (this.iconImage && !this.icon.startsWith("fa://")) { - return html`page icon`; + return html`page icon`; } + const icon = this.icon.replaceAll("fa://", "fa "); - return html``; + + return html``; } return nothing; } render(): TemplateResult { - return html`
- -
-
-

+ return html` + + + +
+

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

- ${this.description ? html`

${this.description}

` : html``} -

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

${this.description}

+
` + : nothing} + +
+ +
+ + `; + } + + //#endregion +} + +//#endregion + +//#region Page Header + +/** + * A page header component, used to display the page title and description. + * + * Internally, this component dispatches the `ak-page-header` event, which is + * listened to by the `ak-page-navbar` component. + * + * @singleton + */ +@customElement("ak-page-header") +export class AKPageHeader extends LitElement implements PageNavbarDetails { + @property({ type: String }) + header?: string; + + @property({ type: String }) + description?: string; + + @property({ type: String }) + icon?: string; + + @property({ type: Boolean }) + iconImage = false; + + static get styles(): CSSResult[] { + return [ + css` + :host { + display: none; + } + `, + ]; + } + + connectedCallback(): void { + super.connectedCallback(); + + AKPageNavbar.setNavbarDetails({ + header: this.header, + description: this.description, + icon: this.icon, + iconImage: this.iconImage, + }); + } + + updated(): void { + AKPageNavbar.setNavbarDetails({ + header: this.header, + description: this.description, + icon: this.icon, + iconImage: this.iconImage, + }); } } +//#endregion + declare global { interface HTMLElementTagNameMap { - "ak-page-header": PageHeader; + "ak-page-header": AKPageHeader; + "ak-page-navbar": AKPageNavbar; } } diff --git a/web/src/elements/sidebar/Sidebar.ts b/web/src/elements/sidebar/Sidebar.ts index 2fcfe3b6b5..43a0ec75ec 100644 --- a/web/src/elements/sidebar/Sidebar.ts +++ b/web/src/elements/sidebar/Sidebar.ts @@ -35,10 +35,7 @@ export class Sidebar extends AKElement { .pf-c-nav__section + .pf-c-nav__section { --pf-c-nav__section--section--MarginTop: var(--pf-global--spacer--sm); } - .pf-c-nav__list .sidebar-brand { - max-height: 82px; - margin-bottom: -0.5rem; - } + nav { display: flex; flex-direction: column; @@ -70,7 +67,6 @@ export class Sidebar extends AKElement { class="pf-c-nav ${this.activeTheme === UiThemeEnum.Light ? "pf-m-light" : ""}" aria-label=${msg("Global")} > -
diff --git a/web/src/elements/sidebar/SidebarBrand.ts b/web/src/elements/sidebar/SidebarBrand.ts index f63bd99b3f..e45da52f4e 100644 --- a/web/src/elements/sidebar/SidebarBrand.ts +++ b/web/src/elements/sidebar/SidebarBrand.ts @@ -1,4 +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"; @@ -42,22 +41,16 @@ export class SidebarBrand extends WithBrandConfig(AKElement) { display: flex; flex-direction: row; align-items: center; - height: 114px; - min-height: 114px; + height: var(--ak-navbar-height); 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); - } `, ]; } @@ -70,32 +63,15 @@ export class SidebarBrand extends WithBrandConfig(AKElement) { } render(): TemplateResult { - return html` ${window.innerWidth <= MIN_WIDTH - ? html` - - ` - : html``} - -
- ${msg( -
-
`; + return html` +
+ ${msg( +
+
`; } }