106 lines
3.2 KiB
TypeScript
106 lines
3.2 KiB
TypeScript
import { NextResponse } from "next/server";
|
|
import { getSessionUser, canMutate } from "@/lib/session";
|
|
import { inviteOrgMember, isValidInviteRole } from "@/lib/team";
|
|
import { z } from "zod";
|
|
import { safeError } from "@/lib/errors";
|
|
|
|
const inviteSchema = z.object({
|
|
email: z.string().email(),
|
|
givenName: z.string().min(1).max(100),
|
|
familyName: z.string().min(1).max(100),
|
|
role: z.enum(["owner", "user"]),
|
|
preferredLanguage: z.enum(["en", "de", "fr", "it"]).optional(),
|
|
});
|
|
|
|
/**
|
|
* POST /api/team/invite
|
|
*
|
|
* Invite a new member into the caller's org. Body shape:
|
|
* { email, givenName, familyName, role: "owner" | "user" }
|
|
*
|
|
* Allowed roles are explicitly only the customer-side ones —
|
|
* `isValidInviteRole` enforces this server-side too as a belt
|
|
* alongside the Zod enum (the Zod enum is the primary check; the
|
|
* helper exists because future callers in admin tooling may want the
|
|
* same predicate).
|
|
*
|
|
* Platform users can also call this — they'd be inviting members
|
|
* into their own platform org, which is uncommon but legal.
|
|
*/
|
|
export async function POST(req: Request) {
|
|
const user = await getSessionUser();
|
|
if (!user) {
|
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
}
|
|
if (!canMutate(user)) {
|
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
|
}
|
|
if (user.isPersonal) {
|
|
return NextResponse.json(
|
|
{
|
|
error:
|
|
"Personal accounts cannot invite additional members. Upgrade to a company account to add a team.",
|
|
code: "personal_account",
|
|
},
|
|
{ status: 403 }
|
|
);
|
|
}
|
|
|
|
const body = await req.json().catch(() => null);
|
|
const parsed = inviteSchema.safeParse(body);
|
|
if (!parsed.success) {
|
|
return NextResponse.json(
|
|
{ error: "Invalid input", details: parsed.error.flatten() },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
const input = parsed.data;
|
|
|
|
// Defensive recheck — the Zod enum already guarantees this, but it
|
|
// makes the intent explicit at the call site.
|
|
if (!isValidInviteRole(input.role)) {
|
|
return NextResponse.json(
|
|
{ error: "Role must be 'owner' or 'user'." },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
try {
|
|
const result = await inviteOrgMember({
|
|
orgId: user.orgId,
|
|
email: input.email,
|
|
givenName: input.givenName,
|
|
familyName: input.familyName,
|
|
role: input.role,
|
|
preferredLanguage: input.preferredLanguage,
|
|
});
|
|
return NextResponse.json(
|
|
{
|
|
userId: result.userId,
|
|
message:
|
|
"Invitation sent. The user will receive an email with a link to set their password.",
|
|
},
|
|
{ status: 201 }
|
|
);
|
|
} catch (e: any) {
|
|
console.error("Invite failed:", e);
|
|
// ZITADEL "user already exists" surfaces as a 4xx error; pass it
|
|
// through with a clean message so the client can render localized
|
|
// text.
|
|
const msg = e?.message ?? "";
|
|
if (msg.includes("already exists") || msg.includes("9.User.AlreadyExisting")) {
|
|
return NextResponse.json(
|
|
{
|
|
error: "A user with this email already exists.",
|
|
code: "user_already_exists",
|
|
},
|
|
{ status: 409 }
|
|
);
|
|
}
|
|
return NextResponse.json(
|
|
{ error: safeError(e, "Failed to invite user") },
|
|
{ status: e.statusCode || 500 }
|
|
);
|
|
}
|
|
}
|