Compare commits
2 Commits
safari-adm
...
router-tid
Author | SHA1 | Date | |
---|---|---|---|
e3f2ed0436 | |||
a5bb22a66a |
@ -16,8 +16,8 @@ import "@goauthentik/elements/messages/MessageContainer";
|
||||
import "@goauthentik/elements/messages/MessageContainer";
|
||||
import "@goauthentik/elements/notifications/APIDrawer";
|
||||
import "@goauthentik/elements/notifications/NotificationDrawer";
|
||||
import { getURLParam, updateURLParams } from "@goauthentik/elements/router/RouteMatch";
|
||||
import "@goauthentik/elements/router/RouterOutlet";
|
||||
import { getRouteParameter, patchRouteParams } from "@goauthentik/elements/router/utils";
|
||||
import "@goauthentik/elements/sidebar/Sidebar";
|
||||
import "@goauthentik/elements/sidebar/SidebarItem";
|
||||
|
||||
@ -37,10 +37,10 @@ import "./AdminSidebar";
|
||||
@customElement("ak-interface-admin")
|
||||
export class AdminInterface extends AuthenticatedInterface {
|
||||
@property({ type: Boolean })
|
||||
notificationDrawerOpen = getURLParam("notificationDrawerOpen", false);
|
||||
notificationDrawerOpen = getRouteParameter("notificationDrawerOpen", false);
|
||||
|
||||
@property({ type: Boolean })
|
||||
apiDrawerOpen = getURLParam("apiDrawerOpen", false);
|
||||
apiDrawerOpen = getRouteParameter("apiDrawerOpen", false);
|
||||
|
||||
ws: WebsocketClient;
|
||||
|
||||
@ -93,14 +93,14 @@ export class AdminInterface extends AuthenticatedInterface {
|
||||
|
||||
window.addEventListener(EVENT_NOTIFICATION_DRAWER_TOGGLE, () => {
|
||||
this.notificationDrawerOpen = !this.notificationDrawerOpen;
|
||||
updateURLParams({
|
||||
patchRouteParams({
|
||||
notificationDrawerOpen: this.notificationDrawerOpen,
|
||||
});
|
||||
});
|
||||
|
||||
window.addEventListener(EVENT_API_DRAWER_TOGGLE, () => {
|
||||
this.apiDrawerOpen = !this.apiDrawerOpen;
|
||||
updateURLParams({
|
||||
patchRouteParams({
|
||||
apiDrawerOpen: this.apiDrawerOpen,
|
||||
});
|
||||
});
|
||||
@ -123,7 +123,7 @@ export class AdminInterface extends AuthenticatedInterface {
|
||||
super.connectedCallback();
|
||||
|
||||
if (process.env.NODE_ENV === "development" && process.env.WATCHER_URL) {
|
||||
const { ESBuildObserver } = await import("@goauthentik/common/client");
|
||||
const { ESBuildObserver } = await import("src/development/build-observer");
|
||||
|
||||
new ESBuildObserver(process.env.WATCHER_URL);
|
||||
}
|
||||
@ -158,7 +158,7 @@ export class AdminInterface extends AuthenticatedInterface {
|
||||
class="pf-c-page__main"
|
||||
tabindex="-1"
|
||||
id="main-content"
|
||||
defaultUrl="/administration/overview"
|
||||
defaultURL="/administration/overview"
|
||||
.routes=${ROUTES}
|
||||
>
|
||||
</ak-router-outlet>
|
||||
|
@ -6,7 +6,7 @@ import {
|
||||
WithCapabilitiesConfig,
|
||||
} from "@goauthentik/elements/Interface/capabilitiesProvider";
|
||||
import { WithVersion } from "@goauthentik/elements/Interface/versionProvider";
|
||||
import { ID_REGEX, SLUG_REGEX, UUID_REGEX } from "@goauthentik/elements/router/Route";
|
||||
import { ID_PATTERN, SLUG_PATTERN, UUID_PATTERN } from "@goauthentik/elements/router";
|
||||
import { getRootStyle } from "@goauthentik/elements/utils/getRootStyle";
|
||||
import { spread } from "@open-wc/lit-helpers";
|
||||
|
||||
@ -95,62 +95,127 @@ export class AkAdminSidebar extends WithCapabilitiesConfig(WithVersion(AKElement
|
||||
}
|
||||
|
||||
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,
|
||||
/**
|
||||
* The pathname to match against. If null, this is a parent item.
|
||||
*/
|
||||
pathname: string | null,
|
||||
/**
|
||||
* The label to display in the sidebar.
|
||||
*/
|
||||
label: string,
|
||||
attributes?: Record<string, any> | string[] | null, // eslint-disable-line
|
||||
/**
|
||||
* The attributes to apply to the sidebar item. This is a map of attribute name to value.
|
||||
*
|
||||
* The second attribute type is of string[] to help with the 'activeWhen' control,
|
||||
* which was commonplace and singular enough to merit its own handler.
|
||||
*/
|
||||
attributes?: Record<string, unknown> | string[] | null,
|
||||
/**
|
||||
* The children of this sidebar item. This is a recursive structure.
|
||||
*/
|
||||
children?: SidebarEntry[],
|
||||
];
|
||||
|
||||
// prettier-ignore
|
||||
const sidebarContent: SidebarEntry[] = [
|
||||
[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")]]],
|
||||
// ---
|
||||
[
|
||||
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_PATTERN})`],
|
||||
],
|
||||
["/core/providers", msg("Providers"), [`/core/providers/:id(${ID_PATTERN})`]],
|
||||
["/outpost/outposts", msg("Outposts")],
|
||||
],
|
||||
],
|
||||
[
|
||||
null,
|
||||
msg("Events"),
|
||||
null,
|
||||
[
|
||||
["/events/log", msg("Logs"), [`/events/log/:id(${UUID_PATTERN})`]],
|
||||
["/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_PATTERN})`]],
|
||||
["/flow/stages", msg("Stages")],
|
||||
["/flow/stages/prompts", msg("Prompts")],
|
||||
],
|
||||
],
|
||||
[
|
||||
null,
|
||||
msg("Directory"),
|
||||
null,
|
||||
[
|
||||
["/identity/users", msg("Users"), [`/identity/users/:id(${ID_PATTERN})`]],
|
||||
["/identity/groups", msg("Groups"), [`/identity/groups/:id(${UUID_PATTERN})`]],
|
||||
["/identity/roles", msg("Roles"), [`/identity/roles/:id(${UUID_PATTERN})`]],
|
||||
[
|
||||
"/core/sources",
|
||||
msg("Federation and Social login"),
|
||||
[`/core/sources/:slug(${SLUG_PATTERN})`],
|
||||
],
|
||||
["/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 renderOneSidebarItem: SidebarRenderer = ([pathname, label, attributes, children]) => {
|
||||
const properties = Array.isArray(attributes)
|
||||
? { ".activeWhen": attributes }
|
||||
: (attributes ?? {});
|
||||
if (path) {
|
||||
properties.path = path;
|
||||
|
||||
if (pathname) {
|
||||
properties.pathname = pathname;
|
||||
}
|
||||
|
||||
return html`<ak-sidebar-item ${spread(properties)}>
|
||||
${label ? html`<span slot="label">${label}</span>` : nothing}
|
||||
${map(children, renderOneSidebarItem)}
|
||||
|
@ -1,155 +1,210 @@
|
||||
import "@goauthentik/admin/admin-overview/AdminOverviewPage";
|
||||
import { ID_REGEX, Route, SLUG_REGEX, UUID_REGEX } from "@goauthentik/elements/router/Route";
|
||||
import { Route } from "@goauthentik/elements/router/Route";
|
||||
import { ID_PATTERN, SLUG_PATTERN, UUID_PATTERN } from "@goauthentik/elements/router/constants";
|
||||
|
||||
import { html } from "lit";
|
||||
|
||||
export const ROUTES: Route[] = [
|
||||
interface IDParameters {
|
||||
id: string;
|
||||
}
|
||||
|
||||
interface SlugParameters {
|
||||
slug: string;
|
||||
}
|
||||
|
||||
interface UUIDParameters {
|
||||
uuid: string;
|
||||
}
|
||||
|
||||
export const ROUTES = [
|
||||
// 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),
|
||||
Route.redirect("^/$", "/administration/overview"),
|
||||
Route.redirect("^#.*", "/administration/overview"),
|
||||
Route.redirect("^/library$", "/if/user/", true),
|
||||
// statically imported since this is the default route
|
||||
new Route(new RegExp("^/administration/overview$"), async () => {
|
||||
new Route("/administration/overview", () => {
|
||||
return html`<ak-admin-overview></ak-admin-overview>`;
|
||||
}),
|
||||
new Route(new RegExp("^/administration/dashboard/users$"), async () => {
|
||||
new Route("/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 () => {
|
||||
new Route("/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 () => {
|
||||
new Route("/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 () => {
|
||||
new Route<IDParameters>(
|
||||
new URLPattern({
|
||||
pathname: `/core/providers/:id(${ID_PATTERN})`,
|
||||
}),
|
||||
async (params) => {
|
||||
await import("@goauthentik/admin/providers/ProviderViewPage");
|
||||
|
||||
return html`<ak-provider-view
|
||||
.providerID=${parseInt(params.id, 10)}
|
||||
></ak-provider-view>`;
|
||||
},
|
||||
),
|
||||
new Route("/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) => {
|
||||
new Route(`/core/applications/:slug(${SLUG_PATTERN})`, async ({ slug }) => {
|
||||
await import("@goauthentik/admin/applications/ApplicationViewPage");
|
||||
return html`<ak-application-view .applicationSlug=${args.slug}></ak-application-view>`;
|
||||
|
||||
return html`<ak-application-view .applicationSlug=${slug}></ak-application-view>`;
|
||||
}),
|
||||
new Route(new RegExp("^/core/sources$"), async () => {
|
||||
new Route("/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) => {
|
||||
new Route(`/core/sources/:slug(${SLUG_PATTERN})`, async ({ slug }) => {
|
||||
await import("@goauthentik/admin/sources/SourceViewPage");
|
||||
return html`<ak-source-view .sourceSlug=${args.slug}></ak-source-view>`;
|
||||
|
||||
return html`<ak-source-view .sourceSlug=${slug}></ak-source-view>`;
|
||||
}),
|
||||
new Route(new RegExp("^/core/property-mappings$"), async () => {
|
||||
new Route("/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 () => {
|
||||
new Route("/core/tokens", async () => {
|
||||
await import("@goauthentik/admin/tokens/TokenListPage");
|
||||
|
||||
return html`<ak-token-list></ak-token-list>`;
|
||||
}),
|
||||
new Route(new RegExp("^/core/brands"), async () => {
|
||||
new Route("/core/brands", async () => {
|
||||
await import("@goauthentik/admin/brands/BrandListPage");
|
||||
|
||||
return html`<ak-brand-list></ak-brand-list>`;
|
||||
}),
|
||||
new Route(new RegExp("^/policy/policies$"), async () => {
|
||||
new Route("/policy/policies", async () => {
|
||||
await import("@goauthentik/admin/policies/PolicyListPage");
|
||||
|
||||
return html`<ak-policy-list></ak-policy-list>`;
|
||||
}),
|
||||
new Route(new RegExp("^/policy/reputation$"), async () => {
|
||||
new Route("/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 () => {
|
||||
new Route("/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) => {
|
||||
new Route<UUIDParameters>(`/identity/groups/:uuid(${UUID_PATTERN})`, async ({ uuid }) => {
|
||||
await import("@goauthentik/admin/groups/GroupViewPage");
|
||||
return html`<ak-group-view .groupId=${args.uuid}></ak-group-view>`;
|
||||
|
||||
return html`<ak-group-view .groupId=${uuid}></ak-group-view>`;
|
||||
}),
|
||||
new Route(new RegExp("^/identity/users$"), async () => {
|
||||
new Route("/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) => {
|
||||
new Route<IDParameters>(`/identity/users/:id(${ID_PATTERN})`, async ({ id }) => {
|
||||
await import("@goauthentik/admin/users/UserViewPage");
|
||||
return html`<ak-user-view .userId=${parseInt(args.id, 10)}></ak-user-view>`;
|
||||
|
||||
return html`<ak-user-view .userId=${parseInt(id, 10)}></ak-user-view>`;
|
||||
}),
|
||||
new Route(new RegExp("^/identity/roles$"), async () => {
|
||||
new Route("/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) => {
|
||||
new Route<IDParameters>(`/identity/roles/:id(${UUID_PATTERN})`, async ({ id }) => {
|
||||
await import("@goauthentik/admin/roles/RoleViewPage");
|
||||
return html`<ak-role-view roleId=${args.id}></ak-role-view>`;
|
||||
|
||||
return html`<ak-role-view roleId=${id}></ak-role-view>`;
|
||||
}),
|
||||
new Route(new RegExp("^/flow/stages/invitations$"), async () => {
|
||||
new Route("/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 () => {
|
||||
new Route("/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 () => {
|
||||
new Route("/flow/stages", async () => {
|
||||
await import("@goauthentik/admin/stages/StageListPage");
|
||||
|
||||
return html`<ak-stage-list></ak-stage-list>`;
|
||||
}),
|
||||
new Route(new RegExp("^/flow/flows$"), async () => {
|
||||
new Route("/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) => {
|
||||
new Route<SlugParameters>(`/flow/flows/:slug(${SLUG_PATTERN})`, async ({ slug }) => {
|
||||
await import("@goauthentik/admin/flows/FlowViewPage");
|
||||
return html`<ak-flow-view .flowSlug=${args.slug}></ak-flow-view>`;
|
||||
|
||||
return html`<ak-flow-view .flowSlug=${slug}></ak-flow-view>`;
|
||||
}),
|
||||
new Route(new RegExp("^/events/log$"), async () => {
|
||||
new Route("/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) => {
|
||||
new Route<IDParameters>(`/events/log/:id(${UUID_PATTERN})`, async ({ id }) => {
|
||||
await import("@goauthentik/admin/events/EventViewPage");
|
||||
return html`<ak-event-view .eventID=${args.id}></ak-event-view>`;
|
||||
|
||||
return html`<ak-event-view .eventID=${id}></ak-event-view>`;
|
||||
}),
|
||||
new Route(new RegExp("^/events/transports$"), async () => {
|
||||
new Route("/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 () => {
|
||||
new Route("/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 () => {
|
||||
new Route("/outpost/outposts", async () => {
|
||||
await import("@goauthentik/admin/outposts/OutpostListPage");
|
||||
|
||||
return html`<ak-outpost-list></ak-outpost-list>`;
|
||||
}),
|
||||
new Route(new RegExp("^/outpost/integrations$"), async () => {
|
||||
new Route("/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 () => {
|
||||
new Route("/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 () => {
|
||||
new Route("/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 () => {
|
||||
new Route("/blueprints/instances", async () => {
|
||||
await import("@goauthentik/admin/blueprints/BlueprintListPage");
|
||||
|
||||
return html`<ak-blueprint-list></ak-blueprint-list>`;
|
||||
}),
|
||||
new Route(new RegExp("^/debug$"), async () => {
|
||||
new Route("/debug", async () => {
|
||||
await import("@goauthentik/admin/DebugPage");
|
||||
|
||||
return html`<ak-admin-debug-page></ak-admin-debug-page>`;
|
||||
}),
|
||||
new Route(new RegExp("^/enterprise/licenses$"), async () => {
|
||||
new Route("/enterprise/licenses", async () => {
|
||||
await import("@goauthentik/admin/enterprise/EnterpriseLicenseListPage");
|
||||
|
||||
return html`<ak-enterprise-license-list></ak-enterprise-license-list>`;
|
||||
}),
|
||||
];
|
||||
] satisfies Route<never>[];
|
||||
|
@ -16,7 +16,7 @@ import "@goauthentik/elements/PageHeader";
|
||||
import "@goauthentik/elements/cards/AggregatePromiseCard";
|
||||
import "@goauthentik/elements/cards/QuickActionsCard.js";
|
||||
import type { QuickAction } from "@goauthentik/elements/cards/QuickActionsCard.js";
|
||||
import { paramURL } from "@goauthentik/elements/router/RouterOutlet";
|
||||
import { formatRouteHash } from "@goauthentik/elements/router";
|
||||
|
||||
import { msg, str } from "@lit/localize";
|
||||
import { CSSResult, TemplateResult, css, html, nothing } from "lit";
|
||||
@ -79,10 +79,13 @@ export class AdminOverviewPage extends AdminOverviewBase {
|
||||
}
|
||||
|
||||
quickActions: QuickAction[] = [
|
||||
[msg("Create a new application"), paramURL("/core/applications", { createForm: true })],
|
||||
[msg("Check the logs"), paramURL("/events/log")],
|
||||
[
|
||||
msg("Create a new application"),
|
||||
formatRouteHash("/core/applications", { createForm: true }),
|
||||
],
|
||||
[msg("Check the logs"), formatRouteHash("/events/log")],
|
||||
[msg("Explore integrations"), "https://goauthentik.io/integrations/", true],
|
||||
[msg("Manage users"), paramURL("/identity/users")],
|
||||
[msg("Manage users"), formatRouteHash("/identity/users")],
|
||||
[msg("Check the release notes"), `https://goauthentik.io/docs/releases/${RELEASE}`, true],
|
||||
];
|
||||
|
||||
@ -195,10 +198,13 @@ export class AdminOverviewPage extends AdminOverviewBase {
|
||||
const release = `${versionFamily()}#fixed-in-${VERSION.replaceAll(".", "")}`;
|
||||
|
||||
const quickActions: [string, string][] = [
|
||||
[msg("Create a new application"), paramURL("/core/applications", { createForm: true })],
|
||||
[msg("Check the logs"), paramURL("/events/log")],
|
||||
[
|
||||
msg("Create a new application"),
|
||||
formatRouteHash("/core/applications", { createForm: true }),
|
||||
],
|
||||
[msg("Check the logs"), formatRouteHash("/events/log")],
|
||||
[msg("Explore integrations"), "https://goauthentik.io/integrations/"],
|
||||
[msg("Manage users"), paramURL("/identity/users")],
|
||||
[msg("Manage users"), formatRouteHash("/identity/users")],
|
||||
[msg("Check the release notes"), `https://goauthentik.io/docs/releases/${release}`],
|
||||
];
|
||||
|
||||
|
@ -7,7 +7,7 @@ import "@goauthentik/elements/ak-mdx";
|
||||
import "@goauthentik/elements/buttons/SpinnerButton";
|
||||
import "@goauthentik/elements/forms/DeleteBulkForm";
|
||||
import "@goauthentik/elements/forms/ModalForm";
|
||||
import { getURLParam } from "@goauthentik/elements/router/RouteMatch";
|
||||
import { getRouteParameter } from "@goauthentik/elements/router/utils";
|
||||
import { PaginatedResponse } from "@goauthentik/elements/table/Table";
|
||||
import { TableColumn } from "@goauthentik/elements/table/Table";
|
||||
import { TablePage } from "@goauthentik/elements/table/TablePage";
|
||||
@ -156,7 +156,7 @@ export class ApplicationListPage extends WithBrandConfig(TablePage<Application>)
|
||||
}
|
||||
|
||||
renderObjectCreate(): TemplateResult {
|
||||
return html` <ak-application-wizard .open=${getURLParam("createWizard", false)}>
|
||||
return html` <ak-application-wizard .open=${getRouteParameter("createWizard", false)}>
|
||||
<button
|
||||
slot="trigger"
|
||||
class="pf-c-button pf-m-primary"
|
||||
@ -165,7 +165,7 @@ export class ApplicationListPage extends WithBrandConfig(TablePage<Application>)
|
||||
${msg("Create with Provider")}
|
||||
</button>
|
||||
</ak-application-wizard>
|
||||
<ak-forms-modal .open=${getURLParam("createForm", false)}>
|
||||
<ak-forms-modal .open=${getRouteParameter("createForm", false)}>
|
||||
<span slot="submit"> ${msg("Create")} </span>
|
||||
<span slot="header"> ${msg("Create Application")} </span>
|
||||
<ak-application-form slot="form"> </ak-application-form>
|
||||
|
@ -8,7 +8,7 @@ import "@goauthentik/components/ak-hint/ak-hint-body";
|
||||
import { AKElement } from "@goauthentik/elements/Base";
|
||||
import "@goauthentik/elements/Label";
|
||||
import "@goauthentik/elements/buttons/ActionButton/ak-action-button";
|
||||
import { getURLParam } from "@goauthentik/elements/router/RouteMatch";
|
||||
import { getRouteParameter } from "@goauthentik/elements/router/utils";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
import { css, html } from "lit";
|
||||
@ -110,7 +110,7 @@ export class AkApplicationWizardHint extends AKElement implements ShowHintContro
|
||||
the same time with our new Application Wizard.
|
||||
<!-- <a href="(link to docs)">Learn more about the wizard here.</a> -->
|
||||
</p>
|
||||
<ak-application-wizard .open=${getURLParam("createWizard", false)}>
|
||||
<ak-application-wizard .open=${getRouteParameter("createWizard", false)}>
|
||||
<button
|
||||
slot="trigger"
|
||||
class="pf-c-button pf-m-primary"
|
||||
|
@ -1,7 +1,6 @@
|
||||
import { policyOptions } from "@goauthentik/admin/applications/PolicyOptions.js";
|
||||
import { ApplicationWizardStep } from "@goauthentik/admin/applications/wizard/ApplicationWizardStep.js";
|
||||
import "@goauthentik/admin/applications/wizard/ak-wizard-title.js";
|
||||
import { isSlug } from "@goauthentik/common/utils.js";
|
||||
import { camelToSnake } from "@goauthentik/common/utils.js";
|
||||
import "@goauthentik/components/ak-radio-input";
|
||||
import "@goauthentik/components/ak-slug-input";
|
||||
@ -11,6 +10,7 @@ import { type NavigableButton, type WizardButton } from "@goauthentik/components
|
||||
import { type KeyUnknown } from "@goauthentik/elements/forms/Form";
|
||||
import "@goauthentik/elements/forms/FormGroup";
|
||||
import "@goauthentik/elements/forms/HorizontalFormElement";
|
||||
import { isSlug } from "@goauthentik/elements/router";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
import { html } from "lit";
|
||||
|
@ -1,7 +1,7 @@
|
||||
import "@goauthentik/admin/flows/FlowForm";
|
||||
import "@goauthentik/admin/flows/FlowImportForm";
|
||||
import { DesignationToLabel } from "@goauthentik/admin/flows/utils";
|
||||
import { AndNext, DEFAULT_CONFIG } from "@goauthentik/common/api/config";
|
||||
import { DesignationToLabel, formatFlowURL } from "@goauthentik/admin/flows/utils";
|
||||
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
|
||||
import { groupBy } from "@goauthentik/common/utils";
|
||||
import "@goauthentik/elements/buttons/SpinnerButton";
|
||||
import "@goauthentik/elements/forms/ConfirmationForm";
|
||||
@ -107,10 +107,9 @@ export class FlowListPage extends TablePage<Flow> {
|
||||
<button
|
||||
class="pf-c-button pf-m-plain"
|
||||
@click=${() => {
|
||||
const finalURL = `${window.location.origin}/if/flow/${item.slug}/${AndNext(
|
||||
`${window.location.pathname}#${window.location.hash}`,
|
||||
)}`;
|
||||
window.open(finalURL, "_blank");
|
||||
const url = formatFlowURL(item);
|
||||
|
||||
window.open(url, "_blank");
|
||||
}}
|
||||
>
|
||||
<pf-tooltip position="top" content=${msg("Execute")}>
|
||||
|
@ -1,10 +1,10 @@
|
||||
import "@goauthentik/admin/flows/BoundStagesList";
|
||||
import "@goauthentik/admin/flows/FlowDiagram";
|
||||
import "@goauthentik/admin/flows/FlowForm";
|
||||
import { DesignationToLabel } from "@goauthentik/admin/flows/utils";
|
||||
import { DesignationToLabel, applyNextParam, formatFlowURL } from "@goauthentik/admin/flows/utils";
|
||||
import "@goauthentik/admin/policies/BoundPoliciesList";
|
||||
import "@goauthentik/admin/rbac/ObjectPermissionsPage";
|
||||
import { AndNext, DEFAULT_CONFIG } from "@goauthentik/common/api/config";
|
||||
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
|
||||
import "@goauthentik/components/events/ObjectChangelog";
|
||||
import { AKElement } from "@goauthentik/elements/Base";
|
||||
import "@goauthentik/elements/PageHeader";
|
||||
@ -151,12 +151,9 @@ export class FlowViewPage extends AKElement {
|
||||
<button
|
||||
class="pf-c-button pf-m-block pf-m-primary"
|
||||
@click=${() => {
|
||||
const finalURL = `${
|
||||
window.location.origin
|
||||
}/if/flow/${this.flow.slug}/${AndNext(
|
||||
`${window.location.pathname}#${window.location.hash}`,
|
||||
)}`;
|
||||
window.open(finalURL, "_blank");
|
||||
const url = formatFlowURL(this.flow);
|
||||
|
||||
window.open(url, "_blank");
|
||||
}}
|
||||
>
|
||||
${msg("Normal")}
|
||||
@ -168,12 +165,16 @@ export class FlowViewPage extends AKElement {
|
||||
.flowsInstancesExecuteRetrieve({
|
||||
slug: this.flow.slug,
|
||||
})
|
||||
.then((link) => {
|
||||
const finalURL = `${
|
||||
link.link
|
||||
}${AndNext(
|
||||
`${window.location.pathname}#${window.location.hash}`,
|
||||
)}`;
|
||||
.then(({ link }) => {
|
||||
const finalURL = URL.canParse(link)
|
||||
? new URL(link)
|
||||
: new URL(
|
||||
link,
|
||||
window.location.origin,
|
||||
);
|
||||
|
||||
applyNextParam(finalURL);
|
||||
|
||||
window.open(finalURL, "_blank");
|
||||
});
|
||||
}}
|
||||
|
@ -43,3 +43,51 @@ export function LayoutToLabel(layout: FlowLayoutEnum): string {
|
||||
return msg("Unknown layout");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the next URL as a query parameter to the given URL or URLSearchParams object.
|
||||
*
|
||||
* @todo deprecate this once hash routing is removed.
|
||||
*/
|
||||
export function applyNextParam(
|
||||
target: URL | URLSearchParams,
|
||||
destination: string | URL = window.location.pathname + "#" + window.location.hash,
|
||||
): void {
|
||||
const searchParams = target instanceof URL ? target.searchParams : target;
|
||||
|
||||
searchParams.set("next", destination.toString());
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a URLSearchParams object with the next URL as a query parameter.
|
||||
*
|
||||
* @todo deprecate this once hash routing is removed.
|
||||
*/
|
||||
export function createNextSearchParams(
|
||||
destination: string | URL = window.location.pathname + "#" + window.location.hash,
|
||||
): URLSearchParams {
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
applyNextParam(searchParams, destination);
|
||||
|
||||
return searchParams;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a URL to a flow, with the next URL as a query parameter.
|
||||
*
|
||||
* @param flow The flow to create the URL for.
|
||||
* @param destination The next URL to redirect to after the flow is completed, `true` to use the current route.
|
||||
*/
|
||||
export function formatFlowURL(
|
||||
flow: Flow,
|
||||
destination: string | URL | null = window.location.pathname + "#" + window.location.hash,
|
||||
): URL {
|
||||
const url = new URL(`/if/flow/${flow.slug}/`, window.location.origin);
|
||||
|
||||
if (destination) {
|
||||
applyNextParam(url, destination);
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
@ -22,7 +22,7 @@ import { Form } from "@goauthentik/elements/forms/Form";
|
||||
import "@goauthentik/elements/forms/HorizontalFormElement";
|
||||
import "@goauthentik/elements/forms/ModalForm";
|
||||
import { showMessage } from "@goauthentik/elements/messages/MessageContainer";
|
||||
import { getURLParam, updateURLParams } from "@goauthentik/elements/router/RouteMatch";
|
||||
import { getRouteParameter, patchRouteParams } from "@goauthentik/elements/router/utils";
|
||||
import { PaginatedResponse } from "@goauthentik/elements/table/Table";
|
||||
import { Table, TableColumn } from "@goauthentik/elements/table/Table";
|
||||
import { UserOption } from "@goauthentik/elements/user/utils";
|
||||
@ -127,7 +127,7 @@ export class RelatedUserList extends WithBrandConfig(WithCapabilitiesConfig(Tabl
|
||||
order = "last_login";
|
||||
|
||||
@property({ type: Boolean })
|
||||
hideServiceAccounts = getURLParam<boolean>("hideServiceAccounts", true);
|
||||
hideServiceAccounts = getRouteParameter<boolean>("hideServiceAccounts", true);
|
||||
|
||||
@state()
|
||||
me?: SessionUser;
|
||||
@ -466,7 +466,7 @@ export class RelatedUserList extends WithBrandConfig(WithCapabilitiesConfig(Tabl
|
||||
this.hideServiceAccounts = !this.hideServiceAccounts;
|
||||
this.page = 1;
|
||||
this.fetch();
|
||||
updateURLParams({
|
||||
patchRouteParams({
|
||||
hideServiceAccounts: this.hideServiceAccounts,
|
||||
});
|
||||
}}
|
||||
|
@ -19,7 +19,7 @@ import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
|
||||
import "@goauthentik/elements/forms/DeleteBulkForm";
|
||||
import "@goauthentik/elements/forms/ModalForm";
|
||||
import "@goauthentik/elements/forms/ProxyForm";
|
||||
import { getURLParam, updateURLParams } from "@goauthentik/elements/router/RouteMatch";
|
||||
import { getRouteParameter, patchRouteParams } from "@goauthentik/elements/router/utils";
|
||||
import { PaginatedResponse } from "@goauthentik/elements/table/Table";
|
||||
import { TableColumn } from "@goauthentik/elements/table/Table";
|
||||
import { TablePage } from "@goauthentik/elements/table/TablePage";
|
||||
@ -54,7 +54,7 @@ export class PropertyMappingListPage extends TablePage<PropertyMapping> {
|
||||
order = "name";
|
||||
|
||||
@state()
|
||||
hideManaged = getURLParam<boolean>("hideManaged", true);
|
||||
hideManaged = getRouteParameter<boolean>("hideManaged", true);
|
||||
|
||||
async apiEndpoint(): Promise<PaginatedResponse<PropertyMapping>> {
|
||||
return new PropertymappingsApi(DEFAULT_CONFIG).propertymappingsAllList({
|
||||
@ -148,7 +148,7 @@ export class PropertyMappingListPage extends TablePage<PropertyMapping> {
|
||||
this.hideManaged = !this.hideManaged;
|
||||
this.page = 1;
|
||||
this.fetch();
|
||||
updateURLParams({
|
||||
patchRouteParams({
|
||||
hideManaged: this.hideManaged,
|
||||
});
|
||||
}}
|
||||
|
@ -3,7 +3,6 @@ import "@goauthentik/admin/providers/proxy/ProxyProviderForm";
|
||||
import "@goauthentik/admin/rbac/ObjectPermissionsPage";
|
||||
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
|
||||
import { EVENT_REFRESH } from "@goauthentik/common/constants";
|
||||
import { convertToSlug } from "@goauthentik/common/utils";
|
||||
import "@goauthentik/components/ak-status-label";
|
||||
import "@goauthentik/components/events/ObjectChangelog";
|
||||
import MDCaddyStandalone from "@goauthentik/docs/add-secure-apps/providers/proxy/_caddy_standalone.md";
|
||||
@ -21,7 +20,8 @@ import "@goauthentik/elements/ak-mdx";
|
||||
import type { Replacer } from "@goauthentik/elements/ak-mdx";
|
||||
import "@goauthentik/elements/buttons/ModalButton";
|
||||
import "@goauthentik/elements/buttons/SpinnerButton";
|
||||
import { getURLParam } from "@goauthentik/elements/router/RouteMatch";
|
||||
import { formatAsSlug } from "@goauthentik/elements/router";
|
||||
import { getRouteParameter } from "@goauthentik/elements/router/utils";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
import { CSSResult, PropertyValues, TemplateResult, css, html } from "lit";
|
||||
@ -156,7 +156,7 @@ export class ProxyProviderViewPage extends AKElement {
|
||||
(input: string): string => {
|
||||
// The generated config is pretty unreliable currently so
|
||||
// put it behind a flag
|
||||
if (!getURLParam("generatedConfig", false)) {
|
||||
if (!getRouteParameter("generatedConfig", false)) {
|
||||
return input;
|
||||
}
|
||||
if (!this.provider) {
|
||||
@ -183,7 +183,7 @@ export class ProxyProviderViewPage extends AKElement {
|
||||
return html`<ak-tabs pageIdentifier="proxy-setup">
|
||||
${servers.map((server) => {
|
||||
return html`<section
|
||||
slot="page-${convertToSlug(server.label)}"
|
||||
slot="page-${formatAsSlug(server.label)}"
|
||||
data-tab-title="${server.label}"
|
||||
class="pf-c-page__main-section pf-m-no-padding-mobile ak-markdown-section"
|
||||
>
|
||||
|
@ -9,7 +9,7 @@ import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
|
||||
import { PFSize } from "@goauthentik/common/enums.js";
|
||||
import { userTypeToLabel } from "@goauthentik/common/labels";
|
||||
import { MessageLevel } from "@goauthentik/common/messages";
|
||||
import { DefaultUIConfig, uiConfig } from "@goauthentik/common/ui/config";
|
||||
import { createUIConfig, uiConfig } from "@goauthentik/common/ui/config";
|
||||
import { me } from "@goauthentik/common/users";
|
||||
import { getRelativeTime } from "@goauthentik/common/utils";
|
||||
import "@goauthentik/components/ak-status-label";
|
||||
@ -24,7 +24,7 @@ import "@goauthentik/elements/buttons/ActionButton";
|
||||
import "@goauthentik/elements/forms/DeleteBulkForm";
|
||||
import "@goauthentik/elements/forms/ModalForm";
|
||||
import { showMessage } from "@goauthentik/elements/messages/MessageContainer";
|
||||
import { getURLParam, updateURLParams } from "@goauthentik/elements/router/RouteMatch";
|
||||
import { getRouteParameter, patchRouteParams } from "@goauthentik/elements/router/utils";
|
||||
import { PaginatedResponse } from "@goauthentik/elements/table/Table";
|
||||
import { TableColumn } from "@goauthentik/elements/table/Table";
|
||||
import { TablePage } from "@goauthentik/elements/table/TablePage";
|
||||
@ -117,7 +117,7 @@ export class UserListPage extends WithBrandConfig(WithCapabilitiesConfig(TablePa
|
||||
activePath;
|
||||
|
||||
@state()
|
||||
hideDeactivated = getURLParam<boolean>("hideDeactivated", false);
|
||||
hideDeactivated = getRouteParameter<boolean>("hideDeactivated", false);
|
||||
|
||||
@state()
|
||||
userPaths?: UserPath;
|
||||
@ -131,8 +131,10 @@ export class UserListPage extends WithBrandConfig(WithCapabilitiesConfig(TablePa
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const defaultPath = new DefaultUIConfig().defaults.userPath;
|
||||
this.activePath = getURLParam<string>("path", defaultPath);
|
||||
|
||||
const defaultPath = createUIConfig().defaults.userPath;
|
||||
this.activePath = getRouteParameter("path", defaultPath);
|
||||
|
||||
uiConfig().then((c) => {
|
||||
if (c.defaults.userPath !== defaultPath) {
|
||||
this.activePath = c.defaults.userPath;
|
||||
@ -143,7 +145,7 @@ export class UserListPage extends WithBrandConfig(WithCapabilitiesConfig(TablePa
|
||||
async apiEndpoint(): Promise<PaginatedResponse<User>> {
|
||||
const users = await new CoreApi(DEFAULT_CONFIG).coreUsersList({
|
||||
...(await this.defaultEndpointConfig()),
|
||||
pathStartswith: getURLParam("path", ""),
|
||||
pathStartswith: getRouteParameter("path", ""),
|
||||
isActive: this.hideDeactivated ? true : undefined,
|
||||
includeGroups: false,
|
||||
});
|
||||
@ -225,7 +227,7 @@ export class UserListPage extends WithBrandConfig(WithCapabilitiesConfig(TablePa
|
||||
this.hideDeactivated = !this.hideDeactivated;
|
||||
this.page = 1;
|
||||
this.fetch();
|
||||
updateURLParams({
|
||||
patchRouteParams({
|
||||
hideDeactivated: this.hideDeactivated,
|
||||
});
|
||||
}}
|
||||
|
@ -79,11 +79,4 @@ export const DEFAULT_CONFIG = new Configuration({
|
||||
],
|
||||
});
|
||||
|
||||
// This is just a function so eslint doesn't complain about
|
||||
// missing-whitespace-between-attributes or
|
||||
// unexpected-character-in-attribute-name
|
||||
export function AndNext(url: string): string {
|
||||
return `?next=${encodeURIComponent(url)}`;
|
||||
}
|
||||
|
||||
console.debug(`authentik(early): version ${VERSION}, apiBase ${DEFAULT_CONFIG.basePath}`);
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { EVENT_REQUEST_POST } from "@goauthentik/common/constants";
|
||||
import { getCookie } from "@goauthentik/common/utils";
|
||||
import { getCookie } from "@goauthentik/common/http";
|
||||
|
||||
import {
|
||||
CurrentBrand,
|
||||
|
@ -1,170 +1,60 @@
|
||||
/**
|
||||
* @file
|
||||
* Client-side observer for ESBuild events.
|
||||
* @file Client-side utilities.
|
||||
*/
|
||||
import type { Message as ESBuildMessage } from "esbuild";
|
||||
import { TITLE_DEFAULT } from "@goauthentik/common/constants";
|
||||
import { isAdminRoute } from "@goauthentik/elements/router";
|
||||
|
||||
const logPrefix = "👷 [ESBuild]";
|
||||
const log = console.debug.bind(console, logPrefix);
|
||||
import { msg } from "@lit/localize";
|
||||
|
||||
type BuildEventListener<Data = unknown> = (event: MessageEvent<Data>) => void;
|
||||
import type { CurrentBrand } from "@goauthentik/api";
|
||||
|
||||
type BrandTitleLike = Partial<Pick<CurrentBrand, "brandingTitle">>;
|
||||
|
||||
/**
|
||||
* A client-side watcher for ESBuild.
|
||||
* Create a title for the page.
|
||||
*
|
||||
* Note that this should be conditionally imported in your code, so that
|
||||
* ESBuild may tree-shake it out of production builds.
|
||||
*
|
||||
* ```ts
|
||||
* if (process.env.NODE_ENV === "development" && process.env.WATCHER_URL) {
|
||||
* const { ESBuildObserver } = await import("@goauthentik/common/client");
|
||||
*
|
||||
* new ESBuildObserver(process.env.WATCHER_URL);
|
||||
* }
|
||||
* ```
|
||||
}
|
||||
|
||||
* @param brand - The brand object to append to the title.
|
||||
* @param segments - The segments to prepend to the title.
|
||||
*/
|
||||
export class ESBuildObserver extends EventSource {
|
||||
/**
|
||||
* Whether the watcher has a recent connection to the server.
|
||||
*/
|
||||
alive = true;
|
||||
export function formatPageTitle(
|
||||
brand: BrandTitleLike | undefined,
|
||||
...segments: Array<string | undefined>
|
||||
): string;
|
||||
/**
|
||||
* Create a title for the page.
|
||||
*
|
||||
* @param segments - The segments to prepend to the title.
|
||||
*/
|
||||
export function formatPageTitle(...segments: Array<string | undefined>): string;
|
||||
/**
|
||||
* Create a title for the page.
|
||||
*
|
||||
* @param args - The segments to prepend to the title.
|
||||
* @param args - The brand object to append to the title.
|
||||
*/
|
||||
export function formatPageTitle(
|
||||
...args: [BrandTitleLike | string | undefined, ...Array<string | undefined>]
|
||||
): string {
|
||||
const segments: string[] = [];
|
||||
|
||||
/**
|
||||
* The number of errors that have occurred since the watcher started.
|
||||
*/
|
||||
errorCount = 0;
|
||||
|
||||
/**
|
||||
* Whether a reload has been requested while offline.
|
||||
*/
|
||||
deferredReload = false;
|
||||
|
||||
/**
|
||||
* The last time a message was received from the server.
|
||||
*/
|
||||
lastUpdatedAt = Date.now();
|
||||
|
||||
/**
|
||||
* Whether the browser considers itself online.
|
||||
*/
|
||||
online = true;
|
||||
|
||||
/**
|
||||
* The ID of the animation frame for the reload.
|
||||
*/
|
||||
#reloadFrameID = -1;
|
||||
|
||||
/**
|
||||
* The interval for the keep-alive check.
|
||||
*/
|
||||
#keepAliveInterval: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
#trackActivity = () => {
|
||||
this.lastUpdatedAt = Date.now();
|
||||
this.alive = true;
|
||||
};
|
||||
|
||||
#startListener: BuildEventListener = () => {
|
||||
this.#trackActivity();
|
||||
log("⏰ Build started...");
|
||||
};
|
||||
|
||||
#internalErrorListener = () => {
|
||||
this.errorCount += 1;
|
||||
|
||||
if (this.errorCount > 100) {
|
||||
clearTimeout(this.#keepAliveInterval);
|
||||
|
||||
this.close();
|
||||
log("⛔️ Closing connection");
|
||||
}
|
||||
};
|
||||
|
||||
#errorListener: BuildEventListener<string> = (event) => {
|
||||
this.#trackActivity();
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.group(logPrefix, "⛔️⛔️⛔️ Build error...");
|
||||
|
||||
const esbuildErrorMessages: ESBuildMessage[] = JSON.parse(event.data);
|
||||
|
||||
for (const error of esbuildErrorMessages) {
|
||||
console.warn(error.text);
|
||||
|
||||
if (error.location) {
|
||||
console.debug(
|
||||
`file://${error.location.file}:${error.location.line}:${error.location.column}`,
|
||||
);
|
||||
console.debug(error.location.lineText);
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.groupEnd();
|
||||
};
|
||||
|
||||
#endListener: BuildEventListener = () => {
|
||||
cancelAnimationFrame(this.#reloadFrameID);
|
||||
|
||||
this.#trackActivity();
|
||||
|
||||
if (!this.online) {
|
||||
log("🚫 Build finished while offline.");
|
||||
this.deferredReload = true;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
log("🛎️ Build completed! Reloading...");
|
||||
|
||||
// We use an animation frame to keep the reload from happening before the
|
||||
// event loop has a chance to process the message.
|
||||
this.#reloadFrameID = requestAnimationFrame(() => {
|
||||
window.location.reload();
|
||||
});
|
||||
};
|
||||
|
||||
#keepAliveListener: BuildEventListener = () => {
|
||||
this.#trackActivity();
|
||||
log("🏓 Keep-alive");
|
||||
};
|
||||
|
||||
constructor(url: string | URL) {
|
||||
super(url);
|
||||
|
||||
this.addEventListener("esbuild:start", this.#startListener);
|
||||
this.addEventListener("esbuild:end", this.#endListener);
|
||||
this.addEventListener("esbuild:error", this.#errorListener);
|
||||
this.addEventListener("esbuild:keep-alive", this.#keepAliveListener);
|
||||
|
||||
this.addEventListener("error", this.#internalErrorListener);
|
||||
|
||||
window.addEventListener("offline", () => {
|
||||
this.online = false;
|
||||
});
|
||||
|
||||
window.addEventListener("online", () => {
|
||||
this.online = true;
|
||||
|
||||
if (!this.deferredReload) return;
|
||||
|
||||
log("🛎️ Reloading after offline build...");
|
||||
this.deferredReload = false;
|
||||
|
||||
window.location.reload();
|
||||
});
|
||||
|
||||
log("🛎️ Listening for build changes...");
|
||||
|
||||
this.#keepAliveInterval = setInterval(() => {
|
||||
const now = Date.now();
|
||||
|
||||
if (now - this.lastUpdatedAt < 10_000) return;
|
||||
|
||||
this.alive = false;
|
||||
log("👋 Waiting for build to start...");
|
||||
}, 15_000);
|
||||
if (isAdminRoute()) {
|
||||
segments.push(msg("Admin"));
|
||||
}
|
||||
|
||||
const [arg1, ...rest] = args;
|
||||
|
||||
if (typeof arg1 === "object") {
|
||||
const { brandingTitle = TITLE_DEFAULT } = arg1;
|
||||
segments.push(brandingTitle);
|
||||
} else {
|
||||
segments.push(TITLE_DEFAULT);
|
||||
}
|
||||
|
||||
for (const segment of rest) {
|
||||
if (segment) {
|
||||
segments.push(segment);
|
||||
}
|
||||
}
|
||||
|
||||
return segments.join(" - ");
|
||||
}
|
||||
|
@ -5,7 +5,6 @@ export const PROGRESS_CLASS = "pf-m-in-progress";
|
||||
export const CURRENT_CLASS = "pf-m-current";
|
||||
export const VERSION = "2025.2.3";
|
||||
export const TITLE_DEFAULT = "authentik";
|
||||
export const ROUTE_SEPARATOR = ";";
|
||||
|
||||
export const EVENT_REFRESH = "ak-refresh";
|
||||
export const EVENT_NOTIFICATION_DRAWER_TOGGLE = "ak-notification-toggle";
|
||||
|
28
web/src/common/http.ts
Normal file
28
web/src/common/http.ts
Normal file
@ -0,0 +1,28 @@
|
||||
/**
|
||||
* @file HTTP utilities.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Get the value of a cookie by its name.
|
||||
*
|
||||
* @param cookieName - The name of the cookie to retrieve.
|
||||
* @returns The value of the cookie, or an empty string if the cookie is not found.
|
||||
*/
|
||||
export function getCookie(cookieName: string): string {
|
||||
if (!cookieName) return "";
|
||||
if (typeof document === "undefined") return "";
|
||||
if (typeof document.cookie !== "string") return "";
|
||||
if (!document.cookie) return "";
|
||||
|
||||
const search = cookieName + "=";
|
||||
// Split the cookie string into individual name=value pairs...
|
||||
const keyValPairs = document.cookie.split(";").map((cookie) => cookie.trim());
|
||||
|
||||
for (const pair of keyValPairs) {
|
||||
if (!pair.startsWith(search)) continue;
|
||||
|
||||
return decodeURIComponent(pair.substring(search.length));
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
@ -2,6 +2,7 @@ import { config } from "@goauthentik/common/api/config";
|
||||
import { VERSION } from "@goauthentik/common/constants";
|
||||
import { SentryIgnoredError } from "@goauthentik/common/errors";
|
||||
import { me } from "@goauthentik/common/users";
|
||||
import { readInterfaceRouteParam } from "@goauthentik/elements/router/utils";
|
||||
import {
|
||||
ErrorEvent,
|
||||
EventHint,
|
||||
@ -64,7 +65,7 @@ export async function configureSentry(canDoPpi = false): Promise<Config> {
|
||||
});
|
||||
setTag(TAG_SENTRY_CAPABILITIES, cfg.capabilities.join(","));
|
||||
if (window.location.pathname.includes("if/")) {
|
||||
setTag(TAG_SENTRY_COMPONENT, `web/${currentInterface()}`);
|
||||
setTag(TAG_SENTRY_COMPONENT, `web/${readInterfaceRouteParam()}`);
|
||||
}
|
||||
if (cfg.capabilities.includes(CapabilitiesEnum.CanDebug)) {
|
||||
const Spotlight = await import("@spotlightjs/spotlight");
|
||||
@ -82,13 +83,3 @@ export async function configureSentry(canDoPpi = false): Promise<Config> {
|
||||
}
|
||||
return cfg;
|
||||
}
|
||||
|
||||
// Get the interface name from URL
|
||||
export function currentInterface(): string {
|
||||
const pathMatches = window.location.pathname.match(/.+if\/(\w+)\//);
|
||||
let currentInterface = "unknown";
|
||||
if (pathMatches && pathMatches.length >= 2) {
|
||||
currentInterface = pathMatches[1];
|
||||
}
|
||||
return currentInterface.toLowerCase();
|
||||
}
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { currentInterface } from "@goauthentik/common/sentry";
|
||||
import { me } from "@goauthentik/common/users";
|
||||
import { isUserRoute } from "@goauthentik/elements/router";
|
||||
|
||||
import { UiThemeEnum, UserSelf } from "@goauthentik/api";
|
||||
import { UiThemeEnum } from "@goauthentik/api";
|
||||
|
||||
export enum UserDisplay {
|
||||
username = "username",
|
||||
@ -18,15 +18,27 @@ export enum LayoutType {
|
||||
|
||||
export interface UIConfig {
|
||||
enabledFeatures: {
|
||||
// API Request drawer in navbar
|
||||
/**
|
||||
* Whether to show the API request drawer in the navbar.
|
||||
*/
|
||||
apiDrawer: boolean;
|
||||
// Notification drawer in navbar
|
||||
/**
|
||||
* Whether to show the notification drawer in the navbar.
|
||||
*/
|
||||
notificationDrawer: boolean;
|
||||
// Settings in user dropdown
|
||||
/**
|
||||
* Whether to show the settings in the user dropdown.
|
||||
*/
|
||||
settings: boolean;
|
||||
// Application edit in library (only shown when user is superuser)
|
||||
/**
|
||||
* Whether to show the application edit button in the library.
|
||||
*
|
||||
* This is only shown when the user is a superuser.
|
||||
*/
|
||||
applicationEdit: boolean;
|
||||
// Search bar
|
||||
/**
|
||||
* Whether to show the search bar.
|
||||
*/
|
||||
search: boolean;
|
||||
};
|
||||
navbar: {
|
||||
@ -38,68 +50,77 @@ export interface UIConfig {
|
||||
cardBackground: string;
|
||||
};
|
||||
pagination: {
|
||||
/**
|
||||
* Number of items to show per page in paginated lists.
|
||||
*/
|
||||
perPage: number;
|
||||
};
|
||||
layout: {
|
||||
/**
|
||||
* Layout type to use for the application.
|
||||
*/
|
||||
type: LayoutType;
|
||||
};
|
||||
/**
|
||||
* Locale to use for the application.
|
||||
*/
|
||||
locale: string;
|
||||
/**
|
||||
* Default values.
|
||||
*/
|
||||
defaults: {
|
||||
/**
|
||||
* Default path to use for user API calls.
|
||||
*/
|
||||
userPath: string;
|
||||
};
|
||||
}
|
||||
|
||||
export class DefaultUIConfig implements UIConfig {
|
||||
enabledFeatures = {
|
||||
apiDrawer: true,
|
||||
notificationDrawer: true,
|
||||
settings: true,
|
||||
applicationEdit: true,
|
||||
search: true,
|
||||
};
|
||||
layout = {
|
||||
type: LayoutType.row,
|
||||
};
|
||||
navbar = {
|
||||
userDisplay: UserDisplay.username,
|
||||
};
|
||||
theme = {
|
||||
base: UiThemeEnum.Automatic,
|
||||
background: "",
|
||||
cardBackground: "",
|
||||
};
|
||||
pagination = {
|
||||
perPage: 20,
|
||||
};
|
||||
locale = "";
|
||||
defaults = {
|
||||
userPath: "users",
|
||||
export function createUIConfig(overrides: Partial<UIConfig> = {}): UIConfig {
|
||||
const uiConfig: UIConfig = {
|
||||
enabledFeatures: {
|
||||
// TODO: Is the intent that only user routes should have the API drawer disabled,
|
||||
// or only admin routes?
|
||||
apiDrawer: !isUserRoute(),
|
||||
notificationDrawer: true,
|
||||
settings: true,
|
||||
applicationEdit: true,
|
||||
search: true,
|
||||
},
|
||||
layout: {
|
||||
type: LayoutType.row,
|
||||
},
|
||||
navbar: {
|
||||
userDisplay: UserDisplay.username,
|
||||
},
|
||||
theme: {
|
||||
base: UiThemeEnum.Automatic,
|
||||
background: "",
|
||||
cardBackground: "",
|
||||
},
|
||||
pagination: {
|
||||
perPage: 20,
|
||||
},
|
||||
locale: "",
|
||||
defaults: {
|
||||
userPath: "users",
|
||||
},
|
||||
};
|
||||
|
||||
constructor() {
|
||||
if (currentInterface() === "user") {
|
||||
this.enabledFeatures.apiDrawer = false;
|
||||
}
|
||||
}
|
||||
// TODO: Should we deep merge the overrides instead of shallow?
|
||||
Object.assign(uiConfig, overrides);
|
||||
|
||||
return uiConfig;
|
||||
}
|
||||
|
||||
let globalUiConfig: Promise<UIConfig>;
|
||||
|
||||
export function getConfigForUser(user: UserSelf): UIConfig {
|
||||
const settings = user.settings;
|
||||
let config = new DefaultUIConfig();
|
||||
if (!settings) {
|
||||
return config;
|
||||
}
|
||||
config = Object.assign(new DefaultUIConfig(), settings);
|
||||
return config;
|
||||
}
|
||||
let cachedUIConfig: UIConfig | null = null;
|
||||
|
||||
export function uiConfig(): Promise<UIConfig> {
|
||||
if (!globalUiConfig) {
|
||||
globalUiConfig = me().then((user) => {
|
||||
return getConfigForUser(user.user);
|
||||
});
|
||||
}
|
||||
return globalUiConfig;
|
||||
if (cachedUIConfig) return Promise.resolve(cachedUIConfig);
|
||||
|
||||
return me().then((session) => {
|
||||
cachedUIConfig = createUIConfig(session.user.settings);
|
||||
|
||||
return cachedUIConfig;
|
||||
});
|
||||
}
|
||||
|
@ -2,35 +2,6 @@ import { SentryIgnoredError } from "@goauthentik/common/errors";
|
||||
|
||||
import { CSSResult, css } from "lit";
|
||||
|
||||
export function getCookie(name: string): string {
|
||||
let cookieValue = "";
|
||||
if (document.cookie && document.cookie !== "") {
|
||||
const cookies = document.cookie.split(";");
|
||||
for (let i = 0; i < cookies.length; i++) {
|
||||
const cookie = cookies[i].trim();
|
||||
// Does this cookie string begin with the name we want?
|
||||
if (cookie.substring(0, name.length + 1) === name + "=") {
|
||||
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return cookieValue;
|
||||
}
|
||||
|
||||
export function convertToSlug(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/ /g, "-")
|
||||
.replace(/[^\w-]+/g, "");
|
||||
}
|
||||
|
||||
export function isSlug(text: string): boolean {
|
||||
const lowered = text.toLowerCase();
|
||||
const forbidden = /([^\w-]|\s)/.test(lowered);
|
||||
return lowered === text && !forbidden;
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate a string based on maximum word count
|
||||
*/
|
||||
@ -63,17 +34,29 @@ export function snakeToCamel(key: string) {
|
||||
|
||||
export function groupBy<T>(objects: T[], callback: (obj: T) => string): Array<[string, T[]]> {
|
||||
const m = new Map<string, T[]>();
|
||||
|
||||
objects.forEach((obj) => {
|
||||
const group = callback(obj);
|
||||
if (!m.has(group)) {
|
||||
m.set(group, []);
|
||||
}
|
||||
|
||||
const tProviders = m.get(group) || [];
|
||||
tProviders.push(obj);
|
||||
});
|
||||
|
||||
return Array.from(m).sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the first non-null and non-undefined argument.
|
||||
*
|
||||
* @deprecated Use nullish coalescing operator (??) instead.
|
||||
* @remarks
|
||||
*
|
||||
* This needs a deeper look. Some instances of this function use `new Date()`
|
||||
* which may cause issues during rendering.
|
||||
*/
|
||||
export function first<T>(...args: Array<T | undefined | null>): T {
|
||||
for (let index = 0; index < args.length; index++) {
|
||||
const element = args[index];
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { convertToSlug } from "@goauthentik/common/utils";
|
||||
import { formatAsSlug } from "@goauthentik/elements/router";
|
||||
|
||||
import { html } from "lit";
|
||||
import { customElement, property, query } from "lit/decorators.js";
|
||||
@ -34,7 +34,7 @@ export class AkSlugInput extends HorizontalLightComponent<string> {
|
||||
// Do not stop propagation of this event; it must be sent up the tree so that a parent
|
||||
// component, such as a custom forms manager, may receive it.
|
||||
handleTouch(ev: Event) {
|
||||
this.input.value = convertToSlug(this.input.value);
|
||||
this.input.value = formatAsSlug(this.input.value);
|
||||
this.value = this.input.value;
|
||||
|
||||
if (this.origin && this.origin.value === "" && this.input.value === "") {
|
||||
@ -67,7 +67,7 @@ export class AkSlugInput extends HorizontalLightComponent<string> {
|
||||
// "any event which adds or removes a character but leaves the rest of the slug looking like
|
||||
// the previous iteration, set it to the current iteration."
|
||||
|
||||
const newSlug = convertToSlug(ev.target.value);
|
||||
const newSlug = formatAsSlug(ev.target.value);
|
||||
const oldSlug = this.input.value;
|
||||
const [shorter, longer] =
|
||||
newSlug.length < oldSlug.length ? [newSlug, oldSlug] : [oldSlug, newSlug];
|
||||
|
169
web/src/development/build-observer.ts
Normal file
169
web/src/development/build-observer.ts
Normal file
@ -0,0 +1,169 @@
|
||||
/**
|
||||
* @file
|
||||
* Client-side observer for ESBuild events.
|
||||
*/
|
||||
import type { Message as ESBuildMessage } from "esbuild";
|
||||
|
||||
const logPrefix = "👷 [ESBuild]";
|
||||
const log = console.debug.bind(console, logPrefix);
|
||||
|
||||
type BuildEventListener<Data = unknown> = (event: MessageEvent<Data>) => void;
|
||||
|
||||
/**
|
||||
* A client-side watcher for ESBuild.
|
||||
*
|
||||
* Note that this should be conditionally imported in your code, so that
|
||||
* ESBuild may tree-shake it out of production builds.
|
||||
*
|
||||
* ```ts
|
||||
* if (process.env.NODE_ENV === "development" && process.env.WATCHER_URL) {
|
||||
* const { ESBuildObserver } = await import("@goauthentik/common/development/build-observer");
|
||||
*
|
||||
* new ESBuildObserver(process.env.WATCHER_URL);
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
*/
|
||||
export class ESBuildObserver extends EventSource {
|
||||
/**
|
||||
* Whether the watcher has a recent connection to the server.
|
||||
*/
|
||||
alive = true;
|
||||
|
||||
/**
|
||||
* The number of errors that have occurred since the watcher started.
|
||||
*/
|
||||
errorCount = 0;
|
||||
|
||||
/**
|
||||
* Whether a reload has been requested while offline.
|
||||
*/
|
||||
deferredReload = false;
|
||||
|
||||
/**
|
||||
* The last time a message was received from the server.
|
||||
*/
|
||||
lastUpdatedAt = Date.now();
|
||||
|
||||
/**
|
||||
* Whether the browser considers itself online.
|
||||
*/
|
||||
online = true;
|
||||
|
||||
/**
|
||||
* The ID of the animation frame for the reload.
|
||||
*/
|
||||
#reloadFrameID = -1;
|
||||
|
||||
/**
|
||||
* The interval for the keep-alive check.
|
||||
*/
|
||||
#keepAliveInterval: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
#trackActivity = () => {
|
||||
this.lastUpdatedAt = Date.now();
|
||||
this.alive = true;
|
||||
};
|
||||
|
||||
#startListener: BuildEventListener = () => {
|
||||
this.#trackActivity();
|
||||
log("⏰ Build started...");
|
||||
};
|
||||
|
||||
#internalErrorListener = () => {
|
||||
this.errorCount += 1;
|
||||
|
||||
if (this.errorCount > 100) {
|
||||
clearTimeout(this.#keepAliveInterval);
|
||||
|
||||
this.close();
|
||||
log("⛔️ Closing connection");
|
||||
}
|
||||
};
|
||||
|
||||
#errorListener: BuildEventListener<string> = (event) => {
|
||||
this.#trackActivity();
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.group(logPrefix, "⛔️⛔️⛔️ Build error...");
|
||||
|
||||
const esbuildErrorMessages: ESBuildMessage[] = JSON.parse(event.data);
|
||||
|
||||
for (const error of esbuildErrorMessages) {
|
||||
console.warn(error.text);
|
||||
|
||||
if (error.location) {
|
||||
console.debug(
|
||||
`file://${error.location.file}:${error.location.line}:${error.location.column}`,
|
||||
);
|
||||
console.debug(error.location.lineText);
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.groupEnd();
|
||||
};
|
||||
|
||||
#endListener: BuildEventListener = () => {
|
||||
cancelAnimationFrame(this.#reloadFrameID);
|
||||
|
||||
this.#trackActivity();
|
||||
|
||||
if (!this.online) {
|
||||
log("🚫 Build finished while offline.");
|
||||
this.deferredReload = true;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
log("🛎️ Build completed! Reloading...");
|
||||
|
||||
// We use an animation frame to keep the reload from happening before the
|
||||
// event loop has a chance to process the message.
|
||||
this.#reloadFrameID = requestAnimationFrame(() => {
|
||||
window.location.reload();
|
||||
});
|
||||
};
|
||||
|
||||
#keepAliveListener: BuildEventListener = () => {
|
||||
this.#trackActivity();
|
||||
log("🏓 Keep-alive");
|
||||
};
|
||||
|
||||
constructor(url: string | URL) {
|
||||
super(url);
|
||||
|
||||
this.addEventListener("esbuild:start", this.#startListener);
|
||||
this.addEventListener("esbuild:end", this.#endListener);
|
||||
this.addEventListener("esbuild:error", this.#errorListener);
|
||||
this.addEventListener("esbuild:keep-alive", this.#keepAliveListener);
|
||||
|
||||
this.addEventListener("error", this.#internalErrorListener);
|
||||
|
||||
window.addEventListener("offline", () => {
|
||||
this.online = false;
|
||||
});
|
||||
|
||||
window.addEventListener("online", () => {
|
||||
this.online = true;
|
||||
|
||||
if (!this.deferredReload) return;
|
||||
|
||||
log("🛎️ Reloading after offline build...");
|
||||
this.deferredReload = false;
|
||||
|
||||
window.location.reload();
|
||||
});
|
||||
|
||||
log("🛎️ Listening for build changes...");
|
||||
|
||||
this.#keepAliveInterval = setInterval(() => {
|
||||
const now = Date.now();
|
||||
|
||||
if (now - this.lastUpdatedAt < 10_000) return;
|
||||
|
||||
this.alive = false;
|
||||
log("👋 Waiting for build to start...");
|
||||
}, 15_000);
|
||||
}
|
||||
}
|
@ -1,10 +1,6 @@
|
||||
import {
|
||||
EVENT_SIDEBAR_TOGGLE,
|
||||
EVENT_WS_MESSAGE,
|
||||
TITLE_DEFAULT,
|
||||
} from "@goauthentik/common/constants";
|
||||
import { formatPageTitle } from "@goauthentik/common/client";
|
||||
import { EVENT_SIDEBAR_TOGGLE, EVENT_WS_MESSAGE } from "@goauthentik/common/constants";
|
||||
import { globalAK } from "@goauthentik/common/global";
|
||||
import { currentInterface } from "@goauthentik/common/sentry";
|
||||
import { UIConfig, UserDisplay, uiConfig } from "@goauthentik/common/ui/config";
|
||||
import { me } from "@goauthentik/common/users";
|
||||
import "@goauthentik/components/ak-nav-buttons";
|
||||
@ -125,17 +121,8 @@ export class PageHeader extends WithBrandConfig(AKElement) {
|
||||
this.uiConfig.navbar.userDisplay = UserDisplay.none;
|
||||
}
|
||||
|
||||
setTitle(header?: string) {
|
||||
const currentIf = currentInterface();
|
||||
let title = this.brand?.brandingTitle || TITLE_DEFAULT;
|
||||
if (currentIf === "admin") {
|
||||
title = `${msg("Admin")} - ${title}`;
|
||||
}
|
||||
// Prepend the header to the title
|
||||
if (header !== undefined && header !== "") {
|
||||
title = `${header} - ${title}`;
|
||||
}
|
||||
document.title = title;
|
||||
setTitle(pageTitle?: string) {
|
||||
document.title = formatPageTitle(this.brand, pageTitle);
|
||||
}
|
||||
|
||||
willUpdate() {
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { CURRENT_CLASS, EVENT_REFRESH, ROUTE_SEPARATOR } from "@goauthentik/common/constants";
|
||||
import { CURRENT_CLASS, EVENT_REFRESH } from "@goauthentik/common/constants";
|
||||
import { AKElement } from "@goauthentik/elements/Base";
|
||||
import { getURLParams, updateURLParams } from "@goauthentik/elements/router/RouteMatch";
|
||||
import { ROUTE_SEPARATOR } from "@goauthentik/elements/router";
|
||||
import { getRouteParams, patchRouteParams } from "@goauthentik/elements/router/utils";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
import { CSSResult, TemplateResult, css, html } from "lit";
|
||||
@ -10,6 +11,8 @@ import { ifDefined } from "lit/directives/if-defined.js";
|
||||
import PFTabs from "@patternfly/patternfly/components/Tabs/tabs.css";
|
||||
import PFGlobal from "@patternfly/patternfly/patternfly-base.css";
|
||||
|
||||
const SLOT_PREFIX = "page-";
|
||||
|
||||
@customElement("ak-tabs")
|
||||
export class Tabs extends AKElement {
|
||||
@property()
|
||||
@ -18,6 +21,14 @@ export class Tabs extends AKElement {
|
||||
@property()
|
||||
currentPage?: string;
|
||||
|
||||
get currentPageParamName(): string | null {
|
||||
if (!this.currentPage) return null;
|
||||
|
||||
return this.currentPage.startsWith(SLOT_PREFIX)
|
||||
? this.currentPage.slice(SLOT_PREFIX.length)
|
||||
: this.currentPage;
|
||||
}
|
||||
|
||||
@property({ type: Boolean })
|
||||
vertical = false;
|
||||
|
||||
@ -68,13 +79,30 @@ export class Tabs extends AKElement {
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
onClick(slot?: string): void {
|
||||
this.currentPage = slot;
|
||||
const params: { [key: string]: string | undefined } = {};
|
||||
params[this.pageIdentifier] = slot;
|
||||
updateURLParams(params);
|
||||
/**
|
||||
* Sync route params with the current page.
|
||||
*
|
||||
* @todo This should be moved to a router component.
|
||||
*/
|
||||
#syncRouteParams(): void {
|
||||
const { currentPageParamName } = this;
|
||||
|
||||
if (!currentPageParamName) return;
|
||||
|
||||
patchRouteParams({
|
||||
[this.pageIdentifier]: currentPageParamName,
|
||||
});
|
||||
}
|
||||
|
||||
activatePage(nextPage?: string): void {
|
||||
this.currentPage = nextPage;
|
||||
|
||||
this.#syncRouteParams();
|
||||
|
||||
const page = this.querySelector(`[slot='${this.currentPage}']`);
|
||||
|
||||
if (!page) return;
|
||||
|
||||
page.dispatchEvent(new CustomEvent(EVENT_REFRESH));
|
||||
page.dispatchEvent(new CustomEvent("activate"));
|
||||
}
|
||||
@ -82,7 +110,7 @@ export class Tabs extends AKElement {
|
||||
renderTab(page: Element): TemplateResult {
|
||||
const slot = page.attributes.getNamedItem("slot")?.value;
|
||||
return html` <li class="pf-c-tabs__item ${slot === this.currentPage ? CURRENT_CLASS : ""}">
|
||||
<button class="pf-c-tabs__link" @click=${() => this.onClick(slot)}>
|
||||
<button class="pf-c-tabs__link" @click=${() => this.activatePage(slot)}>
|
||||
<span class="pf-c-tabs__item-text"> ${page.getAttribute("data-tab-title")} </span>
|
||||
</button>
|
||||
</li>`;
|
||||
@ -90,24 +118,41 @@ export class Tabs extends AKElement {
|
||||
|
||||
render(): TemplateResult {
|
||||
const pages = Array.from(this.querySelectorAll(":scope > [slot^='page-']"));
|
||||
|
||||
if (window.location.hash.includes(ROUTE_SEPARATOR)) {
|
||||
const params = getURLParams();
|
||||
const params = getRouteParams();
|
||||
|
||||
const slotName = params[this.pageIdentifier];
|
||||
|
||||
if (
|
||||
this.pageIdentifier in params &&
|
||||
slotName &&
|
||||
typeof slotName === "string" &&
|
||||
!this.currentPage &&
|
||||
this.querySelector(`[slot='${params[this.pageIdentifier]}']`) !== null
|
||||
this.querySelector(`[slot='${slotName}']`) !== null
|
||||
) {
|
||||
// To update the URL to match with the current slot
|
||||
this.onClick(params[this.pageIdentifier] as string);
|
||||
console.debug(
|
||||
`authentik/tabs (${this.pageIdentifier}): setting current page to`,
|
||||
slotName,
|
||||
);
|
||||
|
||||
this.activatePage(slotName);
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.currentPage) {
|
||||
if (pages.length < 1) {
|
||||
return html`<h1>${msg("no tabs defined")}</h1>`;
|
||||
}
|
||||
|
||||
const wantedPage = pages[0].attributes.getNamedItem("slot")?.value;
|
||||
this.onClick(wantedPage);
|
||||
|
||||
console.debug(
|
||||
`authentik/tabs (${this.pageIdentifier}): setting current page to`,
|
||||
wantedPage,
|
||||
);
|
||||
this.activatePage(wantedPage);
|
||||
}
|
||||
|
||||
return html`<div class="pf-c-tabs ${this.vertical ? "pf-m-vertical pf-m-box" : ""}">
|
||||
<ul class="pf-c-tabs__list">
|
||||
${pages.map((page) => this.renderTab(page))}
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { EVENT_REFRESH } from "@goauthentik/common/constants";
|
||||
import { AKElement } from "@goauthentik/elements/Base";
|
||||
import { setURLParams } from "@goauthentik/elements/router/RouteMatch";
|
||||
import { setRouteParams } from "@goauthentik/elements/router/utils";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
import { CSSResult, TemplateResult, html } from "lit";
|
||||
@ -84,7 +84,7 @@ export class TreeViewNode extends AKElement {
|
||||
if (this.host) {
|
||||
this.host.activeNode = this;
|
||||
}
|
||||
setURLParams({ path: this.fullPath });
|
||||
setRouteParams({ path: this.fullPath });
|
||||
this.dispatchEvent(
|
||||
new CustomEvent(EVENT_REFRESH, {
|
||||
bubbles: true,
|
||||
|
@ -1,11 +1,13 @@
|
||||
import { EVENT_REFRESH } from "@goauthentik/common/constants";
|
||||
import { parseAPIError } from "@goauthentik/common/errors";
|
||||
import { MessageLevel } from "@goauthentik/common/messages";
|
||||
import { camelToSnake, convertToSlug, dateToUTC } from "@goauthentik/common/utils";
|
||||
import { dateToUTC } from "@goauthentik/common/utils";
|
||||
import { camelToSnake } from "@goauthentik/common/utils";
|
||||
import { AKElement } from "@goauthentik/elements/Base";
|
||||
import { HorizontalFormElement } from "@goauthentik/elements/forms/HorizontalFormElement";
|
||||
import { PreventFormSubmit } from "@goauthentik/elements/forms/helpers";
|
||||
import { showMessage } from "@goauthentik/elements/messages/MessageContainer";
|
||||
import { formatAsSlug } from "@goauthentik/elements/router/slugs";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
import { CSSResult, TemplateResult, css, html } from "lit";
|
||||
@ -223,11 +225,11 @@ export abstract class Form<T> extends AKElement {
|
||||
// Only attach handler if the slug is already equal to the name
|
||||
// if not, they are probably completely different and shouldn't update
|
||||
// each other
|
||||
if (convertToSlug(input.value) !== slugField.value) {
|
||||
if (formatAsSlug(input.value) !== slugField.value) {
|
||||
return;
|
||||
}
|
||||
nameInput.addEventListener("input", () => {
|
||||
slugField.value = convertToSlug(input.value);
|
||||
slugField.value = formatAsSlug(input.value);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { convertToSlug } from "@goauthentik/common/utils";
|
||||
import { AKElement } from "@goauthentik/elements/Base";
|
||||
import { FormGroup } from "@goauthentik/elements/forms/FormGroup";
|
||||
import { formatAsSlug } from "@goauthentik/elements/router";
|
||||
|
||||
import { msg, str } from "@lit/localize";
|
||||
import { CSSResult, css } from "lit";
|
||||
@ -123,7 +123,7 @@ export class HorizontalFormElement extends AKElement {
|
||||
if (this.name === "slug" || this.slugMode) {
|
||||
this.querySelectorAll<HTMLInputElement>("input[type='text']").forEach((input) => {
|
||||
input.addEventListener("keyup", () => {
|
||||
input.value = convertToSlug(input.value);
|
||||
input.value = formatAsSlug(input.value);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
@ -1,66 +1,60 @@
|
||||
import "@goauthentik/elements/EmptyState";
|
||||
import { SlottedTemplateResult } from "@goauthentik/elements/types";
|
||||
|
||||
import { TemplateResult, html } from "lit";
|
||||
import { TemplateResult, html, nothing } from "lit";
|
||||
import { until } from "lit/directives/until.js";
|
||||
|
||||
export const SLUG_REGEX = "[-a-zA-Z0-9_]+";
|
||||
export const ID_REGEX = "\\d+";
|
||||
export const UUID_REGEX = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}";
|
||||
export type PrimitiveRouteParameter = string | number | boolean | null | undefined;
|
||||
export type RouteParameterRecord = { [key: string]: PrimitiveRouteParameter };
|
||||
|
||||
export interface RouteArgs {
|
||||
[key: string]: string;
|
||||
}
|
||||
export type RouteCallback<P = unknown> = (
|
||||
params: P,
|
||||
) => SlottedTemplateResult | Promise<SlottedTemplateResult>;
|
||||
|
||||
export class Route {
|
||||
url: RegExp;
|
||||
export type RouteInitTuple = [string | RegExp, RouteCallback | undefined];
|
||||
|
||||
private element?: TemplateResult;
|
||||
private callback?: (args: RouteArgs) => Promise<TemplateResult>;
|
||||
export class Route<P = unknown> {
|
||||
public readonly pattern: URLPattern;
|
||||
|
||||
constructor(url: RegExp, callback?: (args: RouteArgs) => Promise<TemplateResult>) {
|
||||
this.url = url;
|
||||
this.callback = callback;
|
||||
#callback: RouteCallback<P>;
|
||||
|
||||
constructor(patternInit: URLPatternInit | string, callback: RouteCallback<P>) {
|
||||
this.pattern = new URLPattern(
|
||||
typeof patternInit === "string"
|
||||
? {
|
||||
pathname: patternInit,
|
||||
}
|
||||
: patternInit,
|
||||
);
|
||||
|
||||
this.#callback = callback;
|
||||
}
|
||||
|
||||
redirect(to: string, raw = false): Route {
|
||||
this.callback = async () => {
|
||||
/**
|
||||
* Create a new redirect route.
|
||||
*
|
||||
* @param patternInit The pattern to match.
|
||||
* @param to The URL to redirect to.
|
||||
* @param raw Whether to use the raw URL or not.
|
||||
*/
|
||||
static redirect(patternInit: URLPatternInit | string, to: string, raw = false): Route<unknown> {
|
||||
return new Route(patternInit, () => {
|
||||
console.debug(`authentik/router: redirecting ${to}`);
|
||||
|
||||
if (!raw) {
|
||||
window.location.hash = `#${to}`;
|
||||
} else {
|
||||
window.location.hash = to;
|
||||
}
|
||||
return html``;
|
||||
};
|
||||
return this;
|
||||
|
||||
return nothing;
|
||||
});
|
||||
}
|
||||
|
||||
then(render: (args: RouteArgs) => TemplateResult): Route {
|
||||
this.callback = async (args) => {
|
||||
return render(args);
|
||||
};
|
||||
return this;
|
||||
}
|
||||
|
||||
thenAsync(render: (args: RouteArgs) => Promise<TemplateResult>): Route {
|
||||
this.callback = render;
|
||||
return this;
|
||||
}
|
||||
|
||||
render(args: RouteArgs): TemplateResult {
|
||||
if (this.callback) {
|
||||
return html`${until(
|
||||
this.callback(args),
|
||||
html`<ak-empty-state ?loading=${true}></ak-empty-state>`,
|
||||
)}`;
|
||||
}
|
||||
if (this.element) {
|
||||
return this.element;
|
||||
}
|
||||
throw new Error("Route does not have callback or element");
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return `<Route url=${this.url} callback=${this.callback ? "true" : "false"}>`;
|
||||
render(params: P): TemplateResult {
|
||||
return html`${until(
|
||||
this.#callback(params),
|
||||
html`<ak-empty-state ?loading=${true}></ak-empty-state>`,
|
||||
)}`;
|
||||
}
|
||||
}
|
||||
|
@ -1,66 +0,0 @@
|
||||
import { ROUTE_SEPARATOR } from "@goauthentik/common/constants";
|
||||
import { Route } from "@goauthentik/elements/router/Route";
|
||||
|
||||
import { TemplateResult } from "lit";
|
||||
|
||||
export class RouteMatch {
|
||||
route: Route;
|
||||
arguments: { [key: string]: string };
|
||||
fullUrl?: string;
|
||||
|
||||
constructor(route: Route) {
|
||||
this.route = route;
|
||||
this.arguments = {};
|
||||
}
|
||||
|
||||
render(): TemplateResult {
|
||||
return this.route.render(this.arguments);
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return `<RouteMatch url=${this.fullUrl} route=${this.route} arguments=${JSON.stringify(
|
||||
this.arguments,
|
||||
)}>`;
|
||||
}
|
||||
}
|
||||
|
||||
export function getURLParam<T>(key: string, fallback: T): T {
|
||||
const params = getURLParams();
|
||||
if (key in params) {
|
||||
return params[key] as T;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function getURLParams(): { [key: string]: unknown } {
|
||||
const params = {};
|
||||
if (window.location.hash.includes(ROUTE_SEPARATOR)) {
|
||||
const urlParts = window.location.hash.slice(1, Infinity).split(ROUTE_SEPARATOR, 2);
|
||||
const rawParams = decodeURIComponent(urlParts[1]);
|
||||
try {
|
||||
return JSON.parse(rawParams);
|
||||
} catch {
|
||||
return params;
|
||||
}
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
export function setURLParams(params: { [key: string]: unknown }, replace = true): void {
|
||||
const paramsString = JSON.stringify(params);
|
||||
const currentUrl = window.location.hash.slice(1, Infinity).split(ROUTE_SEPARATOR)[0];
|
||||
const newUrl = `#${currentUrl};${encodeURIComponent(paramsString)}`;
|
||||
if (replace) {
|
||||
history.replaceState(undefined, "", newUrl);
|
||||
} else {
|
||||
history.pushState(undefined, "", newUrl);
|
||||
}
|
||||
}
|
||||
|
||||
export function updateURLParams(params: { [key: string]: unknown }, replace = true): void {
|
||||
const currentParams = getURLParams();
|
||||
for (const key in params) {
|
||||
currentParams[key] = params[key] as string;
|
||||
}
|
||||
setURLParams(currentParams, replace);
|
||||
}
|
@ -11,7 +11,7 @@ import PFBase from "@patternfly/patternfly/patternfly-base.css";
|
||||
@customElement("ak-router-404")
|
||||
export class Router404 extends AKElement {
|
||||
@property()
|
||||
url = "";
|
||||
pathname = "";
|
||||
|
||||
static get styles(): CSSResult[] {
|
||||
return [PFBase, PFEmptyState, PFTitle];
|
||||
@ -23,7 +23,7 @@ export class Router404 extends AKElement {
|
||||
<i class="fas fa-question-circle pf-c-empty-state__icon" aria-hidden="true"></i>
|
||||
<h1 class="pf-c-title pf-m-lg">${msg("Not found")}</h1>
|
||||
<div class="pf-c-empty-state__body">
|
||||
${msg(str`The URL "${this.url}" was not found.`)}
|
||||
${msg(str`The URL "${this.pathname}" was not found.`)}
|
||||
</div>
|
||||
<a href="#/" class="pf-c-button pf-m-primary" type="button"
|
||||
>${msg("Return home")}</a
|
||||
|
@ -1,57 +1,47 @@
|
||||
import { ROUTE_SEPARATOR } from "@goauthentik/common/constants";
|
||||
import { AKElement } from "@goauthentik/elements/Base";
|
||||
import { Route } from "@goauthentik/elements/router/Route";
|
||||
import { RouteMatch } from "@goauthentik/elements/router/RouteMatch";
|
||||
import "@goauthentik/elements/router/Router404";
|
||||
import { matchRoute, pluckRoute } from "@goauthentik/elements/router/utils";
|
||||
|
||||
import { CSSResult, TemplateResult, css, html } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
|
||||
// Poliyfill for hashchange.newURL,
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/WindowEventHandlers/onhashchange
|
||||
window.addEventListener("load", () => {
|
||||
if (!window.HashChangeEvent)
|
||||
(function () {
|
||||
let lastURL = document.URL;
|
||||
window.addEventListener("hashchange", function (event) {
|
||||
Object.defineProperty(event, "oldURL", {
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
value: lastURL,
|
||||
});
|
||||
Object.defineProperty(event, "newURL", {
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
value: document.URL,
|
||||
});
|
||||
lastURL = document.URL;
|
||||
});
|
||||
})();
|
||||
});
|
||||
if (window.HashChangeEvent) return;
|
||||
|
||||
export function paramURL(url: string, params?: { [key: string]: unknown }): string {
|
||||
let finalUrl = "#";
|
||||
finalUrl += url;
|
||||
if (params) {
|
||||
finalUrl += ";";
|
||||
finalUrl += encodeURIComponent(JSON.stringify(params));
|
||||
}
|
||||
return finalUrl;
|
||||
}
|
||||
export function navigate(url: string, params?: { [key: string]: unknown }): void {
|
||||
window.location.assign(paramURL(url, params));
|
||||
}
|
||||
console.debug("authentik/router: polyfilling hashchange event");
|
||||
|
||||
let lastURL = document.URL;
|
||||
|
||||
window.addEventListener("hashchange", function (event) {
|
||||
Object.defineProperty(event, "oldURL", {
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
value: lastURL,
|
||||
});
|
||||
|
||||
Object.defineProperty(event, "newURL", {
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
value: document.URL,
|
||||
});
|
||||
|
||||
lastURL = document.URL;
|
||||
});
|
||||
});
|
||||
|
||||
@customElement("ak-router-outlet")
|
||||
export class RouterOutlet extends AKElement {
|
||||
@property({ attribute: false })
|
||||
current?: RouteMatch;
|
||||
@state()
|
||||
private currentPathname: string | null = null;
|
||||
|
||||
@property()
|
||||
defaultUrl?: string;
|
||||
public defaultURL?: string;
|
||||
|
||||
@property({ attribute: false })
|
||||
routes: Route[] = [];
|
||||
public routes: Route[] = [];
|
||||
|
||||
static get styles(): CSSResult[] {
|
||||
return [
|
||||
@ -59,6 +49,7 @@ export class RouterOutlet extends AKElement {
|
||||
:host {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
*:first-child {
|
||||
flex-direction: column;
|
||||
}
|
||||
@ -66,56 +57,78 @@ export class RouterOutlet extends AKElement {
|
||||
];
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
window.addEventListener("hashchange", (ev: HashChangeEvent) => this.navigate(ev));
|
||||
connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
|
||||
window.addEventListener("hashchange", this.#refreshLocation);
|
||||
}
|
||||
|
||||
firstUpdated(): void {
|
||||
this.navigate();
|
||||
disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
|
||||
window.removeEventListener("hashchange", this.#refreshLocation);
|
||||
}
|
||||
|
||||
navigate(ev?: HashChangeEvent): void {
|
||||
let activeUrl = window.location.hash.slice(1, Infinity).split(ROUTE_SEPARATOR)[0];
|
||||
if (ev) {
|
||||
// Check if we've actually changed paths
|
||||
const oldPath = new URL(ev.oldURL).hash.slice(1, Infinity).split(ROUTE_SEPARATOR)[0];
|
||||
if (oldPath === activeUrl) return;
|
||||
}
|
||||
if (activeUrl === "") {
|
||||
activeUrl = this.defaultUrl || "/";
|
||||
window.location.hash = `#${activeUrl}`;
|
||||
console.debug(`authentik/router: defaulted URL to ${window.location.hash}`);
|
||||
protected firstUpdated(): void {
|
||||
const currentPathname = pluckRoute(window.location).pathname;
|
||||
|
||||
if (currentPathname) return;
|
||||
|
||||
console.debug("authentik/router: defaulted route to empty pathname");
|
||||
|
||||
this.#redirectToDefault();
|
||||
}
|
||||
|
||||
#redirectToDefault(): void {
|
||||
const nextPathname = this.defaultURL || "/";
|
||||
|
||||
window.location.hash = "#" + nextPathname;
|
||||
}
|
||||
|
||||
#refreshLocation = (event: HashChangeEvent): void => {
|
||||
console.debug("authentik/router: hashchange event", event);
|
||||
const nextPathname = pluckRoute(event.newURL).pathname;
|
||||
const previousPathname = pluckRoute(event.oldURL).pathname;
|
||||
|
||||
if (previousPathname === nextPathname) {
|
||||
console.debug("authentik/router: hashchange event, but no change in path", event, {
|
||||
currentPathname: nextPathname,
|
||||
previousPathname,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
let matchedRoute: RouteMatch | null = null;
|
||||
this.routes.some((route) => {
|
||||
const match = route.url.exec(activeUrl);
|
||||
if (match !== null) {
|
||||
matchedRoute = new RouteMatch(route);
|
||||
matchedRoute.arguments = match.groups || {};
|
||||
matchedRoute.fullUrl = activeUrl;
|
||||
console.debug("authentik/router: found match ", matchedRoute);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
if (!matchedRoute) {
|
||||
console.debug(`authentik/router: route "${activeUrl}" not defined`);
|
||||
const route = new Route(RegExp(""), async () => {
|
||||
return html`<div class="pf-c-page__main">
|
||||
<ak-router-404 url=${activeUrl}></ak-router-404>
|
||||
</div>`;
|
||||
});
|
||||
matchedRoute = new RouteMatch(route);
|
||||
matchedRoute.arguments = route.url.exec(activeUrl)?.groups || {};
|
||||
matchedRoute.fullUrl = activeUrl;
|
||||
|
||||
if (!nextPathname) {
|
||||
console.debug(`authentik/router: defaulted route to ${nextPathname}`);
|
||||
|
||||
this.#redirectToDefault();
|
||||
return;
|
||||
}
|
||||
this.current = matchedRoute;
|
||||
}
|
||||
|
||||
this.currentPathname = nextPathname;
|
||||
};
|
||||
|
||||
render(): TemplateResult | undefined {
|
||||
return this.current?.render();
|
||||
let currentPathname = this.currentPathname;
|
||||
|
||||
if (!currentPathname) {
|
||||
currentPathname = pluckRoute(window.location).pathname;
|
||||
}
|
||||
|
||||
const match = matchRoute(currentPathname, this.routes);
|
||||
|
||||
if (!match) {
|
||||
return html`<div class="pf-c-page__main">
|
||||
<ak-router-404 pathname=${currentPathname}></ak-router-404>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
console.debug("authentik/router: found match", match);
|
||||
|
||||
const { parameters, route } = match;
|
||||
|
||||
return route.render(parameters);
|
||||
}
|
||||
}
|
||||
|
||||
|
26
web/src/elements/router/constants.ts
Normal file
26
web/src/elements/router/constants.ts
Normal file
@ -0,0 +1,26 @@
|
||||
/**
|
||||
* @file Router constants.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Route separator, used to separate the path from the mock query string.
|
||||
*/
|
||||
export const ROUTE_SEPARATOR = "?";
|
||||
|
||||
/**
|
||||
* Slug pattern, matching alphanumeric characters, underscores, and hyphens.
|
||||
*/
|
||||
export const SLUG_PATTERN = "[a-zA-Z0-9_\\-]+";
|
||||
|
||||
/**
|
||||
* Numeric ID pattern, typically used for database IDs.
|
||||
*/
|
||||
export const ID_PATTERN = "\\d+";
|
||||
|
||||
/**
|
||||
* UUID v4 pattern
|
||||
*
|
||||
* @todo Enforcing this format on the front-end may be a bit too strict.
|
||||
* We may want to allow other UUID formats, or move this to a validation step.
|
||||
*/
|
||||
export const UUID_PATTERN = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}";
|
6
web/src/elements/router/index.ts
Normal file
6
web/src/elements/router/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export * from "./Route.js";
|
||||
export * from "./constants.js";
|
||||
export * from "./Router404.js";
|
||||
export * from "./RouterOutlet.js";
|
||||
export * from "./utils.js";
|
||||
export * from "./slugs.js";
|
23
web/src/elements/router/slugs.ts
Normal file
23
web/src/elements/router/slugs.ts
Normal file
@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Given a string, return a URL-friendly slug.
|
||||
*/
|
||||
export function formatAsSlug(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/ /g, "-")
|
||||
.replace(/[^\w-]+/g, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if a given string is a valid URL slug, i.e.
|
||||
* only containing alphanumeric characters, dashes, and underscores.
|
||||
*/
|
||||
export function isSlug(input: unknown): input is string {
|
||||
if (typeof input !== "string") return false;
|
||||
if (!input) return false;
|
||||
|
||||
const lowered = input.toLowerCase();
|
||||
if (input !== lowered) return false;
|
||||
|
||||
return /([^\w-]|\s)/.test(lowered);
|
||||
}
|
254
web/src/elements/router/utils.ts
Normal file
254
web/src/elements/router/utils.ts
Normal file
@ -0,0 +1,254 @@
|
||||
import { ROUTE_SEPARATOR } from "@goauthentik/elements/router";
|
||||
import type { Route, RouteParameterRecord } from "@goauthentik/elements/router/Route";
|
||||
|
||||
export interface RouteMatch<P extends RouteParameterRecord = RouteParameterRecord> {
|
||||
readonly route: Route<P>;
|
||||
readonly parameters: P;
|
||||
readonly pathname: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Match a route against a pathname.
|
||||
*/
|
||||
export function matchRoute<P extends RouteParameterRecord>(
|
||||
pathname: string,
|
||||
routes: Route<P>[],
|
||||
): RouteMatch<P> | null {
|
||||
if (!pathname) return null;
|
||||
|
||||
for (const route of routes) {
|
||||
const match = route.pattern.exec({ pathname });
|
||||
|
||||
if (!match) continue;
|
||||
|
||||
console.debug(
|
||||
`authentik/router: matched route ${route.pattern} to ${pathname} with params`,
|
||||
match.pathname.groups,
|
||||
);
|
||||
return {
|
||||
route: route as Route<P>,
|
||||
parameters: match.pathname.groups as P,
|
||||
pathname,
|
||||
};
|
||||
}
|
||||
|
||||
console.debug(`authentik/router: no route matched ${pathname}`);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to a route.
|
||||
*
|
||||
* @param {string} pathname The pathname of the route.
|
||||
* @param {RouteParameterRecord} params The parameters to serialize.
|
||||
*/
|
||||
export function navigate(pathname: string, params?: RouteParameterRecord): void {
|
||||
window.location.assign(formatRouteHash(pathname, params));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a route hash from a pathname and parameters.
|
||||
*
|
||||
* @param {string} pathname The pathname of the route.
|
||||
* @param {RouteParameterRecord} params The parameters to serialize.
|
||||
* @returns {string} The formatted route hash, starting with `#`.
|
||||
* @see {@linkcode navigate} to navigate to a route.
|
||||
*/
|
||||
export function formatRouteHash(pathname: string, params?: RouteParameterRecord): string {
|
||||
const routePrefix = "#" + pathname;
|
||||
|
||||
if (!params) return routePrefix;
|
||||
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (typeof value === "boolean" && value) {
|
||||
searchParams.set(key, "true");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof value === "undefined" || value === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
searchParams.append(key, item.toString());
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
searchParams.set(key, String(value));
|
||||
}
|
||||
|
||||
return [routePrefix, searchParams.toString()].join(ROUTE_SEPARATOR);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a route to an interface by name, optionally with parameters.
|
||||
*/
|
||||
export function formatInterfaceRoute(
|
||||
interfaceName: RouteInterfaceName,
|
||||
pathname?: string,
|
||||
params?: RouteParameterRecord,
|
||||
): string {
|
||||
const prefix = `/if/${interfaceName}/`;
|
||||
|
||||
if (!pathname) return prefix;
|
||||
|
||||
return prefix + formatRouteHash(pathname, params);
|
||||
}
|
||||
|
||||
export interface SerializedRoute {
|
||||
pathname: string;
|
||||
serializedParameters?: string;
|
||||
}
|
||||
|
||||
export function pluckRoute(source: Pick<URL, "hash"> | string = window.location): SerializedRoute {
|
||||
source = typeof source === "string" ? new URL(source) : source;
|
||||
|
||||
const [pathname, serializedParameters] = source.hash.slice(1).split(ROUTE_SEPARATOR, 2);
|
||||
|
||||
return {
|
||||
pathname,
|
||||
serializedParameters,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a parameter from the current route.
|
||||
*
|
||||
* @template T - The type of the parameter.
|
||||
* @param {string} paramName - The name of the parameter to retrieve.
|
||||
* @param {T} fallback - The fallback value to return if the parameter is not found.
|
||||
*/
|
||||
export function getRouteParameter<T>(paramName: string, fallback: T): T {
|
||||
const params = getRouteParams();
|
||||
|
||||
if (Object.hasOwn(params, paramName)) {
|
||||
return params[paramName] as T;
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the route parameters from the URL.
|
||||
*
|
||||
* @template T - The type of the route parameters.
|
||||
*/
|
||||
export function getRouteParams<T = RouteParameterRecord>(): T {
|
||||
const { serializedParameters } = pluckRoute();
|
||||
|
||||
if (!serializedParameters) return {} as T;
|
||||
|
||||
let searchParams: URLSearchParams;
|
||||
|
||||
try {
|
||||
searchParams = new URLSearchParams(serializedParameters);
|
||||
} catch (_error) {
|
||||
console.warn("Failed to parse URL parameters", serializedParameters);
|
||||
return {} as T;
|
||||
}
|
||||
|
||||
const decodedParameters: Record<string, unknown> = {};
|
||||
for (const [key, value] of searchParams.entries()) {
|
||||
if (value === "true" || value === "") {
|
||||
decodedParameters[key] = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (value === "false") {
|
||||
decodedParameters[key] = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
decodedParameters[key] = value;
|
||||
}
|
||||
|
||||
return decodedParameters as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the route parameters in the URL.
|
||||
*
|
||||
* @param nextParams - The JSON-serializable parameters to set in the URL.
|
||||
* @param replace - Whether to replace the current history entry or create a new one.
|
||||
*/
|
||||
export function setRouteParams(nextParams: RouteParameterRecord, replace = true): void {
|
||||
const { pathname } = pluckRoute();
|
||||
const nextHash = formatRouteHash(pathname, nextParams);
|
||||
|
||||
if (replace) {
|
||||
history.replaceState(undefined, "", nextHash);
|
||||
} else {
|
||||
history.pushState(undefined, "", nextHash);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Patch the route parameters in the URL, retaining existing parameters not specified in the input.
|
||||
*
|
||||
* @param patchedParams - The parameters to patch in the URL.
|
||||
* @param replace - Whether to replace the current history entry or create a new one.
|
||||
*
|
||||
* @todo Most instances of this should be URL search params, not hash params.
|
||||
*/
|
||||
export function patchRouteParams(patchedParams: RouteParameterRecord, replace = true): void {
|
||||
const currentParams = getRouteParams();
|
||||
const nextParams = { ...currentParams, ...patchedParams };
|
||||
|
||||
setRouteParams(nextParams, replace);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if a given input is parsable as a URL.
|
||||
*
|
||||
* ```js
|
||||
* isURLInput("https://example.com") // true
|
||||
* isURLInput("invalid-url") // false
|
||||
* isURLInput(new URL("https://example.com")) // true
|
||||
* ```
|
||||
*/
|
||||
export function isURLInput(input: unknown): input is string | URL {
|
||||
if (typeof input !== "string" && !(input instanceof URL)) return false;
|
||||
|
||||
if (!input) return false;
|
||||
|
||||
return URL.canParse(input);
|
||||
}
|
||||
|
||||
/**
|
||||
* The name identifier for the current interface.
|
||||
*/
|
||||
export type RouteInterfaceName = "user" | "admin" | "flow" | "unknown";
|
||||
|
||||
/**
|
||||
* Read the current interface route parameter from the URL.
|
||||
*
|
||||
* @param location - The location object to read the pathname from. Defaults to `window.location`.
|
||||
* * @returns The name of the current interface, or "unknown" if not found.
|
||||
*/
|
||||
export function readInterfaceRouteParam(
|
||||
location: Pick<URL, "pathname"> = window.location,
|
||||
): RouteInterfaceName {
|
||||
const [, currentInterface = "unknown"] = location.pathname.match(/.+if\/(\w+)\//) || [];
|
||||
|
||||
return currentInterface.toLowerCase() as RouteInterfaceName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Predicate to determine if the current route is for the admin interface.
|
||||
*/
|
||||
export function isAdminRoute(location: Pick<URL, "pathname"> = window.location): boolean {
|
||||
return readInterfaceRouteParam(location) === "admin";
|
||||
}
|
||||
|
||||
/**
|
||||
* Predicate to determine if the current route is for the user interface.
|
||||
*/
|
||||
export function isUserRoute(location: Pick<URL, "pathname"> = window.location): boolean {
|
||||
return readInterfaceRouteParam(location) === "user";
|
||||
}
|
@ -1,5 +1,5 @@
|
||||
import { ROUTE_SEPARATOR } from "@goauthentik/common/constants";
|
||||
import { AKElement } from "@goauthentik/elements/Base";
|
||||
import { pluckRoute } from "@goauthentik/elements/router";
|
||||
|
||||
import { CSSResult, css } from "lit";
|
||||
import { TemplateResult, html } from "lit";
|
||||
@ -69,9 +69,9 @@ export class SidebarItem extends AKElement {
|
||||
}
|
||||
|
||||
@property()
|
||||
path?: string;
|
||||
pathname?: string;
|
||||
|
||||
activeMatchers: RegExp[] = [];
|
||||
#activeMatchers: URLPattern[] = [];
|
||||
|
||||
@property({ type: Boolean })
|
||||
expanded = false;
|
||||
@ -94,41 +94,57 @@ export class SidebarItem extends AKElement {
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
set activeWhen(regexp: string[]) {
|
||||
regexp.forEach((r) => {
|
||||
this.activeMatchers.push(new RegExp(r));
|
||||
});
|
||||
set activeWhen(nextPathnamePatterns: string[]) {
|
||||
for (const pathname of nextPathnamePatterns) {
|
||||
this.#activeMatchers.push(new URLPattern({ pathname }));
|
||||
}
|
||||
}
|
||||
|
||||
firstUpdated(): void {
|
||||
this.onHashChange();
|
||||
window.addEventListener("hashchange", () => this.onHashChange());
|
||||
this.#hashListener();
|
||||
window.addEventListener("hashchange", this.#hashListener);
|
||||
}
|
||||
|
||||
onHashChange(): void {
|
||||
const activePath = window.location.hash.slice(1, Infinity).split(ROUTE_SEPARATOR)[0];
|
||||
#hashListener = (): void => {
|
||||
const currentPathname = pluckRoute(window.location).pathname;
|
||||
|
||||
this.childItems.forEach((item) => {
|
||||
this.expandParentRecursive(activePath, item);
|
||||
this.expandParentRecursive(currentPathname, item);
|
||||
});
|
||||
this.isActive = this.matchesPath(activePath);
|
||||
}
|
||||
|
||||
private matchesPath(path: string): boolean {
|
||||
if (!this.path) {
|
||||
return false;
|
||||
}
|
||||
this.isActive = this.matchesPath(currentPathname);
|
||||
};
|
||||
|
||||
const ourPath = this.path.split(";")[0];
|
||||
const pathIsWholePath = new RegExp(`^${ourPath}$`).test(path);
|
||||
const pathIsAnActivePath = this.activeMatchers.some((v) => v.test(path));
|
||||
return pathIsWholePath || pathIsAnActivePath;
|
||||
private matchesPath(targetPathname: string): boolean {
|
||||
if (!this.pathname) return false;
|
||||
|
||||
const criteria = {
|
||||
pathname: targetPathname,
|
||||
};
|
||||
|
||||
const matchesWholePath = new URLPattern({
|
||||
pathname: this.pathname,
|
||||
}).test(criteria);
|
||||
|
||||
const activePath = this.#activeMatchers.some((v) => v.test(criteria));
|
||||
|
||||
return matchesWholePath || activePath;
|
||||
}
|
||||
|
||||
expandParentRecursive(activePath: string, item: SidebarItem): void {
|
||||
if (item.matchesPath(activePath) && item.parent) {
|
||||
item.parent.expanded = true;
|
||||
this.requestUpdate();
|
||||
|
||||
if (!item.childItems.length) {
|
||||
requestAnimationFrame(() => {
|
||||
this.scrollIntoView({
|
||||
block: "nearest",
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
item.childItems.forEach((i) => this.expandParentRecursive(activePath, i));
|
||||
}
|
||||
|
||||
@ -191,7 +207,7 @@ export class SidebarItem extends AKElement {
|
||||
renderWithPath() {
|
||||
return html`
|
||||
<a
|
||||
href="${this.isAbsoluteLink ? "" : "#"}${this.path}"
|
||||
href="${this.isAbsoluteLink ? "" : "#"}${this.pathname}"
|
||||
class="pf-c-nav__link ${this.isActive ? "pf-m-current" : ""}"
|
||||
>
|
||||
<slot name="label"></slot>
|
||||
@ -209,11 +225,11 @@ export class SidebarItem extends AKElement {
|
||||
|
||||
renderInner() {
|
||||
if (this.childItems.length > 0) {
|
||||
return this.path ? this.renderWithPathAndChildren() : this.renderWithChildren();
|
||||
return this.pathname ? this.renderWithPathAndChildren() : this.renderWithChildren();
|
||||
}
|
||||
|
||||
return html`<li class="pf-c-nav__item">
|
||||
${this.path ? this.renderWithPath() : this.renderWithLabel()}
|
||||
${this.pathname ? this.renderWithPath() : this.renderWithLabel()}
|
||||
</li>`;
|
||||
}
|
||||
}
|
||||
|
@ -7,7 +7,7 @@ import "@goauthentik/elements/EmptyState";
|
||||
import "@goauthentik/elements/buttons/SpinnerButton";
|
||||
import "@goauthentik/elements/chips/Chip";
|
||||
import "@goauthentik/elements/chips/ChipGroup";
|
||||
import { getURLParam, updateURLParams } from "@goauthentik/elements/router/RouteMatch";
|
||||
import { getRouteParameter, patchRouteParams } from "@goauthentik/elements/router/utils";
|
||||
import "@goauthentik/elements/table/TablePagination";
|
||||
import "@goauthentik/elements/table/TableSearch";
|
||||
|
||||
@ -118,7 +118,7 @@ export abstract class Table<T> extends AKElement implements TableLike {
|
||||
data?: PaginatedResponse<T>;
|
||||
|
||||
@property({ type: Number })
|
||||
page = getURLParam("tablePage", 1);
|
||||
page = getRouteParameter("tablePage", 1);
|
||||
|
||||
/** @prop
|
||||
*
|
||||
@ -200,7 +200,7 @@ export abstract class Table<T> extends AKElement implements TableLike {
|
||||
}
|
||||
});
|
||||
if (this.searchEnabled()) {
|
||||
this.search = getURLParam("search", "");
|
||||
this.search = getRouteParameter("search", "");
|
||||
}
|
||||
}
|
||||
|
||||
@ -441,7 +441,7 @@ export abstract class Table<T> extends AKElement implements TableLike {
|
||||
renderSearch(): TemplateResult {
|
||||
const runSearch = (value: string) => {
|
||||
this.search = value;
|
||||
updateURLParams({
|
||||
patchRouteParams({
|
||||
search: value,
|
||||
});
|
||||
this.fetch();
|
||||
@ -524,7 +524,7 @@ export abstract class Table<T> extends AKElement implements TableLike {
|
||||
/* A simple pagination display, shown at both the top and bottom of the page. */
|
||||
renderTablePagination(): TemplateResult {
|
||||
const handler = (page: number) => {
|
||||
updateURLParams({ tablePage: page });
|
||||
patchRouteParams({ tablePage: page });
|
||||
this.page = page;
|
||||
this.fetch();
|
||||
};
|
||||
|
@ -1,5 +1,5 @@
|
||||
import "@goauthentik/elements/PageHeader";
|
||||
import { updateURLParams } from "@goauthentik/elements/router/RouteMatch";
|
||||
import { patchRouteParams } from "@goauthentik/elements/router/utils";
|
||||
import { Table } from "@goauthentik/elements/table/Table";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
@ -60,7 +60,7 @@ export abstract class TablePage<T> extends Table<T> {
|
||||
this.search = "";
|
||||
this.requestUpdate();
|
||||
this.fetch();
|
||||
updateURLParams({
|
||||
patchRouteParams({
|
||||
search: "",
|
||||
});
|
||||
}}
|
||||
|
@ -1,8 +1,10 @@
|
||||
import { AndNext, DEFAULT_CONFIG } from "@goauthentik/common/api/config";
|
||||
import { applyNextParam } from "@goauthentik/admin/flows/utils";
|
||||
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
|
||||
import { EVENT_REFRESH } from "@goauthentik/common/constants";
|
||||
import { MessageLevel } from "@goauthentik/common/messages";
|
||||
import "@goauthentik/elements/Spinner";
|
||||
import { showMessage } from "@goauthentik/elements/messages/MessageContainer";
|
||||
import { formatInterfaceRoute } from "@goauthentik/elements/router/utils";
|
||||
import { BaseUserSettings } from "@goauthentik/elements/user/sources/BaseUserSettings";
|
||||
|
||||
import { msg, str } from "@lit/localize";
|
||||
@ -57,12 +59,13 @@ export class SourceSettingsOAuth extends BaseUserSettings {
|
||||
</button>`;
|
||||
}
|
||||
if (this.configureUrl) {
|
||||
return html`<a
|
||||
class="pf-c-button pf-m-primary"
|
||||
href="${this.configureUrl}${AndNext(
|
||||
`/if/user/#/settings;${JSON.stringify({ page: "page-sources" })}`,
|
||||
)}"
|
||||
>
|
||||
const target = new URL(this.configureUrl);
|
||||
|
||||
const destination = formatInterfaceRoute("user", "settings", { page: "sources" });
|
||||
|
||||
applyNextParam(target, destination);
|
||||
|
||||
return html`<a class="pf-c-button pf-m-primary" href="${target}">
|
||||
${msg("Connect")}
|
||||
</a>`;
|
||||
}
|
||||
|
@ -1,8 +1,10 @@
|
||||
import { AndNext, DEFAULT_CONFIG } from "@goauthentik/common/api/config";
|
||||
import { applyNextParam } from "@goauthentik/admin/flows/utils";
|
||||
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
|
||||
import { EVENT_REFRESH } from "@goauthentik/common/constants";
|
||||
import { MessageLevel } from "@goauthentik/common/messages";
|
||||
import "@goauthentik/elements/Spinner";
|
||||
import { showMessage } from "@goauthentik/elements/messages/MessageContainer";
|
||||
import { formatInterfaceRoute } from "@goauthentik/elements/router/utils";
|
||||
import { BaseUserSettings } from "@goauthentik/elements/user/sources/BaseUserSettings";
|
||||
|
||||
import { msg, str } from "@lit/localize";
|
||||
@ -57,12 +59,13 @@ export class SourceSettingsSAML extends BaseUserSettings {
|
||||
</button>`;
|
||||
}
|
||||
if (this.configureUrl) {
|
||||
return html`<a
|
||||
class="pf-c-button pf-m-primary"
|
||||
href="${this.configureUrl}${AndNext(
|
||||
`/if/user/#/settings;${JSON.stringify({ page: "page-sources" })}`,
|
||||
)}"
|
||||
>
|
||||
const target = new URL(this.configureUrl);
|
||||
|
||||
const destination = formatInterfaceRoute("user", "settings", { page: "sources" });
|
||||
|
||||
applyNextParam(target, destination);
|
||||
|
||||
return html`<a class="pf-c-button pf-m-primary" href="${target}">
|
||||
${msg("Connect")}
|
||||
</a>`;
|
||||
}
|
||||
|
@ -15,7 +15,7 @@ import "@goauthentik/flow/stages/password/PasswordStage";
|
||||
// end of stage import
|
||||
|
||||
if (process.env.NODE_ENV === "development" && process.env.WATCHER_URL) {
|
||||
const { ESBuildObserver } = await import("@goauthentik/common/client");
|
||||
const { ESBuildObserver } = await import("src/development/build-observer");
|
||||
|
||||
new ESBuildObserver(process.env.WATCHER_URL);
|
||||
}
|
||||
|
@ -1,7 +1,8 @@
|
||||
import { CSRFHeaderName } from "@goauthentik/common/api/middleware";
|
||||
import { EVENT_THEME_CHANGE } from "@goauthentik/common/constants";
|
||||
import { globalAK } from "@goauthentik/common/global";
|
||||
import { first, getCookie } from "@goauthentik/common/utils";
|
||||
import { getCookie } from "@goauthentik/common/http";
|
||||
import { first } from "@goauthentik/common/utils";
|
||||
import { Interface } from "@goauthentik/elements/Interface";
|
||||
import "@goauthentik/elements/ak-locale-context";
|
||||
import { DefaultBrand } from "@goauthentik/elements/sidebar/SidebarBrand";
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { docLink, globalAK } from "@goauthentik/common/global";
|
||||
import { AKElement } from "@goauthentik/elements/Base";
|
||||
import { paramURL } from "@goauthentik/elements/router/RouterOutlet";
|
||||
import { formatRouteHash } from "@goauthentik/elements/router";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
import { css, html } from "lit";
|
||||
@ -41,7 +41,7 @@ export class LibraryPageApplicationEmptyList extends AKElement {
|
||||
isAdmin = false;
|
||||
|
||||
renderNewAppButton() {
|
||||
const href = paramURL("/core/applications", {
|
||||
const href = formatRouteHash("/core/applications", {
|
||||
createWizard: true,
|
||||
});
|
||||
return html`
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { AKElement } from "@goauthentik/elements/Base";
|
||||
import { getURLParam, updateURLParams } from "@goauthentik/elements/router/RouteMatch";
|
||||
import { getRouteParameter, patchRouteParams } from "@goauthentik/elements/router/utils.js";
|
||||
import Fuse from "fuse.js";
|
||||
import { FuseResult } from "fuse.js";
|
||||
|
||||
@ -67,7 +67,7 @@ export class LibraryPageApplicationSearch extends AKElement {
|
||||
}
|
||||
|
||||
@property()
|
||||
query = getURLParam<string | undefined>("search", undefined);
|
||||
query = getRouteParameter<string | undefined>("search", undefined);
|
||||
|
||||
@query("input")
|
||||
searchInput?: HTMLInputElement;
|
||||
@ -114,7 +114,7 @@ export class LibraryPageApplicationSearch extends AKElement {
|
||||
this.searchInput.value = "";
|
||||
}
|
||||
this.query = "";
|
||||
updateURLParams({
|
||||
patchRouteParams({
|
||||
search: this.query,
|
||||
});
|
||||
this.dispatchEvent(new LibraryPageSearchReset());
|
||||
@ -125,7 +125,7 @@ export class LibraryPageApplicationSearch extends AKElement {
|
||||
if (this.query === "") {
|
||||
return this.resetSearch();
|
||||
}
|
||||
updateURLParams({
|
||||
patchRouteParams({
|
||||
search: this.query,
|
||||
});
|
||||
|
||||
|
@ -3,13 +3,14 @@ import "@goauthentik/user/LibraryPage/ak-library.js";
|
||||
|
||||
import { html } from "lit";
|
||||
|
||||
export const ROUTES: Route[] = [
|
||||
export const ROUTES = [
|
||||
// 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 () => {
|
||||
Route.redirect("^/$", "/library"),
|
||||
Route.redirect("^#.*", "/library"),
|
||||
new Route("/library", async () => html`<ak-library></ak-library>`),
|
||||
new Route("/settings", async () => {
|
||||
await import("@goauthentik/user/user-settings/UserSettingsPage");
|
||||
|
||||
return html`<ak-user-settings></ak-user-settings>`;
|
||||
}),
|
||||
];
|
||||
] satisfies Route<never>[];
|
||||
|
@ -18,8 +18,8 @@ import "@goauthentik/elements/buttons/ActionButton";
|
||||
import "@goauthentik/elements/messages/MessageContainer";
|
||||
import "@goauthentik/elements/notifications/APIDrawer";
|
||||
import "@goauthentik/elements/notifications/NotificationDrawer";
|
||||
import { getURLParam, updateURLParams } from "@goauthentik/elements/router/RouteMatch";
|
||||
import "@goauthentik/elements/router/RouterOutlet";
|
||||
import { getRouteParameter, patchRouteParams } from "@goauthentik/elements/router/utils";
|
||||
import "@goauthentik/elements/sidebar/Sidebar";
|
||||
import { DefaultBrand } from "@goauthentik/elements/sidebar/SidebarBrand";
|
||||
import "@goauthentik/elements/sidebar/SidebarItem";
|
||||
@ -226,7 +226,7 @@ class UserInterfacePresentation extends AKElement {
|
||||
class="pf-l-bullseye__item pf-c-page__main"
|
||||
tabindex="-1"
|
||||
id="main-content"
|
||||
defaultUrl="/library"
|
||||
defaultURL="/library"
|
||||
.routes=${ROUTES}
|
||||
>
|
||||
</ak-router-outlet>
|
||||
@ -263,10 +263,10 @@ class UserInterfacePresentation extends AKElement {
|
||||
@customElement("ak-interface-user")
|
||||
export class UserInterface extends AuthenticatedInterface {
|
||||
@property({ type: Boolean })
|
||||
notificationDrawerOpen = getURLParam("notificationDrawerOpen", false);
|
||||
notificationDrawerOpen = getRouteParameter("notificationDrawerOpen", false);
|
||||
|
||||
@state()
|
||||
apiDrawerOpen = getURLParam("apiDrawerOpen", false);
|
||||
apiDrawerOpen = getRouteParameter("apiDrawerOpen", false);
|
||||
|
||||
ws: WebsocketClient;
|
||||
|
||||
@ -293,7 +293,7 @@ export class UserInterface extends AuthenticatedInterface {
|
||||
window.addEventListener(EVENT_WS_MESSAGE, this.fetchConfigurationDetails);
|
||||
|
||||
if (process.env.NODE_ENV === "development" && process.env.WATCHER_URL) {
|
||||
const { ESBuildObserver } = await import("@goauthentik/common/client");
|
||||
const { ESBuildObserver } = await import("src/development/build-observer");
|
||||
|
||||
new ESBuildObserver(process.env.WATCHER_URL);
|
||||
}
|
||||
@ -308,14 +308,14 @@ export class UserInterface extends AuthenticatedInterface {
|
||||
|
||||
toggleNotificationDrawer() {
|
||||
this.notificationDrawerOpen = !this.notificationDrawerOpen;
|
||||
updateURLParams({
|
||||
patchRouteParams({
|
||||
notificationDrawerOpen: this.notificationDrawerOpen,
|
||||
});
|
||||
}
|
||||
|
||||
toggleApiDrawer() {
|
||||
this.apiDrawerOpen = !this.apiDrawerOpen;
|
||||
updateURLParams({
|
||||
patchRouteParams({
|
||||
apiDrawerOpen: this.apiDrawerOpen,
|
||||
});
|
||||
}
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { AndNext } from "@goauthentik/common/api/config";
|
||||
import { createNextSearchParams } from "@goauthentik/admin/flows/utils";
|
||||
import { globalAK } from "@goauthentik/common/global";
|
||||
import { AKElement } from "@goauthentik/elements/Base";
|
||||
import { formatInterfaceRoute } from "@goauthentik/elements/router/utils";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
import { TemplateResult, html } from "lit";
|
||||
@ -25,15 +26,21 @@ export class UserSettingsPassword extends AKElement {
|
||||
}
|
||||
|
||||
render(): TemplateResult {
|
||||
// TODO: The use `ifDefined` below seems odd. Is it necessary?
|
||||
const searchParams = createNextSearchParams(
|
||||
globalAK().api.relBase +
|
||||
formatInterfaceRoute("user", "settings", {
|
||||
page: "details",
|
||||
}),
|
||||
);
|
||||
|
||||
// For this stage we don't need to check for a configureFlow,
|
||||
// as the stage won't return any UI Elements if no configureFlow is set.
|
||||
return html`<div class="pf-c-card">
|
||||
<div class="pf-c-card__title">${msg("Change your password")}</div>
|
||||
<div class="pf-c-card__body">
|
||||
<a
|
||||
href="${ifDefined(this.configureUrl)}${AndNext(
|
||||
`${globalAK().api.relBase}if/user/#/settings;${JSON.stringify({ page: "page-details" })}`,
|
||||
)}"
|
||||
href="${ifDefined(this.configureUrl)}?${searchParams.toString()}"
|
||||
class="pf-c-button pf-m-primary"
|
||||
>
|
||||
${msg("Change password")}
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { AndNext, DEFAULT_CONFIG } from "@goauthentik/common/api/config";
|
||||
import { createNextSearchParams } from "@goauthentik/admin/flows/utils";
|
||||
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
|
||||
import { SentryIgnoredError } from "@goauthentik/common/errors";
|
||||
import { globalAK } from "@goauthentik/common/global";
|
||||
import { deviceTypeName } from "@goauthentik/common/labels";
|
||||
@ -8,6 +9,7 @@ import "@goauthentik/elements/buttons/ModalButton";
|
||||
import "@goauthentik/elements/buttons/TokenCopyButton";
|
||||
import "@goauthentik/elements/forms/DeleteBulkForm";
|
||||
import "@goauthentik/elements/forms/ModalForm";
|
||||
import { formatInterfaceRoute } from "@goauthentik/elements/router/utils";
|
||||
import { PaginatedResponse, Table, TableColumn } from "@goauthentik/elements/table/Table";
|
||||
import "@goauthentik/user/user-settings/mfa/MFADeviceForm";
|
||||
import "@patternfly/elements/pf-tooltip/pf-tooltip.js";
|
||||
@ -71,13 +73,17 @@ export class MFADevicesPage extends Table<Device> {
|
||||
</button>
|
||||
<ul class="pf-c-dropdown__menu" hidden>
|
||||
${settings.map((stage) => {
|
||||
// TODO: The use `ifDefined` below seems odd. Is it necessary?
|
||||
const searchParams = createNextSearchParams(
|
||||
globalAK().api.relBase +
|
||||
formatInterfaceRoute("user", "settings", {
|
||||
page: "mfa",
|
||||
}),
|
||||
);
|
||||
|
||||
return html`<li>
|
||||
<a
|
||||
href="${ifDefined(stage.configureUrl)}${AndNext(
|
||||
`${globalAK().api.relBase}if/user/#/settings;${JSON.stringify({
|
||||
page: "page-mfa",
|
||||
})}`,
|
||||
)}"
|
||||
href="${ifDefined(stage.configureUrl)}?${searchParams.toString()}"
|
||||
class="pf-c-dropdown__menu-item"
|
||||
>
|
||||
${stageToAuthenticatorName(stage)}
|
||||
|
Reference in New Issue
Block a user