Compare commits

..

No commits in common. "80d69a5463c86f42fe3808a6eb2412a96c22ff1d" and "090ce6e13b1aa2dfa87aabdb82fd2da5513a67ef" have entirely different histories.

11 changed files with 88 additions and 490 deletions

View File

@ -1,4 +1,10 @@
import { PartialType } from "@nestjs/swagger"; import { PartialType, ApiProperty } from "@nestjs/swagger";
import { CreateGestionnaireDto } from "./create_gestionnaire.dto"; import { CreateUserDto } from "./create_user.dto";
import { IsOptional, IsUUID } from "class-validator";
export class UpdateGestionnaireDto extends PartialType(CreateGestionnaireDto) {} export class UpdateGestionnaireDto extends PartialType(CreateUserDto) {
@ApiProperty({ required: false, description: 'ID du relais de rattachement' })
@IsOptional()
@IsUUID()
relaisId?: string;
}

View File

@ -155,16 +155,6 @@ 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');
@ -266,12 +256,6 @@ 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

@ -30,9 +30,9 @@
| 17 | [Backend] API Création gestionnaire | ✅ Terminé | | 17 | [Backend] API Création gestionnaire | ✅ Terminé |
| 91 | [Frontend] Inscription AM Branchement soumission formulaire à l'API | Ouvert | | 91 | [Frontend] Inscription AM Branchement soumission formulaire à l'API | Ouvert |
| 92 | [Frontend] Dashboard Admin - Données réelles et branchement API | ✅ Terminé | | 92 | [Frontend] Dashboard Admin - Données réelles et branchement API | ✅ Terminé |
| 93 | [Frontend] Panneau Admin - Homogeneiser la presentation des onglets | ✅ Fermé | | 93 | [Frontend] Panneau Admin - Homogeneiser la presentation des onglets | Ouvert |
| 94 | [Backend] Relais - modele, API CRUD et liaison gestionnaire | ✅ Terminé | | 94 | [Backend] Relais - modele, API CRUD et liaison gestionnaire | ✅ Terminé |
| 95 | [Frontend] Admin - gestion des relais et rattachement gestionnaire | ✅ Fermé | | 95 | [Frontend] Admin - gestion des relais et rattachement gestionnaire | Ouvert |
| 96 | [Frontend] Admin - Création administrateur via modale (sans relais) | ✅ Terminé | | 96 | [Frontend] Admin - Création administrateur via modale (sans relais) | ✅ Terminé |
| 97 | [Backend] Harmoniser API création administrateur avec le contrat frontend | ✅ Terminé | | 97 | [Backend] Harmoniser API création administrateur avec le contrat frontend | ✅ Terminé |
| 89 | Log des appels API en mode debug | Ouvert | | 89 | Log des appels API en mode debug | Ouvert |
@ -665,10 +665,9 @@ Le back-office admin doit gérer des Relais avec des données réelles en base,
--- ---
### Ticket #97 : [Backend] Harmoniser API création administrateur avec le contrat frontend ### Ticket #97 : [Backend] Harmoniser API création administrateur avec le contrat frontend
**Estimation** : 3h **Estimation** : 3h
**Labels** : `backend`, `p2`, `auth`, `admin` **Labels** : `backend`, `p2`, `auth`, `admin`
**Statut** : ✅ TERMINÉ (Fermé le 2026-02-24)
**Description** : **Description** :
Rendre l'API de création administrateur cohérente et stable avec le besoin frontend (modale simplifiée), en définissant un contrat clair et minimal. Rendre l'API de création administrateur cohérente et stable avec le besoin frontend (modale simplifiée), en définissant un contrat clair et minimal.
@ -1075,10 +1074,9 @@ Branchement du formulaire d'inscription AM (étape 4) à l'endpoint d'inscriptio
--- ---
### Ticket #93 : [Frontend] Panneau Admin - Homogénéisation des onglets ### Ticket #93 : [Frontend] Panneau Admin - Homogénéisation des onglets
**Estimation** : 4h **Estimation** : 4h
**Labels** : `frontend`, `p3`, `admin`, `ux` **Labels** : `frontend`, `p3`, `admin`, `ux`
**Statut** : ✅ TERMINÉ (Fermé le 2026-02-24)
**Description** : **Description** :
Uniformiser l'UI/UX des 4 onglets du dashboard admin (Gestionnaires, Parents, AM, Admins). Uniformiser l'UI/UX des 4 onglets du dashboard admin (Gestionnaires, Parents, AM, Admins).
@ -1091,10 +1089,9 @@ Uniformiser l'UI/UX des 4 onglets du dashboard admin (Gestionnaires, Parents, AM
--- ---
### Ticket #95 : [Frontend] Admin - Gestion des Relais et rattachement gestionnaire ### Ticket #95 : [Frontend] Admin - Gestion des Relais et rattachement gestionnaire
**Estimation** : 5h **Estimation** : 5h
**Labels** : `frontend`, `p3`, `admin` **Labels** : `frontend`, `p3`, `admin`
**Statut** : ✅ TERMINÉ (Fermé le 2026-02-24)
**Description** : **Description** :
Interface de gestion des Relais dans le dashboard admin et rattachement des gestionnaires. Interface de gestion des Relais dans le dashboard admin et rattachement des gestionnaires.

