web: ak-checkbox-group for short, static, multi-select events (#9138)

* web: fix esbuild issue with style sheets

Getting ESBuild, Lit, and Storybook to all agree on how to read and parse stylesheets is a serious
pain. This fix better identifies the value types (instances) being passed from various sources in
the repo to the three *different* kinds of style processors we're using (the native one, the
polyfill one, and whatever the heck Storybook does internally).

Falling back to using older CSS instantiating techniques one era at a time seems to do the trick.
It's ugly, but in the face of the aggressive styling we use to avoid Flashes of Unstyled Content
(FLoUC), it's the logic with which we're left.

In standard mode, the following warning appears on the console when running a Flow:

```
Autofocus processing was blocked because a document already has a focused element.
```

In compatibility mode, the following **error** appears on the console when running a Flow:

```
crawler-inject.js:1106 Uncaught TypeError: Failed to execute 'observe' on 'MutationObserver': parameter 1 is not of type 'Node'.
    at initDomMutationObservers (crawler-inject.js:1106:18)
    at crawler-inject.js:1114:24
    at Array.forEach (<anonymous>)
    at initDomMutationObservers (crawler-inject.js:1114:10)
    at crawler-inject.js:1549:1
initDomMutationObservers @ crawler-inject.js:1106
(anonymous) @ crawler-inject.js:1114
initDomMutationObservers @ crawler-inject.js:1114
(anonymous) @ crawler-inject.js:1549
```

Despite this error, nothing seems to be broken and flows work as anticipated.

* web: ak-checkbox-group for short, static, multi-select events

Implements a checkbox groups web component, wholly independent of the API
(although it does implement the 'data-ak-control' protocol, including the
`json()` method that makes it easier to send the data to the Form handler).  The
controller works much like multi-select: `value` returns an array of strings,
the `name` attribute associated with whatever it is you're asking about.

The `required` property only works if you give the whole item a name, as if it
were an input.  Otherwise, it does nothing.

Giving it a `name` also activates the browser standard `formAssociated`
protocol; it works just fine for ordinary HTML forms, and presents to that
protocol the `FormValue` type, so any form using it will automagically convert
it into the CGI (Common Gateway Interface) format of, to use the example from
Storybook:

```
ak-test-checkgroup-input=funky&ak-test-checkgroup-input=invalid
```

Note that the classic CGI format is not automatically key/value; keys can appear
multiple times, and indicate that the value is an array of strings.  Most modern
appservers understand this format. Some do not.

There's a full and complete JSDOC-like comment documenting the component.  I
have even provided CSSPart sections for everything: the wrapper, each line, the
input and its associated label.  The brave or foolhardy can mangle the CSS to
their hearts' content without having to know a thing about Patternfly.

* fix styling alignment with top line

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens Langhammer <jens@goauthentik.io>
This commit is contained in:
Ken Sternberg
2024-04-05 09:47:38 -07:00
committed by GitHub
parent a4a5b97265
commit fcf752905b
6 changed files with 392 additions and 87 deletions

View File

