web: Normalize client-side error handling (#13595)

web: Clean up error handling. Prep for permission checks.

- Add clearer reporting for API and network errors.
- Tidy error checking.
- Partial type safety for events.
This commit is contained in:
Teffen Ellis
2025-04-07 19:50:41 +02:00
committed by GitHub
parent e93b2a1a75
commit 363d655378
53 changed files with 901 additions and 493 deletions

View File

@ -1,10 +1,16 @@
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { VERSION } from "@goauthentik/common/constants";
import { PFSize } from "@goauthentik/common/enums.js";
import { EventContext, EventModel, EventWithContext } from "@goauthentik/common/events";
import {
EventContext,
EventContextProperty,
EventModel,
EventWithContext,
} from "@goauthentik/common/events";
import { AKElement } from "@goauthentik/elements/Base";
import "@goauthentik/elements/Expand";
import "@goauthentik/elements/Spinner";
import { SlottedTemplateResult } from "@goauthentik/elements/types";
import { msg, str } from "@lit/localize";
import { CSSResult, TemplateResult, css, html } from "lit";
@ -23,7 +29,15 @@ import PFBase from "@patternfly/patternfly/patternfly-base.css";
import { EventActions, FlowsApi } from "@goauthentik/api";
type Pair = [string, string | number | EventContext | EventModel | string[] | TemplateResult];
// TODO: Settle these types. It's too hard to make sense of what we're expecting here.
type EventSlotValueType =
| number
| SlottedTemplateResult
| undefined
| EventContext
| EventContextProperty;
type FieldLabelTuple<V extends EventSlotValueType = EventSlotValueType> = [label: string, value: V];
// https://docs.github.com/en/issues/tracking-your-work-with-issues/creating-issues/about-automation-for-issues-and-pull-requests-with-query-parameters
@ -104,7 +118,7 @@ export class EventInfo extends AKElement {
];
}
renderDescriptionGroup([term, description]: Pair) {
renderDescriptionGroup([term, description]: FieldLabelTuple) {
return html` <div class="pf-c-description-list__group">
<dt class="pf-c-description-list__term">
<span class="pf-c-description-list__text">${term}</span>
@ -120,7 +134,7 @@ export class EventInfo extends AKElement {
return html`<span>-</span>`;
}
const modelFields: Pair[] = [
const modelFields: FieldLabelTuple[] = [
[msg("UID"), context.pk],
[msg("Name"), context.name],
[msg("App"), context.app],
@ -134,20 +148,23 @@ export class EventInfo extends AKElement {
</div>`;
}
getEmailInfo(context: EventContext): TemplateResult {
getEmailInfo(context: EventContext): SlottedTemplateResult {
if (context === null) {
return html`<span>-</span>`;
}
// prettier-ignore
const emailFields: Pair[] = [
const emailFields = [
// ---
[msg("Message"), context.message],
[msg("Subject"), context.subject],
[msg("From"), context.from_email],
[msg("To"), html`${(context.to_email as string[]).map((to) => {
[
msg("To"),
html`${(context.to_email as string[]).map((to) => {
return html`<li>${to}</li>`;
})}`],
];
})}`,
],
] satisfies FieldLabelTuple<EventSlotValueType>[];
return html`<dl class="pf-c-description-list pf-m-horizontal">
${map(emailFields, this.renderDescriptionGroup)}

View File

@ -14,7 +14,7 @@ import PFTitle from "@patternfly/patternfly/components/Title/title.css";
import PFWizard from "@patternfly/patternfly/components/Wizard/wizard.css";
import { wizardStepContext } from "./WizardContexts.js";
import { NavigationUpdate, WizardCloseEvent, WizardNavigationEvent } from "./events.js";
import { NavigationEventInit, WizardCloseEvent, WizardNavigationEvent } from "./events.js";
import { WizardStepLabel, WizardStepState } from "./types";
import { type ButtonKind, type NavigableButton, type WizardButton } from "./types";
@ -139,7 +139,7 @@ export class WizardStep extends AKElement {
// Override this to intercept 'next' and 'back' events, perform validation, and include enabling
// before allowing navigation to continue.
public handleButton(button: WizardButton, details?: NavigationUpdate) {
public handleButton(button: WizardButton, details?: NavigationEventInit) {
if (["close", "cancel"].includes(button.kind)) {
this.dispatchEvent(new WizardCloseEvent());
return;
@ -153,7 +153,7 @@ export class WizardStep extends AKElement {
throw new Error(`Incoherent button passed: ${JSON.stringify(button, null, 2)}`);
}
public handleEnabling(details: NavigationUpdate) {
public handleEnabling(details: NavigationEventInit) {
this.dispatchEvent(new WizardNavigationEvent(undefined, details));
}
@ -185,13 +185,6 @@ export class WizardStep extends AKElement {
this.dispatchEvent(new WizardCloseEvent());
}
@bound
onSidebarNav(ev: PointerEvent) {
ev.stopPropagation();
const target = (ev.target as HTMLButtonElement).value;
this.dispatchEvent(new WizardNavigationEvent(target));
}
getButtonLabel(button: WizardButton) {
return button.label ?? BUTTON_KIND_TO_LABEL[button.kind];
}
@ -269,7 +262,7 @@ export class WizardStep extends AKElement {
<button
class=${classMap(buttonClasses)}
?disabled=${!step.enabled}
@click=${this.onSidebarNav}
@click=${WizardNavigationEvent.toListener(this, step.id)}
value=${step.id}
>
${step.label}

View File

@ -7,7 +7,7 @@ import { customElement, property } from "lit/decorators.js";
import { wizardStepContext } from "./WizardContexts";
import { type WizardStep } from "./WizardStep";
import { NavigationUpdate, WizardNavigationEvent } from "./events";
import { NavigationEventInit, WizardNavigationEvent } from "./events";
import { WizardStepState } from "./types";
/**
@ -108,7 +108,7 @@ export class WizardStepsManager extends AKElement {
// through the entire wizard," but since the user invalidated a prior, that shouldn't be
// unexpected. None of the data will have been lost.
updateStepAvailability(details: NavigationUpdate) {
updateStepAvailability(details: NavigationEventInit) {
const asArr = (v?: string[] | string) =>
v === undefined ? [] : Array.isArray(v) ? v : [v];
const enabled = asArr(details.enable);

View File

@ -1,26 +1,49 @@
export type NavigationUpdate = {
/**
* Initialization options for a wizard navigation event.
*/
export interface NavigationEventInit {
disabled?: string[];
enable?: string | string[];
hidden?: string[];
};
}
export class WizardNavigationEvent extends Event {
/**
* Event dispatched when the wizard navigation is updated.
*/
export class WizardNavigationEvent<D extends string = string> extends Event {
static readonly eventName = "ak-wizard-navigation";
destination?: string;
details?: NavigationUpdate;
public readonly destination?: D;
public readonly details?: NavigationEventInit;
constructor(destination?: string, details?: NavigationUpdate) {
constructor(destination?: D, init?: NavigationEventInit) {
super(WizardNavigationEvent.eventName, { bubbles: true, composed: true });
this.destination = destination;
this.details = details;
this.details = init;
}
/**
* Given an event target, bind the destination and details for dispatching.
*/
static toListener<D extends string = string>(
target: EventTarget,
destination: D,
init?: NavigationEventInit,
) {
const wizardNavigationListener = (event?: Event) => {
event?.preventDefault?.();
return target.dispatchEvent(new this(destination, init));
};
return wizardNavigationListener;
}
}
export class WizardUpdateEvent<T> extends Event {
static readonly eventName = "ak-wizard-update";
content: T;
public readonly content: T;
constructor(content: T) {
super(WizardUpdateEvent.eventName, { bubbles: true, composed: true });
@ -39,8 +62,7 @@ export class WizardCloseEvent extends Event {
declare global {
interface GlobalEventHandlersEventMap {
[WizardNavigationEvent.eventName]: WizardNavigationEvent;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[WizardUpdateEvent.eventName]: WizardUpdateEvent<any>;
[WizardUpdateEvent.eventName]: WizardUpdateEvent<never>;
[WizardCloseEvent.eventName]: WizardCloseEvent;
}
}

View File

@ -10,6 +10,7 @@ import "@goauthentik/elements/buttons/ModalButton";
import "@goauthentik/elements/buttons/SpinnerButton";
import { PaginatedResponse } from "@goauthentik/elements/table/Table";
import { Table, TableColumn } from "@goauthentik/elements/table/Table";
import { SlottedTemplateResult } from "@goauthentik/elements/types";
import { msg } from "@lit/localize";
import { PropertyValues, TemplateResult, html } from "lit";
@ -68,7 +69,7 @@ export class ObjectChangelog extends Table<Event> {
}
}
row(item: EventWithContext): TemplateResult[] {
row(item: EventWithContext): SlottedTemplateResult[] {
return [
html`${actionToLabel(item.action)}`,
EventUser(item),

View File

@ -9,6 +9,7 @@ import "@goauthentik/elements/buttons/Dropdown";
import "@goauthentik/elements/buttons/ModalButton";
import "@goauthentik/elements/buttons/SpinnerButton";
import { PaginatedResponse, Table, TableColumn } from "@goauthentik/elements/table/Table";
import { SlottedTemplateResult } from "@goauthentik/elements/types";
import { msg } from "@lit/localize";
import { TemplateResult, html } from "lit";
@ -42,7 +43,7 @@ export class UserEvents extends Table<Event> {
];
}
row(item: EventWithContext): TemplateResult[] {
row(item: EventWithContext): SlottedTemplateResult[] {
return [
html`${actionToLabel(item.action)}`,
EventUser(item),