View File

@ -1,6 +1,6 @@
# SuperNounou SSS-001 # SuperNounou SSS-001
## Spécification technique & opérationnelle unifiée ## Spécification technique & opérationnelle unifiée
_Version 0.3 27 janvier 2026_ _Version 0.2 24 avril 2025_
--- ---
@ -62,13 +62,6 @@ Collection Postman, scripts cURL, guide « Appeler lAPI ».
### B.4 Intégrations futures ### B.4 Intégrations futures
SSO LDAP/SAML, webhook `contract.validated`, export statistiques CSV. SSO LDAP/SAML, webhook `contract.validated`, export statistiques CSV.
### B.5 Contrat de gestion des comptes d'administration
- Création d'un administrateur avec un contrat minimal stable : `nom`, `prenom`, `email`, `password`, `telephone`.
- Le rôle n'est jamais fourni par le frontend pour ce flux ; le backend impose `ADMINISTRATEUR`.
- Les champs hors périmètre (adresse complète, photo, métadonnées métier non nécessaires) ne sont pas requis.
- Les protections d'autorisation restent actives : un `SUPER_ADMIN` n'est pas supprimable et son identité (`nom`, `prenom`) est non modifiable.
- Côté interface d'administration, les actions d'édition sont conditionnées aux droits ; les entrées non éditables restent consultables en lecture seule.
--- ---
# C Déploiement, CI/CD et Observabilité *(nouveau)* # C Déploiement, CI/CD et Observabilité *(nouveau)*
@ -113,4 +106,3 @@ AES-256, JWT, KMS, OpenAPI, RPO, RTO, rate-limit, HMAC, Compose, CI/CD…
|---------|------------|------------------|---------------------------------| |---------|------------|------------------|---------------------------------|
| 0.1-draft | 2025-04-24 | Équipe projet | Création du SSS unifié | | 0.1-draft | 2025-04-24 | Équipe projet | Création du SSS unifié |
| 0.2 | 2025-04-24 | ChatGPT & Julien | Ajout déploiement / CI/CD / logs | | 0.2 | 2025-04-24 | ChatGPT & Julien | Ajout déploiement / CI/CD / logs |
| 0.3 | 2026-01-27 | Équipe projet | Contrat admin harmonisé et règles d'autorisation |

View File

