diff --git a/Makefile b/Makefile index 192b0a1393..08d2304af1 100644 --- a/Makefile +++ b/Makefile @@ -43,7 +43,7 @@ help: ## Show this help sort @echo "" -test-go: +go-test: go test -timeout 0 -v -race -cover ./... test-docker: ## Run all tests in a docker-compose @@ -205,11 +205,14 @@ gen: gen-build gen-client-ts web-build: web-install ## Build the Authentik UI cd web && npm run build -web: web-lint-fix web-lint web-check-compile ## Automatically fix formatting issues in the Authentik UI source code, lint the code, and compile it +web: web-lint-fix web-lint web-check-compile web-test ## Automatically fix formatting issues in the Authentik UI source code, lint the code, and compile it web-install: ## Install the necessary libraries to build the Authentik UI cd web && npm ci +web-test: ## Run tests for the Authentik UI + cd web && npm run test + web-watch: ## Build and watch the Authentik UI for changes, updating automatically rm -rf web/dist/ mkdir web/dist/ diff --git a/web/package-lock.json b/web/package-lock.json index 8190ddadbe..4682032c10 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -120,7 +120,7 @@ "turnstile-types": "^1.2.2", "typescript": "^5.5.4", "typescript-eslint": "^8.2.0", - "vite-tsconfig-paths": "^4.3.2", + "vite-tsconfig-paths": "^5.0.1", "wdio-wait-for": "^3.0.11", "wireit": "^0.14.8" }, @@ -24860,7 +24860,9 @@ } }, "node_modules/vite-tsconfig-paths": { - "version": "4.3.2", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/vite-tsconfig-paths/-/vite-tsconfig-paths-5.0.1.tgz", + "integrity": "sha512-yqwv+LstU7NwPeNqajZzLEBVpUFU6Dugtb2P84FXuvaoYA+/70l9MHE+GYfYAycVyPSDYZ7mjOFuYBRqlEpTig==", "dev": true, "license": "MIT", "dependencies": { diff --git a/web/package.json b/web/package.json index 85f852169b..08ee0b1acc 100644 --- a/web/package.json +++ b/web/package.json @@ -108,7 +108,7 @@ "turnstile-types": "^1.2.2", "typescript": "^5.5.4", "typescript-eslint": "^8.2.0", - "vite-tsconfig-paths": "^4.3.2", + "vite-tsconfig-paths": "^5.0.1", "wdio-wait-for": "^3.0.11", "wireit": "^0.14.8" }, @@ -154,6 +154,7 @@ "tsc": "wireit", "watch": "run-s build-locales esbuild:watch" }, + "type": "module", "wireit": { "build": { "#comment": [ @@ -323,13 +324,17 @@ "command": "node scripts/build-storybook-import-maps.mjs" }, "test": { - "command": "wdio run ./wdio.conf.ts --logLevel=warn --autoCompileOpts.tsNodeOpts.project=tsconfig.test.json", + "command": "wdio run ./wdio.conf.ts --logLevel=warn", "env": { - "CI": "true" + "CI": "true", + "TS_NODE_PROJECT": "tsconfig.test.json" } }, "test-view": { - "command": "wdio run ./wdio.conf.ts --autoCompileOpts.tsNodeOpts.project=tsconfig.test.json" + "command": "wdio run ./wdio.conf.ts", + "env": { + "TS_NODE_PROJECT": "tsconfig.test.json" + } }, "tsc": { "dependencies": [ diff --git a/web/src/admin/enterprise/EnterpriseLicenseListPage.ts b/web/src/admin/enterprise/EnterpriseLicenseListPage.ts index 8da628dc55..3c700b615e 100644 --- a/web/src/admin/enterprise/EnterpriseLicenseListPage.ts +++ b/web/src/admin/enterprise/EnterpriseLicenseListPage.ts @@ -1,4 +1,5 @@ import "@goauthentik/admin/enterprise/EnterpriseLicenseForm"; +import "@goauthentik/admin/enterprise/EnterpriseStatusCard"; import "@goauthentik/admin/rbac/ObjectPermissionModal"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { getRelativeTime } from "@goauthentik/common/utils"; @@ -14,17 +15,14 @@ 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, nothing } from "lit"; +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 PFDescriptionList from "@patternfly/patternfly/components/DescriptionList/description-list.css"; import PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css"; -import PFProgress from "@patternfly/patternfly/components/Progress/progress.css"; import PFGrid from "@patternfly/patternfly/layouts/Grid/grid.css"; -import PFSplit from "@patternfly/patternfly/layouts/Split/split.css"; import { EnterpriseApi, @@ -67,13 +65,10 @@ export class EnterpriseLicenseListPage extends TablePage { static get styles(): CSSResult[] { return super.styles.concat( - PFDescriptionList, PFGrid, PFBanner, PFFormControl, PFButton, - PFProgress, - PFSplit, PFCard, css` .pf-m-no-padding-bottom { @@ -198,103 +193,14 @@ export class EnterpriseLicenseListPage extends TablePage {
- ${this.renderCurrentSummary()} +
`; } - renderSummaryBadge() { - switch (this.summary?.status) { - case LicenseSummaryStatusEnum.Expired: - return html`${msg("Expired")}`; - case LicenseSummaryStatusEnum.ExpirySoon: - return html`${msg("Expiring soon")}`; - case LicenseSummaryStatusEnum.Unlicensed: - return html`${msg("Unlicensed")}`; - case LicenseSummaryStatusEnum.ReadOnly: - return html`${msg("Read Only")}`; - case LicenseSummaryStatusEnum.Valid: - return html`${msg("Valid")}`; - default: - return nothing; - } - } - - renderCurrentSummary() { - if (!this.forecast || !this.summary) { - return html`${msg("Loading")}`; - } - const internalUserPercentage = - this.summary.internalUsers > 0 - ? Math.ceil(this.forecast.internalUsers / (this.summary.internalUsers / 100)) - : 0; - const externalUserPercentage = - this.summary.externalUsers > 0 - ? Math.ceil(this.forecast.externalUsers / (this.summary.externalUsers / 100)) - : 0; - return html`
-
${msg("Current license status")}
-
-
-
-
- ${msg("Overall license status")} -
-
-
- ${this.renderSummaryBadge()} -
-
-
-
-
-
-
${msg("Internal user usage")}
- -
-
-
-
-
-
${msg("External user usage")}
- -
-
-
-
-
-
-
`; - } - row(item: License): TemplateResult[] { let color = PFColor.Green; if (item.expiry) { diff --git a/web/src/admin/enterprise/EnterpriseStatusCard.test.ts b/web/src/admin/enterprise/EnterpriseStatusCard.test.ts new file mode 100644 index 0000000000..483d3d57f3 --- /dev/null +++ b/web/src/admin/enterprise/EnterpriseStatusCard.test.ts @@ -0,0 +1,165 @@ +import { ensureCSSStyleSheet } from "@goauthentik/elements/utils/ensureCSSStyleSheet.js"; +import { $, expect } from "@wdio/globals"; + +import { msg } from "@lit/localize"; +import { TemplateResult, html, render as litRender } from "lit"; + +import AKGlobal from "@goauthentik/common/styles/authentik.css"; +import PFBase from "@patternfly/patternfly/patternfly-base.css"; + +import { LicenseForecast, LicenseSummary, LicenseSummaryStatusEnum } from "@goauthentik/api"; + +import "./EnterpriseStatusCard.js"; + +const render = (body: TemplateResult) => { + document.adoptedStyleSheets = [ + ...document.adoptedStyleSheets, + ensureCSSStyleSheet(PFBase), + ensureCSSStyleSheet(AKGlobal), + ]; + return litRender(body, document.body); +}; + +describe("ak-enterprise-status-card", () => { + it("should not error when no data is loaded", async () => { + render(html``); + + const status = await $("ak-enterprise-status-card"); + await expect(status).toHaveText(msg("Loading")); + }); + + it("should render empty when unlicensed", async () => { + const forecast: LicenseForecast = { + externalUsers: 123, + internalUsers: 123, + forecastedExternalUsers: 123, + forecastedInternalUsers: 123, + }; + const summary: LicenseSummary = { + status: LicenseSummaryStatusEnum.Unlicensed, + internalUsers: 0, + externalUsers: 0, + latestValid: new Date(0), + licenseFlags: [], + }; + render( + html` + `, + ); + + const status = await $("ak-enterprise-status-card").$( + ">>>.pf-c-description-list__description > .pf-c-description-list__text", + ); + await expect(status).toExist(); + await expect(status).toHaveText(msg("Unlicensed")); + + const internalUserProgress = await $("ak-enterprise-status-card").$( + ">>>#internalUsers > .pf-c-progress__bar", + ); + await expect(internalUserProgress).toExist(); + await expect(internalUserProgress).toHaveAttr("aria-valuenow", "0"); + const externalUserProgress = await $("ak-enterprise-status-card").$( + ">>>#externalUsers > .pf-c-progress__bar", + ); + await expect(externalUserProgress).toExist(); + await expect(externalUserProgress).toHaveAttr("aria-valuenow", "0"); + }); + + it("should show warnings when full", async () => { + const forecast: LicenseForecast = { + externalUsers: 123, + internalUsers: 123, + forecastedExternalUsers: 123, + forecastedInternalUsers: 123, + }; + const summary: LicenseSummary = { + status: LicenseSummaryStatusEnum.Valid, + internalUsers: 123, + externalUsers: 123, + latestValid: new Date(), + licenseFlags: [], + }; + render( + html` + `, + ); + + const status = await $("ak-enterprise-status-card").$( + ">>>.pf-c-description-list__description > .pf-c-description-list__text", + ); + await expect(status).toExist(); + await expect(status).toHaveText(msg("Valid")); + + const internalUserProgress = await $("ak-enterprise-status-card").$( + ">>>#internalUsers > .pf-c-progress__bar", + ); + await expect(internalUserProgress).toExist(); + await expect(internalUserProgress).toHaveAttr("aria-valuenow", "100"); + + await expect( + await $("ak-enterprise-status-card").$(">>>#internalUsers"), + ).toHaveElementClass("pf-m-warning"); + + const externalUserProgress = await $("ak-enterprise-status-card").$( + ">>>#externalUsers > .pf-c-progress__bar", + ); + await expect(externalUserProgress).toExist(); + await expect(externalUserProgress).toHaveAttr("aria-valuenow", "100"); + + await expect( + await $("ak-enterprise-status-card").$(">>>#internalUsers"), + ).toHaveElementClass("pf-m-warning"); + await expect( + await $("ak-enterprise-status-card").$(">>>#externalUsers"), + ).toHaveElementClass("pf-m-warning"); + }); + + it("should show infinity when not licensed for a user type", async () => { + const forecast: LicenseForecast = { + externalUsers: 123, + internalUsers: 123, + forecastedExternalUsers: 123, + forecastedInternalUsers: 123, + }; + const summary: LicenseSummary = { + status: LicenseSummaryStatusEnum.Valid, + internalUsers: 123, + externalUsers: 0, + latestValid: new Date(), + licenseFlags: [], + }; + render( + html` + `, + ); + + const status = await $("ak-enterprise-status-card").$( + ">>>.pf-c-description-list__description > .pf-c-description-list__text", + ); + await expect(status).toExist(); + await expect(status).toHaveText(msg("Valid")); + + const internalUserProgress = await $("ak-enterprise-status-card").$( + ">>>#internalUsers > .pf-c-progress__bar", + ); + await expect(internalUserProgress).toExist(); + await expect(internalUserProgress).toHaveAttr("aria-valuenow", "100"); + + await expect( + await $("ak-enterprise-status-card").$(">>>#internalUsers"), + ).toHaveElementClass("pf-m-warning"); + + const externalUserProgress = await $("ak-enterprise-status-card").$( + ">>>#externalUsers > .pf-c-progress__bar", + ); + await expect(externalUserProgress).toExist(); + await expect(externalUserProgress).toHaveAttr("aria-valuenow", "∞"); + + await expect( + await $("ak-enterprise-status-card").$(">>>#internalUsers"), + ).toHaveElementClass("pf-m-warning"); + await expect( + await $("ak-enterprise-status-card").$(">>>#externalUsers"), + ).toHaveElementClass("pf-m-danger"); + }); +}); diff --git a/web/src/admin/enterprise/EnterpriseStatusCard.ts b/web/src/admin/enterprise/EnterpriseStatusCard.ts new file mode 100644 index 0000000000..14086b884a --- /dev/null +++ b/web/src/admin/enterprise/EnterpriseStatusCard.ts @@ -0,0 +1,157 @@ +import { AKElement } from "@goauthentik/elements/Base"; +import { PFColor } from "@goauthentik/elements/Label"; + +import { msg, str } from "@lit/localize"; +import { CSSResult, html, nothing } from "lit"; +import { customElement, state } from "lit/decorators.js"; + +import PFCard from "@patternfly/patternfly/components/Card/card.css"; +import PFDescriptionList from "@patternfly/patternfly/components/DescriptionList/description-list.css"; +import PFProgress from "@patternfly/patternfly/components/Progress/progress.css"; +import PFSplit from "@patternfly/patternfly/layouts/Split/split.css"; +import PFBase from "@patternfly/patternfly/patternfly-base.css"; + +import { LicenseForecast, LicenseSummary, LicenseSummaryStatusEnum } from "@goauthentik/api"; + +@customElement("ak-enterprise-status-card") +export class EnterpriseStatusCard extends AKElement { + @state() + forecast?: LicenseForecast; + + @state() + summary?: LicenseSummary; + + static get styles(): CSSResult[] { + return [PFBase, PFDescriptionList, PFCard, PFSplit, PFProgress]; + } + + renderSummaryBadge() { + switch (this.summary?.status) { + case LicenseSummaryStatusEnum.Expired: + return html`${msg("Expired")}`; + case LicenseSummaryStatusEnum.ExpirySoon: + return html`${msg("Expiring soon")}`; + case LicenseSummaryStatusEnum.Unlicensed: + return html`${msg("Unlicensed")}`; + case LicenseSummaryStatusEnum.ReadOnly: + return html`${msg("Read Only")}`; + case LicenseSummaryStatusEnum.Valid: + return html`${msg("Valid")}`; + default: + return nothing; + } + } + + calcUserPercentage(licensed: number, current: number) { + const percentage = licensed > 0 ? Math.ceil(current / (licensed / 100)) : 0; + if (current > 0 && licensed === 0) return Infinity; + return percentage; + } + + render() { + if (!this.forecast || !this.summary) { + return html`${msg("Loading")}`; + } + let internalUserPercentage = 0; + let externalUserPercentage = 0; + if (this.summary.status !== LicenseSummaryStatusEnum.Unlicensed) { + internalUserPercentage = this.calcUserPercentage( + this.summary.internalUsers, + this.forecast.internalUsers, + ); + externalUserPercentage = this.calcUserPercentage( + this.summary.externalUsers, + this.forecast.externalUsers, + ); + } + return html`
+
${msg("Current license status")}
+
+
+
+
+
+ ${msg("Overall license status")} +
+
+
+ ${this.renderSummaryBadge()} +
+
+
+
+
+
= 80 ? "pf-m-warning" : ""}" + id="internalUsers" + > +
+ ${msg("Internal user usage")} +
+ +
+
+
+
+
= 80 ? "pf-m-warning" : ""}" + id="externalUsers" + > +
+ ${msg("External user usage")} +
+ +
+
+
+
+
+
+
+
`; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ak-enterprise-status-card": EnterpriseStatusCard; + } +} diff --git a/web/src/elements/forms/SearchSelect/tests/ak-search-select-view.test.ts b/web/src/elements/forms/SearchSelect/tests/ak-search-select-view.test.ts index 6c22e88696..6903874333 100644 --- a/web/src/elements/forms/SearchSelect/tests/ak-search-select-view.test.ts +++ b/web/src/elements/forms/SearchSelect/tests/ak-search-select-view.test.ts @@ -17,11 +17,10 @@ describe("Search select: Test Input Field", () => { let select: AkSearchSelectViewDriver; beforeEach(async () => { - await render( + render( html` `, document.body, ); - // @ts-ignore select = await AkSearchSelectViewDriver.build(await $("ak-search-select-view")); }); @@ -57,6 +56,7 @@ describe("Search select: Test Input Field", () => { expect(await select.open).toBe(false); expect(await select.menuIsVisible()).toBe(false); await browser.keys("A"); + select = await AkSearchSelectViewDriver.build(await $("ak-search-select-view")); expect(await select.open).toBe(true); expect(await select.menuIsVisible()).toBe(true); }); @@ -64,19 +64,19 @@ describe("Search select: Test Input Field", () => { it("should update the list as the user types", async () => { await select.focusOnInput(); await browser.keys("Ap"); - expect(await select.menuIsVisible()).toBe(true); + await expect(await select.menuIsVisible()).toBe(true); const elements = Array.from(await select.listElements()); - expect(elements.length).toBe(2); + await expect(elements.length).toBe(2); }); it("set the value when a match is close", async () => { await select.focusOnInput(); await browser.keys("Ap"); - expect(await select.menuIsVisible()).toBe(true); + await expect(await select.menuIsVisible()).toBe(true); const elements = Array.from(await select.listElements()); - expect(elements.length).toBe(2); + await expect(elements.length).toBe(2); await browser.keys(Key.Tab); - expect(await (await select.input()).getValue()).toBe("Apples"); + await expect(await (await select.input()).getValue()).toBe("Apples"); }); it("should close the menu when the user clicks away", async () => { @@ -93,8 +93,8 @@ describe("Search select: Test Input Field", () => { }); afterEach(async () => { - await document.body.querySelector("#a-separate-component")?.remove(); - await document.body.querySelector("ak-search-select-view")?.remove(); + document.body.querySelector("#a-separate-component")?.remove(); + document.body.querySelector("ak-search-select-view")?.remove(); // @ts-expect-error expression of type '"_$litPart$"' is added by Lit if (document.body["_$litPart$"]) { // @ts-expect-error expression of type '"_$litPart$"' is added by Lit diff --git a/web/tsconfig.test.json b/web/tsconfig.test.json index e0d9822190..f93c6adf05 100644 --- a/web/tsconfig.test.json +++ b/web/tsconfig.test.json @@ -3,6 +3,7 @@ "baseUrl": ".", "types": ["node", "webdriverio/async", "@wdio/cucumber-framework", "expect-webdriverio"], "target": "esnext", + "module": "esnext", "forceConsistentCasingInFileNames": true, "experimentalDecorators": true, "lib": [ diff --git a/web/wdio.conf.ts b/web/wdio.conf.ts index 550e44e500..8a78b92c83 100644 --- a/web/wdio.conf.ts +++ b/web/wdio.conf.ts @@ -1,8 +1,7 @@ import replace from "@rollup/plugin-replace"; import type { Options } from "@wdio/types"; import { cwd } from "process"; -// @ts-ignore -import * as postcssLit from "rollup-plugin-postcss-lit"; +import postcssLit from "rollup-plugin-postcss-lit"; import type { UserConfig } from "vite"; import tsconfigPaths from "vite-tsconfig-paths"; @@ -31,7 +30,6 @@ export const config: Options.Testrunner = { "preventAssignment": true, }), ...(config?.plugins ?? []), - // @ts-ignore postcssLit(), tsconfigPaths(), ], @@ -92,14 +90,13 @@ export const config: Options.Testrunner = { capabilities: [ { // capabilities for local browser web tests - browserName: "chrome", // or "firefox", "microsoftedge", "safari" - ...(runHeadless - ? { - "goog:chromeOptions": { - args: ["headless", "disable-gpu"], - }, - } - : {}), + "browserName": "chrome", // or "firefox", "microsoftedge", "safari" + "goog:chromeOptions": { + args: [ + "disable-search-engine-choice-screen", + ...(runHeadless ? ["headless", "disable-gpu"] : []), + ], + }, }, ],