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

@@ -1,6 +1,8 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { prisma } from "@/services/scheduling/__tests__/fixtures";
import { InstallationStatus } from "@/generated/prisma/enums";
import { requestOtp, verifyOtp } from "@/lib/auth/otp.service";
import { MockEmailProvider } from "@/services/notifications/providers/mock-email-provider";
import { installApplication } from "../installer.service";
import type { InstallInput } from "../schema";
@@ -25,8 +27,6 @@ function buildInput(overrides: Partial<InstallInput> = {}): InstallInput {
lastName: "Owner",
email: `install-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@example.com`,
phone: "",
password: "correct-horse-battery",
confirmPassword: "correct-horse-battery",
},
business: {
name: "Integration Test Barbershop",
@@ -75,6 +75,25 @@ function buildInput(overrides: Partial<InstallInput> = {}): InstallInput {
};
}
/** Drives the real requestOtp/verifyOtp pair via a MockEmailProvider (never a real send) so a test can get an administrator's email into the "verified, not yet consumed" state installApplication now requires — see installer.service.ts's finalizeOtp call. */
async function verifyAdministratorEmail(email: string): Promise<void> {
const mockProvider = new MockEmailProvider();
await requestOtp(prisma, { email, purpose: "install", emailProvider: mockProvider });
const sent = mockProvider.sentEmails[0];
const code = /verification code is (\d{6})/.exec(sent?.text ?? "")?.[1];
if (!code) throw new Error("Test setup failed: couldn't extract the OTP code from the captured email.");
const result = await verifyOtp(prisma, email, code);
if (!result.ok) throw new Error(`Test setup failed: OTP verification failed (${result.reason}).`);
}
/** buildInput() plus the email-verification step every successful install now requires. */
async function buildVerifiedInput(overrides: Partial<InstallInput> = {}): Promise<InstallInput> {
const input = buildInput(overrides);
await verifyAdministratorEmail(input.administrator.email);
return input;
}
async function resetInstallationSingleton(): Promise<void> {
await prisma.applicationInstallation.deleteMany({ where: { id: "singleton" } });
}
@@ -116,6 +135,8 @@ describe("installApplication (integration)", () => {
const originalToken = process.env.INSTALLATION_TOKEN;
process.env.INSTALLATION_TOKEN = INSTALLATION_TOKEN;
try {
// Deliberately unverified — the token check happens first and must
// reject before ever reaching the OTP check.
const result = await installApplication(prisma, {
input: buildInput({ installationToken: "wrong-token" }),
installedVersion: "test",
@@ -132,11 +153,31 @@ describe("installApplication (integration)", () => {
}
});
it("refuses to install with an unverified administrator email", async () => {
const originalToken = process.env.INSTALLATION_TOKEN;
process.env.INSTALLATION_TOKEN = INSTALLATION_TOKEN;
try {
// buildInput() alone — no verifyAdministratorEmail call.
const result = await installApplication(prisma, { input: buildInput(), installedVersion: "test" });
expect(result.success).toBe(false);
if (!result.success) expect(result.code).toBe("EMAIL_NOT_VERIFIED");
const business = await prisma.business.findFirst({ where: { name: "Integration Test Barbershop" } });
expect(business).toBeNull();
// The singleton lock row must roll back too — a rejected attempt
// shouldn't leave the whole application stuck "INSTALLING".
const installation = await prisma.applicationInstallation.findUnique({ where: { id: "singleton" } });
expect(installation).toBeNull();
} finally {
process.env.INSTALLATION_TOKEN = originalToken;
}
});
it("creates the business, administrator, permissions, booking rules, and starter services transactionally", async () => {
const originalToken = process.env.INSTALLATION_TOKEN;
process.env.INSTALLATION_TOKEN = INSTALLATION_TOKEN;
try {
const result = await installApplication(prisma, { input: buildInput(), installedVersion: "test" });
const result = await installApplication(prisma, { input: await buildVerifiedInput(), installedVersion: "test" });
expect(result.success).toBe(true);
if (!result.success) return;
createdBusinessId = result.businessId;
@@ -164,6 +205,10 @@ describe("installApplication (integration)", () => {
expect(barberRole).toBeDefined();
expect(ownerRole!.permissions.length).toBeGreaterThan(barberRole!.permissions.length);
const user = await prisma.user.findUnique({ where: { id: result.userId } });
expect(user?.emailVerified).not.toBeNull();
expect(user?.passwordHash).toBeNull();
const staffProfile = await prisma.staffProfile.findUnique({ where: { userId: result.userId }, include: { services: true } });
expect(staffProfile?.roleId).toBe(ownerRole!.id);
// The initial owner is eligible for every starter service — see the brief's "initial owner is service-eligible."
@@ -184,14 +229,15 @@ describe("installApplication (integration)", () => {
const originalToken = process.env.INSTALLATION_TOKEN;
process.env.INSTALLATION_TOKEN = INSTALLATION_TOKEN;
try {
const first = await installApplication(prisma, { input: buildInput(), installedVersion: "test" });
const first = await installApplication(prisma, { input: await buildVerifiedInput(), installedVersion: "test" });
expect(first.success).toBe(true);
if (first.success) createdBusinessId = first.businessId;
const secondEmail = `second-${Date.now()}@example.com`;
await verifyAdministratorEmail(secondEmail);
const secondInput = buildInput();
const second = await installApplication(prisma, {
input: buildInput({
administrator: { ...buildInput().administrator, email: `second-${Date.now()}@example.com` },
}),
input: { ...secondInput, administrator: { ...secondInput.administrator, email: secondEmail } },
installedVersion: "test",
});
expect(second.success).toBe(false);
@@ -205,8 +251,12 @@ describe("installApplication (integration)", () => {
const originalToken = process.env.INSTALLATION_TOKEN;
process.env.INSTALLATION_TOKEN = INSTALLATION_TOKEN;
try {
const inputA = buildInput({ administrator: { ...buildInput().administrator, email: `concurrent-a-${Date.now()}@example.com` } });
const inputB = buildInput({ administrator: { ...buildInput().administrator, email: `concurrent-b-${Date.now()}@example.com` } });
const emailA = `concurrent-a-${Date.now()}@example.com`;
const emailB = `concurrent-b-${Date.now()}@example.com`;
await verifyAdministratorEmail(emailA);
await verifyAdministratorEmail(emailB);
const inputA = buildInput({ administrator: { ...buildInput().administrator, email: emailA } });
const inputB = buildInput({ administrator: { ...buildInput().administrator, email: emailB } });
const [resultA, resultB] = await Promise.all([
installApplication(prisma, { input: inputA, installedVersion: "test" }),
+64 -2
View File
@@ -1,10 +1,13 @@
"use server";
import { signIn } from "@/auth";
import { emailOnlySchema, signInVerifySchema } from "@/features/auth/schemas";
import { requestOtp, verifyOtp } from "@/lib/auth/otp.service";
import { prisma } from "@/lib/db/prisma";
import { getRequestIp } from "@/lib/security/request-ip";
import { checkRateLimit } from "@/lib/security/rate-limit";
import { getRequestIp } from "@/lib/security/request-ip";
import { secretsMatch } from "@/lib/security/secrets-match";
import { getEmailProvider } from "@/services/notifications/providers";
import { installApplication } from "../installer.service";
import { installSchema } from "../schema";
import type { InstallInput } from "../schema";
@@ -40,6 +43,58 @@ export async function verifyInstallationTokenAction(token: string): Promise<Veri
return { valid: true };
}
export interface InstallOtpActionResult {
ok: boolean;
error?: string;
}
/**
* Both OTP actions below additionally require the installation token —
* not just email format — because the wizard's Administrator step embeds
* this inline rather than redirecting to /sign-in, and because
* src/auth.ts's later `{finalize:"true", token}` sign-in call reuses this
* same token as proof the caller is this specific trusted flow, not a
* direct POST to the public credentials callback route (see that file's
* own header for the full reasoning). Requiring it here too means someone
* without the installation token can't even generate/verify a code for an
* arbitrary email through this endpoint.
*/
export async function requestInstallOtpAction(email: string, installationToken: string): Promise<InstallOtpActionResult> {
const tokenCheck = await verifyInstallationTokenAction(installationToken);
if (!tokenCheck.valid) return { ok: false, error: tokenCheck.error ?? "Installation is not available." };
const parsed = emailOnlySchema.safeParse({ email });
if (!parsed.success) return { ok: false, error: "Enter a valid email address." };
const ip = await getRequestIp();
const result = await requestOtp(prisma, {
email: parsed.data.email,
purpose: "install",
ip,
senderName: "NextChair",
emailProvider: getEmailProvider(),
});
if (!result.ok) return { ok: false, error: "Too many attempts. Please try again later." };
return { ok: true };
}
export async function verifyInstallOtpAction(
email: string,
code: string,
installationToken: string,
): Promise<InstallOtpActionResult> {
const tokenCheck = await verifyInstallationTokenAction(installationToken);
if (!tokenCheck.valid) return { ok: false, error: tokenCheck.error ?? "Installation is not available." };
const parsed = signInVerifySchema.safeParse({ email, code });
if (!parsed.success) return { ok: false, error: "Enter the 6-digit code." };
const ip = await getRequestIp();
const result = await verifyOtp(prisma, parsed.data.email, parsed.data.code, ip);
if (!result.ok) return { ok: false, error: "Incorrect or expired code. Please try again." };
return { ok: true };
}
export interface SubmitInstallationResult {
success: boolean;
error?: string;
@@ -63,9 +118,16 @@ export async function submitInstallationAction(input: InstallInput): Promise<Sub
return { success: false, error: result.message };
}
// The administrator's OTP was already consumed inside installApplication
// (see finalizeOtp there) — this sign-in uses the token-gated finalize
// path, not a fresh code, since the code itself can't be re-entered
// (one-time use). See src/auth.ts's own header for why `token` is
// required here rather than just trusting this call came from the right
// place.
await signIn("credentials", {
email: parsed.data.administrator.email,
password: parsed.data.administrator.password,
finalize: "true",
token: parsed.data.installationToken,
redirectTo: "/owner?installed=1",
});
@@ -5,7 +5,13 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Switch } from "@/components/ui/switch";
import { submitInstallationAction, verifyInstallationTokenAction } from "../actions/install";
import { OtpCodeStep } from "@/features/auth/components/otp-code-step";
import {
requestInstallOtpAction,
submitInstallationAction,
verifyInstallOtpAction,
verifyInstallationTokenAction,
} from "../actions/install";
import type { InstallInput } from "../schema";
/**
@@ -105,7 +111,15 @@ const DEFAULT_STARTER_SERVICES: StarterServiceState[] = [
},
];
export function InstallWizard() {
export function InstallWizard({
initialBusinessName = "",
bookingUrl,
}: {
/** From process.env.BUSINESS_NAME, set by the Manager at provisioning time (see manager/src/lib/compose-template.ts) — pre-fills Step 3's Business Name but stays editable, since the operator may want a slightly different public name. */
initialBusinessName?: string;
/** From process.env.NEXT_PUBLIC_APP_URL, also Manager-provisioned — shown read-only in Step 3, never an editable field: this is infrastructure/routing configuration, not business profile data (see stacks/infra's Caddy site, which is what actually makes this domain work). */
bookingUrl?: string;
}) {
const [step, setStep] = useState(1);
const [isPending, startTransition] = useTransition();
const [submitError, setSubmitError] = useState<string | null>(null);
@@ -114,11 +128,23 @@ export function InstallWizard() {
const [token, setToken] = useState("");
const [tokenError, setTokenError] = useState<string | null>(null);
// Step 2
const [admin, setAdmin] = useState({ firstName: "", lastName: "", email: "", phone: "", password: "", confirmPassword: "" });
// Step 2 — otpPhase tracks verification separately from the rest of the
// form: "collect" (editing name/email/phone), "code" (entering the
// emailed code), "verified" (done, can proceed). Editing the email after
// verifying resets to "collect" — see the email field's onChange below.
const [admin, setAdmin] = useState({ firstName: "", lastName: "", email: "", phone: "" });
const [otpPhase, setOtpPhase] = useState<"collect" | "code" | "verified">("collect");
const [otpError, setOtpError] = useState<string | null>(null);
// Step 3
const [business, setBusiness] = useState({ name: "", shortName: "", description: "", email: "", phone: "", website: "" });
const [business, setBusiness] = useState({
name: initialBusinessName,
shortName: "",
description: "",
email: "",
phone: "",
website: "",
});
// Step 4
const [location, setLocation] = useState({
@@ -177,6 +203,30 @@ export function InstallWizard() {
});
};
const requestAdminCode = () => {
setOtpError(null);
startTransition(async () => {
const result = await requestInstallOtpAction(admin.email, token);
if (!result.ok) {
setOtpError(result.error ?? "Couldn't send a code. Please try again.");
return;
}
setOtpPhase("code");
});
};
const verifyAdminCode = async (code: string) => {
const result = await verifyInstallOtpAction(admin.email, code, token);
if (!result.ok) return { ok: false, error: result.error };
setOtpPhase("verified");
return { ok: true };
};
const resendAdminCode = async () => {
const result = await requestInstallOtpAction(admin.email, token);
return result.ok ? { ok: true } : { ok: false, error: result.error };
};
const updateDay = (dayOfWeek: WeekdayCode, patch: Partial<HoursDayState>) => {
setHours((prev) => prev.map((d) => (d.dayOfWeek === dayOfWeek ? { ...d, ...patch } : d)));
};
@@ -261,13 +311,7 @@ export function InstallWizard() {
const canProceedFromStep = (s: number): boolean => {
switch (s) {
case 2:
return (
!!admin.firstName.trim() &&
!!admin.lastName.trim() &&
!!admin.email.trim() &&
admin.password.length >= 8 &&
admin.password === admin.confirmPassword
);
return !!admin.firstName.trim() && !!admin.lastName.trim() && !!admin.email.trim() && otpPhase === "verified";
case 3:
return !!business.name.trim();
case 4:
@@ -320,36 +364,88 @@ export function InstallWizard() {
<div className="space-y-4">
<h2 className="text-xl font-bold">Administrator account</h2>
<p className="text-muted text-sm">This becomes the first owner — full permissions, protected from removal.</p>
<div className="grid grid-cols-2 gap-3">
<Input label="First Name" value={admin.firstName} onChange={(e) => setAdmin({ ...admin, firstName: e.target.value })} required />
<Input label="Last Name" value={admin.lastName} onChange={(e) => setAdmin({ ...admin, lastName: e.target.value })} required />
</div>
<Input label="Email Address" type="email" value={admin.email} onChange={(e) => setAdmin({ ...admin, email: e.target.value })} required />
<Input label="Phone (optional)" type="tel" value={admin.phone} onChange={(e) => setAdmin({ ...admin, phone: e.target.value })} />
<Input
label="Password"
type="password"
value={admin.password}
onChange={(e) => setAdmin({ ...admin, password: e.target.value })}
hint="At least 8 characters."
required
/>
<Input
label="Confirm Password"
type="password"
value={admin.confirmPassword}
onChange={(e) => setAdmin({ ...admin, confirmPassword: e.target.value })}
error={admin.confirmPassword && admin.password !== admin.confirmPassword ? "Passwords don't match" : undefined}
required
/>
<StepNav onBack={() => setStep(1)} onNext={() => setStep(3)} nextDisabled={!canProceedFromStep(2)} />
{otpPhase === "code" ? (
<OtpCodeStep
email={admin.email}
onVerify={verifyAdminCode}
onResend={resendAdminCode}
onChangeEmail={() => setOtpPhase("collect")}
verifyLabel="Verify Email"
/>
) : (
<>
<div className="grid grid-cols-2 gap-3">
<Input label="First Name" value={admin.firstName} onChange={(e) => setAdmin({ ...admin, firstName: e.target.value })} required />
<Input label="Last Name" value={admin.lastName} onChange={(e) => setAdmin({ ...admin, lastName: e.target.value })} required />
</div>
<Input
label="Email Address"
type="email"
value={admin.email}
onChange={(e) => {
setAdmin({ ...admin, email: e.target.value });
// Editing the email after verifying invalidates that
// verification — it was proof of ownership of the old
// address, not this one.
if (otpPhase === "verified") setOtpPhase("collect");
}}
required
/>
<Input label="Phone (optional)" type="tel" value={admin.phone} onChange={(e) => setAdmin({ ...admin, phone: e.target.value })} />
{otpPhase === "verified" ? (
<p role="status" className="text-accent-foreground text-sm font-medium">
✓ {admin.email} verified
</p>
) : (
<>
{otpError && (
<p role="alert" className="text-danger text-sm font-medium">
{otpError}
</p>
)}
<Button
type="button"
variant="secondary"
className="w-full"
isLoading={isPending}
disabled={!admin.firstName.trim() || !admin.lastName.trim() || !admin.email.trim()}
onClick={requestAdminCode}
>
Send Verification Code
</Button>
</>
)}
</>
)}
{otpPhase !== "code" && (
<StepNav onBack={() => setStep(1)} onNext={() => setStep(3)} nextDisabled={!canProceedFromStep(2)} />
)}
</div>
)}
{step === 3 && (
<div className="space-y-4">
<h2 className="text-xl font-bold">Business details</h2>
<Input label="Business Name" value={business.name} onChange={(e) => setBusiness({ ...business, name: e.target.value })} required />
{bookingUrl && (
<div className="rounded-md border-line border bg-white p-4">
<p className="text-muted text-[10px] font-bold tracking-widest uppercase">Booking URL</p>
<p className="mt-1 text-sm">{bookingUrl}</p>
<p className="text-muted mt-1 text-xs">
Where customers book with you online — already set up for this deployment, not something to
configure here.
</p>
</div>
)}
<Input
label="Business Name"
value={business.name}
onChange={(e) => setBusiness({ ...business, name: e.target.value })}
hint={initialBusinessName ? "Pre-filled from setup — change it if you'd like a different public name." : undefined}
required
/>
<Input
label="Short Name (optional)"
hint="Used where space is tight, e.g. the home-screen icon label."
@@ -363,7 +459,13 @@ export function InstallWizard() {
/>
<Input label="Business Email (optional)" type="email" value={business.email} onChange={(e) => setBusiness({ ...business, email: e.target.value })} />
<Input label="Phone Number (optional)" type="tel" value={business.phone} onChange={(e) => setBusiness({ ...business, phone: e.target.value })} />
<Input label="Website (optional)" value={business.website} onChange={(e) => setBusiness({ ...business, website: e.target.value })} />
<Input
label="Business Website (optional)"
placeholder="https://yourbusiness.com"
hint="Your business's own main website, if you have one — not the booking URL above."
value={business.website}
onChange={(e) => setBusiness({ ...business, website: e.target.value })}
/>
<StepNav onBack={() => setStep(2)} onNext={() => setStep(4)} nextDisabled={!canProceedFromStep(3)} />
</div>
)}
+38 -7
View File
@@ -1,6 +1,6 @@
import bcrypt from "bcryptjs";
import type { PrismaClient, Prisma } from "@/generated/prisma/client";
import { InstallationStatus, RewardType } from "@/generated/prisma/enums";
import { finalizeOtp } from "@/lib/auth/otp.service";
import { INSTALLATION_SINGLETON_ID } from "@/lib/installation/installation-state";
import { OWNER_PERMISSIONS, BARBER_PERMISSIONS } from "@/lib/roles/default-permissions";
import { secretsMatch } from "@/lib/security/secrets-match";
@@ -16,6 +16,7 @@ export type InstallErrorCode =
| "ALREADY_INSTALLED"
| "VALIDATION_FAILED"
| "EMAIL_ALREADY_IN_USE"
| "EMAIL_NOT_VERIFIED"
| "INSTALL_FAILED";
export interface InstallSuccess {
@@ -32,6 +33,16 @@ export interface InstallFailure {
export type InstallResult = InstallSuccess | InstallFailure;
/** Thrown inside the transaction to carry a specific InstallErrorCode out through the generic try/catch below — see installApplication's own use. */
class InstallStepError extends Error {
constructor(
readonly code: InstallErrorCode,
message: string,
) {
super(message);
}
}
function slugify(name: string): string {
const base = name
.toLowerCase()
@@ -149,6 +160,22 @@ export async function installApplication(
const { administrator, business: businessInput, location, hours, bookingRules, branding, starterServices } =
params.input;
const administratorEmail = administrator.email.toLowerCase();
// The administrator's email must have been verified via OTP in the
// wizard's own Administrator step (see install-wizard.tsx and
// src/lib/auth/otp.service.ts's verifiedAt/consumedAt design) —
// checked and consumed here, inside this same transaction, so it
// rolls back together with everything else if a later step fails
// (letting an immediate retry reuse the same verification) and so
// installation genuinely cannot complete with an unverified email,
// not just "the client claimed it was verified".
if (!(await finalizeOtp(tx, administratorEmail))) {
throw new InstallStepError(
"EMAIL_NOT_VERIFIED",
"This email hasn't been verified — please verify it again before finishing setup.",
);
}
const business = await tx.business.create({
data: {
@@ -216,13 +243,13 @@ export async function installApplication(
}),
]);
const passwordHash = await bcrypt.hash(administrator.password, 12);
const user = await tx.user.create({
data: {
email: administrator.email.toLowerCase(),
email: administratorEmail,
name: `${administrator.firstName.trim()} ${administrator.lastName.trim()}`,
phone: administrator.phone?.trim() || null,
passwordHash,
// Email OTP is the only auth method now — see finalizeOtp above,
// which is what actually proved this email, not a password.
emailVerified: new Date(),
staffProfile: {
create: {
@@ -299,11 +326,15 @@ export async function installApplication(
return { success: true, userId: result.userId, businessId: result.businessId };
} catch (error) {
// A concurrent installer won the create() race — see the header
// comment above. Anything else is an unexpected failure; either way,
// the transaction rolled back and left no partial data (see
// A concurrent installer won the create() race, or the administrator's
// email wasn't verified — see the header comment above and
// InstallStepError. Anything else is an unexpected failure; either
// way, the transaction rolled back and left no partial data (see
// ApplicationInstallation's own header for why that's true even for
// the lock row itself).
if (error instanceof InstallStepError) {
return { success: false, code: error.code, message: error.message };
}
const isDuplicateSingleton =
typeof error === "object" && error !== null && "code" in error && (error as { code?: string }).code === "P2002";
if (isDuplicateSingleton) {
+6 -13
View File
@@ -10,19 +10,12 @@ import { z } from "zod";
* ever one real validation boundary to define.
*/
export const installAdministratorSchema = z
.object({
firstName: z.string().trim().min(1, "First name is required"),
lastName: z.string().trim().min(1, "Last name is required"),
email: z.email("Enter a valid email address"),
phone: z.string().trim().optional().or(z.literal("")),
password: z.string().min(8, "Password must be at least 8 characters"),
confirmPassword: z.string(),
})
.refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ["confirmPassword"],
});
export const installAdministratorSchema = z.object({
firstName: z.string().trim().min(1, "First name is required"),
lastName: z.string().trim().min(1, "Last name is required"),
email: z.email("Enter a valid email address"),
phone: z.string().trim().optional().or(z.literal("")),
});
export const installBusinessSchema = z.object({
name: z.string().trim().min(1, "Business name is required"),