web: Merge branch -- Stale notifications, synchronized context objects, rendering fixes (#19141)

* web: Fix stale notifications.

* Fix overlap of API and notifications drawers.

* Fix issues surrounding duplicate context controller values.

* Clean up drawer events, alignment.

* Export parts. Fix z-index, colors.

* Fix formatting, alignment. repeated renders.

* Fix indent.

* Fix progress bar fade out, positioning, labels.

* Fix clickable area.

* Ignore clickable icons.

* Clean up logging.

* Fix width.

* Move event listeners into decorator.

* Fix double counting of notifications.

* Fix ARIA lables.

* Fix empty state ARIA.

* Fix order of locale updating.

* Fix rebase.

* web: fix notification count update

* Update selector.

* web: Fix CAPTCHA locale.

* Clean up logging.

---------

Co-authored-by: macmoritz <tratarmoritz@gmail.com>
This commit is contained in:
Teffen Ellis
2026-01-05 15:54:50 -05:00
committed by GitHub
parent 957450b86f
commit 2c813cbe03
55 changed files with 1945 additions and 995 deletions

View File

@@ -89,26 +89,7 @@ class TestSourceOAuth2(SeleniumTestCase):
interface = self.driver.find_element(By.CSS_SELECTOR, "ak-interface-user").shadow_root
interface_wait = WebDriverWait(interface, INTERFACE_TIMEOUT)
try:
interface_wait.until(
ec.presence_of_element_located((By.CSS_SELECTOR, "ak-interface-user-presentation"))
)
except TimeoutException:
snippet = context.text.strip()[:1000].replace("\n", " ")
self.fail(
f"Timed out waiting for element text to appear at {self.driver.current_url}. "
f"Current content: {snippet or '<empty>'}"
)
interface_presentation = interface.find_element(
By.CSS_SELECTOR, "ak-interface-user-presentation"
).shadow_root
user_settings = interface_presentation.find_element(
By.CSS_SELECTOR, "ak-user-settings"
).shadow_root
user_settings = interface.find_element(By.CSS_SELECTOR, "ak-user-settings").shadow_root
tab_panel = user_settings.find_element(By.CSS_SELECTOR, panel_content_selector).shadow_root

139
web/logger/browser.js Normal file
View File

@@ -0,0 +1,139 @@
/**
* @file Console logger for browser environments.
*
* @remarks
* The repetition of log levels, typedefs, and method signatures is intentional
* to give IDEs and type checkers a mapping of log methods to the TypeScript
* provided JSDoc comments.
*
* Additionally, no wrapper functions are used to avoid the browser's console
* reported call site being the wrapper instead of the actual caller.
*/
/* eslint-disable no-console */
//#region Functions
/**
* @typedef {object} Logger
* @property {typeof console.info} info;
* @property {typeof console.warn} warn;
* @property {typeof console.error} error;
* @property {typeof console.debug} debug;
* @property {typeof console.trace} trace;
*/
/**
* Labels log levels in the browser console.
*/
const LogLevelLabel = /** @type {const} */ ({
info: "[INFO]",
warn: "[WARN]",
error: "[ERROR]",
debug: "[DEBUG]",
trace: "[TRACE]",
});
/**
* @typedef {keyof typeof LogLevelLabel} LogLevel
*/
/**
* Predefined log levels.
*/
const LogLevels = /** @type {LogLevel[]} */ (Object.keys(LogLevelLabel));
/**
* Colors for log levels in the browser console.
*
* @remarks
*
* The colors are derived from Carbon Design System's palette to ensure
* sufficient contrast and accessibility across light and dark themes.
*/
const LogLevelColors = /** @type {const} */ ({
info: `light-dark(#0043CE, #4589FF)`,
warn: `light-dark(#F1C21B, #F1C21B)`,
error: `light-dark(#DA1E28, #FA4D56)`,
debug: `light-dark(#8A3FFC, #A56EFF)`,
trace: `light-dark(#8A3FFC, #A56EFF)`,
});
/**
* Creates a logger with the given prefix.
*
* @param {string} [prefix]
* @param {...string} args
* @returns {Logger}
*
*/
export function createLogger(prefix, ...args) {
const suffix = prefix ? `(${prefix}):` : ":";
/**
* @type {Partial<Logger>}
*/
const logger = {};
for (const level of LogLevels) {
const label = LogLevelLabel[level];
const color = LogLevelColors[level];
logger[level] = console[level].bind(
console,
`%c${label}%c ${suffix}%c`,
`font-weight: 700; color: ${color};`,
`font-weight: 600; color: CanvasText;`,
"",
...args,
);
}
return /** @type {Logger} */ (logger);
}
//#endregion
//#region Console Logger
/**
* @typedef {Logger & {prefix: (logPrefix: string) => Logger}} IConsoleLogger
*/
/**
* A singleton logger instance for the browser.
*
* ```js
* import { ConsoleLogger } from "#logger/browser";
*
* ConsoleLogger.info("Hello, world!");
* ```
*
* @implements {IConsoleLogger}
* @runtime browser
*/
// @ts-expect-error Logging properties are dynamically assigned.
export class ConsoleLogger {
/** @type {typeof console.info} */
static info;
/** @type {typeof console.warn} */
static warn;
/** @type {typeof console.error} */
static error;
/** @type {typeof console.debug} */
static debug;
/** @type {typeof console.trace} */
static trace;
/**
* Creates a logger with the given prefix.
* @param {string} logPrefix
*/
static prefix(logPrefix) {
return createLogger(logPrefix);
}
}
Object.assign(ConsoleLogger, createLogger());
//#endregion

View File

@@ -14,10 +14,6 @@
}
}
.display-none {
display: none;
}
ak-page-navbar {
grid-area: header;
}
@@ -36,5 +32,5 @@ ak-sidebar-item:active ak-sidebar-item::part(list-item) {
}
.pf-c-drawer__panel {
z-index: var(--pf-global--ZIndex--xl);
background-color: transparent !important;
}

View File

