diff --git a/web/src/admin/admin-overview/AdminOverviewPage.ts b/web/src/admin/admin-overview/AdminOverviewPage.ts index d7a9e1737c..073bbbb0a3 100644 --- a/web/src/admin/admin-overview/AdminOverviewPage.ts +++ b/web/src/admin/admin-overview/AdminOverviewPage.ts @@ -28,6 +28,7 @@ import { when } from "lit/directives/when.js"; import PFContent from "@patternfly/patternfly/components/Content/content.css"; import PFDivider from "@patternfly/patternfly/components/Divider/divider.css"; import PFPage from "@patternfly/patternfly/components/Page/page.css"; +import PFFlex from "@patternfly/patternfly/layouts/Flex/flex.css"; import PFGrid from "@patternfly/patternfly/layouts/Grid/grid.css"; import PFBase from "@patternfly/patternfly/patternfly-base.css"; @@ -54,6 +55,7 @@ export class AdminOverviewPage extends AdminOverviewBase { return [ PFBase, PFGrid, + PFFlex, PFPage, PFContent, PFDivider, @@ -67,13 +69,6 @@ export class AdminOverviewPage extends AdminOverviewBase { .card-container { max-height: 10em; } - .ak-external-link { - display: inline-block; - margin-left: 0.175rem; - vertical-align: super; - line-height: normal; - font-size: var(--pf-global--icon--FontSize--sm); - } `, ]; } @@ -99,43 +94,34 @@ export class AdminOverviewPage extends AdminOverviewBase { return html` ${msg(str`Welcome, ${name || ""}.`)} +
- -
-
- - -
-
- - - -
-
- - - -
-
-
-
- ${this.renderCards()} -
-
+ ${this.renderCards()} +
-
-
+
+ +
+ +
+ + + +
+ +
+ + + +
+
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; @@ -59,9 +64,9 @@ export abstract class AdminStatusCard extends AggregateCard { this.value = value; // Triggers shouldUpdate this.error = undefined; }) - .catch((err: ResponseError) => { + .catch(async (error) => { this.status = undefined; - this.error = err?.response?.statusText ?? msg("Unknown error"); + this.error = await parseAPIResponseError(error); }); } @@ -79,9 +84,9 @@ export abstract class AdminStatusCard extends AggregateCard { this.status = status; this.error = undefined; }) - .catch((err: ResponseError) => { + .catch(async (error: ResponseError) => { 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 +125,8 @@ export abstract class AdminStatusCard extends AggregateCard { */ private renderError(error: string): TemplateResult { return html` -

 ${error}

-

${msg("Failed to fetch")}

+

 ${msg("Failed to fetch")}

+

${error}

`; } @@ -146,7 +151,7 @@ export abstract class AdminStatusCard 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 }

diff --git a/web/src/admin/admin-overview/cards/RecentEventsCard.ts b/web/src/admin/admin-overview/cards/RecentEventsCard.ts index a18419ebcc..c629148ca3 100644 --- a/web/src/admin/admin-overview/cards/RecentEventsCard.ts +++ b/web/src/admin/admin-overview/cards/RecentEventsCard.ts @@ -1,4 +1,4 @@ -import { EventGeo, EventUser } from "@goauthentik/admin/events/utils"; +import { EventUser, formatGeoEvent } from "@goauthentik/admin/events/utils"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { EventWithContext } from "@goauthentik/common/events"; import { actionToLabel } from "@goauthentik/common/labels"; @@ -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"; @@ -38,6 +39,22 @@ export class RecentEventsCard extends Table { return super.styles.concat( PFCard, css` + .pf-c-table__sort.pf-m-selected { + background-color: var(--pf-global--BackgroundColor--dark-400); + border-block-end: var(--pf-global--BorderWidth--xl) solid var(--ak-accent); + + .pf-c-table__button { + --pf-c-table__sort__button__text--Color: var(--ak-accent); + color: var(--pf-c-nav__link--m-current--Color); + + .pf-c-table__text { + --pf-c-table__sort__button__text--Color: var( + --pf-c-nav__link--m-current--Color + ); + } + } + } + .pf-c-card__title { --pf-c-card__title--FontFamily: var( --pf-global--FontFamily--heading--sans-serif @@ -45,7 +62,47 @@ export class RecentEventsCard extends Table { --pf-c-card__title--FontSize: var(--pf-global--FontSize--md); --pf-c-card__title--FontWeight: var(--pf-global--FontWeight--bold); } - * { + + td[role="cell"] .ip-address { + max-width: 18ch; + text-overflow: ellipsis; + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + } + + th[role="columnheader"]:nth-child(3) { + --pf-c-table--cell--MinWidth: fit-content; + --pf-c-table--cell--MaxWidth: none; + --pf-c-table--cell--Width: 1%; + --pf-c-table--cell--Overflow: visible; + --pf-c-table--cell--TextOverflow: clip; + --pf-c-table--cell--WhiteSpace: nowrap; + } + + .group-header { + display: grid; + grid-template-columns: 1fr auto; + gap: var(--pf-global--spacer--sm); + font-weight: var(--pf-global--FontWeight--bold); + font-size: var(--pf-global--FontSize--md); + font-variant: all-petite-caps; + } + + .pf-c-table thead:not(:first-child) { + background: hsl(0deg 0% 0% / 10%); + + > tr { + border-block-end: 2px solid + var( + --pf-c-page__header-tools--c-button--m-selected--before--BackgroundColor + ); + font-family: var(--pf-global--FontFamily--heading--sans-serif); + } + } + + tbody * { word-break: break-all; } `, @@ -68,20 +125,57 @@ export class RecentEventsCard extends Table {
`; } - row(item: EventWithContext): TemplateResult[] { + override groupBy(items: Event[]): [SlottedTemplateResult, Event[]][] { + const groupedByDay = new Map(); + + for (const item of items) { + const day = new Date(item.created); + day.setHours(0, 0, 0, 0); + const serializedDay = day.toISOString(); + + let dayEvents = groupedByDay.get(serializedDay); + if (!dayEvents) { + dayEvents = []; + groupedByDay.set(serializedDay, dayEvents); + } + + dayEvents.push(item); + } + + return Array.from(groupedByDay, ([serializedDay, events]) => { + const day = new Date(serializedDay); + return [ + html`
+
${getRelativeTime(day)}
+ ${day.toLocaleDateString()} +
`, + events, + ]; + }); + } + + row(item: EventWithContext): SlottedTemplateResult[] { return [ html` - ${item.app}`, + ${item.app}`, EventUser(item), - html`
${getRelativeTime(item.created)}
- ${item.created.toLocaleString()}`, - html`
${item.clientIp || msg("-")}
- ${EventGeo(item)}`, + + html``, + + html`
${item.clientIp || msg("-")}
+ ${formatGeoEvent(item)}`, + html`${item.brand?.name || msg("-")}`, ]; } - renderEmpty(): TemplateResult { + renderEmpty(inner?: SlottedTemplateResult): TemplateResult { + if (this.error) { + return super.renderEmpty(inner); + } + return super.renderEmpty( html`
${msg("No matching events could be found.")}
diff --git a/web/src/admin/admin-overview/charts/OutpostStatusChart.ts b/web/src/admin/admin-overview/charts/OutpostStatusChart.ts index f62535bc84..b543b43889 100644 --- a/web/src/admin/admin-overview/charts/OutpostStatusChart.ts +++ b/web/src/admin/admin-overview/charts/OutpostStatusChart.ts @@ -30,11 +30,13 @@ export class OutpostStatusChart extends AKChart { const api = new OutpostsApi(DEFAULT_CONFIG); const outposts = await api.outpostsInstancesList({}); const outpostStats: SummarizedSyncStatus[] = []; + await Promise.all( outposts.results.map(async (element) => { const health = await api.outpostsInstancesHealthList({ uuid: element.pk || "", }); + const singleStats: SummarizedSyncStatus = { unsynced: 0, healthy: 0, @@ -42,9 +44,11 @@ export class OutpostStatusChart extends AKChart { total: health.length, label: element.name, }; + if (health.length === 0) { singleStats.unsynced += 1; } + health.forEach((h) => { if (h.versionOutdated) { singleStats.failed += 1; @@ -52,11 +56,14 @@ export class OutpostStatusChart extends AKChart { singleStats.healthy += 1; } }); + outpostStats.push(singleStats); }), ); + this.centerText = outposts.pagination.count.toString(); outpostStats.sort((a, b) => a.label.localeCompare(b.label)); + return outpostStats; } diff --git a/web/src/admin/applications/wizard/ApplicationWizardStep.ts b/web/src/admin/applications/wizard/ApplicationWizardStep.ts index 71095a6d46..e8590c28c7 100644 --- a/web/src/admin/applications/wizard/ApplicationWizardStep.ts +++ b/web/src/admin/applications/wizard/ApplicationWizardStep.ts @@ -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; diff --git a/web/src/admin/applications/wizard/steps/ak-application-wizard-submit-step.ts b/web/src/admin/applications/wizard/steps/ak-application-wizard-submit-step.ts index d2e2786971..b3eb017d8f 100644 --- a/web/src/admin/applications/wizard/steps/ak-application-wizard-submit-step.ts +++ b/web/src/admin/applications/wizard/steps/ak-application-wizard-submit-step.ts @@ -1,7 +1,7 @@ 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 { CustomEmitterElement } from "@goauthentik/elements/utils/eventEmitter"; @@ -33,7 +33,7 @@ import { } from "@goauthentik/api"; import { ApplicationWizardStep } from "../ApplicationWizardStep.js"; -import { ExtendedValidationError, OneOfProvider } from "../types.js"; +import { ApplicationTransactionValidationError, OneOfProvider } from "../types.js"; import { providerRenderers } from "./SubmitStepOverviewRenderers.js"; const _submitStates = ["reviewing", "running", "submitted"] as const; @@ -131,39 +131,36 @@ 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 (resolution) => { + const errors = + await parseAPIResponseError(resolution); - // 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; - } + // 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; } - this.handleUpdate({ errors }); - this.state = "reviewing"; - }) - ); + } + + this.handleUpdate({ errors }); + this.state = "reviewing"; + }); } override handleButton(button: WizardButton) { @@ -232,7 +229,7 @@ export class ApplicationWizardSubmitStep extends CustomEmitterElement(Applicatio const navTo = (step: string) => () => this.dispatchEvent(new WizardNavigationEvent(step)); const errors = this.wizard.errors; return html`
- ${match(errors as ExtendedValidationError) + ${match(errors as ApplicationTransactionValidationError) .with( { app: P.nonNullable }, () => 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..374a9d4043 100644 --- a/web/src/admin/applications/wizard/types.ts +++ b/web/src/admin/applications/wizard/types.ts @@ -1,3 +1,5 @@ +import { APIError } from "@goauthentik/common/errors/network"; + import { type ApplicationRequest, type LDAPProviderRequest, @@ -25,16 +27,31 @@ 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; -}; +} + +/** + * Type-guard to determine if an API response is shaped like an {@linkcode ApplicationTransactionValidationError}. + */ +export function isApplicationTransactionValidationError( + error: APIError, +): 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 +66,7 @@ export interface ApplicationWizardState { proxyMode: ProxyMode; bindings: PolicyBinding[]; currentBinding: number; - errors: ExtendedValidationError; + errors: ApplicationTransactionValidationError; } export interface ApplicationWizardStateUpdate { diff --git a/web/src/admin/events/EventListPage.ts b/web/src/admin/events/EventListPage.ts index b8f34df19b..ac58939b39 100644 --- a/web/src/admin/events/EventListPage.ts +++ b/web/src/admin/events/EventListPage.ts @@ -1,5 +1,5 @@ import "@goauthentik/admin/events/EventVolumeChart"; -import { EventGeo, EventUser } from "@goauthentik/admin/events/utils"; +import { EventUser, formatGeoEvent } from "@goauthentik/admin/events/utils"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { EventWithContext } from "@goauthentik/common/events"; import { actionToLabel } from "@goauthentik/common/labels"; @@ -80,7 +80,7 @@ export class EventListPage extends TablePage { html`
${getRelativeTime(item.created)}
${item.created.toLocaleString()}`, html`
${item.clientIp || msg("-")}
- ${EventGeo(item)}`, + ${formatGeoEvent(item)}`, html`${item.brand?.name || msg("-")}`, html` diff --git a/web/src/admin/events/EventViewPage.ts b/web/src/admin/events/EventViewPage.ts index 9c8d4fda9d..39d9ec2808 100644 --- a/web/src/admin/events/EventViewPage.ts +++ b/web/src/admin/events/EventViewPage.ts @@ -1,4 +1,4 @@ -import { EventGeo, EventUser } from "@goauthentik/admin/events/utils"; +import { EventUser, formatGeoEvent } from "@goauthentik/admin/events/utils"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { EventWithContext } from "@goauthentik/common/events"; import { actionToLabel } from "@goauthentik/common/labels"; @@ -118,7 +118,7 @@ export class EventViewPage extends AKElement {
${this.event.clientIp || msg("-")}
- ${EventGeo(this.event)} + ${formatGeoEvent(this.event)}
diff --git a/web/src/admin/events/utils.ts b/web/src/admin/events/utils.ts index 89999d39d6..87ed3f74a3 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 formatGeoEvent(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/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..b0ff63fa19 100644 --- a/web/src/admin/stages/prompt/PromptForm.ts +++ b/web/src/admin/stages/prompt/PromptForm.ts @@ -1,5 +1,9 @@ import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; -import { parseAPIError } from "@goauthentik/common/errors"; +import { + containsNonFieldErrors, + parseAPIResponseError, + pluckErrorDetail, +} from "@goauthentik/common/errors/network"; import { first } from "@goauthentik/common/utils"; import "@goauthentik/elements/CodeMirror"; import { CodeMirrorMode } from "@goauthentik/elements/CodeMirror"; @@ -17,14 +21,7 @@ import { map } from "lit/directives/map.js"; import PFTitle from "@patternfly/patternfly/components/Title/title.css"; import PFGrid from "@patternfly/patternfly/layouts/Grid/grid.css"; -import { - Prompt, - PromptChallenge, - PromptTypeEnum, - ResponseError, - StagesApi, - ValidationError, -} from "@goauthentik/api"; +import { Prompt, PromptChallenge, PromptTypeEnum, StagesApi } from "@goauthentik/api"; class PreviewStageHost implements StageHost { challenge = undefined; @@ -78,15 +75,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) => { + const parsedError = await parseAPIResponseError(error); + + this.previewError = containsNonFieldErrors(parsedError) + ? error.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/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/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..04e203b53d --- /dev/null +++ b/web/src/common/errors/network.ts @@ -0,0 +1,194 @@ +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 parseAPIError} 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; +} + +/** + * Type guard to check if an error contains non-field errors. + * + * This is a reasonable heuristic to determine if an error is a {@linkcode ValidationError}. + * + * @see {@linkcode parseAPIError} to parse the response body into a {@linkcode APIError}. + */ +export function containsNonFieldErrors(error: APIError): error is ValidationError { + return "non_field_errors" in error; +} +/** + * 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) => { + console.error("Failed to parse response error body", transformerError); + + return createSyntheticGenericError(message || response.statusText) as T; + }); +} + +//#endregion diff --git a/web/src/common/errors/sentry.ts b/web/src/common/errors/sentry.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/web/src/common/events.ts b/web/src/common/events.ts index aedad64d97..ad3be909d8 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,13 @@ export interface EventRequest { path: string; method: string; } + +export interface EventContext { + [key: string]: EventContext | EventModel | EventGeo | string | number | string[] | undefined; + 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 0778cbce66..b51daad740 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, @@ -16,69 +15,85 @@ import { CapabilitiesEnum, Config, ResponseError } from "@goauthentik/api"; export const TAG_SENTRY_COMPONENT = "authentik.component"; export const TAG_SENTRY_CAPABILITIES = "authentik.capabilities"; -export async function configureSentry(canDoPpi = false): Promise { - const cfg = await config(); - if (cfg.errorReporting.enabled) { - init({ - dsn: cfg.errorReporting.sentryDsn, - ignoreErrors: [ - /network/gi, - /fetch/gi, - /module/gi, - // Error on edge on ios, - // https://stackoverflow.com/questions/69261499/what-is-instantsearchsdkjsbridgeclearhighlight - /instantSearchSDKJSBridgeClearHighlight/gi, - // Seems to be an issue in Safari and Firefox - /MutationObserver.observe/gi, - /NS_ERROR_FAILURE/gi, - ], - release: `authentik@${VERSION}`, - integrations: [ - browserTracingIntegration({ - shouldCreateSpanForRequest: (url: string) => { - return url.startsWith(window.location.host); - }, - }), - ], - tracesSampleRate: cfg.errorReporting.tracesSampleRate, - environment: cfg.errorReporting.environment, - beforeSend: ( - event: ErrorEvent, - hint: EventHint, - ): ErrorEvent | PromiseLike | null => { - if (!hint) { - return event; - } - if (hint.originalException instanceof SentryIgnoredError) { - return null; - } - if ( - hint.originalException instanceof ResponseError || - hint.originalException instanceof DOMException - ) { - return null; - } - return event; - }, - }); - setTag(TAG_SENTRY_CAPABILITIES, cfg.capabilities.join(",")); - if (window.location.pathname.includes("if/")) { - setTag(TAG_SENTRY_COMPONENT, `web/${currentInterface()}`); - } - if (cfg.capabilities.includes(CapabilitiesEnum.CanDebug)) { - const Spotlight = await import("@spotlightjs/spotlight"); +/** + * A generic error that can be thrown without triggering Sentry's reporting. + */ +export class SentryIgnoredError extends Error {} - Spotlight.init({ injectImmediately: true }); - } - if (cfg.errorReporting.sendPii && canDoPpi) { - me().then((user) => { - setUser({ email: user.user.email }); - console.debug("authentik/config: Sentry with PII enabled."); - }); - } else { - console.debug("authentik/config: Sentry enabled."); - } +/** + * Configure Sentry with the given configuration. + * + * @param canSendPII Whether the user can send personally identifiable information. + */ +export async function configureSentry(canSendPII = false): Promise { + const cfg = await config(); + + if (!cfg.errorReporting.enabled) return cfg; + + init({ + dsn: cfg.errorReporting.sentryDsn, + ignoreErrors: [ + /network/gi, + /fetch/gi, + /module/gi, + // Error on edge on ios, + // https://stackoverflow.com/questions/69261499/what-is-instantsearchsdkjsbridgeclearhighlight + /instantSearchSDKJSBridgeClearHighlight/gi, + // Seems to be an issue in Safari and Firefox + /MutationObserver.observe/gi, + /NS_ERROR_FAILURE/gi, + ], + release: `authentik@${VERSION}`, + integrations: [ + browserTracingIntegration({ + shouldCreateSpanForRequest: (url: string) => { + return url.startsWith(window.location.host); + }, + }), + ], + tracesSampleRate: cfg.errorReporting.tracesSampleRate, + environment: cfg.errorReporting.environment, + beforeSend: ( + event: ErrorEvent, + hint: EventHint, + ): ErrorEvent | PromiseLike | null => { + if (!hint) { + return event; + } + if (hint.originalException instanceof SentryIgnoredError) { + return null; + } + if ( + hint.originalException instanceof ResponseError || + hint.originalException instanceof DOMException + ) { + return null; + } + return event; + }, + }); + + setTag(TAG_SENTRY_CAPABILITIES, cfg.capabilities.join(",")); + + if (window.location.pathname.includes("if/")) { + setTag(TAG_SENTRY_COMPONENT, `web/${currentInterface()}`); } + + if (cfg.capabilities.includes(CapabilitiesEnum.CanDebug)) { + const Spotlight = await import("@spotlightjs/spotlight"); + + Spotlight.init({ injectImmediately: true }); + } + + if (cfg.errorReporting.sendPii && canSendPII) { + await me().then((user) => { + setUser({ email: user.user.email }); + console.debug("authentik/config: Sentry with PII enabled."); + }); + } else { + console.debug("authentik/config: Sentry enabled."); + } + return cfg; } diff --git a/web/src/common/styles/authentik.css b/web/src/common/styles/authentik.css index 2ba80eecc2..c1f21ae046 100644 --- a/web/src/common/styles/authentik.css +++ b/web/src/common/styles/authentik.css @@ -56,6 +56,19 @@ html > form > input { vertical-align: middle; } +.pf-c-card__title { + .pf-icon:first-child, + .fa:first-child { + margin-inline-end: var(--pf-global--spacer--sm); + } +} + +a > .fas.fa-external-link-alt { + margin-inline-start: var(--pf-global--spacer--xs); + font-size: var(--pf-global--FontSize--sm); + transform: translateY(-0.1em); +} + .pf-c-form-control { --pf-c-form-control--m-caps-lock--BackgroundUrl: url("data:image/svg+xml;charset=utf8,%3Csvg fill='%23aaabac' viewBox='0 0 56 56' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M 20.7812 37.6211 L 35.2421 37.6211 C 38.5233 37.6211 40.2577 35.6992 40.2577 32.6055 L 40.2577 28.4570 L 49.1404 28.4570 C 51.0859 28.4570 52.6329 27.3086 52.6329 25.5039 C 52.6329 24.4024 52.0703 23.5351 51.0158 22.6211 L 30.9062 4.8789 C 29.9452 4.0351 29.0546 3.4727 27.9999 3.4727 C 26.9687 3.4727 26.0780 4.0351 25.1171 4.8789 L 4.9843 22.6445 C 3.8828 23.6055 3.3671 24.4024 3.3671 25.5039 C 3.3671 27.3086 4.9140 28.4570 6.8828 28.4570 L 15.7421 28.4570 L 15.7421 32.6055 C 15.7421 35.6992 17.4999 37.6211 20.7812 37.6211 Z M 21.1562 34.0820 C 20.2655 34.0820 19.6562 33.4961 19.6562 32.6055 L 19.6562 25.7149 C 19.6562 25.1524 19.4452 24.9180 18.8828 24.9180 L 8.6640 24.9180 C 8.4999 24.9180 8.4296 24.8476 8.4296 24.7305 C 8.4296 24.6367 8.4530 24.5430 8.5702 24.4492 L 27.5077 7.9961 C 27.7187 7.8086 27.8359 7.7383 27.9999 7.7383 C 28.1640 7.7383 28.3046 7.8086 28.4921 7.9961 L 47.4532 24.4492 C 47.5703 24.5430 47.5939 24.6367 47.5939 24.7305 C 47.5939 24.8476 47.4998 24.9180 47.3356 24.9180 L 37.1406 24.9180 C 36.5780 24.9180 36.3671 25.1524 36.3671 25.7149 L 36.3671 32.6055 C 36.3671 33.4727 35.7109 34.0820 34.8671 34.0820 Z M 19.7733 52.5273 L 36.0624 52.5273 C 38.7577 52.5273 40.3046 51.0273 40.3046 48.3086 L 40.3046 44.9336 C 40.3046 42.2148 38.7577 40.6680 36.0624 40.6680 L 19.7733 40.6680 C 17.0546 40.6680 15.5077 42.2383 15.5077 44.9336 L 15.5077 48.3086 C 15.5077 51.0039 17.0546 52.5273 19.7733 52.5273 Z M 20.3124 49.2227 C 19.4921 49.2227 19.0468 48.8008 19.0468 47.9805 L 19.0468 45.2617 C 19.0468 44.4414 19.4921 43.9727 20.3124 43.9727 L 35.5233 43.9727 C 36.3202 43.9727 36.7655 44.4414 36.7655 45.2617 L 36.7655 47.9805 C 36.7655 48.8008 36.3202 49.2227 35.5233 49.2227 Z'/%3E%3C/svg%3E"); } diff --git a/web/src/common/styles/theme-dark.css b/web/src/common/styles/theme-dark.css index 8023c7d1e4..a601df7c69 100644 --- a/web/src/common/styles/theme-dark.css +++ b/web/src/common/styles/theme-dark.css @@ -4,6 +4,7 @@ --pf-c-page__main-section--m-light--BackgroundColor: var(--ak-dark-background-darker); --pf-global--link--Color: var(--ak-dark-foreground-link) !important; --pf-global--BorderColor--100: var(--ak-dark-background-lighter) !important; + --pf-c-table--m-striped__tr--BackgroundColor: var(--pf-global--BackgroundColor--dark-300); } body { background-color: var(--ak-dark-background) !important; diff --git a/web/src/common/utils.ts b/web/src/common/utils.ts index f9e19234fa..c24820ebd8 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/events/ObjectChangelog.ts b/web/src/components/events/ObjectChangelog.ts index 6459dfe089..1523d22573 100644 --- a/web/src/components/events/ObjectChangelog.ts +++ b/web/src/components/events/ObjectChangelog.ts @@ -1,4 +1,4 @@ -import { EventGeo, EventUser } from "@goauthentik/admin/events/utils"; +import { EventUser, formatGeoEvent } from "@goauthentik/admin/events/utils"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { EventWithContext } from "@goauthentik/common/events"; import { actionToLabel } from "@goauthentik/common/labels"; @@ -76,7 +76,7 @@ export class ObjectChangelog extends Table { ${item.created.toLocaleString()}`, html`
${item.clientIp || msg("-")}
- ${EventGeo(item)}`, + ${formatGeoEvent(item)}`, ]; } diff --git a/web/src/elements/cards/AggregateCard.ts b/web/src/elements/cards/AggregateCard.ts index e64d17952f..9fb229b981 100644 --- a/web/src/elements/cards/AggregateCard.ts +++ b/web/src/elements/cards/AggregateCard.ts @@ -1,4 +1,5 @@ import { AKElement } from "@goauthentik/elements/Base"; +import { SlottedTemplateResult } from "@goauthentik/elements/types"; import { CSSResult, TemplateResult, css, html, nothing } from "lit"; import { customElement, property } from "lit/decorators.js"; @@ -96,24 +97,30 @@ export class AggregateCard extends AKElement implements IAggregateCard { .pf-c-card__footer { padding-bottom: 0; } + + .pf-c-card { + --pf-c-card__title--FontSize: var(--pf-global--FontSize--xs); + --pf-c-card--child--PaddingLeft: var(--pf-global--spacer--md); + --pf-c-card--child--PaddingRight: var(--pf-global--spacer--md); + } `, ]); } - renderInner(): TemplateResult { + renderInner(): SlottedTemplateResult { return html``; } - renderHeaderLink(): TemplateResult { - return html`${this.headerLink - ? html` - - ` - : ""}`; + renderHeaderLink() { + if (!this.headerLink) return nothing; + + return html` + + `; } - renderHeader(): TemplateResult { - return html`${this.header ? this.header : ""}`; + renderHeader(): SlottedTemplateResult { + return this.header ? html`${this.header}` : nothing; } render(): TemplateResult { diff --git a/web/src/elements/charts/Chart.ts b/web/src/elements/charts/Chart.ts index ec36fba392..d07333be23 100644 --- a/web/src/elements/charts/Chart.ts +++ b/web/src/elements/charts/Chart.ts @@ -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 extends AKElement { chart?: Chart; @state() - error?: ResponseError; + error?: APIError; @property() centerText?: string; @@ -79,6 +84,9 @@ export abstract class AKChart 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 extends AKElement { width: 100px; height: 100px; z-index: 1; + cursor: crosshair; } `, ]; @@ -136,19 +145,24 @@ export abstract class AKChart extends AKElement { this.apiRequest() .then((r) => { const canvas = this.shadowRoot?.querySelector("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) => { + const parsedError = await parseAPIResponseError(error); + this.error = parsedError; }); } @@ -214,7 +228,7 @@ export abstract class AKChart extends AKElement { ${this.error ? html` -

${this.error.response.statusText}

+

${pluckErrorDetail(this.error)}

` : html`${this.chart diff --git a/web/src/elements/forms/Form.ts b/web/src/elements/forms/Form.ts index 08ca5c2982..3453fb9d29 100644 --- a/web/src/elements/forms/Form.ts +++ b/web/src/elements/forms/Form.ts @@ -1,5 +1,5 @@ import { EVENT_REFRESH } from "@goauthentik/common/constants"; -import { parseAPIError } from "@goauthentik/common/errors"; +import { parseAPIResponseError } 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 extends AKElement { * field-levels errors to the fields, and send the rest of them to the Notifications. * */ - async submit(ev: Event): Promise { - 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 { + 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) => { + if (error instanceof PreventFormSubmit && error.element) { + error.element.errorMessages = [error.message]; + error.element.invalid = true; + } + + let errorMessage = error.response.statusText; + const parsedError = await parseAPIResponseError(error); + + if (instanceOfValidationError(parsedError)) { // assign all input-related errors to their elements const elements = this.shadowRoot?.querySelectorAll( "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 { diff --git a/web/src/elements/forms/SearchSelect/SearchSelect.ts b/web/src/elements/forms/SearchSelect/SearchSelect.ts index daf292a2f6..c5109e0015 100644 --- a/web/src/elements/forms/SearchSelect/SearchSelect.ts +++ b/web/src/elements/forms/SearchSelect/SearchSelect.ts @@ -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 extends AkControlElement implements ISe isFetchingData = false; @state() - error?: APIErrorTypes; + error?: APIError; public toForm(): string { if (!this.objects) { @@ -128,23 +130,26 @@ export class SearchSelectBase extends AkControlElement 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) => { 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 extends AkControlElement implements ISe public override render() { if (this.error) { - return html`${msg("Failed to fetch objects: ")} ${this.error.detail}`; + return html`${msg("Failed to fetch objects: ")} ${pluckErrorDetail(this.error)}`; } // `this.objects` is both a container and a sigil; if it is in the `undefined` state, it's a diff --git a/web/src/elements/messages/MessageContainer.ts b/web/src/elements/messages/MessageContainer.ts index 9f706079c8..af0058b96d 100644 --- a/web/src/elements/messages/MessageContainer.ts +++ b/web/src/elements/messages/MessageContainer.ts @@ -3,7 +3,7 @@ import { EVENT_WS_MESSAGE, WS_MSG_TYPE_MESSAGE, } from "@goauthentik/common/constants"; -import { SentryIgnoredError } from "@goauthentik/common/errors"; +import { SentryIgnoredError } from "@goauthentik/common/sentry"; import { WSMessage } from "@goauthentik/common/ws"; import { AKElement } from "@goauthentik/elements/Base"; import "@goauthentik/elements/messages/Message"; diff --git a/web/src/elements/table/Table.ts b/web/src/elements/table/Table.ts index 4e1cc5003f..26011dc7ab 100644 --- a/web/src/elements/table/Table.ts +++ b/web/src/elements/table/Table.ts @@ -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 { export abstract class Table extends AKElement implements TableLike { abstract apiEndpoint(): Promise>; abstract columns(): TableColumn[]; - abstract row(item: T): TemplateResult[]; + abstract row(item: T): SlottedTemplateResult[]; private isLoading = false; @@ -106,12 +111,12 @@ export abstract class Table 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 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 extends AKElement implements TableLike { expandedElements: T[] = []; @state() - error?: APIErrorTypes; + error?: APIError; static get styles(): CSSResult[] { return [ @@ -187,6 +193,12 @@ export abstract class Table 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 extends AKElement implements TableLike { }; } - public groupBy(items: T[]): [string, T[]][] { + public groupBy(items: T[]): [SlottedTemplateResult, T[]][] { return groupBy(items, () => { return ""; }); } public async fetch(): Promise { - 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) => { + 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`
- +
`; } - renderEmpty(inner?: TemplateResult): TemplateResult { + renderEmpty(inner?: SlottedTemplateResult): TemplateResult { return html` @@ -285,18 +307,16 @@ export abstract class Table extends AKElement implements TableLike { `; } - renderObjectCreate(): TemplateResult { - return html``; + renderObjectCreate(): SlottedTemplateResult { + return nothing; } - renderError(): TemplateResult { - return this.error - ? html` - ${this.error instanceof ResponseError - ? html`
${this.error.message}
` - : html`
${this.error.detail}
`} -
` - : html``; + renderError(): SlottedTemplateResult { + if (!this.error) return nothing; + + return html` +
${pluckErrorDetail(this.error)}
+
`; } private renderRows(): TemplateResult[] | undefined { @@ -404,15 +424,17 @@ export abstract class Table extends AKElement implements TableLike { } : itemSelectHandler} > - ${this.checkbox ? renderCheckbox() : html``} - ${this.expandable ? renderExpansion() : html``} - ${this.row(item).map((col) => { - return html`${col}`; + ${this.checkbox ? renderCheckbox() : nothing} + ${this.expandable ? renderExpansion() : nothing} + ${this.row(item).map((column, columnIndex) => { + return html` + ${column} + `; })} - ${this.expandedElements.includes(item) ? this.renderExpanded(item) : html``} + ${this.expandedElements.includes(item) ? this.renderExpanded(item) : nothing} `; }); @@ -430,12 +452,12 @@ export abstract class Table 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 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 extends AKElement implements TableLike { ${this.renderToolbarContainer()} - + ${this.checkbox ? this.renderAllOnThisPageCheckbox() : html``} ${this.expandable ? html`` : html``} ${this.columns().map((col) => col.render(this))} diff --git a/web/src/user/user-settings/mfa/MFADeviceForm.ts b/web/src/user/user-settings/mfa/MFADeviceForm.ts index 1b814e3c6c..a55a936720 100644 --- a/web/src/user/user-settings/mfa/MFADeviceForm.ts +++ b/web/src/user/user-settings/mfa/MFADeviceForm.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/elements/forms/HorizontalFormElement"; import { ModelForm } from "@goauthentik/elements/forms/ModelForm"; diff --git a/web/src/user/user-settings/mfa/MFADevicesPage.ts b/web/src/user/user-settings/mfa/MFADevicesPage.ts index 96242537ad..3a19cc0309 100644 --- a/web/src/user/user-settings/mfa/MFADevicesPage.ts +++ b/web/src/user/user-settings/mfa/MFADevicesPage.ts @@ -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";