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:
68
apps/web/src/app/api/sase/users/[id]/lifecycle/route.ts
Normal file
68
apps/web/src/app/api/sase/users/[id]/lifecycle/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user