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,4 +1,5 @@
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { parseAPIResponseError, pluckErrorDetail } from "@goauthentik/common/errors/network";
import { MessageLevel } from "@goauthentik/common/messages";
import { AKElement } from "@goauthentik/elements/Base";
import "@goauthentik/elements/PageHeader";
@ -54,10 +55,12 @@ export class DebugPage extends AKElement {
message: "Success",
});
})
.catch((exc) => {
.catch(async (error) => {
const parsedError = await parseAPIResponseError(error);
showMessage({
level: MessageLevel.error,
message: exc,
message: pluckErrorDetail(parsedError),
});
});
}}

View File

@ -1,13 +1,16 @@
import { EVENT_REFRESH } from "@goauthentik/common/constants";
import { PFSize } from "@goauthentik/common/enums.js";
import {
APIError,
parseAPIResponseError,
pluckErrorDetail,
} from "@goauthentik/common/errors/network";
import { AggregateCard } from "@goauthentik/elements/cards/AggregateCard";
import { msg } from "@lit/localize";
import { PropertyValues, TemplateResult, html, nothing } from "lit";
import { state } from "lit/decorators.js";
import { ResponseError } from "@goauthentik/api";
export interface AdminStatus {
icon: string;
message?: TemplateResult;
@ -29,7 +32,7 @@ export abstract class AdminStatusCard<T> extends AggregateCard {
// Current error state if any request fails
@state()
protected error?: string;
protected error?: APIError;
// Abstract methods to be implemented by subclasses
abstract getPrimaryValue(): Promise<T>;
@ -59,9 +62,9 @@ export abstract class AdminStatusCard<T> extends AggregateCard {
this.value = value; // Triggers shouldUpdate
this.error = undefined;
})
.catch((err: ResponseError) => {
.catch(async (error: unknown) => {
this.status = undefined;
this.error = err?.response?.statusText ?? msg("Unknown error");
this.error = await parseAPIResponseError(error);
});
}
@ -79,9 +82,9 @@ export abstract class AdminStatusCard<T> extends AggregateCard {
this.status = status;
this.error = undefined;
})
.catch((err: ResponseError) => {
.catch(async (error: unknown) => {
this.status = undefined;
this.error = err?.response?.statusText ?? msg("Unknown error");
this.error = await parseAPIResponseError(error);
});
// Prevent immediate re-render if only value changed
@ -120,8 +123,8 @@ export abstract class AdminStatusCard<T> extends AggregateCard {
*/
private renderError(error: string): TemplateResult {
return html`
<p><i class="fa fa-times"></i>&nbsp;${error}</p>
<p class="subtext">${msg("Failed to fetch")}</p>
<p><i class="fa fa-times"></i>&nbsp;${msg("Failed to fetch")}</p>
<p class="subtext">${error}</p>
`;
}
@ -146,7 +149,7 @@ export abstract class AdminStatusCard<T> extends AggregateCard {
this.status
? this.renderStatus(this.status) // Status available
: this.error
? this.renderError(this.error) // Error state
? this.renderError(pluckErrorDetail(this.error)) // Error state
: this.renderLoading() // Loading state
}
</p>

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 { CSSResult, TemplateResult, css, html } from "lit";
@ -68,7 +69,7 @@ export class RecentEventsCard extends Table<Event> {
</div>`;
}
row(item: EventWithContext): TemplateResult[] {
row(item: EventWithContext): SlottedTemplateResult[] {
return [
html`<div><a href="${`#/events/log/${item.pk}`}">${actionToLabel(item.action)}</a></div>
<small>${item.app}</small>`,
@ -81,7 +82,11 @@ export class RecentEventsCard extends Table<Event> {
];
}
renderEmpty(): TemplateResult {
renderEmpty(inner?: SlottedTemplateResult): TemplateResult {
if (this.error) {
return super.renderEmpty(inner);
}
return super.renderEmpty(
html`<ak-empty-state header=${msg("No Events found.")}>
<div slot="body">${msg("No matching events could be found.")}</div>

View File

@ -1,7 +1,7 @@
import { styles } from "@goauthentik/admin/applications/wizard/ApplicationWizardFormStepStyles.css.js";
import { WizardStep } from "@goauthentik/components/ak-wizard/WizardStep.js";
import {
NavigationUpdate,
NavigationEventInit,
WizardNavigationEvent,
WizardUpdateEvent,
} from "@goauthentik/components/ak-wizard/events";
@ -14,9 +14,9 @@ import { property, query } from "lit/decorators.js";
import { ValidationError } from "@goauthentik/api";
import {
ApplicationTransactionValidationError,
type ApplicationWizardState,
type ApplicationWizardStateUpdate,
ExtendedValidationError,
} from "./types";
export class ApplicationWizardStep extends WizardStep {
@ -48,7 +48,7 @@ export class ApplicationWizardStep extends WizardStep {
}
protected removeErrors(
keyToDelete: keyof ExtendedValidationError,
keyToDelete: keyof ApplicationTransactionValidationError,
): ValidationError | undefined {
if (!this.wizard.errors) {
return undefined;
@ -71,7 +71,7 @@ export class ApplicationWizardStep extends WizardStep {
public handleUpdate(
update?: ApplicationWizardStateUpdate,
destination?: string,
enable?: NavigationUpdate,
enable?: NavigationEventInit,
) {
// Inform ApplicationWizard of content state
if (update) {

View File

@ -1,9 +1,10 @@
import "@goauthentik/admin/applications/wizard/ak-wizard-title.js";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { EVENT_REFRESH } from "@goauthentik/common/constants";
import { parseAPIError } from "@goauthentik/common/errors";
import { parseAPIResponseError } from "@goauthentik/common/errors/network";
import { WizardNavigationEvent } from "@goauthentik/components/ak-wizard/events.js";
import { type WizardButton } from "@goauthentik/components/ak-wizard/types";
import { showAPIErrorMessage } from "@goauthentik/elements/messages/MessageContainer";
import { CustomEmitterElement } from "@goauthentik/elements/utils/eventEmitter";
import { P, match } from "ts-pattern";
@ -30,10 +31,11 @@ import {
type TransactionApplicationRequest,
type TransactionApplicationResponse,
type TransactionPolicyBindingRequest,
instanceOfValidationError,
} from "@goauthentik/api";
import { ApplicationWizardStep } from "../ApplicationWizardStep.js";
import { ExtendedValidationError, OneOfProvider } from "../types.js";
import { OneOfProvider, isApplicationTransactionValidationError } from "../types.js";
import { providerRenderers } from "./SubmitStepOverviewRenderers.js";
const _submitStates = ["reviewing", "running", "submitted"] as const;
@ -131,39 +133,46 @@ export class ApplicationWizardSubmitStep extends CustomEmitterElement(Applicatio
this.state = "running";
return (
new CoreApi(DEFAULT_CONFIG)
.coreTransactionalApplicationsUpdate({
transactionApplicationRequest: request,
})
.then((_response: TransactionApplicationResponse) => {
this.dispatchCustomEvent(EVENT_REFRESH);
this.state = "submitted";
})
return new CoreApi(DEFAULT_CONFIG)
.coreTransactionalApplicationsUpdate({
transactionApplicationRequest: request,
})
.then((_response: TransactionApplicationResponse) => {
this.dispatchCustomEvent(EVENT_REFRESH);
this.state = "submitted";
})
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.catch(async (resolution: any) => {
const errors = (await parseAPIError(
await resolution,
)) as ExtendedValidationError;
.catch(async (error) => {
const parsedError = await parseAPIResponseError(error);
// THIS is a really gross special case; if the user is duplicating the name of
// an existing provider, the error appears on the `app` (!) error object. We
// have to move that to the `provider.name` error field so it shows up in the
// right place.
if (Array.isArray(errors?.app?.provider)) {
const providerError = errors.app.provider;
errors.provider = errors.provider ?? {};
errors.provider.name = providerError;
delete errors.app.provider;
if (Object.keys(errors.app).length === 0) {
delete errors.app;
if (!instanceOfValidationError(parsedError)) {
showAPIErrorMessage(parsedError);
return;
}
if (isApplicationTransactionValidationError(parsedError)) {
// THIS is a really gross special case; if the user is duplicating the name of an existing provider, the error appears on the `app` (!) error object.
// We have to move that to the `provider.name` error field so it shows up in the right place.
if (Array.isArray(parsedError.app?.provider)) {
const providerError = parsedError.app.provider;
parsedError.provider = {
...parsedError.provider,
name: providerError,
};
delete parsedError.app.provider;
if (Object.keys(parsedError.app).length === 0) {
delete parsedError.app;
}
}
this.handleUpdate({ errors });
this.state = "reviewing";
})
);
}
this.handleUpdate({ errors: parsedError });
this.state = "reviewing";
});
}
override handleButton(button: WizardButton) {
@ -225,22 +234,20 @@ export class ApplicationWizardSubmitStep extends CustomEmitterElement(Applicatio
}
renderError() {
if (Object.keys(this.wizard.errors).length === 0) {
return nothing;
}
const { errors } = this.wizard;
if (Object.keys(errors).length === 0) return nothing;
const navTo = (step: string) => () => this.dispatchEvent(new WizardNavigationEvent(step));
const errors = this.wizard.errors;
return html` <hr class="pf-c-divider" />
${match(errors as ExtendedValidationError)
${match(errors)
.with(
{ app: P.nonNullable },
() =>
html`<p>${msg("There was an error in the application.")}</p>
<p>
<a @click=${navTo("application")}
>${msg("Review the application.")}</a
>
<a @click=${WizardNavigationEvent.toListener(this, "application")}>
${msg("Review the application.")}
</a>
</p>`,
)
.with(
@ -248,13 +255,20 @@ export class ApplicationWizardSubmitStep extends CustomEmitterElement(Applicatio
() =>
html`<p>${msg("There was an error in the provider.")}</p>
<p>
<a @click=${navTo("provider")}>${msg("Review the provider.")}</a>
<a @click=${WizardNavigationEvent.toListener(this, "provider")}
>${msg("Review the provider.")}</a
>
</p>`,
)
.with(
{ detail: P.nonNullable },
() =>
`<p>${msg("There was an error. Please go back and review the application.")}: ${errors.detail}</p>`,
html`<p>
${msg(
"There was an error. Please go back and review the application.",
)}:
${errors.detail}
</p>`,
)
.with(
{
@ -264,7 +278,7 @@ export class ApplicationWizardSubmitStep extends CustomEmitterElement(Applicatio
html`<p>${msg("There was an error:")}:</p>
<ul>
${(errors.nonFieldErrors ?? []).map(
(e: string) => html`<li>${e}</li>`,
(reason) => html`<li>${reason}</li>`,
)}
</ul>
<p>${msg("Please go back and review the application.")}</p>`,

View File

@ -9,7 +9,7 @@ import { customElement, state } from "lit/decorators.js";
import { OAuth2ProviderRequest, SourcesApi } from "@goauthentik/api";
import { type OAuth2Provider, type PaginatedOAuthSourceList } from "@goauthentik/api";
import { ExtendedValidationError } from "../../types.js";
import { ApplicationTransactionValidationError } from "../../types.js";
import { ApplicationWizardProviderForm } from "./ApplicationWizardProviderForm.js";
@customElement("ak-application-wizard-provider-for-oauth")
@ -34,7 +34,7 @@ export class ApplicationWizardOauth2ProviderForm extends ApplicationWizardProvid
});
}
renderForm(provider: OAuth2Provider, errors: ExtendedValidationError) {
renderForm(provider: OAuth2Provider, errors: ApplicationTransactionValidationError) {
const showClientSecretCallback = (show: boolean) => {
this.showClientSecret = show;
};

View File

@ -25,16 +25,30 @@ export type OneOfProvider =
export type ValidationRecord = { [key: string]: string[] };
// TODO: Elf, extend this type and apply it to every object in the wizard. Then run
// the type-checker again.
export type ExtendedValidationError = ValidationError & {
/**
* An error that occurs during the creation or modification of an application.
*
* @todo (Elf) Extend this type to include all possible errors that can occur during the creation or modification of an application.
*/
export interface ApplicationTransactionValidationError extends ValidationError {
app?: ValidationRecord;
provider?: ValidationRecord;
bindings?: ValidationRecord;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
detail?: any;
};
detail?: unknown;
}
/**
* Type-guard to determine if an API response is shaped like an {@linkcode ApplicationTransactionValidationError}.
*/
export function isApplicationTransactionValidationError(
error: ValidationError,
): error is ApplicationTransactionValidationError {
if ("app" in error) return true;
if ("provider" in error) return true;
if ("bindings" in error) return true;
return false;
}
// We use the PolicyBinding instead of the PolicyBindingRequest here, because that gives us a slot
// in which to preserve the retrieved policy, group, or user object from the SearchSelect used to
@ -49,7 +63,7 @@ export interface ApplicationWizardState {
proxyMode: ProxyMode;
bindings: PolicyBinding[];
currentBinding: number;
errors: ExtendedValidationError;
errors: ValidationError | ApplicationTransactionValidationError;
}
export interface ApplicationWizardStateUpdate {

View File

@ -8,6 +8,7 @@ import "@goauthentik/components/ak-event-info";
import { PaginatedResponse } from "@goauthentik/elements/table/Table";
import { TableColumn } from "@goauthentik/elements/table/Table";
import { TablePage } from "@goauthentik/elements/table/TablePage";
import { SlottedTemplateResult } from "@goauthentik/elements/types";
import "@patternfly/elements/pf-tooltip/pf-tooltip.js";
import { msg } from "@lit/localize";
@ -72,7 +73,7 @@ export class EventListPage extends TablePage<Event> {
`;
}
row(item: EventWithContext): TemplateResult[] {
row(item: EventWithContext): SlottedTemplateResult[] {
return [
html`<div>${actionToLabel(item.action)}</div>
<small>${item.app}</small>`,

View File

@ -1,27 +1,31 @@
import { EventWithContext } from "@goauthentik/common/events";
import { truncate } from "@goauthentik/common/utils";
import { KeyUnknown } from "@goauthentik/elements/forms/Form";
import { SlottedTemplateResult } from "@goauthentik/elements/types";
import { msg, str } from "@lit/localize";
import { TemplateResult, html } from "lit";
import { html, nothing } from "lit";
export function EventGeo(event: EventWithContext): TemplateResult {
let geo: KeyUnknown | undefined = undefined;
if (Object.hasOwn(event.context, "geo")) {
geo = event.context.geo as KeyUnknown;
const parts = [geo.city, geo.country, geo.continent].filter(
(v) => v !== "" && v !== undefined,
);
return html`${parts.join(", ")}`;
}
return html``;
/**
* Given event with a geographical context, format it into a string for display.
*/
export function EventGeo(event: EventWithContext): SlottedTemplateResult {
if (!event.context.geo) return nothing;
const { city, country, continent } = event.context.geo;
const parts = [city, country, continent].filter(Boolean);
return html`${parts.join(", ")}`;
}
export function EventUser(event: EventWithContext, truncateUsername?: number): TemplateResult {
if (!event.user.username) {
return html`-`;
}
let body = html``;
export function EventUser(
event: EventWithContext,
truncateUsername?: number,
): SlottedTemplateResult {
if (!event.user.username) return html`-`;
let body: SlottedTemplateResult = nothing;
if (event.user.is_anonymous) {
body = html`<div>${msg("Anonymous user")}</div>`;
} else {
@ -33,12 +37,14 @@ export function EventUser(event: EventWithContext, truncateUsername?: number): T
>
</div>`;
}
if (event.user.on_behalf_of) {
body = html`${body}<small>
return html`${body}<small>
<a href="#/identity/users/${event.user.on_behalf_of.pk}"
>${msg(str`On behalf of ${event.user.on_behalf_of.username}`)}</a
>
</small>`;
}
return body;
}

View File

@ -1,5 +1,5 @@
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { SentryIgnoredError } from "@goauthentik/common/errors";
import { SentryIgnoredError } from "@goauthentik/common/sentry";
import "@goauthentik/components/ak-status-label";
import "@goauthentik/elements/events/LogViewer";
import { Form } from "@goauthentik/elements/forms/Form";

View File

@ -5,6 +5,7 @@ import { DesignationToLabel } from "@goauthentik/admin/flows/utils";
import "@goauthentik/admin/policies/BoundPoliciesList";
import "@goauthentik/admin/rbac/ObjectPermissionsPage";
import { AndNext, DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { isResponseErrorLike } from "@goauthentik/common/errors/network";
import "@goauthentik/components/events/ObjectChangelog";
import { AKElement } from "@goauthentik/elements/Base";
import "@goauthentik/elements/PageHeader";
@ -23,12 +24,7 @@ import PFPage from "@patternfly/patternfly/components/Page/page.css";
import PFGrid from "@patternfly/patternfly/layouts/Grid/grid.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
import {
Flow,
FlowsApi,
RbacPermissionsAssignedByUsersListModelEnum,
ResponseError,
} from "@goauthentik/api";
import { Flow, FlowsApi, RbacPermissionsAssignedByUsersListModelEnum } from "@goauthentik/api";
@customElement("ak-flow-view")
export class FlowViewPage extends AKElement {
@ -195,13 +191,15 @@ export class FlowViewPage extends AKElement {
)}`;
window.open(finalURL, "_blank");
})
.catch((exc: ResponseError) => {
// This request can return a HTTP 400 when a flow
// is not applicable.
window.open(
exc.response.url,
"_blank",
);
.catch(async (error: unknown) => {
if (isResponseErrorLike(error)) {
// This request can return a HTTP 400 when a flow
// is not applicable.
window.open(
error.response.url,
"_blank",
);
}
});
}}
>

View File

@ -6,6 +6,7 @@ import "@goauthentik/admin/users/UserPasswordForm";
import "@goauthentik/admin/users/UserResetEmailForm";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { PFSize } from "@goauthentik/common/enums.js";
import { parseAPIResponseError, pluckErrorDetail } from "@goauthentik/common/errors/network";
import { MessageLevel } from "@goauthentik/common/messages";
import { me } from "@goauthentik/common/users";
import { getRelativeTime } from "@goauthentik/common/utils";
@ -37,14 +38,7 @@ import PFAlert from "@patternfly/patternfly/components/Alert/alert.css";
import PFBanner from "@patternfly/patternfly/components/Banner/banner.css";
import PFDescriptionList from "@patternfly/patternfly/components/DescriptionList/description-list.css";
import {
CoreApi,
CoreUsersListTypeEnum,
Group,
ResponseError,
SessionUser,
User,
} from "@goauthentik/api";
import { CoreApi, CoreUsersListTypeEnum, Group, SessionUser, User } from "@goauthentik/api";
@customElement("ak-user-related-add")
export class RelatedUserAdd extends Form<{ users: number[] }> {
@ -319,14 +313,16 @@ export class RelatedUserList extends WithBrandConfig(WithCapabilitiesConfig(Tabl
description: rec.link,
});
})
.catch((ex: ResponseError) => {
ex.response.json().then(() => {
showMessage({
level: MessageLevel.error,
message: msg(
"No recovery flow is configured.",
),
});
.catch(async (error: unknown) => {
const parsedError =
await parseAPIResponseError(
error,
);
showMessage({
level: MessageLevel.error,
message:
pluckErrorDetail(parsedError),
});
});
}}

View File

@ -1,6 +1,6 @@
import "@goauthentik/admin/common/ak-flow-search/ak-flow-search-no-default";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { SentryIgnoredError } from "@goauthentik/common/errors";
import { SentryIgnoredError } from "@goauthentik/common/sentry";
import { Form } from "@goauthentik/elements/forms/Form";
import "@goauthentik/elements/forms/HorizontalFormElement";
import "@goauthentik/elements/forms/SearchSelect";

View File

@ -1,5 +1,5 @@
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { parseAPIError } from "@goauthentik/common/errors";
import { parseAPIResponseError, pluckErrorDetail } from "@goauthentik/common/errors/network";
import { first } from "@goauthentik/common/utils";
import "@goauthentik/elements/CodeMirror";
import { CodeMirrorMode } from "@goauthentik/elements/CodeMirror";
@ -21,9 +21,8 @@ import {
Prompt,
PromptChallenge,
PromptTypeEnum,
ResponseError,
StagesApi,
ValidationError,
instanceOfValidationError,
} from "@goauthentik/api";
class PreviewStageHost implements StageHost {
@ -78,15 +77,22 @@ export class PromptForm extends ModelForm<Prompt, string> {
return;
}
}
try {
this.preview = await new StagesApi(DEFAULT_CONFIG).stagesPromptPromptsPreviewCreate({
return new StagesApi(DEFAULT_CONFIG)
.stagesPromptPromptsPreviewCreate({
promptRequest: prompt,
})
.then((nextPreview) => {
this.preview = nextPreview;
this.previewError = undefined;
})
.catch(async (error: unknown) => {
const parsedError = await parseAPIResponseError(error);
this.previewError = instanceOfValidationError(parsedError)
? parsedError.nonFieldErrors
: [pluckErrorDetail(parsedError, msg("Failed to preview prompt"))];
});
this.previewError = undefined;
} catch (exc) {
const errorMessage = parseAPIError(exc as ResponseError);
this.previewError = (errorMessage as ValidationError).nonFieldErrors;
}
}
getSuccessMessage(): string {

View File

@ -1,3 +1,4 @@
import { parseAPIResponseError, pluckErrorDetail } from "@goauthentik/common/errors/network";
import { MessageLevel } from "@goauthentik/common/messages";
import "@goauthentik/elements/buttons/SpinnerButton";
import { DeleteForm } from "@goauthentik/elements/forms/DeleteForm";
@ -16,10 +17,14 @@ export class UserActiveForm extends DeleteForm {
});
}
onError(e: Error): void {
showMessage({
message: msg(str`Failed to update ${this.objectLabel}: ${e.toString()}`),
level: MessageLevel.error,
onError(error: unknown): Promise<void> {
return parseAPIResponseError(error).then((parsedError) => {
showMessage({
message: msg(
str`Failed to update ${this.objectLabel}: ${pluckErrorDetail(parsedError)}`,
),
level: MessageLevel.error,
});
});
}

View File

@ -1,6 +1,6 @@
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { SentryIgnoredError } from "@goauthentik/common/errors";
import { deviceTypeName } from "@goauthentik/common/labels";
import { SentryIgnoredError } from "@goauthentik/common/sentry";
import { getRelativeTime } from "@goauthentik/common/utils";
import "@goauthentik/elements/forms/DeleteBulkForm";
import { PaginatedResponse } from "@goauthentik/elements/table/Table";

View File

@ -7,6 +7,7 @@ import "@goauthentik/admin/users/UserPasswordForm";
import "@goauthentik/admin/users/UserResetEmailForm";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { PFSize } from "@goauthentik/common/enums.js";
import { parseAPIResponseError } from "@goauthentik/common/errors/network";
import { userTypeToLabel } from "@goauthentik/common/labels";
import { MessageLevel } from "@goauthentik/common/messages";
import { DefaultUIConfig, uiConfig } from "@goauthentik/common/ui/config";
@ -23,7 +24,7 @@ import "@goauthentik/elements/TreeView";
import "@goauthentik/elements/buttons/ActionButton";
import "@goauthentik/elements/forms/DeleteBulkForm";
import "@goauthentik/elements/forms/ModalForm";
import { showMessage } from "@goauthentik/elements/messages/MessageContainer";
import { showAPIErrorMessage, showMessage } from "@goauthentik/elements/messages/MessageContainer";
import { getURLParam, updateURLParams } from "@goauthentik/elements/router/RouteMatch";
import { PaginatedResponse } from "@goauthentik/elements/table/Table";
import { TableColumn } from "@goauthentik/elements/table/Table";
@ -39,7 +40,7 @@ import PFAlert from "@patternfly/patternfly/components/Alert/alert.css";
import PFCard from "@patternfly/patternfly/components/Card/card.css";
import PFDescriptionList from "@patternfly/patternfly/components/DescriptionList/description-list.css";
import { CoreApi, ResponseError, SessionUser, User, UserPath } from "@goauthentik/api";
import { CoreApi, SessionUser, User, UserPath } from "@goauthentik/api";
export const requestRecoveryLink = (user: User) =>
new CoreApi(DEFAULT_CONFIG)
@ -57,16 +58,7 @@ export const requestRecoveryLink = (user: User) =>
}),
),
)
.catch((ex: ResponseError) =>
ex.response.json().then(() =>
showMessage({
level: MessageLevel.error,
message: msg(
"The current brand must have a recovery flow configured to use a recovery link",
),
}),
),
);
.catch((error: unknown) => parseAPIResponseError(error).then(showAPIErrorMessage));
export const renderRecoveryEmailRequest = (user: User) =>
html`<ak-forms-modal .closeAfterSuccessfulSubmit=${false} id="ak-email-recovery-request">

View File

@ -1,36 +0,0 @@
import {
GenericError,
GenericErrorFromJSON,
ResponseError,
ValidationError,
ValidationErrorFromJSON,
} from "@goauthentik/api";
export class SentryIgnoredError extends Error {}
export class NotFoundError extends Error {}
export class RequestError extends Error {}
export type APIErrorTypes = ValidationError | GenericError;
export const HTTP_BAD_REQUEST = 400;
export const HTTP_INTERNAL_SERVICE_ERROR = 500;
export async function parseAPIError(error: Error): Promise<APIErrorTypes> {
if (!(error instanceof ResponseError)) {
return error;
}
if (
error.response.status < HTTP_BAD_REQUEST ||
error.response.status >= HTTP_INTERNAL_SERVICE_ERROR
) {
return error;
}
const body = await error.response.json();
if (error.response.status === 400) {
return ValidationErrorFromJSON(body);
}
if (error.response.status === 403) {
return GenericErrorFromJSON(body);
}
return body;
}

View File

@ -0,0 +1,184 @@
import {
GenericError,
GenericErrorFromJSON,
ResponseError,
ValidationError,
ValidationErrorFromJSON,
} from "@goauthentik/api";
//#region HTTP
/**
* Common HTTP status names used in the API and their corresponding codes.
*/
export const HTTPStatusCode = {
BadRequest: 400,
Forbidden: 403,
InternalServiceError: 500,
} as const satisfies Record<string, number>;
export type HTTPStatusCode = (typeof HTTPStatusCode)[keyof typeof HTTPStatusCode];
export type HTTPErrorJSONTransformer<T = unknown> = (json: T) => APIError;
export const HTTPStatusCodeTransformer: Record<number, HTTPErrorJSONTransformer> = {
[HTTPStatusCode.BadRequest]: ValidationErrorFromJSON,
[HTTPStatusCode.Forbidden]: GenericErrorFromJSON,
} as const;
/**
* Type guard to check if a response contains a JSON body.
*
* This is useful to guard against parsing errors when attempting to read the response body.
*/
export function isJSONResponse(response: Response): boolean {
return Boolean(response.headers.get("content-type")?.includes("application/json"));
}
//#endregion
//#region API
/**
* An API response error, typically derived from a {@linkcode Response} body.
*
* @see {@linkcode parseAPIResponseError}
*/
export type APIError = ValidationError | GenericError;
/**
* Given an error-like object, attempts to normalize it into a {@linkcode GenericError}
* suitable for display to the user.
*/
export function createSyntheticGenericError(detail?: string): GenericError {
const syntheticGenericError: GenericError = {
detail: detail || ResponseErrorMessages[HTTPStatusCode.InternalServiceError].reason,
};
return syntheticGenericError;
}
/**
* An error that contains a native response object.
*
* @see {@linkcode isResponseErrorLike} to determine if an error contains a response object.
*/
export type APIErrorWithResponse = Pick<ResponseError, "response" | "message">;
/**
* Type guard to check if an error contains a HTTP {@linkcode Response} object.
*
* @see {@linkcode parseAPIResponseError} to parse the response body into a {@linkcode APIError}.
*/
export function isResponseErrorLike(errorLike: unknown): errorLike is APIErrorWithResponse {
if (!errorLike || typeof errorLike !== "object") return false;
return "response" in errorLike && errorLike.response instanceof Response;
}
/**
* A descriptor to provide a human readable error message for a given HTTP status code.
*
* @see {@linkcode ResponseErrorMessages} for a list of fallback error messages.
*/
interface ResponseErrorDescriptor {
headline: string;
reason: string;
}
/**
* Fallback error messages for HTTP status codes used when a more specific error message is not available in the response.
*/
export const ResponseErrorMessages: Record<number, ResponseErrorDescriptor> = {
[HTTPStatusCode.BadRequest]: {
headline: "Bad request",
reason: "The server did not understand the request",
},
[HTTPStatusCode.InternalServiceError]: {
headline: "Internal server error",
reason: "An unexpected error occurred",
},
} as const;
/**
* Composes a human readable error message from a {@linkcode ResponseErrorDescriptor}.
*
* Note that this is kept separate from localization to lower the complexity of the error handling code.
*/
export function composeResponseErrorDescriptor(descriptor: ResponseErrorDescriptor): string {
return `${descriptor.headline}: ${descriptor.reason}`;
}
/**
* Attempts to pluck a human readable error message from a {@linkcode ValidationError}.
*/
export function pluckErrorDetail(validationError: ValidationError, fallback?: string): string;
/**
* Attempts to pluck a human readable error message from a {@linkcode GenericError}.
*/
export function pluckErrorDetail(genericError: GenericError, fallback?: string): string;
/**
* Attempts to pluck a human readable error message from an `Error` object.
*/
export function pluckErrorDetail(error: Error, fallback?: string): string;
/**
* Attempts to pluck a human readable error message from an error-like object.
*
* Prioritizes the `detail` key, then the `message` key.
*
*/
export function pluckErrorDetail(errorLike: unknown, fallback?: string): string;
export function pluckErrorDetail(errorLike: unknown, fallback?: string): string {
fallback ||= composeResponseErrorDescriptor(
ResponseErrorMessages[HTTPStatusCode.InternalServiceError],
);
if (!errorLike || typeof errorLike !== "object") {
return fallback;
}
if ("detail" in errorLike && typeof errorLike.detail === "string") {
return errorLike.detail;
}
if ("message" in errorLike && typeof errorLike.message === "string") {
return errorLike.message;
}
return fallback;
}
/**
* Given API error, parses the response body and transforms it into a {@linkcode APIError}.
*/
export async function parseAPIResponseError<T extends APIError = APIError>(
error: unknown,
): Promise<T> {
if (!isResponseErrorLike(error)) {
const message = error instanceof Error ? error.message : String(error);
return createSyntheticGenericError(message) as T;
}
const { response, message } = error;
if (!isJSONResponse(response)) {
return createSyntheticGenericError(message || response.statusText) as T;
}
return response
.json()
.then((body) => {
const transformer = HTTPStatusCodeTransformer[response.status];
const transformedBody = transformer ? transformer(body) : body;
return transformedBody as unknown as T;
})
.catch((transformerError: unknown) => {
console.error("Failed to parse response error body", transformerError);
return createSyntheticGenericError(message || response.statusText) as T;
});
}
//#endregion

View File

@ -8,13 +8,10 @@ export interface EventUser {
is_anonymous?: boolean;
}
export interface EventContext {
[key: string]: EventContext | EventModel | string | number | string[];
}
export interface EventWithContext extends Event {
user: EventUser;
context: EventContext;
export interface EventGeo {
city?: string;
country?: string;
continent?: string;
}
export interface EventModel {
@ -28,3 +25,16 @@ export interface EventRequest {
path: string;
method: string;
}
export type EventContextProperty = EventModel | EventGeo | string | number | string[] | undefined;
// TODO: Events should have more specific types.
export interface EventContext {
[key: string]: EventContext | EventContextProperty;
geo?: EventGeo;
}
export interface EventWithContext extends Event {
user: EventUser;
context: EventContext;
}

View File

@ -1,5 +1,5 @@
import { VERSION } from "@goauthentik/common/constants";
import { SentryIgnoredError } from "@goauthentik/common/errors";
import { SentryIgnoredError } from "@goauthentik/common/sentry";
export interface PlexPinResponse {
// Only has the fields we care about

View File

@ -1,6 +1,5 @@
import { config } from "@goauthentik/common/api/config";
import { VERSION } from "@goauthentik/common/constants";
import { SentryIgnoredError } from "@goauthentik/common/errors";
import { me } from "@goauthentik/common/users";
import {
ErrorEvent,
@ -13,6 +12,11 @@ import {
import { CapabilitiesEnum, Config, ResponseError } from "@goauthentik/api";
/**
* A generic error that can be thrown without triggering Sentry's reporting.
*/
export class SentryIgnoredError extends Error {}
export const TAG_SENTRY_COMPONENT = "authentik.component";
export const TAG_SENTRY_CAPABILITIES = "authentik.capabilities";

View File

@ -1,63 +1,96 @@
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { EVENT_LOCALE_REQUEST } from "@goauthentik/common/constants";
import { isResponseErrorLike } from "@goauthentik/common/errors/network";
import { CoreApi, ResponseError, SessionUser } from "@goauthentik/api";
import { CoreApi, SessionUser } from "@goauthentik/api";
let globalMePromise: Promise<SessionUser> | undefined;
/**
* Create a guest session for unauthenticated users.
*
* @see {@linkcode me} for the actual session retrieval.
*/
function createGuestSession(): SessionUser {
const guest: SessionUser = {
user: {
pk: -1,
isSuperuser: false,
isActive: true,
groups: [],
avatar: "",
uid: "",
username: "",
name: "",
settings: {},
systemPermissions: [],
},
};
return guest;
}
let memoizedSession: SessionUser | null = null;
/**
* Refresh the current user session.
*/
export function refreshMe(): Promise<SessionUser> {
globalMePromise = undefined;
memoizedSession = null;
return me();
}
export function me(): Promise<SessionUser> {
if (!globalMePromise) {
globalMePromise = new CoreApi(DEFAULT_CONFIG)
.coreUsersMeRetrieve()
.then((user) => {
if (!user.user.settings || !("locale" in user.user.settings)) {
return user;
}
const locale: string | undefined = user.user.settings.locale;
if (locale && locale !== "") {
console.debug(
`authentik/locale: Activating user's configured locale '${locale}'`,
);
window.dispatchEvent(
new CustomEvent(EVENT_LOCALE_REQUEST, {
composed: true,
bubbles: true,
detail: { locale },
}),
/**
* Retrieve the current user session.
*
* This is a memoized function, so it will only make one request per page load.
*
* @see {@linkcode refreshMe} to force a refresh.
*/
export async function me(): Promise<SessionUser> {
if (memoizedSession) return memoizedSession;
return new CoreApi(DEFAULT_CONFIG)
.coreUsersMeRetrieve()
.then((nextSession) => {
const locale: string | undefined = nextSession.user.settings.locale;
if (locale) {
console.debug(`authentik/locale: Activating user's configured locale '${locale}'`);
window.dispatchEvent(
new CustomEvent(EVENT_LOCALE_REQUEST, {
composed: true,
bubbles: true,
detail: { locale },
}),
);
}
return nextSession;
})
.catch(async (error: unknown) => {
if (isResponseErrorLike(error)) {
const { response } = error;
if (response.status === 401 || response.status === 403) {
const { pathname, search, hash } = window.location;
const authFlowRedirectURL = new URL(
`/flows/-/default/authentication/`,
window.location.origin,
);
authFlowRedirectURL.searchParams.set("next", `${pathname}${search}${hash}`);
window.location.assign(authFlowRedirectURL);
}
return user;
})
.catch((ex: ResponseError) => {
const defaultUser: SessionUser = {
user: {
pk: -1,
isSuperuser: false,
isActive: true,
groups: [],
avatar: "",
uid: "",
username: "",
name: "",
settings: {},
systemPermissions: [],
},
};
if (ex.response?.status === 401 || ex.response?.status === 403) {
const relativeUrl = window.location
.toString()
.substring(window.location.origin.length);
window.location.assign(
`/flows/-/default/authentication/?next=${encodeURIComponent(relativeUrl)}`,
);
}
return defaultUser;
});
}
return globalMePromise;
}
console.debug("authentik/users: Failed to retrieve user session", error);
return createGuestSession();
})
.then((nextSession) => {
memoizedSession = nextSession;
return nextSession;
});
}

View File

@ -1,4 +1,4 @@
import { SentryIgnoredError } from "@goauthentik/common/errors";
import { SentryIgnoredError } from "@goauthentik/common/sentry";
import { CSSResult, css } from "lit";

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),

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);
};
}

View File

@ -1,5 +1,10 @@
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { EVENT_FLOW_ADVANCE, EVENT_FLOW_INSPECTOR_TOGGLE } from "@goauthentik/common/constants";
import {
APIError,
parseAPIResponseError,
pluckErrorDetail,
} from "@goauthentik/common/errors/network";
import { AKElement } from "@goauthentik/elements/Base";
import "@goauthentik/elements/Expand";
@ -15,7 +20,7 @@ import PFProgressStepper from "@patternfly/patternfly/components/ProgressStepper
import PFStack from "@patternfly/patternfly/layouts/Stack/stack.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
import { FlowInspection, FlowsApi, ResponseError, Stage } from "@goauthentik/api";
import { FlowInspection, FlowsApi, Stage } from "@goauthentik/api";
@customElement("ak-flow-inspector")
export class FlowInspector extends AKElement {
@ -26,7 +31,7 @@ export class FlowInspector extends AKElement {
state?: FlowInspection;
@property({ attribute: false })
error?: ResponseError;
error?: APIError;
static get styles(): CSSResult[] {
return [
@ -73,8 +78,10 @@ export class FlowInspector extends AKElement {
this.error = undefined;
this.state = state;
})
.catch((exc) => {
this.error = exc;
.catch(async (error: unknown) => {
const parsedError = await parseAPIResponseError(error);
this.error = parsedError;
});
};
@ -122,7 +129,7 @@ export class FlowInspector extends AKElement {
<div class="pf-l-stack pf-m-gutter">
<div class="pf-l-stack__item">
<div class="pf-c-card">
<div class="pf-c-card__body">${this.error?.message}</div>
<div class="pf-c-card__body">${pluckErrorDetail(this.error)}</div>
</div>
</div>
</div>

View File

@ -1,7 +1,7 @@
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { parseAPIResponseError } from "@goauthentik/common/errors/network";
import { PlexAPIClient, popupCenterScreen } from "@goauthentik/common/helpers/plex";
import { MessageLevel } from "@goauthentik/common/messages";
import { showMessage } from "@goauthentik/elements/messages/MessageContainer";
import { showAPIErrorMessage } from "@goauthentik/elements/messages/MessageContainer";
import { BaseStage } from "@goauthentik/flow/stages/base";
import { msg } from "@lit/localize";
@ -20,7 +20,6 @@ import PFBase from "@patternfly/patternfly/patternfly-base.css";
import {
PlexAuthenticationChallenge,
PlexAuthenticationChallengeResponseRequest,
ResponseError,
} from "@goauthentik/api";
import { SourcesApi } from "@goauthentik/api";
@ -49,19 +48,17 @@ export class PlexLoginInit extends BaseStage<
},
slug: this.challenge?.slug || "",
})
.then((r) => {
window.location.assign(r.to);
.then((redirectChallenge) => {
window.location.assign(redirectChallenge.to);
})
.catch((r: ResponseError) => {
r.response.json().then((body: { detail: string }) => {
showMessage({
level: MessageLevel.error,
message: body.detail,
.catch(async (error: unknown) => {
return parseAPIResponseError(error)
.then(showAPIErrorMessage)
.then(() => {
setTimeout(() => {
window.location.assign("/");
}, 5000);
});
setTimeout(() => {
window.location.assign("/");
}, 5000);
});
});
});
}

View File

@ -90,8 +90,11 @@ export class AuthenticatorValidateStageWebAuthn extends BaseDeviceStage<
}
this.authenticating = true;
this.authenticate()
.catch((e: Error) => {
console.warn("authentik/flows/authenticator_validate/webauthn: failed to auth", e);
.catch((error: unknown) => {
console.warn(
"authentik/flows/authenticator_validate/webauthn: failed to auth",
error,
);
this.errorMessage = msg("Authentication failed. Please try again.");
})
.finally(() => {

View File

@ -106,8 +106,9 @@ export class WebAuthnAuthenticatorRegisterStage extends BaseStage<
}
this.registerRunning = true;
this.register()
.catch((e) => {
console.warn("authentik/flows/authenticator_webauthn: failed to register", e);
.catch((error: unknown) => {
console.warn("authentik/flows/authenticator_webauthn: failed to register", error);
this.registerMessage = msg("Failed to register. Please try again.");
})
.finally(() => {

View File

@ -1,5 +1,10 @@
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { EVENT_REFRESH } from "@goauthentik/common/constants";
import {
APIError,
parseAPIResponseError,
pluckErrorDetail,
} from "@goauthentik/common/errors/network";
import { globalAK } from "@goauthentik/common/global";
import { MessageLevel } from "@goauthentik/common/messages";
import { refreshMe } from "@goauthentik/common/users";
@ -26,7 +31,6 @@ import {
FlowErrorChallenge,
FlowsApi,
RedirectChallenge,
ResponseError,
ShellChallenge,
} from "@goauthentik/api";
@ -73,8 +77,11 @@ export class UserSettingsFlowExecutor
this.challenge = data;
return !this.challenge.responseErrors;
})
.catch((e: Error | ResponseError) => {
this.errorMessage(e);
.catch(async (error: unknown) => {
const parsedError = await parseAPIResponseError(error);
this.errorMessage(parsedError);
return false;
})
.finally(() => {
@ -109,16 +116,13 @@ export class UserSettingsFlowExecutor
}
}
async errorMessage(error: Error | Response): Promise<void> {
let body = "";
if (error instanceof Error) {
body = error.message;
}
async errorMessage(error: APIError): Promise<void> {
const challenge: FlowErrorChallenge = {
component: "ak-stage-flow-error",
error: body,
error: pluckErrorDetail(error),
requestId: "",
};
this.challenge = challenge as ChallengeTypes;
}

View File

@ -1,5 +1,5 @@
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { SentryIgnoredError } from "@goauthentik/common/errors";
import { SentryIgnoredError } from "@goauthentik/common/sentry";
import "@goauthentik/elements/forms/HorizontalFormElement";
import { ModelForm } from "@goauthentik/elements/forms/ModelForm";

View File

@ -1,7 +1,7 @@
import { AndNext, DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { SentryIgnoredError } from "@goauthentik/common/errors";
import { globalAK } from "@goauthentik/common/global";
import { deviceTypeName } from "@goauthentik/common/labels";
import { SentryIgnoredError } from "@goauthentik/common/sentry";
import { getRelativeTime } from "@goauthentik/common/utils";
import "@goauthentik/elements/buttons/Dropdown";
import "@goauthentik/elements/buttons/ModalButton";

View File

@ -43,6 +43,7 @@
"allowUnreachableCode": false,
"allowUnusedLabels": false,
"useDefineForClassFields": false,
"useUnknownInCatchVariables": true,
"alwaysStrict": true,
"noImplicitAny": true,
"plugins": [