diff --git a/Makefile b/Makefile
index a6c4b3e69..54c53afdf 100644
--- a/Makefile
+++ b/Makefile
@@ -28,10 +28,13 @@ CODESPELL_ARGS = -D - -D .github/codespell-dictionary.txt \
all: lint-fix lint test gen web ## Lint, build, and test everything
+HELP_WIDTH := $(shell grep -h '^[a-z][^ ]*:.*\#\#' $(MAKEFILE_LIST) 2>/dev/null | \
+ cut -d':' -f1 | awk '{printf "%d\n", length}' | sort -rn | head -1)
+
help: ## Show this help
@echo "\nSpecify a command. The choices are:\n"
- @grep -E '^[0-9a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \
- awk 'BEGIN {FS = ":.*?## "}; {printf " \033[0;36m%-24s\033[m %s\n", $$1, $$2}' | \
+ @grep -Eh '^[0-9a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \
+ awk 'BEGIN {FS = ":.*?## "}; {printf " \033[0;36m%-$(HELP_WIDTH)s \033[m %s\n", $$1, $$2}' | \
sort
@echo ""
diff --git a/web/.eslintrc.json b/web/.eslintrc.json
index a423627c0..fdae375c6 100644
--- a/web/.eslintrc.json
+++ b/web/.eslintrc.json
@@ -16,11 +16,21 @@
"sourceType": "module"
},
"plugins": ["@typescript-eslint", "lit", "custom-elements"],
+ "ignorePatterns": ["authentik-live-tests/**"],
"rules": {
"indent": "off",
"linebreak-style": ["error", "unix"],
"quotes": ["error", "double", { "avoidEscape": true }],
"semi": ["error", "always"],
- "@typescript-eslint/ban-ts-comment": "off"
+ "@typescript-eslint/ban-ts-comment": "off",
+ "no-unused-vars": "off",
+ "@typescript-eslint/no-unused-vars": [
+ "error",
+ {
+ "argsIgnorePattern": "^_",
+ "varsIgnorePattern": "^_",
+ "caughtErrorsIgnorePattern": "^_"
+ }
+ ]
}
}
diff --git a/web/src/elements/ak-locale-context/ak-locale-context.ts b/web/src/elements/ak-locale-context/ak-locale-context.ts
index 304ddacac..af69fbf19 100644
--- a/web/src/elements/ak-locale-context/ak-locale-context.ts
+++ b/web/src/elements/ak-locale-context/ak-locale-context.ts
@@ -2,13 +2,11 @@ import { EVENT_LOCALE_CHANGE } from "@goauthentik/common/constants";
import { EVENT_LOCALE_REQUEST } from "@goauthentik/common/constants";
import { customEvent, isCustomEvent } from "@goauthentik/elements/utils/customEvents";
-import { provide } from "@lit-labs/context";
import { LitElement, html } from "lit";
import { customElement, property } from "lit/decorators.js";
import { initializeLocalization } from "./configureLocale";
import type { LocaleGetter, LocaleSetter } from "./configureLocale";
-import locale from "./context";
import {
DEFAULT_LOCALE,
autoDetectLanguage,
@@ -32,7 +30,6 @@ import {
@customElement("ak-locale-context")
export class LocaleContext extends LitElement {
/// @attribute The text representation of the current locale */
- @provide({ context: locale })
@property({ attribute: true, type: String })
locale = DEFAULT_LOCALE;
diff --git a/web/src/elements/buttons/ModalButton.ts b/web/src/elements/buttons/ModalButton.ts
index 9a2b4fd0d..dfc594e2c 100644
--- a/web/src/elements/buttons/ModalButton.ts
+++ b/web/src/elements/buttons/ModalButton.ts
@@ -1,7 +1,7 @@
import { AKElement } from "@goauthentik/elements/Base";
import { PFSize } from "@goauthentik/elements/Spinner";
-import { CSSResult, TemplateResult, css, html } from "lit";
+import { CSSResult, TemplateResult, css, html, nothing } from "lit";
import { customElement, property } from "lit/decorators.js";
import PFBackdrop from "@patternfly/patternfly/components/Backdrop/backdrop.css";
@@ -100,7 +100,7 @@ export class ModalButton extends AKElement {
});
}
- renderModalInner(): TemplateResult {
+ renderModalInner(): TemplateResult | typeof nothing {
return html``;
}
@@ -136,6 +136,6 @@ export class ModalButton extends AKElement {
render(): TemplateResult {
return html` this.onClick()}>
- ${this.open ? this.renderModal() : ""}`;
+ ${this.open ? this.renderModal() : nothing}`;
}
}
diff --git a/web/src/elements/forms/Form.ts b/web/src/elements/forms/Form.ts
index 6c69365a7..6da2baff7 100644
--- a/web/src/elements/forms/Form.ts
+++ b/web/src/elements/forms/Form.ts
@@ -31,6 +31,37 @@ export interface KeyUnknown {
[key: string]: unknown;
}
+/**
+ * Form
+ *
+ * The base form element for interacting with user inputs.
+ *
+ * All forms either[1] inherit from this class and implement the `renderInlineForm()` method to
+ * produce the actual form, or include the form in-line as a slotted element. Bizarrely, this form
+ * will not render at all if it's not actually in the viewport?[2]
+ *
+ * @element ak-form
+ *
+ * @slot - Where the form goes if `renderInlineForm()` returns undefined.
+ * @fires eventname - description
+ *
+ * @csspart partname - description
+ */
+
+/* TODO:
+ *
+ * 1. Specialization: Separate this component into three different classes:
+ * - The base class
+ * - The "use `renderInlineForm` class
+ * - The slotted class.
+ * 2. There is already specialization-by-type throughout all of our code.
+ * Consider refactoring serializeForm() so that the conversions are on
+ * the input types, rather than here. (i.e. "Polymorphism is better than
+ * switch.")
+ *
+ *
+ */
+
@customElement("ak-form")
export abstract class Form extends AKElement {
abstract send(data: T): Promise;
@@ -61,6 +92,10 @@ export abstract class Form extends AKElement {
];
}
+ /**
+ * Called by the render function. Blocks rendering the form if the form is not within the
+ * viewport.
+ */
get isInViewport(): boolean {
const rect = this.getBoundingClientRect();
return !(rect.x + rect.y + rect.width + rect.height === 0);
@@ -70,6 +105,11 @@ export abstract class Form extends AKElement {
return this.successMessage;
}
+ /**
+ * After rendering the form, if there is both a `name` and `slug` element within the form,
+ * events the `name` element so that the slug will always have a slugified version of the
+ * `name.`. This duplicates functionality within ak-form-element-horizontal.
+ */
updated(): void {
this.shadowRoot
?.querySelectorAll("ak-form-element-horizontal[name=name]")
@@ -103,6 +143,12 @@ export abstract class Form extends AKElement {
form?.reset();
}
+ /**
+ * Return the form elements that may contain filenames. Not sure why this is quite so
+ * convoluted. There is exactly one case where this is used:
+ * `./flow/stages/prompt/PromptStage: 147: case PromptTypeEnum.File.`
+ * Consider moving this functionality to there.
+ */
getFormFiles(): { [key: string]: File } {
const files: { [key: string]: File } = {};
const elements =
@@ -126,6 +172,10 @@ export abstract class Form extends AKElement {
return files;
}
+ /**
+ * Convert the elements of the form to JSON.[4]
+ *
+ */
serializeForm(): T | undefined {
const elements =
this.shadowRoot?.querySelectorAll(
@@ -147,17 +197,21 @@ export abstract class Form extends AKElement {
"multiple" in inputElement.attributes
) {
const selectElement = inputElement as unknown as HTMLSelectElement;
- json[element.name] = Array.from(selectElement.selectedOptions).map((v) => v.value);
+ this.assignValue(
+ inputElement,
+ Array.from(selectElement.selectedOptions).map((v) => v.value),
+ json,
+ );
} else if (
inputElement.tagName.toLowerCase() === "input" &&
inputElement.type === "date"
) {
- json[element.name] = inputElement.valueAsDate;
+ this.assignValue(inputElement, inputElement.valueAsDate, json);
} else if (
inputElement.tagName.toLowerCase() === "input" &&
inputElement.type === "datetime-local"
) {
- json[element.name] = new Date(inputElement.valueAsNumber);
+ this.assignValue(inputElement, new Date(inputElement.valueAsNumber), json);
} else if (
inputElement.tagName.toLowerCase() === "input" &&
"type" in inputElement.dataset &&
@@ -165,19 +219,19 @@ export abstract class Form extends AKElement {
) {
// Workaround for Firefox <93, since 92 and older don't support
// datetime-local fields
- json[element.name] = new Date(inputElement.value);
+ this.assignValue(inputElement, new Date(inputElement.value), json);
} else if (
inputElement.tagName.toLowerCase() === "input" &&
inputElement.type === "checkbox"
) {
- json[element.name] = inputElement.checked;
+ this.assignValue(inputElement, inputElement.checked, json);
} else if ("selectedFlow" in inputElement) {
- json[element.name] = inputElement.value;
+ this.assignValue(inputElement, inputElement.value, json);
} else if (inputElement.tagName.toLowerCase() === "ak-search-select") {
const select = inputElement as unknown as SearchSelect;
try {
const value = select.toForm();
- json[element.name] = value;
+ this.assignValue(inputElement, value, json);
} catch (exc) {
if (exc instanceof PreventFormSubmit) {
throw new PreventFormSubmit(exc.message, element);
@@ -185,13 +239,16 @@ export abstract class Form extends AKElement {
throw exc;
}
} else {
- this.serializeFieldRecursive(inputElement, inputElement.value, json);
+ this.assignValue(inputElement, inputElement.value, json);
}
});
return json as unknown as T;
}
- private serializeFieldRecursive(
+ /**
+ * Recursively assign `value` into `json` while interpreting the dot-path of `element.name`
+ */
+ private assignValue(
element: HTMLInputElement,
value: unknown,
json: { [key: string]: unknown },
@@ -211,6 +268,12 @@ export abstract class Form extends AKElement {
parent[nameElements[nameElements.length - 1]] = value;
}
+ /**
+ * Serialize and send the form to the destination. The `send()` method must be overridden for
+ * this to work. If processing the data results in an error, we catch the error, distribute
+ * field-levels errors to the fields, and send the rest of them to the Notifications.
+ *
+ */
async submit(ev: Event): Promise {
ev.preventDefault();
try {
diff --git a/web/src/elements/forms/FormElement.ts b/web/src/elements/forms/FormElement.ts
index b910caa90..bb408af88 100644
--- a/web/src/elements/forms/FormElement.ts
+++ b/web/src/elements/forms/FormElement.ts
@@ -9,6 +9,12 @@ import PFFormControl from "@patternfly/patternfly/components/FormControl/form-co
import { ErrorDetail } from "@goauthentik/api";
+/**
+ * This is used in two places outside of Flow, and in both cases is used primarily to
+ * display content, not take input. It displays the TOPT QR code, and the static
+ * recovery tokens. But it's used a lot in Flow.
+ */
+
@customElement("ak-form-element")
export class FormElement extends AKElement {
static get styles(): CSSResult[] {
diff --git a/web/src/elements/forms/FormGroup.ts b/web/src/elements/forms/FormGroup.ts
index 347a06a40..0c867d719 100644
--- a/web/src/elements/forms/FormGroup.ts
+++ b/web/src/elements/forms/FormGroup.ts
@@ -8,11 +8,26 @@ import PFForm from "@patternfly/patternfly/components/Form/form.css";
import PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
+/**
+ * Form Group
+ *
+ * Mostly visual effects, with a single interaction for opening/closing the view.
+ *
+ */
+
+/**
+ * TODO: Listen for custom events from its children about 'invalidation' events, and
+ * trigger the `expanded` property as needed.
+ */
+
@customElement("ak-form-group")
export class FormGroup extends AKElement {
- @property({ type: Boolean })
+ @property({ type: Boolean, reflect: true })
expanded = false;
+ @property({ type: String, attribute: "aria-label", reflect: true })
+ ariaLabel = "Details";
+
static get styles(): CSSResult[] {
return [
PFBase,
@@ -35,7 +50,7 @@ export class FormGroup extends AKElement {
class="pf-c-button pf-m-plain"
type="button"
aria-expanded="${this.expanded}"
- aria-label="Details"
+ aria-label=${this.ariaLabel}
@click=${() => {
this.expanded = !this.expanded;
}}
diff --git a/web/src/elements/forms/HorizontalFormElement.ts b/web/src/elements/forms/HorizontalFormElement.ts
index 3709536b0..9d00327da 100644
--- a/web/src/elements/forms/HorizontalFormElement.ts
+++ b/web/src/elements/forms/HorizontalFormElement.ts
@@ -12,9 +12,30 @@ import PFFormControl from "@patternfly/patternfly/components/FormControl/form-co
import PFBase from "@patternfly/patternfly/patternfly-base.css";
/**
+ *
+ * Horizontal Form Element Container.
+ *
+ * This element provides the interface between elements of our forms and the
+ * form itself.
* @custom-element ak-form-element-horizontal
*/
+/* TODO
+
+ * 1. Replace the "probe upward for a parent object to event" with an event handler on the parent
+ * group.
+ * 2. Updated() has a lot of that slug code again. Really, all you want is for the slug input object
+ * to update itself if its content seems to have been tracking some other key element.
+ * 3. Updated() pushes the `name` field down to the children, as if that were necessary; why isn't
+ * it being written on-demand when the child is written? Because it's slotted... despite there
+ * being very few unique uses.
+ * 4. There is some very specific use-case around the `writeOnly` boolean; this seems to be a case
+ * where the field isn't available for the user to view unless they explicitly request to be able
+ * to see the content; otherwise, a dead password field is shown. There are 10 uses of this
+ * feature.
+ *
+ */
+
@customElement("ak-form-element-horizontal")
export class HorizontalFormElement extends AKElement {
static get styles(): CSSResult[] {
@@ -56,6 +77,9 @@ export class HorizontalFormElement extends AKElement {
_invalid = false;
+ /* If this property changes, we want to make sure the parent control is "opened" so
+ * that users can see the change.[1]
+ */
@property({ type: Boolean })
set invalid(v: boolean) {
this._invalid = v;
diff --git a/web/src/elements/forms/ModelForm.ts b/web/src/elements/forms/ModelForm.ts
index f61a0c659..c45cc7344 100644
--- a/web/src/elements/forms/ModelForm.ts
+++ b/web/src/elements/forms/ModelForm.ts
@@ -5,6 +5,13 @@ import { Form } from "@goauthentik/elements/forms/Form";
import { TemplateResult, html } from "lit";
import { property } from "lit/decorators.js";
+/**
+ * Model form
+ *
+ * A base form that automatically tracks the server-side object (instance)
+ * that we're interested in. Handles loading and tracking of the instance.
+ */
+
export abstract class ModelForm extends Form {
abstract loadInstance(pk: PKT): Promise;
diff --git a/web/src/elements/forms/Radio.ts b/web/src/elements/forms/Radio.ts
index 55b60a3ac..3d82ca4cb 100644
--- a/web/src/elements/forms/Radio.ts
+++ b/web/src/elements/forms/Radio.ts
@@ -1,7 +1,9 @@
import { AKElement } from "@goauthentik/elements/Base";
+import { CustomEmitterElement } from "@goauthentik/elements/utils/eventEmitter";
-import { CSSResult, TemplateResult, css, html } from "lit";
+import { CSSResult, TemplateResult, css, html, nothing } from "lit";
import { customElement, property } from "lit/decorators.js";
+import { map } from "lit/directives/map.js";
import PFForm from "@patternfly/patternfly/components/Form/form.css";
import PFRadio from "@patternfly/patternfly/components/Radio/radio.css";
@@ -15,7 +17,7 @@ export interface RadioOption {
}
@customElement("ak-radio")
-export class Radio extends AKElement {
+export class Radio extends CustomEmitterElement(AKElement) {
@property({ attribute: false })
options: RadioOption[] = [];
@@ -36,44 +38,70 @@ export class Radio extends AKElement {
var(--pf-c-form--m-horizontal__group-label--md--PaddingTop) * 1.3
);
}
+ .pf-c-radio label,
+ .pf-c-radio span {
+ user-select: none;
+ }
`,
];
}
- render(): TemplateResult {
+ constructor() {
+ super();
+ this.renderRadio = this.renderRadio.bind(this);
+ this.buildChangeHandler = this.buildChangeHandler.bind(this);
+ }
+
+ // Set the value if it's not set already. Property changes inside the `willUpdate()` method do
+ // not trigger an element update.
+ willUpdate() {
if (!this.value) {
- const def = this.options.filter((opt) => opt.default);
- if (def.length > 0) {
- this.value = def[0].value;
+ const maybeDefault = this.options.filter((opt) => opt.default);
+ if (maybeDefault.length > 0) {
+ this.value = maybeDefault[0].value;
}
}
+ }
+
+ // When a user clicks on `type="radio"`, *two* events happen in rapid succession: the original
+ // radio loses its setting, and the selected radio gains its setting. We want radio buttons to
+ // present a unified event interface, so we prevent the event from triggering if the value is
+ // already set.
+ buildChangeHandler(option: RadioOption) {
+ return (ev: Event) => {
+ // 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);
+ };
+ }
+
+ renderRadio(option: RadioOption) {
+ const elId = `${this.name}-${option.value}`;
+ const handler = this.buildChangeHandler(option);
+ return html`