@ -1,37 +1,29 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:p_tits_pas/models/relais_model.dart'; import 'package:p_tits_pas/models/relais_model.dart';
import 'package:p_tits_pas/models/user.dart'; import 'package:p_tits_pas/models/user.dart';
import 'package:p_tits_pas/services/relais_service.dart'; import 'package:p_tits_pas/services/relais_service.dart';
import 'package:p_tits_pas/services/user_service.dart'; import 'package:p_tits_pas/services/user_service.dart';
class AdminUserFormDialog extends StatefulWidget { class GestionnaireCreateDialog extends StatefulWidget {
final AppUser? initialUser; final AppUser? initialUser;
final bool withRelais;
final bool adminMode;
final bool readOnly;
const AdminUserFormDialog({ const GestionnaireCreateDialog({
super.key, super.key,
this.initialUser, this.initialUser,
this.withRelais = true,
this.adminMode = false,
this.readOnly = false,
}); });
@override @override
State<AdminUserFormDialog> createState() => _AdminUserFormDialogState(); State<GestionnaireCreateDialog> createState() =>
_GestionnaireCreateDialogState();
} }
class _AdminUserFormDialogState extends State<AdminUserFormDialog> { class _GestionnaireCreateDialogState extends State<GestionnaireCreateDialog> {
final _formKey = GlobalKey<FormState>(); final _formKey = GlobalKey<FormState>();
final _nomController = TextEditingController(); final _nomController = TextEditingController();
final _prenomController = TextEditingController(); final _prenomController = TextEditingController();
final _emailController = TextEditingController(); final _emailController = TextEditingController();
final _passwordController = TextEditingController(); final _passwordController = TextEditingController();
final _telephoneController = TextEditingController(); final _telephoneController = TextEditingController();
final _passwordToggleFocusNode =
FocusNode(skipTraversal: true, canRequestFocus: false);
bool _isSubmitting = false; bool _isSubmitting = false;
bool _obscurePassword = true; bool _obscurePassword = true;
@ -39,50 +31,6 @@ 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;
String get _targetRoleKey {
if (widget.initialUser != null) {
return widget.initialUser!.role.toLowerCase();
}
return widget.adminMode ? 'administrateur' : 'gestionnaire';
}
String get _targetRoleLabel {
switch (_targetRoleKey) {
case 'super_admin':
return 'Super administrateur';
case 'administrateur':
return 'Administrateur';
case 'gestionnaire':
return 'Gestionnaire';
case 'assistante_maternelle':
return 'Assistante maternelle';
case 'parent':
return 'Parent';
default:
return 'Utilisateur';
}
}
IconData get _targetRoleIcon {
switch (_targetRoleKey) {
case 'super_admin':
return Icons.verified_user_outlined;
case 'administrateur':
return Icons.admin_panel_settings_outlined;
case 'gestionnaire':
return Icons.assignment_ind_outlined;
case 'assistante_maternelle':
return Icons.child_care_outlined;
case 'parent':
return Icons.supervisor_account_outlined;
default:
return Icons.person_outline;
}
}
@override @override
void initState() { void initState() {
@ -92,7 +40,7 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
_nomController.text = user.nom ?? ''; _nomController.text = user.nom ?? '';
_prenomController.text = user.prenom ?? ''; _prenomController.text = user.prenom ?? '';
_emailController.text = user.email; _emailController.text = user.email;
_telephoneController.text = _formatPhoneForDisplay(user.telephone ?? ''); _telephoneController.text = user.telephone ?? '';
// En édition, on ne préremplit jamais le mot de passe. // En édition, on ne préremplit jamais le mot de passe.
_passwordController.clear(); _passwordController.clear();
final initialRelaisId = user.relaisId?.trim(); final initialRelaisId = user.relaisId?.trim();
@ -101,11 +49,7 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
? null ? null
: initialRelaisId; : initialRelaisId;
} }
if (widget.withRelais) {
_loadRelais(); _loadRelais();
} else {
_isLoadingRelais = false;
}
} }
@override @override
@ -115,7 +59,6 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
_emailController.dispose(); _emailController.dispose();
_passwordController.dispose(); _passwordController.dispose();
_telephoneController.dispose(); _telephoneController.dispose();
_passwordToggleFocusNode.dispose();
super.dispose(); super.dispose();
} }
@ -179,68 +122,7 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
return null; return null;
} }
String? _validatePhone(String? value) {
if (_isEditMode && (value == null || value.trim().isEmpty)) {
return null;
}
final base = _required(value, 'Téléphone');
if (base != null) return base;
final digits = _normalizePhone(value!);
if (digits.length != 10) {
return 'Le téléphone doit contenir 10 chiffres';
}
if (!digits.startsWith('0')) {
return 'Le téléphone doit commencer par 0';
}
return null;
}
String _normalizePhone(String raw) {
return raw.replaceAll(RegExp(r'\D'), '');
}
String _formatPhoneForDisplay(String raw) {
final normalized = _normalizePhone(raw);
final digits =
normalized.length > 10 ? normalized.substring(0, 10) : normalized;
final buffer = StringBuffer();
for (var i = 0; i < digits.length; i++) {
if (i > 0 && i.isEven) buffer.write(' ');
buffer.write(digits[i]);
}
return buffer.toString();
}
String _toTitleCase(String raw) {
final trimmed = raw.trim();
if (trimmed.isEmpty) return trimmed;
final words = trimmed.split(RegExp(r'\s+'));
final normalizedWords = words.map(_capitalizeComposedWord).toList();
return normalizedWords.join(' ');
}
String _capitalizeComposedWord(String word) {
if (word.isEmpty) return word;
final lower = word.toLowerCase();
final separators = <String>{"-", "'", ""};
final buffer = StringBuffer();
var capitalizeNext = true;
for (var i = 0; i < lower.length; i++) {
final char = lower[i];
if (capitalizeNext && RegExp(r'[a-zà-öø-ÿ]').hasMatch(char)) {
buffer.write(char.toUpperCase());
capitalizeNext = false;
} else {
buffer.write(char);
capitalizeNext = separators.contains(char);
}
}
return buffer.toString();
}
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;
@ -249,87 +131,35 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
}); });
try { try {
final normalizedNom = _toTitleCase(_nomController.text);
final normalizedPrenom = _toTitleCase(_prenomController.text);
final normalizedPhone = _normalizePhone(_telephoneController.text);
final passwordProvided = _passwordController.text.trim().isNotEmpty;
if (_isEditMode) { if (_isEditMode) {
if (widget.adminMode) {
final lockedNom = _toTitleCase(widget.initialUser!.nom ?? '');
final lockedPrenom = _toTitleCase(widget.initialUser!.prenom ?? '');
await UserService.updateAdministrateur(
adminId: widget.initialUser!.id,
nom: _isLockedAdminIdentity ? lockedNom : normalizedNom,
prenom: _isLockedAdminIdentity ? lockedPrenom : normalizedPrenom,
email: _emailController.text.trim(),
telephone: normalizedPhone.isEmpty
? _normalizePhone(widget.initialUser!.telephone ?? '')
: normalizedPhone,
password: passwordProvided ? _passwordController.text : null,
);
} else {
final currentUser = widget.initialUser!;
final initialNom = _toTitleCase(currentUser.nom ?? '');
final initialPrenom = _toTitleCase(currentUser.prenom ?? '');
final initialEmail = currentUser.email.trim();
final initialPhone = _normalizePhone(currentUser.telephone ?? '');
final onlyRelaisChanged =
normalizedNom == initialNom &&
normalizedPrenom == initialPrenom &&
_emailController.text.trim() == initialEmail &&
normalizedPhone == initialPhone &&
!passwordProvided;
if (onlyRelaisChanged) {
await UserService.updateGestionnaireRelais(
gestionnaireId: currentUser.id,
relaisId: _selectedRelaisId,
);
} else {
await UserService.updateGestionnaire( await UserService.updateGestionnaire(
gestionnaireId: currentUser.id, gestionnaireId: widget.initialUser!.id,
nom: normalizedNom, nom: _nomController.text.trim(),
prenom: normalizedPrenom, prenom: _prenomController.text.trim(),
email: _emailController.text.trim(), email: _emailController.text.trim(),
telephone: normalizedPhone.isEmpty ? initialPhone : normalizedPhone, telephone: _telephoneController.text.trim(),
relaisId: _selectedRelaisId, relaisId: _selectedRelaisId,
password: passwordProvided ? _passwordController.text : null, password: _passwordController.text.trim().isEmpty
); ? null
} : _passwordController.text,
}
} else {
if (widget.adminMode) {
await UserService.createAdministrateur(
nom: normalizedNom,
prenom: normalizedPrenom,
email: _emailController.text.trim(),
password: _passwordController.text,
telephone: _normalizePhone(_telephoneController.text),
); );
} else { } else {
await UserService.createGestionnaire( await UserService.createGestionnaire(
nom: normalizedNom, nom: _nomController.text.trim(),
prenom: normalizedPrenom, prenom: _prenomController.text.trim(),
email: _emailController.text.trim(), email: _emailController.text.trim(),
password: _passwordController.text, password: _passwordController.text,
telephone: _normalizePhone(_telephoneController.text), telephone: _telephoneController.text.trim(),
relaisId: _selectedRelaisId, relaisId: _selectedRelaisId,
); );
} }
}
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text( content: Text(
_isEditMode _isEditMode
? (widget.adminMode ? 'Gestionnaire modifié avec succès.'
? 'Administrateur modifié avec succès.' : 'Gestionnaire créé avec succès.',
: 'Gestionnaire modifié avec succès.')
: (widget.adminMode
? 'Administrateur créé avec succès.'
: 'Gestionnaire créé avec succès.'),
), ),
), ),
); );
@ -353,8 +183,6 @@ 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>(
@ -411,26 +239,14 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
return AlertDialog( return AlertDialog(
title: Row( title: Row(
children: [ children: [
CircleAvatar(
radius: 16,
backgroundColor: const Color(0xFFEDE5FA),
child: Icon(
_targetRoleIcon,
size: 20,
color: const Color(0xFF6B3FA0),
),
),
const SizedBox(width: 8),
Expanded( Expanded(
child: Text( child: Text(
_isEditMode _isEditMode
? (widget.readOnly ? 'Modifier un gestionnaire'
? 'Consulter un "$_targetRoleLabel"' : 'Créer un gestionnaire',
: 'Modifier un "$_targetRoleLabel"')
: 'Créer un "$_targetRoleLabel"',
), ),
), ),
if (_isEditMode && !widget.readOnly) if (_isEditMode)
IconButton( IconButton(
icon: const Icon(Icons.close), icon: const Icon(Icons.close),
tooltip: 'Fermer', tooltip: 'Fermer',
@ -450,9 +266,9 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
children: [ children: [
Row( Row(
children: [ children: [
Expanded(child: _buildPrenomField()),
const SizedBox(width: 12),
Expanded(child: _buildNomField()), Expanded(child: _buildNomField()),
const SizedBox(width: 12),
Expanded(child: _buildPrenomField()),
], ],
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
@ -465,23 +281,15 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
Expanded(child: _buildTelephoneField()), Expanded(child: _buildTelephoneField()),
], ],
), ),
if (widget.withRelais) ...[
const SizedBox(height: 12), const SizedBox(height: 12),
_buildRelaisField(), _buildRelaisField(),
], ],
],
), ),
), ),
), ),
), ),
actions: [ actions: [
if (widget.readOnly) ...[ if (_isEditMode) ...[
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),
@ -523,50 +331,42 @@ 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: (widget.readOnly || _isLockedAdminIdentity) validator: (v) => _required(v, 'Nom'),
? 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: (widget.readOnly || _isLockedAdminIdentity) validator: (v) => _required(v, 'Prénom'),
? 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: widget.readOnly ? null : _validateEmail, validator: _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,
@ -578,11 +378,7 @@ 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: widget.readOnly suffixIcon: IconButton(
? null
: ExcludeFocus(
child: IconButton(
focusNode: _passwordToggleFocusNode,
onPressed: () { onPressed: () {
setState(() { setState(() {
_obscurePassword = !_obscurePassword; _obscurePassword = !_obscurePassword;
@ -593,28 +389,19 @@ 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: widget.readOnly
? null
: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(10),
_FrenchPhoneNumberFormatter(),
],
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'Téléphone (ex: 06 12 34 56 78)', labelText: 'Téléphone',
border: OutlineInputBorder(), border: OutlineInputBorder(),
), ),
validator: widget.readOnly ? null : _validatePhone, validator: (v) => _required(v, 'Téléphone'),
); );
} }
@ -646,7 +433,7 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
), ),
), ),
], ],
onChanged: (_isLoadingRelais || widget.readOnly) onChanged: _isLoadingRelais
? null ? null
: (value) { : (value) {
setState(() { setState(() {
@ -662,27 +449,3 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
); );
} }
} }
class _FrenchPhoneNumberFormatter extends TextInputFormatter {
const _FrenchPhoneNumberFormatter();
@override
TextEditingValue formatEditUpdate(
TextEditingValue oldValue,
TextEditingValue newValue,
) {
final digits = newValue.text.replaceAll(RegExp(r'\D'), '');
final normalized = digits.length > 10 ? digits.substring(0, 10) : digits;
final buffer = StringBuffer();
for (var i = 0; i < normalized.length; i++) {
if (i > 0 && i.isEven) buffer.write(' ');
buffer.write(normalized[i]);
}
final formatted = buffer.toString();
return TextEditingValue(
text: formatted,
selection: TextSelection.collapsed(offset: formatted.length),
);
}
}

