690 files changed
+12045
-33943
No files matched your search
@@ -0,0 +1,47 @@
|
||||
"use server";
|
||||
|
||||
import { signIn } from "@/auth";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { acceptInvitation, validateInvitationToken } from "@/services/staff/staff-invitation.service";
|
||||
import { acceptInvitationSchema } from "@/features/auth/schemas";
|
||||
|
||||
export interface AcceptInvitationResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
fieldErrors?: Record<string, string[] | undefined>;
|
||||
}
|
||||
|
||||
/** Public — the invited person has no account/session yet. Mirrors registerAction's shape: create the credential, then sign in immediately, same as a fresh registration. */
|
||||
export async function acceptInvitationAction(token: string, formData: FormData): Promise<AcceptInvitationResult> {
|
||||
const parsed = acceptInvitationSchema.safeParse({
|
||||
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 check = await validateInvitationToken(prisma, token);
|
||||
if (!check.valid) {
|
||||
const message =
|
||||
check.reason === "EXPIRED"
|
||||
? "This invitation link has expired. Ask the business owner to send you a new one."
|
||||
: check.reason === "ALREADY_ACCEPTED"
|
||||
? "This invitation has already been used — try signing in instead."
|
||||
: "This invitation link isn't valid.";
|
||||
return { success: false, error: message };
|
||||
}
|
||||
|
||||
const result = await acceptInvitation(prisma, { token, password: parsed.data.password });
|
||||
if (!result.success) {
|
||||
return { success: false, error: "Couldn't accept this invitation. Please try again." };
|
||||
}
|
||||
|
||||
await signIn("credentials", {
|
||||
email: check.invitation.staff.user.email,
|
||||
password: parsed.data.password,
|
||||
redirectTo: "/staff",
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
"use server";
|
||||
|
||||
import bcrypt from "bcryptjs";
|
||||
import { signIn } from "@/auth";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { registerSchema } from "@/features/auth/schemas";
|
||||
import { checkRateLimit } from "@/lib/security/rate-limit";
|
||||
import { getRequestIp } from "@/lib/security/request-ip";
|
||||
import { safeRedirectPath } from "@/lib/utils/safe-redirect";
|
||||
|
||||
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 ip = await getRequestIp();
|
||||
const { allowed } = checkRateLimit(`register:${ip}`, { maxAttempts: 5, windowMs: 60 * 60 * 1000 });
|
||||
if (!allowed) {
|
||||
return { success: false, error: "Too many attempts. Please try again later." };
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const redirectTo = safeRedirectPath(formData.get("callbackUrl")?.toString(), "/home");
|
||||
await signIn("credentials", { email, password, redirectTo });
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { acceptInvitationAction } from "@/features/auth/actions/accept-invitation";
|
||||
import { acceptInvitationSchema } from "@/features/auth/schemas";
|
||||
import type { AcceptInvitationInput } from "@/features/auth/schemas";
|
||||
|
||||
export function AcceptInvitationForm({ token, businessName }: { token: string; businessName: string }) {
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setError,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<AcceptInvitationInput>({ resolver: zodResolver(acceptInvitationSchema) });
|
||||
|
||||
const onSubmit = async (data: AcceptInvitationInput) => {
|
||||
setFormError(null);
|
||||
const formData = new FormData();
|
||||
formData.set("password", data.password);
|
||||
formData.set("confirmPassword", data.confirmPassword);
|
||||
|
||||
const result = await acceptInvitationAction(token, formData);
|
||||
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 AcceptInvitationInput, { message: messages[0] });
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} noValidate className="space-y-4">
|
||||
<p className="text-muted text-sm">
|
||||
You've been invited to join <span className="text-ink font-bold">{businessName}</span>. Set a password
|
||||
to finish setting up your account.
|
||||
</p>
|
||||
<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-danger text-sm font-medium">
|
||||
{formError}
|
||||
</p>
|
||||
)}
|
||||
<Button type="submit" className="mt-4 w-full" isLoading={isSubmitting}>
|
||||
Set Password & Sign In
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowLeft, Scissors } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
/**
|
||||
* The shared (auth) layout's top-left slot — the business logo by default,
|
||||
* or a back button to the welcome screen on Sign In and Register, the two
|
||||
* auth pages reached directly from Welcome's own buttons (so "back" has an
|
||||
* obvious, single destination there). forgot-password/verify are reached
|
||||
* from elsewhere in the auth flow instead, where a logo reads better than
|
||||
* an ambiguous "back". A small Client Component rather than making the
|
||||
* whole layout client-side, since `usePathname()` is the only piece that
|
||||
* needs the browser.
|
||||
*/
|
||||
export function AuthHeader({ logoUrl }: { logoUrl: string | null }) {
|
||||
const pathname = usePathname();
|
||||
|
||||
if (pathname === "/register" || pathname === "/sign-in") {
|
||||
return (
|
||||
<Link
|
||||
href="/"
|
||||
aria-label="Back to welcome"
|
||||
className="mb-8 flex h-12 w-12 items-center justify-center rounded-full border border-line text-ink hover:bg-white"
|
||||
>
|
||||
<ArrowLeft className="h-6 w-6" aria-hidden="true" />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto mb-8 flex items-center justify-center text-3xl text-white">
|
||||
{logoUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element -- external, business-supplied URL.
|
||||
<img src={logoUrl} alt="" className="h-40 w-40 object-cover" />
|
||||
) : (
|
||||
<Scissors className="h-7 w-7" aria-hidden="true" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
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 { registerAction } from "@/features/auth/actions/register";
|
||||
import { registerSchema, type RegisterInput } from "@/features/auth/schemas";
|
||||
import { safeRedirectPath } from "@/lib/utils/safe-redirect";
|
||||
|
||||
export function RegisterForm() {
|
||||
const searchParams = useSearchParams();
|
||||
const callbackUrl = safeRedirectPath(searchParams.get("callbackUrl"), "/home");
|
||||
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 ?? ""));
|
||||
formData.set("callbackUrl", callbackUrl);
|
||||
|
||||
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-danger text-sm font-medium">
|
||||
{formError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="text-muted px-1 text-[10px] leading-relaxed">
|
||||
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="text-muted mt-10 text-center text-sm">
|
||||
Already have an account?{" "}
|
||||
<Link href={`/sign-in?callbackUrl=${encodeURIComponent(callbackUrl)}`} className="border-ink text-ink border-b font-bold">
|
||||
Log in
|
||||
</Link>
|
||||
</p>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
"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";
|
||||
import { safeRedirectPath } from "@/lib/utils/safe-redirect";
|
||||
|
||||
export function SignInForm() {
|
||||
const searchParams = useSearchParams();
|
||||
// "/" rather than a hardcoded "/home" — the role isn't known client-side
|
||||
// before sign-in completes, so a bare sign-in (no callbackUrl) routes
|
||||
// through the root welcome page's own post-auth redirect, which already
|
||||
// knows how to send a barber/owner/super admin to their Calendar tab
|
||||
// instead of the customer home. See src/app/page.tsx.
|
||||
const callbackUrl = safeRedirectPath(searchParams.get("callbackUrl"), "/");
|
||||
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-danger text-sm font-medium">
|
||||
{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="border-line w-full border-t" />
|
||||
</div>
|
||||
<span className="bg-stone text-muted relative px-4 text-[10px] font-bold tracking-widest uppercase">
|
||||
Or continue with
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="w-full tracking-normal normal-case"
|
||||
onClick={() => signIn("google", { callbackUrl })}
|
||||
>
|
||||
Continue with Google
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="text-muted mt-10 text-center text-sm">
|
||||
Don't have an account?{" "}
|
||||
<Link href={`/register?callbackUrl=${encodeURIComponent(callbackUrl)}`} className="border-ink text-ink border-b-2 font-bold">
|
||||
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="text-danger hover:bg-danger/10 w-full"
|
||||
onClick={() => signOut({ callbackUrl: "/" })}
|
||||
>
|
||||
<LogOut className="h-4 w-4" aria-hidden="true" />
|
||||
Log Out
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
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>;
|
||||
|
||||
export const acceptInvitationSchema = z
|
||||
.object({
|
||||
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 AcceptInvitationInput = z.infer<typeof acceptInvitationSchema>;
|
||||
Reference in new issue
Block a user