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:
@ -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),
|
||||
});
|
||||
});
|
||||
}}
|
||||
|
@ -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> ${error}</p>
|
||||
<p class="subtext">${msg("Failed to fetch")}</p>
|
||||
<p><i class="fa fa-times"></i> ${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>
|
||||
|
@ -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>
|
||||
|
@ -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) {
|
||||
|
@ -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>`,
|
||||
|
@ -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;
|
||||
};
|
||||
|
@ -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 {
|
||||
|
@ -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>`,
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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";
|
||||
|
@ -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",
|
||||
);
|
||||
}
|
||||
});
|
||||
}}
|
||||
>
|
||||
|
@ -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),
|
||||
});
|
||||
});
|
||||
}}
|
||||
|
@ -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";
|
||||
|
@ -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 {
|
||||
|
@ -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,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -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";
|
||||
|
@ -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">
|
||||
|
@ -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;
|
||||
}
|
184
web/src/common/errors/network.ts
Normal file
184
web/src/common/errors/network.ts
Normal 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
|
@ -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;
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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";
|
||||
|
||||
|
@ -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;
|
||||
});
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { SentryIgnoredError } from "@goauthentik/common/errors";
|
||||
import { SentryIgnoredError } from "@goauthentik/common/sentry";
|
||||
|
||||
import { CSSResult, css } from "lit";
|
||||
|
||||
|
@ -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)}
|
||||
|
@ -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}
|
||||
|
@ -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);
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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),
|
||||
|
@ -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),
|
||||
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -84,7 +84,7 @@ export class TokenCopyButton extends BaseTaskButton {
|
||||
|
||||
showMessage({
|
||||
level: MessageLevel.error,
|
||||
message,
|
||||
message: message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -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,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -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 {
|
||||
|
@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
|
@ -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 {
|
||||
|
@ -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();
|
||||
|
@ -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))}
|
||||
|
@ -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(() => {
|
||||
|
@ -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(() => {
|
||||
|
@ -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(() => {
|
||||
|
@ -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);
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -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>
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
@ -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(() => {
|
||||
|
@ -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(() => {
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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";
|
||||
|
||||
|
@ -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";
|
||||
|
@ -43,6 +43,7 @@
|
||||
"allowUnreachableCode": false,
|
||||
"allowUnusedLabels": false,
|
||||
"useDefineForClassFields": false,
|
||||
"useUnknownInCatchVariables": true,
|
||||
"alwaysStrict": true,
|
||||
"noImplicitAny": true,
|
||||
"plugins": [
|
||||
|
Reference in New Issue
Block a user