feat(internal-admin): user lifecycle — suspend / reactivate / ban
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled

Süper Panel Phase 7 — Phase B. Founder can suspend, reactivate, or ban a
Sase user from the panel. Status enforced in the AuthGuard so blocked
users can no longer make authenticated requests.

Schema (migration 0006)
- users.status varchar(20) default 'active' — active|suspended|banned
- users.status_reason text — free-text reason set on transition
- users.status_changed_at, status_changed_by uuid — audit metadata
- users_status_idx

Auth
- AuthGuard rejects 'suspended' / 'banned' with TR-localized message.
- auth.ts: declared `status` as a Better Auth additionalField so the
  session.user object exposes it (matches how `role` is wired).

Endpoints (InternalTokenGuard)
- POST /internal/admin/users/:id/suspend     { reason, founderId }
- POST /internal/admin/users/:id/reactivate  { founderId }
- POST /internal/admin/users/:id/ban         { reason, founderId }

Service
- LifecycleService.setStatus():
  - refuses to touch admin-role users
  - refuses no-op transitions (already in target state)
  - refuses suspended→banned→suspended downgrade path (must reactivate first)
  - on suspend/ban: deletes all sessions for the user (immediate sign-out)
  - returns { from, to, sessionsKilled, changedAt }

Wiring
- LifecycleService + LifecycleController added to InternalAdminModule.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-18 09:34:52 +03:00
parent 3514272936
commit 96a9d11015
9 changed files with 5593 additions and 2 deletions

View File

@@ -0,0 +1,5 @@
ALTER TABLE "users" ADD COLUMN "status" varchar(20) DEFAULT 'active' NOT NULL;--> statement-breakpoint
ALTER TABLE "users" ADD COLUMN "status_reason" text;--> statement-breakpoint
ALTER TABLE "users" ADD COLUMN "status_changed_at" timestamp with time zone;--> statement-breakpoint
ALTER TABLE "users" ADD COLUMN "status_changed_by" uuid;--> statement-breakpoint
CREATE INDEX "users_status_idx" ON "users" USING btree ("status");

File diff suppressed because it is too large Load Diff

View File

@@ -43,6 +43,13 @@
"when": 1779054231051,
"tag": "0005_past_elektra",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1779086009525,
"tag": "0006_chilly_dark_phoenix",
"breakpoints": true
}
]
}

View File

