import { EVENT_REFRESH } from "@goauthentik/common/constants"; import { parseAPIResponseError, pluckErrorDetail } from "@goauthentik/common/errors/network"; import { MessageLevel } from "@goauthentik/common/messages"; import { camelToSnake, convertToSlug, dateToUTC } from "@goauthentik/common/utils"; import { AKElement } from "@goauthentik/elements/Base"; import { HorizontalFormElement } from "@goauthentik/elements/forms/HorizontalFormElement"; import { PreventFormSubmit } from "@goauthentik/elements/forms/helpers"; import { showMessage } from "@goauthentik/elements/messages/MessageContainer"; import { msg } from "@lit/localize"; import { CSSResult, TemplateResult, css, html } from "lit"; import { property, state } from "lit/decorators.js"; import PFAlert from "@patternfly/patternfly/components/Alert/alert.css"; import PFButton from "@patternfly/patternfly/components/Button/button.css"; import PFCard from "@patternfly/patternfly/components/Card/card.css"; import PFForm from "@patternfly/patternfly/components/Form/form.css"; import PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css"; import PFInputGroup from "@patternfly/patternfly/components/InputGroup/input-group.css"; import PFSwitch from "@patternfly/patternfly/components/Switch/switch.css"; import PFBase from "@patternfly/patternfly/patternfly-base.css"; import { instanceOfValidationError } from "@goauthentik/api"; export interface KeyUnknown { [key: string]: unknown; } // Literally the only field `assignValue()` cares about. type HTMLNamedElement = Pick; export type AkControlElement = HTMLInputElement & { json: () => T }; /** * Recursively assign `value` into `json` while interpreting the dot-path of `element.name` */ function assignValue(element: HTMLNamedElement, value: unknown, json: KeyUnknown): void { let parent = json; if (!element.name?.includes(".")) { parent[element.name] = value; return; } const nameElements = element.name.split("."); for (let index = 0; index < nameElements.length - 1; index++) { const nameEl = nameElements[index]; // Ensure all nested structures exist if (!(nameEl in parent)) { parent[nameEl] = {}; } parent = parent[nameEl] as { [key: string]: unknown }; } parent[nameElements[nameElements.length - 1]] = value; } /** * Convert the elements of the form to JSON.[4] * */ export function serializeForm( elements: NodeListOf, ): T | undefined { const json: { [key: string]: unknown } = {}; elements.forEach((element) => { element.requestUpdate(); if (element.hidden) { return; } if ("akControl" in element.dataset) { assignValue(element, (element as unknown as AkControlElement).json(), json); return; } const inputElement = element.querySelector("[name]"); if (element.hidden || !inputElement || (element.writeOnly && !element.writeOnlyActivated)) { return; } if ("akControl" in inputElement.dataset) { assignValue(element, (inputElement as unknown as AkControlElement).json(), json); return; } if ( inputElement.tagName.toLowerCase() === "select" && "multiple" in inputElement.attributes ) { const selectElement = inputElement as unknown as HTMLSelectElement; assignValue( inputElement, Array.from(selectElement.selectedOptions).map((v) => v.value), json, ); } else if (inputElement.tagName.toLowerCase() === "input" && inputElement.type === "date") { assignValue(inputElement, inputElement.valueAsDate, json); } else if ( inputElement.tagName.toLowerCase() === "input" && inputElement.type === "datetime-local" ) { assignValue(inputElement, dateToUTC(new Date(inputElement.valueAsNumber)), json); } else if ( inputElement.tagName.toLowerCase() === "input" && "type" in inputElement.dataset && inputElement.dataset.type === "datetime-local" ) { // Workaround for Firefox <93, since 92 and older don't support // datetime-local fields assignValue(inputElement, dateToUTC(new Date(inputElement.value)), json); } else if ( inputElement.tagName.toLowerCase() === "input" && inputElement.type === "checkbox" ) { assignValue(inputElement, inputElement.checked, json); } else if ("selectedFlow" in inputElement) { assignValue(inputElement, inputElement.value, json); } else { assignValue(inputElement, inputElement.value, json); } }); return json as unknown as T; } /** * Form * * The base form element for interacting with user inputs. * * All forms either[1] inherit from this class and implement the `renderForm()` 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 `renderForm()` returns undefined. * @fires eventname - description * * @csspart partname - description */ /* TODO: * * 1. Specialization: Separate this component into three different classes: * - The base class * - The "use `renderForm` 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.") * * */ export abstract class Form extends AKElement { abstract send(data: T): Promise; viewportCheck = true; @property() successMessage = ""; @state() nonFieldErrors?: string[]; static get styles(): CSSResult[] { return [ PFBase, PFCard, PFButton, PFForm, PFAlert, PFInputGroup, PFFormControl, PFSwitch, css` select[multiple] { height: 15em; } `, ]; } /** * 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; } getSuccessMessage(): string { 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]") .forEach((nameInput) => { const input = nameInput.firstElementChild as HTMLInputElement; const form = nameInput.closest("form"); if (form === null) { return; } const slugFieldWrapper = form.querySelector( "ak-form-element-horizontal[name=slug]", ); if (!slugFieldWrapper) { return; } const slugField = slugFieldWrapper.firstElementChild as HTMLInputElement; // Only attach handler if the slug is already equal to the name // if not, they are probably completely different and shouldn't update // each other if (convertToSlug(input.value) !== slugField.value) { return; } nameInput.addEventListener("input", () => { slugField.value = convertToSlug(input.value); }); }); } resetForm(): void { const form = this.shadowRoot?.querySelector("form"); 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 = this.shadowRoot?.querySelectorAll( "ak-form-element-horizontal", ) || []; for (let i = 0; i < elements.length; i++) { const element = elements[i]; element.requestUpdate(); const inputElement = element.querySelector("[name]"); if (!inputElement) { continue; } if (inputElement.tagName.toLowerCase() === "input" && inputElement.type === "file") { if ((inputElement.files || []).length < 1) { continue; } files[element.name] = (inputElement.files || [])[0]; } } return files; } /** * Convert the elements of the form to JSON.[4] * */ serializeForm(): T | undefined { const elements = this.shadowRoot?.querySelectorAll( "ak-form-element-horizontal", ); if (!elements) { return {} as T; } return serializeForm(elements) as T; } /** * 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(event: Event): Promise { event.preventDefault(); const data = this.serializeForm(); if (!data) return; return this.send(data) .then((response) => { showMessage({ level: MessageLevel.success, message: this.getSuccessMessage(), }); this.dispatchEvent( new CustomEvent(EVENT_REFRESH, { bubbles: true, composed: true, }), ); return response; }) .catch(async (error: unknown) => { if (error instanceof PreventFormSubmit && error.element) { error.element.errorMessages = [error.message]; error.element.invalid = true; } const parsedError = await parseAPIResponseError(error); let errorMessage = pluckErrorDetail(error); if (instanceOfValidationError(parsedError)) { // assign all input-related errors to their elements const elements = this.shadowRoot?.querySelectorAll( "ak-form-element-horizontal", ) || []; elements.forEach((element) => { element.requestUpdate(); const elementName = element.name; if (!elementName) return; const snakeProperty = camelToSnake(elementName); if (snakeProperty in parsedError) { element.errorMessages = parsedError[snakeProperty]; element.invalid = true; } else { element.errorMessages = []; element.invalid = false; } }); if (parsedError.nonFieldErrors) { this.nonFieldErrors = parsedError.nonFieldErrors; } errorMessage = msg("Invalid update request."); // Only change the message when we have `detail`. // Everything else is handled in the form. if ("detail" in parsedError) { errorMessage = parsedError.detail; } } showMessage({ message: errorMessage, level: MessageLevel.error, }); // Rethrow the error so the form doesn't close. throw error; }); } renderFormWrapper(): TemplateResult { const inline = this.renderForm(); if (inline) { return html`
{ ev.preventDefault(); }} > ${inline}
`; } return html``; } renderForm(): TemplateResult | undefined { return undefined; } renderNonFieldErrors(): TemplateResult { if (!this.nonFieldErrors) { return html``; } return html`
${this.nonFieldErrors.map((err) => { return html`

${err}

`; })}
`; } renderVisible(): TemplateResult { return html` ${this.renderNonFieldErrors()} ${this.renderFormWrapper()}`; } render(): TemplateResult { if (this.viewportCheck && !this.isInViewport) { return html``; } return this.renderVisible(); } }