View File

@ -75,41 +75,6 @@ class UserService {
return AppUser.fromJson(data); return AppUser.fromJson(data);
} }
static Future<AppUser> createAdministrateur({
required String nom,
required String prenom,
required String email,
required String password,
required String telephone,
}) async {
final response = await http.post(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.users}/admin'),
headers: await _headers(),
body: jsonEncode(<String, dynamic>{
'nom': nom,
'prenom': prenom,
'email': email,
'password': password,
'telephone': telephone,
}),
);
if (response.statusCode != 200 && response.statusCode != 201) {
final decoded = jsonDecode(response.body);
if (decoded is Map<String, dynamic>) {
final message = decoded['message'];
if (message is List && message.isNotEmpty) {
throw Exception(message.join(' - '));
}
throw Exception(_toStr(message) ?? 'Erreur création administrateur');
}
throw Exception('Erreur création administrateur');
}
final data = jsonDecode(response.body) as Map<String, dynamic>;
return AppUser.fromJson(data);
}
// Récupérer la liste des parents // Récupérer la liste des parents
static Future<List<ParentModel>> getParents() async { static Future<List<ParentModel>> getParents() async {
final response = await http.get( final response = await http.get(
@ -267,7 +232,7 @@ class UserService {
required String nom, required String nom,
required String prenom, required String prenom,
required String email, required String email,
String? telephone, required String telephone,
required String? relaisId, required String? relaisId,
String? password, String? password,
}) async { }) async {
@ -275,13 +240,10 @@ class UserService {
'nom': nom, 'nom': nom,
'prenom': prenom, 'prenom': prenom,
'email': email, 'email': email,
'telephone': telephone,
'relaisId': relaisId, 'relaisId': relaisId,
}; };
if (telephone != null && telephone.trim().isNotEmpty) {
body['telephone'] = telephone.trim();
}
if (password != null && password.trim().isNotEmpty) { if (password != null && password.trim().isNotEmpty) {
body['password'] = password.trim(); body['password'] = password.trim();
} }
@ -308,50 +270,6 @@ class UserService {
return AppUser.fromJson(data); return AppUser.fromJson(data);
} }
static Future<AppUser> updateAdministrateur({
required String adminId,
required String nom,
required String prenom,
required String email,
String? telephone,
String? password,
}) async {
final body = <String, dynamic>{
'nom': nom,
'prenom': prenom,
'email': email,
};
if (telephone != null && telephone.trim().isNotEmpty) {
body['telephone'] = telephone.trim();
}
if (password != null && password.trim().isNotEmpty) {
body['password'] = password.trim();
}
final response = await http.patch(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.users}/$adminId'),
headers: await _headers(),
body: jsonEncode(body),
);
if (response.statusCode != 200) {
final decoded = jsonDecode(response.body);
if (decoded is Map<String, dynamic>) {
final message = decoded['message'];
if (message is List && message.isNotEmpty) {
throw Exception(message.join(' - '));
}
throw Exception(_toStr(message) ?? 'Erreur modification administrateur');
}
throw Exception('Erreur modification administrateur');
}
final data = jsonDecode(response.body) as Map<String, dynamic>;
return AppUser.fromJson(data);
}
static Future<void> deleteUser(String userId) async { static Future<void> deleteUser(String userId) async {
final response = await http.delete( final response = await http.delete(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.users}/$userId'), Uri.parse('${ApiConfig.baseUrl}${ApiConfig.users}/$userId'),

View File

@ -1,7 +1,6 @@
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/admin_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';
@ -22,12 +21,10 @@ 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();
} }
@ -55,44 +52,15 @@ 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,
builder: (dialogContext) { builder: (dialogContext) {
return AdminUserFormDialog( return AdminCreateDialog(initialUser: user);
initialUser: user,
adminMode: true,
withRelais: false,
readOnly: !canEdit,
);
}, },
); );
if (changed == true && canEdit) { if (changed == true) {
await _loadAdmins(); await _loadAdmins();
} }
} }
@ -114,34 +82,17 @@ 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,
fallbackIcon: isSuperAdmin
? Icons.verified_user_outlined
: Icons.manage_accounts_outlined,
subtitleLines: [ subtitleLines: [
user.email, user.email,
'Téléphone : ${user.telephone?.trim().isNotEmpty == true ? user.telephone : 'Non renseigné'}', '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: Icon( icon: const Icon(Icons.edit),
canEdit ? Icons.edit_outlined : Icons.visibility_outlined, tooltip: 'Modifier',
),
tooltip: canEdit ? 'Modifier' : 'Consulter',
onPressed: () { onPressed: () {
_openAdminEditDialog(user); _openAdminEditDialog(user);
}, },

View File

@ -6,10 +6,6 @@ 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,
@ -18,10 +14,6 @@ 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
@ -51,10 +43,9 @@ 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: widget.borderColor ?? Colors.grey.shade300), side: BorderSide(color: Colors.grey.shade300),
), ),
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9),
@ -85,7 +76,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,
), ),
@ -97,7 +88,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,
), ),

