From c6b03a614518aa0e7c54a41ca9047a12ac585975 Mon Sep 17 00:00:00 2001 From: Giuseppe Lanna <49965024+King-witcher@users.noreply.github.com> Date: Fri, 30 Jan 2026 12:19:25 -0300 Subject: [PATCH 1/4] =?UTF-8?q?Prompt=201=20desenvolva=20uma=20funcionalid?= =?UTF-8?q?ade=20que=20permite=20a=20usu=C3=A1rios=20com=20role=20creator?= =?UTF-8?q?=20banir=20outros=20usu=C3=A1rios.=20o=20banimento=20deve=20pod?= =?UTF-8?q?er=20ser=20de=20dois=20tipos:=20tempor=C3=A1rio=20por=20tempo?= =?UTF-8?q?=20determinado,=20e=20permanente.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/docs/DATABASE.md | 23 +++++++ backend/src/common/websocket/base.gateway.ts | 50 +++++++++++++- .../repositories/user/user.repository.ts | 18 ++++- backend/src/modules/admin/admin.controller.ts | 68 ++++++++++++++++++- backend/src/modules/admin/admin.module.ts | 3 +- backend/src/modules/auth/auth.guard.ts | 42 +++++++++++- backend/src/modules/auth/auth.module.ts | 5 +- backend/src/modules/user/user.controller.ts | 1 + packages/api-types/src/controllers/admin.ts | 9 +++ packages/api-types/src/controllers/index.ts | 1 + packages/database-types/src/rows/user-row.ts | 16 +++++ 11 files changed, 228 insertions(+), 8 deletions(-) create mode 100644 packages/api-types/src/controllers/admin.ts diff --git a/backend/docs/DATABASE.md b/backend/docs/DATABASE.md index 854ef446..088b1272 100644 --- a/backend/docs/DATABASE.md +++ b/backend/docs/DATABASE.md @@ -57,6 +57,8 @@ type UserRow = { role: UserRole // 'player' | 'creator' | 'bot' + ban?: UserBan | null // Informações de banimento (ver abaixo) + elo: UserRowElo // Dados de rating (ver abaixo) stats: { @@ -67,6 +69,27 @@ type UserRow = { } ``` +### Campo `ban` (UserBan) + +```typescript +type UserBan = { + type: UserBanType // 'temporary' | 'permanent' + created_at: Date // Data/hora do banimento + banned_by: string // UID do criador que aplicou o ban + reason?: string // Motivo opcional + expires_at?: Date | null // Apenas para ban temporário +} +``` + +### Enum `UserBanType` + +```typescript +enum UserBanType { + Temporary = 'temporary', + Permanent = 'permanent', +} +``` + ### Campo `elo` (UserRowElo) ```typescript diff --git a/backend/src/common/websocket/base.gateway.ts b/backend/src/common/websocket/base.gateway.ts index 52489fc5..ba232479 100644 --- a/backend/src/common/websocket/base.gateway.ts +++ b/backend/src/common/websocket/base.gateway.ts @@ -1,9 +1,9 @@ +import { UserBanType } from '@magic3t/database-types' import { Inject, UseFilters, UseGuards } from '@nestjs/common' import { OnEvent } from '@nestjs/event-emitter' import { OnGatewayConnection, OnGatewayInit, WebSocketServer } from '@nestjs/websockets' import { EventNames, EventParams, EventsMap } from '@socket.io/component-emitter' import { DefaultEventsMap, Namespace, Server, Socket } from 'socket.io' - import { UserRepository } from '@/infra/database' import { WebsocketEmitterEvent } from '@/infra/websocket/types' import { WebsocketCountingService } from '@/infra/websocket/websocket-counting.service' @@ -81,6 +81,54 @@ export class BaseGateway< return } + const user = await this.usersRepository.getById(userId) + if (user?.data.ban) { + const ban = user.data.ban + + if (ban.type === UserBanType.Permanent) { + client.send('error', { + errorCode: 'user-banned', + metadata: { + type: ban.type, + reason: ban.reason, + createdAt: ban.created_at, + }, + }) + client.disconnect() + return + } + + const expiresAt = ban.expires_at + if (!expiresAt || !(expiresAt instanceof Date)) { + client.send('error', { + errorCode: 'user-banned', + metadata: { + type: ban.type, + reason: ban.reason, + createdAt: ban.created_at, + }, + }) + client.disconnect() + return + } + + if (expiresAt.getTime() > Date.now()) { + client.send('error', { + errorCode: 'user-banned', + metadata: { + type: ban.type, + reason: ban.reason, + createdAt: ban.created_at, + expiresAt, + }, + }) + client.disconnect() + return + } + + await this.usersRepository.clearBan(userId) + } + // Attach user ID to the socket data const authClient = client as AuthenticSocket authClient.data.userId = userId diff --git a/backend/src/infra/database/repositories/user/user.repository.ts b/backend/src/infra/database/repositories/user/user.repository.ts index 6519b49c..86cfd0b3 100644 --- a/backend/src/infra/database/repositories/user/user.repository.ts +++ b/backend/src/infra/database/repositories/user/user.repository.ts @@ -1,4 +1,4 @@ -import { IconAssignmentRow, UserRow } from '@magic3t/database-types' +import { IconAssignmentRow, UserBan, UserRow } from '@magic3t/database-types' import { Injectable, Logger } from '@nestjs/common' import { FirestoreDataConverter } from 'firebase-admin/firestore' import { unexpected } from '@/common' @@ -111,6 +111,22 @@ export class UserRepository extends BaseFirestoreRepository { this.user_logger.verbose(`update user "${id}" nickname to ${nickname}.`) } + /** Sets a user's ban data. */ + async setBan(id: string, ban: UserBan): Promise { + await this.collection.doc(id).update({ + ban, + }) + this.user_logger.verbose(`set ban for user "${id}".`) + } + + /** Clears a user's ban data. */ + async clearBan(id: string): Promise { + await this.collection.doc(id).update({ + ban: null, + }) + this.user_logger.verbose(`cleared ban for user "${id}".`) + } + /// Gets all bot profiles, sorted by the bot name (bot0, bot1, bot2 and bot3) async getBots(): Promise> { const bots = await this.configService.getBotConfigs() diff --git a/backend/src/modules/admin/admin.controller.ts b/backend/src/modules/admin/admin.controller.ts index 26257629..7003e5ca 100644 --- a/backend/src/modules/admin/admin.controller.ts +++ b/backend/src/modules/admin/admin.controller.ts @@ -1,17 +1,57 @@ -import { Controller, Post, UseGuards } from '@nestjs/common' -import { ApiOperation } from '@nestjs/swagger' +import { BanUserCommand } from '@magic3t/api-types' +import { UserBanType } from '@magic3t/database-types' +import { Body, Controller, Param, Post, UseGuards } from '@nestjs/common' +import { ApiBearerAuth, ApiOperation, ApiProperty } from '@nestjs/swagger' +import { IsEnum, IsNumber, IsOptional, IsString, Min, ValidateIf } from 'class-validator' +import { respondError } from '@/common' import { ConfigRepository, UserRepository } from '@/infra/database' import { AuthGuard } from '@/modules/auth/auth.guard' +import { UserId } from '@/modules/auth/user-id.decorator' import { AdminGuard } from './admin.guard' @Controller('admin') @UseGuards(AuthGuard, AdminGuard) +@ApiBearerAuth() export class AdminController { constructor( private usersRepository: UserRepository, private configRepository: ConfigRepository ) {} + @ApiOperation({ + summary: 'Ban a user (temporary or permanent)', + }) + @Post('users/:id/ban') + async banUser( + @UserId() actorId: string, + @Param('id') userId: string, + @Body() body: BanUserCommandClass + ) { + if (actorId === userId) { + respondError('cannot-ban-self', 400, 'You cannot ban yourself') + } + + const user = await this.usersRepository.getById(userId) + if (!user) respondError('user-not-found', 404, 'User not found') + + let expiresAt: Date | null = null + if (body.type === UserBanType.Temporary) { + if (!body.durationSeconds || body.durationSeconds <= 0) { + respondError('invalid-ban-duration', 400, 'Temporary bans require a duration in seconds') + } + + expiresAt = new Date(Date.now() + body.durationSeconds * 1000) + } + + await this.usersRepository.setBan(userId, { + type: body.type, + created_at: new Date(), + banned_by: actorId, + reason: body.reason, + expires_at: expiresAt, + }) + } + @ApiOperation({}) @Post('reset-ratings') async resetRatings() { @@ -33,3 +73,27 @@ export class AdminController { ) } } + +export class BanUserCommandClass implements BanUserCommand { + @ApiProperty({ + enum: UserBanType, + }) + @IsEnum(UserBanType) + type: UserBanType + + @ApiProperty({ + required: false, + description: 'Required for temporary bans. Duration in seconds.', + }) + @ValidateIf((o) => o.type === UserBanType.Temporary) + @IsNumber() + @Min(1) + durationSeconds?: number + + @ApiProperty({ + required: false, + }) + @IsOptional() + @IsString() + reason?: string +} diff --git a/backend/src/modules/admin/admin.module.ts b/backend/src/modules/admin/admin.module.ts index 6c80b0a2..79906011 100644 --- a/backend/src/modules/admin/admin.module.ts +++ b/backend/src/modules/admin/admin.module.ts @@ -1,11 +1,12 @@ import { Module } from '@nestjs/common' import { DatabaseModule } from '@/infra/database' import { AdminController } from './admin.controller' +import { AdminGuard } from './admin.guard' import { AdminService } from './admin.service' @Module({ controllers: [AdminController], - providers: [AdminService], + providers: [AdminService, AdminGuard], imports: [DatabaseModule], }) export class AdminModule {} diff --git a/backend/src/modules/auth/auth.guard.ts b/backend/src/modules/auth/auth.guard.ts index c74bca14..4f78ebed 100644 --- a/backend/src/modules/auth/auth.guard.ts +++ b/backend/src/modules/auth/auth.guard.ts @@ -1,7 +1,9 @@ +import { UserBanType } from '@magic3t/database-types' import { CanActivate, ExecutionContext, Injectable, Logger } from '@nestjs/common' import { Reflector } from '@nestjs/core' import { Socket } from 'socket.io' import { respondError } from '@/common' +import { UserRepository } from '@/infra/database' import { AuthService } from './auth.service' import { AuthenticRequest } from './auth-request' import { SKIP_AUTH_KEY } from './skip-auth.decorator' @@ -12,7 +14,8 @@ export class AuthGuard implements CanActivate { constructor( private readonly authService: AuthService, - private readonly reflector: Reflector + private readonly reflector: Reflector, + private readonly userRepository: UserRepository ) {} async canActivate(context: ExecutionContext) { @@ -48,6 +51,7 @@ export class AuthGuard implements CanActivate { if (!token) respondError('unauthorized', 401, '"Authorization" header is missing') const userId = await this.authService.validateToken(token.replace('Bearer ', '')) if (!userId) respondError('unauthorized', 401, 'Invalid auth token') + await this.assertUserNotBanned(userId) request.userId = userId return true } @@ -60,6 +64,42 @@ export class AuthGuard implements CanActivate { return false } + await this.assertUserNotBanned(socket.data.userId) + return true } + + private async assertUserNotBanned(userId: string): Promise { + const user = await this.userRepository.getById(userId) + if (!user || !user.data.ban) return + + const ban = user.data.ban + if (ban.type === UserBanType.Permanent) { + respondError('user-banned', 403, { + type: ban.type, + reason: ban.reason, + createdAt: ban.created_at, + }) + } + + const expiresAt = ban.expires_at + if (!expiresAt || !(expiresAt instanceof Date)) { + respondError('user-banned', 403, { + type: ban.type, + reason: ban.reason, + createdAt: ban.created_at, + }) + } + + if (expiresAt.getTime() > Date.now()) { + respondError('user-banned', 403, { + type: ban.type, + reason: ban.reason, + createdAt: ban.created_at, + expiresAt, + }) + } + + await this.userRepository.clearBan(userId) + } } diff --git a/backend/src/modules/auth/auth.module.ts b/backend/src/modules/auth/auth.module.ts index f47a8b48..fd1d2eaa 100644 --- a/backend/src/modules/auth/auth.module.ts +++ b/backend/src/modules/auth/auth.module.ts @@ -1,12 +1,13 @@ import { Global, Module } from '@nestjs/common' import { FirebaseModule } from '@/infra/firebase' +import { AuthGuard } from './auth.guard' import { AuthService } from './auth.service' @Global() @Module({ imports: [FirebaseModule], - providers: [AuthService], - exports: [AuthService], + providers: [AuthService, AuthGuard], + exports: [AuthService, AuthGuard], }) export class AuthModule { imports: [FirebaseModule] diff --git a/backend/src/modules/user/user.controller.ts b/backend/src/modules/user/user.controller.ts index 75865e93..cab4485d 100644 --- a/backend/src/modules/user/user.controller.ts +++ b/backend/src/modules/user/user.controller.ts @@ -139,6 +139,7 @@ export class UserController { challenger: false, matches: 0, }, + ban: null, experience: 0, identification: { last_changed: new Date(), diff --git a/packages/api-types/src/controllers/admin.ts b/packages/api-types/src/controllers/admin.ts new file mode 100644 index 00000000..8d1b46e0 --- /dev/null +++ b/packages/api-types/src/controllers/admin.ts @@ -0,0 +1,9 @@ +import type { UserBanType } from '@magic3t/database-types' + +export type BanUserCommand = { + type: UserBanType + /** Required for temporary bans. Duration in seconds. */ + durationSeconds?: number + /** Optional ban reason. */ + reason?: string +} diff --git a/packages/api-types/src/controllers/index.ts b/packages/api-types/src/controllers/index.ts index acdc559d..6433980e 100644 --- a/packages/api-types/src/controllers/index.ts +++ b/packages/api-types/src/controllers/index.ts @@ -1,3 +1,4 @@ +export * from './admin' export * from './match' export * from './root' export * from './user' diff --git a/packages/database-types/src/rows/user-row.ts b/packages/database-types/src/rows/user-row.ts index 6772212d..2a929d1f 100644 --- a/packages/database-types/src/rows/user-row.ts +++ b/packages/database-types/src/rows/user-row.ts @@ -19,6 +19,19 @@ export const enum UserRole { Bot = 'bot', } +export const enum UserBanType { + Temporary = 'temporary', + Permanent = 'permanent', +} + +export type UserBan = { + type: UserBanType + created_at: Date + banned_by: string + reason?: string + expires_at?: Date | null +} + export type UserRow = { identification: { /** Nickname slug used to identify the user uniquely */ @@ -40,6 +53,9 @@ export type UserRow = { role: UserRole + /** Ban information, if the user is currently or previously banned. */ + ban?: UserBan | null + elo: UserRowElo stats: { From a39133f951dda833920b681e05785505fae1a3f6 Mon Sep 17 00:00:00 2001 From: Giuseppe Lanna <49965024+King-witcher@users.noreply.github.com> Date: Fri, 30 Jan 2026 12:30:44 -0300 Subject: [PATCH 2/4] =?UTF-8?q?Corre=C3=A7=C3=A3o=20humana=20fix=20const?= =?UTF-8?q?=20enum?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/modules/admin/admin.controller.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/src/modules/admin/admin.controller.ts b/backend/src/modules/admin/admin.controller.ts index 7003e5ca..864a33f2 100644 --- a/backend/src/modules/admin/admin.controller.ts +++ b/backend/src/modules/admin/admin.controller.ts @@ -76,9 +76,9 @@ export class AdminController { export class BanUserCommandClass implements BanUserCommand { @ApiProperty({ - enum: UserBanType, + enum: [UserBanType.Temporary, UserBanType.Permanent], }) - @IsEnum(UserBanType) + @IsEnum([UserBanType.Temporary, UserBanType.Permanent]) type: UserBanType @ApiProperty({ From 3ba10a5db428833aafb9399479aef103857ed5f8 Mon Sep 17 00:00:00 2001 From: Giuseppe Lanna <49965024+King-witcher@users.noreply.github.com> Date: Fri, 30 Jan 2026 12:39:57 -0300 Subject: [PATCH 3/4] =?UTF-8?q?fix=20ordem=20de=20declara=C3=A7=C3=A3o?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/modules/admin/admin.controller.ts | 48 +++++++++---------- 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/backend/src/modules/admin/admin.controller.ts b/backend/src/modules/admin/admin.controller.ts index 864a33f2..4c246ae9 100644 --- a/backend/src/modules/admin/admin.controller.ts +++ b/backend/src/modules/admin/admin.controller.ts @@ -9,6 +9,30 @@ import { AuthGuard } from '@/modules/auth/auth.guard' import { UserId } from '@/modules/auth/user-id.decorator' import { AdminGuard } from './admin.guard' +export class BanUserCommandClass implements BanUserCommand { + @ApiProperty({ + enum: [UserBanType.Temporary, UserBanType.Permanent], + }) + @IsEnum([UserBanType.Temporary, UserBanType.Permanent]) + type: UserBanType + + @ApiProperty({ + required: false, + description: 'Required for temporary bans. Duration in seconds.', + }) + @ValidateIf((o) => o.type === UserBanType.Temporary) + @IsNumber() + @Min(1) + durationSeconds?: number + + @ApiProperty({ + required: false, + }) + @IsOptional() + @IsString() + reason?: string +} + @Controller('admin') @UseGuards(AuthGuard, AdminGuard) @ApiBearerAuth() @@ -73,27 +97,3 @@ export class AdminController { ) } } - -export class BanUserCommandClass implements BanUserCommand { - @ApiProperty({ - enum: [UserBanType.Temporary, UserBanType.Permanent], - }) - @IsEnum([UserBanType.Temporary, UserBanType.Permanent]) - type: UserBanType - - @ApiProperty({ - required: false, - description: 'Required for temporary bans. Duration in seconds.', - }) - @ValidateIf((o) => o.type === UserBanType.Temporary) - @IsNumber() - @Min(1) - durationSeconds?: number - - @ApiProperty({ - required: false, - }) - @IsOptional() - @IsString() - reason?: string -} From 4867924127dea5bdd8c65313dc7f28743eeebfe6 Mon Sep 17 00:00:00 2001 From: Giuseppe Lanna <49965024+King-witcher@users.noreply.github.com> Date: Fri, 30 Jan 2026 12:40:00 -0300 Subject: [PATCH 4/4] Prompt 2 agora implementa no frontend --- frontend/src/route-tree.gen.ts | 21 ++ frontend/src/routes/admin.tsx | 225 ++++++++++++++++++++ frontend/src/services/clients/api-client.ts | 15 ++ 3 files changed, 261 insertions(+) create mode 100644 frontend/src/routes/admin.tsx diff --git a/frontend/src/route-tree.gen.ts b/frontend/src/route-tree.gen.ts index 481ccd11..c25bec5c 100644 --- a/frontend/src/route-tree.gen.ts +++ b/frontend/src/route-tree.gen.ts @@ -11,6 +11,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as LeaderboardRouteImport } from './routes/leaderboard' import { Route as BiancaRouteImport } from './routes/bianca' +import { Route as AdminRouteImport } from './routes/admin' import { Route as AuthGuardedRouteImport } from './routes/_auth-guarded' import { Route as AuthRouteImport } from './routes/_auth' import { Route as TutorialRouteRouteImport } from './routes/tutorial/route' @@ -32,6 +33,11 @@ const BiancaRoute = BiancaRouteImport.update({ path: '/bianca', getParentRoute: () => rootRouteImport, } as any) +const AdminRoute = AdminRouteImport.update({ + id: '/admin', + path: '/admin', + getParentRoute: () => rootRouteImport, +} as any) const AuthGuardedRoute = AuthGuardedRouteImport.update({ id: '/_auth-guarded', getParentRoute: () => rootRouteImport, @@ -84,6 +90,7 @@ const UsersIdUserIdRoute = UsersIdUserIdRouteImport.update({ export interface FileRoutesByFullPath { '/tutorial': typeof TutorialRouteRoute '/': typeof AuthGuardedIndexRoute + '/admin': typeof AdminRoute '/bianca': typeof BiancaRoute '/leaderboard': typeof LeaderboardRoute '/me': typeof AuthGuardedMeRouteRoute @@ -96,6 +103,7 @@ export interface FileRoutesByFullPath { export interface FileRoutesByTo { '/tutorial': typeof TutorialRouteRoute '/': typeof AuthGuardedIndexRoute + '/admin': typeof AdminRoute '/bianca': typeof BiancaRoute '/leaderboard': typeof LeaderboardRoute '/me': typeof AuthGuardedMeRouteRoute @@ -110,6 +118,7 @@ export interface FileRoutesById { '/tutorial': typeof TutorialRouteRoute '/_auth': typeof AuthRouteWithChildren '/_auth-guarded': typeof AuthGuardedRouteWithChildren + '/admin': typeof AdminRoute '/bianca': typeof BiancaRoute '/leaderboard': typeof LeaderboardRoute '/_auth-guarded/me': typeof AuthGuardedMeRouteRoute @@ -125,6 +134,7 @@ export interface FileRouteTypes { fullPaths: | '/tutorial' | '/' + | '/admin' | '/bianca' | '/leaderboard' | '/me' @@ -137,6 +147,7 @@ export interface FileRouteTypes { to: | '/tutorial' | '/' + | '/admin' | '/bianca' | '/leaderboard' | '/me' @@ -150,6 +161,7 @@ export interface FileRouteTypes { | '/tutorial' | '/_auth' | '/_auth-guarded' + | '/admin' | '/bianca' | '/leaderboard' | '/_auth-guarded/me' @@ -165,6 +177,7 @@ export interface RootRouteChildren { TutorialRouteRoute: typeof TutorialRouteRoute AuthRoute: typeof AuthRouteWithChildren AuthGuardedRoute: typeof AuthGuardedRouteWithChildren + AdminRoute: typeof AdminRoute BiancaRoute: typeof BiancaRoute LeaderboardRoute: typeof LeaderboardRoute UsersNicknameRoute: typeof UsersNicknameRoute @@ -187,6 +200,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof BiancaRouteImport parentRoute: typeof rootRouteImport } + '/admin': { + id: '/admin' + path: '/admin' + fullPath: '/admin' + preLoaderRoute: typeof AdminRouteImport + parentRoute: typeof rootRouteImport + } '/_auth-guarded': { id: '/_auth-guarded' path: '' @@ -292,6 +312,7 @@ const rootRouteChildren: RootRouteChildren = { TutorialRouteRoute: TutorialRouteRoute, AuthRoute: AuthRouteWithChildren, AuthGuardedRoute: AuthGuardedRouteWithChildren, + AdminRoute: AdminRoute, BiancaRoute: BiancaRoute, LeaderboardRoute: LeaderboardRoute, UsersNicknameRoute: UsersNicknameRoute, diff --git a/frontend/src/routes/admin.tsx b/frontend/src/routes/admin.tsx new file mode 100644 index 00000000..5c0115cc --- /dev/null +++ b/frontend/src/routes/admin.tsx @@ -0,0 +1,225 @@ +import { UserBanType, UserRole } from '@magic3t/database-types' +import { useMutation } from '@tanstack/react-query' +import { createFileRoute, useNavigate } from '@tanstack/react-router' +import { FormEvent, useEffect, useMemo, useState } from 'react' +import { Button, Input } from '@/components/atoms' +import { Label } from '@/components/ui/label' +import { Panel, PanelDivider } from '@/components/ui/panel' +import { ChooseNicknameTemplate, LoadingSessionTemplate, NotFoundTemplate } from '@/components/templates' +import { AuthState, useAuth } from '@/contexts/auth-context' +import { apiClient } from '@/services/clients/api-client' +import { ClientError } from '@/services/clients/client-error' + +export const Route = createFileRoute('/admin' as any)({ + component: Page, +}) + +type BanTypeOption = UserBanType.Temporary | UserBanType.Permanent + +type BanFormState = { + userId: string + type: BanTypeOption + durationSeconds: string + reason: string +} + +const ERROR_MAP: Record = { + 'cannot-ban-self': 'Você não pode se banir.', + 'user-not-found': 'Usuário não encontrado.', + 'invalid-ban-duration': 'Informe uma duração válida em segundos.', + unauthorized: 'Você precisa estar autenticado.', + forbidden: 'Acesso negado.', +} + +function Page() { + const { state: authState, user } = useAuth() + const navigate = useNavigate() + + useEffect(() => { + if (authState === AuthState.NotSignedIn) { + navigate({ + to: '/sign-in', + search: { + referrer: '/admin', + }, + }) + } + }, [authState, navigate]) + + if (authState === AuthState.LoadingSession || authState === AuthState.LoadingUserData) { + return + } + + if (authState === AuthState.SignedInUnregistered) { + return + } + + if (!user || user.role !== UserRole.Creator) { + return + } + + return +} + +function AdminBanPanel() { + const [form, setForm] = useState({ + userId: '', + type: UserBanType.Temporary, + durationSeconds: '3600', + reason: '', + }) + const [error, setError] = useState(null) + const [success, setSuccess] = useState(null) + + const isTemporary = form.type === UserBanType.Temporary + + const payload = useMemo(() => { + return { + type: form.type, + durationSeconds: isTemporary ? Number(form.durationSeconds) : undefined, + reason: form.reason?.trim() || undefined, + } + }, [form.type, form.durationSeconds, form.reason, isTemporary]) + + const banMutation = useMutation({ + mutationKey: ['ban-user', form.userId, payload.type], + async mutationFn() { + if (!form.userId.trim()) throw new Error('Informe o ID do usuário.') + + if (isTemporary) { + const duration = Number(form.durationSeconds) + if (!Number.isFinite(duration) || duration <= 0) { + throw new Error('Informe uma duração válida em segundos.') + } + } + + await apiClient.admin.banUser(form.userId.trim(), payload) + }, + onMutate() { + setError(null) + setSuccess(null) + }, + async onError(e) { + if (e instanceof ClientError) { + const errorCode = await e.errorCode + if (errorCode && ERROR_MAP[errorCode]) { + setError(ERROR_MAP[errorCode]) + return + } + } + + setError((e as Error).message) + }, + onSuccess() { + setSuccess('Banimento aplicado com sucesso.') + }, + }) + + function handleSubmit(event: FormEvent) { + event.preventDefault() + banMutation.mutate() + } + + return ( +
+
+ +
+

+ Creator Zone +

+

+ Banir usuários (temporário ou permanente) +

+
+ +
+
+ + setForm((prev) => ({ ...prev, userId: e.target.value }))} + disabled={banMutation.isPending} + error={!!error} + /> +
+ + + +
+
+ + +
+ +
+ + setForm((prev) => ({ ...prev, durationSeconds: e.target.value }))} + disabled={!isTemporary || banMutation.isPending} + /> +

+ Obrigatório para banimento temporário. Ex.: 3600 = 1 hora. +

+
+
+ +
+ +