${msg("Please go back and review the application.")}
`,
diff --git a/web/src/admin/applications/wizard/steps/providers/ak-application-wizard-provider-for-oauth.ts b/web/src/admin/applications/wizard/steps/providers/ak-application-wizard-provider-for-oauth.ts
index ff524d65d2..4b23e93950 100644
--- a/web/src/admin/applications/wizard/steps/providers/ak-application-wizard-provider-for-oauth.ts
+++ b/web/src/admin/applications/wizard/steps/providers/ak-application-wizard-provider-for-oauth.ts
@@ -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;
};
diff --git a/web/src/admin/applications/wizard/types.ts b/web/src/admin/applications/wizard/types.ts
index 3529d14f3c..5e20ffe612 100644
--- a/web/src/admin/applications/wizard/types.ts
+++ b/web/src/admin/applications/wizard/types.ts
@@ -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 {
diff --git a/web/src/admin/events/EventListPage.ts b/web/src/admin/events/EventListPage.ts
index b8f34df19b..e62b5767aa 100644
--- a/web/src/admin/events/EventListPage.ts
+++ b/web/src/admin/events/EventListPage.ts
@@ -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 {
`;
}
- row(item: EventWithContext): TemplateResult[] {
+ row(item: EventWithContext): SlottedTemplateResult[] {
return [
html`
${actionToLabel(item.action)}
${item.app}`,
diff --git a/web/src/admin/events/utils.ts b/web/src/admin/events/utils.ts
index 89999d39d6..3c73c3f390 100644
--- a/web/src/admin/events/utils.ts
+++ b/web/src/admin/events/utils.ts
@@ -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`
${msg("Anonymous user")}
`;
} else {
@@ -33,12 +37,14 @@ export function EventUser(event: EventWithContext, truncateUsername?: number): T
>
`;
}
+
if (event.user.on_behalf_of) {
- body = html`${body}
+ return html`${body}
${msg(str`On behalf of ${event.user.on_behalf_of.username}`)}
`;
}
+
return body;
}
diff --git a/web/src/admin/flows/FlowImportForm.ts b/web/src/admin/flows/FlowImportForm.ts
index 783f51217e..2c7b743e46 100644
--- a/web/src/admin/flows/FlowImportForm.ts
+++ b/web/src/admin/flows/FlowImportForm.ts
@@ -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";
diff --git a/web/src/admin/flows/FlowViewPage.ts b/web/src/admin/flows/FlowViewPage.ts
index 661cc3367c..718235b397 100644
--- a/web/src/admin/flows/FlowViewPage.ts
+++ b/web/src/admin/flows/FlowViewPage.ts
@@ -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",
+ );
+ }
});
}}
>
diff --git a/web/src/admin/groups/RelatedUserList.ts b/web/src/admin/groups/RelatedUserList.ts
index ed133ede3d..5370dd6b02 100644
--- a/web/src/admin/groups/RelatedUserList.ts
+++ b/web/src/admin/groups/RelatedUserList.ts
@@ -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),
});
});
}}
diff --git a/web/src/admin/providers/saml/SAMLProviderImportForm.ts b/web/src/admin/providers/saml/SAMLProviderImportForm.ts
index 3241e24b66..07ec8ffaf0 100644
--- a/web/src/admin/providers/saml/SAMLProviderImportForm.ts
+++ b/web/src/admin/providers/saml/SAMLProviderImportForm.ts
@@ -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";
diff --git a/web/src/admin/stages/prompt/PromptForm.ts b/web/src/admin/stages/prompt/PromptForm.ts
index 0a801d6468..cc1d00b9a1 100644
--- a/web/src/admin/stages/prompt/PromptForm.ts
+++ b/web/src/admin/stages/prompt/PromptForm.ts
@@ -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 {
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 {
diff --git a/web/src/admin/users/UserActiveForm.ts b/web/src/admin/users/UserActiveForm.ts
index 7eadb5a52b..ed1499a9aa 100644
--- a/web/src/admin/users/UserActiveForm.ts
+++ b/web/src/admin/users/UserActiveForm.ts
@@ -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 {
+ return parseAPIResponseError(error).then((parsedError) => {
+ showMessage({
+ message: msg(
+ str`Failed to update ${this.objectLabel}: ${pluckErrorDetail(parsedError)}`,
+ ),
+ level: MessageLevel.error,
+ });
});
}
diff --git a/web/src/admin/users/UserDevicesTable.ts b/web/src/admin/users/UserDevicesTable.ts
index 36e885fcd2..8ab7bb5c22 100644
--- a/web/src/admin/users/UserDevicesTable.ts
+++ b/web/src/admin/users/UserDevicesTable.ts
@@ -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";
diff --git a/web/src/admin/users/UserListPage.ts b/web/src/admin/users/UserListPage.ts
index fb62d62bfb..c259ec6623 100644
--- a/web/src/admin/users/UserListPage.ts
+++ b/web/src/admin/users/UserListPage.ts
@@ -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`
diff --git a/web/src/common/errors.ts b/web/src/common/errors.ts
deleted file mode 100644
index da1c68561d..0000000000
--- a/web/src/common/errors.ts
+++ /dev/null
@@ -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 {
- 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;
-}
diff --git a/web/src/common/errors/network.ts b/web/src/common/errors/network.ts
new file mode 100644
index 0000000000..f41b4d4db4
--- /dev/null
+++ b/web/src/common/errors/network.ts
@@ -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;
+
+export type HTTPStatusCode = (typeof HTTPStatusCode)[keyof typeof HTTPStatusCode];
+
+export type HTTPErrorJSONTransformer = (json: T) => APIError;
+
+export const HTTPStatusCodeTransformer: Record = {
+ [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;
+
+/**
+ * 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 = {
+ [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(
+ error: unknown,
+): Promise {
+ 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
diff --git a/web/src/common/events.ts b/web/src/common/events.ts
index aedad64d97..418c9a0a10 100644
--- a/web/src/common/events.ts
+++ b/web/src/common/events.ts
@@ -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;
+}
diff --git a/web/src/common/helpers/plex.ts b/web/src/common/helpers/plex.ts
index c3735af5bd..228a505a07 100644
--- a/web/src/common/helpers/plex.ts
+++ b/web/src/common/helpers/plex.ts
@@ -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
diff --git a/web/src/common/sentry.ts b/web/src/common/sentry.ts
index aa6a8a6eb1..3699ba5d6b 100644
--- a/web/src/common/sentry.ts
+++ b/web/src/common/sentry.ts
@@ -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";
diff --git a/web/src/common/users.ts b/web/src/common/users.ts
index 29c218a021..067b1ddc68 100644
--- a/web/src/common/users.ts
+++ b/web/src/common/users.ts
@@ -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 | 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 {
- globalMePromise = undefined;
+ memoizedSession = null;
return me();
}
-export function me(): Promise {
- 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 {
+ 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;
+ });
}
diff --git a/web/src/common/utils.ts b/web/src/common/utils.ts
index 1d140c5d05..ccf30f6ab3 100644
--- a/web/src/common/utils.ts
+++ b/web/src/common/utils.ts
@@ -1,4 +1,4 @@
-import { SentryIgnoredError } from "@goauthentik/common/errors";
+import { SentryIgnoredError } from "@goauthentik/common/sentry";
import { CSSResult, css } from "lit";
diff --git a/web/src/components/ak-event-info.ts b/web/src/components/ak-event-info.ts
index e94944d976..2b2af8f284 100644
--- a/web/src/components/ak-event-info.ts
+++ b/web/src/components/ak-event-info.ts
@@ -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 = [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`