mirror of
https://github.com/goauthentik/authentik
synced 2026-04-25 17:15:26 +02:00
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:
@@ -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
139
web/logger/browser.js
Normal 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
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -52,7 +52,6 @@ export class DeviceAccessGroupsListPage extends TablePage<DeviceAccessGroup> {
|
||||
</pf-tooltip>
|
||||
</button>
|
||||
</ak-forms-modal>
|
||||
<div></div>
|
||||
</div>`,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
34
web/src/common/api/events.ts
Normal file
34
web/src/common/api/events.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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!");
|
||||
}
|
||||
|
||||
@@ -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
102
web/src/common/ws/events.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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()}
|
||||
<!-- -->
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
12
web/src/elements/controllers/ContextControllerRegistry.ts
Normal file
12
web/src/elements/controllers/ContextControllerRegistry.ts
Normal 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;
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
151
web/src/elements/controllers/NotificationsContextController.ts
Normal file
151
web/src/elements/controllers/NotificationsContextController.ts
Normal 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();
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
190
web/src/elements/decorators/listen.ts
Normal file
190
web/src/elements/decorators/listen.ts
Normal 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
|
||||
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
153
web/src/elements/mixins/notifications.ts
Normal file
153
web/src/elements/mixins/notifications.ts
Normal 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;
|
||||
},
|
||||
);
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
70
web/src/elements/notifications/events.ts
Normal file
70
web/src/elements/notifications/events.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
98
web/src/elements/notifications/utils.ts
Normal file
98
web/src/elements/notifications/utils.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
.pf-c-drawer__body {
|
||||
max-height: 100dvh;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
:host {
|
||||
|
||||
@@ -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
67
web/src/flow/events.ts
Normal 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
|
||||
@@ -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, {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -64,3 +64,7 @@
|
||||
min-height: calc(100vh - 76px);
|
||||
max-height: calc(100vh - 76px);
|
||||
}
|
||||
|
||||
.pf-c-drawer__panel {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user