180 lines
5.8 KiB
TypeScript
180 lines
5.8 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";
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
}
|