mirror of
https://github.com/goauthentik/authentik
synced 2026-05-14 19:06:39 +02:00
Compare commits
1 Commits
saml-provi
...
router-tid
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
01ff1e1002 |
@@ -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";
|
||||
|
||||
@@ -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")}>
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
}}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}`,
|
||||
);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>`,
|
||||
)}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(" - ");
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
28
web/src/elements/router/constants.ts
Normal file
28
web/src/elements/router/constants.ts
Normal 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}";
|
||||
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 "./RouteMatch.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);
|
||||
}
|
||||
@@ -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>`;
|
||||
}
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>[];
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}
|
||||
|
||||
Reference in New Issue
Block a user