This commit is contained in:
148
src/app/api/team/[userId]/role/route.ts
Normal file
148
src/app/api/team/[userId]/role/route.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
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 });
|
||||
}
|
||||
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user