Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions backend/src/infra/database/repositories/user/user.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,4 +202,30 @@ export class UserRepository extends BaseFirestoreRepository<UserRow> {
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<void> {
// 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<void> {
// 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.`)
}
}
40 changes: 39 additions & 1 deletion backend/src/modules/admin/admin.controller.ts
Original file line number Diff line number Diff line change
@@ -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)
Expand Down Expand Up @@ -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)
}
}
23 changes: 23 additions & 0 deletions backend/src/modules/admin/swagger/admin-commands.ts
Original file line number Diff line number Diff line change
@@ -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
}
24 changes: 19 additions & 5 deletions backend/src/modules/queue/queue.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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')
Expand Down
8 changes: 8 additions & 0 deletions backend/src/modules/user/user.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,21 @@ export class UserService {
async getUserByRow(row: GetResult<UserRow>): Promise<GetUserResult> {
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,
nickname: row.data.identification.nickname,
summonerIcon: row.data.summoner_icon,
stats: row.data.stats,
rating: rating.ratingData,
ban,
}
}

Expand Down
177 changes: 177 additions & 0 deletions frontend/src/components/templates/profile/components/ban-modal.tsx
Original file line number Diff line number Diff line change
@@ -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<number | null | 'custom'>(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 (
<div
className={cn(
'relative flex flex-col items-center gap-6 p-8 min-w-80 w-full max-w-md',
'rounded-lg border-2 border-gold-5/50',
'bg-linear-to-b from-grey-3/85 to-grey-3/75',
'backdrop-blur-xl shadow-2xl'
)}
>
{/* Decorative corners */}
<div className="absolute -top-1 -left-1 w-6 h-6 border-t-2 border-l-2 border-gold-5/60" />
<div className="absolute -top-1 -right-1 w-6 h-6 border-t-2 border-r-2 border-gold-5/60" />
<div className="absolute -bottom-1 -left-1 w-6 h-6 border-b-2 border-l-2 border-gold-5/60" />
<div className="absolute -bottom-1 -right-1 w-6 h-6 border-b-2 border-r-2 border-gold-5/60" />

<GiHandcuffs size={52} className="text-gold-4" />

<h2 className="font-serif font-bold text-2xl text-gold-1 uppercase tracking-wider">
Banir Usuário
</h2>

<p className="text-grey-1 text-center text-sm">
Você está prestes a banir{' '}
<span className="text-gold-2 font-semibold">{user.nickname}</span>{' '}
{durationLabel}.
</p>

{/* Reason */}
<div className="w-full space-y-2">
<Label htmlFor="ban-reason" className="text-gold-3 text-sm font-semibold">
Motivo *
</Label>
<Input
id="ban-reason"
placeholder="Informe o motivo do banimento..."
value={reason}
error={reasonError}
onChange={(e) => {
setReason(e.target.value)
if (reasonError) setReasonError(false)
}}
/>
{reasonError && (
<p className="text-red-400 text-xs">O motivo é obrigatório.</p>
)}
</div>

{/* Duration */}
<div className="w-full space-y-2">
<Label className="text-gold-3 text-sm font-semibold">Duração</Label>
<div className="grid grid-cols-2 gap-2">
{DURATION_OPTIONS.map((opt) => (
<button
key={String(opt.value)}
type="button"
onClick={() => setSelectedDuration(opt.value)}
className={cn(
'px-3 py-2 rounded border-2 text-sm font-semibold transition-all duration-200',
selectedDuration === opt.value
? 'border-gold-4 bg-gold-6/30 text-gold-1'
: 'border-grey-1/20 bg-hextech-black/40 text-grey-1 hover:border-grey-1/40'
)}
>
{opt.label}
</button>
))}
</div>
{selectedDuration === 'custom' && (
<div className="flex items-center gap-2 mt-2">
<Input
type="number"
min={1}
placeholder="Número de dias"
value={customDays}
onChange={(e) => setCustomDays(e.target.value)}
className="flex-1"
/>
<span className="text-grey-1 text-sm whitespace-nowrap">dias</span>
</div>
)}
</div>

{banMutation.isError && (
<p className="text-red-400 text-sm text-center">
Ocorreu um erro ao banir o usuário. Tente novamente.
</p>
)}

<div className="flex gap-4 w-full pt-2">
<Button
variant="secondary"
size="md"
onClick={closeModal}
className="flex-1"
disabled={banMutation.isPending}
>
Cancelar
</Button>
<Button
variant="destructive"
size="md"
onClick={handleBan}
className="flex-1"
disabled={banMutation.isPending}
>
{banMutation.isPending ? 'Banindo...' : 'Banir'}
</Button>
</div>
</div>
)
}
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -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 (
<div className="flex flex-col items-center gap-4">
<div className="flex flex-col items-center gap-6">
Expand Down Expand Up @@ -36,9 +46,20 @@ export function ProfileHeader({ user }: ProfileHeaderProps) {
<h1 className="font-serif font-bold text-3xl sm:text-4xl text-gold-1 tracking-wide">
{user.nickname}
</h1>
{isBanned && (
<Tooltip text={banLabel}>
<GiChainedHeart className="text-red-400 size-7" />
</Tooltip>
)}
</div>
{isBanned && (
<p className="text-red-400 text-sm font-semibold uppercase tracking-wider">
{user.ban!.expiresAt === null ? 'Banido permanentemente' : 'Banido temporariamente'}
</p>
)}
</div>
</div>
</div>
)
}

Loading
Loading