Files
NextChair/e2e/merged-nav.spec.ts
Stefan Lazic 94aa1b2f26
CI / ci (push) Canceled after 0s
OPT and Improved Installation Wizzard
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.
2026-09-05 21:03:36 +01:00

196 lines
8.3 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
});
});