Compare commits
	
		
			1 Commits
		
	
	
		
			version/20
			...
			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,17 +94,19 @@ 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" |                         <ak-recent-events pageSize="6"></ak-recent-events> | ||||||
|                     > |                     </div> | ||||||
|                         <div class="pf-l-grid__item pf-m-12-col pf-m-6-col-on-xl pf-m-4-col-on-2xl"> |                     <div class="pf-l-grid__item pf-m-6-col pf-m-3-col-on-md pf-m-3-col-on-xl"> | ||||||
|                         <ak-quick-actions-card .actions=${this.quickActions}> |                         <ak-quick-actions-card .actions=${this.quickActions}> | ||||||
|                         </ak-quick-actions-card> |                         </ak-quick-actions-card> | ||||||
|                     </div> |                     </div> | ||||||
|                         <div class="pf-l-grid__item pf-m-12-col pf-m-6-col-on-xl pf-m-4-col-on-2xl"> |  | ||||||
|  |                     <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 |                         <ak-aggregate-card | ||||||
|                             icon="pf-icon pf-icon-zone" |                             icon="pf-icon pf-icon-zone" | ||||||
|                             header=${msg("Outpost status")} |                             header=${msg("Outpost status")} | ||||||
| @ -118,24 +115,13 @@ export class AdminOverviewPage extends AdminOverviewBase { | |||||||
|                             <ak-admin-status-chart-outpost></ak-admin-status-chart-outpost> |                             <ak-admin-status-chart-outpost></ak-admin-status-chart-outpost> | ||||||
|                         </ak-aggregate-card> |                         </ak-aggregate-card> | ||||||
|                     </div> |                     </div> | ||||||
|                         <div |  | ||||||
|                             class="pf-l-grid__item pf-m-12-col pf-m-12-col-on-xl pf-m-4-col-on-2xl" |                     <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-aggregate-card icon="fa fa-sync-alt" header=${msg("Sync status")}> | ||||||
|                             <ak-admin-status-chart-sync></ak-admin-status-chart-sync> |                             <ak-admin-status-chart-sync></ak-admin-status-chart-sync> | ||||||
|                         </ak-aggregate-card> |                         </ak-aggregate-card> | ||||||
|                     </div> |                     </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> |  | ||||||
|                     </div> |  | ||||||
|                     <div class="pf-l-grid__item pf-m-12-col"> |  | ||||||
|                         <hr class="pf-c-divider" /> |  | ||||||
|                     </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,8 +131,7 @@ 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, | ||||||
|             }) |             }) | ||||||
| @ -141,29 +140,27 @@ export class ApplicationWizardSubmitStep extends CustomEmitterElement(Applicatio | |||||||
|                 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 |  | ||||||
|                     // right place. |  | ||||||
|                 if (Array.isArray(errors?.app?.provider)) { |                 if (Array.isArray(errors?.app?.provider)) { | ||||||
|                     const providerError = errors.app.provider; |                     const providerError = errors.app.provider; | ||||||
|                     errors.provider = errors.provider ?? {}; |                     errors.provider = errors.provider ?? {}; | ||||||
|                     errors.provider.name = providerError; |                     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.handleUpdate({ errors }); | ||||||
|                 this.state = "reviewing"; |                 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"; | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Given event with a geographical context, format it into a string for display. | ||||||
|  |  */ | ||||||
|  | export function formatGeoEvent(event: EventWithContext): SlottedTemplateResult { | ||||||
|  |     if (!event.context.geo) return nothing; | ||||||
|  |  | ||||||
|  |     const { city, country, continent } = event.context.geo; | ||||||
|  |  | ||||||
|  |     const parts = [city, country, continent].filter(Boolean); | ||||||
|  |  | ||||||
| export function EventGeo(event: EventWithContext): TemplateResult { |  | ||||||
|     let geo: KeyUnknown | undefined = undefined; |  | ||||||
|     if (Object.hasOwn(event.context, "geo")) { |  | ||||||
|         geo = event.context.geo as KeyUnknown; |  | ||||||
|         const parts = [geo.city, geo.country, geo.continent].filter( |  | ||||||
|             (v) => v !== "" && v !== undefined, |  | ||||||
|         ); |  | ||||||
|     return html`${parts.join(", ")}`; |     return html`${parts.join(", ")}`; | ||||||
| } | } | ||||||
|     return html``; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| 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; |                 this.previewError = undefined; | ||||||
|         } catch (exc) { |             }) | ||||||
|             const errorMessage = parseAPIError(exc as ResponseError); |             .catch(async (error) => { | ||||||
|             this.previewError = (errorMessage as ValidationError).nonFieldErrors; |                 const parsedError = await parseAPIResponseError(error); | ||||||
|         } |  | ||||||
|  |                 this.previewError = containsNonFieldErrors(parsedError) | ||||||
|  |                     ? error.nonFieldErrors | ||||||
|  |                     : [pluckErrorDetail(parsedError, msg("Failed to preview prompt"))]; | ||||||
|  |             }); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     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,9 +15,21 @@ 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> { | /** | ||||||
|  |  * A generic error that can be thrown without triggering Sentry's reporting. | ||||||
|  |  */ | ||||||
|  | export class SentryIgnoredError extends Error {} | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Configure Sentry with the given configuration. | ||||||
|  |  * | ||||||
|  |  * @param canSendPII Whether the user can send personally identifiable information. | ||||||
|  |  */ | ||||||
|  | export async function configureSentry(canSendPII = false): Promise<Config> { | ||||||
|     const cfg = await config(); |     const cfg = await config(); | ||||||
|     if (cfg.errorReporting.enabled) { |  | ||||||
|  |     if (!cfg.errorReporting.enabled) return cfg; | ||||||
|  |  | ||||||
|     init({ |     init({ | ||||||
|         dsn: cfg.errorReporting.sentryDsn, |         dsn: cfg.errorReporting.sentryDsn, | ||||||
|         ignoreErrors: [ |         ignoreErrors: [ | ||||||
| @ -61,24 +72,28 @@ export async function configureSentry(canDoPpi = false): Promise<Config> { | |||||||
|             return event; |             return event; | ||||||
|         }, |         }, | ||||||
|     }); |     }); | ||||||
|  |  | ||||||
|     setTag(TAG_SENTRY_CAPABILITIES, cfg.capabilities.join(",")); |     setTag(TAG_SENTRY_CAPABILITIES, cfg.capabilities.join(",")); | ||||||
|  |  | ||||||
|     if (window.location.pathname.includes("if/")) { |     if (window.location.pathname.includes("if/")) { | ||||||
|         setTag(TAG_SENTRY_COMPONENT, `web/${currentInterface()}`); |         setTag(TAG_SENTRY_COMPONENT, `web/${currentInterface()}`); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     if (cfg.capabilities.includes(CapabilitiesEnum.CanDebug)) { |     if (cfg.capabilities.includes(CapabilitiesEnum.CanDebug)) { | ||||||
|         const Spotlight = await import("@spotlightjs/spotlight"); |         const Spotlight = await import("@spotlightjs/spotlight"); | ||||||
|  |  | ||||||
|         Spotlight.init({ injectImmediately: true }); |         Spotlight.init({ injectImmediately: true }); | ||||||
|     } |     } | ||||||
|         if (cfg.errorReporting.sendPii && canDoPpi) { |  | ||||||
|             me().then((user) => { |     if (cfg.errorReporting.sendPii && canSendPII) { | ||||||
|  |         await me().then((user) => { | ||||||
|             setUser({ email: user.user.email }); |             setUser({ email: user.user.email }); | ||||||
|             console.debug("authentik/config: Sentry with PII enabled."); |             console.debug("authentik/config: Sentry with PII enabled."); | ||||||
|         }); |         }); | ||||||
|     } else { |     } else { | ||||||
|         console.debug("authentik/config: Sentry enabled."); |         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}"> |  | ||||||
|  |         return html`<a href="${this.headerLink}"> | ||||||
|             <i class="fa fa-link"></i> |             <i class="fa fa-link"></i> | ||||||
|               </a>` |         </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( |                 this.dispatchEvent( | ||||||
|                     new CustomEvent(EVENT_REFRESH, { |                     new CustomEvent(EVENT_REFRESH, { | ||||||
|                         bubbles: true, |                         bubbles: true, | ||||||
|                         composed: true, |                         composed: true, | ||||||
|                     }), |                     }), | ||||||
|                 ); |                 ); | ||||||
|  |  | ||||||
|                 return response; |                 return response; | ||||||
|         } catch (ex) { |             }) | ||||||
|             if (ex instanceof ResponseError) { |             .catch(async (error) => { | ||||||
|                 let errorMessage = ex.response.statusText; |                 if (error instanceof PreventFormSubmit && error.element) { | ||||||
|                 const error = await parseAPIError(ex); |                     error.element.errorMessages = [error.message]; | ||||||
|                 if (instanceOfValidationError(error)) { |                     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,22 +225,25 @@ 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() | ||||||
|  |             .then((data) => { | ||||||
|  |                 this.data = data; | ||||||
|                 this.error = undefined; |                 this.error = undefined; | ||||||
|  |  | ||||||
|                 this.page = this.data.pagination.current; |                 this.page = this.data.pagination.current; | ||||||
|                 const newExpanded: T[] = []; |                 const newExpanded: T[] = []; | ||||||
|  |  | ||||||
|                 this.data.results.forEach((res) => { |                 this.data.results.forEach((res) => { | ||||||
|                     const jsonRes = JSON.stringify(res); |                     const jsonRes = JSON.stringify(res); | ||||||
|                     // So because we're dealing with complex objects here, we can't use indexOf |                     // So because we're dealing with complex objects here, we can't use indexOf | ||||||
| @ -239,6 +254,7 @@ export abstract class Table<T> extends AKElement implements TableLike { | |||||||
|                     let comp = (item: T) => { |                     let comp = (item: T) => { | ||||||
|                         return JSON.stringify(item) === jsonRes; |                         return JSON.stringify(item) === jsonRes; | ||||||
|                     }; |                     }; | ||||||
|  |  | ||||||
|                     if (Object.hasOwn(res as object, "pk")) { |                     if (Object.hasOwn(res as object, "pk")) { | ||||||
|                         comp = (item: T) => { |                         comp = (item: T) => { | ||||||
|                             return ( |                             return ( | ||||||
| @ -247,17 +263,23 @@ export abstract class Table<T> extends AKElement implements TableLike { | |||||||
|                             ); |                             ); | ||||||
|                         }; |                         }; | ||||||
|                     } |                     } | ||||||
|  |  | ||||||
|                     const expandedIndex = this.expandedElements.findIndex(comp); |                     const expandedIndex = this.expandedElements.findIndex(comp); | ||||||
|  |  | ||||||
|                     if (expandedIndex > -1) { |                     if (expandedIndex > -1) { | ||||||
|                         newExpanded.push(res); |                         newExpanded.push(res); | ||||||
|                     } |                     } | ||||||
|                 }); |                 }); | ||||||
|             this.isLoading = false; |  | ||||||
|                 this.expandedElements = newExpanded; |                 this.expandedElements = newExpanded; | ||||||
|         } catch (ex) { |             }) | ||||||
|  |             .catch(async (error) => { | ||||||
|  |                 this.error = await parseAPIResponseError(error); | ||||||
|  |             }) | ||||||
|  |             .finally(() => { | ||||||
|                 this.isLoading = false; |                 this.isLoading = false; | ||||||
|             this.error = await parseAPIError(ex as Error); |                 this.requestUpdate(); | ||||||
|         } |             }); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     private renderLoading(): TemplateResult { |     private renderLoading(): TemplateResult { | ||||||
| @ -270,7 +292,7 @@ export abstract class Table<T> extends AKElement implements TableLike { | |||||||
|         </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
	