feat(auth): disable login submit until email and password are valid

This commit is contained in:
Arthur Bassi 2026-06-18 09:49:02 -03:00
parent 43b53bb610
commit 9a3b49f6e2
5 changed files with 66 additions and 4 deletions

View file

@ -1,4 +1,9 @@
import type { FieldErrors, UseFormHandleSubmit, UseFormRegister } from "react-hook-form";
import type {
FieldErrors,
UseFormHandleSubmit,
UseFormRegister,
UseFormWatch,
} from "react-hook-form";
import { Box } from "@mui/material";
import { Button } from "@/components/ui/button";
@ -6,11 +11,12 @@ import { FormErrorMessage } from "@/components/ui/form-error-message";
import { FormField } from "@/components/ui/form-field";
import { PasswordField } from "@/components/ui/password-field";
import { TextField } from "@/components/ui/text-field";
import type { LoginFormValues } from "@/domain/auth/schemas/login-schema";
import { loginSchema, type LoginFormValues } from "@/domain/auth/schemas/login-schema";
export type LoginFormProps = {
register: UseFormRegister<LoginFormValues>;
handleSubmit: UseFormHandleSubmit<LoginFormValues>;
watch: UseFormWatch<LoginFormValues>;
errors: FieldErrors<LoginFormValues>;
onSubmit: (values: LoginFormValues) => void;
isLoggingIn: boolean;
@ -20,11 +26,15 @@ export type LoginFormProps = {
export function LoginForm({
register,
handleSubmit,
watch,
errors,
onSubmit,
isLoggingIn,
loginError,
}: LoginFormProps) {
const [username, password] = watch(["username", "password"]);
const isSubmitDisabled = !loginSchema.safeParse({ username, password }).success;
return (
<Box
component="form"
@ -74,6 +84,7 @@ export function LoginForm({
size="lg"
loading={isLoggingIn}
loadingText="Signing in…"
disabled={isSubmitDisabled}
className="mt-1!"
>
Sign In

View file

@ -14,6 +14,7 @@ export default function LoginPage() {
const {
register,
handleSubmit,
watch,
formState: { errors },
} = useForm<LoginFormValues>({
resolver: zodResolver(loginSchema),
@ -30,6 +31,7 @@ export default function LoginPage() {
<LoginForm
register={register}
handleSubmit={handleSubmit}
watch={watch}
errors={errors}
onSubmit={login}
isLoggingIn={isLoggingIn}

View file

@ -1,7 +1,7 @@
import { z } from "zod";
export const loginSchema = z.object({
username: z.string().min(1, "Email is required"),
username: z.string().min(1, "Email is required").pipe(z.email("Enter a valid email")),
password: z.string().min(1, "Password is required"),
});

View file

@ -1,6 +1,6 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
import { useForm, type UseFormHandleSubmit } from "react-hook-form";
import { useForm, type UseFormHandleSubmit, type UseFormWatch } from "react-hook-form";
import { describe, expect, it, vi } from "vitest";
import { LoginForm, type LoginFormProps } from "@/app/(auth)/_components/login-form";
@ -14,6 +14,22 @@ function createMockHandleSubmit(): UseFormHandleSubmit<LoginFormValues> {
};
}
function createMockWatch(
values: LoginFormValues = { username: "", password: "" },
): UseFormWatch<LoginFormValues> {
return ((name?: unknown) => {
if (name === undefined) {
return values;
}
if (Array.isArray(name)) {
return name.map((field) => values[field as keyof LoginFormValues]);
}
return values[name as keyof LoginFormValues];
}) as UseFormWatch<LoginFormValues>;
}
type LoginFormHarnessProps = {
onSubmit?: (values: LoginFormValues) => void;
isLoggingIn?: boolean;
@ -28,6 +44,7 @@ function LoginFormHarness({
const {
register,
handleSubmit,
watch,
formState: { errors },
} = useForm<LoginFormValues>({
resolver: zodResolver(loginSchema),
@ -38,6 +55,7 @@ function LoginFormHarness({
<LoginForm
register={register}
handleSubmit={handleSubmit}
watch={watch}
errors={errors}
onSubmit={onSubmit}
isLoggingIn={isLoggingIn}
@ -62,6 +80,7 @@ describe("LoginForm", () => {
<LoginForm
register={vi.fn()}
handleSubmit={createMockHandleSubmit()}
watch={createMockWatch()}
errors={{
username: { type: "required", message: "Email is required" },
password: { type: "required", message: "Password is required" },
@ -97,6 +116,7 @@ describe("LoginForm", () => {
<LoginForm
register={register as LoginFormProps["register"]}
handleSubmit={createMockHandleSubmit()}
watch={createMockWatch({ username: "user@seahavenind.com", password: "secret" })}
errors={{}}
onSubmit={vi.fn()}
isLoggingIn
@ -117,6 +137,26 @@ describe("LoginForm", () => {
expect(within(form!).getByRole("button", { name: /signing in/i })).toBeDisabled();
});
it("keeps submit disabled until email and password are valid", async () => {
renderWithProviders(<LoginFormHarness />, { withAuth: false });
const form = document.querySelector("form");
expect(form).not.toBeNull();
const submitButton = within(form!).getByRole("button", { name: /sign in/i });
const emailInput = within(form!).getByLabelText("Email");
const passwordInput = within(form!).getByLabelText("Password");
expect(submitButton).toBeDisabled();
fireEvent.change(emailInput, { target: { value: "not-an-email" } });
fireEvent.change(passwordInput, { target: { value: "secret123" } });
expect(submitButton).toBeDisabled();
fireEvent.change(emailInput, { target: { value: "user@seahavenind.com" } });
expect(submitButton).toBeEnabled();
});
it("submits credentials via onSubmit", async () => {
const onSubmit = vi.fn();

View file

@ -21,6 +21,15 @@ describe("loginSchema", () => {
}
});
it("rejects invalid email", () => {
const result = loginSchema.safeParse({ username: "not-an-email", password: "secret" });
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.flatten().fieldErrors.username).toEqual(["Enter a valid email"]);
}
});
it("accepts valid credentials", () => {
const credentials = { username: "user@seahavenind.com", password: "secret" };
const result = loginSchema.safeParse(credentials);