From 8a41f4714d1849cd8e2a48d8e8c63933042cc5ae Mon Sep 17 00:00:00 2001 From: Giuseppe Lanna <49965024+King-witcher@users.noreply.github.com> Date: Thu, 19 Feb 2026 12:44:09 -0300 Subject: [PATCH] =?UTF-8?q?Prompt:=20>=20desenvolva=20uma=20funcionalidade?= =?UTF-8?q?=20que=20permite=20a=20usu=C3=A1rios=20com=20role=20creator=20b?= =?UTF-8?q?anir=20outros=20usu=C3=A1rios.=20o=20banimento=20deve=20poder?= =?UTF-8?q?=20ser=20de=20dois=20tipos:=20tempor=C3=A1rio=20por=20tempo=20d?= =?UTF-8?q?eterminado,=20e=20permanente.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repositories/user/user.repository.ts | 26 +++ backend/src/modules/admin/admin.controller.ts | 40 +++- .../modules/admin/swagger/admin-commands.ts | 23 +++ backend/src/modules/queue/queue.controller.ts | 24 ++- backend/src/modules/user/user.service.ts | 8 + .../profile/components/ban-modal.tsx | 177 ++++++++++++++++++ .../profile/components/profile-header.tsx | 23 ++- .../profile/components/unban-modal.tsx | 96 ++++++++++ .../components/templates/profile/profile.tsx | 47 +++++ frontend/src/services/clients/api-client.ts | 24 +++ packages/api-types/src/controllers/admin.ts | 16 ++ packages/api-types/src/controllers/index.ts | 1 + packages/api-types/src/controllers/user.ts | 2 + packages/database-types/src/rows/user-row.ts | 13 ++ 14 files changed, 513 insertions(+), 7 deletions(-) create mode 100644 backend/src/modules/admin/swagger/admin-commands.ts create mode 100644 frontend/src/components/templates/profile/components/ban-modal.tsx create mode 100644 frontend/src/components/templates/profile/components/unban-modal.tsx create mode 100644 packages/api-types/src/controllers/admin.ts diff --git a/backend/src/infra/database/repositories/user/user.repository.ts b/backend/src/infra/database/repositories/user/user.repository.ts index 6519b49c..99385c28 100644 --- a/backend/src/infra/database/repositories/user/user.repository.ts +++ b/backend/src/infra/database/repositories/user/user.repository.ts @@ -202,4 +202,30 @@ export class UserRepository extends BaseFirestoreRepository { date: new Date(), }) } + + /** + * Bans a user. + * @param userId The id of the user to ban + * @param ban The ban data: reason, expiresAt (null = permanent), and bannedBy (creator uid) + */ + async banUser( + userId: string, + ban: { reason: string; expiresAt: Date | null; bannedBy: string } + ): Promise { + // biome-ignore lint/suspicious/noExplicitAny: Firestore UpdateData type doesn't accept optional object fields directly + await this.collection.doc(userId).update({ ban } as any) + this.user_logger.log( + `User "${userId}" banned by "${ban.bannedBy}". Expires: ${ban.expiresAt ?? 'never'}.` + ) + } + + /** + * Removes the ban from a user. + * @param userId The id of the user to unban + */ + async unbanUser(userId: string): Promise { + // biome-ignore lint/suspicious/noExplicitAny: Firestore UpdateData type doesn't accept null for optional object fields directly + await this.collection.doc(userId).update({ ban: null } as any) + this.user_logger.log(`User "${userId}" unbanned.`) + } } diff --git a/backend/src/modules/admin/admin.controller.ts b/backend/src/modules/admin/admin.controller.ts index 26257629..a5c13641 100644 --- a/backend/src/modules/admin/admin.controller.ts +++ b/backend/src/modules/admin/admin.controller.ts @@ -1,8 +1,11 @@ -import { Controller, Post, UseGuards } from '@nestjs/common' +import { Body, Controller, Delete, Param, Post, UseGuards } from '@nestjs/common' import { ApiOperation } from '@nestjs/swagger' +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' +import { BanUserCommandClass } from './swagger/admin-commands' @Controller('admin') @UseGuards(AuthGuard, AdminGuard) @@ -32,4 +35,39 @@ export class AdminController { }) ) } + + @ApiOperation({ + summary: 'Ban a user', + description: + 'Bans a user by user id. The ban can be temporary (with a duration in seconds) or permanent.', + }) + @Post('ban/:userId') + async banUser( + @Param('userId') targetId: string, + @UserId() creatorId: string, + @Body() body: BanUserCommandClass + ) { + const target = await this.usersRepository.getById(targetId) + if (!target) respondError('user-not-found', 404, 'User not found') + + const expiresAt = body.duration != null ? new Date(Date.now() + body.duration * 1000) : null + + await this.usersRepository.banUser(targetId, { + reason: body.reason, + expiresAt, + bannedBy: creatorId, + }) + } + + @ApiOperation({ + summary: 'Unban a user', + description: 'Removes the ban from a user by user id.', + }) + @Delete('ban/:userId') + async unbanUser(@Param('userId') targetId: string) { + const target = await this.usersRepository.getById(targetId) + if (!target) respondError('user-not-found', 404, 'User not found') + + await this.usersRepository.unbanUser(targetId) + } } diff --git a/backend/src/modules/admin/swagger/admin-commands.ts b/backend/src/modules/admin/swagger/admin-commands.ts new file mode 100644 index 00000000..fc1d5d1c --- /dev/null +++ b/backend/src/modules/admin/swagger/admin-commands.ts @@ -0,0 +1,23 @@ +import { BanUserCommand } from '@magic3t/api-types' +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger' +import { IsDefined, IsNumber, IsOptional, IsString, Min } from 'class-validator' + +export class BanUserCommandClass implements BanUserCommand { + @IsDefined() + @IsString() + @ApiProperty({ + type: 'string', + description: 'The reason for the ban', + }) + reason: string + + @IsOptional() + @IsNumber() + @Min(1) + @ApiPropertyOptional({ + type: 'number', + description: 'Duration of the ban in seconds. If not provided or null, the ban is permanent.', + nullable: true, + }) + duration?: number | null +} diff --git a/backend/src/modules/queue/queue.controller.ts b/backend/src/modules/queue/queue.controller.ts index 12573a5d..df95d703 100644 --- a/backend/src/modules/queue/queue.controller.ts +++ b/backend/src/modules/queue/queue.controller.ts @@ -2,6 +2,7 @@ import { BotName } from '@magic3t/database-types' import { Body, Controller, Delete, Post, UseGuards } from '@nestjs/common' import { ApiBearerAuth, ApiOperation } from '@nestjs/swagger' import { respondError } from '@/common' +import { UserRepository } from '@/infra/database' import { AuthGuard } from '@/modules/auth/auth.guard' import { UserId } from '@/modules/auth/user-id.decorator' import { EnqueueDto, QueueMode } from './dtos/enqueue-dto' @@ -11,17 +12,30 @@ import { QueueService } from './queue.service' @ApiBearerAuth() @UseGuards(AuthGuard) export class QueueController { - // private readonly logger = new Logger(QueueController.name, { - // timestamp: true, - // }) - - constructor(private readonly queueService: QueueService) {} + constructor( + private readonly queueService: QueueService, + private readonly userRepository: UserRepository + ) {} @ApiOperation({ summary: 'Enqueue for a match', }) @Post() async handleEnqueue(@UserId() userId: string, @Body() body: EnqueueDto) { + const user = await this.userRepository.getById(userId) + if (!user) respondError('user-not-found', 404, 'User not found') + + if (user.data.ban) { + const { expiresAt, reason } = user.data.ban + const isExpired = expiresAt !== null && expiresAt <= new Date() + if (!isExpired) { + respondError('user-banned', 403, { + reason, + expiresAt: expiresAt ? expiresAt.toISOString() : null, + }) + } + } + switch (body.queueMode) { case QueueMode.Ranked: return this.queueService.enqueue(userId, 'ranked') diff --git a/backend/src/modules/user/user.service.ts b/backend/src/modules/user/user.service.ts index ce074d28..2b598503 100644 --- a/backend/src/modules/user/user.service.ts +++ b/backend/src/modules/user/user.service.ts @@ -11,6 +11,13 @@ export class UserService { async getUserByRow(row: GetResult): Promise { const rating = await this.ratingService.getRatingConverter(row.data.elo) + const ban = row.data.ban + ? { + reason: row.data.ban.reason, + expiresAt: row.data.ban.expiresAt ? row.data.ban.expiresAt.toISOString() : null, + } + : undefined + return { id: row.id, role: row.data.role, @@ -18,6 +25,7 @@ export class UserService { summonerIcon: row.data.summoner_icon, stats: row.data.stats, rating: rating.ratingData, + ban, } } diff --git a/frontend/src/components/templates/profile/components/ban-modal.tsx b/frontend/src/components/templates/profile/components/ban-modal.tsx new file mode 100644 index 00000000..ddc8bb89 --- /dev/null +++ b/frontend/src/components/templates/profile/components/ban-modal.tsx @@ -0,0 +1,177 @@ +import { GetUserResult } from '@magic3t/api-types' +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { GiHandcuffs } from 'react-icons/gi' +import { Button } from '@/components/atoms' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { useDialogStore } from '@/contexts/modal-store' +import { cn } from '@/lib/utils' +import { apiClient } from '@/services/clients/api-client' +import { useState } from 'react' + +interface BanModalProps { + user: GetUserResult +} + +const DURATION_OPTIONS = [ + { label: 'Permanente', value: null }, + { label: '1 hora', value: 3600 }, + { label: '1 dia', value: 86400 }, + { label: '7 dias', value: 604800 }, + { label: '30 dias', value: 2592000 }, + { label: 'Personalizado', value: 'custom' as const }, +] + +export function BanModal({ user }: BanModalProps) { + const closeModal = useDialogStore((s) => s.closeModal) + const queryClient = useQueryClient() + + const [reason, setReason] = useState('') + const [selectedDuration, setSelectedDuration] = useState(null) + const [customDays, setCustomDays] = useState('') + const [reasonError, setReasonError] = useState(false) + + const banMutation = useMutation({ + async mutationFn() { + let duration: number | null = null + if (selectedDuration === 'custom') { + const days = Number.parseFloat(customDays) + duration = Math.round(days * 86400) + } else { + duration = selectedDuration + } + await apiClient.admin.banUser(user.id, { reason: reason.trim(), duration }) + }, + onSuccess() { + queryClient.invalidateQueries({ queryKey: ['user-by-nickname'] }) + queryClient.invalidateQueries({ queryKey: ['user-by-id', user.id] }) + closeModal() + }, + }) + + const handleBan = () => { + if (!reason.trim()) { + setReasonError(true) + return + } + banMutation.mutate() + } + + const durationLabel = + selectedDuration === null + ? 'permanentemente' + : selectedDuration === 'custom' + ? `por ${customDays || '?'} dia(s)` + : DURATION_OPTIONS.find((o) => o.value === selectedDuration)?.label.toLowerCase() + + return ( +
+ {/* Decorative corners */} +
+
+
+
+ + + +

+ Banir Usuário +

+ +

+ Você está prestes a banir{' '} + {user.nickname}{' '} + {durationLabel}. +

+ + {/* Reason */} +
+ + { + setReason(e.target.value) + if (reasonError) setReasonError(false) + }} + /> + {reasonError && ( +

O motivo é obrigatório.

+ )} +
+ + {/* Duration */} +
+ +
+ {DURATION_OPTIONS.map((opt) => ( + + ))} +
+ {selectedDuration === 'custom' && ( +
+ setCustomDays(e.target.value)} + className="flex-1" + /> + dias +
+ )} +
+ + {banMutation.isError && ( +

+ Ocorreu um erro ao banir o usuário. Tente novamente. +

+ )} + +
+ + +
+
+ ) +} diff --git a/frontend/src/components/templates/profile/components/profile-header.tsx b/frontend/src/components/templates/profile/components/profile-header.tsx index 6239ada7..21ef7d57 100644 --- a/frontend/src/components/templates/profile/components/profile-header.tsx +++ b/frontend/src/components/templates/profile/components/profile-header.tsx @@ -1,6 +1,6 @@ import { GetUserResult } from '@magic3t/api-types' import { UserRole } from '@magic3t/database-types' -import { GiCrown, GiRobotGrab } from 'react-icons/gi' +import { GiChainedHeart, GiCrown, GiRobotGrab } from 'react-icons/gi' import { Tooltip } from '@/components/ui/tooltip' import { AvatarDivision, AvatarImage, AvatarRoot, AvatarWing } from './profile-avatar' @@ -9,6 +9,16 @@ interface ProfileHeaderProps { } export function ProfileHeader({ user }: ProfileHeaderProps) { + const isBanned = + user.ban != null && + (user.ban.expiresAt === null || new Date(user.ban.expiresAt) > new Date()) + + const banLabel = isBanned + ? user.ban!.expiresAt === null + ? `Banido permanentemente: ${user.ban!.reason}` + : `Banido até ${new Date(user.ban!.expiresAt).toLocaleString('pt-BR')}: ${user.ban!.reason}` + : '' + return (
@@ -36,9 +46,20 @@ export function ProfileHeader({ user }: ProfileHeaderProps) {

{user.nickname}

+ {isBanned && ( + + + + )}
+ {isBanned && ( +

+ {user.ban!.expiresAt === null ? 'Banido permanentemente' : 'Banido temporariamente'} +

+ )}
) } + diff --git a/frontend/src/components/templates/profile/components/unban-modal.tsx b/frontend/src/components/templates/profile/components/unban-modal.tsx new file mode 100644 index 00000000..14576af2 --- /dev/null +++ b/frontend/src/components/templates/profile/components/unban-modal.tsx @@ -0,0 +1,96 @@ +import { GetUserResult } from '@magic3t/api-types' +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { GiBreakingChain } from 'react-icons/gi' +import { Button } from '@/components/atoms' +import { useDialogStore } from '@/contexts/modal-store' +import { cn } from '@/lib/utils' +import { apiClient } from '@/services/clients/api-client' + +interface UnbanModalProps { + user: GetUserResult +} + +export function UnbanModal({ user }: UnbanModalProps) { + const closeModal = useDialogStore((s) => s.closeModal) + const queryClient = useQueryClient() + + const unbanMutation = useMutation({ + async mutationFn() { + await apiClient.admin.unbanUser(user.id) + }, + onSuccess() { + queryClient.invalidateQueries({ queryKey: ['user-by-nickname'] }) + queryClient.invalidateQueries({ queryKey: ['user-by-id', user.id] }) + closeModal() + }, + }) + + return ( +
+ {/* Decorative corners */} +
+
+
+
+ + + +

+ Desbanir Usuário +

+ +

+ Tem certeza que deseja remover o banimento de{' '} + {user.nickname}? +

+ + {user.ban && ( +
+

+ Motivo: {user.ban.reason} +

+

+ Expira em:{' '} + {user.ban.expiresAt + ? new Date(user.ban.expiresAt).toLocaleString('pt-BR') + : 'Nunca (permanente)'} +

+
+ )} + + {unbanMutation.isError && ( +

+ Ocorreu um erro ao desbanir o usuário. Tente novamente. +

+ )} + +
+ + +
+
+ ) +} diff --git a/frontend/src/components/templates/profile/profile.tsx b/frontend/src/components/templates/profile/profile.tsx index cf1d0600..0b314593 100644 --- a/frontend/src/components/templates/profile/profile.tsx +++ b/frontend/src/components/templates/profile/profile.tsx @@ -1,12 +1,19 @@ import { GetUserResult, ListMatchesResult } from '@magic3t/api-types' +import { UserRole } from '@magic3t/database-types' import { UseQueryResult } from '@tanstack/react-query' +import { GiHandcuffs } from 'react-icons/gi' +import { Button } from '@/components/atoms' import { Panel } from '@/components/ui/panel' +import { AuthState, useAuth } from '@/contexts/auth-context' +import { useDialogStore } from '@/contexts/modal-store' import { useRegisterCommand } from '@/hooks/use-register-command' import { Console } from '@/lib/console' +import { BanModal } from './components/ban-modal' import { MatchHistory } from './components/match-history' import { ProfileHeader } from './components/profile-header' import { ProfileSearch } from './components/profile-search' import { ProfileStats } from './components/profile-stats' +import { UnbanModal } from './components/unban-modal' interface Props { user: GetUserResult @@ -14,6 +21,18 @@ interface Props { } export function ProfileTemplate({ user, matchesQuery }: Props) { + const { state, user: authUser } = useAuth() + const showDialog = useDialogStore((s) => s.showDialog) + + const isCreator = + state === AuthState.SignedIn && authUser?.role === UserRole.Creator + + const isSelf = state === AuthState.SignedIn && authUser?.id === user.id + + const isBanned = + user.ban != null && + (user.ban.expiresAt === null || new Date(user.ban.expiresAt) > new Date()) + // Registers a console command to log the user ID useRegisterCommand( { @@ -35,6 +54,33 @@ export function ProfileTemplate({ user, matchesQuery }: Props) { + + {/* Creator ban/unban controls */} + {isCreator && !isSelf && ( +
+ {isBanned ? ( + + ) : ( + + )} +
+ )} {/* Match History Card */} @@ -45,3 +91,4 @@ export function ProfileTemplate({ user, matchesQuery }: Props) {
) } + diff --git a/frontend/src/services/clients/api-client.ts b/frontend/src/services/clients/api-client.ts index 25f78a6d..8cd20faa 100644 --- a/frontend/src/services/clients/api-client.ts +++ b/frontend/src/services/clients/api-client.ts @@ -1,4 +1,5 @@ import { + BanUserCommand, ChangeIconCommand, ChangeNicknameCommand, CrashReportCommand, @@ -119,10 +120,33 @@ export class QueueApiClient extends BaseApiClient { } } +export class AdminApiClient extends BaseApiClient { + constructor() { + super('admin') + } + + /** + * Bans a user by their user ID. + * @param userId The id of the user to ban + * @param data Ban data: reason and optional duration in seconds (null = permanent) + */ + async banUser(userId: string, data: BanUserCommand): Promise { + await this.post(`ban/${userId}`, data) + } + + /** + * Removes the ban from a user by their user ID. + */ + async unbanUser(userId: string): Promise { + await this.delete(`ban/${userId}`) + } +} + export class ApiClient extends BaseApiClient { public readonly user = new UserApiClient() public readonly match = new MatchApiClient() public readonly queue = new QueueApiClient() + public readonly admin = new AdminApiClient() /** * Gets the status of the API. diff --git a/packages/api-types/src/controllers/admin.ts b/packages/api-types/src/controllers/admin.ts new file mode 100644 index 00000000..a53bffec --- /dev/null +++ b/packages/api-types/src/controllers/admin.ts @@ -0,0 +1,16 @@ +export type BanUserCommand = { + /** + * The reason for the ban. + */ + reason: string + /** + * Duration of the ban in seconds. If null or omitted, the ban is permanent. + */ + duration?: number | null +} + +export type BanInfo = { + reason: string + /** ISO date string of expiry. null means permanent. */ + expiresAt: string | null +} 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/api-types/src/controllers/user.ts b/packages/api-types/src/controllers/user.ts index 1a29f754..b3d22b0d 100644 --- a/packages/api-types/src/controllers/user.ts +++ b/packages/api-types/src/controllers/user.ts @@ -1,5 +1,6 @@ import type { RatingData } from '@magic3t/common-types' import type { UserRole } from '@magic3t/database-types' +import type { BanInfo } from './admin' export type GetUserResult = { id: string @@ -7,6 +8,7 @@ export type GetUserResult = { summonerIcon: number role: UserRole rating: RatingData + ban?: BanInfo stats: { wins: number draws: number diff --git a/packages/database-types/src/rows/user-row.ts b/packages/database-types/src/rows/user-row.ts index 6772212d..29a6ae11 100644 --- a/packages/database-types/src/rows/user-row.ts +++ b/packages/database-types/src/rows/user-row.ts @@ -19,6 +19,16 @@ export const enum UserRole { Bot = 'bot', } +/** Stores ban information for a user */ +export type UserBan = { + /** The reason for the ban */ + reason: string + /** The date when the ban expires. null means the ban is permanent. */ + expiresAt: Date | null + /** The id of the creator who issued the ban */ + bannedBy: string +} + export type UserRow = { identification: { /** Nickname slug used to identify the user uniquely */ @@ -27,6 +37,9 @@ export type UserRow = { last_changed: Date } + /** Ban data. If undefined or null, the user is not banned. */ + ban?: UserBan | null + experience: number /** Credits bought with money */