Revert "PHP Rebuild"

This reverts commit 2ef3654841.
This commit is contained in:
Stefan Lazic committed 2026-09-05 09:32:05 +01:00
1 parent a59f998f2b
commit 2aa8405006
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 };
}
+83
View File
@@ -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&apos;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 &amp; 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&apos;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>
);
}
+39
View File
@@ -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>;