import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { EVENT_SIDEBAR_TOGGLE, VERSION } from "@goauthentik/common/constants"; import { me } from "@goauthentik/common/users"; import { authentikConfigContext } from "@goauthentik/elements/AuthentikContexts"; import { AKElement } from "@goauthentik/elements/Base"; 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 { consume } from "@lit-labs/context"; import { msg, str } from "@lit/localize"; import { TemplateResult, html, nothing } from "lit"; import { customElement, property, state } from "lit/decorators.js"; import { map } from "lit/directives/map.js"; import { AdminApi, CapabilitiesEnum, CoreApi, UiThemeEnum, Version } from "@goauthentik/api"; import type { Config, SessionUser, UserSelf } from "@goauthentik/api"; @customElement("ak-admin-sidebar") export class AkAdminSidebar extends AKElement { @property({ type: Boolean, reflect: true }) open = true; @state() version: Version["versionCurrent"] | null = null; @state() impersonation: UserSelf["username"] | null = null; @consume({ context: authentikConfigContext }) public config!: Config; constructor() { super(); new AdminApi(DEFAULT_CONFIG).adminVersionRetrieve().then((version) => { this.version = version.versionCurrent; }); me().then((user: SessionUser) => { this.impersonation = user.original ? user.user.username : null; }); this.toggleOpen = this.toggleOpen.bind(this); this.checkWidth = this.checkWidth.bind(this); } // This has to be a bound method so the event listener can be removed on disconnection as // needed. toggleOpen() { this.open = !this.open; } checkWidth() { // This works just fine, but it assumes that the `--ak-sidebar--minimum-auto-width` is in // REMs. If that changes, this code will have to be adjusted as well. const minWidth = parseFloat(getRootStyle("--ak-sidebar--minimum-auto-width")) * parseFloat(getRootStyle("font-size")); this.open = window.innerWidth >= minWidth; } connectedCallback() { super.connectedCallback(); window.addEventListener(EVENT_SIDEBAR_TOGGLE, this.toggleOpen); window.addEventListener("resize", this.checkWidth); // After connecting to the DOM, we can now perform this check to see if the sidebar should // be open by default. this.checkWidth(); } // The symmetry (☟, ☝) here is critical in that you want to start adding these handlers after // connection, and removing them before disconnection. disconnectedCallback() { window.removeEventListener(EVENT_SIDEBAR_TOGGLE, this.toggleOpen); window.removeEventListener("resize", this.checkWidth); super.disconnectedCallback(); } render() { return html` <ak-sidebar class="pf-c-page__sidebar ${this.open ? "pf-m-expanded" : "pf-m-collapsed"} ${this .activeTheme === UiThemeEnum.Light ? "pf-m-light" : ""}" > ${this.renderSidebarItems()} </ak-sidebar> `; } updated() { // This is permissible as`:host.classList` is not one of the properties Lit uses as a // scheduling trigger. This sort of shenanigans can trigger an loop, in that it will trigger // a browser reflow, which may trigger some other styling the application is monitoring, // triggering a re-render which triggers a browser reflow, ad infinitum. But we've been // living with that since jQuery, and it's both well-known and fortunately rare. this.classList.remove("pf-m-expanded", "pf-m-collapsed"); 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[] = [ ["/if/user/", msg("User interface"), { "?isAbsoluteLink": true, "?highlight": true }], [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("Customisation"), 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})$`]], ["/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/tenants", msg("Tenants")], ["/crypto/certificates", msg("Certificates")], ["/outpost/integrations", msg("Outpost Integrations")]]] ]; // 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` ${this.renderNewVersionMessage()} ${this.renderImpersonationMessage()} ${map(sidebarContent, renderOneSidebarItem)} ${this.renderEnterpriseMessage()} `; } renderNewVersionMessage() { return this.version && this.version !== VERSION ? html` <ak-sidebar-item ?highlight=${true}> <span slot="label" >${msg("A newer version of the frontend is available.")}</span > </ak-sidebar-item> ` : nothing; } renderImpersonationMessage() { const reload = () => new CoreApi(DEFAULT_CONFIG).coreUsersImpersonateEndRetrieve().then(() => { window.location.reload(); }); return this.impersonation ? html`<ak-sidebar-item ?highlight=${true} @click=${reload}> <span slot="label" >${msg( str`You're currently impersonating ${this.impersonation}. Click to stop.`, )}</span > </ak-sidebar-item>` : nothing; } renderEnterpriseMessage() { return this.config?.capabilities.includes(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; } }