web/router: path-segment tabs via a routed base context

Move tabs from ?page= search params to real path segments:
/settings/sessions, /identity/users/6/credentials/tokens/all-roles. The
page stays mounted across tab changes, and nesting composes with no
per-page wiring.

How the base is delivered:
- The outlet publishes the current page's mount path (the matched path
  minus any tab tail) on a routedTabBaseContext.
- Each <ak-tabs routed> consumes that context as its base, and in turn
  provides its active panel's path to its own subtree. So a nested tab
  group derives its base automatically, and a dispatcher page
  (provider/source/connector view) is transparent to it -- the value
  flows through to the type-specific view's tabs.

Router core:
- sameRouteMatch ignores the wildcard tail (unnamed URLPattern groups),
  so a subtree route (/settings{/*}?) stays mounted while its tabs move
  through the tail; a named-param change (:id) still remounts.
- strip/join are segment-aware, so a base without a trailing slash never
  captures a sibling that shares its text (.../users/22 vs .../users/220).

ak-tabs:
- With "routed" (or an explicit "base"), the active tab is the path
  segment past the base; tabs render as real in-interface links and
  switching navigates the router. Without it, the legacy ?page= behavior
  is retained so not-yet-converted pages keep working during the rollout.
  Slotted panels, lazy-tabs, the command palette, and focus delegation
  are unchanged.
- Pure slot<->segment/href/active-slot logic lives in tabs/tab-path.ts,
  unit-tested independently of the DOM.

Converted so far: user settings (flat); admin user view and its nested
credentials and roles tab groups, plus the RBAC object-permission tabs.
Remaining tabbed pages still use ?page= until converted.
This commit is contained in:
Teffen Ellis
2026-08-13 02:11:09 +01:00
committed by Teffen Ellis
parent 9d8db91e05
commit 2de635f839
14 changed files with 489 additions and 57 deletions

View File

@@ -248,7 +248,9 @@ export const ROUTES: RouteLike[] = [
"users",
),
new Route<{ id: string }>(
"/identity/users/:id",
// The `{/*}?` tail carries the tab path (`/identity/users/22/credentials`)
// to this route while `ak-user-view` stays mounted across tab changes.
"/identity/users/:id{/*}?",
async (args) => {
await import("#admin/users/UserViewPage");
return html`<ak-user-view .userId=${parseInt(args.id, 10)}></ak-user-view>`;

View File

@@ -42,7 +42,7 @@ export class ObjectPermissionPage extends AKElement {
render() {
return this.model === ModelEnum.AuthentikRbacRole
? html`<ak-tabs pageIdentifier="permissionPage" ?vertical=${!this.embedded}>
? html`<ak-tabs routed ?vertical=${!this.embedded}>
${this.renderPermissionsAssignedToRole()}
</ak-tabs>`
: html`<div class="pf-c-page__main-section pf-m-no-padding-mobile">

View File

@@ -38,7 +38,7 @@ export class UserCredentialsTab extends WithLazyTabs(WithLicenseSummary(AKElemen
return nothing;
}
return html`<ak-tabs pageIdentifier="userCredentialsTokens" vertical>
return html`<ak-tabs routed vertical>
<div
role="tabpanel"
tabindex="0"

View File

@@ -27,7 +27,7 @@ export class UserRolesTab extends WithLazyTabs(AKElement) {
return nothing;
}
return html`<ak-tabs pageIdentifier="userRoles" vertical>
return html`<ak-tabs routed vertical>
<div
role="tabpanel"
tabindex="0"

View File

@@ -95,7 +95,7 @@ export class UserViewPage extends WithLazyTabs(
}
return html`<main>
<ak-tabs>
<ak-tabs routed>
<div
role="tabpanel"
tabindex="0"

View File

@@ -7,13 +7,17 @@ import {
PaletteCommandDefinitionInit,
} from "#elements/commands/shared";
import { intersectionObserver } from "#elements/decorators/intersection-observer";
import { navigate, RouterNavigateEvent } from "#elements/router/core/navigation";
import { getSearchParams, updateSearchParams } from "#elements/router/core/search-params";
import Styles from "#elements/Tabs.css" with { type: "bundled-text" };
import { routedTabBaseContext } from "#elements/tabs/tab-context";
import { activeSlotForPath, tabHref } from "#elements/tabs/tab-path";
import { ifPresent } from "#elements/utils/attributes";
import { isFocusable } from "#elements/utils/focus";
import { capitalCase } from "change-case";
import { ContextConsumer, ContextProvider } from "@lit/context";
import { msg, str } from "@lit/localize";
import { CSSResult, html, LitElement, PropertyValues, TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators.js";
@@ -27,6 +31,29 @@ export class Tabs extends AKElement {
};
static styles: CSSResult[] = [Styles];
/**
* Opt into path routing: the active tab becomes a path segment
* (`/if/user/settings/sessions`) and switching tabs navigates the router.
* The base comes from {@linkcode routedTabBaseContext} — provided by the
* router outlet for the page, then refined by each nested group — so no
* page wiring is needed. Without this (and without {@linkcode base}), the
* group falls back to the legacy `?page=` search parameter.
*/
@property({ type: Boolean })
public routed = false;
/**
* An explicit mount path override, e.g. `/if/user/settings`. Rarely needed:
* prefer {@linkcode routed} and let the context supply the base. Setting it
* implies {@linkcode routed}.
*/
@property({ type: String })
public base = "";
/**
* The search parameter used to persist the active tab in the legacy
* (non-{@linkcode base}) mode.
*/
@property({ type: String })
public pageIdentifier = "page";
@@ -49,6 +76,56 @@ export class Tabs extends AKElement {
#commands = new CommandPaletteState<string>();
//#region Routed base
/**
* The base supplied by the nearest routed ancestor (the outlet, or a parent
* tab group), consumed reactively.
*/
#baseConsumer = new ContextConsumer(this, {
context: routedTabBaseContext,
subscribe: true,
callback: () => {
if (!this.#pathMode) return;
this.activeTabName = this.#slotFromLocation();
this.#publishChildBase();
},
});
/**
* Provides this group's active-panel path to its subtree, so a nested
* `<ak-tabs routed>` derives its base with no wiring.
*/
#childBaseProvider = new ContextProvider(this, {
context: routedTabBaseContext,
initialValue: "",
});
#publishChildBase(): void {
this.#childBaseProvider.setValue(
this.activeTabName ? this.#hrefForSlot(this.activeTabName) : this.#effectiveBase,
);
}
/**
* The mount path this group tracks against: an explicit {@linkcode base}
* wins, else the context value from the nearest routed ancestor.
*/
get #effectiveBase(): string {
return this.base || this.#baseConsumer.value || "";
}
/**
* Whether the active tab is tracked as a path segment rather than a search
* parameter.
*/
get #pathMode(): boolean {
return this.routed || Boolean(this.base);
}
//#endregion
#updateTabs = (): void => {
this.tabs = new Map(
Array.from(this.querySelectorAll(":scope > [slot^='page-']"), (element) => {
@@ -93,6 +170,36 @@ export class Tabs extends AKElement {
this.#commands.set(commands);
};
//#region Navigation
/**
* The active slot for the current location, or `null` when the tabs are not
* yet known. Falls back to the first tab when the path names no known tab.
*/
#slotFromLocation(): string | null {
return activeSlotForPath(this.#effectiveBase, window.location.pathname, [
...this.tabs.keys(),
]);
}
#hrefForSlot(slotName: string): string {
return tabHref(this.#effectiveBase, slotName, this.tabs.keys().next().value ?? null);
}
#onNavigate = (): void => {
if (!this.#pathMode) return;
const nextSlot = this.#slotFromLocation();
if (!nextSlot || nextSlot === this.activeTabName) return;
this.activeTabName = nextSlot;
this.#publishChildBase();
this.dispatchActivateEvent();
};
//#endregion
public override connectedCallback(): void {
super.connectedCallback();
@@ -100,20 +207,30 @@ export class Tabs extends AKElement {
this.addEventListener("focus", this.#delegateFocusListener);
if (!this.activeTabName) {
const params = getSearchParams();
const tabParam = params[this.pageIdentifier];
window.addEventListener("popstate", this.#onNavigate);
window.addEventListener(RouterNavigateEvent.eventName, this.#onNavigate);
if (
tabParam &&
typeof tabParam === "string" &&
this.querySelector(`[slot='${tabParam}']`)
) {
this.activeTabName = tabParam;
} else {
this.#updateTabs();
this.activeTabName = this.tabs.keys().next().value || null;
}
if (this.activeTabName) return;
this.#updateTabs();
if (this.#pathMode) {
this.activeTabName = this.#slotFromLocation();
this.#publishChildBase();
return;
}
const params = getSearchParams();
const tabParam = params[this.pageIdentifier];
if (
tabParam &&
typeof tabParam === "string" &&
this.querySelector(`[slot='${tabParam}']`)
) {
this.activeTabName = tabParam;
} else {
this.activeTabName = this.tabs.keys().next().value || null;
}
}
@@ -130,6 +247,10 @@ export class Tabs extends AKElement {
public override disconnectedCallback(): void {
this.#observer?.disconnect();
this.#commands.clear();
window.removeEventListener("popstate", this.#onNavigate);
window.removeEventListener(RouterNavigateEvent.eventName, this.#onNavigate);
super.disconnectedCallback();
}
@@ -156,18 +277,26 @@ export class Tabs extends AKElement {
return;
}
const firstTab = this.tabs.keys().next().value || null;
if (this.#pathMode) {
navigate(this.#hrefForSlot(nextTabName));
} else {
const firstTab = this.tabs.keys().next().value || null;
// We avoid adding the tab parameter to the URL if it's the first tab
// to both reduce URL length and ensure that tests do not have to deal with
// unnecessary URL parameters.
// We avoid adding the tab parameter to the URL if it's the first tab
// to both reduce URL length and ensure that tests do not have to deal with
// unnecessary URL parameters.
updateSearchParams({
[this.pageIdentifier]: nextTabName === firstTab ? null : nextTabName,
});
updateSearchParams({
[this.pageIdentifier]: nextTabName === firstTab ? null : nextTabName,
});
}
this.activeTabName = nextTabName;
if (this.#pathMode) {
this.#publishChildBase();
}
this.dispatchActivateEvent();
}
@@ -198,24 +327,52 @@ export class Tabs extends AKElement {
}
};
#onTabClick(event: MouseEvent, slotName: string): void {
// A modified click (new tab, download) or a non-primary button is left
// to the browser so real links keep working; the top outlet's anchor
// interceptor claims the rest, but activate directly as a fallback.
if (event.button !== 0) return;
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
event.preventDefault();
this.activateTab(slotName);
}
renderTab(slotName: string, tabPanel: Element): TemplateResult {
return html` <li
part="tab-item"
class="pf-c-tabs__item ${slotName === this.activeTabName ? CURRENT_CLASS : ""}"
>
<button
type="button"
role="tab"
part="tab-button"
id=${`${slotName}-tab`}
name=${slotName}
aria-selected=${slotName === this.activeTabName ? "true" : "false"}
aria-controls=${ifPresent(slotName)}
class="pf-c-tabs__link"
@click=${() => this.activateTab(slotName)}
>
<span class="pf-c-tabs__item-text">${tabPanel.getAttribute("aria-label")}</span>
</button>
const current = slotName === this.activeTabName;
const label = tabPanel.getAttribute("aria-label");
// Path mode renders a real in-interface link (accessible, middle-click
// opens the tab's URL); legacy mode keeps the button-driven behavior.
const control = this.#pathMode
? html`<a
href=${this.#hrefForSlot(slotName)}
role="tab"
part="tab-button"
id=${`${slotName}-tab`}
aria-selected=${current ? "true" : "false"}
aria-controls=${ifPresent(slotName)}
class="pf-c-tabs__link"
@click=${(event: MouseEvent) => this.#onTabClick(event, slotName)}
>
<span class="pf-c-tabs__item-text">${label}</span>
</a>`
: html`<button
type="button"
role="tab"
part="tab-button"
id=${`${slotName}-tab`}
name=${slotName}
aria-selected=${current ? "true" : "false"}
aria-controls=${ifPresent(slotName)}
class="pf-c-tabs__link"
@click=${() => this.activateTab(slotName)}
>
<span class="pf-c-tabs__item-text">${label}</span>
</button>`;
return html` <li part="tab-item" class="pf-c-tabs__item ${current ? CURRENT_CLASS : ""}">
${control}
</li>`;
}

View File

@@ -26,6 +26,7 @@ import {
RouterNavigateEvent,
} from "#elements/router/core/navigation";
import { type RouteLike } from "#elements/router/core/Route";
import { routedTabBaseContext } from "#elements/tabs/tab-context";
import { type SlottedTemplateResult } from "#elements/types";
import {
@@ -36,6 +37,7 @@ import {
startBrowserTracingPageLoadSpan,
} from "@sentry/browser";
import { ContextProvider } from "@lit/context";
import { msg } from "@lit/localize";
import { html, type PropertyValues, type TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators.js";
@@ -87,6 +89,14 @@ export class RouterView extends AKElement {
#sentryClient = getClient();
#pageLoadSpan: Span | null = null;
/**
* Publishes the current page's mount path to routed `<ak-tabs>` descendants.
*/
#tabBaseProvider = new ContextProvider(this, {
context: routedTabBaseContext,
initialValue: "",
});
constructor() {
super();
@@ -147,22 +157,46 @@ export class RouterView extends AKElement {
//#region Matching
/**
* Strip the interface prefix, preserving the leading slash the matcher
* requires: `/if/user/settings` → `/settings`, `/if/user/` → `/`. A
* pathname outside the prefix is returned unchanged so it falls through to
* the 404 branch.
* Strip the prefix, preserving the leading slash the matcher requires:
* `/if/user/settings` → `/settings`, `/if/user/` → `/`. Matching is
* segment-aware so a prefix without a trailing slash (a nested outlet's
* base, e.g. `…/users/22`) never captures a sibling that merely shares its
* text (`…/users/220`). A pathname outside the prefix is returned unchanged
* so it falls through to the 404 branch.
*/
#strip(pathname: string): string {
if (!pathname.startsWith(this.prefix)) return pathname;
const base = this.prefix.replace(/\/+$/, "");
return `/${pathname.slice(this.prefix.length).replace(/^\/+/, "")}`;
if (pathname === base) return "/";
if (pathname.startsWith(`${base}/`)) {
return `/${pathname.slice(base.length + 1).replace(/^\/+/, "")}`;
}
return pathname;
}
/**
* Join a route-relative path onto the prefix for navigation.
* Join a route-relative path onto the prefix for navigation, normalizing to
* exactly one separator regardless of whether the prefix ends in a slash.
*/
#join(path: string): string {
return `${this.prefix}${path.replace(/^\/+/, "")}`;
return `${this.prefix.replace(/\/+$/, "")}/${path.replace(/^\/+/, "")}`;
}
/**
* The absolute path consumed to reach a match, excluding its wildcard tail —
* the mount path a tabbed page hands to its nested outlet. `/settings` and
* `/settings/sessions` (a subtree route) both resolve to base
* `/if/user/settings`; the tail (`sessions`) belongs to the nested outlet.
*/
#basePath(match: RouteMatch<RouteLike>): string {
const tail = match.parameters["0"];
if (tail == null) return this.#join(match.pathname);
const consumed = match.pathname.slice(0, match.pathname.length - tail.length);
return this.#join(consumed.replace(/\/+$/, "") || "/");
}
#syncRoute = (): void => {
@@ -177,6 +211,11 @@ export class RouterView extends AKElement {
const next = matchRoute(stripped, this.routes);
// Publish the mount path (minus any tab tail) so routed `<ak-tabs>` under
// this outlet can consume it as their base. Refreshed even when the route
// is unchanged, so re-entering the same page restores a correct base.
this.#tabBaseProvider.setValue(next ? this.#basePath(next) : "");
// Skip re-resolving when the route and its path parameters are unchanged
// (a search-param-only change — a tab, a table filter). Reassigning would
// hand `until()` a new promise and flash the loading state over a view

View File

@@ -51,10 +51,28 @@ export function matchRoute<R extends RoutePatternLike>(
}
/**
* Whether two matches resolve to the same rendered view: the same route with the
* same path parameters. {@linkcode matchRoute} returns a fresh object every call,
* so a search-param-only navigation (a tab, a table filter) yields an equal-but-
* new match — comparing structurally lets a consumer skip re-rendering it.
* `URLPattern` names unnamed groups — `*` and `(.*)` — with sequential integer
* keys. A route that matches a subtree (`/users/:id{/*}?`) captures the tail
* this way, and that tail is sub-navigation the *mounted* view owns (its tabs),
* not the identity of the mount. Named groups (`:id`) identify the mount.
*/
const WILDCARD_GROUP_KEY = /^\d+$/;
function identifyingKeys(parameters: Record<string, string | undefined>): string[] {
return Object.keys(parameters).filter((key) => !WILDCARD_GROUP_KEY.test(key));
}
/**
* Whether two matches resolve to the same mounted view: the same route with the
* same *identifying* (named) path parameters. {@linkcode matchRoute} returns a
* fresh object every call, so a search-only navigation (a table filter) or a
* wildcard-tail change (a tab, in a subtree route) yields an equal-but-new match
* — comparing structurally lets the outlet skip re-resolving it, which would
* otherwise tear down and reload an already-mounted view.
*
* The wildcard tail is deliberately excluded: a subtree route stays mounted
* while its tabs move through the tail, and the nested outlet inside it handles
* the tail. A change to a named parameter (a different `:id`) still remounts.
*/
export function sameRouteMatch<R extends RoutePatternLike>(
a: RouteMatch<R> | null,
@@ -64,8 +82,8 @@ export function sameRouteMatch<R extends RoutePatternLike>(
if (a === null || b === null) return false;
if (a.route !== b.route) return false;
const aKeys = Object.keys(a.parameters);
const bKeys = Object.keys(b.parameters);
const aKeys = identifyingKeys(a.parameters);
const bKeys = identifyingKeys(b.parameters);
if (aKeys.length !== bKeys.length) return false;

View File

@@ -68,4 +68,33 @@ describe("sameRouteMatch", () => {
),
).toBe(false);
});
it("ignores the wildcard tail so a subtree route stays mounted across its tabs", () => {
const subtree = route("/users/:id{/*}?");
// Same `:id`, different tail (a tab change) — same mount.
expect(
sameRouteMatch(
matchRoute("/users/22", [subtree]),
matchRoute("/users/22/credentials", [subtree]),
),
).toBe(true);
expect(
sameRouteMatch(
matchRoute("/users/22/credentials", [subtree]),
matchRoute("/users/22/credentials/tokens", [subtree]),
),
).toBe(true);
});
it("still remounts a subtree route when its named parameter changes", () => {
const subtree = route("/users/:id{/*}?");
expect(
sameRouteMatch(
matchRoute("/users/22/credentials", [subtree]),
matchRoute("/users/23/credentials", [subtree]),
),
).toBe(false);
});
});

View File

@@ -0,0 +1,13 @@
import { createContext } from "@lit/context";
/**
* The absolute mount path a routed tab group lives under, e.g.
* `/if/user/settings`.
*
* The router's outlet provides it for the current page; each `<ak-tabs routed>`
* consumes it as its base and, in turn, provides its active panel's path to its
* own subtree — so a nested tab group derives its base with no page wiring. A
* dispatcher page (provider/source/connector view) is transparent to this: the
* value flows through it to the type-specific view's tabs.
*/
export const routedTabBaseContext = createContext<string>(Symbol("authentik-routed-tab-base"));

View File

@@ -0,0 +1,69 @@
/**
* @file Pure path helpers for path-routed tabs.
*
* A tab panel is a slotted child named `page-<segment>`; its URL is the tab
* group's mount path plus that segment (`/if/user/settings` + `sessions` →
* `/if/user/settings/sessions`). These functions map between the two and pick
* the active tab from a location. No DOM, no globals — unit-testable in Node.
*/
/**
* The `slot` prefix every tab panel carries. The URL segment is the slot name
* with this removed.
*/
export const SLOT_PREFIX = "page-";
export const slotToSegment = (slot: string): string => slot.slice(SLOT_PREFIX.length);
export const segmentToSlot = (segment: string): string => `${SLOT_PREFIX}${segment}`;
/**
* The active tab slot for a location, or `null` when the group has no tabs.
*
* The first tab is the default: the bare base (and any path outside the group's
* subtree) resolves to it. A path whose first segment past the base names a
* known tab selects that tab; an unknown segment falls back to the first.
*
* @param base The group's mount path, e.g. `/if/user/settings`.
* @param pathname The current `location.pathname`.
* @param slots The group's slot names, in tab order.
*/
export function activeSlotForPath(
base: string,
pathname: string,
slots: readonly string[],
): string | null {
const first = slots[0] ?? null;
const normalizedBase = base.replace(/\/+$/, "");
if (pathname.startsWith(`${normalizedBase}/`)) {
const [segment] = pathname
.slice(normalizedBase.length + 1)
.split("/")
.filter(Boolean);
if (segment) {
const slot = segmentToSlot(segment);
if (slots.includes(slot)) return slot;
}
}
return first;
}
/**
* The URL a tab links to: the bare `base` for the first (default) tab,
* `base/segment` otherwise. Keeping the default at the bare base mirrors the
* legacy behavior of omitting the first tab's parameter.
*
* @param base The group's mount path.
* @param slotName The tab's slot name.
* @param firstSlot The group's first slot, the default tab.
*/
export function tabHref(base: string, slotName: string, firstSlot: string | null): string {
const normalizedBase = base.replace(/\/+$/, "");
if (slotName === firstSlot) return normalizedBase;
return `${normalizedBase}/${slotToSegment(slotName)}`;
}

View File

@@ -0,0 +1,102 @@
import { activeSlotForPath, segmentToSlot, slotToSegment, tabHref } from "./tab-path.js";
import { describe, expect, it } from "vitest";
describe("slot ↔ segment", () => {
it("round-trips a slot through its segment", () => {
expect(slotToSegment("page-sessions")).toBe("sessions");
expect(segmentToSlot("sessions")).toBe("page-sessions");
expect(slotToSegment(segmentToSlot("oauth-access"))).toBe("oauth-access");
});
});
describe("activeSlotForPath", () => {
const settings = ["page-details", "page-sessions", "page-consents", "page-sources"];
it("returns null when there are no tabs", () => {
expect(activeSlotForPath("/if/user/settings", "/if/user/settings/sessions", [])).toBeNull();
});
it("selects the first tab at the bare base", () => {
expect(activeSlotForPath("/if/user/settings", "/if/user/settings", settings)).toBe(
"page-details",
);
});
it("tolerates a trailing slash on the base", () => {
expect(activeSlotForPath("/if/user/settings/", "/if/user/settings", settings)).toBe(
"page-details",
);
});
it("selects the tab named by the first segment past the base", () => {
expect(activeSlotForPath("/if/user/settings", "/if/user/settings/sessions", settings)).toBe(
"page-sessions",
);
});
it("falls back to the first tab for an unknown segment", () => {
expect(activeSlotForPath("/if/user/settings", "/if/user/settings/nope", settings)).toBe(
"page-details",
);
});
it("falls back to the first tab for a path outside the group's subtree", () => {
expect(activeSlotForPath("/if/user/settings", "/if/user/library", settings)).toBe(
"page-details",
);
});
it("uses only the first segment, so a nested group owns the rest", () => {
// The outer group at `/…/users/22` sees `credentials/tokens` and selects
// credentials; the `tokens` segment belongs to the nested group.
const outer = ["page-overview", "page-credentials", "page-roles"];
expect(
activeSlotForPath(
"/if/admin/identity/users/22",
"/if/admin/identity/users/22/credentials/tokens",
outer,
),
).toBe("page-credentials");
});
it("resolves the nested group from its own deeper base", () => {
const inner = ["page-sessions", "page-tokens", "page-consent"];
expect(
activeSlotForPath(
"/if/admin/identity/users/22/credentials",
"/if/admin/identity/users/22/credentials/tokens",
inner,
),
).toBe("page-tokens");
});
});
describe("tabHref", () => {
const settings = ["page-details", "page-sessions"];
const [first] = settings;
it("links the first (default) tab to the bare base", () => {
expect(tabHref("/if/user/settings", "page-details", first)).toBe("/if/user/settings");
});
it("links a non-default tab to base/segment", () => {
expect(tabHref("/if/user/settings", "page-sessions", first)).toBe(
"/if/user/settings/sessions",
);
});
it("normalizes a trailing slash on the base", () => {
expect(tabHref("/if/user/settings/", "page-sessions", first)).toBe(
"/if/user/settings/sessions",
);
});
it("builds a nested tab href from the deeper base", () => {
expect(
tabHref("/if/admin/identity/users/22/credentials", "page-tokens", "page-sessions"),
).toBe("/if/admin/identity/users/22/credentials/tokens");
});
});

View File

@@ -38,7 +38,9 @@ export const ROUTES: RouteLike[] = [
"requests.fulfill",
),
new Route(
"/settings",
// The `{/*}?` tail lets the tab segment (`/settings/sessions`) resolve to
// this route while the page stays mounted across tab changes.
"/settings{/*}?",
async () => {
await import("#user/user-settings/UserSettingsPage");

View File

@@ -129,6 +129,7 @@ export class UserSettingsPage extends WithLicenseSummary(WithSession(AKElement))
return html`<div class="pf-c-page">
<div class="pf-c-page__main">
<ak-tabs
routed
vertical
role="main"
aria-label=${msg("User settings")}