View File

@ -59,7 +59,7 @@ class _GestionnaireManagementWidgetState
context: context, context: context,
barrierDismissible: false, barrierDismissible: false,
builder: (dialogContext) { builder: (dialogContext) {
return AdminUserFormDialog(initialUser: user); return GestionnaireCreateDialog(initialUser: user);
}, },
); );
if (changed == true) { if (changed == true) {
@ -86,7 +86,6 @@ class _GestionnaireManagementWidgetState
final user = filteredGestionnaires[index]; final user = filteredGestionnaires[index];
return AdminUserCard( return AdminUserCard(
title: user.fullName, title: user.fullName,
fallbackIcon: Icons.assignment_ind_outlined,
avatarUrl: user.photoUrl, avatarUrl: user.photoUrl,
subtitleLines: [ subtitleLines: [
user.email, user.email,

View File

@ -75,7 +75,6 @@ class _ParentManagementWidgetState extends State<ParentManagementWidget> {
final parent = filteredParents[index]; final parent = filteredParents[index];
return AdminUserCard( return AdminUserCard(
title: parent.user.fullName, title: parent.user.fullName,
fallbackIcon: Icons.supervisor_account_outlined,
avatarUrl: parent.user.photoUrl, avatarUrl: parent.user.photoUrl,
subtitleLines: [ subtitleLines: [
parent.user.email, parent.user.email,

View File

@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:p_tits_pas/screens/administrateurs/creation/admin_create.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/widgets/admin/admin_management_widget.dart'; import 'package:p_tits_pas/widgets/admin/admin_management_widget.dart';
import 'package:p_tits_pas/widgets/admin/assistante_maternelle_management_widget.dart'; import 'package:p_tits_pas/widgets/admin/assistante_maternelle_management_widget.dart';
@ -183,7 +184,7 @@ class _AdminUserManagementPanelState extends State<AdminUserManagementPanel> {
context: context, context: context,
barrierDismissible: false, barrierDismissible: false,
builder: (dialogContext) { builder: (dialogContext) {
return const AdminUserFormDialog(); return const GestionnaireCreateDialog();
}, },
); );
@ -201,10 +202,7 @@ class _AdminUserManagementPanelState extends State<AdminUserManagementPanel> {
context: context, context: context,
barrierDismissible: false, barrierDismissible: false,
builder: (dialogContext) { builder: (dialogContext) {
return const AdminUserFormDialog( return const AdminCreateDialog();
adminMode: true,
withRelais: false,
);
}, },
); );
@ -221,7 +219,7 @@ class _AdminUserManagementPanelState extends State<AdminUserManagementPanel> {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( const SnackBar(
content: Text( content: Text(
'La création est disponible pour les gestionnaires et administrateurs.', 'La création est disponible uniquement pour les gestionnaires et les administrateurs.',
), ),
), ),
); );