diff --git a/web/package.json b/web/package.json index a82ce77bd4..0094f8ea70 100644 --- a/web/package.json +++ b/web/package.json @@ -14,8 +14,8 @@ "build": "run-s build-locales esbuild:build", "build-proxy": "run-s build-locales esbuild:build-proxy", "watch": "run-s build-locales esbuild:watch", - "lint": "cross-env NODE_OPTIONS='--max_old_space_size=8192' eslint . --max-warnings 0 --fix", - "lint:precommit": "cross-env NODE_OPTIONS='--max_old_space_size=8192' node scripts/eslint-precommit.mjs", + "lint": "cross-env NODE_OPTIONS='--max_old_space_size=16384' eslint . --max-warnings 0 --fix", + "lint:precommit": "cross-env NODE_OPTIONS='--max_old_space_size=16384' node scripts/eslint-precommit.mjs", "lint:spelling": "node scripts/check-spelling.mjs", "lit-analyse": "lit-analyzer src", "precommit": "npm-run-all --parallel tsc lit-analyse lint:spelling --sequential lint:precommit prettier", diff --git a/web/src/admin/stages/authenticator_validate/AuthenticatorValidateStageForm.ts b/web/src/admin/stages/authenticator_validate/AuthenticatorValidateStageForm.ts index 2884f948c8..1642ffd785 100644 --- a/web/src/admin/stages/authenticator_validate/AuthenticatorValidateStageForm.ts +++ b/web/src/admin/stages/authenticator_validate/AuthenticatorValidateStageForm.ts @@ -63,6 +63,14 @@ export class AuthenticatorValidateStageForm extends BaseStageForm ${msg( "Stage used to validate any authenticator. This stage should be used during authentication or authorization flows.", @@ -84,44 +92,19 @@ export class AuthenticatorValidateStageForm extends BaseStageForm - + authenticator[0]) + .filter((name) => + this.isDeviceClassSelected(name as DeviceClassesEnum), + )} + >

${msg("Device classes which can be used to authenticate.")}

-

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

