From 7341044e8400197b63b70493502bbc7f709b0f27 Mon Sep 17 00:00:00 2001
From: Alexander Polyankin <alexander.polyankin@metabase.com>
Date: Wed, 26 Oct 2022 19:25:25 +0300
Subject: [PATCH] [RFC] Add first-class formik support (#26100)

---
 .../src/metabase-types/api/mocks/settings.ts  |  1 +
 frontend/src/metabase-types/api/settings.ts   |  3 +-
 .../auth/components/LoginForm/LoginForm.tsx   | 90 +++++++++++++++++++
 .../auth/components/LoginForm/index.ts        |  1 +
 .../PasswordPanel/PasswordPanel.tsx           | 13 +--
 .../PasswordPanel/PasswordPanel.unit.spec.tsx | 38 ++++----
 .../PasswordPanel/PasswordPanel.tsx           |  5 +-
 .../core/components/Button/Button.tsx         |  4 +-
 .../metabase/core/components/Button/index.ts  |  1 +
 .../core/components/CheckBox/CheckBox.tsx     | 65 ++++++++++----
 .../core/components/CheckBox/index.ts         |  1 +
 .../core/components/CheckBox/types.ts         | 25 ------
 .../components/FormCheckBox/FormCheckBox.tsx  | 28 ++++++
 .../core/components/FormCheckBox/index.ts     |  2 +
 .../FormErrorMessage.styled.tsx               |  7 ++
 .../FormErrorMessage/FormErrorMessage.tsx     | 26 ++++++
 .../core/components/FormErrorMessage/index.ts |  1 +
 .../core/components/FormField/FormField.tsx   | 29 ++++++
 .../core/components/FormField/index.ts        |  2 +
 .../core/components/FormField/types.ts        |  2 +
 .../core/components/FormInput/FormInput.tsx   | 30 +++++++
 .../core/components/FormInput/index.ts        |  2 +
 .../FormSubmitButton/FormSubmitButton.tsx     | 60 +++++++++++++
 .../core/components/FormSubmitButton/index.ts |  2 +
 .../metabase/core/components/Input/index.ts   |  1 +
 .../InputField/InputField.styled.tsx          | 55 ++++++++++++
 .../core/components/InputField/InputField.tsx | 60 +++++++++++++
 .../core/components/InputField/index.ts       |  2 +
 .../core/components/InputField/types.ts       |  3 +
 .../hooks/use-form-error-message/index.ts     |  1 +
 .../use-form-error-message.ts                 | 21 +++++
 .../core/hooks/use-form-state/index.ts        |  2 +
 .../core/hooks/use-form-state/types.ts        |  6 ++
 .../hooks/use-form-state/use-form-state.ts    |  9 ++
 .../core/hooks/use-form-status/index.ts       |  1 +
 .../hooks/use-form-status/use-form-status.ts  | 34 +++++++
 .../src/metabase/core/hooks/use-form/index.ts |  1 +
 .../src/metabase/core/hooks/use-form/types.ts | 10 +++
 .../metabase/core/hooks/use-form/use-form.ts  | 30 +++++++
 frontend/src/metabase/entities/users/forms.js | 33 -------
 package.json                                  |  1 +
 yarn.lock                                     | 33 +++++++
 42 files changed, 634 insertions(+), 107 deletions(-)
 create mode 100644 frontend/src/metabase/auth/components/LoginForm/LoginForm.tsx
 create mode 100644 frontend/src/metabase/auth/components/LoginForm/index.ts
 create mode 100644 frontend/src/metabase/core/components/FormCheckBox/FormCheckBox.tsx
 create mode 100644 frontend/src/metabase/core/components/FormCheckBox/index.ts
 create mode 100644 frontend/src/metabase/core/components/FormErrorMessage/FormErrorMessage.styled.tsx
 create mode 100644 frontend/src/metabase/core/components/FormErrorMessage/FormErrorMessage.tsx
 create mode 100644 frontend/src/metabase/core/components/FormErrorMessage/index.ts
 create mode 100644 frontend/src/metabase/core/components/FormField/FormField.tsx
 create mode 100644 frontend/src/metabase/core/components/FormField/index.ts
 create mode 100644 frontend/src/metabase/core/components/FormField/types.ts
 create mode 100644 frontend/src/metabase/core/components/FormInput/FormInput.tsx
 create mode 100644 frontend/src/metabase/core/components/FormInput/index.ts
 create mode 100644 frontend/src/metabase/core/components/FormSubmitButton/FormSubmitButton.tsx
 create mode 100644 frontend/src/metabase/core/components/FormSubmitButton/index.ts
 create mode 100644 frontend/src/metabase/core/components/InputField/InputField.styled.tsx
 create mode 100644 frontend/src/metabase/core/components/InputField/InputField.tsx
 create mode 100644 frontend/src/metabase/core/components/InputField/index.ts
 create mode 100644 frontend/src/metabase/core/components/InputField/types.ts
 create mode 100644 frontend/src/metabase/core/hooks/use-form-error-message/index.ts
 create mode 100644 frontend/src/metabase/core/hooks/use-form-error-message/use-form-error-message.ts
 create mode 100644 frontend/src/metabase/core/hooks/use-form-state/index.ts
 create mode 100644 frontend/src/metabase/core/hooks/use-form-state/types.ts
 create mode 100644 frontend/src/metabase/core/hooks/use-form-state/use-form-state.ts
 create mode 100644 frontend/src/metabase/core/hooks/use-form-status/index.ts
 create mode 100644 frontend/src/metabase/core/hooks/use-form-status/use-form-status.ts
 create mode 100644 frontend/src/metabase/core/hooks/use-form/index.ts
 create mode 100644 frontend/src/metabase/core/hooks/use-form/types.ts
 create mode 100644 frontend/src/metabase/core/hooks/use-form/use-form.ts

diff --git a/frontend/src/metabase-types/api/mocks/settings.ts b/frontend/src/metabase-types/api/mocks/settings.ts
index d2be5ea0e81..b14d44d2bb4 100644
--- a/frontend/src/metabase-types/api/mocks/settings.ts
+++ b/frontend/src/metabase-types/api/mocks/settings.ts
@@ -72,6 +72,7 @@ export const createMockSettings = (opts?: Partial<Settings>): Settings => ({
   "ldap-enabled": false,
   "loading-message": "doing-science",
   "deprecation-notice-version": undefined,
+  "session-cookies": null,
   "site-locale": "en",
   "show-database-syncing-modal": false,
   "show-homepage-data": false,
diff --git a/frontend/src/metabase-types/api/settings.ts b/frontend/src/metabase-types/api/settings.ts
index 5491c5b8f89..0f639288d21 100644
--- a/frontend/src/metabase-types/api/settings.ts
+++ b/frontend/src/metabase-types/api/settings.ts
@@ -52,6 +52,7 @@ export interface Settings {
   "deprecation-notice-version": string | undefined;
   "ldap-enabled": boolean;
   "loading-message": LoadingMessage;
+  "session-cookies": boolean | null;
   "site-locale": string;
   "show-database-syncing-modal": boolean;
   "show-homepage-data": boolean;
@@ -59,10 +60,10 @@ export interface Settings {
   "show-homepage-pin-message": boolean;
   "show-lighthouse-illustration": boolean;
   "show-metabot": boolean;
-  "token-status": TokenStatus | undefined;
   "slack-token": string | undefined;
   "slack-token-valid?": boolean;
   "slack-app-token": string | undefined;
   "slack-files-channel": string | undefined;
+  "token-status": TokenStatus | undefined;
   version: Version;
 }
diff --git a/frontend/src/metabase/auth/components/LoginForm/LoginForm.tsx b/frontend/src/metabase/auth/components/LoginForm/LoginForm.tsx
new file mode 100644
index 00000000000..e73298da514
--- /dev/null
+++ b/frontend/src/metabase/auth/components/LoginForm/LoginForm.tsx
@@ -0,0 +1,90 @@
+import React from "react";
+import { t } from "ttag";
+import { Form, Formik } from "formik";
+import * as Yup from "yup";
+import useForm from "metabase/core/hooks/use-form";
+import FormCheckBox from "metabase/core/components/FormCheckBox";
+import FormErrorMessage from "metabase/core/components/FormErrorMessage";
+import FormField from "metabase/core/components/FormField";
+import FormInput from "metabase/core/components/FormInput";
+import FormSubmitButton from "metabase/core/components/FormSubmitButton";
+import { LoginData } from "../../types";
+
+const LdapSchema = Yup.object().shape({
+  username: Yup.string().required(t`required`),
+  password: Yup.string().required(t`required`),
+  remember: Yup.boolean(),
+});
+
+const PasswordSchema = LdapSchema.shape({
+  username: Yup.string()
+    .required(t`required`)
+    .email(t`must be a valid email address`),
+});
+
+export interface LoginFormProps {
+  isLdapEnabled: boolean;
+  hasSessionCookies: boolean;
+  onSubmit: (data: LoginData) => void;
+}
+
+const LoginForm = ({
+  isLdapEnabled,
+  hasSessionCookies,
+  onSubmit,
+}: LoginFormProps): JSX.Element => {
+  const initialValues: LoginData = {
+    username: "",
+    password: "",
+    remember: !hasSessionCookies,
+  };
+  const handleSubmit = useForm(onSubmit);
+
+  return (
+    <Formik
+      initialValues={initialValues}
+      validationSchema={isLdapEnabled ? LdapSchema : PasswordSchema}
+      isInitialValid={false}
+      onSubmit={handleSubmit}
+    >
+      <Form>
+        <FormField
+          name="username"
+          title={
+            isLdapEnabled ? t`Username or email address` : t`Email address`
+          }
+        >
+          <FormInput
+            name="username"
+            type={isLdapEnabled ? "input" : "email"}
+            placeholder="nicetoseeyou@email.com"
+            autoFocus
+            fullWidth
+          />
+        </FormField>
+        <FormField name="password" title={t`Password`}>
+          <FormInput
+            name="password"
+            type="password"
+            placeholder={t`Shhh...`}
+            fullWidth
+          />
+        </FormField>
+        {!hasSessionCookies && (
+          <FormField
+            name="remember"
+            title={t`Remember me`}
+            alignment="start"
+            orientation="horizontal"
+          >
+            <FormCheckBox name="remember" />
+          </FormField>
+        )}
+        <FormSubmitButton normalText={t`Sign in`} fullWidth />
+        <FormErrorMessage />
+      </Form>
+    </Formik>
+  );
+};
+
+export default LoginForm;
diff --git a/frontend/src/metabase/auth/components/LoginForm/index.ts b/frontend/src/metabase/auth/components/LoginForm/index.ts
new file mode 100644
index 00000000000..8059f00fdfa
--- /dev/null
+++ b/frontend/src/metabase/auth/components/LoginForm/index.ts
@@ -0,0 +1 @@
+export { default } from "./LoginForm";
diff --git a/frontend/src/metabase/auth/components/PasswordPanel/PasswordPanel.tsx b/frontend/src/metabase/auth/components/PasswordPanel/PasswordPanel.tsx
index 388d9a35176..aee68252153 100644
--- a/frontend/src/metabase/auth/components/PasswordPanel/PasswordPanel.tsx
+++ b/frontend/src/metabase/auth/components/PasswordPanel/PasswordPanel.tsx
@@ -1,19 +1,23 @@
 import React, { useCallback } from "react";
 import { t } from "ttag";
-import Users from "metabase/entities/users";
 import AuthButton from "../AuthButton";
+import LoginForm from "../LoginForm";
 import { AuthProvider, LoginData } from "../../types";
 import { ActionListItem, ActionList } from "./PasswordPanel.styled";
 
 export interface PasswordPanelProps {
   providers?: AuthProvider[];
   redirectUrl?: string;
+  isLdapEnabled: boolean;
+  hasSessionCookies: boolean;
   onLogin: (data: LoginData, redirectUrl?: string) => void;
 }
 
 const PasswordPanel = ({
   providers = [],
   redirectUrl,
+  isLdapEnabled,
+  hasSessionCookies,
   onLogin,
 }: PasswordPanelProps) => {
   const handleSubmit = useCallback(
@@ -25,10 +29,9 @@ const PasswordPanel = ({
 
   return (
     <div>
-      <Users.Form
-        form={Users.forms.login()}
-        submitTitle={t`Sign in`}
-        submitFullWidth
+      <LoginForm
+        isLdapEnabled={isLdapEnabled}
+        hasSessionCookies={hasSessionCookies}
         onSubmit={handleSubmit}
       />
       <ActionList>
diff --git a/frontend/src/metabase/auth/components/PasswordPanel/PasswordPanel.unit.spec.tsx b/frontend/src/metabase/auth/components/PasswordPanel/PasswordPanel.unit.spec.tsx
index 1e7bfced023..96382fe6881 100644
--- a/frontend/src/metabase/auth/components/PasswordPanel/PasswordPanel.unit.spec.tsx
+++ b/frontend/src/metabase/auth/components/PasswordPanel/PasswordPanel.unit.spec.tsx
@@ -1,43 +1,39 @@
 import React from "react";
-import { render, screen } from "@testing-library/react";
+import { render, screen, waitFor } from "@testing-library/react";
 import userEvent from "@testing-library/user-event";
 import { AuthProvider } from "metabase/auth/types";
-import PasswordPanel from "./PasswordPanel";
+import PasswordPanel, { PasswordPanelProps } from "./PasswordPanel";
 
 describe("PasswordPanel", () => {
   it("should login successfully", () => {
-    const onLogin = jest.fn().mockResolvedValue({});
+    const props = getProps();
+    const data = { username: "user@example.test", password: "password" };
 
-    render(<PasswordPanel onLogin={onLogin} />);
+    render(<PasswordPanel {...props} />);
+    userEvent.type(screen.getByLabelText("Email address"), data.username);
+    userEvent.type(screen.getByLabelText("Password"), data.password);
     userEvent.click(screen.getByText("Sign in"));
 
-    expect(onLogin).toHaveBeenCalled();
+    waitFor(() => expect(props.onLogin).toHaveBeenCalledWith(data));
   });
 
   it("should render a link to reset the password and a list of auth providers", () => {
-    const providers = [getAuthProvider()];
-    const onLogin = jest.fn();
+    const props = getProps({ providers: [getAuthProvider()] });
 
-    render(<PasswordPanel providers={providers} onLogin={onLogin} />);
+    render(<PasswordPanel {...props} />);
 
     expect(screen.getByText(/forgotten my password/)).toBeInTheDocument();
     expect(screen.getByText("Sign in with Google")).toBeInTheDocument();
   });
 });
 
-interface FormMockProps {
-  submitTitle: string;
-  onSubmit: () => void;
-}
-
-const FormMock = ({ submitTitle, onSubmit }: FormMockProps) => {
-  return <button onClick={onSubmit}>{submitTitle}</button>;
-};
-
-jest.mock("metabase/entities/users", () => ({
-  forms: { login: jest.fn() },
-  Form: FormMock,
-}));
+const getProps = (opts?: Partial<PasswordPanelProps>): PasswordPanelProps => ({
+  providers: [],
+  isLdapEnabled: false,
+  hasSessionCookies: false,
+  onLogin: jest.fn(),
+  ...opts,
+});
 
 const getAuthProvider = (opts?: Partial<AuthProvider>): AuthProvider => ({
   name: "google",
diff --git a/frontend/src/metabase/auth/containers/PasswordPanel/PasswordPanel.tsx b/frontend/src/metabase/auth/containers/PasswordPanel/PasswordPanel.tsx
index 77ff39f3ed5..b1012d34584 100644
--- a/frontend/src/metabase/auth/containers/PasswordPanel/PasswordPanel.tsx
+++ b/frontend/src/metabase/auth/containers/PasswordPanel/PasswordPanel.tsx
@@ -1,10 +1,13 @@
 import { connect } from "react-redux";
 import { getExternalAuthProviders } from "metabase/auth/selectors";
+import { State } from "metabase-types/store";
 import { login } from "../../actions";
 import PasswordPanel from "../../components/PasswordPanel";
 
-const mapStateToProps = (state: any) => ({
+const mapStateToProps = (state: State) => ({
   providers: getExternalAuthProviders(state),
+  isLdapEnabled: state.settings.values["ldap-enabled"],
+  hasSessionCookies: state.settings.values["session-cookies"] ?? false,
 });
 
 const mapDispatchToProps = {
diff --git a/frontend/src/metabase/core/components/Button/Button.tsx b/frontend/src/metabase/core/components/Button/Button.tsx
index 00696998883..c908fe78b52 100644
--- a/frontend/src/metabase/core/components/Button/Button.tsx
+++ b/frontend/src/metabase/core/components/Button/Button.tsx
@@ -33,7 +33,7 @@ const BUTTON_VARIANTS = [
   "fullWidth",
 ] as const;
 
-interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
+export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
   as?: ElementType;
   className?: string;
   to?: string;
@@ -78,7 +78,7 @@ const BaseButton = forwardRef(function BaseButton(
     labelBreakpoint,
     children,
     ...props
-  }: Props,
+  }: ButtonProps,
   ref: Ref<HTMLButtonElement>,
 ) {
   const variantClasses = BUTTON_VARIANTS.filter(variant => props[variant]).map(
diff --git a/frontend/src/metabase/core/components/Button/index.ts b/frontend/src/metabase/core/components/Button/index.ts
index c4719be7c09..c82601dda43 100644
--- a/frontend/src/metabase/core/components/Button/index.ts
+++ b/frontend/src/metabase/core/components/Button/index.ts
@@ -1 +1,2 @@
 export { default } from "./Button";
+export type { ButtonProps } from "./Button";
diff --git a/frontend/src/metabase/core/components/CheckBox/CheckBox.tsx b/frontend/src/metabase/core/components/CheckBox/CheckBox.tsx
index ddf2381bb79..7305a8cc7f8 100644
--- a/frontend/src/metabase/core/components/CheckBox/CheckBox.tsx
+++ b/frontend/src/metabase/core/components/CheckBox/CheckBox.tsx
@@ -1,11 +1,22 @@
 import React, {
+  ChangeEvent,
+  FocusEvent,
   forwardRef,
+  HTMLAttributes,
   isValidElement,
   ReactElement,
+  ReactNode,
   Ref,
   useRef,
 } from "react";
 import Tooltip from "metabase/components/Tooltip";
+import {
+  DEFAULT_CHECKED_COLOR,
+  DEFAULT_ICON_PADDING,
+  DEFAULT_SIZE,
+  DEFAULT_UNCHECKED_COLOR,
+} from "./constants";
+import { isEllipsisActive } from "./utils";
 import {
   CheckBoxContainer,
   CheckBoxIcon,
@@ -14,25 +25,22 @@ import {
   CheckBoxLabel,
   CheckBoxRoot,
 } from "./CheckBox.styled";
-import { CheckBoxProps, CheckboxTooltipProps } from "./types";
-import { isEllipsisActive } from "./utils";
-import {
-  DEFAULT_CHECKED_COLOR,
-  DEFAULT_ICON_PADDING,
-  DEFAULT_SIZE,
-  DEFAULT_UNCHECKED_COLOR,
-} from "./constants";
 
-function CheckboxTooltip({
-  hasTooltip,
-  tooltipLabel,
-  children,
-}: CheckboxTooltipProps): ReactElement {
-  return hasTooltip ? (
-    <Tooltip tooltip={tooltipLabel}>{children}</Tooltip>
-  ) : (
-    <>{children}</>
-  );
+export interface CheckBoxProps
+  extends Omit<HTMLAttributes<HTMLElement>, "onChange" | "onFocus" | "onBlur"> {
+  name?: string;
+  label?: ReactNode;
+  labelEllipsis?: boolean;
+  checked?: boolean;
+  indeterminate?: boolean;
+  disabled?: boolean;
+  size?: number;
+  checkedColor?: string;
+  uncheckedColor?: string;
+  autoFocus?: boolean;
+  onChange?: (event: ChangeEvent<HTMLInputElement>) => void;
+  onFocus?: (event: FocusEvent<HTMLInputElement>) => void;
+  onBlur?: (event: FocusEvent<HTMLInputElement>) => void;
 }
 
 const CheckBox = forwardRef<HTMLLabelElement, CheckBoxProps>(function Checkbox(
@@ -67,6 +75,8 @@ const CheckBox = forwardRef<HTMLLabelElement, CheckBoxProps>(function Checkbox(
         tooltipLabel={label}
       >
         <CheckBoxInput
+          id={name}
+          name={name}
           type="checkbox"
           checked={isControlledCheckBoxInput ? !!checked : undefined}
           defaultChecked={isControlledCheckBoxInput ? undefined : !!checked}
@@ -77,7 +87,6 @@ const CheckBox = forwardRef<HTMLLabelElement, CheckBoxProps>(function Checkbox(
           onChange={isControlledCheckBoxInput ? onChange : undefined}
           onFocus={onFocus}
           onBlur={onBlur}
-          id={name}
         />
         <CheckBoxContainer disabled={disabled}>
           <CheckBoxIconContainer
@@ -109,6 +118,24 @@ const CheckBox = forwardRef<HTMLLabelElement, CheckBoxProps>(function Checkbox(
   );
 });
 
+interface CheckboxTooltipProps {
+  hasTooltip: boolean;
+  tooltipLabel: ReactNode;
+  children: ReactNode;
+}
+
+function CheckboxTooltip({
+  hasTooltip,
+  tooltipLabel,
+  children,
+}: CheckboxTooltipProps): ReactElement {
+  return hasTooltip ? (
+    <Tooltip tooltip={tooltipLabel}>{children}</Tooltip>
+  ) : (
+    <>{children}</>
+  );
+}
+
 export default Object.assign(CheckBox, {
   Label: CheckBoxLabel,
 });
diff --git a/frontend/src/metabase/core/components/CheckBox/index.ts b/frontend/src/metabase/core/components/CheckBox/index.ts
index 551e172305f..fa1802a7294 100644
--- a/frontend/src/metabase/core/components/CheckBox/index.ts
+++ b/frontend/src/metabase/core/components/CheckBox/index.ts
@@ -1 +1,2 @@
 export { default } from "./CheckBox";
+export type { CheckBoxProps } from "./CheckBox";
diff --git a/frontend/src/metabase/core/components/CheckBox/types.ts b/frontend/src/metabase/core/components/CheckBox/types.ts
index 64b21685634..edbd6ca824b 100644
--- a/frontend/src/metabase/core/components/CheckBox/types.ts
+++ b/frontend/src/metabase/core/components/CheckBox/types.ts
@@ -1,5 +1,3 @@
-import { ChangeEvent, FocusEvent, HTMLAttributes, ReactNode } from "react";
-
 export interface CheckBoxInputProps {
   size: number;
 }
@@ -23,26 +21,3 @@ export interface CheckBoxIconContainerProps {
 export interface CheckBoxLabelProps {
   labelEllipsis: boolean;
 }
-
-export interface CheckBoxProps
-  extends Omit<HTMLAttributes<HTMLElement>, "onChange" | "onFocus" | "onBlur"> {
-  name?: string;
-  label?: ReactNode;
-  labelEllipsis?: boolean;
-  checked?: boolean;
-  indeterminate?: boolean;
-  disabled?: boolean;
-  size?: number;
-  checkedColor?: string;
-  uncheckedColor?: string;
-  autoFocus?: boolean;
-  onChange?: (event: ChangeEvent<HTMLInputElement>) => void;
-  onFocus?: (event: FocusEvent<HTMLInputElement>) => void;
-  onBlur?: (event: FocusEvent<HTMLInputElement>) => void;
-}
-
-export interface CheckboxTooltipProps {
-  hasTooltip: boolean;
-  tooltipLabel: ReactNode;
-  children: ReactNode;
-}
diff --git a/frontend/src/metabase/core/components/FormCheckBox/FormCheckBox.tsx b/frontend/src/metabase/core/components/FormCheckBox/FormCheckBox.tsx
new file mode 100644
index 00000000000..f60717543ad
--- /dev/null
+++ b/frontend/src/metabase/core/components/FormCheckBox/FormCheckBox.tsx
@@ -0,0 +1,28 @@
+import React, { forwardRef, Ref } from "react";
+import { useField } from "formik";
+import CheckBox, { CheckBoxProps } from "metabase/core/components/CheckBox";
+
+export interface FormCheckBoxProps
+  extends Omit<CheckBoxProps, "checked" | "onChange" | "onBlur"> {
+  name: string;
+}
+
+const FormCheckBox = forwardRef(function FormCheckBox(
+  { name, ...props }: FormCheckBoxProps,
+  ref: Ref<HTMLLabelElement>,
+) {
+  const [field] = useField(name);
+
+  return (
+    <CheckBox
+      {...props}
+      ref={ref}
+      name={name}
+      checked={field.value}
+      onChange={field.onChange}
+      onBlur={field.onBlur}
+    />
+  );
+});
+
+export default FormCheckBox;
diff --git a/frontend/src/metabase/core/components/FormCheckBox/index.ts b/frontend/src/metabase/core/components/FormCheckBox/index.ts
new file mode 100644
index 00000000000..d93b810dfd9
--- /dev/null
+++ b/frontend/src/metabase/core/components/FormCheckBox/index.ts
@@ -0,0 +1,2 @@
+export { default } from "./FormCheckBox";
+export type { FormCheckBoxProps } from "./FormCheckBox";
diff --git a/frontend/src/metabase/core/components/FormErrorMessage/FormErrorMessage.styled.tsx b/frontend/src/metabase/core/components/FormErrorMessage/FormErrorMessage.styled.tsx
new file mode 100644
index 00000000000..b192d403470
--- /dev/null
+++ b/frontend/src/metabase/core/components/FormErrorMessage/FormErrorMessage.styled.tsx
@@ -0,0 +1,7 @@
+import styled from "@emotion/styled";
+import { color } from "metabase/lib/colors";
+
+export const ErrorMessageRoot = styled.div`
+  color: ${color("error")};
+  margin-top: 1em;
+`;
diff --git a/frontend/src/metabase/core/components/FormErrorMessage/FormErrorMessage.tsx b/frontend/src/metabase/core/components/FormErrorMessage/FormErrorMessage.tsx
new file mode 100644
index 00000000000..3d1b04c34dd
--- /dev/null
+++ b/frontend/src/metabase/core/components/FormErrorMessage/FormErrorMessage.tsx
@@ -0,0 +1,26 @@
+import React, { forwardRef, HTMLAttributes, Ref } from "react";
+import useFormErrorMessage from "metabase/core/hooks/use-form-error-message";
+import { ErrorMessageRoot } from "./FormErrorMessage.styled";
+
+export type FormErrorMessageProps = Omit<
+  HTMLAttributes<HTMLDivElement>,
+  "children"
+>;
+
+const FormErrorMessage = forwardRef(function FormErrorMessage(
+  props: FormErrorMessageProps,
+  ref: Ref<HTMLDivElement>,
+) {
+  const message = useFormErrorMessage();
+  if (!message) {
+    return null;
+  }
+
+  return (
+    <ErrorMessageRoot {...props} ref={ref}>
+      {message}
+    </ErrorMessageRoot>
+  );
+});
+
+export default FormErrorMessage;
diff --git a/frontend/src/metabase/core/components/FormErrorMessage/index.ts b/frontend/src/metabase/core/components/FormErrorMessage/index.ts
new file mode 100644
index 00000000000..f29055dbeaf
--- /dev/null
+++ b/frontend/src/metabase/core/components/FormErrorMessage/index.ts
@@ -0,0 +1 @@
+export { default } from "./FormErrorMessage";
diff --git a/frontend/src/metabase/core/components/FormField/FormField.tsx b/frontend/src/metabase/core/components/FormField/FormField.tsx
new file mode 100644
index 00000000000..16288cfdc9f
--- /dev/null
+++ b/frontend/src/metabase/core/components/FormField/FormField.tsx
@@ -0,0 +1,29 @@
+import React, { forwardRef, Ref } from "react";
+import { useField } from "formik";
+import InputField, {
+  InputFieldProps,
+} from "metabase/core/components/InputField";
+
+export interface FormFieldProps
+  extends Omit<InputFieldProps, "error" | "htmlFor"> {
+  name: string;
+}
+
+const FormField = forwardRef(function FormField(
+  { name, ...props }: FormFieldProps,
+  ref: Ref<HTMLDivElement>,
+) {
+  const [, meta] = useField(name);
+  const { error, touched } = meta;
+
+  return (
+    <InputField
+      {...props}
+      ref={ref}
+      htmlFor={name}
+      error={touched ? error : undefined}
+    />
+  );
+});
+
+export default FormField;
diff --git a/frontend/src/metabase/core/components/FormField/index.ts b/frontend/src/metabase/core/components/FormField/index.ts
new file mode 100644
index 00000000000..f1d921fd974
--- /dev/null
+++ b/frontend/src/metabase/core/components/FormField/index.ts
@@ -0,0 +1,2 @@
+export { default } from "./FormField";
+export type { FormFieldProps } from "./FormField";
diff --git a/frontend/src/metabase/core/components/FormField/types.ts b/frontend/src/metabase/core/components/FormField/types.ts
new file mode 100644
index 00000000000..ce136dd44bb
--- /dev/null
+++ b/frontend/src/metabase/core/components/FormField/types.ts
@@ -0,0 +1,2 @@
+export type FormFieldAlignment = "start" | "end";
+export type FormFieldOrientation = "horizontal" | "vertical";
diff --git a/frontend/src/metabase/core/components/FormInput/FormInput.tsx b/frontend/src/metabase/core/components/FormInput/FormInput.tsx
new file mode 100644
index 00000000000..d63dce5068a
--- /dev/null
+++ b/frontend/src/metabase/core/components/FormInput/FormInput.tsx
@@ -0,0 +1,30 @@
+import React, { forwardRef, Ref } from "react";
+import { useField } from "formik";
+import Input, { InputProps } from "metabase/core/components/Input";
+
+export interface FormInputProps
+  extends Omit<InputProps, "value" | "error" | "onChange" | "onBlur"> {
+  name: string;
+}
+
+const FormInput = forwardRef(function FormInput(
+  { name, ...props }: FormInputProps,
+  ref: Ref<HTMLInputElement>,
+) {
+  const [field, meta] = useField(name);
+
+  return (
+    <Input
+      {...props}
+      ref={ref}
+      id={name}
+      name={name}
+      value={field.value}
+      error={meta.touched && meta.error != null}
+      onChange={field.onChange}
+      onBlur={field.onBlur}
+    />
+  );
+});
+
+export default FormInput;
diff --git a/frontend/src/metabase/core/components/FormInput/index.ts b/frontend/src/metabase/core/components/FormInput/index.ts
new file mode 100644
index 00000000000..9ec828874a8
--- /dev/null
+++ b/frontend/src/metabase/core/components/FormInput/index.ts
@@ -0,0 +1,2 @@
+export { default } from "./FormInput";
+export type { FormInputProps } from "./FormInput";
diff --git a/frontend/src/metabase/core/components/FormSubmitButton/FormSubmitButton.tsx b/frontend/src/metabase/core/components/FormSubmitButton/FormSubmitButton.tsx
new file mode 100644
index 00000000000..6878df90e05
--- /dev/null
+++ b/frontend/src/metabase/core/components/FormSubmitButton/FormSubmitButton.tsx
@@ -0,0 +1,60 @@
+import React, { forwardRef, Ref } from "react";
+import { useFormikContext } from "formik";
+import { t } from "ttag";
+import Button, { ButtonProps } from "metabase/core/components/Button";
+import { FormStatus } from "metabase/core/hooks/use-form-state";
+import useFormStatus from "metabase/core/hooks/use-form-status";
+
+export interface FormSubmitButtonProps extends Omit<ButtonProps, "children"> {
+  normalText?: string;
+  activeText?: string;
+  successText?: string;
+  failedText?: string;
+}
+
+const FormSubmitButton = forwardRef(function FormSubmitButton(
+  { disabled, ...props }: FormSubmitButtonProps,
+  ref: Ref<HTMLButtonElement>,
+) {
+  const { isValid, isSubmitting } = useFormikContext();
+  const status = useFormStatus();
+  const submitText = getSubmitButtonText(status, props);
+  const isEnabled = isValid && !isSubmitting && !disabled;
+
+  return (
+    <Button
+      {...props}
+      ref={ref}
+      type="submit"
+      primary={isEnabled}
+      success={status === "fulfilled"}
+      danger={status === "rejected"}
+      disabled={!isEnabled}
+    >
+      {submitText}
+    </Button>
+  );
+});
+
+const getSubmitButtonText = (
+  status: FormStatus | undefined,
+  {
+    normalText = t`Submit`,
+    activeText = normalText,
+    successText = t`Success`,
+    failedText = t`Failed`,
+  }: FormSubmitButtonProps,
+) => {
+  switch (status) {
+    case "pending":
+      return activeText;
+    case "fulfilled":
+      return successText;
+    case "rejected":
+      return failedText;
+    default:
+      return normalText;
+  }
+};
+
+export default FormSubmitButton;
diff --git a/frontend/src/metabase/core/components/FormSubmitButton/index.ts b/frontend/src/metabase/core/components/FormSubmitButton/index.ts
new file mode 100644
index 00000000000..b1642839452
--- /dev/null
+++ b/frontend/src/metabase/core/components/FormSubmitButton/index.ts
@@ -0,0 +1,2 @@
+export { default } from "./FormSubmitButton";
+export type { FormSubmitButtonProps } from "./FormSubmitButton";
diff --git a/frontend/src/metabase/core/components/Input/index.ts b/frontend/src/metabase/core/components/Input/index.ts
index a50d7d110ed..52609a2e9dc 100644
--- a/frontend/src/metabase/core/components/Input/index.ts
+++ b/frontend/src/metabase/core/components/Input/index.ts
@@ -1 +1,2 @@
 export { default } from "./Input";
+export type { InputProps } from "./Input";
diff --git a/frontend/src/metabase/core/components/InputField/InputField.styled.tsx b/frontend/src/metabase/core/components/InputField/InputField.styled.tsx
new file mode 100644
index 00000000000..be942547e39
--- /dev/null
+++ b/frontend/src/metabase/core/components/InputField/InputField.styled.tsx
@@ -0,0 +1,55 @@
+import styled from "@emotion/styled";
+import { color } from "metabase/lib/colors";
+import { FieldAlignment, FieldOrientation } from "./types";
+
+export const FieldLabelError = styled.span`
+  color: ${color("error")};
+`;
+
+export interface FieldRootProps {
+  orientation: FieldOrientation;
+  hasError: boolean;
+}
+
+export const FieldRoot = styled.div<FieldRootProps>`
+  display: ${props => props.orientation === "horizontal" && "flex"};
+  color: ${props => (props.hasError ? color("error") : color("text-medium"))};
+  margin-bottom: 1.25rem;
+
+  &:focus-within {
+    color: ${color("text-medium")};
+
+    ${FieldLabelError} {
+      display: none;
+    }
+  }
+`;
+
+export interface FormCaptionProps {
+  alignment: FieldAlignment;
+  orientation: FieldOrientation;
+}
+
+export const FieldCaption = styled.div<FormCaptionProps>`
+  display: flex;
+  align-items: center;
+  margin-left: ${props =>
+    props.orientation === "horizontal" &&
+    props.alignment === "start" &&
+    "0.5rem"};
+  margin-right: ${props =>
+    props.orientation === "horizontal" &&
+    props.alignment === "end" &&
+    "0.5rem"};
+  margin-bottom: 0.5rem;
+`;
+
+export const FieldLabel = styled.label`
+  display: block;
+  font-size: 0.77rem;
+  font-weight: 900;
+`;
+
+export const FieldDescription = styled.div`
+  margin-bottom: 0.5rem;
+`;
diff --git a/frontend/src/metabase/core/components/InputField/InputField.tsx b/frontend/src/metabase/core/components/InputField/InputField.tsx
new file mode 100644
index 00000000000..9b4c5bb2284
--- /dev/null
+++ b/frontend/src/metabase/core/components/InputField/InputField.tsx
@@ -0,0 +1,60 @@
+import React, { forwardRef, HTMLAttributes, ReactNode, Ref } from "react";
+import { FieldAlignment, FieldOrientation } from "./types";
+import {
+  FieldCaption,
+  FieldDescription,
+  FieldLabel,
+  FieldLabelError,
+  FieldRoot,
+} from "./InputField.styled";
+
+export interface InputFieldProps extends HTMLAttributes<HTMLDivElement> {
+  title?: string;
+  description?: ReactNode;
+  error?: string;
+  htmlFor?: string;
+  alignment?: FieldAlignment;
+  orientation?: FieldOrientation;
+  children?: ReactNode;
+}
+
+const InputField = forwardRef(function InputField(
+  {
+    title,
+    description,
+    error,
+    htmlFor,
+    alignment = "end",
+    orientation = "vertical",
+    children,
+    ...props
+  }: InputFieldProps,
+  ref: Ref<HTMLDivElement>,
+) {
+  const hasError = Boolean(error);
+
+  return (
+    <FieldRoot
+      {...props}
+      ref={ref}
+      orientation={orientation}
+      hasError={hasError}
+    >
+      {alignment === "start" && children}
+      {(title || description) && (
+        <FieldCaption alignment={alignment} orientation={orientation}>
+          {title && (
+            <FieldLabel htmlFor={htmlFor}>
+              {title}
+              {hasError && <FieldLabelError>: {error}</FieldLabelError>}
+            </FieldLabel>
+          )}
+          {description && <FieldDescription>{description}</FieldDescription>}
+        </FieldCaption>
+      )}
+      {alignment === "end" && children}
+    </FieldRoot>
+  );
+});
+
+export default InputField;
diff --git a/frontend/src/metabase/core/components/InputField/index.ts b/frontend/src/metabase/core/components/InputField/index.ts
new file mode 100644
index 00000000000..23ec5d21c75
--- /dev/null
+++ b/frontend/src/metabase/core/components/InputField/index.ts
@@ -0,0 +1,2 @@
+export { default } from "./InputField";
+export type { InputFieldProps } from "./InputField";
diff --git a/frontend/src/metabase/core/components/InputField/types.ts b/frontend/src/metabase/core/components/InputField/types.ts
new file mode 100644
index 00000000000..c2dc2f099c1
--- /dev/null
+++ b/frontend/src/metabase/core/components/InputField/types.ts
@@ -0,0 +1,3 @@
+export type FieldAlignment = "start" | "end";
+
+export type FieldOrientation = "horizontal" | "vertical";
diff --git a/frontend/src/metabase/core/hooks/use-form-error-message/index.ts b/frontend/src/metabase/core/hooks/use-form-error-message/index.ts
new file mode 100644
index 00000000000..a7f88391af8
--- /dev/null
+++ b/frontend/src/metabase/core/hooks/use-form-error-message/index.ts
@@ -0,0 +1 @@
+export { default } from "./use-form-error-message";
diff --git a/frontend/src/metabase/core/hooks/use-form-error-message/use-form-error-message.ts b/frontend/src/metabase/core/hooks/use-form-error-message/use-form-error-message.ts
new file mode 100644
index 00000000000..8442d393e94
--- /dev/null
+++ b/frontend/src/metabase/core/hooks/use-form-error-message/use-form-error-message.ts
@@ -0,0 +1,21 @@
+import { useLayoutEffect, useState } from "react";
+import { useFormikContext } from "formik";
+import useFormState from "../use-form-state";
+
+const useFormErrorMessage = () => {
+  const { values } = useFormikContext();
+  const { message } = useFormState();
+  const [errorMessage, setErrorMessage] = useState(message);
+
+  useLayoutEffect(() => {
+    setErrorMessage(undefined);
+  }, [values]);
+
+  useLayoutEffect(() => {
+    setErrorMessage(message);
+  }, [message]);
+
+  return errorMessage;
+};
+
+export default useFormErrorMessage;
diff --git a/frontend/src/metabase/core/hooks/use-form-state/index.ts b/frontend/src/metabase/core/hooks/use-form-state/index.ts
new file mode 100644
index 00000000000..1acef3bdec7
--- /dev/null
+++ b/frontend/src/metabase/core/hooks/use-form-state/index.ts
@@ -0,0 +1,2 @@
+export { default } from "./use-form-state";
+export type { FormState, FormStatus } from "./types";
diff --git a/frontend/src/metabase/core/hooks/use-form-state/types.ts b/frontend/src/metabase/core/hooks/use-form-state/types.ts
new file mode 100644
index 00000000000..f8cd0fb25c9
--- /dev/null
+++ b/frontend/src/metabase/core/hooks/use-form-state/types.ts
@@ -0,0 +1,6 @@
+export type FormStatus = "pending" | "fulfilled" | "rejected";
+
+export interface FormState {
+  status?: FormStatus;
+  message?: string;
+}
diff --git a/frontend/src/metabase/core/hooks/use-form-state/use-form-state.ts b/frontend/src/metabase/core/hooks/use-form-state/use-form-state.ts
new file mode 100644
index 00000000000..46e50c29be4
--- /dev/null
+++ b/frontend/src/metabase/core/hooks/use-form-state/use-form-state.ts
@@ -0,0 +1,9 @@
+import { useFormikContext } from "formik";
+import { FormState } from "./types";
+
+const useFormState = (): FormState => {
+  const { status } = useFormikContext();
+  return status ?? {};
+};
+
+export default useFormState;
diff --git a/frontend/src/metabase/core/hooks/use-form-status/index.ts b/frontend/src/metabase/core/hooks/use-form-status/index.ts
new file mode 100644
index 00000000000..aaf24961a28
--- /dev/null
+++ b/frontend/src/metabase/core/hooks/use-form-status/index.ts
@@ -0,0 +1 @@
+export { default } from "./use-form-status";
diff --git a/frontend/src/metabase/core/hooks/use-form-status/use-form-status.ts b/frontend/src/metabase/core/hooks/use-form-status/use-form-status.ts
new file mode 100644
index 00000000000..f9893af13e3
--- /dev/null
+++ b/frontend/src/metabase/core/hooks/use-form-status/use-form-status.ts
@@ -0,0 +1,34 @@
+import { useEffect, useLayoutEffect, useState } from "react";
+import useFormState, { FormStatus } from "../use-form-state";
+
+const STATUS_TIMEOUT = 5000;
+
+const useFormStatus = (): FormStatus | undefined => {
+  const { status } = useFormState();
+  const isRecent = useIsRecent(status, STATUS_TIMEOUT);
+
+  switch (status) {
+    case "pending":
+      return status;
+    case "fulfilled":
+    case "rejected":
+      return isRecent ? status : undefined;
+  }
+};
+
+function useIsRecent(value: unknown, timeout: number) {
+  const [isRecent, setIsRecent] = useState(true);
+
+  useEffect(() => {
+    const timerId = setTimeout(() => setIsRecent(false), timeout);
+    return () => clearTimeout(timerId);
+  }, [value, timeout]);
+
+  useLayoutEffect(() => {
+    setIsRecent(true);
+  }, [value]);
+
+  return isRecent;
+}
+
+export default useFormStatus;
diff --git a/frontend/src/metabase/core/hooks/use-form/index.ts b/frontend/src/metabase/core/hooks/use-form/index.ts
new file mode 100644
index 00000000000..fc6c71c16f1
--- /dev/null
+++ b/frontend/src/metabase/core/hooks/use-form/index.ts
@@ -0,0 +1 @@
+export { default } from "./use-form";
diff --git a/frontend/src/metabase/core/hooks/use-form/types.ts b/frontend/src/metabase/core/hooks/use-form/types.ts
new file mode 100644
index 00000000000..1380e5611f8
--- /dev/null
+++ b/frontend/src/metabase/core/hooks/use-form/types.ts
@@ -0,0 +1,10 @@
+import type { FormikErrors } from "formik";
+
+export interface FormError<T> {
+  data?: FormErrorData<T>;
+}
+
+export interface FormErrorData<T> {
+  errors?: FormikErrors<T>;
+  message?: string;
+}
diff --git a/frontend/src/metabase/core/hooks/use-form/use-form.ts b/frontend/src/metabase/core/hooks/use-form/use-form.ts
new file mode 100644
index 00000000000..e281cafefed
--- /dev/null
+++ b/frontend/src/metabase/core/hooks/use-form/use-form.ts
@@ -0,0 +1,30 @@
+import { useCallback } from "react";
+import type { FormikHelpers } from "formik";
+import { FormError } from "./types";
+
+const useForm = <T>(onSubmit: (data: T) => void) => {
+  return useCallback(
+    async (data: T, helpers: FormikHelpers<T>) => {
+      try {
+        helpers.setStatus({ status: "pending", message: undefined });
+        await onSubmit(data);
+        helpers.setStatus({ status: "fulfilled" });
+      } catch (error) {
+        if (isFormError(error)) {
+          const { data } = error;
+          helpers.setErrors(data?.errors ?? {});
+          helpers.setStatus({ status: "rejected", message: data?.message });
+        } else {
+          helpers.setStatus({ status: "rejected", message: undefined });
+        }
+      }
+    },
+    [onSubmit],
+  );
+};
+
+const isFormError = <T>(error: unknown): error is FormError<T> => {
+  return error != null && typeof error === "object";
+};
+
+export default useForm;
diff --git a/frontend/src/metabase/entities/users/forms.js b/frontend/src/metabase/entities/users/forms.js
index 4eabad82aef..52dfb1ff5c6 100644
--- a/frontend/src/metabase/entities/users/forms.js
+++ b/frontend/src/metabase/entities/users/forms.js
@@ -129,39 +129,6 @@ export default {
       },
     ],
   }),
-  login: () => {
-    const ldap = MetabaseSettings.isLdapEnabled();
-    const cookies = MetabaseSettings.get("session-cookies");
-
-    return {
-      fields: [
-        {
-          name: "username",
-          type: ldap ? "input" : "email",
-          title: ldap ? t`Username or email address` : t`Email address`,
-          placeholder: "nicetoseeyou@email.com",
-          validate: ldap ? validate.required() : validate.required().email(),
-          autoFocus: true,
-        },
-        {
-          name: "password",
-          type: "password",
-          title: t`Password`,
-          placeholder: t`Shhh...`,
-          validate: validate.required(),
-        },
-        {
-          name: "remember",
-          type: "checkbox",
-          title: t`Remember me`,
-          initial: true,
-          hidden: cookies,
-          horizontal: true,
-          align: "left",
-        },
-      ],
-    };
-  },
   password: {
     fields: [
       {
diff --git a/package.json b/package.json
index 17e3913fa54..83f4abab081 100644
--- a/package.json
+++ b/package.json
@@ -105,6 +105,7 @@
     "ttag": "1.7.15",
     "underscore": "~1.13.3",
     "yarn.lock": "^0.0.1-security",
+    "yup": "^0.32.11",
     "z-index": "0.0.1"
   },
   "devDependencies": {
diff --git a/yarn.lock b/yarn.lock
index ae6524e84de..4b2c102c90d 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -5334,6 +5334,11 @@
   resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.185.tgz#c9843f5a40703a8f5edfd53358a58ae729816908"
   integrity sha512-evMDG1bC4rgQg4ku9tKpuMh5iBNEwNa3tf9zRHdP1qlv+1WUg44xat4IxCE14gIpZRGUUWAx2VhItCZc25NfMA==
 
+"@types/lodash@^4.14.175":
+  version "4.14.186"
+  resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.186.tgz#862e5514dd7bd66ada6c70ee5fce844b06c8ee97"
+  integrity sha512-eHcVlLXP0c2FlMPm56ITode2AgLMSa6aJ05JTTbYbI+7EMkCEE5qk2E41d5g2lCVTqRe0GnnRFurmlCsDODrPw==
+
 "@types/mdast@^3.0.0":
   version "3.0.4"
   resolved "https://registry.yarnpkg.com/@types/mdast/-/mdast-3.0.4.tgz#8ee6b5200751b6cadb9a043ca39612693ad6cb9e"
@@ -16566,6 +16571,11 @@ nan@^2.12.1:
   resolved "https://registry.yarnpkg.com/nan/-/nan-2.14.2.tgz#f5376400695168f4cc694ac9393d0c9585eeea19"
   integrity sha512-M2ufzIiINKCuDfBSAUr1vWQ+vuVcA9kqx8JJUsbQi6yf1uGRyb7HfpdfUr5qLXf3B/t8dPvcjhKMmlfnP47EzQ==
 
+nanoclone@^0.2.1:
+  version "0.2.1"
+  resolved "https://registry.yarnpkg.com/nanoclone/-/nanoclone-0.2.1.tgz#dd4090f8f1a110d26bb32c49ed2f5b9235209ed4"
+  integrity sha512-wynEP02LmIbLpcYw8uBKpcfF6dmg2vcpKqxeH5UcoKEYdExslsdUA4ugFauuaeYdTB76ez6gJW8XAZ6CgkXYxA==
+
 nanoid@3.1.31, nanoid@^3.1.23, nanoid@^3.1.30, nanoid@^3.3.3:
   version "3.1.31"
   resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.1.31.tgz#f5b58a1ce1b7604da5f0605757840598d8974dc6"
@@ -18676,6 +18686,11 @@ prop-types@15.x, prop-types@^15.0.0, prop-types@^15.5.10, prop-types@^15.5.4, pr
     object-assign "^4.1.1"
     react-is "^16.8.1"
 
+property-expr@^2.0.4:
+  version "2.0.5"
+  resolved "https://registry.yarnpkg.com/property-expr/-/property-expr-2.0.5.tgz#278bdb15308ae16af3e3b9640024524f4dc02cb4"
+  integrity sha512-IJUkICM5dP5znhCckHSv30Q4b5/JA5enCtkRHYaOVOAocnH/1BQEYTC5NMfT3AVl/iXKdr3aqQbQn9DxyWknwA==
+
 property-information@^5.0.0, property-information@^5.3.0:
   version "5.6.0"
   resolved "https://registry.yarnpkg.com/property-information/-/property-information-5.6.0.tgz#61675545fb23002f245c6540ec46077d4da3ed69"
@@ -21797,6 +21812,11 @@ toidentifier@1.0.0:
   resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.0.tgz#7e1be3470f1e77948bc43d94a3c8f4d7752ba553"
   integrity sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==
 
+toposort@^2.0.2:
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/toposort/-/toposort-2.0.2.tgz#ae21768175d1559d48bef35420b2f4962f09c330"
+  integrity sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==
+
 tough-cookie@^4.0.0:
   version "4.0.0"
   resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-4.0.0.tgz#d822234eeca882f991f0f908824ad2622ddbece4"
@@ -23365,6 +23385,19 @@ yocto-queue@^0.1.0:
   resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b"
   integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==
 
+yup@^0.32.11:
+  version "0.32.11"
+  resolved "https://registry.yarnpkg.com/yup/-/yup-0.32.11.tgz#d67fb83eefa4698607982e63f7ca4c5ed3cf18c5"
+  integrity sha512-Z2Fe1bn+eLstG8DRR6FTavGD+MeAwyfmouhHsIUgaADz8jvFKbO/fXc2trJKZg+5EBjh4gGm3iU/t3onKlXHIg==
+  dependencies:
+    "@babel/runtime" "^7.15.4"
+    "@types/lodash" "^4.14.175"
+    lodash "^4.17.21"
+    lodash-es "^4.17.21"
+    nanoclone "^0.2.1"
+    property-expr "^2.0.4"
+    toposort "^2.0.2"
+
 z-index@0.0.1:
   version "0.0.1"
   resolved "https://registry.yarnpkg.com/z-index/-/z-index-0.0.1.tgz#4f3d257a36869dabd990572b70494291cb3eab8f"
-- 
GitLab