From 9cd180bf6a9be6d41a671188979891d3e84156b0 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Thu, 10 Sep 2026 22:54:49 +0200 Subject: [PATCH] =?UTF-8?q?feat(#161):=20admin=20peut=20cr=C3=A9er=20gesti?= =?UTF-8?q?onnaire=20et=20administrateur.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assouplit POST /gestionnaires, PATCH /gestionnaires/:id et POST /users/admin (+ check createAdmin) pour ADMINISTRATEUR. Masque « Ajouter » staff sur le dashboard gestionnaire. Tests droits Roles + createAdmin. Co-authored-by: Cursor --- .../gestionnaires.controller.spec.ts | 29 ++++--- .../gestionnaires/gestionnaires.controller.ts | 10 +-- .../src/routes/user/user.controller.spec.ts | 25 ++---- backend/src/routes/user/user.controller.ts | 6 +- backend/src/routes/user/user.service.spec.ts | 87 +++++++++++++++++-- backend/src/routes/user/user.service.ts | 10 ++- .../gestionnaire_dashboard_screen.dart | 5 +- frontend/lib/utils/staff_deletion_rights.dart | 6 ++ .../widgets/admin/user_management_panel.dart | 19 +++- 9 files changed, 146 insertions(+), 51 deletions(-) diff --git a/backend/src/routes/user/gestionnaires/gestionnaires.controller.spec.ts b/backend/src/routes/user/gestionnaires/gestionnaires.controller.spec.ts index 6dee426..f572a58 100644 --- a/backend/src/routes/user/gestionnaires/gestionnaires.controller.spec.ts +++ b/backend/src/routes/user/gestionnaires/gestionnaires.controller.spec.ts @@ -1,20 +1,21 @@ -import { Test, TestingModule } from '@nestjs/testing'; +import 'reflect-metadata'; import { GestionnairesController } from './gestionnaires.controller'; -import { GestionnairesService } from './gestionnaires.service'; +import { RoleType } from 'src/entities/users.entity'; -describe('GestionnairesController', () => { - let controller: GestionnairesController; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - controllers: [GestionnairesController], - providers: [GestionnairesService], - }).compile(); - - controller = module.get(GestionnairesController); +describe('GestionnairesController roles (#161)', () => { + it('POST /gestionnaires autorise SUPER_ADMIN et ADMINISTRATEUR', () => { + const roles = Reflect.getMetadata('roles', GestionnairesController.prototype.create); + expect(roles).toEqual( + expect.arrayContaining([RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR]), + ); + expect(roles).not.toContain(RoleType.GESTIONNAIRE); }); - it('should be defined', () => { - expect(controller).toBeDefined(); + it('PATCH /gestionnaires/:id autorise SUPER_ADMIN et ADMINISTRATEUR', () => { + const roles = Reflect.getMetadata('roles', GestionnairesController.prototype.update); + expect(roles).toEqual( + expect.arrayContaining([RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR]), + ); + expect(roles).not.toContain(RoleType.GESTIONNAIRE); }); }); diff --git a/backend/src/routes/user/gestionnaires/gestionnaires.controller.ts b/backend/src/routes/user/gestionnaires/gestionnaires.controller.ts index 8fd23c6..cc0fe62 100644 --- a/backend/src/routes/user/gestionnaires/gestionnaires.controller.ts +++ b/backend/src/routes/user/gestionnaires/gestionnaires.controller.ts @@ -25,10 +25,10 @@ import { AuthGuard } from 'src/common/guards/auth.guard'; export class GestionnairesController { constructor(private readonly gestionnairesService: GestionnairesService) { } - @Roles(RoleType.SUPER_ADMIN) + @Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR) @ApiResponse({ status: 201, description: 'Le gestionnaire a été créé avec succès.', type: Users }) @ApiResponse({ status: 409, description: 'Conflit. L\'email est déjà utilisé.' }) - @ApiOperation({ summary: 'Création d\'un gestionnaire' }) + @ApiOperation({ summary: 'Création d\'un gestionnaire (admin / super admin)' }) @ApiBody({ type: CreateGestionnaireDto }) @Post() create(@Body() dto: CreateGestionnaireDto): Promise { @@ -43,7 +43,7 @@ export class GestionnairesController { return this.gestionnairesService.findAll(); } - @Roles(RoleType.GESTIONNAIRE, RoleType.SUPER_ADMIN) + @Roles(RoleType.GESTIONNAIRE, RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR) @ApiOperation({ summary: 'Récupérer un gestionnaire par ID' }) @ApiResponse({ status: 400, description: 'ID invalide' }) @ApiResponse({ status: 403, description: 'Accès refusé' }) @@ -56,8 +56,8 @@ export class GestionnairesController { return this.gestionnairesService.findOne(id); } - @Roles(RoleType.SUPER_ADMIN) - @ApiOperation({ summary: 'Mettre à jour un gestionnaire' }) + @Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR) + @ApiOperation({ summary: 'Mettre à jour un gestionnaire (admin / super admin)' }) @ApiResponse({ status: 200, description: 'Le gestionnaire a été mis à jour avec succès.', type: Users }) @ApiResponse({ status: 404, description: 'Gestionnaire non trouvé' }) @ApiResponse({ status: 403, description: 'Accès refusé' }) diff --git a/backend/src/routes/user/user.controller.spec.ts b/backend/src/routes/user/user.controller.spec.ts index 1f38440..c630d2a 100644 --- a/backend/src/routes/user/user.controller.spec.ts +++ b/backend/src/routes/user/user.controller.spec.ts @@ -1,20 +1,13 @@ -import { Test, TestingModule } from '@nestjs/testing'; +import 'reflect-metadata'; import { UserController } from './user.controller'; -import { UserService } from './user.service'; +import { RoleType } from 'src/entities/users.entity'; -describe('UserController', () => { - let controller: UserController; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - controllers: [UserController], - providers: [UserService], - }).compile(); - - controller = module.get(UserController); - }); - - it('should be defined', () => { - expect(controller).toBeDefined(); +describe('UserController roles (#161)', () => { + it('POST /users/admin autorise SUPER_ADMIN et ADMINISTRATEUR', () => { + const roles = Reflect.getMetadata('roles', UserController.prototype.createAdmin); + expect(roles).toEqual( + expect.arrayContaining([RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR]), + ); + expect(roles).not.toContain(RoleType.GESTIONNAIRE); }); }); diff --git a/backend/src/routes/user/user.controller.ts b/backend/src/routes/user/user.controller.ts index c419ac2..72f7f70 100644 --- a/backend/src/routes/user/user.controller.ts +++ b/backend/src/routes/user/user.controller.ts @@ -22,10 +22,10 @@ export class UserController { private readonly suppressionService: SuppressionService, ) { } - // Création d'un administrateur (réservée aux super admins) + // Création d'un administrateur (admin + super admin) — #161 @Post('admin') - @Roles(RoleType.SUPER_ADMIN) - @ApiOperation({ summary: 'Créer un nouvel administrateur (super admin seulement)' }) + @Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR) + @ApiOperation({ summary: 'Créer un nouvel administrateur (admin / super admin)' }) createAdmin( @Body() dto: CreateAdminDto, @User() currentUser: Users diff --git a/backend/src/routes/user/user.service.spec.ts b/backend/src/routes/user/user.service.spec.ts index 873de8a..81b4551 100644 --- a/backend/src/routes/user/user.service.spec.ts +++ b/backend/src/routes/user/user.service.spec.ts @@ -1,18 +1,87 @@ -import { Test, TestingModule } from '@nestjs/testing'; +import { BadRequestException, ForbiddenException } from '@nestjs/common'; import { UserService } from './user.service'; +import { RoleType, StatutUtilisateurType } from 'src/entities/users.entity'; + +describe('UserService.createAdmin (#161)', () => { + const usersRepository = { + findOneBy: jest.fn(), + create: jest.fn(), + save: jest.fn(), + }; -describe('UserService', () => { let service: UserService; - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [UserService], - }).compile(); + const dto = { + email: 'nouveau.admin@ptits-pas.fr', + password: 'Password1!', + prenom: 'Nina', + nom: 'Admin', + telephone: '0601020304', + }; - service = module.get(UserService); + beforeEach(() => { + jest.clearAllMocks(); + service = new UserService( + usersRepository as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + ); }); - it('should be defined', () => { - expect(service).toBeDefined(); + it('autorise un administrateur à créer un admin', async () => { + usersRepository.findOneBy.mockResolvedValue(null); + usersRepository.create.mockImplementation((data) => data); + usersRepository.save.mockImplementation(async (entity) => ({ + id: 'new-admin', + ...entity, + })); + + const result = await service.createAdmin(dto as never, { + id: 'admin-1', + role: RoleType.ADMINISTRATEUR, + } as never); + + expect(result.role).toBe(RoleType.ADMINISTRATEUR); + expect(result.statut).toBe(StatutUtilisateurType.ACTIF); + expect(usersRepository.save).toHaveBeenCalled(); + }); + + it('autorise un super_admin à créer un admin', async () => { + usersRepository.findOneBy.mockResolvedValue(null); + usersRepository.create.mockImplementation((data) => data); + usersRepository.save.mockImplementation(async (entity) => ({ + id: 'new-admin', + ...entity, + })); + + await expect( + service.createAdmin(dto as never, { + id: 'sa-1', + role: RoleType.SUPER_ADMIN, + } as never), + ).resolves.toMatchObject({ role: RoleType.ADMINISTRATEUR }); + }); + + it('refuse un gestionnaire (403 métier)', async () => { + await expect( + service.createAdmin(dto as never, { + id: 'gest-1', + role: RoleType.GESTIONNAIRE, + } as never), + ).rejects.toBeInstanceOf(ForbiddenException); + expect(usersRepository.save).not.toHaveBeenCalled(); + }); + + it('refuse un email déjà utilisé', async () => { + usersRepository.findOneBy.mockResolvedValue({ id: 'exists' }); + await expect( + service.createAdmin(dto as never, { + id: 'admin-1', + role: RoleType.ADMINISTRATEUR, + } as never), + ).rejects.toBeInstanceOf(BadRequestException); }); }); diff --git a/backend/src/routes/user/user.service.ts b/backend/src/routes/user/user.service.ts index 50a0c52..c10d96a 100644 --- a/backend/src/routes/user/user.service.ts +++ b/backend/src/routes/user/user.service.ts @@ -117,8 +117,14 @@ export class UserService { } async createAdmin(dto: CreateAdminDto, currentUser: Users): Promise { - if (currentUser.role !== RoleType.SUPER_ADMIN) { - throw new ForbiddenException('Seuls les super administrateurs peuvent créer un administrateur'); + // #161 — admin et super_admin peuvent créer un administrateur + if ( + currentUser.role !== RoleType.SUPER_ADMIN && + currentUser.role !== RoleType.ADMINISTRATEUR + ) { + throw new ForbiddenException( + 'Seuls les administrateurs et super administrateurs peuvent créer un administrateur', + ); } const exist = await this.usersRepository.findOneBy({ email: dto.email }); diff --git a/frontend/lib/screens/gestionnaire/gestionnaire_dashboard_screen.dart b/frontend/lib/screens/gestionnaire/gestionnaire_dashboard_screen.dart index 921ad0f..2b2ccf5 100644 --- a/frontend/lib/screens/gestionnaire/gestionnaire_dashboard_screen.dart +++ b/frontend/lib/screens/gestionnaire/gestionnaire_dashboard_screen.dart @@ -62,7 +62,10 @@ class _GestionnaireDashboardScreenState extends State { bool get _isDossiersTab => _subIndex == 0; + bool get _isStaffAccountsTab => + _subIndex == 4 || (widget.showAdministrateursTab && _subIndex == 5); + + bool get _canShowAddButton { + if (_isDossiersTab) return false; + if (_isStaffAccountsTab && !widget.allowStaffAccountCreation) return false; + return true; + } + String _searchHintForTab() { switch (_subIndex) { case 0: @@ -293,7 +306,8 @@ class _UserManagementPanelState extends State { searchTooltip: _searchTooltipForTab(), filterControl: _subBarFilterControl(), // Pas de « Créer » sur l’onglet Dossiers (#153). - onAddPressed: _isDossiersTab ? null : _handleAddPressed, + // Pas de création staff pour le dashboard gestionnaire (#161). + onAddPressed: _canShowAddButton ? _handleAddPressed : null, addLabel: 'Ajouter', subTabCount: labels.length, tabLabels: labels, @@ -305,6 +319,9 @@ class _UserManagementPanelState extends State { Future _handleAddPressed() async { // 1 Parents, 2 Enfants, 3 AM, 4 Gestionnaires, 5 Admin + if (_isStaffAccountsTab && !widget.allowStaffAccountCreation) { + return; + } if (_subIndex == 1) { await showDialog( context: context,