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..4c246ae9 100644 --- a/backend/src/modules/admin/admin.controller.ts +++ b/backend/src/modules/admin/admin.controller.ts @@ -1,17 +1,81 @@ -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' +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() 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() { 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/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. +

+
+
+ +
+ +