import { NextRequest, NextResponse } from "next/server"; import { registerCustomer } from "@/lib/zitadel"; import { rateLimit } from "@/lib/rate-limit"; import { checkDuplicateDomain } from "@/lib/db"; import { generatePersonalOrgName } from "@/lib/personal-org"; import type { RegistrationInput } from "@/types"; import { z } from "zod"; /** * Registration schema. * * Slice 4 changes * --------------- * - `companyName` is no longer always required. It's required when * `isPersonal` is false/absent, ignored when `isPersonal` is true. * - `isPersonal` flag distinguishes personal accounts. The server * derives the ZITADEL org name from a generated opaque ID * (`personal-{8hex}`) — see `lib/personal-org.ts` for the format * spec. Customers cannot rename their own org, so the marker is * stable. * - Personal accounts skip the duplicate-domain check entirely. Their * row is also excluded from future domain checks (see * `lib/domain-check.ts::findDuplicateInDb`). */ const registrationSchema = z .object({ companyName: z.string().min(2).max(100).optional(), givenName: z.string().min(1).max(100), familyName: z.string().min(1).max(100), email: z.string().email(), preferredLanguage: z.enum(["en", "de", "fr", "it"]).optional(), isPersonal: z.boolean().optional().default(false), }) .refine( (data) => data.isPersonal || (data.companyName && data.companyName.trim().length >= 2), { message: "Company name is required for company registrations", path: ["companyName"], } ); /** 3 registrations per IP per hour */ const RATE_LIMIT = 3; const RATE_WINDOW_MS = 3_600_000; // 1 hour export async function POST(request: NextRequest) { // --- Rate limiting --- const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? request.headers.get("x-real-ip") ?? "unknown"; const rl = rateLimit(`register:${ip}`, RATE_LIMIT, RATE_WINDOW_MS); if (!rl.allowed) { return NextResponse.json( { error: "Too many registration attempts. Please try again later." }, { status: 429, headers: { "Retry-After": String(Math.ceil(rl.resetMs / 1000)), "X-RateLimit-Limit": String(RATE_LIMIT), "X-RateLimit-Remaining": "0", }, }, ); } // --- Validation --- try { const body = await request.json(); const parsed = registrationSchema.safeParse(body); if (!parsed.success) { return NextResponse.json( { error: "Validation failed", details: parsed.error.flatten() }, { status: 400 }, ); } const input: RegistrationInput = parsed.data; const isPersonal = input.isPersonal === true; // --- Duplicate-domain check (skipped for personal accounts) --- // // Personal accounts are explicitly allowed to use any email domain // (including corporate). Their tenant_request rows are excluded // from this check by lib/domain-check.ts, so a personal account // doesn't block a later real-company registration on the same // domain. if (!isPersonal) { const dup = await checkDuplicateDomain(input.email); if (dup.blocked && dup.domain) { return NextResponse.json( { error: `An account for the email domain ${dup.domain} is already registered. Please contact your company administrator or PieCed IT support.`, code: "duplicate_domain", domain: dup.domain, }, { status: 409 }, ); } } // --- Determine the ZITADEL org name --- // // For company: use the customer-supplied companyName (already // validated to be present + ≥2 chars by the schema refinement). // For personal: a fresh opaque ID like "personal-3f2a8b1c". The // user's actual display name is per-user (`session.user.name`), // so the GUI shows that instead — see `displayOrgNameFor()`. // This keeps personal orgs collision-free (Bug 9: two people // named "Eva Müller" both being able to register). const orgName = isPersonal ? generatePersonalOrgName() : input.companyName!.trim(); const result = await registerCustomer({ companyName: orgName, email: input.email, givenName: input.givenName, familyName: input.familyName, preferredLanguage: input.preferredLanguage, }); return NextResponse.json( { orgId: result.orgId, userId: result.userId, isPersonal, message: "Registration successful. You will receive an invitation email to set your password.", }, { status: 201, headers: { "X-RateLimit-Limit": String(RATE_LIMIT), "X-RateLimit-Remaining": String(rl.remaining), }, }, ); } catch (e: any) { console.error("Registration failed:", e); const zitadelMessage = extractZitadelMessage(e.message); return NextResponse.json( { error: zitadelMessage || "Registration failed. Please try again." }, { status: e.statusCode || 500 }, ); } } /** * ZITADEL errors come as: * "ZITADEL POST /path: 400 {"code":3, "message":"..."}" * Extract the human-readable "message" field. */ function extractZitadelMessage(errorMsg: string): string | null { try { const jsonStart = errorMsg.indexOf("{"); if (jsonStart === -1) return null; const json = JSON.parse(errorMsg.slice(jsonStart)); return json.message || null; } catch { return null; } }