Compare commits
49 Commits
main
...
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[] = [];
|
||||
|
||||
.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;
|
||||
}
|
||||
`,
|
||||
];
|
||||
static get styles() {
|
||||
return sidebarStyles;
|
||||
}
|
||||
|
||||
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