 363d655378
			
		
	
	363d655378
	
	
	
		
			
			web: Clean up error handling. Prep for permission checks. - Add clearer reporting for API and network errors. - Tidy error checking. - Partial type safety for events.
		
			
				
	
	
		
			406 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			406 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
| 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<HTMLInputElement, "name">;
 | |
| 
 | |
| export type AkControlElement<T = string | string[]> = 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<T extends KeyUnknown>(
 | |
|     elements: NodeListOf<HorizontalFormElement>,
 | |
| ): 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<AkControlElement>("[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<T> extends AKElement {
 | |
|     abstract send(data: T): Promise<unknown>;
 | |
| 
 | |
|     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<HTMLFormElement>("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<HorizontalFormElement>(
 | |
|                 "ak-form-element-horizontal",
 | |
|             ) || [];
 | |
|         for (let i = 0; i < elements.length; i++) {
 | |
|             const element = elements[i];
 | |
|             element.requestUpdate();
 | |
|             const inputElement = element.querySelector<HTMLInputElement>("[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<HorizontalFormElement>(
 | |
|             "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<unknown | undefined> {
 | |
|         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<HorizontalFormElement>(
 | |
|                             "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`<form
 | |
|                 class="pf-c-form pf-m-horizontal"
 | |
|                 @submit=${(ev: Event) => {
 | |
|                     ev.preventDefault();
 | |
|                 }}
 | |
|             >
 | |
|                 ${inline}
 | |
|             </form>`;
 | |
|         }
 | |
|         return html`<slot></slot>`;
 | |
|     }
 | |
| 
 | |
|     renderForm(): TemplateResult | undefined {
 | |
|         return undefined;
 | |
|     }
 | |
| 
 | |
|     renderNonFieldErrors(): TemplateResult {
 | |
|         if (!this.nonFieldErrors) {
 | |
|             return html``;
 | |
|         }
 | |
|         return html`<div class="pf-c-form__alert">
 | |
|             ${this.nonFieldErrors.map((err) => {
 | |
|                 return html`<div class="pf-c-alert pf-m-inline pf-m-danger">
 | |
|                     <div class="pf-c-alert__icon">
 | |
|                         <i class="fas fa-exclamation-circle"></i>
 | |
|                     </div>
 | |
|                     <h4 class="pf-c-alert__title">${err}</h4>
 | |
|                 </div>`;
 | |
|             })}
 | |
|         </div>`;
 | |
|     }
 | |
| 
 | |
|     renderVisible(): TemplateResult {
 | |
|         return html` ${this.renderNonFieldErrors()} ${this.renderFormWrapper()}`;
 | |
|     }
 | |
| 
 | |
|     render(): TemplateResult {
 | |
|         if (this.viewportCheck && !this.isInViewport) {
 | |
|             return html``;
 | |
|         }
 | |
|         return this.renderVisible();
 | |
|     }
 | |
| }
 |