diff --git a/backend/src/modules/auth/auth.guard.ts b/backend/src/modules/auth/auth.guard.ts index c74bca14..b8c3311d 100644 --- a/backend/src/modules/auth/auth.guard.ts +++ b/backend/src/modules/auth/auth.guard.ts @@ -2,6 +2,7 @@ import { CanActivate, ExecutionContext, Injectable, Logger } from '@nestjs/commo 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 +13,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 +50,24 @@ 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') + // Ban check + const user = await this.userRepository.getById(userId) + if (user?.data?.ban) { + const ban = user.data.ban + const now = new Date() + if ( + ban.type === 'permanent' || + (ban.type === 'temporary' && ban.expiresAt && now < new Date(ban.expiresAt)) + ) { + respondError( + 'banned', + 403, + ban.type === 'permanent' + ? 'User is permanently banned' + : `User is banned until ${ban.expiresAt}` + ) + } + } request.userId = userId return true } @@ -60,6 +80,20 @@ export class AuthGuard implements CanActivate { return false } + // Ban check + const user = await this.userRepository.getById(socket.data.userId) + if (user?.data?.ban) { + const ban = user.data.ban + const now = new Date() + if ( + ban.type === 'permanent' || + (ban.type === 'temporary' && ban.expiresAt && now < new Date(ban.expiresAt)) + ) { + this.logger.warn(`banned user ${socket.data.userId} tried to connect`) + return false + } + } + return true } } diff --git a/backend/src/modules/user/swagger/ban-user-command.ts b/backend/src/modules/user/swagger/ban-user-command.ts new file mode 100644 index 00000000..eb392496 --- /dev/null +++ b/backend/src/modules/user/swagger/ban-user-command.ts @@ -0,0 +1,20 @@ +import { BanType } from '@magic3t/database-types' +import { ApiProperty } from '@nestjs/swagger' +import { IsDateString, IsDefined, IsEnum, IsOptional, IsString } from 'class-validator' + +export class BanUserCommand { + @ApiProperty({ enum: ['temporary', 'permanent'] }) + @IsDefined() + @IsEnum(['temporary', 'permanent']) + type: BanType + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + reason?: string + + @ApiProperty({ required: false, description: 'Expiration date for temporary bans (ISO string)' }) + @IsOptional() + @IsDateString() + expiresAt?: string +} diff --git a/backend/src/modules/user/swagger/user-commands.ts b/backend/src/modules/user/swagger/user-commands.ts index 65b16cab..e4842402 100644 --- a/backend/src/modules/user/swagger/user-commands.ts +++ b/backend/src/modules/user/swagger/user-commands.ts @@ -1,15 +1,6 @@ import { ChangeIconCommand, ChangeNicknameCommand, RegisterUserCommand } from '@magic3t/api-types' import { ApiProperty } from '@nestjs/swagger' -import { - IsDefined, - IsNumber, - IsString, - Matches, - Max, - MaxLength, - Min, - MinLength, -} from 'class-validator' +import { IsDefined, IsNumber, IsString, Matches, MaxLength, MinLength } from 'class-validator' export class RegisterUserCommandClass implements RegisterUserCommand { @IsDefined() diff --git a/backend/src/modules/user/user.controller.ts b/backend/src/modules/user/user.controller.ts index 75865e93..4514e46c 100644 --- a/backend/src/modules/user/user.controller.ts +++ b/backend/src/modules/user/user.controller.ts @@ -5,8 +5,10 @@ import { ApiBearerAuth, ApiOperation, ApiResponse } from '@nestjs/swagger' import { range } from 'lodash' import { respondError } from '@/common' import { ConfigRepository, UserRepository } from '@/infra/database' +import { AdminGuard } from '@/modules/admin/admin.guard' import { AuthGuard } from '@/modules/auth/auth.guard' import { UserId } from '@/modules/auth/user-id.decorator' +import { BanUserCommand } from './swagger/ban-user-command' import { ChangeIconCommandClass, ChangeNickCommandClass, @@ -24,6 +26,43 @@ export class UserController { private readonly configRepository: ConfigRepository ) {} + @Post(':id/ban') + @UseGuards(AuthGuard, AdminGuard) + @ApiBearerAuth() + @ApiOperation({ summary: 'Ban a user (creator only)' }) + async banUser( + @UserId() creatorId: string, + @Param('id') userId: string, + @Body() body: BanUserCommand + ) { + if (creatorId === userId) respondError('cannot-ban-self', 400, 'You cannot ban yourself') + const user = await this.userRepository.getById(userId) + if (!user) respondError('user-not-found', 404, 'User not found') + if (user.data.role === 'creator') + respondError('cannot-ban-creator', 403, 'Cannot ban another creator') + + const now = new Date() + let expiresAt: Date | undefined + if (body.type === 'temporary') { + if (!body.expiresAt) + respondError('missing-expiry', 400, 'expiresAt is required for temporary bans') + expiresAt = new Date(body.expiresAt) + if (Number.isNaN(expiresAt.getTime()) || expiresAt <= now) + respondError('invalid-expiry', 400, 'expiresAt must be a future date') + } + + await this.userRepository.update(userId, { + ban: { + type: body.type, + reason: body.reason, + bannedAt: now, + expiresAt: expiresAt ?? null, + bannedBy: creatorId, + }, + }) + return { success: true } + } + @Get('id/:id') @ApiOperation({ summary: 'Get a user by id', diff --git a/frontend/src/components/templates/profile/profile.tsx b/frontend/src/components/templates/profile/profile.tsx index cf1d0600..2b92d901 100644 --- a/frontend/src/components/templates/profile/profile.tsx +++ b/frontend/src/components/templates/profile/profile.tsx @@ -6,6 +6,13 @@ import { Console } from '@/lib/console' import { MatchHistory } from './components/match-history' import { ProfileHeader } from './components/profile-header' import { ProfileSearch } from './components/profile-search' + +import { useAuth } from '@/contexts/auth-context' +import { Button } from '@/components/ui/button' +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog' +import { Input } from '@/components/ui/input' +import { useState } from 'react' +import { banUser, BanUserPayload } from '@/services/clients/ban-user' import { ProfileStats } from './components/profile-stats' interface Props { @@ -14,6 +21,35 @@ interface Props { } export function ProfileTemplate({ user, matchesQuery }: Props) { + const auth = useAuth() + const [banOpen, setBanOpen] = useState(false) + const [banType, setBanType] = useState<'temporary' | 'permanent'>('permanent') + const [reason, setReason] = useState('') + const [expiresAt, setExpiresAt] = useState('') + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [success, setSuccess] = useState(false) + + const isCreator = auth.user?.role === 'creator' + const isSelf = auth.user?.id === user.id + const canBan = isCreator && !isSelf && user.role !== 'creator' && !user.ban + + async function handleBan() { + setLoading(true) + setError(null) + setSuccess(false) + try { + const payload: BanUserPayload = { type: banType, reason } + if (banType === 'temporary') payload.expiresAt = expiresAt + await banUser(user.id, payload) + setSuccess(true) + setBanOpen(false) + } catch (e: any) { + setError(e?.message || 'Erro ao banir usuário') + } finally { + setLoading(false) + } + } // Registers a console command to log the user ID useRegisterCommand( { @@ -35,6 +71,51 @@ export function ProfileTemplate({ user, matchesQuery }: Props) { + {canBan && ( +
+ + + + + + + Banir usuário + + Escolha o tipo de banimento e, se temporário, defina a data de expiração. + + +
+ + + {banType === 'temporary' && ( + setExpiresAt(e.target.value)} + placeholder="Data/hora de expiração" + /> + )} + setReason(e.target.value)} + placeholder="Motivo (opcional)" + /> + {error &&
{error}
} + {success &&
Usuário banido com sucesso!
} +
+ + + +
+
+
+ )} {/* Match History Card */} diff --git a/frontend/src/services/clients/ban-user.ts b/frontend/src/services/clients/ban-user.ts new file mode 100644 index 00000000..47e812c2 --- /dev/null +++ b/frontend/src/services/clients/ban-user.ts @@ -0,0 +1,12 @@ +import { apiClient } from '@/services/clients/api-client' +import { BanType } from '@magic3t/database-types' + +export interface BanUserPayload { + type: BanType + reason?: string + expiresAt?: string +} + +export async function banUser(userId: string, payload: BanUserPayload): Promise { + await apiClient.user.post(`${userId}/ban`, payload) +} diff --git a/packages/database-types/src/rows/index.ts b/packages/database-types/src/rows/index.ts index bde4b613..69cc64da 100644 --- a/packages/database-types/src/rows/index.ts +++ b/packages/database-types/src/rows/index.ts @@ -2,5 +2,6 @@ export * from './config' export * from './crash-report-row' export * from './icon-assignment-row' export * from './match-row' +export * from './user-ban' export * from './user-row' export * from './with-id' diff --git a/packages/database-types/src/rows/user-ban.ts b/packages/database-types/src/rows/user-ban.ts new file mode 100644 index 00000000..7580e60b --- /dev/null +++ b/packages/database-types/src/rows/user-ban.ts @@ -0,0 +1,9 @@ +export type BanType = 'temporary' | 'permanent' + +export interface UserBan { + type: BanType + reason?: string + bannedAt: Date + expiresAt: Date | null // Only for temporary bans + bannedBy: string // userId of the creator +} diff --git a/packages/database-types/src/rows/user-row.ts b/packages/database-types/src/rows/user-row.ts index 6772212d..692894ba 100644 --- a/packages/database-types/src/rows/user-row.ts +++ b/packages/database-types/src/rows/user-row.ts @@ -47,4 +47,6 @@ export type UserRow = { draws: number defeats: number } + /** Ban info, if user is banned */ + ban?: import('./user-ban').UserBan }