diff --git a/Makefile b/Makefile index 54c53afdf..8a122b7e1 100644 --- a/Makefile +++ b/Makefile @@ -78,7 +78,7 @@ install: web-install website-install ## Install all requires dependencies for ` poetry install dev-drop-db: - echo dropdb -U ${pg_user} -h ${pg_host} ${pg_name} + dropdb -U ${pg_user} -h ${pg_host} ${pg_name} # Also remove the test-db if it exists dropdb -U ${pg_user} -h ${pg_host} test_${pg_name} || true echo redis-cli -n 0 flushall diff --git a/tests/wdio/test/pageobjects/admin.page.ts b/tests/wdio/test/pageobjects/admin.page.ts index 62db9fbc0..1be845500 100644 --- a/tests/wdio/test/pageobjects/admin.page.ts +++ b/tests/wdio/test/pageobjects/admin.page.ts @@ -5,7 +5,7 @@ const CLICK_TIME_DELAY = 250; export default class AdminPage extends Page { public get pageHeader() { - return $(">>>ak-page-header h1"); + return $('>>>ak-page-header slot[name="header"]'); } async openApplicationsListPage() { diff --git a/tests/wdio/test/pageobjects/applications-list.page.ts b/tests/wdio/test/pageobjects/applications-list.page.ts deleted file mode 100644 index 0f3d93e03..000000000 --- a/tests/wdio/test/pageobjects/applications-list.page.ts +++ /dev/null @@ -1,21 +0,0 @@ -import AdminPage from "./admin.page.js"; -import { $ } from "@wdio/globals"; - -/** - * sub page containing specific selectors and methods for a specific page - */ -class ApplicationsListPage extends AdminPage { - /** - * define selectors using getter methods - */ - - get startWizardButton() { - return $('>>>ak-wizard-frame button[slot="trigger"]'); - } - - async open() { - return await super.open("if/admin/#/core/applications"); - } -} - -export default new ApplicationsListPage(); diff --git a/tests/wdio/test/pageobjects/forms/oauth.form.ts b/tests/wdio/test/pageobjects/forms/oauth.form.ts new file mode 100644 index 000000000..d614d74ce --- /dev/null +++ b/tests/wdio/test/pageobjects/forms/oauth.form.ts @@ -0,0 +1,18 @@ +import Page from "../page.js"; +import { $ } from "@wdio/globals"; + +export class OauthForm extends Page { + async setAuthorizationFlow(selector: string) { + await this.searchSelect( + '>>>ak-flow-search[name="authorizationFlow"] input[type="text"]', + "authorizationFlow", + `button*=${selector}`, + ); + } + + get providerName() { + return $('>>>ak-form-element-horizontal[name="name"] input'); + } +} + +export default new OauthForm(); diff --git a/tests/wdio/test/pageobjects/page.ts b/tests/wdio/test/pageobjects/page.ts index ef915c6c5..3828261b9 100644 --- a/tests/wdio/test/pageobjects/page.ts +++ b/tests/wdio/test/pageobjects/page.ts @@ -38,4 +38,9 @@ export default class Page { const target = searchBlock.$(buttonSelector); return await target.click(); } + + public async logout() { + await browser.url("http://localhost:9000/flows/-/default/invalidation/"); + return await this.pause(); + } } diff --git a/tests/wdio/test/pageobjects/provider-wizard.page.ts b/tests/wdio/test/pageobjects/provider-wizard.page.ts new file mode 100644 index 000000000..e318f5cb5 --- /dev/null +++ b/tests/wdio/test/pageobjects/provider-wizard.page.ts @@ -0,0 +1,53 @@ +import AdminPage from "./admin.page.js"; +import OauthForm from "./forms/oauth.form.js"; +import { $ } from "@wdio/globals"; + +/** + * sub page containing specific selectors and methods for a specific page + */ + +class ProviderWizardView extends AdminPage { + /** + * define selectors using getter methods + */ + + oauth = OauthForm; + + get wizardTitle() { + return $(">>>ak-wizard .pf-c-wizard__header h1.pf-c-title"); + } + + get providerList() { + return $(">>>ak-provider-wizard-initial"); + } + + get nextButton() { + return $(">>>ak-wizard footer button.pf-m-primary"); + } + + async getProviderType(type: string) { + return await this.providerList.$(`>>>input[value="${type}"]`); + } + + get successMessage() { + return $('>>>[data-commit-state="success"]'); + } +} + +type Pair = [string, string]; + +// Define a getter for each provider type in the radio button collection. + +const providerValues: Pair[] = [["oauth2", "oauth2Provider"]]; + +providerValues.forEach(([value, name]: Pair) => { + Object.defineProperties(ProviderWizardView.prototype, { + [name]: { + get: function () { + return this.providerList.$(`>>>input[id="ak-provider-${value}-form"]`); + }, + }, + }); +}); + +export default new ProviderWizardView(); diff --git a/tests/wdio/test/pageobjects/providers-list.page.ts b/tests/wdio/test/pageobjects/providers-list.page.ts new file mode 100644 index 000000000..a49c18182 --- /dev/null +++ b/tests/wdio/test/pageobjects/providers-list.page.ts @@ -0,0 +1,47 @@ +import AdminPage from "./admin.page.js"; +import { $, browser } from "@wdio/globals"; +import { Key } from "webdriverio"; + +/** + * sub page containing specific selectors and methods for a specific page + */ +class ApplicationsListPage extends AdminPage { + /** + * define selectors using getter methods + */ + + get startWizardButton() { + return $('>>>ak-wizard button[slot="trigger"]'); + } + + get searchInput() { + return $('>>>ak-table-search input[name="search"]'); + } + + searchButton() { + return $('>>>ak-table-search button[type="submit"]'); + } + + // Sufficiently esoteric to justify having its own method + async clickSearchButton() { + await browser.execute( + function (searchButton: unknown) { + (searchButton as HTMLButtonElement).focus(); + }, + await $('>>>ak-table-search button[type="submit"]'), + ); + + return await browser.action("key").down(Key.Enter).up(Key.Enter).perform(); + } + + // Only use after a very precise search. :-) + async findProviderRow() { + return await $(">>>ak-provider-list td a"); + } + + async open() { + return await super.open("if/admin/#/core/providers"); + } +} + +export default new ApplicationsListPage(); diff --git a/tests/wdio/test/specs/oauth-provider.ts b/tests/wdio/test/specs/oauth-provider.ts new file mode 100644 index 000000000..e2b9af5dc --- /dev/null +++ b/tests/wdio/test/specs/oauth-provider.ts @@ -0,0 +1,46 @@ +import ProviderWizardView from "../pageobjects/provider-wizard.page.js"; +import ProvidersListPage from "../pageobjects/providers-list.page.js"; +import { randomId } from "../utils/index.js"; +import { login } from "../utils/login.js"; +import { expect } from "@wdio/globals"; + +async function reachTheProvider() { + await ProvidersListPage.logout(); + await login(); + await ProvidersListPage.open(); + await expect(await ProvidersListPage.pageHeader).toHaveText("Providers"); + + await ProvidersListPage.startWizardButton.click(); + await ProviderWizardView.wizardTitle.waitForDisplayed(); + await expect(await ProviderWizardView.wizardTitle).toHaveText("New provider"); +} + +describe("Configure Oauth2 Providers", () => { + it("Should configure a simple LDAP Application", async () => { + const newProviderName = `New OAuth2 Provider - ${randomId()}`; + + await reachTheProvider(); + + await ProviderWizardView.providerList.waitForDisplayed(); + await ProviderWizardView.oauth2Provider.scrollIntoView(); + await ProviderWizardView.oauth2Provider.click(); + await ProviderWizardView.nextButton.click(); + await ProviderWizardView.pause(); + + await ProviderWizardView.oauth.providerName.setValue(newProviderName); + await ProviderWizardView.oauth.setAuthorizationFlow( + "default-provider-authorization-explicit-consent", + ); + await ProviderWizardView.nextButton.click(); + await ProviderWizardView.pause(); + + await ProvidersListPage.searchInput.setValue(newProviderName); + await ProvidersListPage.clickSearchButton(); + await ProvidersListPage.pause(); + + const newProvider = await ProvidersListPage.findProviderRow(newProviderName); + await newProvider.waitForDisplayed(); + expect(newProvider).toExist(); + expect(await newProvider.getText()).toHaveText(newProviderName); + }); +}); diff --git a/web/.storybook/manager.ts b/web/.storybook/manager.ts index ab364c0c4..2ac3045a4 100644 --- a/web/.storybook/manager.ts +++ b/web/.storybook/manager.ts @@ -5,4 +5,5 @@ import authentikTheme from "./authentikTheme"; addons.setConfig({ theme: authentikTheme, + enableShortcuts: false, }); diff --git a/web/package.json b/web/package.json index 6a30ba57b..856e6d98b 100644 --- a/web/package.json +++ b/web/package.json @@ -15,7 +15,7 @@ "build-proxy": "run-s build-locales rollup:build-proxy", "watch": "run-s build-locales rollup:watch", "lint": "eslint . --max-warnings 0 --fix", - "lint:precommit": "eslint --max-warnings 0 --config ./.eslintrc.precommit.json $(git status --porcelain | grep '^[M?][M?]' | cut -c8- | grep -E '\\.(ts|js|tsx|jsx)$') ", + "lint:precommit": "eslint --max-warnings 0 --config ./.eslintrc.precommit.json $(git status --porcelain . | grep '^[M?][M?]' | cut -c8- | grep -E '\\.(ts|js|tsx|jsx)$') ", "lint:spelling": "codespell -D - -D ../.github/codespell-dictionary.txt -I ../.github/codespell-words.txt -S './src/locales/**' ./src -s", "lit-analyse": "lit-analyzer src", "precommit": "run-s tsc lit-analyse lint:precommit lint:spelling prettier", diff --git a/web/src/admin/common/ak-core-group-search.ts b/web/src/admin/common/ak-core-group-search.ts new file mode 100644 index 000000000..768b81c51 --- /dev/null +++ b/web/src/admin/common/ak-core-group-search.ts @@ -0,0 +1,104 @@ +import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; +import { AKElement } from "@goauthentik/elements/Base"; +import { SearchSelect } from "@goauthentik/elements/forms/SearchSelect"; +import { CustomListenerElement } from "@goauthentik/elements/utils/eventEmitter"; + +import { html } from "lit"; +import { customElement } from "lit/decorators.js"; +import { property, query } from "lit/decorators.js"; + +import { CoreApi, CoreGroupsListRequest, Group } from "@goauthentik/api"; + +async function fetchObjects(query?: string): Promise { + const args: CoreGroupsListRequest = { + ordering: "name", + }; + if (query !== undefined) { + args.search = query; + } + const groups = await new CoreApi(DEFAULT_CONFIG).coreGroupsList(args); + return groups.results; +} + +const renderElement = (group: Group): string => group.name; + +const renderValue = (group: Group | undefined): string | undefined => group?.pk; + +/** + * Core Group Search + * + * @element ak-core-group-search + * + * A wrapper around SearchSelect for the 8 search of groups used throughout our code + * base. This is one of those "If it's not error-free, at least it's localized to + * one place" issues. + * + */ + +@customElement("ak-core-group-search") +export class CoreGroupSearch extends CustomListenerElement(AKElement) { + /** + * The current group known to the caller. + * + * @attr + */ + @property({ type: String, reflect: true }) + group?: string; + + @query("ak-search-select") + search!: SearchSelect; + + @property({ type: String }) + name: string | null | undefined; + + selectedGroup?: Group; + + constructor() { + super(); + this.selected = this.selected.bind(this); + this.handleSearchUpdate = this.handleSearchUpdate.bind(this); + this.addCustomListener("ak-change", this.handleSearchUpdate); + } + + get value() { + return this.selectedGroup ? renderValue(this.selectedGroup) : undefined; + } + + connectedCallback() { + super.connectedCallback(); + const horizontalContainer = this.closest("ak-form-element-horizontal[name]"); + if (!horizontalContainer) { + throw new Error("This search can only be used in a named ak-form-element-horizontal"); + } + const name = horizontalContainer.getAttribute("name"); + const myName = this.getAttribute("name"); + if (name !== null && name !== myName) { + this.setAttribute("name", name); + } + } + + handleSearchUpdate(ev: CustomEvent) { + ev.stopPropagation(); + this.selectedGroup = ev.detail.value; + this.dispatchEvent(new InputEvent("input", { bubbles: true, composed: true })); + } + + selected(group: Group) { + return this.group === group.pk; + } + + render() { + return html` + + + `; + } +} + +export default CoreGroupSearch; diff --git a/web/src/admin/common/ak-flow-search/FlowSearch.ts b/web/src/admin/common/ak-flow-search/FlowSearch.ts index 0300e0bd3..3c146a8bd 100644 --- a/web/src/admin/common/ak-flow-search/FlowSearch.ts +++ b/web/src/admin/common/ak-flow-search/FlowSearch.ts @@ -80,6 +80,7 @@ export class FlowSearch extends CustomListenerElement(AKElement) handleSearchUpdate(ev: CustomEvent) { ev.stopPropagation(); this.selectedFlow = ev.detail.value; + this.dispatchEvent(new InputEvent("input", { bubbles: true, composed: true })); } async fetchObjects(query?: string): Promise { diff --git a/web/src/admin/policies/PolicyBindingForm.ts b/web/src/admin/policies/PolicyBindingForm.ts index 7de9d66f0..7c830a56b 100644 --- a/web/src/admin/policies/PolicyBindingForm.ts +++ b/web/src/admin/policies/PolicyBindingForm.ts @@ -92,7 +92,7 @@ export class PolicyBindingForm extends ModelForm { data.group = null; break; } - console.log(data); + if (this.instance?.pk) { return new PoliciesApi(DEFAULT_CONFIG).policiesBindingsUpdate({ policyBindingUuid: this.instance.pk, diff --git a/web/src/admin/providers/oauth2/OAuth2ProviderForm.ts b/web/src/admin/providers/oauth2/OAuth2ProviderForm.ts index b1e07c352..e20c63d9a 100644 --- a/web/src/admin/providers/oauth2/OAuth2ProviderForm.ts +++ b/web/src/admin/providers/oauth2/OAuth2ProviderForm.ts @@ -2,6 +2,9 @@ import "@goauthentik/admin/common/ak-crypto-certificate-search"; import "@goauthentik/admin/common/ak-flow-search/ak-flow-search"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { ascii_letters, digits, first, randomString } from "@goauthentik/common/utils"; +import "@goauthentik/components/ak-radio-input"; +import "@goauthentik/components/ak-text-input"; +import "@goauthentik/components/ak-textarea-input"; import "@goauthentik/elements/forms/FormGroup"; import "@goauthentik/elements/forms/HorizontalFormElement"; import { ModelForm } from "@goauthentik/elements/forms/ModelForm"; @@ -27,6 +30,91 @@ import { SubModeEnum, } from "@goauthentik/api"; +export const clientTypeOptions = [ + { + label: msg("Confidential"), + value: ClientTypeEnum.Confidential, + default: true, + description: html`${msg( + "Confidential clients are capable of maintaining the confidentiality of their credentials such as client secrets", + )}`, + }, + { + label: msg("Public"), + value: ClientTypeEnum.Public, + description: html`${msg( + "Public clients are incapable of maintaining the confidentiality and should use methods like PKCE. ", + )}`, + }, +]; + +export const subjectModeOptions = [ + { + label: msg("Based on the User's hashed ID"), + value: SubModeEnum.HashedUserId, + default: true, + }, + { + label: msg("Based on the User's ID"), + value: SubModeEnum.UserId, + }, + { + label: msg("Based on the User's UUID"), + value: SubModeEnum.UserUuid, + }, + { + label: msg("Based on the User's username"), + value: SubModeEnum.UserUsername, + }, + { + label: msg("Based on the User's Email"), + value: SubModeEnum.UserEmail, + description: html`${msg("This is recommended over the UPN mode.")}`, + }, + { + label: msg("Based on the User's UPN"), + value: SubModeEnum.UserUpn, + description: html`${msg( + "Requires the user to have a 'upn' attribute set, and falls back to hashed user ID. Use this mode only if you have different UPN and Mail domains.", + )}`, + }, +]; + +export const issuerModeOptions = [ + { + label: msg("Each provider has a different issuer, based on the application slug"), + value: IssuerModeEnum.PerProvider, + default: true, + }, + { + label: msg("Same identifier is used for all providers"), + value: IssuerModeEnum.Global, + }, +]; + +const redirectUriHelpMessages = [ + msg( + "Valid redirect URLs after a successful authorization flow. Also specify any origins here for Implicit flows.", + ), + msg( + "If no explicit redirect URIs are specified, the first successfully used redirect URI will be saved.", + ), + msg( + 'To allow any redirect URI, set this value to ".*". Be aware of the possible security implications this can have.', + ), +]; + +export const redirectUriHelp = html`${redirectUriHelpMessages.map( + (m) => html`

