import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; import { getSessionUser, isCustomerOwner } from "@/lib/session"; import { getOrgMembers, isValidInviteRole } from "@/lib/team"; import { updateAuthorizationRoles } from "@/lib/zitadel"; import { safeError } from "@/lib/errors"; const patchSchema = z.object({ role: z.enum(["owner", "user"]), }); /** * PATCH /api/team/[userId]/role * * Change the role of an existing member of the caller's org. * * Body: { role: "owner" | "user" } * * Authorization * ------------- * Customer-side: only an `owner` of the caller's org may change roles. * `isCustomerOwner` is the right gate — `canMutate` would also accept * platform users, but cross-org role mutation by platform staff * belongs in ZITADEL Console with audited admin tooling, not here. * * Safety guards * ------------- * 1. Self-demotion is blocked. An owner demoting themself to `user` * could lose access to /team and never come back. If the user * genuinely wants to step down they should promote a colleague to * `owner` first, then ask that colleague to demote them. * 2. Last-owner demotion is blocked. Demoting the org's only owner * to `user` would lock the org out of all future role changes, * invites, and tenant requests. We count owners across the whole * member list and refuse if this change would leave zero. * 3. The target must already have an authorization on the project. * A member without one — orphan, mid-invite race — has nothing * for `UpdateAuthorization` to update; we return a clear 409. * * The mutation itself is replace-not-merge: see * `lib/zitadel.ts::updateAuthorizationRoles`. Passing `[role]` revokes * any other roles the member happened to hold. */ export async function PATCH( req: NextRequest, { params }: { params: Promise<{ userId: string }> } ) { const user = await getSessionUser(); if (!user) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } // Only customer owners — platform staff use Console. if (!isCustomerOwner(user)) { return NextResponse.json({ error: "Forbidden" }, { status: 403 }); } if (user.isPersonal) { return NextResponse.json( { error: "Personal accounts have no team roles to change." }, { status: 403 } ); } const { userId } = await params; if (userId === user.id) { return NextResponse.json( { error: "You cannot change your own role. Ask another owner, or promote a colleague to owner first.", code: "self_change_blocked", }, { status: 403 } ); } const body = await req.json().catch(() => null); const parsed = patchSchema.safeParse(body); if (!parsed.success) { return NextResponse.json( { error: "Invalid input", details: parsed.error.flatten() }, { status: 400 } ); } const { role } = parsed.data; // Defensive — the Zod enum already enforces this. if (!isValidInviteRole(role)) { return NextResponse.json( { error: "Role must be 'owner' or 'user'." }, { status: 400 } ); } try { const members = await getOrgMembers(user.orgId); const target = members.find((m) => m.userId === userId); if (!target) { return NextResponse.json( { error: "Target user is not a member of this organization." }, { status: 404 } ); } if (!target.authorizationId) { // Should be very rare — implies the row was created out-of-band // (e.g. directly in Console) without an authorization. Surface a // clear message rather than a confusing 500 from ZITADEL. return NextResponse.json( { error: "Member has no authorization record on the project. Re-invite them or contact support.", code: "no_authorization", }, { status: 409 } ); } // Last-owner protection: this matters when the target is currently // an owner AND the new role is something other than owner. We could // narrow the count to "before this change" but the simpler form is // equivalent: if there's only one owner and that owner is the // target, refuse. const currentlyOwner = target.roles.includes("owner"); if (currentlyOwner && role !== "owner") { const ownerCount = members.filter((m) => m.roles.includes("owner")).length; if (ownerCount <= 1) { return NextResponse.json( { error: "This is the only owner. Promote another member to owner before demoting this one.", code: "last_owner", }, { status: 409 } ); } } // No-op: target already has the requested role and ONLY that role. if (target.roles.length === 1 && target.roles[0] === role) { return NextResponse.json({ message: "No change.", role }, { status: 200 }); } await updateAuthorizationRoles(target.authorizationId, [role]); return NextResponse.json( { message: "Role updated.", userId, role }, { status: 200 } ); } catch (e: any) { console.error("Role update failed:", e); return NextResponse.json( { error: safeError(e, "Failed to update role") }, { status: e.statusCode || 500 } ); } }