@@ -2,8 +2,6 @@ import "#admin/AdminInterface/AboutModal";
import "#elements/banner/EnterpriseStatusBanner";
import "#elements/banner/VersionBanner";
import "#elements/messages/MessageContainer";
import "#elements/notifications/APIDrawer";
import "#elements/notifications/NotificationDrawer";
import "#elements/router/RouterOutlet";
import "#elements/sidebar/Sidebar";
import "#elements/sidebar/SidebarItem";
@@ -15,15 +13,22 @@ import {
} from "./AdminSidebar.js";
import { isAPIResultReady } from "#common/api/responses";
import { EVENT_API_DRAWER_TOGGLE, EVENT_NOTIFICATION_DRAWER_TOGGLE } from "#common/constants";
import { configureSentry } from "#common/sentry/index";
import { isGuest } from "#common/users";
import { WebsocketClient } from "#common/ws";
import { WebsocketClient } from "#common/ws/WebSocketClient";
import { AuthenticatedInterface } from "#elements/AuthenticatedInterface";
import { listen } from "#elements/decorators/listen";
import { WithCapabilitiesConfig } from "#elements/mixins/capabilities";
import { WithNotifications } from "#elements/mixins/notifications";
import { canAccessAdmin, WithSession } from "#elements/mixins/session";
import { getURLParam, updateURLParams } from "#elements/router/RouteMatch";
import { AKDrawerChangeEvent } from "#elements/notifications/events";
import {
DrawerState,
persistDrawerParams,
readDrawerParams,
renderNotificationDrawerPanel,
} from "#elements/notifications/utils";
import { PageNavMenuToggle } from "#components/ak-page-navbar";
@@ -34,29 +39,37 @@ import { ROUTES } from "#admin/Routes";
import { CapabilitiesEnum } from "@goauthentik/api";
import { CSSResult, html, nothing, PropertyValues, TemplateResult } from "lit";
import { customElement, property, query } from "lit/decorators.js";
import { customElement, property, query, state } from "lit/decorators.js";
import { classMap } from "lit/directives/class-map.js";
import PFButton from "@patternfly/patternfly/components/Button/button.css";
import PFDrawer from "@patternfly/patternfly/components/Drawer/drawer.css";
import PFNav from "@patternfly/patternfly/components/Nav/nav.css";
import PFPage from "@patternfly/patternfly/components/Page/page.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
if (process.env.NODE_ENV === "development") {
await import("@goauthentik/esbuild-plugin-live-reload/client");
}
@customElement("ak-interface-admin")
export class AdminInterface extends WithCapabilitiesConfig(WithSession(AuthenticatedInterface)) {
export class AdminInterface extends WithCapabilitiesConfig(
WithNotifications(WithSession(AuthenticatedInterface)),
) {
//#region Styles
public static readonly styles: CSSResult[] = [
// ---
PFPage,
PFButton,
PFDrawer,
PFNav,
Styles,
];
//#endregion
//#region Properties
@property({ type: Boolean })
public notificationDrawerOpen = getURLParam("notificationDrawerOpen", false);
@property({ type: Boolean })
public apiDrawerOpen = getURLParam("apiDrawerOpen", false);
@query("ak-about-modal")
public aboutModal?: AboutModal;
@@ -74,19 +87,14 @@ export class AdminInterface extends WithCapabilitiesConfig(WithSession(Authentic
//#endregion
//#region Styles
@state()
protected drawer: DrawerState = readDrawerParams();
static styles: CSSResult[] = [
// ---
PFBase,
PFPage,
PFButton,
PFDrawer,
PFNav,
Styles,
];
//#endregion
@listen(AKDrawerChangeEvent)
protected drawerListener = (event: AKDrawerChangeEvent) => {
this.drawer = event.drawer;
persistDrawerParams(event.drawer);
};
//#region Lifecycle
@@ -99,6 +107,7 @@ export class AdminInterface extends WithCapabilitiesConfig(WithSession(Authentic
this.#sidebarMatcher = window.matchMedia("(min-width: 1200px)");
this.sidebarOpen = this.#sidebarMatcher.matches;
this.addEventListener(PageNavMenuToggle.eventName, this.#onPageNavMenuEvent, {
passive: true,
});
@@ -107,20 +116,6 @@ export class AdminInterface extends WithCapabilitiesConfig(WithSession(Authentic
public connectedCallback() {
super.connectedCallback();
window.addEventListener(EVENT_NOTIFICATION_DRAWER_TOGGLE, () => {
this.notificationDrawerOpen = !this.notificationDrawerOpen;
updateURLParams({
notificationDrawerOpen: this.notificationDrawerOpen,
});
});
window.addEventListener(EVENT_API_DRAWER_TOGGLE, () => {
this.apiDrawerOpen = !this.apiDrawerOpen;
updateURLParams({
apiDrawerOpen: this.apiDrawerOpen,
});
});
this.#sidebarMatcher.addEventListener("change", this.#sidebarMediaQueryListener, {
passive: true,
});
@@ -128,6 +123,7 @@ export class AdminInterface extends WithCapabilitiesConfig(WithSession(Authentic
public disconnectedCallback(): void {
super.disconnectedCallback();
this.#sidebarMatcher.removeEventListener("change", this.#sidebarMediaQueryListener);
WebsocketClient.close();
@@ -154,11 +150,10 @@ export class AdminInterface extends WithCapabilitiesConfig(WithSession(Authentic
"pf-m-collapsed": !this.sidebarOpen,
};
const drawerOpen = this.notificationDrawerOpen || this.apiDrawerOpen;
const openDrawerCount = (this.drawer.notifications ? 1 : 0) + (this.drawer.api ? 1 : 0);
const drawerClasses = {
"pf-m-expanded": drawerOpen,
"pf-m-collapsed": !drawerOpen,
"pf-m-expanded": openDrawerCount !== 0,
"pf-m-collapsed": openDrawerCount === 0,
};
return html`<div class="pf-c-page">
@@ -190,18 +185,7 @@ export class AdminInterface extends WithCapabilitiesConfig(WithSession(Authentic
</ak-router-outlet>
</div>
</div>
<ak-notification-drawer
class="pf-c-drawer__panel pf-m-width-33 ${this.notificationDrawerOpen
? ""
: "display-none"}"
?hidden=${!this.notificationDrawerOpen}
></ak-notification-drawer>
<ak-api-drawer
class="pf-c-drawer__panel pf-m-width-33 ${this.apiDrawerOpen
? ""
: "display-none"}"
?hidden=${!this.apiDrawerOpen}
></ak-api-drawer>
${renderNotificationDrawerPanel(this.drawer)}
<ak-about-modal></ak-about-modal>
</div>
</div>

View File

@@ -52,7 +52,6 @@ export class DeviceAccessGroupsListPage extends TablePage<DeviceAccessGroup> {
</pf-tooltip>
</button>
</ak-forms-modal>
<div></div>
</div>`,
];
}

View File

@@ -1,8 +1,7 @@
import { APIError } from "#common/errors/network";
import { MessageLevel } from "#common/messages";
import { APIMessage, MessageLevel } from "#common/messages";
import { ModelForm } from "#elements/forms/ModelForm";
import { APIMessage } from "#elements/messages/Message";
import { msg } from "@lit/localize";

View File

@@ -1,10 +1,9 @@
import "#components/ak-text-input";
import { DEFAULT_CONFIG } from "#common/api/config";
import { MessageLevel } from "#common/messages";
import { APIMessage, MessageLevel } from "#common/messages";
import { Form } from "#elements/forms/Form";
import { APIMessage } from "#elements/messages/Message";
import { AdminApi, CoreApi, ImpersonationRequest } from "@goauthentik/api";

View File

@@ -0,0 +1,34 @@
/**
* @file API event utilities.
*/
/**
* Information about a completed API request.
*/
export interface APIRequestInfo {
time: number;
method: string;
path: string;
status: number;
}
/**
* Event dispatched via EventMiddleware after an API request is completed.
*/
export class AKRequestPostEvent extends Event {
public static readonly eventName = "ak-request-post";
public readonly requestInfo: APIRequestInfo;
constructor(requestInfo: APIRequestInfo) {
super(AKRequestPostEvent.eventName, { bubbles: true, composed: true });
this.requestInfo = requestInfo;
}
}
declare global {
interface WindowEventMap {
[AKRequestPostEvent.eventName]: AKRequestPostEvent;
}
}

View File

@@ -1,7 +1,9 @@
import { EVENT_REQUEST_POST } from "#common/constants";
import { AKRequestPostEvent, APIRequestInfo } from "#common/api/events";
import { autoDetectLanguage } from "#common/ui/locale/utils";
import { getCookie } from "#common/utils";
import { ConsoleLogger, Logger } from "#logger/browser";
import {
CurrentBrand,
FetchParams,
@@ -15,29 +17,26 @@ import { LOCALE_STATUS_EVENT, LocaleStatusEventDetail } from "@lit/localize";
export const CSRFHeaderName = "X-authentik-CSRF";
export const AcceptLanguage = "Accept-Language";
export interface RequestInfo {
time: number;
method: string;
path: string;
status: number;
}
export class LoggingMiddleware implements Middleware {
brand: CurrentBrand;
#logger: Logger;
constructor(brand: CurrentBrand) {
this.brand = brand;
const prefix =
brand.matchedDomain === "authentik-default" ? "api" : `api/${brand.matchedDomain}`;
this.#logger = ConsoleLogger.prefix(prefix);
}
post(context: ResponseContext): Promise<Response | void> {
let msg = `authentik/api[${this.brand.matchedDomain}]: `;
// https://developer.mozilla.org/en-US/docs/Web/API/console#styling_console_output
msg += `%c${context.response.status}%c ${context.init.method} ${context.url}`;
let style = "";
if (context.response.status >= 400) {
style = "color: red; font-weight: bold;";
post({ response, init, url }: ResponseContext): Promise<Response> {
const parsedURL = URL.canParse(url) ? new URL(url) : null;
const path = parsedURL ? parsedURL.pathname + parsedURL.search : url;
if (response.ok) {
this.#logger.debug(`${init.method} ${path}`);
} else {
this.#logger.warn(`${response.status} ${init.method} ${path}`);
}
console.debug(msg, style, "");
return Promise.resolve(context.response);
return Promise.resolve(response);
}
}
@@ -54,19 +53,15 @@ export class CSRFMiddleware implements Middleware {
export class EventMiddleware implements Middleware {
post?(context: ResponseContext): Promise<Response | void> {
const request: RequestInfo = {
const requestInfo: APIRequestInfo = {
time: new Date().getTime(),
method: (context.init.method || "GET").toUpperCase(),
path: context.url,
status: context.response.status,
};
window.dispatchEvent(
new CustomEvent(EVENT_REQUEST_POST, {
bubbles: true,
composed: true,
detail: request,
}),
);
window.dispatchEvent(new AKRequestPostEvent(requestInfo));
return Promise.resolve(context.response);
}
}

View File

@@ -6,6 +6,9 @@
/// <reference types="../../types/esbuild.js" />
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import type { AKEnterpriseRefreshEvent, AKRefreshEvent } from "#common/events";
//#region Patternfly
export const SECONDARY_CLASS = "pf-m-secondary";
@@ -29,28 +32,22 @@ export const ROUTE_SEPARATOR = ";";
//#region Events
/**
* Event name for refresh events.
*
* @deprecated Use {@linkcode AKRefreshEvent}
*/
export const EVENT_REFRESH = "ak-refresh";
export const EVENT_NOTIFICATION_DRAWER_TOGGLE = "ak-notification-toggle";
export const EVENT_API_DRAWER_TOGGLE = "ak-api-drawer-toggle";
export const EVENT_FLOW_INSPECTOR_TOGGLE = "ak-flow-inspector-toggle";
export const EVENT_WS_MESSAGE = "ak-ws-message";
export const EVENT_FLOW_ADVANCE = "ak-flow-advance";
export const EVENT_LOCALE_REQUEST = "ak-locale-request";
export const EVENT_REQUEST_POST = "ak-request-post";
export const EVENT_MESSAGE = "ak-message";
export const EVENT_THEME_CHANGE = "ak-theme-change";
/**
* Event name for enterprise refresh events.
*
* @deprecated Use {@linkcode AKEnterpriseRefreshEvent}
*/
export const EVENT_REFRESH_ENTERPRISE = "ak-refresh-enterprise";
//#endregion
//#region WebSocket
export const WS_MSG_TYPE_MESSAGE = "message";
export const WS_MSG_TYPE_NOTIFICATION = "notification.new";
export const WS_MSG_TYPE_REFRESH = "refresh";
//#endregion
//#region LocalStorage
export const LOCALSTORAGE_AUTHENTIK_KEY = "authentik-local-settings";

View File

@@ -1,4 +1,26 @@
import { Event } from "@goauthentik/api";
import { Event as EventSerializer } from "@goauthentik/api";
/**
* Event dispatched when the UI should refresh.
*/
export class AKRefreshEvent extends Event {
public static readonly eventName = "ak-refresh";
constructor() {
super(AKRefreshEvent.eventName, { bubbles: true, composed: true });
}
}
/**
* Event dispatched when a change in enterprise features requires a refresh.
*/
export class AKEnterpriseRefreshEvent extends Event {
public static readonly eventName = "ak-refresh-enterprise";
constructor() {
super(AKEnterpriseRefreshEvent.eventName, { bubbles: true, composed: true });
}
}
export interface EventUser {
pk: number;
@@ -38,7 +60,7 @@ export interface EventContext {
device?: EventModel;
}
export interface EventWithContext extends Event {
export interface EventWithContext extends EventSerializer {
user: EventUser;
context: EventContext;
}

View File

@@ -1,6 +1,36 @@
import type { TemplateResult } from "lit";
export enum MessageLevel {
error = "error",
warning = "warning",
success = "success",
info = "info",
}
/**
* An error message returned from an API endpoint.
*
* @remarks
* This interface must align with the server-side event dispatcher.
*
* @see {@link ../authentik/core/templates/base/skeleton.html}
*/
export interface APIMessage {
level: MessageLevel;
message: string;
description?: string | TemplateResult;
}
export class AKMessageEvent extends Event {
static readonly eventName = "ak-message";
constructor(public readonly message: APIMessage) {
super(AKMessageEvent.eventName, { bubbles: true, composed: true });
}
}
declare global {
interface WindowEventMap {
[AKMessageEvent.eventName]: AKMessageEvent;
}
}

View File

@@ -2,6 +2,8 @@ import { globalAK } from "#common/global";
import { readInterfaceRouteParam } from "#elements/router/utils";
import { ConsoleLogger } from "#logger/browser";
import { CapabilitiesEnum, ResponseError } from "@goauthentik/api";
import {
@@ -49,6 +51,8 @@ export function configureSentry(): void {
return;
}
const logger = ConsoleLogger.prefix("sentry");
const integrations: Integration[] = [
browserTracingIntegration({
// https://docs.sentry.io/platforms/javascript/tracing/instrumentation/automatic-instrumentation/#custom-routing
@@ -59,7 +63,7 @@ export function configureSentry(): void {
];
if (debug) {
console.debug("authentik/config: Enabled Sentry Spotlight");
logger.debug("Enabled Spotlight");
integrations.push(spotlightBrowserIntegration());
}
@@ -90,5 +94,5 @@ export function configureSentry(): void {
setTag(TAG_SENTRY_COMPONENT, `web/${readInterfaceRouteParam()}`);
}
console.debug("authentik/config: Sentry enabled.");
logger.debug("Initialized!");
}

View File

@@ -1,19 +1,20 @@
import { EVENT_MESSAGE, EVENT_WS_MESSAGE } from "#common/constants";
import { globalAK } from "#common/global";
import { MessageLevel } from "#common/messages";
import { createEventFromWSMessage, WSMessage } from "#common/ws/events";
import { showMessage } from "#elements/messages/MessageContainer";
import { ConsoleLogger } from "#logger/browser";
import { msg } from "@lit/localize";
export interface WSMessage {
message_type: string;
}
/**
* A websocket client that automatically reconnects.
*
* @singleton
*/
export class WebsocketClient extends WebSocket implements Disposable {
static #logger = ConsoleLogger.prefix("ws");
static #connection: WebsocketClient | null = null;
public static get connection(): WebsocketClient | null {
@@ -45,6 +46,7 @@ export class WebsocketClient extends WebSocket implements Disposable {
}
#retryDelay = 200;
#connectionTimeoutID = -1;
//#endregion
@@ -94,17 +96,17 @@ export class WebsocketClient extends WebSocket implements Disposable {
#messageListener = (e: MessageEvent<string>) => {
const data: WSMessage = JSON.parse(e.data);
window.dispatchEvent(
new CustomEvent(EVENT_WS_MESSAGE, {
bubbles: true,
composed: true,
detail: data,
}),
);
const event = createEventFromWSMessage(data);
if (event) {
window.dispatchEvent(event);
}
};
#openListener = () => {
console.debug(`authentik/ws: connected to ${this.url}`);
window.clearTimeout(this.#connectionTimeoutID);
WebsocketClient.#logger.debug(`Connected to ${this.url}`);
WebsocketClient.#connection = this;
@@ -112,25 +114,24 @@ export class WebsocketClient extends WebSocket implements Disposable {
};
#closeListener = (event: CloseEvent) => {
console.debug("authentik/ws: closed ws connection", event);
window.clearTimeout(this.#connectionTimeoutID);
WebsocketClient.#logger.warn("Connection closed", event);
WebsocketClient.#connection = null;
if (this.#retryDelay > 6000) {
window.dispatchEvent(
new CustomEvent(EVENT_MESSAGE, {
bubbles: true,
composed: true,
detail: {
level: MessageLevel.error,
message: msg("Connection error, reconnecting..."),
},
}),
showMessage(
{
level: MessageLevel.error,
message: msg("Connection error, reconnecting..."),
},
true,
);
}
setTimeout(() => {
console.debug(`authentik/ws: reconnecting ws in ${this.#retryDelay}ms`);
this.#connectionTimeoutID = window.setTimeout(() => {
WebsocketClient.#logger.info(`Reconnecting in ${this.#retryDelay}ms`);
WebsocketClient.connect();
}, this.#retryDelay);

102
web/src/common/ws/events.ts Normal file
View File

@@ -0,0 +1,102 @@
/**
* WebSocket event definitions.
*/
import { EVENT_REFRESH } from "#common/constants";
import { AKMessageEvent, APIMessage } from "#common/messages";
import { Notification, NotificationFromJSON } from "@goauthentik/api";
//#region WebSocket Messages
export enum WSMessageType {
Message = "message",
NotificationNew = "notification.new",
Refresh = "refresh",
SessionAuthenticated = "session.authenticated",
}
export interface WSMessageMessage extends APIMessage {
message_type: WSMessageType.Message;
}
export interface WSMessageNotification {
id: string;
data: Notification;
message_type: WSMessageType.NotificationNew;
}
export interface WSMessageRefresh {
message_type: WSMessageType.Refresh;
}
export interface WSMessageSessionAuthenticated {
message_type: WSMessageType.SessionAuthenticated;
}
export type WSMessage =
| WSMessageMessage
| WSMessageNotification
| WSMessageRefresh
| WSMessageSessionAuthenticated;
//#endregion
//#region WebSocket Events
export class AKNotificationEvent extends Event {
static readonly eventName = "ak-notification";
public readonly notification: Notification;
constructor(input: Partial<Notification>) {
super(AKNotificationEvent.eventName, { bubbles: true, composed: true });
this.notification = NotificationFromJSON(input);
}
}
export class AKSessionAuthenticatedEvent extends Event {
static readonly eventName = "ak-session-authenticated";
constructor() {
super(AKSessionAuthenticatedEvent.eventName, { bubbles: true, composed: true });
}
}
//#endregion
//#region Utilities
/**
* Create an Event from a {@linkcode WSMessage}.
*
* @throws {TypeError} If the message type is unknown.
*/
export function createEventFromWSMessage(message: WSMessage): Event {
switch (message.message_type) {
case WSMessageType.Message:
return new AKMessageEvent(message);
case WSMessageType.NotificationNew:
return new AKNotificationEvent(message.data);
case WSMessageType.Refresh:
return new CustomEvent(EVENT_REFRESH, {
bubbles: true,
composed: true,
});
case WSMessageType.SessionAuthenticated:
return new AKSessionAuthenticatedEvent();
default: {
throw new TypeError(`Unknown WS message type: ${message satisfies never}`, {
cause: message,
});
}
}
}
declare global {
interface WindowEventMap {
[AKNotificationEvent.eventName]: AKNotificationEvent;
[AKSessionAuthenticatedEvent.eventName]: AKSessionAuthenticatedEvent;
}
}

View File

@@ -4,21 +4,23 @@ import "#elements/buttons/ActionButton/ak-action-button";
import "@patternfly/elements/pf-tooltip/pf-tooltip.js";
import { DEFAULT_CONFIG } from "#common/api/config";
import { EVENT_API_DRAWER_TOGGLE, EVENT_NOTIFICATION_DRAWER_TOGGLE } from "#common/constants";
import { globalAK } from "#common/global";
import { formatUserDisplayName, isGuest } from "#common/users";
import { formatUserDisplayName } from "#common/users";
import { AKElement } from "#elements/Base";
import { WithNotifications } from "#elements/mixins/notifications";
import { WithSession } from "#elements/mixins/session";
import { AKDrawerChangeEvent } from "#elements/notifications/events";
import { isDefaultAvatar } from "#elements/utils/images";
import Styles from "#components/ak-nav-button.css";
import { CoreApi, EventsApi } from "@goauthentik/api";
import { CoreApi } from "@goauthentik/api";
import { msg } from "@lit/localize";
import { html, nothing } from "lit";
import { customElement, property } from "lit/decorators.js";
import { guard } from "lit/directives/guard.js";
import PFAvatar from "@patternfly/patternfly/components/Avatar/avatar.css";
import PFBrand from "@patternfly/patternfly/components/Brand/brand.css";
@@ -31,16 +33,13 @@ import PFBase from "@patternfly/patternfly/patternfly-base.css";
import PFDisplay from "@patternfly/patternfly/utilities/Display/display.css";
@customElement("ak-nav-buttons")
export class NavigationButtons extends WithSession(AKElement) {
export class NavigationButtons extends WithNotifications(WithSession(AKElement)) {
@property({ type: Boolean, reflect: true })
notificationDrawerOpen = false;
@property({ type: Boolean, reflect: true })
apiDrawerOpen = false;
@property({ type: Number })
notificationsCount = 0;
static styles = [
PFBase,
PFDisplay,
@@ -54,80 +53,78 @@ export class NavigationButtons extends WithSession(AKElement) {
Styles,
];
connectedCallback(): void {
super.connectedCallback();
this.refreshNotifications();
}
protected renderAPIDrawerTrigger() {
const { apiDrawer } = this.uiConfig.enabledFeatures;
protected async refreshNotifications(): Promise<void> {
const { currentUser } = this;
return guard([apiDrawer], () => {
if (!apiDrawer) {
return nothing;
}
if (!currentUser || isGuest(currentUser)) {
return;
}
const notifications = await new EventsApi(DEFAULT_CONFIG).eventsNotificationsList({
seen: false,
ordering: "-created",
pageSize: 1,
user: currentUser.pk,
});
this.notificationsCount = notifications.pagination.count;
}
renderApiDrawerTrigger() {
if (!this.uiConfig?.enabledFeatures.apiDrawer) {
return nothing;
}
const onClick = (ev: Event) => {
ev.stopPropagation();
this.dispatchEvent(
new Event(EVENT_API_DRAWER_TOGGLE, { bubbles: true, composed: true }),
);
};
return html`<div class="pf-c-page__header-tools-item pf-m-hidden pf-m-visible-on-xl">
<button class="pf-c-button pf-m-plain" type="button" @click=${onClick}>
<pf-tooltip position="top" content=${msg("Open API drawer")}>
<i class="fas fa-code" aria-hidden="true"></i>
</pf-tooltip>
</button>
</div>`;
}
renderNotificationDrawerTrigger() {
if (!this.uiConfig?.enabledFeatures.notificationDrawer) {
return nothing;
}
const onClick = (ev: Event) => {
ev.stopPropagation();
this.dispatchEvent(
new Event(EVENT_NOTIFICATION_DRAWER_TOGGLE, { bubbles: true, composed: true }),
);
};
return html`<div class="pf-c-page__header-tools-item pf-m-hidden pf-m-visible-on-xl">
<button
class="pf-c-button pf-m-plain"
type="button"
aria-label="${msg("Unread notifications")}"
@click=${onClick}
>
<span
class="pf-c-notification-badge ${this.notificationsCount > 0
? "pf-m-unread"
: ""}"
return html`<div class="pf-c-page__header-tools-item pf-m-hidden pf-m-visible-on-xl">
<button
id="api-drawer-toggle-button"
class="pf-c-button pf-m-plain"
type="button"
aria-label=${msg("Toggle API requests drawer", {
id: "drawer-toggle-button-api-requests",
})}
@click=${AKDrawerChangeEvent.dispatchAPIToggle}
>
<pf-tooltip position="top" content=${msg("Open Notification drawer")}>
<i class="fas fa-bell" aria-hidden="true"></i>
<pf-tooltip
position="top"
content=${msg("API Drawer")}
trigger="api-drawer-toggle-button"
>
<i class="fas fa-code" aria-hidden="true"></i>
</pf-tooltip>
<span class="pf-c-notification-badge__count">${this.notificationsCount}</span>
</span>
</button>
</div> `;
</button>
</div>`;
});
}
protected renderNotificationDrawerTrigger() {
const { notificationDrawer } = this.uiConfig.enabledFeatures;
const notificationCount = this.notificationCount;
return guard([notificationDrawer, notificationCount], () => {
if (!notificationDrawer) {
return nothing;
}
return html`<div class="pf-c-page__header-tools-item pf-m-hidden pf-m-visible-on-xl">
<button
id="notification-drawer-toggle-button"
class="pf-c-button pf-m-plain"
type="button"
aria-label=${msg("Toggle notifications drawer", {
id: "drawer-toggle-button-notifications",
})}
aria-describedby="notification-count"
@click=${AKDrawerChangeEvent.dispatchNotificationsToggle}
>
<span class="pf-c-notification-badge ${notificationCount ? "pf-m-unread" : ""}">
<pf-tooltip
position="top"
content=${msg("Notification Drawer", {
id: "drawer-invoker-tooltip-notifications",
})}
trigger="notification-drawer-toggle-button"
>
<i class="fas fa-bell" aria-hidden="true"></i>
</pf-tooltip>
<span
id="notification-count"
class="pf-c-notification-badge__count"
aria-live="polite"
>
${notificationCount}
<span class="sr-only">unread</span>
</span>
</span>
</button>
</div>`;
});
}
renderSettings() {
@@ -140,6 +137,7 @@ export class NavigationButtons extends WithSession(AKElement) {
class="pf-c-button pf-m-plain"
type="button"
href="${globalAK().api.base}if/user/#/settings"
aria-label=${msg("Settings")}
>
<pf-tooltip position="top" content=${msg("Settings")}>
<i class="fas fa-cog" aria-hidden="true"></i>
@@ -192,7 +190,7 @@ export class NavigationButtons extends WithSession(AKElement) {
return html`<div role="presentation" class="pf-c-page__header-tools">
<div class="pf-c-page__header-tools-group">
${this.renderApiDrawerTrigger()}
${this.renderAPIDrawerTrigger()}
<!-- -->
${this.renderNotificationDrawerTrigger()}
<!-- -->

View File

@@ -1,9 +1,7 @@
import "#components/ak-nav-buttons";
import "@patternfly/elements/pf-tooltip/pf-tooltip.js";
import { EVENT_WS_MESSAGE } from "#common/constants";
import { globalAK } from "#common/global";
import { UserDisplay } from "#common/ui/config";
import { AKElement } from "#elements/Base";
import { WithBrandConfig } from "#elements/mixins/branding";
@@ -318,10 +316,6 @@ export class AKPageNavbar
//#region Event Handlers
#onWebSocket = () => {
this.firstUpdated();
};
#onPageDetails = (ev: PageDetailsUpdate) => {
const { header, description, icon, iconImage } = ev.header;
this.header = header;
@@ -337,20 +331,14 @@ export class AKPageNavbar
public connectedCallback(): void {
super.connectedCallback();
window.addEventListener(EVENT_WS_MESSAGE, this.#onWebSocket);
window.addEventListener(PageDetailsUpdate.eventName, this.#onPageDetails);
}
public disconnectedCallback(): void {
window.removeEventListener(EVENT_WS_MESSAGE, this.#onWebSocket);
window.removeEventListener(PageDetailsUpdate.eventName, this.#onPageDetails);
super.disconnectedCallback();
}
public async firstUpdated() {
this.uiConfig.navbar.userDisplay = UserDisplay.none;
}
willUpdate() {
// Always update title, even if there's no header value set,
// as in that case we still need to return to the generic title

View File

@@ -1,14 +1,18 @@
import { LicenseContextController } from "#elements/controllers/LicenseContextController";
import { NotificationsContextController } from "#elements/controllers/NotificationsContextController";
import { SessionContextController } from "#elements/controllers/SessionContextController";
import { VersionContextController } from "#elements/controllers/VersionContextController";
import { Interface } from "#elements/Interface";
import { NotificationsContext } from "#elements/mixins/notifications";
import { SessionContext } from "#elements/mixins/session";
export class AuthenticatedInterface extends Interface {
constructor() {
super();
this.addController(new LicenseContextController(this));
this.addController(new SessionContextController(this));
this.addController(new SessionContextController(this), SessionContext);
this.addController(new VersionContextController(this));
this.addController(new NotificationsContextController(this), NotificationsContext);
}
}

View File

@@ -1,7 +1,8 @@
import "#elements/EmptyState";
import { EVENT_REFRESH, EVENT_THEME_CHANGE } from "#common/constants";
import { EVENT_REFRESH } from "#common/constants";
import { DOM_PURIFY_STRICT } from "#common/purify";
import { ThemeChangeEvent } from "#common/theme";
import { AKElement } from "#elements/Base";
@@ -58,7 +59,7 @@ export class Diagram extends AKElement {
firstUpdated(): void {
if (this.handlerBound) return;
window.addEventListener(EVENT_REFRESH, this.refreshHandler);
this.addEventListener(EVENT_THEME_CHANGE, ((ev: CustomEvent<UiThemeEnum>) => {
this.addEventListener(ThemeChangeEvent.eventName, ((ev: CustomEvent<UiThemeEnum>) => {
if (ev.detail === UiThemeEnum.Dark) {
this.config.theme = "dark";
} else {

View File

@@ -4,8 +4,13 @@ import { applyDocumentTheme, createUIThemeEffect } from "#common/theme";
import { AKElement } from "#elements/Base";
import { BrandingContextController } from "#elements/controllers/BrandContextController";
import { ConfigContextController } from "#elements/controllers/ConfigContextController";
import { ContextControllerRegistry } from "#elements/controllers/ContextControllerRegistry";
import { LocaleContextController } from "#elements/controllers/LocaleContextController";
import { ModalOrchestrationController } from "#elements/controllers/ModalOrchestrationController";
import { ReactiveContextController } from "#elements/types";
import { Context, ContextType } from "@lit/context";
import { ReactiveController } from "lit";
/**
* The base interface element for the application.
@@ -24,6 +29,17 @@ export abstract class Interface extends AKElement {
this.addController(new ModalOrchestrationController());
}
public override addController(
controller: ReactiveController,
registryKey?: ContextType<Context<unknown, unknown>>,
): void {
super.addController(controller);
if (registryKey) {
ContextControllerRegistry.set(registryKey, controller as ReactiveContextController);
}
}
public connectedCallback(): void {
super.connectedCallback();
this.dataset.testId = "interface-root";

View File

@@ -4,7 +4,7 @@ import "../ak-list-select.js";
import { ListSelect } from "../ak-list-select.js";
import { groupedSampleData, sampleData } from "./sampleData.js";
import { EVENT_MESSAGE } from "#common/constants";
import { APIMessage, MessageLevel } from "#common/messages";
import { Meta, StoryObj } from "@storybook/web-components";
import { kebabCase } from "change-case";
@@ -40,7 +40,14 @@ type Story = StoryObj;
const sendMessage = (message: string) =>
document.dispatchEvent(
new CustomEvent(EVENT_MESSAGE, { bubbles: true, composed: true, detail: { message } }),
new CustomEvent<APIMessage>("ak-message", {
bubbles: true,
composed: true,
detail: {
level: MessageLevel.info,
message,
},
}),
);
const container = (testItem: TemplateResult) => {

View File

@@ -1,9 +1,7 @@
import { APIMessage } from "../../messages/Message.js";
import BaseTaskButton from "../SpinnerButton/BaseTaskButton.js";
import { DEFAULT_CONFIG } from "#common/api/config";
import { MessageLevel } from "#common/messages";
import { APIMessage, MessageLevel } from "#common/messages";
import { BaseTaskButton } from "#elements/buttons/SpinnerButton/BaseTaskButton";
import { showMessage } from "#elements/messages/MessageContainer";
import { writeToClipboard } from "#elements/utils/writeToClipboard";

View File

@@ -1,9 +1,10 @@
import "#elements/EmptyState";
import "chartjs-adapter-date-fns";
import { EVENT_REFRESH, EVENT_THEME_CHANGE } from "#common/constants";
import { EVENT_REFRESH } from "#common/constants";
import { APIError, parseAPIResponseError, pluckErrorDetail } from "#common/errors/network";
import { formatElapsedTime } from "#common/temporal";
import { ThemeChangeEvent } from "#common/theme";
import { AKElement } from "#elements/Base";
@@ -88,7 +89,7 @@ export abstract class AKChart<T> extends AKElement {
super.connectedCallback();
window.addEventListener("resize", this.resizeHandler);
this.addEventListener(EVENT_REFRESH, this.refreshHandler);
this.addEventListener(EVENT_THEME_CHANGE, ((ev: CustomEvent<UiThemeEnum>) => {
this.addEventListener(ThemeChangeEvent.eventName, ((ev: CustomEvent<UiThemeEnum>) => {
if (ev.detail === UiThemeEnum.Light) {
this.fontColour = FONT_COLOUR_LIGHT_MODE;
} else {

View File

@@ -11,18 +11,18 @@ import { ContextProvider } from "@lit/context";
export class BrandingContextController extends ReactiveContextController<CurrentBrand> {
protected static override logPrefix = "branding";
#host: ReactiveElementHost<BrandingMixin>;
#context: ContextProvider<BrandingContext>;
public host: ReactiveElementHost<BrandingMixin>;
public context: ContextProvider<BrandingContext>;
constructor(host: ReactiveElementHost<BrandingMixin>, initialValue: CurrentBrand) {
super();
this.#host = host;
this.#context = new ContextProvider(this.#host, {
this.host = host;
this.context = new ContextProvider(this.host, {
context: BrandingContext,
initialValue,
});
this.#host.brand = initialValue;
this.host.brand = initialValue;
}
protected apiEndpoint(requestInit?: RequestInit) {
@@ -30,15 +30,15 @@ export class BrandingContextController extends ReactiveContextController<Current
}
protected doRefresh(brand: CurrentBrand) {
this.#context.setValue(brand);
this.#host.brand = brand;
this.context.setValue(brand);
this.host.brand = brand;
}
public hostUpdate() {
// If the Interface changes its brand information for some reason,
// we should notify all users of the context of that change. doesn't
if (this.#host.brand && this.#host.brand !== this.#context.value) {
this.#context.setValue(this.#host.brand);
if (this.host.brand && this.host.brand !== this.context.value) {
this.context.setValue(this.host.brand);
}
}
}

View File

@@ -14,19 +14,19 @@ import { ContextProvider } from "@lit/context";
export class ConfigContextController extends ReactiveContextController<Config> {
protected static override logPrefix = "config";
#host: ReactiveElementHost<AKConfigMixin>;
#context: ContextProvider<AuthentikConfigContext>;
public host: ReactiveElementHost<AKConfigMixin>;
public context: ContextProvider<AuthentikConfigContext>;
constructor(host: ReactiveElementHost<AKConfigMixin>, initialValue: Config) {
super();
this.#host = host;
this.host = host;
this.#context = new ContextProvider(this.#host, {
this.context = new ContextProvider(this.host, {
context: AuthentikConfigContext,
initialValue,
});
this.#host[kAKConfig] = initialValue;
this.host[kAKConfig] = initialValue;
}
protected apiEndpoint(requestInit?: RequestInit) {
@@ -34,16 +34,16 @@ export class ConfigContextController extends ReactiveContextController<Config> {
}
protected doRefresh(authentikConfig: Config) {
this.#context.setValue(authentikConfig);
this.#host[kAKConfig] = authentikConfig;
this.context.setValue(authentikConfig);
this.host[kAKConfig] = authentikConfig;
}
public hostUpdate() {
// If the Interface changes its config information, we should notify all
// users of the context of that change, without creating an infinite
// loop of resets.
if (this.#host[kAKConfig] && this.#host[kAKConfig] !== this.#context.value) {
this.#context.setValue(this.#host[kAKConfig]);
if (this.host[kAKConfig] && this.host[kAKConfig] !== this.context.value) {
this.context.setValue(this.host[kAKConfig]);
}
}
}

View File

@@ -0,0 +1,12 @@
import { type ContextControllerRegistryMap } from "#elements/types";
/**
* A registry of context controllers added to the Interface.
*
* @singleton
*
* @remarks
*
* This is exported separately to avoid circular dependencies.
*/
export const ContextControllerRegistry = new WeakMap() as ContextControllerRegistryMap;

View File

@@ -15,14 +15,14 @@ export class LicenseContextController extends ReactiveContextController<LicenseS
protected static refreshEvent = EVENT_REFRESH_ENTERPRISE;
protected static logPrefix = "license";
#host: ReactiveElementHost<SessionMixin & LicenseMixin>;
#context: ContextProvider<LicenseContext>;
public host: ReactiveElementHost<SessionMixin & LicenseMixin>;
public context: ContextProvider<LicenseContext>;
constructor(host: ReactiveElementHost<LicenseMixin>, initialValue?: LicenseSummary) {
super();
this.#host = host;
this.#context = new ContextProvider(this.#host, {
this.host = host;
this.context = new ContextProvider(this.host, {
context: LicenseContext,
initialValue: initialValue,
});
@@ -33,12 +33,12 @@ export class LicenseContextController extends ReactiveContextController<LicenseS
}
protected doRefresh(licenseSummary: LicenseSummary) {
this.#context.setValue(licenseSummary);
this.#host.licenseSummary = licenseSummary;
this.context.setValue(licenseSummary);
this.host.licenseSummary = licenseSummary;
}
public hostUpdate() {
const { currentUser } = this.#host;
const { currentUser } = this.host;
if (currentUser && !isGuest(currentUser) && !this.abortController) {
this.refresh();

View File

@@ -7,6 +7,8 @@ import { autoDetectLanguage } from "#common/ui/locale/utils";
import { kAKLocale, LocaleContext, LocaleMixin } from "#elements/mixins/locale";
import type { ReactiveElementHost } from "#elements/types";
import { ConsoleLogger } from "#logger/browser";
import { ContextProvider } from "@lit/context";
import { configureLocalization, LOCALE_STATUS_EVENT, LocaleStatusEventDetail } from "@lit/localize";
import type { ReactiveController } from "lit";
@@ -21,7 +23,7 @@ export class LocaleContextController implements ReactiveController {
attributeOldValue: true,
};
#log = console.debug.bind(console, `authentik/controller/locale`);
protected logger = ConsoleLogger.prefix("controller/locale");
/**
* Attempts to apply the given locale code.
@@ -37,14 +39,14 @@ export class LocaleContextController implements ReactiveController {
const displayName = formatDisplayName(nextLocale, nextLocale, languageNames);
if (activeLanguageTag === nextLocale) {
this.#log("Skipping locale update, already set to:", displayName);
this.logger.debug("Skipping locale update, already set to:", displayName);
return;
}
this.#context.value.setLocale(nextLocale);
this.#host.activeLanguageTag = nextLocale;
this.#log("Applied locale:", displayName);
this.logger.info("Applied locale:", displayName);
}
// #region Attribute Observation
@@ -69,10 +71,10 @@ export class LocaleContextController implements ReactiveController {
current: document.documentElement.lang,
};
this.#log("Detected document `lang` attribute change", attribute);
this.logger.debug("Detected document `lang` attribute change", attribute);
if (attribute.previous === attribute.current) {
this.#log("Skipping locale update, `lang` unchanged", attribute);
this.logger.debug("Skipping locale update, `lang` unchanged", attribute);
continue;
}
@@ -121,7 +123,7 @@ export class LocaleContextController implements ReactiveController {
const displayName = formatDisplayName(locale, locale, languageNames);
this.#log(`Loading "${displayName}" module...`);
this.logger.debug(`Loading "${displayName}" module...`);
const loader = LocaleLoaderRecord[locale];
@@ -160,7 +162,7 @@ export class LocaleContextController implements ReactiveController {
#localeStatusListener = (event: CustomEvent<LocaleStatusEventDetail>) => {
if (event.detail.status === "error") {
this.#log("Error loading locale:", event.detail);
this.logger.debug("Error loading locale:", event.detail);
return;
}
@@ -169,7 +171,7 @@ export class LocaleContextController implements ReactiveController {
}
const { readyLocale } = event.detail;
this.#log(`Updating \`lang\` attribute to: \`${readyLocale}\``);
this.logger.debug(`Updating \`lang\` attribute to: \`${readyLocale}\``);
// Prevent observation while we update the `lang` attribute...
this.#disconnectDocumentObserver();

View File

@@ -0,0 +1,151 @@
import { DEFAULT_CONFIG } from "#common/api/config";
import { isAPIResultReady } from "#common/api/responses";
import { actionToLabel } from "#common/labels";
import { MessageLevel } from "#common/messages";
import { isGuest } from "#common/users";
import { AKNotificationEvent } from "#common/ws/events";
import { ReactiveContextController } from "#elements/controllers/ReactiveContextController";
import { showMessage } from "#elements/messages/MessageContainer";
import {
NotificationsContext,
NotificationsContextValue,
NotificationsMixin,
} from "#elements/mixins/notifications";
import { SessionMixin } from "#elements/mixins/session";
import { createPaginatedNotificationListFrom } from "#elements/notifications/utils";
import type { ReactiveElementHost } from "#elements/types";
import { EventsApi } from "@goauthentik/api";
import { ContextProvider } from "@lit/context";
import { msg } from "@lit/localize";
import { html, nothing } from "lit";
export class NotificationsContextController extends ReactiveContextController<NotificationsContextValue> {
protected static override logPrefix = "notifications";
public host: ReactiveElementHost<SessionMixin & NotificationsMixin>;
public context: ContextProvider<NotificationsContext>;
constructor(host: ReactiveElementHost<SessionMixin & NotificationsMixin>) {
super();
this.host = host;
this.context = new ContextProvider(this.host, {
context: NotificationsContext,
initialValue: { loading: true, error: null },
});
}
protected apiEndpoint(requestInit?: RequestInit) {
const fallback = createPaginatedNotificationListFrom();
const { session } = this.host;
if (!isAPIResultReady(session)) {
this.logger.info("Session not ready, skipping notifications refresh");
return Promise.resolve(fallback);
}
if (session.error) {
this.logger.warn("Session error, skipping notifications refresh");
return Promise.resolve(fallback);
}
if (!session.user || isGuest(session.user)) {
this.logger.info("No current user, skipping");
return Promise.resolve(fallback);
}
this.logger.debug("Fetching notifications...");
return new EventsApi(DEFAULT_CONFIG)
.eventsNotificationsList(
{
seen: false,
ordering: "-created",
user: session.user.pk,
},
{
...requestInit,
},
)
.then((data) => {
this.host.notifications = data;
return this.host.notifications;
});
}
protected doRefresh(notifications: NotificationsContextValue) {
this.context.setValue(notifications);
this.host.requestUpdate?.();
}
public override hostConnected() {
window.addEventListener(AKNotificationEvent.eventName, this.#messageListener, {
passive: true,
});
}
public override hostDisconnected() {
window.removeEventListener(AKNotificationEvent.eventName, this.#messageListener);
super.hostDisconnected();
}
public hostUpdate() {
const { currentUser } = this.host;
if (
currentUser &&
!isGuest(currentUser) &&
!isAPIResultReady(this.host.notifications) &&
!this.abortController
) {
this.refresh();
return;
}
if (!currentUser) {
this.abort("Session Invalidated");
}
}
#messageListener = ({ notification }: AKNotificationEvent) => {
showMessage({
level: MessageLevel.info,
message: actionToLabel(notification.event?.action) ?? notification.body,
description: html`${notification.body}
${notification.hyperlink
? html`<br /><a href=${notification.hyperlink}>${notification.hyperlinkLabel}</a>`
: nothing}
${notification.event
? html`<br /><a href="#/events/log/${notification.event.pk}"
>${msg("View details...")}</a
>`
: nothing}`,
});
const currentNotifications = this.context.value;
if (isAPIResultReady(currentNotifications)) {
this.logger.info("Adding notification to context");
const appended = createPaginatedNotificationListFrom([
notification,
...currentNotifications.results,
]);
this.context.setValue(appended);
this.host.requestUpdate?.();
} else if (currentNotifications?.error) {
this.logger.info("Current notifications context in error state, refreshing");
this.refresh();
}
};
}

View File

@@ -1,7 +1,14 @@
import { EVENT_REFRESH } from "#common/constants";
import { isCausedByAbortError, parseAPIResponseError } from "#common/errors/network";
import type { ReactiveController } from "lit";
import {
ReactiveContextController as IReactiveContextController,
ReactiveElementHost,
} from "#elements/types";
import { ConsoleLogger, Logger } from "#logger/browser";
import { Context, ContextProvider } from "@lit/context";
/**
* A base Lit controller for API-backed context providers.
@@ -9,7 +16,13 @@ import type { ReactiveController } from "lit";
* Subclasses must implement {@linkcode apiEndpoint} and {@linkcode doRefresh}
* to fetch data and update the context value, respectively.
*/
export abstract class ReactiveContextController<T extends object> implements ReactiveController {
export abstract class ReactiveContextController<
Value extends object,
Host extends object = object,
> implements IReactiveContextController<Context<symbol, Value>, Host> {
public abstract context: ContextProvider<Context<symbol, Value>>;
public abstract host: ReactiveElementHost<Host>;
/**
* A prefix for log messages from this controller.
*/
@@ -21,10 +34,8 @@ export abstract class ReactiveContextController<T extends object> implements Rea
/**
* Log a debug message with the controller's prefix.
*
* @todo Port `ConsoleLogger` here for better logging.
*/
protected debug: (...args: unknown[]) => void;
protected logger: Logger;
/**
* An {@linkcode AbortController} that can be used to cancel ongoing refreshes.
@@ -41,10 +52,9 @@ export abstract class ReactiveContextController<T extends object> implements Rea
protected hostAbortController: AbortController | null = null;
public constructor() {
const Constructor = this.constructor as typeof ReactiveContextController;
const logPrefix = Constructor.logPrefix;
const { logPrefix } = this.constructor as typeof ReactiveContextController;
this.debug = console.debug.bind(console, `authentik/context/${logPrefix}`);
this.logger = ConsoleLogger.prefix(`controller/${logPrefix}`);
}
/**
@@ -54,7 +64,7 @@ export abstract class ReactiveContextController<T extends object> implements Rea
*
* @see {@linkcode refresh} for fetching new data.
*/
protected abstract doRefresh(data: T): void | Promise<void>;
protected abstract doRefresh(data: Value): void | Promise<void>;
/**
* Fetches data from the API endpoint.
@@ -62,7 +72,7 @@ export abstract class ReactiveContextController<T extends object> implements Rea
* @param requestInit Optional request initialization parameters.
* @returns A promise that resolves to the fetched data.
*/
protected abstract apiEndpoint(requestInit?: RequestInit): Promise<T>;
protected abstract apiEndpoint(requestInit?: RequestInit): Promise<Value>;
/**
* Refreshes the context by calling the API endpoint and updating the context value.
@@ -70,10 +80,10 @@ export abstract class ReactiveContextController<T extends object> implements Rea
* @see {@linkcode apiEndpoint} for the API call.
* @see {@linkcode doRefresh} for updating the context value.
*/
protected refresh = (): Promise<void> => {
public refresh = (): Promise<Value | null> => {
this.abort("Refresh aborted by new refresh call");
this.debug("Refreshing...");
this.logger.debug("Refresh requested");
this.abortController?.abort();
@@ -82,9 +92,13 @@ export abstract class ReactiveContextController<T extends object> implements Rea
return this.apiEndpoint({
signal: this.abortController.signal,
})
.then((data) => this.doRefresh(data))
.then(async (data) => {
await this.doRefresh(data);
return data;
})
.catch(this.suppressAbortError)
.catch(this.reportSessionError);
.catch(this.reportRefreshError);
};
/**
@@ -94,9 +108,9 @@ export abstract class ReactiveContextController<T extends object> implements Rea
*/
protected suppressAbortError = (error: unknown) => {
if (isCausedByAbortError(error)) {
this.debug("Aborted:", error.message);
this.logger.info(`Aborted: ${error.message}`);
return;
return null;
}
throw error;
@@ -107,10 +121,12 @@ export abstract class ReactiveContextController<T extends object> implements Rea
*
* @param error The error to report.
*/
protected reportSessionError = async (error: unknown) => {
protected reportRefreshError = async (error: unknown) => {
const parsedError = await parseAPIResponseError(error);
this.debug(parsedError);
this.logger.info(parsedError);
return null;
};
/**

View File

@@ -1,14 +1,17 @@
import type { APIResult } from "#common/api/responses";
import { createSyntheticGenericError } from "#common/errors/network";
import { type APIResult, isAPIResultReady } from "#common/api/responses";
import { autoDetectLanguage } from "#common/ui/locale/utils";
import { me } from "#common/users";
import { ReactiveContextController } from "#elements/controllers/ReactiveContextController";
import { AKConfigMixin } from "#elements/mixins/config";
import { type LocaleMixin } from "#elements/mixins/locale";
import { AKConfigMixin, kAKConfig } from "#elements/mixins/config";
import { kAKLocale, type LocaleMixin } from "#elements/mixins/locale";
import { SessionContext, SessionMixin } from "#elements/mixins/session";
import type { ReactiveElementHost } from "#elements/types";
import { SessionUser } from "@goauthentik/api";
import { setUser } from "@sentry/browser";
import { ContextProvider } from "@lit/context";
/**
@@ -19,8 +22,8 @@ import { ContextProvider } from "@lit/context";
export class SessionContextController extends ReactiveContextController<APIResult<SessionUser>> {
protected static override logPrefix = "session";
#host: ReactiveElementHost<LocaleMixin & SessionMixin & AKConfigMixin>;
#context: ContextProvider<SessionContext>;
public host: ReactiveElementHost<LocaleMixin & SessionMixin & AKConfigMixin>;
public context: ContextProvider<SessionContext>;
constructor(
host: ReactiveElementHost<SessionMixin & AKConfigMixin>,
@@ -28,45 +31,48 @@ export class SessionContextController extends ReactiveContextController<APIResul
) {
super();
this.#host = host;
this.host = host;
this.#context = new ContextProvider(this.#host, {
this.context = new ContextProvider(this.host, {
context: SessionContext,
initialValue: initialValue ?? { loading: true, error: null },
});
}
protected apiEndpoint(requestInit?: RequestInit) {
if (!this.#host.refreshSession) {
// This situation is unlikely, but possible if a host reference becomes
// stale or is misconfigured.
this.debug(
"No `refreshSession` method available, skipping session fetch. Check if the `SessionMixin` is applied correctly.",
);
const result: APIResult<SessionUser> = {
loading: false,
error: createSyntheticGenericError("No `refreshSession` method available"),
};
return Promise.resolve(result);
}
return this.#host.refreshSession(requestInit);
return me(requestInit);
}
protected doRefresh(session: APIResult<SessionUser>) {
this.#context.setValue(session);
this.#host.requestUpdate?.();
this.context.setValue(session);
this.host.session = session;
if (isAPIResultReady(session)) {
const localeHint: string | undefined = session.user.settings.locale;
if (localeHint) {
const locale = autoDetectLanguage(localeHint);
this.logger.info(`Activating user's configured locale '${locale}'`);
this.host[kAKLocale]?.setLocale(locale);
}
const config = this.host[kAKConfig];
if (config?.errorReporting.sendPii) {
this.logger.info("Sentry with PII enabled.");
setUser({ email: session.user.email });
}
}
}
public override hostConnected() {
this.logger.debug("Host connected, refreshing session");
this.refresh();
}
public override hostDisconnected() {
this.#context.clearCallbacks();
this.context.clearCallbacks();
super.hostDisconnected();
}

View File

@@ -13,14 +13,14 @@ import { ContextProvider } from "@lit/context";
export class VersionContextController extends ReactiveContextController<Version> {
protected static override logPrefix = "version";
#host: ReactiveElementHost<SessionMixin & VersionMixin>;
#context: ContextProvider<VersionContext>;
public host: ReactiveElementHost<SessionMixin & VersionMixin>;
public context: ContextProvider<VersionContext>;
constructor(host: ReactiveElementHost<SessionMixin & VersionMixin>, initialValue?: Version) {
super();
this.#host = host;
this.#context = new ContextProvider(this.#host, {
this.host = host;
this.context = new ContextProvider(this.host, {
context: VersionContext,
initialValue,
});
@@ -31,14 +31,14 @@ export class VersionContextController extends ReactiveContextController<Version>
}
protected doRefresh(version: Version) {
this.#context.setValue(version);
this.#host.version = version;
this.context.setValue(version);
this.host.version = version;
}
public hostUpdate() {
const { currentUser } = this.#host;
const { currentUser } = this.host;
if (currentUser && !isGuest(currentUser) && !this.#host.version && !this.abortController) {
if (currentUser && !isGuest(currentUser) && !this.host.version && !this.abortController) {
this.refresh();
return;

View File

@@ -0,0 +1,190 @@
/**
* @file Event Listener Decorator for LitElement
*/
import type { LitElement } from "lit";
//#region Types
const listenerDecoratorSymbol = Symbol("listener-decorator");
const abortControllerSymbol = Symbol("listener-decorator-abort-controller");
/**
* Options for the {@linkcode listen} decorator.
*/
export interface ListenDecoratorOptions extends AddEventListenerOptions {
/**
* The target to attach the event listener to.
*
* @default window
*/
target?: EventTarget;
}
/**
* Internal store for the {@linkcode listen} decorator.
*/
interface ListenDecoratorStore {
propToEventName: Map<PropertyKey, keyof WindowEventMap>;
propToOptions: Map<PropertyKey, ListenDecoratorOptions>;
}
/**
* Mixin interface for elements using the {@linkcode listen} decorator.
*/
interface ListenerMixin extends LitElement {
[listenerDecoratorSymbol]?: ListenDecoratorStore;
[abortControllerSymbol]?: AbortController | null;
}
/**
* A type representing an Event constructor.
*/
export type EventConstructor<K extends keyof WindowEventMap = keyof WindowEventMap> = {
eventName: K;
} & (new (...args: never[]) => WindowEventMap[K]);
// #endregion
//#region Utilities
/**
* Type guard for EventListener-like objects.
*
* @remarks
*
* Type-safety for this is limited due to the dynamic nature of event listeners.
*/
function isEventListenerLike(input: unknown): input is EventListenerOrEventListenerObject {
if (!input) return false;
if (typeof input === "function") return true;
return typeof (input as EventListenerObject).handleEvent === "function";
}
/**
* Registers the connected and disconnected callbacks to manage event listeners.
*
* @see {@linkcode listen} for usage.
*
* @param target The target class to register the callbacks on.
* @internal
*/
function registerEventCallbacks<T extends ListenerMixin>(target: T): ListenDecoratorStore {
const { connectedCallback, disconnectedCallback } = target;
// Inherit parent's listeners, if they exist.
const parentData = target[listenerDecoratorSymbol];
const propToEventName = new Map(parentData?.propToEventName || []);
const propToOptions = new Map(parentData?.propToOptions || []);
// Wrap connectedCallback to register event listeners, with AbortController for easy removal.
target.connectedCallback = function connectedCallbackWrapped(this: T) {
connectedCallback.call(this);
const abortController = new AbortController();
this[abortControllerSymbol] = abortController;
// Register all listeners
for (const [propKey, eventType] of propToEventName) {
const { target = window, ...options } = propToOptions.get(propKey) || {};
const listener = this[propKey as keyof T];
if (!listener) {
throw new TypeError(
`Listener method "${String(propKey)}" not found on component. Was it re-assigned or removed?`,
);
}
if (!isEventListenerLike(listener)) {
throw new TypeError(
`Listener "${String(
propKey,
)}" is not a valid event listener. It must be a function or an object with a handleEvent method.`,
);
}
target.addEventListener(eventType, listener, {
...options,
signal: abortController.signal,
});
}
};
// Wrap disconnectedCallback to invoke the abort controller, removing all listeners.
target.disconnectedCallback = function disconnectedCallbackWrapped(this: T) {
disconnectedCallback.call(this);
this[abortControllerSymbol]?.abort();
this[abortControllerSymbol] = null;
};
target[listenerDecoratorSymbol] = {
propToEventName,
propToOptions,
};
return target[listenerDecoratorSymbol];
}
//#endregion
//#region Decorator
/**
* Type for the decorator.
*
* @see {@linkcode listen} for usage.
*/
export type ListenDecorator = <T extends LitElement>(target: T, propertyKey: string) => void;
/**
* Adds an event listener to the `window` object that is automatically
* removed when the element is disconnected.
*
* @param EventConstructor The event constructor to listen for.
* @param listener The event listener callback.
* @param options Additional options for `addEventListener`.
*/
export function listen<K extends keyof WindowEventMap>(
EventConstructor: EventConstructor<K>,
options?: ListenDecoratorOptions,
): ListenDecorator;
/**
* Adds an event listener to the `window` object that is automatically
* removed when the element is disconnected.
*
* @param type The event type to listen for.
* @param listener The event listener callback.
* @param options Additional options for `addEventListener`.
*/
export function listen<K extends keyof WindowEventMap>(
type: K,
options?: ListenDecoratorOptions,
): ListenDecorator;
/**
* Adds an event listener to the `window` object that is automatically
* removed when the element is disconnected.
*
* @param type The event type or constructor to listen for.
* @param listener The event listener callback.
* @param options Additional options for `addEventListener`.
*/
export function listen<K extends keyof WindowEventMap>(
type: K | EventConstructor<K>,
options: ListenDecoratorOptions = {},
): ListenDecorator {
const eventType = typeof type === "function" ? type.eventName : type;
return <T extends ListenerMixin>(target: T, key: string) => {
const store = Object.hasOwn(target, listenerDecoratorSymbol)
? target[listenerDecoratorSymbol]!
: registerEventCallbacks(target);
store.propToEventName.set(key, eventType);
store.propToOptions.set(key, options);
};
}
//#endregion

View File

@@ -5,7 +5,7 @@ import {
pluckErrorDetail,
pluckFallbackFieldErrors,
} from "#common/errors/network";
import { MessageLevel } from "#common/messages";
import { APIMessage, MessageLevel } from "#common/messages";
import { dateToUTC } from "#common/temporal";
import { isControlElement } from "#elements/AkControlElement";
@@ -13,7 +13,6 @@ import { AKElement } from "#elements/Base";
import { reportValidityDeep } from "#elements/forms/FormGroup";
import { PreventFormSubmit } from "#elements/forms/helpers";
import { HorizontalFormElement } from "#elements/forms/HorizontalFormElement";
import { APIMessage } from "#elements/messages/Message";
import { showMessage } from "#elements/messages/MessageContainer";
import { SlottedTemplateResult } from "#elements/types";
import { createFileMap, isNamedElement, NamedElement } from "#elements/utils/inputs";

View File

@@ -1,4 +1,4 @@
import { MessageLevel } from "#common/messages";
import { APIMessage, MessageLevel } from "#common/messages";
import { AKElement } from "#elements/Base";
@@ -13,20 +13,6 @@ import PFAlertGroup from "@patternfly/patternfly/components/AlertGroup/alert-gro
import PFButton from "@patternfly/patternfly/components/Button/button.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
/**
* An error message returned from an API endpoint.
*
* @remarks
* This interface must align with the server-side event dispatcher.
*
* @see {@link ../authentik/core/templates/base/skeleton.html}
*/
export interface APIMessage {
level: MessageLevel;
message: string;
description?: string | TemplateResult;
}
const LevelIconMap = {
[MessageLevel.error]: "fas fa-exclamation-circle",
[MessageLevel.warning]: "fas fa-exclamation-triangle",

View File

@@ -1,13 +1,10 @@
import "#elements/messages/Message";
import { EVENT_MESSAGE, EVENT_WS_MESSAGE, WS_MSG_TYPE_MESSAGE } from "#common/constants";
import { APIError, pluckErrorDetail } from "#common/errors/network";
import { MessageLevel } from "#common/messages";
import { APIMessage, MessageLevel } from "#common/messages";
import { SentryIgnoredError } from "#common/sentry/index";
import { WSMessage } from "#common/ws";
import { AKElement } from "#elements/Base";
import { APIMessage } from "#elements/messages/Message";
import { instanceOfValidationError } from "@goauthentik/api";
@@ -112,15 +109,9 @@ export class MessageContainer extends AKElement {
// Note: This seems to be susceptible to race conditions.
// Events are dispatched regardless if the message container is listening.
window.addEventListener(EVENT_WS_MESSAGE, ((e: CustomEvent<WSMessage>) => {
if (e.detail.message_type !== WS_MSG_TYPE_MESSAGE) return;
this.addMessage(e.detail as unknown as APIMessage);
}) as EventListener);
window.addEventListener(EVENT_MESSAGE, ((e: CustomEvent<APIMessage>) => {
this.addMessage(e.detail);
}) as EventListener);
window.addEventListener("ak-message", (event) => {
this.addMessage(event.message);
});
}
public addMessage(message: APIMessage, unique = false): void {

View File

@@ -0,0 +1,153 @@
import { DEFAULT_CONFIG } from "#common/api/config";
import { APIResult, isAPIResultReady } from "#common/api/responses";
import { MessageLevel } from "#common/messages";
import { ContextControllerRegistry } from "#elements/controllers/ContextControllerRegistry";
import { showMessage } from "#elements/messages/MessageContainer";
import { AKDrawerChangeEvent } from "#elements/notifications/events";
import { createPaginatedNotificationListFrom } from "#elements/notifications/utils";
import { createMixin } from "#elements/types";
import { ConsoleLogger } from "#logger/browser";
import {
EventsApi,
type Notification,
PaginatedNotificationList,
SessionUser,
} from "@goauthentik/api";
import { consume, createContext } from "@lit/context";
import { msg } from "@lit/localize";
import { property } from "lit/decorators.js";
export type NotificationsContextValue = APIResult<Readonly<PaginatedNotificationList>>;
/**
* The Lit context for the user's notifications.
*
* @category Context
* @see {@linkcode SessionMixin}
* @see {@linkcode WithSession}
*/
export const NotificationsContext = createContext<NotificationsContextValue>(
Symbol("authentik-notifications-context"),
);
export type NotificationsContext = typeof NotificationsContext;
/**
* A mixin that provides the current version to the element.
*
* @see {@linkcode WithNotifications}
*/
export interface NotificationsMixin {
/**
* The current user's notifications.
*/
readonly notifications: APIResult<Readonly<PaginatedNotificationList>>;
/**
* The total count of unread notifications, including those not loaded.
*/
readonly notificationCount: number;
/**
* Refresh the current user's notifications.
*
* @param requestInit Optional parameters to pass to the fetch call.
*/
refreshNotifications(): Promise<void>;
/**
* Mark a notification as read.
*
* @param notificationPk Primary key of the notification to mark as read.
* @param requestInit Optional parameters to pass to the fetch call.
*/
markAsRead: (notificationPk: Notification["pk"], requestInit?: RequestInit) => Promise<void>;
/**
* Clear all notifications.
*/
clearNotifications: () => Promise<void>;
}
/**
* A mixin that provides the current authentik version to the element.
*
* @category Mixin
*/
export const WithNotifications = createMixin<NotificationsMixin>(
({
// ---
SuperClass,
subscribe = true,
}) => {
abstract class NotificationsProvider extends SuperClass implements NotificationsMixin {
#logger = ConsoleLogger.prefix("notifications");
#contextController = ContextControllerRegistry.get(NotificationsContext);
public session!: APIResult<Readonly<SessionUser>>;
public get notificationCount(): number {
if (!isAPIResultReady(this.notifications)) {
return 0;
}
return this.notifications.pagination.count;
}
@consume({
context: NotificationsContext,
subscribe,
})
@property({ attribute: false })
public notifications!: APIResult<Readonly<PaginatedNotificationList>>;
//#region Methods
public refreshNotifications = async (): Promise<void> => {
await this.#contextController?.refresh();
};
public markAsRead = (
notificationID: Notification["pk"],
requestInit?: RequestInit,
): Promise<void> => {
this.#logger.debug(`Marking notification ${notificationID} as read...`);
return new EventsApi(DEFAULT_CONFIG)
.eventsNotificationsPartialUpdate(
{
uuid: notificationID || "",
patchedNotificationRequest: {
seen: true,
},
},
requestInit,
)
.then(() => this.refreshNotifications());
};
public clearNotifications = (): Promise<void> => {
return new EventsApi(DEFAULT_CONFIG)
.eventsNotificationsMarkAllSeenCreate()
.then(() => {
showMessage({
level: MessageLevel.success,
message: msg("Successfully cleared notifications"),
});
this.#contextController?.context.setValue(
createPaginatedNotificationListFrom(),
);
this.requestUpdate?.();
})
.then(AKDrawerChangeEvent.dispatchCloseNotifications);
};
}
return NotificationsProvider;
},
);

View File

@@ -1,30 +1,33 @@
import { APIResult, isAPIResultReady } from "#common/api/responses";
import { createUIConfig, DefaultUIConfig, UIConfig } from "#common/ui/config";
import { autoDetectLanguage } from "#common/ui/locale/utils";
import { me } from "#common/users";
import { ContextControllerRegistry } from "#elements/controllers/ContextControllerRegistry";
import { AuthentikConfigContext, kAKConfig } from "#elements/mixins/config";
import { kAKLocale, LocaleContext, LocaleContextValue } from "#elements/mixins/locale";
import { createMixin } from "#elements/types";
import type { Config, SessionUser, UserSelf } from "@goauthentik/api";
import { ConsoleLogger } from "#logger/browser";
import { setUser } from "@sentry/browser";
import { type Config, type SessionUser, type UserSelf } from "@goauthentik/api";
import { consume, createContext } from "@lit/context";
import { property } from "lit/decorators.js";
export const kAKSessionContext = Symbol("kAKSessionContext");
/**
* The Lit context for the application configuration.
* The Lit context for the session information.
*
* @category Context
* @see {@linkcode SessionMixin}
* @see {@linkcode WithSession}
*/
export const SessionContext = createContext<APIResult<SessionUser>>(
Symbol.for("authentik-session-context"),
Symbol("authentik-session-context"),
);
export type SessionContext = typeof SessionContext;
/**
* A consumer that provides session information to the element.
*
@@ -66,10 +69,8 @@ export interface SessionMixin {
/**
* Refresh the current session information.
*
* @param requestInit Optional parameters to pass to the fetch call.
*/
refreshSession(requestInit?: RequestInit): Promise<SessionUser>;
refreshSession(): Promise<SessionUser | null>;
}
/**
@@ -84,7 +85,7 @@ export function canAccessAdmin(user?: UserSelf | null) {
);
}
// uiConfig.enabledFeatures.applicationEdit && currentUser?.isSuperuser
// console.debug.bind(console, `authentik/session:${this.constructor.name}`);
/**
* A mixin that provides the session information to the element.
@@ -98,7 +99,10 @@ export const WithSession = createMixin<SessionMixin>(
subscribe = true,
}) => {
abstract class SessionProvider extends SuperClass implements SessionMixin {
#log = console.debug.bind(console, `authentik/session`);
#logger = ConsoleLogger.prefix("session");
#contextController = ContextControllerRegistry.get(SessionContext);
//#region Context Consumers
@consume({
context: AuthentikConfigContext,
@@ -112,77 +116,54 @@ export const WithSession = createMixin<SessionMixin>(
})
public [kAKLocale]!: LocaleContextValue;
#data: APIResult<Readonly<SessionUser>> = {
loading: true,
error: null,
};
#uiConfig: Readonly<UIConfig> = DefaultUIConfig;
@consume({
context: SessionContext,
subscribe,
})
public set session(nextResult: APIResult<SessionUser>) {
const previousValue = this.#data;
@property({ attribute: false })
public session!: APIResult<Readonly<SessionUser>>;
this.#data = nextResult;
//#endregion
if (isAPIResultReady(nextResult)) {
const { settings = {} } = nextResult.user || {};
this.#uiConfig = createUIConfig(settings);
}
this.requestUpdate("session", previousValue);
}
public get session(): APIResult<Readonly<SessionUser>> {
return this.#data;
}
//#region Properties
public get uiConfig(): Readonly<UIConfig> {
return this.#uiConfig;
}
public get currentUser(): Readonly<UserSelf> | null {
return (isAPIResultReady(this.#data) && this.#data.user) || null;
return (isAPIResultReady(this.session) && this.session.user) || null;
}
public get originalUser(): Readonly<UserSelf> | null {
return (isAPIResultReady(this.#data) && this.#data.original) || null;
return (isAPIResultReady(this.session) && this.session.original) || null;
}
public get impersonating(): boolean {
return !!this.originalUser;
}
public refreshSession(requestInit?: RequestInit): Promise<SessionUser> {
this.#log("Fetching session...");
//#endregion
return me(requestInit).then((session) => {
const localeHint: string | undefined = session.user.settings.locale;
//#region Methods
if (localeHint) {
const locale = autoDetectLanguage(localeHint);
this.#log(`Activating user's configured locale '${locale}'`);
this[kAKLocale]?.setLocale(locale);
}
public async refreshSession(): Promise<SessionUser | null> {
this.#logger.debug("Fetching session...");
const nextResult = await this.#contextController?.refresh();
const config = this[kAKConfig];
if (!isAPIResultReady(nextResult)) {
return null;
}
if (config?.errorReporting.sendPii) {
this.#log("Sentry with PII enabled.");
const { settings = {} } = nextResult.user || {};
setUser({ email: session.user.email });
}
this.#uiConfig = createUIConfig(settings);
this.#log("Fetched session", session);
this.session = session;
return session;
});
return nextResult;
}
//#endregion
}
return SessionProvider;

View File

@@ -6,7 +6,7 @@ import { consume, createContext } from "@lit/context";
import { property } from "lit/decorators.js";
/**
* The Lit context for application branding.
* The Lit context for authentik's version.
*
* @category Context
* @see {@linkcode VersionMixin}

View File

@@ -1,10 +1,11 @@
import "#elements/timestamp/ak-timestamp";
import { RequestInfo } from "#common/api/middleware";
import { EVENT_API_DRAWER_TOGGLE, EVENT_REQUEST_POST } from "#common/constants";
import { AKRequestPostEvent, APIRequestInfo } from "#common/api/events";
import { globalAK } from "#common/global";
import { AKElement } from "#elements/Base";
import { listen } from "#elements/decorators/listen";
import { AKDrawerChangeEvent } from "#elements/notifications/events";
import { msg } from "@lit/localize";
import { css, CSSResult, html, TemplateResult } from "lit";
@@ -16,7 +17,7 @@ import PFDropdown from "@patternfly/patternfly/components/Dropdown/dropdown.css"
import PFNotificationDrawer from "@patternfly/patternfly/components/NotificationDrawer/notification-drawer.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
function renderItem(item: RequestInfo, idx: number): TemplateResult {
function renderItem(item: APIRequestInfo, idx: number): TemplateResult {
const subheading = `${item.method}: ${item.status}`;
const label = URL.canParse(item.path) ? new URL(item.path).pathname : null;
@@ -45,7 +46,7 @@ function renderItem(item: RequestInfo, idx: number): TemplateResult {
@customElement("ak-api-drawer")
export class APIDrawer extends AKElement {
@property({ attribute: false })
requests: RequestInfo[] = [];
public requests: APIRequestInfo[] = [];
static styles: CSSResult[] = [
PFBase,
@@ -54,56 +55,54 @@ export class APIDrawer extends AKElement {
PFContent,
PFDropdown,
css`
:host {
--header-height: 114px;
.pf-c-drawer__body {
height: 100%;
}
.pf-c-notification-drawer__header {
height: var(--header-height);
align-items: center;
}
.pf-c-notification-drawer__header-action,
.pf-c-notification-drawer__header-action-close,
.pf-c-notification-drawer__header-action-close > .pf-c-button.pf-m-plain {
height: 100%;
}
.pf-c-notification-drawer__list-item-description {
white-space: pre-wrap;
font-family: var(--pf-global--FontFamily--monospace);
}
.pf-c-notification-drawer__body {
overflow-x: hidden;
}
.pf-c-notification-drawer__list {
max-height: calc(100vh - var(--header-height));
overflow-x: auto;
}
`,
];
constructor() {
super();
window.addEventListener(EVENT_REQUEST_POST, ((e: CustomEvent<RequestInfo>) => {
this.requests.push(e.detail);
this.requests.sort((a, b) => a.time - b.time).reverse();
if (this.requests.length > 50) {
this.requests.shift();
}
this.requestUpdate();
}) as EventListener);
}
@listen(AKRequestPostEvent)
protected enqueueRequest = ({ requestInfo }: AKRequestPostEvent) => {
this.requests.push(requestInfo);
this.requests.sort((a, b) => a.time - b.time).reverse();
if (this.requests.length > 50) {
this.requests.shift();
}
this.requestUpdate();
};
render(): TemplateResult {
return html`<div
return html`<aside
class="pf-c-drawer__body pf-m-no-padding"
aria-label=${msg("API drawer")}
role="region"
tabindex="0"
>
<div class="pf-c-notification-drawer">
<div class="pf-c-notification-drawer__header">
<header class="pf-c-notification-drawer__header">
<div class="text">
<h1 class="pf-c-notification-drawer__header-title">
<h2 class="pf-c-notification-drawer__header-title">
${msg("API Requests")}
</h1>
</h2>
<a href="${globalAK().api.base}api/v3/" target="_blank"
>${msg("Open API Browser")}</a
>
@@ -111,14 +110,7 @@ export class APIDrawer extends AKElement {
<div class="pf-c-notification-drawer__header-action">
<div class="pf-c-notification-drawer__header-action-close">
<button
@click=${() => {
this.dispatchEvent(
new CustomEvent(EVENT_API_DRAWER_TOGGLE, {
bubbles: true,
composed: true,
}),
);
}}
@click=${AKDrawerChangeEvent.dispatchAPIToggle}
class="pf-c-button pf-m-plain"
type="button"
aria-label=${msg("Close API drawer")}
@@ -127,14 +119,14 @@ export class APIDrawer extends AKElement {
</button>
</div>
</div>
</div>
</header>
<div class="pf-c-notification-drawer__body">
<ul class="pf-c-notification-drawer__list">
${this.requests.map(renderItem)}
</ul>
</div>
</div>
</div>`;
</aside>`;
}
}

View File

@@ -1,48 +1,35 @@
import "#elements/EmptyState";
import "@patternfly/elements/pf-tooltip/pf-tooltip.js";
import { DEFAULT_CONFIG } from "#common/api/config";
import {
EVENT_NOTIFICATION_DRAWER_TOGGLE,
EVENT_REFRESH,
EVENT_WS_MESSAGE,
WS_MSG_TYPE_NOTIFICATION,
} from "#common/constants";
import { isAPIResultReady } from "#common/api/responses";
import { pluckErrorDetail } from "#common/errors/network";
import { globalAK } from "#common/global";
import { actionToLabel, severityToLevel } from "#common/labels";
import { MessageLevel } from "#common/messages";
import { formatElapsedTime } from "#common/temporal";
import { isGuest } from "#common/users";
import { WSMessage } from "#common/ws";
import { AKElement } from "#elements/Base";
import { showMessage } from "#elements/messages/MessageContainer";
import { WithNotifications } from "#elements/mixins/notifications";
import { WithSession } from "#elements/mixins/session";
import { PaginatedResponse } from "#elements/table/Table";
import { AKDrawerChangeEvent } from "#elements/notifications/events";
import { SlottedTemplateResult } from "#elements/types";
import { ifPresent } from "#elements/utils/attributes";
import { EventsApi, Notification, NotificationFromJSON } from "@goauthentik/api";
import { Notification } from "@goauthentik/api";
import { msg, str } from "@lit/localize";
import { css, CSSResult, html, nothing, TemplateResult } from "lit";
import { customElement, property } from "lit/decorators.js";
import { customElement } from "lit/decorators.js";
import { guard } from "lit/directives/guard.js";
import { repeat } from "lit/directives/repeat.js";
import PFButton from "@patternfly/patternfly/components/Button/button.css";
import PFContent from "@patternfly/patternfly/components/Content/content.css";
import PFDropdown from "@patternfly/patternfly/components/Dropdown/dropdown.css";
import PFNotificationDrawer from "@patternfly/patternfly/components/NotificationDrawer/notification-drawer.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
@customElement("ak-notification-drawer")
export class NotificationDrawer extends WithSession(AKElement) {
@property({ attribute: false })
notifications?: PaginatedResponse<Notification>;
@property({ type: Number })
unread = 0;
export class NotificationDrawer extends WithNotifications(WithSession(AKElement)) {
static styles: CSSResult[] = [
PFBase,
PFButton,
PFNotificationDrawer,
PFContent,
@@ -51,82 +38,38 @@ export class NotificationDrawer extends WithSession(AKElement) {
.pf-c-drawer__body {
height: 100%;
}
.pf-c-notification-drawer__body {
flex-grow: 1;
overflow-x: hidden;
}
.pf-c-notification-drawer__header {
height: 114px;
align-items: center;
}
.pf-c-notification-drawer__header-action,
.pf-c-notification-drawer__header-action-close,
.pf-c-notification-drawer__header-action-close > .pf-c-button.pf-m-plain {
height: 100%;
}
.pf-c-notification-drawer__list-item-description {
white-space: pre-wrap;
}
.pf-c-notification-drawer__list-item-action {
display: flex;
flex-flow: row;
align-items: start;
gap: var(--pf-global--spacer--sm);
}
`,
];
#onWSMessage = (
e: CustomEvent<
WSMessage & {
data: unknown;
}
>,
) => {
if (e.detail.message_type !== WS_MSG_TYPE_NOTIFICATION) {
return;
}
const notification = NotificationFromJSON(e.detail.data);
showMessage({
level: MessageLevel.info,
message: actionToLabel(notification.event?.action) ?? notification.body,
description: html`${notification.body}
${notification.hyperlink
? html`<br /><a href=${notification.hyperlink}>${notification.hyperlinkLabel}</a>`
: nothing}
${notification.event
? html`<br /><a href="#/events/log/${notification.event.pk}"
>${msg("View details...")}</a
>`
: nothing}`,
});
};
#APIBase = globalAK().api.base;
connectedCallback(): void {
super.connectedCallback();
this.refreshNotifications();
this.#onWSMessage = this.#onWSMessage.bind(this);
window.addEventListener(EVENT_WS_MESSAGE, this.#onWSMessage as EventListener);
}
public disconnectedCallback(): void {
super.disconnectedCallback();
window.removeEventListener(EVENT_WS_MESSAGE, this.#onWSMessage as EventListener);
}
protected async refreshNotifications(): Promise<void> {
const { currentUser } = this;
if (!currentUser || isGuest(currentUser)) {
return Promise.resolve();
}
return new EventsApi(DEFAULT_CONFIG)
.eventsNotificationsList({
seen: false,
ordering: "-created",
user: currentUser.pk,
})
.then((r) => {
this.notifications = r;
this.unread = r.results.length;
this.requestUpdate();
});
}
//#region Rendering
protected renderHyperlink(item: Notification) {
if (!item.hyperlink) {
@@ -140,7 +83,14 @@ export class NotificationDrawer extends WithSession(AKElement) {
const label = actionToLabel(item.event?.action);
const level = severityToLevel(item.severity);
return html`<li class="pf-c-notification-drawer__list-item">
// There's little information we can have to determine if the body
// contains code, but if it looks like JSON, we can at least style it better.
const code = item.body.includes("{");
return html`<li
class="pf-c-notification-drawer__list-item"
data-notification-action=${ifPresent(item.event?.action)}
>
<div class="pf-c-notification-drawer__list-item-header">
<span class="pf-c-notification-drawer__list-item-header-icon ${level}">
<i class="fas fa-info-circle" aria-hidden="true"></i>
@@ -152,7 +102,7 @@ export class NotificationDrawer extends WithSession(AKElement) {
html`
<a
class="pf-c-dropdown__toggle pf-m-plain"
href="${globalAK().api.base}if/admin/#/events/log/${item.event?.pk}"
href="${this.#APIBase}if/admin/#/events/log/${item.event?.pk}"
aria-label=${msg(str`View details for ${label}`)}
>
<pf-tooltip position="top" content=${msg("Show details")}>
@@ -163,30 +113,17 @@ export class NotificationDrawer extends WithSession(AKElement) {
<button
class="pf-c-dropdown__toggle pf-m-plain"
type="button"
@click=${() => {
new EventsApi(DEFAULT_CONFIG)
.eventsNotificationsPartialUpdate({
uuid: item.pk || "",
patchedNotificationRequest: {
seen: true,
},
})
.then(() => {
this.refreshNotifications();
this.dispatchEvent(
new CustomEvent(EVENT_REFRESH, {
bubbles: true,
composed: true,
}),
);
});
}}
@click=${() => this.markAsRead(item.pk)}
aria-label=${msg("Mark as read")}
>
<i class="fas fa-times" aria-hidden="true"></i>
</button>
</div>
<p class="pf-c-notification-drawer__list-item-description">${item.body}</p>
${code && item.event?.context
? html`<pre class="pf-c-notification-drawer__list-item-description">
${JSON.stringify(item.event.context, null, 2)}</pre
>`
: html`<p class="pf-c-notification-drawer__list-item-description">${item.body}</p>`}
<small class="pf-c-notification-drawer__list-item-timestamp"
><pf-tooltip position="top" .content=${item.created?.toLocaleString()}>
${formatElapsedTime(item.created!)}
@@ -196,97 +133,90 @@ export class NotificationDrawer extends WithSession(AKElement) {
</li>`;
};
clearNotifications() {
new EventsApi(DEFAULT_CONFIG).eventsNotificationsMarkAllSeenCreate().then(() => {
showMessage({
level: MessageLevel.success,
message: msg("Successfully cleared notifications"),
});
this.refreshNotifications();
this.dispatchEvent(
new CustomEvent(EVENT_REFRESH, {
bubbles: true,
composed: true,
}),
);
this.dispatchEvent(
new CustomEvent(EVENT_NOTIFICATION_DRAWER_TOGGLE, {
bubbles: true,
composed: true,
}),
);
});
}
renderEmpty() {
protected renderEmpty() {
return html`<ak-empty-state
><span>${msg("No notifications found.")}</span>
<div slot="body">${msg("You don't have any notifications currently.")}</div>
</ak-empty-state>`;
}
render(): SlottedTemplateResult {
if (!this.notifications) {
return nothing;
}
protected renderBody() {
return guard([this.notifications], () => {
if (this.notifications.loading) {
return html`<ak-empty-state default-label></ak-empty-state>`;
}
const { results } = this.notifications;
if (this.notifications.error) {
return html`<ak-empty-state icon="fa-ban"
><span>${msg("Failed to fetch notifications.")}</span>
<div slot="body">${pluckErrorDetail(this.notifications.error)}</div>
</ak-empty-state>`;
}
return html`<div
if (!this.notificationCount) {
return this.renderEmpty();
}
return html`<ul class="pf-c-notification-drawer__list" role="list">
${repeat(
this.notifications.results,
(n) => n.pk,
(n) => this.#renderItem(n),
)}
</ul>`;
});
}
protected override render(): SlottedTemplateResult {
const unreadCount = isAPIResultReady(this.notifications) ? this.notificationCount : 0;
return html`<aside
class="pf-c-drawer__body pf-m-no-padding"
aria-label=${msg("Notification drawer")}
role="region"
tabindex="0"
aria-labelledby="notification-drawer-title"
>
<div class="pf-c-notification-drawer">
<div class="pf-c-notification-drawer__header">
<header class="pf-c-notification-drawer__header">
<div class="text">
<h1 class="pf-c-notification-drawer__header-title">
<h2
id="notification-drawer-title"
class="pf-c-notification-drawer__header-title"
>
${msg("Notifications")}
</h1>
<span> ${msg(str`${this.unread} unread`)}</span>
</h2>
<span aria-live="polite" aria-atomic="true">
${msg(str`${unreadCount} unread`, {
id: "notification-unread-count",
desc: "Indicates the number of unread notifications in the notification drawer",
})}
</span>
</div>
<div class="pf-c-notification-drawer__header-action">
<div>
<button
@click=${() => {
this.clearNotifications();
}}
class="pf-c-button pf-m-plain"
type="button"
aria-label=${msg("Clear all")}
>
<i class="fa fa-trash" aria-hidden="true"></i>
</button>
</div>
<div class="pf-c-notification-drawer__header-action-close">
<button
@click=${() => {
this.dispatchEvent(
new CustomEvent(EVENT_NOTIFICATION_DRAWER_TOGGLE, {
bubbles: true,
composed: true,
}),
);
}}
class="pf-c-button pf-m-plain"
type="button"
aria-label=${msg("Close")}
>
<i class="fas fa-times" aria-hidden="true"></i>
</button>
</div>
<button
@click=${this.clearNotifications}
class="pf-c-button pf-m-plain"
type="button"
aria-label=${msg("Clear all notifications", {
id: "notification-drawer-clear-all",
})}
?disabled=${!unreadCount}
>
<i class="fa fa-trash" aria-hidden="true"></i>
</button>
<button
@click=${AKDrawerChangeEvent.dispatchNotificationsToggle}
class="pf-c-button pf-m-plain"
type="button"
aria-label=${msg("Close notification drawer", {
id: "notification-drawer-close",
})}
>
<i class="fas fa-times" aria-hidden="true"></i>
</button>
</div>
</div>
<div class="pf-c-notification-drawer__body">
${results.length
? html`<ul class="pf-c-notification-drawer__list" role="list">
${results.map((n) => this.#renderItem(n))}
</ul>`
: this.renderEmpty()}
</div>
</header>
<div class="pf-c-notification-drawer__body">${this.renderBody()}</div>
</div>
</div>`;
</aside>`;
}
}

View File

@@ -0,0 +1,70 @@
import { DrawerState, readDrawerParams } from "#elements/notifications/utils";
/**
* Event dispatched when the state of the interface drawers changes.
*/
export class AKDrawerChangeEvent extends Event {
public static readonly eventName = "ak-drawer-change";
public readonly drawer: DrawerState;
constructor(input: DrawerState) {
super(AKDrawerChangeEvent.eventName, { bubbles: true, composed: true });
this.drawer = input;
}
//#region Static Dispatchers
/**
* Dispatches an event to close the notification drawer.
*/
public static dispatchCloseNotifications() {
const params = {
...readDrawerParams(),
notifications: false,
};
window.dispatchEvent(new AKDrawerChangeEvent(params));
}
/**
* Dispatches an event to close the API drawer.
*/
public static dispatchCloseAPI() {
const params = {
...readDrawerParams(),
api: false,
};
window.dispatchEvent(new AKDrawerChangeEvent(params));
}
/**
* Dispatches an event to toggle the notification drawer.
*/
public static dispatchNotificationsToggle() {
const params = readDrawerParams();
params.notifications = !params.notifications;
window.dispatchEvent(new AKDrawerChangeEvent(params));
}
/**
* Dispatches an event to toggle the API drawer.
*/
public static dispatchAPIToggle() {
const params = readDrawerParams();
params.api = !params.api;
window.dispatchEvent(new AKDrawerChangeEvent(params));
}
//#endregion
}
declare global {
interface WindowEventMap {
[AKDrawerChangeEvent.eventName]: AKDrawerChangeEvent;
}
}

View File

@@ -0,0 +1,98 @@
/**
* @file Notification drawer utilities.
*/
import "#elements/notifications/APIDrawer";
import "#elements/notifications/NotificationDrawer";
import { getURLParam, updateURLParams } from "#elements/router/RouteMatch";
import { type Notification, type PaginatedNotificationList } from "@goauthentik/api";
import { html } from "lit";
import { classMap } from "lit/directives/class-map.js";
import { guard } from "lit/directives/guard.js";
/**
* Creates a PaginatedNotificationList from an iterable of notifications.
*/
export function createPaginatedNotificationListFrom(
input: Iterable<Notification> = [],
): PaginatedNotificationList {
const results = Array.from(input);
return {
pagination: {
count: results.length,
next: 0,
previous: 0,
current: 0,
totalPages: 1,
startIndex: 0,
endIndex: 0,
},
results,
autocomplete: [],
};
}
/**
* The state of the interface drawers.
*
* @remarks
* These values are stored together to avoid awkward rendering states during
* initialization or rapid toggling.
*/
export interface DrawerState {
/** Whether the notification drawer is open. */
notifications: boolean;
/** Whether the API drawer is open. */
api: boolean;
}
/**
* Renders the notification and API drawers based on the provided state.
*
* @param drawers The state of the drawers.
* @returns The rendered drawer panels.
*/
export function renderNotificationDrawerPanel({ notifications, api }: DrawerState) {
return guard([notifications, api], () => {
const openDrawerCount = (notifications ? 1 : 0) + (api ? 1 : 0);
return html`<div
class=${classMap({
"pf-c-drawer__panel": true,
"pf-m-width-33": openDrawerCount === 1,
"pf-m-width-66": openDrawerCount === 2,
"pf-u-display-none": openDrawerCount === 0,
})}
>
<ak-api-drawer class="pf-c-drawer__panel_content" ?hidden=${!api}></ak-api-drawer>
<ak-notification-drawer
class="pf-c-drawer__panel_content"
?hidden=${!notifications}
></ak-notification-drawer>
</div>`;
});
}
/**
* Persists the drawer state to the URL parameters.
*/
export function persistDrawerParams(drawers: DrawerState) {
updateURLParams({
"drawer-notification": drawers.notifications,
"drawer-api": drawers.api,
});
}
/**
* Reads the drawer state from the URL parameters.
*/
export function readDrawerParams(): DrawerState {
return {
notifications: getURLParam("drawer-notification", false),
api: getURLParam("drawer-api", false),
};
}

View File

@@ -8,14 +8,16 @@ import { AKElement } from "#elements/Base";
import { Route } from "#elements/router/Route";
import { RouteMatch } from "#elements/router/RouteMatch";
import { ConsoleLogger } from "#logger/browser";
import {
BrowserClient,
getClient,
SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
Span,
startBrowserTracingNavigationSpan,
startBrowserTracingPageLoadSpan,
} from "@sentry/browser";
import { BaseTransportOptions, Client, ClientOptions } from "@sentry/core";
import { html, PropertyValues, TemplateResult } from "lit";
import { customElement, property } from "lit/decorators.js";
@@ -60,6 +62,7 @@ export function navigate(url: string, params?: { [key: string]: unknown }): void
@customElement("ak-router-outlet")
export class RouterOutlet extends AKElement {
#logger = ConsoleLogger.prefix("router");
protected createRenderRoot() {
return this;
}
@@ -79,25 +82,33 @@ export class RouterOutlet extends AKElement {
//#region Lifecycle
private sentryClient?: BrowserClient;
private pageLoadSpan?: Span;
#sentryClient: Client<ClientOptions<BaseTransportOptions>> | null = getClient() || null;
#pageLoadSpan: Span | null = null;
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",
},
});
window.addEventListener("hashchange", this.navigate);
if (this.#sentryClient) {
this.#pageLoadSpan =
startBrowserTracingPageLoadSpan(this.#sentryClient, {
name: window.location.pathname,
attributes: {
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: "url",
},
}) || null;
}
}
firstUpdated(): void {
this.navigate();
//#endregion
public override connectedCallback(): void {
super.connectedCallback();
this.#mutationObserver.observe(this.renderRoot, {
childList: true,
});
}
//#endregion
@@ -118,41 +129,43 @@ export class RouterOutlet extends AKElement {
#mutationObserver = new MutationObserver(this.#synchronizeContentTarget);
connectedCallback(): void {
super.connectedCallback();
this.#mutationObserver.observe(this.renderRoot, {
childList: true,
});
}
//#endregion
navigate(ev?: HashChangeEvent): void {
let activeUrl = window.location.hash.slice(1, Infinity).split(ROUTE_SEPARATOR)[0];
if (ev) {
protected navigate = (event?: HashChangeEvent): void => {
let activeUrl = window.location.hash.slice(1).split(ROUTE_SEPARATOR)[0];
if (event) {
// Check if we've actually changed paths
const oldPath = new URL(ev.oldURL).hash.slice(1, Infinity).split(ROUTE_SEPARATOR)[0];
const oldPath = new URL(event.oldURL).hash.slice(1).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}`);
this.#logger.info(`Defaulted URL to ${window.location.hash}`);
return;
}
let matchedRoute: RouteMatch | null = null;
this.routes.some((route) => {
for (const route of this.routes) {
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;
this.#logger.debug(matchedRoute);
break;
}
return false;
});
}
if (!matchedRoute) {
console.debug(`authentik/router: route "${activeUrl}" not defined`);
this.#logger.info(`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>
@@ -162,18 +175,23 @@ export class RouterOutlet extends AKElement {
matchedRoute.arguments = route.url.exec(activeUrl)?.groups || {};
}
this.current = matchedRoute;
};
protected override firstUpdated(): void {
this.navigate();
}
updated(changedProperties: PropertyValues<this>): void {
protected override updated(changedProperties: PropertyValues<this>): void {
if (!changedProperties.has("current") || !this.current) return;
if (!this.sentryClient) 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;
if (this.#pageLoadSpan) {
this.#pageLoadSpan.updateName(this.current.sanitizedURL());
this.#pageLoadSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, "route");
this.#pageLoadSpan = null;
} else {
startBrowserTracingNavigationSpan(this.sentryClient, {
startBrowserTracingNavigationSpan(this.#sentryClient, {
op: "navigation",
name: this.current.sanitizedURL(),
attributes: {

View File

@@ -1,6 +1,14 @@
import { OwnPropertyRecord, Writeable } from "#common/types";
import type { LitElement, nothing, ReactiveControllerHost, TemplateResult } from "lit";
import { Context, ContextProvider, ContextType } from "@lit/context";
import type {
LitElement,
nothing,
ReactiveController,
ReactiveControllerHost,
TemplateResult,
} from "lit";
import { DirectiveResult } from "lit-html/directive.js";
//#region HTML Helpers
@@ -71,14 +79,42 @@ export type LitFC<P> = (
//#region Host/Controller
export interface ReactiveContextController<
T extends Context<unknown, unknown> = Context<unknown, unknown>,
Host extends object = object,
> extends ReactiveController {
context: ContextProvider<T>;
host: ReactiveElementHost<Host>;
refresh(): Promise<ContextType<T> | null>;
}
/**
* A custom element which may be used as a host for a ReactiveController.
* A registry mapping context keys to their respective ReactiveControllers.
*/
export interface ContextControllerRegistryMap {
get<T extends Context<unknown, unknown>>(
key: T,
): ReactiveContextController<T, object> | undefined;
set<T extends Context<unknown, unknown>>(
key: ContextType<T>,
controller: ReactiveContextController<T, object>,
): void;
}
export interface ReactiveControllerHostRegistry extends ReactiveControllerHost {
contextControllers: ContextControllerRegistryMap;
}
/**
* A custom element which may be used as a host for a {@linkcode ReactiveController}.
*
* @remarks
*
* This type is derived from an internal type in Lit.
*/
export type ReactiveElementHost<T> = Partial<ReactiveControllerHost & Writeable<T>> & HTMLElement;
export type ReactiveElementHost<T> = Partial<ReactiveControllerHostRegistry & Writeable<T>> &
HTMLElement;
//#endregion
@@ -238,5 +274,10 @@ export type SelectOptions<T = never> = SelectOption<T>[] | GroupedOptions<T>;
* - A TemplateResult, which will be rendered as HTML.
* - `nothing` or `null`, which will not be rendered.
*/
export type SlottedTemplateResult = string | TemplateResult | typeof nothing | null;
export type SlottedTemplateResult =
| string
| TemplateResult
| typeof nothing
| null
| DirectiveResult;
export type Spread = { [key: string]: unknown };

View File

@@ -13,17 +13,14 @@ import "#flow/stages/RedirectStage";
import Styles from "./FlowExecutor.css" with { type: "bundled-text" };
import { DEFAULT_CONFIG } from "#common/api/config";
import {
EVENT_FLOW_ADVANCE,
EVENT_FLOW_INSPECTOR_TOGGLE,
EVENT_WS_MESSAGE,
} from "#common/constants";
import { pluckErrorDetail } from "#common/errors/network";
import { globalAK } from "#common/global";
import { configureSentry } from "#common/sentry/index";
import { applyBackgroundImageProperty } from "#common/theme";
import { WebsocketClient, WSMessage } from "#common/ws";
import { AKSessionAuthenticatedEvent } from "#common/ws/events";
import { WebsocketClient } from "#common/ws/WebSocketClient";
import { listen } from "#elements/decorators/listen";
import { Interface } from "#elements/Interface";
import { WithBrandConfig } from "#elements/mixins/branding";
import { WithCapabilitiesConfig } from "#elements/mixins/capabilities";
@@ -31,6 +28,7 @@ import { LitPropertyRecord } from "#elements/types";
import { exportParts } from "#elements/utils/attributes";
import { renderImage } from "#elements/utils/images";
import { AKFlowAdvanceEvent, AKFlowInspectorChangeEvent } from "#flow/events";
import { BaseStage, StageHost, SubmitOptions } from "#flow/stages/base";
import {
@@ -49,6 +47,7 @@ import { spread } from "@open-wc/lit-helpers";
import { msg } from "@lit/localize";
import { CSSResult, html, nothing, PropertyValues, TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { guard } from "lit/directives/guard.js";
import { unsafeHTML } from "lit/directives/unsafe-html.js";
import { until } from "lit/directives/until.js";
@@ -135,7 +134,7 @@ export class FlowExecutor
WebsocketClient.connect();
const inspector = new URL(window.location.toString()).searchParams.get("inspector");
const inspector = new URLSearchParams(window.location.search).get("inspector");
if (inspector === "" || inspector === "open") {
this.inspectorOpen = true;
@@ -160,29 +159,19 @@ export class FlowExecutor
});
}
#websocketHandler = (e: CustomEvent<WSMessage>) => {
if (e.detail.message_type === "session.authenticated") {
if (!document.hidden) {
return;
}
console.debug("authentik/ws: Reloading after session authenticated event");
window.location.reload();
@listen(AKSessionAuthenticatedEvent)
protected sessionAuthenticatedListener = () => {
if (!document.hidden) {
return;
}
console.debug("authentik/ws: Reloading after session authenticated event");
window.location.reload();
};
public connectedCallback(): void {
super.connectedCallback();
window.addEventListener(EVENT_FLOW_INSPECTOR_TOGGLE, this.#toggleInspector);
window.addEventListener(EVENT_WS_MESSAGE, this.#websocketHandler as EventListener);
}
public disconnectedCallback(): void {
super.disconnectedCallback();
window.removeEventListener(EVENT_FLOW_INSPECTOR_TOGGLE, this.#toggleInspector);
window.removeEventListener(EVENT_WS_MESSAGE, this.#websocketHandler as EventListener);
WebsocketClient.close();
}
@@ -200,12 +189,7 @@ export class FlowExecutor
})
.then((challenge: ChallengeTypes) => {
if (this.inspectorOpen) {
window.dispatchEvent(
new CustomEvent(EVENT_FLOW_ADVANCE, {
bubbles: true,
composed: true,
}),
);
window.dispatchEvent(new AKFlowAdvanceEvent());
}
this.challenge = challenge;
@@ -276,12 +260,7 @@ export class FlowExecutor
})
.then((challenge) => {
if (this.inspectorOpen) {
window.dispatchEvent(
new CustomEvent(EVENT_FLOW_ADVANCE, {
bubbles: true,
composed: true,
}),
);
window.dispatchEvent(new AKFlowAdvanceEvent());
}
this.challenge = challenge;
@@ -452,7 +431,8 @@ export class FlowExecutor
//#region Render Inspector
#toggleInspector = () => {
@listen(AKFlowInspectorChangeEvent)
protected toggleInspector = () => {
this.inspectorOpen = !this.inspectorOpen;
const drawer = document.getElementById("flow-drawer");
@@ -466,21 +446,23 @@ export class FlowExecutor
};
protected renderInspectorButton() {
if (!this.inspectorAvailable || this.inspectorOpen) {
return null;
}
return guard([this.inspectorAvailable, this.inspectorOpen], () => {
if (!this.inspectorAvailable || this.inspectorOpen) {
return null;
}
return html`<button
aria-label=${this.inspectorOpen
? msg("Close flow inspector")
: msg("Open flow inspector")}
aria-expanded=${this.inspectorOpen ? "true" : "false"}
class="inspector-toggle pf-c-button pf-m-primary"
aria-controls="flow-inspector"
@click=${this.#toggleInspector}
>
<i class="fa fa-search-plus" aria-hidden="true"></i>
</button>`;
return html`<button
aria-label=${this.inspectorOpen
? msg("Close flow inspector")
: msg("Open flow inspector")}
aria-expanded=${this.inspectorOpen ? "true" : "false"}
class="inspector-toggle pf-c-button pf-m-primary"
aria-controls="flow-inspector"
@click=${this.toggleInspector}
>
<i class="fa fa-search-plus" aria-hidden="true"></i>
</button>`;
});
}
//#endregion
@@ -494,7 +476,7 @@ export class FlowExecutor
public override render(): TemplateResult {
const { component } = this.challenge || {};
return html` <ak-locale-select
return html`<ak-locale-select
part="locale-select"
exportparts="label:locale-select-label,select:locale-select-select"
></ak-locale-select>

View File

@@ -1,5 +1,6 @@
.pf-c-drawer__body {
max-height: 100dvh;
width: 100%;
}
:host {

View File

@@ -2,11 +2,13 @@ import "#elements/EmptyState";
import "#elements/Expand";
import { DEFAULT_CONFIG } from "#common/api/config";
import { EVENT_FLOW_ADVANCE, EVENT_FLOW_INSPECTOR_TOGGLE } from "#common/constants";
import { AKRequestPostEvent } from "#common/api/events";
import { APIError, parseAPIResponseError, pluckErrorDetail } from "#common/errors/network";
import { AKElement } from "#elements/Base";
import { listen } from "#elements/decorators/listen";
import { AKFlowInspectorChangeEvent } from "#flow/events";
import Styles from "#flow/FlowInspector.css";
import { FlowInspection, FlowsApi, Stage } from "@goauthentik/api";
@@ -14,6 +16,7 @@ import { FlowInspection, FlowsApi, Stage } from "@goauthentik/api";
import { msg } from "@lit/localize";
import { CSSResult, html, nothing, TemplateResult } from "lit";
import { customElement, property } from "lit/decorators.js";
import { guard } from "lit/directives/guard.js";
import PFButton from "@patternfly/patternfly/components/Button/button.css";
import PFCard from "@patternfly/patternfly/components/Card/card.css";
@@ -29,15 +32,6 @@ function stringify(obj: unknown): string {
@customElement("ak-flow-inspector")
export class FlowInspector extends AKElement {
@property({ type: String, attribute: "slug", useDefault: true })
public flowSlug: string = window.location.pathname.split("/")[3];
@property({ attribute: false })
state?: FlowInspection;
@property({ attribute: false })
error?: APIError;
static styles: CSSResult[] = [
PFBase,
PFButton,
@@ -49,17 +43,21 @@ export class FlowInspector extends AKElement {
Styles,
];
constructor() {
super();
window.addEventListener(EVENT_FLOW_ADVANCE, this.advanceHandler as EventListener);
}
//#region Properties
disconnectedCallback(): void {
super.disconnectedCallback();
window.removeEventListener(EVENT_FLOW_ADVANCE, this.advanceHandler as EventListener);
}
@property({ type: String, attribute: "slug", useDefault: true })
public flowSlug: string = window.location.pathname.split("/")[3];
advanceHandler = (): void => {
@property({ attribute: false })
public state?: FlowInspection;
@property({ attribute: false })
public error?: APIError;
//#endregion
@listen(AKRequestPostEvent)
protected advanceHandler = (): void => {
new FlowsApi(DEFAULT_CONFIG)
.flowsInspectorGet({
flowSlug: this.flowSlug || "",
@@ -85,31 +83,28 @@ export class FlowInspector extends AKElement {
return conciseStage;
}
//#region Rendering
protected renderHeader() {
return html`<div class="pf-c-notification-drawer__header">
<div class="text">
<h1 class="pf-c-notification-drawer__header-title">${msg("Flow inspector")}</h1>
</div>
<div class="pf-c-notification-drawer__header-action">
<div class="pf-c-notification-drawer__header-action-close">
<button
@click=${() => {
window.dispatchEvent(
new CustomEvent(EVENT_FLOW_INSPECTOR_TOGGLE, {
bubbles: true,
composed: true,
}),
);
}}
class="pf-c-button pf-m-plain"
type="button"
aria-label=${msg("Close flow inspector")}
>
<i class="fas fa-times" aria-hidden="true"></i>
</button>
return guard([], () => {
return html`<div class="pf-c-notification-drawer__header">
<div class="text">
<h1 class="pf-c-notification-drawer__header-title">${msg("Flow inspector")}</h1>
</div>
</div>
</div>`;
<div class="pf-c-notification-drawer__header-action">
<div class="pf-c-notification-drawer__header-action-close">
<button
@click=${AKFlowInspectorChangeEvent.dispatchClose}
class="pf-c-button pf-m-plain"
type="button"
aria-label=${msg("Close flow inspector")}
>
<i class="fas fa-times" aria-hidden="true"></i>
</button>
</div>
</div>
</div>`;
});
}
protected renderAccessDenied(): TemplateResult {
@@ -311,6 +306,8 @@ ${stringify(this.getStage(currentPlan?.nextPlannedStage?.stageObj))}</pre
</div>
</aside>`;
}
//#endregion
}
declare global {

67
web/src/flow/events.ts Normal file
View File

@@ -0,0 +1,67 @@
/**
* @file Flow event utilities.
*/
//#region Flow Inspector
/**
* Event dispatched when flow inspector state changes.
*/
export class AKFlowInspectorChangeEvent extends Event {
public static readonly eventName = "ak-flow-inspector-change";
public readonly open: boolean;
constructor(open: boolean) {
super(AKFlowInspectorChangeEvent.eventName, { bubbles: true, composed: true });
this.open = open;
}
//#region Static Dispatchers
/**
* Dispatches an event to close flow inspector.
*/
public static dispatchClose() {
window.dispatchEvent(new AKFlowInspectorChangeEvent(false));
}
/**
* Dispatches an event to open flow inspector.
*/
public static dispatchOpen() {
window.dispatchEvent(new AKFlowInspectorChangeEvent(true));
}
//#endregion
}
declare global {
interface WindowEventMap {
[AKFlowInspectorChangeEvent.eventName]: AKFlowInspectorChangeEvent;
}
}
//#endregion
//#region Flow Inspector
/**
* Event dispatched when the state of the interface drawers changes.
*/
export class AKFlowAdvanceEvent extends Event {
public static readonly eventName = "ak-flow-advance";
constructor() {
super(AKFlowAdvanceEvent.eventName, { bubbles: true, composed: true });
}
}
declare global {
interface WindowEventMap {
[AKFlowAdvanceEvent.eventName]: AKFlowAdvanceEvent;
}
}
//#endregion

View File

@@ -11,11 +11,13 @@ import { randomId } from "#elements/utils/randomId";
import { BaseStage } from "#flow/stages/base";
import { CaptchaHandler, CaptchaProvider, iframeTemplate } from "#flow/stages/captcha/shared";
import { ConsoleLogger } from "#logger/browser";
import { CaptchaChallenge, CaptchaChallengeResponseRequest } from "@goauthentik/api";
import { match } from "ts-pattern";
import { msg } from "@lit/localize";
import { LOCALE_STATUS_EVENT, LocaleStatusEventDetail, msg } from "@lit/localize";
import { css, CSSResult, html, nothing, PropertyValues } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
@@ -94,6 +96,8 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
`,
];
#logger = ConsoleLogger.prefix("flow:captcha");
//#region Properties
@property({ type: Boolean })
@@ -167,7 +171,7 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
.with({ message: "captcha" }, ({ token }) => this.onTokenChange(token))
.with({ message: "load" }, this.#loadListener)
.otherwise(({ message }) => {
console.debug(`authentik/stages/captcha: Unknown message: ${message}`);
this.#logger.debug(`Unknown message: ${message}`);
});
};
@@ -245,7 +249,19 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
//#region Turnstile
/**
* Renders the Turnstile captcha frame.
*
* @remarks
*
* Turnstile will log a warning if the `data-language` attribute
* is not in lower-case format.
*
* @see {@link https://developers.cloudflare.com/turnstile/reference/supported-languages/ Turnstile Supported Languages}
*/
protected renderTurnstileFrame = () => {
const languageTag = this.activeLanguageTag.toLowerCase();
return html`<div
id="ak-container"
class="cf-turnstile"
@@ -253,7 +269,7 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
data-theme="${this.activeTheme}"
data-callback="callback"
data-size="flexible"
data-language=${ifPresent(this.activeLanguageTag)}
data-language=${ifPresent(languageTag)}
></div>`;
};
@@ -409,14 +425,18 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
return;
}
console.debug("authentik/stages/captcha: refresh triggered");
this.#logger.debug("refresh triggered");
this.#run(this.activeHandler);
}
#refreshVendor() {
// First, remove any existing script & listeners...
window.removeEventListener(LOCALE_STATUS_EVENT, this.#localeStatusListener);
this.#scriptElement?.remove();
// Then, load the new script...
const scriptElement = document.createElement("script");
scriptElement.src = this.challenge.jsUrl;
@@ -433,6 +453,26 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
}
}
#localeStatusListener = (event: CustomEvent<LocaleStatusEventDetail>) => {
if (!this.activeHandler) {
return;
}
if (event.detail.status === "error") {
this.#logger.debug("Error loading locale:", event.detail);
return;
}
if (event.detail.status === "loading") {
return;
}
const { readyLocale } = event.detail;
this.#logger.debug(`Locale changed to \`${readyLocale}\``);
this.#run(this.activeHandler);
};
//#endregion
//#region Resizing
@@ -524,7 +564,7 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
//#region Loading
#scriptLoadListener = async (): Promise<void> => {
console.debug("authentik/stages/captcha: script loaded");
this.#logger.debug("script loaded");
this.error = null;
this.#iframeLoaded = false;
@@ -536,17 +576,23 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
try {
await this.#run(name);
console.debug(`authentik/stages/captcha[${name}]: handler succeeded`);
this.#logger.debug(`[${name}]: handler succeeded`);
this.activeHandler = name;
return;
} catch (error) {
console.debug(`authentik/stages/captcha[${name}]: handler failed`);
console.debug(error);
this.#logger.debug(`[${name}]: handler failed`);
this.#logger.debug(error);
this.error = pluckErrorDetail(error, "Unspecified error");
}
// We begin listening for locale changes once a handler has been successfully run
// to avoid interrupting the initial load.
window.addEventListener(LOCALE_STATUS_EVENT, this.#localeStatusListener, {
signal: this.#listenController.signal,
});
return;
}
};
@@ -557,21 +603,19 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
const iframe = this.#iframeRef.value;
if (!iframe) {
console.debug(`authentik/stages/captcha: No iframe found, skipping.`);
this.#logger.debug(`No iframe found, skipping.`);
return;
}
const { contentDocument } = iframe;
if (!contentDocument) {
console.debug(
`authentik/stages/captcha: No iframe content window found, skipping.`,
);
this.#logger.debug("No iframe content window found, skipping.");
return;
}
console.debug(`authentik/stages/captcha: Rendering interactive.`);
this.#logger.debug(`Rendering interactive.`);
const captchaElement = handler.interactive();
const template = iframeTemplate(captchaElement, {

View File

@@ -10,6 +10,19 @@
.pf-c-drawer {
.pf-c-drawer__panel {
background-color: var(--pf-c-drawer__panel--BackgroundColor);
transition-behavior: allow-discrete;
gap: var(--pf-global--spacer--sm);
@media (width > 768px) {
flex-flow: row;
.pf-c-drawer__panel_content {
flex: 1 1 auto;
max-width: 33dvw;
}
}
}
}

View File

@@ -64,3 +64,7 @@
min-height: calc(100vh - 76px);
max-height: calc(100vh - 76px);
}
.pf-c-drawer__panel {
background-color: transparent !important;
}

View File

@@ -9,29 +9,34 @@ import "#elements/sidebar/Sidebar";
import "#elements/sidebar/SidebarItem";
import "@patternfly/elements/pf-tooltip/pf-tooltip.js";
import { DEFAULT_CONFIG } from "#common/api/config";
import { EVENT_API_DRAWER_TOGGLE, EVENT_NOTIFICATION_DRAWER_TOGGLE } from "#common/constants";
import { globalAK } from "#common/global";
import { configureSentry } from "#common/sentry/index";
import { isGuest } from "#common/users";
import { WebsocketClient } from "#common/ws";
import { WebsocketClient } from "#common/ws/WebSocketClient";
import { AuthenticatedInterface } from "#elements/AuthenticatedInterface";
import { AKElement } from "#elements/Base";
import { listen } from "#elements/decorators/listen";
import { WithBrandConfig } from "#elements/mixins/branding";
import { canAccessAdmin, WithSession } from "#elements/mixins/session";
import { getURLParam, updateURLParams } from "#elements/router/RouteMatch";
import { AKDrawerChangeEvent } from "#elements/notifications/events";
import {
DrawerState,
persistDrawerParams,
readDrawerParams,
renderNotificationDrawerPanel,
} from "#elements/notifications/utils";
import { ifPresent } from "#elements/utils/attributes";
import { renderImage } from "#elements/utils/images";
import Styles from "#user/index.entrypoint.css";
import { ROUTES } from "#user/Routes";
import { EventsApi } from "@goauthentik/api";
import { ConsoleLogger } from "#logger/browser";
import { msg } from "@lit/localize";
import { html, nothing, PropertyValues } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { html, nothing } from "lit";
import { customElement, state } from "lit/decorators.js";
import { guard } from "lit/directives/guard.js";
import PFAvatar from "@patternfly/patternfly/components/Avatar/avatar.css";
import PFBrand from "@patternfly/patternfly/components/Brand/brand.css";
@@ -46,22 +51,9 @@ if (process.env.NODE_ENV === "development") {
await import("@goauthentik/esbuild-plugin-live-reload/client");
}
// ___ _ _ _
// | _ \_ _ ___ ___ ___ _ _| |_ __ _| |_(_)___ _ _
// | _/ '_/ -_|_-</ -_) ' \ _/ _` | _| / _ \ ' \
// |_| |_| \___/__/\___|_||_\__\__,_|\__|_\___/_||_|
//
// Despite the length of the render() method and its accessories, this top-level Interface does
// surprisingly little. It has been broken into two parts: the business logic at the bottom, and the
// rendering code at the top, which is wholly independent of APIs and Interfaces.
// Because this is not exported, and because it's invoked as a web component, neither TSC or ESLint
// trusts that we actually used it. Hence the double ignore below:
@customElement("ak-interface-user-presentation")
class UserInterfacePresentation extends WithBrandConfig(WithSession(AKElement)) {
static styles = [
@customElement("ak-interface-user")
class UserInterface extends WithBrandConfig(WithSession(AuthenticatedInterface)) {
public static readonly styles = [
PFDisplay,
PFBrand,
PFPage,
@@ -73,51 +65,77 @@ class UserInterfacePresentation extends WithBrandConfig(WithSession(AKElement))
Styles,
];
@property({ type: Boolean, reflect: true })
notificationDrawerOpen = false;
#logger = ConsoleLogger.prefix("user-interface");
@property({ type: Boolean, reflect: true })
apiDrawerOpen = false;
@state()
protected drawer: DrawerState = readDrawerParams();
@property({ type: Number })
notificationsCount = 0;
@listen(AKDrawerChangeEvent)
protected drawerListener = (event: AKDrawerChangeEvent) => {
this.drawer = event.drawer;
persistDrawerParams(event.drawer);
};
renderAdminInterfaceLink() {
if (!canAccessAdmin(this.currentUser)) {
return nothing;
}
//#region Lifecycle
return html`<a
class="pf-c-button pf-m-secondary pf-m-small pf-u-display-none pf-u-display-block-on-md"
href="${globalAK().api.base}if/admin/"
slot="extra"
>
${msg("Admin interface")}
</a>
<a
class="pf-c-button pf-m-secondary pf-m-small pf-u-display-none-on-md pf-u-display-block"
href="${globalAK().api.base}if/admin/"
slot="extra"
>
${msg("Admin")}
</a>`;
constructor() {
configureSentry();
super();
WebsocketClient.connect();
}
render() {
public override disconnectedCallback() {
super.disconnectedCallback();
WebsocketClient.close();
}
//#endregion
//#region Rendering
protected renderAdminInterfaceLink() {
return guard([this.currentUser], () => {
if (!canAccessAdmin(this.currentUser)) {
return nothing;
}
const { base } = globalAK().api;
return html`<a
class="pf-c-button pf-m-secondary pf-m-small pf-u-display-none pf-u-display-block-on-md"
href="${base}if/admin/"
slot="extra"
>
${msg("Admin interface")}
</a>
<a
class="pf-c-button pf-m-secondary pf-m-small pf-u-display-none-on-md pf-u-display-block"
href="${base}if/admin/"
slot="extra"
>
${msg("Admin")}
</a>`;
});
}
protected render() {
const { currentUser } = this;
if (!currentUser) {
console.debug(`authentik/user/UserInterface: waiting for user session to be available`);
const guest = isGuest(currentUser);
return html`<slot></slot>`;
}
if (!currentUser || guest) {
this.#logger.debug("Waiting for user session", {
currentUser,
guest,
});
if (isGuest(currentUser)) {
// TODO: There might be a hidden feature here.
// Allowing guest users to see some parts of the interface?
// Maybe redirect to a flow?
return html`<slot></slot>`;
return html`<slot name="placeholder"></slot>`;
}
const backgroundStyles = this.uiConfig.theme.background;
@@ -139,7 +157,7 @@ class UserInterfacePresentation extends WithBrandConfig(WithSession(AKElement))
</header>
<div class="pf-c-page__drawer">
<div
class="pf-c-drawer ${this.notificationDrawerOpen || this.apiDrawerOpen
class="pf-c-drawer ${this.drawer.notifications || this.drawer.api
? "pf-m-expanded"
: "pf-m-collapsed"}"
>
@@ -156,19 +174,7 @@ class UserInterfacePresentation extends WithBrandConfig(WithSession(AKElement))
</ak-router-outlet>
</div>
</div>
<ak-notification-drawer
class="pf-c-drawer__panel pf-m-width-33 ${this
.notificationDrawerOpen
? ""
: "display-none"}"
?hidden=${!this.notificationDrawerOpen}
></ak-notification-drawer>
<ak-api-drawer
class="pf-c-drawer__panel pf-m-width-33 ${this.apiDrawerOpen
? ""
: "display-none"}"
?hidden=${!this.apiDrawerOpen}
></ak-api-drawer>
${renderNotificationDrawerPanel(this.drawer)}
</div>
</div>
</div>
@@ -176,118 +182,8 @@ class UserInterfacePresentation extends WithBrandConfig(WithSession(AKElement))
}
}
// ___ _
// | _ )_ _ __(_)_ _ ___ ______
// | _ \ || (_-< | ' \/ -_|_-<_-<
// |___/\_,_/__/_|_||_\___/__/__/
//
//
@customElement("ak-interface-user")
export class UserInterface extends WithBrandConfig(WithSession(AuthenticatedInterface)) {
public static shadowRootOptions = { ...AKElement.shadowRootOptions, delegatesFocus: true };
public override tabIndex = -1;
@property({ type: Boolean })
notificationDrawerOpen = getURLParam("notificationDrawerOpen", false);
@state()
apiDrawerOpen = getURLParam("apiDrawerOpen", false);
@state()
notificationsCount = 0;
constructor() {
configureSentry();
super();
WebsocketClient.connect();
this.toggleNotificationDrawer = this.toggleNotificationDrawer.bind(this);
this.toggleApiDrawer = this.toggleApiDrawer.bind(this);
}
async connectedCallback() {
super.connectedCallback();
window.addEventListener(EVENT_NOTIFICATION_DRAWER_TOGGLE, this.toggleNotificationDrawer);
window.addEventListener(EVENT_API_DRAWER_TOGGLE, this.toggleApiDrawer);
}
disconnectedCallback() {
super.disconnectedCallback();
window.removeEventListener(EVENT_NOTIFICATION_DRAWER_TOGGLE, this.toggleNotificationDrawer);
window.removeEventListener(EVENT_API_DRAWER_TOGGLE, this.toggleApiDrawer);
WebsocketClient.close();
}
public updated(changedProperties: PropertyValues<this>): void {
super.updated(changedProperties);
if (changedProperties.has("session")) {
this.refreshNotifications();
}
}
protected refreshNotifications(): Promise<void> {
const { currentUser } = this;
if (!currentUser || isGuest(currentUser)) {
return Promise.resolve();
}
return new EventsApi(DEFAULT_CONFIG)
.eventsNotificationsList({
seen: false,
ordering: "-created",
pageSize: 1,
user: currentUser.pk,
})
.then((notifications) => {
this.notificationsCount = notifications.pagination.count;
});
}
toggleNotificationDrawer() {
this.notificationDrawerOpen = !this.notificationDrawerOpen;
updateURLParams({
notificationDrawerOpen: this.notificationDrawerOpen,
});
}
toggleApiDrawer() {
this.apiDrawerOpen = !this.apiDrawerOpen;
updateURLParams({
apiDrawerOpen: this.apiDrawerOpen,
});
}
render() {
const { currentUser } = this;
if (!currentUser || isGuest(currentUser)) {
console.debug(`authentik/user/UserInterface: waiting for user session to be available`);
return html`<slot></slot>`;
}
return html`<ak-interface-user-presentation
exportparts="background-wrapper, background-default-slant, page, brand, page__header"
?notificationDrawerOpen=${this.notificationDrawerOpen}
?apiDrawerOpen=${this.apiDrawerOpen}
notificationsCount=${this.notificationsCount}
>
<slot name="placeholder"></slot>
</ak-interface-user-presentation>`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ak-interface-user-presentation": UserInterfacePresentation;
"ak-interface-user": UserInterface;
}
}

View File

@@ -19,6 +19,7 @@ import { AuthenticatorsApi, Device, UserSetting } from "@goauthentik/api";
import { msg, str } from "@lit/localize";
import { html, nothing, TemplateResult } from "lit";
import { customElement, property } from "lit/decorators.js";
import { guard } from "lit/directives/guard.js";
import { ifDefined } from "lit/directives/if-defined.js";
export const stageToAuthenticatorName = (stage: UserSetting) =>
@@ -29,8 +30,11 @@ export class MFADevicesPage extends Table<Device> {
@property({ attribute: false })
userSettings?: UserSetting[];
checkbox = true;
clearOnRefresh = true;
public override checkbox = true;
public override clearOnRefresh = true;
public override label = msg("MFA Devices");
protected override emptyStateMessage = msg("No MFA devices enrolled.");
async apiEndpoint(): Promise<PaginatedResponse<Device>> {
const devices = await new AuthenticatorsApi(DEFAULT_CONFIG).authenticatorsAllList();
@@ -56,14 +60,17 @@ export class MFADevicesPage extends Table<Device> {
[msg("Actions"), null, msg("Row Actions")],
];
renderToolbar(): TemplateResult {
const settings = (this.userSettings || []).filter((stage) => {
if (stage.component === "ak-user-settings-password") {
return false;
}
return stage.configureUrl;
});
return html`<ak-dropdown class="pf-c-dropdown">
protected renderEnrollButton(): SlottedTemplateResult {
return guard([this.userSettings], () => {
const settings = (this.userSettings || []).filter((stage) => {
if (stage.component === "ak-user-settings-password") {
return false;
}
return stage.configureUrl;
});
return html`<ak-dropdown class="pf-c-dropdown">
<button
class="pf-m-primary pf-c-dropdown__toggle"
type="button"
@@ -99,8 +106,12 @@ export class MFADevicesPage extends Table<Device> {
</li>`;
})}
</ul>
</ak-dropdown>
${super.renderToolbar()}`;
</ak-dropdown>`;
});
}
protected override renderToolbar(): TemplateResult {
return html`${this.renderEnrollButton()} ${super.renderToolbar()}`;
}
async deleteWrapper(device: Device) {

View File

@@ -26,18 +26,22 @@ import PFDescriptionList from "@patternfly/patternfly/components/DescriptionList
export class UserTokenList extends Table<Token> {
protected override searchEnabled = true;
expandable = true;
checkbox = true;
clearOnRefresh = true;
public override expandable = true;
public override checkbox = true;
public override clearOnRefresh = true;
@property()
order = "expires";
@property({ type: String })
public override order = "expires";
public override label = msg("User Tokens");
protected override emptyStateMessage = msg("No User Tokens enrolled.");
async apiEndpoint(): Promise<PaginatedResponse<Token>> {
let { currentUser } = this;
if (!currentUser) {
currentUser = (await this.refreshSession()).user;
const session = await this.refreshSession();
currentUser = session ? session.user : null;
}
return new CoreApi(DEFAULT_CONFIG).coreTokensList({
@@ -45,7 +49,7 @@ export class UserTokenList extends Table<Token> {
managed: "",
// The user might have access to other tokens that aren't for their user
// but only show tokens for their user here
userUsername: currentUser.username,
userUsername: currentUser?.username,
});
}