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

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

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

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

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

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

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

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

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

No files matched your search

+8 -8
View File
@@ -50,7 +50,7 @@ test.describe("customer booking journeys", () => {
});
test("2. registered customer books an appointment", async ({ page }) => {
await signIn(page, SEEDED.registeredCustomer.email, SEEDED.registeredCustomer.password);
await signIn(page, SEEDED.registeredCustomer.email);
await page.goto("/book");
await completeBookingFlow(page, {
@@ -66,7 +66,7 @@ test.describe("customer booking journeys", () => {
});
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");
// 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 }) => {
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.
await page.goto("/book");
@@ -113,7 +113,7 @@ test.describe("customer booking journeys", () => {
});
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 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 }) => {
await signIn(page, SEEDED.registeredCustomer.email, SEEDED.registeredCustomer.password);
await signIn(page, SEEDED.registeredCustomer.email);
await page.goto("/appointments");
// 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 }) => {
await signIn(page, SEEDED.registeredCustomer.email, SEEDED.registeredCustomer.password);
await signIn(page, SEEDED.registeredCustomer.email);
await page.goto("/book");
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 }) => {
// 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.
await signIn(page, SEEDED.secondCustomer.email, SEEDED.secondCustomer.password);
await signIn(page, SEEDED.secondCustomer.email);
await page.goto("/home");
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 }) => {
await signIn(page, SEEDED.registeredCustomer.email, SEEDED.registeredCustomer.password);
await signIn(page, SEEDED.registeredCustomer.email);
await page.goto("/home");
// The description stays visible on the Services tab row — the Book button doesn't hide it.
+41 -6
View File
@@ -1,20 +1,26 @@
import { expect } from "@playwright/test";
import 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 = {
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
// AVAILABLE reward) — see prisma/seed.ts. owner-journeys.spec.ts's
// journey 10 redeems this exact reward, so any test relying on it still
// being AVAILABLE must run before that (customer-journeys.spec.ts runs
// 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" },
// Provisioned by global-setup.ts via scripts/ensure-super-admin.ts, not
// prisma/seed.ts — a Super Administrator deliberately has no StaffProfile
// (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" },
barberSkinfadeBeard: { email: "jamie@blakeheritage.co.uk", name: "Jamie Wallace" },
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");
// Turbopack dev-mode compiles each route group on first visit — on a
// 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.
await page.waitForLoadState("networkidle");
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();
// Not asserting a specific destination here — a bare sign-in (no
// callbackUrl, which this helper never sets) now lands on a role-aware
+5 -5
View File
@@ -41,7 +41,7 @@ function sidebar(page: Page) {
test.describe.serial("merged role-aware navigation", () => {
test("1. barber sees exactly the 4 merged bottom-nav items (mobile)", async ({ page }) => {
await page.setViewportSize(MOBILE);
await signIn(page, SEEDED.barberHaircutOnly.email, "password123");
await signIn(page, SEEDED.barberHaircutOnly.email);
await page.goto("/staff");
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 }) => {
await page.setViewportSize(MOBILE);
await signIn(page, SEEDED.barberHaircutOnly.email, "password123");
await signIn(page, SEEDED.barberHaircutOnly.email);
await page.goto("/staff");
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 }) => {
page = await browser.newPage();
await page.setViewportSize(MOBILE);
await signIn(page, SEEDED.owner.email, "password123");
await signIn(page, SEEDED.owner.email);
});
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 }) => {
await page.setViewportSize(DESKTOP);
await signIn(page, SEEDED.owner.email, "password123");
await signIn(page, SEEDED.owner.email);
await page.goto("/owner");
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 }) => {
await page.setViewportSize(MOBILE);
await signIn(page, SEEDED.superAdmin.email, SEEDED.superAdmin.password);
await signIn(page, SEEDED.superAdmin.email);
await page.goto("/owner");
const bar = bottomBar(page);
+14 -14
View File
@@ -32,7 +32,7 @@ test.describe.serial("owner administration journeys", () => {
});
test("1. owner sees a real, data-backed dashboard with configuration warnings", async ({ page }) => {
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).
+10 -10
View File
@@ -38,7 +38,7 @@ test.describe.serial("barber operations journeys", () => {
});
test("1. barber signs in and sees today's schedule", async ({ page }) => {
await signIn(page, SEEDED.owner.email, "password123");
await signIn(page, SEEDED.owner.email);
await page.goto("/staff");
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 }) => {
await signIn(page, SEEDED.owner.email, "password123");
await signIn(page, SEEDED.owner.email);
await page.goto("/staff/current");
// 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 }) => {
await signIn(page, SEEDED.owner.email, "password123");
await signIn(page, SEEDED.owner.email);
await page.goto("/staff/book?q=Jordan");
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 }) => {
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.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 }) => {
await signIn(page, SEEDED.owner.email, "password123");
await signIn(page, SEEDED.owner.email);
await page.goto("/staff/clients");
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 }) => {
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.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 }) => {
await signIn(page, SEEDED.barberHaircutOnly.email, "password123");
await signIn(page, SEEDED.barberHaircutOnly.email);
await page.goto("/staff/settings/working-hours");
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 }) => {
await signIn(page, SEEDED.barberSkinfadeBeard.email, "password123");
await signIn(page, SEEDED.barberSkinfadeBeard.email);
await page.goto("/staff/settings/breaks");
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 }) => {
await signIn(page, SEEDED.owner.email, "password123");
await signIn(page, SEEDED.owner.email);
await page.goto("/staff/settings/time-off");
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
// journey 3). See NotificationService/booking-notifications.ts, notifyBookingCreated, which
// 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.getByRole("button", { name: /Jordan Blake/ }).click();