 27b7b0b0e7
			
		
	
	27b7b0b0e7
	
	
	
		
			
			* web: Add InvalidationFlow to Radius Provider dialogues
## What
- Bugfix: adds the InvalidationFlow to the Radius Provider dialogues
  - Repairs: `{"invalidation_flow":["This field is required."]}` message, which was *not* propagated
    to the Notification.
- Nitpick: Pretties `?foo=${true}` expressions: `s/\?([^=]+)=\$\{true\}/\1/`
## Note
Yes, I know I'm going to have to do more magic when we harmonize the forms, and no, I didn't add the
Property Mappings to the wizard, and yes, I know I'm going to have pain with the *new* version of
the wizard. But this is a serious bug; you can't make Radius servers with *either* of the current
dialogues at the moment.
* This (temporary) change is needed to prevent the unit tests from failing.
\# What
\# Why
\# How
\# Designs
\# Test Steps
\# Other Notes
* Revert "This (temporary) change is needed to prevent the unit tests from failing."
This reverts commit dddde09be5.
* web/element: empty-state should not have a default label when used as a loading indicator
* .
* web/bug/empty-state: Fix issues with EmptyState and Loading Overlay
- Add a method, `hasSlotted()`, to the Base component.
- Revise `EmptyState` to use `hasSlotted()`.
- Revise `LoadingOverlay` to use `hasSlotted()`.
- Provide (hopefully complete) Storybook stories for both
- Revise use of these components throughout the codebase.
The essential problem here was mine: I misunderstood what the Patternfly `SlotController` does (and,
yikes, how it does it). Slots aren't magical; they're just named containers, in which lightDOM
elements that appear between the opening and closing tags of a web component can be strategically
placed, shown or hidden, and to some extent styled, within the rendered and visible results of the
shadowDOM component that will fill the browser's RECT allocated to that component.
SlotController tries to associate the template with slots by creating the shadowDOM *first*, then
working backwards to see if there are lightDOM components to put into those slots.  That's not what
we want; we want to see if there are lightDOM components that meet our slot requirements and, if
there are, create corresponding slots for them.
That's what `hasSlotted()` does: it returns true or false to the question, "Is there currently in
the lightDOM for this component an entry requesting a known slot name?"  Components are free to do
what they want with that knowledge.
`<ak-empty-state>` now has several modes, all well-documented in the Storybook story.  But in short,
the Title is now a default slot; any HTML Element not sent to one of the named slots are put into
the Title.  The two named slots are `body` and `primary`.  The header is bold and large; body is
just text, and primary is boxed to indicate that one or more buttons should be placed there, to
allow interaction.
The extra modes are controlled by boolean attributes:
- `loading`: Shows the loading spinner, overriding the `icon` attribute
- `default`: Shows the loading spinner *and* the word "Loading" (i18n-aware).
The priority for all of these is:
- Has something in the default (header) slot: That text will be shown. Overrides both
- `default` overrides `loading`
- `loading`
q`<ak-loading-overlay>` is a specialized variant of `<ak-empty-state>` over what will become
`<ak-backdrop>`, but for now is just internal.  It allows only for the heading and primary slots,
forwarding them `<ak-empty-state>`.  Since this is literally the *Loading*Overlay, showing the
`loading` spinner is the default; to prevent it, pass `no-spinner` as an attribute.
* Grammatical error.
* Prettier had opinions that shouldn't have been aired in public.
* Prettier had opinions that shouldn't have been aired in public.
* Collapsing unnecessary boolean nest.
* fix typo
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
* always render icon
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
* missing default in flow exec
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
* unrelated: fix loading interface
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
* rename default attr
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
* fix jsdoc
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
---------
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens Langhammer <jens@goauthentik.io>
		
	
		
			
				
	
	
		
			293 lines
		
	
	
		
			11 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			293 lines
		
	
	
		
			11 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
| import "@goauthentik/admin/enterprise/EnterpriseLicenseForm";
 | |
| import "@goauthentik/admin/enterprise/EnterpriseStatusCard";
 | |
| import "@goauthentik/admin/rbac/ObjectPermissionModal";
 | |
| import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
 | |
| import { formatElapsedTime } from "@goauthentik/common/temporal";
 | |
| import { PFColor } from "@goauthentik/elements/Label";
 | |
| import "@goauthentik/elements/Spinner";
 | |
| import "@goauthentik/elements/buttons/SpinnerButton";
 | |
| import "@goauthentik/elements/cards/AggregateCard";
 | |
| import "@goauthentik/elements/forms/DeleteBulkForm";
 | |
| import "@goauthentik/elements/forms/ModalForm";
 | |
