Phase 2
Base app architecture:
Route groups for customer, staff and owner.
* Reusable design system with around 20 UI components.
* Complete Prisma schema.
* Authentication with email/password and Google.
* Role and permission model.
* Protected routes.
* Clean separation between features, services, components and shared utilities.
This commit is contained in:
201 files changed
+31984
No files matched your search
@@ -0,0 +1,73 @@
|
||||
"use server";
|
||||
|
||||
import bcrypt from "bcryptjs";
|
||||
import { signIn } from "@/auth";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { registerSchema } from "@/features/auth/schemas";
|
||||
|
||||
export interface RegisterResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
/** Per-field errors, keyed by field name, for the form to surface inline. */
|
||||
fieldErrors?: Record<string, string[] | undefined>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a User + CustomerProfile in one transaction, then signs the user
|
||||
* straight in — no email-verification gate yet (that needs the
|
||||
* Notifications system, which is out of scope for Phase 2; see
|
||||
* docs/implementation-plan.md §15, Question 3-adjacent).
|
||||
*/
|
||||
export async function registerAction(formData: FormData): Promise<RegisterResult> {
|
||||
const parsed = registerSchema.safeParse({
|
||||
firstName: formData.get("firstName"),
|
||||
lastName: formData.get("lastName"),
|
||||
email: formData.get("email"),
|
||||
phone: formData.get("phone"),
|
||||
password: formData.get("password"),
|
||||
confirmPassword: formData.get("confirmPassword"),
|
||||
});
|
||||
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Please fix the highlighted fields.",
|
||||
fieldErrors: parsed.error.flatten().fieldErrors,
|
||||
};
|
||||
}
|
||||
|
||||
const { firstName, lastName, email, phone, password } = parsed.data;
|
||||
|
||||
const existing = await prisma.user.findUnique({ where: { email } });
|
||||
if (existing) {
|
||||
return {
|
||||
success: false,
|
||||
error: "An account with this email already exists.",
|
||||
fieldErrors: { email: ["An account with this email already exists."] },
|
||||
};
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(password, 12);
|
||||
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
email,
|
||||
phone: phone || null,
|
||||
name: `${firstName} ${lastName}`,
|
||||
passwordHash,
|
||||
customerProfile: {
|
||||
create: {
|
||||
firstName,
|
||||
lastName,
|
||||
email,
|
||||
phone: phone || null,
|
||||
isGuest: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await signIn("credentials", { email, password, redirectTo: "/home" });
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { registerAction } from "@/features/auth/actions/register";
|
||||
import { registerSchema, type RegisterInput } from "@/features/auth/schemas";
|
||||
|
||||
export function RegisterForm() {
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setError,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<RegisterInput>({
|
||||
resolver: zodResolver(registerSchema),
|
||||
});
|
||||
|
||||
const onSubmit = async (data: RegisterInput) => {
|
||||
setFormError(null);
|
||||
const formData = new FormData();
|
||||
Object.entries(data).forEach(([key, value]) => formData.set(key, value ?? ""));
|
||||
|
||||
const result = await registerAction(formData);
|
||||
|
||||
// A successful call redirects server-side and never resolves here with
|
||||
// success: true in practice, but we still handle it for completeness.
|
||||
if (!result.success) {
|
||||
setFormError(result.error ?? "Something went wrong. Please try again.");
|
||||
if (result.fieldErrors) {
|
||||
for (const [field, messages] of Object.entries(result.fieldErrors)) {
|
||||
if (messages?.[0]) {
|
||||
setError(field as keyof RegisterInput, { message: messages[0] });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} noValidate className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input label="First Name" placeholder="Alex" error={errors.firstName?.message} {...register("firstName")} />
|
||||
<Input label="Last Name" placeholder="Chen" error={errors.lastName?.message} {...register("lastName")} />
|
||||
</div>
|
||||
<Input
|
||||
label="Email Address"
|
||||
type="email"
|
||||
placeholder="name@example.com"
|
||||
error={errors.email?.message}
|
||||
{...register("email")}
|
||||
/>
|
||||
<Input
|
||||
label="Phone Number (optional)"
|
||||
type="tel"
|
||||
placeholder="+44 7700 900000"
|
||||
error={errors.phone?.message}
|
||||
{...register("phone")}
|
||||
/>
|
||||
<Input
|
||||
label="Password"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
error={errors.password?.message}
|
||||
{...register("password")}
|
||||
/>
|
||||
<Input
|
||||
label="Confirm Password"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
error={errors.confirmPassword?.message}
|
||||
{...register("confirmPassword")}
|
||||
/>
|
||||
|
||||
{formError && (
|
||||
<p role="alert" className="text-sm font-medium text-red-500">
|
||||
{formError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="px-1 text-[10px] leading-relaxed text-muted">
|
||||
By creating an account, you agree to our <a href="#" className="underline">Terms of Service</a> and{" "}
|
||||
<a href="#" className="underline">Privacy Policy</a>.
|
||||
</p>
|
||||
|
||||
<Button type="submit" className="mt-4 w-full" isLoading={isSubmitting}>
|
||||
Sign Up
|
||||
</Button>
|
||||
|
||||
<p className="mt-10 text-center text-sm text-muted">
|
||||
Already have an account?{" "}
|
||||
<Link href="/sign-in" className="border-b border-ink font-bold text-ink">
|
||||
Log in
|
||||
</Link>
|
||||
</p>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Mail, Lock } from "lucide-react";
|
||||
import { signIn } from "next-auth/react";
|
||||
import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { signInSchema, type SignInInput } from "@/features/auth/schemas";
|
||||
|
||||
export function SignInForm() {
|
||||
const searchParams = useSearchParams();
|
||||
const callbackUrl = searchParams.get("callbackUrl") ?? "/home";
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<SignInInput>({
|
||||
resolver: zodResolver(signInSchema),
|
||||
});
|
||||
|
||||
const onSubmit = async (data: SignInInput) => {
|
||||
setFormError(null);
|
||||
const result = await signIn("credentials", { ...data, redirect: false, callbackUrl });
|
||||
if (result?.error) {
|
||||
setFormError("Incorrect email or password.");
|
||||
return;
|
||||
}
|
||||
window.location.assign(result?.url ?? callbackUrl);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<form onSubmit={handleSubmit(onSubmit)} noValidate className="space-y-4">
|
||||
<Input
|
||||
label="Email Address"
|
||||
type="email"
|
||||
icon={<Mail className="h-4 w-4" aria-hidden="true" />}
|
||||
placeholder="name@example.com"
|
||||
error={errors.email?.message}
|
||||
{...register("email")}
|
||||
/>
|
||||
<Input
|
||||
label="Password"
|
||||
id="password"
|
||||
type="password"
|
||||
icon={<Lock className="h-4 w-4" aria-hidden="true" />}
|
||||
placeholder="••••••••"
|
||||
error={errors.password?.message}
|
||||
{...register("password")}
|
||||
/>
|
||||
{/* Password reset needs the Notifications system (email delivery)
|
||||
to actually send a reset link — out of scope for this phase.
|
||||
The "Forgot Password" template (8) is a design reference for
|
||||
when that lands; no dead link to it here in the meantime. */}
|
||||
|
||||
{formError && (
|
||||
<p role="alert" className="text-sm font-medium text-red-500">
|
||||
{formError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Button type="submit" className="mt-2 w-full" isLoading={isSubmitting}>
|
||||
Sign In
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="mt-10">
|
||||
<div className="relative mb-8 flex items-center justify-center">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-line" />
|
||||
</div>
|
||||
<span className="relative bg-stone px-4 text-[10px] font-bold uppercase tracking-widest text-muted">
|
||||
Or continue with
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="w-full normal-case tracking-normal"
|
||||
onClick={() => signIn("google", { callbackUrl })}
|
||||
>
|
||||
Continue with Google
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="mt-10 text-center text-sm text-muted">
|
||||
Don't have an account?{" "}
|
||||
<Link href="/register" className="border-b-2 border-ink font-bold text-ink">
|
||||
Create one
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { LogOut } from "lucide-react";
|
||||
import { signOut } from "next-auth/react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export function SignOutButton() {
|
||||
return (
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="w-full text-red-500 hover:bg-red-50"
|
||||
onClick={() => signOut({ callbackUrl: "/" })}
|
||||
>
|
||||
<LogOut className="h-4 w-4" aria-hidden="true" />
|
||||
Log Out
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* Single source of truth for auth form shapes — shared between the client
|
||||
* form (react-hook-form + zodResolver) and the server action / Credentials
|
||||
* `authorize()` callback, so validation never drifts between the two.
|
||||
*/
|
||||
|
||||
export const signInSchema = z.object({
|
||||
email: z.email("Enter a valid email address"),
|
||||
password: z.string().min(1, "Password is required"),
|
||||
});
|
||||
export type SignInInput = z.infer<typeof signInSchema>;
|
||||
|
||||
export const registerSchema = z
|
||||
.object({
|
||||
firstName: z.string().trim().min(1, "First name is required"),
|
||||
lastName: z.string().trim().min(1, "Last name is required"),
|
||||
email: z.email("Enter a valid email address"),
|
||||
phone: z.string().trim().optional().or(z.literal("")),
|
||||
password: z.string().min(8, "Password must be at least 8 characters"),
|
||||
confirmPassword: z.string(),
|
||||
})
|
||||
.refine((data) => data.password === data.confirmPassword, {
|
||||
message: "Passwords don't match",
|
||||
path: ["confirmPassword"],
|
||||
});
|
||||
export type RegisterInput = z.infer<typeof registerSchema>;
|
||||
Reference in new issue
Block a user