Skip to content

Commit c2ebadf

Browse files
committed
feat(login): implement login and signup schemas with validation
1 parent 1ac5d67 commit c2ebadf

6 files changed

Lines changed: 96 additions & 49 deletions

File tree

.gitignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,4 +57,6 @@ drizzle/meta/
5757
.supabase/
5858

5959

60-
.agents/
60+
.agents/
61+
.playwright-mcp/
62+
.mcp.json

app/login/actions.ts

Lines changed: 41 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,43 +2,66 @@
22

33
import { redirect } from "next/navigation";
44
import { createClient } from "@/utils/supabase/server";
5+
import { loginSchema, signupSchema } from "./schema";
56

6-
export async function login(formData: FormData) {
7-
const supabase = await createClient();
7+
export type ActionState = {
8+
errors: Partial<Record<string, string[]>>;
9+
} | null;
810

9-
const { error } = await supabase.auth.signInWithPassword({
10-
email: formData.get("email") as string,
11-
password: formData.get("password") as string,
11+
export async function login(
12+
_prevState: ActionState,
13+
formData: FormData
14+
): Promise<ActionState> {
15+
const result = loginSchema.safeParse({
16+
email: formData.get("email"),
17+
password: formData.get("password"),
1218
});
1319

20+
if (!result.success) {
21+
return { errors: result.error.flatten().fieldErrors };
22+
}
23+
24+
const supabase = await createClient();
25+
const { error } = await supabase.auth.signInWithPassword(result.data);
26+
1427
if (error) {
15-
redirect(`/login?error=${encodeURIComponent(error.message)}`);
28+
return { errors: { email: [error.message] } };
1629
}
1730

1831
redirect("/");
1932
}
2033

21-
export async function signup(formData: FormData) {
22-
const supabase = await createClient();
34+
export async function signup(
35+
_prevState: ActionState,
36+
formData: FormData
37+
): Promise<ActionState> {
38+
const result = signupSchema.safeParse({
39+
firstName: formData.get("first_name"),
40+
lastName: formData.get("last_name"),
41+
email: formData.get("email"),
42+
password: formData.get("password"),
43+
});
44+
45+
if (!result.success) {
46+
return { errors: result.error.flatten().fieldErrors };
47+
}
2348

49+
const { firstName, lastName, email, password } = result.data;
50+
51+
const supabase = await createClient();
2452
const { error } = await supabase.auth.signUp({
25-
email: formData.get("email") as string,
26-
password: formData.get("password") as string,
53+
email,
54+
password,
2755
options: {
28-
data: {
29-
first_name: formData.get("first_name") as string,
30-
last_name: formData.get("last_name") as string,
31-
},
56+
data: { first_name: firstName, last_name: lastName },
3257
},
3358
});
3459

3560
if (error) {
36-
redirect(`/login?error=${encodeURIComponent(error.message)}`);
61+
return { errors: { email: [error.message] } };
3762
}
3863

39-
redirect(
40-
`/login?message=${encodeURIComponent("Check your email to confirm your account.")}`
41-
);
64+
redirect("/login?message=Check+your+email+to+confirm+your+account.");
4265
}
4366

4467
export async function signout() {

app/login/page.tsx

Lines changed: 30 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
"use client";
22

3-
import { login, signup } from "./actions";
3+
import { useActionState, useState, Suspense } from "react";
44
import { useSearchParams } from "next/navigation";
5-
import { Suspense, useState } from "react";
5+
import { login, signup, type ActionState } from "./actions";
66
import {
77
Card,
88
CardContent,
@@ -14,12 +14,28 @@ import { Input } from "@/components/ui/input";
1414
import { Label } from "@/components/ui/label";
1515
import { Button } from "@/components/ui/button";
1616

17+
function FieldError({ errors }: { errors?: string[] }) {
18+
if (!errors?.length) return null;
19+
return <p className="text-sm text-destructive">{errors[0]}</p>;
20+
}
21+
1722
function LoginForm() {
1823
const searchParams = useSearchParams();
1924
const message = searchParams.get("message");
20-
const error = searchParams.get("error");
2125
const [mode, setMode] = useState<"login" | "signup">("login");
2226

27+
const [loginState, loginAction] = useActionState<ActionState, FormData>(
28+
login,
29+
null
30+
);
31+
const [signupState, signupAction] = useActionState<ActionState, FormData>(
32+
signup,
33+
null
34+
);
35+
36+
const state = mode === "login" ? loginState : signupState;
37+
const action = mode === "login" ? loginAction : signupAction;
38+
2339
return (
2440
<div className="flex min-h-screen items-center justify-center p-4">
2541
<Card className="w-full max-w-md">
@@ -34,67 +50,61 @@ function LoginForm() {
3450
</CardDescription>
3551
</CardHeader>
3652
<CardContent>
37-
{error && (
38-
<div className="mb-4 rounded-md bg-destructive/10 p-3 text-sm text-destructive">
39-
{error}
40-
</div>
41-
)}
4253
{message && (
4354
<div className="mb-4 rounded-md bg-green-500/10 p-3 text-sm text-green-700 dark:text-green-400">
4455
{message}
4556
</div>
4657
)}
4758

48-
<form className="space-y-4">
59+
<form action={action} className="space-y-4" noValidate>
4960
{mode === "signup" && (
5061
<div className="flex gap-3">
51-
<div className="space-y-2 flex-1">
62+
<div className="space-y-1 flex-1">
5263
<Label htmlFor="first_name">First name</Label>
5364
<Input
5465
id="first_name"
5566
name="first_name"
5667
type="text"
57-
required
5868
placeholder="Jane"
5969
/>
70+
<FieldError errors={signupState?.errors?.firstName} />
6071
</div>
61-
<div className="space-y-2 flex-1">
72+
<div className="space-y-1 flex-1">
6273
<Label htmlFor="last_name">Last name</Label>
6374
<Input
6475
id="last_name"
6576
name="last_name"
6677
type="text"
67-
required
6878
placeholder="Doe"
6979
/>
80+
<FieldError errors={signupState?.errors?.lastName} />
7081
</div>
7182
</div>
7283
)}
73-
<div className="space-y-2">
84+
<div className="space-y-1">
7485
<Label htmlFor="email">Email</Label>
7586
<Input
7687
id="email"
7788
name="email"
7889
type="email"
79-
required
8090
placeholder="you@example.com"
8191
/>
92+
<FieldError errors={state?.errors?.email} />
8293
</div>
83-
<div className="space-y-2">
94+
<div className="space-y-1">
8495
<Label htmlFor="password">Password</Label>
8596
<Input
8697
id="password"
8798
name="password"
8899
type="password"
89-
required
90-
minLength={6}
91100
placeholder="••••••••"
92101
/>
102+
<FieldError errors={state?.errors?.password} />
93103
</div>
94104
<div className="flex flex-col gap-2 pt-2">
95105
{mode === "login" ? (
96106
<>
97-
<Button formAction={login}>Log in</Button>
107+
<Button type="submit">Log in</Button>
98108
<Button
99109
type="button"
100110
variant="outline"
@@ -105,7 +115,7 @@ function LoginForm() {
105115
</>
106116
) : (
107117
<>
108-
<Button formAction={signup}>Sign up</Button>
118+
<Button type="submit">Sign up</Button>
109119
<Button
110120
type="button"
111121
variant="outline"

app/login/schema.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { z } from "zod";
2+
3+
export const loginSchema = z.object({
4+
email: z.string().email("Invalid email address"),
5+
password: z.string().min(6, "Password must be at least 6 characters"),
6+
});
7+
8+
export const signupSchema = z.object({
9+
firstName: z.string().min(1, "First name is required"),
10+
lastName: z.string().min(1, "Last name is required"),
11+
email: z.string().email("Invalid email address"),
12+
password: z.string().min(6, "Password must be at least 6 characters"),
13+
});

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@
2929
"react-dom": "19.2.4",
3030
"shadcn": "^4.1.0",
3131
"tailwind-merge": "^3.5.0",
32-
"tw-animate-css": "^1.4.0"
32+
"tw-animate-css": "^1.4.0",
33+
"zod": "^3.24.0"
3334
},
3435
"devDependencies": {
3536
"@tailwindcss/postcss": "^4",

pnpm-lock.yaml

Lines changed: 7 additions & 9 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)