import { NextRequest, NextResponse } from "next/server"; import { registerCustomer } from "@/lib/zitadel"; import { rateLimit } from "@/lib/rate-limit"; import { checkDuplicateDomain } from "@/lib/db"; 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 `${givenName} ${familyName} * (Personal)` for personals — the suffix is the canonical marker * that downstream code (onboarding POST, admin views) uses to * distinguish personal orgs from companies. Customers cannot rename * their own org, so the suffix 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 /** * Suffix appended to personal-account ZITADEL org names. Used here to * build the org name and elsewhere (session.orgName check) to detect * whether the current user is on a personal org. * * Keep this in sync with `isPersonalOrgName()` in `lib/personal-org.ts`. */ const PERSONAL_ORG_SUFFIX = " (Personal)"; 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: synthesise from full name + " (Personal)" suffix. // The suffix is the canonical marker for personal orgs. // // ZITADEL does NOT enforce org-name uniqueness, so two "Hans Müller // (Personal)" orgs can coexist; the org id is what matters for our // labelling and lookups, the name is human-readable only. const orgName = isPersonal ? `${input.givenName.trim()} ${input.familyName.trim()}${PERSONAL_ORG_SUFFIX}` : 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; } }