Compare commits

...
Author SHA1 Message Date
jmartinandCursor 9cd180bf6a feat(#161): admin peut créer gestionnaire et administrateur.
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 <cursoragent@cursor.com>
2026-09-10 22:54:49 +02:00
jmartin ca07d2e111 merge(#160): suppressions dashboard front + API #159. 2026-09-10 22:41:02 +02:00
9 changed files with 146 additions and 51 deletions
@@ -1,20 +1,21 @@
import { Test, TestingModule } from '@nestjs/testing'; import 'reflect-metadata';
import { GestionnairesController } from './gestionnaires.controller'; import { GestionnairesController } from './gestionnaires.controller';
import { GestionnairesService } from './gestionnaires.service'; import { RoleType } from 'src/entities/users.entity';
describe('GestionnairesController', () => { describe('GestionnairesController roles (#161)', () => {
let controller: GestionnairesController; it('POST /gestionnaires autorise SUPER_ADMIN et ADMINISTRATEUR', () => {
const roles = Reflect.getMetadata('roles', GestionnairesController.prototype.create);
beforeEach(async () => { expect(roles).toEqual(
const module: TestingModule = await Test.createTestingModule({ expect.arrayContaining([RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR]),
controllers: [GestionnairesController], );
providers: [GestionnairesService], expect(roles).not.toContain(RoleType.GESTIONNAIRE);
}).compile();
controller = module.get<GestionnairesController>(GestionnairesController);
}); });
it('should be defined', () => { it('PATCH /gestionnaires/:id autorise SUPER_ADMIN et ADMINISTRATEUR', () => {
expect(controller).toBeDefined(); const roles = Reflect.getMetadata('roles', GestionnairesController.prototype.update);
expect(roles).toEqual(
expect.arrayContaining([RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR]),
);
expect(roles).not.toContain(RoleType.GESTIONNAIRE);
}); });
}); });
@@ -25,10 +25,10 @@ import { AuthGuard } from 'src/common/guards/auth.guard';
export class GestionnairesController { export class GestionnairesController {
constructor(private readonly gestionnairesService: GestionnairesService) { } 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: 201, description: 'Le gestionnaire a été créé avec succès.', type: Users })
@ApiResponse({ status: 409, description: 'Conflit. L\'email est déjà utilisé.' }) @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 }) @ApiBody({ type: CreateGestionnaireDto })
@Post() @Post()
create(@Body() dto: CreateGestionnaireDto): Promise<Users> { create(@Body() dto: CreateGestionnaireDto): Promise<Users> {
@@ -43,7 +43,7 @@ export class GestionnairesController {
return this.gestionnairesService.findAll(); 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' }) @ApiOperation({ summary: 'Récupérer un gestionnaire par ID' })
@ApiResponse({ status: 400, description: 'ID invalide' }) @ApiResponse({ status: 400, description: 'ID invalide' })
@ApiResponse({ status: 403, description: 'Accès refusé' }) @ApiResponse({ status: 403, description: 'Accès refusé' })
@@ -56,8 +56,8 @@ export class GestionnairesController {
return this.gestionnairesService.findOne(id); return this.gestionnairesService.findOne(id);
} }
@Roles(RoleType.SUPER_ADMIN) @Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR)
@ApiOperation({ summary: 'Mettre à jour un gestionnaire' }) @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: 200, description: 'Le gestionnaire a été mis à jour avec succès.', type: Users })
@ApiResponse({ status: 404, description: 'Gestionnaire non trouvé' }) @ApiResponse({ status: 404, description: 'Gestionnaire non trouvé' })
@ApiResponse({ status: 403, description: 'Accès refusé' }) @ApiResponse({ status: 403, description: 'Accès refusé' })
@@ -1,20 +1,13 @@
import { Test, TestingModule } from '@nestjs/testing'; import 'reflect-metadata';
import { UserController } from './user.controller'; import { UserController } from './user.controller';
import { UserService } from './user.service'; import { RoleType } from 'src/entities/users.entity';
describe('UserController', () => { describe('UserController roles (#161)', () => {
let controller: UserController; it('POST /users/admin autorise SUPER_ADMIN et ADMINISTRATEUR', () => {
const roles = Reflect.getMetadata('roles', UserController.prototype.createAdmin);
beforeEach(async () => { expect(roles).toEqual(
const module: TestingModule = await Test.createTestingModule({ expect.arrayContaining([RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR]),
controllers: [UserController], );
providers: [UserService], expect(roles).not.toContain(RoleType.GESTIONNAIRE);
}).compile();
controller = module.get<UserController>(UserController);
});
it('should be defined', () => {
expect(controller).toBeDefined();
}); });
}); });
+3 -3
View File
@@ -22,10 +22,10 @@ export class UserController {
private readonly suppressionService: SuppressionService, 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') @Post('admin')
@Roles(RoleType.SUPER_ADMIN) @Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR)
@ApiOperation({ summary: 'Créer un nouvel administrateur (super admin seulement)' }) @ApiOperation({ summary: 'Créer un nouvel administrateur (admin / super admin)' })
createAdmin( createAdmin(
@Body() dto: CreateAdminDto, @Body() dto: CreateAdminDto,
@User() currentUser: Users @User() currentUser: Users
+78 -9
View File
@@ -1,18 +1,87 @@
import { Test, TestingModule } from '@nestjs/testing'; import { BadRequestException, ForbiddenException } from '@nestjs/common';
import { UserService } from './user.service'; 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; let service: UserService;
beforeEach(async () => { const dto = {
const module: TestingModule = await Test.createTestingModule({ email: 'nouveau.admin@ptits-pas.fr',
providers: [UserService], password: 'Password1!',
}).compile(); prenom: 'Nina',
nom: 'Admin',
telephone: '0601020304',
};
service = module.get<UserService>(UserService); beforeEach(() => {
jest.clearAllMocks();
service = new UserService(
usersRepository as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
);
}); });
it('should be defined', () => { it('autorise un administrateur à créer un admin', async () => {
expect(service).toBeDefined(); 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);
}); });
}); });
+8 -2
View File
@@ -117,8 +117,14 @@ export class UserService {
} }
async createAdmin(dto: CreateAdminDto, currentUser: Users): Promise<Users> { async createAdmin(dto: CreateAdminDto, currentUser: Users): Promise<Users> {
if (currentUser.role !== RoleType.SUPER_ADMIN) { // #161 — admin et super_admin peuvent créer un administrateur
throw new ForbiddenException('Seuls les super administrateurs 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 }); const exist = await this.usersRepository.findOneBy({ email: dto.email });
@@ -62,7 +62,10 @@ class _GestionnaireDashboardScreenState extends State<GestionnaireDashboardScree
body: Column( body: Column(
children: [ children: [
Expanded( Expanded(
child: UserManagementPanel(showAdministrateursTab: false), child: UserManagementPanel(
showAdministrateursTab: false,
allowStaffAccountCreation: false,
),
), ),
const AppFooter(), const AppFooter(),
], ],
@@ -1,3 +1,9 @@
/// Création comptes staff (gestionnaire / admin) — ticket #161.
bool canCreateStaffAccounts(String? role) {
final r = (role ?? '').trim().toLowerCase();
return r == 'administrateur' || r == 'super_admin';
}
/// Droits daffichage poubelle — tickets #154 / #160. /// Droits daffichage poubelle — tickets #154 / #160.
bool canDeleteMetier(String? role) { bool canDeleteMetier(String? role) {
final r = (role ?? '').trim().toLowerCase(); final r = (role ?? '').trim().toLowerCase();
@@ -15,9 +15,13 @@ class UserManagementPanel extends StatefulWidget {
/// Afficher l'onglet Administrateurs (sinon sans Administrateurs). /// Afficher l'onglet Administrateurs (sinon sans Administrateurs).
final bool showAdministrateursTab; final bool showAdministrateursTab;
/// Création gestionnaire / admin (#161). False pour le dashboard gestionnaire.
final bool allowStaffAccountCreation;
const UserManagementPanel({ const UserManagementPanel({
super.key, super.key,
this.showAdministrateursTab = true, this.showAdministrateursTab = true,
this.allowStaffAccountCreation = true,
}); });
@override @override
@@ -86,6 +90,15 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
bool get _isDossiersTab => _subIndex == 0; 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() { String _searchHintForTab() {
switch (_subIndex) { switch (_subIndex) {
case 0: case 0:
@@ -293,7 +306,8 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
searchTooltip: _searchTooltipForTab(), searchTooltip: _searchTooltipForTab(),
filterControl: _subBarFilterControl(), filterControl: _subBarFilterControl(),
// Pas de « Créer » sur longlet Dossiers (#153). // Pas de « Créer » sur longlet Dossiers (#153).
onAddPressed: _isDossiersTab ? null : _handleAddPressed, // Pas de création staff pour le dashboard gestionnaire (#161).
onAddPressed: _canShowAddButton ? _handleAddPressed : null,
addLabel: 'Ajouter', addLabel: 'Ajouter',
subTabCount: labels.length, subTabCount: labels.length,
tabLabels: labels, tabLabels: labels,
@@ -305,6 +319,9 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
Future<void> _handleAddPressed() async { Future<void> _handleAddPressed() async {
// 1 Parents, 2 Enfants, 3 AM, 4 Gestionnaires, 5 Admin // 1 Parents, 2 Enfants, 3 AM, 4 Gestionnaires, 5 Admin
if (_isStaffAccountsTab && !widget.allowStaffAccountCreation) {
return;
}
if (_subIndex == 1) { if (_subIndex == 1) {
await showDialog<void>( await showDialog<void>(
context: context, context: context,