${m}

`, +)}`; + +/** + * Form page for OAuth2 Authentication Method + * + * @element ak-provider-oauth2-form + * + */ + @customElement("ak-provider-oauth2-form") export class OAuth2ProviderFormPage extends ModelForm { propertyMappings?: PaginatedScopeMappingList; @@ -77,22 +165,23 @@ export class OAuth2ProviderFormPage extends ModelForm { } renderForm(): TemplateResult { + const provider = this.instance; + return html`
- - - + +

@@ -100,13 +189,13 @@ export class OAuth2ProviderFormPage extends ModelForm {

@@ -117,96 +206,50 @@ export class OAuth2ProviderFormPage extends ModelForm { ${msg("Protocol settings")}

- ) => { + this.showClientSecret = ev.detail !== ClientTypeEnum.Public; + }} + .options=${clientTypeOptions} > - ) => { - if (ev.detail === ClientTypeEnum.Public) { - this.showClientSecret = false; - } else { - this.showClientSecret = true; - } - }} - .options=${[ - { - label: msg("Confidential"), - value: ClientTypeEnum.Confidential, - default: true, - description: html`${msg( - "Confidential clients are capable of maintaining the confidentiality of their credentials such as client secrets", - )}`, - }, - { - label: msg("Public"), - value: ClientTypeEnum.Public, - description: html`${msg( - "Public clients are incapable of maintaining the confidentiality and should use methods like PKCE. ", - )}`, - }, - ]} - .value=${this.instance?.clientType} - > - - - + - - - + - - - + - -

- ${msg( - "Valid redirect URLs after a successful authorization flow. Also specify any origins here for Implicit flows.", - )} -

-

- ${msg( - "If no explicit redirect URIs are specified, the first successfully used redirect URI will be saved.", - )} -

-

- ${msg( - 'To allow any redirect URI, set this value to ".*". Be aware of the possible security implications this can have.', - )} -

-
+ + +

${msg("Key used to sign the tokens.")}

@@ -216,69 +259,53 @@ ${this.instance?.redirectUris} ${msg("Advanced protocol settings")}
- + ${msg("Configure how long access codes are valid for.")} +

+ `} > - -

- ${msg("Configure how long access codes are valid for.")} -

- -
- + + ${msg("Configure how long access tokens are valid for.")} +

+ `} > - -

- ${msg("Configure how long access tokens are valid for.")} -

- -
- + + + ${msg("Configure how long refresh tokens are valid for.")} +

+ `} > - -

