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

@@ -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),
],
);