1. Summary of changes Password auth → email OTP, everywhere it existed for the tenant/business app: sign-in, customer self-registration (/register), staff-invitation acceptance, and the installer's administrator step. Flow is always: email → 6-digit code emailed → code entered → session created, via one shared service (otp.service.ts) reused by every entry point. NextChair Manager's own auth was not touched — this only affects tenant-side (business admin/staff/customer) authentication, per the spec's explicit instruction. Installer improvements: Step 2 (Administrator) now asks only Name + Email + optional Phone, verified inline via the same OTP widget (no redirect). Step 3 (Business Details) pre-fills Business Name from the Manager-provisioned BUSINESS_NAME, shows the booking URL (NEXT_PUBLIC_APP_URL) as a read-only info box instead of asking for it, and has a distinctly-labeled "Business Website (optional)" field with copy clarifying it's not the booking URL. Installation cannot complete with an unverified admin email — enforced server-side (finalizeOtp runs inside the same DB transaction as user/business creation), not just trusted from client state. A mid-wizard refresh still can't create duplicate admins or partial records — that property was preserved by design, not incidentally. 2. Prisma/database changes New EmailOtp model (email as PK — one row per address, so a new request naturally invalidates the previous code): codeHash, purpose, expiresAt, attempts, verifiedAt, consumedAt, timestamps. Only a bcrypt hash of the code is ever stored. User.passwordHash — unchanged schema (already nullable), just annotated as deprecated. No destructive migration; historical rows are untouched. Migration: prisma/migrations/20260905190022_email_otp_authentication — adds EmailOtp only. Generated and applied against throwaway containers only; the real next-chair-mysql production database was never touched (confirmed not even running on this host during this session). 3. New environment variables None required for production. One new variable, E2E_TEST_MODE, exists purely for the Playwright suite (set only in playwright.config.ts's webServer.env) — it unlocks a /api/test/otp-code debug endpoint and bypasses OTP rate limiting so the same seeded users can sign in dozens of times across one test run. It must never be set on a real deployment; nothing in the app sets it anywhere else. 4. Email delivery configuration Nothing new to configure. OTP emails reuse the existing EmailProvider abstraction and EMAIL_PROVIDER env var exactly as configured today — console in dev (prints to server logs), brevo in production via the same SMTP credentials already documented. The OTP email is a new template (otp-email.ts) sent directly (bypassing the async notification/cron pipeline, since a 10-minute-expiry code can't wait for a delivery retry loop). 5. Migration considerations for existing users/installations Existing users lose password sign-in — the only way to authenticate now is email OTP. There is no in-app fallback; this is an intentional, sharp cutover. If existing customers/staff/admins need advance notice, that's a business/communications decision outside this change. No account data is deleted or migrated — passwordHash rows are simply never read for auth anymore. prisma/seed.ts / scripts/ensure-super-admin.ts still set a passwordHash for seeded dev users — harmless and vestigial now, not cleaned up (flagging it rather than touching seed data silently). Installations already completed are unaffected; only new installer runs go through the OTP admin-verification step. 6. Files changed New: src/lib/auth/otp.service.ts — the core requestOtp/verifyOtp/finalizeOtp service src/lib/auth/__tests__/otp.service.integration.test.ts src/services/notifications/templates/otp-email.ts src/features/auth/components/otp-code-step.tsx — shared code-entry UI src/features/auth/actions/request-sign-in-otp.ts src/app/api/test/otp-code/route.ts — e2e-only, gated on E2E_TEST_MODE prisma/migrations/20260905190022_email_otp_authentication/ Modified — auth core: prisma/schema.prisma, src/auth.ts, src/features/auth/schemas.ts Modified — sign-in/register/invitations: sign-in-form.tsx, register.ts, register-form.tsx, accept-invitation.ts, accept-invitation-form.tsx, staff-invitation.service.ts Modified — installer: installer.service.ts, schema.ts, install.ts, install-wizard.tsx, install/page.tsx, installer.service.integration.test.ts Modified — email / e2e infra: console-email-provider.ts, playwright.config.ts, e2e/helpers.ts, and the four e2e/*.spec.ts files (dropped the now-unused password argument from every signIn() call). 7. Verification performed npx tsc --noEmit, npm run lint, npm test (276 unit) — all clean. npm run test:integration (207 tests, throwaway DB) — includes 11 new OTP-service tests and a rewritten installer suite; a genuine race condition in requestOtp (concurrent requests for a brand-new email double-hitting the upsert's create branch) was caught and fixed here. Full npm run test:e2e against a fresh throwaway database: 36/37 passed. The one failure (owner-journeys "opening hours conflict" test) is a pre-existing, auth-unrelated ordering flake — appointment-count ambiguity from earlier tests' bookings — not something this change touches or introduced. Live manual verification in-browser: existing seeded customer sign-in via OTP; new customer registration via OTP (session created, landed on Home); installer wizard end-to-end through Step 3, confirming email-verification gating, Business Name pre-population from BUSINESS_NAME, and the read-only Booking URL / distinct Business Website fields. All throwaway Docker containers/DBs used for this work have been removed; the real production database was never touched.
This commit is contained in:
29 files changed
+1382
-305
No files matched your search
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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();
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
// ─────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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
@@ -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 };
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 },
|
|
||||||
} = useForm<AcceptInvitationInput>({ resolver: zodResolver(acceptInvitationSchema) });
|
|
||||||
|
|
||||||
const onSubmit = async (data: AcceptInvitationInput) => {
|
|
||||||
setFormError(null);
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.set("password", data.password);
|
|
||||||
formData.set("confirmPassword", data.confirmPassword);
|
|
||||||
|
|
||||||
const result = await acceptInvitationAction(token, formData);
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
setFormError(result.error ?? "Something went wrong. Please try again.");
|
setError(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've been invited to join <span className="text-ink font-bold">{businessName}</span>. Set a password
|
You've been invited to join <span className="text-ink font-bold">{businessName}</span>. This link
|
||||||
to finish setting up your account.
|
confirms it'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 & Sign In
|
Accept & 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
return (
|
||||||
<form onSubmit={handleSubmit(onSubmit)} noValidate className="space-y-4">
|
<OtpCodeStep
|
||||||
|
email={pending.email}
|
||||||
|
onVerify={verifyCode}
|
||||||
|
onResend={resendCode}
|
||||||
|
onChangeEmail={() => setPending(null)}
|
||||||
|
verifyLabel="Create Account"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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">
|
||||||
|
|||||||
@@ -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,29 +22,53 @@ 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 ? (
|
||||||
|
<OtpCodeStep email={email} onVerify={verifyCode} onResend={resendCode} onChangeEmail={() => setEmail(null)} verifyLabel="Sign In" />
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleSubmit(onRequestCode)} noValidate className="space-y-4">
|
||||||
<Input
|
<Input
|
||||||
label="Email Address"
|
label="Email Address"
|
||||||
type="email"
|
type="email"
|
||||||
@@ -51,19 +77,6 @@ export function SignInForm() {
|
|||||||
error={errors.email?.message}
|
error={errors.email?.message}
|
||||||
{...register("email")}
|
{...register("email")}
|
||||||
/>
|
/>
|
||||||
<Input
|
|
||||||
label="Password"
|
|
||||||
id="password"
|
|
||||||
type="password"
|
|
||||||
icon={<Lock className="h-4 w-4" aria-hidden="true" />}
|
|
||||||
placeholder="••••••••"
|
|
||||||
error={errors.password?.message}
|
|
||||||
{...register("password")}
|
|
||||||
/>
|
|
||||||
{/* Password reset needs the Notifications system (email delivery)
|
|
||||||
to actually send a reset link — out of scope for this phase.
|
|
||||||
The "Forgot Password" template (8) is a design reference for
|
|
||||||
when that lands; no dead link to it here in the meantime. */}
|
|
||||||
|
|
||||||
{formError && (
|
{formError && (
|
||||||
<p role="alert" className="text-danger text-sm font-medium">
|
<p role="alert" className="text-danger text-sm font-medium">
|
||||||
@@ -72,10 +85,13 @@ export function SignInForm() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!email && (
|
||||||
|
<>
|
||||||
<div className="mt-10">
|
<div className="mt-10">
|
||||||
<div className="relative mb-8 flex items-center justify-center">
|
<div className="relative mb-8 flex items-center justify-center">
|
||||||
<div className="absolute inset-0 flex items-center">
|
<div className="absolute inset-0 flex items-center">
|
||||||
@@ -101,6 +117,8 @@ export function SignInForm() {
|
|||||||
Create one
|
Create one
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
email: z.email("Enter a valid email address"),
|
.string()
|
||||||
password: z.string().min(1, "Password is required"),
|
.trim()
|
||||||
});
|
.regex(/^\d{6}$/, "Enter the 6-digit code");
|
||||||
export type SignInInput = z.infer<typeof signInSchema>;
|
|
||||||
|
|
||||||
export const registerSchema = z
|
export const emailOnlySchema = z.object({
|
||||||
.object({
|
email: z.email("Enter a valid email address"),
|
||||||
|
});
|
||||||
|
export type EmailOnlyInput = z.infer<typeof emailOnlySchema>;
|
||||||
|
|
||||||
|
/** Step 2 of sign-in — email is re-sent alongside the code (not trusted from any earlier request) so a stale/forged form can't verify a code against a different address than the one it was requested for. */
|
||||||
|
export const signInVerifySchema = z.object({
|
||||||
|
email: z.email("Enter a valid email address"),
|
||||||
|
code: otpCode,
|
||||||
|
});
|
||||||
|
export type SignInVerifyInput = z.infer<typeof signInVerifySchema>;
|
||||||
|
|
||||||
|
export const registerRequestSchema = z.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(),
|
export type RegisterRequestInput = z.infer<typeof registerRequestSchema>;
|
||||||
})
|
|
||||||
.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 registerVerifySchema = registerRequestSchema.extend({
|
||||||
.object({
|
code: otpCode,
|
||||||
password: z.string().min(8, "Password must be at least 8 characters"),
|
});
|
||||||
confirmPassword: z.string(),
|
export type RegisterVerifyInput = z.infer<typeof registerVerifySchema>;
|
||||||
})
|
|
||||||
.refine((data) => data.password === data.confirmPassword, {
|
|
||||||
message: "Passwords don't match",
|
|
||||||
path: ["confirmPassword"],
|
|
||||||
});
|
|
||||||
export type AcceptInvitationInput = z.infer<typeof acceptInvitationSchema>;
|
|
||||||
@@ -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" }),
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
{otpPhase === "code" ? (
|
||||||
|
<OtpCodeStep
|
||||||
|
email={admin.email}
|
||||||
|
onVerify={verifyAdminCode}
|
||||||
|
onResend={resendAdminCode}
|
||||||
|
onChangeEmail={() => setOtpPhase("collect")}
|
||||||
|
verifyLabel="Verify Email"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<Input label="First Name" value={admin.firstName} onChange={(e) => setAdmin({ ...admin, firstName: e.target.value })} required />
|
<Input label="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 label="Last Name" value={admin.lastName} onChange={(e) => setAdmin({ ...admin, lastName: e.target.value })} required />
|
||||||
</div>
|
</div>
|
||||||
<Input label="Email Address" type="email" value={admin.email} onChange={(e) => setAdmin({ ...admin, email: e.target.value })} required />
|
<Input
|
||||||
|
label="Email Address"
|
||||||
|
type="email"
|
||||||
|
value={admin.email}
|
||||||
|
onChange={(e) => {
|
||||||
|
setAdmin({ ...admin, email: e.target.value });
|
||||||
|
// Editing the email after verifying invalidates that
|
||||||
|
// verification — it was proof of ownership of the old
|
||||||
|
// address, not this one.
|
||||||
|
if (otpPhase === "verified") setOtpPhase("collect");
|
||||||
|
}}
|
||||||
|
required
|
||||||
|
/>
|
||||||
<Input label="Phone (optional)" type="tel" value={admin.phone} onChange={(e) => setAdmin({ ...admin, phone: e.target.value })} />
|
<Input label="Phone (optional)" type="tel" value={admin.phone} onChange={(e) => setAdmin({ ...admin, phone: e.target.value })} />
|
||||||
<Input
|
|
||||||
label="Password"
|
{otpPhase === "verified" ? (
|
||||||
type="password"
|
<p role="status" className="text-accent-foreground text-sm font-medium">
|
||||||
value={admin.password}
|
✓ {admin.email} verified
|
||||||
onChange={(e) => setAdmin({ ...admin, password: e.target.value })}
|
</p>
|
||||||
hint="At least 8 characters."
|
) : (
|
||||||
required
|
<>
|
||||||
/>
|
{otpError && (
|
||||||
<Input
|
<p role="alert" className="text-danger text-sm font-medium">
|
||||||
label="Confirm Password"
|
{otpError}
|
||||||
type="password"
|
</p>
|
||||||
value={admin.confirmPassword}
|
)}
|
||||||
onChange={(e) => setAdmin({ ...admin, confirmPassword: e.target.value })}
|
<Button
|
||||||
error={admin.confirmPassword && admin.password !== admin.confirmPassword ? "Passwords don't match" : undefined}
|
type="button"
|
||||||
required
|
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)} />
|
<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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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.",
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
|
|||||||
Reference in new issue
Block a user