Compare commits
	
		
			49 Commits
		
	
	
		
			version/20
			...
			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 { getURLParam, updateURLParams } from "@goauthentik/elements/router/RouteMatch"; | ||||||
| import "@goauthentik/elements/router/RouterOutlet"; | import "@goauthentik/elements/router/RouterOutlet"; | ||||||
| import "@goauthentik/elements/sidebar/Sidebar"; | import "@goauthentik/elements/sidebar/Sidebar"; | ||||||
| import "@goauthentik/elements/sidebar/SidebarItem"; |  | ||||||
|  |  | ||||||
| import { CSSResult, TemplateResult, css, html } from "lit"; | import { CSSResult, TemplateResult, css, html } from "lit"; | ||||||
| import { customElement, property, state } from "lit/decorators.js"; | import { customElement, property, state } from "lit/decorators.js"; | ||||||
|  | |||||||
| @ -1,5 +1,6 @@ | |||||||
| import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; | import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; | ||||||
| import { EVENT_SIDEBAR_TOGGLE, VERSION } from "@goauthentik/common/constants"; | import { EVENT_SIDEBAR_TOGGLE, VERSION } from "@goauthentik/common/constants"; | ||||||
|  | import { eventActionLabels } from "@goauthentik/common/labels"; | ||||||
| import { me } from "@goauthentik/common/users"; | import { me } from "@goauthentik/common/users"; | ||||||
| import { AKElement } from "@goauthentik/elements/Base"; | import { AKElement } from "@goauthentik/elements/Base"; | ||||||
| import { | import { | ||||||
| @ -7,17 +8,63 @@ import { | |||||||
|     WithCapabilitiesConfig, |     WithCapabilitiesConfig, | ||||||
| } from "@goauthentik/elements/Interface/capabilitiesProvider"; | } from "@goauthentik/elements/Interface/capabilitiesProvider"; | ||||||
| import { ID_REGEX, SLUG_REGEX, UUID_REGEX } from "@goauthentik/elements/router/Route"; | 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 { getRootStyle } from "@goauthentik/elements/utils/getRootStyle"; | ||||||
| import { spread } from "@open-wc/lit-helpers"; |  | ||||||
|  |  | ||||||
| import { msg, str } from "@lit/localize"; | 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 { 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 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") | @customElement("ak-admin-sidebar") | ||||||
| export class AkAdminSidebar extends WithCapabilitiesConfig(AKElement) { | export class AkAdminSidebar extends WithCapabilitiesConfig(AKElement) { | ||||||
|     @property({ type: Boolean, reflect: true }) |     @property({ type: Boolean, reflect: true }) | ||||||
| @ -29,6 +76,13 @@ export class AkAdminSidebar extends WithCapabilitiesConfig(AKElement) { | |||||||
|     @state() |     @state() | ||||||
|     impersonation: UserSelf["username"] | null = null; |     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() { |     constructor() { | ||||||
|         super(); |         super(); | ||||||
|         new AdminApi(DEFAULT_CONFIG).adminVersionRetrieve().then((version) => { |         new AdminApi(DEFAULT_CONFIG).adminVersionRetrieve().then((version) => { | ||||||
| @ -74,19 +128,6 @@ export class AkAdminSidebar extends WithCapabilitiesConfig(AKElement) { | |||||||
|         super.disconnectedCallback(); |         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() { |     updated() { | ||||||
|         // This is permissible as`:host.classList` is not one of the properties Lit uses as a |         // 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 |         // 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"); |         this.classList.add(this.open ? "pf-m-expanded" : "pf-m-collapsed"); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     renderSidebarItems(): TemplateResult { |     get sidebarItems(): SidebarEntry[] { | ||||||
|         // 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() { |  | ||||||
|         const reload = () => |         const reload = () => | ||||||
|             new CoreApi(DEFAULT_CONFIG).coreUsersImpersonateEndRetrieve().then(() => { |             new CoreApi(DEFAULT_CONFIG).coreUsersImpersonateEndRetrieve().then(() => { | ||||||
|                 window.location.reload(); |                 window.location.reload(); | ||||||
|             }); |             }); | ||||||
|  |  | ||||||
|         return this.impersonation |         // prettier-ignore | ||||||
|             ? html`<ak-sidebar-item ?highlight=${true} @click=${reload}> |         const newVersionMessage: LocalSidebarEntry[] = | ||||||
|                   <span slot="label" |             this.version && this.version !== VERSION | ||||||
|                       >${msg( |                 ? [[ "https://goauthentik.io", msg("A newer version of the frontend is available."), | ||||||
|                           str`You're currently impersonating ${this.impersonation}. Click to stop.`, |                      { highlight: true }]] | ||||||
|                       )}</span |                 : []; | ||||||
|                   > |  | ||||||
|               </ak-sidebar-item>` |         // prettier-ignore | ||||||
|             : nothing; |         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() { |     render() { | ||||||
|         return this.can(CapabilitiesEnum.IsEnterprise) |         return html` | ||||||
|             ? html` |             <ak-sidebar class="pf-c-page__sidebar" .entries=${this.sidebarItems}></ak-sidebar> | ||||||
|                   <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; |  | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  | |||||||
| @ -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})`; |     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 { | export function DesignationToLabel(designation: FlowDesignationEnum): string { | ||||||
|     switch (designation) { |     return flowDesignations.get(designation) ?? msg("Unknown 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"); |  | ||||||
|     } |  | ||||||
| } | } | ||||||
|  |  | ||||||
|  | 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 { | export function LayoutToLabel(layout: FlowLayoutEnum): string { | ||||||
|     switch (layout) { |     return layoutToLabel.get(layout) ?? msg("Unknown 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"); |  | ||||||
|     } |  | ||||||
| } | } | ||||||
|  | |||||||
| @ -2,6 +2,8 @@ import { msg } from "@lit/localize"; | |||||||
|  |  | ||||||
| import { Device, EventActions, IntentEnum, SeverityEnum, UserTypeEnum } from "@goauthentik/api"; | 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 */ | /* Various tables in the API for which we need to supply labels */ | ||||||
|  |  | ||||||
| export const intentEnumToLabel = new Map<IntentEnum, string>([ | 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 intentToLabel = (intent: IntentEnum) => intentEnumToLabel.get(intent); | ||||||
|  |  | ||||||
| export const eventActionToLabel = new Map<EventActions | undefined, string>([ | export const eventActionLabels: Pair<EventActions>[] = [ | ||||||
|     [EventActions.Login, msg("Login")], |     [EventActions.Login, msg("Login")], | ||||||
|     [EventActions.LoginFailed, msg("Failed login")], |     [EventActions.LoginFailed, msg("Failed login")], | ||||||
|     [EventActions.Logout, msg("Logout")], |     [EventActions.Logout, msg("Logout")], | ||||||
| @ -43,7 +45,9 @@ export const eventActionToLabel = new Map<EventActions | undefined, string>([ | |||||||
|     [EventActions.ModelDeleted, msg("Model deleted")], |     [EventActions.ModelDeleted, msg("Model deleted")], | ||||||
|     [EventActions.EmailSent, msg("Email sent")], |     [EventActions.EmailSent, msg("Email sent")], | ||||||
|     [EventActions.UpdateAvailable, msg("Update available")], |     [EventActions.UpdateAvailable, msg("Update available")], | ||||||
| ]); | ]; | ||||||
|  |  | ||||||
|  | export const eventActionToLabel = new Map<EventActions | undefined, string>(eventActionLabels); | ||||||
|  |  | ||||||
| export const actionToLabel = (action?: EventActions): string => | export const actionToLabel = (action?: EventActions): string => | ||||||
|     eventActionToLabel.get(action) ?? action ?? ""; |     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 { AKElement } from "@goauthentik/elements/Base"; | ||||||
| import "@goauthentik/elements/sidebar/SidebarBrand"; | import "@goauthentik/elements/sidebar/SidebarBrand"; | ||||||
|  | import "@goauthentik/elements/sidebar/SidebarItems"; | ||||||
| import "@goauthentik/elements/sidebar/SidebarUser"; | import "@goauthentik/elements/sidebar/SidebarUser"; | ||||||
|  |  | ||||||
| import { CSSResult, TemplateResult, css, html } from "lit"; | import { html } from "lit"; | ||||||
| import { customElement } from "lit/decorators.js"; | import { customElement, property } 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 { UiThemeEnum } from "@goauthentik/api"; | import { UiThemeEnum } from "@goauthentik/api"; | ||||||
|  |  | ||||||
|  | import { sidebarStyles } from "./Sidebar.css.js"; | ||||||
|  | import type { SidebarEntry } from "./types"; | ||||||
|  |  | ||||||
| @customElement("ak-sidebar") | @customElement("ak-sidebar") | ||||||
| export class Sidebar extends AKElement { | export class Sidebar extends AKElement { | ||||||
|     static get styles(): CSSResult[] { |     @property({ type: Array }) | ||||||
|         return [ |     entries: SidebarEntry[] = []; | ||||||
|             PFBase, |  | ||||||
|             PFPage, |     static get styles() { | ||||||
|             PFNav, |         return sidebarStyles; | ||||||
|             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 { |     render() { | ||||||
|                     --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 { |  | ||||||
|         return html`<nav |         return html`<nav | ||||||
|             class="pf-c-nav ${this.activeTheme === UiThemeEnum.Light ? "pf-m-light" : ""}" |             class="pf-c-nav ${this.activeTheme === UiThemeEnum.Light ? "pf-m-light" : ""}" | ||||||
|             aria-label="Global" |             aria-label="Global" | ||||||
|         > |         > | ||||||
|             <ak-sidebar-brand></ak-sidebar-brand> |             <ak-sidebar-brand></ak-sidebar-brand> | ||||||
|             <ul class="pf-c-nav__list"> |             <ak-sidebar-items .entries=${this.entries}></ak-sidebar-items> | ||||||
|                 <slot></slot> |  | ||||||
|             </ul> |  | ||||||
|             <ak-sidebar-user></ak-sidebar-user> |             <ak-sidebar-user></ak-sidebar-user> | ||||||
|         </nav>`; |         </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); |         return super.styles.concat(PFPage, PFContent, PFSidebar); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     constructor() { | ||||||
|  |         super(); | ||||||
|  |         this.dataset.akApiTable = "true"; | ||||||
|  |     } | ||||||
|  |  | ||||||
|     renderSidebarBefore(): TemplateResult { |     renderSidebarBefore(): TemplateResult { | ||||||
|         return html``; |         return html``; | ||||||
|     } |     } | ||||||
| @ -92,3 +97,18 @@ export abstract class TablePage<T> extends Table<T> { | |||||||
|             ${this.renderSectionAfter()}`; |             ${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
	