diff --git a/ui/apps/console/package.json b/ui/apps/console/package.json index 42423e10ea4..3aa1aad9117 100644 --- a/ui/apps/console/package.json +++ b/ui/apps/console/package.json @@ -35,6 +35,7 @@ "axios": "^1.18.1", "font-logos": "^1.3.0", "node-rsa": "^1.1.1", + "react-hook-form": "^7.0.0", "react-router-dom": "^7.17.0", "sshpk": "^1.18.0", "zustand": "^5.0.14" diff --git a/ui/apps/console/src/components/common/fields/rhf/FormCheckboxField.tsx b/ui/apps/console/src/components/common/fields/rhf/FormCheckboxField.tsx new file mode 100644 index 00000000000..38792dea7e6 --- /dev/null +++ b/ui/apps/console/src/components/common/fields/rhf/FormCheckboxField.tsx @@ -0,0 +1,43 @@ +import { useController, type Control, type FieldValues, type Path } from "react-hook-form"; +import CheckboxField from "@/components/common/fields/CheckboxField"; +import type { ComponentProps } from "react"; + +type CheckboxFieldProps = Omit< + ComponentProps, + "checked" | "onChange" +>; + +type Props = CheckboxFieldProps & { + name: Path; + control: Control; + /** Called on every value change, in addition to RHF's internal onChange. */ + onValueChange?: (value: boolean) => void; +}; + +export default function FormCheckboxField({ + name, + control, + error: errorOverride, + onValueChange, + ...rest +}: Props) { + const { + field, + fieldState: { error: fieldError }, + } = useController({ name, control }); + + const resolvedError = errorOverride ?? fieldError?.message; + + return ( + { + field.onChange(v); + onValueChange?.(v); + }} + onBlur={field.onBlur} + error={resolvedError} + /> + ); +} diff --git a/ui/apps/console/src/components/common/fields/rhf/FormInputField.tsx b/ui/apps/console/src/components/common/fields/rhf/FormInputField.tsx new file mode 100644 index 00000000000..130e4c8bee2 --- /dev/null +++ b/ui/apps/console/src/components/common/fields/rhf/FormInputField.tsx @@ -0,0 +1,43 @@ +import { useController, type Control, type FieldValues, type Path } from "react-hook-form"; +import InputField from "@/components/common/fields/InputField"; +import type { ComponentProps } from "react"; + +type InputFieldProps = Omit< + ComponentProps, + "value" | "onChange" +>; + +type Props = InputFieldProps & { + name: Path; + control: Control; + /** Called on every value change, in addition to RHF's internal onChange. */ + onValueChange?: (value: string) => void; +}; + +export default function FormInputField({ + name, + control, + error: errorOverride, + onValueChange, + ...rest +}: Props) { + const { + field, + fieldState: { error: fieldError }, + } = useController({ name, control }); + + const resolvedError = errorOverride ?? fieldError?.message; + + return ( + { + field.onChange(v); + onValueChange?.(v); + }} + onBlur={field.onBlur} + error={resolvedError} + /> + ); +} diff --git a/ui/apps/console/src/components/common/fields/rhf/FormPasswordField.tsx b/ui/apps/console/src/components/common/fields/rhf/FormPasswordField.tsx new file mode 100644 index 00000000000..c4592bd5971 --- /dev/null +++ b/ui/apps/console/src/components/common/fields/rhf/FormPasswordField.tsx @@ -0,0 +1,43 @@ +import { useController, type Control, type FieldValues, type Path } from "react-hook-form"; +import PasswordField from "@/components/common/fields/PasswordField"; +import type { ComponentProps } from "react"; + +type PasswordFieldProps = Omit< + ComponentProps, + "value" | "onChange" +>; + +type Props = PasswordFieldProps & { + name: Path; + control: Control; + /** Called on every value change, in addition to RHF's internal onChange. */ + onValueChange?: (value: string) => void; +}; + +export default function FormPasswordField({ + name, + control, + error: errorOverride, + onValueChange, + ...rest +}: Props) { + const { + field, + fieldState: { error: fieldError }, + } = useController({ name, control }); + + const resolvedError = errorOverride ?? fieldError?.message; + + return ( + { + field.onChange(v); + onValueChange?.(v); + }} + onBlur={field.onBlur} + error={resolvedError} + /> + ); +} diff --git a/ui/apps/console/src/components/common/fields/rhf/__tests__/formFields.test.tsx b/ui/apps/console/src/components/common/fields/rhf/__tests__/formFields.test.tsx new file mode 100644 index 00000000000..ee6db79eb65 --- /dev/null +++ b/ui/apps/console/src/components/common/fields/rhf/__tests__/formFields.test.tsx @@ -0,0 +1,289 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useEffect, type ReactElement } from "react"; +import { useForm, type Control, type FieldValues } from "react-hook-form"; +import FormInputField from "@/components/common/fields/rhf/FormInputField"; +import FormPasswordField from "@/components/common/fields/rhf/FormPasswordField"; +import FormCheckboxField from "@/components/common/fields/rhf/FormCheckboxField"; + +// --------------------------------------------------------------------------- +// Shared adapter contract — text-like fields (FormInputField, FormPasswordField) +// +// FormInputField and FormPasswordField are near-identical react-hook-form +// adapters over a controlled text input. Rather than copy the same suite into +// two files, we describe the contract once and run it against each wrapper. The +// only thing that differs is which component renders the input, so each case +// provides a `render` closure that keeps full generic typing (no casts). +// --------------------------------------------------------------------------- + +interface TextFormValues extends FieldValues { + value: string; +} + +type TextFieldRenderer = (props: { + control: Control; + label: string; + error?: string; + onValueChange?: (v: string) => void; +}) => ReactElement; + +const TEXT_CASES: { name: string; label: string; render: TextFieldRenderer }[] = [ + { + name: "FormInputField", + label: "Username", + render: ({ control, label, error, onValueChange }) => ( + + name="value" + control={control} + id="value" + label={label} + error={error} + onValueChange={onValueChange} + /> + ), + }, + { + name: "FormPasswordField", + label: "Password", + render: ({ control, label, error, onValueChange }) => ( + + name="value" + control={control} + id="value" + label={label} + error={error} + onValueChange={onValueChange} + /> + ), + }, +]; + +/** Wires a text field into a real RHF form so we can assert against real usage. */ +function TextForm({ + render: renderField, + label, + defaultValue = "", + onValueChange, +}: { + render: TextFieldRenderer; + label: string; + defaultValue?: string; + onValueChange?: (v: string) => void; +}) { + const { control } = useForm({ + defaultValues: { value: defaultValue }, + }); + + return renderField({ control, label, onValueChange }); +} + +/** Same harness, but seeds a fieldState error (and optionally an override). */ +function TextFormWithFieldError({ + render: renderField, + label, + message, + error, +}: { + render: TextFieldRenderer; + label: string; + message: string; + error?: string; +}) { + const { control, setError } = useForm({ + defaultValues: { value: "" }, + }); + + useEffect(() => { + setError("value", { message }); + }, [setError, message]); + + return renderField({ control, label, error }); +} + +describe.each(TEXT_CASES)("$name (RHF adapter contract)", ({ label, render: renderField }) => { + it("binds the initial value from the form state into the input", () => { + render(); + + expect(screen.getByLabelText(label)).toHaveValue("preset"); + }); + + it("updates the input value as the user types (two-way binding)", async () => { + const user = userEvent.setup(); + render(); + + const input = screen.getByLabelText(label); + await user.type(input, "bob"); + + expect(input).toHaveValue("bob"); + }); + + it("surfaces fieldState.error.message via the underlying field", async () => { + render( + , + ); + + expect( + await screen.findByText("This field is required"), + ).toBeInTheDocument(); + }); + + it("error override prop takes precedence over fieldState error", () => { + render( + , + ); + + expect(screen.getByText("Override error")).toBeInTheDocument(); + expect(screen.queryByText("Field-state error")).not.toBeInTheDocument(); + }); + + it("calls onValueChange on each edit, forwarding the current field value", async () => { + const onValueChange = vi.fn(); + const user = userEvent.setup(); + + render( + , + ); + + await user.type(screen.getByLabelText(label), "hi"); + + // RHF's controlled onChange fires per keystroke with the accumulated value. + expect(onValueChange).toHaveBeenCalledTimes(2); + expect(onValueChange).toHaveBeenNthCalledWith(1, "h"); + expect(onValueChange).toHaveBeenNthCalledWith(2, "hi"); + }); +}); + +// --------------------------------------------------------------------------- +// FormCheckboxField — same contract, but boolean/checked semantics differ +// enough (toggle via click, checked instead of value) to warrant its own block. +// --------------------------------------------------------------------------- + +interface CheckboxFormValues extends FieldValues { + agree: boolean; +} + +function CheckboxForm({ + defaultValue = false, + error, + onValueChange, +}: { + defaultValue?: boolean; + error?: string; + onValueChange?: (v: boolean) => void; +}) { + const { control } = useForm({ + defaultValues: { agree: defaultValue }, + }); + + return ( + + ); +} + +function CheckboxFormWithFieldError({ + message, + error, +}: { + message: string; + error?: string; +}) { + const { control, setError } = useForm({ + defaultValues: { agree: false }, + }); + + useEffect(() => { + setError("agree", { message }); + }, [setError, message]); + + return ( + + ); +} + +describe("FormCheckboxField (RHF adapter contract)", () => { + it("binds the checked state from the form default value (false)", () => { + render(); + + expect(screen.getByLabelText("I agree")).not.toBeChecked(); + }); + + it("binds the checked state from the form default value (true)", () => { + render(); + + expect(screen.getByLabelText("I agree")).toBeChecked(); + }); + + it("toggles the checkbox on click (two-way binding)", async () => { + const user = userEvent.setup(); + render(); + + const checkbox = screen.getByLabelText("I agree"); + expect(checkbox).not.toBeChecked(); + + await user.click(checkbox); + expect(checkbox).toBeChecked(); + + await user.click(checkbox); + expect(checkbox).not.toBeChecked(); + }); + + it("surfaces fieldState.error.message via the underlying field", async () => { + render(); + + expect( + await screen.findByText("You must agree to continue"), + ).toBeInTheDocument(); + }); + + it("error override prop takes precedence over fieldState error", () => { + render( + , + ); + + expect(screen.getByText("Override error")).toBeInTheDocument(); + expect(screen.queryByText("Field-state error")).not.toBeInTheDocument(); + }); + + it("calls onValueChange with the new boolean on each toggle", async () => { + const onValueChange = vi.fn(); + const user = userEvent.setup(); + + render(); + + const checkbox = screen.getByLabelText("I agree"); + + await user.click(checkbox); + expect(onValueChange).toHaveBeenCalledTimes(1); + expect(onValueChange).toHaveBeenNthCalledWith(1, true); + + await user.click(checkbox); + expect(onValueChange).toHaveBeenCalledTimes(2); + expect(onValueChange).toHaveBeenNthCalledWith(2, false); + }); +}); diff --git a/ui/apps/console/src/components/common/fields/rhf/index.ts b/ui/apps/console/src/components/common/fields/rhf/index.ts new file mode 100644 index 00000000000..e1efe3c49e8 --- /dev/null +++ b/ui/apps/console/src/components/common/fields/rhf/index.ts @@ -0,0 +1,3 @@ +export { default as FormInputField } from "./FormInputField"; +export { default as FormPasswordField } from "./FormPasswordField"; +export { default as FormCheckboxField } from "./FormCheckboxField"; diff --git a/ui/apps/console/src/pages/ForgotPassword.tsx b/ui/apps/console/src/pages/ForgotPassword.tsx index 6d130a4d450..6340aea7546 100644 --- a/ui/apps/console/src/pages/ForgotPassword.tsx +++ b/ui/apps/console/src/pages/ForgotPassword.tsx @@ -1,5 +1,6 @@ -import { useState, FormEvent } from "react"; +import { useState } from "react"; import { Link } from "react-router-dom"; +import { useForm } from "react-hook-form"; import { EnvelopeIcon, CheckCircleIcon, @@ -7,20 +8,27 @@ import { } from "@heroicons/react/24/outline"; import { Button } from "@shellhub/design-system/primitives"; import { recoverPassword } from "../client"; -import InputField from "@/components/common/fields/InputField"; +import FormInputField from "@/components/common/fields/rhf/FormInputField"; +import { + forgotPasswordResolver, + type ForgotPasswordFormValues, +} from "./setup/forgotPasswordResolver"; export default function ForgotPassword() { - const [account, setAccount] = useState(""); const [loading, setLoading] = useState(false); const [sent, setSent] = useState(false); - const handleSubmit = async (e: FormEvent) => { - e.preventDefault(); - if (loading) return; + const { control, handleSubmit, formState } = useForm({ + resolver: forgotPasswordResolver, + mode: "onTouched", + defaultValues: { account: "" }, + }); + + const onSubmit = async (values: ForgotPasswordFormValues) => { setLoading(true); try { await recoverPassword({ - body: { username: account.trim() }, + body: { username: values.account }, throwOnError: true, }); } catch { @@ -83,15 +91,14 @@ export default function ForgotPassword() { ) : ( -
void handleSubmit(e)} className="space-y-5"> - void handleSubmit(onSubmit)(e)} className="space-y-5"> + + name="account" + control={control} id="account" label="Username or email address" - value={account} - onChange={setAccount} placeholder="username or email" autoComplete="username" - required /> @@ -102,7 +109,7 @@ export default function ForgotPassword() { type="submit" className="px-4" loading={loading} - disabled={loading || !account.trim()} + disabled={loading || !formState.isValid} icon={} > {loading ? "Sending..." : "Reset Password"} diff --git a/ui/apps/console/src/pages/Login.tsx b/ui/apps/console/src/pages/Login.tsx index 2a89c458558..fe1485546f9 100644 --- a/ui/apps/console/src/pages/Login.tsx +++ b/ui/apps/console/src/pages/Login.tsx @@ -1,4 +1,5 @@ import { useState, useEffect, FormEvent } from "react"; +import { useForm } from "react-hook-form"; import { isSdkError } from "../api/errors"; import { useNavigate, @@ -16,8 +17,12 @@ import { getConfig } from "../env"; import { getSafeRedirect } from "../utils/navigation"; import AuthFooterLinks from "../components/common/AuthFooterLinks"; import { getInfo, getSamlAuthUrl } from "../client"; -import InputField from "@/components/common/fields/InputField"; -import PasswordField from "@/components/common/fields/PasswordField"; +import { + FormInputField, + FormPasswordField, +} from "@/components/common/fields/rhf"; +import { loginResolver } from "./setup/loginResolver"; +import type { LoginFormValues } from "./setup/loginResolver"; interface CountdownState { display: string; @@ -95,8 +100,6 @@ export default function Login() { .catch(() => setAuthentication(null)); }, []); - const [username, setUsername] = useState(""); - const [password, setPassword] = useState(""); const [error, setError] = useState(null); const [lockoutEndEpoch, setLockoutEndEpoch] = useState(null); const { login, loading } = useAuthStore(); @@ -104,9 +107,11 @@ export default function Login() { const { display: countdownDisplay, expired: lockoutExpired } = useLoginCountdown(lockoutEndEpoch); - const trimmedUsername = username.trim(); - - const disableSubmit = trimmedUsername === "" || password === "" || loading; + const { control, handleSubmit, formState } = useForm({ + resolver: loginResolver, + mode: "onTouched", + defaultValues: { username: "", password: "" }, + }); useEffect(() => { if (!queryToken) return; @@ -133,12 +138,11 @@ export default function Login() { } }; - const handleSubmit = async (e: FormEvent) => { - e.preventDefault(); + const onSubmit = async (values: LoginFormValues) => { setError(null); setLockoutEndEpoch(null); try { - await login(trimmedUsername, password); + await login(values.username, values.password); const state = useAuthStore.getState(); const params = new URLSearchParams(location.search); @@ -167,7 +171,7 @@ export default function Login() { break; case 403: void navigate( - `/confirm-account?username=${encodeURIComponent(trimmedUsername)}`, + `/confirm-account?username=${encodeURIComponent(values.username)}`, ); break; case 429: { @@ -182,7 +186,10 @@ export default function Login() { setError("Something went wrong on our end. Please try again later."); } } - // Else: error is already set in store + }; + + const handleFormSubmit = (e: FormEvent) => { + void handleSubmit(onSubmit)(e); }; // On enterprise, show the local form only once we know local auth is enabled. @@ -261,25 +268,23 @@ export default function Login() { className="w-full max-w-sm bg-card/80 border border-border rounded-2xl p-8 backdrop-blur-sm animate-slide-up" style={{ animationDelay: "200ms" }} > - void handleSubmit(e)} className="space-y-5"> - + id="username" label="Username" - value={username} - onChange={setUsername} + name="username" + control={control} placeholder="username" autoComplete="username" - required /> - id="password" label="Password" - value={password} - onChange={setPassword} + name="password" + control={control} placeholder="password" autoComplete="current-password" - required /> {isCloud && ( @@ -300,7 +305,7 @@ export default function Login() { type="submit" className="px-4" loading={loading} - disabled={disableSubmit} + disabled={!formState.isValid || loading} > {loading ? "Authenticating..." : "Sign In"} diff --git a/ui/apps/console/src/pages/MfaRecover.tsx b/ui/apps/console/src/pages/MfaRecover.tsx index 576438cd640..1066eb84076 100644 --- a/ui/apps/console/src/pages/MfaRecover.tsx +++ b/ui/apps/console/src/pages/MfaRecover.tsx @@ -1,11 +1,14 @@ import { useState, FormEvent, useEffect } from "react"; import { useNavigate, Link } from "react-router-dom"; import { KeyIcon } from "@heroicons/react/24/outline"; +import { useForm } from "react-hook-form"; import { Button, Callout } from "@shellhub/design-system/primitives"; import { useAuthStore } from "../stores/authStore"; import { disableMfa } from "../client"; import MfaRecoveryTimeoutModal from "../components/mfa/MfaRecoveryTimeoutModal"; import AuthFooterLinks from "../components/common/AuthFooterLinks"; +import { mfaRecoverResolver } from "./setup/mfaRecoverResolver"; +import type { MfaRecoverFormValues } from "./setup/mfaRecoverResolver"; export default function MfaRecover() { const { @@ -21,43 +24,45 @@ export default function MfaRecover() { const navigate = useNavigate(); const identifier = user || username; - const [recoveryCode, setRecoveryCode] = useState(""); const [showTimeoutModal, setShowTimeoutModal] = useState(false); - // Clear stale error from previous session + const { register, handleSubmit, getValues, resetField, formState } = + useForm({ + resolver: mfaRecoverResolver, + mode: "onTouched", + defaultValues: { recoveryCode: "" }, + }); + useEffect(() => { useAuthStore.setState({ error: null }); }, []); - // Redirect to login if no identifier available (but only if not in active MFA session) useEffect(() => { if (!identifier && !mfaToken) { void navigate("/login"); } }, [identifier, mfaToken, navigate]); - // Don't render if we don't have an identifier if (!identifier) { return null; } - const handleSubmit = async (e: FormEvent) => { - e.preventDefault(); - if (!recoveryCode.trim()) return; - + const onSubmit = async (values: MfaRecoverFormValues) => { try { - await recoverWithCode(recoveryCode, identifier); + await recoverWithCode(values.recoveryCode, identifier); setShowTimeoutModal(true); } catch { - // Error is set in store - setRecoveryCode(""); + resetField("recoveryCode"); } }; + const handleFormSubmit = (e: FormEvent) => { + void handleSubmit(onSubmit)(e); + }; + const handleDisableMfa = async () => { - // Use the recovery code that was just entered await disableMfa({ - body: { recovery_code: recoveryCode }, + body: { recovery_code: getValues("recoveryCode").trim() }, throwOnError: true, }); updateMfaStatus(false); @@ -101,7 +106,7 @@ export default function MfaRecover() { className="w-full max-w-sm bg-card/80 border border-border rounded-2xl p-8 backdrop-blur-sm animate-slide-up" style={{ animationDelay: "200ms" }} > - void handleSubmit(e)} className="space-y-5"> + {error && {error}}
@@ -114,9 +119,7 @@ export default function MfaRecover() { setRecoveryCode(e.target.value)} - required + {...register("recoveryCode")} className="w-full px-4 py-3 bg-background border border-border rounded-lg text-sm text-text-primary font-mono placeholder:text-text-secondary focus:outline-none focus:border-accent-yellow/50 focus:ring-1 focus:ring-accent-yellow/20 transition-all duration-200" placeholder="Enter recovery code" /> @@ -130,7 +133,7 @@ export default function MfaRecover() { fullWidth type="submit" loading={loading} - disabled={loading || !recoveryCode.trim()} + disabled={loading || !formState.isValid} > {loading ? "Recovering..." : "Recover Account"} diff --git a/ui/apps/console/src/pages/MfaResetRequest.tsx b/ui/apps/console/src/pages/MfaResetRequest.tsx index 1824013de00..cf2ebc338d3 100644 --- a/ui/apps/console/src/pages/MfaResetRequest.tsx +++ b/ui/apps/console/src/pages/MfaResetRequest.tsx @@ -1,5 +1,6 @@ -import { FormEvent, useEffect, useState } from "react"; +import { useEffect, useState } from "react"; import { useNavigate, Link } from "react-router-dom"; +import { useForm } from "react-hook-form"; import { EnvelopeIcon, CheckCircleIcon } from "@heroicons/react/24/outline"; import { Button, Callout } from "@shellhub/design-system/primitives"; import { useAuthStore } from "../stores/authStore"; @@ -14,6 +15,8 @@ export default function MfaResetRequest() { const identifier = user || username; + const { handleSubmit } = useForm(); + // Clear stale error from previous session useEffect(() => { useMfaResetStore.setState({ error: null }); @@ -31,9 +34,7 @@ export default function MfaResetRequest() { return null; } - const handleSubmit = async (e: FormEvent) => { - e.preventDefault(); - + const onSubmit = async () => { try { await requestMfaReset(identifier); setEmailsSent(true); @@ -72,7 +73,7 @@ export default function MfaResetRequest() { style={{ animationDelay: "200ms" }} > {!emailsSent ? ( - void handleSubmit(e)} className="space-y-5"> + void handleSubmit(onSubmit)(e)} className="space-y-5"> {error && {error}}
diff --git a/ui/apps/console/src/pages/Setup.tsx b/ui/apps/console/src/pages/Setup.tsx index f2036bf3414..aeff7c8de89 100644 --- a/ui/apps/console/src/pages/Setup.tsx +++ b/ui/apps/console/src/pages/Setup.tsx @@ -1,12 +1,15 @@ import { useState, useEffect, useCallback, FormEvent } from "react"; +import { useForm } from "react-hook-form"; import { isSdkError } from "../api/errors"; import { useNavigate } from "react-router-dom"; import { CheckIcon, ExclamationCircleIcon } from "@heroicons/react/24/outline"; import { setup } from "../client"; import { getConfig } from "../env"; -import { validate, type FormErrors } from "./setup/validate"; -import InputField from "@/components/common/fields/InputField"; -import PasswordField from "@/components/common/fields/PasswordField"; +import { setupResolver, type SetupFormValues } from "./setup/setupResolver"; +import { + FormInputField, + FormPasswordField, +} from "@/components/common/fields/rhf"; import { Button, ShellHubLogo } from "@shellhub/design-system/primitives"; const STEP_ONBOARDING = 1; @@ -22,20 +25,24 @@ export default function Setup() { const [step, setStep] = useState( showOnboarding ? STEP_ONBOARDING : STEP_ACCOUNT, ); - const [name, setName] = useState(""); - const [username, setUsername] = useState(""); - const [email, setEmail] = useState(""); - const [password, setPassword] = useState(""); - const [confirmPassword, setConfirmPassword] = useState(""); - const [touched, setTouched] = useState>({}); const [loading, setLoading] = useState(false); const [error, setError] = useState(""); const [success, setSuccess] = useState(false); const [surveyCompleted, setSurveyCompleted] = useState(false); - const errors = validate({ name, username, email, password, confirmPassword }); + const { control, handleSubmit, formState } = useForm({ + resolver: setupResolver, + mode: "onTouched", + defaultValues: { + name: "", + username: "", + email: "", + password: "", + confirmPassword: "", + }, + }); - const disableCreateAccountButton = loading || Object.keys(errors).length > 0; + const disableCreateAccountButton = loading || !formState.isValid; const onboardingUrl = (() => { if (!config.onboardingUrl) return ""; @@ -81,32 +88,18 @@ export default function Setup() { } }, [success, navigate]); - const handleBlur = (field: string) => { - setTouched((prev) => ({ ...prev, [field]: true })); - }; - - const showError = (field: keyof FormErrors) => - touched[field] ? errors[field] : undefined; - - const handleSubmit = async (e: FormEvent) => { - e.preventDefault(); - - setTouched({ - name: true, - username: true, - email: true, - password: true, - confirmPassword: true, - }); - - if (Object.keys(errors).length > 0) return; - + const onSubmit = async (values: SetupFormValues) => { setLoading(true); setError(""); try { await setup({ - body: { name, username, email, password }, + body: { + name: values.name, + username: values.username, + email: values.email, + password: values.password, + }, throwOnError: true, }); setSuccess(true); @@ -121,6 +114,10 @@ export default function Setup() { } }; + const handleFormSubmit = (e: FormEvent) => { + void handleSubmit(onSubmit)(e); + }; + const totalSteps = showOnboarding ? 2 : 1; const displayStep = step === STEP_ONBOARDING ? 1 : totalSteps; @@ -217,61 +214,51 @@ export default function Setup() { )} {step === STEP_ACCOUNT && ( - void handleSubmit(e)} className="space-y-4"> +

Set up your admin account with your personal information.

- id="name" label="Name" - value={name} - onChange={setName} - onBlur={() => handleBlur("name")} - error={showError("name")} + name="name" + control={control} placeholder="Your name" maxLength={64} /> - id="username" label="Username" - value={username} - onChange={setUsername} - onBlur={() => handleBlur("username")} - error={showError("username")} + name="username" + control={control} placeholder="username" maxLength={32} /> - id="email" label="Email" + name="email" + control={control} type="email" - value={email} - onChange={setEmail} - onBlur={() => handleBlur("email")} - error={showError("email")} placeholder="you@example.com" /> - id="password" label="Password" - value={password} - onChange={setPassword} - onBlur={() => handleBlur("password")} - error={showError("password")} + name="password" + control={control} placeholder="Min. 5 characters" /> - id="confirmPassword" label="Confirm Password" - value={confirmPassword} - onChange={setConfirmPassword} - onBlur={() => handleBlur("confirmPassword")} - error={showError("confirmPassword")} + name="confirmPassword" + control={control} placeholder="Re-enter password" /> diff --git a/ui/apps/console/src/pages/SignUp.tsx b/ui/apps/console/src/pages/SignUp.tsx index 5988087efc6..20690f3c886 100644 --- a/ui/apps/console/src/pages/SignUp.tsx +++ b/ui/apps/console/src/pages/SignUp.tsx @@ -1,15 +1,20 @@ -import { useState, useMemo, FormEvent, useEffect } from "react"; +import { useState, useEffect, FormEvent } from "react"; import { Link, useNavigate, useSearchParams } from "react-router-dom"; import { UserPlusIcon } from "@heroicons/react/24/outline"; -import { validate, type FormErrors } from "./setup/validate"; +import { useForm } from "react-hook-form"; +import { signUpResolver } from "./setup/signUpResolver"; +import type { SignUpFormValues } from "./setup/signUpResolver"; import { useSignUpStore } from "../stores/signUpStore"; import AccountCreated from "../components/auth/AccountCreated"; import { Button, Callout } from "@shellhub/design-system/primitives"; -import InputField from "@/components/common/fields/InputField"; -import PasswordField from "@/components/common/fields/PasswordField"; +import { + FormInputField, + FormPasswordField, + FormCheckboxField, +} from "@/components/common/fields/rhf"; import CheckboxField from "@/components/common/fields/CheckboxField"; -const SERVER_FIELD_MAP: Record = { +const SERVER_FIELD_MAP: Record = { username: "username", email: "email", name: "name", @@ -43,80 +48,47 @@ export default function SignUp() { const sigFromQuery = searchParams.get("sig") ?? ""; const isInvite = Boolean(emailFromQuery && sigFromQuery); - const [name, setName] = useState(""); - const [username, setUsername] = useState(""); - const [email, setEmail] = useState(emailFromQuery); - const [password, setPassword] = useState(""); - const [confirmPassword, setConfirmPassword] = useState(""); - const [acceptPrivacyPolicy, setAcceptPrivacyPolicy] = useState(false); + // acceptMarketing is optional and not part of the validated form values const [acceptMarketing, setAcceptMarketing] = useState(false); - const [touched, setTouched] = useState>({}); const [accountCreated, setAccountCreated] = useState(false); - const serverFieldErrors = useMemo(() => { - const mapped: Partial = {}; + const { control, handleSubmit, setError, formState } = + useForm({ + resolver: signUpResolver, + mode: "onTouched", + defaultValues: { + name: "", + username: "", + email: emailFromQuery, + password: "", + confirmPassword: "", + acceptPrivacyPolicy: false, + }, + }); + + // Sync server-side field errors into RHF field state whenever the store changes + useEffect(() => { for (const field of signUpServerFields) { const key = SERVER_FIELD_MAP[field]; - if (key) mapped[key] = SERVER_FIELD_MESSAGES[field]; - } - return mapped; - }, [signUpServerFields]); - - const validationErrors = useMemo( - () => validate({ name, username, email, password, confirmPassword }), - [name, username, email, password, confirmPassword], - ); - - const fieldError = (field: keyof FormErrors): string | undefined => { - if (serverFieldErrors[field]) return serverFieldErrors[field]; - return touched[field] ? validationErrors[field] : undefined; - }; - - const handleBlur = (field: string) => - setTouched((prev) => ({ ...prev, [field]: true })); - - const isFormValid = useMemo( - () => - Object.keys(validationErrors).length === 0 && - !Object.values(serverFieldErrors).some(Boolean) && - acceptPrivacyPolicy, - [validationErrors, serverFieldErrors, acceptPrivacyPolicy], - ); + const message = SERVER_FIELD_MESSAGES[field]; - const handleSubmit = async (e: FormEvent) => { - e.preventDefault(); - - setTouched({ - name: true, - username: true, - email: true, - password: true, - confirmPassword: true, - }); + if (key && message) { + setError(key, { type: "server", message }); + } + } + }, [signUpServerFields, setError]); - // Compute validity from source of truth rather than the memoized `isFormValid`, - // which reflects the previous render and would be stale after `setTouched`. - const errors = validate({ - name, - username, - email, - password, - confirmPassword, - }); - if ( - Object.keys(errors).length > 0 || - !acceptPrivacyPolicy || - signUpServerFields.length > 0 - ) - return; + const onSubmit = async (values: SignUpFormValues) => { + // Guard: still block if server field errors haven't been cleared yet + if (signUpServerFields.length > 0) return; resetSignUpErrors(); const token = await signUp({ - name, - email, - username, - password, + name: values.name, + email: values.email, + username: values.username, + password: values.password, email_marketing: acceptMarketing, ...(sigFromQuery ? { sig: sigFromQuery } : {}), }); @@ -124,11 +96,12 @@ export default function SignUp() { // signUp absorbed any errors into the store; bail out if errors were set const { signUpError: err, signUpServerFields: fields } = useSignUpStore.getState(); + if (err !== null || fields.length > 0) return; if (!token) { void navigate( - `/confirm-account?username=${encodeURIComponent(username)}`, + `/confirm-account?username=${encodeURIComponent(values.username)}`, ); return; } @@ -137,6 +110,10 @@ export default function SignUp() { setAccountCreated(true); }; + const handleFormSubmit = (e: FormEvent) => { + void handleSubmit(onSubmit)(e); + }; + if (accountCreated) { return (
@@ -190,82 +167,64 @@ export default function SignUp() { )} void handleSubmit(e)} + onSubmit={handleFormSubmit} className="space-y-4" aria-label="Create account" > - id="name" label="Name" - value={name} - onChange={(v) => { - setName(v); - clearSignUpServerField("name"); - }} - onBlur={() => handleBlur("name")} - error={fieldError("name")} + name="name" + control={control} placeholder="Your name" autoComplete="name" + onValueChange={() => clearSignUpServerField("name")} /> - id="username" label="Username" - value={username} - onChange={(v) => { - setUsername(v); - clearSignUpServerField("username"); - }} - onBlur={() => handleBlur("username")} - error={fieldError("username")} + name="username" + control={control} placeholder="username" autoComplete="username" + onValueChange={() => clearSignUpServerField("username")} /> - id="email" label="Email" + name="email" + control={control} type="email" - value={email} - onChange={(v) => { - setEmail(v); - clearSignUpServerField("email"); - }} - onBlur={() => handleBlur("email")} - error={fieldError("email")} placeholder="you@example.com" autoComplete="email" disabled={isInvite} + onValueChange={() => clearSignUpServerField("email")} /> - id="password" label="Password" - value={password} - onChange={(v) => { - setPassword(v); - clearSignUpServerField("password"); - }} - onBlur={() => handleBlur("password")} - error={fieldError("password")} + name="password" + control={control} placeholder="Min. 5 characters" + onValueChange={() => clearSignUpServerField("password")} /> - id="confirmPassword" label="Confirm Password" - value={confirmPassword} - onChange={setConfirmPassword} - onBlur={() => handleBlur("confirmPassword")} - error={fieldError("confirmPassword")} + name="confirmPassword" + control={control} placeholder="Re-enter password" /> {/* Privacy Policy checkbox (required) */} - id="signup-accept-privacy" - checked={acceptPrivacyPolicy} - onChange={setAcceptPrivacyPolicy} + name="acceptPrivacyPolicy" + control={control} required label={ <> @@ -282,7 +241,7 @@ export default function SignUp() { } /> - {/* Marketing checkbox (optional) */} + {/* Marketing checkbox (optional) — not validated, kept as local state */} 0 + } > {signUpLoading ? "Creating account..." : "Create Account"} diff --git a/ui/apps/console/src/pages/UpdatePassword.tsx b/ui/apps/console/src/pages/UpdatePassword.tsx index d62692dace8..9b67de46ed3 100644 --- a/ui/apps/console/src/pages/UpdatePassword.tsx +++ b/ui/apps/console/src/pages/UpdatePassword.tsx @@ -4,10 +4,12 @@ import { ExclamationCircleIcon, LockClosedIcon, } from "@heroicons/react/24/outline"; +import { useForm } from "react-hook-form"; import { Button } from "@shellhub/design-system/primitives"; -import { updateRecoverPassword } from "../client"; -import { validatePassword } from "../utils/validation"; -import PasswordField from "@/components/common/fields/PasswordField"; +import { updateRecoverPassword } from "@/client"; +import { updatePasswordResolver } from "./setup/updatePasswordResolver"; +import type { UpdatePasswordFormValues } from "./setup/updatePasswordResolver"; +import { FormPasswordField } from "@/components/common/fields/rhf"; export default function UpdatePassword() { const [searchParams] = useSearchParams(); @@ -16,28 +18,24 @@ export default function UpdatePassword() { const uid = searchParams.get("id") ?? ""; const token = searchParams.get("token") ?? ""; - const [password, setPassword] = useState(""); - const [confirm, setConfirm] = useState(""); - const [touched, setTouched] = useState>({}); - const [loading, setLoading] = useState(false); const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); - const rawPasswordError = validatePassword(password); - const passwordError = touched.password ? rawPasswordError : null; - const confirmError = - touched.confirm && password !== confirm ? "Passwords do not match" : null; - - const isValid = !rawPasswordError && password === confirm; + const { control, handleSubmit, formState } = useForm( + { + resolver: updatePasswordResolver, + mode: "onTouched", + defaultValues: { password: "", confirmPassword: "" }, + }, + ); - const handleSubmit = async (e: FormEvent) => { - e.preventDefault(); - if (!isValid || loading) return; + const onSubmit = async (values: UpdatePasswordFormValues) => { setError(""); setLoading(true); try { await updateRecoverPassword({ path: { uid }, - body: { token, password }, + body: { token, password: values.password }, throwOnError: true, }); void navigate("/login", { @@ -52,6 +50,10 @@ export default function UpdatePassword() { } }; + const handleFormSubmit = (e: FormEvent) => { + void handleSubmit(onSubmit)(e); + }; + if (!uid || !token) { return (
@@ -106,7 +108,7 @@ export default function UpdatePassword() { className="w-full max-w-sm bg-card/80 border border-border rounded-2xl p-8 backdrop-blur-sm animate-slide-up" style={{ animationDelay: "200ms" }} > - void handleSubmit(e)} className="space-y-5"> + {error && (
)} - id="password" label="New Password" - value={password} - onChange={setPassword} - onBlur={() => setTouched((prev) => ({ ...prev, password: true }))} + name="password" + control={control} placeholder="••••••••" - error={passwordError ?? undefined} hint="5–32 characters" - required /> - + id="confirmPassword" label="Confirm Password" - value={confirm} - onChange={setConfirm} - onBlur={() => setTouched((prev) => ({ ...prev, confirm: true }))} + name="confirmPassword" + control={control} placeholder="••••••••" - error={confirmError ?? undefined} required /> @@ -151,7 +148,7 @@ export default function UpdatePassword() { type="submit" className="px-4" loading={loading} - disabled={loading || !isValid} + disabled={loading || !formState.isValid} > {loading ? "Updating..." : "Update Password"} diff --git a/ui/apps/console/src/pages/__tests__/ForgotPassword.test.tsx b/ui/apps/console/src/pages/__tests__/ForgotPassword.test.tsx new file mode 100644 index 00000000000..402f16f801a --- /dev/null +++ b/ui/apps/console/src/pages/__tests__/ForgotPassword.test.tsx @@ -0,0 +1,128 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, cleanup, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { MemoryRouter } from "react-router-dom"; +import ForgotPassword from "../ForgotPassword"; + +vi.mock("@/client", () => ({ + recoverPassword: vi.fn(), +})); + +import { recoverPassword as recoverPasswordSdk } from "@/client"; + +const mockedRecoverPassword = vi.mocked(recoverPasswordSdk); + +type SdkResponse = { data: T; request: Request; response: Response }; + +function mockSdkResponse(data: T): SdkResponse { + return { + data, + request: new Request("http://localhost"), + response: new Response(), + }; +} + +function renderForgotPassword() { + return render( + + + , + ); +} + +afterEach(cleanup); + +beforeEach(() => { + mockedRecoverPassword.mockReset(); +}); + +describe("ForgotPassword", () => { + describe("initial state", () => { + it("does not show an account error before the field is touched", () => { + renderForgotPassword(); + expect(screen.queryByText(/enter a valid username or email/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/is required/i)).not.toBeInTheDocument(); + }); + + it("disables the submit button when the form is empty", () => { + renderForgotPassword(); + expect(screen.getByRole("button", { name: /reset password/i })).toBeDisabled(); + }); + }); + + describe("field validation", () => { + it("shows an error after blurring with an invalid account value", async () => { + const user = userEvent.setup(); + renderForgotPassword(); + + const input = screen.getByLabelText(/username or email/i); + await user.type(input, "!!"); + await user.tab(); + + expect(await screen.findByText(/enter a valid username or email/i)).toBeInTheDocument(); + }); + + it("keeps the submit button disabled when the account is invalid", async () => { + const user = userEvent.setup(); + renderForgotPassword(); + + await user.type(screen.getByLabelText(/username or email/i), "!!"); + await user.tab(); + + await screen.findByText(/enter a valid username or email/i); + expect(screen.getByRole("button", { name: /reset password/i })).toBeDisabled(); + }); + }); + + describe("valid submission", () => { + it("enables the submit button once a valid account is entered", async () => { + const user = userEvent.setup(); + renderForgotPassword(); + + await user.type(screen.getByLabelText(/username or email/i), "alice"); + + expect(screen.getByRole("button", { name: /reset password/i })).toBeEnabled(); + }); + + it("calls recoverPassword with the trimmed username on valid submit", async () => { + mockedRecoverPassword.mockResolvedValue(mockSdkResponse(undefined)); + const user = userEvent.setup(); + renderForgotPassword(); + + await user.type(screen.getByLabelText(/username or email/i), " alice "); + await user.click(screen.getByRole("button", { name: /reset password/i })); + + await waitFor(() => expect(mockedRecoverPassword).toHaveBeenCalledTimes(1)); + expect(mockedRecoverPassword).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ username: "alice" }), + throwOnError: true, + }), + ); + }); + + it("shows the sent view after a successful submission", async () => { + mockedRecoverPassword.mockResolvedValue(mockSdkResponse(undefined)); + const user = userEvent.setup(); + renderForgotPassword(); + + await user.type(screen.getByLabelText(/username or email/i), "alice"); + await user.click(screen.getByRole("button", { name: /reset password/i })); + + expect(await screen.findByRole("alert")).toBeInTheDocument(); + expect(screen.getByText(/check your inbox/i)).toBeInTheDocument(); + }); + + it("shows the sent view even when the API call fails (anti-enumeration)", async () => { + mockedRecoverPassword.mockRejectedValue(new Error("Not Found")); + const user = userEvent.setup(); + renderForgotPassword(); + + await user.type(screen.getByLabelText(/username or email/i), "alice"); + await user.click(screen.getByRole("button", { name: /reset password/i })); + + expect(await screen.findByRole("alert")).toBeInTheDocument(); + expect(screen.getByText(/check your inbox/i)).toBeInTheDocument(); + }); + }); +}); diff --git a/ui/apps/console/src/pages/__tests__/Login.test.tsx b/ui/apps/console/src/pages/__tests__/Login.test.tsx index 5e1841bb387..1a37d435ba5 100644 --- a/ui/apps/console/src/pages/__tests__/Login.test.tsx +++ b/ui/apps/console/src/pages/__tests__/Login.test.tsx @@ -6,10 +6,6 @@ import { useAuthStore } from "@/stores/authStore"; import type { UserAuth, Info } from "@/client"; import Login from "../Login"; -/* ------------------------------------------------------------------ */ -/* Mocks */ -/* ------------------------------------------------------------------ */ - const mockNavigate = vi.hoisted(() => vi.fn()); vi.mock("react-router-dom", async (importOriginal) => { @@ -93,7 +89,6 @@ function mockSdkResponse( }; } -/** Creates a mock SDK error with status and optional headers. */ function makeSdkError(status: number, headers?: Record) { const headerObj = new Headers(headers); return Object.assign(new Error("Request failed"), { @@ -110,31 +105,29 @@ function renderLogin() { ); } +// With RHF onTouched mode, fields must be blurred before validation runs. +// user.tab() after typing each field triggers the blur that enables the button. async function fillAndSubmit( username = "admin", password = "secret", user = userEvent.setup(), ) { await user.type(screen.getByLabelText(/username/i), username); + await user.tab(); await user.type(screen.getByLabelText(/^password$/i), password); + await user.tab(); await user.click(screen.getByRole("button", { name: /sign in/i })); } -/* ------------------------------------------------------------------ */ -/* Setup / teardown */ -/* ------------------------------------------------------------------ */ - afterEach(cleanup); beforeEach(() => { mockNavigate.mockReset(); mockedLogin.mockReset(); mockedGetSamlAuthUrl.mockReset(); - // Default: local=true, saml=false — community/non-enterprise baseline. mockedGetInfo.mockResolvedValue( mockSdkResponse(mockInfo({ authentication: { local: true, saml: false } })), ); - // Default: community edition (no enterprise/cloud flags). mockedGetConfig.mockReturnValue({ ...defaultConfig }); useAuthStore.setState({ token: null, @@ -154,10 +147,6 @@ afterEach(() => { vi.useRealTimers(); }); -/* ================================================================== */ -/* Tests */ -/* ================================================================== */ - describe("Login", () => { describe("form rendering", () => { it("renders username and password fields with a submit button", () => { @@ -196,6 +185,24 @@ describe("Login", () => { ); }); + // RHF onTouched: blurring an invalid field surfaces a per-field error + // message inline. The old useState implementation never showed field errors. + it("shows a field error on the username field after blur when empty", async () => { + const user = userEvent.setup(); + renderLogin(); + + await user.type(screen.getByLabelText(/username/i), "admin"); + await user.clear(screen.getByLabelText(/username/i)); + await user.tab(); + + await waitFor(() => + expect(screen.getByText(/is required/i)).toBeInTheDocument(), + ); + }); + + // With RHF onTouched, validation runs after blur. The button is disabled + // until both fields have been touched and are valid. Tab away from each + // field to trigger blur-time validation before asserting enabled state. it("disables the submit button when username or password is empty", async () => { const user = userEvent.setup(); renderLogin(); @@ -204,13 +211,16 @@ describe("Login", () => { expect(submitButton).toBeDisabled(); await user.type(screen.getByLabelText(/username/i), "admin"); + await user.tab(); expect(submitButton).toBeDisabled(); await user.type(screen.getByLabelText(/^password$/i), "secret"); - expect(submitButton).toBeEnabled(); + await user.tab(); + await waitFor(() => expect(submitButton).toBeEnabled()); await user.clear(screen.getByLabelText(/username/i)); - expect(submitButton).toBeDisabled(); + await user.tab(); + await waitFor(() => expect(submitButton).toBeDisabled()); }); }); @@ -238,7 +248,9 @@ describe("Login", () => { renderLogin(); await userEvent.type(screen.getByLabelText(/username/i), "admin"); + await userEvent.tab(); await userEvent.type(screen.getByLabelText(/^password$/i), "secret"); + await userEvent.tab(); const clickPromise = userEvent.click( screen.getByRole("button", { name: /sign in/i }), @@ -265,7 +277,9 @@ describe("Login", () => { renderLogin(); await userEvent.type(screen.getByLabelText(/username/i), "admin"); + await userEvent.tab(); await userEvent.type(screen.getByLabelText(/^password$/i), "secret"); + await userEvent.tab(); const clickPromise = userEvent.click( screen.getByRole("button", { name: /sign in/i }), @@ -276,7 +290,6 @@ describe("Login", () => { ); // DS Button sets aria-busy="true" on the button element when loading=true. - // Plain
} /> + + , + ); +} + +beforeEach(() => { + useAuthStore.setState({ user: "admin", username: null, mfaToken: "tok" }); + useMfaResetStore.setState({ + loading: false, + error: null, + requestMfaReset: vi.fn().mockResolvedValue(undefined), + }); +}); + +describe("MfaResetRequest", () => { + it("calls requestMfaReset with the identifier when the form is submitted", async () => { + const mockRequest = vi.fn().mockResolvedValue(undefined); + useMfaResetStore.setState({ requestMfaReset: mockRequest }); + + renderPage(); + + const user = userEvent.setup(); + await user.click(screen.getByRole("button", { name: /send verification codes/i })); + + await waitFor(() => { + expect(mockRequest).toHaveBeenCalledWith("admin"); + }); + }); + + it("shows the email-sent success view after a successful submit", async () => { + renderPage(); + + const user = userEvent.setup(); + await user.click(screen.getByRole("button", { name: /send verification codes/i })); + + await waitFor(() => { + expect(screen.getByText(/Emails Sent!/i)).toBeInTheDocument(); + }); + }); + + it("shows the store error in a Callout when the request fails", async () => { + const mockRequest = vi.fn().mockImplementation(async () => { + useMfaResetStore.setState({ error: "Unable to send reset emails. Please check your identifier." }); + throw new Error("Reset request failed"); + }); + useMfaResetStore.setState({ requestMfaReset: mockRequest, error: null }); + + renderPage(); + + const user = userEvent.setup(); + await user.click(screen.getByRole("button", { name: /send verification codes/i })); + + await waitFor(() => { + expect(screen.getByText(/Unable to send reset emails/i)).toBeInTheDocument(); + }); + }); + + it("redirects to login when there is no identifier and no mfaToken", async () => { + useAuthStore.setState({ user: null, username: null, mfaToken: null }); + + renderPage(); + + await waitFor(() => { + expect(screen.getByText("Login Page")).toBeInTheDocument(); + }); + }); + + it("renders nothing when there is no identifier but an active mfaToken", () => { + useAuthStore.setState({ user: null, username: null, mfaToken: "active-tok" }); + + const { container } = renderPage(); + + expect(container.firstChild).toBeNull(); + }); +}); diff --git a/ui/apps/console/src/pages/__tests__/Setup.test.tsx b/ui/apps/console/src/pages/__tests__/Setup.test.tsx new file mode 100644 index 00000000000..efcf7c31c3c --- /dev/null +++ b/ui/apps/console/src/pages/__tests__/Setup.test.tsx @@ -0,0 +1,334 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, cleanup, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { MemoryRouter } from "react-router-dom"; +import Setup from "../Setup"; + +const mockNavigate = vi.hoisted(() => vi.fn()); + +vi.mock("react-router-dom", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, useNavigate: () => mockNavigate }; +}); + +vi.mock("@/client", () => ({ + setup: vi.fn(), +})); + +vi.mock("@/env", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, getConfig: vi.fn(() => actual.getConfig()) }; +}); + +import { setup as setupSdk } from "@/client"; +import { getConfig, defaultConfig } from "@/env"; + +const mockedSetup = vi.mocked(setupSdk); +const mockedGetConfig = vi.mocked(getConfig); + +type SdkResponse = { + data: T; + request: Request; + response: Response; +}; + +function mockSdkResponse(data: T): SdkResponse { + return { + data, + request: new Request("http://localhost"), + response: new Response(), + }; +} + +function makeSdkError(status: number) { + return Object.assign(new Error("Request failed"), { status }); +} + +function renderSetup() { + return render( + + + , + ); +} + +/** + * Fill all account fields with valid values. Does NOT submit. + */ +async function fillValidForm(user: ReturnType) { + await user.type(screen.getByLabelText(/^name$/i), "Alice Smith"); + await user.type(screen.getByLabelText(/^username$/i), "alice"); + await user.type(screen.getByLabelText(/^email$/i), "alice@example.com"); + await user.type(screen.getByLabelText(/^password$/i), "Secret123"); + await user.type(screen.getByLabelText(/^confirm password$/i), "Secret123"); +} + +afterEach(cleanup); + +beforeEach(() => { + mockNavigate.mockReset(); + mockedSetup.mockReset(); + mockedGetConfig.mockReturnValue({ ...defaultConfig }); +}); + +describe("Setup", () => { + describe("initial render", () => { + it("renders all account form fields and the submit button", () => { + renderSetup(); + expect(screen.getByLabelText(/^name$/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/^username$/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/^email$/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/^password$/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/^confirm password$/i)).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /create account/i }), + ).toBeInTheDocument(); + }); + + it("submit button is disabled when all fields are empty", () => { + renderSetup(); + expect( + screen.getByRole("button", { name: /create account/i }), + ).toBeDisabled(); + }); + }); + + describe("field validation — errors only after blur (onTouched mode)", () => { + it("does not show name error before the field is touched", () => { + renderSetup(); + expect(screen.queryByText(/name must be/i)).not.toBeInTheDocument(); + }); + + it("shows name error after blurring an empty name field", async () => { + const user = userEvent.setup(); + renderSetup(); + + await user.click(screen.getByLabelText(/^name$/i)); + await user.tab(); + + expect(await screen.findByText(/name must be/i)).toBeInTheDocument(); + }); + + it("does not show username error before the field is touched", () => { + renderSetup(); + expect(screen.queryByText(/username must be/i)).not.toBeInTheDocument(); + }); + + it("shows username error after blurring with too-short value", async () => { + const user = userEvent.setup(); + renderSetup(); + + await user.type(screen.getByLabelText(/^username$/i), "ab"); + await user.tab(); + + expect(await screen.findByText(/username must be/i)).toBeInTheDocument(); + }); + + it("does not show email error before the field is touched", () => { + renderSetup(); + expect(screen.queryByText(/enter a valid email/i)).not.toBeInTheDocument(); + }); + + it("shows email error after blurring with an invalid address", async () => { + const user = userEvent.setup(); + renderSetup(); + + await user.type(screen.getByLabelText(/^email$/i), "not-an-email"); + await user.tab(); + + expect( + await screen.findByText(/enter a valid email/i), + ).toBeInTheDocument(); + }); + + it("does not show password error before the field is touched", () => { + renderSetup(); + expect(screen.queryByText(/password must be/i)).not.toBeInTheDocument(); + }); + + it("shows password error after blurring an empty password field", async () => { + const user = userEvent.setup(); + renderSetup(); + + await user.click(screen.getByLabelText(/^password$/i)); + await user.tab(); + + expect(await screen.findByText(/password must be/i)).toBeInTheDocument(); + }); + + it("shows 'Passwords do not match' after blurring confirm password with a mismatched value", async () => { + const user = userEvent.setup(); + renderSetup(); + + await user.type(screen.getByLabelText(/^password$/i), "Secret123"); + await user.type( + screen.getByLabelText(/^confirm password$/i), + "Different", + ); + await user.tab(); + + expect( + await screen.findByText(/passwords do not match/i), + ).toBeInTheDocument(); + }); + }); + + describe("submit gate", () => { + it("enables the submit button only when all fields are valid", async () => { + const user = userEvent.setup(); + renderSetup(); + + const submit = screen.getByRole("button", { name: /create account/i }); + expect(submit).toBeDisabled(); + + await fillValidForm(user); + + expect(submit).toBeEnabled(); + }); + }); + + describe("successful submission", () => { + it("calls setup() with the correct payload and shows the success screen", async () => { + mockedSetup.mockResolvedValue(mockSdkResponse({})); + const user = userEvent.setup(); + renderSetup(); + + await fillValidForm(user); + await user.click( + screen.getByRole("button", { name: /create account/i }), + ); + + await waitFor(() => expect(mockedSetup).toHaveBeenCalledTimes(1)); + expect(mockedSetup).toHaveBeenCalledWith( + expect.objectContaining({ + body: { + name: "Alice Smith", + username: "alice", + email: "alice@example.com", + password: "Secret123", + }, + throwOnError: true, + }), + ); + + expect( + await screen.findByText(/account created successfully/i), + ).toBeInTheDocument(); + }); + + it("redirects to /login after 3 seconds on success", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + + mockedSetup.mockResolvedValue(mockSdkResponse({})); + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + renderSetup(); + + await fillValidForm(user); + await user.click( + screen.getByRole("button", { name: /create account/i }), + ); + + await screen.findByText(/account created successfully/i); + + vi.advanceTimersByTime(3000); + + await waitFor(() => + expect(mockNavigate).toHaveBeenCalledWith("/login", { + replace: true, + }), + ); + + vi.useRealTimers(); + }); + }); + + describe("error handling", () => { + it("shows 'Setup has already been completed' on 409", async () => { + mockedSetup.mockRejectedValue(makeSdkError(409)); + const user = userEvent.setup(); + renderSetup(); + + await fillValidForm(user); + await user.click( + screen.getByRole("button", { name: /create account/i }), + ); + + expect( + await screen.findByText(/setup has already been completed/i), + ).toBeInTheDocument(); + }); + + it("shows a generic error on unexpected server errors", async () => { + mockedSetup.mockRejectedValue(makeSdkError(500)); + const user = userEvent.setup(); + renderSetup(); + + await fillValidForm(user); + await user.click( + screen.getByRole("button", { name: /create account/i }), + ); + + expect( + await screen.findByText(/an error occurred/i), + ).toBeInTheDocument(); + }); + }); + + describe("two-step onboarding flow (when onboardingUrl is set)", () => { + beforeEach(() => { + mockedGetConfig.mockReturnValue({ + ...defaultConfig, + onboardingUrl: "https://onboarding.example.com/survey", + }); + }); + + it("starts on the onboarding step and shows the survey iframe", () => { + renderSetup(); + expect(screen.getByTitle(/onboarding survey/i)).toBeInTheDocument(); + expect( + screen.queryByLabelText(/^name$/i), + ).not.toBeInTheDocument(); + }); + + it("moves to the account step after Continue is clicked and survey is completed", async () => { + const user = userEvent.setup(); + renderSetup(); + + // Simulate the survey-completed postMessage from the iframe origin + window.dispatchEvent( + new MessageEvent("message", { + data: "formbricksSurveyCompleted", + origin: "https://onboarding.example.com", + }), + ); + + await user.click( + await screen.findByRole("button", { name: /continue/i }), + ); + + expect(screen.getByLabelText(/^name$/i)).toBeInTheDocument(); + }); + + it("shows a Back button on the account step that returns to onboarding", async () => { + const user = userEvent.setup(); + renderSetup(); + + window.dispatchEvent( + new MessageEvent("message", { + data: "formbricksSurveyCompleted", + origin: "https://onboarding.example.com", + }), + ); + + await user.click( + await screen.findByRole("button", { name: /continue/i }), + ); + + expect(screen.getByRole("button", { name: /back/i })).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: /back/i })); + + expect(screen.getByTitle(/onboarding survey/i)).toBeInTheDocument(); + }); + }); +}); diff --git a/ui/apps/console/src/pages/__tests__/SignUp.test.tsx b/ui/apps/console/src/pages/__tests__/SignUp.test.tsx new file mode 100644 index 00000000000..50683758196 --- /dev/null +++ b/ui/apps/console/src/pages/__tests__/SignUp.test.tsx @@ -0,0 +1,385 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, cleanup, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { MemoryRouter } from "react-router-dom"; +import { useSignUpStore } from "@/stores/signUpStore"; +import SignUp from "../SignUp"; + +/* ------------------------------------------------------------------ */ +/* Mocks */ +/* ------------------------------------------------------------------ */ + +const mockNavigate = vi.hoisted(() => vi.fn()); + +vi.mock("react-router-dom", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, useNavigate: () => mockNavigate }; +}); + +vi.mock("@/client", () => ({ + registerUser: vi.fn(), + resendEmail: vi.fn(), + getValidateAccount: vi.fn(), +})); + +import { registerUser as registerUserSdk } from "@/client"; + +const mockedRegisterUser = vi.mocked(registerUserSdk); + +type SdkResponse = { data: T; request: Request; response: Response }; + +function mockSdkResponse(data: T): SdkResponse { + return { + data, + request: new Request("http://localhost"), + response: new Response(), + }; +} + +/* ------------------------------------------------------------------ */ +/* Helpers */ +/* ------------------------------------------------------------------ */ + +function renderSignUp(search = "") { + return render( + + + , + ); +} + +/** + * Fill all required fields with valid data and check privacy policy. + * Does NOT submit. + */ +async function fillValidForm( + user: ReturnType, + overrides: { + name?: string; + username?: string; + email?: string; + password?: string; + confirmPassword?: string; + acceptMarketing?: boolean; + } = {}, +) { + const { + name = "Alice Smith", + username = "alice", + email = "alice@example.com", + password = "Secret123", + confirmPassword = "Secret123", + acceptMarketing = false, + } = overrides; + + await user.type(screen.getByLabelText(/^name$/i), name); + await user.type(screen.getByLabelText(/^username$/i), username); + await user.type(screen.getByLabelText(/^email$/i), email); + await user.type(screen.getByLabelText(/^password$/i), password); + await user.type(screen.getByLabelText(/^confirm password$/i), confirmPassword); + + // Accept privacy policy (required) + await user.click(screen.getByLabelText(/privacy policy/i)); + + if (acceptMarketing) { + await user.click(screen.getByLabelText(/receive news and updates/i)); + } +} + +/* ------------------------------------------------------------------ */ +/* Setup / teardown */ +/* ------------------------------------------------------------------ */ + +afterEach(cleanup); + +beforeEach(() => { + mockNavigate.mockReset(); + mockedRegisterUser.mockReset(); + useSignUpStore.setState({ + signUpLoading: false, + signUpError: null, + signUpServerFields: [], + signUpToken: null, + signUpTenant: null, + }); +}); + +/* ================================================================== */ +/* Tests */ +/* ================================================================== */ + +describe("SignUp", () => { + /* ---------------------------------------------------------------- */ + /* 1. Submit button disabled on initial render with empty defaults */ + /* ---------------------------------------------------------------- */ + describe("initial render", () => { + it("disables the submit button when all fields are empty", () => { + renderSignUp(); + + const submit = screen.getByRole("button", { name: /create account/i }); + expect(submit).toBeDisabled(); + }); + }); + + /* ---------------------------------------------------------------- */ + /* 2. Filling valid data + checking privacy policy calls signUp */ + /* with correct payload including email_marketing */ + /* ---------------------------------------------------------------- */ + describe("successful submission", () => { + it("calls signUp with correct payload including email_marketing when form is valid", async () => { + mockedRegisterUser.mockResolvedValue(mockSdkResponse({})); + const user = userEvent.setup(); + renderSignUp(); + + await fillValidForm(user, { acceptMarketing: true }); + await user.click(screen.getByRole("button", { name: /create account/i })); + + await waitFor(() => expect(mockedRegisterUser).toHaveBeenCalledTimes(1)); + expect(mockedRegisterUser).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + name: "Alice Smith", + username: "alice", + email: "alice@example.com", + password: "Secret123", + email_marketing: true, + }), + }), + ); + }); + + it("calls signUp with email_marketing: false when marketing checkbox is unchecked", async () => { + mockedRegisterUser.mockResolvedValue(mockSdkResponse({})); + const user = userEvent.setup(); + renderSignUp(); + + await fillValidForm(user, { acceptMarketing: false }); + await user.click(screen.getByRole("button", { name: /create account/i })); + + await waitFor(() => expect(mockedRegisterUser).toHaveBeenCalledTimes(1)); + expect(mockedRegisterUser).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ email_marketing: false }), + }), + ); + }); + }); + + /* ---------------------------------------------------------------- */ + /* 3. Field errors appear after blur not before */ + /* ---------------------------------------------------------------- */ + describe("field validation on blur", () => { + it("does not show a name error before the field is touched", () => { + renderSignUp(); + expect(screen.queryByText(/name must be/i)).not.toBeInTheDocument(); + }); + + it("shows name error after blur when field is empty", async () => { + const user = userEvent.setup(); + renderSignUp(); + + const nameInput = screen.getByLabelText(/^name$/i); + await user.click(nameInput); + await user.tab(); // blur + + expect(await screen.findByText(/name must be/i)).toBeInTheDocument(); + }); + + it("does not show username error before the field is touched", () => { + renderSignUp(); + expect(screen.queryByText(/username must be/i)).not.toBeInTheDocument(); + }); + + it("shows username error after blur when field is too short", async () => { + const user = userEvent.setup(); + renderSignUp(); + + await user.type(screen.getByLabelText(/^username$/i), "ab"); + await user.tab(); + + expect(await screen.findByText(/username must be/i)).toBeInTheDocument(); + }); + }); + + /* ---------------------------------------------------------------- */ + /* 4. Privacy unchecked keeps submit disabled */ + /* ---------------------------------------------------------------- */ + describe("privacy policy gate", () => { + it("keeps submit disabled when all text fields are valid but privacy policy is unchecked", async () => { + const user = userEvent.setup(); + renderSignUp(); + + // Fill all required text fields but skip checking the privacy checkbox + await user.type(screen.getByLabelText(/^name$/i), "Alice Smith"); + await user.type(screen.getByLabelText(/^username$/i), "alice"); + await user.type(screen.getByLabelText(/^email$/i), "alice@example.com"); + await user.type(screen.getByLabelText(/^password$/i), "Secret123"); + await user.type(screen.getByLabelText(/^confirm password$/i), "Secret123"); + + const submit = screen.getByRole("button", { name: /create account/i }); + expect(submit).toBeDisabled(); + }); + }); + + /* ---------------------------------------------------------------- */ + /* 5. Password mismatch shows 'Passwords do not match' */ + /* ---------------------------------------------------------------- */ + describe("password mismatch", () => { + it("shows 'Passwords do not match' when confirmPassword differs from password", async () => { + const user = userEvent.setup(); + renderSignUp(); + + await user.type(screen.getByLabelText(/^password$/i), "Secret123"); + await user.type(screen.getByLabelText(/^confirm password$/i), "DifferentPass"); + await user.tab(); + + expect( + await screen.findByText(/passwords do not match/i), + ).toBeInTheDocument(); + }); + + it("does not show mismatch error before confirmPassword is touched", async () => { + const user = userEvent.setup(); + renderSignUp(); + + await user.type(screen.getByLabelText(/^password$/i), "Secret123"); + // No blur on confirmPassword + + expect(screen.queryByText(/passwords do not match/i)).not.toBeInTheDocument(); + }); + }); + + /* ---------------------------------------------------------------- */ + /* 6. Server field errors render on correct fields, disable submit, */ + /* and clear after field edit */ + /* ---------------------------------------------------------------- */ + describe("server field errors", () => { + /** + * Simulate a 400 server response with field errors, matching the shape + * that isSdkError accepts: an error with numeric `status` that is also + * Array-like (the store checks `Array.isArray(error)`). + */ + function makeServerFieldError(fields: string[], status = 400) { + // The store does: isSdkError(error) && Array.isArray(error) + // isSdkError checks for numeric `status` on the object + // Array.isArray requires the value to actually be an array + const err = Object.assign([...fields], { status }) as unknown as Error; + return err; + } + + it("shows server-side username error on the username field and disables submit", async () => { + mockedRegisterUser.mockRejectedValue(makeServerFieldError(["username"])); + const user = userEvent.setup(); + renderSignUp(); + + await fillValidForm(user); + await user.click(screen.getByRole("button", { name: /create account/i })); + + // The server error should appear on the username field + expect( + await screen.findByText(/this username already exists/i), + ).toBeInTheDocument(); + + // Submit should be disabled because of the server field error + expect(screen.getByRole("button", { name: /create account/i })).toBeDisabled(); + }); + + it("shows server-side email error on the email field", async () => { + mockedRegisterUser.mockRejectedValue(makeServerFieldError(["email"])); + const user = userEvent.setup(); + renderSignUp(); + + await fillValidForm(user); + await user.click(screen.getByRole("button", { name: /create account/i })); + + expect( + await screen.findByText(/this email is invalid or already in use/i), + ).toBeInTheDocument(); + }); + + it("clears the server field error after the user edits that field", async () => { + mockedRegisterUser.mockRejectedValue(makeServerFieldError(["username"])); + const user = userEvent.setup(); + renderSignUp(); + + await fillValidForm(user); + await user.click(screen.getByRole("button", { name: /create account/i })); + + await screen.findByText(/this username already exists/i); + + // Edit the username field — server error should be cleared. The clear is + // synchronous with the keystroke, so waitForElementToBeRemoved can't be + // used (the element is already gone by call time); assert absence instead. + await user.type(screen.getByLabelText(/^username$/i), "a"); + + await waitFor(() => + expect( + screen.queryByText(/this username already exists/i), + ).not.toBeInTheDocument(), + ); + + // The re-enable is the headline behavior: editing the field must clear the + // server-field gate (clearSignUpServerField), not just RHF's error text. + expect( + screen.getByRole("button", { name: /create account/i }), + ).toBeEnabled(); + }); + }); + + /* ---------------------------------------------------------------- */ + /* 7. Invite flow: prefills and disables email, includes sig */ + /* ---------------------------------------------------------------- */ + describe("invite flow", () => { + const INVITE_EMAIL = "invited@example.com"; + const INVITE_SIG = "abc123signature"; + + function renderInviteSignUp() { + return renderSignUp( + `?email=${encodeURIComponent(INVITE_EMAIL)}&sig=${INVITE_SIG}`, + ); + } + + it("prefills the email field from the query param", () => { + renderInviteSignUp(); + expect(screen.getByLabelText(/^email$/i)).toHaveValue(INVITE_EMAIL); + }); + + it("disables the email field in invite mode", () => { + renderInviteSignUp(); + expect(screen.getByLabelText(/^email$/i)).toBeDisabled(); + }); + + it("includes sig in the signUp payload during invite flow", async () => { + mockedRegisterUser.mockResolvedValue(mockSdkResponse({ token: "tok", tenant: "t1" })); + const user = userEvent.setup(); + renderInviteSignUp(); + + // Fill all other fields (email is prefilled) + await user.type(screen.getByLabelText(/^name$/i), "Alice Smith"); + await user.type(screen.getByLabelText(/^username$/i), "alice"); + // email is already filled via query param and disabled + await user.type(screen.getByLabelText(/^password$/i), "Secret123"); + await user.type(screen.getByLabelText(/^confirm password$/i), "Secret123"); + await user.click(screen.getByLabelText(/privacy policy/i)); + + await user.click(screen.getByRole("button", { name: /create account/i })); + + await waitFor(() => expect(mockedRegisterUser).toHaveBeenCalledTimes(1)); + expect(mockedRegisterUser).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + email: INVITE_EMAIL, + sig: INVITE_SIG, + }), + }), + ); + }); + + it("shows the invite warning callout", () => { + renderInviteSignUp(); + expect( + screen.getByText(/please create your account before accepting/i), + ).toBeInTheDocument(); + }); + }); +}); diff --git a/ui/apps/console/src/pages/__tests__/UpdatePassword.test.tsx b/ui/apps/console/src/pages/__tests__/UpdatePassword.test.tsx new file mode 100644 index 00000000000..158316c0b5e --- /dev/null +++ b/ui/apps/console/src/pages/__tests__/UpdatePassword.test.tsx @@ -0,0 +1,205 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; +import UpdatePassword from "../UpdatePassword"; + +const mockNavigate = vi.hoisted(() => vi.fn()); + +vi.mock("react-router-dom", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, useNavigate: () => mockNavigate }; +}); + +vi.mock("@/client", () => ({ + updateRecoverPassword: vi.fn(), +})); + +import { updateRecoverPassword } from "@/client"; +const mockedUpdate = vi.mocked(updateRecoverPassword); + +function mockSdkResponse(data: T) { + return { + data, + request: new Request("http://localhost"), + response: new Response(), + }; +} + +function renderWithParams(search = "?id=uid123&token=tok456") { + return render( + + + } /> + Login Page
} /> + + , + ); +} + +beforeEach(() => { + mockNavigate.mockReset(); + mockedUpdate.mockReset(); + mockedUpdate.mockResolvedValue(mockSdkResponse(undefined)); +}); + +describe("UpdatePassword", () => { + describe("uid/token guard", () => { + it("renders an error card with a link when uid and token are missing", () => { + renderWithParams("?id=&token="); + + expect(screen.getByText(/invalid reset link/i)).toBeInTheDocument(); + expect( + screen.getByRole("link", { name: /request a new reset link/i }), + ).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: /update password/i }), + ).not.toBeInTheDocument(); + }); + + it("renders an error card when only uid is missing", () => { + renderWithParams("?token=tok456"); + + expect(screen.getByText(/invalid reset link/i)).toBeInTheDocument(); + }); + + it("renders an error card when only token is missing", () => { + renderWithParams("?id=uid123"); + + expect(screen.getByText(/invalid reset link/i)).toBeInTheDocument(); + }); + }); + + describe("validation — no errors before blur", () => { + it("shows no password error on initial render", () => { + renderWithParams(); + + expect( + screen.queryByText(/password must be/i), + ).not.toBeInTheDocument(); + }); + + it("shows no mismatch error on initial render", () => { + renderWithParams(); + + expect( + screen.queryByText(/passwords do not match/i), + ).not.toBeInTheDocument(); + }); + }); + + describe("validation — errors appear after blur", () => { + it("shows a too-short error after the password field is blurred with a short value", async () => { + const user = userEvent.setup(); + renderWithParams(); + + await user.type(screen.getByLabelText(/^new password$/i), "abc"); + await user.tab(); + + expect( + await screen.findByText(/password must be/i), + ).toBeInTheDocument(); + }); + + it("shows a mismatch error after confirmPassword is blurred with a non-matching value", async () => { + const user = userEvent.setup(); + renderWithParams(); + + await user.type(screen.getByLabelText(/^new password$/i), "Secret123"); + await user.type(screen.getByLabelText(/^confirm password$/i), "Different1"); + await user.tab(); + + expect( + await screen.findByText(/passwords do not match/i), + ).toBeInTheDocument(); + }); + }); + + describe("submit button gate", () => { + it("disables the submit button on initial render", () => { + renderWithParams(); + + expect( + screen.getByRole("button", { name: /update password/i }), + ).toBeDisabled(); + }); + + it("enables the submit button only when both password fields contain valid, matching values", async () => { + const user = userEvent.setup(); + renderWithParams(); + + await user.type(screen.getByLabelText(/^new password$/i), "Secret123"); + await user.type(screen.getByLabelText(/^confirm password$/i), "Secret123"); + + expect( + screen.getByRole("button", { name: /update password/i }), + ).toBeEnabled(); + }); + + it("keeps the submit button disabled when passwords match but are too short", async () => { + const user = userEvent.setup(); + renderWithParams(); + + await user.type(screen.getByLabelText(/^new password$/i), "abc"); + await user.type(screen.getByLabelText(/^confirm password$/i), "abc"); + + expect( + screen.getByRole("button", { name: /update password/i }), + ).toBeDisabled(); + }); + }); + + describe("successful submission", () => { + it("calls updateRecoverPassword with uid, token, and password on valid submit", async () => { + const user = userEvent.setup(); + renderWithParams(); + + await user.type(screen.getByLabelText(/^new password$/i), "Secret123"); + await user.type(screen.getByLabelText(/^confirm password$/i), "Secret123"); + await user.click(screen.getByRole("button", { name: /update password/i })); + + await waitFor(() => expect(mockedUpdate).toHaveBeenCalledTimes(1)); + expect(mockedUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + path: { uid: "uid123" }, + body: expect.objectContaining({ + token: "tok456", + password: "Secret123", + }), + }), + ); + }); + + it("navigates to /login with a success notice after successful submission", async () => { + const user = userEvent.setup(); + renderWithParams(); + + await user.type(screen.getByLabelText(/^new password$/i), "Secret123"); + await user.type(screen.getByLabelText(/^confirm password$/i), "Secret123"); + await user.click(screen.getByRole("button", { name: /update password/i })); + + await waitFor(() => + expect(mockNavigate).toHaveBeenCalledWith( + "/login", + expect.objectContaining({ + state: expect.objectContaining({ notice: expect.any(String) }), + }), + ), + ); + }); + }); + + describe("API failure", () => { + it("shows a generic error message when the API call fails", async () => { + mockedUpdate.mockRejectedValue(new Error("network error")); + const user = userEvent.setup(); + renderWithParams(); + + await user.type(screen.getByLabelText(/^new password$/i), "Secret123"); + await user.type(screen.getByLabelText(/^confirm password$/i), "Secret123"); + await user.click(screen.getByRole("button", { name: /update password/i })); + + expect(await screen.findByRole("alert")).toBeInTheDocument(); + }); + }); +}); diff --git a/ui/apps/console/src/pages/setup/__tests__/forgotPasswordResolver.test.ts b/ui/apps/console/src/pages/setup/__tests__/forgotPasswordResolver.test.ts new file mode 100644 index 00000000000..5b658d2e9c1 --- /dev/null +++ b/ui/apps/console/src/pages/setup/__tests__/forgotPasswordResolver.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect } from "vitest"; +import { forgotPasswordResolver } from "../forgotPasswordResolver"; +import type { ForgotPasswordFormValues } from "../forgotPasswordResolver"; + +const emptyOptions = { + criteriaMode: undefined, + fields: {}, + names: undefined, + shouldUseNativeValidation: undefined, +} as Parameters[2]; + +type RhfErrors = Record; + +const resolve = (input: ForgotPasswordFormValues) => + forgotPasswordResolver(input, undefined, emptyOptions); + +async function expectFieldError( + input: ForgotPasswordFormValues, + field: keyof ForgotPasswordFormValues, + type: string, +) { + const result = await resolve(input); + const errors = result.errors as Partial; + + expect(errors).toHaveProperty(field); + expect(errors[field]?.type).toBe(type); + expect(typeof errors[field]?.message).toBe("string"); + expect(result.values).toEqual({}); +} + +describe("forgotPasswordResolver", () => { + it("accepts a valid username", async () => { + const result = await resolve({ account: "admin" }); + expect(result.errors).toEqual({}); + }); + + it("accepts a valid email", async () => { + const result = await resolve({ account: "user@example.com" }); + expect(result.errors).toEqual({}); + }); + + it("rejects an invalid value", async () => { + await expectFieldError({ account: "!!" }, "account", "validate"); + }); + + it("returns trimmed account in values on success", async () => { + const result = await resolve({ account: " admin " }); + expect(result.values).toEqual({ account: "admin" }); + expect(result.errors).toEqual({}); + }); +}); diff --git a/ui/apps/console/src/pages/setup/__tests__/loginResolver.test.ts b/ui/apps/console/src/pages/setup/__tests__/loginResolver.test.ts new file mode 100644 index 00000000000..15110c550f3 --- /dev/null +++ b/ui/apps/console/src/pages/setup/__tests__/loginResolver.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect } from "vitest"; +import { loginResolver } from "../loginResolver"; +import type { LoginFormValues } from "../loginResolver"; + +const validInput: LoginFormValues = { + username: "admin", + password: "secret", +}; + +const emptyOptions = { + criteriaMode: undefined, + fields: {}, + names: undefined, + shouldUseNativeValidation: undefined, +} as Parameters[2]; + +type RhfErrors = Record; + +const resolve = (input: LoginFormValues) => + loginResolver(input, undefined, emptyOptions); + +async function expectFieldError( + input: LoginFormValues, + field: keyof LoginFormValues, + type: string, +) { + const result = await resolve(input); + const errors = result.errors as Partial; + + expect(errors).toHaveProperty(field); + expect(errors[field]?.type).toBe(type); + expect(typeof errors[field]?.message).toBe("string"); + expect(result.values).toEqual({}); +} + +describe("loginResolver", () => { + it("accepts a valid username", async () => { + const result = await resolve(validInput); + expect(result.errors).toEqual({}); + }); + + it("accepts a valid email as username", async () => { + const result = await resolve({ ...validInput, username: "user@example.com" }); + expect(result.errors).toEqual({}); + }); + + it("rejects an invalid username", async () => { + await expectFieldError({ ...validInput, username: "!!" }, "username", "validate"); + }); + + it("rejects an empty password", async () => { + await expectFieldError({ ...validInput, password: "" }, "password", "required"); + }); + + it("does not validate password length", async () => { + const result = await resolve({ ...validInput, password: "a" }); + expect(result.errors).not.toHaveProperty("password"); + }); + + it("returns trimmed username in values on success", async () => { + const result = await resolve({ username: " admin ", password: "secret" }); + expect(result.values).toEqual({ username: "admin", password: "secret" }); + expect(result.errors).toEqual({}); + }); +}); diff --git a/ui/apps/console/src/pages/setup/__tests__/mfaRecoverResolver.test.ts b/ui/apps/console/src/pages/setup/__tests__/mfaRecoverResolver.test.ts new file mode 100644 index 00000000000..73017282057 --- /dev/null +++ b/ui/apps/console/src/pages/setup/__tests__/mfaRecoverResolver.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect } from "vitest"; +import { mfaRecoverResolver } from "../mfaRecoverResolver"; +import type { MfaRecoverFormValues } from "../mfaRecoverResolver"; + +const emptyOptions = { + criteriaMode: undefined, + fields: {}, + names: undefined, + shouldUseNativeValidation: undefined, +} as Parameters[2]; + +type RhfErrors = Record; + +const resolve = (input: MfaRecoverFormValues) => + mfaRecoverResolver(input, undefined, emptyOptions); + +async function expectFieldError( + input: MfaRecoverFormValues, + field: keyof MfaRecoverFormValues, + type: string, +) { + const result = await resolve(input); + const errors = result.errors as Partial; + + expect(errors).toHaveProperty(field); + expect(errors[field]?.type).toBe(type); + expect(typeof errors[field]?.message).toBe("string"); + expect(result.values).toEqual({}); +} + +describe("mfaRecoverResolver", () => { + it("accepts a non-empty recovery code", async () => { + const result = await resolve({ recoveryCode: "ABCD-1234" }); + expect(result.errors).toEqual({}); + }); + + it("rejects an empty recovery code", async () => { + await expectFieldError({ recoveryCode: "" }, "recoveryCode", "required"); + }); + + it("rejects a whitespace-only recovery code", async () => { + await expectFieldError({ recoveryCode: " " }, "recoveryCode", "required"); + }); + + it("returns trimmed recoveryCode in values on success", async () => { + const result = await resolve({ recoveryCode: " ABCD-1234 " }); + expect(result.values).toEqual({ recoveryCode: "ABCD-1234" }); + expect(result.errors).toEqual({}); + }); +}); diff --git a/ui/apps/console/src/pages/setup/__tests__/setupResolver.test.ts b/ui/apps/console/src/pages/setup/__tests__/setupResolver.test.ts new file mode 100644 index 00000000000..bfc8c4299b3 --- /dev/null +++ b/ui/apps/console/src/pages/setup/__tests__/setupResolver.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect } from "vitest"; +import { setupResolver } from "../setupResolver"; +import type { SetupFormValues } from "../setupResolver"; + +const validInput: SetupFormValues = { + name: "Admin User", + username: "admin", + email: "admin@example.com", + password: "secret123", + confirmPassword: "secret123", +}; + +const emptyOptions = { + criteriaMode: undefined, + fields: {}, + names: undefined, + shouldUseNativeValidation: undefined, +} as Parameters[2]; + +type RhfErrors = Record; + +const resolve = (input: SetupFormValues) => + setupResolver(input, undefined, emptyOptions); + +/** + * Every invalid-field case shares the same shape: the resolver should surface a + * single RHF error on `field` with the expected `type`, a string message, and + * empty `values`. Asserting that contract in one place keeps the cases below to + * just the input that triggers each error. + */ +async function expectFieldError( + input: SetupFormValues, + field: keyof SetupFormValues, + type: string, +) { + const result = await resolve(input); + const errors = result.errors as Partial; + + expect(errors).toHaveProperty(field); + expect(errors[field]?.type).toBe(type); + expect(typeof errors[field]?.message).toBe("string"); + expect(result.values).toEqual({}); +} + +describe("setupResolver", () => { + it("returns values with no errors for valid input", async () => { + const result = await resolve(validInput); + expect(result.values).toEqual(validInput); + expect(result.errors).toEqual({}); + }); + + describe("validate() field error mapping", () => { + const invalidCases: { field: keyof SetupFormValues; input: SetupFormValues }[] = [ + { field: "name", input: { ...validInput, name: "" } }, + { field: "username", input: { ...validInput, username: "ab" } }, + { field: "email", input: { ...validInput, email: "not-an-email" } }, + { field: "password", input: { ...validInput, password: "123", confirmPassword: "123" } }, + { field: "confirmPassword", input: { ...validInput, confirmPassword: "different" } }, + ]; + + it.each(invalidCases)("maps $field error to RHF error shape", async ({ field, input }) => { + await expectFieldError(input, field, "validate"); + }); + }); +}); diff --git a/ui/apps/console/src/pages/setup/__tests__/signUpResolver.test.ts b/ui/apps/console/src/pages/setup/__tests__/signUpResolver.test.ts new file mode 100644 index 00000000000..72d54f6ad38 --- /dev/null +++ b/ui/apps/console/src/pages/setup/__tests__/signUpResolver.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect } from "vitest"; +import { signUpResolver } from "../signUpResolver"; +import type { SignUpFormValues } from "../signUpResolver"; + +const validInput: SignUpFormValues = { + name: "Admin User", + username: "admin", + email: "admin@example.com", + password: "secret123", + confirmPassword: "secret123", + acceptPrivacyPolicy: true, +}; + +const emptyOptions = { + criteriaMode: undefined, + fields: {}, + names: undefined, + shouldUseNativeValidation: undefined, +} as Parameters[2]; + +type RhfErrors = Record; + +const resolve = (input: SignUpFormValues) => + signUpResolver(input, undefined, emptyOptions); + +/** + * Every invalid-field case shares the same shape: the resolver should surface a + * single RHF error on `field` with the expected `type`, a string message, and + * empty `values`. Asserting that contract in one place keeps the cases below to + * just the input that triggers each error. + */ +async function expectFieldError( + input: SignUpFormValues, + field: keyof SignUpFormValues, + type: string, +) { + const result = await resolve(input); + const errors = result.errors as Partial; + + expect(errors).toHaveProperty(field); + expect(errors[field]?.type).toBe(type); + expect(typeof errors[field]?.message).toBe("string"); + expect(result.values).toEqual({}); +} + +describe("signUpResolver", () => { + it("returns values with no errors for valid input", async () => { + const result = await resolve(validInput); + expect(result.values).toEqual(validInput); + expect(result.errors).toEqual({}); + }); + + describe("validate() field error mapping", () => { + const invalidCases: { field: keyof SignUpFormValues; input: SignUpFormValues }[] = [ + { field: "name", input: { ...validInput, name: "" } }, + { field: "username", input: { ...validInput, username: "ab" } }, + { field: "email", input: { ...validInput, email: "not-an-email" } }, + { field: "password", input: { ...validInput, password: "123", confirmPassword: "123" } }, + { field: "confirmPassword", input: { ...validInput, confirmPassword: "different" } }, + ]; + + it.each(invalidCases)("maps $field error to RHF error shape", async ({ field, input }) => { + await expectFieldError(input, field, "validate"); + }); + }); + + describe("acceptPrivacyPolicy", () => { + it("returns error on acceptPrivacyPolicy when false", async () => { + await expectFieldError( + { ...validInput, acceptPrivacyPolicy: false }, + "acceptPrivacyPolicy", + "required", + ); + }); + + it("clears acceptPrivacyPolicy error when true", async () => { + const result = await resolve(validInput); + expect(result.errors).not.toHaveProperty("acceptPrivacyPolicy"); + }); + }); +}); diff --git a/ui/apps/console/src/pages/setup/__tests__/updatePasswordResolver.test.ts b/ui/apps/console/src/pages/setup/__tests__/updatePasswordResolver.test.ts new file mode 100644 index 00000000000..90e4621deaf --- /dev/null +++ b/ui/apps/console/src/pages/setup/__tests__/updatePasswordResolver.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect } from "vitest"; +import { updatePasswordResolver } from "../updatePasswordResolver"; +import type { UpdatePasswordFormValues } from "../updatePasswordResolver"; + +const emptyOptions = { + criteriaMode: undefined, + fields: {}, + names: undefined, + shouldUseNativeValidation: undefined, +} as Parameters[2]; + +type RhfErrors = Record; + +const resolve = (input: UpdatePasswordFormValues) => + updatePasswordResolver(input, undefined, emptyOptions); + +async function expectFieldError( + input: UpdatePasswordFormValues, + field: keyof UpdatePasswordFormValues, + type: string, +) { + const result = await resolve(input); + const errors = result.errors as Partial; + + expect(errors).toHaveProperty(field); + expect(errors[field]?.type).toBe(type); + expect(typeof errors[field]?.message).toBe("string"); + expect(result.values).toEqual({}); +} + +describe("updatePasswordResolver", () => { + it("returns values with no errors for valid matching passwords", async () => { + const input: UpdatePasswordFormValues = { password: "secret", confirmPassword: "secret" }; + const result = await resolve(input); + expect(result.values).toEqual(input); + expect(result.errors).toEqual({}); + }); + + it("rejects a too-short password", async () => { + await expectFieldError( + { password: "abc", confirmPassword: "abc" }, + "password", + "validate", + ); + }); + + it("rejects a too-long password", async () => { + await expectFieldError( + { password: "a".repeat(33), confirmPassword: "a".repeat(33) }, + "password", + "validate", + ); + }); + + it("rejects mismatched confirmPassword with the correct message", async () => { + const result = await resolve({ password: "secret", confirmPassword: "different" }); + const errors = result.errors as Partial; + + expect(errors).toHaveProperty("confirmPassword"); + expect(errors.confirmPassword?.message).toBe("Passwords do not match"); + expect(result.values).toEqual({}); + }); +}); diff --git a/ui/apps/console/src/pages/setup/forgotPasswordResolver.ts b/ui/apps/console/src/pages/setup/forgotPasswordResolver.ts new file mode 100644 index 00000000000..8fd5484d6a9 --- /dev/null +++ b/ui/apps/console/src/pages/setup/forgotPasswordResolver.ts @@ -0,0 +1,21 @@ +import type { FieldErrors, Resolver } from "react-hook-form"; +import { validateIdentifier } from "@/utils/validation"; + +export interface ForgotPasswordFormValues { + account: string; +} + +export const forgotPasswordResolver: Resolver = (values) => { + const errors: FieldErrors = {}; + + const accountError = validateIdentifier(values.account); + if (accountError) { + errors.account = { type: "validate", message: accountError }; + } + + if (Object.keys(errors).length > 0) { + return { values: {}, errors }; + } + + return { values: { ...values, account: values.account.trim() }, errors: {} }; +}; diff --git a/ui/apps/console/src/pages/setup/loginResolver.ts b/ui/apps/console/src/pages/setup/loginResolver.ts new file mode 100644 index 00000000000..2737516108f --- /dev/null +++ b/ui/apps/console/src/pages/setup/loginResolver.ts @@ -0,0 +1,26 @@ +import type { FieldErrors, Resolver } from "react-hook-form"; +import { validateIdentifier } from "@/utils/validation"; + +export interface LoginFormValues { + username: string; + password: string; +} + +export const loginResolver: Resolver = (values) => { + const errors: FieldErrors = {}; + + const usernameError = validateIdentifier(values.username); + if (usernameError) { + errors.username = { type: "validate", message: usernameError }; + } + + if (!values.password) { + errors.password = { type: "required", message: "Password is required" }; + } + + if (Object.keys(errors).length > 0) { + return { values: {}, errors }; + } + + return { values: { ...values, username: values.username.trim() }, errors: {} }; +}; diff --git a/ui/apps/console/src/pages/setup/mfaRecoverResolver.ts b/ui/apps/console/src/pages/setup/mfaRecoverResolver.ts new file mode 100644 index 00000000000..46f5201eb2e --- /dev/null +++ b/ui/apps/console/src/pages/setup/mfaRecoverResolver.ts @@ -0,0 +1,20 @@ +import type { FieldErrors, Resolver } from "react-hook-form"; + +export interface MfaRecoverFormValues { + recoveryCode: string; +} + +export const mfaRecoverResolver: Resolver = (values) => { + const recoveryCode = values.recoveryCode.trim(); + const errors: FieldErrors = {}; + + if (!recoveryCode) { + errors.recoveryCode = { + type: "required", + message: "Recovery code is required", + }; + return { values: {}, errors }; + } + + return { values: { ...values, recoveryCode }, errors: {} }; +}; diff --git a/ui/apps/console/src/pages/setup/setupResolver.ts b/ui/apps/console/src/pages/setup/setupResolver.ts new file mode 100644 index 00000000000..bc4d684f2b7 --- /dev/null +++ b/ui/apps/console/src/pages/setup/setupResolver.ts @@ -0,0 +1,39 @@ +import type { FieldErrors, Resolver } from "react-hook-form"; +import { validate, type FormErrors } from "./validate"; + +export interface SetupFormValues { + name: string; + username: string; + email: string; + password: string; + confirmPassword: string; +} + +type ValidateField = keyof FormErrors; + +const VALIDATE_FIELDS: ValidateField[] = [ + "name", + "username", + "email", + "password", + "confirmPassword", +]; + +export const setupResolver: Resolver = (values) => { + const formErrors = validate(values); + const errors: FieldErrors = {}; + + for (const field of VALIDATE_FIELDS) { + const message = formErrors[field]; + + if (message) { + errors[field] = { type: "validate", message }; + } + } + + if (Object.keys(errors).length > 0) { + return { values: {}, errors }; + } + + return { values, errors: {} }; +}; diff --git a/ui/apps/console/src/pages/setup/signUpResolver.ts b/ui/apps/console/src/pages/setup/signUpResolver.ts new file mode 100644 index 00000000000..6e0d862cd66 --- /dev/null +++ b/ui/apps/console/src/pages/setup/signUpResolver.ts @@ -0,0 +1,47 @@ +import type { FieldErrors, Resolver } from "react-hook-form"; +import { validate, type FormErrors } from "./validate"; + +export interface SignUpFormValues { + name: string; + username: string; + email: string; + password: string; + confirmPassword: string; + acceptPrivacyPolicy: boolean; +} + +type ValidateField = keyof FormErrors; + +const VALIDATE_FIELDS: ValidateField[] = [ + "name", + "username", + "email", + "password", + "confirmPassword", +]; + +export const signUpResolver: Resolver = (values) => { + const formErrors = validate(values); + const errors: FieldErrors = {}; + + for (const field of VALIDATE_FIELDS) { + const message = formErrors[field]; + + if (message) { + errors[field] = { type: "validate", message }; + } + } + + if (!values.acceptPrivacyPolicy) { + errors.acceptPrivacyPolicy = { + type: "required", + message: "You must accept the privacy policy", + }; + } + + if (Object.keys(errors).length > 0) { + return { values: {}, errors }; + } + + return { values, errors: {} }; +}; diff --git a/ui/apps/console/src/pages/setup/updatePasswordResolver.ts b/ui/apps/console/src/pages/setup/updatePasswordResolver.ts new file mode 100644 index 00000000000..5c0a47237de --- /dev/null +++ b/ui/apps/console/src/pages/setup/updatePasswordResolver.ts @@ -0,0 +1,26 @@ +import type { FieldErrors, Resolver } from "react-hook-form"; +import { validatePassword } from "@/utils/validation"; + +export interface UpdatePasswordFormValues { + password: string; + confirmPassword: string; +} + +export const updatePasswordResolver: Resolver = (values) => { + const errors: FieldErrors = {}; + + const passwordError = validatePassword(values.password); + if (passwordError) { + errors.password = { type: "validate", message: passwordError }; + } + + if (values.confirmPassword !== values.password) { + errors.confirmPassword = { type: "validate", message: "Passwords do not match" }; + } + + if (Object.keys(errors).length > 0) { + return { values: {}, errors }; + } + + return { values: { ...values }, errors: {} }; +}; diff --git a/ui/apps/console/src/utils/__tests__/validation.test.ts b/ui/apps/console/src/utils/__tests__/validation.test.ts index 55decc51276..9e76c82b06c 100644 --- a/ui/apps/console/src/utils/__tests__/validation.test.ts +++ b/ui/apps/console/src/utils/__tests__/validation.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from "vitest"; import { validateEmail, + validateIdentifier, validateName, validateNamespaceName, validatePassword, @@ -147,3 +148,27 @@ describe("validatePassword", () => { ); }); }); + +describe("validateIdentifier", () => { + it.each([ + " alice ", + "alice.smith", + " alice@example.com ", + "user+tag@example.org", + ])("accepts whitespace-padded valid username/email %p", (value) => { + expect(validateIdentifier(value)).toBeNull(); + }); + + it.each(["", " "])("rejects empty/whitespace-only %p as required", (value) => { + expect(validateIdentifier(value)).toBe("Username or email is required"); + }); + + it.each(["!", "a", " ! ", "has CAPS", "has space@no.tld"])( + "rejects invalid identifier %p", + (value) => { + expect(validateIdentifier(value)).toBe( + "Enter a valid username or email", + ); + }, + ); +}); diff --git a/ui/apps/console/src/utils/validation.ts b/ui/apps/console/src/utils/validation.ts index 679f99254f9..7ae8db974d6 100644 --- a/ui/apps/console/src/utils/validation.ts +++ b/ui/apps/console/src/utils/validation.ts @@ -35,6 +35,13 @@ export function validateEmail(value: string): string | null { return null; } +export function validateIdentifier(value: string): string | null { + const trimmed = value.trim(); + if (!trimmed) return "Username or email is required"; + if (EMAIL_REGEX.test(trimmed) || USERNAME_REGEX.test(trimmed)) return null; + return "Enter a valid username or email"; +} + export function validatePassword(value: string): string | null { if (value.length < PASSWORD_MIN_LENGTH || value.length > PASSWORD_MAX_LENGTH) return `Password must be ${PASSWORD_MIN_LENGTH}–${PASSWORD_MAX_LENGTH} characters long`; diff --git a/ui/package-lock.json b/ui/package-lock.json index f6afd495f52..983c1550a39 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -75,6 +75,7 @@ "axios": "^1.18.1", "font-logos": "^1.3.0", "node-rsa": "^1.1.1", + "react-hook-form": "^7.0.0", "react-router-dom": "^7.17.0", "sshpk": "^1.18.0", "zustand": "^5.0.14" @@ -12697,6 +12698,22 @@ "react": "^19.2.7" } }, + "node_modules/react-hook-form": { + "version": "7.80.0", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.80.0.tgz", + "integrity": "sha512-4P+fk6oXsxY+6xSj7Euhc2sumQD8zQqCuVHoJwoyp9EchP+IUW9OESB7uHFJOKsIBQ4MQqYE84INJFqUCYNoOg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, "node_modules/react-is": { "version": "17.0.2", "dev": true,