diff --git a/e2e/customer-journeys.spec.ts b/e2e/customer-journeys.spec.ts index 9d58bb4..15f341e 100644 --- a/e2e/customer-journeys.spec.ts +++ b/e2e/customer-journeys.spec.ts @@ -50,7 +50,7 @@ test.describe("customer booking journeys", () => { }); test("2. registered customer books an appointment", async ({ page }) => { - await signIn(page, SEEDED.registeredCustomer.email, SEEDED.registeredCustomer.password); + await signIn(page, SEEDED.registeredCustomer.email); await page.goto("/book"); await completeBookingFlow(page, { @@ -66,7 +66,7 @@ test.describe("customer booking journeys", () => { }); test("3. customer views upcoming appointments", async ({ page }) => { - await signIn(page, SEEDED.registeredCustomer.email, SEEDED.registeredCustomer.password); + await signIn(page, SEEDED.registeredCustomer.email); await page.goto("/appointments"); // Seeded: an upcoming CONFIRMED Haircut & Style with Marcus Reed. @@ -87,7 +87,7 @@ test.describe("customer booking journeys", () => { }); test("4. customer reschedules an appointment", async ({ page }) => { - await signIn(page, SEEDED.registeredCustomer.email, SEEDED.registeredCustomer.password); + await signIn(page, SEEDED.registeredCustomer.email); // Book a dedicated appointment for this test so it doesn't depend on the seeded one or on other tests. await page.goto("/book"); @@ -113,7 +113,7 @@ test.describe("customer booking journeys", () => { }); test("5. customer cancels an appointment", async ({ page }) => { - await signIn(page, SEEDED.registeredCustomer.email, SEEDED.registeredCustomer.password); + await signIn(page, SEEDED.registeredCustomer.email); await page.goto("/book"); await completeBookingFlow(page, { serviceName: SEEDED.services.beardTrim, staffName: SEEDED.barberSkinfadeBeard.name }); @@ -142,7 +142,7 @@ test.describe("customer booking journeys", () => { }); test("6. customer books again from history", async ({ page }) => { - await signIn(page, SEEDED.registeredCustomer.email, SEEDED.registeredCustomer.password); + await signIn(page, SEEDED.registeredCustomer.email); await page.goto("/appointments"); // Seeded COMPLETED appointment (Haircut & Style with Leo Vance). @@ -166,7 +166,7 @@ test.describe("customer booking journeys", () => { }); test("7. booking confirmation creates an in-app notification the customer can read and mark read", async ({ page }) => { - await signIn(page, SEEDED.registeredCustomer.email, SEEDED.registeredCustomer.password); + await signIn(page, SEEDED.registeredCustomer.email); await page.goto("/book"); await completeBookingFlow(page, { @@ -197,7 +197,7 @@ test.describe("customer booking journeys", () => { test("8. customer sees their available loyalty reward on Home and the Loyalty page", async ({ page }) => { // Seeded at exactly 10/10 stamps with one AVAILABLE reward (see prisma/seed.ts) — // must run before owner-journeys.spec.ts's journey 10, which redeems it. - await signIn(page, SEEDED.secondCustomer.email, SEEDED.secondCustomer.password); + await signIn(page, SEEDED.secondCustomer.email); await page.goto("/home"); await expect(page.getByText(/Reward available/)).toBeVisible(); @@ -215,7 +215,7 @@ test.describe("customer booking journeys", () => { }); test("9. clicking Book on a Home service preselects it in the booking draft", async ({ page }) => { - await signIn(page, SEEDED.registeredCustomer.email, SEEDED.registeredCustomer.password); + await signIn(page, SEEDED.registeredCustomer.email); await page.goto("/home"); // The description stays visible on the Services tab row — the Book button doesn't hide it. diff --git a/e2e/helpers.ts b/e2e/helpers.ts index c80f20a..ae597e2 100644 --- a/e2e/helpers.ts +++ b/e2e/helpers.ts @@ -1,20 +1,26 @@ import { expect } from "@playwright/test"; import type { Page } from "@playwright/test"; -/** Seeded in prisma/seed.ts — see that file for the full fixture set this suite relies on. */ +/** + * Seeded in prisma/seed.ts — see that file for the full fixture set this + * suite relies on. No `password` fields here anymore: sign-in is OTP-only + * now (see signIn() below), so a seeded user's passwordHash (still written + * by prisma/seed.ts / ensure-super-admin.ts) is vestigial and unused by + * any auth path. + */ export const SEEDED = { - registeredCustomer: { email: "alex.chen@example.com", password: "password123", name: "Alex Chen" }, + registeredCustomer: { email: "alex.chen@example.com", name: "Alex Chen" }, // Seeded right at their loyalty reward threshold (10/10 stamps, one // AVAILABLE reward) — see prisma/seed.ts. owner-journeys.spec.ts's // journey 10 redeems this exact reward, so any test relying on it still // being AVAILABLE must run before that (customer-journeys.spec.ts runs // first in the suite, so this is safe there). - secondCustomer: { email: "sam.osei@example.com", password: "password123", name: "Sam Osei" }, + secondCustomer: { email: "sam.osei@example.com", name: "Sam Osei" }, owner: { email: "marcus@blakeheritage.co.uk", name: "Marcus Reed" }, // Provisioned by global-setup.ts via scripts/ensure-super-admin.ts, not // prisma/seed.ts — a Super Administrator deliberately has no StaffProfile // (see isSuperAdmin) and stays out of the dev/demo seed data on purpose. - superAdmin: { email: "superadmin@blakeheritage.co.uk", password: "password123", name: "Super Admin" }, + superAdmin: { email: "superadmin@blakeheritage.co.uk", name: "Super Admin" }, barberHaircutOnly: { email: "leo@blakeheritage.co.uk", name: "Leo Vance" }, barberSkinfadeBeard: { email: "jamie@blakeheritage.co.uk", name: "Jamie Wallace" }, services: { @@ -24,7 +30,32 @@ export const SEEDED = { }, }; -export async function signIn(page: Page, email: string, password: string): Promise { +/** + * Reads a just-sent OTP code back out of the dev server's ConsoleEmailProvider + * capture buffer via /api/test/otp-code (see that route's own header — it + * 404s unless E2E_TEST_MODE is set, which playwright.config.ts's webServer + * does for exactly this). Polls briefly since the email is sent by a + * Server Action that may not have resolved the instant the client-side + * step transition happens. + * + * `sentAfter` (epoch ms) must be captured right before whatever action + * triggers the send — the same seeded users sign in repeatedly across this + * suite, so without it this could hand back a *previous*, already-consumed + * code still sitting in the buffer instead of the one just requested (see + * that route's own `since` param doc for the full reasoning). + */ +async function fetchOtpCode(page: Page, email: string, sentAfter: number): Promise { + let code: string | null = null; + await expect(async () => { + const res = await page.request.get(`/api/test/otp-code?email=${encodeURIComponent(email)}&since=${sentAfter}`); + expect(res.ok()).toBe(true); + code = ((await res.json()) as { code: string }).code; + }).toPass({ timeout: 10_000 }); + if (!code) throw new Error(`Test setup failed: no OTP code captured for ${email}.`); + return code; +} + +export async function signIn(page: Page, email: string): Promise { await page.goto("/sign-in"); // Turbopack dev-mode compiles each route group on first visit — on a // cold server, a click that lands before that finishes (and before @@ -33,7 +64,11 @@ export async function signIn(page: Page, email: string, password: string): Promi // race the very first visit to an as-yet-uncompiled route. await page.waitForLoadState("networkidle"); await page.getByLabel("Email Address").fill(email); - await page.getByLabel("Password", { exact: true }).fill(password); + const requestedAt = Date.now(); + await page.getByRole("button", { name: "Continue", exact: true }).click(); + + const code = await fetchOtpCode(page, email, requestedAt); + await page.getByLabel("Verification Code").fill(code); await page.getByRole("button", { name: "Sign In", exact: true }).click(); // Not asserting a specific destination here — a bare sign-in (no // callbackUrl, which this helper never sets) now lands on a role-aware diff --git a/e2e/merged-nav.spec.ts b/e2e/merged-nav.spec.ts index 7eda530..8d0d017 100644 --- a/e2e/merged-nav.spec.ts +++ b/e2e/merged-nav.spec.ts @@ -41,7 +41,7 @@ function sidebar(page: Page) { test.describe.serial("merged role-aware navigation", () => { test("1. barber sees exactly the 4 merged bottom-nav items (mobile)", async ({ page }) => { await page.setViewportSize(MOBILE); - await signIn(page, SEEDED.barberHaircutOnly.email, "password123"); + await signIn(page, SEEDED.barberHaircutOnly.email); await page.goto("/staff"); const bar = bottomBar(page); @@ -52,7 +52,7 @@ test.describe.serial("merged role-aware navigation", () => { test("2. barber does not see Manage and cannot reach owner routes directly", async ({ page }) => { await page.setViewportSize(MOBILE); - await signIn(page, SEEDED.barberHaircutOnly.email, "password123"); + await signIn(page, SEEDED.barberHaircutOnly.email); await page.goto("/staff"); await expect(bottomBar(page).getByRole("link", { name: "Manage" })).toHaveCount(0); @@ -71,7 +71,7 @@ test.describe.serial("merged role-aware navigation", () => { test.beforeAll(async ({ browser }) => { page = await browser.newPage(); await page.setViewportSize(MOBILE); - await signIn(page, SEEDED.owner.email, "password123"); + await signIn(page, SEEDED.owner.email); }); test.afterAll(async () => { @@ -161,7 +161,7 @@ test.describe.serial("merged role-aware navigation", () => { test("11. desktop sidebar uses the same merged structure as mobile", async ({ page }) => { await page.setViewportSize(DESKTOP); - await signIn(page, SEEDED.owner.email, "password123"); + await signIn(page, SEEDED.owner.email); await page.goto("/owner"); const side = sidebar(page); @@ -175,7 +175,7 @@ test.describe.serial("merged role-aware navigation", () => { test("13. a Super Administrator sees the business-wide merged nav with no personal schedule tab", async ({ page }) => { await page.setViewportSize(MOBILE); - await signIn(page, SEEDED.superAdmin.email, SEEDED.superAdmin.password); + await signIn(page, SEEDED.superAdmin.email); await page.goto("/owner"); const bar = bottomBar(page); diff --git a/e2e/owner-journeys.spec.ts b/e2e/owner-journeys.spec.ts index d13d69c..2387f5f 100644 --- a/e2e/owner-journeys.spec.ts +++ b/e2e/owner-journeys.spec.ts @@ -32,7 +32,7 @@ test.describe.serial("owner administration journeys", () => { }); test("1. owner sees a real, data-backed dashboard with configuration warnings", async ({ page }) => { - await signIn(page, SEEDED.owner.email, "password123"); + await signIn(page, SEEDED.owner.email); await page.goto("/owner"); // The Manage hub's H1 (see (owner)/owner/page.tsx) — Dashboard content @@ -51,7 +51,7 @@ test.describe.serial("owner administration journeys", () => { }); test("2. owner views the all-staff calendar, filters by staff, and switches to agenda", async ({ page }) => { - await signIn(page, SEEDED.owner.email, "password123"); + await signIn(page, SEEDED.owner.email); await page.goto("/owner/calendar"); await expect(page.getByRole("heading", { name: "Calendar" })).toBeVisible(); @@ -79,7 +79,7 @@ test.describe.serial("owner administration journeys", () => { }); test("3. owner edits business details and the change persists", async ({ page }) => { - await signIn(page, SEEDED.owner.email, "password123"); + await signIn(page, SEEDED.owner.email); await page.goto("/owner/settings/business"); await expect(page.getByRole("heading", { name: "Business Details" })).toBeVisible(); @@ -100,7 +100,7 @@ test.describe.serial("owner administration journeys", () => { }); test("4. owner tightens opening hours, reviews the appointment conflict, and saves anyway without cancelling it", async ({ page }) => { - await signIn(page, SEEDED.owner.email, "password123"); + await signIn(page, SEEDED.owner.email); // Marcus and Leo each have a seeded future CONFIRMED appointment (see // prisma/seed.ts, appt-upcoming-confirmed / appt-leo-future). Those rows @@ -158,7 +158,7 @@ test.describe.serial("owner administration journeys", () => { }); test("5. owner adds a new barber and gets a dev invite link", async ({ page }) => { - await signIn(page, SEEDED.owner.email, "password123"); + await signIn(page, SEEDED.owner.email); await page.goto("/owner/team/add"); await expect(page.getByRole("heading", { name: "Add Staff" })).toBeVisible(); @@ -179,7 +179,7 @@ test.describe.serial("owner administration journeys", () => { }); test("6. owner changes a barber's role and service eligibility", async ({ page }) => { - await signIn(page, SEEDED.owner.email, "password123"); + await signIn(page, SEEDED.owner.email); await page.goto("/owner/team?q=Jamie"); await page.getByRole("link", { name: /Jamie Wallace/ }).click(); @@ -212,7 +212,7 @@ test.describe.serial("owner administration journeys", () => { }); test("7. owner creates a service category and service, visible immediately on the customer booking flow", async ({ page }) => { - await signIn(page, SEEDED.owner.email, "password123"); + await signIn(page, SEEDED.owner.email); await page.goto("/owner/services"); await expect(page.getByRole("heading", { name: "Services", exact: true })).toBeVisible(); @@ -239,7 +239,7 @@ test.describe.serial("owner administration journeys", () => { }); test("8. owner requires booking approval, and a new customer booking reflects it immediately", async ({ page }) => { - await signIn(page, SEEDED.owner.email, "password123"); + await signIn(page, SEEDED.owner.email); await page.goto("/owner/settings/booking-rules"); await expect(page.getByRole("heading", { name: "Booking Rules" })).toBeVisible(); @@ -269,7 +269,7 @@ test.describe.serial("owner administration journeys", () => { await expect(page.getByText("Pending Approval")).toBeVisible(); // Restore the seeded default so later journeys (and re-runs) see auto-confirmed bookings again. - await signIn(page, SEEDED.owner.email, "password123"); + await signIn(page, SEEDED.owner.email); await page.goto("/owner/settings/booking-rules"); await page.getByRole("switch", { name: "Require approval for new bookings" }).click(); await page.getByRole("button", { name: "Save", exact: true }).click(); @@ -277,7 +277,7 @@ test.describe.serial("owner administration journeys", () => { }); test("9. owner configures the loyalty programme", async ({ page }) => { - await signIn(page, SEEDED.owner.email, "password123"); + await signIn(page, SEEDED.owner.email); await page.goto("/owner/settings/loyalty"); await expect(page.getByRole("heading", { name: "Loyalty Programme" })).toBeVisible(); @@ -297,7 +297,7 @@ test.describe.serial("owner administration journeys", () => { }); test("10. owner redeems a customer's available loyalty reward on an appointment", async ({ page }) => { - await signIn(page, SEEDED.owner.email, "password123"); + await signIn(page, SEEDED.owner.email); await page.goto("/owner/calendar?view=agenda"); // Seeded: Sam Osei has a CHECKED_IN appointment with Leo today, and sits @@ -322,7 +322,7 @@ test.describe.serial("owner administration journeys", () => { }); test("11. a barber without MANAGE_STAFF is denied access to owner administration", async ({ page }) => { - await signIn(page, SEEDED.barberHaircutOnly.email, "password123"); + await signIn(page, SEEDED.barberHaircutOnly.email); await page.goto("/owner"); await expect(page).toHaveURL(/\/staff$/); @@ -331,7 +331,7 @@ test.describe.serial("owner administration journeys", () => { }); test("12. the owner reaches barber tools straight from the merged nav — no mode switch", async ({ page }) => { - await signIn(page, SEEDED.owner.email, "password123"); + await signIn(page, SEEDED.owner.email); await page.goto("/owner"); // The old "Switch to Owner Admin"/"Switch to Barber Tools" links are @@ -345,7 +345,7 @@ test.describe.serial("owner administration journeys", () => { }); test("13. owner reports show real, data-backed appointment/service/staff/customer/loyalty/notification figures", async ({ page }) => { - await signIn(page, SEEDED.owner.email, "password123"); + await signIn(page, SEEDED.owner.email); await page.goto("/owner"); // "View Reports" was the old Dashboard footer's button label; Reports is // now one of the Manage hub's grouped sections (see (owner)/owner/page.tsx). diff --git a/e2e/staff-journeys.spec.ts b/e2e/staff-journeys.spec.ts index d59e3a9..ca06cf5 100644 --- a/e2e/staff-journeys.spec.ts +++ b/e2e/staff-journeys.spec.ts @@ -38,7 +38,7 @@ test.describe.serial("barber operations journeys", () => { }); test("1. barber signs in and sees today's schedule", async ({ page }) => { - await signIn(page, SEEDED.owner.email, "password123"); + await signIn(page, SEEDED.owner.email); await page.goto("/staff"); await expect(page.getByRole("heading", { name: "Calendar" })).toBeVisible(); @@ -50,7 +50,7 @@ test.describe.serial("barber operations journeys", () => { }); test("2. barber opens the current appointment", async ({ page }) => { - await signIn(page, SEEDED.owner.email, "password123"); + await signIn(page, SEEDED.owner.email); await page.goto("/staff/current"); // Tier 1 of selectCurrentAppointment: the IN_PROGRESS row wins. @@ -60,7 +60,7 @@ test.describe.serial("barber operations journeys", () => { }); test("3. barber books, checks in, and starts an appointment", async ({ page }) => { - await signIn(page, SEEDED.owner.email, "password123"); + await signIn(page, SEEDED.owner.email); await page.goto("/staff/book?q=Jordan"); await page.getByRole("button", { name: /Jordan Blake/ }).click(); @@ -82,7 +82,7 @@ test.describe.serial("barber operations journeys", () => { test("4. barber completes the appointment and adds a loyalty stamp", async ({ page }) => { if (!chainedAppointmentUrl) throw new Error("journey 3 must run first to create the appointment"); - await signIn(page, SEEDED.owner.email, "password123"); + await signIn(page, SEEDED.owner.email); await page.goto(chainedAppointmentUrl); await page.getByRole("button", { name: "Complete Appointment", exact: true }).click(); @@ -104,7 +104,7 @@ test.describe.serial("barber operations journeys", () => { }); test("5. barber finds a customer and adds a note", async ({ page }) => { - await signIn(page, SEEDED.owner.email, "password123"); + await signIn(page, SEEDED.owner.email); await page.goto("/staff/clients"); await page.getByPlaceholder("Search by name, email, or phone").fill("Osei"); @@ -120,7 +120,7 @@ test.describe.serial("barber operations journeys", () => { test("6. barber books a follow-up from a completed appointment", async ({ page }) => { if (!chainedAppointmentUrl) throw new Error("journey 4 must run first to complete the appointment"); - await signIn(page, SEEDED.owner.email, "password123"); + await signIn(page, SEEDED.owner.email); await page.goto(chainedAppointmentUrl); await page.getByRole("button", { name: "Book Follow-Up" }).click(); @@ -143,7 +143,7 @@ test.describe.serial("barber operations journeys", () => { }); test("7. barber changes their working hours", async ({ page }) => { - await signIn(page, SEEDED.barberHaircutOnly.email, "password123"); + await signIn(page, SEEDED.barberHaircutOnly.email); await page.goto("/staff/settings/working-hours"); const sundaySwitch = page.getByRole("switch", { name: "Sunday working" }); @@ -176,7 +176,7 @@ test.describe.serial("barber operations journeys", () => { }); test("8. barber adds a recurring break", async ({ page }) => { - await signIn(page, SEEDED.barberSkinfadeBeard.email, "password123"); + await signIn(page, SEEDED.barberSkinfadeBeard.email); await page.goto("/staff/settings/breaks"); await page.getByRole("button", { name: "Add Break", exact: true }).click(); @@ -198,7 +198,7 @@ test.describe.serial("barber operations journeys", () => { }); test("9. barber adds time off and resolves a scheduling conflict", async ({ page }) => { - await signIn(page, SEEDED.owner.email, "password123"); + await signIn(page, SEEDED.owner.email); await page.goto("/staff/settings/time-off"); const now = new Date(); @@ -246,7 +246,7 @@ test.describe.serial("barber operations journeys", () => { // in confusing ways whenever it ran outside the full serial file (e.g. `-g` filtering past // journey 3). See NotificationService/booking-notifications.ts, notifyBookingCreated, which // queues STAFF_NEW_APPOINTMENT for the assigned barber on every booking. - await signIn(page, SEEDED.owner.email, "password123"); + await signIn(page, SEEDED.owner.email); await page.goto("/staff/book?q=Jordan"); await page.getByRole("button", { name: /Jordan Blake/ }).click(); diff --git a/playwright.config.ts b/playwright.config.ts index 4ff1637..89ca9d2 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -38,6 +38,10 @@ export default defineConfig({ // needs to be consistent for the lifetime of this one server process. AUTH_SECRET: process.env.AUTH_SECRET ?? "playwright-e2e-test-secret-do-not-use-in-production", NEXT_PUBLIC_APP_URL: baseURL, + // Unlocks GET /api/test/otp-code so helpers.ts's signIn() can read a + // just-sent OTP code the way a human reads their inbox — see that + // route's own header. Never set outside this one dev-mode webServer. + E2E_TEST_MODE: "true", }, }, }); diff --git a/prisma/migrations/20260905190022_email_otp_authentication/migration.sql b/prisma/migrations/20260905190022_email_otp_authentication/migration.sql new file mode 100644 index 0000000..61baa77 --- /dev/null +++ b/prisma/migrations/20260905190022_email_otp_authentication/migration.sql @@ -0,0 +1,14 @@ +-- CreateTable +CREATE TABLE `EmailOtp` ( + `email` VARCHAR(191) NOT NULL, + `codeHash` VARCHAR(191) NOT NULL, + `purpose` VARCHAR(191) NOT NULL, + `expiresAt` DATETIME(3) NOT NULL, + `attempts` INTEGER NOT NULL DEFAULT 0, + `verifiedAt` DATETIME(3) NULL, + `consumedAt` DATETIME(3) NULL, + `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updatedAt` DATETIME(3) NOT NULL, + + PRIMARY KEY (`email`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 401104c..6ecf615 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -629,7 +629,11 @@ model User { phone String? @unique phoneVerified DateTime? image String? - /// null for OAuth-only users (no password set). + /// Deprecated — password auth was replaced by email OTP (see EmailOtp + /// and src/auth.ts's Credentials.authorize). Kept nullable and + /// unremoved purely so historical rows and any direct reads of old data + /// don't break; no code path writes or checks this for authentication + /// anymore. Safe to drop in a future migration once confirmed unused. passwordHash String? /// Platform-level flag, separate from the business Role/Permission /// model — out of scope to exercise in Phase 2, but the brief's "Future @@ -694,6 +698,54 @@ model VerificationToken { @@unique([identifier, token]) } +/// Email OTP (one-time passcode) authentication — replaces password-based +/// sign-in across the app (see src/auth.ts's Credentials.authorize and +/// src/lib/auth/otp.service.ts, the one place that reads/writes this +/// model). One row per email, not one per code: a new request UPSERTs +/// this row, which is what makes "generating a new OTP invalidates any +/// previous active OTP for that email" true by construction rather than +/// something extra to enforce. `codeHash` only — the plain 6-digit code +/// is never persisted anywhere. +/// +/// Two timestamps, not one, because two genuinely different flows need +/// this: a plain sign-in verifies and immediately consumes a code in one +/// request (`verifiedAt` and `consumedAt` set together), while the +/// installer and staff-invitation-acceptance flows verify the code in an +/// earlier request (setting `verifiedAt` only) and finalize/consume it in +/// a later one, after their own transaction commits — the code itself +/// can't be re-entered at that point (one-time use), so finalizing keys +/// only off `verifiedAt` being recent and not yet consumed. +model EmailOtp { + email String @id + + codeHash String + + /// "login" | "register" | "install" | "staff-invite" — audit/debugging + /// context only, never branched on for verification logic. + purpose String + + expiresAt DateTime + + /// Incorrect-code attempts against this row — capped independently of + /// (and in addition to) the request/verify rate limits in + /// otp.service.ts, so exhausting attempts on one code can't be worked + /// around just by calling verify faster. + attempts Int @default(0) + + /// Set once the correct code is entered. Not itself proof of a + /// completed sign-in — see `consumedAt`. + verifiedAt DateTime? + + /// Set once this verification has actually been used to create a + /// session — enforces one-time use. A row with `verifiedAt` set but + /// `consumedAt` still null is "proven but not yet spent", the + /// installer/invite-accept window described above. + consumedAt DateTime? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + // ───────────────────────────────────────────────────────────────────────── // People // ───────────────────────────────────────────────────────────────────────── diff --git a/src/app/api/test/otp-code/route.ts b/src/app/api/test/otp-code/route.ts new file mode 100644 index 0000000..52e7441 --- /dev/null +++ b/src/app/api/test/otp-code/route.ts @@ -0,0 +1,46 @@ +import { NextResponse } from "next/server"; +import { capturedEmails } from "@/services/notifications/providers/console-email-provider"; + +/** + * Test-only backdoor letting the e2e suite read a just-sent OTP code the + * way a human would read it out of their inbox — the database only ever + * stores a bcrypt hash of the code (see otp.service.ts), so there is no + * other way for a black-box browser test to complete an OTP flow. + * + * Gated on E2E_TEST_MODE, which nothing sets except playwright.config.ts's + * own webServer env — a plain `npm run dev` (E2E_TEST_MODE unset) 404s + * here exactly as if the route didn't exist, and a real deployment must + * never set this flag. Also refuses to answer at all when EMAIL_PROVIDER + * isn't "console" (or unset, which defaults to console — see + * providers/index.ts), so this can never leak anything real even if + * E2E_TEST_MODE were ever set somewhere it shouldn't be. + * + * `since` (epoch ms, optional) excludes anything sent before it — required + * because the same seeded email signs in repeatedly across the e2e suite, + * so without it this would happily hand back a previous, already-consumed + * code still sitting in the capture buffer instead of the one just + * requested. Callers should capture `Date.now()` right before triggering + * the send and pass it here. + */ +export async function GET(request: Request) { + if (process.env.E2E_TEST_MODE !== "true" || process.env.EMAIL_PROVIDER === "brevo") { + return NextResponse.json({ error: "Not found" }, { status: 404 }); + } + + const url = new URL(request.url); + const email = url.searchParams.get("email")?.trim().toLowerCase(); + if (!email) { + return NextResponse.json({ error: "email query parameter is required" }, { status: 400 }); + } + const sinceParam = url.searchParams.get("since"); + const since = sinceParam ? Number(sinceParam) : 0; + + for (let i = capturedEmails.length - 1; i >= 0; i -= 1) { + const sent = capturedEmails[i]; + if (!sent || sent.sentAt < since || sent.params.to.email.toLowerCase() !== email) continue; + const code = /verification code is (\d{6})/.exec(sent.params.text)?.[1]; + if (code) return NextResponse.json({ code }); + } + + return NextResponse.json({ error: "No OTP email captured for this address yet." }, { status: 404 }); +} diff --git a/src/app/install/page.tsx b/src/app/install/page.tsx index f04b478..ee200ee 100644 --- a/src/app/install/page.tsx +++ b/src/app/install/page.tsx @@ -37,5 +37,10 @@ export default async function InstallPage() { ); } - return ; + // Both provisioned by the Manager (see manager/src/lib/compose-template.ts) + // into this business's own .env — never read anywhere else in the main + // app before this. Neither is required: a deployment set up by hand + // (not through the Manager) simply gets an empty/undefined value here + // and the wizard's own defaults apply instead. + return ; } diff --git a/src/auth.ts b/src/auth.ts index 99852f6..689067d 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -1,22 +1,45 @@ import { PrismaAdapter } from "@auth/prisma-adapter"; -import bcrypt from "bcryptjs"; import NextAuth from "next-auth"; import Credentials from "next-auth/providers/credentials"; import Google from "next-auth/providers/google"; import { authConfig } from "@/auth.config"; +import { finalizeOtp, verifyOtp } from "@/lib/auth/otp.service"; import { prisma } from "@/lib/db/prisma"; -import { signInSchema } from "@/features/auth/schemas"; import { checkRateLimit } from "@/lib/security/rate-limit"; +import { secretsMatch } from "@/lib/security/secrets-match"; -// A dummy hash to compare against when no real user/passwordHash exists, -// so a "no such account" / "Google-only account" attempt still pays -// bcrypt's cost — without this, that branch returns near-instantly while -// a wrong-password-for-a-real-account attempt always waits for a real -// bcrypt.compare, letting a timing measurement distinguish "no such -// account" from "wrong password" (an account-enumeration side channel). -// Computed once at module load (not hand-typed) so it's guaranteed to be -// a valid hash at the same cost factor registration uses. -const DUMMY_HASH = bcrypt.hashSync("nextchair-timing-safety-placeholder", 12); +/** + * Two credential shapes, both ending in the same finalizeOtp() call and + * the same `{id, email, name, image}` return shape — see + * src/lib/auth/otp.service.ts's own header for the full verify/finalize + * design this builds on. + * + * `{ email, code }` — normal sign-in/registration: verifyOtp then + * finalizeOtp back to back in this one request (functionally "verify and + * consume in one shot" from the caller's point of view). + * + * `{ email, finalize: "true", token }` — used only by the installer's and + * staff-invitation-acceptance's own immediate post-completion sign-in, + * where the code was already consumed by their own earlier verifyOtp call + * and can't be re-entered (one-time use). `token` must match either the + * live INSTALLATION_TOKEN or a valid StaffInvitation.token for this exact + * email — required because /api/auth/callback/credentials is a public + * route regardless of this change (true of any Credentials provider), so + * this path must independently prove the caller already holds a secret an + * attacker can't have, not just trust that it's being called "from the + * right place". Without this, a race window (someone else's verifiedAt- + * but-not-yet-consumedAt row, mid-wizard) would be forgeable via a direct + * POST to that route with just a guessed email. + */ +async function verifyFinalizeToken(email: string, token: string): Promise { + if (!token) return false; + + const installToken = process.env.INSTALLATION_TOKEN; + if (installToken && secretsMatch(token, installToken)) return true; + + const invitation = await prisma.staffInvitation.findUnique({ where: { token }, select: { email: true } }); + return !!invitation && invitation.email.toLowerCase() === email; +} export const { handlers, auth, signIn, signOut } = NextAuth({ ...authConfig, @@ -32,32 +55,40 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ Credentials({ credentials: { email: { label: "Email", type: "email" }, - password: { label: "Password", type: "password" }, + code: { label: "Code", type: "text" }, + // Only ever set by trusted server-side callers (the installer, + // staff-invitation acceptance) — never rendered on any real form. + // See this file's own header for the full finalize/token design. + finalize: { label: "Finalize", type: "text" }, + token: { label: "Token", type: "text" }, }, authorize: async (raw) => { - const parsed = signInSchema.safeParse(raw); - if (!parsed.success) return null; - - const { email, password } = parsed.data; + const email = typeof raw?.email === "string" ? raw.email.trim().toLowerCase() : ""; + if (!email) return null; // Keyed by the attempted email, not IP — this is what actually // stops credential stuffing against one account regardless of how // many source addresses it's spread across. 30 attempts per 15 - // minutes is well above anything a real forgetful user (or a - // shared/office login) would ever hit, while still making online - // guessing impractical — a real brute-force attempt wants - // thousands of guesses, not tens. - const { allowed } = checkRateLimit(`signin:${email.toLowerCase()}`, { - maxAttempts: 30, - windowMs: 15 * 60 * 1000, - }); + // minutes is well above anything a real forgetful user would ever + // hit, while still making online guessing impractical. + const { allowed } = checkRateLimit(`signin:${email}`, { maxAttempts: 30, windowMs: 15 * 60 * 1000 }); if (!allowed) return null; + if (raw?.finalize === "true") { + const token = typeof raw?.token === "string" ? raw.token : ""; + const trusted = await verifyFinalizeToken(email, token); + if (!trusted) return null; + if (!(await finalizeOtp(prisma, email))) return null; + } else { + const code = typeof raw?.code === "string" ? raw.code : ""; + if (!code) return null; + const verified = await verifyOtp(prisma, email, code); + if (!verified.ok) return null; + if (!(await finalizeOtp(prisma, email))) return null; + } + const user = await prisma.user.findUnique({ where: { email } }); - // Always compare against *something* — see DUMMY_HASH above — so - // this call's timing doesn't reveal whether the account exists. - const valid = await bcrypt.compare(password, user?.passwordHash ?? DUMMY_HASH); - if (!user?.passwordHash || !valid) return null; + if (!user) return null; return { id: user.id, email: user.email, name: user.name, image: user.image }; }, diff --git a/src/features/auth/actions/accept-invitation.ts b/src/features/auth/actions/accept-invitation.ts index 6cff142..d35dd82 100644 --- a/src/features/auth/actions/accept-invitation.ts +++ b/src/features/auth/actions/accept-invitation.ts @@ -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; } -/** 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 { - 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 { 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", }); diff --git a/src/features/auth/actions/register.ts b/src/features/auth/actions/register.ts index 057eac6..8dc4fbd 100644 --- a/src/features/auth/actions/register.ts +++ b/src/features/auth/actions/register.ts @@ -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; } -/** - * 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 { +/** 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 { 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 { + 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 { + 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 }; +} diff --git a/src/features/auth/components/accept-invitation-form.tsx b/src/features/auth/components/accept-invitation-form.tsx index 89fa1c3..f540564 100644 --- a/src/features/auth/components/accept-invitation-form.tsx +++ b/src/features/auth/components/accept-invitation-form.tsx @@ -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(null); + const [error, setError] = useState(null); + const [isPending, startTransition] = useTransition(); - const { - register, - handleSubmit, - setError, - formState: { errors, isSubmitting }, - } = useForm({ 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 ( -
+

- You've been invited to join {businessName}. Set a password - to finish setting up your account. + You've been invited to join {businessName}. This link + confirms it's really you — no password needed.

- - - {formError && ( + {error && (

- {formError} + {error}

)} - - +
); } diff --git a/src/features/auth/components/otp-code-step.tsx b/src/features/auth/components/otp-code-step.tsx new file mode 100644 index 0000000..592c84a --- /dev/null +++ b/src/features/auth/components/otp-code-step.tsx @@ -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; + onResend: () => Promise; + 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(null); + const [resendMessage, setResendMessage] = useState(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 ( +
+

+ We sent a code to {email} +

+ setCode(e.target.value.replace(/\D/g, "").slice(0, 6))} + error={error ?? undefined} + /> + {resendMessage && !error && ( +

+ {resendMessage} +

+ )} + +
+ + +
+
+ ); +} diff --git a/src/features/auth/components/register-form.tsx b/src/features/auth/components/register-form.tsx index c61320f..3a46a09 100644 --- a/src/features/auth/components/register-form.tsx +++ b/src/features/auth/components/register-form.tsx @@ -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(null); + const [pending, setPending] = useState(null); const { register, handleSubmit, setError, formState: { errors, isSubmitting }, - } = useForm({ - resolver: zodResolver(registerSchema), + } = useForm({ + 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 ( + setPending(null)} + verifyLabel="Create Account" + /> + ); + } + return ( -
+
- - {formError && (

@@ -111,7 +130,7 @@ export function RegisterForm() {

diff --git a/src/features/auth/components/sign-in-form.tsx b/src/features/auth/components/sign-in-form.tsx index 3442e90..4e4414e 100644 --- a/src/features/auth/components/sign-in-form.tsx +++ b/src/features/auth/components/sign-in-form.tsx @@ -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(null); const [formError, setFormError] = useState(null); const { register, handleSubmit, formState: { errors, isSubmitting }, - } = useForm({ - resolver: zodResolver(signInSchema), + } = useForm({ + 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 (

- - } - placeholder="name@example.com" - error={errors.email?.message} - {...register("email")} - /> - } - 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 ? ( + setEmail(null)} verifyLabel="Sign In" /> + ) : ( + + } + placeholder="name@example.com" + error={errors.email?.message} + {...register("email")} + /> - {formError && ( -

- {formError} -

- )} + {formError && ( +

+ {formError} +

+ )} - - + + + )} -
-
-
-
+ {!email && ( + <> +
+
+
+
+
+ + Or continue with + +
+
- - Or continue with - -
- -
-

- Don't have an account?{" "} - - Create one - -

+

+ Don't have an account?{" "} + + Create one + +

+ + )}
); } diff --git a/src/features/auth/schemas.ts b/src/features/auth/schemas.ts index 819f712..ca4ef5a 100644 --- a/src/features/auth/schemas.ts +++ b/src/features/auth/schemas.ts @@ -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; +export type EmailOnlyInput = z.infer; -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; +/** 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; -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; +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; + +export const registerVerifySchema = registerRequestSchema.extend({ + code: otpCode, +}); +export type RegisterVerifyInput = z.infer; diff --git a/src/features/installation/__tests__/installer.service.integration.test.ts b/src/features/installation/__tests__/installer.service.integration.test.ts index b5a60b9..3d3052b 100644 --- a/src/features/installation/__tests__/installer.service.integration.test.ts +++ b/src/features/installation/__tests__/installer.service.integration.test.ts @@ -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 { 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 { }; } +/** 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 { + 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 = {}): Promise { + const input = buildInput(overrides); + await verifyAdministratorEmail(input.administrator.email); + return input; +} + async function resetInstallationSingleton(): Promise { 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" }), diff --git a/src/features/installation/actions/install.ts b/src/features/installation/actions/install.ts index 05bcf56..712605a 100644 --- a/src/features/installation/actions/install.ts +++ b/src/features/installation/actions/install.ts @@ -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 { + 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 { + 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(null); @@ -114,11 +128,23 @@ export function InstallWizard() { const [token, setToken] = useState(""); const [tokenError, setTokenError] = useState(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(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) => { 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() {

Administrator account

This becomes the first owner — full permissions, protected from removal.

-
- setAdmin({ ...admin, firstName: e.target.value })} required /> - setAdmin({ ...admin, lastName: e.target.value })} required /> -
- setAdmin({ ...admin, email: e.target.value })} required /> - setAdmin({ ...admin, phone: e.target.value })} /> - setAdmin({ ...admin, password: e.target.value })} - hint="At least 8 characters." - required - /> - setAdmin({ ...admin, confirmPassword: e.target.value })} - error={admin.confirmPassword && admin.password !== admin.confirmPassword ? "Passwords don't match" : undefined} - required - /> - setStep(1)} onNext={() => setStep(3)} nextDisabled={!canProceedFromStep(2)} /> + + {otpPhase === "code" ? ( + setOtpPhase("collect")} + verifyLabel="Verify Email" + /> + ) : ( + <> +
+ setAdmin({ ...admin, firstName: e.target.value })} required /> + setAdmin({ ...admin, lastName: e.target.value })} required /> +
+ { + 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 + /> + setAdmin({ ...admin, phone: e.target.value })} /> + + {otpPhase === "verified" ? ( +

+ ✓ {admin.email} verified +

+ ) : ( + <> + {otpError && ( +

+ {otpError} +

+ )} + + + )} + + )} + + {otpPhase !== "code" && ( + setStep(1)} onNext={() => setStep(3)} nextDisabled={!canProceedFromStep(2)} /> + )}
)} {step === 3 && (

Business details

- setBusiness({ ...business, name: e.target.value })} required /> + {bookingUrl && ( +
+

Booking URL

+

{bookingUrl}

+

+ Where customers book with you online — already set up for this deployment, not something to + configure here. +

+
+ )} + setBusiness({ ...business, name: e.target.value })} + hint={initialBusinessName ? "Pre-filled from setup — change it if you'd like a different public name." : undefined} + required + /> setBusiness({ ...business, email: e.target.value })} /> setBusiness({ ...business, phone: e.target.value })} /> - setBusiness({ ...business, website: e.target.value })} /> + setBusiness({ ...business, website: e.target.value })} + /> setStep(2)} onNext={() => setStep(4)} nextDisabled={!canProceedFromStep(3)} />
)} diff --git a/src/features/installation/installer.service.ts b/src/features/installation/installer.service.ts index 0274c79..2ebc456 100644 --- a/src/features/installation/installer.service.ts +++ b/src/features/installation/installer.service.ts @@ -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) { diff --git a/src/features/installation/schema.ts b/src/features/installation/schema.ts index ff28800..d291b28 100644 --- a/src/features/installation/schema.ts +++ b/src/features/installation/schema.ts @@ -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"), diff --git a/src/lib/auth/__tests__/otp.service.integration.test.ts b/src/lib/auth/__tests__/otp.service.integration.test.ts new file mode 100644 index 0000000..1bff21b --- /dev/null +++ b/src/lib/auth/__tests__/otp.service.integration.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it } from "vitest"; +import { prisma } from "@/services/scheduling/__tests__/fixtures"; +import { MockEmailProvider } from "@/services/notifications/providers/mock-email-provider"; +import { finalizeOtp, requestOtp, verifyOtp } from "../otp.service"; + +/** + * Real MySQL, real upsert/race semantics — deliberately not a hand-rolled + * fake Prisma client, since the behaviour this file exists to prove + * (a second request invalidating the first, one-time consumption) is + * exactly the kind of thing a fake could get subtly wrong. See + * installer.service.integration.test.ts for the same reasoning applied + * to installApplication. + */ + +function uniqueEmail(prefix: string): string { + return `otp-test-${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@example.com`; +} + +async function requestAndExtractCode(email: string, purpose: "login" | "register" | "install" | "staff-invite" = "login"): Promise { + const mockProvider = new MockEmailProvider(); + const result = await requestOtp(prisma, { email, purpose, emailProvider: mockProvider }); + expect(result.ok).toBe(true); + 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."); + return code; +} + +describe("otp.service (integration)", () => { + it("verifies a correctly-entered code", async () => { + const email = uniqueEmail("verify-ok"); + const code = await requestAndExtractCode(email); + + const result = await verifyOtp(prisma, email, code); + expect(result.ok).toBe(true); + }); + + it("rejects an incorrect code and records the attempt", async () => { + const email = uniqueEmail("wrong-code"); + await requestAndExtractCode(email); + + const result = await verifyOtp(prisma, email, "000000"); + expect(result.ok).toBe(false); + expect(result.reason).toBe("invalid_code"); + + const row = await prisma.emailOtp.findUnique({ where: { email } }); + expect(row?.attempts).toBe(1); + }); + + it("locks out further attempts once the attempt cap is exhausted", async () => { + const email = uniqueEmail("lockout"); + await requestAndExtractCode(email); + + for (let i = 0; i < 5; i += 1) { + await verifyOtp(prisma, email, "000000"); + } + + // Even the (unknown to this test) correct code can no longer succeed — + // the row's attempts counter, not the code's correctness, is what's + // gating this now. + const result = await verifyOtp(prisma, email, "000000"); + expect(result.ok).toBe(false); + expect(result.reason).toBe("too_many_attempts"); + }); + + it("rejects an expired code", async () => { + const email = uniqueEmail("expired"); + const code = await requestAndExtractCode(email); + await prisma.emailOtp.update({ where: { email }, data: { expiresAt: new Date(Date.now() - 1000) } }); + + const result = await verifyOtp(prisma, email, code); + expect(result.ok).toBe(false); + expect(result.reason).toBe("expired"); + }); + + it("rejects a code that's already been consumed (reuse)", async () => { + const email = uniqueEmail("reuse"); + const code = await requestAndExtractCode(email); + + const first = await verifyOtp(prisma, email, code); + expect(first.ok).toBe(true); + expect(await finalizeOtp(prisma, email)).toBe(true); + + // Same code, after consumption — must fail, not succeed a second time. + const second = await verifyOtp(prisma, email, code); + expect(second.ok).toBe(false); + expect(second.reason).toBe("not_found"); + }); + + it("a new request invalidates the previous code entirely", async () => { + const email = uniqueEmail("invalidate"); + const firstCode = await requestAndExtractCode(email); + const secondCode = await requestAndExtractCode(email); + expect(secondCode).not.toBe(firstCode); + + const oldCodeResult = await verifyOtp(prisma, email, firstCode); + expect(oldCodeResult.ok).toBe(false); + + const newCodeResult = await verifyOtp(prisma, email, secondCode); + expect(newCodeResult.ok).toBe(true); + }); + + it("finalizeOtp enforces one-time use even when verifiedAt is fresh", async () => { + const email = uniqueEmail("finalize-once"); + const code = await requestAndExtractCode(email); + expect((await verifyOtp(prisma, email, code)).ok).toBe(true); + + expect(await finalizeOtp(prisma, email)).toBe(true); + // A second finalize on the same (now-consumed) row must fail — this is + // exactly what stands between the installer and a duplicate admin if + // its final submit were somehow retried after already succeeding once. + expect(await finalizeOtp(prisma, email)).toBe(false); + }); + + it("finalizeOtp refuses a row that was never verified", async () => { + const email = uniqueEmail("never-verified"); + await requestAndExtractCode(email); + + expect(await finalizeOtp(prisma, email)).toBe(false); + }); + + it("rate-limits repeated requests for the same email", async () => { + const email = uniqueEmail("rate-limit-request"); + const mockProvider = new MockEmailProvider(); + const results = await Promise.all( + Array.from({ length: 7 }, () => requestOtp(prisma, { email, purpose: "login", emailProvider: mockProvider })), + ); + expect(results.some((r) => !r.ok)).toBe(true); + }); + + it("rate-limits repeated verify attempts for the same email", async () => { + const email = uniqueEmail("rate-limit-verify"); + await requestAndExtractCode(email); + + const results: boolean[] = []; + for (let i = 0; i < 12; i += 1) { + const result = await verifyOtp(prisma, email, "111111"); + results.push(result.reason === "rate_limited"); + } + expect(results.some(Boolean)).toBe(true); + }); + + it("onlyIfUserExists sends nothing for an email with no account, but still reports ok", async () => { + const email = uniqueEmail("no-such-user"); + const mockProvider = new MockEmailProvider(); + + const result = await requestOtp(prisma, { email, purpose: "login", onlyIfUserExists: true, emailProvider: mockProvider }); + expect(result.ok).toBe(true); + expect(mockProvider.sentEmails).toHaveLength(0); + + const row = await prisma.emailOtp.findUnique({ where: { email } }); + expect(row).toBeNull(); + }); +}); diff --git a/src/lib/auth/otp.service.ts b/src/lib/auth/otp.service.ts new file mode 100644 index 0000000..ada1c78 --- /dev/null +++ b/src/lib/auth/otp.service.ts @@ -0,0 +1,237 @@ +import { randomInt } from "node:crypto"; +import bcrypt from "bcryptjs"; +import type { Prisma, PrismaClient } from "@/generated/prisma/client"; +import { checkRateLimit } from "@/lib/security/rate-limit"; +// Type-only — the barrel this comes from has a `server-only` guard that +// throws under Vitest's plain Node runtime (see reminder-worker.ts's own +// established pattern: never call getEmailProvider() from inside a +// service function Vitest loads directly, only from the outermost +// Server Action/route caller, which passes it down explicitly). +import type { EmailProvider } from "@/services/notifications/providers"; +import { renderOtpEmail } from "@/services/notifications/templates/otp-email"; + +/** + * The one place email OTPs (one-time passcodes) are generated, sent, + * verified, and consumed — reused identically by normal sign-in, + * registration, the installer's administrator step, and staff-invitation + * acceptance (see src/auth.ts's Credentials.authorize, the actual caller + * of verifyOtp/finalizeOtp; the four request call sites each just pick a + * `purpose` and whether an email should only be sent to an already- + * existing account). Bcrypt cost 12 throughout, matching this app's + * existing password-hashing convention (see scripts/ensure-super-admin.ts, + * src/auth.ts's old DUMMY_HASH) even though a 6-digit code's real defense + * is the rate limits and attempt cap below, not hash cost alone. + * + * Every function here takes its Prisma client as an explicit first + * parameter — the same convention as installer.service.ts/notification. + * service.ts's notifySafely — rather than importing the app's singleton + * internally, specifically so installer.service.ts can call finalizeOtp + * with its own `tx` and have the OTP consumption roll back together with + * the rest of installation if a later step fails. + * + * See prisma/schema.prisma's EmailOtp model doc comment for the + * verifiedAt/consumedAt two-phase design this is built around. + */ + +type Db = PrismaClient | Prisma.TransactionClient; + +const OTP_TTL_MS = 10 * 60 * 1000; +const VERIFIED_GRACE_MS = 30 * 60 * 1000; +const MAX_VERIFY_ATTEMPTS = 5; +const BCRYPT_COST = 12; + +// The e2e suite signs in as the same handful of seeded users dozens of +// times across a single dev-server run — a real user never does that in +// 15 minutes, but the rate limiter can't tell the difference. E2E_TEST_MODE +// only exists on playwright.config.ts's own webServer (see that config and +// /api/test/otp-code's identical gate) and must never be set anywhere a +// real user could reach, so bypassing rate limiting under it doesn't weaken +// anything in production. +const rateLimitBypassed = process.env.E2E_TEST_MODE === "true"; + +// Compared against when no row exists, already consumed, or attempts are +// exhausted — so verifyOtp's response timing doesn't itself reveal which +// of those is true, mirroring src/auth.ts's own DUMMY_HASH pattern for +// the exact same reason (account/state enumeration via timing). +const DUMMY_HASH = bcrypt.hashSync("nextchair-otp-timing-safety-placeholder", BCRYPT_COST); + +export type OtpPurpose = "login" | "register" | "install" | "staff-invite"; + +function normalizeEmail(email: string): string { + return email.trim().toLowerCase(); +} + +function generateCode(): string { + // randomInt's upper bound is exclusive — 0..999999 inclusive, zero-padded + // to always be exactly 6 digits (e.g. "004213"). + return randomInt(0, 1_000_000).toString().padStart(6, "0"); +} + +export interface RequestOtpOptions { + email: string; + purpose: OtpPurpose; + /** From getRequestIp() — rate-limited alongside the per-email key, so a flood spread across many target emails from one source is also bounded. */ + ip?: string; + /** Shown as the email's visible sender — the resolved business name where one exists, a neutral "NextChair" pre-install/pre-account. */ + senderName?: string; + /** + * Login-specific: only actually generates/sends a code if a User already + * exists for this email. The caller still gets the same {ok:true} result + * either way — this is what keeps "do not reveal whether an email + * address exists" true for the sign-in page without silently creating a + * dead OTP row (or emailing a stranger) for a typo'd/unregistered address. + */ + onlyIfUserExists?: boolean; + /** Required, not defaulted internally — see this file's own import comment for why. Real callers pass getEmailProvider(); tests pass a MockEmailProvider. */ + emailProvider: EmailProvider; +} + +export interface RequestOtpResult { + ok: boolean; + retryAfterSeconds?: number; +} + +export async function requestOtp(db: Db, options: RequestOtpOptions): Promise { + const email = normalizeEmail(options.email); + + if (!rateLimitBypassed) { + const emailLimit = checkRateLimit(`otp-request:${email}`, { maxAttempts: 5, windowMs: 15 * 60 * 1000 }); + if (!emailLimit.allowed) return { ok: false, retryAfterSeconds: emailLimit.retryAfterSeconds }; + + if (options.ip) { + const ipLimit = checkRateLimit(`otp-request-ip:${options.ip}`, { maxAttempts: 20, windowMs: 15 * 60 * 1000 }); + if (!ipLimit.allowed) return { ok: false, retryAfterSeconds: ipLimit.retryAfterSeconds }; + } + } + + if (options.onlyIfUserExists) { + const user = await db.user.findUnique({ where: { email }, select: { id: true } }); + // Same generic {ok:true} either way — see this option's own doc comment. + if (!user) return { ok: true }; + } + + // ~1% of requests — see cleanupExpiredOtps's own comment. + if (Math.random() < 0.01) void cleanupExpiredOtps(db); + + const code = generateCode(); + const codeHash = await bcrypt.hash(code, BCRYPT_COST); + const expiresAt = new Date(Date.now() + OTP_TTL_MS); + + // Upsert, not insert — this IS the "a new OTP invalidates the previous + // one" requirement: there is only ever one row per email, so requesting + // again always replaces whatever code/attempts/verified state existed. + // + // Two concurrent requests for the same brand-new email (a double- + // clicked "send code" button, a client retry) can both reach this line + // finding no existing row and both attempt the `create` branch — only + // one wins, the other's upsert throws a unique-constraint violation + // (P2002) rather than transparently falling back to `update` the way a + // single atomic "INSERT ... ON DUPLICATE KEY UPDATE" would. Retrying + // once as a plain update resolves it: by the time this catch runs, the + // row the other request just created definitely exists. + try { + await db.emailOtp.upsert({ + where: { email }, + create: { email, codeHash, purpose: options.purpose, expiresAt }, + update: { codeHash, purpose: options.purpose, expiresAt, attempts: 0, verifiedAt: null, consumedAt: null }, + }); + } catch (error) { + const isDuplicateKey = + typeof error === "object" && error !== null && "code" in error && (error as { code?: string }).code === "P2002"; + if (!isDuplicateKey) throw error; + await db.emailOtp.update({ + where: { email }, + data: { codeHash, purpose: options.purpose, expiresAt, attempts: 0, verifiedAt: null, consumedAt: null }, + }); + } + + const rendered = renderOtpEmail({ senderName: options.senderName ?? "NextChair", code, expiresInMinutes: 10 }); + // Never logged — the code must not reach application logs, only this + // one email (see the brief's "do not include the OTP in logs"). + await options.emailProvider.sendEmail({ + to: { email }, + sender: { name: options.senderName ?? "NextChair" }, + subject: rendered.subject, + html: rendered.html, + text: rendered.text, + }); + + return { ok: true }; +} + +export type VerifyOtpFailureReason = "not_found" | "expired" | "too_many_attempts" | "invalid_code" | "rate_limited"; + +export interface VerifyOtpResult { + ok: boolean; + reason?: VerifyOtpFailureReason; + retryAfterSeconds?: number; +} + +/** Checks the code is correct and not expired/exhausted, and marks the row `verifiedAt` — does NOT consume it. See finalizeOtp for the second phase, and this module's own header for why. */ +export async function verifyOtp(db: Db, email: string, code: string, ip?: string): Promise { + const normalizedEmail = normalizeEmail(email); + + if (!rateLimitBypassed) { + const emailLimit = checkRateLimit(`otp-verify:${normalizedEmail}`, { maxAttempts: 10, windowMs: 15 * 60 * 1000 }); + if (!emailLimit.allowed) return { ok: false, reason: "rate_limited", retryAfterSeconds: emailLimit.retryAfterSeconds }; + + if (ip) { + const ipLimit = checkRateLimit(`otp-verify-ip:${ip}`, { maxAttempts: 30, windowMs: 15 * 60 * 1000 }); + if (!ipLimit.allowed) return { ok: false, reason: "rate_limited", retryAfterSeconds: ipLimit.retryAfterSeconds }; + } + } + + const row = await db.emailOtp.findUnique({ where: { email: normalizedEmail } }); + + // Always compare against *something* — see DUMMY_HASH above — so this + // call's timing doesn't reveal whether a row/valid-state exists before + // the early-return checks below run. + const codeMatches = await bcrypt.compare(code, row?.codeHash ?? DUMMY_HASH); + + if (!row || row.consumedAt) return { ok: false, reason: "not_found" }; + if (row.expiresAt < new Date()) return { ok: false, reason: "expired" }; + if (row.attempts >= MAX_VERIFY_ATTEMPTS) return { ok: false, reason: "too_many_attempts" }; + + if (!codeMatches) { + await db.emailOtp.update({ where: { email: normalizedEmail }, data: { attempts: { increment: 1 } } }); + return { ok: false, reason: "invalid_code" }; + } + + await db.emailOtp.update({ where: { email: normalizedEmail }, data: { verifiedAt: new Date() } }); + return { ok: true }; +} + +/** + * Second phase: consumes a previously-`verifyOtp`'d row, enforcing + * one-time use. For a plain sign-in, callers run this immediately after + * verifyOtp in the same request — functionally "verify and consume in one + * shot". For the installer, this runs *inside* the same transaction that + * creates the administrator (see installer.service.ts), using the + * `verifiedAt` proof from an earlier request instead of re-entering a + * code that's no longer usable — and rolls back with the rest of that + * transaction if a later step fails, so a retry isn't forced to re-verify. + * Staff-invitation-acceptance uses the invite token as its trust anchor + * instead (see auth.ts's `finalize` credential path) and never calls this + * at all. + */ +export async function finalizeOtp(db: Db, email: string): Promise { + const normalizedEmail = normalizeEmail(email); + const row = await db.emailOtp.findUnique({ where: { email: normalizedEmail } }); + if (!row || !row.verifiedAt || row.consumedAt) return false; + if (Date.now() - row.verifiedAt.getTime() > VERIFIED_GRACE_MS) return false; + + await db.emailOtp.update({ where: { email: normalizedEmail }, data: { consumedAt: new Date() } }); + return true; +} + +/** + * Opportunistic cleanup, same spirit as rate-limit.ts's sweepExpired — no + * cron needed since a stale row is bounded by the number of distinct real + * emails that ever requested a code, and any later request for that email + * overwrites it anyway. Called with low probability from requestOtp so it + * doesn't add latency to every single request. + */ +export async function cleanupExpiredOtps(db: Db): Promise { + const cutoff = new Date(Date.now() - 24 * 60 * 60 * 1000); + await db.emailOtp.deleteMany({ where: { expiresAt: { lt: cutoff } } }); +} diff --git a/src/services/notifications/providers/console-email-provider.ts b/src/services/notifications/providers/console-email-provider.ts index deb8fd6..a8bfc88 100644 --- a/src/services/notifications/providers/console-email-provider.ts +++ b/src/services/notifications/providers/console-email-provider.ts @@ -1,5 +1,39 @@ import type { EmailProvider, EmailSendResult, SendEmailParams } from "./email-provider"; +export interface CapturedEmail { + sentAt: number; + params: SendEmailParams; +} + +/** + * Small ring buffer of what ConsoleEmailProvider has "sent" — read only by + * /api/test/otp-code (itself gated on E2E_TEST_MODE, see that route's own + * header) so the e2e suite can pull a real OTP code the same way a human + * would read it out of their inbox, without ever storing the plain code + * anywhere in the database. Capped so a long-running dev server's memory + * doesn't grow unbounded; nothing in production ever reads or writes this + * (EMAIL_PROVIDER=brevo is required there — see providers/index.ts). + * + * Stashed on `globalThis`, same trick as src/lib/db/prisma.ts's own + * singleton — Next.js dev (Turbopack) bundles a Server Action and a Route + * Handler as separate module graphs, so a plain module-scoped array + * wouldn't actually be the same array by the time /api/test/otp-code reads + * it; `globalThis` is the one thing guaranteed to be the same object + * across every module graph in a single Node.js process. + * + * Each entry carries its own `sentAt` so a re-request for the same address + * (the OTP flow's own "a new code invalidates the old one" behaviour, + * exercised constantly across the e2e suite's many sequential sign-ins as + * the same seeded users) can't have a caller accidentally read back a + * *previous*, already-consumed code that's still sitting in the buffer — + * see /api/test/otp-code's `since` parameter, which this exists to support. + */ +const MAX_CAPTURED = 50; +const globalForCapturedEmails = globalThis as unknown as { + __nextchairCapturedEmails: CapturedEmail[] | undefined; +}; +export const capturedEmails: CapturedEmail[] = (globalForCapturedEmails.__nextchairCapturedEmails ??= []); + /** * Dev-only — never actually sends anything, just prints what would have * been sent so a developer can see confirmation/reminder emails locally @@ -24,6 +58,8 @@ export class ConsoleEmailProvider implements EmailProvider { "──────────────────────────────────────────────────────────────────────", ].join("\n"), ); + capturedEmails.push({ sentAt: Date.now(), params }); + if (capturedEmails.length > MAX_CAPTURED) capturedEmails.shift(); return { status: "sent", providerMessageId: `console-${Date.now()}` }; } } diff --git a/src/services/notifications/templates/otp-email.ts b/src/services/notifications/templates/otp-email.ts new file mode 100644 index 0000000..19126de --- /dev/null +++ b/src/services/notifications/templates/otp-email.ts @@ -0,0 +1,44 @@ +import { paragraph, renderEmailLayout, renderPlainTextLayout } from "./layout"; +import type { RenderedEmail } from "./shared"; + +export interface OtpEmailData { + /** Displayed sender name in the layout header (business name once one exists, a neutral fallback pre-install — see otp.service.ts). */ + senderName: string; + code: string; + expiresInMinutes: number; +} + +/** + * Deliberately not part of NotificationType/notify()'s async cron-delivery + * pipeline (see otp.service.ts's own header for why) — a code that expires + * in minutes needs to go out now, not on the next processing tick. This is + * the one email template rendered and sent directly, bypassing + * render-email.ts's dispatcher entirely. + */ +export function renderOtpEmail(data: OtpEmailData): RenderedEmail { + const bodyHtml = [ + paragraph(`Your verification code is ${data.code}`), + paragraph(`This code expires in ${data.expiresInMinutes} minutes and can only be used once.`), + paragraph("If you didn't request this, you can safely ignore this email."), + ].join(""); + + return { + subject: "Your NextChair verification code", + html: renderEmailLayout({ + businessName: data.senderName, + headline: "Your verification code", + bodyHtml, + }), + text: renderPlainTextLayout({ + businessName: data.senderName, + headline: "Your verification code", + bodyLines: [ + `Your verification code is ${data.code}`, + "", + `This code expires in ${data.expiresInMinutes} minutes and can only be used once.`, + "", + "If you didn't request this, you can safely ignore this email.", + ], + }), + }; +} diff --git a/src/services/staff/staff-invitation.service.ts b/src/services/staff/staff-invitation.service.ts index 18360d0..bf14fa1 100644 --- a/src/services/staff/staff-invitation.service.ts +++ b/src/services/staff/staff-invitation.service.ts @@ -1,5 +1,4 @@ import { randomBytes } from "node:crypto"; -import bcrypt from "bcryptjs"; import type { PrismaClient } from "@/generated/prisma/client"; import { NotificationType } from "@/generated/prisma/enums"; import { notifySafely } from "@/services/notifications/notification.service"; @@ -24,7 +23,7 @@ import { getOwnerUserIds } from "@/services/notifications/recipients"; const INVITATION_TTL_DAYS = 7; -export type StaffInvitationErrorCode = "INVALID_TOKEN" | "EXPIRED" | "ALREADY_ACCEPTED" | "INVALID_INPUT"; +export type StaffInvitationErrorCode = "INVALID_TOKEN" | "EXPIRED" | "ALREADY_ACCEPTED"; function generateToken(): string { return randomBytes(32).toString("base64url"); @@ -105,20 +104,26 @@ export async function validateInvitationToken(prisma: PrismaClient, token: strin return { valid: true, invitation }; } +/** + * Activates the invited staff member's account — no password to set: + * possessing this exact token (delivered only to the invited email) is + * itself the proof of email ownership, same as it already was for + * `emailVerified` before this function ever checked a password (see this + * file's own header). The caller (acceptInvitationAction) signs the user + * in immediately after, via src/auth.ts's token-gated finalize path, + * reusing this same invitation token as the required secret. + */ export async function acceptInvitation( prisma: PrismaClient, - params: { token: string; password: string }, + params: { token: string }, ): Promise<{ success: boolean; reason?: StaffInvitationErrorCode }> { - if (params.password.length < 8) return { success: false, reason: "INVALID_INPUT" }; - const check = await validateInvitationToken(prisma, params.token); if (!check.valid) return { success: false, reason: check.reason }; - const passwordHash = await bcrypt.hash(params.password, 12); await prisma.$transaction([ prisma.user.update({ where: { id: check.invitation.staff.userId }, - data: { passwordHash, emailVerified: new Date() }, + data: { emailVerified: new Date() }, }), prisma.staffInvitation.update({ where: { id: check.invitation.id },