From 6792bf887672ec1c2f8430fa8a13f487f758e2fd Mon Sep 17 00:00:00 2001 From: Ken Sternberg <133134217+kensternberg-authentik@users.noreply.github.com> Date: Wed, 4 Oct 2023 13:07:52 -0700 Subject: [PATCH] web: package up horizontal elements into their own components (#7053) * web: laying the groundwork for future expansion This commit is a hodge-podge of updates and changes to the web. Functional changes: - Makefile: Fixed a bug in the `help` section that prevented the WIDTH from being accurately calculated if `help` was included rather than in-lined. - ESLint: Modified the "unused vars" rule so that variables starting with an underline are not considered by the rule. This allows for elided variables in event handlers. It's not a perfect solution-- a better one would be to use Typescript's function-specialization typing, but there are too many places where we elide or ignore some variables in a function's usage that switching over to specialization would be a huge lift. - locale: It turns out, lit-locale does its own context management. We don't need to have a context at all in this space, and that's one less listener we need to attach t othe DOM. - ModalButton: A small thing, but using `nothing` instead of "html``" allows lit better control over rendering and reduces the number of actual renders of the page. - FormGroup: Provided a means to modify the aria-label, rather than stick with the just the word "Details." Specializing this field will both help users of screen readers in the future, and will allow test suites to find specific form groups now. - RadioButton: provide a more consistent interface to the RadioButton. First, we dispatch the events to the outside world, and we set the value locally so that the current `Form.ts` continues to behave as expected. We also prevent the "button lost value" event from propagating; this presents a unified select-like interface to users of the RadioButtonGroup. The current value semantics are preserved; other clients of the RadioButton do not see a change in behavior. - EventEmitter: If the custom event detail is *not* an object, do not use the object-like semantics for forwarding it; just send it as-is. - Comments: In the course of laying the groundwork for the application wizard, I throw a LOT of comments into the code, describing APIs, interfaces, class and function signatures, to better document the behavior inside and as signposts for future work. * web: permit arrays to be sent in custom events without interpolation. * actually use assignValue or rather serializeFieldRecursive Signed-off-by: Jens Langhammer * web: package up horizontal elements into their own components. This commit introduces a number of "components." Jens has this idiom: ``` ``` It's a very web-oriented idiom in that it's built out of two building blocks, the "element-horizontal" descriptor, and the input object itself. This idiom is repeated a lot throughout the code. As an alternative, let's wrap everything into an inheritable interface: ``` --------- Signed-off-by: Jens Langhammer Co-authored-by: Jens Langhammer --- Makefile | 2 +- tests/wdio/test/pageobjects/admin.page.ts | 2 +- .../pageobjects/applications-list.page.ts | 21 - .../wdio/test/pageobjects/forms/oauth.form.ts | 18 + tests/wdio/test/pageobjects/page.ts | 5 + .../test/pageobjects/provider-wizard.page.ts | 53 +++ .../test/pageobjects/providers-list.page.ts | 47 ++ tests/wdio/test/specs/oauth-provider.ts | 46 ++ web/.storybook/manager.ts | 1 + web/package.json | 2 +- web/src/admin/common/ak-core-group-search.ts | 104 +++++ .../admin/common/ak-flow-search/FlowSearch.ts | 1 + web/src/admin/policies/PolicyBindingForm.ts | 2 +- .../providers/oauth2/OAuth2ProviderForm.ts | 428 ++++++++---------- .../providers/proxy/ProxyProviderForm.ts | 44 +- web/src/components/ak-app-icon.ts | 2 +- web/src/components/ak-file-input.ts | 66 +++ web/src/components/ak-number-input.ts | 52 +++ web/src/components/ak-radio-input.ts | 61 +++ web/src/components/ak-slug-input.ts | 171 +++++++ web/src/components/ak-switch-input.ts | 55 +++ web/src/components/ak-text-input.ts | 66 +++ web/src/components/ak-textarea-input.ts | 58 +++ .../components/stories/ak-app-icon.stories.ts | 38 ++ .../stories/ak-number-input.stories.ts | 55 +++ .../stories/ak-radio-input.stories.ts | 67 +++ .../stories/ak-slug-input.stories.ts | 64 +++ .../stories/ak-switch-input.stories.ts | 63 +++ .../stories/ak-text-input.stories.ts | 57 +++ .../stories/ak-textarea-input.stories.ts | 55 +++ .../ak-token-copy-button.stories.ts | 1 - web/src/elements/forms/Radio.ts | 14 +- web/src/elements/utils/randomId.ts | 8 + 33 files changed, 1434 insertions(+), 295 deletions(-) delete mode 100644 tests/wdio/test/pageobjects/applications-list.page.ts create mode 100644 tests/wdio/test/pageobjects/forms/oauth.form.ts create mode 100644 tests/wdio/test/pageobjects/provider-wizard.page.ts create mode 100644 tests/wdio/test/pageobjects/providers-list.page.ts create mode 100644 tests/wdio/test/specs/oauth-provider.ts create mode 100644 web/src/admin/common/ak-core-group-search.ts create mode 100644 web/src/components/ak-file-input.ts create mode 100644 web/src/components/ak-number-input.ts create mode 100644 web/src/components/ak-radio-input.ts create mode 100644 web/src/components/ak-slug-input.ts create mode 100644 web/src/components/ak-switch-input.ts create mode 100644 web/src/components/ak-text-input.ts create mode 100644 web/src/components/ak-textarea-input.ts create mode 100644 web/src/components/stories/ak-app-icon.stories.ts create mode 100644 web/src/components/stories/ak-number-input.stories.ts create mode 100644 web/src/components/stories/ak-radio-input.stories.ts create mode 100644 web/src/components/stories/ak-slug-input.stories.ts create mode 100644 web/src/components/stories/ak-switch-input.stories.ts create mode 100644 web/src/components/stories/ak-text-input.stories.ts create mode 100644 web/src/components/stories/ak-textarea-input.stories.ts create mode 100644 web/src/elements/utils/randomId.ts 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); + }); +}