web: almost there with the sidebar

The actual behavior is more or less what I expected.  What's missing is:

- Persistence of location (the hover effect fades with a click anywhere else)
- Proper testing of the oddities
- Full (or any!) responsiveness when moving between third-tier links in the same category

Stretch goal:
- Remembering the state of the sidebar when transitioning between the user and the admin (this will require using some localstorage, I suspect).

I also think that in my rush there's a bit of lost internal coherency.  I'd like to figure out what's wiggling around my brain and solve that discomfort.
This commit is contained in:
Ken Sternberg 2023-11-16 14:59:02 -08:00
parent 3c277f14c8
commit ff78f2f00a
4 changed files with 137 additions and 52 deletions

View file

@ -5,18 +5,35 @@ import { authentikConfigContext } from "@goauthentik/elements/AuthentikContexts"
import { AKElement } from "@goauthentik/elements/Base"; import { AKElement } from "@goauthentik/elements/Base";
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 "@goauthentik/elements/sidebar/Sidebar";
import { SidebarAttributes, SidebarEntry, SidebarEventHandler } from "@goauthentik/elements/sidebar/SidebarItems"; import {
SidebarAttributes,
SidebarEntry,
SidebarEventHandler,
} from "@goauthentik/elements/sidebar/SidebarItems";
import { getRootStyle } from "@goauthentik/elements/utils/getRootStyle"; import { getRootStyle } from "@goauthentik/elements/utils/getRootStyle";
import { consume } from "@lit-labs/context"; import { consume } from "@lit-labs/context";
import { msg, str } from "@lit/localize"; import { msg, str } from "@lit/localize";
import { html } from "lit"; import { html } from "lit";
import { customElement, property, state } from "lit/decorators.js"; import { customElement, property, state } from "lit/decorators.js";
import { eventActionLabels } from "@goauthentik/common/labels";
import { ProvidersApi, TypeCreate } from "@goauthentik/api"; import { ProvidersApi, TypeCreate } from "@goauthentik/api";
import { AdminApi, CapabilitiesEnum, CoreApi, Version } from "@goauthentik/api"; import {
AdminApi,
CapabilitiesEnum,
CoreApi,
OutpostsApi,
PoliciesApi,
PropertymappingsApi,
SourcesApi,
StagesApi,
Version,
} from "@goauthentik/api";
import type { Config, SessionUser, UserSelf } from "@goauthentik/api"; import type { Config, SessionUser, UserSelf } from "@goauthentik/api";
import { flowDesignationTable } from "../flows/utils";
/** /**
* AdminSidebar * AdminSidebar
* *
@ -24,7 +41,7 @@ import type { Config, SessionUser, UserSelf } from "@goauthentik/api";
* it. Rendering decisions are left to the sidebar itself. * it. Rendering decisions are left to the sidebar itself.
*/ */
type LocalSidebarEntry = [ export type LocalSidebarEntry = [
string | SidebarEventHandler | null, string | SidebarEventHandler | null,
string, string,
(SidebarAttributes | string[] | null)?, // eslint-disable-line (SidebarAttributes | string[] | null)?, // eslint-disable-line
@ -34,12 +51,21 @@ type LocalSidebarEntry = [
const localToSidebarEntry = (l: LocalSidebarEntry): SidebarEntry => ({ const localToSidebarEntry = (l: LocalSidebarEntry): SidebarEntry => ({
path: l[0], path: l[0],
label: l[1], label: l[1],
...(l[2]? { attributes: Array.isArray(l[2]) ? { activeWhen: l[2] } : l[2] } : {}), ...(l[2] ? { attributes: Array.isArray(l[2]) ? { activeWhen: l[2] } : l[2] } : {}),
...(l[3] ? { children: l[3].map(localToSidebarEntry) } : {}), ...(l[3] ? { children: l[3].map(localToSidebarEntry) } : {}),
}); });
const typeCreateToSidebar = (baseUrl: string, tcreate: TypeCreate[]): LocalSidebarEntry[] =>
tcreate.map((t) => [
`${baseUrl};${encodeURIComponent(JSON.stringify({ search: t.name }))}`,
t.name,
]);
@customElement("ak-admin-sidebar") @customElement("ak-admin-sidebar")
export class AkAdminSidebar extends AKElement { export class AkAdminSidebar extends AKElement {
@consume({ context: authentikConfigContext })
public config!: Config;
@property({ type: Boolean, reflect: true }) @property({ type: Boolean, reflect: true })
open = true; open = true;
@ -52,8 +78,20 @@ export class AkAdminSidebar extends AKElement {
@state() @state()
providerTypes: TypeCreate[] = []; providerTypes: TypeCreate[] = [];
@consume({ context: authentikConfigContext }) @state()
public config!: Config; stageTypes: TypeCreate[] = [];
@state()
mappingTypes: TypeCreate[] = [];
@state()
sourceTypes: TypeCreate[] = [];
@state()
policyTypes: TypeCreate[] = [];
@state()
connectionTypes: TypeCreate[] = [];
constructor() { constructor() {
super(); super();
@ -66,6 +104,22 @@ export class AkAdminSidebar extends AKElement {
new ProvidersApi(DEFAULT_CONFIG).providersAllTypesList().then((types) => { new ProvidersApi(DEFAULT_CONFIG).providersAllTypesList().then((types) => {
this.providerTypes = types; this.providerTypes = types;
}); });
new StagesApi(DEFAULT_CONFIG).stagesAllTypesList().then((types) => {
this.stageTypes = types;
});
new PropertymappingsApi(DEFAULT_CONFIG).propertymappingsAllTypesList().then((types) => {
this.mappingTypes = types;
});
new SourcesApi(DEFAULT_CONFIG).sourcesAllTypesList().then((types) => {
this.sourceTypes = types;
});
new PoliciesApi(DEFAULT_CONFIG).policiesAllTypesList().then((types) => {
this.policyTypes = types;
});
new OutpostsApi(DEFAULT_CONFIG).outpostsServiceConnectionsAllTypesList().then((types) => {
this.connectionTypes = types;
});
this.toggleOpen = this.toggleOpen.bind(this); this.toggleOpen = this.toggleOpen.bind(this);
this.checkWidth = this.checkWidth.bind(this); this.checkWidth = this.checkWidth.bind(this);
} }
@ -79,7 +133,9 @@ export class AkAdminSidebar extends AKElement {
checkWidth() { checkWidth() {
// This works just fine, but it assumes that the `--ak-sidebar--minimum-auto-width` is in // This works just fine, but it assumes that the `--ak-sidebar--minimum-auto-width` is in
// REMs. If that changes, this code will have to be adjusted as well. // REMs. If that changes, this code will have to be adjusted as well.
const minWidth = parseFloat(getRootStyle("--ak-sidebar--minimum-auto-width")) * parseFloat(getRootStyle("font-size")); const minWidth =
parseFloat(getRootStyle("--ak-sidebar--minimum-auto-width")) *
parseFloat(getRootStyle("font-size"));
this.open = window.innerWidth >= minWidth; this.open = window.innerWidth >= minWidth;
} }
@ -133,8 +189,21 @@ export class AkAdminSidebar extends AKElement {
: []; : [];
// prettier-ignore // prettier-ignore
const providerTypes: LocalSidebarEntry[] = this.providerTypes.map((ptype) => const flowTypes: LocalSidebarEntry[] = flowDesignationTable.map(([_designation, label]) =>
([`/core/providers;${encodeURIComponent(JSON.stringify({ search: ptype.modelName.replace(/provider$/, "") }))}`, ptype.name])); ([`/flow/flows;${encodeURIComponent(JSON.stringify({ search: label }))}`, label]));
const eventTypes: LocalSidebarEntry[] = eventActionLabels.map(([_action, label]) =>
([`/events/log;${encodeURIComponent(JSON.stringify({ search: label }))}`, label]));
const [mappingTypes, providerTypes, sourceTypes, stageTypes, connectionTypes, policyTypes] = [
typeCreateToSidebar("/core/property-mappings", this.mappingTypes),
typeCreateToSidebar("/core/providers", this.providerTypes),
typeCreateToSidebar("/core/sources", this.sourceTypes),
typeCreateToSidebar("/flow/stages", this.stageTypes),
typeCreateToSidebar("/outpost/integrations", this.connectionTypes),
typeCreateToSidebar("/policy/policies", this.policyTypes),
];
// prettier-ignore // prettier-ignore
const localSidebar: LocalSidebarEntry[] = [ const localSidebar: LocalSidebarEntry[] = [
@ -150,36 +219,38 @@ export class AkAdminSidebar extends AKElement {
["/core/providers", msg("Providers"), [`^/core/providers/(?<id>${ID_REGEX})$`], providerTypes], ["/core/providers", msg("Providers"), [`^/core/providers/(?<id>${ID_REGEX})$`], providerTypes],
["/outpost/outposts", msg("Outposts")]]], ["/outpost/outposts", msg("Outposts")]]],
[null, msg("Events"), null, [ [null, msg("Events"), null, [
["/events/log", msg("Logs"), [`^/events/log/(?<id>${UUID_REGEX})$`]], ["/events/log", msg("Logs"), [`^/events/log/(?<id>${UUID_REGEX})$`], eventTypes],
["/events/rules", msg("Notification Rules")], ["/events/rules", msg("Notification Rules")],
["/events/transports", msg("Notification Transports")]]], ["/events/transports", msg("Notification Transports")]]],
[null, msg("Customisation"), null, [ [null, msg("Customisation"), null, [
["/policy/policies", msg("Policies")], ["/policy/policies", msg("Policies"), null, policyTypes],
["/core/property-mappings", msg("Property Mappings")], ["/core/property-mappings", msg("Property Mappings"), null, mappingTypes],
["/blueprints/instances", msg("Blueprints")], ["/blueprints/instances", msg("Blueprints")],
["/policy/reputation", msg("Reputation scores")]]], ["/policy/reputation", msg("Reputation scores")]]],
[null, msg("Flows and Stages"), null, [ [null, msg("Flows and Stages"), null, [
["/flow/flows", msg("Flows"), [`^/flow/flows/(?<slug>${SLUG_REGEX})$`]], ["/flow/flows", msg("Flows"), [`^/flow/flows/(?<slug>${SLUG_REGEX})$`], flowTypes],
["/flow/stages", msg("Stages")], ["/flow/stages", msg("Stages"), null, stageTypes],
["/flow/stages/prompts", msg("Prompts")]]], ["/flow/stages/prompts", msg("Prompts")]]],
[null, msg("Directory"), null, [ [null, msg("Directory"), null, [
["/identity/users", msg("Users"), [`^/identity/users/(?<id>${ID_REGEX})$`]], ["/identity/users", msg("Users"), [`^/identity/users/(?<id>${ID_REGEX})$`]],
["/identity/groups", msg("Groups"), [`^/identity/groups/(?<id>${UUID_REGEX})$`]], ["/identity/groups", msg("Groups"), [`^/identity/groups/(?<id>${UUID_REGEX})$`]],
["/identity/roles", msg("Roles"), [`^/identity/roles/(?<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/sources", msg("Federation and Social login"), [`^/core/sources/(?<slug>${SLUG_REGEX})$`], sourceTypes],
["/core/tokens", msg("Tokens and App passwords")], ["/core/tokens", msg("Tokens and App passwords")],
["/flow/stages/invitations", msg("Invitations")]]], ["/flow/stages/invitations", msg("Invitations")]]],
[null, msg("System"), null, [ [null, msg("System"), null, [
["/core/tenants", msg("Tenants")], ["/core/tenants", msg("Tenants")],
["/crypto/certificates", msg("Certificates")], ["/crypto/certificates", msg("Certificates")],
["/outpost/integrations", msg("Outpost Integrations")]]], ["/outpost/integrations", msg("Outpost Integrations"), null, connectionTypes]]],
...(enterpriseMenu) ...(enterpriseMenu)
]; ];
return localSidebar.map(localToSidebarEntry); return localSidebar.map(localToSidebarEntry);
} }
render() { render() {
return html` <ak-sidebar class="pf-c-page__sidebar" .entries=${this.sidebarItems}></ak-sidebar> `; return html`
<ak-sidebar class="pf-c-page__sidebar" .entries=${this.sidebarItems}></ak-sidebar>
`;
} }
} }

View file

@ -6,40 +6,33 @@ export function RenderFlowOption(flow: Flow): string {
return `${flow.slug} (${flow.name})`; return `${flow.slug} (${flow.name})`;
} }
type Pair = [FlowDesignationEnum, string];
export const flowDesignationTable: Pair[] = [
[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([
[LayoutEnum.Stacked, msg("Stacked")],
[LayoutEnum.ContentLeft, msg("Content left")],
[LayoutEnum.ContentRight, msg("Content right")],
[LayoutEnum.SidebarLeft, msg("Sidebar left")],
[LayoutEnum.SidebarRight, msg("Sidebar right")],
]);
export function LayoutToLabel(layout: LayoutEnum): string { export function LayoutToLabel(layout: LayoutEnum): string {
switch (layout) { return layoutToLabel.get(layout) ?? msg("Unknown layout");
case LayoutEnum.Stacked:
return msg("Stacked");
case LayoutEnum.ContentLeft:
return msg("Content left");
case LayoutEnum.ContentRight:
return msg("Content right");
case LayoutEnum.SidebarLeft:
return msg("Sidebar left");
case LayoutEnum.SidebarRight:
return msg("Sidebar right");
case LayoutEnum.UnknownDefaultOpenApi:
return msg("Unknown layout");
}
} }

View file

@ -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 ?? "";

View file

@ -69,6 +69,12 @@ export class SidebarItems extends AKElement {
max-height: 82px; max-height: 82px;
margin-bottom: -0.5rem; margin-bottom: -0.5rem;
} }
.pf-c-nav__toggle {
width: calc(
var(--pf-c-nav__toggle--FontSize) + calc(2 * var(--pf-global--spacer--md))
);
}
nav { nav {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -86,6 +92,17 @@ export class SidebarItems extends AKElement {
--pf-c-nav__link--PaddingRight: 0.5rem; --pf-c-nav__link--PaddingRight: 0.5rem;
--pf-c-nav__link--PaddingBottom: 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 { .pf-c-nav__section-title {
font-size: 12px; font-size: 12px;
} }