@ -63,6 +63,14 @@ export class AuthenticatorValidateStageForm extends BaseStageForm<AuthenticatorV
}
renderForm(): TemplateResult {
const authenticators = [
[DeviceClassesEnum.Static, msg("Static Tokens")],
[DeviceClassesEnum.Totp, msg("TOTP Authenticators")],
[DeviceClassesEnum.Webauthn, msg("WebAuthn Authenticators")],
[DeviceClassesEnum.Duo, msg("Duo Authenticators")],
[DeviceClassesEnum.Sms, msg("SMS-based Authenticators")],
];
return html` <span>
${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<AuthenticatorV
?required=${true}
name="deviceClasses"
>
<select name="users" class="pf-c-form-control" multiple>
<option
value=${DeviceClassesEnum.Static}
?selected=${this.isDeviceClassSelected(DeviceClassesEnum.Static)}
>
${msg("Static Tokens")}
</option>
<option
value=${DeviceClassesEnum.Totp}
?selected=${this.isDeviceClassSelected(DeviceClassesEnum.Totp)}
>
${msg("TOTP Authenticators")}
</option>
<option
value=${DeviceClassesEnum.Webauthn}
?selected=${this.isDeviceClassSelected(DeviceClassesEnum.Webauthn)}
>
${msg("WebAuthn Authenticators")}
</option>
<option
value=${DeviceClassesEnum.Duo}
?selected=${this.isDeviceClassSelected(DeviceClassesEnum.Duo)}
>
${msg("Duo Authenticators")}
</option>
<option
value=${DeviceClassesEnum.Sms}
?selected=${this.isDeviceClassSelected(DeviceClassesEnum.Sms)}
>
${msg("SMS-based Authenticators")}
</option>
</select>
<ak-checkbox-group
name="users"
class="user-field-select"
.options=${authenticators}
.value=${authenticators
.map((authenticator) => authenticator[0])
.filter((name) =>
this.isDeviceClassSelected(name as DeviceClassesEnum),
)}
></ak-checkbox-group>
<p class="pf-c-form__helper-text">
${msg("Device classes which can be used to authenticate.")}
</p>
<p class="pf-c-form__helper-text">
${msg("Hold control/command to select multiple items.")}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${msg("Last validation threshold")}

View File

@ -2,12 +2,13 @@ import "@goauthentik/admin/common/ak-flow-search/ak-flow-search";
import { BaseStageForm } from "@goauthentik/admin/stages/BaseStageForm";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { first, groupBy } from "@goauthentik/common/utils";
import "@goauthentik/elements/ak-checkbox-group/ak-checkbox-group.js";
import "@goauthentik/elements/forms/FormGroup";
import "@goauthentik/elements/forms/HorizontalFormElement";
import "@goauthentik/elements/forms/SearchSelect";
import { msg } from "@lit/localize";
import { TemplateResult, html } from "lit";
import { TemplateResult, css, html } from "lit";
import { customElement } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
@ -24,6 +25,17 @@ import {
@customElement("ak-stage-identification-form")
export class IdentificationStageForm extends BaseStageForm<IdentificationStage> {
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<IdentificationStage> {
return new StagesApi(DEFAULT_CONFIG).stagesIdentificationRetrieve({
stageUuid: pk,
@ -60,6 +72,12 @@ export class IdentificationStageForm extends BaseStageForm<IdentificationStage>
}
renderForm(): TemplateResult {
const userSelectFields = [
{ name: UserFieldsEnum.Username, label: msg("Username") },
{ name: UserFieldsEnum.Email, label: msg("Email") },
{ name: UserFieldsEnum.Upn, label: msg("UPN") },
];
return html`<span>
${msg("Let the user identify themselves with their username or Email address.")}
</span>
@ -75,34 +93,18 @@ export class IdentificationStageForm extends BaseStageForm<IdentificationStage>
<span slot="header"> ${msg("Stage-specific settings")} </span>
<div slot="body" class="pf-c-form">
<ak-form-element-horizontal label=${msg("User fields")} name="userFields">
<select class="pf-c-form-control" multiple>
<option
value=${UserFieldsEnum.Username}
?selected=${this.isUserFieldSelected(UserFieldsEnum.Username)}
>
${msg("Username")}
</option>
<option
value=${UserFieldsEnum.Email}
?selected=${this.isUserFieldSelected(UserFieldsEnum.Email)}
>
${msg("Email")}
</option>
<option
value=${UserFieldsEnum.Upn}
?selected=${this.isUserFieldSelected(UserFieldsEnum.Upn)}
>
${msg("UPN")}
</option>
</select>
<ak-checkbox-group
class="user-field-select"
.options=${userSelectFields}
.value=${userSelectFields
.map(({ name }) => name)
.filter((name) => this.isUserFieldSelected(name))}
></ak-checkbox-group>
<p class="pf-c-form__helper-text">
${msg(
"Fields a user can identify themselves with. If no fields are selected, the user will only be able to use sources.",
)}
</p>
<p class="pf-c-form__helper-text">
${msg("Hold control/command to select multiple items.")}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal label=${msg("Password stage")} name="passwordStage">
<ak-search-select

View File

@ -54,6 +54,21 @@ export class PasswordStageForm extends BaseStageForm<PasswordStage> {
}
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` <span>
${msg("Validate the user's password against the selected backend(s).")}
</span>
@ -73,32 +88,13 @@ export class PasswordStageForm extends BaseStageForm<PasswordStage> {
?required=${true}
name="backends"
>
<select name="users" class="pf-c-form-control" multiple>
<option
value=${BackendsEnum.CoreAuthInbuiltBackend}
?selected=${this.isBackendSelected(
BackendsEnum.CoreAuthInbuiltBackend,
)}
>
${msg("User database + standard password")}
</option>
<option
value=${BackendsEnum.CoreAuthTokenBackend}
?selected=${this.isBackendSelected(
BackendsEnum.CoreAuthTokenBackend,
)}
>
${msg("User database + app passwords")}
</option>
<option
value=${BackendsEnum.SourcesLdapAuthLdapBackend}
?selected=${this.isBackendSelected(
BackendsEnum.SourcesLdapAuthLdapBackend,
)}
>
${msg("User database + LDAP password")}
</option>
</select>
<ak-checkbox-group
class="user-field-select"
.options=${backends}
.value=${backends
.map(({ name }) => name)
.filter((name) => this.isBackendSelected(name))}
></ak-checkbox-group>
<p class="pf-c-form__helper-text">
${msg("Selection of backends to test the password against.")}
</p>

View File

@ -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<AkCheckboxGroup> = {
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` <div style="background: #fff; padding: 2em">
<style>
li {
display: block;
}
p {
margin-top: 1em;
}
</style>
${testItem}
<ul id="check-message-pad" style="margin-top: 1em"></ul>
</div>`;
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 = `
<p>Values selected on target: ${ev.target.value.join(", ")}</p>
<p>Values sent in event: ${ev.detail.join(", ")}</p>
<p>Values present as data-ak-control: <kbd>${JSON.stringify(ev.target.json, null)}</kbd></p>`;
};
return container(
html` <p style="max-width: 50ch; padding-bottom: 1rem;">
Evented example. Intercept the <kbd>input</kbd> event and display the value seen in
the event target.
</p>
<ak-checkbox-group
@change=${displayChange}
name="ak-test-check-input"
.options=${testOptions}
></ak-checkbox-group>`,
);
};
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 = `
<p>Values as seen in \`form.formData\`: ${valList}</p>
<p>Values as seen in x-form-encoded format: <kbd>${fdList}</kbd></p>`;
};
return container(
html`<p style="max-width: 50ch; padding-bottom: 1rem;">
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 &lt;form&gt; object, via its internal \`formData\`
field, to demonstrate that this component works with forms as if it were a native
form element.
</p>
<form @submit=${displayChange}>
<ak-checkbox-group
name="ak-test-checkgroup-input"
.options=${testOptions}
></ak-checkbox-group>
<button type="submit" style="margin-top: 2em">
<em><strong>Submit</strong></em>
</button>
</form>`,
);
};

View File

@ -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<CheckboxPr> {
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 <form>
* 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 <form> 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<HTMLInputElement>;
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,
// <select multiple>.
this.addEventListener("input", (ev) => {
ev.stopPropagation();
});
this.addEventListener("change", (ev) => {
ev.stopPropagation();
});
}
render() {
const renderOne = ([name, label]: CheckboxPr) => {
const selected = this.value.includes(name);
const blockFwd = (e: Event) => {
e.stopImmediatePropagation();
};
return html` <div part="checkbox" class="pf-c-check" @click=${this.onClick}>
<input
part="input"
@change=${blockFwd}
@input=${blockFwd}
name="${name}"
class="pf-c-check__input"
type="checkbox"
?checked=${selected}
id="ak-check-${name}"
/>
<label part="label" class="pf-c-check__label" for="ak-check-${name}"
>${label}</label
>
</div>`;
};
return html`<div part="checkbox-group" class="pf-c-form__group-control pf-m-stack">
${map(kvToPairs(this.options), renderOne)}
</div>`;
}
}