+ ${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` `,
+ 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
>