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
@@ -1,5 +1,39 @@
|
||||
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
|
||||
* been sent so a developer can see confirmation/reminder emails locally
|
||||
@@ -24,6 +58,8 @@ export class ConsoleEmailProvider implements EmailProvider {
|
||||
"──────────────────────────────────────────────────────────────────────",
|
||||
].join("\n"),
|
||||
);
|
||||
capturedEmails.push({ sentAt: Date.now(), params });
|
||||
if (capturedEmails.length > MAX_CAPTURED) capturedEmails.shift();
|
||||
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 bcrypt from "bcryptjs";
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
import { NotificationType } from "@/generated/prisma/enums";
|
||||
import { notifySafely } from "@/services/notifications/notification.service";
|
||||
@@ -24,7 +23,7 @@ import { getOwnerUserIds } from "@/services/notifications/recipients";
|
||||
|
||||
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 {
|
||||
return randomBytes(32).toString("base64url");
|
||||
@@ -105,20 +104,26 @@ export async function validateInvitationToken(prisma: PrismaClient, token: strin
|
||||
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(
|
||||
prisma: PrismaClient,
|
||||
params: { token: string; password: string },
|
||||
params: { token: string },
|
||||
): Promise<{ success: boolean; reason?: StaffInvitationErrorCode }> {
|
||||
if (params.password.length < 8) return { success: false, reason: "INVALID_INPUT" };
|
||||
|
||||
const check = await validateInvitationToken(prisma, params.token);
|
||||
if (!check.valid) return { success: false, reason: check.reason };
|
||||
|
||||
const passwordHash = await bcrypt.hash(params.password, 12);
|
||||
await prisma.$transaction([
|
||||
prisma.user.update({
|
||||
where: { id: check.invitation.staff.userId },
|
||||
data: { passwordHash, emailVerified: new Date() },
|
||||
data: { emailVerified: new Date() },
|
||||
}),
|
||||
prisma.staffInvitation.update({
|
||||
where: { id: check.invitation.id },
|
||||
|
||||
Reference in new issue
Block a user