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

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

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

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

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

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

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

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

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

No files matched your search

+8 -8
View File
@@ -50,7 +50,7 @@ test.describe("customer booking journeys", () => {
}); });
test("2. registered customer books an appointment", async ({ page }) => { 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 page.goto("/book");
await completeBookingFlow(page, { await completeBookingFlow(page, {
@@ -66,7 +66,7 @@ test.describe("customer booking journeys", () => {
}); });
test("3. customer views upcoming appointments", async ({ page }) => { 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"); await page.goto("/appointments");
// Seeded: an upcoming CONFIRMED Haircut & Style with Marcus Reed. // 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 }) => { 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. // Book a dedicated appointment for this test so it doesn't depend on the seeded one or on other tests.
await page.goto("/book"); await page.goto("/book");
@@ -113,7 +113,7 @@ test.describe("customer booking journeys", () => {
}); });
test("5. customer cancels an appointment", async ({ page }) => { 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 page.goto("/book");
await completeBookingFlow(page, { serviceName: SEEDED.services.beardTrim, staffName: SEEDED.barberSkinfadeBeard.name }); 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 }) => { 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"); await page.goto("/appointments");
// Seeded COMPLETED appointment (Haircut & Style with Leo Vance). // 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 }) => { 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 page.goto("/book");
await completeBookingFlow(page, { 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 }) => { 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) — // 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. // 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 page.goto("/home");
await expect(page.getByText(/Reward available/)).toBeVisible(); 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 }) => { 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"); await page.goto("/home");
// The description stays visible on the Services tab row — the Book button doesn't hide it. // The description stays visible on the Services tab row — the Book button doesn't hide it.
+41 -6
View File
@@ -1,20 +1,26 @@
import { expect } from "@playwright/test"; import { expect } from "@playwright/test";
import type { Page } 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 = { 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 // Seeded right at their loyalty reward threshold (10/10 stamps, one
// AVAILABLE reward) — see prisma/seed.ts. owner-journeys.spec.ts's // AVAILABLE reward) — see prisma/seed.ts. owner-journeys.spec.ts's
// journey 10 redeems this exact reward, so any test relying on it still // journey 10 redeems this exact reward, so any test relying on it still
// being AVAILABLE must run before that (customer-journeys.spec.ts runs // being AVAILABLE must run before that (customer-journeys.spec.ts runs
// first in the suite, so this is safe there). // 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" }, owner: { email: "marcus@blakeheritage.co.uk", name: "Marcus Reed" },
// Provisioned by global-setup.ts via scripts/ensure-super-admin.ts, not // Provisioned by global-setup.ts via scripts/ensure-super-admin.ts, not
// prisma/seed.ts — a Super Administrator deliberately has no StaffProfile // prisma/seed.ts — a Super Administrator deliberately has no StaffProfile
// (see isSuperAdmin) and stays out of the dev/demo seed data on purpose. // (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" }, barberHaircutOnly: { email: "leo@blakeheritage.co.uk", name: "Leo Vance" },
barberSkinfadeBeard: { email: "jamie@blakeheritage.co.uk", name: "Jamie Wallace" }, barberSkinfadeBeard: { email: "jamie@blakeheritage.co.uk", name: "Jamie Wallace" },
services: { services: {
@@ -24,7 +30,32 @@ export const SEEDED = {
}, },
}; };
export async function signIn(page: Page, email: string, password: string): Promise<void> { /**
* 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<string> {
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<void> {
await page.goto("/sign-in"); await page.goto("/sign-in");
// Turbopack dev-mode compiles each route group on first visit — on a // Turbopack dev-mode compiles each route group on first visit — on a
// cold server, a click that lands before that finishes (and before // 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. // race the very first visit to an as-yet-uncompiled route.
await page.waitForLoadState("networkidle"); await page.waitForLoadState("networkidle");
await page.getByLabel("Email Address").fill(email); 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(); await page.getByRole("button", { name: "Sign In", exact: true }).click();
// Not asserting a specific destination here — a bare sign-in (no // Not asserting a specific destination here — a bare sign-in (no
// callbackUrl, which this helper never sets) now lands on a role-aware // callbackUrl, which this helper never sets) now lands on a role-aware
+5 -5
View File
@@ -41,7 +41,7 @@ function sidebar(page: Page) {
test.describe.serial("merged role-aware navigation", () => { test.describe.serial("merged role-aware navigation", () => {
test("1. barber sees exactly the 4 merged bottom-nav items (mobile)", async ({ page }) => { test("1. barber sees exactly the 4 merged bottom-nav items (mobile)", async ({ page }) => {
await page.setViewportSize(MOBILE); await page.setViewportSize(MOBILE);
await signIn(page, SEEDED.barberHaircutOnly.email, "password123"); await signIn(page, SEEDED.barberHaircutOnly.email);
await page.goto("/staff"); await page.goto("/staff");
const bar = bottomBar(page); 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 }) => { test("2. barber does not see Manage and cannot reach owner routes directly", async ({ page }) => {
await page.setViewportSize(MOBILE); await page.setViewportSize(MOBILE);
await signIn(page, SEEDED.barberHaircutOnly.email, "password123"); await signIn(page, SEEDED.barberHaircutOnly.email);
await page.goto("/staff"); await page.goto("/staff");
await expect(bottomBar(page).getByRole("link", { name: "Manage" })).toHaveCount(0); 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 }) => { test.beforeAll(async ({ browser }) => {
page = await browser.newPage(); page = await browser.newPage();
await page.setViewportSize(MOBILE); await page.setViewportSize(MOBILE);
await signIn(page, SEEDED.owner.email, "password123"); await signIn(page, SEEDED.owner.email);
}); });
test.afterAll(async () => { 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 }) => { test("11. desktop sidebar uses the same merged structure as mobile", async ({ page }) => {
await page.setViewportSize(DESKTOP); await page.setViewportSize(DESKTOP);
await signIn(page, SEEDED.owner.email, "password123"); await signIn(page, SEEDED.owner.email);
await page.goto("/owner"); await page.goto("/owner");
const side = sidebar(page); 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 }) => { test("13. a Super Administrator sees the business-wide merged nav with no personal schedule tab", async ({ page }) => {
await page.setViewportSize(MOBILE); await page.setViewportSize(MOBILE);
await signIn(page, SEEDED.superAdmin.email, SEEDED.superAdmin.password); await signIn(page, SEEDED.superAdmin.email);
await page.goto("/owner"); await page.goto("/owner");
const bar = bottomBar(page); const bar = bottomBar(page);
+14 -14
View File
@@ -32,7 +32,7 @@ test.describe.serial("owner administration journeys", () => {
}); });
test("1. owner sees a real, data-backed dashboard with configuration warnings", async ({ page }) => { 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"); await page.goto("/owner");
// The Manage hub's H1 (see (owner)/owner/page.tsx) — Dashboard content // 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 }) => { 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 page.goto("/owner/calendar");
await expect(page.getByRole("heading", { name: "Calendar" })).toBeVisible(); 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 }) => { 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 page.goto("/owner/settings/business");
await expect(page.getByRole("heading", { name: "Business Details" })).toBeVisible(); 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 }) => { 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 // Marcus and Leo each have a seeded future CONFIRMED appointment (see
// prisma/seed.ts, appt-upcoming-confirmed / appt-leo-future). Those rows // 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 }) => { 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 page.goto("/owner/team/add");
await expect(page.getByRole("heading", { name: "Add Staff" })).toBeVisible(); 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 }) => { 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.goto("/owner/team?q=Jamie");
await page.getByRole("link", { name: /Jamie Wallace/ }).click(); 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 }) => { 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 page.goto("/owner/services");
await expect(page.getByRole("heading", { name: "Services", exact: true })).toBeVisible(); 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 }) => { 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 page.goto("/owner/settings/booking-rules");
await expect(page.getByRole("heading", { name: "Booking Rules" })).toBeVisible(); 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(); await expect(page.getByText("Pending Approval")).toBeVisible();
// Restore the seeded default so later journeys (and re-runs) see auto-confirmed bookings again. // 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.goto("/owner/settings/booking-rules");
await page.getByRole("switch", { name: "Require approval for new bookings" }).click(); await page.getByRole("switch", { name: "Require approval for new bookings" }).click();
await page.getByRole("button", { name: "Save", exact: true }).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 }) => { 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 page.goto("/owner/settings/loyalty");
await expect(page.getByRole("heading", { name: "Loyalty Programme" })).toBeVisible(); 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 }) => { 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"); await page.goto("/owner/calendar?view=agenda");
// Seeded: Sam Osei has a CHECKED_IN appointment with Leo today, and sits // 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 }) => { 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 page.goto("/owner");
await expect(page).toHaveURL(/\/staff$/); 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 }) => { 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"); await page.goto("/owner");
// The old "Switch to Owner Admin"/"Switch to Barber Tools" links are // 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 }) => { 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"); await page.goto("/owner");
// "View Reports" was the old Dashboard footer's button label; Reports is // "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). // now one of the Manage hub's grouped sections (see (owner)/owner/page.tsx).
+10 -10
View File
@@ -38,7 +38,7 @@ test.describe.serial("barber operations journeys", () => {
}); });
test("1. barber signs in and sees today's schedule", async ({ page }) => { 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 page.goto("/staff");
await expect(page.getByRole("heading", { name: "Calendar" })).toBeVisible(); 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 }) => { 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"); await page.goto("/staff/current");
// Tier 1 of selectCurrentAppointment: the IN_PROGRESS row wins. // 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 }) => { 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.goto("/staff/book?q=Jordan");
await page.getByRole("button", { name: /Jordan Blake/ }).click(); 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 }) => { 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"); 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.goto(chainedAppointmentUrl);
await page.getByRole("button", { name: "Complete Appointment", exact: true }).click(); 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 }) => { 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.goto("/staff/clients");
await page.getByPlaceholder("Search by name, email, or phone").fill("Osei"); 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 }) => { 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"); 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.goto(chainedAppointmentUrl);
await page.getByRole("button", { name: "Book Follow-Up" }).click(); 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 }) => { 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"); await page.goto("/staff/settings/working-hours");
const sundaySwitch = page.getByRole("switch", { name: "Sunday working" }); 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 }) => { 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.goto("/staff/settings/breaks");
await page.getByRole("button", { name: "Add Break", exact: true }).click(); 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 }) => { 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"); await page.goto("/staff/settings/time-off");
const now = new Date(); 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 // 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 // journey 3). See NotificationService/booking-notifications.ts, notifyBookingCreated, which
// queues STAFF_NEW_APPOINTMENT for the assigned barber on every booking. // 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.goto("/staff/book?q=Jordan");
await page.getByRole("button", { name: /Jordan Blake/ }).click(); await page.getByRole("button", { name: /Jordan Blake/ }).click();
+4
View File
@@ -38,6 +38,10 @@ export default defineConfig({
// needs to be consistent for the lifetime of this one server process. // 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", AUTH_SECRET: process.env.AUTH_SECRET ?? "playwright-e2e-test-secret-do-not-use-in-production",
NEXT_PUBLIC_APP_URL: baseURL, 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",
}, },
}, },
}); });
@@ -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;
+53 -1
View File
@@ -629,7 +629,11 @@ model User {
phone String? @unique phone String? @unique
phoneVerified DateTime? phoneVerified DateTime?
image String? 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? passwordHash String?
/// Platform-level flag, separate from the business Role/Permission /// Platform-level flag, separate from the business Role/Permission
/// model — out of scope to exercise in Phase 2, but the brief's "Future /// model — out of scope to exercise in Phase 2, but the brief's "Future
@@ -694,6 +698,54 @@ model VerificationToken {
@@unique([identifier, token]) @@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 // People
// ───────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────
+46
View File
@@ -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 });
}
+6 -1
View File
@@ -37,5 +37,10 @@ export default async function InstallPage() {
); );
} }
return <InstallWizard />; // 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 <InstallWizard initialBusinessName={process.env.BUSINESS_NAME} bookingUrl={process.env.NEXT_PUBLIC_APP_URL} />;
} }
+59 -28
View File
@@ -1,22 +1,45 @@
import { PrismaAdapter } from "@auth/prisma-adapter"; import { PrismaAdapter } from "@auth/prisma-adapter";
import bcrypt from "bcryptjs";
import NextAuth from "next-auth"; import NextAuth from "next-auth";
import Credentials from "next-auth/providers/credentials"; import Credentials from "next-auth/providers/credentials";
import Google from "next-auth/providers/google"; import Google from "next-auth/providers/google";
import { authConfig } from "@/auth.config"; import { authConfig } from "@/auth.config";
import { finalizeOtp, verifyOtp } from "@/lib/auth/otp.service";
import { prisma } from "@/lib/db/prisma"; import { prisma } from "@/lib/db/prisma";
import { signInSchema } from "@/features/auth/schemas";
import { checkRateLimit } from "@/lib/security/rate-limit"; 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 * Two credential shapes, both ending in the same finalizeOtp() call and
// bcrypt's cost — without this, that branch returns near-instantly while * the same `{id, email, name, image}` return shape — see
// a wrong-password-for-a-real-account attempt always waits for a real * src/lib/auth/otp.service.ts's own header for the full verify/finalize
// bcrypt.compare, letting a timing measurement distinguish "no such * design this builds on.
// account" from "wrong password" (an account-enumeration side channel). *
// Computed once at module load (not hand-typed) so it's guaranteed to be * `{ email, code }` — normal sign-in/registration: verifyOtp then
// a valid hash at the same cost factor registration uses. * finalizeOtp back to back in this one request (functionally "verify and
const DUMMY_HASH = bcrypt.hashSync("nextchair-timing-safety-placeholder", 12); * 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<boolean> {
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({ export const { handlers, auth, signIn, signOut } = NextAuth({
...authConfig, ...authConfig,
@@ -32,32 +55,40 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
Credentials({ Credentials({
credentials: { credentials: {
email: { label: "Email", type: "email" }, 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) => { authorize: async (raw) => {
const parsed = signInSchema.safeParse(raw); const email = typeof raw?.email === "string" ? raw.email.trim().toLowerCase() : "";
if (!parsed.success) return null; if (!email) return null;
const { email, password } = parsed.data;
// Keyed by the attempted email, not IP — this is what actually // Keyed by the attempted email, not IP — this is what actually
// stops credential stuffing against one account regardless of how // stops credential stuffing against one account regardless of how
// many source addresses it's spread across. 30 attempts per 15 // many source addresses it's spread across. 30 attempts per 15
// minutes is well above anything a real forgetful user (or a // minutes is well above anything a real forgetful user would ever
// shared/office login) would ever hit, while still making online // hit, while still making online guessing impractical.
// guessing impractical — a real brute-force attempt wants const { allowed } = checkRateLimit(`signin:${email}`, { maxAttempts: 30, windowMs: 15 * 60 * 1000 });
// thousands of guesses, not tens.
const { allowed } = checkRateLimit(`signin:${email.toLowerCase()}`, {
maxAttempts: 30,
windowMs: 15 * 60 * 1000,
});
if (!allowed) return null; 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 } }); const user = await prisma.user.findUnique({ where: { email } });
// Always compare against *something* — see DUMMY_HASH above — so if (!user) return null;
// 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;
return { id: user.id, email: user.email, name: user.name, image: user.image }; return { id: user.id, email: user.email, name: user.name, image: user.image };
}, },
+11 -14
View File
@@ -3,24 +3,20 @@
import { signIn } from "@/auth"; import { signIn } from "@/auth";
import { prisma } from "@/lib/db/prisma"; import { prisma } from "@/lib/db/prisma";
import { acceptInvitation, validateInvitationToken } from "@/services/staff/staff-invitation.service"; import { acceptInvitation, validateInvitationToken } from "@/services/staff/staff-invitation.service";
import { acceptInvitationSchema } from "@/features/auth/schemas";
export interface AcceptInvitationResult { export interface AcceptInvitationResult {
success: boolean; success: boolean;
error?: string; error?: string;
fieldErrors?: Record<string, string[] | undefined>;
} }
/** Public — the invited person has no account/session yet. Mirrors registerAction's shape: create the credential, then sign in immediately, same as a fresh registration. */ /**
export async function acceptInvitationAction(token: string, formData: FormData): Promise<AcceptInvitationResult> { * Public — the invited person has no account/session yet. No password or
const parsed = acceptInvitationSchema.safeParse({ * OTP step: the invite token itself (delivered only to the invited
password: formData.get("password"), * email) is already the trust anchor — see acceptInvitation's own doc
confirmPassword: formData.get("confirmPassword"), * comment. Signs in via src/auth.ts's token-gated finalize path,
}); * reusing this same token as the required secret.
if (!parsed.success) { */
return { success: false, error: "Please fix the highlighted fields.", fieldErrors: parsed.error.flatten().fieldErrors }; export async function acceptInvitationAction(token: string): Promise<AcceptInvitationResult> {
}
const check = await validateInvitationToken(prisma, token); const check = await validateInvitationToken(prisma, token);
if (!check.valid) { if (!check.valid) {
const message = const message =
@@ -32,14 +28,15 @@ export async function acceptInvitationAction(token: string, formData: FormData):
return { success: false, error: message }; return { success: false, error: message };
} }
const result = await acceptInvitation(prisma, { token, password: parsed.data.password }); const result = await acceptInvitation(prisma, { token });
if (!result.success) { if (!result.success) {
return { success: false, error: "Couldn't accept this invitation. Please try again." }; return { success: false, error: "Couldn't accept this invitation. Please try again." };
} }
await signIn("credentials", { await signIn("credentials", {
email: check.invitation.staff.user.email, email: check.invitation.staff.user.email,
password: parsed.data.password, finalize: "true",
token,
redirectTo: "/staff", redirectTo: "/staff",
}); });
+57 -22
View File
@@ -1,51 +1,88 @@
"use server"; "use server";
import bcrypt from "bcryptjs";
import { signIn } from "@/auth"; 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 { prisma } from "@/lib/db/prisma";
import { registerSchema } from "@/features/auth/schemas";
import { checkRateLimit } from "@/lib/security/rate-limit"; import { checkRateLimit } from "@/lib/security/rate-limit";
import { getRequestIp } from "@/lib/security/request-ip"; import { getRequestIp } from "@/lib/security/request-ip";
import { safeRedirectPath } from "@/lib/utils/safe-redirect"; 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; success: boolean;
error?: string; error?: string;
/** Per-field errors, keyed by field name, for the form to surface inline. */
fieldErrors?: Record<string, string[] | undefined>; fieldErrors?: Record<string, string[] | undefined>;
} }
/** /** 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). */
* Creates a User + CustomerProfile in one transaction, then signs the user export async function requestRegisterOtpAction(formData: FormData): Promise<RegisterActionResult> {
* straight in — no email-verification gate yet (that needs the
* Notifications system, which is out of scope for Phase 2; see
* docs/implementation-plan.md §15, Question 3-adjacent).
*/
export async function registerAction(formData: FormData): Promise<RegisterResult> {
const ip = await getRequestIp(); const ip = await getRequestIp();
const { allowed } = checkRateLimit(`register:${ip}`, { maxAttempts: 5, windowMs: 60 * 60 * 1000 }); const { allowed } = checkRateLimit(`register:${ip}`, { maxAttempts: 5, windowMs: 60 * 60 * 1000 });
if (!allowed) { if (!allowed) {
return { success: false, error: "Too many attempts. Please try again later." }; return { success: false, error: "Too many attempts. Please try again later." };
} }
const parsed = registerSchema.safeParse({ const parsed = registerRequestSchema.safeParse({
firstName: formData.get("firstName"), firstName: formData.get("firstName"),
lastName: formData.get("lastName"), lastName: formData.get("lastName"),
email: formData.get("email"), email: formData.get("email"),
phone: formData.get("phone"), phone: formData.get("phone"),
password: formData.get("password"),
confirmPassword: formData.get("confirmPassword"),
}); });
if (!parsed.success) { 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 { return {
success: false, success: false,
error: "Please fix the highlighted fields.", error: "An account with this email already exists.",
fieldErrors: parsed.error.flatten().fieldErrors, fieldErrors: { email: ["An account with this email already exists."] },
}; };
} }
const { firstName, lastName, email, phone, password } = parsed.data; const branding = await getBranding();
await requestOtp(prisma, {
email: parsed.data.email,
purpose: "register",
ip,
senderName: resolveSenderName(branding.configured ? branding.name : null),
emailProvider: getEmailProvider(),
});
return { success: true };
}
/**
* Step 2 — verifies the code, creates the User + CustomerProfile, then
* signs in with the same still-valid (verified-but-not-yet-consumed) code
* — see src/lib/auth/otp.service.ts's header for why verifyOtp can safely
* run again here without a separate finalize/token path: it doesn't
* consume the code, only signIn's own authorize() call does, immediately
* after.
*/
export async function verifyRegisterOtpAction(formData: FormData): Promise<RegisterActionResult> {
const ip = await getRequestIp();
const parsed = registerVerifySchema.safeParse({
firstName: formData.get("firstName"),
lastName: formData.get("lastName"),
email: formData.get("email"),
phone: formData.get("phone"),
code: formData.get("code"),
});
if (!parsed.success) {
return { success: false, error: "Please fix the highlighted fields.", fieldErrors: parsed.error.flatten().fieldErrors };
}
const { firstName, lastName, email, phone, code } = parsed.data;
const verified = await verifyOtp(prisma, email, code, ip);
if (!verified.ok) {
return { success: false, error: "Incorrect or expired code. Please try again." };
}
const existing = await prisma.user.findUnique({ where: { email } }); const existing = await prisma.user.findUnique({ where: { email } });
if (existing) { if (existing) {
@@ -56,14 +93,12 @@ export async function registerAction(formData: FormData): Promise<RegisterResult
}; };
} }
const passwordHash = await bcrypt.hash(password, 12);
await prisma.user.create({ await prisma.user.create({
data: { data: {
email, email,
phone: phone || null, phone: phone || null,
name: `${firstName} ${lastName}`, name: `${firstName} ${lastName}`,
passwordHash, emailVerified: new Date(),
customerProfile: { customerProfile: {
create: { create: {
firstName, firstName,
@@ -77,7 +112,7 @@ export async function registerAction(formData: FormData): Promise<RegisterResult
}); });
const redirectTo = safeRedirectPath(formData.get("callbackUrl")?.toString(), "/home"); const redirectTo = safeRedirectPath(formData.get("callbackUrl")?.toString(), "/home");
await signIn("credentials", { email, password, redirectTo }); await signIn("credentials", { email, code, redirectTo });
return { success: true }; return { success: true };
} }
@@ -0,0 +1,40 @@
"use server";
import { emailOnlySchema } from "@/features/auth/schemas";
import { requestOtp } from "@/lib/auth/otp.service";
import { getBranding } from "@/lib/business/branding";
import { prisma } from "@/lib/db/prisma";
import { getRequestIp } from "@/lib/security/request-ip";
import { getEmailProvider } from "@/services/notifications/providers";
import { resolveSenderName } from "@/services/notifications/sender-identity";
export interface RequestOtpActionResult {
ok: boolean;
error?: string;
}
/**
* Always returns {ok:true} for a well-formed email address, whether or
* not an account exists for it — `onlyIfUserExists` is what keeps "do not
* reveal on the normal login screen whether an email address exists"
* true, by simply not sending anything (and not creating an OTP row) for
* an email with no account, without the caller being able to tell the
* difference from the response.
*/
export async function requestSignInOtpAction(email: string): Promise<RequestOtpActionResult> {
const parsed = emailOnlySchema.safeParse({ email });
if (!parsed.success) return { ok: false, error: "Enter a valid email address." };
const ip = await getRequestIp();
const branding = await getBranding();
const result = await requestOtp(prisma, {
email: parsed.data.email,
purpose: "login",
ip,
onlyIfUserExists: true,
senderName: resolveSenderName(branding.configured ? branding.name : null),
emailProvider: getEmailProvider(),
});
if (!result.ok) return { ok: false, error: "Too many attempts. Please try again later." };
return { ok: true };
}
@@ -1,69 +1,39 @@
"use client"; "use client";
import { zodResolver } from "@hookform/resolvers/zod"; import { useState, useTransition } from "react";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { acceptInvitationAction } from "@/features/auth/actions/accept-invitation"; 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 }) { export function AcceptInvitationForm({ token, businessName }: { token: string; businessName: string }) {
const [formError, setFormError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const { const onAccept = () => {
register, setError(null);
handleSubmit, startTransition(async () => {
setError, const result = await acceptInvitationAction(token);
formState: { errors, isSubmitting }, if (!result.success) {
} = useForm<AcceptInvitationInput>({ resolver: zodResolver(acceptInvitationSchema) }); setError(result.error ?? "Something went wrong. Please try again.");
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] });
}
} }
} // On success, acceptInvitationAction signs the user in and redirects
// server-side — nothing else to do here.
});
}; };
return ( return (
<form onSubmit={handleSubmit(onSubmit)} noValidate className="space-y-4"> <div className="space-y-4">
<p className="text-muted text-sm"> <p className="text-muted text-sm">
You&apos;ve been invited to join <span className="text-ink font-bold">{businessName}</span>. Set a password You&apos;ve been invited to join <span className="text-ink font-bold">{businessName}</span>. This link
to finish setting up your account. confirms it&apos;s really you — no password needed.
</p> </p>
<Input {error && (
label="Password"
type="password"
placeholder="••••••••"
error={errors.password?.message}
{...register("password")}
/>
<Input
label="Confirm Password"
type="password"
placeholder="••••••••"
error={errors.confirmPassword?.message}
{...register("confirmPassword")}
/>
{formError && (
<p role="alert" className="text-danger text-sm font-medium"> <p role="alert" className="text-danger text-sm font-medium">
{formError} {error}
</p> </p>
)} )}
<Button type="submit" className="mt-4 w-full" isLoading={isSubmitting}> <Button type="button" className="mt-4 w-full" isLoading={isPending} onClick={onAccept}>
Set Password &amp; Sign In Accept &amp; Sign In
</Button> </Button>
</form> </div>
); );
} }
@@ -0,0 +1,98 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
export interface OtpVerifyResult {
ok: boolean;
error?: string;
}
export interface OtpCodeStepProps {
email: string;
onVerify: (code: string) => Promise<OtpVerifyResult>;
onResend: () => Promise<OtpVerifyResult>;
onChangeEmail: () => void;
verifyLabel?: string;
}
/**
* The "enter the code" half of every OTP flow in the app — reused
* verbatim by sign-in, registration, and the installer's administrator
* step (embedded inline there, not a redirect — see install-wizard.tsx).
* Deliberately owns only this step: each caller manages its own "email"
* vs "code" phase and renders whatever step-1 fields it needs (sign-in
* needs just an email, registration/installer also need a name) before
* mounting this component — trying to also genericize step 1 fought the
* different field sets more than it saved.
*/
export function OtpCodeStep({ email, onVerify, onResend, onChangeEmail, verifyLabel = "Verify" }: OtpCodeStepProps) {
const [code, setCode] = useState("");
const [error, setError] = useState<string | null>(null);
const [resendMessage, setResendMessage] = useState<string | null>(null);
const [status, setStatus] = useState<"idle" | "verifying" | "resending">("idle");
const handleVerify = async () => {
setError(null);
setResendMessage(null);
setStatus("verifying");
const result = await onVerify(code);
setStatus("idle");
if (!result.ok) setError(result.error ?? "That code didn't work. Please try again.");
};
const handleResend = async () => {
setError(null);
setResendMessage(null);
setStatus("resending");
const result = await onResend();
setStatus("idle");
setResendMessage(result.ok ? "We sent a new code." : (result.error ?? "Couldn't resend right now."));
};
return (
<div className="space-y-4">
<p className="text-muted text-sm">
We sent a code to <span className="text-ink font-semibold">{email}</span>
</p>
<Input
label="Verification Code"
inputMode="numeric"
autoComplete="one-time-code"
placeholder="123456"
maxLength={6}
value={code}
onChange={(e) => setCode(e.target.value.replace(/\D/g, "").slice(0, 6))}
error={error ?? undefined}
/>
{resendMessage && !error && (
<p role="status" className="text-accent-foreground text-sm font-medium">
{resendMessage}
</p>
)}
<Button
type="button"
className="w-full"
isLoading={status === "verifying"}
disabled={code.length !== 6 || status !== "idle"}
onClick={handleVerify}
>
{verifyLabel}
</Button>
<div className="flex items-center justify-between text-sm">
<button
type="button"
onClick={handleResend}
disabled={status !== "idle"}
className="border-ink text-ink border-b-2 font-bold disabled:opacity-50"
>
Resend code
</button>
<button type="button" onClick={onChangeEmail} className="text-muted font-bold underline">
Change email
</button>
</div>
</div>
);
}
+50 -31
View File
@@ -7,48 +7,81 @@ import { useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { registerAction } from "@/features/auth/actions/register"; import { OtpCodeStep } from "@/features/auth/components/otp-code-step";
import { registerSchema, type RegisterInput } from "@/features/auth/schemas"; import { requestRegisterOtpAction, verifyRegisterOtpAction } from "@/features/auth/actions/register";
import { registerRequestSchema, type RegisterRequestInput } from "@/features/auth/schemas";
import { safeRedirectPath } from "@/lib/utils/safe-redirect"; import { safeRedirectPath } from "@/lib/utils/safe-redirect";
export function RegisterForm() { export function RegisterForm() {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const callbackUrl = safeRedirectPath(searchParams.get("callbackUrl"), "/home"); const callbackUrl = safeRedirectPath(searchParams.get("callbackUrl"), "/home");
const [formError, setFormError] = useState<string | null>(null); const [formError, setFormError] = useState<string | null>(null);
const [pending, setPending] = useState<RegisterRequestInput | null>(null);
const { const {
register, register,
handleSubmit, handleSubmit,
setError, setError,
formState: { errors, isSubmitting }, formState: { errors, isSubmitting },
} = useForm<RegisterInput>({ } = useForm<RegisterRequestInput>({
resolver: zodResolver(registerSchema), resolver: zodResolver(registerRequestSchema),
}); });
const onSubmit = async (data: RegisterInput) => { const toFormData = (data: RegisterRequestInput): FormData => {
setFormError(null);
const formData = new FormData(); const formData = new FormData();
Object.entries(data).forEach(([key, value]) => formData.set(key, value ?? "")); formData.set("firstName", data.firstName);
formData.set("callbackUrl", callbackUrl); formData.set("lastName", data.lastName);
formData.set("email", data.email);
formData.set("phone", data.phone ?? "");
return formData;
};
const result = await registerAction(formData); const onRequestCode = async (data: RegisterRequestInput) => {
setFormError(null);
// A successful call redirects server-side and never resolves here with const result = await requestRegisterOtpAction(toFormData(data));
// success: true in practice, but we still handle it for completeness.
if (!result.success) { if (!result.success) {
setFormError(result.error ?? "Something went wrong. Please try again."); setFormError(result.error ?? "Something went wrong. Please try again.");
if (result.fieldErrors) { if (result.fieldErrors) {
for (const [field, messages] of Object.entries(result.fieldErrors)) { for (const [field, messages] of Object.entries(result.fieldErrors)) {
if (messages?.[0]) { if (messages?.[0]) setError(field as keyof RegisterRequestInput, { message: messages[0] });
setError(field as keyof RegisterInput, { message: messages[0] });
}
} }
} }
return;
} }
setPending(data);
}; };
const verifyCode = async (code: string) => {
if (!pending) return { ok: false, error: "Something went wrong. Please start again." };
const formData = toFormData(pending);
formData.set("code", code);
formData.set("callbackUrl", callbackUrl);
const result = await verifyRegisterOtpAction(formData);
if (!result.success) return { ok: false, error: result.error };
return { ok: true };
};
const resendCode = async () => {
if (!pending) return { ok: false, error: "Something went wrong. Please start again." };
const result = await requestRegisterOtpAction(toFormData(pending));
return result.success ? { ok: true } : { ok: false, error: result.error };
};
if (pending) {
return (
<OtpCodeStep
email={pending.email}
onVerify={verifyCode}
onResend={resendCode}
onChangeEmail={() => setPending(null)}
verifyLabel="Create Account"
/>
);
}
return ( return (
<form onSubmit={handleSubmit(onSubmit)} noValidate className="space-y-4"> <form onSubmit={handleSubmit(onRequestCode)} noValidate className="space-y-4">
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<Input <Input
label="First Name" label="First Name"
@@ -77,20 +110,6 @@ export function RegisterForm() {
error={errors.phone?.message} error={errors.phone?.message}
{...register("phone")} {...register("phone")}
/> />
<Input
label="Password"
type="password"
placeholder="••••••••"
error={errors.password?.message}
{...register("password")}
/>
<Input
label="Confirm Password"
type="password"
placeholder="••••••••"
error={errors.confirmPassword?.message}
{...register("confirmPassword")}
/>
{formError && ( {formError && (
<p role="alert" className="text-danger text-sm font-medium"> <p role="alert" className="text-danger text-sm font-medium">
@@ -111,7 +130,7 @@ export function RegisterForm() {
</p> </p>
<Button type="submit" className="mt-4 w-full" isLoading={isSubmitting}> <Button type="submit" className="mt-4 w-full" isLoading={isSubmitting}>
Sign Up Continue
</Button> </Button>
<p className="text-muted mt-10 text-center text-sm"> <p className="text-muted mt-10 text-center text-sm">
+80 -62
View File
@@ -1,7 +1,7 @@
"use client"; "use client";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { Mail, Lock } from "lucide-react"; import { Mail } from "lucide-react";
import { signIn } from "next-auth/react"; import { signIn } from "next-auth/react";
import Link from "next/link"; import Link from "next/link";
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
@@ -9,7 +9,9 @@ import { useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; 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"; import { safeRedirectPath } from "@/lib/utils/safe-redirect";
export function SignInForm() { export function SignInForm() {
@@ -20,87 +22,103 @@ export function SignInForm() {
// knows how to send a barber/owner/super admin to their Calendar tab // knows how to send a barber/owner/super admin to their Calendar tab
// instead of the customer home. See src/app/page.tsx. // instead of the customer home. See src/app/page.tsx.
const callbackUrl = safeRedirectPath(searchParams.get("callbackUrl"), "/"); const callbackUrl = safeRedirectPath(searchParams.get("callbackUrl"), "/");
const [email, setEmail] = useState<string | null>(null);
const [formError, setFormError] = useState<string | null>(null); const [formError, setFormError] = useState<string | null>(null);
const { const {
register, register,
handleSubmit, handleSubmit,
formState: { errors, isSubmitting }, formState: { errors, isSubmitting },
} = useForm<SignInInput>({ } = useForm<EmailOnlyInput>({
resolver: zodResolver(signInSchema), resolver: zodResolver(emailOnlySchema),
}); });
const onSubmit = async (data: SignInInput) => { const onRequestCode = async (data: EmailOnlyInput) => {
setFormError(null); setFormError(null);
const result = await signIn("credentials", { ...data, redirect: false, callbackUrl }); // Deliberately no branching on the result's success/failure content
if (result?.error) { // here beyond the rate-limit case — a well-formed email always
setFormError("Incorrect email or password."); // 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; 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); 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 ( return (
<div className="w-full"> <div className="w-full">
<form onSubmit={handleSubmit(onSubmit)} noValidate className="space-y-4"> {email ? (
<Input <OtpCodeStep email={email} onVerify={verifyCode} onResend={resendCode} onChangeEmail={() => setEmail(null)} verifyLabel="Sign In" />
label="Email Address" ) : (
type="email" <form onSubmit={handleSubmit(onRequestCode)} noValidate className="space-y-4">
icon={<Mail className="h-4 w-4" aria-hidden="true" />} <Input
placeholder="name@example.com" label="Email Address"
error={errors.email?.message} type="email"
{...register("email")} icon={<Mail className="h-4 w-4" aria-hidden="true" />}
/> placeholder="name@example.com"
<Input error={errors.email?.message}
label="Password" {...register("email")}
id="password" />
type="password"
icon={<Lock className="h-4 w-4" aria-hidden="true" />}
placeholder="••••••••"
error={errors.password?.message}
{...register("password")}
/>
{/* Password reset needs the Notifications system (email delivery)
to actually send a reset link — out of scope for this phase.
The "Forgot Password" template (8) is a design reference for
when that lands; no dead link to it here in the meantime. */}
{formError && ( {formError && (
<p role="alert" className="text-danger text-sm font-medium"> <p role="alert" className="text-danger text-sm font-medium">
{formError} {formError}
</p> </p>
)} )}
<Button type="submit" className="mt-2 w-full" isLoading={isSubmitting}> <Button type="submit" className="mt-2 w-full" isLoading={isSubmitting}>
Sign In Continue
</Button> </Button>
</form> </form>
)}
<div className="mt-10"> {!email && (
<div className="relative mb-8 flex items-center justify-center"> <>
<div className="absolute inset-0 flex items-center"> <div className="mt-10">
<div className="border-line w-full border-t" /> <div className="relative mb-8 flex items-center justify-center">
<div className="absolute inset-0 flex items-center">
<div className="border-line w-full border-t" />
</div>
<span className="bg-stone text-muted relative px-4 text-[10px] font-bold tracking-widest uppercase">
Or continue with
</span>
</div>
<Button
type="button"
variant="secondary"
className="w-full tracking-normal normal-case"
onClick={() => signIn("google", { callbackUrl })}
>
Continue with Google
</Button>
</div> </div>
<span className="bg-stone text-muted relative px-4 text-[10px] font-bold tracking-widest uppercase">
Or continue with
</span>
</div>
<Button
type="button"
variant="secondary"
className="w-full tracking-normal normal-case"
onClick={() => signIn("google", { callbackUrl })}
>
Continue with Google
</Button>
</div>
<p className="text-muted mt-10 text-center text-sm"> <p className="text-muted mt-10 text-center text-sm">
Don&apos;t have an account?{" "} Don&apos;t have an account?{" "}
<Link href={`/register?callbackUrl=${encodeURIComponent(callbackUrl)}`} className="border-ink text-ink border-b-2 font-bold"> <Link href={`/register?callbackUrl=${encodeURIComponent(callbackUrl)}`} className="border-ink text-ink border-b-2 font-bold">
Create one Create one
</Link> </Link>
</p> </p>
</>
)}
</div> </div>
); );
} }
+29 -30
View File
@@ -1,39 +1,38 @@
import { z } from "zod"; import { z } from "zod";
/** /**
* Single source of truth for auth form shapes — shared between the client * Single source of truth for auth form shapes — shared between client
* form (react-hook-form + zodResolver) and the server action / Credentials * forms (react-hook-form + zodResolver) and their server actions. Every
* `authorize()` callback, so validation never drifts between the two. * 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"), email: z.email("Enter a valid email address"),
password: z.string().min(1, "Password is required"),
}); });
export type SignInInput = z.infer<typeof signInSchema>; export type EmailOnlyInput = z.infer<typeof emailOnlySchema>;
export const registerSchema = z /** 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. */
.object({ export const signInVerifySchema = z.object({
firstName: z.string().trim().min(1, "First name is required"), email: z.email("Enter a valid email address"),
lastName: z.string().trim().min(1, "Last name is required"), code: otpCode,
email: z.email("Enter a valid email address"), });
phone: z.string().trim().optional().or(z.literal("")), export type SignInVerifyInput = z.infer<typeof signInVerifySchema>;
password: z.string().min(8, "Password must be at least 8 characters"),
confirmPassword: z.string(),
})
.refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ["confirmPassword"],
});
export type RegisterInput = z.infer<typeof registerSchema>;
export const acceptInvitationSchema = z export const registerRequestSchema = z.object({
.object({ firstName: z.string().trim().min(1, "First name is required"),
password: z.string().min(8, "Password must be at least 8 characters"), lastName: z.string().trim().min(1, "Last name is required"),
confirmPassword: z.string(), email: z.email("Enter a valid email address"),
}) phone: z.string().trim().optional().or(z.literal("")),
.refine((data) => data.password === data.confirmPassword, { });
message: "Passwords don't match", export type RegisterRequestInput = z.infer<typeof registerRequestSchema>;
path: ["confirmPassword"],
}); export const registerVerifySchema = registerRequestSchema.extend({
export type AcceptInvitationInput = z.infer<typeof acceptInvitationSchema>; code: otpCode,
});
export type RegisterVerifyInput = z.infer<typeof registerVerifySchema>;
@@ -1,6 +1,8 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { prisma } from "@/services/scheduling/__tests__/fixtures"; import { prisma } from "@/services/scheduling/__tests__/fixtures";
import { InstallationStatus } from "@/generated/prisma/enums"; 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 { installApplication } from "../installer.service";
import type { InstallInput } from "../schema"; import type { InstallInput } from "../schema";
@@ -25,8 +27,6 @@ function buildInput(overrides: Partial<InstallInput> = {}): InstallInput {
lastName: "Owner", lastName: "Owner",
email: `install-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@example.com`, email: `install-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@example.com`,
phone: "", phone: "",
password: "correct-horse-battery",
confirmPassword: "correct-horse-battery",
}, },
business: { business: {
name: "Integration Test Barbershop", name: "Integration Test Barbershop",
@@ -75,6 +75,25 @@ function buildInput(overrides: Partial<InstallInput> = {}): InstallInput {
}; };
} }
/** Drives the real requestOtp/verifyOtp pair via a MockEmailProvider (never a real send) so a test can get an administrator's email into the "verified, not yet consumed" state installApplication now requires — see installer.service.ts's finalizeOtp call. */
async function verifyAdministratorEmail(email: string): Promise<void> {
const mockProvider = new MockEmailProvider();
await requestOtp(prisma, { email, purpose: "install", emailProvider: mockProvider });
const sent = mockProvider.sentEmails[0];
const code = /verification code is (\d{6})/.exec(sent?.text ?? "")?.[1];
if (!code) throw new Error("Test setup failed: couldn't extract the OTP code from the captured email.");
const result = await verifyOtp(prisma, email, code);
if (!result.ok) throw new Error(`Test setup failed: OTP verification failed (${result.reason}).`);
}
/** buildInput() plus the email-verification step every successful install now requires. */
async function buildVerifiedInput(overrides: Partial<InstallInput> = {}): Promise<InstallInput> {
const input = buildInput(overrides);
await verifyAdministratorEmail(input.administrator.email);
return input;
}
async function resetInstallationSingleton(): Promise<void> { async function resetInstallationSingleton(): Promise<void> {
await prisma.applicationInstallation.deleteMany({ where: { id: "singleton" } }); await prisma.applicationInstallation.deleteMany({ where: { id: "singleton" } });
} }
@@ -116,6 +135,8 @@ describe("installApplication (integration)", () => {
const originalToken = process.env.INSTALLATION_TOKEN; const originalToken = process.env.INSTALLATION_TOKEN;
process.env.INSTALLATION_TOKEN = INSTALLATION_TOKEN; process.env.INSTALLATION_TOKEN = INSTALLATION_TOKEN;
try { try {
// Deliberately unverified — the token check happens first and must
// reject before ever reaching the OTP check.
const result = await installApplication(prisma, { const result = await installApplication(prisma, {
input: buildInput({ installationToken: "wrong-token" }), input: buildInput({ installationToken: "wrong-token" }),
installedVersion: "test", 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 () => { it("creates the business, administrator, permissions, booking rules, and starter services transactionally", async () => {
const originalToken = process.env.INSTALLATION_TOKEN; const originalToken = process.env.INSTALLATION_TOKEN;
process.env.INSTALLATION_TOKEN = INSTALLATION_TOKEN; process.env.INSTALLATION_TOKEN = INSTALLATION_TOKEN;
try { 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); expect(result.success).toBe(true);
if (!result.success) return; if (!result.success) return;
createdBusinessId = result.businessId; createdBusinessId = result.businessId;
@@ -164,6 +205,10 @@ describe("installApplication (integration)", () => {
expect(barberRole).toBeDefined(); expect(barberRole).toBeDefined();
expect(ownerRole!.permissions.length).toBeGreaterThan(barberRole!.permissions.length); 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 } }); const staffProfile = await prisma.staffProfile.findUnique({ where: { userId: result.userId }, include: { services: true } });
expect(staffProfile?.roleId).toBe(ownerRole!.id); expect(staffProfile?.roleId).toBe(ownerRole!.id);
// The initial owner is eligible for every starter service — see the brief's "initial owner is service-eligible." // 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; const originalToken = process.env.INSTALLATION_TOKEN;
process.env.INSTALLATION_TOKEN = INSTALLATION_TOKEN; process.env.INSTALLATION_TOKEN = INSTALLATION_TOKEN;
try { 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); expect(first.success).toBe(true);
if (first.success) createdBusinessId = first.businessId; if (first.success) createdBusinessId = first.businessId;
const secondEmail = `second-${Date.now()}@example.com`;
await verifyAdministratorEmail(secondEmail);
const secondInput = buildInput();
const second = await installApplication(prisma, { const second = await installApplication(prisma, {
input: buildInput({ input: { ...secondInput, administrator: { ...secondInput.administrator, email: secondEmail } },
administrator: { ...buildInput().administrator, email: `second-${Date.now()}@example.com` },
}),
installedVersion: "test", installedVersion: "test",
}); });
expect(second.success).toBe(false); expect(second.success).toBe(false);
@@ -205,8 +251,12 @@ describe("installApplication (integration)", () => {
const originalToken = process.env.INSTALLATION_TOKEN; const originalToken = process.env.INSTALLATION_TOKEN;
process.env.INSTALLATION_TOKEN = INSTALLATION_TOKEN; process.env.INSTALLATION_TOKEN = INSTALLATION_TOKEN;
try { try {
const inputA = buildInput({ administrator: { ...buildInput().administrator, email: `concurrent-a-${Date.now()}@example.com` } }); const emailA = `concurrent-a-${Date.now()}@example.com`;
const inputB = buildInput({ administrator: { ...buildInput().administrator, email: `concurrent-b-${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([ const [resultA, resultB] = await Promise.all([
installApplication(prisma, { input: inputA, installedVersion: "test" }), installApplication(prisma, { input: inputA, installedVersion: "test" }),
+64 -2
View File
@@ -1,10 +1,13 @@
"use server"; "use server";
import { signIn } from "@/auth"; 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 { prisma } from "@/lib/db/prisma";
import { getRequestIp } from "@/lib/security/request-ip";
import { checkRateLimit } from "@/lib/security/rate-limit"; import { checkRateLimit } from "@/lib/security/rate-limit";
import { getRequestIp } from "@/lib/security/request-ip";
import { secretsMatch } from "@/lib/security/secrets-match"; import { secretsMatch } from "@/lib/security/secrets-match";
import { getEmailProvider } from "@/services/notifications/providers";
import { installApplication } from "../installer.service"; import { installApplication } from "../installer.service";
import { installSchema } from "../schema"; import { installSchema } from "../schema";
import type { InstallInput } from "../schema"; import type { InstallInput } from "../schema";
@@ -40,6 +43,58 @@ export async function verifyInstallationTokenAction(token: string): Promise<Veri
return { valid: true }; return { valid: true };
} }
export interface InstallOtpActionResult {
ok: boolean;
error?: string;
}
/**
* Both OTP actions below additionally require the installation token —
* not just email format — because the wizard's Administrator step embeds
* this inline rather than redirecting to /sign-in, and because
* src/auth.ts's later `{finalize:"true", token}` sign-in call reuses this
* same token as proof the caller is this specific trusted flow, not a
* direct POST to the public credentials callback route (see that file's
* own header for the full reasoning). Requiring it here too means someone
* without the installation token can't even generate/verify a code for an
* arbitrary email through this endpoint.
*/
export async function requestInstallOtpAction(email: string, installationToken: string): Promise<InstallOtpActionResult> {
const tokenCheck = await verifyInstallationTokenAction(installationToken);
if (!tokenCheck.valid) return { ok: false, error: tokenCheck.error ?? "Installation is not available." };
const parsed = emailOnlySchema.safeParse({ email });
if (!parsed.success) return { ok: false, error: "Enter a valid email address." };
const ip = await getRequestIp();
const result = await requestOtp(prisma, {
email: parsed.data.email,
purpose: "install",
ip,
senderName: "NextChair",
emailProvider: getEmailProvider(),
});
if (!result.ok) return { ok: false, error: "Too many attempts. Please try again later." };
return { ok: true };
}
export async function verifyInstallOtpAction(
email: string,
code: string,
installationToken: string,
): Promise<InstallOtpActionResult> {
const tokenCheck = await verifyInstallationTokenAction(installationToken);
if (!tokenCheck.valid) return { ok: false, error: tokenCheck.error ?? "Installation is not available." };
const parsed = signInVerifySchema.safeParse({ email, code });
if (!parsed.success) return { ok: false, error: "Enter the 6-digit code." };
const ip = await getRequestIp();
const result = await verifyOtp(prisma, parsed.data.email, parsed.data.code, ip);
if (!result.ok) return { ok: false, error: "Incorrect or expired code. Please try again." };
return { ok: true };
}
export interface SubmitInstallationResult { export interface SubmitInstallationResult {
success: boolean; success: boolean;
error?: string; error?: string;
@@ -63,9 +118,16 @@ export async function submitInstallationAction(input: InstallInput): Promise<Sub
return { success: false, error: result.message }; return { success: false, error: result.message };
} }
// The administrator's OTP was already consumed inside installApplication
// (see finalizeOtp there) — this sign-in uses the token-gated finalize
// path, not a fresh code, since the code itself can't be re-entered
// (one-time use). See src/auth.ts's own header for why `token` is
// required here rather than just trusting this call came from the right
// place.
await signIn("credentials", { await signIn("credentials", {
email: parsed.data.administrator.email, email: parsed.data.administrator.email,
password: parsed.data.administrator.password, finalize: "true",
token: parsed.data.installationToken,
redirectTo: "/owner?installed=1", redirectTo: "/owner?installed=1",
}); });
@@ -5,7 +5,13 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { submitInstallationAction, verifyInstallationTokenAction } from "../actions/install"; import { OtpCodeStep } from "@/features/auth/components/otp-code-step";
import {
requestInstallOtpAction,
submitInstallationAction,
verifyInstallOtpAction,
verifyInstallationTokenAction,
} from "../actions/install";
import type { InstallInput } from "../schema"; import type { InstallInput } from "../schema";
/** /**
@@ -105,7 +111,15 @@ const DEFAULT_STARTER_SERVICES: StarterServiceState[] = [
}, },
]; ];
export function InstallWizard() { export function InstallWizard({
initialBusinessName = "",
bookingUrl,
}: {
/** From process.env.BUSINESS_NAME, set by the Manager at provisioning time (see manager/src/lib/compose-template.ts) — pre-fills Step 3's Business Name but stays editable, since the operator may want a slightly different public name. */
initialBusinessName?: string;
/** From process.env.NEXT_PUBLIC_APP_URL, also Manager-provisioned — shown read-only in Step 3, never an editable field: this is infrastructure/routing configuration, not business profile data (see stacks/infra's Caddy site, which is what actually makes this domain work). */
bookingUrl?: string;
}) {
const [step, setStep] = useState(1); const [step, setStep] = useState(1);
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const [submitError, setSubmitError] = useState<string | null>(null); const [submitError, setSubmitError] = useState<string | null>(null);
@@ -114,11 +128,23 @@ export function InstallWizard() {
const [token, setToken] = useState(""); const [token, setToken] = useState("");
const [tokenError, setTokenError] = useState<string | null>(null); const [tokenError, setTokenError] = useState<string | null>(null);
// Step 2 // Step 2 — otpPhase tracks verification separately from the rest of the
const [admin, setAdmin] = useState({ firstName: "", lastName: "", email: "", phone: "", password: "", confirmPassword: "" }); // form: "collect" (editing name/email/phone), "code" (entering the
// emailed code), "verified" (done, can proceed). Editing the email after
// verifying resets to "collect" — see the email field's onChange below.
const [admin, setAdmin] = useState({ firstName: "", lastName: "", email: "", phone: "" });
const [otpPhase, setOtpPhase] = useState<"collect" | "code" | "verified">("collect");
const [otpError, setOtpError] = useState<string | null>(null);
// Step 3 // 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 // Step 4
const [location, setLocation] = useState({ const [location, setLocation] = useState({
@@ -177,6 +203,30 @@ export function InstallWizard() {
}); });
}; };
const requestAdminCode = () => {
setOtpError(null);
startTransition(async () => {
const result = await requestInstallOtpAction(admin.email, token);
if (!result.ok) {
setOtpError(result.error ?? "Couldn't send a code. Please try again.");
return;
}
setOtpPhase("code");
});
};
const verifyAdminCode = async (code: string) => {
const result = await verifyInstallOtpAction(admin.email, code, token);
if (!result.ok) return { ok: false, error: result.error };
setOtpPhase("verified");
return { ok: true };
};
const resendAdminCode = async () => {
const result = await requestInstallOtpAction(admin.email, token);
return result.ok ? { ok: true } : { ok: false, error: result.error };
};
const updateDay = (dayOfWeek: WeekdayCode, patch: Partial<HoursDayState>) => { const updateDay = (dayOfWeek: WeekdayCode, patch: Partial<HoursDayState>) => {
setHours((prev) => prev.map((d) => (d.dayOfWeek === dayOfWeek ? { ...d, ...patch } : d))); setHours((prev) => prev.map((d) => (d.dayOfWeek === dayOfWeek ? { ...d, ...patch } : d)));
}; };
@@ -261,13 +311,7 @@ export function InstallWizard() {
const canProceedFromStep = (s: number): boolean => { const canProceedFromStep = (s: number): boolean => {
switch (s) { switch (s) {
case 2: case 2:
return ( return !!admin.firstName.trim() && !!admin.lastName.trim() && !!admin.email.trim() && otpPhase === "verified";
!!admin.firstName.trim() &&
!!admin.lastName.trim() &&
!!admin.email.trim() &&
admin.password.length >= 8 &&
admin.password === admin.confirmPassword
);
case 3: case 3:
return !!business.name.trim(); return !!business.name.trim();
case 4: case 4:
@@ -320,36 +364,88 @@ export function InstallWizard() {
<div className="space-y-4"> <div className="space-y-4">
<h2 className="text-xl font-bold">Administrator account</h2> <h2 className="text-xl font-bold">Administrator account</h2>
<p className="text-muted text-sm">This becomes the first owner — full permissions, protected from removal.</p> <p className="text-muted text-sm">This becomes the first owner — full permissions, protected from removal.</p>
<div className="grid grid-cols-2 gap-3">
<Input label="First Name" value={admin.firstName} onChange={(e) => setAdmin({ ...admin, firstName: e.target.value })} required /> {otpPhase === "code" ? (
<Input label="Last Name" value={admin.lastName} onChange={(e) => setAdmin({ ...admin, lastName: e.target.value })} required /> <OtpCodeStep
</div> email={admin.email}
<Input label="Email Address" type="email" value={admin.email} onChange={(e) => setAdmin({ ...admin, email: e.target.value })} required /> onVerify={verifyAdminCode}
<Input label="Phone (optional)" type="tel" value={admin.phone} onChange={(e) => setAdmin({ ...admin, phone: e.target.value })} /> onResend={resendAdminCode}
<Input onChangeEmail={() => setOtpPhase("collect")}
label="Password" verifyLabel="Verify Email"
type="password" />
value={admin.password} ) : (
onChange={(e) => setAdmin({ ...admin, password: e.target.value })} <>
hint="At least 8 characters." <div className="grid grid-cols-2 gap-3">
required <Input label="First Name" value={admin.firstName} onChange={(e) => setAdmin({ ...admin, firstName: e.target.value })} required />
/> <Input label="Last Name" value={admin.lastName} onChange={(e) => setAdmin({ ...admin, lastName: e.target.value })} required />
<Input </div>
label="Confirm Password" <Input
type="password" label="Email Address"
value={admin.confirmPassword} type="email"
onChange={(e) => setAdmin({ ...admin, confirmPassword: e.target.value })} value={admin.email}
error={admin.confirmPassword && admin.password !== admin.confirmPassword ? "Passwords don't match" : undefined} onChange={(e) => {
required setAdmin({ ...admin, email: e.target.value });
/> // Editing the email after verifying invalidates that
<StepNav onBack={() => setStep(1)} onNext={() => setStep(3)} nextDisabled={!canProceedFromStep(2)} /> // verification — it was proof of ownership of the old
// address, not this one.
if (otpPhase === "verified") setOtpPhase("collect");
}}
required
/>
<Input label="Phone (optional)" type="tel" value={admin.phone} onChange={(e) => setAdmin({ ...admin, phone: e.target.value })} />
{otpPhase === "verified" ? (
<p role="status" className="text-accent-foreground text-sm font-medium">
✓ {admin.email} verified
</p>
) : (
<>
{otpError && (
<p role="alert" className="text-danger text-sm font-medium">
{otpError}
</p>
)}
<Button
type="button"
variant="secondary"
className="w-full"
isLoading={isPending}
disabled={!admin.firstName.trim() || !admin.lastName.trim() || !admin.email.trim()}
onClick={requestAdminCode}
>
Send Verification Code
</Button>
</>
)}
</>
)}
{otpPhase !== "code" && (
<StepNav onBack={() => setStep(1)} onNext={() => setStep(3)} nextDisabled={!canProceedFromStep(2)} />
)}
</div> </div>
)} )}
{step === 3 && ( {step === 3 && (
<div className="space-y-4"> <div className="space-y-4">
<h2 className="text-xl font-bold">Business details</h2> <h2 className="text-xl font-bold">Business details</h2>
<Input label="Business Name" value={business.name} onChange={(e) => setBusiness({ ...business, name: e.target.value })} required /> {bookingUrl && (
<div className="rounded-md border-line border bg-white p-4">
<p className="text-muted text-[10px] font-bold tracking-widest uppercase">Booking URL</p>
<p className="mt-1 text-sm">{bookingUrl}</p>
<p className="text-muted mt-1 text-xs">
Where customers book with you online — already set up for this deployment, not something to
configure here.
</p>
</div>
)}
<Input
label="Business Name"
value={business.name}
onChange={(e) => setBusiness({ ...business, name: e.target.value })}
hint={initialBusinessName ? "Pre-filled from setup — change it if you'd like a different public name." : undefined}
required
/>
<Input <Input
label="Short Name (optional)" label="Short Name (optional)"
hint="Used where space is tight, e.g. the home-screen icon label." hint="Used where space is tight, e.g. the home-screen icon label."
@@ -363,7 +459,13 @@ export function InstallWizard() {
/> />
<Input label="Business Email (optional)" type="email" value={business.email} onChange={(e) => setBusiness({ ...business, email: e.target.value })} /> <Input label="Business Email (optional)" type="email" value={business.email} onChange={(e) => setBusiness({ ...business, email: e.target.value })} />
<Input label="Phone Number (optional)" type="tel" value={business.phone} onChange={(e) => setBusiness({ ...business, phone: e.target.value })} /> <Input label="Phone Number (optional)" type="tel" value={business.phone} onChange={(e) => setBusiness({ ...business, phone: e.target.value })} />
<Input label="Website (optional)" value={business.website} onChange={(e) => setBusiness({ ...business, website: e.target.value })} /> <Input
label="Business Website (optional)"
placeholder="https://yourbusiness.com"
hint="Your business's own main website, if you have one — not the booking URL above."
value={business.website}
onChange={(e) => setBusiness({ ...business, website: e.target.value })}
/>
<StepNav onBack={() => setStep(2)} onNext={() => setStep(4)} nextDisabled={!canProceedFromStep(3)} /> <StepNav onBack={() => setStep(2)} onNext={() => setStep(4)} nextDisabled={!canProceedFromStep(3)} />
</div> </div>
)} )}
+38 -7
View File
@@ -1,6 +1,6 @@
import bcrypt from "bcryptjs";
import type { PrismaClient, Prisma } from "@/generated/prisma/client"; import type { PrismaClient, Prisma } from "@/generated/prisma/client";
import { InstallationStatus, RewardType } from "@/generated/prisma/enums"; import { InstallationStatus, RewardType } from "@/generated/prisma/enums";
import { finalizeOtp } from "@/lib/auth/otp.service";
import { INSTALLATION_SINGLETON_ID } from "@/lib/installation/installation-state"; import { INSTALLATION_SINGLETON_ID } from "@/lib/installation/installation-state";
import { OWNER_PERMISSIONS, BARBER_PERMISSIONS } from "@/lib/roles/default-permissions"; import { OWNER_PERMISSIONS, BARBER_PERMISSIONS } from "@/lib/roles/default-permissions";
import { secretsMatch } from "@/lib/security/secrets-match"; import { secretsMatch } from "@/lib/security/secrets-match";
@@ -16,6 +16,7 @@ export type InstallErrorCode =
| "ALREADY_INSTALLED" | "ALREADY_INSTALLED"
| "VALIDATION_FAILED" | "VALIDATION_FAILED"
| "EMAIL_ALREADY_IN_USE" | "EMAIL_ALREADY_IN_USE"
| "EMAIL_NOT_VERIFIED"
| "INSTALL_FAILED"; | "INSTALL_FAILED";
export interface InstallSuccess { export interface InstallSuccess {
@@ -32,6 +33,16 @@ export interface InstallFailure {
export type InstallResult = InstallSuccess | 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 { function slugify(name: string): string {
const base = name const base = name
.toLowerCase() .toLowerCase()
@@ -149,6 +160,22 @@ export async function installApplication(
const { administrator, business: businessInput, location, hours, bookingRules, branding, starterServices } = const { administrator, business: businessInput, location, hours, bookingRules, branding, starterServices } =
params.input; 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({ const business = await tx.business.create({
data: { data: {
@@ -216,13 +243,13 @@ export async function installApplication(
}), }),
]); ]);
const passwordHash = await bcrypt.hash(administrator.password, 12);
const user = await tx.user.create({ const user = await tx.user.create({
data: { data: {
email: administrator.email.toLowerCase(), email: administratorEmail,
name: `${administrator.firstName.trim()} ${administrator.lastName.trim()}`, name: `${administrator.firstName.trim()} ${administrator.lastName.trim()}`,
phone: administrator.phone?.trim() || null, 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(), emailVerified: new Date(),
staffProfile: { staffProfile: {
create: { create: {
@@ -299,11 +326,15 @@ export async function installApplication(
return { success: true, userId: result.userId, businessId: result.businessId }; return { success: true, userId: result.userId, businessId: result.businessId };
} catch (error) { } catch (error) {
// A concurrent installer won the create() race — see the header // A concurrent installer won the create() race, or the administrator's
// comment above. Anything else is an unexpected failure; either way, // email wasn't verified — see the header comment above and
// the transaction rolled back and left no partial data (see // 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 // ApplicationInstallation's own header for why that's true even for
// the lock row itself). // the lock row itself).
if (error instanceof InstallStepError) {
return { success: false, code: error.code, message: error.message };
}
const isDuplicateSingleton = const isDuplicateSingleton =
typeof error === "object" && error !== null && "code" in error && (error as { code?: string }).code === "P2002"; typeof error === "object" && error !== null && "code" in error && (error as { code?: string }).code === "P2002";
if (isDuplicateSingleton) { if (isDuplicateSingleton) {
+6 -13
View File
@@ -10,19 +10,12 @@ import { z } from "zod";
* ever one real validation boundary to define. * ever one real validation boundary to define.
*/ */
export const installAdministratorSchema = z export const installAdministratorSchema = z.object({
.object({ firstName: z.string().trim().min(1, "First name is required"),
firstName: z.string().trim().min(1, "First name is required"), lastName: z.string().trim().min(1, "Last name is required"),
lastName: z.string().trim().min(1, "Last name is required"), email: z.email("Enter a valid email address"),
email: z.email("Enter a valid email address"), phone: z.string().trim().optional().or(z.literal("")),
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 installBusinessSchema = z.object({ export const installBusinessSchema = z.object({
name: z.string().trim().min(1, "Business name is required"), name: z.string().trim().min(1, "Business name is required"),
@@ -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<string> {
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();
});
});
+237
View File
@@ -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<RequestOtpResult> {
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<VerifyOtpResult> {
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<boolean> {
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<void> {
const cutoff = new Date(Date.now() - 24 * 60 * 60 * 1000);
await db.emailOtp.deleteMany({ where: { expiresAt: { lt: cutoff } } });
}
@@ -1,5 +1,39 @@
import type { EmailProvider, EmailSendResult, SendEmailParams } from "./email-provider"; 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 * Dev-only — never actually sends anything, just prints what would have
* been sent so a developer can see confirmation/reminder emails locally * been sent so a developer can see confirmation/reminder emails locally
@@ -24,6 +58,8 @@ export class ConsoleEmailProvider implements EmailProvider {
"──────────────────────────────────────────────────────────────────────", "──────────────────────────────────────────────────────────────────────",
].join("\n"), ].join("\n"),
); );
capturedEmails.push({ sentAt: Date.now(), params });
if (capturedEmails.length > MAX_CAPTURED) capturedEmails.shift();
return { status: "sent", providerMessageId: `console-${Date.now()}` }; return { status: "sent", providerMessageId: `console-${Date.now()}` };
} }
} }
@@ -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.",
],
}),
};
}
+12 -7
View File
@@ -1,5 +1,4 @@
import { randomBytes } from "node:crypto"; import { randomBytes } from "node:crypto";
import bcrypt from "bcryptjs";
import type { PrismaClient } from "@/generated/prisma/client"; import type { PrismaClient } from "@/generated/prisma/client";
import { NotificationType } from "@/generated/prisma/enums"; import { NotificationType } from "@/generated/prisma/enums";
import { notifySafely } from "@/services/notifications/notification.service"; import { notifySafely } from "@/services/notifications/notification.service";
@@ -24,7 +23,7 @@ import { getOwnerUserIds } from "@/services/notifications/recipients";
const INVITATION_TTL_DAYS = 7; 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 { function generateToken(): string {
return randomBytes(32).toString("base64url"); return randomBytes(32).toString("base64url");
@@ -105,20 +104,26 @@ export async function validateInvitationToken(prisma: PrismaClient, token: strin
return { valid: true, invitation }; 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( export async function acceptInvitation(
prisma: PrismaClient, prisma: PrismaClient,
params: { token: string; password: string }, params: { token: string },
): Promise<{ success: boolean; reason?: StaffInvitationErrorCode }> { ): Promise<{ success: boolean; reason?: StaffInvitationErrorCode }> {
if (params.password.length < 8) return { success: false, reason: "INVALID_INPUT" };
const check = await validateInvitationToken(prisma, params.token); const check = await validateInvitationToken(prisma, params.token);
if (!check.valid) return { success: false, reason: check.reason }; if (!check.valid) return { success: false, reason: check.reason };
const passwordHash = await bcrypt.hash(params.password, 12);
await prisma.$transaction([ await prisma.$transaction([
prisma.user.update({ prisma.user.update({
where: { id: check.invitation.staff.userId }, where: { id: check.invitation.staff.userId },
data: { passwordHash, emailVerified: new Date() }, data: { emailVerified: new Date() },
}), }),
prisma.staffInvitation.update({ prisma.staffInvitation.update({
where: { id: check.invitation.id }, where: { id: check.invitation.id },