| import { PaginatedResponse } from "@goauthentik/elements/table/Table";
 | |
| import { TableColumn } from "@goauthentik/elements/table/Table";
 | |
| import { TablePage } from "@goauthentik/elements/table/TablePage";
 | |
| import "@patternfly/elements/pf-tooltip/pf-tooltip.js";
 | |
| 
 | |
| import { msg, str } from "@lit/localize";
 | |
| import { CSSResult, TemplateResult, css, html } from "lit";
 | |
| import { customElement, property, state } from "lit/decorators.js";
 | |
| 
 | |
| import PFBanner from "@patternfly/patternfly/components/Banner/banner.css";
 | |
| import PFButton from "@patternfly/patternfly/components/Button/button.css";
 | |
| import PFCard from "@patternfly/patternfly/components/Card/card.css";
 | |
| import PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css";
 | |
| import PFGrid from "@patternfly/patternfly/layouts/Grid/grid.css";
 | |
| 
 | |
| import {
 | |
|     EnterpriseApi,
 | |
|     License,
 | |
|     LicenseForecast,
 | |
|     LicenseSummary,
 | |
|     LicenseSummaryStatusEnum,
 | |
|     RbacPermissionsAssignedByUsersListModelEnum,
 | |
| } from "@goauthentik/api";
 | |
| 
 | |
| @customElement("ak-enterprise-license-list")
 | |