- ${msg("Configure how long refresh tokens are valid for.")} -

- -
+ - - - - - - ${msg("Include claims in id_token")} - -

- ${msg( - "Include User claims from scopes in the id_token, for applications that don't access the userinfo endpoint.", - )} -

-
- + + label=${msg("Include claims in id_token")} + ?checked=${first(provider?.includeClaimsInIdToken, true)} + help=${msg( + "Include User claims from scopes in the id_token, for applications that don't access the userinfo endpoint.", + )}> + - - -

- ${msg( - "Configure how the issuer field of the ID Token should be filled.", - )} -

-
+
@@ -407,7 +365,7 @@ ${this.instance?.redirectUris} -

- ${msg( - "User/Group Attribute used for the user part of the HTTP-Basic Header. If not set, the user's Email address is used.", - )} -

- - + + - -

- ${msg( - "User/Group Attribute used for the password part of the HTTP-Basic Header.", - )} -

-
`; + `; } renderModeSelector(): TemplateResult { diff --git a/web/src/components/ak-app-icon.ts b/web/src/components/ak-app-icon.ts index 5af288ad8..fcdb9d893 100644 --- a/web/src/components/ak-app-icon.ts +++ b/web/src/components/ak-app-icon.ts @@ -13,7 +13,7 @@ import { Application } from "@goauthentik/api"; @customElement("ak-app-icon") export class AppIcon extends AKElement { - @property({ attribute: false }) + @property({ type: Object, attribute: false }) app?: Application; @property() diff --git a/web/src/components/ak-file-input.ts b/web/src/components/ak-file-input.ts new file mode 100644 index 000000000..1fe2117a8 --- /dev/null +++ b/web/src/components/ak-file-input.ts @@ -0,0 +1,66 @@ +import { AKElement } from "@goauthentik/elements/Base"; + +import { msg } from "@lit/localize"; +import { html, nothing } from "lit"; +import { customElement, property, query } from "lit/decorators.js"; + +@customElement("ak-file-input") +export class AkFileInput extends AKElement { + // Render into the lightDOM. This effectively erases the shadowDOM nature of this component, but + // we're not actually using that and, for the meantime, we need the form handlers to be able to + // find the children of this component. + // + // TODO: This abstraction is wrong; it's putting *more* layers in as a way of managing the + // visual clutter and legibility issues of ak-form-elemental-horizontal and patternfly in + // general. + protected createRenderRoot() { + return this; + } + + @property({ type: String }) + name!: string; + + @property({ type: String }) + label = ""; + + /* + * The message to show next to the "current icon". + * + * @attr + */ + @property({ type: String }) + current = msg("Currently set to:"); + + @property({ type: String }) + value = ""; + + @property({ type: Boolean }) + required = false; + + @property({ type: String }) + help = ""; + + @query('input[type="file"]') + input!: HTMLInputElement; + + get files() { + return this.input.files; + } + + render() { + const currentMsg = + this.value && this.current + ? html`

${this.current} ${this.value}

` + : nothing; + + return html` + + ${currentMsg} + ${this.help.trim() ? html`

${this.help}

` : nothing} +
`; + } +} diff --git a/web/src/components/ak-number-input.ts b/web/src/components/ak-number-input.ts new file mode 100644 index 000000000..dcfef1541 --- /dev/null +++ b/web/src/components/ak-number-input.ts @@ -0,0 +1,52 @@ +import { AKElement } from "@goauthentik/elements/Base"; + +import { html, nothing } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import { ifDefined } from "lit/directives/if-defined.js"; + +@customElement("ak-number-input") +export class AkNumberInput extends AKElement { + // Render into the lightDOM. This effectively erases the shadowDOM nature of this component, but + // we're not actually using that and, for the meantime, we need the form handlers to be able to + // find the children of this component. + // + // TODO: This abstraction is wrong; it's putting *more* layers in as a way of managing the + // visual clutter and legibility issues of ak-form-elemental-horizontal and patternfly in + // general. + protected createRenderRoot() { + return this; + } + + @property({ type: String }) + name!: string; + + @property({ type: String }) + label = ""; + + @property({ type: Number, reflect: true }) + value = 0; + + @property({ type: Boolean }) + required = false; + + @property({ type: String }) + help = ""; + + render() { + return html` + + ${this.help ? html`

${this.help}

` : nothing} +
`; + } +} + +export default AkNumberInput; diff --git a/web/src/components/ak-radio-input.ts b/web/src/components/ak-radio-input.ts new file mode 100644 index 000000000..c65b8f1ae --- /dev/null +++ b/web/src/components/ak-radio-input.ts @@ -0,0 +1,61 @@ +import { AKElement } from "@goauthentik/elements/Base"; +import { RadioOption } from "@goauthentik/elements/forms/Radio"; +import "@goauthentik/elements/forms/Radio"; + +import { html, nothing } from "lit"; +import { customElement, property } from "lit/decorators.js"; + +@customElement("ak-radio-input") +export class AkRadioInput extends AKElement { + // Render into the lightDOM. This effectively erases the shadowDOM nature of this component, but + // we're not actually using that and, for the meantime, we need the form handlers to be able to + // find the children of this component. + // + // TODO: This abstraction is wrong; it's putting *more* layers in as a way of managing the + // visual clutter and legibility issues of ak-form-elemental-horizontal and patternfly in + // general. + protected createRenderRoot() { + return this; + } + + @property({ type: String }) + name!: string; + + @property({ type: String }) + label = ""; + + @property({ type: String }) + help = ""; + + @property({ type: Boolean }) + required = false; + + @property({ type: Object }) + value!: T; + + @property({ type: Array }) + options: RadioOption[] = []; + + handleInput(ev: CustomEvent) { + this.value = ev.detail.value; + } + + render() { + return html` + + ${this.help.trim() + ? html`

${this.help}

` + : nothing} +
`; + } +} + +export default AkRadioInput; diff --git a/web/src/components/ak-slug-input.ts b/web/src/components/ak-slug-input.ts new file mode 100644 index 000000000..b4fac3380 --- /dev/null +++ b/web/src/components/ak-slug-input.ts @@ -0,0 +1,171 @@ +import { convertToSlug } from "@goauthentik/common/utils"; +import { AKElement } from "@goauthentik/elements/Base"; + +import { TemplateResult, html, nothing } from "lit"; +import { customElement, property, query } from "lit/decorators.js"; +import { ifDefined } from "lit/directives/if-defined.js"; + +@customElement("ak-slug-input") +export class AkSlugInput extends AKElement { + // Render into the lightDOM. This effectively erases the shadowDOM nature of this component, but + // we're not actually using that and, for the meantime, we need the form handlers to be able to + // find the children of this component. + // + // TODO: This abstraction is wrong; it's putting *more* layers in as a way of managing the + // visual clutter and legibility issues of ak-form-elemental-horizontal and patternfly in + // general. + protected createRenderRoot() { + return this; + } + + @property({ type: String }) + name!: string; + + @property({ type: String }) + label = ""; + + @property({ type: String, reflect: true }) + value = ""; + + @property({ type: Boolean }) + required = false; + + @property({ type: String }) + help = ""; + + @property({ type: Boolean }) + hidden = false; + + @property({ type: Object }) + bighelp!: TemplateResult | TemplateResult[]; + + @property({ type: String }) + source = ""; + + origin?: HTMLInputElement | null; + + @query("input") + input!: HTMLInputElement; + + touched: boolean = false; + + constructor() { + super(); + this.slugify = this.slugify.bind(this); + this.handleTouch = this.handleTouch.bind(this); + } + + firstUpdated() { + this.input.addEventListener("input", this.handleTouch); + } + + renderHelp() { + return [ + this.help ? html`

${this.help}

` : nothing, + this.bighelp ? this.bighelp : nothing, + ]; + } + + // Do not stop propagation of this event; it must be sent up the tree so that a parent + // component, such as a custom forms manager, may receive it. + handleTouch(ev: Event) { + this.input.value = convertToSlug(this.input.value); + this.value = this.input.value; + + if (this.origin && this.origin.value === "" && this.input.value === "") { + this.touched = false; + return; + } + + if (ev && ev.target && ev.target instanceof HTMLInputElement) { + this.touched = true; + } + } + + slugify(ev: Event) { + if (!(ev && ev.target && ev.target instanceof HTMLInputElement)) { + return; + } + + // Reset 'touched' status if the slug & target have been reset + if (ev.target.value === "" && this.input.value === "") { + this.touched = false; + } + + // Don't proceed if the user has hand-modified the slug + if (this.touched) { + return; + } + + // A very primitive heuristic: if the previous iteration of the slug and the current + // iteration are *similar enough*, set the input value. "Similar enough" here is defined as + // "any event which adds or removes a character but leaves the rest of the slug looking like + // the previous iteration, set it to the current iteration." + + const newSlug = convertToSlug(ev.target.value); + const oldSlug = this.input.value; + const [shorter, longer] = + newSlug.length < oldSlug.length ? [newSlug, oldSlug] : [oldSlug, newSlug]; + + if (longer.substring(0, shorter.length) !== shorter) { + return; + } + + // The browser, as a security measure, sets the originating HTML object to be the + // target; developers cannot change it. In order to provide a meaningful value + // to listeners, both the name and value of the host must match those of the target + // input. The name is already handled since it's both required and automatically + // forwarded to our templated input, but the value must also be set. + + this.value = this.input.value = newSlug; + this.dispatchEvent( + new Event("input", { + bubbles: true, + cancelable: true, + }), + ); + } + + connectedCallback() { + super.connectedCallback(); + + // Set up listener on source element, so we can slugify the content. + setTimeout(() => { + if (this.source) { + const rootNode = this.getRootNode(); + if (rootNode instanceof ShadowRoot || rootNode instanceof Document) { + this.origin = rootNode.querySelector(this.source); + } + if (this.origin) { + this.origin.addEventListener("input", this.slugify); + } + } + }, 0); + } + + disconnectedCallback() { + if (this.origin) { + this.origin.removeEventListener("input", this.slugify); + } + super.disconnectedCallback(); + } + + render() { + return html` + + ${this.renderHelp()} + `; + } +} + +export default AkSlugInput; diff --git a/web/src/components/ak-switch-input.ts b/web/src/components/ak-switch-input.ts new file mode 100644 index 000000000..33eb0434c --- /dev/null +++ b/web/src/components/ak-switch-input.ts @@ -0,0 +1,55 @@ +import { AKElement } from "@goauthentik/elements/Base"; + +import { html, nothing } from "lit"; +import { customElement, property, query } from "lit/decorators.js"; + +@customElement("ak-switch-input") +export class AkSwitchInput extends AKElement { + // Render into the lightDOM. This effectively erases the shadowDOM nature of this component, but + // we're not actually using that and, for the meantime, we need the form handlers to be able to + // find the children of this component. + // + // TODO: This abstraction is wrong; it's putting *more* layers in as a way of managing the + // visual clutter and legibility issues of ak-form-elemental-horizontal and patternfly in + // general. + protected createRenderRoot() { + return this; + } + + @property({ type: String }) + name!: string; + + @property({ type: String }) + label = ""; + + @property({ type: Boolean }) + checked: boolean = false; + + @property({ type: Boolean }) + required = false; + + @property({ type: String }) + help = ""; + + @query("input.pf-c-switch__input[type=checkbox]") + checkbox!: HTMLInputElement; + + render() { + const doCheck = this.checked ? this.checked : undefined; + + return html` + + ${this.help.trim() ? html`

${this.help}

` : nothing} +
`; + } +} + +export default AkSwitchInput; diff --git a/web/src/components/ak-text-input.ts b/web/src/components/ak-text-input.ts new file mode 100644 index 000000000..2e7a9dd63 --- /dev/null +++ b/web/src/components/ak-text-input.ts @@ -0,0 +1,66 @@ +import { AKElement } from "@goauthentik/elements/Base"; + +import { TemplateResult, html, nothing } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import { ifDefined } from "lit/directives/if-defined.js"; + +@customElement("ak-text-input") +export class AkTextInput extends AKElement { + // Render into the lightDOM. This effectively erases the shadowDOM nature of this component, but + // we're not actually using that and, for the meantime, we need the form handlers to be able to + // find the children of this component. + // + // TODO: This abstraction is wrong; it's putting *more* layers in as a way of managing the + // visual clutter and legibility issues of ak-form-elemental-horizontal and patternfly in + // general. + protected createRenderRoot() { + return this; + } + + @property({ type: String }) + name!: string; + + @property({ type: String }) + label = ""; + + @property({ type: String, reflect: true }) + value = ""; + + @property({ type: Boolean }) + required = false; + + @property({ type: String }) + help = ""; + + @property({ type: Boolean }) + hidden = false; + + @property({ type: Object }) + bighelp!: TemplateResult | TemplateResult[]; + + renderHelp() { + return [ + this.help ? html`

${this.help}

` : nothing, + this.bighelp ? this.bighelp : nothing, + ]; + } + + render() { + return html` + + ${this.renderHelp()} + `; + } +} + +export default AkTextInput; diff --git a/web/src/components/ak-textarea-input.ts b/web/src/components/ak-textarea-input.ts new file mode 100644 index 000000000..95b138550 --- /dev/null +++ b/web/src/components/ak-textarea-input.ts @@ -0,0 +1,58 @@ +import { AKElement } from "@goauthentik/elements/Base"; + +import { TemplateResult, html, nothing } from "lit"; +import { customElement, property } from "lit/decorators.js"; + +@customElement("ak-textarea-input") +export class AkTextareaInput extends AKElement { + // Render into the lightDOM. This effectively erases the shadowDOM nature of this component, but + // we're not actually using that and, for the meantime, we need the form handlers to be able to + // find the children of this component. + // + // TODO: This abstraction is wrong; it's putting *more* layers in as a way of managing the + // visual clutter and legibility issues of ak-form-elemental-horizontal and patternfly in + // general. + protected createRenderRoot() { + return this; + } + + @property({ type: String }) + name!: string; + + @property({ type: String }) + label = ""; + + @property({ type: String }) + value = ""; + + @property({ type: Boolean }) + required = false; + + @property({ type: String }) + help = ""; + + @property({ type: Object }) + bighelp!: TemplateResult | TemplateResult[]; + + renderHelp() { + return [ + this.help ? html`

${this.help}

` : nothing, + this.bighelp ? this.bighelp : nothing, + ]; + } + + render() { + return html` + + ${this.renderHelp()} + `; + } +} + +export default AkTextareaInput; diff --git a/web/src/components/stories/ak-app-icon.stories.ts b/web/src/components/stories/ak-app-icon.stories.ts new file mode 100644 index 000000000..1847ad7bd --- /dev/null +++ b/web/src/components/stories/ak-app-icon.stories.ts @@ -0,0 +1,38 @@ +import "@goauthentik/elements/messages/MessageContainer"; +import { Meta } from "@storybook/web-components"; + +import { TemplateResult, html } from "lit"; + +import "../ak-app-icon"; +import AkAppIcon from "../ak-app-icon"; + +const metadata: Meta = { + title: "Components / App Icon", + component: "ak-app-icon", + parameters: { + docs: { + description: { + component: "A small card displaying an application icon", + }, + }, + }, +}; + +export default metadata; + +const container = (testItem: TemplateResult) => + html`
+ + ${testItem} +
`; + +export const AppIcon = () => { + return container(html``); +}; diff --git a/web/src/components/stories/ak-number-input.stories.ts b/web/src/components/stories/ak-number-input.stories.ts new file mode 100644 index 000000000..642a543a8 --- /dev/null +++ b/web/src/components/stories/ak-number-input.stories.ts @@ -0,0 +1,55 @@ +import "@goauthentik/elements/messages/MessageContainer"; +import { Meta } from "@storybook/web-components"; + +import { TemplateResult, html } from "lit"; + +import "../ak-number-input"; +import AkNumberInput from "../ak-number-input"; + +const metadata: Meta = { + title: "Components / Number Input", + component: "ak-number-input", + parameters: { + docs: { + description: { + component: "A stylized value control for number input", + }, + }, + }, +}; + +export default metadata; + +const container = (testItem: TemplateResult) => + html`
+ + + ${testItem} + +
    +
    `; + +export const NumberInput = () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const displayChange = (ev: any) => { + document.getElementById( + "number-message-pad", + )!.innerText = `Value selected: ${JSON.stringify(ev.target.value, null, 2)}`; + }; + + return container( + html``, + ); +}; diff --git a/web/src/components/stories/ak-radio-input.stories.ts b/web/src/components/stories/ak-radio-input.stories.ts new file mode 100644 index 000000000..68be02ecd --- /dev/null +++ b/web/src/components/stories/ak-radio-input.stories.ts @@ -0,0 +1,67 @@ +import "@goauthentik/elements/messages/MessageContainer"; +import { Meta } from "@storybook/web-components"; + +import { TemplateResult, html } from "lit"; + +import "../ak-radio-input"; +import AkRadioInput from "../ak-radio-input"; + +const metadata: Meta>> = { + title: "Components / Radio Input", + component: "ak-radio-input", + parameters: { + docs: { + description: { + component: "A stylized value control for radio buttons", + }, + }, + }, +}; + +export default metadata; + +const container = (testItem: TemplateResult) => + html`
    + + + ${testItem} + +
      +
      `; + +const testOptions = [ + { label: "Option One", description: html`This is option one.`, value: { funky: 1 } }, + { label: "Option Two", description: html`This is option two.`, value: { invalid: 2 } }, + { label: "Option Three", description: html`This is option three.`, value: { weird: 3 } }, +]; + +export const RadioInput = () => { + const result = ""; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const displayChange = (ev: any) => { + document.getElementById("radio-message-pad")!.innerText = `Value selected: ${JSON.stringify( + ev.target.value, + null, + 2, + )}`; + }; + + return container( + html` +
      ${result}
      `, + ); +}; diff --git a/web/src/components/stories/ak-slug-input.stories.ts b/web/src/components/stories/ak-slug-input.stories.ts new file mode 100644 index 000000000..57a51fc5e --- /dev/null +++ b/web/src/components/stories/ak-slug-input.stories.ts @@ -0,0 +1,64 @@ +import "@goauthentik/elements/messages/MessageContainer"; +import { Meta } from "@storybook/web-components"; + +import { TemplateResult, html } from "lit"; + +import "../ak-slug-input"; +import AkSlugInput from "../ak-slug-input"; +import "../ak-text-input"; + +const metadata: Meta = { + title: "Components / Slug Input", + component: "ak-slug-input", + parameters: { + docs: { + description: { + component: "A stylized value control for slug input", + }, + }, + }, +}; + +export default metadata; + +const container = (testItem: TemplateResult) => + html`
      + + + ${testItem} + +
        +
        `; + +export const SlugInput = () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const displayChange = (ev: any) => { + document.getElementById("text-message-pad")!.innerText = `Value selected: ${JSON.stringify( + ev.target.value, + null, + 2, + )}`; + }; + + return container( + html` + `, + ); +}; diff --git a/web/src/components/stories/ak-switch-input.stories.ts b/web/src/components/stories/ak-switch-input.stories.ts new file mode 100644 index 000000000..530e8c368 --- /dev/null +++ b/web/src/components/stories/ak-switch-input.stories.ts @@ -0,0 +1,63 @@ +import "@goauthentik/elements/messages/MessageContainer"; +import { Meta } from "@storybook/web-components"; + +import { TemplateResult, html } from "lit"; + +// Necessary because we're NOT supplying the CSS for the interiors +// in our "light" dom. +import PFSwitch from "@patternfly/patternfly/components/Switch/switch.css"; + +import "../ak-switch-input"; +import AkSwitchInput from "../ak-switch-input"; + +const metadata: Meta = { + title: "Components / Switch Input", + component: "ak-switch-input", + parameters: { + docs: { + description: { + component: "A stylized value control for a switch-like toggle", + }, + }, + }, +}; + +export default metadata; + +const container = (testItem: TemplateResult) => + html`
        + + + ${testItem} + +
          +
          `; + +export const SwitchInput = () => { + const result = ""; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const displayChange = (ev: any) => { + document.getElementById( + "switch-message-pad", + )!.innerText = `Value selected: ${JSON.stringify(ev.target.checked, null, 2)}`; + }; + + return container( + html` +
          ${result}
          `, + ); +}; diff --git a/web/src/components/stories/ak-text-input.stories.ts b/web/src/components/stories/ak-text-input.stories.ts new file mode 100644 index 000000000..0359f2fab --- /dev/null +++ b/web/src/components/stories/ak-text-input.stories.ts @@ -0,0 +1,57 @@ +import "@goauthentik/elements/messages/MessageContainer"; +import { Meta } from "@storybook/web-components"; + +import { TemplateResult, html } from "lit"; + +import "../ak-text-input"; +import AkTextInput from "../ak-text-input"; + +const metadata: Meta = { + title: "Components / Text Input", + component: "ak-text-input", + parameters: { + docs: { + description: { + component: "A stylized value control for text input", + }, + }, + }, +}; + +export default metadata; + +const container = (testItem: TemplateResult) => + html`
          + + + ${testItem} + +
            +
            `; + +export const TextInput = () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const displayChange = (ev: any) => { + document.getElementById("text-message-pad")!.innerText = `Value selected: ${JSON.stringify( + ev.target.value, + null, + 2, + )}`; + }; + + return container( + html``, + ); +}; diff --git a/web/src/components/stories/ak-textarea-input.stories.ts b/web/src/components/stories/ak-textarea-input.stories.ts new file mode 100644 index 000000000..cab3c47ff --- /dev/null +++ b/web/src/components/stories/ak-textarea-input.stories.ts @@ -0,0 +1,55 @@ +import "@goauthentik/elements/messages/MessageContainer"; +import { Meta } from "@storybook/web-components"; + +import { TemplateResult, html } from "lit"; + +import "../ak-textarea-input"; +import AkTextareaInput from "../ak-textarea-input"; + +const metadata: Meta = { + title: "Components / Textarea Input", + component: "ak-textarea-input", + parameters: { + docs: { + description: { + component: "A stylized value control for textarea input", + }, + }, + }, +}; + +export default metadata; + +const container = (testItem: TemplateResult) => + html`
            + + + ${testItem} + +
              +
              `; + +export const TextareaInput = () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const displayChange = (ev: any) => { + document.getElementById( + "textarea-message-pad", + )!.innerText = `Value selected: ${JSON.stringify(ev.target.value, null, 2)}`; + }; + + return container( + html``, + ); +}; diff --git a/web/src/elements/buttons/TokenCopyButton/ak-token-copy-button.stories.ts b/web/src/elements/buttons/TokenCopyButton/ak-token-copy-button.stories.ts index 5aa8be34d..6b3f52e84 100644 --- a/web/src/elements/buttons/TokenCopyButton/ak-token-copy-button.stories.ts +++ b/web/src/elements/buttons/TokenCopyButton/ak-token-copy-button.stories.ts @@ -44,7 +44,6 @@ const container = (testItem: TemplateResult) => // eslint-disable-next-line @typescript-eslint/no-explicit-any const displayMessage = (result: any) => { - console.log(result); const doc = new DOMParser().parseFromString( `
            • Event: ${ "result" in result.detail ? result.detail.result.key : result.detail.error diff --git a/web/src/elements/forms/Radio.ts b/web/src/elements/forms/Radio.ts index 3d82ca4cb..27fbdf0b9 100644 --- a/web/src/elements/forms/Radio.ts +++ b/web/src/elements/forms/Radio.ts @@ -9,6 +9,8 @@ import PFForm from "@patternfly/patternfly/components/Form/form.css"; import PFRadio from "@patternfly/patternfly/components/Radio/radio.css"; import PFBase from "@patternfly/patternfly/patternfly-base.css"; +import { randomId } from "../utils/randomId"; + export interface RadioOption { label: string; description?: TemplateResult; @@ -27,6 +29,8 @@ export class Radio extends CustomEmitterElement(AKElement) { @property({ attribute: false }) value?: T; + internalId: string; + static get styles(): CSSResult[] { return [ PFBase, @@ -50,6 +54,7 @@ export class Radio extends CustomEmitterElement(AKElement) { super(); this.renderRadio = this.renderRadio.bind(this); this.buildChangeHandler = this.buildChangeHandler.bind(this); + this.internalId = this.name || `radio-${randomId(8)}`; } // Set the value if it's not set already. Property changes inside the `willUpdate()` method do @@ -72,15 +77,14 @@ export class Radio extends CustomEmitterElement(AKElement) { // This is a controlled input. Stop the native event from escaping or affecting the // value. We'll do that ourselves. ev.stopPropagation(); - ev.preventDefault(); this.value = option.value; - this.dispatchCustomEvent("change", option.value); - this.dispatchCustomEvent("input", option.value); + this.dispatchCustomEvent("change", { value: option.value }); + this.dispatchCustomEvent("input", { value: option.value }); }; } - renderRadio(option: RadioOption) { - const elId = `${this.name}-${option.value}`; + renderRadio(option: RadioOption, index: number) { + const elId = `${this.internalId}-${index}`; const handler = this.buildChangeHandler(option); return html`

              { + const r = (dt + Math.random() * 16) % 16 | 0; + dt = Math.floor(dt / 16); + return (c == "x" ? r : (r & 0x3) | 0x8).toString(16); + }); +}