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:
@@ -13,16 +13,20 @@ datasource db {
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
name String @db.VarChar(255)
|
||||
email String @unique @db.VarChar(255)
|
||||
emailVerified Boolean @default(false) @map("email_verified")
|
||||
role String @default("user") @db.VarChar(20)
|
||||
image String?
|
||||
referralCode String? @map("referral_code") @db.VarChar(20)
|
||||
referredBy String? @map("referred_by") @db.Uuid
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
updatedAt DateTime @default(now()) @map("updated_at") @db.Timestamptz(6)
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
name String @db.VarChar(255)
|
||||
email String @unique @db.VarChar(255)
|
||||
emailVerified Boolean @default(false) @map("email_verified")
|
||||
role String @default("user") @db.VarChar(20)
|
||||
status String @default("active") @db.VarChar(20)
|
||||
statusReason String? @map("status_reason")
|
||||
statusChangedAt DateTime? @map("status_changed_at") @db.Timestamptz(6)
|
||||
statusChangedBy String? @map("status_changed_by") @db.Uuid
|
||||
image String?
|
||||
referralCode String? @map("referral_code") @db.VarChar(20)
|
||||
referredBy String? @map("referred_by") @db.Uuid
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
updatedAt DateTime @default(now()) @map("updated_at") @db.Timestamptz(6)
|
||||
|
||||
subscriptions UserSubscription[]
|
||||
payments Payment[]
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
187
apps/web/src/app/projects/sase/users/[id]/_lifecycle-buttons.tsx
Normal file
187
apps/web/src/app/projects/sase/users/[id]/_lifecycle-buttons.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
type Action = "suspend" | "reactivate" | "ban";
|
||||
|
||||
export function LifecycleStatusBadge({ status }: { status: string }) {
|
||||
if (status === "suspended") return <Badge variant="secondary">suspended</Badge>;
|
||||
if (status === "banned") return <Badge variant="destructive">banned</Badge>;
|
||||
return <Badge variant="default">active</Badge>;
|
||||
}
|
||||
|
||||
export function LifecycleButtons({
|
||||
userId,
|
||||
userName,
|
||||
currentStatus,
|
||||
}: {
|
||||
userId: string;
|
||||
userName: string;
|
||||
currentStatus: string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState<Action | null>(null);
|
||||
const [reason, setReason] = useState("");
|
||||
const [banConfirmed, setBanConfirmed] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pending, startTransition] = useTransition();
|
||||
|
||||
function close() {
|
||||
setOpen(null);
|
||||
setReason("");
|
||||
setBanConfirmed(false);
|
||||
setError(null);
|
||||
}
|
||||
|
||||
function submit() {
|
||||
if (!open) return;
|
||||
setError(null);
|
||||
if (open !== "reactivate" && reason.trim().length < 5) {
|
||||
setError("Sebep en az 5 karakter olmalı.");
|
||||
return;
|
||||
}
|
||||
if (open === "ban" && !banConfirmed) {
|
||||
setError("Ban kalıcıdır onay kutusunu işaretle.");
|
||||
return;
|
||||
}
|
||||
startTransition(async () => {
|
||||
const res = await fetch(`/api/sase/users/${userId}/lifecycle`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: open, reason: reason.trim() }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
setError(data.error ?? `HTTP ${res.status}`);
|
||||
return;
|
||||
}
|
||||
close();
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{currentStatus === "active" && (
|
||||
<>
|
||||
<Button variant="outline" size="sm" onClick={() => setOpen("suspend")}>
|
||||
Suspend
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" onClick={() => setOpen("ban")}>
|
||||
Ban
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{currentStatus === "suspended" && (
|
||||
<>
|
||||
<Button variant="default" size="sm" onClick={() => setOpen("reactivate")}>
|
||||
Reactivate
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" onClick={() => setOpen("ban")}>
|
||||
Ban
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{currentStatus === "banned" && (
|
||||
<Button variant="default" size="sm" onClick={() => setOpen("reactivate")}>
|
||||
Reactivate
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog open={open !== null} onOpenChange={(v) => !v && close()}>
|
||||
<DialogContent>
|
||||
{open === "suspend" && (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Kullanıcıyı askıya al</DialogTitle>
|
||||
<DialogDescription>
|
||||
{userName} suspended olarak işaretlenir, tüm aktif session'ları
|
||||
kapatılır, kullanıcı login olamaz. Reactivate ile geri açabilirsin.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
</>
|
||||
)}
|
||||
{open === "reactivate" && (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Hesabı tekrar aktif et</DialogTitle>
|
||||
<DialogDescription>
|
||||
{userName} tekrar login olabilir. Sebep opsiyonel ama audit'e geçer.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
</>
|
||||
)}
|
||||
{open === "ban" && (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Kullanıcıyı kalıcı engelle</DialogTitle>
|
||||
<DialogDescription>
|
||||
⚠ {userName} kalıcı olarak banned olur. Reactivate ile geri açılabilir
|
||||
ama bu yıkıcı bir aksiyon; sahteciliği teyit ettikten sonra kullan.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Textarea
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
placeholder={
|
||||
open === "reactivate"
|
||||
? "Sebep (opsiyonel, örn. 'Müşteri ödeme yaptı')"
|
||||
: "Sebep zorunlu (min 5 karakter)"
|
||||
}
|
||||
rows={3}
|
||||
maxLength={500}
|
||||
/>
|
||||
{open === "ban" && (
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={banConfirmed}
|
||||
onCheckedChange={(v) => setBanConfirmed(v === true)}
|
||||
/>
|
||||
Bu işlemin kalıcı olduğunu anlıyorum.
|
||||
</label>
|
||||
)}
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={close} disabled={pending}>
|
||||
İptal
|
||||
</Button>
|
||||
<Button
|
||||
variant={open === "ban" ? "destructive" : "default"}
|
||||
onClick={submit}
|
||||
disabled={pending}
|
||||
>
|
||||
{pending
|
||||
? "..."
|
||||
: open === "suspend"
|
||||
? "Askıya al"
|
||||
: open === "reactivate"
|
||||
? "Aktif et"
|
||||
: "Banla"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
import { EmailReveal } from "./_email-reveal";
|
||||
import { ImpersonateButton } from "./_impersonate-button";
|
||||
import { NotesTab } from "./_notes";
|
||||
import { LifecycleButtons, LifecycleStatusBadge } from "./_lifecycle-buttons";
|
||||
import { saseAdminWired } from "@/lib/admin-sdk/sase";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -71,12 +72,27 @@ export default async function SaseUserDetailPage({
|
||||
) : (
|
||||
<Badge variant="outline">unverified</Badge>
|
||||
)}
|
||||
<LifecycleStatusBadge status={user.lifecycleStatus} />
|
||||
{user.statusReason && (
|
||||
<span className="text-xs italic text-muted-foreground">
|
||||
"{user.statusReason}"
|
||||
</span>
|
||||
)}
|
||||
</CardDescription>
|
||||
<p className="font-mono text-xs text-muted-foreground">{user.id}</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
{saseAdminWired() && (
|
||||
<ImpersonateButton userId={user.id} userName={user.name} />
|
||||
<>
|
||||
<LifecycleButtons
|
||||
userId={user.id}
|
||||
userName={user.name}
|
||||
currentStatus={user.lifecycleStatus}
|
||||
/>
|
||||
{user.lifecycleStatus === "active" && (
|
||||
<ImpersonateButton userId={user.id} userName={user.name} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<div className="text-right text-xs text-muted-foreground">
|
||||
<p>Kayıt: {user.createdAt.toISOString().slice(0, 10)}</p>
|
||||
|
||||
@@ -35,6 +35,36 @@ export type SaseAdmin = {
|
||||
ttlMinutes: number;
|
||||
reason: string;
|
||||
}): Promise<{ success: boolean; redirectUrl: string; expiresAt: string; sessionIdPrefix: string }>;
|
||||
|
||||
/** Suspend a Sase user (temporary block + session invalidation). */
|
||||
suspendUser(input: {
|
||||
userId: string;
|
||||
founderId: string;
|
||||
reason: string;
|
||||
}): Promise<LifecycleResult>;
|
||||
|
||||
/** Reactivate a previously suspended user. */
|
||||
reactivateUser(input: {
|
||||
userId: string;
|
||||
founderId: string;
|
||||
reason?: string;
|
||||
}): Promise<LifecycleResult>;
|
||||
|
||||
/** Permanently ban a Sase user. */
|
||||
banUser(input: {
|
||||
userId: string;
|
||||
founderId: string;
|
||||
reason: string;
|
||||
}): Promise<LifecycleResult>;
|
||||
};
|
||||
|
||||
export type LifecycleResult = {
|
||||
success: boolean;
|
||||
userId: string;
|
||||
from: string;
|
||||
to: string;
|
||||
sessionsKilled: number;
|
||||
changedAt: string;
|
||||
};
|
||||
|
||||
export function createSaseAdmin(): SaseAdmin {
|
||||
@@ -57,6 +87,21 @@ export function createSaseAdmin(): SaseAdmin {
|
||||
reason: input.reason,
|
||||
founderId: input.founderId,
|
||||
}),
|
||||
suspendUser: (input) =>
|
||||
client.call("POST", `/internal/admin/users/${input.userId}/suspend`, {
|
||||
reason: input.reason,
|
||||
founderId: input.founderId,
|
||||
}),
|
||||
reactivateUser: (input) =>
|
||||
client.call("POST", `/internal/admin/users/${input.userId}/reactivate`, {
|
||||
reason: input.reason ?? null,
|
||||
founderId: input.founderId,
|
||||
}),
|
||||
banUser: (input) =>
|
||||
client.call("POST", `/internal/admin/users/${input.userId}/ban`, {
|
||||
reason: input.reason,
|
||||
founderId: input.founderId,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -71,6 +116,9 @@ function notWiredSdk(projectKey: string): SaseAdmin {
|
||||
cancelSubscription: () => Promise.reject(reject()),
|
||||
resendVerification: () => Promise.reject(reject()),
|
||||
impersonateReadonly: () => Promise.reject(reject()),
|
||||
suspendUser: () => Promise.reject(reject()),
|
||||
reactivateUser: () => Promise.reject(reject()),
|
||||
banUser: () => Promise.reject(reject()),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -83,4 +131,7 @@ export const SASE_ADMIN_ENDPOINTS = [
|
||||
"DELETE /internal/admin/subscriptions/:id",
|
||||
"POST /internal/admin/users/:id/resend-verification",
|
||||
"POST /internal/admin/users/:id/impersonate-readonly",
|
||||
"POST /internal/admin/users/:id/suspend",
|
||||
"POST /internal/admin/users/:id/reactivate",
|
||||
"POST /internal/admin/users/:id/ban",
|
||||
];
|
||||
|
||||
@@ -183,6 +183,10 @@ export async function listUsers(
|
||||
|
||||
export type UserDetail = UserRow & {
|
||||
referralCode: string | null;
|
||||
role: string;
|
||||
lifecycleStatus: string;
|
||||
statusReason: string | null;
|
||||
statusChangedAt: Date | null;
|
||||
payments: Array<{
|
||||
id: string;
|
||||
amount: number;
|
||||
@@ -232,6 +236,10 @@ export async function getUser(id: string): Promise<UserDetail | null> {
|
||||
emailVerified: u.emailVerified,
|
||||
createdAt: u.createdAt,
|
||||
referralCode: u.referralCode,
|
||||
role: u.role,
|
||||
lifecycleStatus: u.status,
|
||||
statusReason: u.statusReason,
|
||||
statusChangedAt: u.statusChangedAt,
|
||||
planName: sub?.plan.name ?? null,
|
||||
planTier: tierFromPlan(sub?.plan.name ?? null, sub?.plan.brandCount ?? null),
|
||||
subscriptionStatus: sub?.status ?? null,
|
||||
|
||||
Reference in New Issue
Block a user