From d208dc323d3911eaac2b6b3c0b6267b587f64454 Mon Sep 17 00:00:00 2001 From: Ken Sternberg Date: Tue, 20 Aug 2024 15:00:25 -0700 Subject: [PATCH 01/18] web: fix Flash of Unstructured Content while SearchSelect is loading from the backend Provide an alternative, readonly, disabled, unindexed input object with the text "Loading...", to be replaced with the _real_ input element after the content is loaded. This provides the correct appearance and spacing so the content doesn't jiggle about between the start of loading and the SearchSelect element being finalized. It was visually distracting and unappealing. --- .../forms/SearchSelect/SearchSelect.ts | 5 +- .../forms/SearchSelect/ak-search-select-ez.ts | 4 +- .../ak-search-select-loading-indicator.ts | 64 +++++++++++++++++++ .../forms/SearchSelect/ak-search-select.ts | 4 +- 4 files changed, 70 insertions(+), 7 deletions(-) create mode 100644 web/src/elements/forms/SearchSelect/ak-search-select-loading-indicator.ts diff --git a/web/src/elements/forms/SearchSelect/SearchSelect.ts b/web/src/elements/forms/SearchSelect/SearchSelect.ts index 35b6939409a0..c3ba79085805 100644 --- a/web/src/elements/forms/SearchSelect/SearchSelect.ts +++ b/web/src/elements/forms/SearchSelect/SearchSelect.ts @@ -16,6 +16,7 @@ import PFBase from "@patternfly/patternfly/patternfly-base.css"; import { ResponseError } from "@goauthentik/api"; +import "./ak-search-select-loading-indicator.js"; import "./ak-search-select-view.js"; import { SearchSelectView } from "./ak-search-select-view.js"; @@ -229,7 +230,9 @@ export class SearchSelectBase } if (!this.objects) { - return html`${msg("Loading...")}`; + return html``; } const options = this.getGroupedItems(); diff --git a/web/src/elements/forms/SearchSelect/ak-search-select-ez.ts b/web/src/elements/forms/SearchSelect/ak-search-select-ez.ts index a05d1d7f6202..416039044369 100644 --- a/web/src/elements/forms/SearchSelect/ak-search-select-ez.ts +++ b/web/src/elements/forms/SearchSelect/ak-search-select-ez.ts @@ -1,8 +1,6 @@ import { TemplateResult } from "lit"; import { customElement, property } from "lit/decorators.js"; -import PFBase from "@patternfly/patternfly/patternfly-base.css"; - import { type ISearchSelectBase, SearchSelectBase } from "./SearchSelect.js"; export interface ISearchSelectApi { @@ -48,7 +46,7 @@ export interface ISearchSelectEz extends ISearchSelectBase { @customElement("ak-search-select-ez") export class SearchSelectEz extends SearchSelectBase implements ISearchSelectEz { static get styles() { - return [PFBase]; + return [...SearchSelectBase.styles]; } @property({ type: Object, attribute: false }) diff --git a/web/src/elements/forms/SearchSelect/ak-search-select-loading-indicator.ts b/web/src/elements/forms/SearchSelect/ak-search-select-loading-indicator.ts new file mode 100644 index 000000000000..94b12212624d --- /dev/null +++ b/web/src/elements/forms/SearchSelect/ak-search-select-loading-indicator.ts @@ -0,0 +1,64 @@ +import { AKElement } from "@goauthentik/elements/Base.js"; +import { randomId } from "@goauthentik/elements/utils/randomId.js"; + +import { msg } from "@lit/localize"; +import { html } from "lit"; +import { customElement } from "lit/decorators.js"; + +import PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css"; +import PFSelect from "@patternfly/patternfly/components/Select/select.css"; +import PFBase from "@patternfly/patternfly/patternfly-base.css"; + +/** + * @class SearchSelectLoadingIndicator + * @element ak-search-select-loading-indicator + * + * Just a loading indicator to fill in while we wait for the view to settle + * + * ## Available CSS `part::` + * + * - @part ak-search-select: The main Patternfly div + * - @part ak-search-select-toggle: The Patternfly inner div + * - @part ak-search-select-wrapper: Yet another Patternfly inner div + * - @part ak-search-select-loading-indicator: The input object that hosts the "Loading..." message + */ + +@customElement("ak-search-select-loading-indicator") +export class SearchSelectLoadingIndicator extends AKElement { + static get styles() { + return [PFBase, PFFormControl, PFSelect]; + } + + connectedCallback() { + super.connectedCallback(); + this.setAttribute("data-ouia-component-type", "ak-search-select-loading-indicator"); + this.setAttribute("data-ouia-component-id", this.getAttribute("id") || randomId()); + this.setAttribute("data-ouia-component-safe", "true"); + } + + render() { + return html` +
+
+
+ +
+
+
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ak-search-select-loading-indicator": SearchSelectLoadingIndicator; + } +} diff --git a/web/src/elements/forms/SearchSelect/ak-search-select.ts b/web/src/elements/forms/SearchSelect/ak-search-select.ts index 0c7bcf9b6708..36b8b4332537 100644 --- a/web/src/elements/forms/SearchSelect/ak-search-select.ts +++ b/web/src/elements/forms/SearchSelect/ak-search-select.ts @@ -3,8 +3,6 @@ import { groupBy } from "@goauthentik/common/utils"; import { TemplateResult } from "lit"; import { customElement, property } from "lit/decorators.js"; -import PFBase from "@patternfly/patternfly/patternfly-base.css"; - import { type ISearchSelectBase, SearchSelectBase } from "./SearchSelect.js"; export interface ISearchSelect extends ISearchSelectBase { @@ -57,7 +55,7 @@ export interface ISearchSelect extends ISearchSelectBase { @customElement("ak-search-select") export class SearchSelect extends SearchSelectBase implements ISearchSelect { static get styles() { - return [PFBase]; + return [...SearchSelectBase.styles]; } // A function which takes the query state object (accepting that it may be empty) and returns a From f2f4d09de4753587c4aeb3af8201d561c56ffc4d Mon Sep 17 00:00:00 2001 From: Ken Sternberg Date: Wed, 21 Aug 2024 07:51:09 -0700 Subject: [PATCH 02/18] web: comment on state management in API layer, move file to point to correct component under test. --- .../forms/SearchSelect/SearchSelect.ts | 27 ++++++++++--------- ....test.ts => ak-search-select-view.test.ts} | 0 2 files changed, 14 insertions(+), 13 deletions(-) rename web/src/elements/forms/SearchSelect/tests/{ak-search-select.test.ts => ak-search-select-view.test.ts} (100%) diff --git a/web/src/elements/forms/SearchSelect/SearchSelect.ts b/web/src/elements/forms/SearchSelect/SearchSelect.ts index c3ba79085805..03514c649b6d 100644 --- a/web/src/elements/forms/SearchSelect/SearchSelect.ts +++ b/web/src/elements/forms/SearchSelect/SearchSelect.ts @@ -8,7 +8,7 @@ import { CustomEmitterElement } from "@goauthentik/elements/utils/eventEmitter"; import { randomId } from "@goauthentik/elements/utils/randomId.js"; import { msg } from "@lit/localize"; -import { TemplateResult, html } from "lit"; +import { PropertyValues, TemplateResult, html } from "lit"; import { property, state } from "lit/decorators.js"; import { ifDefined } from "lit/directives/if-defined.js"; @@ -32,10 +32,7 @@ export interface ISearchSelectBase { emptyOption: string; } -export class SearchSelectBase - extends CustomEmitterElement(AkControlElement) - implements ISearchSelectBase -{ +export class SearchSelectBase extends CustomEmitterElement(AkControlElement) implements ISearchSelectBase { static get styles() { return [PFBase]; } @@ -121,6 +118,7 @@ export class SearchSelectBase return Promise.resolve(); } this.isFetchingData = true; + this.dispatchEvent(new Event("loading")); return this.fetchObjects(this.query) .then((objects) => { objects.forEach((obj) => { @@ -203,10 +201,7 @@ export class SearchSelectBase return { grouped: false, options: [] }; } - if ( - groupedItems.length === 1 && - (groupedItems[0].length < 1 || groupedItems[0][0] === "") - ) { + if (groupedItems.length === 1 && (groupedItems[0].length < 1 || groupedItems[0][0] === "")) { return { grouped: false, options: makeSearchTuples(groupedItems[0][1]), @@ -229,10 +224,13 @@ export class SearchSelectBase return html`${msg("Failed to fetch objects: ")} ${this.error.detail}`; } + // `this.objects` is both a container and a sigil; if it is in the `undefined` state, it's a + // marker that this component has not yet completed a *first* load. After that, it should + // never be empty. The only state that allows it to be empty after a successful retrieval is + // a subsequent retrieval failure, in which case `this.error` above will be populated and + // displayed before this. if (!this.objects) { - return html``; + return html``; } const options = this.getGroupedItems(); @@ -251,7 +249,10 @@ export class SearchSelectBase > `; } - public override updated() { + public override updated(changed: PropertyValues) { + if (!this.isFetchingData && changed.has("objects")) { + this.dispatchEvent(new Event("ready")); + } // It is not safe for automated tests to interact with this component while it is fetching // data. if (!this.isFetchingData) { diff --git a/web/src/elements/forms/SearchSelect/tests/ak-search-select.test.ts b/web/src/elements/forms/SearchSelect/tests/ak-search-select-view.test.ts similarity index 100% rename from web/src/elements/forms/SearchSelect/tests/ak-search-select.test.ts rename to web/src/elements/forms/SearchSelect/tests/ak-search-select-view.test.ts From 014f43b0cd484827e9182227c9f5133af998da8f Mon Sep 17 00:00:00 2001 From: Ken Sternberg Date: Wed, 21 Aug 2024 10:24:46 -0700 Subject: [PATCH 03/18] web: test for flash of unstructured content - Add a unit test to ensure the "Loading..." element is displayed correctly before data arrives - Demo how to mock a `fetchObjects()` call in testing. Very cool. - Make distinguishing rule sets for code, tests, and scripts in nightmare mode - In SearchSelect, Move the `styles()` declaration to the top of the class for consistency. - To test for the FLOUC issue in SearchSelect. This is both an exercise in mocking @beryju's `fetchObjects()` protocol, and shows how we can unit test generic components that render API objects. --- web/scripts/eslint.nightmare.mjs | 295 +++++++++--------- .../forms/SearchSelect/SearchSelect.ts | 14 +- .../SearchSelect/ak-search-select-view.ts | 8 +- .../tests/ak-search-select.test.ts | 112 +++++++ 4 files changed, 280 insertions(+), 149 deletions(-) create mode 100644 web/src/elements/forms/SearchSelect/tests/ak-search-select.test.ts diff --git a/web/scripts/eslint.nightmare.mjs b/web/scripts/eslint.nightmare.mjs index 0c44096ee8c7..66869375f528 100644 --- a/web/scripts/eslint.nightmare.mjs +++ b/web/scripts/eslint.nightmare.mjs @@ -6,6 +6,142 @@ import wcconf from "eslint-plugin-wc"; import globals from "globals"; import tseslint from "typescript-eslint"; +const MAX_DEPTH = 4; +const MAX_NESTED_CALLBACKS = 4; +const MAX_PARAMS = 5; +const MAX_COGNITIVE_COMPLEXITY = 9; + +const rules = { + "accessor-pairs": "error", + "array-callback-return": "error", + "block-scoped-var": "error", + "consistent-return": "error", + "consistent-this": ["error", "that"], + "curly": ["error", "all"], + "dot-notation": [ + "error", + { + allowKeywords: true, + }, + ], + "eqeqeq": "error", + "func-names": "error", + "guard-for-in": "error", + "max-depth": ["error", MAX_DEPTH], + "max-nested-callbacks": ["error", MAX_NESTED_CALLBACKS], + "max-params": ["error", MAX_PARAMS], + "new-cap": "error", + "no-alert": "error", + "no-array-constructor": "error", + "no-bitwise": "error", + "no-caller": "error", + "no-case-declarations": "error", + "no-class-assign": "error", + "no-cond-assign": "error", + "no-const-assign": "error", + "no-constant-condition": "error", + "no-control-regex": "error", + "no-debugger": "error", + "no-delete-var": "error", + "no-div-regex": "error", + "no-dupe-args": "error", + "no-dupe-keys": "error", + "no-duplicate-case": "error", + "no-else-return": "error", + "no-empty": "error", + "no-empty-character-class": "error", + "no-empty-function": "error", + "no-labels": "error", + "no-eq-null": "error", + "no-eval": "error", + "no-ex-assign": "error", + "no-extend-native": "error", + "no-extra-bind": "error", + "no-extra-boolean-cast": "error", + "no-extra-label": "error", + "no-fallthrough": "error", + "no-func-assign": "error", + "no-implied-eval": "error", + "no-implicit-coercion": "error", + "no-implicit-globals": "error", + "no-inner-declarations": ["error", "functions"], + "no-invalid-regexp": "error", + "no-irregular-whitespace": "error", + "no-iterator": "error", + "no-invalid-this": "error", + "no-label-var": "error", + "no-lone-blocks": "error", + "no-lonely-if": "error", + "no-loop-func": "error", + "no-magic-numbers": ["error", { ignore: [0, 1, -1] }], + "no-multi-str": "error", + "no-negated-condition": "error", + "no-nested-ternary": "error", + "no-new": "error", + "no-new-func": "error", + "no-new-wrappers": "error", + "no-obj-calls": "error", + "no-octal": "error", + "no-octal-escape": "error", + "no-param-reassign": "error", + "no-proto": "error", + "no-redeclare": "error", + "no-regex-spaces": "error", + "no-restricted-syntax": ["error", "WithStatement"], + "no-script-url": "error", + "no-self-assign": "error", + "no-self-compare": "error", + "no-sequences": "error", + "no-shadow": "error", + "no-shadow-restricted-names": "error", + "no-sparse-arrays": "error", + "no-this-before-super": "error", + "no-throw-literal": "error", + "no-trailing-spaces": "error", + "no-undef": "error", + "no-undef-init": "error", + "no-unexpected-multiline": "error", + "no-useless-constructor": "error", + "no-unmodified-loop-condition": "error", + "no-unneeded-ternary": "error", + "no-unreachable": "error", + "no-unused-expressions": "error", + "no-unused-labels": "error", + "no-use-before-define": "error", + "no-useless-call": "error", + "no-dupe-class-members": "error", + "no-var": "error", + "no-void": "error", + "no-with": "error", + "prefer-arrow-callback": "error", + "prefer-const": "error", + "prefer-rest-params": "error", + "prefer-spread": "error", + "prefer-template": "error", + "radix": "error", + "require-yield": "error", + "strict": ["error", "global"], + "use-isnan": "error", + "valid-typeof": "error", + "vars-on-top": "error", + "yoda": ["error", "never"], + + "no-unused-vars": "off", + "no-console": ["error", { allow: ["debug", "warn", "error"] }], + "sonarjs/cognitive-complexity": ["off", MAX_COGNITIVE_COMPLEXITY], + "sonarjs/no-duplicate-string": "off", + "sonarjs/no-nested-template-literals": "off", + "@typescript-eslint/ban-ts-comment": "off", + "@typescript-eslint/no-unused-vars": [ + "error", + { + argsIgnorePattern: "^_", + varsIgnorePattern: "^_", + caughtErrorsIgnorePattern: "^_", + }, + ], +}; + export default [ // You would not believe how much this change has frustrated users: ["if an ignores key is used // without any other keys in the configuration object, then the patterns act as global @@ -24,6 +160,7 @@ export default [ "src/locale-codes.ts", "storybook-static/", "src/locales/", + "src/**/*.test.ts", ], }, eslint.configs.recommended, @@ -43,136 +180,7 @@ export default [ }, }, files: ["src/**"], - rules: { - "accessor-pairs": "error", - "array-callback-return": "error", - "block-scoped-var": "error", - "consistent-return": "error", - "consistent-this": ["error", "that"], - "curly": ["error", "all"], - "dot-notation": [ - "error", - { - allowKeywords: true, - }, - ], - "eqeqeq": "error", - "func-names": "error", - "guard-for-in": "error", - "max-depth": ["error", 4], - "max-nested-callbacks": ["error", 4], - "max-params": ["error", 5], - "new-cap": "error", - "no-alert": "error", - "no-array-constructor": "error", - "no-bitwise": "error", - "no-caller": "error", - "no-case-declarations": "error", - "no-class-assign": "error", - "no-cond-assign": "error", - "no-const-assign": "error", - "no-constant-condition": "error", - "no-control-regex": "error", - "no-debugger": "error", - "no-delete-var": "error", - "no-div-regex": "error", - "no-dupe-args": "error", - "no-dupe-keys": "error", - "no-duplicate-case": "error", - "no-else-return": "error", - "no-empty": "error", - "no-empty-character-class": "error", - "no-empty-function": "error", - "no-labels": "error", - "no-eq-null": "error", - "no-eval": "error", - "no-ex-assign": "error", - "no-extend-native": "error", - "no-extra-bind": "error", - "no-extra-boolean-cast": "error", - "no-extra-label": "error", - "no-fallthrough": "error", - "no-func-assign": "error", - "no-implied-eval": "error", - "no-implicit-coercion": "error", - "no-implicit-globals": "error", - "no-inner-declarations": ["error", "functions"], - "no-invalid-regexp": "error", - "no-irregular-whitespace": "error", - "no-iterator": "error", - "no-invalid-this": "error", - "no-label-var": "error", - "no-lone-blocks": "error", - "no-lonely-if": "error", - "no-loop-func": "error", - "no-magic-numbers": ["error", { ignore: [0, 1, -1] }], - "no-multi-str": "error", - "no-negated-condition": "error", - "no-nested-ternary": "error", - "no-new": "error", - "no-new-func": "error", - "no-new-wrappers": "error", - "no-obj-calls": "error", - "no-octal": "error", - "no-octal-escape": "error", - "no-param-reassign": "error", - "no-proto": "error", - "no-redeclare": "error", - "no-regex-spaces": "error", - "no-restricted-syntax": ["error", "WithStatement"], - "no-script-url": "error", - "no-self-assign": "error", - "no-self-compare": "error", - "no-sequences": "error", - "no-shadow": "error", - "no-shadow-restricted-names": "error", - "no-sparse-arrays": "error", - "no-this-before-super": "error", - "no-throw-literal": "error", - "no-trailing-spaces": "error", - "no-undef": "error", - "no-undef-init": "error", - "no-unexpected-multiline": "error", - "no-useless-constructor": "error", - "no-unmodified-loop-condition": "error", - "no-unneeded-ternary": "error", - "no-unreachable": "error", - "no-unused-expressions": "error", - "no-unused-labels": "error", - "no-use-before-define": "error", - "no-useless-call": "error", - "no-dupe-class-members": "error", - "no-var": "error", - "no-void": "error", - "no-with": "error", - "prefer-arrow-callback": "error", - "prefer-const": "error", - "prefer-rest-params": "error", - "prefer-spread": "error", - "prefer-template": "error", - "radix": "error", - "require-yield": "error", - "strict": ["error", "global"], - "use-isnan": "error", - "valid-typeof": "error", - "vars-on-top": "error", - "yoda": ["error", "never"], - - "no-unused-vars": "off", - "no-console": ["error", { allow: ["debug", "warn", "error"] }], - "sonarjs/cognitive-complexity": ["off", 9], - "sonarjs/no-duplicate-string": "off", - "sonarjs/no-nested-template-literals": "off", - "@typescript-eslint/ban-ts-comment": "off", - "@typescript-eslint/no-unused-vars": [ - "error", - { - argsIgnorePattern: "^_", - varsIgnorePattern: "^_", - caughtErrorsIgnorePattern: "^_", - }, - ], - }, + rules, }, { languageOptions: { @@ -186,18 +194,21 @@ export default [ }, }, files: ["scripts/*.mjs", "*.ts", "*.mjs"], - rules: { - "no-unused-vars": "off", - "no-console": "off", - "@typescript-eslint/ban-ts-comment": "off", - "@typescript-eslint/no-unused-vars": [ - "error", - { - argsIgnorePattern: "^_", - varsIgnorePattern: "^_", - caughtErrorsIgnorePattern: "^_", - }, - ], + rules, + }, + { + languageOptions: { + parser: tsparser, + parserOptions: { + ecmaVersion: 12, + sourceType: "module", + }, + globals: { + ...globals.nodeBuiltin, + ...globals.jest, + }, }, + files: ["src/**/*.test.ts"], + rules, }, ]; diff --git a/web/src/elements/forms/SearchSelect/SearchSelect.ts b/web/src/elements/forms/SearchSelect/SearchSelect.ts index 03514c649b6d..662e70b0e16a 100644 --- a/web/src/elements/forms/SearchSelect/SearchSelect.ts +++ b/web/src/elements/forms/SearchSelect/SearchSelect.ts @@ -32,7 +32,10 @@ export interface ISearchSelectBase { emptyOption: string; } -export class SearchSelectBase extends CustomEmitterElement(AkControlElement) implements ISearchSelectBase { +export class SearchSelectBase + extends CustomEmitterElement(AkControlElement) + implements ISearchSelectBase +{ static get styles() { return [PFBase]; } @@ -201,7 +204,10 @@ export class SearchSelectBase extends CustomEmitterElement(AkControlElement) return { grouped: false, options: [] }; } - if (groupedItems.length === 1 && (groupedItems[0].length < 1 || groupedItems[0][0] === "")) { + if ( + groupedItems.length === 1 && + (groupedItems[0].length < 1 || groupedItems[0][0] === "") + ) { return { grouped: false, options: makeSearchTuples(groupedItems[0][1]), @@ -230,7 +236,9 @@ export class SearchSelectBase extends CustomEmitterElement(AkControlElement) // a subsequent retrieval failure, in which case `this.error` above will be populated and // displayed before this. if (!this.objects) { - return html``; + return html``; } const options = this.getGroupedItems(); diff --git a/web/src/elements/forms/SearchSelect/ak-search-select-view.ts b/web/src/elements/forms/SearchSelect/ak-search-select-view.ts index 7324b9edc4c5..c2133f6f1633 100644 --- a/web/src/elements/forms/SearchSelect/ak-search-select-view.ts +++ b/web/src/elements/forms/SearchSelect/ak-search-select-view.ts @@ -69,6 +69,10 @@ export interface ISearchSelectView { */ @customElement("ak-search-select-view") export class SearchSelectView extends AKElement implements ISearchSelectView { + static get styles() { + return [PFBase, PFForm, PFFormControl, PFSelect]; + } + /** * The options collection. The simplest variant is just [key, label, optional]. See * the `./types.ts` file for variants and how to use them. @@ -186,10 +190,6 @@ export class SearchSelectView extends AKElement implements ISearchSelectView { */ flatOptions: [string, SelectOption][] = []; - static get styles() { - return [PFBase, PFForm, PFFormControl, PFSelect]; - } - connectedCallback() { super.connectedCallback(); this.setAttribute("data-ouia-component-type", "ak-search-select-view"); diff --git a/web/src/elements/forms/SearchSelect/tests/ak-search-select.test.ts b/web/src/elements/forms/SearchSelect/tests/ak-search-select.test.ts new file mode 100644 index 000000000000..c0026e8ee865 --- /dev/null +++ b/web/src/elements/forms/SearchSelect/tests/ak-search-select.test.ts @@ -0,0 +1,112 @@ +/* eslint-env jest */ +import { AKElement } from "@goauthentik/elements/Base"; +import { bound } from "@goauthentik/elements/decorators/bound.js"; +import { CustomListenerElement } from "@goauthentik/elements/utils/eventEmitter"; +import { $, browser } from "@wdio/globals"; +import { slug } from "github-slugger"; + +import { html, render } from "lit"; +import { customElement } from "lit/decorators.js"; +import { property, query } from "lit/decorators.js"; + +import "../ak-search-select.js"; +import { SearchSelect } from "../ak-search-select.js"; +import { type ViewSample, sampleData } from "../stories/sampleData.js"; +import { AkSearchSelectViewDriver } from "./ak-search-select-view.comp.js"; + +const renderElement = (fruit: ViewSample) => fruit.produce; + +const renderDescription = (fruit: ViewSample) => html`${fruit.desc}`; + +const renderValue = (fruit: ViewSample | undefined) => slug(fruit?.produce ?? ""); + +@customElement("ak-mock-search-group") +export class MockSearch extends CustomListenerElement(AKElement) { + /** + * The current fruit + * + * @attr + */ + @property({ type: String, reflect: true }) + fruit?: string; + + @query("ak-search-select") + search!: SearchSelect; + + selectedFruit?: ViewSample; + + get value() { + return this.selectedFruit ? renderValue(this.selectedFruit) : undefined; + } + + @bound + handleSearchUpdate(ev: CustomEvent) { + ev.stopPropagation(); + this.selectedFruit = ev.detail.value; + this.dispatchEvent(new InputEvent("input", { bubbles: true, composed: true })); + } + + @bound + selected(fruit: ViewSample) { + return this.fruit === slug(fruit.produce); + } + + @bound + fetchObjects() { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const resolver = (resolve: any) => { + this.addEventListener("resolve", () => { + resolve(sampleData); + }); + }; + return new Promise(resolver); + } + + render() { + return html` + + + `; + } +} + +describe("Search select: event driven startup", () => { + let select: AkSearchSelectViewDriver; + let wrapper: SearchSelect; + + beforeEach(async () => { + await render(html``, document.body); + // @ts-ignore + wrapper = await $(">>>ak-search-select"); + }); + + it("should shift from the loading indicator to search select view on fetch event completed", async () => { + expect(await wrapper).toBeExisting(); + expect(await $(">>>ak-search-select-loading-indicator")).toBeDisplayed(); + await browser.execute(() => { + const mock = document.querySelector("ak-mock-search-group"); + mock?.dispatchEvent(new Event("resolve")); + }); + expect(await $(">>>ak-search-select-loading-indicator")).not.toBeDisplayed(); + select = await AkSearchSelectViewDriver.build(await $(">>>ak-search-select-view")); + expect(await select).toBeExisting(); + }); + + afterEach(async () => { + await document.body.querySelector("ak-mock-search-group")?.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 + delete document.body["_$litPart$"]; + } + }); +}); From 351a83a400a408f35f1807a7af8767b587b8a5e7 Mon Sep 17 00:00:00 2001 From: Andreas <43118918+eulores@users.noreply.github.com> Date: Wed, 21 Aug 2024 21:19:34 +0200 Subject: [PATCH 04/18] website/docs: Correct the forward authentication configuration template for Caddy (#11012) Correct the forward authentication configuration template for Caddy The directives were not executed in the given order, but instead, using the implicit sequence defined in https://caddyserver.com/docs/caddyfile/directives#directive-order. Surrounding the directives with route {} fixes this. Signed-off-by: Andreas <43118918+eulores@users.noreply.github.com> --- .../docs/providers/proxy/_caddy_standalone.md | 27 ++++++++++--------- 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/website/docs/providers/proxy/_caddy_standalone.md b/website/docs/providers/proxy/_caddy_standalone.md index 09e857a6ceae..2ee6b700cc7a 100644 --- a/website/docs/providers/proxy/_caddy_standalone.md +++ b/website/docs/providers/proxy/_caddy_standalone.md @@ -2,22 +2,25 @@ Use the following configuration: ``` app.company { - # always forward outpost path to actual outpost - reverse_proxy /outpost.goauthentik.io/* http://outpost.company:9000 + # directive execution order is only as stated if enclosed with route. + route { + # always forward outpost path to actual outpost + reverse_proxy /outpost.goauthentik.io/* http://outpost.company:9000 - # forward authentication to outpost - forward_auth http://outpost.company:9000 { - uri /outpost.goauthentik.io/auth/caddy + # forward authentication to outpost + forward_auth http://outpost.company:9000 { + uri /outpost.goauthentik.io/auth/caddy - # capitalization of the headers is important, otherwise they will be empty - copy_headers X-Authentik-Username X-Authentik-Groups X-Authentik-Email X-Authentik-Name X-Authentik-Uid X-Authentik-Jwt X-Authentik-Meta-Jwks X-Authentik-Meta-Outpost X-Authentik-Meta-Provider X-Authentik-Meta-App X-Authentik-Meta-Version + # capitalization of the headers is important, otherwise they will be empty + copy_headers X-Authentik-Username X-Authentik-Groups X-Authentik-Email X-Authentik-Name X-Authentik-Uid X-Authentik-Jwt X-Authentik-Meta-Jwks X-Authentik-Meta-Outpost X-Authentik-Meta-Provider X-Authentik-Meta-App X-Authentik-Meta-Version - # optional, in this config trust all private ranges, should probably be set to the outposts IP - trusted_proxies private_ranges - } + # optional, in this config trust all private ranges, should probably be set to the outposts IP + trusted_proxies private_ranges + } - # actual site configuration below, for example - reverse_proxy localhost:1234 + # actual site configuration below, for example + reverse_proxy localhost:1234 + } } ``` From 5a8bffadcaea4cb0fdfd7c07d9a1f5f8bfa71d71 Mon Sep 17 00:00:00 2001 From: Ken Sternberg Date: Wed, 21 Aug 2024 12:23:07 -0700 Subject: [PATCH 05/18] web: interim commit of the basic sortable & selectable table. --- web/eslint.config.mjs | 4 + web/package.json | 1 + web/src/elements/ak-table/TableColumn.ts | 103 +++++++ web/src/elements/ak-table/ak-select-table.ts | 257 ++++++++++++++++++ web/src/elements/ak-table/ak-simple-table.ts | 199 ++++++++++++++ .../stories/ak-select-table.stories.ts | 138 ++++++++++ .../stories/ak-simple-table.stories.ts | 147 ++++++++++ .../ak-table/stories/sample_nutrition_db.ts | 213 +++++++++++++++ web/src/elements/ak-table/types.ts | 62 +++++ web/src/elements/ak-table/utils.ts | 99 +++++++ 10 files changed, 1223 insertions(+) create mode 100644 web/src/elements/ak-table/TableColumn.ts create mode 100644 web/src/elements/ak-table/ak-select-table.ts create mode 100644 web/src/elements/ak-table/ak-simple-table.ts create mode 100644 web/src/elements/ak-table/stories/ak-select-table.stories.ts create mode 100644 web/src/elements/ak-table/stories/ak-simple-table.stories.ts create mode 100644 web/src/elements/ak-table/stories/sample_nutrition_db.ts create mode 100644 web/src/elements/ak-table/types.ts create mode 100644 web/src/elements/ak-table/utils.ts diff --git a/web/eslint.config.mjs b/web/eslint.config.mjs index 65b6199ae15f..8c835055a894 100644 --- a/web/eslint.config.mjs +++ b/web/eslint.config.mjs @@ -41,6 +41,10 @@ export default [ }, files: ["src/**"], rules: { + "lit/attribute-names": "error", + "lit/no-private-properties": "error", + "lit/prefer-nothing": "warn", + "lit/no-template-bind": "error", "no-unused-vars": "off", "no-console": ["error", { allow: ["debug", "warn", "error"] }], "@typescript-eslint/ban-ts-comment": "off", diff --git a/web/package.json b/web/package.json index d455ec1734fa..ae4aee75bd79 100644 --- a/web/package.json +++ b/web/package.json @@ -296,6 +296,7 @@ "lint:types", "lint:components", "lint:spelling", + "lint:package", "lint:lockfile", "lint:lockfiles", "lint:precommit", diff --git a/web/src/elements/ak-table/TableColumn.ts b/web/src/elements/ak-table/TableColumn.ts new file mode 100644 index 000000000000..447c67c3d1af --- /dev/null +++ b/web/src/elements/ak-table/TableColumn.ts @@ -0,0 +1,103 @@ +import { bound } from "@goauthentik/elements/decorators/bound"; + +import { html } from "lit"; +import { classMap } from "lit/directives/class-map.js"; + +// Because TableColumn isn't a component, it won't be the dispatch target and it won't have an +// identity beyond the host passed in, so we must include with the event a payload that identifies +// the source TableColumn in some way. +// +export class TableSortEvent extends Event { + static readonly eventName = "tablesort"; + public value: string; + constructor(value: string) { + super(TableSortEvent.eventName, { composed: true, bubbles: true }); + this.value = value; + } +} + +/** + * class TableColumn + * + * This is a helper class for rendering the contents of a table column header. + * + * ## Events + * + * - @fires tablesort: when the header is clicked, if the host is not undefined + * + */ +export class TableColumn { + /** + * The text to show in the column header + */ + value: string; + + /** + * If not undefined, the element that will first receive the `tablesort` event + */ + host?: HTMLElement; + + /** + * If not undefined, show the sort indicator, and indicate the sort state + */ + orderBy?: string; + + constructor(value: string, orderBy?: string, host?: HTMLElement) { + this.value = value; + this.orderBy = orderBy; + if (host) { + this.host = host; + } + } + + @bound + private onSort() { + if (this.host && this.orderBy) { + this.host.dispatchEvent(new TableSortEvent(this.orderBy)); + } + } + + private sortIndicator(orderBy: string) { + // prettier-ignore + switch(orderBy) { + case this.orderBy: return "fa-long-arrow-alt-down"; + case `-${this.orderBy}`: return "fa-long-arrow-alt-up"; + default: return "fa-arrows-alt-v"; + } + } + + private sortButton(orderBy: string) { + return html` `; + } + + public render(orderBy?: string) { + const isSelected = orderBy === this.orderBy || orderBy === `-${this.orderBy}`; + + const classes = { + "pf-c-table__sort": Boolean(this.host && this.orderBy), + "pf-m-selected": Boolean(this.host && isSelected), + }; + + return html` + ${orderBy && this.orderBy ? this.sortButton(orderBy) : html`${this.value}`} + `; + } +} + +declare global { + interface GlobalEventHandlersEventMap { + [TableSortEvent.eventName]: TableSortEvent; + } +} diff --git a/web/src/elements/ak-table/ak-select-table.ts b/web/src/elements/ak-table/ak-select-table.ts new file mode 100644 index 000000000000..59c12ba16a96 --- /dev/null +++ b/web/src/elements/ak-table/ak-select-table.ts @@ -0,0 +1,257 @@ +import { bound } from "@goauthentik/elements/decorators/bound"; + +import { msg } from "@lit/localize"; +import { PropertyValues, TemplateResult, html } from "lit"; +import { customElement, property, queryAll } from "lit/decorators.js"; +import { map } from "lit/directives/map.js"; + +import { type ISimpleTable, SimpleTable } from "./ak-simple-table"; +import type { TableRow } from "./types"; + +export interface ISelectTable extends ISimpleTable { + value: string; + radio: boolean; + valueSep: string; + selected: string[]; +} + +/** + * @element ak-select-table + * @class SelectTable + * + * Extends the SimpleTable with a select column, emitting a `change` event whenever the selected + * table updates. The `multiple` keyword creates a multi-select table. Sorting behavior resembles + * that of `SimpleTable`. + * + * Aside from overriding the `renderRow()` and `renderColumnHeaders()` methods to add the room + * for the checkbox, this is entirely an additive feature; the logic of `ak-simple-table` is + * otherwise completely preserved. + * + * Note that this implementation caches any values that it may have seen prior, but are not + * currently visible on the page. This preserves the selection collection in case the client wishes + * to implement pagination. + * + * ## Properties + * + * - @prop content (see types): The content to show. The simplest content is just `string[][]`, but + * see the types. + * + * - @prop columns (see types): The column headers for the table. Can be just a `string[]`, but see + * the types. + * + * - @attr (string, optional): The current column to order the content by. By convention, prefix + * with a `-` to indicate a reverse sort order. (See "Does not handle sorting" above). + * + * - @attr multiple (boolean): If true, this table is "multi-select" and a 'select all' checkbox will + * be available. + * + * - @attr value (string): If set, will set the value of the component. For multi-select, will split + * on the `valueSep` (see next entry). Get is the reverse: either the value of the component, + * or for multi-select, the value of the component `.join()`ed with the `valueSep` + * + * - @attr valueSep (string): For multi-select only, the (ideally one) characters which will separate + * values. + * + * - @prop selected (string[]): The values selected. Always an array, even for mult-select. When not + * multi-select, will have zero or one items only. + * + * ## Messages + * + * - `clear()`: Sets the `selected` collection to empty, erasing all values. + * + * ## Events + * + * - @fires tablesort (Custom): A table header has been clicked, requesting a sort event. See "Does + * not handle sorting" above. + * + * ## CSS Customizations + * + * - @part table: the `` element + * - @part column-header: the `` element for the column headers themselves + * - @part column-row: The `` element for the column headers + * - @part column-item: The `` element for a group header + * - @part group-row: The `` element for a group header + * - @part group-head: The `` element for a standard row + * - @part cell cell-{index}: The ``; + } + // The double `checked` there is not a typo. The first one ensures the input's DOM object + // receives the state; the second ensures the input tag on the page reflects the state + // accurately. See https://github.com/lit/lit-element/issues/601 + const checked = this.selected.includes(key); + return html``; + } + + // Without the `bound`, Lit's `map()` will pick up the parent class's `renderRow()`. This + // override makes room for the select checkbox. + @bound + public override renderRow(row: TableRow, _rowidx: number) { + return html` + ${this.renderCheckbox(row.key)} + ${map(row.content, (col, idx) => html``)} + `; + } + + renderAllOnThisPageCheckbox(): TemplateResult { + const checked = this.selectedOnPage.length && this.selectedOnPage.length === this.valuesOnPage.length; + + const onInput = (ev: InputEvent) => { + const selected = [...this.selected]; + const values = this.valuesOnPage; + // The behavior preserves the `selected` elements that are not currently visible; its + // purpose is to preserve the complete value list locally in case clients want to + // implement pagination. To clear the entire list, call `clear()` on the component. + this.selected = (ev.target as HTMLInputElement).checked + ? // add to `selected` all values not already present + [...selected, ...values.filter((i) => !selected.includes(i))] + : // remove from `selected` all values present + this.selected.filter((i) => !values.includes(i)); + }; + + return html``; + } + + // This override makes room for the select checkbox. + public override renderColumnHeaders() { + return html` + ${this.multiple ? this.renderAllOnThisPageCheckbox() : html``} + ${map(this.icolumns, (col) => col.render(this.order))} + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ak-select-table": SelectTable; + } +} diff --git a/web/src/elements/ak-table/ak-simple-table.ts b/web/src/elements/ak-table/ak-simple-table.ts new file mode 100644 index 000000000000..a7e035c0ca7e --- /dev/null +++ b/web/src/elements/ak-table/ak-simple-table.ts @@ -0,0 +1,199 @@ +import { AKElement } from "@goauthentik/elements/Base.js"; +import { bound } from "@goauthentik/elements/decorators/bound"; +import { randomId } from "@goauthentik/elements/utils/randomId.js"; + +import { TemplateResult, html } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import { map } from "lit/directives/map.js"; + +import PFTable from "@patternfly/patternfly/components/Table/table.css"; +import PFBase from "@patternfly/patternfly/patternfly-base.css"; + +import { TableColumn } from "./TableColumn.js"; +import type { Column, TableFlat, TableGroup, TableGrouped, TableRow } from "./types"; +import { convertContent } from "./utils"; + +export type RawContent = string | number | TemplateResult; +export type ContentType = RawContent[][] | TableRow[] | TableGrouped; + +export interface ISimpleTable { + columns: Column[]; + content: TableGrouped | TableFlat; + order?: string; +} + +/** + * @element ak-simple-table + * class Table + * + * Our simplest table. It takes a column definition and an array (rows) of array (one row) of + * TemplateResults, and it renders a table. If the column definition includes keys, the column will + * be rendered with a sort indicator. + * + * ## Does not handle sorting. + * + * ... that's _all_ this does. It is the responsibility of clients using this table to: + * + * - marshall their content into TemplateResults + * - catch the 'tablesort' event and send the table a new collection of rows sorted according to + * the client scheme. + * + * ## Properties + * + * - @prop content (see types): The content to show. The simplest content is just `string[][]`, but + * see the types. + * + * - @prop columns (see types): The column headers for the table. Can be just a `string[]`, but see + * the types. + * + * - @attr order (string, optional): The current column to order the content by. By convention, prefix + * with a `-` to indicate a reverse sort order. (See "Does not handle sorting" above). + * + * ## Events + * + * - @fires tablesort (Custom): A table header has been clicked, requesting a sort event. See "Does + * not handle sorting" above. + * + * ## CSS Customizations + * + * - @part table: the `
` element for each column header + * - @part column-text: The text `` of the column header + * - @part column-sort: The sort indicator `` of a column header, if activated + * - @part group-header: The `
` element for a group header + * - @part row: The `
` element for a single datum. Can be accessed via the index, + * which is zero-indexed + * - @part select-all-header: The `` element for the select-all checkbox, when _multiple_ + * - @part select-all-input: The `` element for the select-all checkbox, when _multiple_ + * - @part select-cell: The `` element for a select checkbox + * - @part select-input: The ` element for a select checkbox + * + * NOTE: The select-cell is *not* indexed. The `::part(cell-{idx})` remains indexed by zero; you + * cannot access the select-cell via `cell-0`; that would be the first data column. This is due to a + * limitation on the `part::` semantics. + * + */ + +@customElement("ak-select-table") +export class SelectTable extends SimpleTable { + // WARNING: This property and `set selected` must mirror each other perfectly. + @property({ type: String, attribute: true, reflect: true }) + public set value(value: string) { + this._value = value; + this._selected = value.split(this.valueSep).filter((v) => v.trim() !== ""); + } + + public get value() { + return this._value; + } + + private _value: string = ""; + + @property({ type: Boolean, attribute: true }) + multiple = false; + + @property({ type: String, attribute: true }) + valueSep = ";"; + + // WARNING: This property and `set value` must mirror each other perfectly. + @property({ attribute: false }) + public set selected(selected: string[]) { + this._selected = selected; + this._value = this._selected.toSorted().join(this.valueSep); + } + + @queryAll('input[data-ouia-component-role="select"]') + selectCheckboxesOnPage!: HTMLInputElement[]; + + public get selected() { + return this._selected; + } + + public json() { + return this._selected; + } + + private get valuesOnPage() { + return Array.from(this.selectCheckboxesOnPage).map((checkbox) => checkbox.value); + } + + private get checkedValuesOnPage() { + return Array.from(this.selectCheckboxesOnPage) + .filter((checkbox) => checkbox.checked) + .map((checkbox) => checkbox.value); + } + + private get selectedOnPage() { + return this.checkedValuesOnPage.filter((value) => this._selected.includes(value)); + } + + public clear() { + this.selected = []; + } + + private _selected: string[] = []; + + @bound + private onSelect(ev: InputEvent) { + ev.stopPropagation(); + const value = (ev.target as HTMLInputElement).value; + if (this.multiple) { + this.selected = this.selected.includes(value) + ? this.selected.filter((v) => v !== value) + : [...this.selected, value]; + } else { + this.selected = this.selected.includes(value) ? [] : [value]; + } + this.dispatchEvent(new Event("change")); + } + + protected override ouiaTypeDeclaration() { + this.setAttribute("data-ouia-component-type", "ak-select-table"); + } + + public override connectedCallback(): void { + super.connectedCallback(); + this.dataset.akControl = "true"; + } + + public override willUpdate(changed: PropertyValues) { + super.willUpdate(changed); + // Ensure the value attribute in the component reflects the current value after an update + // via onSelect() or other change to `this.selected`. Done here instead of in `updated` as + // changes here cannot trigger an update. See: + // https://lit.dev/docs/components/lifecycle/#willupdate + this.setAttribute("value", this._value); + } + + public renderCheckbox(key: string | undefined) { + if (key === undefined) { + return html` + +
${col}
+ +
` element + * - @part column-header: the `` element for the column headers themselves + * - @part column-row: The `` element for the column headers + * - @part column-item: The `` element for a group header + * - @part group-row: The `` element for a group header + * - @part group-head: The `` element for a standard row + * - @part cell cell-{index}: The ` + ${map( + row.content, + (col, idx) => html``, + )} + `; + } + + public renderRows(rows: TableRow[]) { + return html` + ${map(rows, this.renderRow)} + `; + } + + @bound + public renderRowGroup({ group, content }: TableGroup) { + return html` + + + + + ${this.renderRows(content)}`; + } + + @bound + public renderRowGroups(rowGroups: TableGroup[]) { + return html`${map(rowGroups, this.renderRowGroup)}`; + } + + public renderBody() { + // prettier-ignore + return this.content.kind === 'flat' + ? this.renderRows(this.content.content) + : this.renderRowGroups(this.content.content); + } + + public renderColumnHeaders() { + return html` + ${map(this.icolumns, (col) => col.render(this.order))} + `; + } + + public renderTable() { + return html` +
` element for each column header + * - @part column-text: The text `` of the column header + * - @part column-sort: The sort indicator `` of a column header, if activated + * - @part group-header: The `
` element for a group header + * - @part row: The `
` element for a single datum. Can be accessed via the index, + * which is zero-indexed + * + */ + +@customElement("ak-simple-table") +export class SimpleTable extends AKElement implements ISimpleTable { + static get styles() { + return [PFBase, PFTable]; + } + + @property({ type: String, attribute: true, reflect: true }) + order?: string; + + @property({ type: Array, attribute: false }) + columns: Column[] = []; + + @property({ type: Object, attribute: false }) + set content(content: ContentType) { + this._content = convertContent(content); + } + + get content(): TableGrouped | TableFlat { + return this._content; + } + + private _content: TableGrouped | TableFlat = { + kind: "flat", + content: [], + }; + + protected get icolumns(): TableColumn[] { + const hosted = (column: TableColumn) => { + column.host = this; + return column; + }; + + return this.columns.map((column) => + typeof column === "string" + ? hosted(new TableColumn(column)) + : Array.isArray(column) + ? hosted(new TableColumn(...column)) + : hosted(column), + ); + } + + protected ouiaTypeDeclaration() { + this.setAttribute("data-ouia-component-type", "ak-simple-table"); + } + + public override connectedCallback(): void { + super.connectedCallback(); + this.ouiaTypeDeclaration(); + this.setAttribute("data-ouia-component-id", this.getAttribute("id") || randomId()); + } + + public override performUpdate() { + this.removeAttribute("data-ouia-component-safe"); + super.performUpdate(); + } + + public renderRow(row: TableRow, _rownum: number) { + return html`
${col}
+ ${group} +
+ + ${this.renderColumnHeaders()} + + ${this.renderBody()} +
+ `; + } + + public render() { + return this.renderTable(); + } + + public override updated() { + this.setAttribute("data-ouia-component-safe", "true"); + } +} + +declare global { + interface HTMLElementTagNameMap { + "ak-simple-table": SimpleTable; + } +} diff --git a/web/src/elements/ak-table/stories/ak-select-table.stories.ts b/web/src/elements/ak-table/stories/ak-select-table.stories.ts new file mode 100644 index 000000000000..e14a9843cbdf --- /dev/null +++ b/web/src/elements/ak-table/stories/ak-select-table.stories.ts @@ -0,0 +1,138 @@ +import { Meta, StoryObj } from "@storybook/web-components"; +import { slug } from "github-slugger"; + +import { LitElement, TemplateResult, html } from "lit"; +import { customElement, property, state } from "lit/decorators.js"; + +import { TableSortEvent } from "../TableColumn.js"; +import "../ak-select-table.js"; +import { SelectTable } from "../ak-select-table.js"; +import { nutritionDbUSDA } from "./sample_nutrition_db.js"; + +const metadata: Meta = { + title: "Elements / Table / SelectTable", + component: "ak-select-table", + parameters: { + docs: { + description: { + component: "Our table with a select field", + }, + }, + }, + argTypes: { + content: { + type: "function", + description: "An array of arrays of items to show", + }, + columns: { + type: "function", + description: "An array of column headers", + }, + order: { + type: "string", + description: + "A key indicating which column to highlight as the current sort target, if any", + }, + }, +}; + +export default metadata; + +type Story = StoryObj; + +const container = (testItem: TemplateResult) => + html`
+ + ${testItem} +
`; + +const columns = ["Name", "Calories", "Protein", "Fiber", "Sugar"]; +const content = nutritionDbUSDA.map(({ name, calories, sugar, fiber, protein }) => ({ + key: slug(name), + content: [name, calories, protein, fiber, sugar].map((a) => html`${a}`), +})); + +export const Default: Story = { + render: () => + container( + html``, + ), +}; + +export const MultiSelect: Story = { + render: () => + container( + html``, + ), +}; + +type Ord = Record; + +@customElement("ak-select-table-test-sort") +export class SimpleTableSortTest extends LitElement { + @state() + order = "name"; + + @state() + sortDown = true; + + @property({ type: Boolean, attribute: true }) + multiple = false; + + columns = columns.map((a) => [a, a.toLowerCase()]); + + get content() { + const content = [...nutritionDbUSDA]; + + // Sort according to the key + const comparison = this.sortDown + ? (a: Ord, b: Ord) => (a[this.order] > b[this.order] ? -1 : 1) + : (a: Ord, b: Ord) => (a[this.order] > b[this.order] ? 1 : -1); + content.sort(comparison); + + // Return the content, processed to comply with the format expected by a selectable table. + return content.map(({ name, calories, sugar, fiber, protein }) => ({ + key: slug(name), + content: [name, calories, protein, fiber, sugar].map((a) => html`${a}`), + })); + } + + render() { + const onTableSort = (event: TableSortEvent) => { + if (event.value === this.order) { + this.sortDown = !this.sortDown; + return; + } + this.order = event.value; + }; + + const direction = this.sortDown ? "" : "-"; + + return html``; + } +} + +export const TableWithSorting: Story = { + render: () => container(html``), +}; + +export const MultiselectTableWithSorting: Story = { + render: () => container(html``), +}; diff --git a/web/src/elements/ak-table/stories/ak-simple-table.stories.ts b/web/src/elements/ak-table/stories/ak-simple-table.stories.ts new file mode 100644 index 000000000000..99a202f8d416 --- /dev/null +++ b/web/src/elements/ak-table/stories/ak-simple-table.stories.ts @@ -0,0 +1,147 @@ +import { Meta, StoryObj } from "@storybook/web-components"; +import { slug } from "github-slugger"; + +import { LitElement, TemplateResult, html } from "lit"; +import { customElement, state } from "lit/decorators.js"; + +import { TableSortEvent } from "../TableColumn.js"; +import "../ak-simple-table.js"; +import { SimpleTable } from "../ak-simple-table.js"; +import { KeyBy } from "../types"; +import type { TableRow } from "../types"; +import { convertContent } from "../utils.js"; +import { nutritionDbUSDA } from "./sample_nutrition_db.js"; + +const metadata: Meta = { + title: "Elements / Table / SimpleTable", + component: "ak-simple-table", + parameters: { + docs: { + description: { + component: "Our basic table", + }, + }, + }, + argTypes: { + content: { + type: "function", + description: "An array of arrays of items to show", + }, + columns: { + type: "function", + description: "An array of column headers", + }, + order: { + type: "string", + description: + "A key indicating which column to highlight as the current sort target, if any", + }, + }, +}; + +export default metadata; + +type Story = StoryObj; + +const container = (testItem: TemplateResult) => + html`
+ + ${testItem} +
`; + +const columns = ["Name", "Calories", "Protein", "Fiber", "Sugar"]; +const content = nutritionDbUSDA.map(({ name, calories, sugar, fiber, protein }) => [ + name, + calories, + protein, + fiber, + sugar, +]); + +export const Default: Story = { + render: () => + container( + html``, + ), +}; + +type Ord = Record; + +@customElement("ak-simple-table-test-sort") +export class SimpleTableSortTest extends LitElement { + @state() + order = "name"; + + @state() + sortDown = true; + + columns = columns.map((a) => [a, a.toLowerCase()]); + + get content() { + const content = [...nutritionDbUSDA]; + const comparison = this.sortDown + ? (a: Ord, b: Ord) => (a[this.order] < b[this.order] ? -1 : 1) + : (a: Ord, b: Ord) => (a[this.order] < b[this.order] ? 1 : -1); + content.sort(comparison); + return content.map(({ name, calories, sugar, fiber, protein }) => [ + name, + calories, + protein, + fiber, + sugar, + ]); + } + + render() { + const onTableSort = (event: TableSortEvent) => { + if (event.value === this.order) { + this.sortDown = !this.sortDown; + return; + } + this.order = event.value; + }; + + const direction = this.sortDown ? "" : "-"; + + return html``; + } +} + +export const TableWithSorting: Story = { + render: () => container(html``), +}; + +const rowContent: TableRow[] = nutritionDbUSDA.map(({ name, calories, sugar, fiber, protein }) => ({ + key: slug(name), + content: [name, calories, protein, fiber, sugar].map((a) => html`${a}`), +})); + +export const PreprocessedContent: Story = { + render: () => + container( + html``, + ), +}; + +const capitalize = (s = "") => `${s.substring(0, 1).toUpperCase()}${s.substring(1)}`; + +const groups = new Map(nutritionDbUSDA.map(({ name, group }) => [name, group])); +const groupFoods: KeyBy = (content) => capitalize(groups.get(content[0] as string)); +const groupedContent = convertContent(content, { groupBy: groupFoods }); + +export const GroupedTable: Story = { + render: () => + html``, +}; diff --git a/web/src/elements/ak-table/stories/sample_nutrition_db.ts b/web/src/elements/ak-table/stories/sample_nutrition_db.ts new file mode 100644 index 000000000000..2ddc33025913 --- /dev/null +++ b/web/src/elements/ak-table/stories/sample_nutrition_db.ts @@ -0,0 +1,213 @@ +// Taken from the https://fdc.nal.usda.gov/data-documentation.html database of "Foundational Foods." + +export const nutritionDbUSDA = [ + { + name: "Hummus", + calories: 229, + sugar: "0.34g", + fiber: "5.4g", + protein: "7.35g", + group: "processed", + }, + { + name: "Onion Rings, breaded", + calories: 288, + sugar: "4.5g", + fiber: "2.4g", + protein: "4.52g", + group: "processed", + }, + { + name: "Bread, white", + calories: 270, + sugar: "5.34g", + fiber: "2.3g", + protein: "9.43g", + group: "processed", + }, + { + name: "Sweet and Sour Pork, frozen", + calories: 260, + sugar: "10.3g", + fiber: "1g", + protein: "8.88g", + group: "processed", + }, + { + name: "Almonds", + calories: 620, + sugar: "4.17g", + fiber: "11g", + protein: "20.4g", + group: "organic", + }, + { + name: "Kale", + calories: 35, + sugar: "0.8g", + fiber: "4.1g", + protein: "2.92g", + group: "organic", + }, + { + name: "Pickles", + calories: 12, + sugar: "1.28g", + fiber: "1g", + protein: "0.48g", + group: "organic", + }, + { + name: "Kiwifruit", + calories: 58, + sugar: "8.99g", + fiber: "3g", + protein: "1.06g", + group: "organic", + }, + { + name: "Sunflower Seeds", + calories: 612, + sugar: "3.14g", + fiber: "10.3g", + protein: "21g", + group: "organic", + }, + + { + name: "Nectarines", + calories: 39, + sugar: "7.89g", + fiber: "1.5g", + protein: "1.06g", + group: "organic", + }, + { + name: "Oatmeal Cookies", + calories: 430, + sugar: "34.8g", + fiber: "3.3g", + protein: "5.79g", + group: "processed", + }, + { + name: "Carrots", + calories: 37, + sugar: "4.2g", + fiber: "3.2g", + protein: "0.81g", + group: "organic", + }, + { + name: "Figs", + calories: 249, + sugar: "47.9g", + fiber: "9.8g", + protein: "3.3g", + group: "organic", + }, + { + name: "Lettuce", + calories: 17, + sugar: "1.19g", + fiber: "1.8g", + protein: "1.24g", + group: "organic", + }, + { + name: "Cantaloupe", + calories: 34, + sugar: "7.88g", + fiber: "0.8g", + protein: "0.82g", + group: "organic", + }, + { + name: "Oranges", + calories: 47, + sugar: "8.57g", + fiber: "2g", + protein: "0.91g", + group: "organic", + }, + { + name: "Pears", + calories: 57, + sugar: "9.69g", + fiber: "3.1g", + protein: "0.38g", + group: "organic", + }, + { + name: "Broccoli", + calories: 31, + sugar: "1.4g", + fiber: "2.4g", + protein: "2.57g", + group: "organic", + }, + { + name: "Eggs", + calories: 148, + sugar: "0.2g", + fiber: "0g", + protein: "12.4g", + group: "organic", + }, + { + name: "Onions", + calories: 44, + sugar: "5.76g", + fiber: "2.2g", + protein: "0.94g", + group: "organic", + }, + { + name: "Bananas", + calories: 97, + sugar: "15.8g", + fiber: "1.7g", + protein: "0.74g", + group: "organic", + }, + { + name: "Apples", + calories: 64.7, + sugar: "13.3g", + fiber: "2.08g", + protein: "0.148g", + group: "organic", + }, + { + name: "Pineapple", + calories: 60.1, + sugar: "11.4g", + fiber: "0.935g", + protein: "0.461g", + group: "organic", + }, + { + name: "Snap Green Beans", + calories: 40, + sugar: "2.33g", + fiber: "3.01g", + protein: "1.97g", + group: "organic", + }, + { + name: "Beets", + calories: 44.6, + sugar: "5.1g", + fiber: "3.12g", + protein: "1.69g", + group: "organic", + }, + { + name: "Eggplant", + calories: 26.1, + sugar: "2.35g", + fiber: "2.45g", + protein: "0.852g", + group: "organic", + }, +]; diff --git a/web/src/elements/ak-table/types.ts b/web/src/elements/ak-table/types.ts new file mode 100644 index 000000000000..e036faa9bbbc --- /dev/null +++ b/web/src/elements/ak-table/types.ts @@ -0,0 +1,62 @@ +import { TemplateResult } from "lit"; + +import { TableColumn } from "./TableColumn"; + +// authentik's tables (ak-basic-table, ak-select-table, ak-table) all take a tuple of two +// or three items, or a collection of groups of such tuples. In order to push dynamic checking +// around, we also allow the inclusion of a fourth component, which is just a scratchpad the +// developer can use for their own reasons. + +// The displayed element for our list can be a TemplateResult. If it is, we *strongly* recommend +// that you include the `sortBy` string as well, which is used for sorting but is also used for our +// autocomplete element (ak-search-select) both for tracking the user's input and for what we +// display in the autocomplete input box. + +/** + * - key (string, option): the value to return on "click", if the table is clickable / selectable + * - content (TemplateResult[]): The contents of the rows to be shown + */ +export type TableRow = { + key?: string; + content: TemplateResult[]; + expansion?: () => TemplateResult; +}; + +/** + * For a collection of rows without groups + * + */ +export type TableFlat = { + kind: "flat"; + content: TableRow[]; +}; + +/** + * For a single grouped collection; the name of the group and the contents. + */ +export type TableGroup = { + kind: "group"; + group: string; + content: TableRow[]; +}; + +/** + * For a grouped collection, all of the groups. + */ +export type TableGrouped = { + kind: "groups"; + content: TableGroup[]; +}; + +/** + * For convenience, a table column can be defined either by the string defining its + * content, or by a pair of strings defining the content and the sort-by header + * used to indicate and control sortability. + */ +export type Column = TableColumn | string | [string, string?]; + +export type RawType = string | number | TemplateResult; +export type TableInputType = RawType[][] | TableRow[] | TableGrouped | TableFlat; +export type TableType = TableGrouped | TableFlat; + +export type KeyBy = (_: RawType[]) => string; diff --git a/web/src/elements/ak-table/utils.ts b/web/src/elements/ak-table/utils.ts new file mode 100644 index 000000000000..9555c151adea --- /dev/null +++ b/web/src/elements/ak-table/utils.ts @@ -0,0 +1,99 @@ +import { groupBy as groupByProcessor } from "@goauthentik/common/utils.js"; + +import { html } from "lit"; + +import { + KeyBy, + RawType, + TableFlat, + TableGrouped, + TableInputType, + TableRow, + TableType, +} from "./types"; + +// TypeScript was extremely specific about due diligence here. +export const isTableRows = (v: unknown): v is TableRow[] => + Array.isArray(v) && + v.length > 0 && + typeof v[0] === "object" && + v[0] !== null && + !("kind" in v[0]) && + "content" in v[0]; + +export const isTableGrouped = (v: unknown): v is TableGrouped => + typeof v === "object" && v !== null && "kind" in v && v.kind === "groups"; + +export const isTableFlat = (v: unknown): v is TableFlat => + typeof v === "object" && v !== null && "kind" in v && v.kind === "flat"; + +/** + * @func convertForTable + * + * Takes a variety of input types and streamlines them. Can't handle every contingency; be prepared + * to do conversions yourself as resources demand. Great for about 80% of use cases, though. + */ + +export function convertContent( + content: TableInputType, + { groupBy, keyBy }: { groupBy?: KeyBy; keyBy?: KeyBy } = {}, +): TableType { + // TableGrouped + if (isTableGrouped(content)) { + if (groupBy || keyBy) { + console.warn("Passed processor function when content is already marked as grouped"); + } + return content; + } + + if (isTableFlat(content)) { + if (groupBy || keyBy) { + console.warn("Passed processor function when content is already marked as flat"); + } + return content; + } + + // TableRow[] + if (isTableRows(content)) { + if (groupBy) { + console.warn( + "Passed processor function when content is processed and can't be analyzed for grouping", + ); + } + return { + kind: "flat", + content: content, + }; + } + + // TableRow or Rawtype, but empty + if (Array.isArray(content) && content.length === 0) { + return { + kind: "flat", + content: [], + }; + } + + const templatizeAsNeeded = (rows: RawType[][]): TableRow[] => + rows.map((row) => ({ + ...(keyBy ? { key: keyBy(row) } : {}), + content: row.map((item) => (typeof item === "object" ? item : html`${item}`)), + })); + + if (groupBy) { + const groupedContent = groupByProcessor(content, groupBy); + return { + kind: "groups", + content: groupedContent.map(([group, rowsForGroup]) => ({ + kind: "group", + group, + content: templatizeAsNeeded(rowsForGroup), + })), + }; + } + + return { + kind: "flat", + content: templatizeAsNeeded(content), + }; +} From 116a3e75dadaf4384e364a35f09785d493b1824b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 22 Aug 2024 10:53:58 +0200 Subject: [PATCH 06/18] core: bump github.com/gorilla/sessions from 1.3.0 to 1.4.0 (#11002) * core: bump github.com/gorilla/sessions from 1.3.0 to 1.4.0 Bumps [github.com/gorilla/sessions](https://github.com/gorilla/sessions) from 1.3.0 to 1.4.0. - [Release notes](https://github.com/gorilla/sessions/releases) - [Commits](https://github.com/gorilla/sessions/compare/v1.3.0...v1.4.0) --- updated-dependencies: - dependency-name: github.com/gorilla/sessions dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] * tidy Signed-off-by: Jens Langhammer * bump golangci Signed-off-by: Jens Langhammer * bump golang docker image to 1.23 too Signed-off-by: Jens Langhammer --------- Signed-off-by: dependabot[bot] Signed-off-by: Jens Langhammer Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Jens Langhammer --- .github/workflows/ci-outpost.yml | 2 +- Dockerfile | 2 +- go.mod | 6 ++++-- go.sum | 4 ++-- ldap.Dockerfile | 2 +- proxy.Dockerfile | 2 +- rac.Dockerfile | 2 +- radius.Dockerfile | 2 +- 8 files changed, 12 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci-outpost.yml b/.github/workflows/ci-outpost.yml index 58e00682895d..abb9f41bc00c 100644 --- a/.github/workflows/ci-outpost.yml +++ b/.github/workflows/ci-outpost.yml @@ -31,7 +31,7 @@ jobs: - name: golangci-lint uses: golangci/golangci-lint-action@v6 with: - version: v1.54.2 + version: latest args: --timeout 5000s --verbose skip-cache: true test-unittest: diff --git a/Dockerfile b/Dockerfile index 76156ccbd8c1..ab5ce799b717 100644 --- a/Dockerfile +++ b/Dockerfile @@ -43,7 +43,7 @@ COPY ./gen-ts-api /work/web/node_modules/@goauthentik/api RUN npm run build # Stage 3: Build go proxy -FROM --platform=${BUILDPLATFORM} mcr.microsoft.com/oss/go/microsoft/golang:1.22-fips-bookworm AS go-builder +FROM --platform=${BUILDPLATFORM} mcr.microsoft.com/oss/go/microsoft/golang:1.23-fips-bookworm AS go-builder ARG TARGETOS ARG TARGETARCH diff --git a/go.mod b/go.mod index 945bc328eab4..80dc52a735d3 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,8 @@ module goauthentik.io -go 1.22.2 +go 1.23 + +toolchain go1.23.0 require ( beryju.io/ldap v0.1.0 @@ -14,7 +16,7 @@ require ( github.com/gorilla/handlers v1.5.2 github.com/gorilla/mux v1.8.1 github.com/gorilla/securecookie v1.1.2 - github.com/gorilla/sessions v1.3.0 + github.com/gorilla/sessions v1.4.0 github.com/gorilla/websocket v1.5.3 github.com/jellydator/ttlcache/v3 v3.2.0 github.com/mitchellh/mapstructure v1.5.0 diff --git a/go.sum b/go.sum index 68949cca28ef..540384f9a99c 100644 --- a/go.sum +++ b/go.sum @@ -175,8 +175,8 @@ github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+ github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= -github.com/gorilla/sessions v1.3.0 h1:XYlkq7KcpOB2ZhHBPv5WpjMIxrQosiZanfoy1HLZFzg= -github.com/gorilla/sessions v1.3.0/go.mod h1:ePLdVu+jbEgHH+KWw8I1z2wqd0BAdAQh/8LRvBeoNcQ= +github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ= +github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik= github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= diff --git a/ldap.Dockerfile b/ldap.Dockerfile index df9d737067c8..37b7a9f0725f 100644 --- a/ldap.Dockerfile +++ b/ldap.Dockerfile @@ -1,7 +1,7 @@ # syntax=docker/dockerfile:1 # Stage 1: Build -FROM --platform=${BUILDPLATFORM} mcr.microsoft.com/oss/go/microsoft/golang:1.22-fips-bookworm AS builder +FROM --platform=${BUILDPLATFORM} mcr.microsoft.com/oss/go/microsoft/golang:1.23-fips-bookworm AS builder ARG TARGETOS ARG TARGETARCH diff --git a/proxy.Dockerfile b/proxy.Dockerfile index e223e5266b37..51bd22a27a72 100644 --- a/proxy.Dockerfile +++ b/proxy.Dockerfile @@ -17,7 +17,7 @@ COPY web . RUN npm run build-proxy # Stage 2: Build -FROM --platform=${BUILDPLATFORM} mcr.microsoft.com/oss/go/microsoft/golang:1.22-fips-bookworm AS builder +FROM --platform=${BUILDPLATFORM} mcr.microsoft.com/oss/go/microsoft/golang:1.23-fips-bookworm AS builder ARG TARGETOS ARG TARGETARCH diff --git a/rac.Dockerfile b/rac.Dockerfile index 87b214408e45..849f5d32a54e 100644 --- a/rac.Dockerfile +++ b/rac.Dockerfile @@ -1,7 +1,7 @@ # syntax=docker/dockerfile:1 # Stage 1: Build -FROM --platform=${BUILDPLATFORM} mcr.microsoft.com/oss/go/microsoft/golang:1.22-fips-bookworm AS builder +FROM --platform=${BUILDPLATFORM} mcr.microsoft.com/oss/go/microsoft/golang:1.23-fips-bookworm AS builder ARG TARGETOS ARG TARGETARCH diff --git a/radius.Dockerfile b/radius.Dockerfile index e19b682f98ca..1db5c49256f4 100644 --- a/radius.Dockerfile +++ b/radius.Dockerfile @@ -1,7 +1,7 @@ # syntax=docker/dockerfile:1 # Stage 1: Build -FROM --platform=${BUILDPLATFORM} mcr.microsoft.com/oss/go/microsoft/golang:1.22-fips-bookworm AS builder +FROM --platform=${BUILDPLATFORM} mcr.microsoft.com/oss/go/microsoft/golang:1.23-fips-bookworm AS builder ARG TARGETOS ARG TARGETARCH From 40b93e9b10ad95aa317e7c6769702199922d0e0f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 22 Aug 2024 10:55:03 +0200 Subject: [PATCH 07/18] core: bump drf-orjson-renderer from 1.7.2 to 1.7.3 (#11015) Bumps [drf-orjson-renderer](https://github.com/brianjbuck/drf_orjson_renderer) from 1.7.2 to 1.7.3. - [Commits](https://github.com/brianjbuck/drf_orjson_renderer/commits) --- updated-dependencies: - dependency-name: drf-orjson-renderer dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- poetry.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/poetry.lock b/poetry.lock index a634da8517b0..a1fe267ae9c3 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1497,13 +1497,13 @@ tests = ["black", "django-stubs[compatible-mypy]", "djangorestframework-stubs[co [[package]] name = "drf-orjson-renderer" -version = "1.7.2" +version = "1.7.3" description = "Django RestFramework JSON Renderer Backed by orjson" optional = false python-versions = ">=3.6.0" files = [ - {file = "drf_orjson_renderer-1.7.2-py3-none-any.whl", hash = "sha256:4fbf6f91d7032fbf23e31837e4c5cf6950af4e588bf34e9feadc809c67977657"}, - {file = "drf_orjson_renderer-1.7.2.tar.gz", hash = "sha256:b8a47c38a6eeaf5ffc7a5d53d028b95fa8b340999507681e403a599f0a3be456"}, + {file = "drf_orjson_renderer-1.7.3-py3-none-any.whl", hash = "sha256:9c3fe521b0e8c641b334c40bb81ecadb14519a27599a495d360385abe193a4b4"}, + {file = "drf_orjson_renderer-1.7.3.tar.gz", hash = "sha256:0c49760fc415df8096c1ef05f029802f2e5862d4e15fe96066289b8c526835f1"}, ] [package.dependencies] From 85eb1049660fb15a5f22d4962db9045e4e399abf Mon Sep 17 00:00:00 2001 From: Ken Sternberg <133134217+kensternberg-authentik@users.noreply.github.com> Date: Thu, 22 Aug 2024 02:17:30 -0700 Subject: [PATCH 08/18] web: fix flash of unstructured content, add tests for it (#11013) * web: fix Flash of Unstructured Content while SearchSelect is loading from the backend Provide an alternative, readonly, disabled, unindexed input object with the text "Loading...", to be replaced with the _real_ input element after the content is loaded. This provides the correct appearance and spacing so the content doesn't jiggle about between the start of loading and the SearchSelect element being finalized. It was visually distracting and unappealing. * web: comment on state management in API layer, move file to point to correct component under test. * web: test for flash of unstructured content - Add a unit test to ensure the "Loading..." element is displayed correctly before data arrives - Demo how to mock a `fetchObjects()` call in testing. Very cool. - Make distinguishing rule sets for code, tests, and scripts in nightmare mode - In SearchSelect, Move the `styles()` declaration to the top of the class for consistency. - To test for the FLOUC issue in SearchSelect. This is both an exercise in mocking @beryju's `fetchObjects()` protocol, and shows how we can unit test generic components that render API objects. --- web/scripts/eslint.nightmare.mjs | 295 +++++++++--------- .../forms/SearchSelect/SearchSelect.ts | 18 +- .../forms/SearchSelect/ak-search-select-ez.ts | 4 +- .../ak-search-select-loading-indicator.ts | 64 ++++ .../SearchSelect/ak-search-select-view.ts | 8 +- .../forms/SearchSelect/ak-search-select.ts | 4 +- .../tests/ak-search-select-view.test.ts | 104 ++++++ .../tests/ak-search-select.test.ts | 162 +++++----- 8 files changed, 427 insertions(+), 232 deletions(-) create mode 100644 web/src/elements/forms/SearchSelect/ak-search-select-loading-indicator.ts create mode 100644 web/src/elements/forms/SearchSelect/tests/ak-search-select-view.test.ts diff --git a/web/scripts/eslint.nightmare.mjs b/web/scripts/eslint.nightmare.mjs index 0c44096ee8c7..66869375f528 100644 --- a/web/scripts/eslint.nightmare.mjs +++ b/web/scripts/eslint.nightmare.mjs @@ -6,6 +6,142 @@ import wcconf from "eslint-plugin-wc"; import globals from "globals"; import tseslint from "typescript-eslint"; +const MAX_DEPTH = 4; +const MAX_NESTED_CALLBACKS = 4; +const MAX_PARAMS = 5; +const MAX_COGNITIVE_COMPLEXITY = 9; + +const rules = { + "accessor-pairs": "error", + "array-callback-return": "error", + "block-scoped-var": "error", + "consistent-return": "error", + "consistent-this": ["error", "that"], + "curly": ["error", "all"], + "dot-notation": [ + "error", + { + allowKeywords: true, + }, + ], + "eqeqeq": "error", + "func-names": "error", + "guard-for-in": "error", + "max-depth": ["error", MAX_DEPTH], + "max-nested-callbacks": ["error", MAX_NESTED_CALLBACKS], + "max-params": ["error", MAX_PARAMS], + "new-cap": "error", + "no-alert": "error", + "no-array-constructor": "error", + "no-bitwise": "error", + "no-caller": "error", + "no-case-declarations": "error", + "no-class-assign": "error", + "no-cond-assign": "error", + "no-const-assign": "error", + "no-constant-condition": "error", + "no-control-regex": "error", + "no-debugger": "error", + "no-delete-var": "error", + "no-div-regex": "error", + "no-dupe-args": "error", + "no-dupe-keys": "error", + "no-duplicate-case": "error", + "no-else-return": "error", + "no-empty": "error", + "no-empty-character-class": "error", + "no-empty-function": "error", + "no-labels": "error", + "no-eq-null": "error", + "no-eval": "error", + "no-ex-assign": "error", + "no-extend-native": "error", + "no-extra-bind": "error", + "no-extra-boolean-cast": "error", + "no-extra-label": "error", + "no-fallthrough": "error", + "no-func-assign": "error", + "no-implied-eval": "error", + "no-implicit-coercion": "error", + "no-implicit-globals": "error", + "no-inner-declarations": ["error", "functions"], + "no-invalid-regexp": "error", + "no-irregular-whitespace": "error", + "no-iterator": "error", + "no-invalid-this": "error", + "no-label-var": "error", + "no-lone-blocks": "error", + "no-lonely-if": "error", + "no-loop-func": "error", + "no-magic-numbers": ["error", { ignore: [0, 1, -1] }], + "no-multi-str": "error", + "no-negated-condition": "error", + "no-nested-ternary": "error", + "no-new": "error", + "no-new-func": "error", + "no-new-wrappers": "error", + "no-obj-calls": "error", + "no-octal": "error", + "no-octal-escape": "error", + "no-param-reassign": "error", + "no-proto": "error", + "no-redeclare": "error", + "no-regex-spaces": "error", + "no-restricted-syntax": ["error", "WithStatement"], + "no-script-url": "error", + "no-self-assign": "error", + "no-self-compare": "error", + "no-sequences": "error", + "no-shadow": "error", + "no-shadow-restricted-names": "error", + "no-sparse-arrays": "error", + "no-this-before-super": "error", + "no-throw-literal": "error", + "no-trailing-spaces": "error", + "no-undef": "error", + "no-undef-init": "error", + "no-unexpected-multiline": "error", + "no-useless-constructor": "error", + "no-unmodified-loop-condition": "error", + "no-unneeded-ternary": "error", + "no-unreachable": "error", + "no-unused-expressions": "error", + "no-unused-labels": "error", + "no-use-before-define": "error", + "no-useless-call": "error", + "no-dupe-class-members": "error", + "no-var": "error", + "no-void": "error", + "no-with": "error", + "prefer-arrow-callback": "error", + "prefer-const": "error", + "prefer-rest-params": "error", + "prefer-spread": "error", + "prefer-template": "error", + "radix": "error", + "require-yield": "error", + "strict": ["error", "global"], + "use-isnan": "error", + "valid-typeof": "error", + "vars-on-top": "error", + "yoda": ["error", "never"], + + "no-unused-vars": "off", + "no-console": ["error", { allow: ["debug", "warn", "error"] }], + "sonarjs/cognitive-complexity": ["off", MAX_COGNITIVE_COMPLEXITY], + "sonarjs/no-duplicate-string": "off", + "sonarjs/no-nested-template-literals": "off", + "@typescript-eslint/ban-ts-comment": "off", + "@typescript-eslint/no-unused-vars": [ + "error", + { + argsIgnorePattern: "^_", + varsIgnorePattern: "^_", + caughtErrorsIgnorePattern: "^_", + }, + ], +}; + export default [ // You would not believe how much this change has frustrated users: ["if an ignores key is used // without any other keys in the configuration object, then the patterns act as global @@ -24,6 +160,7 @@ export default [ "src/locale-codes.ts", "storybook-static/", "src/locales/", + "src/**/*.test.ts", ], }, eslint.configs.recommended, @@ -43,136 +180,7 @@ export default [ }, }, files: ["src/**"], - rules: { - "accessor-pairs": "error", - "array-callback-return": "error", - "block-scoped-var": "error", - "consistent-return": "error", - "consistent-this": ["error", "that"], - "curly": ["error", "all"], - "dot-notation": [ - "error", - { - allowKeywords: true, - }, - ], - "eqeqeq": "error", - "func-names": "error", - "guard-for-in": "error", - "max-depth": ["error", 4], - "max-nested-callbacks": ["error", 4], - "max-params": ["error", 5], - "new-cap": "error", - "no-alert": "error", - "no-array-constructor": "error", - "no-bitwise": "error", - "no-caller": "error", - "no-case-declarations": "error", - "no-class-assign": "error", - "no-cond-assign": "error", - "no-const-assign": "error", - "no-constant-condition": "error", - "no-control-regex": "error", - "no-debugger": "error", - "no-delete-var": "error", - "no-div-regex": "error", - "no-dupe-args": "error", - "no-dupe-keys": "error", - "no-duplicate-case": "error", - "no-else-return": "error", - "no-empty": "error", - "no-empty-character-class": "error", - "no-empty-function": "error", - "no-labels": "error", - "no-eq-null": "error", - "no-eval": "error", - "no-ex-assign": "error", - "no-extend-native": "error", - "no-extra-bind": "error", - "no-extra-boolean-cast": "error", - "no-extra-label": "error", - "no-fallthrough": "error", - "no-func-assign": "error", - "no-implied-eval": "error", - "no-implicit-coercion": "error", - "no-implicit-globals": "error", - "no-inner-declarations": ["error", "functions"], - "no-invalid-regexp": "error", - "no-irregular-whitespace": "error", - "no-iterator": "error", - "no-invalid-this": "error", - "no-label-var": "error", - "no-lone-blocks": "error", - "no-lonely-if": "error", - "no-loop-func": "error", - "no-magic-numbers": ["error", { ignore: [0, 1, -1] }], - "no-multi-str": "error", - "no-negated-condition": "error", - "no-nested-ternary": "error", - "no-new": "error", - "no-new-func": "error", - "no-new-wrappers": "error", - "no-obj-calls": "error", - "no-octal": "error", - "no-octal-escape": "error", - "no-param-reassign": "error", - "no-proto": "error", - "no-redeclare": "error", - "no-regex-spaces": "error", - "no-restricted-syntax": ["error", "WithStatement"], - "no-script-url": "error", - "no-self-assign": "error", - "no-self-compare": "error", - "no-sequences": "error", - "no-shadow": "error", - "no-shadow-restricted-names": "error", - "no-sparse-arrays": "error", - "no-this-before-super": "error", - "no-throw-literal": "error", - "no-trailing-spaces": "error", - "no-undef": "error", - "no-undef-init": "error", - "no-unexpected-multiline": "error", - "no-useless-constructor": "error", - "no-unmodified-loop-condition": "error", - "no-unneeded-ternary": "error", - "no-unreachable": "error", - "no-unused-expressions": "error", - "no-unused-labels": "error", - "no-use-before-define": "error", - "no-useless-call": "error", - "no-dupe-class-members": "error", - "no-var": "error", - "no-void": "error", - "no-with": "error", - "prefer-arrow-callback": "error", - "prefer-const": "error", - "prefer-rest-params": "error", - "prefer-spread": "error", - "prefer-template": "error", - "radix": "error", - "require-yield": "error", - "strict": ["error", "global"], - "use-isnan": "error", - "valid-typeof": "error", - "vars-on-top": "error", - "yoda": ["error", "never"], - - "no-unused-vars": "off", - "no-console": ["error", { allow: ["debug", "warn", "error"] }], - "sonarjs/cognitive-complexity": ["off", 9], - "sonarjs/no-duplicate-string": "off", - "sonarjs/no-nested-template-literals": "off", - "@typescript-eslint/ban-ts-comment": "off", - "@typescript-eslint/no-unused-vars": [ - "error", - { - argsIgnorePattern: "^_", - varsIgnorePattern: "^_", - caughtErrorsIgnorePattern: "^_", - }, - ], - }, + rules, }, { languageOptions: { @@ -186,18 +194,21 @@ export default [ }, }, files: ["scripts/*.mjs", "*.ts", "*.mjs"], - rules: { - "no-unused-vars": "off", - "no-console": "off", - "@typescript-eslint/ban-ts-comment": "off", - "@typescript-eslint/no-unused-vars": [ - "error", - { - argsIgnorePattern: "^_", - varsIgnorePattern: "^_", - caughtErrorsIgnorePattern: "^_", - }, - ], + rules, + }, + { + languageOptions: { + parser: tsparser, + parserOptions: { + ecmaVersion: 12, + sourceType: "module", + }, + globals: { + ...globals.nodeBuiltin, + ...globals.jest, + }, }, + files: ["src/**/*.test.ts"], + rules, }, ]; diff --git a/web/src/elements/forms/SearchSelect/SearchSelect.ts b/web/src/elements/forms/SearchSelect/SearchSelect.ts index 35b6939409a0..662e70b0e16a 100644 --- a/web/src/elements/forms/SearchSelect/SearchSelect.ts +++ b/web/src/elements/forms/SearchSelect/SearchSelect.ts @@ -8,7 +8,7 @@ import { CustomEmitterElement } from "@goauthentik/elements/utils/eventEmitter"; import { randomId } from "@goauthentik/elements/utils/randomId.js"; import { msg } from "@lit/localize"; -import { TemplateResult, html } from "lit"; +import { PropertyValues, TemplateResult, html } from "lit"; import { property, state } from "lit/decorators.js"; import { ifDefined } from "lit/directives/if-defined.js"; @@ -16,6 +16,7 @@ import PFBase from "@patternfly/patternfly/patternfly-base.css"; import { ResponseError } from "@goauthentik/api"; +import "./ak-search-select-loading-indicator.js"; import "./ak-search-select-view.js"; import { SearchSelectView } from "./ak-search-select-view.js"; @@ -120,6 +121,7 @@ export class SearchSelectBase return Promise.resolve(); } this.isFetchingData = true; + this.dispatchEvent(new Event("loading")); return this.fetchObjects(this.query) .then((objects) => { objects.forEach((obj) => { @@ -228,8 +230,15 @@ export class SearchSelectBase return html`${msg("Failed to fetch objects: ")} ${this.error.detail}`; } + // `this.objects` is both a container and a sigil; if it is in the `undefined` state, it's a + // marker that this component has not yet completed a *first* load. After that, it should + // never be empty. The only state that allows it to be empty after a successful retrieval is + // a subsequent retrieval failure, in which case `this.error` above will be populated and + // displayed before this. if (!this.objects) { - return html`${msg("Loading...")}`; + return html``; } const options = this.getGroupedItems(); @@ -248,7 +257,10 @@ export class SearchSelectBase > `; } - public override updated() { + public override updated(changed: PropertyValues) { + if (!this.isFetchingData && changed.has("objects")) { + this.dispatchEvent(new Event("ready")); + } // It is not safe for automated tests to interact with this component while it is fetching // data. if (!this.isFetchingData) { diff --git a/web/src/elements/forms/SearchSelect/ak-search-select-ez.ts b/web/src/elements/forms/SearchSelect/ak-search-select-ez.ts index a05d1d7f6202..416039044369 100644 --- a/web/src/elements/forms/SearchSelect/ak-search-select-ez.ts +++ b/web/src/elements/forms/SearchSelect/ak-search-select-ez.ts @@ -1,8 +1,6 @@ import { TemplateResult } from "lit"; import { customElement, property } from "lit/decorators.js"; -import PFBase from "@patternfly/patternfly/patternfly-base.css"; - import { type ISearchSelectBase, SearchSelectBase } from "./SearchSelect.js"; export interface ISearchSelectApi { @@ -48,7 +46,7 @@ export interface ISearchSelectEz extends ISearchSelectBase { @customElement("ak-search-select-ez") export class SearchSelectEz extends SearchSelectBase implements ISearchSelectEz { static get styles() { - return [PFBase]; + return [...SearchSelectBase.styles]; } @property({ type: Object, attribute: false }) diff --git a/web/src/elements/forms/SearchSelect/ak-search-select-loading-indicator.ts b/web/src/elements/forms/SearchSelect/ak-search-select-loading-indicator.ts new file mode 100644 index 000000000000..94b12212624d --- /dev/null +++ b/web/src/elements/forms/SearchSelect/ak-search-select-loading-indicator.ts @@ -0,0 +1,64 @@ +import { AKElement } from "@goauthentik/elements/Base.js"; +import { randomId } from "@goauthentik/elements/utils/randomId.js"; + +import { msg } from "@lit/localize"; +import { html } from "lit"; +import { customElement } from "lit/decorators.js"; + +import PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css"; +import PFSelect from "@patternfly/patternfly/components/Select/select.css"; +import PFBase from "@patternfly/patternfly/patternfly-base.css"; + +/** + * @class SearchSelectLoadingIndicator + * @element ak-search-select-loading-indicator + * + * Just a loading indicator to fill in while we wait for the view to settle + * + * ## Available CSS `part::` + * + * - @part ak-search-select: The main Patternfly div + * - @part ak-search-select-toggle: The Patternfly inner div + * - @part ak-search-select-wrapper: Yet another Patternfly inner div + * - @part ak-search-select-loading-indicator: The input object that hosts the "Loading..." message + */ + +@customElement("ak-search-select-loading-indicator") +export class SearchSelectLoadingIndicator extends AKElement { + static get styles() { + return [PFBase, PFFormControl, PFSelect]; + } + + connectedCallback() { + super.connectedCallback(); + this.setAttribute("data-ouia-component-type", "ak-search-select-loading-indicator"); + this.setAttribute("data-ouia-component-id", this.getAttribute("id") || randomId()); + this.setAttribute("data-ouia-component-safe", "true"); + } + + render() { + return html` +
+
+
+ +
+
+
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ak-search-select-loading-indicator": SearchSelectLoadingIndicator; + } +} diff --git a/web/src/elements/forms/SearchSelect/ak-search-select-view.ts b/web/src/elements/forms/SearchSelect/ak-search-select-view.ts index 7324b9edc4c5..c2133f6f1633 100644 --- a/web/src/elements/forms/SearchSelect/ak-search-select-view.ts +++ b/web/src/elements/forms/SearchSelect/ak-search-select-view.ts @@ -69,6 +69,10 @@ export interface ISearchSelectView { */ @customElement("ak-search-select-view") export class SearchSelectView extends AKElement implements ISearchSelectView { + static get styles() { + return [PFBase, PFForm, PFFormControl, PFSelect]; + } + /** * The options collection. The simplest variant is just [key, label, optional]. See * the `./types.ts` file for variants and how to use them. @@ -186,10 +190,6 @@ export class SearchSelectView extends AKElement implements ISearchSelectView { */ flatOptions: [string, SelectOption][] = []; - static get styles() { - return [PFBase, PFForm, PFFormControl, PFSelect]; - } - connectedCallback() { super.connectedCallback(); this.setAttribute("data-ouia-component-type", "ak-search-select-view"); diff --git a/web/src/elements/forms/SearchSelect/ak-search-select.ts b/web/src/elements/forms/SearchSelect/ak-search-select.ts index 0c7bcf9b6708..36b8b4332537 100644 --- a/web/src/elements/forms/SearchSelect/ak-search-select.ts +++ b/web/src/elements/forms/SearchSelect/ak-search-select.ts @@ -3,8 +3,6 @@ import { groupBy } from "@goauthentik/common/utils"; import { TemplateResult } from "lit"; import { customElement, property } from "lit/decorators.js"; -import PFBase from "@patternfly/patternfly/patternfly-base.css"; - import { type ISearchSelectBase, SearchSelectBase } from "./SearchSelect.js"; export interface ISearchSelect extends ISearchSelectBase { @@ -57,7 +55,7 @@ export interface ISearchSelect extends ISearchSelectBase { @customElement("ak-search-select") export class SearchSelect extends SearchSelectBase implements ISearchSelect { static get styles() { - return [PFBase]; + return [...SearchSelectBase.styles]; } // A function which takes the query state object (accepting that it may be empty) and returns a 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 new file mode 100644 index 000000000000..6c22e88696be --- /dev/null +++ b/web/src/elements/forms/SearchSelect/tests/ak-search-select-view.test.ts @@ -0,0 +1,104 @@ +import { $, browser } from "@wdio/globals"; +import { slug } from "github-slugger"; +import { Key } from "webdriverio"; + +import { html, render } from "lit"; + +import "../ak-search-select-view.js"; +import { sampleData } from "../stories/sampleData.js"; +import { AkSearchSelectViewDriver } from "./ak-search-select-view.comp.js"; + +const longGoodForYouPairs = { + grouped: false, + options: sampleData.map(({ produce }) => [slug(produce), produce]), +}; + +describe("Search select: Test Input Field", () => { + let select: AkSearchSelectViewDriver; + + beforeEach(async () => { + await render( + html` `, + document.body, + ); + // @ts-ignore + select = await AkSearchSelectViewDriver.build(await $("ak-search-select-view")); + }); + + it("should open the menu when the input is clicked", async () => { + expect(await select.open).toBe(false); + expect(await select.menuIsVisible()).toBe(false); + await select.clickInput(); + expect(await select.open).toBe(true); + // expect(await select.menuIsVisible()).toBe(true); + }); + + it("should not open the menu when the input is focused", async () => { + expect(await select.open).toBe(false); + await select.focusOnInput(); + expect(await select.open).toBe(false); + expect(await select.menuIsVisible()).toBe(false); + }); + + it("should close the menu when the input is clicked a second time", async () => { + expect(await select.open).toBe(false); + expect(await select.menuIsVisible()).toBe(false); + await select.clickInput(); + expect(await select.menuIsVisible()).toBe(true); + expect(await select.open).toBe(true); + await select.clickInput(); + expect(await select.open).toBe(false); + expect(await select.open).toBe(false); + }); + + it("should open the menu from a focused but closed input when a search is begun", async () => { + expect(await select.open).toBe(false); + await select.focusOnInput(); + expect(await select.open).toBe(false); + expect(await select.menuIsVisible()).toBe(false); + await browser.keys("A"); + expect(await select.open).toBe(true); + expect(await select.menuIsVisible()).toBe(true); + }); + + it("should update the list as the user types", async () => { + await select.focusOnInput(); + await browser.keys("Ap"); + expect(await select.menuIsVisible()).toBe(true); + const elements = Array.from(await select.listElements()); + 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); + const elements = Array.from(await select.listElements()); + expect(elements.length).toBe(2); + await browser.keys(Key.Tab); + expect(await (await select.input()).getValue()).toBe("Apples"); + }); + + it("should close the menu when the user clicks away", async () => { + document.body.insertAdjacentHTML( + "afterbegin", + '', + ); + const input = await browser.$("#a-separate-component"); + + await select.clickInput(); + expect(await select.open).toBe(true); + await input.click(); + expect(await select.open).toBe(false); + }); + + afterEach(async () => { + await document.body.querySelector("#a-separate-component")?.remove(); + await 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 + delete document.body["_$litPart$"]; + } + }); +}); diff --git a/web/src/elements/forms/SearchSelect/tests/ak-search-select.test.ts b/web/src/elements/forms/SearchSelect/tests/ak-search-select.test.ts index 6c22e88696be..c0026e8ee865 100644 --- a/web/src/elements/forms/SearchSelect/tests/ak-search-select.test.ts +++ b/web/src/elements/forms/SearchSelect/tests/ak-search-select.test.ts @@ -1,100 +1,108 @@ +/* eslint-env jest */ +import { AKElement } from "@goauthentik/elements/Base"; +import { bound } from "@goauthentik/elements/decorators/bound.js"; +import { CustomListenerElement } from "@goauthentik/elements/utils/eventEmitter"; import { $, browser } from "@wdio/globals"; import { slug } from "github-slugger"; -import { Key } from "webdriverio"; import { html, render } from "lit"; +import { customElement } from "lit/decorators.js"; +import { property, query } from "lit/decorators.js"; -import "../ak-search-select-view.js"; -import { sampleData } from "../stories/sampleData.js"; +import "../ak-search-select.js"; +import { SearchSelect } from "../ak-search-select.js"; +import { type ViewSample, sampleData } from "../stories/sampleData.js"; import { AkSearchSelectViewDriver } from "./ak-search-select-view.comp.js"; -const longGoodForYouPairs = { - grouped: false, - options: sampleData.map(({ produce }) => [slug(produce), produce]), -}; +const renderElement = (fruit: ViewSample) => fruit.produce; -describe("Search select: Test Input Field", () => { - let select: AkSearchSelectViewDriver; +const renderDescription = (fruit: ViewSample) => html`${fruit.desc}`; - beforeEach(async () => { - await render( - html` `, - document.body, - ); - // @ts-ignore - select = await AkSearchSelectViewDriver.build(await $("ak-search-select-view")); - }); +const renderValue = (fruit: ViewSample | undefined) => slug(fruit?.produce ?? ""); - it("should open the menu when the input is clicked", async () => { - expect(await select.open).toBe(false); - expect(await select.menuIsVisible()).toBe(false); - await select.clickInput(); - expect(await select.open).toBe(true); - // expect(await select.menuIsVisible()).toBe(true); - }); +@customElement("ak-mock-search-group") +export class MockSearch extends CustomListenerElement(AKElement) { + /** + * The current fruit + * + * @attr + */ + @property({ type: String, reflect: true }) + fruit?: string; - it("should not open the menu when the input is focused", async () => { - expect(await select.open).toBe(false); - await select.focusOnInput(); - expect(await select.open).toBe(false); - expect(await select.menuIsVisible()).toBe(false); - }); + @query("ak-search-select") + search!: SearchSelect; - it("should close the menu when the input is clicked a second time", async () => { - expect(await select.open).toBe(false); - expect(await select.menuIsVisible()).toBe(false); - await select.clickInput(); - expect(await select.menuIsVisible()).toBe(true); - expect(await select.open).toBe(true); - await select.clickInput(); - expect(await select.open).toBe(false); - expect(await select.open).toBe(false); - }); + selectedFruit?: ViewSample; - it("should open the menu from a focused but closed input when a search is begun", async () => { - expect(await select.open).toBe(false); - await select.focusOnInput(); - expect(await select.open).toBe(false); - expect(await select.menuIsVisible()).toBe(false); - await browser.keys("A"); - expect(await select.open).toBe(true); - expect(await select.menuIsVisible()).toBe(true); - }); + get value() { + return this.selectedFruit ? renderValue(this.selectedFruit) : undefined; + } - it("should update the list as the user types", async () => { - await select.focusOnInput(); - await browser.keys("Ap"); - expect(await select.menuIsVisible()).toBe(true); - const elements = Array.from(await select.listElements()); - expect(elements.length).toBe(2); - }); + @bound + handleSearchUpdate(ev: CustomEvent) { + ev.stopPropagation(); + this.selectedFruit = ev.detail.value; + this.dispatchEvent(new InputEvent("input", { bubbles: true, composed: true })); + } + + @bound + selected(fruit: ViewSample) { + return this.fruit === slug(fruit.produce); + } + + @bound + fetchObjects() { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const resolver = (resolve: any) => { + this.addEventListener("resolve", () => { + resolve(sampleData); + }); + }; + return new Promise(resolver); + } + + render() { + return html` + + + `; + } +} - it("set the value when a match is close", async () => { - await select.focusOnInput(); - await browser.keys("Ap"); - expect(await select.menuIsVisible()).toBe(true); - const elements = Array.from(await select.listElements()); - expect(elements.length).toBe(2); - await browser.keys(Key.Tab); - expect(await (await select.input()).getValue()).toBe("Apples"); +describe("Search select: event driven startup", () => { + let select: AkSearchSelectViewDriver; + let wrapper: SearchSelect; + + beforeEach(async () => { + await render(html``, document.body); + // @ts-ignore + wrapper = await $(">>>ak-search-select"); }); - it("should close the menu when the user clicks away", async () => { - document.body.insertAdjacentHTML( - "afterbegin", - '', - ); - const input = await browser.$("#a-separate-component"); - - await select.clickInput(); - expect(await select.open).toBe(true); - await input.click(); - expect(await select.open).toBe(false); + it("should shift from the loading indicator to search select view on fetch event completed", async () => { + expect(await wrapper).toBeExisting(); + expect(await $(">>>ak-search-select-loading-indicator")).toBeDisplayed(); + await browser.execute(() => { + const mock = document.querySelector("ak-mock-search-group"); + mock?.dispatchEvent(new Event("resolve")); + }); + expect(await $(">>>ak-search-select-loading-indicator")).not.toBeDisplayed(); + select = await AkSearchSelectViewDriver.build(await $(">>>ak-search-select-view")); + expect(await select).toBeExisting(); }); afterEach(async () => { - await document.body.querySelector("#a-separate-component")?.remove(); - await document.body.querySelector("ak-search-select-view")?.remove(); + await document.body.querySelector("ak-mock-search-group")?.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 From 3de78ebb0950a3451752ef864996467dd5e28b55 Mon Sep 17 00:00:00 2001 From: Ken Sternberg <133134217+kensternberg-authentik@users.noreply.github.com> Date: Thu, 22 Aug 2024 02:59:03 -0700 Subject: [PATCH 09/18] web: dual-select uses, part 2: dual-select harder (#9377) * web: fix esbuild issue with style sheets Getting ESBuild, Lit, and Storybook to all agree on how to read and parse stylesheets is a serious pain. This fix better identifies the value types (instances) being passed from various sources in the repo to the three *different* kinds of style processors we're using (the native one, the polyfill one, and whatever the heck Storybook does internally). Falling back to using older CSS instantiating techniques one era at a time seems to do the trick. It's ugly, but in the face of the aggressive styling we use to avoid Flashes of Unstyled Content (FLoUC), it's the logic with which we're left. In standard mode, the following warning appears on the console when running a Flow: ``` Autofocus processing was blocked because a document already has a focused element. ``` In compatibility mode, the following **error** appears on the console when running a Flow: ``` crawler-inject.js:1106 Uncaught TypeError: Failed to execute 'observe' on 'MutationObserver': parameter 1 is not of type 'Node'. at initDomMutationObservers (crawler-inject.js:1106:18) at crawler-inject.js:1114:24 at Array.forEach () at initDomMutationObservers (crawler-inject.js:1114:10) at crawler-inject.js:1549:1 initDomMutationObservers @ crawler-inject.js:1106 (anonymous) @ crawler-inject.js:1114 initDomMutationObservers @ crawler-inject.js:1114 (anonymous) @ crawler-inject.js:1549 ``` Despite this error, nothing seems to be broken and flows work as anticipated. * web: replace multi-select with dual-select for all propertyMapping invocations All of the uses of - ${this.oauthSources?.results.map((source) => { - const selected = (provider?.jwksSources || []).some((su) => { - return su == source.pk; - }); - return html``; - })} - +

${msg( "JWTs signed by certificates configured in the selected sources can be used to authenticate to this provider.", )}

-

- ${msg("Hold control/command to select multiple items.")} -

diff --git a/web/src/admin/applications/wizard/methods/proxy/AuthenticationByProxyPage.ts b/web/src/admin/applications/wizard/methods/proxy/AuthenticationByProxyPage.ts index 05d45f6c8618..36058f9d7d6e 100644 --- a/web/src/admin/applications/wizard/methods/proxy/AuthenticationByProxyPage.ts +++ b/web/src/admin/applications/wizard/methods/proxy/AuthenticationByProxyPage.ts @@ -1,4 +1,5 @@ import "@goauthentik/admin/applications/wizard/ak-wizard-title"; +import { oauth2SourcesProvider } from "@goauthentik/admin/providers/oauth2/OAuth2Sources.js"; import { makeProxyPropertyMappingsSelector, proxyPropertyMappingsProvider, @@ -10,6 +11,7 @@ import "@goauthentik/components/ak-text-input"; import "@goauthentik/components/ak-textarea-input"; import "@goauthentik/components/ak-toggle-group"; import "@goauthentik/elements/ak-dual-select/ak-dual-select-dynamic-selected-provider.js"; +import "@goauthentik/elements/ak-dual-select/ak-dual-select-provider.js"; import "@goauthentik/elements/forms/HorizontalFormElement"; import { msg } from "@lit/localize"; @@ -226,26 +228,17 @@ export class AkTypeProxyApplicationWizardPage extends BaseProviderPanel { name="jwksSources" .errorMessages=${errors?.jwksSources ?? []} > - +

${msg( "JWTs signed by certificates configured in the selected sources can be used to authenticate to this provider.", )}

-

- ${msg("Hold control/command to select multiple items.")} -

diff --git a/web/src/admin/applications/wizard/methods/saml/ak-application-wizard-authentication-by-saml-configuration.ts b/web/src/admin/applications/wizard/methods/saml/ak-application-wizard-authentication-by-saml-configuration.ts index f2947a0381ff..403398bf3712 100644 --- a/web/src/admin/applications/wizard/methods/saml/ak-application-wizard-authentication-by-saml-configuration.ts +++ b/web/src/admin/applications/wizard/methods/saml/ak-application-wizard-authentication-by-saml-configuration.ts @@ -266,11 +266,8 @@ export class ApplicationWizardProviderSamlConfiguration extends BaseProviderPane .options=${propertyPairs} .values=${pmValues} .richhelp=${html`

- ${msg("Property mappings used for user mapping.")} -

-

- ${msg("Hold control/command to select multiple items.")} -

`} + ${msg("Property mappings used for user mapping.")} +

`} > + .richhelp=${html` +

${msg("Property mappings used for user mapping.")}

-

- ${msg("Hold control/command to select multiple items.")} -

`} + `} > + .richhelp=${html` +

${msg("Property mappings used for group creation.")}

-

- ${msg("Hold control/command to select multiple items.")} -

`} + `} >
diff --git a/web/src/admin/events/RuleForm.ts b/web/src/admin/events/RuleForm.ts index 7e2ff57b5d71..b26318adb1a0 100644 --- a/web/src/admin/events/RuleForm.ts +++ b/web/src/admin/events/RuleForm.ts @@ -20,6 +20,20 @@ import { SeverityEnum, } from "@goauthentik/api"; +async function eventTransportsProvider(page = 1, search = "") { + const eventTransports = await new EventsApi(DEFAULT_CONFIG).eventsTransportsList({ + ordering: "name", + pageSize: 20, + search: search.trim(), + page, + }); + + return { + pagination: eventTransports.pagination, + options: eventTransports.results.map((transport) => [transport.pk, transport.name]), + }; +} + @customElement("ak-event-rule-form") export class RuleForm extends ModelForm { eventTransports?: PaginatedNotificationTransportList; @@ -100,24 +114,17 @@ export class RuleForm extends ModelForm { ?required=${true} name="transports" > - +

${msg( "Select which transports should be used to notify the user. If none are selected, the notification will only be shown in the authentik UI.", )}

-

- ${msg("Hold control/command to select multiple items.")} -

{ html`${item.name}`, html`${item.parentName || msg("-")}`, html`${Array.from(item.users || []).length}`, - html``, + html``, html` ${msg("Update")} ${msg("Update Group")} diff --git a/web/src/admin/providers/ProviderListPage.ts b/web/src/admin/providers/ProviderListPage.ts index ce96d63faf64..e6922b5ccbe2 100644 --- a/web/src/admin/providers/ProviderListPage.ts +++ b/web/src/admin/providers/ProviderListPage.ts @@ -2,7 +2,7 @@ import "@goauthentik/admin/applications/ApplicationWizardHint"; import "@goauthentik/admin/providers/ProviderWizard"; import "@goauthentik/admin/providers/google_workspace/GoogleWorkspaceProviderForm"; import "@goauthentik/admin/providers/ldap/LDAPProviderForm"; -import "@goauthentik/admin/providers/microsoft_entra/MicrosoftEntraProviderViewPage"; +import "@goauthentik/admin/providers/microsoft_entra/MicrosoftEntraProviderForm"; import "@goauthentik/admin/providers/oauth2/OAuth2ProviderForm"; import "@goauthentik/admin/providers/proxy/ProxyProviderForm"; import "@goauthentik/admin/providers/rac/RACProviderForm"; diff --git a/web/src/admin/providers/google_workspace/GoogleWorkspaceProviderForm.ts b/web/src/admin/providers/google_workspace/GoogleWorkspaceProviderForm.ts index b99fb4bebf4d..86f2598db0ac 100644 --- a/web/src/admin/providers/google_workspace/GoogleWorkspaceProviderForm.ts +++ b/web/src/admin/providers/google_workspace/GoogleWorkspaceProviderForm.ts @@ -1,8 +1,14 @@ import { BaseProviderForm } from "@goauthentik/admin/providers/BaseProviderForm"; +import { + googleWorkspacePropertyMappingsProvider, + makeGoogleWorkspacePropertyMappingsSelector, +} from "@goauthentik/admin/providers/google_workspace/GoogleWorkspaceProviderPropertyMappings"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { first } from "@goauthentik/common/utils"; import "@goauthentik/elements/CodeMirror"; import { CodeMirrorMode } from "@goauthentik/elements/CodeMirror"; +import "@goauthentik/elements/ak-dual-select/ak-dual-select-dynamic-selected-provider.js"; +import "@goauthentik/elements/ak-dual-select/ak-dual-select-provider.js"; import "@goauthentik/elements/forms/FormGroup"; import "@goauthentik/elements/forms/HorizontalFormElement"; import "@goauthentik/elements/forms/Radio"; @@ -19,8 +25,6 @@ import { GoogleWorkspaceProvider, Group, OutgoingSyncDeleteAction, - PaginatedGoogleWorkspaceProviderMappingList, - PropertymappingsApi, ProvidersApi, } from "@goauthentik/api"; @@ -32,16 +36,6 @@ export class GoogleWorkspaceProviderFormPage extends BaseProviderForm { - this.propertyMappings = await new PropertymappingsApi( - DEFAULT_CONFIG, - ).propertymappingsProviderGoogleWorkspaceList({ - ordering: "managed", - }); - } - - propertyMappings?: PaginatedGoogleWorkspaceProviderMappingList; - async send(data: GoogleWorkspaceProvider): Promise { if (this.instance) { return new ProvidersApi(DEFAULT_CONFIG).providersGoogleWorkspaceUpdate({ @@ -229,68 +223,35 @@ export class GoogleWorkspaceProviderFormPage extends BaseProviderForm - +

${msg("Property mappings used to user mapping.")}

-

- ${msg("Hold control/command to select multiple items.")} -

- +

${msg("Property mappings used to group creation.")}

-

- ${msg("Hold control/command to select multiple items.")} -

`; diff --git a/web/src/admin/providers/google_workspace/GoogleWorkspaceProviderPropertyMappings.ts b/web/src/admin/providers/google_workspace/GoogleWorkspaceProviderPropertyMappings.ts new file mode 100644 index 000000000000..36996885b98f --- /dev/null +++ b/web/src/admin/providers/google_workspace/GoogleWorkspaceProviderPropertyMappings.ts @@ -0,0 +1,30 @@ +import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; +import { DualSelectPair } from "@goauthentik/elements/ak-dual-select/types.js"; + +import { PropertymappingsApi, ScopeMapping } from "@goauthentik/api"; + +export async function googleWorkspacePropertyMappingsProvider(page = 1, search = "") { + const propertyMappings = await new PropertymappingsApi( + DEFAULT_CONFIG, + ).propertymappingsProviderGoogleWorkspaceList({ + ordering: "managed", + pageSize: 20, + search: search.trim(), + page, + }); + return { + pagination: propertyMappings.pagination, + options: propertyMappings.results.map((scope) => [scope.pk, scope.name, scope.name, scope]), + }; +} + +export function makeGoogleWorkspacePropertyMappingsSelector( + instanceMappings: string[] | undefined, + defaultSelection: string, +) { + const localMappings = instanceMappings ? new Set(instanceMappings) : undefined; + return localMappings + ? ([pk, _]: DualSelectPair) => localMappings.has(pk) + : ([_0, _1, _2, scope]: DualSelectPair) => + scope?.managed === defaultSelection; +} diff --git a/web/src/admin/providers/microsoft_entra/MicrosoftEntraProviderFormPage.ts b/web/src/admin/providers/microsoft_entra/MicrosoftEntraProviderForm.ts similarity index 76% rename from web/src/admin/providers/microsoft_entra/MicrosoftEntraProviderFormPage.ts rename to web/src/admin/providers/microsoft_entra/MicrosoftEntraProviderForm.ts index d6c62de86d5a..de2eb396ebc8 100644 --- a/web/src/admin/providers/microsoft_entra/MicrosoftEntraProviderFormPage.ts +++ b/web/src/admin/providers/microsoft_entra/MicrosoftEntraProviderForm.ts @@ -1,6 +1,12 @@ import { BaseProviderForm } from "@goauthentik/admin/providers/BaseProviderForm"; +import { + makeMicrosoftEntraPropertyMappingsSelector, + microsoftEntraPropertyMappingsProvider, +} from "@goauthentik/admin/providers/microsoft_entra/MicrosoftEntraProviderPropertyMappings"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { first } from "@goauthentik/common/utils"; +import "@goauthentik/elements/ak-dual-select/ak-dual-select-dynamic-selected-provider.js"; +import "@goauthentik/elements/ak-dual-select/ak-dual-select-provider.js"; import "@goauthentik/elements/forms/FormGroup"; import "@goauthentik/elements/forms/HorizontalFormElement"; import "@goauthentik/elements/forms/Radio"; @@ -17,8 +23,6 @@ import { Group, MicrosoftEntraProvider, OutgoingSyncDeleteAction, - PaginatedMicrosoftEntraProviderMappingList, - PropertymappingsApi, ProvidersApi, } from "@goauthentik/api"; @@ -30,16 +34,6 @@ export class MicrosoftEntraProviderFormPage extends BaseProviderForm { - this.propertyMappings = await new PropertymappingsApi( - DEFAULT_CONFIG, - ).propertymappingsProviderMicrosoftEntraList({ - ordering: "managed", - }); - } - - propertyMappings?: PaginatedMicrosoftEntraProviderMappingList; - async send(data: MicrosoftEntraProvider): Promise { if (this.instance) { return new ProvidersApi(DEFAULT_CONFIG).providersMicrosoftEntraUpdate({ @@ -218,68 +212,35 @@ export class MicrosoftEntraProviderFormPage extends BaseProviderForm - +

${msg("Property mappings used to user mapping.")}

-

- ${msg("Hold control/command to select multiple items.")} -

- +

${msg("Property mappings used to group creation.")}

-

- ${msg("Hold control/command to select multiple items.")} -

`; diff --git a/web/src/admin/providers/microsoft_entra/MicrosoftEntraProviderPropertyMappings.ts b/web/src/admin/providers/microsoft_entra/MicrosoftEntraProviderPropertyMappings.ts new file mode 100644 index 000000000000..6a9fa34dd576 --- /dev/null +++ b/web/src/admin/providers/microsoft_entra/MicrosoftEntraProviderPropertyMappings.ts @@ -0,0 +1,30 @@ +import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; +import { DualSelectPair } from "@goauthentik/elements/ak-dual-select/types.js"; + +import { PropertymappingsApi, ScopeMapping } from "@goauthentik/api"; + +export async function microsoftEntraPropertyMappingsProvider(page = 1, search = "") { + const propertyMappings = await new PropertymappingsApi( + DEFAULT_CONFIG, + ).propertymappingsProviderMicrosoftEntraList({ + ordering: "managed", + pageSize: 20, + search: search.trim(), + page, + }); + return { + pagination: propertyMappings.pagination, + options: propertyMappings.results.map((scope) => [scope.pk, scope.name, scope.name, scope]), + }; +} + +export function makeMicrosoftEntraPropertyMappingsSelector( + instanceMappings: string[] | undefined, + defaultSelection: string, +) { + const localMappings = instanceMappings ? new Set(instanceMappings) : undefined; + return localMappings + ? ([pk, _]: DualSelectPair) => localMappings.has(pk) + : ([_0, _1, _2, scope]: DualSelectPair) => + scope?.managed === defaultSelection; +} diff --git a/web/src/admin/providers/microsoft_entra/MicrosoftEntraProviderViewPage.ts b/web/src/admin/providers/microsoft_entra/MicrosoftEntraProviderViewPage.ts index 4cb7f34d59b5..bf4197c936e7 100644 --- a/web/src/admin/providers/microsoft_entra/MicrosoftEntraProviderViewPage.ts +++ b/web/src/admin/providers/microsoft_entra/MicrosoftEntraProviderViewPage.ts @@ -1,4 +1,4 @@ -import "@goauthentik/admin/providers/microsoft_entra/MicrosoftEntraProviderFormPage"; +import "@goauthentik/admin/providers/microsoft_entra/MicrosoftEntraProviderForm"; import "@goauthentik/admin/providers/microsoft_entra/MicrosoftEntraProviderGroupList"; import "@goauthentik/admin/providers/microsoft_entra/MicrosoftEntraProviderUserList"; import "@goauthentik/admin/rbac/ObjectPermissionsPage"; diff --git a/web/src/admin/providers/oauth2/Oauth2PropertyMappings.ts b/web/src/admin/providers/oauth2/OAuth2PropertyMappings.ts similarity index 100% rename from web/src/admin/providers/oauth2/Oauth2PropertyMappings.ts rename to web/src/admin/providers/oauth2/OAuth2PropertyMappings.ts diff --git a/web/src/admin/providers/oauth2/OAuth2ProviderForm.ts b/web/src/admin/providers/oauth2/OAuth2ProviderForm.ts index 935b976a3691..5cb3c417b59c 100644 --- a/web/src/admin/providers/oauth2/OAuth2ProviderForm.ts +++ b/web/src/admin/providers/oauth2/OAuth2ProviderForm.ts @@ -7,6 +7,7 @@ import "@goauthentik/components/ak-radio-input"; import "@goauthentik/components/ak-text-input"; import "@goauthentik/components/ak-textarea-input"; import "@goauthentik/elements/ak-dual-select/ak-dual-select-dynamic-selected-provider.js"; +import "@goauthentik/elements/ak-dual-select/ak-dual-select-provider.js"; import "@goauthentik/elements/forms/FormGroup"; import "@goauthentik/elements/forms/HorizontalFormElement"; import "@goauthentik/elements/forms/Radio"; @@ -23,16 +24,15 @@ import { FlowsInstancesListDesignationEnum, IssuerModeEnum, OAuth2Provider, - PaginatedOAuthSourceList, ProvidersApi, - SourcesApi, SubModeEnum, } from "@goauthentik/api"; import { makeOAuth2PropertyMappingsSelector, oauth2PropertyMappingsProvider, -} from "./Oauth2PropertyMappings.js"; +} from "./OAuth2PropertyMappings.js"; +import { oauth2SourcesProvider } from "./OAuth2Sources.js"; export const clientTypeOptions = [ { @@ -127,8 +127,6 @@ export const redirectUriHelp = html`${redirectUriHelpMessages.map( @customElement("ak-provider-oauth2-form") export class OAuth2ProviderFormPage extends BaseProviderForm { - oauthSources?: PaginatedOAuthSourceList; - @state() showClientSecret = true; @@ -140,13 +138,6 @@ export class OAuth2ProviderFormPage extends BaseProviderForm { return provider; } - async load(): Promise { - this.oauthSources = await new SourcesApi(DEFAULT_CONFIG).sourcesOauthList({ - ordering: "name", - hasJwks: true, - }); - } - async send(data: OAuth2Provider): Promise { if (this.instance) { return new ProvidersApi(DEFAULT_CONFIG).providersOauth2Update({ @@ -344,24 +335,17 @@ export class OAuth2ProviderFormPage extends BaseProviderForm { label=${msg("Trusted OIDC Sources")} name="jwksSources" > - +

${msg( "JWTs signed by certificates configured in the selected sources can be used to authenticate to this provider.", )}

-

- ${msg("Hold control/command to select multiple items.")} -

`; diff --git a/web/src/admin/providers/oauth2/OAuth2Sources.ts b/web/src/admin/providers/oauth2/OAuth2Sources.ts new file mode 100644 index 000000000000..4adc6dd42552 --- /dev/null +++ b/web/src/admin/providers/oauth2/OAuth2Sources.ts @@ -0,0 +1,21 @@ +import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; + +import { SourcesApi } from "@goauthentik/api"; + +export async function oauth2SourcesProvider(page = 1, search = "") { + const oauthSources = await new SourcesApi(DEFAULT_CONFIG).sourcesOauthList({ + ordering: "name", + hasJwks: true, + pageSize: 20, + search: search.trim(), + page, + }); + + return { + pagination: oauthSources.pagination, + options: oauthSources.results.map((source) => [ + source.pk, + `${source.name} (${source.slug})`, + ]), + }; +} diff --git a/web/src/admin/providers/proxy/ProxyProviderForm.ts b/web/src/admin/providers/proxy/ProxyProviderForm.ts index 86c4c5ba64a3..1e741fb1c82e 100644 --- a/web/src/admin/providers/proxy/ProxyProviderForm.ts +++ b/web/src/admin/providers/proxy/ProxyProviderForm.ts @@ -1,10 +1,12 @@ import "@goauthentik/admin/common/ak-crypto-certificate-search"; import "@goauthentik/admin/common/ak-flow-search/ak-flow-search"; import { BaseProviderForm } from "@goauthentik/admin/providers/BaseProviderForm"; +import { oauth2SourcesProvider } from "@goauthentik/admin/providers/oauth2/OAuth2Sources.js"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { first } from "@goauthentik/common/utils"; import "@goauthentik/components/ak-toggle-group"; import "@goauthentik/elements/ak-dual-select/ak-dual-select-dynamic-selected-provider.js"; +import "@goauthentik/elements/ak-dual-select/ak-dual-select-provider.js"; import "@goauthentik/elements/forms/FormGroup"; import "@goauthentik/elements/forms/HorizontalFormElement"; import "@goauthentik/elements/forms/SearchSelect"; @@ -21,11 +23,9 @@ import PFSpacing from "@patternfly/patternfly/utilities/Spacing/spacing.css"; import { FlowsInstancesListDesignationEnum, - PaginatedOAuthSourceList, ProvidersApi, ProxyMode, ProxyProvider, - SourcesApi, } from "@goauthentik/api"; import { @@ -48,15 +48,6 @@ export class ProxyProviderFormPage extends BaseProviderForm { return provider; } - async load(): Promise { - this.oauthSources = await new SourcesApi(DEFAULT_CONFIG).sourcesOauthList({ - ordering: "name", - hasJwks: true, - }); - } - - oauthSources?: PaginatedOAuthSourceList; - @state() showHttpBasic = true; @@ -412,24 +403,17 @@ ${this.instance?.skipPathRegex} - +

${msg( "JWTs signed by certificates configured in the selected sources can be used to authenticate to this provider.", )}

-

- ${msg("Hold control/command to select multiple items.")} -

`; diff --git a/web/src/admin/providers/radius/RadiusProviderForm.ts b/web/src/admin/providers/radius/RadiusProviderForm.ts index bb689d161e23..9beb0e115fc1 100644 --- a/web/src/admin/providers/radius/RadiusProviderForm.ts +++ b/web/src/admin/providers/radius/RadiusProviderForm.ts @@ -159,9 +159,6 @@ export class RadiusProviderFormPage extends WithBrandConfig(BaseProviderForm -

- ${msg("Hold control/command to select multiple items.")} -

`; diff --git a/web/src/admin/sources/ldap/LDAPSourceForm.ts b/web/src/admin/sources/ldap/LDAPSourceForm.ts index 7ab492901891..3004abec192c 100644 --- a/web/src/admin/sources/ldap/LDAPSourceForm.ts +++ b/web/src/admin/sources/ldap/LDAPSourceForm.ts @@ -34,6 +34,7 @@ async function propertyMappingsProvider(page = 1, search = "") { search: search.trim(), page, }); + return { pagination: propertyMappings.pagination, options: propertyMappings.results.map((m) => [m.pk, m.name, m.name, m]), diff --git a/web/src/admin/sources/plex/PlexSourceForm.ts b/web/src/admin/sources/plex/PlexSourceForm.ts index 4a01823af2d0..347954b76a5e 100644 --- a/web/src/admin/sources/plex/PlexSourceForm.ts +++ b/web/src/admin/sources/plex/PlexSourceForm.ts @@ -13,6 +13,8 @@ import { WithCapabilitiesConfig, } from "@goauthentik/elements/Interface/capabilitiesProvider"; import "@goauthentik/elements/ak-dual-select/ak-dual-select-dynamic-selected-provider.js"; +import "@goauthentik/elements/ak-dual-select/ak-dual-select-dynamic-selected-provider.js"; +import "@goauthentik/elements/ak-dual-select/ak-dual-select-provider.js"; import { DualSelectPair } from "@goauthentik/elements/ak-dual-select/types.js"; import "@goauthentik/elements/forms/FormGroup"; import "@goauthentik/elements/forms/HorizontalFormElement"; @@ -194,9 +196,6 @@ export class PlexSourceForm extends WithCapabilitiesConfig(BaseSourceForm -

- ${msg("Hold control/command to select multiple items.")} -

`; } diff --git a/web/src/admin/stages/authenticator_validate/AuthenticatorValidateStageForm.ts b/web/src/admin/stages/authenticator_validate/AuthenticatorValidateStageForm.ts index bd35b026742d..ff438f6963bc 100644 --- a/web/src/admin/stages/authenticator_validate/AuthenticatorValidateStageForm.ts +++ b/web/src/admin/stages/authenticator_validate/AuthenticatorValidateStageForm.ts @@ -3,7 +3,6 @@ import { deviceTypeRestrictionPair } from "@goauthentik/admin/stages/authenticat import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import "@goauthentik/elements/Alert"; import "@goauthentik/elements/ak-dual-select/ak-dual-select-provider"; -import { DataProvision } from "@goauthentik/elements/ak-dual-select/types"; import "@goauthentik/elements/forms/FormGroup"; import "@goauthentik/elements/forms/HorizontalFormElement"; import "@goauthentik/elements/forms/Radio"; @@ -23,6 +22,35 @@ import { UserVerificationEnum, } from "@goauthentik/api"; +async function stagesProvider(page = 1, search = "") { + const stages = await new StagesApi(DEFAULT_CONFIG).stagesAllList({ + ordering: "name", + pageSize: 20, + search: search.trim(), + page, + }); + + return { + pagination: stages.pagination, + options: stages.results.map((stage) => [stage.pk, `${stage.name} (${stage.verboseName})`]), + }; +} + +async function authenticatorWebauthnDeviceTypesListProvider(page = 1, search = "") { + const devicetypes = await new StagesApi( + DEFAULT_CONFIG, + ).stagesAuthenticatorWebauthnDeviceTypesList({ + pageSize: 20, + search: search.trim(), + page, + }); + + return { + pagination: devicetypes.pagination, + options: devicetypes.results.map(deviceTypeRestrictionPair), + }; +} + @customElement("ak-stage-authenticator-validate-form") export class AuthenticatorValidateStageForm extends BaseStageForm { async loadInstance(pk: string): Promise { @@ -177,21 +205,14 @@ export class AuthenticatorValidateStageForm extends BaseStageForm - +

${msg( "Stages used to configure Authenticator when user doesn't have any compatible devices. After this configuration Stage passes, the user is not prompted again.", @@ -242,19 +263,7 @@ export class AuthenticatorValidateStageForm extends BaseStageForm => { - return new StagesApi(DEFAULT_CONFIG) - .stagesAuthenticatorWebauthnDeviceTypesList({ - page: page, - search: search, - }) - .then((results) => { - return { - pagination: results.pagination, - options: results.results.map(deviceTypeRestrictionPair), - }; - }); - }} + .provider=${authenticatorWebauthnDeviceTypesListProvider} .selected=${(this.instance?.webauthnAllowedDeviceTypesObj ?? []).map( deviceTypeRestrictionPair, )} diff --git a/web/src/admin/stages/identification/IdentificationStageForm.ts b/web/src/admin/stages/identification/IdentificationStageForm.ts index fb097e6e821c..8b4c553c9639 100644 --- a/web/src/admin/stages/identification/IdentificationStageForm.ts +++ b/web/src/admin/stages/identification/IdentificationStageForm.ts @@ -3,6 +3,8 @@ import { BaseStageForm } from "@goauthentik/admin/stages/BaseStageForm"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { first, groupBy } from "@goauthentik/common/utils"; import "@goauthentik/elements/ak-checkbox-group/ak-checkbox-group.js"; +import "@goauthentik/elements/ak-dual-select/ak-dual-select-dynamic-selected-provider.js"; +import { DualSelectPair } from "@goauthentik/elements/ak-dual-select/types.js"; import "@goauthentik/elements/forms/FormGroup"; import "@goauthentik/elements/forms/HorizontalFormElement"; import "@goauthentik/elements/forms/SearchSelect"; @@ -15,7 +17,7 @@ import { ifDefined } from "lit/directives/if-defined.js"; import { FlowsInstancesListDesignationEnum, IdentificationStage, - PaginatedSourceList, + Source, SourcesApi, Stage, StagesApi, @@ -23,6 +25,31 @@ import { UserFieldsEnum, } from "@goauthentik/api"; +async function sourcesProvider(page = 1, search = "") { + const sources = await new SourcesApi(DEFAULT_CONFIG).sourcesAllList({ + ordering: "slug", + pageSize: 20, + search: search.trim(), + page, + }); + + return { + pagination: sources.pagination, + options: sources.results + .filter((source) => source.component !== "") + .map((source) => [source.pk, source.name, source.name, source]), + }; +} + +async function makeSourcesSelector(instanceSources: string[] | undefined) { + const localSources = instanceSources ? new Set(instanceSources) : undefined; + + return localSources + ? ([pk, _]: DualSelectPair) => localSources.has(pk) + : ([_0, _1, _2, source]: DualSelectPair) => + source !== undefined && source.component === ""; +} + @customElement("ak-stage-identification-form") export class IdentificationStageForm extends BaseStageForm { static get styles() { @@ -42,14 +69,6 @@ export class IdentificationStageForm extends BaseStageForm }); } - async load(): Promise { - this.sources = await new SourcesApi(DEFAULT_CONFIG).sourcesAllList({ - ordering: "slug", - }); - } - - sources?: PaginatedSourceList; - async send(data: IdentificationStage): Promise { if (this.instance) { return new StagesApi(DEFAULT_CONFIG).stagesIdentificationUpdate({ @@ -213,33 +232,17 @@ export class IdentificationStageForm extends BaseStageForm ?required=${true} name="sources" > - +

${msg( "Select sources should be shown for users to authenticate with. This only affects web-based sources, not LDAP.", )}

-

- ${msg("Hold control/command to select multiple items.")} -

- +

${msg( "Selected policies are executed when the stage is submitted to validate the data.", )}

-

- ${msg("Hold control/command to select multiple items.")} -

`; From e428e4cf5e898d2ab1ca8cbb494fe935121b952c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 22 Aug 2024 11:59:25 +0200 Subject: [PATCH 10/18] web: bump chromedriver from 127.0.3 to 128.0.0 in /tests/wdio (#11017) Bumps [chromedriver](https://github.com/giggio/node-chromedriver) from 127.0.3 to 128.0.0. - [Commits](https://github.com/giggio/node-chromedriver/compare/127.0.3...128.0.0) --- updated-dependencies: - dependency-name: chromedriver dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- tests/wdio/package-lock.json | 8 ++++---- tests/wdio/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/wdio/package-lock.json b/tests/wdio/package-lock.json index e8652b32edf3..0b94109e58a2 100644 --- a/tests/wdio/package-lock.json +++ b/tests/wdio/package-lock.json @@ -6,7 +6,7 @@ "": { "name": "@goauthentik/web-tests", "dependencies": { - "chromedriver": "^127.0.3" + "chromedriver": "^128.0.0" }, "devDependencies": { "@trivago/prettier-plugin-sort-imports": "^4.3.0", @@ -3238,9 +3238,9 @@ } }, "node_modules/chromedriver": { - "version": "127.0.3", - "resolved": "https://registry.npmjs.org/chromedriver/-/chromedriver-127.0.3.tgz", - "integrity": "sha512-trUHkFt0n7jGzNOgkO1srOJfz50kKyAGJ016PyV0hrtyKNIGnOC9r3Jlssz19UoEjSzI/1g2shEiIFtDbBYVaw==", + "version": "128.0.0", + "resolved": "https://registry.npmjs.org/chromedriver/-/chromedriver-128.0.0.tgz", + "integrity": "sha512-Ggo21z/dFQxTOTgU0vm0V59Mi79yyR+9AUk/KiVAsRfbDRdVZQYQWfgxnIvD/x8KOKn0oB7haRzDO/KfrKyvOA==", "hasInstallScript": true, "dependencies": { "@testim/chrome-version": "^1.1.4", diff --git a/tests/wdio/package.json b/tests/wdio/package.json index 4408df5f4274..86a18e0f8994 100644 --- a/tests/wdio/package.json +++ b/tests/wdio/package.json @@ -32,6 +32,6 @@ "node": ">=20" }, "dependencies": { - "chromedriver": "^127.0.3" + "chromedriver": "^128.0.0" } } From 46acab3b2ec72c628047f36492ca621c357c4d65 Mon Sep 17 00:00:00 2001 From: "Jens L." Date: Thu, 22 Aug 2024 16:38:55 +0200 Subject: [PATCH 11/18] providers/scim: add API endpoint to sync single user (#8486) * add api Signed-off-by: Jens Langhammer * add UI Signed-off-by: Jens Langhammer * format Signed-off-by: Jens Langhammer --------- Signed-off-by: Jens Langhammer --- .../google_workspace/api/providers.py | 6 +- .../microsoft_entra/api/providers.py | 6 +- authentik/lib/sync/outgoing/api.py | 56 ++++++- authentik/lib/sync/outgoing/tasks.py | 4 +- authentik/providers/scim/api/providers.py | 3 +- schema.yml | 148 ++++++++++++++++++ .../GoogleWorkspaceProviderGroupList.ts | 21 ++- .../GoogleWorkspaceProviderUserList.ts | 21 ++- .../MicrosoftEntraProviderGroupList.ts | 21 ++- .../MicrosoftEntraProviderUserList.ts | 21 ++- web/src/elements/sync/SyncObjectForm.ts | 131 ++++++++++++++++ 11 files changed, 424 insertions(+), 14 deletions(-) create mode 100644 web/src/elements/sync/SyncObjectForm.ts diff --git a/authentik/enterprise/providers/google_workspace/api/providers.py b/authentik/enterprise/providers/google_workspace/api/providers.py index 392ff9ea4fed..cae19432e62a 100644 --- a/authentik/enterprise/providers/google_workspace/api/providers.py +++ b/authentik/enterprise/providers/google_workspace/api/providers.py @@ -6,7 +6,10 @@ from authentik.core.api.used_by import UsedByMixin from authentik.enterprise.api import EnterpriseRequiredMixin from authentik.enterprise.providers.google_workspace.models import GoogleWorkspaceProvider -from authentik.enterprise.providers.google_workspace.tasks import google_workspace_sync +from authentik.enterprise.providers.google_workspace.tasks import ( + google_workspace_sync, + google_workspace_sync_objects, +) from authentik.lib.sync.outgoing.api import OutgoingSyncProviderStatusMixin @@ -52,3 +55,4 @@ class GoogleWorkspaceProviderViewSet(OutgoingSyncProviderStatusMixin, UsedByMixi search_fields = ["name"] ordering = ["name"] sync_single_task = google_workspace_sync + sync_objects_task = google_workspace_sync_objects diff --git a/authentik/enterprise/providers/microsoft_entra/api/providers.py b/authentik/enterprise/providers/microsoft_entra/api/providers.py index a5552c560ee9..40c7576cb168 100644 --- a/authentik/enterprise/providers/microsoft_entra/api/providers.py +++ b/authentik/enterprise/providers/microsoft_entra/api/providers.py @@ -6,7 +6,10 @@ from authentik.core.api.used_by import UsedByMixin from authentik.enterprise.api import EnterpriseRequiredMixin from authentik.enterprise.providers.microsoft_entra.models import MicrosoftEntraProvider -from authentik.enterprise.providers.microsoft_entra.tasks import microsoft_entra_sync +from authentik.enterprise.providers.microsoft_entra.tasks import ( + microsoft_entra_sync, + microsoft_entra_sync_objects, +) from authentik.lib.sync.outgoing.api import OutgoingSyncProviderStatusMixin @@ -50,3 +53,4 @@ class MicrosoftEntraProviderViewSet(OutgoingSyncProviderStatusMixin, UsedByMixin search_fields = ["name"] ordering = ["name"] sync_single_task = microsoft_entra_sync + sync_objects_task = microsoft_entra_sync_objects diff --git a/authentik/lib/sync/outgoing/api.py b/authentik/lib/sync/outgoing/api.py index 6ecb13ddd2b6..a808535321e3 100644 --- a/authentik/lib/sync/outgoing/api.py +++ b/authentik/lib/sync/outgoing/api.py @@ -1,16 +1,19 @@ -from collections.abc import Callable - +from celery import Task from django.utils.text import slugify from drf_spectacular.utils import OpenApiResponse, extend_schema from guardian.shortcuts import get_objects_for_user from rest_framework.decorators import action -from rest_framework.fields import BooleanField +from rest_framework.fields import BooleanField, CharField, ChoiceField from rest_framework.request import Request from rest_framework.response import Response from authentik.core.api.utils import ModelSerializer, PassiveSerializer +from authentik.core.models import Group, User from authentik.events.api.tasks import SystemTaskSerializer +from authentik.events.logs import LogEvent, LogEventSerializer from authentik.lib.sync.outgoing.models import OutgoingSyncProvider +from authentik.lib.utils.reflection import class_to_path +from authentik.rbac.filters import ObjectFilter class SyncStatusSerializer(PassiveSerializer): @@ -20,10 +23,29 @@ class SyncStatusSerializer(PassiveSerializer): tasks = SystemTaskSerializer(many=True, read_only=True) +class SyncObjectSerializer(PassiveSerializer): + """Sync object serializer""" + + sync_object_model = ChoiceField( + choices=( + (class_to_path(User), "user"), + (class_to_path(Group), "group"), + ) + ) + sync_object_id = CharField() + + +class SyncObjectResultSerializer(PassiveSerializer): + """Result of a single object sync""" + + messages = LogEventSerializer(many=True, read_only=True) + + class OutgoingSyncProviderStatusMixin: """Common API Endpoints for Outgoing sync providers""" - sync_single_task: Callable = None + sync_single_task: type[Task] = None + sync_objects_task: type[Task] = None @extend_schema( responses={ @@ -36,7 +58,7 @@ class OutgoingSyncProviderStatusMixin: detail=True, pagination_class=None, url_path="sync/status", - filter_backends=[], + filter_backends=[ObjectFilter], ) def sync_status(self, request: Request, pk: int) -> Response: """Get provider's sync status""" @@ -55,6 +77,30 @@ def sync_status(self, request: Request, pk: int) -> Response: } return Response(SyncStatusSerializer(status).data) + @extend_schema( + request=SyncObjectSerializer, + responses={200: SyncObjectResultSerializer()}, + ) + @action( + methods=["POST"], + detail=True, + pagination_class=None, + url_path="sync/object", + filter_backends=[ObjectFilter], + ) + def sync_object(self, request: Request, pk: int) -> Response: + """Sync/Re-sync a single user/group object""" + provider: OutgoingSyncProvider = self.get_object() + params = SyncObjectSerializer(data=request.data) + params.is_valid(raise_exception=True) + res: list[LogEvent] = self.sync_objects_task.delay( + params.validated_data["sync_object_model"], + page=1, + provider_pk=provider.pk, + pk=params.validated_data["sync_object_id"], + ).get() + return Response(SyncObjectResultSerializer(instance={"messages": res}).data) + class OutgoingSyncConnectionCreateMixin: """Mixin for connection objects that fetches remote data upon creation""" diff --git a/authentik/lib/sync/outgoing/tasks.py b/authentik/lib/sync/outgoing/tasks.py index 7491c72989d8..4ce74df1f994 100644 --- a/authentik/lib/sync/outgoing/tasks.py +++ b/authentik/lib/sync/outgoing/tasks.py @@ -105,7 +105,7 @@ def sync_single( return task.set_status(TaskStatus.SUCCESSFUL, *messages) - def sync_objects(self, object_type: str, page: int, provider_pk: int): + def sync_objects(self, object_type: str, page: int, provider_pk: int, **filter): _object_type = path_to_class(object_type) self.logger = get_logger().bind( provider_type=class_to_path(self._provider_model), @@ -120,7 +120,7 @@ def sync_objects(self, object_type: str, page: int, provider_pk: int): client = provider.client_for_model(_object_type) except TransientSyncException: return messages - paginator = Paginator(provider.get_object_qs(_object_type), PAGE_SIZE) + paginator = Paginator(provider.get_object_qs(_object_type).filter(**filter), PAGE_SIZE) if client.can_discover: self.logger.debug("starting discover") client.discover() diff --git a/authentik/providers/scim/api/providers.py b/authentik/providers/scim/api/providers.py index 45b3c4556f94..731077e6aa64 100644 --- a/authentik/providers/scim/api/providers.py +++ b/authentik/providers/scim/api/providers.py @@ -6,7 +6,7 @@ from authentik.core.api.used_by import UsedByMixin from authentik.lib.sync.outgoing.api import OutgoingSyncProviderStatusMixin from authentik.providers.scim.models import SCIMProvider -from authentik.providers.scim.tasks import scim_sync +from authentik.providers.scim.tasks import scim_sync, scim_sync_objects class SCIMProviderSerializer(ProviderSerializer): @@ -42,3 +42,4 @@ class SCIMProviderViewSet(OutgoingSyncProviderStatusMixin, UsedByMixin, ModelVie search_fields = ["name", "url"] ordering = ["name", "url"] sync_single_task = scim_sync + sync_objects_task = scim_sync_objects diff --git a/schema.yml b/schema.yml index e8f9838aafe8..8e809bcc57ba 100644 --- a/schema.yml +++ b/schema.yml @@ -17853,6 +17853,46 @@ paths: schema: $ref: '#/components/schemas/GenericError' description: '' + /providers/google_workspace/{id}/sync/object/: + post: + operationId: providers_google_workspace_sync_object_create + description: Sync/Re-sync a single user/group object + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this Google Workspace Provider. + required: true + tags: + - providers + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/SyncObjectRequest' + required: true + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/SyncObjectResult' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' /providers/google_workspace/{id}/sync/status/: get: operationId: providers_google_workspace_sync_status_retrieve @@ -18856,6 +18896,46 @@ paths: schema: $ref: '#/components/schemas/GenericError' description: '' + /providers/microsoft_entra/{id}/sync/object/: + post: + operationId: providers_microsoft_entra_sync_object_create + description: Sync/Re-sync a single user/group object + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this Microsoft Entra Provider. + required: true + tags: + - providers + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/SyncObjectRequest' + required: true + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/SyncObjectResult' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' /providers/microsoft_entra/{id}/sync/status/: get: operationId: providers_microsoft_entra_sync_status_retrieve @@ -21346,6 +21426,46 @@ paths: schema: $ref: '#/components/schemas/GenericError' description: '' + /providers/scim/{id}/sync/object/: + post: + operationId: providers_scim_sync_object_create + description: Sync/Re-sync a single user/group object + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this SCIM Provider. + required: true + tags: + - providers + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/SyncObjectRequest' + required: true + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/SyncObjectResult' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' /providers/scim/{id}/sync/status/: get: operationId: providers_scim_sync_status_retrieve @@ -51354,6 +51474,34 @@ components: - user_email - user_upn type: string + SyncObjectModelEnum: + enum: + - authentik.core.models.User + - authentik.core.models.Group + type: string + SyncObjectRequest: + type: object + description: Sync object serializer + properties: + sync_object_model: + $ref: '#/components/schemas/SyncObjectModelEnum' + sync_object_id: + type: string + minLength: 1 + required: + - sync_object_id + - sync_object_model + SyncObjectResult: + type: object + description: Result of a single object sync + properties: + messages: + type: array + items: + $ref: '#/components/schemas/LogEvent' + readOnly: true + required: + - messages SyncStatus: type: object description: Provider sync status diff --git a/web/src/admin/providers/google_workspace/GoogleWorkspaceProviderGroupList.ts b/web/src/admin/providers/google_workspace/GoogleWorkspaceProviderGroupList.ts index 5a48cec2867c..58c7252fd417 100644 --- a/web/src/admin/providers/google_workspace/GoogleWorkspaceProviderGroupList.ts +++ b/web/src/admin/providers/google_workspace/GoogleWorkspaceProviderGroupList.ts @@ -1,12 +1,14 @@ import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import "@goauthentik/elements/forms/DeleteBulkForm"; +import "@goauthentik/elements/forms/ModalForm"; +import "@goauthentik/elements/sync/SyncObjectForm"; import { PaginatedResponse, Table, TableColumn } from "@goauthentik/elements/table/Table"; import { msg } from "@lit/localize"; import { TemplateResult, html } from "lit"; import { customElement, property } from "lit/decorators.js"; -import { GoogleWorkspaceProviderGroup, ProvidersApi } from "@goauthentik/api"; +import { GoogleWorkspaceProviderGroup, ProvidersApi, SyncObjectModelEnum } from "@goauthentik/api"; @customElement("ak-provider-google-workspace-groups-list") export class GoogleWorkspaceProviderGroupList extends Table { @@ -22,6 +24,23 @@ export class GoogleWorkspaceProviderGroupList extends Table + ${msg("Sync")} + ${msg("Sync User")} + + + + + ${super.renderToolbar()}`; + } + renderToolbarSelected(): TemplateResult { const disabled = this.selectedElements.length < 1; return html` { @@ -22,6 +24,23 @@ export class GoogleWorkspaceProviderUserList extends Table + ${msg("Sync")} + ${msg("Sync User")} + + + + + ${super.renderToolbar()}`; + } + renderToolbarSelected(): TemplateResult { const disabled = this.selectedElements.length < 1; return html` { @@ -19,6 +21,23 @@ export class MicrosoftEntraProviderGroupList extends Table + ${msg("Sync")} + ${msg("Sync User")} + + + + + ${super.renderToolbar()}`; + } + renderToolbarSelected(): TemplateResult { const disabled = this.selectedElements.length < 1; return html` { @@ -22,6 +24,23 @@ export class MicrosoftEntraProviderUserList extends Table + ${msg("Sync")} + ${msg("Sync User")} + + + + + ${super.renderToolbar()}`; + } + renderToolbarSelected(): TemplateResult { const disabled = this.selectedElements.length < 1; return html` { + @property({ type: Number }) + provider?: number; + + @property() + model: SyncObjectModelEnum = SyncObjectModelEnum.UnknownDefaultOpenApi; + + @property({ attribute: false }) + result?: SyncObjectResult; + + @property({ attribute: false }) + sync: ( + requestParameters: { + id: number; + syncObjectRequest: SyncObjectRequest; + }, + initOverrides?: RequestInit | InitOverrideFunction, + ) => Promise = (_, __) => { + return Promise.reject(); + }; + + getSuccessMessage(): string { + return msg("Successfully triggered sync."); + } + + async send(data: SyncObjectRequest): Promise { + data.syncObjectModel = this.model; + this.result = await this.sync({ + id: this.provider || 0, + syncObjectRequest: data, + }); + } + + renderSelectUser() { + return html` + => { + const args: CoreUsersListRequest = { + ordering: "username", + }; + if (query !== undefined) { + args.search = query; + } + const users = await new CoreApi(DEFAULT_CONFIG).coreUsersList(args); + return users.results; + }} + .renderElement=${(user: User): string => { + return user.username; + }} + .renderDescription=${(user: User): TemplateResult => { + return html`${user.name}`; + }} + .value=${(user: User | undefined): number | undefined => { + return user?.pk; + }} + > + + `; + } + + renderSelectGroup() { + return html` + => { + const args: CoreGroupsListRequest = { + ordering: "name", + }; + if (query !== undefined) { + args.search = query; + } + const groups = await new CoreApi(DEFAULT_CONFIG).coreGroupsList(args); + return groups.results; + }} + .renderElement=${(group: Group): string => { + return group.name; + }} + .value=${(group: Group | undefined): string | undefined => { + return group?.pk; + }} + > + + `; + } + + renderResult(): TemplateResult { + return html` +
+
+
+ +
+
+
+
`; + } + + renderForm() { + return html` ${this.model === SyncObjectModelEnum.User ? this.renderSelectUser() : nothing} + ${this.model === SyncObjectModelEnum.Group ? this.renderSelectGroup() : nothing} + ${this.result ? this.renderResult() : html``}`; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ak-sync-object-form": SyncObjectForm; + } +} From eafb7093c946276cbca2418cdfd18e932836b249 Mon Sep 17 00:00:00 2001 From: "Jens L." Date: Thu, 22 Aug 2024 16:39:18 +0200 Subject: [PATCH 12/18] providers/scim: optimize sending all members within a group (#9968) * providers/scim: optimize sending all members within a group Signed-off-by: Jens Langhammer * correctly batch requests Signed-off-by: Jens Langhammer --------- Signed-off-by: Jens Langhammer --- authentik/providers/scim/clients/groups.py | 82 ++++++++++++++-------- authentik/providers/scim/clients/schema.py | 12 +++- 2 files changed, 62 insertions(+), 32 deletions(-) diff --git a/authentik/providers/scim/clients/groups.py b/authentik/providers/scim/clients/groups.py index b1dc657dcb2a..1f39eea8f52d 100644 --- a/authentik/providers/scim/clients/groups.py +++ b/authentik/providers/scim/clients/groups.py @@ -1,5 +1,7 @@ """Group client""" +from itertools import batched + from pydantic import ValidationError from pydanticscim.group import GroupMember from pydanticscim.responses import PatchOp, PatchOperation @@ -56,17 +58,22 @@ def to_schema(self, obj: Group, connection: SCIMProviderGroup) -> SCIMGroupSchem if not scim_group.externalId: scim_group.externalId = str(obj.pk) - users = list(obj.users.order_by("id").values_list("id", flat=True)) - connections = SCIMProviderUser.objects.filter(provider=self.provider, user__pk__in=users) - members = [] - for user in connections: - members.append( - GroupMember( - value=user.scim_id, - ) + if not self._config.patch.supported: + users = list(obj.users.order_by("id").values_list("id", flat=True)) + connections = SCIMProviderUser.objects.filter( + provider=self.provider, user__pk__in=users ) - if members: - scim_group.members = members + members = [] + for user in connections: + members.append( + GroupMember( + value=user.scim_id, + ) + ) + if members: + scim_group.members = members + else: + del scim_group.members return scim_group def delete(self, obj: Group): @@ -93,16 +100,19 @@ def create(self, group: Group): scim_id = response.get("id") if not scim_id or scim_id == "": raise StopSync("SCIM Response with missing or invalid `id`") - return SCIMProviderGroup.objects.create( + connection = SCIMProviderGroup.objects.create( provider=self.provider, group=group, scim_id=scim_id ) + users = list(group.users.order_by("id").values_list("id", flat=True)) + self._patch_add_users(group, users) + return connection def update(self, group: Group, connection: SCIMProviderGroup): """Update existing group""" scim_group = self.to_schema(group, connection) scim_group.id = connection.scim_id try: - return self._request( + self._request( "PUT", f"/Groups/{connection.scim_id}", json=scim_group.model_dump( @@ -110,6 +120,8 @@ def update(self, group: Group, connection: SCIMProviderGroup): exclude_unset=True, ), ) + users = list(group.users.order_by("id").values_list("id", flat=True)) + return self._patch_add_users(group, users) except NotFoundSyncException: # Resource missing is handled by self.write, which will re-create the group raise @@ -152,14 +164,18 @@ def _patch( group_id: str, *ops: PatchOperation, ): - req = PatchRequest(Operations=ops) - self._request( - "PATCH", - f"/Groups/{group_id}", - json=req.model_dump( - mode="json", - ), - ) + chunk_size = self._config.bulk.maxOperations + if chunk_size < 1: + chunk_size = len(ops) + for chunk in batched(ops, chunk_size): + req = PatchRequest(Operations=list(chunk)) + self._request( + "PATCH", + f"/Groups/{group_id}", + json=req.model_dump( + mode="json", + ), + ) def _patch_add_users(self, group: Group, users_set: set[int]): """Add users in users_set to group""" @@ -180,11 +196,14 @@ def _patch_add_users(self, group: Group, users_set: set[int]): return self._patch( scim_group.scim_id, - PatchOperation( - op=PatchOp.add, - path="members", - value=[{"value": x} for x in user_ids], - ), + *[ + PatchOperation( + op=PatchOp.add, + path="members", + value=[{"value": x}], + ) + for x in user_ids + ], ) def _patch_remove_users(self, group: Group, users_set: set[int]): @@ -206,9 +225,12 @@ def _patch_remove_users(self, group: Group, users_set: set[int]): return self._patch( scim_group.scim_id, - PatchOperation( - op=PatchOp.remove, - path="members", - value=[{"value": x} for x in user_ids], - ), + *[ + PatchOperation( + op=PatchOp.remove, + path="members", + value=[{"value": x}], + ) + for x in user_ids + ], ) diff --git a/authentik/providers/scim/clients/schema.py b/authentik/providers/scim/clients/schema.py index f56d6b0e46f8..b4444b37346f 100644 --- a/authentik/providers/scim/clients/schema.py +++ b/authentik/providers/scim/clients/schema.py @@ -1,9 +1,11 @@ """Custom SCIM schemas""" +from pydantic import Field from pydanticscim.group import Group as BaseGroup from pydanticscim.responses import PatchRequest as BasePatchRequest from pydanticscim.responses import SCIMError as BaseSCIMError -from pydanticscim.service_provider import Bulk, ChangePassword, Filter, Patch, Sort +from pydanticscim.service_provider import Bulk as BaseBulk +from pydanticscim.service_provider import ChangePassword, Filter, Patch, Sort from pydanticscim.service_provider import ( ServiceProviderConfiguration as BaseServiceProviderConfiguration, ) @@ -29,10 +31,16 @@ class Group(BaseGroup): meta: dict | None = None +class Bulk(BaseBulk): + + maxOperations: int = Field() + + class ServiceProviderConfiguration(BaseServiceProviderConfiguration): """ServiceProviderConfig with fallback""" _is_fallback: bool | None = False + bulk: Bulk = Field(..., description="A complex type that specifies bulk configuration options.") @property def is_fallback(self) -> bool: @@ -45,7 +53,7 @@ def default() -> "ServiceProviderConfiguration": """Get default configuration, which doesn't support any optional features as fallback""" return ServiceProviderConfiguration( patch=Patch(supported=False), - bulk=Bulk(supported=False), + bulk=Bulk(supported=False, maxOperations=0), filter=Filter(supported=False), changePassword=ChangePassword(supported=False), sort=Sort(supported=False), From 72f2f974105862a976d28642d23ee018140348f6 Mon Sep 17 00:00:00 2001 From: "authentik-automation[bot]" <135050075+authentik-automation[bot]@users.noreply.github.com> Date: Thu, 22 Aug 2024 16:42:39 +0200 Subject: [PATCH 13/18] web: bump API Client version (#11021) Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: authentik-automation[bot] <135050075+authentik-automation[bot]@users.noreply.github.com> --- web/package-lock.json | 8 ++++---- web/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/web/package-lock.json b/web/package-lock.json index 16b43741ec1b..baf05d9c6e64 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -23,7 +23,7 @@ "@floating-ui/dom": "^1.6.9", "@formatjs/intl-listformat": "^7.5.7", "@fortawesome/fontawesome-free": "^6.6.0", - "@goauthentik/api": "^2024.6.3-1723921843", + "@goauthentik/api": "^2024.6.3-1724337552", "@lit/context": "^1.1.2", "@lit/localize": "^0.12.2", "@lit/reactive-element": "^2.0.4", @@ -3387,9 +3387,9 @@ "license": "MIT" }, "node_modules/@goauthentik/api": { - "version": "2024.6.3-1723921843", - "resolved": "https://registry.npmjs.org/@goauthentik/api/-/api-2024.6.3-1723921843.tgz", - "integrity": "sha512-WSn53NhZh2PVhGQ9g/PYQyHR0TVvZRtCLm94LVUci/JDNlqQNdJIpsoBSbOu3JJqmtI7pjlaX+DUT7ZHifU2Iw==" + "version": "2024.6.3-1724337552", + "resolved": "https://registry.npmjs.org/@goauthentik/api/-/api-2024.6.3-1724337552.tgz", + "integrity": "sha512-siu5qJqUt13iUPsLI0RfieVkDU8IMhuP2i5C/RRqY6oek0z+srSom9UTBAh6n6a2pTTNQO3clE2zxvAIJPahVg==" }, "node_modules/@goauthentik/web": { "resolved": "", diff --git a/web/package.json b/web/package.json index d455ec1734fa..f41ea5834a5d 100644 --- a/web/package.json +++ b/web/package.json @@ -11,7 +11,7 @@ "@floating-ui/dom": "^1.6.9", "@formatjs/intl-listformat": "^7.5.7", "@fortawesome/fontawesome-free": "^6.6.0", - "@goauthentik/api": "^2024.6.3-1723921843", + "@goauthentik/api": "^2024.6.3-1724337552", "@lit/context": "^1.1.2", "@lit/localize": "^0.12.2", "@lit/reactive-element": "^2.0.4", From 3daf8f8db4bd0bfc98d116c76f88c7ca2fe4258c Mon Sep 17 00:00:00 2001 From: "Jens L." Date: Thu, 22 Aug 2024 17:17:06 +0200 Subject: [PATCH 14/18] security: fix CVE-2024-42490 (#11022) CVE-2024-42490 Signed-off-by: Jens Langhammer --- authentik/core/api/used_by.py | 3 +- authentik/crypto/api.py | 5 +- authentik/crypto/tests.py | 60 +++++++++++++++++++ authentik/flows/api/flows.py | 3 +- authentik/outposts/api/service_connections.py | 3 +- website/docs/security/CVE-2024-42490.md | 31 ++++++++++ website/sidebars.js | 1 + 7 files changed, 101 insertions(+), 5 deletions(-) create mode 100644 website/docs/security/CVE-2024-42490.md diff --git a/authentik/core/api/used_by.py b/authentik/core/api/used_by.py index 3158420c4144..01b0c41cbf6a 100644 --- a/authentik/core/api/used_by.py +++ b/authentik/core/api/used_by.py @@ -14,6 +14,7 @@ from rest_framework.response import Response from authentik.core.api.utils import PassiveSerializer +from authentik.rbac.filters import ObjectFilter class DeleteAction(Enum): @@ -53,7 +54,7 @@ class UsedByMixin: @extend_schema( responses={200: UsedBySerializer(many=True)}, ) - @action(detail=True, pagination_class=None, filter_backends=[]) + @action(detail=True, pagination_class=None, filter_backends=[ObjectFilter]) def used_by(self, request: Request, *args, **kwargs) -> Response: """Get a list of all objects that use this object""" model: Model = self.get_object() diff --git a/authentik/crypto/api.py b/authentik/crypto/api.py index 95f4513f6117..5bd2665347e7 100644 --- a/authentik/crypto/api.py +++ b/authentik/crypto/api.py @@ -35,6 +35,7 @@ from authentik.crypto.models import CertificateKeyPair from authentik.events.models import Event, EventAction from authentik.rbac.decorators import permission_required +from authentik.rbac.filters import ObjectFilter LOGGER = get_logger() @@ -265,7 +266,7 @@ def generate(self, request: Request) -> Response: ], responses={200: CertificateDataSerializer(many=False)}, ) - @action(detail=True, pagination_class=None, filter_backends=[]) + @action(detail=True, pagination_class=None, filter_backends=[ObjectFilter]) def view_certificate(self, request: Request, pk: str) -> Response: """Return certificate-key pairs certificate and log access""" certificate: CertificateKeyPair = self.get_object() @@ -295,7 +296,7 @@ def view_certificate(self, request: Request, pk: str) -> Response: ], responses={200: CertificateDataSerializer(many=False)}, ) - @action(detail=True, pagination_class=None, filter_backends=[]) + @action(detail=True, pagination_class=None, filter_backends=[ObjectFilter]) def view_private_key(self, request: Request, pk: str) -> Response: """Return certificate-key pairs private key and log access""" certificate: CertificateKeyPair = self.get_object() diff --git a/authentik/crypto/tests.py b/authentik/crypto/tests.py index ae3a84260907..e2dc755e7ca0 100644 --- a/authentik/crypto/tests.py +++ b/authentik/crypto/tests.py @@ -214,6 +214,46 @@ def test_private_key_download(self): self.assertEqual(200, response.status_code) self.assertIn("Content-Disposition", response) + def test_certificate_download_denied(self): + """Test certificate export (download)""" + self.client.logout() + keypair = create_test_cert() + response = self.client.get( + reverse( + "authentik_api:certificatekeypair-view-certificate", + kwargs={"pk": keypair.pk}, + ) + ) + self.assertEqual(403, response.status_code) + response = self.client.get( + reverse( + "authentik_api:certificatekeypair-view-certificate", + kwargs={"pk": keypair.pk}, + ), + data={"download": True}, + ) + self.assertEqual(403, response.status_code) + + def test_private_key_download_denied(self): + """Test private_key export (download)""" + self.client.logout() + keypair = create_test_cert() + response = self.client.get( + reverse( + "authentik_api:certificatekeypair-view-private-key", + kwargs={"pk": keypair.pk}, + ) + ) + self.assertEqual(403, response.status_code) + response = self.client.get( + reverse( + "authentik_api:certificatekeypair-view-private-key", + kwargs={"pk": keypair.pk}, + ), + data={"download": True}, + ) + self.assertEqual(403, response.status_code) + def test_used_by(self): """Test used_by endpoint""" self.client.force_login(create_test_admin_user()) @@ -246,6 +286,26 @@ def test_used_by(self): ], ) + def test_used_by_denied(self): + """Test used_by endpoint""" + self.client.logout() + keypair = create_test_cert() + OAuth2Provider.objects.create( + name=generate_id(), + client_id="test", + client_secret=generate_key(), + authorization_flow=create_test_flow(), + redirect_uris="http://localhost", + signing_key=keypair, + ) + response = self.client.get( + reverse( + "authentik_api:certificatekeypair-used-by", + kwargs={"pk": keypair.pk}, + ) + ) + self.assertEqual(403, response.status_code) + def test_discovery(self): """Test certificate discovery""" name = generate_id() diff --git a/authentik/flows/api/flows.py b/authentik/flows/api/flows.py index 767ceea309d7..70bee5674ccb 100644 --- a/authentik/flows/api/flows.py +++ b/authentik/flows/api/flows.py @@ -37,6 +37,7 @@ ) from authentik.lib.views import bad_request_message from authentik.rbac.decorators import permission_required +from authentik.rbac.filters import ObjectFilter LOGGER = get_logger() @@ -281,7 +282,7 @@ def set_background_url(self, request: Request, slug: str): 400: OpenApiResponse(description="Flow not applicable"), }, ) - @action(detail=True, pagination_class=None, filter_backends=[]) + @action(detail=True, pagination_class=None, filter_backends=[ObjectFilter]) def execute(self, request: Request, slug: str): """Execute flow for current user""" # Because we pre-plan the flow here, and not in the planner, we need to manually clear diff --git a/authentik/outposts/api/service_connections.py b/authentik/outposts/api/service_connections.py index a677ccb5a463..85dadb515c14 100644 --- a/authentik/outposts/api/service_connections.py +++ b/authentik/outposts/api/service_connections.py @@ -26,6 +26,7 @@ KubernetesServiceConnection, OutpostServiceConnection, ) +from authentik.rbac.filters import ObjectFilter class ServiceConnectionSerializer(ModelSerializer, MetaNameSerializer): @@ -75,7 +76,7 @@ class ServiceConnectionViewSet( filterset_fields = ["name"] @extend_schema(responses={200: ServiceConnectionStateSerializer(many=False)}) - @action(detail=True, pagination_class=None, filter_backends=[]) + @action(detail=True, pagination_class=None, filter_backends=[ObjectFilter]) def state(self, request: Request, pk: str) -> Response: """Get the service connection's state""" connection = self.get_object() diff --git a/website/docs/security/CVE-2024-42490.md b/website/docs/security/CVE-2024-42490.md new file mode 100644 index 000000000000..3a024aa80d90 --- /dev/null +++ b/website/docs/security/CVE-2024-42490.md @@ -0,0 +1,31 @@ +# CVE-2024-42490 + +_Reported by [@m2a2](https://github.com/m2a2)_ + +## Improper Authorization for Token modification + +### Summary + +Several API endpoints can be accessed by users without correct authentication/authorization. + +The main API endpoints affected by this: + +- `/api/v3/crypto/certificatekeypairs//view_certificate/` +- `/api/v3/crypto/certificatekeypairs//view_private_key/` +- `/api/v3/.../used_by/` + +Note that all of the affected API endpoints require the knowledge of the ID of an object, which especially for certificates is not accessible to an unprivileged user. Additionally the IDs for most objects are UUIDv4, meaning they are not easily guessable/enumerable. + +### Patches + +authentik 2024.4.4, 2024.6.4 and 2024.8.0 fix this issue. + +### Workarounds + +Access to the API endpoints can be blocked at a Reverse-proxy/Load balancer level to prevent this issue from being exploited. + +### For more information + +If you have any questions or comments about this advisory: + +- Email us at [security@goauthentik.io](mailto:security@goauthentik.io) diff --git a/website/sidebars.js b/website/sidebars.js index 362acd06b727..dafd1a9db080 100644 --- a/website/sidebars.js +++ b/website/sidebars.js @@ -520,6 +520,7 @@ const docsSidebar = { items: [ "security/security-hardening", "security/policy", + "security/CVE-2024-42490", "security/CVE-2024-38371", "security/CVE-2024-37905", "security/CVE-2024-23647", From b301048a272aa76756d9d0d13d0b8b8fedeb0cfe Mon Sep 17 00:00:00 2001 From: "Jens L." Date: Thu, 22 Aug 2024 17:28:09 +0200 Subject: [PATCH 15/18] website/docs: cve release notes (#11026) website/docs: prepare cve releases Signed-off-by: Jens Langhammer --- website/docs/releases/2024/v2024.4.md | 4 ++++ website/docs/releases/2024/v2024.6.md | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/website/docs/releases/2024/v2024.4.md b/website/docs/releases/2024/v2024.4.md index 1f161bd244d2..6e89277270de 100644 --- a/website/docs/releases/2024/v2024.4.md +++ b/website/docs/releases/2024/v2024.4.md @@ -243,6 +243,10 @@ helm upgrade authentik authentik/authentik -f values.yaml --version ^2024.4 - sources/saml: fix FlowPlanner error due to pickle (cherry-pick #9708) (#9709) - web: fix value handling inside controlled components (cherry-pick #9648) (#9685) +## Fixed in 2024.4.4 + +- security: fix [CVE-2024-42490](../../security/CVE-2024-42490.md), reported by [@m2a2](https://github.com/m2a2) (cherry-pick #11022) #11024 + ## API Changes #### What's New diff --git a/website/docs/releases/2024/v2024.6.md b/website/docs/releases/2024/v2024.6.md index 6a5b8039f658..252aaa19bd3a 100644 --- a/website/docs/releases/2024/v2024.6.md +++ b/website/docs/releases/2024/v2024.6.md @@ -231,6 +231,10 @@ helm upgrade authentik authentik/authentik -f values.yaml --version ^2024.6 - web: fix theme not applying to document correctly (cherry-pick #10721) (#10722) - web: replace all occurrences of the theme placeholder (cherry-pick #10749) (#10750) +## Fixed in 2024.6.4 + +- security: fix [CVE-2024-42490](../../security/CVE-2024-42490.md), reported by [@m2a2](https://github.com/m2a2) (cherry-pick #11022) #11025 + ## API Changes #### What's New From 8cb03815fa4951e2c7a559c8d11a45a16f48a1ea Mon Sep 17 00:00:00 2001 From: Ken Sternberg Date: Thu, 22 Aug 2024 12:08:58 -0700 Subject: [PATCH 16/18] web: added basic unit testing to API-free tables Mostly these tests assert that the table renders and that the content we give it is where we expect it to be after sorting. For select tables, it also asserts that the overall value of the table is what we expect it to be when we click on a single row, or on the "select all" button. --- .../ak-table/tests/ak-select-table.tests.ts | 126 ++++++++++++++++++ .../ak-table/tests/ak-simple-table.tests.ts | 42 ++++++ 2 files changed, 168 insertions(+) create mode 100644 web/src/elements/ak-table/tests/ak-select-table.tests.ts create mode 100644 web/src/elements/ak-table/tests/ak-simple-table.tests.ts diff --git a/web/src/elements/ak-table/tests/ak-select-table.tests.ts b/web/src/elements/ak-table/tests/ak-select-table.tests.ts new file mode 100644 index 000000000000..89288fe5411e --- /dev/null +++ b/web/src/elements/ak-table/tests/ak-select-table.tests.ts @@ -0,0 +1,126 @@ +import { $, browser } from "@wdio/globals"; +import { slug } from "github-slugger"; + +import { html, render } from "lit"; + +import "../ak-select-table.js"; +import { SelectTable } from "../ak-select-table.js"; +import { nutritionDbUSDA as unsortedNutritionDbUSDA } from "../stories/sample_nutrition_db.js"; + +const alphaSort = (a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0); +const nutritionDbUSDA = unsortedNutritionDbUSDA.toSorted(alphaSort); + +const columns = ["Name", "Calories", "Protein", "Fiber", "Sugar"]; +const content = nutritionDbUSDA.map(({ name, calories, sugar, fiber, protein }) => ({ + key: slug(name), + content: [name, calories, protein, fiber, sugar].map((a) => html`${a}`), +})); + +const item3 = nutritionDbUSDA[2]; + +describe("Select Table", () => { + let selecttable: SelectTable; + let table: HTMLTableElement; + + beforeEach(async () => { + await render(html` `, document.body); + // @ts-ignore + selecttable = await $("ak-select-table"); + table = await selecttable.$(">>>table"); + }); + + it("it should render a select table", async () => { + expect(table).toBeDisplayed(); + }); + + it("the table should have as many entries as the data source", async () => { + const rows = await table.$("tbody").$$("tr"); + expect(rows.length).toBe(content.length); + }); + + it(`the third item ought to have the name ${item3.name}`, async () => { + const rows = await table.$("tbody").$$("tr"); + const cells = await rows[2].$$("td"); + const cell1Text = await cells[1].getText(); + expect(cell1Text).toEqual(item3.name); + }); + + it("Selecting one item ought to result in the value of the table being set", async () => { + const rows = await table.$("tbody").$$("tr"); + const control = await rows[2].$$("td")[0].$("input"); + await control.click(); + expect(await selecttable.getValue()).toEqual(slug(item3.name)); + }); + + afterEach(async () => { + await browser.execute(() => { + document.body.querySelector("ak-select-table")?.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 + delete document.body["_$litPart$"]; + } + }); +}); + +describe("Multiselect Table", () => { + let selecttable: SelectTable; + let table: HTMLTableElement; + + beforeEach(async () => { + await render( + html` `, + document.body + ); + // @ts-ignore + selecttable = await $("ak-select-table"); + table = await selecttable.$(">>>table"); + }); + + it("it should render the select-all control", async () => { + const selall = await table.$("thead").$$("tr")[0].$$("th")[0]; + const input = await selall.$("input"); + expect(await input.getProperty("name")).toEqual("select-all-input"); + }); + + it("it should set the value when one input is clicked", async () => { + const input = await table.$("tbody").$$("tr")[2].$$("td")[0].$("input"); + await input.click(); + expect(await selecttable.getValue()).toEqual(slug(nutritionDbUSDA[2].name)); + }); + + it("it should select all when that control is clicked", async () => { + const selall = await table.$("thead").$$("tr")[0].$$("th")[0]; + const input = await selall.$("input"); + await input.click(); + const value = await selecttable.getValue(); + const values = value.split(";").toSorted(alphaSort).join(";"); + const expected = nutritionDbUSDA.map((a) => slug(a.name)).join(";"); + expect(values).toEqual(expected); + }); + + it("it should clear all when that control is clicked twice", async () => { + const selall = await table.$("thead").$$("tr")[0].$$("th")[0]; + const input = await selall.$("input"); + await input.click(); + const value = await selecttable.getValue(); + const values = value.split(";").toSorted(alphaSort).join(";"); + const expected = nutritionDbUSDA.map((a) => slug(a.name)).join(";"); + expect(values).toEqual(expected); + await input.click(); + const newvalue = await selecttable.getValue(); + expect(newvalue).toEqual(""); + }); + + afterEach(async () => { + await browser.execute(() => { + document.body.querySelector("ak-select-table")?.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 + delete document.body["_$litPart$"]; + } + }); +}); diff --git a/web/src/elements/ak-table/tests/ak-simple-table.tests.ts b/web/src/elements/ak-table/tests/ak-simple-table.tests.ts new file mode 100644 index 000000000000..0a0177214f41 --- /dev/null +++ b/web/src/elements/ak-table/tests/ak-simple-table.tests.ts @@ -0,0 +1,42 @@ +import { $ } from "@wdio/globals"; +import { slug } from "github-slugger"; + +import { html, render } from "lit"; + +import "../ak-simple-table.js"; +import { SimpleTable } from "../ak-simple-table.js"; +import { nutritionDbUSDA } from "../stories/sample_nutrition_db.js"; + +const columns = ["Name", "Calories", "Protein", "Fiber", "Sugar"]; +const content = nutritionDbUSDA.map(({ name, calories, sugar, fiber, protein }) => ({ + key: slug(name), + content: [name, calories, protein, fiber, sugar].map((a) => html`${a}`), +})); + +describe("Simple Table", () => { + let table: SimpleTable; + + beforeEach(async () => { + await render(html` `, document.body); + // @ts-ignore + table = await $("ak-simple-table").$(">>>table"); + }); + + it("it should render a simple table", async () => { + expect(table).toBeDisplayed(); + }); + + it("the table should have as many entries as the data source", async () => { + const rows = await table.$("tbody").$$("tr"); + expect(rows.length).toBe(content.length); + }); + + afterEach(async () => { + await document.body.querySelector("ak-simple-table")?.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 + delete document.body["_$litPart$"]; + } + }); +}); From 198dc69d3a40f4a06480e6fc31193b1cdcb0c125 Mon Sep 17 00:00:00 2001 From: Ken Sternberg Date: Thu, 22 Aug 2024 13:54:28 -0700 Subject: [PATCH 17/18] web: finalize testing for tables Includes documentation updates and better tests for select-table. --- web/src/elements/ak-table/ak-select-table.ts | 8 +++-- ...table.tests.ts => ak-select-table.test.ts} | 36 +++++++++++++------ ...table.tests.ts => ak-simple-table.test.ts} | 8 +++-- web/src/elements/ak-table/types.ts | 19 +++++----- web/src/elements/ak-table/utils.ts | 10 +++++- 5 files changed, 55 insertions(+), 26 deletions(-) rename web/src/elements/ak-table/tests/{ak-select-table.tests.ts => ak-select-table.test.ts} (81%) rename web/src/elements/ak-table/tests/{ak-simple-table.tests.ts => ak-simple-table.test.ts} (86%) diff --git a/web/src/elements/ak-table/ak-select-table.ts b/web/src/elements/ak-table/ak-select-table.ts index 59c12ba16a96..7d6cddf85c85 100644 --- a/web/src/elements/ak-table/ak-select-table.ts +++ b/web/src/elements/ak-table/ak-select-table.ts @@ -209,12 +209,16 @@ export class SelectTable extends SimpleTable { public override renderRow(row: TableRow, _rowidx: number) { return html` ${this.renderCheckbox(row.key)} - ${map(row.content, (col, idx) => html`${col}`)} + ${map( + row.content, + (col, idx) => html`${col}`, + )} `; } renderAllOnThisPageCheckbox(): TemplateResult { - const checked = this.selectedOnPage.length && this.selectedOnPage.length === this.valuesOnPage.length; + const checked = + this.selectedOnPage.length && this.selectedOnPage.length === this.valuesOnPage.length; const onInput = (ev: InputEvent) => { const selected = [...this.selected]; diff --git a/web/src/elements/ak-table/tests/ak-select-table.tests.ts b/web/src/elements/ak-table/tests/ak-select-table.test.ts similarity index 81% rename from web/src/elements/ak-table/tests/ak-select-table.tests.ts rename to web/src/elements/ak-table/tests/ak-select-table.test.ts index 89288fe5411e..a44fd8c89492 100644 --- a/web/src/elements/ak-table/tests/ak-select-table.tests.ts +++ b/web/src/elements/ak-table/tests/ak-select-table.test.ts @@ -4,11 +4,14 @@ import { slug } from "github-slugger"; import { html, render } from "lit"; import "../ak-select-table.js"; -import { SelectTable } from "../ak-select-table.js"; import { nutritionDbUSDA as unsortedNutritionDbUSDA } from "../stories/sample_nutrition_db.js"; -const alphaSort = (a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0); -const nutritionDbUSDA = unsortedNutritionDbUSDA.toSorted(alphaSort); +type SortableRecord = Record; + +const dbSort = (a: SortableRecord, b: SortableRecord) => + a.name < b.name ? -1 : a.name > b.name ? 1 : 0; +const alphaSort = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0); +const nutritionDbUSDA = unsortedNutritionDbUSDA.toSorted(dbSort); const columns = ["Name", "Calories", "Protein", "Fiber", "Sugar"]; const content = nutritionDbUSDA.map(({ name, calories, sugar, fiber, protein }) => ({ @@ -19,11 +22,14 @@ const content = nutritionDbUSDA.map(({ name, calories, sugar, fiber, protein }) const item3 = nutritionDbUSDA[2]; describe("Select Table", () => { - let selecttable: SelectTable; - let table: HTMLTableElement; + let selecttable: WebdriverIO.Element; + let table: WebdriverIO.Element; beforeEach(async () => { - await render(html` `, document.body); + await render( + html` `, + document.body, + ); // @ts-ignore selecttable = await $("ak-select-table"); table = await selecttable.$(">>>table"); @@ -65,13 +71,14 @@ describe("Select Table", () => { }); describe("Multiselect Table", () => { - let selecttable: SelectTable; - let table: HTMLTableElement; + let selecttable: WebdriverIO.Element; + let table: WebdriverIO.Element; beforeEach(async () => { await render( - html` `, - document.body + html` + `, + document.body, ); // @ts-ignore selecttable = await $("ak-select-table"); @@ -80,6 +87,9 @@ describe("Multiselect Table", () => { it("it should render the select-all control", async () => { const selall = await table.$("thead").$$("tr")[0].$$("th")[0]; + if (selall === undefined) { + throw new Error("Could not find table header"); + } const input = await selall.$("input"); expect(await input.getProperty("name")).toEqual("select-all-input"); }); @@ -92,6 +102,9 @@ describe("Multiselect Table", () => { it("it should select all when that control is clicked", async () => { const selall = await table.$("thead").$$("tr")[0].$$("th")[0]; + if (selall === undefined) { + throw new Error("Could not find table header"); + } const input = await selall.$("input"); await input.click(); const value = await selecttable.getValue(); @@ -102,6 +115,9 @@ describe("Multiselect Table", () => { it("it should clear all when that control is clicked twice", async () => { const selall = await table.$("thead").$$("tr")[0].$$("th")[0]; + if (selall === undefined) { + throw new Error("Could not find table header"); + } const input = await selall.$("input"); await input.click(); const value = await selecttable.getValue(); diff --git a/web/src/elements/ak-table/tests/ak-simple-table.tests.ts b/web/src/elements/ak-table/tests/ak-simple-table.test.ts similarity index 86% rename from web/src/elements/ak-table/tests/ak-simple-table.tests.ts rename to web/src/elements/ak-table/tests/ak-simple-table.test.ts index 0a0177214f41..b88396f24c22 100644 --- a/web/src/elements/ak-table/tests/ak-simple-table.tests.ts +++ b/web/src/elements/ak-table/tests/ak-simple-table.test.ts @@ -4,7 +4,6 @@ import { slug } from "github-slugger"; import { html, render } from "lit"; import "../ak-simple-table.js"; -import { SimpleTable } from "../ak-simple-table.js"; import { nutritionDbUSDA } from "../stories/sample_nutrition_db.js"; const columns = ["Name", "Calories", "Protein", "Fiber", "Sugar"]; @@ -14,10 +13,13 @@ const content = nutritionDbUSDA.map(({ name, calories, sugar, fiber, protein }) })); describe("Simple Table", () => { - let table: SimpleTable; + let table: WebdriverIO.Element; beforeEach(async () => { - await render(html` `, document.body); + await render( + html` `, + document.body, + ); // @ts-ignore table = await $("ak-simple-table").$(">>>table"); }); diff --git a/web/src/elements/ak-table/types.ts b/web/src/elements/ak-table/types.ts index e036faa9bbbc..de59ce838d07 100644 --- a/web/src/elements/ak-table/types.ts +++ b/web/src/elements/ak-table/types.ts @@ -2,15 +2,14 @@ import { TemplateResult } from "lit"; import { TableColumn } from "./TableColumn"; -// authentik's tables (ak-basic-table, ak-select-table, ak-table) all take a tuple of two -// or three items, or a collection of groups of such tuples. In order to push dynamic checking -// around, we also allow the inclusion of a fourth component, which is just a scratchpad the -// developer can use for their own reasons. - -// The displayed element for our list can be a TemplateResult. If it is, we *strongly* recommend -// that you include the `sortBy` string as well, which is used for sorting but is also used for our -// autocomplete element (ak-search-select) both for tracking the user's input and for what we -// display in the autocomplete input box. +// authentik's standard tables (ak-simple-table, ak-select-table) all take a variety of types, the +// simplest of which is just an array of tuples, one for each column, along with an tuple for +// the definition of the column itself. +// +// More complex types are defined below, including those for grouped content. In he "utils" +// collection with this element you can find the [`convertContent`](./utils.ts) function, which can +// be used to create grouped content by providing a `groupBy` function, as well as selectable +// content by providing a `keyBy` function. See the documentation for `convertContent`. /** * - key (string, option): the value to return on "click", if the table is clickable / selectable @@ -19,7 +18,7 @@ import { TableColumn } from "./TableColumn"; export type TableRow = { key?: string; content: TemplateResult[]; - expansion?: () => TemplateResult; + // expansion?: () => TemplateResult; }; /** diff --git a/web/src/elements/ak-table/utils.ts b/web/src/elements/ak-table/utils.ts index 9555c151adea..9611f84417d5 100644 --- a/web/src/elements/ak-table/utils.ts +++ b/web/src/elements/ak-table/utils.ts @@ -31,7 +31,15 @@ export const isTableFlat = (v: unknown): v is TableFlat => * @func convertForTable * * Takes a variety of input types and streamlines them. Can't handle every contingency; be prepared - * to do conversions yourself as resources demand. Great for about 80% of use cases, though. + * to do conversions yourself as resources demand. Great for about 80% of use cases, though. + * + * - @param groupBy: If provided, for each item it must provide the group's name, by which the + * content will be grouped. The name is not a slug; it is what will be displayed. + * - @param keyBy: If provided, for each item it must provide a key for the item, which will be the + * value returned. + * + * For content that has already been grouped or converted into a single "flat" group, providing + * these functions will not do anything except generate a warning on the console. */ export function convertContent( From 484ebe9ff7b8485fb6ba1951a01399c8efce393b Mon Sep 17 00:00:00 2001 From: Ken Sternberg Date: Thu, 22 Aug 2024 14:49:42 -0700 Subject: [PATCH 18/18] Provide unit test accessibility to Firefox and Safari; wrap calls to manipulate test DOMs directly in a browser.exec call so they run in the proper context and be await()ed properly --- .../ak-table/tests/ak-select-table.test.ts | 20 +++---- .../ak-table/tests/ak-simple-table.test.ts | 12 ++-- .../tests/ak-search-select-view.test.ts | 14 +++-- .../tests/ak-search-select.test.ts | 12 ++-- web/wdio.conf.ts | 60 ++++++++++++------- 5 files changed, 71 insertions(+), 47 deletions(-) diff --git a/web/src/elements/ak-table/tests/ak-select-table.test.ts b/web/src/elements/ak-table/tests/ak-select-table.test.ts index a44fd8c89492..e17f09a95e3b 100644 --- a/web/src/elements/ak-table/tests/ak-select-table.test.ts +++ b/web/src/elements/ak-table/tests/ak-select-table.test.ts @@ -61,12 +61,12 @@ describe("Select Table", () => { afterEach(async () => { await browser.execute(() => { document.body.querySelector("ak-select-table")?.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 - delete document.body["_$litPart$"]; - } + if (document.body["_$litPart$"]) { + // @ts-expect-error expression of type '"_$litPart$"' is added by Lit + delete document.body["_$litPart$"]; + } + }); }); }); @@ -132,11 +132,11 @@ describe("Multiselect Table", () => { afterEach(async () => { await browser.execute(() => { document.body.querySelector("ak-select-table")?.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 - delete document.body["_$litPart$"]; - } + if (document.body["_$litPart$"]) { + // @ts-expect-error expression of type '"_$litPart$"' is added by Lit + delete document.body["_$litPart$"]; + } + }); }); }); diff --git a/web/src/elements/ak-table/tests/ak-simple-table.test.ts b/web/src/elements/ak-table/tests/ak-simple-table.test.ts index b88396f24c22..35bb7265a6e2 100644 --- a/web/src/elements/ak-table/tests/ak-simple-table.test.ts +++ b/web/src/elements/ak-table/tests/ak-simple-table.test.ts @@ -34,11 +34,13 @@ describe("Simple Table", () => { }); afterEach(async () => { - await document.body.querySelector("ak-simple-table")?.remove(); - // @ts-expect-error expression of type '"_$litPart$"' is added by Lit - if (document.body["_$litPart$"]) { + await browser.execute(() => { + document.body.querySelector("ak-simple-table")?.remove(); // @ts-expect-error expression of type '"_$litPart$"' is added by Lit - delete document.body["_$litPart$"]; - } + if (document.body["_$litPart$"]) { + // @ts-expect-error expression of type '"_$litPart$"' is added by Lit + delete document.body["_$litPart$"]; + } + }); }); }); 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 6c22e88696be..8c680b5e09e1 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 @@ -93,12 +93,14 @@ 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(); - // @ts-expect-error expression of type '"_$litPart$"' is added by Lit - if (document.body["_$litPart$"]) { + await browser.execute(() => { + 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 - delete document.body["_$litPart$"]; - } + if (document.body["_$litPart$"]) { + // @ts-expect-error expression of type '"_$litPart$"' is added by Lit + delete document.body["_$litPart$"]; + } + }); }); }); diff --git a/web/src/elements/forms/SearchSelect/tests/ak-search-select.test.ts b/web/src/elements/forms/SearchSelect/tests/ak-search-select.test.ts index c0026e8ee865..b43212d7d757 100644 --- a/web/src/elements/forms/SearchSelect/tests/ak-search-select.test.ts +++ b/web/src/elements/forms/SearchSelect/tests/ak-search-select.test.ts @@ -102,11 +102,13 @@ describe("Search select: event driven startup", () => { }); afterEach(async () => { - await document.body.querySelector("ak-mock-search-group")?.remove(); - // @ts-expect-error expression of type '"_$litPart$"' is added by Lit - if (document.body["_$litPart$"]) { + await browser.execute(() => { + document.body.querySelector("ak-mock-search-group")?.remove(); // @ts-expect-error expression of type '"_$litPart$"' is added by Lit - delete document.body["_$litPart$"]; - } + if (document.body["_$litPart$"]) { + // @ts-expect-error expression of type '"_$litPart$"' is added by Lit + delete document.body["_$litPart$"]; + } + }); }); }); diff --git a/web/wdio.conf.ts b/web/wdio.conf.ts index 550e44e50081..6f1edfee0ba6 100644 --- a/web/wdio.conf.ts +++ b/web/wdio.conf.ts @@ -9,6 +9,39 @@ import tsconfigPaths from "vite-tsconfig-paths"; const isProdBuild = process.env.NODE_ENV === "production"; const apiBasePath = process.env.AK_API_BASE_PATH || ""; const runHeadless = process.env.CI !== undefined; +const testSafari = process.env.WDIO_TEST_SAFARI !== undefined; +const testFirefox = process.env.WDIO_TEST_FIREFOX !== undefined; +const skipChrome = process.env.WDIO_SKIP_CHROME !== undefined; + +const capabilities = []; + +const MAX_INSTANCES = 10; + +if (!skipChrome) { + capabilities.push({ + // capabilities for local browser web tests + browserName: "chrome", // or "firefox", "microsoftedge", "safari" + ...(runHeadless + ? { + "goog:chromeOptions": { + args: ["headless", "disable-gpu"], + }, + } + : {}), + }); +} + +if (testSafari) { + capabilities.push({ + browserName: "safari", // or "firefox", "microsoftedge", "safari" + }); +} + +if (testFirefox) { + capabilities.push({ + browserName: "firefox", // or "firefox", "microsoftedge", "safari" + }); +} export const config: Options.Testrunner = { // @@ -19,18 +52,16 @@ export const config: Options.Testrunner = { runner: [ "browser", { - viteConfig: (config: UserConfig = { plugins: [] }) => ({ - ...config, + viteConfig: (userConfig: UserConfig = { plugins: [] }) => ({ + ...userConfig, plugins: [ replace({ - "process.env.NODE_ENV": JSON.stringify( - isProdBuild ? "production" : "development", - ), + "process.env.NODE_ENV": JSON.stringify(isProdBuild ? "production" : "development"), "process.env.CWD": JSON.stringify(cwd()), "process.env.AK_API_BASE_PATH": JSON.stringify(apiBasePath), "preventAssignment": true, }), - ...(config?.plugins ?? []), + ...(userConfig?.plugins ?? []), // @ts-ignore postcssLit(), tsconfigPaths(), @@ -83,26 +114,13 @@ export const config: Options.Testrunner = { // and 30 processes will get spawned. The property handles how many capabilities // from the same test should run tests. // - maxInstances: 10, + maxInstances: runHeadless ? MAX_INSTANCES : 1, // // If you have trouble getting all important capabilities together, check out the // Sauce Labs platform configurator - a great tool to configure your capabilities: // https://saucelabs.com/platform/platform-configurator // - capabilities: [ - { - // capabilities for local browser web tests - browserName: "chrome", // or "firefox", "microsoftedge", "safari" - ...(runHeadless - ? { - "goog:chromeOptions": { - args: ["headless", "disable-gpu"], - }, - } - : {}), - }, - ], - + capabilities, // // =================== // Test Configurations