@@ -101,6 +101,11 @@ export function createAuth(
defaultValue: "user",
input: false,
},
status: {
type: "string",
defaultValue: "active",
input: false,
},
referralCode: {
type: "string",
required: false,

View File

@@ -36,6 +36,17 @@ export class AuthGuard implements CanActivate {
throw new UnauthorizedException("Kimlik doğrulama gerekli");
}
// Süper Panel admin lifecycle controls: block non-active users.
// The status column is added by migration 0006; tolerate older rows by
// treating missing/empty as "active".
const status = (session.user as { status?: string }).status ?? "active";
if (status === "suspended") {
throw new UnauthorizedException("Hesabınız askıya alınmıştır. Destek ile iletişime geçin.");
}
if (status === "banned") {
throw new UnauthorizedException("Hesabınız kapatılmıştır.");
}
request.user = session.user;
request.session = session.session;
return true;

View File

@@ -22,6 +22,15 @@ export const users = pgTable(
emailVerified: boolean("email_verified").default(false).notNull(),
image: text("image"),
role: varchar("role", { length: 20 }).default("user").notNull(),
// Lifecycle state controlled by Süper Panel admin actions.
// active - normal
// suspended - temporary block (payment issue, abuse review, etc.)
// banned - permanent block (fraud)
// AuthGuard rejects sessions for non-active users.
status: varchar("status", { length: 20 }).default("active").notNull(),
statusReason: text("status_reason"),
statusChangedAt: timestamp("status_changed_at", { withTimezone: true }),
statusChangedBy: uuid("status_changed_by"),
referralCode: varchar("referral_code", { length: 20 }),
referredBy: uuid("referred_by"),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
@@ -30,6 +39,7 @@ export const users = pgTable(
(table) => [
uniqueIndex("users_email_idx").on(table.email),
uniqueIndex("users_referral_code_idx").on(table.referralCode),
index("users_status_idx").on(table.status),
],
);

View File

@@ -1,9 +1,11 @@
import { Module } from "@nestjs/common";
import { ImpersonationController } from "./impersonation.controller";
import { ImpersonationService } from "./impersonation.service";
import { LifecycleController } from "./lifecycle.controller";
import { LifecycleService } from "./lifecycle.service";
@Module({
controllers: [ImpersonationController],
providers: [ImpersonationService],
controllers: [ImpersonationController, LifecycleController],
providers: [ImpersonationService, LifecycleService],
})
export class InternalAdminModule {}

View File

@@ -0,0 +1,70 @@
import {
BadRequestException,
Body,
Controller,
HttpCode,
HttpStatus,
Param,
Post,
UseGuards,
} from "@nestjs/common";
import { Public } from "../common/decorators/public.decorator";
import { InternalTokenGuard } from "../common/guards/internal-token.guard";
import { LifecycleService } from "./lifecycle.service";
type LifecycleBody = { reason?: string; founderId?: string };
@Controller("internal/admin/users")
@Public()
@UseGuards(InternalTokenGuard)
export class LifecycleController {
constructor(private lifecycle: LifecycleService) {}
@Post(":id/suspend")
@HttpCode(HttpStatus.OK)
async suspend(@Param("id") id: string, @Body() body: LifecycleBody) {
this.requireFounder(body);
this.requireReason(body, "suspend");
return this.lifecycle.setStatus({
userId: id,
newStatus: "suspended",
reason: body.reason,
founderId: body.founderId!,
});
}
@Post(":id/reactivate")
@HttpCode(HttpStatus.OK)
async reactivate(@Param("id") id: string, @Body() body: LifecycleBody) {
this.requireFounder(body);
return this.lifecycle.setStatus({
userId: id,
newStatus: "active",
reason: body.reason,
founderId: body.founderId!,
});
}
@Post(":id/ban")
@HttpCode(HttpStatus.OK)
async ban(@Param("id") id: string, @Body() body: LifecycleBody) {
this.requireFounder(body);
this.requireReason(body, "ban");
return this.lifecycle.setStatus({
userId: id,
newStatus: "banned",
reason: body.reason,
founderId: body.founderId!,
});
}
private requireFounder(body: LifecycleBody) {
if (!body.founderId) throw new BadRequestException("founderId required");
}
private requireReason(body: LifecycleBody, action: string) {
if (!body.reason || body.reason.trim().length < 5) {
throw new BadRequestException(`${action} requires reason (min 5 chars)`);
}
}
}

View File

@@ -0,0 +1,87 @@
import {
ConflictException,
ForbiddenException,
Inject,
Injectable,
Logger,
NotFoundException,
} from "@nestjs/common";
import { eq } from "drizzle-orm";
import { DATABASE, type Database } from "../database/database.provider";
import { sessions, users } from "../database/schema/core";
type LifecycleStatus = "active" | "suspended" | "banned";
@Injectable()
export class LifecycleService {
private readonly logger = new Logger(LifecycleService.name);
constructor(@Inject(DATABASE) private db: Database) {}
async setStatus(input: {
userId: string;
newStatus: LifecycleStatus;
reason?: string;
founderId: string;
}) {
const [target] = await this.db
.select({
id: users.id,
role: users.role,
status: users.status,
})
.from(users)
.where(eq(users.id, input.userId))
.limit(1);
if (!target) throw new NotFoundException("Kullanıcı bulunamadı");
if (target.role === "admin") {
throw new ForbiddenException("Admin kullanıcılar üzerinde yapılamaz");
}
if (target.status === input.newStatus) {
throw new ConflictException(`Kullanıcı zaten '${input.newStatus}' durumunda`);
}
if (target.status === "banned" && input.newStatus === "suspended") {
throw new ConflictException(
"Banned bir kullanıcı suspended'a alınamaz; önce reactivate edin",
);
}
const now = new Date();
await this.db
.update(users)
.set({
status: input.newStatus,
statusReason: input.reason ?? null,
statusChangedAt: now,
statusChangedBy: input.founderId,
updatedAt: now,
})
.where(eq(users.id, input.userId));
// Suspend / ban: kill all sessions so the user is immediately signed out.
// Reactivate leaves sessions intact (user already logged in is fine to stay).
let sessionsKilled = 0;
if (input.newStatus === "suspended" || input.newStatus === "banned") {
const killed = await this.db
.delete(sessions)
.where(eq(sessions.userId, input.userId))
.returning({ id: sessions.id });
sessionsKilled = killed.length;
}
this.logger.log(
`lifecycle: user=${input.userId} ${target.status}${input.newStatus}` +
` founder=${input.founderId} sessionsKilled=${sessionsKilled}` +
(input.reason ? ` reason="${input.reason.slice(0, 80)}"` : ""),
);
return {
success: true,
userId: input.userId,
from: target.status,
to: input.newStatus,
sessionsKilled,
changedAt: now.toISOString(),
};
}
}