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

@ -47,7 +47,7 @@ export class ActionButton extends BaseTaskButton {
const message = error instanceof Error ? error.toString() : await error.text();
showMessage({
level: MessageLevel.error,
message,
message: message,
});
}
}

View File

@ -84,7 +84,7 @@ export class TokenCopyButton extends BaseTaskButton {
showMessage({
level: MessageLevel.error,
message,
message: message,
});
}
}

View File

@ -1,4 +1,9 @@
import { EVENT_REFRESH, EVENT_THEME_CHANGE } from "@goauthentik/common/constants";
import {
APIError,
parseAPIResponseError,
pluckErrorDetail,
} from "@goauthentik/common/errors/network";
import { getRelativeTime } from "@goauthentik/common/utils";
import { AKElement } from "@goauthentik/elements/Base";
import "@goauthentik/elements/EmptyState";
@ -23,7 +28,7 @@ import { msg } from "@lit/localize";
import { CSSResult, TemplateResult, css, html } from "lit";
import { property, state } from "lit/decorators.js";
import { ResponseError, UiThemeEnum } from "@goauthentik/api";
import { UiThemeEnum } from "@goauthentik/api";
Chart.register(Legend, Tooltip);
Chart.register(LineController, BarController, DoughnutController);
@ -67,7 +72,7 @@ export abstract class AKChart<T> extends AKElement {
chart?: Chart;
@state()
error?: ResponseError;
error?: APIError;
@property()
centerText?: string;
@ -79,6 +84,9 @@ export abstract class AKChart<T> extends AKElement {
css`
.container {
height: 100%;
width: 100%;
aspect-ratio: 1 / 1;
display: flex;
justify-content: center;
align-items: center;
@ -92,6 +100,7 @@ export abstract class AKChart<T> extends AKElement {
width: 100px;
height: 100px;
z-index: 1;
cursor: crosshair;
}
`,
];
@ -136,19 +145,24 @@ export abstract class AKChart<T> extends AKElement {
this.apiRequest()
.then((r) => {
const canvas = this.shadowRoot?.querySelector<HTMLCanvasElement>("canvas");
if (!canvas) {
console.warn("Failed to get canvas element");
return;
}
const ctx = canvas.getContext("2d");
if (!ctx) {
console.warn("failed to get 2d context");
return;
}
this.chart = this.configureChart(r, ctx);
})
.catch((exc: ResponseError) => {
this.error = exc;
.catch(async (error: unknown) => {
const parsedError = await parseAPIResponseError(error);
this.error = parsedError;
});
}
@ -214,7 +228,7 @@ export abstract class AKChart<T> extends AKElement {
${this.error
? html`
<ak-empty-state header="${msg("Failed to fetch data.")}" icon="fa-times">
<p slot="body">${this.error.response.statusText}</p>
<p slot="body">${pluckErrorDetail(this.error)}</p>
</ak-empty-state>
`
: html`${this.chart

View File

@ -1,4 +1,5 @@
import { EVENT_REFRESH } from "@goauthentik/common/constants";
import { parseAPIResponseError, pluckErrorDetail } from "@goauthentik/common/errors/network";
import { MessageLevel } from "@goauthentik/common/messages";
import { ModalButton } from "@goauthentik/elements/buttons/ModalButton";
import "@goauthentik/elements/buttons/SpinnerButton";
@ -33,9 +34,9 @@ export class ConfirmationForm extends ModalButton {
}),
);
})
.catch((e) => {
this.onError(e);
throw e;
.catch(async (error: unknown) => {
await this.onError(error);
throw error;
});
}
@ -46,10 +47,12 @@ export class ConfirmationForm extends ModalButton {
});
}
onError(e: Error): void {
showMessage({
message: msg(str`${this.errorMessage}: ${e.toString()}`),
level: MessageLevel.error,
onError(error: unknown): Promise<void> {
return parseAPIResponseError(error).then((parsedError) => {
showMessage({
message: msg(str`${this.errorMessage}: ${pluckErrorDetail(parsedError)}`),
level: MessageLevel.error,
});
});
}

View File

@ -1,4 +1,5 @@
import { EVENT_REFRESH } from "@goauthentik/common/constants";
import { parseAPIResponseError, pluckErrorDetail } from "@goauthentik/common/errors/network";
import { MessageLevel } from "@goauthentik/common/messages";
import { ModalButton } from "@goauthentik/elements/buttons/ModalButton";
import "@goauthentik/elements/buttons/SpinnerButton";
@ -36,6 +37,7 @@ export class DeleteForm extends ModalButton {
.then(() => {
this.onSuccess();
this.open = false;
this.dispatchEvent(
new CustomEvent(EVENT_REFRESH, {
bubbles: true,
@ -43,9 +45,10 @@ export class DeleteForm extends ModalButton {
}),
);
})
.catch((e) => {
this.onError(e);
throw e;
.catch(async (error: unknown) => {
await this.onError(error);
throw error;
});
}
@ -56,10 +59,14 @@ export class DeleteForm extends ModalButton {
});
}
onError(e: Error): void {
showMessage({
message: msg(str`Failed to delete ${this.objectLabel}: ${e.toString()}`),
level: MessageLevel.error,
onError(error: unknown): Promise<void> {
return parseAPIResponseError(error).then((parsedError) => {
showMessage({
message: msg(
str`Failed to delete ${this.objectLabel}: ${pluckErrorDetail(parsedError)}`,
),
level: MessageLevel.error,
});
});
}

View File

@ -1,5 +1,5 @@
import { EVENT_REFRESH } from "@goauthentik/common/constants";
import { parseAPIError } from "@goauthentik/common/errors";
import { parseAPIResponseError, pluckErrorDetail } from "@goauthentik/common/errors/network";
import { MessageLevel } from "@goauthentik/common/messages";
import { camelToSnake, convertToSlug, dateToUTC } from "@goauthentik/common/utils";
import { AKElement } from "@goauthentik/elements/Base";
@ -20,13 +20,7 @@ import PFInputGroup from "@patternfly/patternfly/components/InputGroup/input-gro
import PFSwitch from "@patternfly/patternfly/components/Switch/switch.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
import { ResponseError, ValidationError, instanceOfValidationError } from "@goauthentik/api";
export class APIError extends Error {
constructor(public response: ValidationError) {
super();
}
}
import { instanceOfValidationError } from "@goauthentik/api";
export interface KeyUnknown {
[key: string]: unknown;
@ -285,73 +279,82 @@ export abstract class Form<T> extends AKElement {
* field-levels errors to the fields, and send the rest of them to the Notifications.
*
*/
async submit(ev: Event): Promise<unknown | undefined> {
ev.preventDefault();
try {
const data = this.serializeForm();
if (!data) {
return;
}
const response = await this.send(data);
showMessage({
level: MessageLevel.success,
message: this.getSuccessMessage(),
});
this.dispatchEvent(
new CustomEvent(EVENT_REFRESH, {
bubbles: true,
composed: true,
}),
);
return response;
} catch (ex) {
if (ex instanceof ResponseError) {
let errorMessage = ex.response.statusText;
const error = await parseAPIError(ex);
if (instanceOfValidationError(error)) {
async submit(event: Event): Promise<unknown | undefined> {
event.preventDefault();
const data = this.serializeForm();
if (!data) return;
return this.send(data)
.then((response) => {
showMessage({
level: MessageLevel.success,
message: this.getSuccessMessage(),
});
this.dispatchEvent(
new CustomEvent(EVENT_REFRESH, {
bubbles: true,
composed: true,
}),
);
return response;
})
.catch(async (error: unknown) => {
if (error instanceof PreventFormSubmit && error.element) {
error.element.errorMessages = [error.message];
error.element.invalid = true;
}
const parsedError = await parseAPIResponseError(error);
let errorMessage = pluckErrorDetail(error);
if (instanceOfValidationError(parsedError)) {
// assign all input-related errors to their elements
const elements =
this.shadowRoot?.querySelectorAll<HorizontalFormElement>(
"ak-form-element-horizontal",
) || [];
elements.forEach((element) => {
element.requestUpdate();
const elementName = element.name;
if (!elementName) {
return;
}
if (camelToSnake(elementName) in error) {
element.errorMessages = (error as ValidationError)[
camelToSnake(elementName)
];
if (!elementName) return;
const snakeProperty = camelToSnake(elementName);
if (snakeProperty in parsedError) {
element.errorMessages = parsedError[snakeProperty];
element.invalid = true;
} else {
element.errorMessages = [];
element.invalid = false;
}
});
if ((error as ValidationError).nonFieldErrors) {
this.nonFieldErrors = (error as ValidationError).nonFieldErrors;
if (parsedError.nonFieldErrors) {
this.nonFieldErrors = parsedError.nonFieldErrors;
}
errorMessage = msg("Invalid update request.");
// Only change the message when we have `detail`.
// Everything else is handled in the form.
if ("detail" in (error as ValidationError)) {
errorMessage = (error as ValidationError).detail;
if ("detail" in parsedError) {
errorMessage = parsedError.detail;
}
}
showMessage({
message: errorMessage,
level: MessageLevel.error,
});
}
if (ex instanceof PreventFormSubmit && ex.element) {
ex.element.errorMessages = [ex.message];
ex.element.invalid = true;
}
// rethrow the error so the form doesn't close
throw ex;
}
// Rethrow the error so the form doesn't close.
throw error;
});
}
renderFormWrapper(): TemplateResult {

View File

@ -47,10 +47,11 @@ export class ModalForm extends ModalButton {
this.loading = false;
this.locked = false;
})
.catch((exc) => {
.catch((error: unknown) => {
this.loading = false;
this.locked = false;
throw exc;
throw error;
});
}

View File

@ -1,5 +1,9 @@
import { EVENT_REFRESH } from "@goauthentik/common/constants";
import { APIErrorTypes, parseAPIError } from "@goauthentik/common/errors";
import {
APIError,
parseAPIResponseError,
pluckErrorDetail,
} from "@goauthentik/common/errors/network";
import { groupBy } from "@goauthentik/common/utils";
import { AkControlElement } from "@goauthentik/elements/AkControlElement.js";
import { PreventFormSubmit } from "@goauthentik/elements/forms/helpers";
@ -13,8 +17,6 @@ import { ifDefined } from "lit/directives/if-defined.js";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
import { ResponseError } from "@goauthentik/api";
import "./ak-search-select-loading-indicator.js";
import "./ak-search-select-view.js";
import { SearchSelectView } from "./ak-search-select-view.js";
@ -99,7 +101,7 @@ export class SearchSelectBase<T> extends AkControlElement<string> implements ISe
isFetchingData = false;
@state()
error?: APIErrorTypes;
error?: APIError;
public toForm(): string {
if (!this.objects) {
@ -128,23 +130,26 @@ export class SearchSelectBase<T> extends AkControlElement<string> implements ISe
}
this.isFetchingData = true;
this.dispatchEvent(new Event("loading"));
return this.fetchObjects(this.query)
.then((objects) => {
objects.forEach((obj) => {
if (this.selected && this.selected(obj, objects || [])) {
.then((nextObjects) => {
nextObjects.forEach((obj) => {
if (this.selected && this.selected(obj, nextObjects || [])) {
this.selectedObject = obj;
this.dispatchChangeEvent(this.selectedObject);
}
});
this.objects = objects;
this.objects = nextObjects;
this.isFetchingData = false;
})
.catch((exc: ResponseError) => {
.catch(async (error: unknown) => {
this.isFetchingData = false;
this.objects = undefined;
parseAPIError(exc).then((err) => {
this.error = err;
});
const parsedError = await parseAPIResponseError(error);
this.error = parsedError;
});
}
@ -233,7 +238,9 @@ export class SearchSelectBase<T> extends AkControlElement<string> implements ISe
public override render() {
if (this.error) {
return html`<em>${msg("Failed to fetch objects: ")} ${this.error.detail}</em>`;
return html`<em
>${msg("Failed to fetch objects: ")} ${pluckErrorDetail(this.error)}</em
>`;
}
// `this.objects` is both a container and a sigil; if it is in the `undefined` state, it's a

View File

@ -11,17 +11,16 @@ import PFBase from "@patternfly/patternfly/patternfly-base.css";
export interface APIMessage {
level: MessageLevel;
tags?: string;
message: string;
description?: string;
}
const LEVEL_ICON_MAP: { [key: string]: string } = {
const LEVEL_ICON_MAP = {
error: "fas fa-exclamation-circle",
warning: "fas fa-exclamation-triangle",
success: "fas fa-check-circle",
info: "fas fa-info",
};
} as const satisfies Record<MessageLevel, string>;
@customElement("ak-message")
export class Message extends AKElement {

View File

@ -3,7 +3,9 @@ import {
EVENT_WS_MESSAGE,
WS_MSG_TYPE_MESSAGE,
} from "@goauthentik/common/constants";
import { SentryIgnoredError } from "@goauthentik/common/errors";
import { APIError, pluckErrorDetail } from "@goauthentik/common/errors/network";
import { MessageLevel } from "@goauthentik/common/messages";
import { SentryIgnoredError } from "@goauthentik/common/sentry";
import { WSMessage } from "@goauthentik/common/ws";
import { AKElement } from "@goauthentik/elements/Base";
import "@goauthentik/elements/messages/Message";
@ -16,18 +18,63 @@ import { customElement, property } from "lit/decorators.js";
import PFAlertGroup from "@patternfly/patternfly/components/AlertGroup/alert-group.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
import { instanceOfValidationError } from "@goauthentik/api";
/**
* Adds a message to the message container, displaying it to the user.
* @param message The message to display.
* @param unique Whether to only display the message if the title is unique.
*/
export function showMessage(message: APIMessage, unique = false): void {
const container = document.querySelector<MessageContainer>("ak-message-container");
if (!container) {
throw new SentryIgnoredError("failed to find message container");
}
if (message.message.trim() === "") {
if (!message.message.trim()) {
message.message = msg("Error");
}
container.addMessage(message, unique);
container.requestUpdate();
}
/**
* Given an API error, display the error(s) to the user.
*
* @param error The API error to display.
* @param unique Whether to only display the message if the title is unique.
* @see {@link parseAPIResponseError} for more information on how to handle API errors.
*/
export function showAPIErrorMessage(error: APIError, unique = false): void {
if (
instanceOfValidationError(error) &&
Array.isArray(error.nonFieldErrors) &&
error.nonFieldErrors.length
) {
for (const nonFieldError of error.nonFieldErrors) {
showMessage(
{
level: MessageLevel.error,
message: nonFieldError,
},
unique,
);
}
return;
}
showMessage(
{
level: MessageLevel.error,
message: pluckErrorDetail(error),
},
unique,
);
}
@customElement("ak-message-container")
export class MessageContainer extends AKElement {
@property({ attribute: false })
@ -48,10 +95,13 @@ export class MessageContainer extends AKElement {
constructor() {
super();
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);
@ -59,20 +109,20 @@ export class MessageContainer extends AKElement {
addMessage(message: APIMessage, unique = false): void {
if (unique) {
const matchingMessages = this.messages.filter((m) => m.message == message.message);
if (matchingMessages.length > 0) {
return;
}
const matchIndex = this.messages.findIndex((m) => m.message === message.message);
if (matchIndex !== -1) return;
}
this.messages.push(message);
this.requestUpdate();
}
render(): TemplateResult {
return html`<ul class="pf-c-alert-group pf-m-toast">
${this.messages.map((m) => {
${this.messages.map((message) => {
return html`<ak-message
.message=${m}
.message=${message}
.onRemove=${(m: APIMessage) => {
this.messages = this.messages.filter((v) => v !== m);
this.requestUpdate();

View File

@ -1,5 +1,9 @@
import { EVENT_REFRESH } from "@goauthentik/common/constants";
import { APIErrorTypes, parseAPIError } from "@goauthentik/common/errors";
import {
APIError,
parseAPIResponseError,
pluckErrorDetail,
} from "@goauthentik/common/errors/network";
import { uiConfig } from "@goauthentik/common/ui/config";
import { groupBy } from "@goauthentik/common/utils";
import { AKElement } from "@goauthentik/elements/Base";
@ -10,9 +14,10 @@ import "@goauthentik/elements/chips/ChipGroup";
import { getURLParam, updateURLParams } from "@goauthentik/elements/router/RouteMatch";
import "@goauthentik/elements/table/TablePagination";
import "@goauthentik/elements/table/TableSearch";
import { SlottedTemplateResult } from "@goauthentik/elements/types";
import { msg } from "@lit/localize";
import { CSSResult, TemplateResult, css, html } from "lit";
import { CSSResult, TemplateResult, css, html, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import { classMap } from "lit/directives/class-map.js";
import { ifDefined } from "lit/directives/if-defined.js";
@ -26,7 +31,7 @@ import PFToolbar from "@patternfly/patternfly/components/Toolbar/toolbar.css";
import PFBullseye from "@patternfly/patternfly/layouts/Bullseye/bullseye.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
import { Pagination, ResponseError } from "@goauthentik/api";
import { Pagination } from "@goauthentik/api";
export interface TableLike {
order?: string;
@ -98,7 +103,7 @@ export interface PaginatedResponse<T> {
export abstract class Table<T> extends AKElement implements TableLike {
abstract apiEndpoint(): Promise<PaginatedResponse<T>>;
abstract columns(): TableColumn[];
abstract row(item: T): TemplateResult[];
abstract row(item: T): SlottedTemplateResult[];
private isLoading = false;
@ -106,12 +111,12 @@ export abstract class Table<T> extends AKElement implements TableLike {
return false;
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
renderExpanded(item: T): TemplateResult {
renderExpanded(_item: T): SlottedTemplateResult {
if (this.expandable) {
throw new Error("Expandable is enabled but renderExpanded is not overridden!");
}
return html``;
return nothing;
}
@property({ attribute: false })
@ -120,10 +125,11 @@ export abstract class Table<T> extends AKElement implements TableLike {
@property({ type: Number })
page = getURLParam("tablePage", 1);
/** @prop
/**
* Set if your `selectedElements` use of the selection box is to enable bulk-delete,
* so that stale data is cleared out when the API returns a new list minus the deleted entries.
*
* Set if your `selectedElements` use of the selection box is to enable bulk-delete, so that
* stale data is cleared out when the API returns a new list minus the deleted entries.
* @prop
*/
@property({ attribute: "clear-on-refresh", type: Boolean, reflect: true })
clearOnRefresh = false;
@ -162,7 +168,7 @@ export abstract class Table<T> extends AKElement implements TableLike {
expandedElements: T[] = [];
@state()
error?: APIErrorTypes;
error?: APIError;
static get styles(): CSSResult[] {
return [
@ -187,6 +193,12 @@ export abstract class Table<T> extends AKElement implements TableLike {
.pf-c-toolbar__item .pf-c-input-group {
padding: 0 var(--pf-global--spacer--sm);
}
.pf-c-table {
--pf-c-table--m-striped__tr--BackgroundColor: var(
--pf-global--BackgroundColor--dark-300
);
}
`,
];
}
@ -213,64 +225,74 @@ export abstract class Table<T> extends AKElement implements TableLike {
};
}
public groupBy(items: T[]): [string, T[]][] {
public groupBy(items: T[]): [SlottedTemplateResult, T[]][] {
return groupBy(items, () => {
return "";
});
}
public async fetch(): Promise<void> {
if (this.isLoading) {
return;
}
if (this.isLoading) return;
this.isLoading = true;
try {
this.data = await this.apiEndpoint();
this.error = undefined;
this.page = this.data.pagination.current;
const newExpanded: T[] = [];
this.data.results.forEach((res) => {
const jsonRes = JSON.stringify(res);
// So because we're dealing with complex objects here, we can't use indexOf
// since it checks strict equality, and we also can't easily check in findIndex()
// Instead we default to comparing the JSON of both objects, which is quite slow
// Hence we check if the objects have `pk` attributes set (as most models do)
// and compare that instead, which will be much faster.
let comp = (item: T) => {
return JSON.stringify(item) === jsonRes;
};
if (Object.hasOwn(res as object, "pk")) {
comp = (item: T) => {
return (
(item as unknown as { pk: string | number }).pk ===
(res as unknown as { pk: string | number }).pk
);
return this.apiEndpoint()
.then((data) => {
this.data = data;
this.error = undefined;
this.page = this.data.pagination.current;
const newExpanded: T[] = [];
this.data.results.forEach((res) => {
const jsonRes = JSON.stringify(res);
// So because we're dealing with complex objects here, we can't use indexOf
// since it checks strict equality, and we also can't easily check in findIndex()
// Instead we default to comparing the JSON of both objects, which is quite slow
// Hence we check if the objects have `pk` attributes set (as most models do)
// and compare that instead, which will be much faster.
let comp = (item: T) => {
return JSON.stringify(item) === jsonRes;
};
}
const expandedIndex = this.expandedElements.findIndex(comp);
if (expandedIndex > -1) {
newExpanded.push(res);
}
if (Object.hasOwn(res as object, "pk")) {
comp = (item: T) => {
return (
(item as unknown as { pk: string | number }).pk ===
(res as unknown as { pk: string | number }).pk
);
};
}
const expandedIndex = this.expandedElements.findIndex(comp);
if (expandedIndex > -1) {
newExpanded.push(res);
}
});
this.expandedElements = newExpanded;
})
.catch(async (error: unknown) => {
this.error = await parseAPIResponseError(error);
})
.finally(() => {
this.isLoading = false;
this.requestUpdate();
});
this.isLoading = false;
this.expandedElements = newExpanded;
} catch (ex) {
this.isLoading = false;
this.error = await parseAPIError(ex as Error);
}
}
private renderLoading(): TemplateResult {
return html`<tr role="row">
<td role="cell" colspan="25">
<div class="pf-l-bullseye">
<ak-empty-state loading header=${msg("Loading")}> </ak-empty-state>
<ak-empty-state loading header=${msg("Loading")}></ak-empty-state>
</div>
</td>
</tr>`;
}
renderEmpty(inner?: TemplateResult): TemplateResult {
renderEmpty(inner?: SlottedTemplateResult): TemplateResult {
return html`<tbody role="rowgroup">
<tr role="row">
<td role="cell" colspan="8">
@ -285,18 +307,16 @@ export abstract class Table<T> extends AKElement implements TableLike {
</tbody>`;
}
renderObjectCreate(): TemplateResult {
return html``;
renderObjectCreate(): SlottedTemplateResult {
return nothing;
}
renderError(): TemplateResult {
return this.error
? html`<ak-empty-state header="${msg("Failed to fetch objects.")}" icon="fa-times">
${this.error instanceof ResponseError
? html` <div slot="body">${this.error.message}</div> `
: html`<div slot="body">${this.error.detail}</div>`}
</ak-empty-state>`
: html``;
renderError(): SlottedTemplateResult {
if (!this.error) return nothing;
return html`<ak-empty-state header="${msg("Failed to fetch objects.")}" icon="fa-ban">
<div slot="body">${pluckErrorDetail(this.error)}</div>
</ak-empty-state>`;
}
private renderRows(): TemplateResult[] | undefined {
@ -404,15 +424,17 @@ export abstract class Table<T> extends AKElement implements TableLike {
}
: itemSelectHandler}
>
${this.checkbox ? renderCheckbox() : html``}
${this.expandable ? renderExpansion() : html``}
${this.row(item).map((col) => {
return html`<td role="cell">${col}</td>`;
${this.checkbox ? renderCheckbox() : nothing}
${this.expandable ? renderExpansion() : nothing}
${this.row(item).map((column, columnIndex) => {
return html`<td data-column-index="${columnIndex}" role="cell">
${column}
</td>`;
})}
</tr>
<tr class="pf-c-table__expandable-row ${classMap(expandedClass)}" role="row">
<td></td>
${this.expandedElements.includes(item) ? this.renderExpanded(item) : html``}
${this.expandedElements.includes(item) ? this.renderExpanded(item) : nothing}
</tr>
</tbody>`;
});
@ -430,12 +452,12 @@ export abstract class Table<T> extends AKElement implements TableLike {
>`;
}
renderToolbarSelected(): TemplateResult {
return html``;
renderToolbarSelected(): SlottedTemplateResult {
return nothing;
}
renderToolbarAfter(): TemplateResult {
return html``;
renderToolbarAfter(): SlottedTemplateResult {
return nothing;
}
renderSearch(): TemplateResult {
@ -504,9 +526,9 @@ export abstract class Table<T> extends AKElement implements TableLike {
* chip-based subtable at the top that shows the list of selected entries. Long text result in
* ellipsized chips, which is sub-optimal.
*/
renderSelectedChip(_item: T): TemplateResult {
renderSelectedChip(_item: T): SlottedTemplateResult {
// Override this for chip-based displays
return html``;
return nothing;
}
get needChipGroup() {
@ -547,7 +569,7 @@ export abstract class Table<T> extends AKElement implements TableLike {
${this.renderToolbarContainer()}
<table class="pf-c-table pf-m-compact pf-m-grid-md pf-m-expandable">
<thead>
<tr role="row">
<tr role="row" class="pf-c-table__header-row">
${this.checkbox ? this.renderAllOnThisPageCheckbox() : html``}
${this.expandable ? html`<td role="cell"></td>` : html``}
${this.columns().map((col) => col.render(this))}

View File

@ -1,5 +1,6 @@
import { AndNext, DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { EVENT_REFRESH } from "@goauthentik/common/constants";
import { parseAPIResponseError, pluckErrorDetail } from "@goauthentik/common/errors/network";
import { MessageLevel } from "@goauthentik/common/messages";
import "@goauthentik/elements/Spinner";
import { showMessage } from "@goauthentik/elements/messages/MessageContainer";
@ -37,10 +38,14 @@ export class SourceSettingsOAuth extends BaseUserSettings {
message: msg("Successfully disconnected source"),
});
})
.catch((exc) => {
.catch(async (error: unknown) => {
const parsedError = await parseAPIResponseError(error);
showMessage({
level: MessageLevel.error,
message: msg(str`Failed to disconnected source: ${exc}`),
message: msg(
str`Failed to disconnected source: ${pluckErrorDetail(parsedError)}`,
),
});
})
.finally(() => {

View File

@ -1,5 +1,6 @@
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { EVENT_REFRESH } from "@goauthentik/common/constants";
import { parseAPIResponseError, pluckErrorDetail } from "@goauthentik/common/errors/network";
import { PlexAPIClient, popupCenterScreen } from "@goauthentik/common/helpers/plex";
import { MessageLevel } from "@goauthentik/common/messages";
import "@goauthentik/elements/Spinner";
@ -58,10 +59,13 @@ export class SourceSettingsPlex extends BaseUserSettings {
message: msg("Successfully disconnected source"),
});
})
.catch((exc) => {
.catch(async (error: unknown) => {
const parsedError = await parseAPIResponseError(error);
showMessage({
level: MessageLevel.error,
message: msg(str`Failed to disconnected source: ${exc}`),
message: msg(
str`Failed to disconnected source: ${pluckErrorDetail(parsedError)}`,
),
});
})
.finally(() => {

View File

@ -1,5 +1,6 @@
import { AndNext, DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { EVENT_REFRESH } from "@goauthentik/common/constants";
import { parseAPIResponseError, pluckErrorDetail } from "@goauthentik/common/errors/network";
import { MessageLevel } from "@goauthentik/common/messages";
import "@goauthentik/elements/Spinner";
import { showMessage } from "@goauthentik/elements/messages/MessageContainer";
@ -37,10 +38,14 @@ export class SourceSettingsSAML extends BaseUserSettings {
message: msg("Successfully disconnected source"),
});
})
.catch((exc) => {
.catch(async (error: unknown) => {
const parsedError = await parseAPIResponseError(error);
showMessage({
level: MessageLevel.error,
message: msg(str`Failed to disconnected source: ${exc}`),
message: msg(
str`Failed to disconnected source: ${pluckErrorDetail(parsedError)}`,
),
});
})
.finally(() => {

View File

@ -19,24 +19,27 @@ export class FormWizardPage extends WizardPage {
this.activePageCallback(this);
};
nextCallback = async () => {
nextCallback = async (): Promise<boolean> => {
const form = this.querySelector<Form<unknown>>("*");
if (!form) {
return Promise.reject(msg("No form found"));
}
const formPromise = form.submit(new Event("submit"));
if (!formPromise) {
return Promise.reject(msg("Form didn't return a promise for submitting"));
}
return formPromise
.then((data) => {
this.host.state[this.slot] = data;
this.host.canBack = false;
return true;
})
.catch(() => {
return false;
});
.catch(() => false);
};
}