feat(internal-admin): user lifecycle — suspend / reactivate / ban
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
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:
5
apps/api/drizzle/0006_chilly_dark_phoenix.sql
Normal file
5
apps/api/drizzle/0006_chilly_dark_phoenix.sql
Normal 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");
|
||||
5394
apps/api/drizzle/meta/0006_snapshot.json
Normal file
5394
apps/api/drizzle/meta/0006_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -101,6 +101,11 @@ export function createAuth(
|
||||
defaultValue: "user",
|
||||
input: false,
|
||||
},
|
||||
status: {
|
||||
type: "string",
|
||||
defaultValue: "active",
|
||||
input: false,
|
||||
},
|
||||
referralCode: {
|
||||
type: "string",
|
||||
required: false,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
70
apps/api/src/internal-admin/lifecycle.controller.ts
Normal file
70
apps/api/src/internal-admin/lifecycle.controller.ts
Normal 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)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
87
apps/api/src/internal-admin/lifecycle.service.ts
Normal file
87
apps/api/src/internal-admin/lifecycle.service.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user