Compare commits
1 Commits
safari-fol
...
permission
Author | SHA1 | Date | |
---|---|---|---|
ff787a0f59 |
@ -28,6 +28,7 @@ import { when } from "lit/directives/when.js";
|
|||||||
import PFContent from "@patternfly/patternfly/components/Content/content.css";
|
import PFContent from "@patternfly/patternfly/components/Content/content.css";
|
||||||
import PFDivider from "@patternfly/patternfly/components/Divider/divider.css";
|
import PFDivider from "@patternfly/patternfly/components/Divider/divider.css";
|
||||||
import PFPage from "@patternfly/patternfly/components/Page/page.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 PFGrid from "@patternfly/patternfly/layouts/Grid/grid.css";
|
||||||
import PFBase from "@patternfly/patternfly/patternfly-base.css";
|
import PFBase from "@patternfly/patternfly/patternfly-base.css";
|
||||||
|
|
||||||
@ -54,6 +55,7 @@ export class AdminOverviewPage extends AdminOverviewBase {
|
|||||||
return [
|
return [
|
||||||
PFBase,
|
PFBase,
|
||||||
PFGrid,
|
PFGrid,
|
||||||
|
PFFlex,
|
||||||
PFPage,
|
PFPage,
|
||||||
PFContent,
|
PFContent,
|
||||||
PFDivider,
|
PFDivider,
|
||||||
@ -67,13 +69,6 @@ export class AdminOverviewPage extends AdminOverviewBase {
|
|||||||
.card-container {
|
.card-container {
|
||||||
max-height: 10em;
|
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`<ak-page-header description=${msg("General system status")} ?hasIcon=${false}>
|
return html`<ak-page-header description=${msg("General system status")} ?hasIcon=${false}>
|
||||||
<span slot="header"> ${msg(str`Welcome, ${name || ""}.`)} </span>
|
<span slot="header"> ${msg(str`Welcome, ${name || ""}.`)} </span>
|
||||||
</ak-page-header>
|
</ak-page-header>
|
||||||
|
|
||||||
<section class="pf-c-page__main-section">
|
<section class="pf-c-page__main-section">
|
||||||
<div class="pf-l-grid pf-m-gutter">
|
<div class="pf-l-grid pf-m-gutter">
|
||||||
<!-- row 1 -->
|
${this.renderCards()}
|
||||||
<div
|
<div class="pf-l-grid__item pf-m-9-col pf-m-3-row">
|
||||||
class="pf-l-grid__item pf-m-12-col pf-m-6-col-on-xl pf-m-6-col-on-2xl pf-l-grid pf-m-gutter"
|
|
||||||
>
|
|
||||||
<div class="pf-l-grid__item pf-m-12-col pf-m-6-col-on-xl pf-m-4-col-on-2xl">
|
|
||||||
<ak-quick-actions-card .actions=${this.quickActions}>
|
|
||||||
</ak-quick-actions-card>
|
|
||||||
</div>
|
|
||||||
<div class="pf-l-grid__item pf-m-12-col pf-m-6-col-on-xl pf-m-4-col-on-2xl">
|
|
||||||
<ak-aggregate-card
|
|
||||||
icon="pf-icon pf-icon-zone"
|
|
||||||
header=${msg("Outpost status")}
|
|
||||||
headerLink="#/outpost/outposts"
|
|
||||||
>
|
|
||||||
<ak-admin-status-chart-outpost></ak-admin-status-chart-outpost>
|
|
||||||
</ak-aggregate-card>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="pf-l-grid__item pf-m-12-col pf-m-12-col-on-xl pf-m-4-col-on-2xl"
|
|
||||||
>
|
|
||||||
<ak-aggregate-card icon="fa fa-sync-alt" header=${msg("Sync status")}>
|
|
||||||
<ak-admin-status-chart-sync></ak-admin-status-chart-sync>
|
|
||||||
</ak-aggregate-card>
|
|
||||||
</div>
|
|
||||||
<div class="pf-l-grid__item pf-m-12-col">
|
|
||||||
<hr class="pf-c-divider" />
|
|
||||||
</div>
|
|
||||||
${this.renderCards()}
|
|
||||||
</div>
|
|
||||||
<div class="pf-l-grid__item pf-m-12-col pf-m-6-col-on-xl">
|
|
||||||
<ak-recent-events pageSize="6"></ak-recent-events>
|
<ak-recent-events pageSize="6"></ak-recent-events>
|
||||||
</div>
|
</div>
|
||||||
<div class="pf-l-grid__item pf-m-12-col">
|
<div class="pf-l-grid__item pf-m-6-col pf-m-3-col-on-md pf-m-3-col-on-xl">
|
||||||
<hr class="pf-c-divider" />
|
<ak-quick-actions-card .actions=${this.quickActions}>
|
||||||
|
</ak-quick-actions-card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="pf-l-grid__item pf-m-6-col pf-m-3-col-on-md pf-m-3-col-on-xl">
|
||||||
|
<ak-aggregate-card
|
||||||
|
icon="pf-icon pf-icon-zone"
|
||||||
|
header=${msg("Outpost status")}
|
||||||
|
headerLink="#/outpost/outposts"
|
||||||
|
>
|
||||||
|
<ak-admin-status-chart-outpost></ak-admin-status-chart-outpost>
|
||||||
|
</ak-aggregate-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pf-l-grid__item pf-m-6-col pf-m-3-col-on-md pf-m-3-col-on-xl">
|
||||||
|
<ak-aggregate-card icon="fa fa-sync-alt" header=${msg("Sync status")}>
|
||||||
|
<ak-admin-status-chart-sync></ak-admin-status-chart-sync>
|
||||||
|
</ak-aggregate-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- row 3 -->
|
<!-- row 3 -->
|
||||||
<div
|
<div
|
||||||
class="pf-l-grid__item pf-m-12-col pf-m-6-col-on-xl pf-m-8-col-on-2xl big-graph-container"
|
class="pf-l-grid__item pf-m-12-col pf-m-6-col-on-xl pf-m-8-col-on-2xl big-graph-container"
|
||||||
|
@ -1,5 +1,10 @@
|
|||||||
import { EVENT_REFRESH } from "@goauthentik/common/constants";
|
import { EVENT_REFRESH } from "@goauthentik/common/constants";
|
||||||
import { PFSize } from "@goauthentik/common/enums.js";
|
import { PFSize } from "@goauthentik/common/enums.js";
|
||||||
|
import {
|
||||||
|
APIError,
|
||||||
|
parseAPIResponseError,
|
||||||
|
pluckErrorDetail,
|
||||||
|
} from "@goauthentik/common/errors/network";
|
||||||
import { AggregateCard } from "@goauthentik/elements/cards/AggregateCard";
|
import { AggregateCard } from "@goauthentik/elements/cards/AggregateCard";
|
||||||
|
|
||||||
import { msg } from "@lit/localize";
|
import { msg } from "@lit/localize";
|
||||||
@ -29,7 +34,7 @@ export abstract class AdminStatusCard<T> extends AggregateCard {
|
|||||||
|
|
||||||
// Current error state if any request fails
|
// Current error state if any request fails
|
||||||
@state()
|
@state()
|
||||||
protected error?: string;
|
protected error?: APIError;
|
||||||
|
|
||||||
// Abstract methods to be implemented by subclasses
|
// Abstract methods to be implemented by subclasses
|
||||||
abstract getPrimaryValue(): Promise<T>;
|
abstract getPrimaryValue(): Promise<T>;
|
||||||
@ -59,9 +64,9 @@ export abstract class AdminStatusCard<T> extends AggregateCard {
|
|||||||
this.value = value; // Triggers shouldUpdate
|
this.value = value; // Triggers shouldUpdate
|
||||||
this.error = undefined;
|
this.error = undefined;
|
||||||
})
|
})
|
||||||
.catch((err: ResponseError) => {
|
.catch(async (error) => {
|
||||||
this.status = undefined;
|
this.status = undefined;
|
||||||
this.error = err?.response?.statusText ?? msg("Unknown error");
|
this.error = await parseAPIResponseError(error);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -79,9 +84,9 @@ export abstract class AdminStatusCard<T> extends AggregateCard {
|
|||||||
this.status = status;
|
this.status = status;
|
||||||
this.error = undefined;
|
this.error = undefined;
|
||||||
})
|
})
|
||||||
.catch((err: ResponseError) => {
|
.catch(async (error: ResponseError) => {
|
||||||
this.status = undefined;
|
this.status = undefined;
|
||||||
this.error = err?.response?.statusText ?? msg("Unknown error");
|
this.error = await parseAPIResponseError(error);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Prevent immediate re-render if only value changed
|
// Prevent immediate re-render if only value changed
|
||||||
@ -120,8 +125,8 @@ export abstract class AdminStatusCard<T> extends AggregateCard {
|
|||||||
*/
|
*/
|
||||||
private renderError(error: string): TemplateResult {
|
private renderError(error: string): TemplateResult {
|
||||||
return html`
|
return html`
|
||||||
<p><i class="fa fa-times"></i> ${error}</p>
|
<p><i class="fa fa-times"></i> ${msg("Failed to fetch")}</p>
|
||||||
<p class="subtext">${msg("Failed to fetch")}</p>
|
<p class="subtext">${error}</p>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -146,7 +151,7 @@ export abstract class AdminStatusCard<T> extends AggregateCard {
|
|||||||
this.status
|
this.status
|
||||||
? this.renderStatus(this.status) // Status available
|
? this.renderStatus(this.status) // Status available
|
||||||
: this.error
|
: this.error
|
||||||
? this.renderError(this.error) // Error state
|
? this.renderError(pluckErrorDetail(this.error)) // Error state
|
||||||
: this.renderLoading() // Loading state
|
: this.renderLoading() // Loading state
|
||||||
}
|
}
|
||||||
</p>
|
</p>
|
||||||
|
@ -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 { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
|
||||||
import { EventWithContext } from "@goauthentik/common/events";
|
import { EventWithContext } from "@goauthentik/common/events";
|
||||||
import { actionToLabel } from "@goauthentik/common/labels";
|
import { actionToLabel } from "@goauthentik/common/labels";
|
||||||
@ -10,6 +10,7 @@ import "@goauthentik/elements/buttons/ModalButton";
|
|||||||
import "@goauthentik/elements/buttons/SpinnerButton";
|
import "@goauthentik/elements/buttons/SpinnerButton";
|
||||||
import { PaginatedResponse } from "@goauthentik/elements/table/Table";
|
import { PaginatedResponse } from "@goauthentik/elements/table/Table";
|
||||||
import { Table, TableColumn } 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 { msg } from "@lit/localize";
|
||||||
import { CSSResult, TemplateResult, css, html } from "lit";
|
import { CSSResult, TemplateResult, css, html } from "lit";
|
||||||
@ -38,6 +39,22 @@ export class RecentEventsCard extends Table<Event> {
|
|||||||
return super.styles.concat(
|
return super.styles.concat(
|
||||||
PFCard,
|
PFCard,
|
||||||
css`
|
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 {
|
||||||
--pf-c-card__title--FontFamily: var(
|
--pf-c-card__title--FontFamily: var(
|
||||||
--pf-global--FontFamily--heading--sans-serif
|
--pf-global--FontFamily--heading--sans-serif
|
||||||
@ -45,7 +62,47 @@ export class RecentEventsCard extends Table<Event> {
|
|||||||
--pf-c-card__title--FontSize: var(--pf-global--FontSize--md);
|
--pf-c-card__title--FontSize: var(--pf-global--FontSize--md);
|
||||||
--pf-c-card__title--FontWeight: var(--pf-global--FontWeight--bold);
|
--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;
|
word-break: break-all;
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
@ -68,20 +125,57 @@ export class RecentEventsCard extends Table<Event> {
|
|||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
row(item: EventWithContext): TemplateResult[] {
|
override groupBy(items: Event[]): [SlottedTemplateResult, Event[]][] {
|
||||||
|
const groupedByDay = new Map<string, Event[]>();
|
||||||
|
|
||||||
|
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` <div class="pf-c-content group-header">
|
||||||
|
<div>${getRelativeTime(day)}</div>
|
||||||
|
<small>${day.toLocaleDateString()}</small>
|
||||||
|
</div>`,
|
||||||
|
events,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
row(item: EventWithContext): SlottedTemplateResult[] {
|
||||||
return [
|
return [
|
||||||
html`<div><a href="${`#/events/log/${item.pk}`}">${actionToLabel(item.action)}</a></div>
|
html`<div><a href="${`#/events/log/${item.pk}`}">${actionToLabel(item.action)}</a></div>
|
||||||
<small>${item.app}</small>`,
|
<small class="pf-m-monospace">${item.app}</small>`,
|
||||||
EventUser(item),
|
EventUser(item),
|
||||||
html`<div>${getRelativeTime(item.created)}</div>
|
|
||||||
<small>${item.created.toLocaleString()}</small>`,
|
html`<time datetime="${item.created.toISOString()}" class="pf-c-content">
|
||||||
html` <div>${item.clientIp || msg("-")}</div>
|
<div><small>${item.created.toLocaleTimeString()}</small></div>
|
||||||
<small>${EventGeo(item)}</small>`,
|
</time>`,
|
||||||
|
|
||||||
|
html`<div class="ip-address pf-m-monospace">${item.clientIp || msg("-")}</div>
|
||||||
|
<small class="geographic-location">${formatGeoEvent(item)}</small>`,
|
||||||
|
|
||||||
html`<span>${item.brand?.name || msg("-")}</span>`,
|
html`<span>${item.brand?.name || msg("-")}</span>`,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
renderEmpty(): TemplateResult {
|
renderEmpty(inner?: SlottedTemplateResult): TemplateResult {
|
||||||
|
if (this.error) {
|
||||||
|
return super.renderEmpty(inner);
|
||||||
|
}
|
||||||
|
|
||||||
return super.renderEmpty(
|
return super.renderEmpty(
|
||||||
html`<ak-empty-state header=${msg("No Events found.")}>
|
html`<ak-empty-state header=${msg("No Events found.")}>
|
||||||
<div slot="body">${msg("No matching events could be found.")}</div>
|
<div slot="body">${msg("No matching events could be found.")}</div>
|
||||||
|
@ -30,11 +30,13 @@ export class OutpostStatusChart extends AKChart<SummarizedSyncStatus[]> {
|
|||||||
const api = new OutpostsApi(DEFAULT_CONFIG);
|
const api = new OutpostsApi(DEFAULT_CONFIG);
|
||||||
const outposts = await api.outpostsInstancesList({});
|
const outposts = await api.outpostsInstancesList({});
|
||||||
const outpostStats: SummarizedSyncStatus[] = [];
|
const outpostStats: SummarizedSyncStatus[] = [];
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
outposts.results.map(async (element) => {
|
outposts.results.map(async (element) => {
|
||||||
const health = await api.outpostsInstancesHealthList({
|
const health = await api.outpostsInstancesHealthList({
|
||||||
uuid: element.pk || "",
|
uuid: element.pk || "",
|
||||||
});
|
});
|
||||||
|
|
||||||
const singleStats: SummarizedSyncStatus = {
|
const singleStats: SummarizedSyncStatus = {
|
||||||
unsynced: 0,
|
unsynced: 0,
|
||||||
healthy: 0,
|
healthy: 0,
|
||||||
@ -42,9 +44,11 @@ export class OutpostStatusChart extends AKChart<SummarizedSyncStatus[]> {
|
|||||||
total: health.length,
|
total: health.length,
|
||||||
label: element.name,
|
label: element.name,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (health.length === 0) {
|
if (health.length === 0) {
|
||||||
singleStats.unsynced += 1;
|
singleStats.unsynced += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
health.forEach((h) => {
|
health.forEach((h) => {
|
||||||
if (h.versionOutdated) {
|
if (h.versionOutdated) {
|
||||||
singleStats.failed += 1;
|
singleStats.failed += 1;
|
||||||
@ -52,11 +56,14 @@ export class OutpostStatusChart extends AKChart<SummarizedSyncStatus[]> {
|
|||||||
singleStats.healthy += 1;
|
singleStats.healthy += 1;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
outpostStats.push(singleStats);
|
outpostStats.push(singleStats);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
this.centerText = outposts.pagination.count.toString();
|
this.centerText = outposts.pagination.count.toString();
|
||||||
outpostStats.sort((a, b) => a.label.localeCompare(b.label));
|
outpostStats.sort((a, b) => a.label.localeCompare(b.label));
|
||||||
|
|
||||||
return outpostStats;
|
return outpostStats;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -14,9 +14,9 @@ import { property, query } from "lit/decorators.js";
|
|||||||
import { ValidationError } from "@goauthentik/api";
|
import { ValidationError } from "@goauthentik/api";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
ApplicationTransactionValidationError,
|
||||||
type ApplicationWizardState,
|
type ApplicationWizardState,
|
||||||
type ApplicationWizardStateUpdate,
|
type ApplicationWizardStateUpdate,
|
||||||
ExtendedValidationError,
|
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
export class ApplicationWizardStep extends WizardStep {
|
export class ApplicationWizardStep extends WizardStep {
|
||||||
@ -48,7 +48,7 @@ export class ApplicationWizardStep extends WizardStep {
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected removeErrors(
|
protected removeErrors(
|
||||||
keyToDelete: keyof ExtendedValidationError,
|
keyToDelete: keyof ApplicationTransactionValidationError,
|
||||||
): ValidationError | undefined {
|
): ValidationError | undefined {
|
||||||
if (!this.wizard.errors) {
|
if (!this.wizard.errors) {
|
||||||
return undefined;
|
return undefined;
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import "@goauthentik/admin/applications/wizard/ak-wizard-title.js";
|
import "@goauthentik/admin/applications/wizard/ak-wizard-title.js";
|
||||||
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
|
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
|
||||||
import { EVENT_REFRESH } from "@goauthentik/common/constants";
|
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 { WizardNavigationEvent } from "@goauthentik/components/ak-wizard/events.js";
|
||||||
import { type WizardButton } from "@goauthentik/components/ak-wizard/types";
|
import { type WizardButton } from "@goauthentik/components/ak-wizard/types";
|
||||||
import { CustomEmitterElement } from "@goauthentik/elements/utils/eventEmitter";
|
import { CustomEmitterElement } from "@goauthentik/elements/utils/eventEmitter";
|
||||||
@ -33,7 +33,7 @@ import {
|
|||||||
} from "@goauthentik/api";
|
} from "@goauthentik/api";
|
||||||
|
|
||||||
import { ApplicationWizardStep } from "../ApplicationWizardStep.js";
|
import { ApplicationWizardStep } from "../ApplicationWizardStep.js";
|
||||||
import { ExtendedValidationError, OneOfProvider } from "../types.js";
|
import { ApplicationTransactionValidationError, OneOfProvider } from "../types.js";
|
||||||
import { providerRenderers } from "./SubmitStepOverviewRenderers.js";
|
import { providerRenderers } from "./SubmitStepOverviewRenderers.js";
|
||||||
|
|
||||||
const _submitStates = ["reviewing", "running", "submitted"] as const;
|
const _submitStates = ["reviewing", "running", "submitted"] as const;
|
||||||
@ -131,39 +131,36 @@ export class ApplicationWizardSubmitStep extends CustomEmitterElement(Applicatio
|
|||||||
|
|
||||||
this.state = "running";
|
this.state = "running";
|
||||||
|
|
||||||
return (
|
return new CoreApi(DEFAULT_CONFIG)
|
||||||
new CoreApi(DEFAULT_CONFIG)
|
.coreTransactionalApplicationsUpdate({
|
||||||
.coreTransactionalApplicationsUpdate({
|
transactionApplicationRequest: request,
|
||||||
transactionApplicationRequest: request,
|
})
|
||||||
})
|
.then((_response: TransactionApplicationResponse) => {
|
||||||
.then((_response: TransactionApplicationResponse) => {
|
this.dispatchCustomEvent(EVENT_REFRESH);
|
||||||
this.dispatchCustomEvent(EVENT_REFRESH);
|
this.state = "submitted";
|
||||||
this.state = "submitted";
|
})
|
||||||
})
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
.catch(async (resolution) => {
|
||||||
.catch(async (resolution: any) => {
|
const errors =
|
||||||
const errors = (await parseAPIError(
|
await parseAPIResponseError<ApplicationTransactionValidationError>(resolution);
|
||||||
await resolution,
|
|
||||||
)) as ExtendedValidationError;
|
|
||||||
|
|
||||||
// THIS is a really gross special case; if the user is duplicating the name of
|
// 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.
|
||||||
// an existing provider, the error appears on the `app` (!) error object. We
|
// We have to move that to the `provider.name` error field so it shows up in the right place.
|
||||||
// have to move that to the `provider.name` error field so it shows up in the
|
if (Array.isArray(errors?.app?.provider)) {
|
||||||
// right place.
|
const providerError = errors.app.provider;
|
||||||
if (Array.isArray(errors?.app?.provider)) {
|
errors.provider = errors.provider ?? {};
|
||||||
const providerError = errors.app.provider;
|
errors.provider.name = providerError;
|
||||||
errors.provider = errors.provider ?? {};
|
|
||||||
errors.provider.name = providerError;
|
delete errors.app.provider;
|
||||||
delete errors.app.provider;
|
|
||||||
if (Object.keys(errors.app).length === 0) {
|
if (Object.keys(errors.app).length === 0) {
|
||||||
delete errors.app;
|
delete errors.app;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
this.handleUpdate({ errors });
|
}
|
||||||
this.state = "reviewing";
|
|
||||||
})
|
this.handleUpdate({ errors });
|
||||||
);
|
this.state = "reviewing";
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
override handleButton(button: WizardButton) {
|
override handleButton(button: WizardButton) {
|
||||||
@ -232,7 +229,7 @@ export class ApplicationWizardSubmitStep extends CustomEmitterElement(Applicatio
|
|||||||
const navTo = (step: string) => () => this.dispatchEvent(new WizardNavigationEvent(step));
|
const navTo = (step: string) => () => this.dispatchEvent(new WizardNavigationEvent(step));
|
||||||
const errors = this.wizard.errors;
|
const errors = this.wizard.errors;
|
||||||
return html` <hr class="pf-c-divider" />
|
return html` <hr class="pf-c-divider" />
|
||||||
${match(errors as ExtendedValidationError)
|
${match(errors as ApplicationTransactionValidationError)
|
||||||
.with(
|
.with(
|
||||||
{ app: P.nonNullable },
|
{ app: P.nonNullable },
|
||||||
() =>
|
() =>
|
||||||
|
@ -9,7 +9,7 @@ import { customElement, state } from "lit/decorators.js";
|
|||||||
import { OAuth2ProviderRequest, SourcesApi } from "@goauthentik/api";
|
import { OAuth2ProviderRequest, SourcesApi } from "@goauthentik/api";
|
||||||
import { type OAuth2Provider, type PaginatedOAuthSourceList } 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";
|
import { ApplicationWizardProviderForm } from "./ApplicationWizardProviderForm.js";
|
||||||
|
|
||||||
@customElement("ak-application-wizard-provider-for-oauth")
|
@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) => {
|
const showClientSecretCallback = (show: boolean) => {
|
||||||
this.showClientSecret = show;
|
this.showClientSecret = show;
|
||||||
};
|
};
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
import { APIError } from "@goauthentik/common/errors/network";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
type ApplicationRequest,
|
type ApplicationRequest,
|
||||||
type LDAPProviderRequest,
|
type LDAPProviderRequest,
|
||||||
@ -25,16 +27,31 @@ export type OneOfProvider =
|
|||||||
|
|
||||||
export type ValidationRecord = { [key: string]: string[] };
|
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.
|
* An error that occurs during the creation or modification of an application.
|
||||||
|
*
|
||||||
export type ExtendedValidationError = ValidationError & {
|
* @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;
|
app?: ValidationRecord;
|
||||||
provider?: ValidationRecord;
|
provider?: ValidationRecord;
|
||||||
bindings?: ValidationRecord;
|
bindings?: ValidationRecord;
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
detail?: 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
|
// 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
|
// 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;
|
proxyMode: ProxyMode;
|
||||||
bindings: PolicyBinding[];
|
bindings: PolicyBinding[];
|
||||||
currentBinding: number;
|
currentBinding: number;
|
||||||
errors: ExtendedValidationError;
|
errors: ApplicationTransactionValidationError;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ApplicationWizardStateUpdate {
|
export interface ApplicationWizardStateUpdate {
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import "@goauthentik/admin/events/EventVolumeChart";
|
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 { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
|
||||||
import { EventWithContext } from "@goauthentik/common/events";
|
import { EventWithContext } from "@goauthentik/common/events";
|
||||||
import { actionToLabel } from "@goauthentik/common/labels";
|
import { actionToLabel } from "@goauthentik/common/labels";
|
||||||
@ -80,7 +80,7 @@ export class EventListPage extends TablePage<Event> {
|
|||||||
html`<div>${getRelativeTime(item.created)}</div>
|
html`<div>${getRelativeTime(item.created)}</div>
|
||||||
<small>${item.created.toLocaleString()}</small>`,
|
<small>${item.created.toLocaleString()}</small>`,
|
||||||
html`<div>${item.clientIp || msg("-")}</div>
|
html`<div>${item.clientIp || msg("-")}</div>
|
||||||
<small>${EventGeo(item)}</small>`,
|
<small>${formatGeoEvent(item)}</small>`,
|
||||||
html`<span>${item.brand?.name || msg("-")}</span>`,
|
html`<span>${item.brand?.name || msg("-")}</span>`,
|
||||||
html`<a href="#/events/log/${item.pk}">
|
html`<a href="#/events/log/${item.pk}">
|
||||||
<pf-tooltip position="top" content=${msg("Show details")}>
|
<pf-tooltip position="top" content=${msg("Show details")}>
|
||||||
|
@ -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 { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
|
||||||
import { EventWithContext } from "@goauthentik/common/events";
|
import { EventWithContext } from "@goauthentik/common/events";
|
||||||
import { actionToLabel } from "@goauthentik/common/labels";
|
import { actionToLabel } from "@goauthentik/common/labels";
|
||||||
@ -118,7 +118,7 @@ export class EventViewPage extends AKElement {
|
|||||||
<dd class="pf-c-description-list__description">
|
<dd class="pf-c-description-list__description">
|
||||||
<div class="pf-c-description-list__text">
|
<div class="pf-c-description-list__text">
|
||||||
<div>${this.event.clientIp || msg("-")}</div>
|
<div>${this.event.clientIp || msg("-")}</div>
|
||||||
<small>${EventGeo(this.event)}</small>
|
<small>${formatGeoEvent(this.event)}</small>
|
||||||
</div>
|
</div>
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,27 +1,31 @@
|
|||||||
import { EventWithContext } from "@goauthentik/common/events";
|
import { EventWithContext } from "@goauthentik/common/events";
|
||||||
import { truncate } from "@goauthentik/common/utils";
|
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 { 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;
|
* Given event with a geographical context, format it into a string for display.
|
||||||
if (Object.hasOwn(event.context, "geo")) {
|
*/
|
||||||
geo = event.context.geo as KeyUnknown;
|
export function formatGeoEvent(event: EventWithContext): SlottedTemplateResult {
|
||||||
const parts = [geo.city, geo.country, geo.continent].filter(
|
if (!event.context.geo) return nothing;
|
||||||
(v) => v !== "" && v !== undefined,
|
|
||||||
);
|
const { city, country, continent } = event.context.geo;
|
||||||
return html`${parts.join(", ")}`;
|
|
||||||
}
|
const parts = [city, country, continent].filter(Boolean);
|
||||||
return html``;
|
|
||||||
|
return html`${parts.join(", ")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EventUser(event: EventWithContext, truncateUsername?: number): TemplateResult {
|
export function EventUser(
|
||||||
if (!event.user.username) {
|
event: EventWithContext,
|
||||||
return html`-`;
|
truncateUsername?: number,
|
||||||
}
|
): SlottedTemplateResult {
|
||||||
let body = html``;
|
if (!event.user.username) return html`-`;
|
||||||
|
|
||||||
|
let body: SlottedTemplateResult = nothing;
|
||||||
|
|
||||||
if (event.user.is_anonymous) {
|
if (event.user.is_anonymous) {
|
||||||
body = html`<div>${msg("Anonymous user")}</div>`;
|
body = html`<div>${msg("Anonymous user")}</div>`;
|
||||||
} else {
|
} else {
|
||||||
@ -33,12 +37,14 @@ export function EventUser(event: EventWithContext, truncateUsername?: number): T
|
|||||||
>
|
>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.user.on_behalf_of) {
|
if (event.user.on_behalf_of) {
|
||||||
body = html`${body}<small>
|
return html`${body}<small>
|
||||||
<a href="#/identity/users/${event.user.on_behalf_of.pk}"
|
<a href="#/identity/users/${event.user.on_behalf_of.pk}"
|
||||||
>${msg(str`On behalf of ${event.user.on_behalf_of.username}`)}</a
|
>${msg(str`On behalf of ${event.user.on_behalf_of.username}`)}</a
|
||||||
>
|
>
|
||||||
</small>`;
|
</small>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return body;
|
return body;
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
|
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/components/ak-status-label";
|
||||||
import "@goauthentik/elements/events/LogViewer";
|
import "@goauthentik/elements/events/LogViewer";
|
||||||
import { Form } from "@goauthentik/elements/forms/Form";
|
import { Form } from "@goauthentik/elements/forms/Form";
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import "@goauthentik/admin/common/ak-flow-search/ak-flow-search-no-default";
|
import "@goauthentik/admin/common/ak-flow-search/ak-flow-search-no-default";
|
||||||
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
|
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 { Form } from "@goauthentik/elements/forms/Form";
|
||||||
import "@goauthentik/elements/forms/HorizontalFormElement";
|
import "@goauthentik/elements/forms/HorizontalFormElement";
|
||||||
import "@goauthentik/elements/forms/SearchSelect";
|
import "@goauthentik/elements/forms/SearchSelect";
|
||||||
|
@ -1,5 +1,9 @@
|
|||||||
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
|
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 { first } from "@goauthentik/common/utils";
|
||||||
import "@goauthentik/elements/CodeMirror";
|
import "@goauthentik/elements/CodeMirror";
|
||||||
import { CodeMirrorMode } from "@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 PFTitle from "@patternfly/patternfly/components/Title/title.css";
|
||||||
import PFGrid from "@patternfly/patternfly/layouts/Grid/grid.css";
|
import PFGrid from "@patternfly/patternfly/layouts/Grid/grid.css";
|
||||||
|
|
||||||
import {
|
import { Prompt, PromptChallenge, PromptTypeEnum, StagesApi } from "@goauthentik/api";
|
||||||
Prompt,
|
|
||||||
PromptChallenge,
|
|
||||||
PromptTypeEnum,
|
|
||||||
ResponseError,
|
|
||||||
StagesApi,
|
|
||||||
ValidationError,
|
|
||||||
} from "@goauthentik/api";
|
|
||||||
|
|
||||||
class PreviewStageHost implements StageHost {
|
class PreviewStageHost implements StageHost {
|
||||||
challenge = undefined;
|
challenge = undefined;
|
||||||
@ -78,15 +75,22 @@ export class PromptForm extends ModelForm<Prompt, string> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
try {
|
|
||||||
this.preview = await new StagesApi(DEFAULT_CONFIG).stagesPromptPromptsPreviewCreate({
|
return new StagesApi(DEFAULT_CONFIG)
|
||||||
|
.stagesPromptPromptsPreviewCreate({
|
||||||
promptRequest: prompt,
|
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 {
|
getSuccessMessage(): string {
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
|
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
|
||||||
import { SentryIgnoredError } from "@goauthentik/common/errors";
|
|
||||||
import { deviceTypeName } from "@goauthentik/common/labels";
|
import { deviceTypeName } from "@goauthentik/common/labels";
|
||||||
|
import { SentryIgnoredError } from "@goauthentik/common/sentry";
|
||||||
import { getRelativeTime } from "@goauthentik/common/utils";
|
import { getRelativeTime } from "@goauthentik/common/utils";
|
||||||
import "@goauthentik/elements/forms/DeleteBulkForm";
|
import "@goauthentik/elements/forms/DeleteBulkForm";
|
||||||
import { PaginatedResponse } from "@goauthentik/elements/table/Table";
|
import { PaginatedResponse } from "@goauthentik/elements/table/Table";
|
||||||
|
@ -1,36 +0,0 @@
|
|||||||
import {
|
|
||||||
GenericError,
|
|
||||||
GenericErrorFromJSON,
|
|
||||||
ResponseError,
|
|
||||||
ValidationError,
|
|
||||||
ValidationErrorFromJSON,
|
|
||||||
} from "@goauthentik/api";
|
|
||||||
|
|
||||||
export class SentryIgnoredError extends Error {}
|
|
||||||
export class NotFoundError extends Error {}
|
|
||||||
export class RequestError extends Error {}
|
|
||||||
|
|
||||||
export type APIErrorTypes = ValidationError | GenericError;
|
|
||||||
|
|
||||||
export const HTTP_BAD_REQUEST = 400;
|
|
||||||
export const HTTP_INTERNAL_SERVICE_ERROR = 500;
|
|
||||||
|
|
||||||
export async function parseAPIError(error: Error): Promise<APIErrorTypes> {
|
|
||||||
if (!(error instanceof ResponseError)) {
|
|
||||||
return error;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
error.response.status < HTTP_BAD_REQUEST ||
|
|
||||||
error.response.status >= HTTP_INTERNAL_SERVICE_ERROR
|
|
||||||
) {
|
|
||||||
return error;
|
|
||||||
}
|
|
||||||
const body = await error.response.json();
|
|
||||||
if (error.response.status === 400) {
|
|
||||||
return ValidationErrorFromJSON(body);
|
|
||||||
}
|
|
||||||
if (error.response.status === 403) {
|
|
||||||
return GenericErrorFromJSON(body);
|
|
||||||
}
|
|
||||||
return body;
|
|
||||||
}
|
|
194
web/src/common/errors/network.ts
Normal file
194
web/src/common/errors/network.ts
Normal file
@ -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<string, number>;
|
||||||
|
|
||||||
|
export type HTTPStatusCode = (typeof HTTPStatusCode)[keyof typeof HTTPStatusCode];
|
||||||
|
|
||||||
|
export type HTTPErrorJSONTransformer<T = unknown> = (json: T) => APIError;
|
||||||
|
|
||||||
|
export const HTTPStatusCodeTransformer: Record<number, HTTPErrorJSONTransformer> = {
|
||||||
|
[HTTPStatusCode.BadRequest]: ValidationErrorFromJSON,
|
||||||
|
[HTTPStatusCode.Forbidden]: GenericErrorFromJSON,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type guard to check if a response contains a JSON body.
|
||||||
|
*
|
||||||
|
* This is useful to guard against parsing errors when attempting to read the response body.
|
||||||
|
*/
|
||||||
|
export function isJSONResponse(response: Response): boolean {
|
||||||
|
return Boolean(response.headers.get("content-type")?.includes("application/json"));
|
||||||
|
}
|
||||||
|
|
||||||
|
//#endregion
|
||||||
|
|
||||||
|
//#region API
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An API response error, typically derived from a {@linkcode Response} body.
|
||||||
|
*
|
||||||
|
* @see {@linkcode parseAPIResponseError}
|
||||||
|
*/
|
||||||
|
export type APIError = ValidationError | GenericError;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given an error-like object, attempts to normalize it into a {@linkcode GenericError}
|
||||||
|
* suitable for display to the user.
|
||||||
|
*/
|
||||||
|
export function createSyntheticGenericError(detail?: string): GenericError {
|
||||||
|
const syntheticGenericError: GenericError = {
|
||||||
|
detail: detail || ResponseErrorMessages[HTTPStatusCode.InternalServiceError].reason,
|
||||||
|
};
|
||||||
|
|
||||||
|
return syntheticGenericError;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An error that contains a native response object.
|
||||||
|
*
|
||||||
|
* @see {@linkcode isResponseErrorLike} to determine if an error contains a response object.
|
||||||
|
*/
|
||||||
|
export type APIErrorWithResponse = Pick<ResponseError, "response" | "message">;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type guard to check if an error contains a HTTP {@linkcode Response} object.
|
||||||
|
*
|
||||||
|
* @see {@linkcode 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<number, ResponseErrorDescriptor> = {
|
||||||
|
[HTTPStatusCode.BadRequest]: {
|
||||||
|
headline: "Bad request",
|
||||||
|
reason: "The server did not understand the request",
|
||||||
|
},
|
||||||
|
[HTTPStatusCode.InternalServiceError]: {
|
||||||
|
headline: "Internal server error",
|
||||||
|
reason: "An unexpected error occurred",
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composes a human readable error message from a {@linkcode ResponseErrorDescriptor}.
|
||||||
|
*
|
||||||
|
* Note that this is kept separate from localization to lower the complexity of the error handling code.
|
||||||
|
*/
|
||||||
|
export function composeResponseErrorDescriptor(descriptor: ResponseErrorDescriptor): string {
|
||||||
|
return `${descriptor.headline}: ${descriptor.reason}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempts to pluck a human readable error message from a {@linkcode ValidationError}.
|
||||||
|
*/
|
||||||
|
export function pluckErrorDetail(validationError: ValidationError, fallback?: string): string;
|
||||||
|
/**
|
||||||
|
* Attempts to pluck a human readable error message from a {@linkcode GenericError}.
|
||||||
|
*/
|
||||||
|
export function pluckErrorDetail(genericError: GenericError, fallback?: string): string;
|
||||||
|
/**
|
||||||
|
* Attempts to pluck a human readable error message from an `Error` object.
|
||||||
|
*/
|
||||||
|
export function pluckErrorDetail(error: Error, fallback?: string): string;
|
||||||
|
/**
|
||||||
|
* Attempts to pluck a human readable error message from an error-like object.
|
||||||
|
*
|
||||||
|
* Prioritizes the `detail` key, then the `message` key.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
export function pluckErrorDetail(errorLike: unknown, fallback?: string): string;
|
||||||
|
export function pluckErrorDetail(errorLike: unknown, fallback?: string): string {
|
||||||
|
fallback ||= composeResponseErrorDescriptor(
|
||||||
|
ResponseErrorMessages[HTTPStatusCode.InternalServiceError],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!errorLike || typeof errorLike !== "object") {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("detail" in errorLike && typeof errorLike.detail === "string") {
|
||||||
|
return errorLike.detail;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("message" in errorLike && typeof errorLike.message === "string") {
|
||||||
|
return errorLike.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given API error, parses the response body and transforms it into a {@linkcode APIError}.
|
||||||
|
*/
|
||||||
|
export async function parseAPIResponseError<T extends APIError = APIError>(
|
||||||
|
error: unknown,
|
||||||
|
): Promise<T> {
|
||||||
|
if (!isResponseErrorLike(error)) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
|
||||||
|
return createSyntheticGenericError(message) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { response, message } = error;
|
||||||
|
|
||||||
|
if (!isJSONResponse(response)) {
|
||||||
|
return createSyntheticGenericError(message || response.statusText) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
return response
|
||||||
|
.json()
|
||||||
|
.then((body) => {
|
||||||
|
const transformer = HTTPStatusCodeTransformer[response.status];
|
||||||
|
const transformedBody = transformer ? transformer(body) : body;
|
||||||
|
|
||||||
|
return transformedBody as unknown as T;
|
||||||
|
})
|
||||||
|
.catch((transformerError) => {
|
||||||
|
console.error("Failed to parse response error body", transformerError);
|
||||||
|
|
||||||
|
return createSyntheticGenericError(message || response.statusText) as T;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
//#endregion
|
0
web/src/common/errors/sentry.ts
Normal file
0
web/src/common/errors/sentry.ts
Normal file
@ -8,13 +8,10 @@ export interface EventUser {
|
|||||||
is_anonymous?: boolean;
|
is_anonymous?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EventContext {
|
export interface EventGeo {
|
||||||
[key: string]: EventContext | EventModel | string | number | string[];
|
city?: string;
|
||||||
}
|
country?: string;
|
||||||
|
continent?: string;
|
||||||
export interface EventWithContext extends Event {
|
|
||||||
user: EventUser;
|
|
||||||
context: EventContext;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EventModel {
|
export interface EventModel {
|
||||||
@ -28,3 +25,13 @@ export interface EventRequest {
|
|||||||
path: string;
|
path: string;
|
||||||
method: 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;
|
||||||
|
}
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { VERSION } from "@goauthentik/common/constants";
|
import { VERSION } from "@goauthentik/common/constants";
|
||||||
import { SentryIgnoredError } from "@goauthentik/common/errors";
|
import { SentryIgnoredError } from "@goauthentik/common/sentry";
|
||||||
|
|
||||||
export interface PlexPinResponse {
|
export interface PlexPinResponse {
|
||||||
// Only has the fields we care about
|
// Only has the fields we care about
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
import { config } from "@goauthentik/common/api/config";
|
import { config } from "@goauthentik/common/api/config";
|
||||||
import { VERSION } from "@goauthentik/common/constants";
|
import { VERSION } from "@goauthentik/common/constants";
|
||||||
import { SentryIgnoredError } from "@goauthentik/common/errors";
|
|
||||||
import { me } from "@goauthentik/common/users";
|
import { me } from "@goauthentik/common/users";
|
||||||
import {
|
import {
|
||||||
ErrorEvent,
|
ErrorEvent,
|
||||||
@ -16,69 +15,85 @@ import { CapabilitiesEnum, Config, ResponseError } from "@goauthentik/api";
|
|||||||
export const TAG_SENTRY_COMPONENT = "authentik.component";
|
export const TAG_SENTRY_COMPONENT = "authentik.component";
|
||||||
export const TAG_SENTRY_CAPABILITIES = "authentik.capabilities";
|
export const TAG_SENTRY_CAPABILITIES = "authentik.capabilities";
|
||||||
|
|
||||||
export async function configureSentry(canDoPpi = false): Promise<Config> {
|
/**
|
||||||
const cfg = await config();
|
* A generic error that can be thrown without triggering Sentry's reporting.
|
||||||
if (cfg.errorReporting.enabled) {
|
*/
|
||||||
init({
|
export class SentryIgnoredError extends Error {}
|
||||||
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<ErrorEvent | null> | 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 });
|
/**
|
||||||
}
|
* Configure Sentry with the given configuration.
|
||||||
if (cfg.errorReporting.sendPii && canDoPpi) {
|
*
|
||||||
me().then((user) => {
|
* @param canSendPII Whether the user can send personally identifiable information.
|
||||||
setUser({ email: user.user.email });
|
*/
|
||||||
console.debug("authentik/config: Sentry with PII enabled.");
|
export async function configureSentry(canSendPII = false): Promise<Config> {
|
||||||
});
|
const cfg = await config();
|
||||||
} else {
|
|
||||||
console.debug("authentik/config: Sentry enabled.");
|
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<ErrorEvent | null> | 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;
|
return cfg;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -56,6 +56,19 @@ html > form > input {
|
|||||||
vertical-align: middle;
|
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 {
|
||||||
--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");
|
--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");
|
||||||
}
|
}
|
||||||
|
@ -4,6 +4,7 @@
|
|||||||
--pf-c-page__main-section--m-light--BackgroundColor: var(--ak-dark-background-darker);
|
--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--link--Color: var(--ak-dark-foreground-link) !important;
|
||||||
--pf-global--BorderColor--100: var(--ak-dark-background-lighter) !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 {
|
body {
|
||||||
background-color: var(--ak-dark-background) !important;
|
background-color: var(--ak-dark-background) !important;
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { SentryIgnoredError } from "@goauthentik/common/errors";
|
import { SentryIgnoredError } from "@goauthentik/common/sentry";
|
||||||
|
|
||||||
import { CSSResult, css } from "lit";
|
import { CSSResult, css } from "lit";
|
||||||
|
|
||||||
|
@ -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 { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
|
||||||
import { EventWithContext } from "@goauthentik/common/events";
|
import { EventWithContext } from "@goauthentik/common/events";
|
||||||
import { actionToLabel } from "@goauthentik/common/labels";
|
import { actionToLabel } from "@goauthentik/common/labels";
|
||||||
@ -76,7 +76,7 @@ export class ObjectChangelog extends Table<Event> {
|
|||||||
<small>${item.created.toLocaleString()}</small>`,
|
<small>${item.created.toLocaleString()}</small>`,
|
||||||
html`<div>${item.clientIp || msg("-")}</div>
|
html`<div>${item.clientIp || msg("-")}</div>
|
||||||
|
|
||||||
<small>${EventGeo(item)}</small>`,
|
<small>${formatGeoEvent(item)}</small>`,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { AKElement } from "@goauthentik/elements/Base";
|
import { AKElement } from "@goauthentik/elements/Base";
|
||||||
|
import { SlottedTemplateResult } from "@goauthentik/elements/types";
|
||||||
|
|
||||||
import { CSSResult, TemplateResult, css, html, nothing } from "lit";
|
import { CSSResult, TemplateResult, css, html, nothing } from "lit";
|
||||||
import { customElement, property } from "lit/decorators.js";
|
import { customElement, property } from "lit/decorators.js";
|
||||||
@ -96,24 +97,30 @@ export class AggregateCard extends AKElement implements IAggregateCard {
|
|||||||
.pf-c-card__footer {
|
.pf-c-card__footer {
|
||||||
padding-bottom: 0;
|
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`<slot></slot>`;
|
return html`<slot></slot>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
renderHeaderLink(): TemplateResult {
|
renderHeaderLink() {
|
||||||
return html`${this.headerLink
|
if (!this.headerLink) return nothing;
|
||||||
? html`<a href="${this.headerLink}">
|
|
||||||
<i class="fa fa-link"> </i>
|
return html`<a href="${this.headerLink}">
|
||||||
</a>`
|
<i class="fa fa-link"></i>
|
||||||
: ""}`;
|
</a>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
renderHeader(): TemplateResult {
|
renderHeader(): SlottedTemplateResult {
|
||||||
return html`${this.header ? this.header : ""}`;
|
return this.header ? html`${this.header}` : nothing;
|
||||||
}
|
}
|
||||||
|
|
||||||
render(): TemplateResult {
|
render(): TemplateResult {
|
||||||
|
@ -1,4 +1,9 @@
|
|||||||
import { EVENT_REFRESH, EVENT_THEME_CHANGE } from "@goauthentik/common/constants";
|
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 { getRelativeTime } from "@goauthentik/common/utils";
|
||||||
import { AKElement } from "@goauthentik/elements/Base";
|
import { AKElement } from "@goauthentik/elements/Base";
|
||||||
import "@goauthentik/elements/EmptyState";
|
import "@goauthentik/elements/EmptyState";
|
||||||
@ -23,7 +28,7 @@ import { msg } from "@lit/localize";
|
|||||||
import { CSSResult, TemplateResult, css, html } from "lit";
|
import { CSSResult, TemplateResult, css, html } from "lit";
|
||||||
import { property, state } from "lit/decorators.js";
|
import { property, state } from "lit/decorators.js";
|
||||||
|
|
||||||
import { ResponseError, UiThemeEnum } from "@goauthentik/api";
|
import { UiThemeEnum } from "@goauthentik/api";
|
||||||
|
|
||||||
Chart.register(Legend, Tooltip);
|
Chart.register(Legend, Tooltip);
|
||||||
Chart.register(LineController, BarController, DoughnutController);
|
Chart.register(LineController, BarController, DoughnutController);
|
||||||
@ -67,7 +72,7 @@ export abstract class AKChart<T> extends AKElement {
|
|||||||
chart?: Chart;
|
chart?: Chart;
|
||||||
|
|
||||||
@state()
|
@state()
|
||||||
error?: ResponseError;
|
error?: APIError;
|
||||||
|
|
||||||
@property()
|
@property()
|
||||||
centerText?: string;
|
centerText?: string;
|
||||||
@ -79,6 +84,9 @@ export abstract class AKChart<T> extends AKElement {
|
|||||||
css`
|
css`
|
||||||
.container {
|
.container {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -92,6 +100,7 @@ export abstract class AKChart<T> extends AKElement {
|
|||||||
width: 100px;
|
width: 100px;
|
||||||
height: 100px;
|
height: 100px;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
|
cursor: crosshair;
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
];
|
];
|
||||||
@ -136,19 +145,24 @@ export abstract class AKChart<T> extends AKElement {
|
|||||||
this.apiRequest()
|
this.apiRequest()
|
||||||
.then((r) => {
|
.then((r) => {
|
||||||
const canvas = this.shadowRoot?.querySelector<HTMLCanvasElement>("canvas");
|
const canvas = this.shadowRoot?.querySelector<HTMLCanvasElement>("canvas");
|
||||||
|
|
||||||
if (!canvas) {
|
if (!canvas) {
|
||||||
console.warn("Failed to get canvas element");
|
console.warn("Failed to get canvas element");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ctx = canvas.getContext("2d");
|
const ctx = canvas.getContext("2d");
|
||||||
|
|
||||||
if (!ctx) {
|
if (!ctx) {
|
||||||
console.warn("failed to get 2d context");
|
console.warn("failed to get 2d context");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.chart = this.configureChart(r, ctx);
|
this.chart = this.configureChart(r, ctx);
|
||||||
})
|
})
|
||||||
.catch((exc: ResponseError) => {
|
.catch(async (error) => {
|
||||||
this.error = exc;
|
const parsedError = await parseAPIResponseError(error);
|
||||||
|
this.error = parsedError;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -214,7 +228,7 @@ export abstract class AKChart<T> extends AKElement {
|
|||||||
${this.error
|
${this.error
|
||||||
? html`
|
? html`
|
||||||
<ak-empty-state header="${msg("Failed to fetch data.")}" icon="fa-times">
|
<ak-empty-state header="${msg("Failed to fetch data.")}" icon="fa-times">
|
||||||
<p slot="body">${this.error.response.statusText}</p>
|
<p slot="body">${pluckErrorDetail(this.error)}</p>
|
||||||
</ak-empty-state>
|
</ak-empty-state>
|
||||||
`
|
`
|
||||||
: html`${this.chart
|
: html`${this.chart
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { EVENT_REFRESH } from "@goauthentik/common/constants";
|
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 { MessageLevel } from "@goauthentik/common/messages";
|
||||||
import { camelToSnake, convertToSlug, dateToUTC } from "@goauthentik/common/utils";
|
import { camelToSnake, convertToSlug, dateToUTC } from "@goauthentik/common/utils";
|
||||||
import { AKElement } from "@goauthentik/elements/Base";
|
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 PFSwitch from "@patternfly/patternfly/components/Switch/switch.css";
|
||||||
import PFBase from "@patternfly/patternfly/patternfly-base.css";
|
import PFBase from "@patternfly/patternfly/patternfly-base.css";
|
||||||
|
|
||||||
import { ResponseError, ValidationError, instanceOfValidationError } from "@goauthentik/api";
|
import { instanceOfValidationError } from "@goauthentik/api";
|
||||||
|
|
||||||
export class APIError extends Error {
|
|
||||||
constructor(public response: ValidationError) {
|
|
||||||
super();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface KeyUnknown {
|
export interface KeyUnknown {
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
@ -285,73 +279,82 @@ export abstract class Form<T> extends AKElement {
|
|||||||
* field-levels errors to the fields, and send the rest of them to the Notifications.
|
* field-levels errors to the fields, and send the rest of them to the Notifications.
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
async submit(ev: Event): Promise<unknown | undefined> {
|
async submit(event: Event): Promise<unknown | undefined> {
|
||||||
ev.preventDefault();
|
event.preventDefault();
|
||||||
try {
|
|
||||||
const data = this.serializeForm();
|
const data = this.serializeForm();
|
||||||
if (!data) {
|
if (!data) return;
|
||||||
return;
|
|
||||||
}
|
return this.send(data)
|
||||||
const response = await this.send(data);
|
.then((response) => {
|
||||||
showMessage({
|
showMessage({
|
||||||
level: MessageLevel.success,
|
level: MessageLevel.success,
|
||||||
message: this.getSuccessMessage(),
|
message: this.getSuccessMessage(),
|
||||||
});
|
});
|
||||||
this.dispatchEvent(
|
|
||||||
new CustomEvent(EVENT_REFRESH, {
|
this.dispatchEvent(
|
||||||
bubbles: true,
|
new CustomEvent(EVENT_REFRESH, {
|
||||||
composed: true,
|
bubbles: true,
|
||||||
}),
|
composed: true,
|
||||||
);
|
}),
|
||||||
return response;
|
);
|
||||||
} catch (ex) {
|
|
||||||
if (ex instanceof ResponseError) {
|
return response;
|
||||||
let errorMessage = ex.response.statusText;
|
})
|
||||||
const error = await parseAPIError(ex);
|
.catch(async (error) => {
|
||||||
if (instanceOfValidationError(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
|
// assign all input-related errors to their elements
|
||||||
const elements =
|
const elements =
|
||||||
this.shadowRoot?.querySelectorAll<HorizontalFormElement>(
|
this.shadowRoot?.querySelectorAll<HorizontalFormElement>(
|
||||||
"ak-form-element-horizontal",
|
"ak-form-element-horizontal",
|
||||||
) || [];
|
) || [];
|
||||||
|
|
||||||
elements.forEach((element) => {
|
elements.forEach((element) => {
|
||||||
element.requestUpdate();
|
element.requestUpdate();
|
||||||
|
|
||||||
const elementName = element.name;
|
const elementName = element.name;
|
||||||
if (!elementName) {
|
if (!elementName) return;
|
||||||
return;
|
|
||||||
}
|
const snakeProperty = camelToSnake(elementName);
|
||||||
if (camelToSnake(elementName) in error) {
|
|
||||||
element.errorMessages = (error as ValidationError)[
|
if (snakeProperty in parsedError) {
|
||||||
camelToSnake(elementName)
|
element.errorMessages = parsedError[snakeProperty];
|
||||||
];
|
|
||||||
element.invalid = true;
|
element.invalid = true;
|
||||||
} else {
|
} else {
|
||||||
element.errorMessages = [];
|
element.errorMessages = [];
|
||||||
element.invalid = false;
|
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.");
|
errorMessage = msg("Invalid update request.");
|
||||||
|
|
||||||
// Only change the message when we have `detail`.
|
// Only change the message when we have `detail`.
|
||||||
// Everything else is handled in the form.
|
// Everything else is handled in the form.
|
||||||
if ("detail" in (error as ValidationError)) {
|
if ("detail" in parsedError) {
|
||||||
errorMessage = (error as ValidationError).detail;
|
errorMessage = parsedError.detail;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
showMessage({
|
showMessage({
|
||||||
message: errorMessage,
|
message: errorMessage,
|
||||||
level: MessageLevel.error,
|
level: MessageLevel.error,
|
||||||
});
|
});
|
||||||
}
|
|
||||||
if (ex instanceof PreventFormSubmit && ex.element) {
|
// Rethrow the error so the form doesn't close.
|
||||||
ex.element.errorMessages = [ex.message];
|
throw error;
|
||||||
ex.element.invalid = true;
|
});
|
||||||
}
|
|
||||||
// rethrow the error so the form doesn't close
|
|
||||||
throw ex;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
renderFormWrapper(): TemplateResult {
|
renderFormWrapper(): TemplateResult {
|
||||||
|
@ -1,5 +1,9 @@
|
|||||||
import { EVENT_REFRESH } from "@goauthentik/common/constants";
|
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 { groupBy } from "@goauthentik/common/utils";
|
||||||
import { AkControlElement } from "@goauthentik/elements/AkControlElement.js";
|
import { AkControlElement } from "@goauthentik/elements/AkControlElement.js";
|
||||||
import { PreventFormSubmit } from "@goauthentik/elements/forms/helpers";
|
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 PFBase from "@patternfly/patternfly/patternfly-base.css";
|
||||||
|
|
||||||
import { ResponseError } from "@goauthentik/api";
|
|
||||||
|
|
||||||
import "./ak-search-select-loading-indicator.js";
|
import "./ak-search-select-loading-indicator.js";
|
||||||
import "./ak-search-select-view.js";
|
import "./ak-search-select-view.js";
|
||||||
import { SearchSelectView } from "./ak-search-select-view.js";
|
import { SearchSelectView } from "./ak-search-select-view.js";
|
||||||
@ -99,7 +101,7 @@ export class SearchSelectBase<T> extends AkControlElement<string> implements ISe
|
|||||||
isFetchingData = false;
|
isFetchingData = false;
|
||||||
|
|
||||||
@state()
|
@state()
|
||||||
error?: APIErrorTypes;
|
error?: APIError;
|
||||||
|
|
||||||
public toForm(): string {
|
public toForm(): string {
|
||||||
if (!this.objects) {
|
if (!this.objects) {
|
||||||
@ -128,23 +130,26 @@ export class SearchSelectBase<T> extends AkControlElement<string> implements ISe
|
|||||||
}
|
}
|
||||||
this.isFetchingData = true;
|
this.isFetchingData = true;
|
||||||
this.dispatchEvent(new Event("loading"));
|
this.dispatchEvent(new Event("loading"));
|
||||||
|
|
||||||
return this.fetchObjects(this.query)
|
return this.fetchObjects(this.query)
|
||||||
.then((objects) => {
|
.then((nextObjects) => {
|
||||||
objects.forEach((obj) => {
|
nextObjects.forEach((obj) => {
|
||||||
if (this.selected && this.selected(obj, objects || [])) {
|
if (this.selected && this.selected(obj, nextObjects || [])) {
|
||||||
this.selectedObject = obj;
|
this.selectedObject = obj;
|
||||||
this.dispatchChangeEvent(this.selectedObject);
|
this.dispatchChangeEvent(this.selectedObject);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
this.objects = objects;
|
|
||||||
|
this.objects = nextObjects;
|
||||||
this.isFetchingData = false;
|
this.isFetchingData = false;
|
||||||
})
|
})
|
||||||
.catch((exc: ResponseError) => {
|
.catch(async (error) => {
|
||||||
this.isFetchingData = false;
|
this.isFetchingData = false;
|
||||||
this.objects = undefined;
|
this.objects = undefined;
|
||||||
parseAPIError(exc).then((err) => {
|
|
||||||
this.error = err;
|
const parsedError = await parseAPIResponseError(error);
|
||||||
});
|
|
||||||
|
this.error = parsedError;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -233,7 +238,9 @@ export class SearchSelectBase<T> extends AkControlElement<string> implements ISe
|
|||||||
|
|
||||||
public override render() {
|
public override render() {
|
||||||
if (this.error) {
|
if (this.error) {
|
||||||
return html`<em>${msg("Failed to fetch objects: ")} ${this.error.detail}</em>`;
|
return html`<em
|
||||||
|
>${msg("Failed to fetch objects: ")} ${pluckErrorDetail(this.error)}</em
|
||||||
|
>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// `this.objects` is both a container and a sigil; if it is in the `undefined` state, it's a
|
// `this.objects` is both a container and a sigil; if it is in the `undefined` state, it's a
|
||||||
|
@ -3,7 +3,7 @@ import {
|
|||||||
EVENT_WS_MESSAGE,
|
EVENT_WS_MESSAGE,
|
||||||
WS_MSG_TYPE_MESSAGE,
|
WS_MSG_TYPE_MESSAGE,
|
||||||
} from "@goauthentik/common/constants";
|
} from "@goauthentik/common/constants";
|
||||||
import { SentryIgnoredError } from "@goauthentik/common/errors";
|
import { SentryIgnoredError } from "@goauthentik/common/sentry";
|
||||||
import { WSMessage } from "@goauthentik/common/ws";
|
import { WSMessage } from "@goauthentik/common/ws";
|
||||||
import { AKElement } from "@goauthentik/elements/Base";
|
import { AKElement } from "@goauthentik/elements/Base";
|
||||||
import "@goauthentik/elements/messages/Message";
|
import "@goauthentik/elements/messages/Message";
|
||||||
|
@ -1,5 +1,9 @@
|
|||||||
import { EVENT_REFRESH } from "@goauthentik/common/constants";
|
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 { uiConfig } from "@goauthentik/common/ui/config";
|
||||||
import { groupBy } from "@goauthentik/common/utils";
|
import { groupBy } from "@goauthentik/common/utils";
|
||||||
import { AKElement } from "@goauthentik/elements/Base";
|
import { AKElement } from "@goauthentik/elements/Base";
|
||||||
@ -10,9 +14,10 @@ import "@goauthentik/elements/chips/ChipGroup";
|
|||||||
import { getURLParam, updateURLParams } from "@goauthentik/elements/router/RouteMatch";
|
import { getURLParam, updateURLParams } from "@goauthentik/elements/router/RouteMatch";
|
||||||
import "@goauthentik/elements/table/TablePagination";
|
import "@goauthentik/elements/table/TablePagination";
|
||||||
import "@goauthentik/elements/table/TableSearch";
|
import "@goauthentik/elements/table/TableSearch";
|
||||||
|
import { SlottedTemplateResult } from "@goauthentik/elements/types";
|
||||||
|
|
||||||
import { msg } from "@lit/localize";
|
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 { property, state } from "lit/decorators.js";
|
||||||
import { classMap } from "lit/directives/class-map.js";
|
import { classMap } from "lit/directives/class-map.js";
|
||||||
import { ifDefined } from "lit/directives/if-defined.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 PFBullseye from "@patternfly/patternfly/layouts/Bullseye/bullseye.css";
|
||||||
import PFBase from "@patternfly/patternfly/patternfly-base.css";
|
import PFBase from "@patternfly/patternfly/patternfly-base.css";
|
||||||
|
|
||||||
import { Pagination, ResponseError } from "@goauthentik/api";
|
import { Pagination } from "@goauthentik/api";
|
||||||
|
|
||||||
export interface TableLike {
|
export interface TableLike {
|
||||||
order?: string;
|
order?: string;
|
||||||
@ -98,7 +103,7 @@ export interface PaginatedResponse<T> {
|
|||||||
export abstract class Table<T> extends AKElement implements TableLike {
|
export abstract class Table<T> extends AKElement implements TableLike {
|
||||||
abstract apiEndpoint(): Promise<PaginatedResponse<T>>;
|
abstract apiEndpoint(): Promise<PaginatedResponse<T>>;
|
||||||
abstract columns(): TableColumn[];
|
abstract columns(): TableColumn[];
|
||||||
abstract row(item: T): TemplateResult[];
|
abstract row(item: T): SlottedTemplateResult[];
|
||||||
|
|
||||||
private isLoading = false;
|
private isLoading = false;
|
||||||
|
|
||||||
@ -106,12 +111,12 @@ export abstract class Table<T> extends AKElement implements TableLike {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
renderExpanded(_item: T): SlottedTemplateResult {
|
||||||
renderExpanded(item: T): TemplateResult {
|
|
||||||
if (this.expandable) {
|
if (this.expandable) {
|
||||||
throw new Error("Expandable is enabled but renderExpanded is not overridden!");
|
throw new Error("Expandable is enabled but renderExpanded is not overridden!");
|
||||||
}
|
}
|
||||||
return html``;
|
|
||||||
|
return nothing;
|
||||||
}
|
}
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
@ -120,10 +125,11 @@ export abstract class Table<T> extends AKElement implements TableLike {
|
|||||||
@property({ type: Number })
|
@property({ type: Number })
|
||||||
page = getURLParam("tablePage", 1);
|
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
|
* @prop
|
||||||
* stale data is cleared out when the API returns a new list minus the deleted entries.
|
|
||||||
*/
|
*/
|
||||||
@property({ attribute: "clear-on-refresh", type: Boolean, reflect: true })
|
@property({ attribute: "clear-on-refresh", type: Boolean, reflect: true })
|
||||||
clearOnRefresh = false;
|
clearOnRefresh = false;
|
||||||
@ -162,7 +168,7 @@ export abstract class Table<T> extends AKElement implements TableLike {
|
|||||||
expandedElements: T[] = [];
|
expandedElements: T[] = [];
|
||||||
|
|
||||||
@state()
|
@state()
|
||||||
error?: APIErrorTypes;
|
error?: APIError;
|
||||||
|
|
||||||
static get styles(): CSSResult[] {
|
static get styles(): CSSResult[] {
|
||||||
return [
|
return [
|
||||||
@ -187,6 +193,12 @@ export abstract class Table<T> extends AKElement implements TableLike {
|
|||||||
.pf-c-toolbar__item .pf-c-input-group {
|
.pf-c-toolbar__item .pf-c-input-group {
|
||||||
padding: 0 var(--pf-global--spacer--sm);
|
padding: 0 var(--pf-global--spacer--sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pf-c-table {
|
||||||
|
--pf-c-table--m-striped__tr--BackgroundColor: var(
|
||||||
|
--pf-global--BackgroundColor--dark-300
|
||||||
|
);
|
||||||
|
}
|
||||||
`,
|
`,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@ -213,64 +225,74 @@ export abstract class Table<T> extends AKElement implements TableLike {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public groupBy(items: T[]): [string, T[]][] {
|
public groupBy(items: T[]): [SlottedTemplateResult, T[]][] {
|
||||||
return groupBy(items, () => {
|
return groupBy(items, () => {
|
||||||
return "";
|
return "";
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async fetch(): Promise<void> {
|
public async fetch(): Promise<void> {
|
||||||
if (this.isLoading) {
|
if (this.isLoading) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.isLoading = true;
|
this.isLoading = true;
|
||||||
try {
|
|
||||||
this.data = await this.apiEndpoint();
|
return this.apiEndpoint()
|
||||||
this.error = undefined;
|
.then((data) => {
|
||||||
this.page = this.data.pagination.current;
|
this.data = data;
|
||||||
const newExpanded: T[] = [];
|
this.error = undefined;
|
||||||
this.data.results.forEach((res) => {
|
|
||||||
const jsonRes = JSON.stringify(res);
|
this.page = this.data.pagination.current;
|
||||||
// So because we're dealing with complex objects here, we can't use indexOf
|
const newExpanded: T[] = [];
|
||||||
// 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
|
this.data.results.forEach((res) => {
|
||||||
// Hence we check if the objects have `pk` attributes set (as most models do)
|
const jsonRes = JSON.stringify(res);
|
||||||
// and compare that instead, which will be much faster.
|
// So because we're dealing with complex objects here, we can't use indexOf
|
||||||
let comp = (item: T) => {
|
// since it checks strict equality, and we also can't easily check in findIndex()
|
||||||
return JSON.stringify(item) === jsonRes;
|
// 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)
|
||||||
if (Object.hasOwn(res as object, "pk")) {
|
// and compare that instead, which will be much faster.
|
||||||
comp = (item: T) => {
|
let comp = (item: T) => {
|
||||||
return (
|
return JSON.stringify(item) === jsonRes;
|
||||||
(item as unknown as { pk: string | number }).pk ===
|
|
||||||
(res as unknown as { pk: string | number }).pk
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
}
|
|
||||||
const expandedIndex = this.expandedElements.findIndex(comp);
|
if (Object.hasOwn(res as object, "pk")) {
|
||||||
if (expandedIndex > -1) {
|
comp = (item: T) => {
|
||||||
newExpanded.push(res);
|
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 {
|
private renderLoading(): TemplateResult {
|
||||||
return html`<tr role="row">
|
return html`<tr role="row">
|
||||||
<td role="cell" colspan="25">
|
<td role="cell" colspan="25">
|
||||||
<div class="pf-l-bullseye">
|
<div class="pf-l-bullseye">
|
||||||
<ak-empty-state loading header=${msg("Loading")}> </ak-empty-state>
|
<ak-empty-state loading header=${msg("Loading")}></ak-empty-state>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
renderEmpty(inner?: TemplateResult): TemplateResult {
|
renderEmpty(inner?: SlottedTemplateResult): TemplateResult {
|
||||||
return html`<tbody role="rowgroup">
|
return html`<tbody role="rowgroup">
|
||||||
<tr role="row">
|
<tr role="row">
|
||||||
<td role="cell" colspan="8">
|
<td role="cell" colspan="8">
|
||||||
@ -285,18 +307,16 @@ export abstract class Table<T> extends AKElement implements TableLike {
|
|||||||
</tbody>`;
|
</tbody>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
renderObjectCreate(): TemplateResult {
|
renderObjectCreate(): SlottedTemplateResult {
|
||||||
return html``;
|
return nothing;
|
||||||
}
|
}
|
||||||
|
|
||||||
renderError(): TemplateResult {
|
renderError(): SlottedTemplateResult {
|
||||||
return this.error
|
if (!this.error) return nothing;
|
||||||
? html`<ak-empty-state header="${msg("Failed to fetch objects.")}" icon="fa-times">
|
|
||||||
${this.error instanceof ResponseError
|
return html`<ak-empty-state header="${msg("Failed to fetch objects.")}" icon="fa-ban">
|
||||||
? html` <div slot="body">${this.error.message}</div> `
|
<div slot="body">${pluckErrorDetail(this.error)}</div>
|
||||||
: html`<div slot="body">${this.error.detail}</div>`}
|
</ak-empty-state>`;
|
||||||
</ak-empty-state>`
|
|
||||||
: html``;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private renderRows(): TemplateResult[] | undefined {
|
private renderRows(): TemplateResult[] | undefined {
|
||||||
@ -404,15 +424,17 @@ export abstract class Table<T> extends AKElement implements TableLike {
|
|||||||
}
|
}
|
||||||
: itemSelectHandler}
|
: itemSelectHandler}
|
||||||
>
|
>
|
||||||
${this.checkbox ? renderCheckbox() : html``}
|
${this.checkbox ? renderCheckbox() : nothing}
|
||||||
${this.expandable ? renderExpansion() : html``}
|
${this.expandable ? renderExpansion() : nothing}
|
||||||
${this.row(item).map((col) => {
|
${this.row(item).map((column, columnIndex) => {
|
||||||
return html`<td role="cell">${col}</td>`;
|
return html`<td data-column-index="${columnIndex}" role="cell">
|
||||||
|
${column}
|
||||||
|
</td>`;
|
||||||
})}
|
})}
|
||||||
</tr>
|
</tr>
|
||||||
<tr class="pf-c-table__expandable-row ${classMap(expandedClass)}" role="row">
|
<tr class="pf-c-table__expandable-row ${classMap(expandedClass)}" role="row">
|
||||||
<td></td>
|
<td></td>
|
||||||
${this.expandedElements.includes(item) ? this.renderExpanded(item) : html``}
|
${this.expandedElements.includes(item) ? this.renderExpanded(item) : nothing}
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>`;
|
</tbody>`;
|
||||||
});
|
});
|
||||||
@ -430,12 +452,12 @@ export abstract class Table<T> extends AKElement implements TableLike {
|
|||||||
>`;
|
>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
renderToolbarSelected(): TemplateResult {
|
renderToolbarSelected(): SlottedTemplateResult {
|
||||||
return html``;
|
return nothing;
|
||||||
}
|
}
|
||||||
|
|
||||||
renderToolbarAfter(): TemplateResult {
|
renderToolbarAfter(): SlottedTemplateResult {
|
||||||
return html``;
|
return nothing;
|
||||||
}
|
}
|
||||||
|
|
||||||
renderSearch(): TemplateResult {
|
renderSearch(): TemplateResult {
|
||||||
@ -504,9 +526,9 @@ export abstract class Table<T> extends AKElement implements TableLike {
|
|||||||
* chip-based subtable at the top that shows the list of selected entries. Long text result in
|
* chip-based subtable at the top that shows the list of selected entries. Long text result in
|
||||||
* ellipsized chips, which is sub-optimal.
|
* ellipsized chips, which is sub-optimal.
|
||||||
*/
|
*/
|
||||||
renderSelectedChip(_item: T): TemplateResult {
|
renderSelectedChip(_item: T): SlottedTemplateResult {
|
||||||
// Override this for chip-based displays
|
// Override this for chip-based displays
|
||||||
return html``;
|
return nothing;
|
||||||
}
|
}
|
||||||
|
|
||||||
get needChipGroup() {
|
get needChipGroup() {
|
||||||
@ -547,7 +569,7 @@ export abstract class Table<T> extends AKElement implements TableLike {
|
|||||||
${this.renderToolbarContainer()}
|
${this.renderToolbarContainer()}
|
||||||
<table class="pf-c-table pf-m-compact pf-m-grid-md pf-m-expandable">
|
<table class="pf-c-table pf-m-compact pf-m-grid-md pf-m-expandable">
|
||||||
<thead>
|
<thead>
|
||||||
<tr role="row">
|
<tr role="row" class="pf-c-table__header-row">
|
||||||
${this.checkbox ? this.renderAllOnThisPageCheckbox() : html``}
|
${this.checkbox ? this.renderAllOnThisPageCheckbox() : html``}
|
||||||
${this.expandable ? html`<td role="cell"></td>` : html``}
|
${this.expandable ? html`<td role="cell"></td>` : html``}
|
||||||
${this.columns().map((col) => col.render(this))}
|
${this.columns().map((col) => col.render(this))}
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
|
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 "@goauthentik/elements/forms/HorizontalFormElement";
|
||||||
import { ModelForm } from "@goauthentik/elements/forms/ModelForm";
|
import { ModelForm } from "@goauthentik/elements/forms/ModelForm";
|
||||||
|
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { AndNext, DEFAULT_CONFIG } from "@goauthentik/common/api/config";
|
import { AndNext, DEFAULT_CONFIG } from "@goauthentik/common/api/config";
|
||||||
import { SentryIgnoredError } from "@goauthentik/common/errors";
|
|
||||||
import { globalAK } from "@goauthentik/common/global";
|
import { globalAK } from "@goauthentik/common/global";
|
||||||
import { deviceTypeName } from "@goauthentik/common/labels";
|
import { deviceTypeName } from "@goauthentik/common/labels";
|
||||||
|
import { SentryIgnoredError } from "@goauthentik/common/sentry";
|
||||||
import { getRelativeTime } from "@goauthentik/common/utils";
|
import { getRelativeTime } from "@goauthentik/common/utils";
|
||||||
import "@goauthentik/elements/buttons/Dropdown";
|
import "@goauthentik/elements/buttons/Dropdown";
|
||||||
import "@goauthentik/elements/buttons/ModalButton";
|
import "@goauthentik/elements/buttons/ModalButton";
|
||||||
|
Reference in New Issue
Block a user