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
36 changes: 35 additions & 1 deletion backend/src/modules/auth/auth.guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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) {
Expand Down Expand Up @@ -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
}
Expand All @@ -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
}
}
20 changes: 20 additions & 0 deletions backend/src/modules/user/swagger/ban-user-command.ts
Original file line number Diff line number Diff line change
@@ -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
}
11 changes: 1 addition & 10 deletions backend/src/modules/user/swagger/user-commands.ts
Original file line number Diff line number Diff line change
@@ -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()
Expand Down
39 changes: 39 additions & 0 deletions backend/src/modules/user/user.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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',
Expand Down
81 changes: 81 additions & 0 deletions frontend/src/components/templates/profile/profile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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<string | null>(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(
{
Expand All @@ -35,6 +71,51 @@ export function ProfileTemplate({ user, matchesQuery }: Props) {
<ProfileHeader user={user} />
<ProfileStats user={user} />
<ProfileSearch />
{canBan && (
<div className="flex flex-col items-end">
<Dialog open={banOpen} onOpenChange={setBanOpen}>
<DialogTrigger asChild>
<Button variant="destructive" size="sm">Banir usuário</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Banir usuário</DialogTitle>
<DialogDescription>
Escolha o tipo de banimento e, se temporário, defina a data de expiração.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4 mt-2">
<label className="flex gap-2 items-center">
<input type="radio" checked={banType === 'permanent'} onChange={() => setBanType('permanent')} /> Permanente
</label>
<label className="flex gap-2 items-center">
<input type="radio" checked={banType === 'temporary'} onChange={() => setBanType('temporary')} /> Temporário
</label>
{banType === 'temporary' && (
<Input
type="datetime-local"
value={expiresAt}
onChange={e => setExpiresAt(e.target.value)}
placeholder="Data/hora de expiração"
/>
)}
<Input
value={reason}
onChange={e => setReason(e.target.value)}
placeholder="Motivo (opcional)"
/>
{error && <div className="text-red-500 text-sm">{error}</div>}
{success && <div className="text-green-500 text-sm">Usuário banido com sucesso!</div>}
</div>
<DialogFooter>
<Button variant="destructive" onClick={handleBan} disabled={loading}>
{loading ? 'Banindo...' : 'Confirmar Banimento'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)}
</Panel>

{/* Match History Card */}
Expand Down
12 changes: 12 additions & 0 deletions frontend/src/services/clients/ban-user.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
await apiClient.user.post(`${userId}/ban`, payload)
}
1 change: 1 addition & 0 deletions packages/database-types/src/rows/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
9 changes: 9 additions & 0 deletions packages/database-types/src/rows/user-ban.ts
Original file line number Diff line number Diff line change
@@ -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
}
2 changes: 2 additions & 0 deletions packages/database-types/src/rows/user-row.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,4 +47,6 @@ export type UserRow = {
draws: number
defeats: number
}
/** Ban info, if user is banned */
ban?: import('./user-ban').UserBan
}
Loading