129 lines
3.9 KiB
TypeScript
129 lines
3.9 KiB
TypeScript
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";
|
|
|
|
const registrationSchema = z.object({
|
|
companyName: z.string().min(2).max(100),
|
|
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(),
|
|
});
|
|
|
|
/** 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;
|
|
|
|
// --- Duplicate-domain check ---
|
|
//
|
|
// Block if another active tenant_request or ZITADEL org already exists
|
|
// for this corporate email domain. Public domains (gmail, gmx, etc.)
|
|
// are exempted by checkDuplicateDomain.
|
|
//
|
|
// We return a structured `code: "duplicate_domain"` with the matched
|
|
// domain so the client can render the localized message via
|
|
// register.duplicateDomain (with {domain} interpolation). The fallback
|
|
// English string is included for non-i18n clients (curl, monitoring).
|
|
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 },
|
|
);
|
|
}
|
|
|
|
const result = await registerCustomer({
|
|
companyName: input.companyName,
|
|
email: input.email,
|
|
givenName: input.givenName,
|
|
familyName: input.familyName,
|
|
preferredLanguage: input.preferredLanguage,
|
|
});
|
|
|
|
return NextResponse.json(
|
|
{
|
|
orgId: result.orgId,
|
|
userId: result.userId,
|
|
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;
|
|
}
|
|
}
|