CI / ci (push) Canceled after 0s
1. Summary of changes Password auth → email OTP, everywhere it existed for the tenant/business app: sign-in, customer self-registration (/register), staff-invitation acceptance, and the installer's administrator step. Flow is always: email → 6-digit code emailed → code entered → session created, via one shared service (otp.service.ts) reused by every entry point. NextChair Manager's own auth was not touched — this only affects tenant-side (business admin/staff/customer) authentication, per the spec's explicit instruction. Installer improvements: Step 2 (Administrator) now asks only Name + Email + optional Phone, verified inline via the same OTP widget (no redirect). Step 3 (Business Details) pre-fills Business Name from the Manager-provisioned BUSINESS_NAME, shows the booking URL (NEXT_PUBLIC_APP_URL) as a read-only info box instead of asking for it, and has a distinctly-labeled "Business Website (optional)" field with copy clarifying it's not the booking URL. Installation cannot complete with an unverified admin email — enforced server-side (finalizeOtp runs inside the same DB transaction as user/business creation), not just trusted from client state. A mid-wizard refresh still can't create duplicate admins or partial records — that property was preserved by design, not incidentally. 2. Prisma/database changes New EmailOtp model (email as PK — one row per address, so a new request naturally invalidates the previous code): codeHash, purpose, expiresAt, attempts, verifiedAt, consumedAt, timestamps. Only a bcrypt hash of the code is ever stored. User.passwordHash — unchanged schema (already nullable), just annotated as deprecated. No destructive migration; historical rows are untouched. Migration: prisma/migrations/20260905190022_email_otp_authentication — adds EmailOtp only. Generated and applied against throwaway containers only; the real next-chair-mysql production database was never touched (confirmed not even running on this host during this session). 3. New environment variables None required for production. One new variable, E2E_TEST_MODE, exists purely for the Playwright suite (set only in playwright.config.ts's webServer.env) — it unlocks a /api/test/otp-code debug endpoint and bypasses OTP rate limiting so the same seeded users can sign in dozens of times across one test run. It must never be set on a real deployment; nothing in the app sets it anywhere else. 4. Email delivery configuration Nothing new to configure. OTP emails reuse the existing EmailProvider abstraction and EMAIL_PROVIDER env var exactly as configured today — console in dev (prints to server logs), brevo in production via the same SMTP credentials already documented. The OTP email is a new template (otp-email.ts) sent directly (bypassing the async notification/cron pipeline, since a 10-minute-expiry code can't wait for a delivery retry loop). 5. Migration considerations for existing users/installations Existing users lose password sign-in — the only way to authenticate now is email OTP. There is no in-app fallback; this is an intentional, sharp cutover. If existing customers/staff/admins need advance notice, that's a business/communications decision outside this change. No account data is deleted or migrated — passwordHash rows are simply never read for auth anymore. prisma/seed.ts / scripts/ensure-super-admin.ts still set a passwordHash for seeded dev users — harmless and vestigial now, not cleaned up (flagging it rather than touching seed data silently). Installations already completed are unaffected; only new installer runs go through the OTP admin-verification step. 6. Files changed New: src/lib/auth/otp.service.ts — the core requestOtp/verifyOtp/finalizeOtp service src/lib/auth/__tests__/otp.service.integration.test.ts src/services/notifications/templates/otp-email.ts src/features/auth/components/otp-code-step.tsx — shared code-entry UI src/features/auth/actions/request-sign-in-otp.ts src/app/api/test/otp-code/route.ts — e2e-only, gated on E2E_TEST_MODE prisma/migrations/20260905190022_email_otp_authentication/ Modified — auth core: prisma/schema.prisma, src/auth.ts, src/features/auth/schemas.ts Modified — sign-in/register/invitations: sign-in-form.tsx, register.ts, register-form.tsx, accept-invitation.ts, accept-invitation-form.tsx, staff-invitation.service.ts Modified — installer: installer.service.ts, schema.ts, install.ts, install-wizard.tsx, install/page.tsx, installer.service.integration.test.ts Modified — email / e2e infra: console-email-provider.ts, playwright.config.ts, e2e/helpers.ts, and the four e2e/*.spec.ts files (dropped the now-unused password argument from every signIn() call). 7. Verification performed npx tsc --noEmit, npm run lint, npm test (276 unit) — all clean. npm run test:integration (207 tests, throwaway DB) — includes 11 new OTP-service tests and a rewritten installer suite; a genuine race condition in requestOtp (concurrent requests for a brand-new email double-hitting the upsert's create branch) was caught and fixed here. Full npm run test:e2e against a fresh throwaway database: 36/37 passed. The one failure (owner-journeys "opening hours conflict" test) is a pre-existing, auth-unrelated ordering flake — appointment-count ambiguity from earlier tests' bookings — not something this change touches or introduced. Live manual verification in-browser: existing seeded customer sign-in via OTP; new customer registration via OTP (session created, landed on Home); installer wizard end-to-end through Step 3, confirming email-verification gating, Business Name pre-population from BUSINESS_NAME, and the read-only Booking URL / distinct Business Website fields. All throwaway Docker containers/DBs used for this work have been removed; the real production database was never touched.
196 lines
8.3 KiB
TypeScript
196 lines
8.3 KiB
TypeScript
import { expect, test } from "@playwright/test";
|
||
import type { Page } from "@playwright/test";
|
||
import { SEEDED, signIn } from "./helpers";
|
||
|
||
/**
|
||
* The 12 merged-navigation scenarios required by the Phase 11 brief
|
||
* ("Merge Staff and Owner Navigation into One Role-Aware Menu"), run at
|
||
* both mobile and desktop widths. See lib/navigation/nav-config.ts for the
|
||
* single source of truth these assertions are checking against, and
|
||
* (staff)/layout.tsx / (owner)/layout.tsx for where it's wired in.
|
||
*
|
||
* The bottom bar and desktop sidebar are both always present in the DOM
|
||
* (see components/navigation/bottom-nav.tsx and staff-sidebar-nav.tsx) —
|
||
* only one is visually shown per viewport width via CSS (`lg:hidden` /
|
||
* `lg:flex`), so tests select by a class fragment unique to each rather
|
||
* than by role/label alone, and independently verify the *other* one stays
|
||
* hidden.
|
||
*
|
||
* The owner-role, mobile-viewport tests (3–10, 12) share one signed-in
|
||
* page instead of each calling signIn() fresh — /sign-in rate-limits
|
||
* repeated attempts for the same account (see
|
||
* lib/security/rate-limit.ts's `signin:` bucket, 30/15min), and this file
|
||
* plus owner-journeys.spec.ts plus staff-journeys.spec.ts all authenticate
|
||
* as the same seeded owner throughout a single `npm run test:e2e`
|
||
* process — the combined count was tipping over that limit before this
|
||
* consolidation. Test 11 (desktop) and 13 (Super Administrator) still get
|
||
* their own page, since they need a different viewport/account.
|
||
*/
|
||
|
||
const MOBILE = { width: 390, height: 844 };
|
||
const DESKTOP = { width: 1280, height: 800 };
|
||
|
||
function bottomBar(page: Page) {
|
||
return page.locator('nav[aria-label="Primary"][class*="bottom-0"]');
|
||
}
|
||
|
||
function sidebar(page: Page) {
|
||
return page.locator('nav[aria-label="Primary"][class*="inset-y-0"]');
|
||
}
|
||
|
||
test.describe.serial("merged role-aware navigation", () => {
|
||
test("1. barber sees exactly the 4 merged bottom-nav items (mobile)", async ({ page }) => {
|
||
await page.setViewportSize(MOBILE);
|
||
await signIn(page, SEEDED.barberHaircutOnly.email);
|
||
await page.goto("/staff");
|
||
|
||
const bar = bottomBar(page);
|
||
await expect(bar).toBeVisible();
|
||
await expect(sidebar(page)).toBeHidden();
|
||
await expect(bar.getByRole("link")).toHaveText(["Calendar", "Now", "Clients", "Settings"]);
|
||
});
|
||
|
||
test("2. barber does not see Manage and cannot reach owner routes directly", async ({ page }) => {
|
||
await page.setViewportSize(MOBILE);
|
||
await signIn(page, SEEDED.barberHaircutOnly.email);
|
||
await page.goto("/staff");
|
||
|
||
await expect(bottomBar(page).getByRole("link", { name: "Manage" })).toHaveCount(0);
|
||
|
||
// Route-level authorization must hold independent of what nav is shown
|
||
// (see (owner)/layout.tsx's hasPermission(MANAGE_STAFF) gate) — a
|
||
// barber hitting /owner directly is bounced to /staff, not merely kept
|
||
// off a nav link.
|
||
await page.goto("/owner");
|
||
await expect(page).toHaveURL(/\/staff$/);
|
||
});
|
||
|
||
test.describe("owner, mobile — one shared session for tests 3–10, 12", () => {
|
||
let page: Page;
|
||
|
||
test.beforeAll(async ({ browser }) => {
|
||
page = await browser.newPage();
|
||
await page.setViewportSize(MOBILE);
|
||
await signIn(page, SEEDED.owner.email);
|
||
});
|
||
|
||
test.afterAll(async () => {
|
||
await page.close();
|
||
});
|
||
|
||
test("3. owner sees exactly the 5 merged bottom-nav items (mobile)", async () => {
|
||
await page.goto("/owner");
|
||
|
||
const bar = bottomBar(page);
|
||
await expect(bar).toBeVisible();
|
||
await expect(bar.getByRole("link")).toHaveText(["Calendar", "Now", "Clients", "Manage", "Settings"]);
|
||
});
|
||
|
||
test("4. owner opens Now directly from the merged nav — no mode switch", async () => {
|
||
await page.goto("/owner");
|
||
|
||
await bottomBar(page).getByRole("link", { name: "Now" }).click();
|
||
await expect(page).toHaveURL(/\/staff\/current$/);
|
||
});
|
||
|
||
test("5. owner opens Manage and lands on the Dashboard overview", async () => {
|
||
await page.goto("/staff");
|
||
|
||
await bottomBar(page).getByRole("link", { name: "Manage" }).click();
|
||
await expect(page).toHaveURL(/\/owner$/);
|
||
await expect(page.getByRole("heading", { name: "Manage" })).toBeVisible();
|
||
await expect(page.getByText("Today's appointments")).toBeVisible();
|
||
});
|
||
|
||
test("6. owner reaches Team from Manage", async () => {
|
||
await page.goto("/owner");
|
||
|
||
await page.getByRole("link", { name: "Team Management" }).click();
|
||
await expect(page).toHaveURL(/\/owner\/team$/);
|
||
});
|
||
|
||
test("7. owner reaches Services from Manage", async () => {
|
||
await page.goto("/owner");
|
||
|
||
await page.getByRole("link", { name: "Services", exact: true }).click();
|
||
await expect(page).toHaveURL(/\/owner\/services$/);
|
||
});
|
||
|
||
test("8. owner reaches Business Details from Manage", async () => {
|
||
await page.goto("/owner");
|
||
|
||
await page.getByRole("link", { name: "Business Details" }).click();
|
||
await expect(page).toHaveURL(/\/owner\/settings\/business$/);
|
||
});
|
||
|
||
test("9. owner returns to Calendar from Manage without switching modes", async () => {
|
||
await page.goto("/owner");
|
||
|
||
await bottomBar(page).getByRole("link", { name: "Calendar" }).click();
|
||
await expect(page).toHaveURL(/\/owner\/calendar$/);
|
||
});
|
||
|
||
test("10. nested routes keep the correct primary tab active", async () => {
|
||
await page.goto("/staff/current");
|
||
await expect(bottomBar(page).getByRole("link", { name: "Now" })).toHaveAttribute("aria-current", "page");
|
||
|
||
await page.goto("/owner/team");
|
||
await expect(bottomBar(page).getByRole("link", { name: "Manage" })).toHaveAttribute("aria-current", "page");
|
||
|
||
await page.goto("/owner/services");
|
||
await expect(bottomBar(page).getByRole("link", { name: "Manage" })).toHaveAttribute("aria-current", "page");
|
||
|
||
await page.goto("/owner/settings/loyalty");
|
||
await expect(bottomBar(page).getByRole("link", { name: "Manage" })).toHaveAttribute("aria-current", "page");
|
||
|
||
await page.goto("/staff/settings/calendar");
|
||
await expect(bottomBar(page).getByRole("link", { name: "Settings" })).toHaveAttribute("aria-current", "page");
|
||
});
|
||
|
||
test("12. no switch-mode controls remain anywhere in the app", async () => {
|
||
for (const path of ["/staff/settings", "/owner", "/owner/settings"]) {
|
||
await page.goto(path);
|
||
await expect(page.getByText("Switch to Owner Admin")).toHaveCount(0);
|
||
await expect(page.getByText("Switch to Barber Tools")).toHaveCount(0);
|
||
}
|
||
// /owner/settings is a compatibility redirect to /staff/settings (see
|
||
// (owner)/owner/settings/page.tsx) — the old bookmark still works.
|
||
await expect(page).toHaveURL(/\/staff\/settings$/);
|
||
});
|
||
});
|
||
|
||
test("11. desktop sidebar uses the same merged structure as mobile", async ({ page }) => {
|
||
await page.setViewportSize(DESKTOP);
|
||
await signIn(page, SEEDED.owner.email);
|
||
await page.goto("/owner");
|
||
|
||
const side = sidebar(page);
|
||
await expect(side).toBeVisible();
|
||
await expect(bottomBar(page)).toBeHidden();
|
||
await expect(side.getByRole("link")).toHaveText(["Calendar", "Now", "Clients", "Manage", "Settings"]);
|
||
|
||
await page.goto("/owner/team");
|
||
await expect(side.getByRole("link", { name: "Manage" })).toHaveAttribute("aria-current", "page");
|
||
});
|
||
|
||
test("13. a Super Administrator sees the business-wide merged nav with no personal schedule tab", async ({ page }) => {
|
||
await page.setViewportSize(MOBILE);
|
||
await signIn(page, SEEDED.superAdmin.email);
|
||
await page.goto("/owner");
|
||
|
||
const bar = bottomBar(page);
|
||
await expect(bar).toBeVisible();
|
||
await expect(bar.getByRole("link")).toHaveText(["Calendar", "Clients", "Manage", "Settings"]);
|
||
await expect(bar.getByRole("link", { name: "Now" })).toHaveCount(0);
|
||
|
||
await bar.getByRole("link", { name: "Settings" }).click();
|
||
await expect(page).toHaveURL(/\/staff\/settings$/);
|
||
// Two "Notifications" links exist on this page — the fixed notification
|
||
// bell (aria-label="Notifications") and the Settings hub's own
|
||
// Notifications row — so scope to the settings list by href, not the bell.
|
||
await expect(page.locator('a[href="/staff/settings/notifications"]')).toBeVisible();
|
||
await expect(page.getByRole("link", { name: "Working Hours" })).toHaveCount(0);
|
||
await expect(page.getByRole("link", { name: "Google Calendar" })).toHaveCount(0);
|
||
});
|
||
});
|