feat(sase): user lifecycle controls — Phase B

Suspend / Reactivate / Ban buttons on the user detail page; wired to
spoke endpoints (sase.tr#27).

Admin SDK
- suspendUser/reactivateUser/banUser on SaseAdmin + LifecycleResult.
- SASE_ADMIN_ENDPOINTS updated.

Route
- POST /api/sase/users/[id]/lifecycle (action + reason). Auth + spoke
  wired + min reason length checks. Audit on both success and failure.

RO model
- Sase Prisma schema adds status/statusReason/statusChangedAt/
  statusChangedBy. getUser() returns lifecycleStatus + statusReason.

UI
- LifecycleStatusBadge in header next to email reveal.
- LifecycleButtons renders the right actions for the current state.
- Modals with reason textarea; ban requires a double-confirm checkbox.
- Impersonate hidden when user is suspended/banned (AuthGuard rejects).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Semih
2026-05-18 09:38:07 +03:00
parent 5b26d10485
commit b1ec7a5277
6 changed files with 345 additions and 11 deletions

View File

@@ -0,0 +1,68 @@
import { NextResponse } from "next/server";
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { createSaseAdmin, saseAdminWired } from "@/lib/admin-sdk/sase";
import { writeAudit } from "@/lib/audit";
export const dynamic = "force-dynamic";
const ALLOWED_ACTIONS = new Set(["suspend", "reactivate", "ban"]);
export async function POST(
req: Request,
ctx: { params: Promise<{ id: string }> },
) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) {
return NextResponse.json({ ok: false, error: "unauthenticated" }, { status: 401 });
}
if (!saseAdminWired()) {
return NextResponse.json({ ok: false, error: "spoke_not_wired" }, { status: 503 });
}
const { id } = await ctx.params;
const body = (await req.json().catch(() => ({}))) as { action?: string; reason?: string };
if (!body.action || !ALLOWED_ACTIONS.has(body.action)) {
return NextResponse.json({ ok: false, error: "invalid_action" }, { status: 400 });
}
const reason = (body.reason ?? "").trim();
if (body.action !== "reactivate" && reason.length < 5) {
return NextResponse.json(
{ ok: false, error: "reason_required" },
{ status: 400 },
);
}
const endpoint = `/api/sase/users/${id}/lifecycle/${body.action}`;
try {
const sdk = createSaseAdmin();
const result =
body.action === "suspend"
? await sdk.suspendUser({ userId: id, founderId: session.user.id, reason })
: body.action === "reactivate"
? await sdk.reactivateUser({ userId: id, founderId: session.user.id, reason })
: await sdk.banUser({ userId: id, founderId: session.user.id, reason });
await writeAudit({
projectKey: "sase",
endpoint,
method: "POST",
requestPayload: { action: body.action, reasonLen: reason.length },
responseStatus: 200,
});
return NextResponse.json({ ok: true, result });
} catch (err) {
const status = (err as { status?: number } | undefined)?.status ?? 500;
const message = err instanceof Error ? err.message : "unknown";
await writeAudit({
projectKey: "sase",
endpoint,
method: "POST",
requestPayload: { action: body.action, reasonLen: reason.length },
responseStatus: status,
});
return NextResponse.json({ ok: false, error: message }, { status });
}
}