diff --git a/web/src/admin/AdminInterface/AdminSidebar.ts b/web/src/admin/AdminInterface/AdminSidebar.ts index 9cc8148ef..42b381095 100644 --- a/web/src/admin/AdminInterface/AdminSidebar.ts +++ b/web/src/admin/AdminInterface/AdminSidebar.ts @@ -10,7 +10,7 @@ import { SidebarAttributes, SidebarEntry, SidebarEventHandler, -} from "@goauthentik/elements/sidebar/SidebarItems"; +} from "@goauthentik/elements/sidebar/types"; import { getRootStyle } from "@goauthentik/elements/utils/getRootStyle"; import { consume } from "@lit-labs/context"; @@ -38,7 +38,7 @@ import StageTypesController from "./SidebarEntries/StageTypesController"; * it as an overlay or as a push. * 2. Control what content the sidebar will receive. The sidebar takes a tree, maximally three deep, * of type SidebarEventHandler. - */ + */ type SidebarUrl = string; @@ -144,18 +144,30 @@ export class AkAdminSidebar extends AKElement { window.location.reload(); }); - // prettier-ignore - const newVersionMessage: LocalSidebarEntry[] = this.version && this.version !== VERSION - ? [["https://goauthentik.io", msg("A newer version of the frontend is available."), { "?highlight": true }]] + const newVersionMessage: LocalSidebarEntry[] = + this.version && this.version !== VERSION + ? [ + [ + "https://goauthentik.io", + msg("A newer version of the frontend is available."), + { highlight: true }, + ], + ] : []; - // prettier-ignore const impersonationMessage: LocalSidebarEntry[] = this.impersonation - ? [[reload, msg(str`You're currently impersonating ${this.impersonation}. Click to stop.`)]] + ? [ + [ + reload, + msg( + str`You're currently impersonating ${this.impersonation}. Click to stop.`, + ), + ], + ] : []; const enterpriseMenu: LocalSidebarEntry[] = this.config?.capabilities.includes( - CapabilitiesEnum.IsEnterprise + CapabilitiesEnum.IsEnterprise, ) ? [[null, msg("Enterprise"), null, [["/enterprise/licenses", msg("Licenses")]]]] : []; diff --git a/web/src/admin/AdminInterface/SidebarEntries/ConnectionTypesController.ts b/web/src/admin/AdminInterface/SidebarEntries/ConnectionTypesController.ts index 49c0bd67b..5ecebe5fe 100644 --- a/web/src/admin/AdminInterface/SidebarEntries/ConnectionTypesController.ts +++ b/web/src/admin/AdminInterface/SidebarEntries/ConnectionTypesController.ts @@ -6,7 +6,7 @@ import { createTypesController } from "./GenericTypesController"; export const ConnectionTypesController = createTypesController( () => new OutpostsApi(DEFAULT_CONFIG).outpostsServiceConnectionsAllTypesList(), - "/outpost/integrations" + "/outpost/integrations", ); export default ConnectionTypesController; diff --git a/web/src/admin/AdminInterface/SidebarEntries/GenericTypesController.ts b/web/src/admin/AdminInterface/SidebarEntries/GenericTypesController.ts index 84b1f8f5c..69239257a 100644 --- a/web/src/admin/AdminInterface/SidebarEntries/GenericTypesController.ts +++ b/web/src/admin/AdminInterface/SidebarEntries/GenericTypesController.ts @@ -1,5 +1,7 @@ import { ReactiveControllerHost } from "lit"; + import { TypeCreate } from "@goauthentik/api"; + import { LocalSidebarEntry } from "../AdminSidebar"; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -27,7 +29,11 @@ const typeCreateToSidebar = (baseUrl: string, tcreate: TypeCreate[]): LocalSideb * */ -export function createTypesController(fetch: Fetcher, path: string, converter = typeCreateToSidebar) { +export function createTypesController( + fetch: Fetcher, + path: string, + converter = typeCreateToSidebar, +) { return class GenericTypesController { createTypes: TypeCreate[] = []; host: ReactiveControllerHost; diff --git a/web/src/admin/AdminInterface/SidebarEntries/PolicyTypesController.ts b/web/src/admin/AdminInterface/SidebarEntries/PolicyTypesController.ts index ad40fe19f..9cc9cddb1 100644 --- a/web/src/admin/AdminInterface/SidebarEntries/PolicyTypesController.ts +++ b/web/src/admin/AdminInterface/SidebarEntries/PolicyTypesController.ts @@ -6,7 +6,7 @@ import { createTypesController } from "./GenericTypesController"; export const PolicyTypesController = createTypesController( () => new PoliciesApi(DEFAULT_CONFIG).policiesAllTypesList(), - "/policy/policies" + "/policy/policies", ); export default PolicyTypesController; diff --git a/web/src/admin/AdminInterface/SidebarEntries/PropertyMappingsController.ts b/web/src/admin/AdminInterface/SidebarEntries/PropertyMappingsController.ts index 6f0c47f41..47a92de61 100644 --- a/web/src/admin/AdminInterface/SidebarEntries/PropertyMappingsController.ts +++ b/web/src/admin/AdminInterface/SidebarEntries/PropertyMappingsController.ts @@ -6,7 +6,7 @@ import { createTypesController } from "./GenericTypesController"; export const PropertyMappingsController = createTypesController( () => new PropertymappingsApi(DEFAULT_CONFIG).propertymappingsAllTypesList(), - "/core/property-mappings" + "/core/property-mappings", ); export default PropertyMappingsController; diff --git a/web/src/admin/AdminInterface/SidebarEntries/ProviderTypesController.ts b/web/src/admin/AdminInterface/SidebarEntries/ProviderTypesController.ts index bdb98f8f5..3b727e3ad 100644 --- a/web/src/admin/AdminInterface/SidebarEntries/ProviderTypesController.ts +++ b/web/src/admin/AdminInterface/SidebarEntries/ProviderTypesController.ts @@ -6,7 +6,7 @@ import { createTypesController } from "./GenericTypesController"; export const ProviderTypesController = createTypesController( () => new ProvidersApi(DEFAULT_CONFIG).providersAllTypesList(), - "/core/providers" + "/core/providers", ); export default ProviderTypesController; diff --git a/web/src/admin/AdminInterface/SidebarEntries/SourceTypesController.ts b/web/src/admin/AdminInterface/SidebarEntries/SourceTypesController.ts index 04b7cb611..2d607a4c6 100644 --- a/web/src/admin/AdminInterface/SidebarEntries/SourceTypesController.ts +++ b/web/src/admin/AdminInterface/SidebarEntries/SourceTypesController.ts @@ -6,7 +6,7 @@ import { createTypesController } from "./GenericTypesController"; export const SourceTypesController = createTypesController( () => new SourcesApi(DEFAULT_CONFIG).sourcesAllTypesList(), - "/core/sources" + "/core/sources", ); export default SourceTypesController; diff --git a/web/src/admin/AdminInterface/SidebarEntries/StageTypesController.ts b/web/src/admin/AdminInterface/SidebarEntries/StageTypesController.ts index a497eaf3e..b56296788 100644 --- a/web/src/admin/AdminInterface/SidebarEntries/StageTypesController.ts +++ b/web/src/admin/AdminInterface/SidebarEntries/StageTypesController.ts @@ -6,7 +6,7 @@ import { createTypesController } from "./GenericTypesController"; export const StageTypesController = createTypesController( () => new StagesApi(DEFAULT_CONFIG).stagesAllTypesList(), - "/flow/stages" + "/flow/stages", ); export default StageTypesController; diff --git a/web/src/admin/flows/utils.ts b/web/src/admin/flows/utils.ts index 9dadc3a8b..96bfac0a5 100644 --- a/web/src/admin/flows/utils.ts +++ b/web/src/admin/flows/utils.ts @@ -9,14 +9,14 @@ export function RenderFlowOption(flow: Flow): string { type FlowDesignationPair = [FlowDesignationEnum, string]; export const flowDesignationTable: FlowDesignationPair[] = [ - [FlowDesignationEnum.Authentication, msg("Authentication")], - [FlowDesignationEnum.Authorization, msg("Authorization")], - [FlowDesignationEnum.Enrollment, msg("Enrollment")], - [FlowDesignationEnum.Invalidation, msg("Invalidation")], - [FlowDesignationEnum.Recovery, msg("Recovery")], - [FlowDesignationEnum.StageConfiguration, msg("Stage Configuration")], - [FlowDesignationEnum.Unenrollment, msg("Unenrollment")], -] + [FlowDesignationEnum.Authentication, msg("Authentication")], + [FlowDesignationEnum.Authorization, msg("Authorization")], + [FlowDesignationEnum.Enrollment, msg("Enrollment")], + [FlowDesignationEnum.Invalidation, msg("Invalidation")], + [FlowDesignationEnum.Recovery, msg("Recovery")], + [FlowDesignationEnum.StageConfiguration, msg("Stage Configuration")], + [FlowDesignationEnum.Unenrollment, msg("Unenrollment")], +]; // prettier-ignore const flowDesignations = new Map(flowDesignationTable); @@ -26,11 +26,11 @@ export function DesignationToLabel(designation: FlowDesignationEnum): string { } const layoutToLabel = new Map([ - [LayoutEnum.Stacked, msg("Stacked")], - [LayoutEnum.ContentLeft, msg("Content left")], - [LayoutEnum.ContentRight, msg("Content right")], - [LayoutEnum.SidebarLeft, msg("Sidebar left")], - [LayoutEnum.SidebarRight, msg("Sidebar right")], + [LayoutEnum.Stacked, msg("Stacked")], + [LayoutEnum.ContentLeft, msg("Content left")], + [LayoutEnum.ContentRight, msg("Content right")], + [LayoutEnum.SidebarLeft, msg("Sidebar left")], + [LayoutEnum.SidebarRight, msg("Sidebar right")], ]); export function LayoutToLabel(layout: LayoutEnum): string { diff --git a/web/src/common/labels.ts b/web/src/common/labels.ts index fc76406b1..7e2d6ca8d 100644 --- a/web/src/common/labels.ts +++ b/web/src/common/labels.ts @@ -45,7 +45,7 @@ export const eventActionLabels: Pair[] = [ [EventActions.ModelDeleted, msg("Model deleted")], [EventActions.EmailSent, msg("Email sent")], [EventActions.UpdateAvailable, msg("Update available")], -] +]; export const eventActionToLabel = new Map(eventActionLabels); diff --git a/web/src/elements/sidebar/Sidebar.ts b/web/src/elements/sidebar/Sidebar.ts index 211825784..c07dcf828 100644 --- a/web/src/elements/sidebar/Sidebar.ts +++ b/web/src/elements/sidebar/Sidebar.ts @@ -12,7 +12,7 @@ import PFBase from "@patternfly/patternfly/patternfly-base.css"; import { UiThemeEnum } from "@goauthentik/api"; -import type { SidebarEntry } from "./SidebarItems"; +import type { SidebarEntry } from "./types"; @customElement("ak-sidebar") export class Sidebar extends AKElement { diff --git a/web/src/elements/sidebar/SidebarItems.ts b/web/src/elements/sidebar/SidebarItems.ts index 28fc2f7fc..53b56bb57 100644 --- a/web/src/elements/sidebar/SidebarItems.ts +++ b/web/src/elements/sidebar/SidebarItems.ts @@ -11,29 +11,8 @@ import PFBase from "@patternfly/patternfly/patternfly-base.css"; import { UiThemeEnum } from "@goauthentik/api"; -// The second attribute type is of string[] to help with the 'activeWhen' control, which was -// commonplace and singular enough to merit its own handler. -export type SidebarEventHandler = () => void; - -export type SidebarAttributes = { - isAbsoluteLink?: boolean | (() => boolean); - highlight?: boolean | (() => boolean); - expanded?: boolean | (() => boolean); - activeWhen?: string[]; - isActive?: boolean; -}; - -export type SidebarEntry = { - path: string | SidebarEventHandler | null; - label: string; - attributes?: SidebarAttributes | null; // eslint-disable-line - children?: SidebarEntry[]; -}; - -// Typescript requires the type here to correctly type the recursive path -export type SidebarRenderer = (_: SidebarEntry) => TemplateResult; - -const entryKey = (entry: SidebarEntry) => `${entry.path || "no-path"}:${entry.label}`; +import type { SidebarEntry } from "./types"; +import { entryKey, findMatchForNavbarUrl, makeParentMap } from "./utils"; @customElement("ak-sidebar-items") export class SidebarItems extends AKElement { @@ -146,14 +125,22 @@ export class SidebarItems extends AKElement { super.disconnectedCallback(); } - render(): TemplateResult { - const lightThemed = { "pf-m-light": this.activeTheme === UiThemeEnum.Light }; + expandParents(entry: SidebarEntry) { + const reverseMap = makeParentMap(this.entries); + let start: SidebarEntry | undefined = reverseMap.get(entry); + while (start) { + this.expanded.add(entryKey(start)); + start = reverseMap.get(start); + } + } - return html` `; + onHashChange() { + this.current = ""; + const match = findMatchForNavbarUrl(this.entries); + if (match) { + this.current = entryKey(match); + this.expandParents(match); + } } toggleExpand(entry: SidebarEntry) { @@ -166,31 +153,39 @@ export class SidebarItems extends AKElement { this.requestUpdate(); } + render(): TemplateResult { + const lightThemed = { "pf-m-light": this.activeTheme === UiThemeEnum.Light }; + + return html` `; + } + renderItem(entry: SidebarEntry) { - const { path, label, attributes, children } = entry; // Ensure the attributes are undefined, not null; they can be null in the placeholders, but // not when being forwarded to the correct renderer. - const attr = attributes ?? undefined; - const hasChildren = !!(children && children.length > 0); + const hasChildren = !!(entry.children && entry.children.length > 0); - // This is grossly imperative, in that it HAS to come before the content is rendered - // to make sure the content gets the right settings with respect to expansion. - if (attr?.expanded) { + // This is grossly imperative, in that it HAS to come before the content is rendered to make + // sure the content gets the right settings with respect to expansion. + if (entry.attributes?.expanded) { this.expanded.add(entryKey(entry)); - delete attr.expanded; + delete entry.attributes.expanded; } const content = - path && hasChildren + entry.path && hasChildren ? this.renderLinkAndChildren(entry) : hasChildren - ? this.renderLabelAndChildren(entry) - : path - ? this.renderLink(label, path, attr) - : this.renderLabel(label, attr); + ? this.renderLabelAndChildren(entry) + : entry.path + ? this.renderLink(entry) + : this.renderLabel(entry); const expanded = { - "highlighted": !!attr?.highlight, + "highlighted": !!entry.attributes?.highlight, "pf-m-expanded": this.expanded.has(entryKey(entry)), "pf-m-expandable": hasChildren, }; @@ -198,32 +193,31 @@ export class SidebarItems extends AKElement { return html`
  • ${content}
  • `; } - toLinkClasses(attr: SidebarAttributes) { + getLinkClasses(entry: SidebarEntry) { + const a = entry.attributes ?? {}; return { - "pf-m-current": !!attr.isActive, + "pf-m-current": a == this.current, "pf-c-nav__link": true, - "highlight": !!(typeof attr.highlight === "function" - ? attr.highlight() - : attr.highlight), + "highlight": !!(typeof a.highlight === "function" ? a.highlight() : a.highlight), }; } - renderLabel(label: string, attr: SidebarAttributes = {}) { - return html`
    ${label}
    `; + renderLabel(entry: SidebarEntry) { + return html`
    ${entry.label}
    `; } // note the responsibilities pushed up to the caller - renderLink(label: string, path: string | SidebarEventHandler, attr: SidebarAttributes = {}) { - if (typeof path === "function") { - return html` - ${label} + renderLink(entry: SidebarEntry) { + if (typeof entry.path === "function") { + return html` + ${entry.label} `; } return html` - ${label} + ${entry.label} `; } diff --git a/web/src/elements/sidebar/types.ts b/web/src/elements/sidebar/types.ts new file mode 100644 index 000000000..a8ee73331 --- /dev/null +++ b/web/src/elements/sidebar/types.ts @@ -0,0 +1,21 @@ +import { TemplateResult } from "lit"; + +export type SidebarEventHandler = () => void; + +export type SidebarAttributes = { + isAbsoluteLink?: boolean | (() => boolean); + highlight?: boolean | (() => boolean); + expanded?: boolean | (() => boolean); + activeWhen?: string[]; + isActive?: boolean; +}; + +export type SidebarEntry = { + path: string | SidebarEventHandler | null; + label: string; + attributes?: SidebarAttributes | null; // eslint-disable-line + children?: SidebarEntry[]; +}; + +// Typescript requires the type here to correctly type the recursive path +export type SidebarRenderer = (_: SidebarEntry) => TemplateResult; diff --git a/web/src/elements/sidebar/utils.ts b/web/src/elements/sidebar/utils.ts new file mode 100644 index 000000000..bece5d7a9 --- /dev/null +++ b/web/src/elements/sidebar/utils.ts @@ -0,0 +1,46 @@ +import { ROUTE_SEPARATOR } from "@goauthentik/common/constants"; + +import { SidebarEntry } from "./types"; + +export function entryKey(entry: SidebarEntry) { + return `${entry.path || "no-path"}:${entry.label}`; +} + +export function makeParentMap(entries: SidebarEntry[]) { + const reverseMap = new WeakMap(); + function reverse(entry: SidebarEntry) { + (entry.children ?? []).forEach((e) => { + reverseMap.set(e, entry); + reverse(e); + }); + } + entries.forEach(reverse); + return reverseMap; +} + +function scanner(entry: SidebarEntry, activePath: string): SidebarEntry | undefined { + for (const matcher of entry.attributes?.activeWhen ?? []) { + const matchtest = new RegExp(matcher); + if (matchtest.test(activePath)) { + return entry; + } + const match: SidebarEntry | undefined = (entry.children ?? []).find((e) => + scanner(e, activePath), + ); + if (match) { + return match; + } + } + return undefined; +} + +export function findMatchForNavbarUrl(entries: SidebarEntry[]) { + const activePath = window.location.hash.slice(1, Infinity).split(ROUTE_SEPARATOR)[0]; + for (const entry of entries) { + const result = scanner(entry, activePath); + if (result) { + return result; + } + } + return undefined; +}