1. Summary of changes Password auth → email OTP, everywhere it existed for the tenant/business app: sign-in, customer self-registration (/register), staff-invitation acceptance, and the installer's administrator step. Flow is always: email → 6-digit code emailed → code entered → session created, via one shared service (otp.service.ts) reused by every entry point. NextChair Manager's own auth was not touched — this only affects tenant-side (business admin/staff/customer) authentication, per the spec's explicit instruction. Installer improvements: Step 2 (Administrator) now asks only Name + Email + optional Phone, verified inline via the same OTP widget (no redirect). Step 3 (Business Details) pre-fills Business Name from the Manager-provisioned BUSINESS_NAME, shows the booking URL (NEXT_PUBLIC_APP_URL) as a read-only info box instead of asking for it, and has a distinctly-labeled "Business Website (optional)" field with copy clarifying it's not the booking URL. Installation cannot complete with an unverified admin email — enforced server-side (finalizeOtp runs inside the same DB transaction as user/business creation), not just trusted from client state. A mid-wizard refresh still can't create duplicate admins or partial records — that property was preserved by design, not incidentally. 2. Prisma/database changes New EmailOtp model (email as PK — one row per address, so a new request naturally invalidates the previous code): codeHash, purpose, expiresAt, attempts, verifiedAt, consumedAt, timestamps. Only a bcrypt hash of the code is ever stored. User.passwordHash — unchanged schema (already nullable), just annotated as deprecated. No destructive migration; historical rows are untouched. Migration: prisma/migrations/20260905190022_email_otp_authentication — adds EmailOtp only. Generated and applied against throwaway containers only; the real next-chair-mysql production database was never touched (confirmed not even running on this host during this session). 3. New environment variables None required for production. One new variable, E2E_TEST_MODE, exists purely for the Playwright suite (set only in playwright.config.ts's webServer.env) — it unlocks a /api/test/otp-code debug endpoint and bypasses OTP rate limiting so the same seeded users can sign in dozens of times across one test run. It must never be set on a real deployment; nothing in the app sets it anywhere else. 4. Email delivery configuration Nothing new to configure. OTP emails reuse the existing EmailProvider abstraction and EMAIL_PROVIDER env var exactly as configured today — console in dev (prints to server logs), brevo in production via the same SMTP credentials already documented. The OTP email is a new template (otp-email.ts) sent directly (bypassing the async notification/cron pipeline, since a 10-minute-expiry code can't wait for a delivery retry loop). 5. Migration considerations for existing users/installations Existing users lose password sign-in — the only way to authenticate now is email OTP. There is no in-app fallback; this is an intentional, sharp cutover. If existing customers/staff/admins need advance notice, that's a business/communications decision outside this change. No account data is deleted or migrated — passwordHash rows are simply never read for auth anymore. prisma/seed.ts / scripts/ensure-super-admin.ts still set a passwordHash for seeded dev users — harmless and vestigial now, not cleaned up (flagging it rather than touching seed data silently). Installations already completed are unaffected; only new installer runs go through the OTP admin-verification step. 6. Files changed New: src/lib/auth/otp.service.ts — the core requestOtp/verifyOtp/finalizeOtp service src/lib/auth/__tests__/otp.service.integration.test.ts src/services/notifications/templates/otp-email.ts src/features/auth/components/otp-code-step.tsx — shared code-entry UI src/features/auth/actions/request-sign-in-otp.ts src/app/api/test/otp-code/route.ts — e2e-only, gated on E2E_TEST_MODE prisma/migrations/20260905190022_email_otp_authentication/ Modified — auth core: prisma/schema.prisma, src/auth.ts, src/features/auth/schemas.ts Modified — sign-in/register/invitations: sign-in-form.tsx, register.ts, register-form.tsx, accept-invitation.ts, accept-invitation-form.tsx, staff-invitation.service.ts Modified — installer: installer.service.ts, schema.ts, install.ts, install-wizard.tsx, install/page.tsx, installer.service.integration.test.ts Modified — email / e2e infra: console-email-provider.ts, playwright.config.ts, e2e/helpers.ts, and the four e2e/*.spec.ts files (dropped the now-unused password argument from every signIn() call). 7. Verification performed npx tsc --noEmit, npm run lint, npm test (276 unit) — all clean. npm run test:integration (207 tests, throwaway DB) — includes 11 new OTP-service tests and a rewritten installer suite; a genuine race condition in requestOtp (concurrent requests for a brand-new email double-hitting the upsert's create branch) was caught and fixed here. Full npm run test:e2e against a fresh throwaway database: 36/37 passed. The one failure (owner-journeys "opening hours conflict" test) is a pre-existing, auth-unrelated ordering flake — appointment-count ambiguity from earlier tests' bookings — not something this change touches or introduced. Live manual verification in-browser: existing seeded customer sign-in via OTP; new customer registration via OTP (session created, landed on Home); installer wizard end-to-end through Step 3, confirming email-verification gating, Business Name pre-population from BUSINESS_NAME, and the read-only Booking URL / distinct Business Website fields. All throwaway Docker containers/DBs used for this work have been removed; the real production database was never touched.
This commit is contained in:
29 files changed
+1434
-357
No files matched your search
+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 }) => {
|
||||
await signIn(page, SEEDED.owner.email, "password123");
|
||||
await signIn(page, SEEDED.owner.email);
|
||||
await page.goto("/owner");
|
||||
|
||||
// The Manage hub's H1 (see (owner)/owner/page.tsx) — Dashboard content
|
||||
@@ -51,7 +51,7 @@ test.describe.serial("owner administration journeys", () => {
|
||||
});
|
||||
|
||||
test("2. owner views the all-staff calendar, filters by staff, and switches to agenda", async ({ page }) => {
|
||||
await signIn(page, SEEDED.owner.email, "password123");
|
||||
await signIn(page, SEEDED.owner.email);
|
||||
await page.goto("/owner/calendar");
|
||||
|
||||
await expect(page.getByRole("heading", { name: "Calendar" })).toBeVisible();
|
||||
@@ -79,7 +79,7 @@ test.describe.serial("owner administration journeys", () => {
|
||||
});
|
||||
|
||||
test("3. owner edits business details and the change persists", async ({ page }) => {
|
||||
await signIn(page, SEEDED.owner.email, "password123");
|
||||
await signIn(page, SEEDED.owner.email);
|
||||
await page.goto("/owner/settings/business");
|
||||
|
||||
await expect(page.getByRole("heading", { name: "Business Details" })).toBeVisible();
|
||||
@@ -100,7 +100,7 @@ test.describe.serial("owner administration journeys", () => {
|
||||
});
|
||||
|
||||
test("4. owner tightens opening hours, reviews the appointment conflict, and saves anyway without cancelling it", async ({ page }) => {
|
||||
await signIn(page, SEEDED.owner.email, "password123");
|
||||
await signIn(page, SEEDED.owner.email);
|
||||
|
||||
// Marcus and Leo each have a seeded future CONFIRMED appointment (see
|
||||
// prisma/seed.ts, appt-upcoming-confirmed / appt-leo-future). Those rows
|
||||
@@ -158,7 +158,7 @@ test.describe.serial("owner administration journeys", () => {
|
||||
});
|
||||
|
||||
test("5. owner adds a new barber and gets a dev invite link", async ({ page }) => {
|
||||
await signIn(page, SEEDED.owner.email, "password123");
|
||||
await signIn(page, SEEDED.owner.email);
|
||||
await page.goto("/owner/team/add");
|
||||
|
||||
await expect(page.getByRole("heading", { name: "Add Staff" })).toBeVisible();
|
||||
@@ -179,7 +179,7 @@ test.describe.serial("owner administration journeys", () => {
|
||||
});
|
||||
|
||||
test("6. owner changes a barber's role and service eligibility", async ({ page }) => {
|
||||
await signIn(page, SEEDED.owner.email, "password123");
|
||||
await signIn(page, SEEDED.owner.email);
|
||||
await page.goto("/owner/team?q=Jamie");
|
||||
await page.getByRole("link", { name: /Jamie Wallace/ }).click();
|
||||
|
||||
@@ -212,7 +212,7 @@ test.describe.serial("owner administration journeys", () => {
|
||||
});
|
||||
|
||||
test("7. owner creates a service category and service, visible immediately on the customer booking flow", async ({ page }) => {
|
||||
await signIn(page, SEEDED.owner.email, "password123");
|
||||
await signIn(page, SEEDED.owner.email);
|
||||
await page.goto("/owner/services");
|
||||
await expect(page.getByRole("heading", { name: "Services", exact: true })).toBeVisible();
|
||||
|
||||
@@ -239,7 +239,7 @@ test.describe.serial("owner administration journeys", () => {
|
||||
});
|
||||
|
||||
test("8. owner requires booking approval, and a new customer booking reflects it immediately", async ({ page }) => {
|
||||
await signIn(page, SEEDED.owner.email, "password123");
|
||||
await signIn(page, SEEDED.owner.email);
|
||||
await page.goto("/owner/settings/booking-rules");
|
||||
await expect(page.getByRole("heading", { name: "Booking Rules" })).toBeVisible();
|
||||
|
||||
@@ -269,7 +269,7 @@ test.describe.serial("owner administration journeys", () => {
|
||||
await expect(page.getByText("Pending Approval")).toBeVisible();
|
||||
|
||||
// Restore the seeded default so later journeys (and re-runs) see auto-confirmed bookings again.
|
||||
await signIn(page, SEEDED.owner.email, "password123");
|
||||
await signIn(page, SEEDED.owner.email);
|
||||
await page.goto("/owner/settings/booking-rules");
|
||||
await page.getByRole("switch", { name: "Require approval for new bookings" }).click();
|
||||
await page.getByRole("button", { name: "Save", exact: true }).click();
|
||||
@@ -277,7 +277,7 @@ test.describe.serial("owner administration journeys", () => {
|
||||
});
|
||||
|
||||
test("9. owner configures the loyalty programme", async ({ page }) => {
|
||||
await signIn(page, SEEDED.owner.email, "password123");
|
||||
await signIn(page, SEEDED.owner.email);
|
||||
await page.goto("/owner/settings/loyalty");
|
||||
await expect(page.getByRole("heading", { name: "Loyalty Programme" })).toBeVisible();
|
||||
|
||||
@@ -297,7 +297,7 @@ test.describe.serial("owner administration journeys", () => {
|
||||
});
|
||||
|
||||
test("10. owner redeems a customer's available loyalty reward on an appointment", async ({ page }) => {
|
||||
await signIn(page, SEEDED.owner.email, "password123");
|
||||
await signIn(page, SEEDED.owner.email);
|
||||
await page.goto("/owner/calendar?view=agenda");
|
||||
|
||||
// Seeded: Sam Osei has a CHECKED_IN appointment with Leo today, and sits
|
||||
@@ -322,7 +322,7 @@ test.describe.serial("owner administration journeys", () => {
|
||||
});
|
||||
|
||||
test("11. a barber without MANAGE_STAFF is denied access to owner administration", async ({ page }) => {
|
||||
await signIn(page, SEEDED.barberHaircutOnly.email, "password123");
|
||||
await signIn(page, SEEDED.barberHaircutOnly.email);
|
||||
|
||||
await page.goto("/owner");
|
||||
await expect(page).toHaveURL(/\/staff$/);
|
||||
@@ -331,7 +331,7 @@ test.describe.serial("owner administration journeys", () => {
|
||||
});
|
||||
|
||||
test("12. the owner reaches barber tools straight from the merged nav — no mode switch", async ({ page }) => {
|
||||
await signIn(page, SEEDED.owner.email, "password123");
|
||||
await signIn(page, SEEDED.owner.email);
|
||||
await page.goto("/owner");
|
||||
|
||||
// The old "Switch to Owner Admin"/"Switch to Barber Tools" links are
|
||||
@@ -345,7 +345,7 @@ test.describe.serial("owner administration journeys", () => {
|
||||
});
|
||||
|
||||
test("13. owner reports show real, data-backed appointment/service/staff/customer/loyalty/notification figures", async ({ page }) => {
|
||||
await signIn(page, SEEDED.owner.email, "password123");
|
||||
await signIn(page, SEEDED.owner.email);
|
||||
await page.goto("/owner");
|
||||
// "View Reports" was the old Dashboard footer's button label; Reports is
|
||||
// now one of the Manage hub's grouped sections (see (owner)/owner/page.tsx).
|
||||
|
||||
Reference in new issue
Block a user