diff --git a/tests/e2e/test_source_oauth_oauth2.py b/tests/e2e/test_source_oauth_oauth2.py index bef591742b40..e1752c6f24e4 100644 --- a/tests/e2e/test_source_oauth_oauth2.py +++ b/tests/e2e/test_source_oauth_oauth2.py @@ -89,26 +89,7 @@ def find_settings_tab_panel(self, tab_name: str, panel_content_selector: str): 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 ''}" - ) - - 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 diff --git a/web/logger/browser.js b/web/logger/browser.js new file mode 100644 index 000000000000..bc1da186a3fe --- /dev/null +++ b/web/logger/browser.js @@ -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} + */ + 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 diff --git a/web/src/admin/AdminInterface/index.entrypoint.css b/web/src/admin/AdminInterface/index.entrypoint.css index 8cf89f789b9c..d5fb6420c2ee 100644 --- a/web/src/admin/AdminInterface/index.entrypoint.css +++ b/web/src/admin/AdminInterface/index.entrypoint.css @@ -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; } diff --git a/web/src/admin/AdminInterface/index.entrypoint.ts b/web/src/admin/AdminInterface/index.entrypoint.ts index 9392ebbaeeef..6f09c4926431 100644 --- a/web/src/admin/AdminInterface/index.entrypoint.ts +++ b/web/src/admin/AdminInterface/index.entrypoint.ts @@ -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,28 +39,36 @@ 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)) { - //#region Properties +export class AdminInterface extends WithCapabilitiesConfig( + WithNotifications(WithSession(AuthenticatedInterface)), +) { + //#region Styles + + public static readonly styles: CSSResult[] = [ + // --- + PFPage, + PFButton, + PFDrawer, + PFNav, + Styles, + ]; - @property({ type: Boolean }) - public notificationDrawerOpen = getURLParam("notificationDrawerOpen", false); + //#endregion - @property({ type: Boolean }) - public apiDrawerOpen = getURLParam("apiDrawerOpen", false); + //#region Properties @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`
@@ -190,18 +185,7 @@ export class AdminInterface extends WithCapabilitiesConfig(WithSession(Authentic
- - + ${renderNotificationDrawerPanel(this.drawer)} diff --git a/web/src/admin/endpoints/DeviceAccessGroupsListPage.ts b/web/src/admin/endpoints/DeviceAccessGroupsListPage.ts index bc28585e2b76..5644b3d77b02 100644 --- a/web/src/admin/endpoints/DeviceAccessGroupsListPage.ts +++ b/web/src/admin/endpoints/DeviceAccessGroupsListPage.ts @@ -52,7 +52,6 @@ export class DeviceAccessGroupsListPage extends TablePage { -
`, ]; } diff --git a/web/src/admin/providers/BaseProviderForm.ts b/web/src/admin/providers/BaseProviderForm.ts index 3283ee01463f..6457bde38408 100644 --- a/web/src/admin/providers/BaseProviderForm.ts +++ b/web/src/admin/providers/BaseProviderForm.ts @@ -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"; diff --git a/web/src/admin/users/UserImpersonateForm.ts b/web/src/admin/users/UserImpersonateForm.ts index 867299630456..02de9997487f 100644 --- a/web/src/admin/users/UserImpersonateForm.ts +++ b/web/src/admin/users/UserImpersonateForm.ts @@ -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"; diff --git a/web/src/common/api/events.ts b/web/src/common/api/events.ts new file mode 100644 index 000000000000..8840b6a4eeb5 --- /dev/null +++ b/web/src/common/api/events.ts @@ -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; + } +} diff --git a/web/src/common/api/middleware.ts b/web/src/common/api/middleware.ts index 39a31abd65e6..a43e032a7193 100644 --- a/web/src/common/api/middleware.ts +++ b/web/src/common/api/middleware.ts @@ -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 { - 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 { + 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 { - 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); } } diff --git a/web/src/common/constants.ts b/web/src/common/constants.ts index cdcde9287cbd..46ef022224ad 100644 --- a/web/src/common/constants.ts +++ b/web/src/common/constants.ts @@ -6,6 +6,9 @@ /// +// 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,25 +32,19 @@ 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"; -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"; +/** + * Event name for enterprise refresh events. + * + * @deprecated Use {@linkcode AKEnterpriseRefreshEvent} + */ +export const EVENT_REFRESH_ENTERPRISE = "ak-refresh-enterprise"; //#endregion diff --git a/web/src/common/events.ts b/web/src/common/events.ts index b0efac85aafd..04a6a214ac03 100644 --- a/web/src/common/events.ts +++ b/web/src/common/events.ts @@ -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; } diff --git a/web/src/common/messages.ts b/web/src/common/messages.ts index 651a7d9f3a22..2553ee43cb52 100644 --- a/web/src/common/messages.ts +++ b/web/src/common/messages.ts @@ -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; + } +} diff --git a/web/src/common/sentry/index.ts b/web/src/common/sentry/index.ts index e59cb6bd015b..44debb9085e7 100644 --- a/web/src/common/sentry/index.ts +++ b/web/src/common/sentry/index.ts @@ -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!"); } diff --git a/web/src/common/ws.ts b/web/src/common/ws/WebSocketClient.ts similarity index 73% rename from web/src/common/ws.ts rename to web/src/common/ws/WebSocketClient.ts index 695d091006d6..a9d1a03fe777 100644 --- a/web/src/common/ws.ts +++ b/web/src/common/ws/WebSocketClient.ts @@ -1,12 +1,12 @@ -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 { msg } from "@lit/localize"; +import { showMessage } from "#elements/messages/MessageContainer"; -export interface WSMessage { - message_type: string; -} +import { ConsoleLogger } from "#logger/browser"; + +import { msg } from "@lit/localize"; /** * A websocket client that automatically reconnects. @@ -14,6 +14,7 @@ export interface WSMessage { * @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) => { 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); diff --git a/web/src/common/ws/events.ts b/web/src/common/ws/events.ts new file mode 100644 index 000000000000..dbb8f694f823 --- /dev/null +++ b/web/src/common/ws/events.ts @@ -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) { + 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; + } +} diff --git a/web/src/components/ak-nav-buttons.ts b/web/src/components/ak-nav-buttons.ts index 018c538f8a38..b4f09a6d76f0 100644 --- a/web/src/components/ak-nav-buttons.ts +++ b/web/src/components/ak-nav-buttons.ts @@ -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 async refreshNotifications(): Promise { - const { currentUser } = this; - - if (!currentUser || isGuest(currentUser)) { - return; - } - - const notifications = await new EventsApi(DEFAULT_CONFIG).eventsNotificationsList({ - seen: false, - ordering: "-created", - pageSize: 1, - user: currentUser.pk, + protected renderAPIDrawerTrigger() { + const { apiDrawer } = this.uiConfig.enabledFeatures; + + return guard([apiDrawer], () => { + if (!apiDrawer) { + return nothing; + } + + return html`
+ +
`; }); - - 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`
- -
`; - } - - 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`
- -
`; + + + + + + ${notificationCount} + unread + + + + `; + }); } 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")} > @@ -192,7 +190,7 @@ export class NavigationButtons extends WithSession(AKElement) { return html`