web: remove a lot of duplication from the Routes

This commit extracts the highly repetitive `new Route(new RegExp(...))` syntax into a mappable
function, leaving only the regular expression and the asynchronous loader behind.  In the process,
it creates a typed description of what kinds of routes we have, and uses type-level pattern matching
to generate the route handlers and redirects.

For example, it establishes that for the purposes of discriminating between redirects and loaders,
the loader type is irrelevant to routing correctly. The loader type *is* still well-typed to prevent
anyone from putting an incompatible loader into a route, but the two different loader types-- ones
that take arguments, and ones that do not-- do not matter to the route builder.

Likewise, the two different kinds of redirects *do* matter, but only because JavaScript makes a
distinction between methods of one argument that can be `call`'ed and methods of more than one
argument that must be `apply`'d.

I suppose I could make this irrelevant by enforcing that the second argument to RawRedirect always
be an Array, but that's not ergonomic, and when the `match()` function has such a lovely and simple
way of distinguishing between the two forms, ergonomics always wins.

This might seem like a curious bit of cleanup, but by exposing the regular expressions as strings,
independent of the Routes that they ultimately represent, we get the power to associate paths to
routeable objects with paths to navigable objects in the Sidebar, *and* ultimately with paths to
navigable objects in a command palette. It might also lead to replacing our Routes infrastructure
with an off-the-shelf router such as [Vaadin.router](https://github.com/vaadin/router) or Justin
Fagnani's preferred [page.js](https://visionmedia.github.io/page.js/)
This commit is contained in:
Ken Sternberg
2024-03-14 14:19:41 -07:00
parent 8b4e0361c4
commit 70bf745c0c
3 changed files with 310 additions and 157 deletions

View File

@ -1,155 +1,266 @@
import "@goauthentik/admin/admin-overview/AdminOverviewPage";
import { ID_REGEX, Route, SLUG_REGEX, UUID_REGEX } from "@goauthentik/elements/router/Route";
import { ID_REGEX, SLUG_REGEX, UUID_REGEX } from "@goauthentik/elements/router/Route";
import { RawRoute, makeRoute } from "@goauthentik/elements/router/routeUtils";
import { html } from "lit";
export const ROUTES: Route[] = [
export const _ROUTES: RawRoute[] = [
// Prevent infinite Shell loops
new Route(new RegExp("^/$")).redirect("/administration/overview"),
new Route(new RegExp("^#.*")).redirect("/administration/overview"),
new Route(new RegExp("^/library$")).redirect("/if/user/", true),
["^/$", "/administration/overview"],
["^#.*", "/administration/overview"],
["^/library$", ["/if/user/", true]],
// statically imported since this is the default route
new Route(new RegExp("^/administration/overview$"), async () => {
return html`<ak-admin-overview></ak-admin-overview>`;
}),
new Route(new RegExp("^/administration/dashboard/users$"), async () => {
await import("@goauthentik/admin/admin-overview/DashboardUserPage");
return html`<ak-admin-dashboard-users></ak-admin-dashboard-users>`;
}),
new Route(new RegExp("^/administration/system-tasks$"), async () => {
await import("@goauthentik/admin/system-tasks/SystemTaskListPage");
return html`<ak-system-task-list></ak-system-task-list>`;
}),
new Route(new RegExp("^/core/providers$"), async () => {
await import("@goauthentik/admin/providers/ProviderListPage");
return html`<ak-provider-list></ak-provider-list>`;
}),
new Route(new RegExp(`^/core/providers/(?<id>${ID_REGEX})$`), async (args) => {
await import("@goauthentik/admin/providers/ProviderViewPage");
return html`<ak-provider-view .providerID=${parseInt(args.id, 10)}></ak-provider-view>`;
}),
new Route(new RegExp("^/core/applications$"), async () => {
await import("@goauthentik/admin/applications/ApplicationListPage");
return html`<ak-application-list></ak-application-list>`;
}),
new Route(new RegExp(`^/core/applications/(?<slug>${SLUG_REGEX})$`), async (args) => {
await import("@goauthentik/admin/applications/ApplicationViewPage");
return html`<ak-application-view .applicationSlug=${args.slug}></ak-application-view>`;
}),
new Route(new RegExp("^/core/sources$"), async () => {
await import("@goauthentik/admin/sources/SourceListPage");
return html`<ak-source-list></ak-source-list>`;
}),
new Route(new RegExp(`^/core/sources/(?<slug>${SLUG_REGEX})$`), async (args) => {
await import("@goauthentik/admin/sources/SourceViewPage");
return html`<ak-source-view .sourceSlug=${args.slug}></ak-source-view>`;
}),
new Route(new RegExp("^/core/property-mappings$"), async () => {
await import("@goauthentik/admin/property-mappings/PropertyMappingListPage");
return html`<ak-property-mapping-list></ak-property-mapping-list>`;
}),
new Route(new RegExp("^/core/tokens$"), async () => {
await import("@goauthentik/admin/tokens/TokenListPage");
return html`<ak-token-list></ak-token-list>`;
}),
new Route(new RegExp("^/core/brands"), async () => {
await import("@goauthentik/admin/brands/BrandListPage");
return html`<ak-brand-list></ak-brand-list>`;
}),
new Route(new RegExp("^/policy/policies$"), async () => {
await import("@goauthentik/admin/policies/PolicyListPage");
return html`<ak-policy-list></ak-policy-list>`;
}),
new Route(new RegExp("^/policy/reputation$"), async () => {
await import("@goauthentik/admin/policies/reputation/ReputationListPage");
return html`<ak-policy-reputation-list></ak-policy-reputation-list>`;
}),
new Route(new RegExp("^/identity/groups$"), async () => {
await import("@goauthentik/admin/groups/GroupListPage");
return html`<ak-group-list></ak-group-list>`;
}),
new Route(new RegExp(`^/identity/groups/(?<uuid>${UUID_REGEX})$`), async (args) => {
await import("@goauthentik/admin/groups/GroupViewPage");
return html`<ak-group-view .groupId=${args.uuid}></ak-group-view>`;
}),
new Route(new RegExp("^/identity/users$"), async () => {
await import("@goauthentik/admin/users/UserListPage");
return html`<ak-user-list></ak-user-list>`;
}),
new Route(new RegExp(`^/identity/users/(?<id>${ID_REGEX})$`), async (args) => {
await import("@goauthentik/admin/users/UserViewPage");
return html`<ak-user-view .userId=${parseInt(args.id, 10)}></ak-user-view>`;
}),
new Route(new RegExp("^/identity/roles$"), async () => {
await import("@goauthentik/admin/roles/RoleListPage");
return html`<ak-role-list></ak-role-list>`;
}),
new Route(new RegExp(`^/identity/roles/(?<id>${UUID_REGEX})$`), async (args) => {
await import("@goauthentik/admin/roles/RoleViewPage");
return html`<ak-role-view roleId=${args.id}></ak-role-view>`;
}),
new Route(new RegExp("^/flow/stages/invitations$"), async () => {
await import("@goauthentik/admin/stages/invitation/InvitationListPage");
return html`<ak-stage-invitation-list></ak-stage-invitation-list>`;
}),
new Route(new RegExp("^/flow/stages/prompts$"), async () => {
await import("@goauthentik/admin/stages/prompt/PromptListPage");
return html`<ak-stage-prompt-list></ak-stage-prompt-list>`;
}),
new Route(new RegExp("^/flow/stages$"), async () => {
await import("@goauthentik/admin/stages/StageListPage");
return html`<ak-stage-list></ak-stage-list>`;
}),
new Route(new RegExp("^/flow/flows$"), async () => {
await import("@goauthentik/admin/flows/FlowListPage");
return html`<ak-flow-list></ak-flow-list>`;
}),
new Route(new RegExp(`^/flow/flows/(?<slug>${SLUG_REGEX})$`), async (args) => {
await import("@goauthentik/admin/flows/FlowViewPage");
return html`<ak-flow-view .flowSlug=${args.slug}></ak-flow-view>`;
}),
new Route(new RegExp("^/events/log$"), async () => {
await import("@goauthentik/admin/events/EventListPage");
return html`<ak-event-list></ak-event-list>`;
}),
new Route(new RegExp(`^/events/log/(?<id>${UUID_REGEX})$`), async (args) => {
await import("@goauthentik/admin/events/EventViewPage");
return html`<ak-event-view .eventID=${args.id}></ak-event-view>`;
}),
new Route(new RegExp("^/events/transports$"), async () => {
await import("@goauthentik/admin/events/TransportListPage");
return html`<ak-event-transport-list></ak-event-transport-list>`;
}),
new Route(new RegExp("^/events/rules$"), async () => {
await import("@goauthentik/admin/events/RuleListPage");
return html`<ak-event-rule-list></ak-event-rule-list>`;
}),
new Route(new RegExp("^/outpost/outposts$"), async () => {
await import("@goauthentik/admin/outposts/OutpostListPage");
return html`<ak-outpost-list></ak-outpost-list>`;
}),
new Route(new RegExp("^/outpost/integrations$"), async () => {
await import("@goauthentik/admin/outposts/ServiceConnectionListPage");
return html`<ak-outpost-service-connection-list></ak-outpost-service-connection-list>`;
}),
new Route(new RegExp("^/crypto/certificates$"), async () => {
await import("@goauthentik/admin/crypto/CertificateKeyPairListPage");
return html`<ak-crypto-certificate-list></ak-crypto-certificate-list>`;
}),
new Route(new RegExp("^/admin/settings$"), async () => {
await import("@goauthentik/admin/admin-settings/AdminSettingsPage");
return html`<ak-admin-settings></ak-admin-settings>`;
}),
new Route(new RegExp("^/blueprints/instances$"), async () => {
await import("@goauthentik/admin/blueprints/BlueprintListPage");
return html`<ak-blueprint-list></ak-blueprint-list>`;
}),
new Route(new RegExp("^/debug$"), async () => {
await import("@goauthentik/admin/DebugPage");
return html`<ak-admin-debug-page></ak-admin-debug-page>`;
}),
new Route(new RegExp("^/enterprise/licenses$"), async () => {
await import("@goauthentik/admin/enterprise/EnterpriseLicenseListPage");
return html`<ak-enterprise-license-list></ak-enterprise-license-list>`;
}),
[
"^/administration/overview$",
async () => {
return html`<ak-admin-overview></ak-admin-overview>`;
},
],
[
"^/administration/dashboard/users$",
async () => {
await import("@goauthentik/admin/admin-overview/DashboardUserPage");
return html`<ak-admin-dashboard-users></ak-admin-dashboard-users>`;
},
],
[
"^/administration/system-tasks$",
async () => {
await import("@goauthentik/admin/system-tasks/SystemTaskListPage");
return html`<ak-system-task-list></ak-system-task-list>`;
},
],
[
"^/core/providers$",
async () => {
await import("@goauthentik/admin/providers/ProviderListPage");
return html`<ak-provider-list></ak-provider-list>`;
},
],
[
`^/core/providers/(?<id>${ID_REGEX}])$`,
async (args) => {
await import("@goauthentik/admin/providers/ProviderViewPage");
return html`<ak-provider-view .providerID=${parseInt(args.id, 10)}></ak-provider-view>`;
},
],
[
"^/core/applications$",
async () => {
await import("@goauthentik/admin/applications/ApplicationListPage");
return html`<ak-application-list></ak-application-list>`;
},
],
[
`^/core/applications/(?<slug>${SLUG_REGEX})$`,
async (args) => {
await import("@goauthentik/admin/applications/ApplicationViewPage");
return html`<ak-application-view .applicationSlug=${args.slug}></ak-application-view>`;
},
],
[
"^/core/sources$",
async () => {
await import("@goauthentik/admin/sources/SourceListPage");
return html`<ak-source-list></ak-source-list>`;
},
],
[
`^/core/sources/(?<slug>${SLUG_REGEX})$`,
async (args) => {
await import("@goauthentik/admin/sources/SourceViewPage");
return html`<ak-source-view .sourceSlug=${args.slug}></ak-source-view>`;
},
],
[
"^/core/property-mappings$",
async () => {
await import("@goauthentik/admin/property-mappings/PropertyMappingListPage");
return html`<ak-property-mapping-list></ak-property-mapping-list>`;
},
],
[
"^/core/tokens$",
async () => {
await import("@goauthentik/admin/tokens/TokenListPage");
return html`<ak-token-list></ak-token-list>`;
},
],
[
"^/core/brands",
async () => {
await import("@goauthentik/admin/brands/BrandListPage");
return html`<ak-brand-list></ak-brand-list>`;
},
],
[
"^/policy/policies$",
async () => {
await import("@goauthentik/admin/policies/PolicyListPage");
return html`<ak-policy-list></ak-policy-list>`;
},
],
[
"^/policy/reputation$",
async () => {
await import("@goauthentik/admin/policies/reputation/ReputationListPage");
return html`<ak-policy-reputation-list></ak-policy-reputation-list>`;
},
],
[
"^/identity/groups$",
async () => {
await import("@goauthentik/admin/groups/GroupListPage");
return html`<ak-group-list></ak-group-list>`;
},
],
[
`^/identity/groups/(?<uuid>${UUID_REGEX})$`,
async (args) => {
await import("@goauthentik/admin/groups/GroupViewPage");
return html`<ak-group-view .groupId=${args.uuid}></ak-group-view>`;
},
],
[
"^/identity/users$",
async () => {
await import("@goauthentik/admin/users/UserListPage");
return html`<ak-user-list></ak-user-list>`;
},
],
[
`^/identity/users/(?<id>${ID_REGEX})$`,
async (args) => {
await import("@goauthentik/admin/users/UserViewPage");
return html`<ak-user-view .userId=${parseInt(args.id, 10)}></ak-user-view>`;
},
],
[
"^/identity/roles$",
async () => {
await import("@goauthentik/admin/roles/RoleListPage");
return html`<ak-role-list></ak-role-list>`;
},
],
[
`^/identity/roles/(?<id>${UUID_REGEX})$`,
async (args) => {
await import("@goauthentik/admin/roles/RoleViewPage");
return html`<ak-role-view roleId=${args.id}></ak-role-view>`;
},
],
[
"^/flow/stages/invitations$",
async () => {
await import("@goauthentik/admin/stages/invitation/InvitationListPage");
return html`<ak-stage-invitation-list></ak-stage-invitation-list>`;
},
],
[
"^/flow/stages/prompts$",
async () => {
await import("@goauthentik/admin/stages/prompt/PromptListPage");
return html`<ak-stage-prompt-list></ak-stage-prompt-list>`;
},
],
[
"^/flow/stages$",
async () => {
await import("@goauthentik/admin/stages/StageListPage");
return html`<ak-stage-list></ak-stage-list>`;
},
],
[
"^/flow/flows$",
async () => {
await import("@goauthentik/admin/flows/FlowListPage");
return html`<ak-flow-list></ak-flow-list>`;
},
],
[
`^/flow/flows/(?<slug>${SLUG_REGEX})$`,
async (args) => {
await import("@goauthentik/admin/flows/FlowViewPage");
return html`<ak-flow-view .flowSlug=${args.slug}></ak-flow-view>`;
},
],
[
"^/events/log$",
async () => {
await import("@goauthentik/admin/events/EventListPage");
return html`<ak-event-list></ak-event-list>`;
},
],
[
`^/events/log/(?<id>${UUID_REGEX})$`,
async (args) => {
await import("@goauthentik/admin/events/EventViewPage");
return html`<ak-event-view .eventID=${args.id}></ak-event-view>`;
},
],
[
"^/events/transports$",
async () => {
await import("@goauthentik/admin/events/TransportListPage");
return html`<ak-event-transport-list></ak-event-transport-list>`;
},
],
[
"^/events/rules$",
async () => {
await import("@goauthentik/admin/events/RuleListPage");
return html`<ak-event-rule-list></ak-event-rule-list>`;
},
],
[
"^/outpost/outposts$",
async () => {
await import("@goauthentik/admin/outposts/OutpostListPage");
return html`<ak-outpost-list></ak-outpost-list>`;
},
],
[
"^/outpost/integrations$",
async () => {
await import("@goauthentik/admin/outposts/ServiceConnectionListPage");
return html`<ak-outpost-service-connection-list></ak-outpost-service-connection-list>`;
},
],
[
"^/crypto/certificates$",
async () => {
await import("@goauthentik/admin/crypto/CertificateKeyPairListPage");
return html`<ak-crypto-certificate-list></ak-crypto-certificate-list>`;
},
],
[
"^/admin/settings$",
async () => {
await import("@goauthentik/admin/admin-settings/AdminSettingsPage");
return html`<ak-admin-settings></ak-admin-settings>`;
},
],
[
"^/blueprints/instances$",
async () => {
await import("@goauthentik/admin/blueprints/BlueprintListPage");
return html`<ak-blueprint-list></ak-blueprint-list>`;
},
],
[
"^/debug$",
async () => {
await import("@goauthentik/admin/DebugPage");
return html`<ak-admin-debug-page></ak-admin-debug-page>`;
},
],
[
"^/enterprise/licenses$",
async () => {
await import("@goauthentik/admin/enterprise/EnterpriseLicenseListPage");
return html`<ak-enterprise-license-list></ak-enterprise-license-list>`;
},
],
];
export const ROUTES = _ROUTES.map(makeRoute);

View File

@ -0,0 +1,37 @@
import { Route, RouteArgs } from "@goauthentik/elements/router/Route";
import { P, match } from "ts-pattern";
import { TemplateResult } from "lit";
type RouteInvoke =
| ((_1: RouteArgs) => Promise<TemplateResult<1>>)
| (() => Promise<TemplateResult<1>>);
type _RawRedirect = [string, string | [string, boolean]];
type _RawRoute = [string, RouteInvoke];
export type RawRoute = _RawRoute | _RawRedirect;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const isLoader = (v: any): v is RouteInvoke => typeof v === "function";
// When discriminating between redirects and loaders, the loader type is irrelevant to routing
// correctly. The loader type *is* still well-typed to prevent an incompatible loader being added to
// a route, but the two different loader types-- ones that take arguments, and ones that do not-- do
// not matter to the route builder.
// On the other hand, the two different kinds of redirects *do* matter, but only because JavaScript
// makes a distinction between methods of one argument that can be `call`'ed and methods of more
// than one argument that must be `apply`'d. (Spread arguments are converted to call or apply as
// needed).
// prettier-ignore
export function makeRoute(route: RawRoute): Route {
return match(route)
.with([P.string, P.when(isLoader)],
([path, loader]) => new Route(new RegExp(path), loader))
.with([P.string, P.string],
([path, redirect]) => new Route(new RegExp(path)).redirect(redirect))
.with([P.string, [P.string, P.boolean]],
([path, redirect]) => new Route(new RegExp(path)).redirect(...redirect))
.exhaustive();
}

View File

@ -1,15 +1,20 @@
import { Route } from "@goauthentik/elements/router/Route";
import { RawRoute, makeRoute } from "@goauthentik/elements/router/routeUtils";
import "@goauthentik/user/LibraryPage/LibraryPage";
import { html } from "lit";
export const ROUTES: Route[] = [
export const _ROUTES: RawRoute[] = [
// Prevent infinite Shell loops
new Route(new RegExp("^/$")).redirect("/library"),
new Route(new RegExp("^#.*")).redirect("/library"),
new Route(new RegExp("^/library$"), async () => html`<ak-library></ak-library>`),
new Route(new RegExp("^/settings$"), async () => {
await import("@goauthentik/user/user-settings/UserSettingsPage");
return html`<ak-user-settings></ak-user-settings>`;
}),
["^/$", "/library"],
["^#.*", "/library"],
["^/library$", async () => html`<ak-library></ak-library>`],
[
"^/settings$",
async () => {
await import("@goauthentik/user/user-settings/UserSettingsPage");
return html`<ak-user-settings></ak-user-settings>`;
},
],
];
export const ROUTES = _ROUTES.map(makeRoute);