Compare commits
	
		
			49 Commits
		
	
	
		
			tests/e2e/
			...
			web/sideba
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| b865ed4392 | |||
| f0afac0b87 | |||
| a31588668d | |||
| 9768684c3c | |||
| cde94c2377 | |||
| b0e852afca | |||
| e35cefb63e | |||
| 2a11356961 | |||
| a3673906c7 | |||
| f2834cc7e2 | |||
| 5b898bef01 | |||
| 6b9201907d | |||
| 2ec8932891 | |||
| a9886b047e | |||
| a0dfe7ce78 | |||
| c471428c6b | |||
| 83e934f80c | |||
| 5386f0f4c3 | |||
| d5875a597b | |||
| 25ecc21d6d | |||
| ff78f2f00a | |||
| 3c277f14c8 | |||
| d539884204 | |||
| 476adef4ea | |||
| 3e905cc956 | |||
| e3b1ba63a6 | |||
| 2aed74bd9f | |||
| 2545815f08 | |||
| 657089eac9 | |||
| 19e8b675ae | |||
| bdd92f63d8 | |||
| 829ad5d3f2 | |||
| 58639a5d03 | |||
| 67cae13f93 | |||
| 100a6f02f1 | |||
| 242e5b492b | |||
| 48495f3c53 | |||
| 77549753c2 | |||
| 3b19aa1915 | |||
| 6653bd8224 | |||
| 639a8ceb5a | |||
| 0449fd07c5 | |||
| 8e892373a1 | |||
| 8713a1d120 | |||
| 0123bf61ab | |||
| e8edbdb4ae | |||
| 83338f8c32 | |||
| e51b36c614 | |||
| 314d89b1b7 | 
| @ -17,7 +17,6 @@ import "@goauthentik/elements/notifications/NotificationDrawer"; | ||||
| import { getURLParam, updateURLParams } from "@goauthentik/elements/router/RouteMatch"; | ||||
| import "@goauthentik/elements/router/RouterOutlet"; | ||||
| import "@goauthentik/elements/sidebar/Sidebar"; | ||||
| import "@goauthentik/elements/sidebar/SidebarItem"; | ||||
|  | ||||
| import { CSSResult, TemplateResult, css, html } from "lit"; | ||||
| import { customElement, property, state } from "lit/decorators.js"; | ||||
|  | ||||
| @ -1,5 +1,6 @@ | ||||
| import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; | ||||
| import { EVENT_SIDEBAR_TOGGLE, VERSION } from "@goauthentik/common/constants"; | ||||
| import { eventActionLabels } from "@goauthentik/common/labels"; | ||||
| import { me } from "@goauthentik/common/users"; | ||||
| import { AKElement } from "@goauthentik/elements/Base"; | ||||
| import { | ||||
| @ -7,17 +8,63 @@ import { | ||||
|     WithCapabilitiesConfig, | ||||
| } from "@goauthentik/elements/Interface/capabilitiesProvider"; | ||||
| import { ID_REGEX, SLUG_REGEX, UUID_REGEX } from "@goauthentik/elements/router/Route"; | ||||
| import "@goauthentik/elements/sidebar/Sidebar"; | ||||
| import { | ||||
|     SidebarAttributes, | ||||
|     SidebarEntry, | ||||
|     SidebarEventHandler, | ||||
| } from "@goauthentik/elements/sidebar/types"; | ||||
| import { getRootStyle } from "@goauthentik/elements/utils/getRootStyle"; | ||||
| import { spread } from "@open-wc/lit-helpers"; | ||||
|  | ||||
| import { msg, str } from "@lit/localize"; | ||||
| import { TemplateResult, html, nothing } from "lit"; | ||||
| import { html } from "lit"; | ||||
| import { customElement, property, state } from "lit/decorators.js"; | ||||
| import { map } from "lit/directives/map.js"; | ||||
|  | ||||
| import { AdminApi, CoreApi, UiThemeEnum, Version } from "@goauthentik/api"; | ||||
| import { AdminApi } from "@goauthentik/api"; | ||||
| import { CoreApi, Version } from "@goauthentik/api"; | ||||
| import type { SessionUser, UserSelf } from "@goauthentik/api"; | ||||
|  | ||||
| import { flowDesignationTable } from "../flows/utils"; | ||||
| import ConnectionTypesController from "./SidebarEntries/ConnectionTypesController"; | ||||
| import PolicyTypesController from "./SidebarEntries/PolicyTypesController"; | ||||
| import PropertyMappingsController from "./SidebarEntries/PropertyMappingsController"; | ||||
| import ProviderTypesController from "./SidebarEntries/ProviderTypesController"; | ||||
| import SourceTypesController from "./SidebarEntries/SourceTypesController"; | ||||
| import StageTypesController from "./SidebarEntries/StageTypesController"; | ||||
|  | ||||
| /** | ||||
|  * AdminSidebar | ||||
|  * | ||||
|  * The AdminSidebar has two responsibilities: | ||||
|  * | ||||
|  * 1. Control the styling of the sidebar host, specifically when to show it and whether to show | ||||
|  *    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; | ||||
|  | ||||
| export type LocalSidebarEntry = [ | ||||
|     // - null: This entry is not a link. | ||||
|     // - string: the url for the entry | ||||
|     // - SidebarEventHandler: a function to run if the entry is clicked. | ||||
|     SidebarUrl | SidebarEventHandler | null, | ||||
|     // The visible text of the entry. | ||||
|     string, | ||||
|     // Attributes to which the sidebar responds. See the sidebar for details. | ||||
|     (SidebarAttributes | string[] | null)?, // eslint-disable-line | ||||
|     // Children of the entry | ||||
|     LocalSidebarEntry[]?, | ||||
| ]; | ||||
|  | ||||
| const localToSidebarEntry = (l: LocalSidebarEntry): SidebarEntry => ({ | ||||
|     path: l[0], | ||||
|     label: l[1], | ||||
|     ...(l[2] ? { attributes: Array.isArray(l[2]) ? { activeWhen: l[2] } : l[2] } : {}), | ||||
|     ...(l[3] ? { children: l[3].map(localToSidebarEntry) } : {}), | ||||
| }); | ||||
|  | ||||
| @customElement("ak-admin-sidebar") | ||||
| export class AkAdminSidebar extends WithCapabilitiesConfig(AKElement) { | ||||
|     @property({ type: Boolean, reflect: true }) | ||||
| @ -29,6 +76,13 @@ export class AkAdminSidebar extends WithCapabilitiesConfig(AKElement) { | ||||
|     @state() | ||||
|     impersonation: UserSelf["username"] | null = null; | ||||
|  | ||||
|     private connectionTypes = new ConnectionTypesController(this); | ||||
|     private policyTypes = new PolicyTypesController(this); | ||||
|     private propertyMapper = new PropertyMappingsController(this); | ||||
|     private providerTypes = new ProviderTypesController(this); | ||||
|     private sourceTypes = new SourceTypesController(this); | ||||
|     private stageTypes = new StageTypesController(this); | ||||
|  | ||||
|     constructor() { | ||||
|         super(); | ||||
|         new AdminApi(DEFAULT_CONFIG).adminVersionRetrieve().then((version) => { | ||||
| @ -74,19 +128,6 @@ export class AkAdminSidebar extends WithCapabilitiesConfig(AKElement) { | ||||
|         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 | ||||
| @ -97,118 +138,86 @@ export class AkAdminSidebar extends WithCapabilitiesConfig(AKElement) { | ||||
|         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("Customization"), null, [ | ||||
|                 ["/policy/policies", msg("Policies")], | ||||
|                 ["/core/property-mappings", msg("Property Mappings")], | ||||
|                 ["/blueprints/instances", msg("Blueprints")], | ||||
|                 ["/policy/reputation", msg("Reputation scores")]]], | ||||
|             [null, msg("Flows and Stages"), null, [ | ||||
|                 ["/flow/flows", msg("Flows"), [`^/flow/flows/(?<slug>${SLUG_REGEX})$`]], | ||||
|                 ["/flow/stages", msg("Stages")], | ||||
|                 ["/flow/stages/prompts", msg("Prompts")]]], | ||||
|             [null, msg("Directory"), null, [ | ||||
|                 ["/identity/users", msg("Users"), [`^/identity/users/(?<id>${ID_REGEX})$`]], | ||||
|                 ["/identity/groups", msg("Groups"), [`^/identity/groups/(?<id>${UUID_REGEX})$`]], | ||||
|                 ["/identity/roles", msg("Roles"), [`^/identity/roles/(?<id>${UUID_REGEX})$`]], | ||||
|                 ["/core/sources", msg("Federation and Social login"), [`^/core/sources/(?<slug>${SLUG_REGEX})$`]], | ||||
|                 ["/core/tokens", msg("Tokens and App passwords")], | ||||
|                 ["/flow/stages/invitations", msg("Invitations")]]], | ||||
|             [null, msg("System"), null, [ | ||||
|                 ["/core/brands", msg("Brands")], | ||||
|                 ["/crypto/certificates", msg("Certificates")], | ||||
|                 ["/outpost/integrations", msg("Outpost Integrations")], | ||||
|                 ["/admin/settings", msg("Settings")]]], | ||||
|         ]; | ||||
|  | ||||
|         // Typescript requires the type here to correctly type the recursive path | ||||
|         type SidebarRenderer = (_: SidebarEntry) => TemplateResult; | ||||
|  | ||||
|         const renderOneSidebarItem: SidebarRenderer = ([path, label, attributes, children]) => { | ||||
|             const properties = Array.isArray(attributes) | ||||
|                 ? { ".activeWhen": attributes } | ||||
|                 : attributes ?? {}; | ||||
|             if (path) { | ||||
|                 properties["path"] = path; | ||||
|             } | ||||
|             return html`<ak-sidebar-item ${spread(properties)}> | ||||
|                 ${label ? html`<span slot="label">${label}</span>` : nothing} | ||||
|                 ${map(children, renderOneSidebarItem)} | ||||
|             </ak-sidebar-item>`; | ||||
|         }; | ||||
|  | ||||
|         // prettier-ignore | ||||
|         return html` | ||||
|             ${this.renderNewVersionMessage()} | ||||
|             ${this.renderImpersonationMessage()} | ||||
|             ${map(sidebarContent, renderOneSidebarItem)} | ||||
|             ${this.renderEnterpriseMenu()} | ||||
|         `; | ||||
|     } | ||||
|  | ||||
|     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() { | ||||
|     get sidebarItems(): SidebarEntry[] { | ||||
|         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; | ||||
|         // prettier-ignore | ||||
|         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.`)]] | ||||
|             : []; | ||||
|  | ||||
|         // prettier-ignore | ||||
|         const enterpriseMenu: LocalSidebarEntry[] = this.can(CapabilitiesEnum.IsEnterprise) | ||||
|             ? [[null, msg("Enterprise"), null, [["/enterprise/licenses", msg("Licenses")]]]] | ||||
|             : []; | ||||
|  | ||||
|         const flowTypes: LocalSidebarEntry[] = flowDesignationTable.map(([_designation, label]) => [ | ||||
|             `/flow/flows;${encodeURIComponent(JSON.stringify({ search: label }))}`, | ||||
|             label, | ||||
|         ]); | ||||
|  | ||||
|         const eventTypes: LocalSidebarEntry[] = eventActionLabels.map(([_action, label]) => [ | ||||
|             `/events/log;${encodeURIComponent(JSON.stringify({ search: label }))}`, | ||||
|             label, | ||||
|         ]); | ||||
|  | ||||
|         // prettier-ignore | ||||
|         const localSidebar: LocalSidebarEntry[] = [ | ||||
|             ...(newVersionMessage), | ||||
|             ...(impersonationMessage), | ||||
|             ["/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}))?$`], this.providerTypes.entries()], | ||||
|                 ["/outpost/outposts", msg("Outposts")]]], | ||||
|             [null, msg("Events"), null, [ | ||||
|                 ["/events/log", msg("Logs"), [`^/events/log(/(?<id>${UUID_REGEX}))?$`], eventTypes], | ||||
|                 ["/events/rules", msg("Notification Rules")], | ||||
|                 ["/events/transports", msg("Notification Transports")]]], | ||||
|             [null, msg("Customisation"), null, [ | ||||
|                 ["/policy/policies", msg("Policies"), null, this.policyTypes.entries()], | ||||
|                 ["/core/property-mappings", msg("Property Mappings"), null, this.propertyMapper.entries()], | ||||
|                 ["/blueprints/instances", msg("Blueprints")], | ||||
|                 ["/policy/reputation", msg("Reputation scores")]]], | ||||
|             [null, msg("Flows and Stages"), null, [ | ||||
|                 ["/flow/flows", msg("Flows"), [`^/flow/flows(/(?<slug>${SLUG_REGEX}))?$`], flowTypes], | ||||
|                 ["/flow/stages", msg("Stages"), null, this.stageTypes.entries()], | ||||
|                 ["/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}))?$`], this.sourceTypes.entries()], | ||||
|                 ["/core/tokens", msg("Tokens and App passwords")], | ||||
|                 ["/flow/stages/invitations", msg("Invitations")]]], | ||||
|              [null, msg("System"), null, [ | ||||
|                  ["/core/brands", msg("Brands")], | ||||
|                  ["/crypto/certificates", msg("Certificates")], | ||||
|                  ["/outpost/integrations", msg("Outpost Integrations"), null, this.connectionTypes.entries()], | ||||
|                  ["/admin/settings", msg("Settings")]]], | ||||
|             ...(enterpriseMenu) | ||||
|         ]; | ||||
|  | ||||
|         return localSidebar.map(localToSidebarEntry); | ||||
|     } | ||||
|  | ||||
|     renderEnterpriseMenu() { | ||||
|         return this.can(CapabilitiesEnum.IsEnterprise) | ||||
|             ? html` | ||||
|                   <ak-sidebar-item> | ||||
|                       <span slot="label">${msg("Enterprise")}</span> | ||||
|                       <ak-sidebar-item path="/enterprise/licenses"> | ||||
|                           <span slot="label">${msg("Licenses")}</span> | ||||
|                       </ak-sidebar-item> | ||||
|                   </ak-sidebar-item> | ||||
|               ` | ||||
|             : nothing; | ||||
|     render() { | ||||
|         return html` | ||||
|             <ak-sidebar class="pf-c-page__sidebar" .entries=${this.sidebarItems}></ak-sidebar> | ||||
|         `; | ||||
|     } | ||||
| } | ||||
|  | ||||
| @ -0,0 +1,12 @@ | ||||
| import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; | ||||
|  | ||||
| import { OutpostsApi } from "@goauthentik/api"; | ||||
|  | ||||
| import { createTypesController } from "./GenericTypesController"; | ||||
|  | ||||
| export const ConnectionTypesController = createTypesController( | ||||
|     () => new OutpostsApi(DEFAULT_CONFIG).outpostsServiceConnectionsAllTypesList(), | ||||
|     "/outpost/integrations", | ||||
| ); | ||||
|  | ||||
| export default ConnectionTypesController; | ||||
| @ -0,0 +1,55 @@ | ||||
| import { ReactiveControllerHost } from "lit"; | ||||
|  | ||||
| import { TypeCreate } from "@goauthentik/api"; | ||||
|  | ||||
| import { LocalSidebarEntry } from "../AdminSidebar"; | ||||
|  | ||||
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | ||||
| type Fetcher = () => Promise<TypeCreate[]>; | ||||
|  | ||||
| const typeCreateToSidebar = (baseUrl: string, tcreate: TypeCreate[]): LocalSidebarEntry[] => | ||||
|     tcreate.map((t) => [ | ||||
|         `${baseUrl};${encodeURIComponent(JSON.stringify({ search: t.name }))}`, | ||||
|         t.name, | ||||
|     ]); | ||||
|  | ||||
| /** | ||||
|  * createTypesController | ||||
|  * | ||||
|  * The Sidebar accesses a number objects of `TypeCreate`, which all have the exact same type, just | ||||
|  * different accessors for generating the lists and different paths to which they respond. This | ||||
|  * function is a template for a (simple) reactive controller that fetches the data for that type on | ||||
|  * construction, then informs the host that the data is available. | ||||
|  */ | ||||
|  | ||||
| /** | ||||
|  * TODO (2023-11-17): This function is unlikely to survive in this form. It would be nice if it were more | ||||
|  * generic, able to take a converter that can handle more that TypeCreate[] as its inbound argument, | ||||
|  * since we need to refine what's displayed and on what the search is conducted. | ||||
|  * | ||||
|  */ | ||||
|  | ||||
| export function createTypesController( | ||||
|     fetch: Fetcher, | ||||
|     path: string, | ||||
|     converter = typeCreateToSidebar, | ||||
| ) { | ||||
|     return class GenericTypesController { | ||||
|         createTypes: TypeCreate[] = []; | ||||
|         host: ReactiveControllerHost; | ||||
|  | ||||
|         constructor(host: ReactiveControllerHost) { | ||||
|             this.host = host; | ||||
|             fetch().then((types) => { | ||||
|                 this.createTypes = types; | ||||
|                 host.requestUpdate(); | ||||
|             }); | ||||
|         } | ||||
|  | ||||
|         entries(): LocalSidebarEntry[] { | ||||
|             return converter(path, this.createTypes); | ||||
|         } | ||||
|     }; | ||||
| } | ||||
|  | ||||
| export default createTypesController; | ||||
| @ -0,0 +1,12 @@ | ||||
| import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; | ||||
|  | ||||
| import { PoliciesApi } from "@goauthentik/api"; | ||||
|  | ||||
| import { createTypesController } from "./GenericTypesController"; | ||||
|  | ||||
| export const PolicyTypesController = createTypesController( | ||||
|     () => new PoliciesApi(DEFAULT_CONFIG).policiesAllTypesList(), | ||||
|     "/policy/policies", | ||||
| ); | ||||
|  | ||||
| export default PolicyTypesController; | ||||
| @ -0,0 +1,12 @@ | ||||
| import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; | ||||
|  | ||||
| import { PropertymappingsApi } from "@goauthentik/api"; | ||||
|  | ||||
| import { createTypesController } from "./GenericTypesController"; | ||||
|  | ||||
| export const PropertyMappingsController = createTypesController( | ||||
|     () => new PropertymappingsApi(DEFAULT_CONFIG).propertymappingsAllTypesList(), | ||||
|     "/core/property-mappings", | ||||
| ); | ||||
|  | ||||
| export default PropertyMappingsController; | ||||
| @ -0,0 +1,12 @@ | ||||
| import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; | ||||
|  | ||||
| import { ProvidersApi } from "@goauthentik/api"; | ||||
|  | ||||
| import { createTypesController } from "./GenericTypesController"; | ||||
|  | ||||
| export const ProviderTypesController = createTypesController( | ||||
|     () => new ProvidersApi(DEFAULT_CONFIG).providersAllTypesList(), | ||||
|     "/core/providers", | ||||
| ); | ||||
|  | ||||
| export default ProviderTypesController; | ||||
| @ -0,0 +1,12 @@ | ||||
| import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; | ||||
|  | ||||
| import { SourcesApi } from "@goauthentik/api"; | ||||
|  | ||||
| import { createTypesController } from "./GenericTypesController"; | ||||
|  | ||||
| export const SourceTypesController = createTypesController( | ||||
|     () => new SourcesApi(DEFAULT_CONFIG).sourcesAllTypesList(), | ||||
|     "/core/sources", | ||||
| ); | ||||
|  | ||||
| export default SourceTypesController; | ||||
| @ -0,0 +1,12 @@ | ||||
| import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; | ||||
|  | ||||
| import { StagesApi } from "@goauthentik/api"; | ||||
|  | ||||
| import { createTypesController } from "./GenericTypesController"; | ||||
|  | ||||
| export const StageTypesController = createTypesController( | ||||
|     () => new StagesApi(DEFAULT_CONFIG).stagesAllTypesList(), | ||||
|     "/flow/stages", | ||||
| ); | ||||
|  | ||||
| export default StageTypesController; | ||||
| @ -6,40 +6,33 @@ export function RenderFlowOption(flow: Flow): string { | ||||
|     return `${flow.slug} (${flow.name})`; | ||||
| } | ||||
|  | ||||
| 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")], | ||||
| ]; | ||||
|  | ||||
| // prettier-ignore | ||||
| const flowDesignations = new Map(flowDesignationTable); | ||||
|  | ||||
| export function DesignationToLabel(designation: FlowDesignationEnum): string { | ||||
|     switch (designation) { | ||||
|         case FlowDesignationEnum.Authentication: | ||||
|             return msg("Authentication"); | ||||
|         case FlowDesignationEnum.Authorization: | ||||
|             return msg("Authorization"); | ||||
|         case FlowDesignationEnum.Enrollment: | ||||
|             return msg("Enrollment"); | ||||
|         case FlowDesignationEnum.Invalidation: | ||||
|             return msg("Invalidation"); | ||||
|         case FlowDesignationEnum.Recovery: | ||||
|             return msg("Recovery"); | ||||
|         case FlowDesignationEnum.StageConfiguration: | ||||
|             return msg("Stage Configuration"); | ||||
|         case FlowDesignationEnum.Unenrollment: | ||||
|             return msg("Unenrollment"); | ||||
|         case FlowDesignationEnum.UnknownDefaultOpenApi: | ||||
|             return msg("Unknown designation"); | ||||
|     } | ||||
|     return flowDesignations.get(designation) ?? msg("Unknown designation"); | ||||
| } | ||||
|  | ||||
| const layoutToLabel = new Map<FlowLayoutEnum, string>([ | ||||
|     [FlowLayoutEnum.Stacked, msg("Stacked")], | ||||
|     [FlowLayoutEnum.ContentLeft, msg("Content left")], | ||||
|     [FlowLayoutEnum.ContentRight, msg("Content right")], | ||||
|     [FlowLayoutEnum.SidebarLeft, msg("Sidebar left")], | ||||
|     [FlowLayoutEnum.SidebarRight, msg("Sidebar right")], | ||||
| ]); | ||||
|  | ||||
| export function LayoutToLabel(layout: FlowLayoutEnum): string { | ||||
|     switch (layout) { | ||||
|         case FlowLayoutEnum.Stacked: | ||||
|             return msg("Stacked"); | ||||
|         case FlowLayoutEnum.ContentLeft: | ||||
|             return msg("Content left"); | ||||
|         case FlowLayoutEnum.ContentRight: | ||||
|             return msg("Content right"); | ||||
|         case FlowLayoutEnum.SidebarLeft: | ||||
|             return msg("Sidebar left"); | ||||
|         case FlowLayoutEnum.SidebarRight: | ||||
|             return msg("Sidebar right"); | ||||
|         case FlowLayoutEnum.UnknownDefaultOpenApi: | ||||
|             return msg("Unknown layout"); | ||||
|     } | ||||
|     return layoutToLabel.get(layout) ?? msg("Unknown layout"); | ||||
| } | ||||
|  | ||||
| @ -2,6 +2,8 @@ import { msg } from "@lit/localize"; | ||||
|  | ||||
| import { Device, EventActions, IntentEnum, SeverityEnum, UserTypeEnum } from "@goauthentik/api"; | ||||
|  | ||||
| type Pair<T> = [T, string]; | ||||
|  | ||||
| /* Various tables in the API for which we need to supply labels */ | ||||
|  | ||||
| export const intentEnumToLabel = new Map<IntentEnum, string>([ | ||||
| @ -14,7 +16,7 @@ export const intentEnumToLabel = new Map<IntentEnum, string>([ | ||||
|  | ||||
| export const intentToLabel = (intent: IntentEnum) => intentEnumToLabel.get(intent); | ||||
|  | ||||
| export const eventActionToLabel = new Map<EventActions | undefined, string>([ | ||||
| export const eventActionLabels: Pair<EventActions>[] = [ | ||||
|     [EventActions.Login, msg("Login")], | ||||
|     [EventActions.LoginFailed, msg("Failed login")], | ||||
|     [EventActions.Logout, msg("Logout")], | ||||
| @ -43,7 +45,9 @@ export const eventActionToLabel = new Map<EventActions | undefined, string>([ | ||||
|     [EventActions.ModelDeleted, msg("Model deleted")], | ||||
|     [EventActions.EmailSent, msg("Email sent")], | ||||
|     [EventActions.UpdateAvailable, msg("Update available")], | ||||
| ]); | ||||
| ]; | ||||
|  | ||||
| export const eventActionToLabel = new Map<EventActions | undefined, string>(eventActionLabels); | ||||
|  | ||||
| export const actionToLabel = (action?: EventActions): string => | ||||
|     eventActionToLabel.get(action) ?? action ?? ""; | ||||
|  | ||||
							
								
								
									
										56
									
								
								web/src/elements/sidebar/Sidebar.css.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										56
									
								
								web/src/elements/sidebar/Sidebar.css.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,56 @@ | ||||
| import { css } from "lit"; | ||||
|  | ||||
| import PFNav from "@patternfly/patternfly/components/Nav/nav.css"; | ||||
| import PFPage from "@patternfly/patternfly/components/Page/page.css"; | ||||
| import PFBase from "@patternfly/patternfly/patternfly-base.css"; | ||||
|  | ||||
| export const sidebarStyles = [ | ||||
|     PFBase, | ||||
|     PFPage, | ||||
|     PFNav, | ||||
|     css` | ||||
|         :host { | ||||
|             z-index: 100; | ||||
|         } | ||||
|         .pf-c-nav__link.pf-m-current::after, | ||||
|         .pf-c-nav__link.pf-m-current:hover::after, | ||||
|         .pf-c-nav__item.pf-m-current:not(.pf-m-expanded) .pf-c-nav__link::after { | ||||
|             --pf-c-nav__link--m-current--after--BorderColor: #fd4b2d; | ||||
|         } | ||||
|         :host([theme="light"]) { | ||||
|             border-right-color: transparent !important; | ||||
|         } | ||||
|  | ||||
|         .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; | ||||
|             max-height: 100vh; | ||||
|             height: 100%; | ||||
|             overflow-y: hidden; | ||||
|         } | ||||
|  | ||||
|         ak-sidebar-items { | ||||
|             flex-grow: 1; | ||||
|             overflow-y: auto; | ||||
|         } | ||||
|  | ||||
|         .pf-c-nav__link { | ||||
|             --pf-c-nav__link--PaddingTop: 0.5rem; | ||||
|             --pf-c-nav__link--PaddingRight: 0.5rem; | ||||
|             --pf-c-nav__link--PaddingBottom: 0.5rem; | ||||
|         } | ||||
|         .pf-c-nav__section-title { | ||||
|             font-size: 12px; | ||||
|         } | ||||
|         .pf-c-nav__item { | ||||
|             --pf-c-nav__item--MarginTop: 0px; | ||||
|         } | ||||
|     `, | ||||
| ]; | ||||
| @ -1,79 +1,32 @@ | ||||
| import { AKElement } from "@goauthentik/elements/Base"; | ||||
| import "@goauthentik/elements/sidebar/SidebarBrand"; | ||||
| import "@goauthentik/elements/sidebar/SidebarItems"; | ||||
| import "@goauthentik/elements/sidebar/SidebarUser"; | ||||
|  | ||||
| import { CSSResult, TemplateResult, css, html } from "lit"; | ||||
| import { customElement } from "lit/decorators.js"; | ||||
|  | ||||
| import PFNav from "@patternfly/patternfly/components/Nav/nav.css"; | ||||
| import PFPage from "@patternfly/patternfly/components/Page/page.css"; | ||||
| import PFBase from "@patternfly/patternfly/patternfly-base.css"; | ||||
| import { html } from "lit"; | ||||
| import { customElement, property } from "lit/decorators.js"; | ||||
|  | ||||
| import { UiThemeEnum } from "@goauthentik/api"; | ||||
|  | ||||
| import { sidebarStyles } from "./Sidebar.css.js"; | ||||
| import type { SidebarEntry } from "./types"; | ||||
|  | ||||
| @customElement("ak-sidebar") | ||||
| export class Sidebar extends AKElement { | ||||
|     static get styles(): CSSResult[] { | ||||
|         return [ | ||||
|             PFBase, | ||||
|             PFPage, | ||||
|             PFNav, | ||||
|             css` | ||||
|                 :host { | ||||
|                     z-index: 100; | ||||
|                 } | ||||
|                 .pf-c-nav__link.pf-m-current::after, | ||||
|                 .pf-c-nav__link.pf-m-current:hover::after, | ||||
|                 .pf-c-nav__item.pf-m-current:not(.pf-m-expanded) .pf-c-nav__link::after { | ||||
|                     --pf-c-nav__link--m-current--after--BorderColor: #fd4b2d; | ||||
|                 } | ||||
|                 :host([theme="light"]) { | ||||
|                     border-right-color: transparent !important; | ||||
|     @property({ type: Array }) | ||||
|     entries: SidebarEntry[] = []; | ||||
|  | ||||
|     static get styles() { | ||||
|         return sidebarStyles; | ||||
|     } | ||||
|  | ||||
|                 .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; | ||||
|                     max-height: 100vh; | ||||
|                     height: 100%; | ||||
|                     overflow-y: hidden; | ||||
|                 } | ||||
|                 .pf-c-nav__list { | ||||
|                     flex-grow: 1; | ||||
|                     overflow-y: auto; | ||||
|                 } | ||||
|  | ||||
|                 .pf-c-nav__link { | ||||
|                     --pf-c-nav__link--PaddingTop: 0.5rem; | ||||
|                     --pf-c-nav__link--PaddingRight: 0.5rem; | ||||
|                     --pf-c-nav__link--PaddingBottom: 0.5rem; | ||||
|                 } | ||||
|                 .pf-c-nav__section-title { | ||||
|                     font-size: 12px; | ||||
|                 } | ||||
|                 .pf-c-nav__item { | ||||
|                     --pf-c-nav__item--MarginTop: 0px; | ||||
|                 } | ||||
|             `, | ||||
|         ]; | ||||
|     } | ||||
|  | ||||
|     render(): TemplateResult { | ||||
|     render() { | ||||
|         return html`<nav | ||||
|             class="pf-c-nav ${this.activeTheme === UiThemeEnum.Light ? "pf-m-light" : ""}" | ||||
|             aria-label="Global" | ||||
|         > | ||||
|             <ak-sidebar-brand></ak-sidebar-brand> | ||||
|             <ul class="pf-c-nav__list"> | ||||
|                 <slot></slot> | ||||
|             </ul> | ||||
|             <ak-sidebar-items .entries=${this.entries}></ak-sidebar-items> | ||||
|             <ak-sidebar-user></ak-sidebar-user> | ||||
|         </nav>`; | ||||
|     } | ||||
|  | ||||
							
								
								
									
										86
									
								
								web/src/elements/sidebar/SidebarItems.css.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										86
									
								
								web/src/elements/sidebar/SidebarItems.css.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,86 @@ | ||||
| import { css } from "lit"; | ||||
|  | ||||
| import PFNav from "@patternfly/patternfly/components/Nav/nav.css"; | ||||
| import PFPage from "@patternfly/patternfly/components/Page/page.css"; | ||||
| import PFBase from "@patternfly/patternfly/patternfly-base.css"; | ||||
|  | ||||
| export const sidebarItemStyles = [ | ||||
|     PFBase, | ||||
|     PFPage, | ||||
|     PFNav, | ||||
|     css` | ||||
|         :host { | ||||
|             z-index: 100; | ||||
|             box-shadow: none !important; | ||||
|         } | ||||
|  | ||||
|         .highlighted { | ||||
|             background-color: var(--ak-accent); | ||||
|             margin: 16px; | ||||
|         } | ||||
|  | ||||
|         .highlighted .pf-c-nav__link { | ||||
|             padding-left: 0.5rem; | ||||
|         } | ||||
|  | ||||
|         .pf-c-nav__link.pf-m-current::after, | ||||
|         .pf-c-nav__link.pf-m-current:hover::after, | ||||
|         .pf-c-nav__item.pf-m-current:not(.pf-m-expanded) .pf-c-nav__link::after { | ||||
|             --pf-c-nav__link--m-current--after--BorderColor: #fd4b2d; | ||||
|         } | ||||
|  | ||||
|         .pf-c-nav__item .pf-c-nav__item::before { | ||||
|             border-bottom-width: 0; | ||||
|         } | ||||
|  | ||||
|         .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; | ||||
|         } | ||||
|         .pf-c-nav__toggle { | ||||
|             width: calc(var(--pf-c-nav__toggle--FontSize) + calc(2 * var(--pf-global--spacer--md))); | ||||
|         } | ||||
|  | ||||
|         nav { | ||||
|             display: flex; | ||||
|             flex-direction: column; | ||||
|             max-height: 100vh; | ||||
|             height: 100%; | ||||
|             overflow-y: hidden; | ||||
|         } | ||||
|         .pf-c-nav__list { | ||||
|             flex: 1 0 1fr; | ||||
|             overflow-y: auto; | ||||
|         } | ||||
|  | ||||
|         .pf-c-nav__link { | ||||
|             --pf-c-nav__link--PaddingTop: 0.5rem; | ||||
|             --pf-c-nav__link--PaddingRight: 0.5rem; | ||||
|             --pf-c-nav__link--PaddingBottom: 0.5rem; | ||||
|         } | ||||
|  | ||||
|         .pf-c-nav__link a { | ||||
|             flex: 1 0 max-content; | ||||
|             color: var(--pf-c-nav__link--Color); | ||||
|         } | ||||
|  | ||||
|         a.pf-c-nav__link:hover { | ||||
|             color: var(--pf-c-nav__link--Color); | ||||
|             text-decoration: var(--pf-global--link--TextDecoration--hover); | ||||
|         } | ||||
|  | ||||
|         .pf-c-nav__section-title { | ||||
|             font-size: 12px; | ||||
|         } | ||||
|         .pf-c-nav__item { | ||||
|             --pf-c-nav__item--MarginTop: 0px; | ||||
|         } | ||||
|  | ||||
|         .pf-c-nav__toggle-icon { | ||||
|             padding: var(--pf-global--spacer--sm) var(--pf-global--spacer--md); | ||||
|         } | ||||
|     `, | ||||
| ]; | ||||
							
								
								
									
										247
									
								
								web/src/elements/sidebar/SidebarItems.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										247
									
								
								web/src/elements/sidebar/SidebarItems.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,247 @@ | ||||
| import { ROUTE_SEPARATOR } from "@goauthentik/common/constants"; | ||||
| import { AKElement } from "@goauthentik/elements/Base"; | ||||
| import { findTable } from "@goauthentik/elements/table/TablePage"; | ||||
|  | ||||
| import { TemplateResult, html, nothing } from "lit"; | ||||
| import { customElement, property, state } from "lit/decorators.js"; | ||||
| import { classMap } from "lit/directives/class-map.js"; | ||||
| import { map } from "lit/directives/map.js"; | ||||
|  | ||||
| import { UiThemeEnum } from "@goauthentik/api"; | ||||
|  | ||||
| import { sidebarItemStyles } from "./SidebarItems.css.js"; | ||||
| import type { SidebarEntry } from "./types"; | ||||
| import { entryKey, findMatchForNavbarUrl, makeParentMap } from "./utils"; | ||||
|  | ||||
| /** | ||||
|  * Display the sidebar item tree. | ||||
|  * | ||||
|  * Along with the `reclick()` complaint down below, the other thing I dislike about this design is | ||||
|  * that it's effectively two different programs glued together. The first responds to the `click` | ||||
|  * and performs the navigation, which either triggers the router or triggers a new search on the | ||||
|  * existing view. The second responds to the navigation change event when the URL is changed by the | ||||
|  * navigation event, at which point it figures out which entry to highlight as "current," which | ||||
|  * causes the re-render. | ||||
|  */ | ||||
|  | ||||
| @customElement("ak-sidebar-items") | ||||
| export class SidebarItems extends AKElement { | ||||
|     static get styles() { | ||||
|         return sidebarItemStyles; | ||||
|     } | ||||
|  | ||||
|     @property({ type: Array }) | ||||
|     entries: SidebarEntry[] = []; | ||||
|  | ||||
|     expanded: Set<string> = new Set(); | ||||
|  | ||||
|     @state() | ||||
|     current = ""; | ||||
|  | ||||
|     constructor() { | ||||
|         super(); | ||||
|         this.renderItem = this.renderItem.bind(this); | ||||
|         this.toggleExpand = this.toggleExpand.bind(this); | ||||
|         this.onHashChange = this.onHashChange.bind(this); | ||||
|         this.reclick = this.reclick.bind(this); | ||||
|     } | ||||
|  | ||||
|     connectedCallback() { | ||||
|         super.connectedCallback(); | ||||
|         this.onHashChange(); | ||||
|         window.addEventListener("hashchange", this.onHashChange); | ||||
|     } | ||||
|  | ||||
|     disconnectedCallback() { | ||||
|         window.removeEventListener("hashchange", this.onHashChange); | ||||
|         super.disconnectedCallback(); | ||||
|     } | ||||
|  | ||||
|     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); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     onHashChange() { | ||||
|         this.current = ""; | ||||
|         const match = findMatchForNavbarUrl(this.entries); | ||||
|         if (match) { | ||||
|             this.current = entryKey(match); | ||||
|             this.expandParents(match); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     toggleExpand(entry: SidebarEntry) { | ||||
|         const key = entryKey(entry); | ||||
|         if (this.expanded.has(key)) { | ||||
|             this.expanded.delete(key); | ||||
|         } else { | ||||
|             this.expanded.add(key); | ||||
|         } | ||||
|         this.requestUpdate(); | ||||
|     } | ||||
|  | ||||
|     // This is gross and feels like 2007: using a path from the root through the shadowDoms (see | ||||
|     // `TablePage:findTable()`), this code finds the element that *should* be triggered by an event | ||||
|     // on the URL, and forcibly injects the text of the search and the click of the search button. | ||||
|  | ||||
|     reclick(ev: Event, path: string) { | ||||
|         const oldPath = window.location.hash.split(ROUTE_SEPARATOR)[0]; | ||||
|         const [curPath, ...curSearchComponents] = path.split(ROUTE_SEPARATOR); | ||||
|         const curSearch: string = | ||||
|             curSearchComponents.length > 0 ? curSearchComponents.join(ROUTE_SEPARATOR) : ""; | ||||
|  | ||||
|         if (curPath !== oldPath) { | ||||
|             // A Tier 1 or Tier 2 change should be handled by the router. (So should a Tier 3 | ||||
|             // change, but... here we are.) | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         const table = findTable(); | ||||
|         if (!table) { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         // Always wrap the minimal exceptional code possible in an IIFE and supply the failure | ||||
|         // alternative. Turn exceptions into expressions with the smallest functional rewind | ||||
|         // whenever possible. | ||||
|         const search = (() => { | ||||
|             try { | ||||
|                 return curSearch ? JSON.parse(decodeURIComponent(curSearch)) : { search: "" }; | ||||
|             } catch { | ||||
|                 return { search: "" }; | ||||
|             } | ||||
|         })(); | ||||
|  | ||||
|         if ("search" in search) { | ||||
|             ev.preventDefault(); | ||||
|             ev.stopPropagation(); | ||||
|             table.search = search.search; | ||||
|             table.fetch(); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     render(): TemplateResult { | ||||
|         console.log("C:", this.current); | ||||
|         const lightThemed = { "pf-m-light": this.activeTheme === UiThemeEnum.Light }; | ||||
|  | ||||
|         return html` <nav class="pf-c-nav ${classMap(lightThemed)}" aria-label="Navigation"> | ||||
|             <ul class="pf-c-nav__list"> | ||||
|                 ${map(this.entries, this.renderItem)} | ||||
|             </ul> | ||||
|         </nav>`; | ||||
|     } | ||||
|  | ||||
|     renderItem(entry: SidebarEntry) { | ||||
|         // Ensure the attributes are undefined, not null; they can be null in the placeholders, but | ||||
|         // not when being forwarded to the correct renderer. | ||||
|         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 (entry.attributes?.expanded) { | ||||
|             this.expanded.add(entryKey(entry)); | ||||
|             delete entry.attributes.expanded; | ||||
|         } | ||||
|  | ||||
|         const content = | ||||
|             entry.path && hasChildren | ||||
|                 ? this.renderLinkAndChildren(entry) | ||||
|                 : hasChildren | ||||
|                   ? this.renderLabelAndChildren(entry) | ||||
|                   : entry.path | ||||
|                     ? this.renderLink(entry) | ||||
|                     : this.renderLabel(entry); | ||||
|  | ||||
|         const expanded = { | ||||
|             "highlighted": !!entry.attributes?.highlight, | ||||
|             "pf-m-expanded": this.expanded.has(entryKey(entry)), | ||||
|             "pf-m-expandable": hasChildren, | ||||
|         }; | ||||
|  | ||||
|         return html`<li class="pf-c-nav__item ${classMap(expanded)}">${content}</li>`; | ||||
|     } | ||||
|  | ||||
|     getLinkClasses(entry: SidebarEntry) { | ||||
|         const a = entry.attributes ?? {}; | ||||
|         const key = entryKey(entry); | ||||
|         return { | ||||
|             "pf-m-current": key === this.current, | ||||
|             "pf-c-nav__link": true, | ||||
|             "highlight": !!(typeof a.highlight === "function" ? a.highlight() : a.highlight), | ||||
|         }; | ||||
|     } | ||||
|  | ||||
|     renderLabel(entry: SidebarEntry) { | ||||
|         return html`<div class=${classMap(this.getLinkClasses(entry))}>${entry.label}</div>`; | ||||
|     } | ||||
|  | ||||
|     // note the responsibilities pushed up to the caller | ||||
|     renderLink(entry: SidebarEntry) { | ||||
|         if (typeof entry.path === "function") { | ||||
|             return html` <a @click=${entry.path} class=${classMap(this.getLinkClasses(entry))}> | ||||
|                 ${entry.label} | ||||
|             </a>`; | ||||
|         } | ||||
|         const path = `${entry.attributes?.isAbsoluteLink ? "" : "#"}${entry.path}`; | ||||
|         return html` <a | ||||
|             href=${path} | ||||
|             @click=${(ev: Event) => this.reclick(ev, path)} | ||||
|             class=${classMap(this.getLinkClasses(entry))} | ||||
|         > | ||||
|             ${entry.label} | ||||
|         </a>`; | ||||
|     } | ||||
|  | ||||
|     renderChildren(children: SidebarEntry[]) { | ||||
|         return html`<section class="pf-c-nav__subnav"> | ||||
|             <ul class="pf-c-nav__list"> | ||||
|                 ${map(children, this.renderItem)} | ||||
|             </ul> | ||||
|         </section>`; | ||||
|     } | ||||
|  | ||||
|     renderLabelAndChildren(entry: SidebarEntry): TemplateResult { | ||||
|         const handler = () => this.toggleExpand(entry); | ||||
|         const current = { "pf-m-current": this.current === entryKey(entry) }; | ||||
|  | ||||
|         return html` <div class="pf-c-nav__link  ${classMap(current)}"> | ||||
|                 <div class="ak-nav__link">${entry.label}</div> | ||||
|                 <span class="pf-c-nav__toggle" @click=${handler}> | ||||
|                     <span class="pf-c-nav__toggle-icon"> | ||||
|                         <i class="fas fa-angle-right" aria-hidden="true"></i> | ||||
|                     </span> | ||||
|                 </span> | ||||
|             </div> | ||||
|             ${this.expanded.has(entryKey(entry)) | ||||
|                 ? this.renderChildren(entry.children ?? []) | ||||
|                 : nothing}`; | ||||
|     } | ||||
|  | ||||
|     renderLinkAndChildren(entry: SidebarEntry): TemplateResult { | ||||
|         const handler = () => this.toggleExpand(entry); | ||||
|         const current = { "pf-m-current": this.current === entryKey(entry) }; | ||||
|         const path = `${entry.attributes?.isAbsoluteLink ? "" : "#"}${entry.path}`; | ||||
|         return html` <div class="pf-c-nav__link ${classMap(current)}"> | ||||
|                 <a | ||||
|                     href=${path} | ||||
|                     @click=${(ev: Event) => this.reclick(ev, path)} | ||||
|                     class="ak-nav__link" | ||||
|                 > | ||||
|                     ${entry.label} | ||||
|                 </a> | ||||
|                 <span class="pf-c-nav__toggle" @click=${handler}> | ||||
|                     <span class="pf-c-nav__toggle-icon"> | ||||
|                         <i class="fas fa-angle-right" aria-hidden="true"></i> | ||||
|                     </span> | ||||
|                 </span> | ||||
|             </div> | ||||
|             ${this.expanded.has(entryKey(entry)) | ||||
|                 ? this.renderChildren(entry.children ?? []) | ||||
|                 : nothing}`; | ||||
|     } | ||||
| } | ||||
							
								
								
									
										21
									
								
								web/src/elements/sidebar/types.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								web/src/elements/sidebar/types.ts
									
									
									
									
									
										Normal file
									
								
							| @ -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; | ||||
							
								
								
									
										60
									
								
								web/src/elements/sidebar/utils.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										60
									
								
								web/src/elements/sidebar/utils.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,60 @@ | ||||
| import { ROUTE_SEPARATOR } from "@goauthentik/common/constants"; | ||||
|  | ||||
| import { SidebarEntry } from "./types"; | ||||
|  | ||||
| export function entryKey(entry: SidebarEntry) { | ||||
|     return `${entry.path || "no-path"}:${entry.label}`; | ||||
| } | ||||
|  | ||||
| // "Never store what you can calculate." (At least, if it's cheap.) | ||||
|  | ||||
| /** | ||||
|  * Takes tree and creates a map where every key is an entry in the tree and every value is that | ||||
|  * entry's parent. | ||||
|  */ | ||||
|  | ||||
| export function makeParentMap(entries: SidebarEntry[]) { | ||||
|     const reverseMap = new WeakMap<SidebarEntry, SidebarEntry>(); | ||||
|     function reverse(entry: SidebarEntry) { | ||||
|         (entry.children ?? []).forEach((e) => { | ||||
|             reverseMap.set(e, entry); | ||||
|             reverse(e); | ||||
|         }); | ||||
|     } | ||||
|     entries.forEach(reverse); | ||||
|     return reverseMap; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Given the current path and the collection of entries, identify which entry is currently live. | ||||
|  * | ||||
|  */ | ||||
|  | ||||
| const trailingSlash = new RegExp("/$"); | ||||
| const fixed = (s: string) => s.replace(trailingSlash, ""); | ||||
|  | ||||
| function scanner(entry: SidebarEntry, activePath: string): SidebarEntry | undefined { | ||||
|     if (typeof entry.path === "string" && fixed(activePath) === fixed(entry.path)) { | ||||
|         return entry; | ||||
|     } | ||||
|  | ||||
|     for (const matcher of entry.attributes?.activeWhen ?? []) { | ||||
|         const matchtest = new RegExp(matcher); | ||||
|         if (matchtest.test(activePath)) { | ||||
|             return entry; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     return (entry.children ?? []).find((e) => scanner(e, activePath)); | ||||
| } | ||||
|  | ||||
| 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; | ||||
| } | ||||
| @ -20,6 +20,11 @@ export abstract class TablePage<T> extends Table<T> { | ||||
|         return super.styles.concat(PFPage, PFContent, PFSidebar); | ||||
|     } | ||||
|  | ||||
|     constructor() { | ||||
|         super(); | ||||
|         this.dataset.akApiTable = "true"; | ||||
|     } | ||||
|  | ||||
|     renderSidebarBefore(): TemplateResult { | ||||
|         return html``; | ||||
|     } | ||||
| @ -92,3 +97,18 @@ export abstract class TablePage<T> extends Table<T> { | ||||
|             ${this.renderSectionAfter()}`; | ||||
|     } | ||||
| } | ||||
|  | ||||
| // This painstakingly researched path is nonetheless surprisingly robust; it works for every extant | ||||
| // TablePage, but only because Jens has been utterly consistent in where he puts his TablePage | ||||
| // elements with respect to the Interface object.  If we ever re-arrange this code, we're going | ||||
| // to have to re-arrange this as well. | ||||
|  | ||||
| export function findTable<T, U extends TablePage<T>>(): U | undefined { | ||||
|     return ( | ||||
|         (document.body | ||||
|             ?.querySelector("[data-ak-interface-root]") | ||||
|             ?.shadowRoot?.querySelector("ak-locale-context") | ||||
|             ?.querySelector("ak-router-outlet") | ||||
|             ?.shadowRoot?.querySelector("[data-ak-api-table]") as U) ?? undefined | ||||
|     ); | ||||
| } | ||||
|  | ||||
		Reference in New Issue
	
	Block a user
	