| export class EnterpriseLicenseListPage extends TablePage<License> {
 | |
|     checkbox = true;
 | |
|     clearOnRefresh = true;
 | |
| 
 | |
|     searchEnabled(): boolean {
 | |
|         return true;
 | |
|     }
 | |
|     pageTitle(): string {
 | |
|         return msg("Licenses");
 | |
|     }
 | |
|     pageDescription(): string {
 | |
|         return msg("Manage enterprise licenses");
 | |
|     }
 | |
|     pageIcon(): string {
 | |
|         return "pf-icon pf-icon-key";
 | |
|     }
 | |
| 
 | |
|     @property()
 | |
|     order = "name";
 | |
| 
 | |
|     @state()
 | |
|     forecast?: LicenseForecast;
 | |
| 
 | |
|     @state()
 | |
|     summary?: LicenseSummary;
 | |
| 
 | |
|     @state()
 | |
|     installID?: string;
 | |
| 
 | |
|     static get styles(): CSSResult[] {
 | |
|         return super.styles.concat(
 | |
|             PFGrid,
 | |
|             PFBanner,
 | |
|             PFFormControl,
 | |
|             PFButton,
 | |
|             PFCard,
 | |
|             css`
 | |
|                 .pf-m-no-padding-bottom {
 | |
|                     padding-bottom: 0;
 | |
|                 }
 | |
|                 .install-id {
 | |
|                     word-break: break-all;
 | |
|                 }
 | |
|             `,
 | |
|         );
 | |
|     }
 | |
| 
 | |
|     async apiEndpoint(): Promise<PaginatedResponse<License>> {
 | |
|         this.forecast = await new EnterpriseApi(DEFAULT_CONFIG).enterpriseLicenseForecastRetrieve();
 | |
|         this.summary = await new EnterpriseApi(DEFAULT_CONFIG).enterpriseLicenseSummaryRetrieve({
 | |
|             cached: false,
 | |
|         });
 | |
|         this.installID = (
 | |
|             await new EnterpriseApi(DEFAULT_CONFIG).enterpriseLicenseInstallIdRetrieve()
 | |
|         ).installId;
 | |
|         return new EnterpriseApi(DEFAULT_CONFIG).enterpriseLicenseList(
 | |
|             await this.defaultEndpointConfig(),
 | |
|         );
 | |
|     }
 | |
| 
 | |
|     columns(): TableColumn[] {
 | |
|         return [
 | |
|             new TableColumn(msg("Name"), "name"),
 | |
|             new TableColumn(msg("Users")),
 | |
|             new TableColumn(msg("Expiry date")),
 | |
|             new TableColumn(msg("Actions")),
 | |
|         ];
 | |
|     }
 | |
| 
 | |
|     // TODO: Make this more generic, maybe automatically get the plural name
 | |
|     // of the object to use in the renderEmpty
 | |
|     renderEmpty(inner?: TemplateResult): TemplateResult {
 | |
|         return super.renderEmpty(html`
 | |
|             ${inner
 | |
|                 ? inner
 | |
|                 : html`<ak-empty-state icon=${this.pageIcon()}
 | |
|                       ><span>${msg("No licenses found.")}</span>
 | |
|                       <div slot="body">
 | |
|                           ${this.searchEnabled() ? this.renderEmptyClearSearch() : html``}
 | |
|                       </div>
 | |
|                       <div slot="primary">${this.renderObjectCreate()}</div>
 | |
|                   </ak-empty-state>`}
 | |
|         `);
 | |
|     }
 | |
| 
 | |
|     renderToolbarSelected(): TemplateResult {
 | |
|         const disabled = this.selectedElements.length < 1;
 | |
|         return html`<ak-forms-delete-bulk
 | |
|             objectLabel=${msg("License(s)")}
 | |
|             .objects=${this.selectedElements}
 | |
|             .metadata=${(item: License) => {
 | |
|                 return [
 | |
|                     { key: msg("Name"), value: item.name },
 | |
|                     { key: msg("Expiry"), value: item.expiry?.toLocaleString() },
 | |
|                 ];
 | |
|             }}
 | |
|             .usedBy=${(item: License) => {
 | |
|                 return new EnterpriseApi(DEFAULT_CONFIG).enterpriseLicenseUsedByList({
 | |
|                     licenseUuid: item.licenseUuid,
 | |
|                 });
 | |
|             }}
 | |
|             .delete=${(item: License) => {
 | |
|                 return new EnterpriseApi(DEFAULT_CONFIG).enterpriseLicenseDestroy({
 | |
|                     licenseUuid: item.licenseUuid,
 | |
|                 });
 | |
|             }}
 | |
|         >
 | |
|             <button ?disabled=${disabled} slot="trigger" class="pf-c-button pf-m-danger">
 | |
|                 ${msg("Delete")}
 | |
|             </button>
 | |
|         </ak-forms-delete-bulk>`;
 | |
|     }
 | |
| 
 | |
|     renderSectionBefore(): TemplateResult {
 | |
|         return html`
 | |
|             <section class="pf-c-page__main-section pf-m-no-padding-bottom">
 | |
|                 <div
 | |
|                     class="pf-l-grid pf-m-gutter pf-m-all-6-col-on-sm pf-m-all-4-col-on-md pf-m-all-3-col-on-lg pf-m-all-3-col-on-xl"
 | |
|                 >
 | |
|                     ${this.renderGetLicenseCard()}
 | |
|                     <ak-aggregate-card
 | |
|                         class="pf-l-grid__item"
 | |
|                         icon="pf-icon pf-icon-user"
 | |
|                         header=${msg("Forecast internal users")}
 | |
|                         subtext=${msg(
 | |
|                             str`Estimated user count one year from now based on ${this.forecast?.internalUsers} current internal users and ${this.forecast?.forecastedInternalUsers} forecasted internal users.`,
 | |
|                         )}
 | |
|                     >
 | |
|                         ~ ${(this.forecast?.internalUsers || 0) +
 | |
|                         (this.forecast?.forecastedInternalUsers || 0)}
 | |
|                     </ak-aggregate-card>
 | |
|                     <ak-aggregate-card
 | |
|                         class="pf-l-grid__item"
 | |
|                         icon="pf-icon pf-icon-user"
 | |
|                         header=${msg("Forecast external users")}
 | |
|                         subtext=${msg(
 | |
|                             str`Estimated user count one year from now based on ${this.forecast?.externalUsers} current external users and ${this.forecast?.forecastedExternalUsers} forecasted external users.`,
 | |
|                         )}
 | |
|                     >
 | |
|                         ~ ${(this.forecast?.externalUsers || 0) +
 | |
|                         (this.forecast?.forecastedExternalUsers || 0)}
 | |
|                     </ak-aggregate-card>
 | |
|                     <ak-aggregate-card
 | |
|                         class="pf-l-grid__item"
 | |
|                         icon="pf-icon pf-icon-user"
 | |
|                         header=${msg("Expiry")}
 | |
|                         subtext=${msg("Cumulative license expiry")}
 | |
|                     >
 | |
|                         ${this.summary &&
 | |
|                         this.summary?.status !== LicenseSummaryStatusEnum.Unlicensed
 | |
|                             ? html`<div>${formatElapsedTime(this.summary.latestValid)}</div>
 | |
|                                   <small>${this.summary.latestValid.toLocaleString()}</small>`
 | |
|                             : "-"}
 | |
|                     </ak-aggregate-card>
 | |
|                 </div>
 | |
|             </section>
 | |
|             <section class="pf-c-page__main-section pf-m-no-padding-bottom">
 | |
|                 <ak-enterprise-status-card
 | |
|                     .summary=${this.summary}
 | |
|                     .forecast=${this.forecast}
 | |
|                 ></ak-enterprise-status-card>
 | |
|             </section>
 | |
|         `;
 | |
|     }
 | |
| 
 | |
|     row(item: License): TemplateResult[] {
 | |
|         let color = PFColor.Green;
 | |
|         if (item.expiry) {
 | |
|             const now = new Date();
 | |
|             const inAMonth = new Date();
 | |
|             inAMonth.setDate(inAMonth.getDate() + 30);
 | |
|             if (item.expiry <= inAMonth) {
 | |
|                 color = PFColor.Orange;
 | |
|             }
 | |
|             if (item.expiry <= now) {
 | |
|                 color = PFColor.Red;
 | |
|             }
 | |
|         }
 | |
|         return [
 | |
|             html`<div>${item.name}</div>`,
 | |
|             html`<div>${msg(str`Internal: ${item.internalUsers}`)}</div>
 | |
|                 <div>${msg(str`External: ${item.externalUsers}`)}</div>`,
 | |
|             html`<ak-label color=${color}> ${item.expiry?.toLocaleString()} </ak-label>`,
 | |
|             html`<ak-forms-modal>
 | |
|                     <span slot="submit"> ${msg("Update")} </span>
 | |
|                     <span slot="header"> ${msg("Update License")} </span>
 | |
|                     <ak-enterprise-license-form slot="form" .instancePk=${item.licenseUuid}>
 | |
|                     </ak-enterprise-license-form>
 | |
|                     <button slot="trigger" class="pf-c-button pf-m-plain">
 | |
|                         <pf-tooltip position="top" content=${msg("Edit")}>
 | |
|                             <i class="fas fa-edit"></i>
 | |
|                         </pf-tooltip>
 | |
|                     </button>
 | |
|                 </ak-forms-modal>
 | |
|                 <ak-rbac-object-permission-modal
 | |
|                     model=${RbacPermissionsAssignedByUsersListModelEnum.AuthentikEnterpriseLicense}
 | |
|                     objectPk=${item.licenseUuid}
 | |
|                 >
 | |
|                 </ak-rbac-object-permission-modal> `,
 | |
|         ];
 | |
|     }
 | |
| 
 | |
|     renderGetLicenseCard() {
 | |
|         const renderSpinner = () =>
 | |
|             html` <div class="pf-c-card__body">
 | |
|                 <ak-spinner></ak-spinner>
 | |
|             </div>`;
 | |
| 
 | |
|         const installURL = (installID: string) =>
 | |
|             [
 | |
|                 "https://customers.goauthentik.io/from_authentik/purchase/?install_id=",
 | |
|                 encodeURIComponent(installID),
 | |
|                 "&authentik_url=",
 | |
|                 encodeURI(window.location.origin),
 | |
|             ].join("");
 | |
| 
 | |
|         const renderCard = (installID: string) => html`
 | |
|             <div class="pf-c-card__title">${msg("Your Install ID")}</div>
 | |
|             <div class="pf-c-card__body install-id pf-m-monospace">${installID}</div>
 | |
|             <div class="pf-c-card__body">
 | |
|                 <a
 | |
|                     target="_blank"
 | |
|                     href="${installURL(installID)}"
 | |
|                     class="pf-c-button pf-m-primary pf-m-block"
 | |
|                     >${msg("Go to Customer Portal")}</a
 | |
|                 >
 | |
|             </div>
 | |
|             <div class="pf-c-card__body">
 | |
|                 <a target="_blank" href="https://docs.goauthentik.io/docs/enterprise/get-started"
 | |
|                     >${msg("Learn more")}</a
 | |
|                 >
 | |
|             </div>
 | |
|         `;
 | |
| 
 | |
|         return html`<div class="pf-l-grid__item pf-c-card">
 | |
|             ${this.installID ? renderCard(this.installID) : renderSpinner()}
 | |
|         </div> `;
 | |
|     }
 | |
| 
 | |
|     renderObjectCreate(): TemplateResult {
 | |
|         return html`
 | |
|             <ak-forms-modal>
 | |
|                 <span slot="submit"> ${msg("Install")} </span>
 | |
|                 <span slot="header"> ${msg("Install License")} </span>
 | |
|                 <ak-enterprise-license-form slot="form"> </ak-enterprise-license-form>
 | |
|                 <button slot="trigger" class="pf-c-button pf-m-primary">${msg("Install")}</button>
 | |
|             </ak-forms-modal>
 | |
|         `;
 | |
|     }
 | |
| }
 | |
| 
 | |
| declare global {
 | |
|     interface HTMLElementTagNameMap {
 | |
|         "ak-enterprise-license-list": EnterpriseLicenseListPage;
 | |
|     }
 | |
| }
 |