OPT and Improved Installation Wizzard
CI / ci (push) Canceled after 0s

1. Summary of changes
Password auth → email OTP, everywhere it existed for the tenant/business app: sign-in, customer self-registration (/register), staff-invitation acceptance, and the installer's administrator step. Flow is always: email → 6-digit code emailed → code entered → session created, via one shared service (otp.service.ts) reused by every entry point.
NextChair Manager's own auth was not touched — this only affects tenant-side (business admin/staff/customer) authentication, per the spec's explicit instruction.
Installer improvements: Step 2 (Administrator) now asks only Name + Email + optional Phone, verified inline via the same OTP widget (no redirect). Step 3 (Business Details) pre-fills Business Name from the Manager-provisioned BUSINESS_NAME, shows the booking URL (NEXT_PUBLIC_APP_URL) as a read-only info box instead of asking for it, and has a distinctly-labeled "Business Website (optional)" field with copy clarifying it's not the booking URL.
Installation cannot complete with an unverified admin email — enforced server-side (finalizeOtp runs inside the same DB transaction as user/business creation), not just trusted from client state. A mid-wizard refresh still can't create duplicate admins or partial records — that property was preserved by design, not incidentally.
2. Prisma/database changes
New EmailOtp model (email as PK — one row per address, so a new request naturally invalidates the previous code): codeHash, purpose, expiresAt, attempts, verifiedAt, consumedAt, timestamps. Only a bcrypt hash of the code is ever stored.
User.passwordHash — unchanged schema (already nullable), just annotated as deprecated. No destructive migration; historical rows are untouched.
Migration: prisma/migrations/20260905190022_email_otp_authentication — adds EmailOtp only. Generated and applied against throwaway containers only; the real next-chair-mysql production database was never touched (confirmed not even running on this host during this session).
3. New environment variables
None required for production. One new variable, E2E_TEST_MODE, exists purely for the Playwright suite (set only in playwright.config.ts's webServer.env) — it unlocks a /api/test/otp-code debug endpoint and bypasses OTP rate limiting so the same seeded users can sign in dozens of times across one test run. It must never be set on a real deployment; nothing in the app sets it anywhere else.

4. Email delivery configuration
Nothing new to configure. OTP emails reuse the existing EmailProvider abstraction and EMAIL_PROVIDER env var exactly as configured today — console in dev (prints to server logs), brevo in production via the same SMTP credentials already documented. The OTP email is a new template (otp-email.ts) sent directly (bypassing the async notification/cron pipeline, since a 10-minute-expiry code can't wait for a delivery retry loop).

5. Migration considerations for existing users/installations
Existing users lose password sign-in — the only way to authenticate now is email OTP. There is no in-app fallback; this is an intentional, sharp cutover. If existing customers/staff/admins need advance notice, that's a business/communications decision outside this change.
No account data is deleted or migrated — passwordHash rows are simply never read for auth anymore.
prisma/seed.ts / scripts/ensure-super-admin.ts still set a passwordHash for seeded dev users — harmless and vestigial now, not cleaned up (flagging it rather than touching seed data silently).
Installations already completed are unaffected; only new installer runs go through the OTP admin-verification step.
6. Files changed
New:

src/lib/auth/otp.service.ts — the core requestOtp/verifyOtp/finalizeOtp service
src/lib/auth/__tests__/otp.service.integration.test.ts
src/services/notifications/templates/otp-email.ts
src/features/auth/components/otp-code-step.tsx — shared code-entry UI
src/features/auth/actions/request-sign-in-otp.ts
src/app/api/test/otp-code/route.ts — e2e-only, gated on E2E_TEST_MODE
prisma/migrations/20260905190022_email_otp_authentication/
Modified — auth core: prisma/schema.prisma, src/auth.ts, src/features/auth/schemas.ts

Modified — sign-in/register/invitations: sign-in-form.tsx, register.ts, register-form.tsx, accept-invitation.ts, accept-invitation-form.tsx, staff-invitation.service.ts

Modified — installer: installer.service.ts, schema.ts, install.ts, install-wizard.tsx, install/page.tsx, installer.service.integration.test.ts

Modified — email / e2e infra: console-email-provider.ts, playwright.config.ts, e2e/helpers.ts, and the four e2e/*.spec.ts files (dropped the now-unused password argument from every signIn() call).

7. Verification performed
npx tsc --noEmit, npm run lint, npm test (276 unit) — all clean.
npm run test:integration (207 tests, throwaway DB) — includes 11 new OTP-service tests and a rewritten installer suite; a genuine race condition in requestOtp (concurrent requests for a brand-new email double-hitting the upsert's create branch) was caught and fixed here.
Full npm run test:e2e against a fresh throwaway database: 36/37 passed. The one failure (owner-journeys "opening hours conflict" test) is a pre-existing, auth-unrelated ordering flake — appointment-count ambiguity from earlier tests' bookings — not something this change touches or introduced.
Live manual verification in-browser: existing seeded customer sign-in via OTP; new customer registration via OTP (session created, landed on Home); installer wizard end-to-end through Step 3, confirming email-verification gating, Business Name pre-population from BUSINESS_NAME, and the read-only Booking URL / distinct Business Website fields.
All throwaway Docker containers/DBs used for this work have been removed; the real production database was never touched.
This commit is contained in:
Stefan Lazic committed 2026-09-05 21:03:36 +01:00
1 parent ba46334179
commit 94aa1b2f26
29 files changed
+1434 -357

No files matched your search

+11 -14
View File
@@ -3,24 +3,20 @@
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 };
}
/**
* Public — the invited person has no account/session yet. No password or
* OTP step: the invite token itself (delivered only to the invited
* email) is already the trust anchor — see acceptInvitation's own doc
* comment. Signs in via src/auth.ts's token-gated finalize path,
* reusing this same token as the required secret.
*/
export async function acceptInvitationAction(token: string): Promise<AcceptInvitationResult> {
const check = await validateInvitationToken(prisma, token);
if (!check.valid) {
const message =
@@ -32,14 +28,15 @@ export async function acceptInvitationAction(token: string, formData: FormData):
return { success: false, error: message };
}
const result = await acceptInvitation(prisma, { token, password: parsed.data.password });
const result = await acceptInvitation(prisma, { token });
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,
finalize: "true",
token,
redirectTo: "/staff",
});
+57 -22
View File
@@ -1,51 +1,88 @@
"use server";
import bcrypt from "bcryptjs";
import { signIn } from "@/auth";
import { registerRequestSchema, registerVerifySchema } from "@/features/auth/schemas";
import { requestOtp, verifyOtp } from "@/lib/auth/otp.service";
import { getBranding } from "@/lib/business/branding";
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";
import { getEmailProvider } from "@/services/notifications/providers";
import { resolveSenderName } from "@/services/notifications/sender-identity";
export interface RegisterResult {
export interface RegisterActionResult {
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> {
/** Step 1 — collects name/email, sends a code. Unlike sign-in's requestOtp call, this always sends (there's no existing account to hide the presence/absence of — the email-in-use case is reported explicitly, matching this app's existing registration UX). */
export async function requestRegisterOtpAction(formData: FormData): Promise<RegisterActionResult> {
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({
const parsed = registerRequestSchema.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 existing = await prisma.user.findUnique({ where: { email: parsed.data.email } });
if (existing) {
return {
success: false,
error: "Please fix the highlighted fields.",
fieldErrors: parsed.error.flatten().fieldErrors,
error: "An account with this email already exists.",
fieldErrors: { email: ["An account with this email already exists."] },
};
}
const { firstName, lastName, email, phone, password } = parsed.data;
const branding = await getBranding();
await requestOtp(prisma, {
email: parsed.data.email,
purpose: "register",
ip,
senderName: resolveSenderName(branding.configured ? branding.name : null),
emailProvider: getEmailProvider(),
});
return { success: true };
}
/**
* Step 2 — verifies the code, creates the User + CustomerProfile, then
* signs in with the same still-valid (verified-but-not-yet-consumed) code
* — see src/lib/auth/otp.service.ts's header for why verifyOtp can safely
* run again here without a separate finalize/token path: it doesn't
* consume the code, only signIn's own authorize() call does, immediately
* after.
*/
export async function verifyRegisterOtpAction(formData: FormData): Promise<RegisterActionResult> {
const ip = await getRequestIp();
const parsed = registerVerifySchema.safeParse({
firstName: formData.get("firstName"),
lastName: formData.get("lastName"),
email: formData.get("email"),
phone: formData.get("phone"),
code: formData.get("code"),
});
if (!parsed.success) {
return { success: false, error: "Please fix the highlighted fields.", fieldErrors: parsed.error.flatten().fieldErrors };
}
const { firstName, lastName, email, phone, code } = parsed.data;
const verified = await verifyOtp(prisma, email, code, ip);
if (!verified.ok) {
return { success: false, error: "Incorrect or expired code. Please try again." };
}
const existing = await prisma.user.findUnique({ where: { email } });
if (existing) {
@@ -56,14 +93,12 @@ export async function registerAction(formData: FormData): Promise<RegisterResult
};
}
const passwordHash = await bcrypt.hash(password, 12);
await prisma.user.create({
data: {
email,
phone: phone || null,
name: `${firstName} ${lastName}`,
passwordHash,
emailVerified: new Date(),
customerProfile: {
create: {
firstName,
@@ -77,7 +112,7 @@ export async function registerAction(formData: FormData): Promise<RegisterResult
});
const redirectTo = safeRedirectPath(formData.get("callbackUrl")?.toString(), "/home");
await signIn("credentials", { email, password, redirectTo });
await signIn("credentials", { email, code, redirectTo });
return { success: true };
}
@@ -0,0 +1,40 @@
"use server";
import { emailOnlySchema } from "@/features/auth/schemas";
import { requestOtp } from "@/lib/auth/otp.service";
import { getBranding } from "@/lib/business/branding";
import { prisma } from "@/lib/db/prisma";
import { getRequestIp } from "@/lib/security/request-ip";
import { getEmailProvider } from "@/services/notifications/providers";
import { resolveSenderName } from "@/services/notifications/sender-identity";
export interface RequestOtpActionResult {
ok: boolean;
error?: string;
}
/**
* Always returns {ok:true} for a well-formed email address, whether or
* not an account exists for it — `onlyIfUserExists` is what keeps "do not
* reveal on the normal login screen whether an email address exists"
* true, by simply not sending anything (and not creating an OTP row) for
* an email with no account, without the caller being able to tell the
* difference from the response.
*/
export async function requestSignInOtpAction(email: string): Promise<RequestOtpActionResult> {
const parsed = emailOnlySchema.safeParse({ email });
if (!parsed.success) return { ok: false, error: "Enter a valid email address." };
const ip = await getRequestIp();
const branding = await getBranding();
const result = await requestOtp(prisma, {
email: parsed.data.email,
purpose: "login",
ip,
onlyIfUserExists: true,
senderName: resolveSenderName(branding.configured ? branding.name : null),
emailProvider: getEmailProvider(),
});
if (!result.ok) return { ok: false, error: "Too many attempts. Please try again later." };
return { ok: true };
}
@@ -1,69 +1,39 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { useState, useTransition } from "react";
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 [error, setError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
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] });
}
const onAccept = () => {
setError(null);
startTransition(async () => {
const result = await acceptInvitationAction(token);
if (!result.success) {
setError(result.error ?? "Something went wrong. Please try again.");
}
}
// On success, acceptInvitationAction signs the user in and redirects
// server-side — nothing else to do here.
});
};
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate className="space-y-4">
<div 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.
You&apos;ve been invited to join <span className="text-ink font-bold">{businessName}</span>. This link
confirms it&apos;s really you — no password needed.
</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 && (
{error && (
<p role="alert" className="text-danger text-sm font-medium">
{formError}
{error}
</p>
)}
<Button type="submit" className="mt-4 w-full" isLoading={isSubmitting}>
Set Password &amp; Sign In
<Button type="button" className="mt-4 w-full" isLoading={isPending} onClick={onAccept}>
Accept &amp; Sign In
</Button>
</form>
</div>
);
}
@@ -0,0 +1,98 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
export interface OtpVerifyResult {
ok: boolean;
error?: string;
}
export interface OtpCodeStepProps {
email: string;
onVerify: (code: string) => Promise<OtpVerifyResult>;
onResend: () => Promise<OtpVerifyResult>;
onChangeEmail: () => void;
verifyLabel?: string;
}
/**
* The "enter the code" half of every OTP flow in the app — reused
* verbatim by sign-in, registration, and the installer's administrator
* step (embedded inline there, not a redirect — see install-wizard.tsx).
* Deliberately owns only this step: each caller manages its own "email"
* vs "code" phase and renders whatever step-1 fields it needs (sign-in
* needs just an email, registration/installer also need a name) before
* mounting this component — trying to also genericize step 1 fought the
* different field sets more than it saved.
*/
export function OtpCodeStep({ email, onVerify, onResend, onChangeEmail, verifyLabel = "Verify" }: OtpCodeStepProps) {
const [code, setCode] = useState("");
const [error, setError] = useState<string | null>(null);
const [resendMessage, setResendMessage] = useState<string | null>(null);
const [status, setStatus] = useState<"idle" | "verifying" | "resending">("idle");
const handleVerify = async () => {
setError(null);
setResendMessage(null);
setStatus("verifying");
const result = await onVerify(code);
setStatus("idle");
if (!result.ok) setError(result.error ?? "That code didn't work. Please try again.");
};
const handleResend = async () => {
setError(null);
setResendMessage(null);
setStatus("resending");
const result = await onResend();
setStatus("idle");
setResendMessage(result.ok ? "We sent a new code." : (result.error ?? "Couldn't resend right now."));
};
return (
<div className="space-y-4">
<p className="text-muted text-sm">
We sent a code to <span className="text-ink font-semibold">{email}</span>
</p>
<Input
label="Verification Code"
inputMode="numeric"
autoComplete="one-time-code"
placeholder="123456"
maxLength={6}
value={code}
onChange={(e) => setCode(e.target.value.replace(/\D/g, "").slice(0, 6))}
error={error ?? undefined}
/>
{resendMessage && !error && (
<p role="status" className="text-accent-foreground text-sm font-medium">
{resendMessage}
</p>
)}
<Button
type="button"
className="w-full"
isLoading={status === "verifying"}
disabled={code.length !== 6 || status !== "idle"}
onClick={handleVerify}
>
{verifyLabel}
</Button>
<div className="flex items-center justify-between text-sm">
<button
type="button"
onClick={handleResend}
disabled={status !== "idle"}
className="border-ink text-ink border-b-2 font-bold disabled:opacity-50"
>
Resend code
</button>
<button type="button" onClick={onChangeEmail} className="text-muted font-bold underline">
Change email
</button>
</div>
</div>
);
}
+50 -31
View File
@@ -7,48 +7,81 @@ 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 { OtpCodeStep } from "@/features/auth/components/otp-code-step";
import { requestRegisterOtpAction, verifyRegisterOtpAction } from "@/features/auth/actions/register";
import { registerRequestSchema, type RegisterRequestInput } 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 [pending, setPending] = useState<RegisterRequestInput | null>(null);
const {
register,
handleSubmit,
setError,
formState: { errors, isSubmitting },
} = useForm<RegisterInput>({
resolver: zodResolver(registerSchema),
} = useForm<RegisterRequestInput>({
resolver: zodResolver(registerRequestSchema),
});
const onSubmit = async (data: RegisterInput) => {
setFormError(null);
const toFormData = (data: RegisterRequestInput): FormData => {
const formData = new FormData();
Object.entries(data).forEach(([key, value]) => formData.set(key, value ?? ""));
formData.set("callbackUrl", callbackUrl);
formData.set("firstName", data.firstName);
formData.set("lastName", data.lastName);
formData.set("email", data.email);
formData.set("phone", data.phone ?? "");
return formData;
};
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.
const onRequestCode = async (data: RegisterRequestInput) => {
setFormError(null);
const result = await requestRegisterOtpAction(toFormData(data));
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] });
}
if (messages?.[0]) setError(field as keyof RegisterRequestInput, { message: messages[0] });
}
}
return;
}
setPending(data);
};
const verifyCode = async (code: string) => {
if (!pending) return { ok: false, error: "Something went wrong. Please start again." };
const formData = toFormData(pending);
formData.set("code", code);
formData.set("callbackUrl", callbackUrl);
const result = await verifyRegisterOtpAction(formData);
if (!result.success) return { ok: false, error: result.error };
return { ok: true };
};
const resendCode = async () => {
if (!pending) return { ok: false, error: "Something went wrong. Please start again." };
const result = await requestRegisterOtpAction(toFormData(pending));
return result.success ? { ok: true } : { ok: false, error: result.error };
};
if (pending) {
return (
<OtpCodeStep
email={pending.email}
onVerify={verifyCode}
onResend={resendCode}
onChangeEmail={() => setPending(null)}
verifyLabel="Create Account"
/>
);
}
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate className="space-y-4">
<form onSubmit={handleSubmit(onRequestCode)} noValidate className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<Input
label="First Name"
@@ -77,20 +110,6 @@ export function RegisterForm() {
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">
@@ -111,7 +130,7 @@ export function RegisterForm() {
</p>
<Button type="submit" className="mt-4 w-full" isLoading={isSubmitting}>
Sign Up
Continue
</Button>
<p className="text-muted mt-10 text-center text-sm">
+80 -62
View File
@@ -1,7 +1,7 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { Mail, Lock } from "lucide-react";
import { Mail } from "lucide-react";
import { signIn } from "next-auth/react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
@@ -9,7 +9,9 @@ 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 { OtpCodeStep } from "@/features/auth/components/otp-code-step";
import { requestSignInOtpAction } from "@/features/auth/actions/request-sign-in-otp";
import { emailOnlySchema, type EmailOnlyInput } from "@/features/auth/schemas";
import { safeRedirectPath } from "@/lib/utils/safe-redirect";
export function SignInForm() {
@@ -20,87 +22,103 @@ export function SignInForm() {
// 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 [email, setEmail] = useState<string | null>(null);
const [formError, setFormError] = useState<string | null>(null);
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<SignInInput>({
resolver: zodResolver(signInSchema),
} = useForm<EmailOnlyInput>({
resolver: zodResolver(emailOnlySchema),
});
const onSubmit = async (data: SignInInput) => {
const onRequestCode = async (data: EmailOnlyInput) => {
setFormError(null);
const result = await signIn("credentials", { ...data, redirect: false, callbackUrl });
if (result?.error) {
setFormError("Incorrect email or password.");
// Deliberately no branching on the result's success/failure content
// here beyond the rate-limit case — a well-formed email always
// advances to the code step, whether or not an account exists (see
// requestSignInOtpAction's own doc comment for why).
const result = await requestSignInOtpAction(data.email);
if (!result.ok) {
setFormError(result.error ?? "Something went wrong. Please try again.");
return;
}
setEmail(data.email);
};
const verifyCode = async (code: string) => {
if (!email) return { ok: false, error: "Something went wrong. Please start again." };
const result = await signIn("credentials", { email, code, redirect: false, callbackUrl });
if (result?.error) {
return { ok: false, error: "Incorrect or expired code. Please try again." };
}
window.location.assign(result?.url ?? callbackUrl);
return { ok: true };
};
const resendCode = async () => {
if (!email) return { ok: false, error: "Something went wrong. Please start again." };
const result = await requestSignInOtpAction(email);
return result.ok ? { ok: true } : { ok: false, error: result.error };
};
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. */}
{email ? (
<OtpCodeStep email={email} onVerify={verifyCode} onResend={resendCode} onChangeEmail={() => setEmail(null)} verifyLabel="Sign In" />
) : (
<form onSubmit={handleSubmit(onRequestCode)} 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")}
/>
{formError && (
<p role="alert" className="text-danger text-sm font-medium">
{formError}
</p>
)}
{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>
<Button type="submit" className="mt-2 w-full" isLoading={isSubmitting}>
Continue
</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" />
{!email && (
<>
<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>
<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>
<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>
);
}
+29 -30
View File
@@ -1,39 +1,38 @@
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.
* Single source of truth for auth form shapes — shared between client
* forms (react-hook-form + zodResolver) and their server actions. Every
* flow here is now email-OTP-based (see src/lib/auth/otp.service.ts) —
* there is no password field anywhere in this file.
*/
export const signInSchema = z.object({
const otpCode = z
.string()
.trim()
.regex(/^\d{6}$/, "Enter the 6-digit code");
export const emailOnlySchema = 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 type EmailOnlyInput = z.infer<typeof emailOnlySchema>;
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>;
/** Step 2 of sign-in — email is re-sent alongside the code (not trusted from any earlier request) so a stale/forged form can't verify a code against a different address than the one it was requested for. */
export const signInVerifySchema = z.object({
email: z.email("Enter a valid email address"),
code: otpCode,
});
export type SignInVerifyInput = z.infer<typeof signInVerifySchema>;
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>;
export const registerRequestSchema = 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("")),
});
export type RegisterRequestInput = z.infer<typeof registerRequestSchema>;
export const registerVerifySchema = registerRequestSchema.extend({
code: otpCode,
});
export type RegisterVerifyInput = z.infer<typeof registerVerifySchema>;