Compare commits

...

1 Commits

Author SHA1 Message Date
Teffen Ellis
01ff1e1002 web: Flesh out router. 2025-07-24 19:45:43 +02:00
21 changed files with 610 additions and 304 deletions

View File

@@ -17,7 +17,7 @@ import { me } from "#common/users";
import { AKElement } from "#elements/Base";
import type { QuickAction } from "#elements/cards/QuickActionsCard";
import { WithLicenseSummary } from "#elements/mixins/license";
import { paramURL } from "#elements/router/RouterOutlet";
import { paramURL } from "#elements/router/RouteMatch";
import { SessionUser } from "@goauthentik/api";
import { createReleaseNotesURL } from "@goauthentik/core/version";

View File

@@ -6,13 +6,13 @@ import "#elements/forms/DeleteBulkForm";
import "#elements/forms/ModalForm";
import "@patternfly/elements/pf-tooltip/pf-tooltip.js";
import { AndNext, DEFAULT_CONFIG } from "#common/api/config";
import { DEFAULT_CONFIG } from "#common/api/config";
import { groupBy } from "#common/utils";
import { PaginatedResponse, TableColumn } from "#elements/table/Table";
import { TablePage } from "#elements/table/TablePage";
import { DesignationToLabel } from "#admin/flows/utils";
import { DesignationToLabel, formatFlowURL } from "#admin/flows/utils";
import { Flow, FlowsApi } from "@goauthentik/api";
@@ -109,10 +109,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")}>

View File

@@ -8,12 +8,12 @@ import "#components/events/ObjectChangelog";
import "#elements/Tabs";
import "#elements/buttons/SpinnerButton/ak-spinner-button";
import { AndNext, DEFAULT_CONFIG } from "#common/api/config";
import { DEFAULT_CONFIG } from "#common/api/config";
import { isResponseErrorLike } from "#common/errors/network";
import { AKElement } from "#elements/Base";
import { DesignationToLabel } from "#admin/flows/utils";
import { applyNextParam, DesignationToLabel, formatFlowURL } from "#admin/flows/utils";
import { Flow, FlowsApi, RbacPermissionsAssignedByUsersListModelEnum } from "@goauthentik/api";
@@ -157,12 +157,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")}
@@ -174,12 +171,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");
});
}}

View File

@@ -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;
}

View File

