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:
29 files changed
+1434
-357
No files matched your search
@@ -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",
|
||||
});
|
||||
|
||||
|
||||
@@ -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've been invited to join <span className="text-ink font-bold">{businessName}</span>. Set a password
|
||||
to finish setting up your account.
|
||||
You've been invited to join <span className="text-ink font-bold">{businessName}</span>. This link
|
||||
confirms it'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 & Sign In
|
||||
<Button type="button" className="mt-4 w-full" isLoading={isPending} onClick={onAccept}>
|
||||
Accept & 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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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'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'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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -1,6 +1,8 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { prisma } from "@/services/scheduling/__tests__/fixtures";
|
||||
import { InstallationStatus } from "@/generated/prisma/enums";
|
||||
import { requestOtp, verifyOtp } from "@/lib/auth/otp.service";
|
||||
import { MockEmailProvider } from "@/services/notifications/providers/mock-email-provider";
|
||||
import { installApplication } from "../installer.service";
|
||||
import type { InstallInput } from "../schema";
|
||||
|
||||
@@ -25,8 +27,6 @@ function buildInput(overrides: Partial<InstallInput> = {}): InstallInput {
|
||||
lastName: "Owner",
|
||||
email: `install-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@example.com`,
|
||||
phone: "",
|
||||
password: "correct-horse-battery",
|
||||
confirmPassword: "correct-horse-battery",
|
||||
},
|
||||
business: {
|
||||
name: "Integration Test Barbershop",
|
||||
@@ -75,6 +75,25 @@ function buildInput(overrides: Partial<InstallInput> = {}): InstallInput {
|
||||
};
|
||||
}
|
||||
|
||||
/** Drives the real requestOtp/verifyOtp pair via a MockEmailProvider (never a real send) so a test can get an administrator's email into the "verified, not yet consumed" state installApplication now requires — see installer.service.ts's finalizeOtp call. */
|
||||
async function verifyAdministratorEmail(email: string): Promise<void> {
|
||||
const mockProvider = new MockEmailProvider();
|
||||
await requestOtp(prisma, { email, purpose: "install", emailProvider: mockProvider });
|
||||
const sent = mockProvider.sentEmails[0];
|
||||
const code = /verification code is (\d{6})/.exec(sent?.text ?? "")?.[1];
|
||||
if (!code) throw new Error("Test setup failed: couldn't extract the OTP code from the captured email.");
|
||||
|
||||
const result = await verifyOtp(prisma, email, code);
|
||||
if (!result.ok) throw new Error(`Test setup failed: OTP verification failed (${result.reason}).`);
|
||||
}
|
||||
|
||||
/** buildInput() plus the email-verification step every successful install now requires. */
|
||||
async function buildVerifiedInput(overrides: Partial<InstallInput> = {}): Promise<InstallInput> {
|
||||
const input = buildInput(overrides);
|
||||
await verifyAdministratorEmail(input.administrator.email);
|
||||
return input;
|
||||
}
|
||||
|
||||
async function resetInstallationSingleton(): Promise<void> {
|
||||
await prisma.applicationInstallation.deleteMany({ where: { id: "singleton" } });
|
||||
}
|
||||
@@ -116,6 +135,8 @@ describe("installApplication (integration)", () => {
|
||||
const originalToken = process.env.INSTALLATION_TOKEN;
|
||||
process.env.INSTALLATION_TOKEN = INSTALLATION_TOKEN;
|
||||
try {
|
||||
// Deliberately unverified — the token check happens first and must
|
||||
// reject before ever reaching the OTP check.
|
||||
const result = await installApplication(prisma, {
|
||||
input: buildInput({ installationToken: "wrong-token" }),
|
||||
installedVersion: "test",
|
||||
@@ -132,11 +153,31 @@ describe("installApplication (integration)", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses to install with an unverified administrator email", async () => {
|
||||
const originalToken = process.env.INSTALLATION_TOKEN;
|
||||
process.env.INSTALLATION_TOKEN = INSTALLATION_TOKEN;
|
||||
try {
|
||||
// buildInput() alone — no verifyAdministratorEmail call.
|
||||
const result = await installApplication(prisma, { input: buildInput(), installedVersion: "test" });
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) expect(result.code).toBe("EMAIL_NOT_VERIFIED");
|
||||
|
||||
const business = await prisma.business.findFirst({ where: { name: "Integration Test Barbershop" } });
|
||||
expect(business).toBeNull();
|
||||
// The singleton lock row must roll back too — a rejected attempt
|
||||
// shouldn't leave the whole application stuck "INSTALLING".
|
||||
const installation = await prisma.applicationInstallation.findUnique({ where: { id: "singleton" } });
|
||||
expect(installation).toBeNull();
|
||||
} finally {
|
||||
process.env.INSTALLATION_TOKEN = originalToken;
|
||||
}
|
||||
});
|
||||
|
||||
it("creates the business, administrator, permissions, booking rules, and starter services transactionally", async () => {
|
||||
const originalToken = process.env.INSTALLATION_TOKEN;
|
||||
process.env.INSTALLATION_TOKEN = INSTALLATION_TOKEN;
|
||||
try {
|
||||
const result = await installApplication(prisma, { input: buildInput(), installedVersion: "test" });
|
||||
const result = await installApplication(prisma, { input: await buildVerifiedInput(), installedVersion: "test" });
|
||||
expect(result.success).toBe(true);
|
||||
if (!result.success) return;
|
||||
createdBusinessId = result.businessId;
|
||||
@@ -164,6 +205,10 @@ describe("installApplication (integration)", () => {
|
||||
expect(barberRole).toBeDefined();
|
||||
expect(ownerRole!.permissions.length).toBeGreaterThan(barberRole!.permissions.length);
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { id: result.userId } });
|
||||
expect(user?.emailVerified).not.toBeNull();
|
||||
expect(user?.passwordHash).toBeNull();
|
||||
|
||||
const staffProfile = await prisma.staffProfile.findUnique({ where: { userId: result.userId }, include: { services: true } });
|
||||
expect(staffProfile?.roleId).toBe(ownerRole!.id);
|
||||
// The initial owner is eligible for every starter service — see the brief's "initial owner is service-eligible."
|
||||
@@ -184,14 +229,15 @@ describe("installApplication (integration)", () => {
|
||||
const originalToken = process.env.INSTALLATION_TOKEN;
|
||||
process.env.INSTALLATION_TOKEN = INSTALLATION_TOKEN;
|
||||
try {
|
||||
const first = await installApplication(prisma, { input: buildInput(), installedVersion: "test" });
|
||||
const first = await installApplication(prisma, { input: await buildVerifiedInput(), installedVersion: "test" });
|
||||
expect(first.success).toBe(true);
|
||||
if (first.success) createdBusinessId = first.businessId;
|
||||
|
||||
const secondEmail = `second-${Date.now()}@example.com`;
|
||||
await verifyAdministratorEmail(secondEmail);
|
||||
const secondInput = buildInput();
|
||||
const second = await installApplication(prisma, {
|
||||
input: buildInput({
|
||||
administrator: { ...buildInput().administrator, email: `second-${Date.now()}@example.com` },
|
||||
}),
|
||||
input: { ...secondInput, administrator: { ...secondInput.administrator, email: secondEmail } },
|
||||
installedVersion: "test",
|
||||
});
|
||||
expect(second.success).toBe(false);
|
||||
@@ -205,8 +251,12 @@ describe("installApplication (integration)", () => {
|
||||
const originalToken = process.env.INSTALLATION_TOKEN;
|
||||
process.env.INSTALLATION_TOKEN = INSTALLATION_TOKEN;
|
||||
try {
|
||||
const inputA = buildInput({ administrator: { ...buildInput().administrator, email: `concurrent-a-${Date.now()}@example.com` } });
|
||||
const inputB = buildInput({ administrator: { ...buildInput().administrator, email: `concurrent-b-${Date.now()}@example.com` } });
|
||||
const emailA = `concurrent-a-${Date.now()}@example.com`;
|
||||
const emailB = `concurrent-b-${Date.now()}@example.com`;
|
||||
await verifyAdministratorEmail(emailA);
|
||||
await verifyAdministratorEmail(emailB);
|
||||
const inputA = buildInput({ administrator: { ...buildInput().administrator, email: emailA } });
|
||||
const inputB = buildInput({ administrator: { ...buildInput().administrator, email: emailB } });
|
||||
|
||||
const [resultA, resultB] = await Promise.all([
|
||||
installApplication(prisma, { input: inputA, installedVersion: "test" }),
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
"use server";
|
||||
|
||||
import { signIn } from "@/auth";
|
||||
import { emailOnlySchema, signInVerifySchema } from "@/features/auth/schemas";
|
||||
import { requestOtp, verifyOtp } from "@/lib/auth/otp.service";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { getRequestIp } from "@/lib/security/request-ip";
|
||||
import { checkRateLimit } from "@/lib/security/rate-limit";
|
||||
import { getRequestIp } from "@/lib/security/request-ip";
|
||||
import { secretsMatch } from "@/lib/security/secrets-match";
|
||||
import { getEmailProvider } from "@/services/notifications/providers";
|
||||
import { installApplication } from "../installer.service";
|
||||
import { installSchema } from "../schema";
|
||||
import type { InstallInput } from "../schema";
|
||||
@@ -40,6 +43,58 @@ export async function verifyInstallationTokenAction(token: string): Promise<Veri
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
export interface InstallOtpActionResult {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Both OTP actions below additionally require the installation token —
|
||||
* not just email format — because the wizard's Administrator step embeds
|
||||
* this inline rather than redirecting to /sign-in, and because
|
||||
* src/auth.ts's later `{finalize:"true", token}` sign-in call reuses this
|
||||
* same token as proof the caller is this specific trusted flow, not a
|
||||
* direct POST to the public credentials callback route (see that file's
|
||||
* own header for the full reasoning). Requiring it here too means someone
|
||||
* without the installation token can't even generate/verify a code for an
|
||||
* arbitrary email through this endpoint.
|
||||
*/
|
||||
export async function requestInstallOtpAction(email: string, installationToken: string): Promise<InstallOtpActionResult> {
|
||||
const tokenCheck = await verifyInstallationTokenAction(installationToken);
|
||||
if (!tokenCheck.valid) return { ok: false, error: tokenCheck.error ?? "Installation is not available." };
|
||||
|
||||
const parsed = emailOnlySchema.safeParse({ email });
|
||||
if (!parsed.success) return { ok: false, error: "Enter a valid email address." };
|
||||
|
||||
const ip = await getRequestIp();
|
||||
const result = await requestOtp(prisma, {
|
||||
email: parsed.data.email,
|
||||
purpose: "install",
|
||||
ip,
|
||||
senderName: "NextChair",
|
||||
emailProvider: getEmailProvider(),
|
||||
});
|
||||
if (!result.ok) return { ok: false, error: "Too many attempts. Please try again later." };
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function verifyInstallOtpAction(
|
||||
email: string,
|
||||
code: string,
|
||||
installationToken: string,
|
||||
): Promise<InstallOtpActionResult> {
|
||||
const tokenCheck = await verifyInstallationTokenAction(installationToken);
|
||||
if (!tokenCheck.valid) return { ok: false, error: tokenCheck.error ?? "Installation is not available." };
|
||||
|
||||
const parsed = signInVerifySchema.safeParse({ email, code });
|
||||
if (!parsed.success) return { ok: false, error: "Enter the 6-digit code." };
|
||||
|
||||
const ip = await getRequestIp();
|
||||
const result = await verifyOtp(prisma, parsed.data.email, parsed.data.code, ip);
|
||||
if (!result.ok) return { ok: false, error: "Incorrect or expired code. Please try again." };
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export interface SubmitInstallationResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
@@ -63,9 +118,16 @@ export async function submitInstallationAction(input: InstallInput): Promise<Sub
|
||||
return { success: false, error: result.message };
|
||||
}
|
||||
|
||||
// The administrator's OTP was already consumed inside installApplication
|
||||
// (see finalizeOtp there) — this sign-in uses the token-gated finalize
|
||||
// path, not a fresh code, since the code itself can't be re-entered
|
||||
// (one-time use). See src/auth.ts's own header for why `token` is
|
||||
// required here rather than just trusting this call came from the right
|
||||
// place.
|
||||
await signIn("credentials", {
|
||||
email: parsed.data.administrator.email,
|
||||
password: parsed.data.administrator.password,
|
||||
finalize: "true",
|
||||
token: parsed.data.installationToken,
|
||||
redirectTo: "/owner?installed=1",
|
||||
});
|
||||
|
||||
|
||||
@@ -5,7 +5,13 @@ import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { submitInstallationAction, verifyInstallationTokenAction } from "../actions/install";
|
||||
import { OtpCodeStep } from "@/features/auth/components/otp-code-step";
|
||||
import {
|
||||
requestInstallOtpAction,
|
||||
submitInstallationAction,
|
||||
verifyInstallOtpAction,
|
||||
verifyInstallationTokenAction,
|
||||
} from "../actions/install";
|
||||
import type { InstallInput } from "../schema";
|
||||
|
||||
/**
|
||||
@@ -105,7 +111,15 @@ const DEFAULT_STARTER_SERVICES: StarterServiceState[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export function InstallWizard() {
|
||||
export function InstallWizard({
|
||||
initialBusinessName = "",
|
||||
bookingUrl,
|
||||
}: {
|
||||
/** From process.env.BUSINESS_NAME, set by the Manager at provisioning time (see manager/src/lib/compose-template.ts) — pre-fills Step 3's Business Name but stays editable, since the operator may want a slightly different public name. */
|
||||
initialBusinessName?: string;
|
||||
/** From process.env.NEXT_PUBLIC_APP_URL, also Manager-provisioned — shown read-only in Step 3, never an editable field: this is infrastructure/routing configuration, not business profile data (see stacks/infra's Caddy site, which is what actually makes this domain work). */
|
||||
bookingUrl?: string;
|
||||
}) {
|
||||
const [step, setStep] = useState(1);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
@@ -114,11 +128,23 @@ export function InstallWizard() {
|
||||
const [token, setToken] = useState("");
|
||||
const [tokenError, setTokenError] = useState<string | null>(null);
|
||||
|
||||
// Step 2
|
||||
const [admin, setAdmin] = useState({ firstName: "", lastName: "", email: "", phone: "", password: "", confirmPassword: "" });
|
||||
// Step 2 — otpPhase tracks verification separately from the rest of the
|
||||
// form: "collect" (editing name/email/phone), "code" (entering the
|
||||
// emailed code), "verified" (done, can proceed). Editing the email after
|
||||
// verifying resets to "collect" — see the email field's onChange below.
|
||||
const [admin, setAdmin] = useState({ firstName: "", lastName: "", email: "", phone: "" });
|
||||
const [otpPhase, setOtpPhase] = useState<"collect" | "code" | "verified">("collect");
|
||||
const [otpError, setOtpError] = useState<string | null>(null);
|
||||
|
||||
// Step 3
|
||||
const [business, setBusiness] = useState({ name: "", shortName: "", description: "", email: "", phone: "", website: "" });
|
||||
const [business, setBusiness] = useState({
|
||||
name: initialBusinessName,
|
||||
shortName: "",
|
||||
description: "",
|
||||
email: "",
|
||||
phone: "",
|
||||
website: "",
|
||||
});
|
||||
|
||||
// Step 4
|
||||
const [location, setLocation] = useState({
|
||||
@@ -177,6 +203,30 @@ export function InstallWizard() {
|
||||
});
|
||||
};
|
||||
|
||||
const requestAdminCode = () => {
|
||||
setOtpError(null);
|
||||
startTransition(async () => {
|
||||
const result = await requestInstallOtpAction(admin.email, token);
|
||||
if (!result.ok) {
|
||||
setOtpError(result.error ?? "Couldn't send a code. Please try again.");
|
||||
return;
|
||||
}
|
||||
setOtpPhase("code");
|
||||
});
|
||||
};
|
||||
|
||||
const verifyAdminCode = async (code: string) => {
|
||||
const result = await verifyInstallOtpAction(admin.email, code, token);
|
||||
if (!result.ok) return { ok: false, error: result.error };
|
||||
setOtpPhase("verified");
|
||||
return { ok: true };
|
||||
};
|
||||
|
||||
const resendAdminCode = async () => {
|
||||
const result = await requestInstallOtpAction(admin.email, token);
|
||||
return result.ok ? { ok: true } : { ok: false, error: result.error };
|
||||
};
|
||||
|
||||
const updateDay = (dayOfWeek: WeekdayCode, patch: Partial<HoursDayState>) => {
|
||||
setHours((prev) => prev.map((d) => (d.dayOfWeek === dayOfWeek ? { ...d, ...patch } : d)));
|
||||
};
|
||||
@@ -261,13 +311,7 @@ export function InstallWizard() {
|
||||
const canProceedFromStep = (s: number): boolean => {
|
||||
switch (s) {
|
||||
case 2:
|
||||
return (
|
||||
!!admin.firstName.trim() &&
|
||||
!!admin.lastName.trim() &&
|
||||
!!admin.email.trim() &&
|
||||
admin.password.length >= 8 &&
|
||||
admin.password === admin.confirmPassword
|
||||
);
|
||||
return !!admin.firstName.trim() && !!admin.lastName.trim() && !!admin.email.trim() && otpPhase === "verified";
|
||||
case 3:
|
||||
return !!business.name.trim();
|
||||
case 4:
|
||||
@@ -320,36 +364,88 @@ export function InstallWizard() {
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-xl font-bold">Administrator account</h2>
|
||||
<p className="text-muted text-sm">This becomes the first owner — full permissions, protected from removal.</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Input label="First Name" value={admin.firstName} onChange={(e) => setAdmin({ ...admin, firstName: e.target.value })} required />
|
||||
<Input label="Last Name" value={admin.lastName} onChange={(e) => setAdmin({ ...admin, lastName: e.target.value })} required />
|
||||
</div>
|
||||
<Input label="Email Address" type="email" value={admin.email} onChange={(e) => setAdmin({ ...admin, email: e.target.value })} required />
|
||||
<Input label="Phone (optional)" type="tel" value={admin.phone} onChange={(e) => setAdmin({ ...admin, phone: e.target.value })} />
|
||||
<Input
|
||||
label="Password"
|
||||
type="password"
|
||||
value={admin.password}
|
||||
onChange={(e) => setAdmin({ ...admin, password: e.target.value })}
|
||||
hint="At least 8 characters."
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="Confirm Password"
|
||||
type="password"
|
||||
value={admin.confirmPassword}
|
||||
onChange={(e) => setAdmin({ ...admin, confirmPassword: e.target.value })}
|
||||
error={admin.confirmPassword && admin.password !== admin.confirmPassword ? "Passwords don't match" : undefined}
|
||||
required
|
||||
/>
|
||||
<StepNav onBack={() => setStep(1)} onNext={() => setStep(3)} nextDisabled={!canProceedFromStep(2)} />
|
||||
|
||||
{otpPhase === "code" ? (
|
||||
<OtpCodeStep
|
||||
email={admin.email}
|
||||
onVerify={verifyAdminCode}
|
||||
onResend={resendAdminCode}
|
||||
onChangeEmail={() => setOtpPhase("collect")}
|
||||
verifyLabel="Verify Email"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Input label="First Name" value={admin.firstName} onChange={(e) => setAdmin({ ...admin, firstName: e.target.value })} required />
|
||||
<Input label="Last Name" value={admin.lastName} onChange={(e) => setAdmin({ ...admin, lastName: e.target.value })} required />
|
||||
</div>
|
||||
<Input
|
||||
label="Email Address"
|
||||
type="email"
|
||||
value={admin.email}
|
||||
onChange={(e) => {
|
||||
setAdmin({ ...admin, email: e.target.value });
|
||||
// Editing the email after verifying invalidates that
|
||||
// verification — it was proof of ownership of the old
|
||||
// address, not this one.
|
||||
if (otpPhase === "verified") setOtpPhase("collect");
|
||||
}}
|
||||
required
|
||||
/>
|
||||
<Input label="Phone (optional)" type="tel" value={admin.phone} onChange={(e) => setAdmin({ ...admin, phone: e.target.value })} />
|
||||
|
||||
{otpPhase === "verified" ? (
|
||||
<p role="status" className="text-accent-foreground text-sm font-medium">
|
||||
✓ {admin.email} verified
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
{otpError && (
|
||||
<p role="alert" className="text-danger text-sm font-medium">
|
||||
{otpError}
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="w-full"
|
||||
isLoading={isPending}
|
||||
disabled={!admin.firstName.trim() || !admin.lastName.trim() || !admin.email.trim()}
|
||||
onClick={requestAdminCode}
|
||||
>
|
||||
Send Verification Code
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{otpPhase !== "code" && (
|
||||
<StepNav onBack={() => setStep(1)} onNext={() => setStep(3)} nextDisabled={!canProceedFromStep(2)} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 3 && (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-xl font-bold">Business details</h2>
|
||||
<Input label="Business Name" value={business.name} onChange={(e) => setBusiness({ ...business, name: e.target.value })} required />
|
||||
{bookingUrl && (
|
||||
<div className="rounded-md border-line border bg-white p-4">
|
||||
<p className="text-muted text-[10px] font-bold tracking-widest uppercase">Booking URL</p>
|
||||
<p className="mt-1 text-sm">{bookingUrl}</p>
|
||||
<p className="text-muted mt-1 text-xs">
|
||||
Where customers book with you online — already set up for this deployment, not something to
|
||||
configure here.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<Input
|
||||
label="Business Name"
|
||||
value={business.name}
|
||||
onChange={(e) => setBusiness({ ...business, name: e.target.value })}
|
||||
hint={initialBusinessName ? "Pre-filled from setup — change it if you'd like a different public name." : undefined}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="Short Name (optional)"
|
||||
hint="Used where space is tight, e.g. the home-screen icon label."
|
||||
@@ -363,7 +459,13 @@ export function InstallWizard() {
|
||||
/>
|
||||
<Input label="Business Email (optional)" type="email" value={business.email} onChange={(e) => setBusiness({ ...business, email: e.target.value })} />
|
||||
<Input label="Phone Number (optional)" type="tel" value={business.phone} onChange={(e) => setBusiness({ ...business, phone: e.target.value })} />
|
||||
<Input label="Website (optional)" value={business.website} onChange={(e) => setBusiness({ ...business, website: e.target.value })} />
|
||||
<Input
|
||||
label="Business Website (optional)"
|
||||
placeholder="https://yourbusiness.com"
|
||||
hint="Your business's own main website, if you have one — not the booking URL above."
|
||||
value={business.website}
|
||||
onChange={(e) => setBusiness({ ...business, website: e.target.value })}
|
||||
/>
|
||||
<StepNav onBack={() => setStep(2)} onNext={() => setStep(4)} nextDisabled={!canProceedFromStep(3)} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import bcrypt from "bcryptjs";
|
||||
import type { PrismaClient, Prisma } from "@/generated/prisma/client";
|
||||
import { InstallationStatus, RewardType } from "@/generated/prisma/enums";
|
||||
import { finalizeOtp } from "@/lib/auth/otp.service";
|
||||
import { INSTALLATION_SINGLETON_ID } from "@/lib/installation/installation-state";
|
||||
import { OWNER_PERMISSIONS, BARBER_PERMISSIONS } from "@/lib/roles/default-permissions";
|
||||
import { secretsMatch } from "@/lib/security/secrets-match";
|
||||
@@ -16,6 +16,7 @@ export type InstallErrorCode =
|
||||
| "ALREADY_INSTALLED"
|
||||
| "VALIDATION_FAILED"
|
||||
| "EMAIL_ALREADY_IN_USE"
|
||||
| "EMAIL_NOT_VERIFIED"
|
||||
| "INSTALL_FAILED";
|
||||
|
||||
export interface InstallSuccess {
|
||||
@@ -32,6 +33,16 @@ export interface InstallFailure {
|
||||
|
||||
export type InstallResult = InstallSuccess | InstallFailure;
|
||||
|
||||
/** Thrown inside the transaction to carry a specific InstallErrorCode out through the generic try/catch below — see installApplication's own use. */
|
||||
class InstallStepError extends Error {
|
||||
constructor(
|
||||
readonly code: InstallErrorCode,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
function slugify(name: string): string {
|
||||
const base = name
|
||||
.toLowerCase()
|
||||
@@ -149,6 +160,22 @@ export async function installApplication(
|
||||
|
||||
const { administrator, business: businessInput, location, hours, bookingRules, branding, starterServices } =
|
||||
params.input;
|
||||
const administratorEmail = administrator.email.toLowerCase();
|
||||
|
||||
// The administrator's email must have been verified via OTP in the
|
||||
// wizard's own Administrator step (see install-wizard.tsx and
|
||||
// src/lib/auth/otp.service.ts's verifiedAt/consumedAt design) —
|
||||
// checked and consumed here, inside this same transaction, so it
|
||||
// rolls back together with everything else if a later step fails
|
||||
// (letting an immediate retry reuse the same verification) and so
|
||||
// installation genuinely cannot complete with an unverified email,
|
||||
// not just "the client claimed it was verified".
|
||||
if (!(await finalizeOtp(tx, administratorEmail))) {
|
||||
throw new InstallStepError(
|
||||
"EMAIL_NOT_VERIFIED",
|
||||
"This email hasn't been verified — please verify it again before finishing setup.",
|
||||
);
|
||||
}
|
||||
|
||||
const business = await tx.business.create({
|
||||
data: {
|
||||
@@ -216,13 +243,13 @@ export async function installApplication(
|
||||
}),
|
||||
]);
|
||||
|
||||
const passwordHash = await bcrypt.hash(administrator.password, 12);
|
||||
const user = await tx.user.create({
|
||||
data: {
|
||||
email: administrator.email.toLowerCase(),
|
||||
email: administratorEmail,
|
||||
name: `${administrator.firstName.trim()} ${administrator.lastName.trim()}`,
|
||||
phone: administrator.phone?.trim() || null,
|
||||
passwordHash,
|
||||
// Email OTP is the only auth method now — see finalizeOtp above,
|
||||
// which is what actually proved this email, not a password.
|
||||
emailVerified: new Date(),
|
||||
staffProfile: {
|
||||
create: {
|
||||
@@ -299,11 +326,15 @@ export async function installApplication(
|
||||
|
||||
return { success: true, userId: result.userId, businessId: result.businessId };
|
||||
} catch (error) {
|
||||
// A concurrent installer won the create() race — see the header
|
||||
// comment above. Anything else is an unexpected failure; either way,
|
||||
// the transaction rolled back and left no partial data (see
|
||||
// A concurrent installer won the create() race, or the administrator's
|
||||
// email wasn't verified — see the header comment above and
|
||||
// InstallStepError. Anything else is an unexpected failure; either
|
||||
// way, the transaction rolled back and left no partial data (see
|
||||
// ApplicationInstallation's own header for why that's true even for
|
||||
// the lock row itself).
|
||||
if (error instanceof InstallStepError) {
|
||||
return { success: false, code: error.code, message: error.message };
|
||||
}
|
||||
const isDuplicateSingleton =
|
||||
typeof error === "object" && error !== null && "code" in error && (error as { code?: string }).code === "P2002";
|
||||
if (isDuplicateSingleton) {
|
||||
|
||||
@@ -10,19 +10,12 @@ import { z } from "zod";
|
||||
* ever one real validation boundary to define.
|
||||
*/
|
||||
|
||||
export const installAdministratorSchema = 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 const installAdministratorSchema = 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 const installBusinessSchema = z.object({
|
||||
name: z.string().trim().min(1, "Business name is required"),
|
||||
|
||||
Reference in new issue
Block a user