Compare commits

..

2 Commits

Author SHA1 Message Date
2645cf1cd6 fix(#96): protéger le super admin en édition et suppression
Empêche la suppression d'un super administrateur et fige son identité (nom/prénom) côté API, avec alignement de la modale frontend pour masquer la suppression et verrouiller ces champs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-24 22:05:17 +01:00
e2ebc6a0a1 feat(#96): différencier la consultation admin et le mode édition
Affiche une identité visuelle dédiée pour les super admins et adapte l’action par ligne (oeil en lecture seule, crayon en édition) avec modale strictement read-only quand l’utilisateur n’a pas les droits.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-24 21:48:11 +01:00
4 changed files with 142 additions and 41 deletions

View File

@ -155,6 +155,16 @@ export class UserService {
async updateUser(id: string, dto: UpdateUserDto, currentUser: Users): Promise<Users> { async updateUser(id: string, dto: UpdateUserDto, currentUser: Users): Promise<Users> {
const user = await this.findOne(id); const user = await this.findOne(id);
// Le super administrateur conserve une identité figée.
if (
user.role === RoleType.SUPER_ADMIN &&
(dto.nom !== undefined || dto.prenom !== undefined)
) {
throw new ForbiddenException(
'Le nom et le prénom du super administrateur ne peuvent pas être modifiés',
);
}
// Interdire changement de rôle si pas super admin // Interdire changement de rôle si pas super admin
if (dto.role && currentUser.role !== RoleType.SUPER_ADMIN) { if (dto.role && currentUser.role !== RoleType.SUPER_ADMIN) {
throw new ForbiddenException('Accès réservé aux super admins'); throw new ForbiddenException('Accès réservé aux super admins');
@ -251,6 +261,12 @@ export class UserService {
if (currentUser.role !== RoleType.SUPER_ADMIN) { if (currentUser.role !== RoleType.SUPER_ADMIN) {
throw new ForbiddenException('Accès réservé aux super admins'); throw new ForbiddenException('Accès réservé aux super admins');
} }
const user = await this.findOne(id);
if (user.role === RoleType.SUPER_ADMIN) {
throw new ForbiddenException(
'Le super administrateur ne peut pas être supprimé',
);
}
const result = await this.usersRepository.delete(id); const result = await this.usersRepository.delete(id);
if (result.affected === 0) { if (result.affected === 0) {
throw new NotFoundException('Utilisateur introuvable'); throw new NotFoundException('Utilisateur introuvable');

View File

@ -9,12 +9,14 @@ class AdminUserFormDialog extends StatefulWidget {
final AppUser? initialUser; final AppUser? initialUser;
final bool withRelais; final bool withRelais;
final bool adminMode; final bool adminMode;
final bool readOnly;
const AdminUserFormDialog({ const AdminUserFormDialog({
super.key, super.key,
this.initialUser, this.initialUser,
this.withRelais = true, this.withRelais = true,
this.adminMode = false, this.adminMode = false,
this.readOnly = false,
}); });
@override @override
@ -37,6 +39,10 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
List<RelaisModel> _relais = []; List<RelaisModel> _relais = [];
String? _selectedRelaisId; String? _selectedRelaisId;
bool get _isEditMode => widget.initialUser != null; bool get _isEditMode => widget.initialUser != null;
bool get _isSuperAdminTarget =>
widget.initialUser?.role.toLowerCase() == 'super_admin';
bool get _isLockedAdminIdentity =>
_isEditMode && widget.adminMode && _isSuperAdminTarget;
@override @override
void initState() { void initState() {
@ -194,6 +200,7 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
} }
Future<void> _submit() async { Future<void> _submit() async {
if (widget.readOnly) return;
if (_isSubmitting) return; if (_isSubmitting) return;
if (!_formKey.currentState!.validate()) return; if (!_formKey.currentState!.validate()) return;
@ -209,10 +216,12 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
if (_isEditMode) { if (_isEditMode) {
if (widget.adminMode) { if (widget.adminMode) {
final lockedNom = _toTitleCase(widget.initialUser!.nom ?? '');
final lockedPrenom = _toTitleCase(widget.initialUser!.prenom ?? '');
await UserService.updateAdministrateur( await UserService.updateAdministrateur(
adminId: widget.initialUser!.id, adminId: widget.initialUser!.id,
nom: normalizedNom, nom: _isLockedAdminIdentity ? lockedNom : normalizedNom,
prenom: normalizedPrenom, prenom: _isLockedAdminIdentity ? lockedPrenom : normalizedPrenom,
email: _emailController.text.trim(), email: _emailController.text.trim(),
telephone: normalizedPhone.isEmpty telephone: normalizedPhone.isEmpty
? _normalizePhone(widget.initialUser!.telephone ?? '') ? _normalizePhone(widget.initialUser!.telephone ?? '')
@ -304,6 +313,8 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
} }
Future<void> _delete() async { Future<void> _delete() async {
if (widget.readOnly) return;
if (_isSuperAdminTarget) return;
if (!_isEditMode || _isSubmitting) return; if (!_isEditMode || _isSubmitting) return;
final confirmed = await showDialog<bool>( final confirmed = await showDialog<bool>(
@ -363,15 +374,19 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
Expanded( Expanded(
child: Text( child: Text(
_isEditMode _isEditMode
? (widget.readOnly
? (widget.adminMode ? (widget.adminMode
? 'Consulter un administrateur'
: 'Consulter un gestionnaire')
: (widget.adminMode
? 'Modifier un administrateur' ? 'Modifier un administrateur'
: 'Modifier un gestionnaire') : 'Modifier un gestionnaire'))
: (widget.adminMode : (widget.adminMode
? 'Créer un administrateur' ? 'Créer un administrateur'
: 'Créer un gestionnaire'), : 'Créer un gestionnaire'),
), ),
), ),
if (_isEditMode) if (_isEditMode && !widget.readOnly)
IconButton( IconButton(
icon: const Icon(Icons.close), icon: const Icon(Icons.close),
tooltip: 'Fermer', tooltip: 'Fermer',
@ -416,7 +431,13 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
), ),
), ),
actions: [ actions: [
if (_isEditMode) ...[ if (widget.readOnly) ...[
FilledButton(
onPressed: _isSubmitting ? null : () => Navigator.of(context).pop(false),
child: const Text('Fermer'),
),
] else if (_isEditMode) ...[
if (!_isSuperAdminTarget)
OutlinedButton( OutlinedButton(
onPressed: _isSubmitting ? null : _delete, onPressed: _isSubmitting ? null : _delete,
style: OutlinedButton.styleFrom(foregroundColor: Colors.red.shade700), style: OutlinedButton.styleFrom(foregroundColor: Colors.red.shade700),
@ -458,42 +479,50 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
Widget _buildNomField() { Widget _buildNomField() {
return TextFormField( return TextFormField(
controller: _nomController, controller: _nomController,
readOnly: widget.readOnly || _isLockedAdminIdentity,
textCapitalization: TextCapitalization.words, textCapitalization: TextCapitalization.words,
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'Nom', labelText: 'Nom',
border: OutlineInputBorder(), border: OutlineInputBorder(),
), ),
validator: (v) => _required(v, 'Nom'), validator: (widget.readOnly || _isLockedAdminIdentity)
? null
: (v) => _required(v, 'Nom'),
); );
} }
Widget _buildPrenomField() { Widget _buildPrenomField() {
return TextFormField( return TextFormField(
controller: _prenomController, controller: _prenomController,
readOnly: widget.readOnly || _isLockedAdminIdentity,
textCapitalization: TextCapitalization.words, textCapitalization: TextCapitalization.words,
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'Prénom', labelText: 'Prénom',
border: OutlineInputBorder(), border: OutlineInputBorder(),
), ),
validator: (v) => _required(v, 'Prénom'), validator: (widget.readOnly || _isLockedAdminIdentity)
? null
: (v) => _required(v, 'Prénom'),
); );
} }
Widget _buildEmailField() { Widget _buildEmailField() {
return TextFormField( return TextFormField(
controller: _emailController, controller: _emailController,
readOnly: widget.readOnly,
keyboardType: TextInputType.emailAddress, keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'Email', labelText: 'Email',
border: OutlineInputBorder(), border: OutlineInputBorder(),
), ),
validator: _validateEmail, validator: widget.readOnly ? null : _validateEmail,
); );
} }
Widget _buildPasswordField() { Widget _buildPasswordField() {
return TextFormField( return TextFormField(
controller: _passwordController, controller: _passwordController,
readOnly: widget.readOnly,
obscureText: _obscurePassword, obscureText: _obscurePassword,
enableSuggestions: false, enableSuggestions: false,
autocorrect: false, autocorrect: false,
@ -505,7 +534,9 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
? 'Nouveau mot de passe' ? 'Nouveau mot de passe'
: 'Mot de passe', : 'Mot de passe',
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
suffixIcon: ExcludeFocus( suffixIcon: widget.readOnly
? null
: ExcludeFocus(
child: IconButton( child: IconButton(
focusNode: _passwordToggleFocusNode, focusNode: _passwordToggleFocusNode,
onPressed: () { onPressed: () {
@ -519,15 +550,18 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
), ),
), ),
), ),
validator: _validatePassword, validator: widget.readOnly ? null : _validatePassword,
); );
} }
Widget _buildTelephoneField() { Widget _buildTelephoneField() {
return TextFormField( return TextFormField(
controller: _telephoneController, controller: _telephoneController,
readOnly: widget.readOnly,
keyboardType: TextInputType.phone, keyboardType: TextInputType.phone,
inputFormatters: [ inputFormatters: widget.readOnly
? null
: [
FilteringTextInputFormatter.digitsOnly, FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(10), LengthLimitingTextInputFormatter(10),
_FrenchPhoneNumberFormatter(), _FrenchPhoneNumberFormatter(),
@ -536,7 +570,7 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
labelText: 'Téléphone (ex: 06 12 34 56 78)', labelText: 'Téléphone (ex: 06 12 34 56 78)',
border: OutlineInputBorder(), border: OutlineInputBorder(),
), ),
validator: _validatePhone, validator: widget.readOnly ? null : _validatePhone,
); );
} }
@ -568,7 +602,7 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
), ),
), ),
], ],
onChanged: _isLoadingRelais onChanged: (_isLoadingRelais || widget.readOnly)
? null ? null
: (value) { : (value) {
setState(() { setState(() {

View File

@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/user.dart'; import 'package:p_tits_pas/models/user.dart';
import 'package:p_tits_pas/screens/administrateurs/creation/gestionnaires_create.dart'; import 'package:p_tits_pas/screens/administrateurs/creation/gestionnaires_create.dart';
import 'package:p_tits_pas/services/auth_service.dart';
import 'package:p_tits_pas/services/user_service.dart'; import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart';
import 'package:p_tits_pas/widgets/admin/common/user_list.dart'; import 'package:p_tits_pas/widgets/admin/common/user_list.dart';
@ -21,10 +22,12 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
bool _isLoading = false; bool _isLoading = false;
String? _error; String? _error;
List<AppUser> _admins = []; List<AppUser> _admins = [];
String? _currentUserRole;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_loadCurrentUserRole();
_loadAdmins(); _loadAdmins();
} }
@ -52,7 +55,31 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
} }
} }
Future<void> _loadCurrentUserRole() async {
final cached = await AuthService.getCurrentUser();
if (!mounted) return;
if (cached != null) {
setState(() {
_currentUserRole = cached.role.toLowerCase();
});
return;
}
final refreshed = await AuthService.refreshCurrentUser();
if (!mounted || refreshed == null) return;
setState(() {
_currentUserRole = refreshed.role.toLowerCase();
});
}
bool _isSuperAdmin(AppUser user) => user.role.toLowerCase() == 'super_admin';
bool _canEditAdmin(AppUser target) {
if (!_isSuperAdmin(target)) return true;
return _currentUserRole == 'super_admin';
}
Future<void> _openAdminEditDialog(AppUser user) async { Future<void> _openAdminEditDialog(AppUser user) async {
final canEdit = _canEditAdmin(user);
final changed = await showDialog<bool>( final changed = await showDialog<bool>(
context: context, context: context,
barrierDismissible: false, barrierDismissible: false,
@ -61,10 +88,11 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
initialUser: user, initialUser: user,
adminMode: true, adminMode: true,
withRelais: false, withRelais: false,
readOnly: !canEdit,
); );
}, },
); );
if (changed == true) { if (changed == true && canEdit) {
await _loadAdmins(); await _loadAdmins();
} }
} }
@ -86,6 +114,8 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
itemCount: filteredAdmins.length, itemCount: filteredAdmins.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final user = filteredAdmins[index]; final user = filteredAdmins[index];
final isSuperAdmin = _isSuperAdmin(user);
final canEdit = _canEditAdmin(user);
return AdminUserCard( return AdminUserCard(
title: user.fullName, title: user.fullName,
subtitleLines: [ subtitleLines: [
@ -93,10 +123,22 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
'Rôle : ${user.role}', 'Rôle : ${user.role}',
], ],
avatarUrl: user.photoUrl, avatarUrl: user.photoUrl,
borderColor: isSuperAdmin
? const Color(0xFF8E6AC8)
: Colors.grey.shade300,
backgroundColor: isSuperAdmin
? const Color(0xFFF4EEFF)
: Colors.white,
titleColor: isSuperAdmin ? const Color(0xFF5D2F99) : null,
infoColor: isSuperAdmin
? const Color(0xFF6D4EA1)
: Colors.black54,
actions: [ actions: [
IconButton( IconButton(
icon: const Icon(Icons.edit), icon: Icon(
tooltip: 'Modifier', canEdit ? Icons.edit_outlined : Icons.visibility_outlined,
),
tooltip: canEdit ? 'Modifier' : 'Consulter',
onPressed: () { onPressed: () {
_openAdminEditDialog(user); _openAdminEditDialog(user);
}, },

View File

@ -6,6 +6,10 @@ class AdminUserCard extends StatefulWidget {
final String? avatarUrl; final String? avatarUrl;
final IconData fallbackIcon; final IconData fallbackIcon;
final List<Widget> actions; final List<Widget> actions;
final Color? borderColor;
final Color? backgroundColor;
final Color? titleColor;
final Color? infoColor;
const AdminUserCard({ const AdminUserCard({
super.key, super.key,
@ -14,6 +18,10 @@ class AdminUserCard extends StatefulWidget {
this.avatarUrl, this.avatarUrl,
this.fallbackIcon = Icons.person, this.fallbackIcon = Icons.person,
this.actions = const [], this.actions = const [],
this.borderColor,
this.backgroundColor,
this.titleColor,
this.infoColor,
}); });
@override @override
@ -43,9 +51,10 @@ class _AdminUserCardState extends State<AdminUserCard> {
child: Card( child: Card(
margin: const EdgeInsets.only(bottom: 12), margin: const EdgeInsets.only(bottom: 12),
elevation: 0, elevation: 0,
color: widget.backgroundColor,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
side: BorderSide(color: Colors.grey.shade300), side: BorderSide(color: widget.borderColor ?? Colors.grey.shade300),
), ),
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9),
@ -76,7 +85,7 @@ class _AdminUserCardState extends State<AdminUserCard> {
style: const TextStyle( style: const TextStyle(
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
fontSize: 14, fontSize: 14,
), ).copyWith(color: widget.titleColor),
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
@ -88,7 +97,7 @@ class _AdminUserCardState extends State<AdminUserCard> {
style: const TextStyle( style: const TextStyle(
color: Colors.black54, color: Colors.black54,
fontSize: 12, fontSize: 12,
), ).copyWith(color: widget.infoColor),
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),