{ + static get styles() { + return [ + ...super.styles, + css` + ak-checkbox-group::part(checkbox-group) { + padding-top: var(--pf-c-form--m-horizontal__group-label--md--PaddingTop); + } + `, + ]; + } + loadInstance(pk: string): Promise { return new StagesApi(DEFAULT_CONFIG).stagesIdentificationRetrieve({ stageUuid: pk, @@ -60,6 +72,12 @@ export class IdentificationStageForm extends BaseStageForm } renderForm(): TemplateResult { + const userSelectFields = [ + { name: UserFieldsEnum.Username, label: msg("Username") }, + { name: UserFieldsEnum.Email, label: msg("Email") }, + { name: UserFieldsEnum.Upn, label: msg("UPN") }, + ]; + return html` ${msg("Let the user identify themselves with their username or Email address.")} @@ -75,34 +93,18 @@ export class IdentificationStageForm extends BaseStageForm ${msg("Stage-specific settings")}
- + name) + .filter((name) => this.isUserFieldSelected(name))} + >

${msg( "Fields a user can identify themselves with. If no fields are selected, the user will only be able to use sources.", )}

-

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

{ } renderForm(): TemplateResult { + const backends = [ + { + name: BackendsEnum.CoreAuthInbuiltBackend, + label: msg("User database + standard password"), + }, + { + name: BackendsEnum.CoreAuthTokenBackend, + label: msg("User database + app passwords"), + }, + { + name: BackendsEnum.SourcesLdapAuthLdapBackend, + label: msg("User database + LDAP password"), + }, + ]; + return html` ${msg("Validate the user's password against the selected backend(s).")} @@ -73,32 +88,13 @@ export class PasswordStageForm extends BaseStageForm { ?required=${true} name="backends" > - + name) + .filter((name) => this.isBackendSelected(name))} + >

${msg("Selection of backends to test the password against.")}

diff --git a/web/src/elements/ak-checkbox-group/ak-checkbox-group.stories.ts b/web/src/elements/ak-checkbox-group/ak-checkbox-group.stories.ts new file mode 100644 index 0000000000..11ec69e094 --- /dev/null +++ b/web/src/elements/ak-checkbox-group/ak-checkbox-group.stories.ts @@ -0,0 +1,112 @@ +import "@goauthentik/elements/messages/MessageContainer"; +import { Meta } from "@storybook/web-components"; + +import { TemplateResult, html } from "lit"; + +import "./ak-checkbox-group"; +import { CheckboxGroup as AkCheckboxGroup } from "./ak-checkbox-group"; + +const metadata: Meta = { + title: "Elements / Checkbox Group", + component: "ak-checkbox-group", + parameters: { + docs: { + description: { + component: "A stylized value control for check buttons", + }, + }, + }, +}; + +export default metadata; + +const container = (testItem: TemplateResult) => + html`
+ + + ${testItem} + +
    +
    `; + +const testOptions = [ + { label: "Option One: funky", name: "funky" }, + { label: "Option Two: invalid", name: "invalid" }, + { label: "Option Three: weird", name: "weird" }, +]; + +export const CheckboxGroup = () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const displayChange = (ev: any) => { + document.getElementById("check-message-pad")!.innerHTML = ` +

    Values selected on target: ${ev.target.value.join(", ")}

    +

    Values sent in event: ${ev.detail.join(", ")}

    +

    Values present as data-ak-control: ${JSON.stringify(ev.target.json, null)}

    `; + }; + + return container( + html`

    + Evented example. Intercept the input event and display the value seen in + the event target. +

    + + `, + ); +}; + +type FDType = [string, string | FormDataEntryValue]; + +export const FormCheckboxGroup = () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const displayChange = (ev: any) => { + ev.preventDefault(); + const formData = new FormData(ev.target); + + const valList = Array.from(formData) + .map(([_key, val]: FDType) => val) + .join(", "); + + const fdList = Array.from(formData) + .map( + ([key, val]: FDType) => + `${encodeURIComponent(key)}=${encodeURIComponent(val as string)}`, + ) + .join("&"); + + document.getElementById("check-message-pad")!.innerHTML = ` +

    Values as seen in \`form.formData\`: ${valList}

    +

    Values as seen in x-form-encoded format: ${fdList}

    `; + }; + + return container( + html`

    + FormData example. This variant emits the same events and exhibits the same behavior + as the above, but instead of monitoring for 'change' events on the checkbox group, + we monitor for the user pressing the 'submit' button. What is displayed is the + values as understood by the <form> object, via its internal \`formData\` + field, to demonstrate that this component works with forms as if it were a native + form element. +

    + +
    + + +
    `, + ); +}; diff --git a/web/src/elements/ak-checkbox-group/ak-checkbox-group.ts b/web/src/elements/ak-checkbox-group/ak-checkbox-group.ts new file mode 100644 index 0000000000..60fe49b224 --- /dev/null +++ b/web/src/elements/ak-checkbox-group/ak-checkbox-group.ts @@ -0,0 +1,212 @@ +import { AKElement } from "@goauthentik/elements/Base"; +import { CustomEmitterElement } from "@goauthentik/elements/utils/eventEmitter"; + +import { msg } from "@lit/localize"; +import { TemplateResult, css, html } from "lit"; +import { customElement, property, queryAll } from "lit/decorators.js"; +import { map } from "lit/directives/map.js"; + +import PFCheck from "@patternfly/patternfly/components/Check/check.css"; +import PFForm from "@patternfly/patternfly/components/Form/form.css"; +import PFBase from "@patternfly/patternfly/patternfly-base.css"; + +type CheckboxKv = { name: string; label: string | TemplateResult }; +type CheckboxPr = [string, string | TemplateResult]; +export type CheckboxPair = CheckboxKv | CheckboxPr; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const isCheckboxPr = (t: any): t is CheckboxPr => Array.isArray(t); +function* kvToPairs(items: CheckboxPair[]): Iterable { + for (const item of items) { + yield isCheckboxPr(item) ? item : [item.name, item.label]; + } +} + +const AkElementWithCustomEvents = CustomEmitterElement(AKElement); + +/** + * @element ak-checkbox-group + * + * @class CheckboxGroup + * + * @description + * CheckboxGroup renders a collection of checkboxes in a linear list. Multiple + * checkboxes may be picked. + * + * @attr {options} - An array of either `[string, string | TemplateResult]` or + * `{ name: string, label: string | TemplateResult }`. The first value or + * `name` field must be a valid HTML identifier compatible with the HTML + * `name` attribute. + * + * @attr {value} - An array of `name` values corresponding to the options that + * are selected when the element is rendered. + * + * @attr {name} - The name of this element as it will appear in any
    + * transaction + * + * @attr {required} - If true, and if name is set, and no values are chosen, + * will automatically fail a form `submit` event, providing a warning + * message for any labeling. Note: if `name` is not set, this has no effect, + * and a warn() will appear on the console. + * + * @event {input} - Fired when the component's value has changed. Current value + * as an array of `name` will be in the `Event.detail` field. + * + * @event {change} - Fired when the component's value has changed. Current value + * as an array of `name` will be in the `Event.detail` field. + * + * @csspart checkbox - The div containing the checkbox item and the label + * @csspart label - the label + * @csspart input - the input item + * @csspart checkbox-group - the wrapper div with flexbox control + * + * ## Bigger hit area + * + * Providing properly formatted names for selections allows the element to + * associate the label with the event, so the entire horizontal area from + * checkbox to end-of-label will be the hit area. + * + * ## FormAssociated compliance + * + * If a component is a parent, this component will correctly send its + * values to the form for `x-form-encoded` data; multiples will appear in the + * form of `name=value1&name=value2` format, and must be unpacked into an array + * correctly on the server side according to the CGI (common gateway interface) + * protocol. + * + */ + +@customElement("ak-checkbox-group") +export class CheckboxGroup extends AkElementWithCustomEvents { + static get styles() { + return [ + PFBase, + PFForm, + PFCheck, + css` + .pf-c-form__group-control { + padding-top: calc( + var(--pf-c-form--m-horizontal__group-label--md--PaddingTop) * 1.3 + ); + } + `, + ]; + } + + static get formAssociated() { + return true; + } + + @property({ type: Array }) + options: CheckboxPair[] = []; + + @property({ type: Array }) + value: string[] = []; + + @property({ type: String }) + name?: string; + + @property({ type: Boolean }) + required = false; + + @queryAll('input[type="checkbox"]') + checkboxes!: NodeListOf; + + internals?: ElementInternals; + + get json() { + return this.value; + } + + private get formValue() { + if (this.name === undefined) { + throw new Error("This cannot be called without having the name set."); + } + const name = this.name; + const entries = new FormData(); + this.value.forEach((v) => entries.append(name, v)); + return entries; + } + + constructor() { + super(); + this.onClick = this.onClick.bind(this); + this.dataset.akControl = "true"; + } + + onClick(ev: Event) { + ev.stopPropagation(); + this.value = Array.from(this.checkboxes) + .filter((checkbox) => checkbox.checked) + .map((checkbox) => checkbox.name); + this.dispatchCustomEvent("change", this.value); + this.dispatchCustomEvent("input", this.value); + if (this.internals) { + this.internals.setValidity({}); + if (this.required && this.value.length === 0) { + this.internals.setValidity( + { + valueMissing: true, + }, + msg("A selection is required"), + this, + ); + } + this.internals.setFormValue(this.formValue); + } + } + + connectedCallback() { + super.connectedCallback(); + if (this.name && !this.internals) { + this.internals = this.attachInternals(); + } + if (this.internals && this.name) { + this.internals.ariaRequired = this.required ? "true" : "false"; + } + if (this.required && !this.internals) { + console.warn( + "Setting `required` on ak-checkbox-group has no effect when the `name` attribute is unset", + ); + } + // These are necessary to prevent the input components' own events from + // leaking out. This helps maintain the illusion that this component + // behaves similarly to the multiple selection behavior of, well, + // + +
    `; + }; + + return html`
    + ${map(kvToPairs(this.options), renderOne)} +
    `; + } +}