From 4e8f4daf778071a23a6304c675c47ef837704371 Mon Sep 17 00:00:00 2001 From: sammid37 Date: Sun, 8 Mar 2026 16:45:37 -0300 Subject: [PATCH 1/5] Add roles to users --- backend/src/users/entities/user.entity.ts | 9 +++++++++ backend/src/users/enums/user-role.enum.ts | 4 ++++ 2 files changed, 13 insertions(+) create mode 100644 backend/src/users/enums/user-role.enum.ts diff --git a/backend/src/users/entities/user.entity.ts b/backend/src/users/entities/user.entity.ts index c2d9f2f..8fefeee 100644 --- a/backend/src/users/entities/user.entity.ts +++ b/backend/src/users/entities/user.entity.ts @@ -6,6 +6,8 @@ import { OneToMany, } from 'typeorm'; +import { UserRole } from '../enums/user-role.enum'; + @Entity('users') export class User { @PrimaryGeneratedColumn('uuid') @@ -21,6 +23,13 @@ export class User { @Column({ select: false }) password: string; + @Column({ + type: 'enum', + enum: UserRole, + default: UserRole.TRAINER, + }) + role: UserRole; + @CreateDateColumn() createdAt: Date; } diff --git a/backend/src/users/enums/user-role.enum.ts b/backend/src/users/enums/user-role.enum.ts new file mode 100644 index 0000000..edd4cc2 --- /dev/null +++ b/backend/src/users/enums/user-role.enum.ts @@ -0,0 +1,4 @@ +export enum UserRole { + TRAINER = 'trainer', + NURSE = 'nurse', +} \ No newline at end of file From 82f2f1c3e58207f75f28497da2b18ebf3c4540d0 Mon Sep 17 00:00:00 2001 From: sammid37 Date: Sun, 8 Mar 2026 16:48:03 -0300 Subject: [PATCH 2/5] Update auth module to include role validation --- backend/src/auth/auth.service.ts | 9 ++++--- .../src/auth/decorators/roles.decorator.ts | 8 ++++++ backend/src/auth/dto/register.dto.ts | 14 +++++++++- backend/src/auth/guards/roles.guard.ts | 26 +++++++++++++++++++ backend/src/auth/strategies/jwt.strategy.ts | 4 +-- 5 files changed, 54 insertions(+), 7 deletions(-) create mode 100644 backend/src/auth/decorators/roles.decorator.ts create mode 100644 backend/src/auth/guards/roles.guard.ts diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts index ee0552a..f1fdd4e 100644 --- a/backend/src/auth/auth.service.ts +++ b/backend/src/auth/auth.service.ts @@ -19,9 +19,10 @@ export class AuthService { name: dto.name, email: dto.email, password: hashedPassword, + role: dto.role, }); - return this.generateToken(user.id, user.email); + return this.generateToken(user.id, user.email, user.role); } async login(dto: LoginDto) { @@ -38,11 +39,11 @@ export class AuthService { throw new UnauthorizedException('Credenciais inválidas'); } - return this.generateToken(user.id, user.email); + return this.generateToken(user.id, user.email, user.role); } - private generateToken(userId: string, email: string) { - const payload = { sub: userId, email }; + private generateToken(userId: string, email: string, role: string) { + const payload = { sub: userId, email, role }; return { access_token: this.jwtService.sign(payload), }; diff --git a/backend/src/auth/decorators/roles.decorator.ts b/backend/src/auth/decorators/roles.decorator.ts new file mode 100644 index 0000000..e5c5b66 --- /dev/null +++ b/backend/src/auth/decorators/roles.decorator.ts @@ -0,0 +1,8 @@ +import { SetMetadata } from '@nestjs/common'; +import { UserRole } from '../../users/enums/user-role.enum'; + +export const ROLES_KEY = 'roles'; + +// Decorator para definir os roles permitidos em uma rota +// Exemplo de uso: @Roles(UserRole.NURSE) +export const Roles = (...roles: UserRole[]) => SetMetadata(ROLES_KEY, roles); \ No newline at end of file diff --git a/backend/src/auth/dto/register.dto.ts b/backend/src/auth/dto/register.dto.ts index 463827b..2ab4ef2 100644 --- a/backend/src/auth/dto/register.dto.ts +++ b/backend/src/auth/dto/register.dto.ts @@ -1,4 +1,6 @@ -import { IsEmail, IsString, MinLength } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger/dist/decorators/api-property.decorator'; +import { IsEmail, IsEnum, IsOptional, IsString, MinLength } from 'class-validator'; +import { UserRole } from 'src/users/enums/user-role.enum'; export class RegisterDto { @IsString() @@ -10,4 +12,14 @@ export class RegisterDto { @IsString() @MinLength(6, { message: 'A senha deve ter no mínimo 6 caracteres' }) password: string; + + @ApiProperty({ + enum: UserRole, + example: UserRole.TRAINER, + required: false, + description: 'Padrão: trainer', + }) + @IsEnum(UserRole) + @IsOptional() + role?: UserRole; } diff --git a/backend/src/auth/guards/roles.guard.ts b/backend/src/auth/guards/roles.guard.ts new file mode 100644 index 0000000..c2472ba --- /dev/null +++ b/backend/src/auth/guards/roles.guard.ts @@ -0,0 +1,26 @@ +import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { UserRole } from '../../users/enums/user-role.enum'; +import { ROLES_KEY } from '../decorators/roles.decorator'; + +// Guard que verifica se o usuário tem o role necessário para acessar a rota +@Injectable() +export class RolesGuard implements CanActivate { + constructor(private readonly reflector: Reflector) {} + + canActivate(context: ExecutionContext): boolean { + // Busca os roles exigidos pela rota via decorator @Roles() + const requiredRoles = this.reflector.getAllAndOverride( + ROLES_KEY, + [context.getHandler(), context.getClass()], + ); + + // Se a rota não exige roles específicos, libera o acesso + if (!requiredRoles || requiredRoles.length === 0) { + return true; + } + + const { user } = context.switchToHttp().getRequest(); + return requiredRoles.includes(user.role); + } +} \ No newline at end of file diff --git a/backend/src/auth/strategies/jwt.strategy.ts b/backend/src/auth/strategies/jwt.strategy.ts index 962d163..c3c9928 100644 --- a/backend/src/auth/strategies/jwt.strategy.ts +++ b/backend/src/auth/strategies/jwt.strategy.ts @@ -16,7 +16,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) { }); } - async validate(payload: { sub: string; email: string }) { - return { id: payload.sub, email: payload.email }; + async validate(payload: { sub: string; email: string; role: string }) { + return { id: payload.sub, email: payload.email, role: payload.role }; } } From afaf9a2d1d745d081d12e4e71d7b9d879874c2a7 Mon Sep 17 00:00:00 2001 From: sammid37 Date: Sun, 8 Mar 2026 16:52:01 -0300 Subject: [PATCH 3/5] Add roles to pokemon controller --- backend/src/pokemons/pokemons.controller.ts | 123 +++++++++----------- 1 file changed, 57 insertions(+), 66 deletions(-) diff --git a/backend/src/pokemons/pokemons.controller.ts b/backend/src/pokemons/pokemons.controller.ts index 61440a7..efb6af8 100644 --- a/backend/src/pokemons/pokemons.controller.ts +++ b/backend/src/pokemons/pokemons.controller.ts @@ -1,84 +1,75 @@ import { - Controller, - Get, - Post, - Put, - Delete, - Body, - Param, - UseGuards, - Request, + Controller, + Get, + Post, + Put, + Delete, + Body, + Param, + UseGuards, + Request, } from '@nestjs/common'; import { - ApiTags, - ApiOperation, - ApiResponse, - ApiBearerAuth, + ApiTags, + ApiOperation, + ApiResponse, + ApiBearerAuth, } from '@nestjs/swagger'; import { PokemonsService } from './pokemons.service'; import { CreatePokemonDto } from './dto/create-pokemon.dto'; import { UpdatePokemonDto } from './dto/update-pokemon.dto'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../auth/guards/roles.guard'; +import { Roles } from '../auth/decorators/roles.decorator'; +import { UserRole } from '../users/enums/user-role.enum'; @ApiTags('Pokémons') @ApiBearerAuth() -@UseGuards(JwtAuthGuard) +@UseGuards(JwtAuthGuard, RolesGuard) @Controller('pokemons') export class PokemonsController { - constructor(private readonly pokemonsService: PokemonsService) {} + constructor(private readonly pokemonsService: PokemonsService) {} - @Get() - @ApiOperation({ summary: 'Listar todos os pokémons' }) - @ApiResponse({ - status: 200, - description: 'Lista de pokémons retornada com sucesso', - }) - findAll(@Request() req) { - // TODO: depois validar se o tipo de usuário é do tipo treinador ou enfermeira Joy - return this.pokemonsService.findAll(req.user.id); - } + @Get() + @ApiOperation({ summary: 'Listar pokémons' }) + @ApiResponse({ status: 200, description: 'Lista retornada com sucesso' }) + findAll(@Request() req) { + const isNurse = req.user.role === UserRole.NURSE; + return this.pokemonsService.findAll(isNurse ? undefined : req.user.id); + } - @Get(':id') - @ApiOperation({ summary: 'Buscar um pokémon pelo ID' }) - @ApiResponse({ status: 200, description: 'Pokémon encontrado' }) - @ApiResponse({ status: 404, description: 'Pokémon não encontrado' }) - findOne(@Param('id') id: string) { - return this.pokemonsService.findOne(id); - } + @Get(':id') + @ApiOperation({ summary: 'Buscar um pokémon pelo ID' }) + @ApiResponse({ status: 200, description: 'Pokémon encontrado' }) + @ApiResponse({ status: 404, description: 'Pokémon não encontrado' }) + findOne(@Param('id') id: string) { + return this.pokemonsService.findOne(id); + } - @Post() - @ApiOperation({ summary: 'Cadastrar novo pokémon' }) - @ApiResponse({ status: 201, description: 'Pokémon cadastrado com sucesso' }) - @ApiResponse({ status: 400, description: 'Dados inválidos' }) - create(@Body() dto: CreatePokemonDto, @Request() req) { - return this.pokemonsService.create(dto, req.user.id); - } + @Post() + @Roles(UserRole.TRAINER) + @ApiOperation({ summary: 'Cadastrar novo pokémon' }) + @ApiResponse({ status: 201, description: 'Pokémon cadastrado com sucesso' }) + @ApiResponse({ status: 403, description: 'Apenas treinadores podem cadastrar pokémons' }) + create(@Body() dto: CreatePokemonDto, @Request() req) { + return this.pokemonsService.create(dto, req.user.id); + } - @Put(':id') - @ApiOperation({ summary: 'Atualizar pokémon — apenas o dono pode editar' }) - @ApiResponse({ status: 200, description: 'Pokémon atualizado com sucesso' }) - @ApiResponse({ - status: 403, - description: 'Sem permissão para editar este pokémon', - }) - @ApiResponse({ status: 404, description: 'Pokémon não encontrado' }) - update( - @Param('id') id: string, - @Body() dto: UpdatePokemonDto, - @Request() req, - ) { - return this.pokemonsService.update(id, dto, req.user.id); - } + @Put(':id') + @Roles(UserRole.TRAINER) + @ApiOperation({ summary: 'Atualizar pokémon (apenas o dono pode editar)' }) + @ApiResponse({ status: 200, description: 'Pokémon atualizado com sucesso' }) + @ApiResponse({ status: 403, description: 'Sem permissão para editar este pokémon' }) + update(@Param('id') id: string, @Body() dto: UpdatePokemonDto, @Request() req) { + return this.pokemonsService.update(id, dto, req.user.id); + } - @Delete(':id') - @ApiOperation({ summary: 'Excluir pokémon — apenas o dono pode excluir' }) - @ApiResponse({ status: 200, description: 'Pokémon excluído com sucesso' }) - @ApiResponse({ - status: 403, - description: 'Sem permissão para excluir este pokémon', - }) - @ApiResponse({ status: 404, description: 'Pokémon não encontrado' }) - remove(@Param('id') id: string, @Request() req) { - return this.pokemonsService.remove(id, req.user.id); - } -} + @Delete(':id') + @Roles(UserRole.TRAINER) + @ApiOperation({ summary: 'Excluir pokémon (apenas o dono pode excluir)' }) + @ApiResponse({ status: 200, description: 'Pokémon excluído com sucesso' }) + @ApiResponse({ status: 403, description: 'Sem permissão para excluir este pokémon' }) + remove(@Param('id') id: string, @Request() req) { + return this.pokemonsService.remove(id, req.user.id); + } +} \ No newline at end of file From 73a964779e4bda7182fed7ec424d3dd43754f3d8 Mon Sep 17 00:00:00 2001 From: sammid37 Date: Sun, 8 Mar 2026 16:52:33 -0300 Subject: [PATCH 4/5] Add roles to users --- frontend/src/types/index.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index bb43760..f65bf49 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -35,10 +35,16 @@ export enum PokemonHealthStatus { POISONED = 'Poisoned', } +export enum UserRole { + TRAINER = 'trainer', + NURSE = 'nurse', +} + export interface User { id: string; name: string; email: string; + role: UserRole; } export interface Pokemon { From 44ad25619171d407a4eb78981c2ceac931a8d38a Mon Sep 17 00:00:00 2001 From: sammid37 Date: Sun, 8 Mar 2026 16:53:35 -0300 Subject: [PATCH 5/5] Updates components and pages to include views according to the role --- frontend/src/app/dashboard/page.tsx | 55 +++++++++++++------ frontend/src/components/auth/RegisterForm.tsx | 44 +++++++++------ .../src/components/pokemon/PokemonCard.tsx | 15 +++-- frontend/src/components/ui/Navbar.tsx | 28 ++++++++-- frontend/src/services/auth.service.ts | 1 + 5 files changed, 96 insertions(+), 47 deletions(-) diff --git a/frontend/src/app/dashboard/page.tsx b/frontend/src/app/dashboard/page.tsx index 336c165..63ea382 100644 --- a/frontend/src/app/dashboard/page.tsx +++ b/frontend/src/app/dashboard/page.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'; import { useRouter } from 'next/navigation'; -import { Pokemon, User, CreatePokemonDto, UpdatePokemonDto } from '@/types'; +import { Pokemon, User, UserRole, CreatePokemonDto, UpdatePokemonDto } from '@/types'; import { pokemonService } from '@/services/pokemon.service'; import { authService } from '@/services/auth.service'; import Navbar from '@/components/ui/Navbar'; @@ -26,7 +26,8 @@ export default function DashboardPage() { const [deleteTarget, setDeleteTarget] = useState(null); const [error, setError] = useState(null); - // Carrega os pokémons e o usuário logado ao montar o componente + const isNurse = currentUser?.role === UserRole.NURSE; + useEffect(() => { async function loadData() { try { @@ -62,13 +63,12 @@ export default function DashboardPage() { } async function handleEdit(data: CreatePokemonDto | UpdatePokemonDto) { - if (modal.open && modal.mode !== 'edit') return; - const pokemon = (modal as { open: true; mode: 'edit'; pokemon: Pokemon }).pokemon; + if (!modal.open || modal.mode !== 'edit') return; setIsSubmitting(true); setError(null); try { - const updated = await pokemonService.update(pokemon.id, data); + const updated = await pokemonService.update(modal.pokemon.id, data); setPokemons((prev) => prev.map((p) => (p.id === updated.id ? updated : p))); setModal({ open: false }); } catch (err: any) { @@ -97,39 +97,58 @@ export default function DashboardPage() { return (
- +
{/* Header */}
-

Pokédex

+

+ {isNurse ? '🏥 Pokémons no Centro' : '🎒 Meus Pokémons'} +

- {pokemons.length} pokémon{pokemons.length !== 1 ? 's' : ''} cadastrado{pokemons.length !== 1 ? 's' : ''} + {isNurse + ? `${pokemons.length} pokémon${pokemons.length !== 1 ? 's' : ''} sob cuidados` + : `${pokemons.length} pokémon${pokemons.length !== 1 ? 's' : ''} cadastrado${pokemons.length !== 1 ? 's' : ''}` + }

- + + {/* Botão de novo pokémon — apenas para treinadores */} + {!isNurse && ( + + )}
{/* Alerta de erro */} {error && (
{error} - +
)} {/* Lista de pokémons */} {pokemons.length === 0 ? (
-

Nenhum pokémon cadastrado ainda.

-

Clique em "Novo Pokémon" para começar!

+

+ {isNurse + ? 'Nenhum pokémon no centro no momento.' + : 'Nenhum pokémon cadastrado ainda.'} +

+ {!isNurse && ( +

+ Clique em "Novo Pokémon" para começar! +

+ )}
) : (
@@ -137,7 +156,7 @@ export default function DashboardPage() { setModal({ open: true, mode: 'edit', pokemon: p })} onDelete={setDeleteTarget} /> diff --git a/frontend/src/components/auth/RegisterForm.tsx b/frontend/src/components/auth/RegisterForm.tsx index 7fc4e56..cea8564 100644 --- a/frontend/src/components/auth/RegisterForm.tsx +++ b/frontend/src/components/auth/RegisterForm.tsx @@ -3,8 +3,8 @@ import { useState } from 'react'; import { useRouter } from 'next/navigation'; import Link from 'next/link'; -import { AxiosError } from 'axios'; import { authService } from '@/services/auth.service'; +import { UserRole } from '@/types'; export default function RegisterForm() { const router = useRouter(); @@ -14,11 +14,14 @@ export default function RegisterForm() { email: '', password: '', confirmPassword: '', + role: UserRole.TRAINER, }); const [error, setError] = useState(null); const [isLoading, setIsLoading] = useState(false); - function handleChange(e: React.ChangeEvent) { + function handleChange( + e: React.ChangeEvent, + ) { setFormData((prev) => ({ ...prev, [e.target.name]: e.target.value })); setError(null); } @@ -28,7 +31,6 @@ export default function RegisterForm() { setIsLoading(true); setError(null); - // Validação de confirmação de senha no frontend if (formData.password !== formData.confirmPassword) { setError('As senhas não coincidem'); setIsLoading(false); @@ -40,16 +42,13 @@ export default function RegisterForm() { name: formData.name, email: formData.email, password: formData.password, + role: formData.role, }); - // Salva o token também como cookie para o middleware conseguir ler document.cookie = `access_token=${localStorage.getItem('access_token')}; path=/`; - router.push('/dashboard'); - } catch (err: unknown) { - if (err instanceof AxiosError) { - setError(err.response?.data?.message ?? 'Erro ao realizar cadastro'); - } + } catch (err: any) { + setError(err.response?.data?.message ?? 'Erro ao realizar cadastro'); } finally { setIsLoading(false); } @@ -58,14 +57,13 @@ export default function RegisterForm() { return (
- {/* Alerta de erro */} {error && (
{error}
)} - {/* Campo nome */} + {/* Nome */}