@@ -129,7 +129,8 @@ export class UserListPage extends WithBrandConfig(WithCapabilitiesConfig(TablePa
constructor() {
super();
const defaultPath = new DefaultUIConfig().defaults.userPath;
this.activePath = getURLParam<string>("path", defaultPath);
this.activePath = getURLParam("path", defaultPath);
uiConfig().then((c) => {
if (c.defaults.userPath !== defaultPath) {
this.activePath = c.defaults.userPath;

View File

@@ -68,13 +68,6 @@ 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 ${import.meta.env.AK_VERSION}, apiBase ${DEFAULT_CONFIG.basePath}`,
);

View File

@@ -16,17 +16,6 @@ export const CURRENT_CLASS = "pf-m-current";
//#endregion
//#region Application
/**
* The delimiter used to parse the URL for the current route.
*
* @todo Move this to the ak-router.
*/
export const ROUTE_SEPARATOR = ";";
//#endregion
//#region Events
export const EVENT_REFRESH = "ak-refresh";

View File

@@ -1,66 +1,61 @@
import "#elements/EmptyState";
import { html, TemplateResult } from "lit";
import { SlottedTemplateResult } from "#elements/types";
import { html, nothing, TemplateResult } 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></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>`,
)}`;
}
}

View File

@@ -1,44 +1,125 @@
import { ROUTE_SEPARATOR } from "#common/constants";
import { ROUTE_SEPARATOR, TITLE_DEFAULT } from "./constants.js";
import { Route } from "#elements/router/Route";
import type { Route, RouteParameterRecord } from "#elements/router/Route";
import { TemplateResult } from "lit";
import type { CurrentBrand } from "@goauthentik/api";
export class RouteMatch {
route: Route;
arguments: { [key: string]: string };
fullURL: string;
import { msg } from "@lit/localize";
constructor(route: Route, fullUrl: string) {
this.route = route;
this.arguments = {};
this.fullURL = fullUrl;
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,
};
}
render(): TemplateResult {
return this.route.render(this.arguments);
}
console.debug(`authentik/router: no route matched ${pathname}`);
/**
* Convert the matched Route's URL regex to a sanitized, readable URL by replacing
* all regex values with placeholders according to the name of their regex group.
*
* @returns The sanitized URL for logging/tracing.
*/
sanitizedURL() {
let cleanedURL = this.fullURL;
for (const match of Object.keys(this.arguments)) {
const value = this.arguments[match];
cleanedURL = cleanedURL?.replace(value, `:${match}`);
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(paramURL(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 paramURL(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;
}
return cleanedURL;
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));
}
toString(): string {
return `<RouteMatch url=${this.sanitizedURL()} route=${this.route} arguments=${JSON.stringify(
this.arguments,
)}>`;
}
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 + paramURL(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,
};
}
export function createPathnameHash(
@@ -49,30 +130,68 @@ export function createPathnameHash(
return `${basePath}#${hashRoute}`;
}
export function getURLParam<T>(key: string, fallback: T): T {
const params = getURLParams();
if (key in params) {
return params[key] as T;
/**
* 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 getURLParam<T>(paramName: string, fallback: T): T {
const params = getRouteParams();
if (Object.hasOwn(params, paramName)) {
return params[paramName] 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;
}
/**
* 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;
}
return params;
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;
}
export function setURLParams(params: { [key: string]: unknown }, replace = true): void {
const serializedParams = JSON.stringify(params);
/**
* 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 setURLParams(nextParams: RouteParameterRecord, replace = true): void {
const serializedParams = JSON.stringify(nextParams);
const [currentRoute] = window.location.hash.slice(1).split(ROUTE_SEPARATOR);
@@ -87,11 +206,118 @@ export function setURLParams(params: { [key: string]: unknown }, replace = true)
}
}
export function updateURLParams(params: { [key: string]: unknown }, replace = true): void {
const currentParams = getURLParams();
for (const key in params) {
currentParams[key] = params[key] as string;
/**
* 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 updateURLParams(patchedParams: RouteParameterRecord, replace = true): void {
const currentParams = getRouteParams();
const nextParams = { ...currentParams, ...patchedParams };
setURLParams(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";
}
type BrandTitleLike = Partial<Pick<CurrentBrand, "brandingTitle">>;
/**
* Create a title for the page.
*
* @param brand - The brand object to append to the title.
* @param segments - The segments to prepend to the title.
*/
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[] = [];
if (isAdminRoute()) {
segments.push(msg("Admin"));
}
setURLParams(currentParams, replace);
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(" - ");
}

View File

@@ -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 styles: CSSResult[] = [PFBase, PFEmptyState, PFTitle];
@@ -21,7 +21,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

View File

@@ -1,159 +1,135 @@
import "#elements/router/Router404";
import { ROUTE_SEPARATOR } from "#common/constants";
import { AKElement } from "#elements/Base";
import { Route } from "#elements/router/Route";
import { RouteMatch } from "#elements/router/RouteMatch";
import { matchRoute, pluckRoute } from "#elements/router/RouteMatch";
import {
BrowserClient,
getClient,
SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
Span,
startBrowserTracingNavigationSpan,
startBrowserTracingPageLoadSpan,
} from "@sentry/browser";
import { css, CSSResult, html, PropertyValues, TemplateResult } from "lit";
import { customElement, property } from "lit/decorators.js";
import { css, CSSResult, html, TemplateResult } from "lit";
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[] = [];
private sentryClient?: BrowserClient;
private pageLoadSpan?: Span;
static get styles(): CSSResult[] {
return [
css`
:host {
background-color: transparent !important;
}
static styles: CSSResult[] = [
css`
:host {
background-color: transparent !important;
}
*:first-child {
flex-direction: column;
}
`,
];
*:first-child {
flex-direction: column;
}
`,
];
}
constructor() {
super();
window.addEventListener("hashchange", (ev: HashChangeEvent) => this.navigate(ev));
this.sentryClient = getClient();
if (this.sentryClient) {
this.pageLoadSpan = startBrowserTracingPageLoadSpan(this.sentryClient, {
name: window.location.pathname,
attributes: {
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: "url",
},
connectedCallback(): void {
super.connectedCallback();
window.addEventListener("hashchange", this.#refreshLocation);
}
disconnectedCallback(): void {
super.disconnectedCallback();
window.removeEventListener("hashchange", this.#refreshLocation);
}
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,
});
}
}
firstUpdated(): void {
this.navigate();
}
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}`);
return;
}
let matchedRoute: RouteMatch | null = null;
this.routes.some((route) => {
const match = route.url.exec(activeUrl);
if (match !== null) {
matchedRoute = new RouteMatch(route, activeUrl);
matchedRoute.arguments = match.groups || {};
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, activeUrl);
matchedRoute.arguments = route.url.exec(activeUrl)?.groups || {};
}
this.current = matchedRoute;
}
updated(changedProperties: PropertyValues<this>): void {
if (!changedProperties.has("current") || !this.current) return;
if (!this.sentryClient) return;
// https://docs.sentry.io/platforms/javascript/tracing/instrumentation/automatic-instrumentation/#custom-routing
if (this.pageLoadSpan) {
this.pageLoadSpan.updateName(this.current.sanitizedURL());
this.pageLoadSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, "route");
this.pageLoadSpan = undefined;
} else {
startBrowserTracingNavigationSpan(this.sentryClient, {
op: "navigation",
name: this.current.sanitizedURL(),
attributes: {
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: "route",
},
});
if (!nextPathname) {
console.debug(`authentik/router: defaulted route to ${nextPathname}`);
this.#redirectToDefault();
return;
}
}
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);
}
}

View File

@@ -0,0 +1,28 @@
/**
* @file Router constants.
*/
export const TITLE_DEFAULT = "authentik";
/**
* 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}";

View File

@@ -0,0 +1,6 @@
export * from "./Route.js";
export * from "./constants.js";
export * from "./Router404.js";
export * from "./RouterOutlet.js";
export * from "./RouteMatch.js";
export * from "./slugs.js";

View 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);
}

View File

@@ -1,13 +1,16 @@
import "#elements/Spinner";
import { AndNext, DEFAULT_CONFIG } from "#common/api/config";
import { DEFAULT_CONFIG } from "#common/api/config";
import { EVENT_REFRESH } from "#common/constants";
import { parseAPIResponseError, pluckErrorDetail } from "#common/errors/network";
import { MessageLevel } from "#common/messages";
import { showMessage } from "#elements/messages/MessageContainer";
import { formatInterfaceRoute } from "#elements/router/RouteMatch";
import { BaseUserSettings } from "#elements/user/sources/BaseUserSettings";
import { applyNextParam } from "#admin/flows/utils";
import { SourcesApi } from "@goauthentik/api";
import { msg, str } from "@lit/localize";
@@ -64,12 +67,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>`;
}

View File

@@ -1,13 +1,16 @@
import "#elements/Spinner";
import { AndNext, DEFAULT_CONFIG } from "#common/api/config";
import { DEFAULT_CONFIG } from "#common/api/config";
import { EVENT_REFRESH } from "#common/constants";
import { parseAPIResponseError, pluckErrorDetail } from "#common/errors/network";
import { MessageLevel } from "#common/messages";
import { showMessage } from "#elements/messages/MessageContainer";
import { formatInterfaceRoute } from "#elements/router/RouteMatch";
import { BaseUserSettings } from "#elements/user/sources/BaseUserSettings";
import { applyNextParam } from "#admin/flows/utils";
import { SourcesApi } from "@goauthentik/api";
import { msg, str } from "@lit/localize";
@@ -64,12 +67,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>`;
}

View File

@@ -1,7 +1,7 @@
import { docLink, globalAK } from "#common/global";
import { AKElement } from "#elements/Base";
import { paramURL } from "#elements/router/RouterOutlet";
import { paramURL } from "#elements/router/RouteMatch";
import { msg } from "@lit/localize";
import { css, html, nothing } from "lit";

View File

@@ -4,13 +4,13 @@ import { Route } from "#elements/router/Route";
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("#user/user-settings/UserSettingsPage");
return html`<ak-user-settings></ak-user-settings>`;
}),
];
] satisfies Route<never>[];

View File

@@ -227,7 +227,7 @@ class UserInterfacePresentation extends WithBrandConfig(AKElement) {
class="pf-l-bullseye__item pf-c-page__main"
tabindex="-1"
id="main-content"
defaultUrl="/library"
defaultURL="/library"
.routes=${ROUTES}
>
</ak-router-outlet>

View File

@@ -1,7 +1,9 @@
import { AndNext } from "#common/api/config";
import { globalAK } from "#common/global";
import { AKElement } from "#elements/Base";
import { formatInterfaceRoute } from "#elements/router/RouteMatch";
import { createNextSearchParams } from "#admin/flows/utils";
import { msg } from "@lit/localize";
import { CSSResult, html, TemplateResult } from "lit";
@@ -22,17 +24,21 @@ export class UserSettingsPassword extends AKElement {
static styles: CSSResult[] = [PFBase, PFCard, PFButton, PFForm, PFFormControl];
render(): TemplateResult {
const searchParams = createNextSearchParams(
globalAK().api.relBase +
formatInterfaceRoute("user", "settings", {
page: "details",
}),
);
const href = `${this.configureUrl || ""}?${searchParams}`;
// 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" })}`,
)}"
class="pf-c-button pf-m-primary"
>
<a href=${ifDefined(href)} class="pf-c-button pf-m-primary">
${msg("Change password")}
</a>
</div>

View File

@@ -6,14 +6,17 @@ import "#elements/forms/ModalForm";
import "#user/user-settings/mfa/MFADeviceForm";
import "@patternfly/elements/pf-tooltip/pf-tooltip.js";
import { AndNext, DEFAULT_CONFIG } from "#common/api/config";
import { DEFAULT_CONFIG } from "#common/api/config";
import { globalAK } from "#common/global";
import { deviceTypeName } from "#common/labels";
import { SentryIgnoredError } from "#common/sentry/index";
import { formatElapsedTime } from "#common/temporal";
import { formatInterfaceRoute } from "#elements/router/RouteMatch";
import { PaginatedResponse, Table, TableColumn } from "#elements/table/Table";
import { createNextSearchParams } from "#admin/flows/utils";
import { AuthenticatorsApi, Device, UserSetting } from "@goauthentik/api";
import { msg, str } from "@lit/localize";
@@ -73,13 +76,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)}