Compare commits

...

6 Commits

Author SHA1 Message Date
80d69a5463 docs: aligner tickets Gitea et figer la spec admin
Synchronise les statuts des tickets #93/#95/#96/#97 avec l'API Gitea et finalise la SSS-001 avec le contrat de gestion des comptes d'administration.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-24 22:55:22 +01:00
0579fda553 merge: intégrer feature/96-creation-admin-modale dans develop
Fusionne le ticket #96 avec résolution des conflits sur la modale partagée, les droits admin/super admin et l’harmonisation visuelle des listes utilisateurs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-24 22:36:58 +01:00
d14550a1cf feat(#96): harmoniser les icones de rôles en liste et modale
Uniformise l'identité visuelle des rôles (admin, super admin, gestionnaire, parent) avec icônes dédiées dans les listes et la modale, et affiche le téléphone dans la ligne admin en retirant le rôle redondant.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-24 22:31:23 +01:00
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
d8572e7fd6 feat(#96): finaliser la modale admin/gestionnaire et les règles d’édition
Unifie la modale utilisateur pour création/édition admin et gestionnaire, fiabilise la saisie/normalisation (téléphone, nom/prénom) et corrige la mise à jour backend pour accepter le rattachement relais sans erreur 400.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-24 17:25:15 +01:00
11 changed files with 490 additions and 88 deletions

View File

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

View File

@ -155,6 +155,16 @@ export class UserService {
async updateUser(id: string, dto: UpdateUserDto, currentUser: Users): Promise<Users> {
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
if (dto.role && currentUser.role !== RoleType.SUPER_ADMIN) {
throw new ForbiddenException('Accès réservé aux super admins');
@ -256,6 +266,12 @@ export class UserService {
if (currentUser.role !== RoleType.SUPER_ADMIN) {
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);
if (result.affected === 0) {
throw new NotFoundException('Utilisateur introuvable');

View File

@ -30,9 +30,9 @@
| 17 | [Backend] API Création gestionnaire | ✅ Terminé |
| 91 | [Frontend] Inscription AM Branchement soumission formulaire à l'API | Ouvert |
| 92 | [Frontend] Dashboard Admin - Données réelles et branchement API | ✅ Terminé |
| 93 | [Frontend] Panneau Admin - Homogeneiser la presentation des onglets | Ouvert |
| 93 | [Frontend] Panneau Admin - Homogeneiser la presentation des onglets | ✅ Fermé |
| 94 | [Backend] Relais - modele, API CRUD et liaison gestionnaire | ✅ Terminé |
| 95 | [Frontend] Admin - gestion des relais et rattachement gestionnaire | Ouvert |
| 95 | [Frontend] Admin - gestion des relais et rattachement gestionnaire | ✅ Fermé |
| 96 | [Frontend] Admin - Création administrateur via modale (sans relais) | ✅ Terminé |
| 97 | [Backend] Harmoniser API création administrateur avec le contrat frontend | ✅ Terminé |
| 89 | Log des appels API en mode debug | Ouvert |
@ -665,9 +665,10 @@ 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
**Labels** : `backend`, `p2`, `auth`, `admin`
**Statut** : ✅ TERMINÉ (Fermé le 2026-02-24)
**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.
@ -1074,9 +1075,10 @@ 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
**Labels** : `frontend`, `p3`, `admin`, `ux`
**Statut** : ✅ TERMINÉ (Fermé le 2026-02-24)
**Description** :
Uniformiser l'UI/UX des 4 onglets du dashboard admin (Gestionnaires, Parents, AM, Admins).
@ -1089,9 +1091,10 @@ 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
**Labels** : `frontend`, `p3`, `admin`
**Statut** : ✅ TERMINÉ (Fermé le 2026-02-24)
**Description** :
Interface de gestion des Relais dans le dashboard admin et rattachement des gestionnaires.

View File

@ -1,6 +1,6 @@
# SuperNounou SSS-001
## Spécification technique & opérationnelle unifiée
_Version 0.2 24 avril 2025_
_Version 0.3 27 janvier 2026_
---
@ -62,6 +62,13 @@ Collection Postman, scripts cURL, guide « Appeler lAPI ».
### B.4 Intégrations futures
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)*
@ -106,3 +113,4 @@ 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.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,29 +1,37 @@
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/user.dart';
import 'package:p_tits_pas/services/relais_service.dart';
import 'package:p_tits_pas/services/user_service.dart';
class GestionnaireCreateDialog extends StatefulWidget {
class AdminUserFormDialog extends StatefulWidget {
final AppUser? initialUser;
final bool withRelais;
final bool adminMode;
final bool readOnly;
const GestionnaireCreateDialog({
const AdminUserFormDialog({
super.key,
this.initialUser,
this.withRelais = true,
this.adminMode = false,
this.readOnly = false,
});
@override
State<GestionnaireCreateDialog> createState() =>
_GestionnaireCreateDialogState();
State<AdminUserFormDialog> createState() => _AdminUserFormDialogState();
}
class _GestionnaireCreateDialogState extends State<GestionnaireCreateDialog> {
class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
final _formKey = GlobalKey<FormState>();
final _nomController = TextEditingController();
final _prenomController = TextEditingController();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
final _telephoneController = TextEditingController();
final _passwordToggleFocusNode =
FocusNode(skipTraversal: true, canRequestFocus: false);
bool _isSubmitting = false;
bool _obscurePassword = true;
@ -31,6 +39,50 @@ class _GestionnaireCreateDialogState extends State<GestionnaireCreateDialog> {
List<RelaisModel> _relais = [];
String? _selectedRelaisId;
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
void initState() {
@ -40,7 +92,7 @@ class _GestionnaireCreateDialogState extends State<GestionnaireCreateDialog> {
_nomController.text = user.nom ?? '';
_prenomController.text = user.prenom ?? '';
_emailController.text = user.email;
_telephoneController.text = user.telephone ?? '';
_telephoneController.text = _formatPhoneForDisplay(user.telephone ?? '');
// En édition, on ne préremplit jamais le mot de passe.
_passwordController.clear();
final initialRelaisId = user.relaisId?.trim();
@ -49,7 +101,11 @@ class _GestionnaireCreateDialogState extends State<GestionnaireCreateDialog> {
? null
: initialRelaisId;
}
_loadRelais();
if (widget.withRelais) {
_loadRelais();
} else {
_isLoadingRelais = false;
}
}
@override
@ -59,6 +115,7 @@ class _GestionnaireCreateDialogState extends State<GestionnaireCreateDialog> {
_emailController.dispose();
_passwordController.dispose();
_telephoneController.dispose();
_passwordToggleFocusNode.dispose();
super.dispose();
}
@ -122,7 +179,68 @@ class _GestionnaireCreateDialogState extends State<GestionnaireCreateDialog> {
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 {
if (widget.readOnly) return;
if (_isSubmitting) return;
if (!_formKey.currentState!.validate()) return;
@ -131,35 +249,87 @@ class _GestionnaireCreateDialogState extends State<GestionnaireCreateDialog> {
});
try {
final normalizedNom = _toTitleCase(_nomController.text);
final normalizedPrenom = _toTitleCase(_prenomController.text);
final normalizedPhone = _normalizePhone(_telephoneController.text);
final passwordProvided = _passwordController.text.trim().isNotEmpty;
if (_isEditMode) {
await UserService.updateGestionnaire(
gestionnaireId: widget.initialUser!.id,
nom: _nomController.text.trim(),
prenom: _prenomController.text.trim(),
email: _emailController.text.trim(),
telephone: _telephoneController.text.trim(),
relaisId: _selectedRelaisId,
password: _passwordController.text.trim().isEmpty
? null
: _passwordController.text,
);
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(
gestionnaireId: currentUser.id,
nom: normalizedNom,
prenom: normalizedPrenom,
email: _emailController.text.trim(),
telephone: normalizedPhone.isEmpty ? initialPhone : normalizedPhone,
relaisId: _selectedRelaisId,
password: passwordProvided ? _passwordController.text : null,
);
}
}
} else {
await UserService.createGestionnaire(
nom: _nomController.text.trim(),
prenom: _prenomController.text.trim(),
email: _emailController.text.trim(),
password: _passwordController.text,
telephone: _telephoneController.text.trim(),
relaisId: _selectedRelaisId,
);
if (widget.adminMode) {
await UserService.createAdministrateur(
nom: normalizedNom,
prenom: normalizedPrenom,
email: _emailController.text.trim(),
password: _passwordController.text,
telephone: _normalizePhone(_telephoneController.text),
);
} else {
await UserService.createGestionnaire(
nom: normalizedNom,
prenom: normalizedPrenom,
email: _emailController.text.trim(),
password: _passwordController.text,
telephone: _normalizePhone(_telephoneController.text),
relaisId: _selectedRelaisId,
);
}
}
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
_isEditMode
? 'Gestionnaire modifié avec succès.'
: 'Gestionnaire créé avec succès.',
? (widget.adminMode
? 'Administrateur modifié avec succès.'
: 'Gestionnaire modifié avec succès.')
: (widget.adminMode
? 'Administrateur créé avec succès.'
: 'Gestionnaire créé avec succès.'),
),
),
);
@ -183,6 +353,8 @@ class _GestionnaireCreateDialogState extends State<GestionnaireCreateDialog> {
}
Future<void> _delete() async {
if (widget.readOnly) return;
if (_isSuperAdminTarget) return;
if (!_isEditMode || _isSubmitting) return;
final confirmed = await showDialog<bool>(
@ -239,14 +411,26 @@ class _GestionnaireCreateDialogState extends State<GestionnaireCreateDialog> {
return AlertDialog(
title: Row(
children: [
CircleAvatar(
radius: 16,
backgroundColor: const Color(0xFFEDE5FA),
child: Icon(
_targetRoleIcon,
size: 20,
color: const Color(0xFF6B3FA0),
),
),
const SizedBox(width: 8),
Expanded(
child: Text(
_isEditMode
? 'Modifier un gestionnaire'
: 'Créer un gestionnaire',
? (widget.readOnly
? 'Consulter un "$_targetRoleLabel"'
: 'Modifier un "$_targetRoleLabel"')
: 'Créer un "$_targetRoleLabel"',
),
),
if (_isEditMode)
if (_isEditMode && !widget.readOnly)
IconButton(
icon: const Icon(Icons.close),
tooltip: 'Fermer',
@ -266,9 +450,9 @@ class _GestionnaireCreateDialogState extends State<GestionnaireCreateDialog> {
children: [
Row(
children: [
Expanded(child: _buildNomField()),
const SizedBox(width: 12),
Expanded(child: _buildPrenomField()),
const SizedBox(width: 12),
Expanded(child: _buildNomField()),
],
),
const SizedBox(height: 12),
@ -281,20 +465,28 @@ class _GestionnaireCreateDialogState extends State<GestionnaireCreateDialog> {
Expanded(child: _buildTelephoneField()),
],
),
const SizedBox(height: 12),
_buildRelaisField(),
if (widget.withRelais) ...[
const SizedBox(height: 12),
_buildRelaisField(),
],
],
),
),
),
),
actions: [
if (_isEditMode) ...[
OutlinedButton(
onPressed: _isSubmitting ? null : _delete,
style: OutlinedButton.styleFrom(foregroundColor: Colors.red.shade700),
child: const Text('Supprimer'),
if (widget.readOnly) ...[
FilledButton(
onPressed: _isSubmitting ? null : () => Navigator.of(context).pop(false),
child: const Text('Fermer'),
),
] else if (_isEditMode) ...[
if (!_isSuperAdminTarget)
OutlinedButton(
onPressed: _isSubmitting ? null : _delete,
style: OutlinedButton.styleFrom(foregroundColor: Colors.red.shade700),
child: const Text('Supprimer'),
),
FilledButton.icon(
onPressed: _isSubmitting ? null : _submit,
icon: _isSubmitting
@ -331,42 +523,50 @@ class _GestionnaireCreateDialogState extends State<GestionnaireCreateDialog> {
Widget _buildNomField() {
return TextFormField(
controller: _nomController,
readOnly: widget.readOnly || _isLockedAdminIdentity,
textCapitalization: TextCapitalization.words,
decoration: const InputDecoration(
labelText: 'Nom',
border: OutlineInputBorder(),
),
validator: (v) => _required(v, 'Nom'),
validator: (widget.readOnly || _isLockedAdminIdentity)
? null
: (v) => _required(v, 'Nom'),
);
}
Widget _buildPrenomField() {
return TextFormField(
controller: _prenomController,
readOnly: widget.readOnly || _isLockedAdminIdentity,
textCapitalization: TextCapitalization.words,
decoration: const InputDecoration(
labelText: 'Prénom',
border: OutlineInputBorder(),
),
validator: (v) => _required(v, 'Prénom'),
validator: (widget.readOnly || _isLockedAdminIdentity)
? null
: (v) => _required(v, 'Prénom'),
);
}
Widget _buildEmailField() {
return TextFormField(
controller: _emailController,
readOnly: widget.readOnly,
keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration(
labelText: 'Email',
border: OutlineInputBorder(),
),
validator: _validateEmail,
validator: widget.readOnly ? null : _validateEmail,
);
}
Widget _buildPasswordField() {
return TextFormField(
controller: _passwordController,
readOnly: widget.readOnly,
obscureText: _obscurePassword,
enableSuggestions: false,
autocorrect: false,
@ -378,30 +578,43 @@ class _GestionnaireCreateDialogState extends State<GestionnaireCreateDialog> {
? 'Nouveau mot de passe'
: 'Mot de passe',
border: const OutlineInputBorder(),
suffixIcon: IconButton(
onPressed: () {
setState(() {
_obscurePassword = !_obscurePassword;
});
},
icon: Icon(
_obscurePassword ? Icons.visibility_off : Icons.visibility,
),
),
suffixIcon: widget.readOnly
? null
: ExcludeFocus(
child: IconButton(
focusNode: _passwordToggleFocusNode,
onPressed: () {
setState(() {
_obscurePassword = !_obscurePassword;
});
},
icon: Icon(
_obscurePassword ? Icons.visibility_off : Icons.visibility,
),
),
),
),
validator: _validatePassword,
validator: widget.readOnly ? null : _validatePassword,
);
}
Widget _buildTelephoneField() {
return TextFormField(
controller: _telephoneController,
readOnly: widget.readOnly,
keyboardType: TextInputType.phone,
inputFormatters: widget.readOnly
? null
: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(10),
_FrenchPhoneNumberFormatter(),
],
decoration: const InputDecoration(
labelText: 'Téléphone',
labelText: 'Téléphone (ex: 06 12 34 56 78)',
border: OutlineInputBorder(),
),
validator: (v) => _required(v, 'Téléphone'),
validator: widget.readOnly ? null : _validatePhone,
);
}
@ -433,7 +646,7 @@ class _GestionnaireCreateDialogState extends State<GestionnaireCreateDialog> {
),
),
],
onChanged: _isLoadingRelais
onChanged: (_isLoadingRelais || widget.readOnly)
? null
: (value) {
setState(() {
@ -449,3 +662,27 @@ class _GestionnaireCreateDialogState extends State<GestionnaireCreateDialog> {
);
}
}
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,6 +75,41 @@ class UserService {
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
static Future<List<ParentModel>> getParents() async {
final response = await http.get(
@ -232,7 +267,7 @@ class UserService {
required String nom,
required String prenom,
required String email,
required String telephone,
String? telephone,
required String? relaisId,
String? password,
}) async {
@ -240,10 +275,13 @@ class UserService {
'nom': nom,
'prenom': prenom,
'email': email,
'telephone': telephone,
'relaisId': relaisId,
};
if (telephone != null && telephone.trim().isNotEmpty) {
body['telephone'] = telephone.trim();
}
if (password != null && password.trim().isNotEmpty) {
body['password'] = password.trim();
}
@ -270,6 +308,50 @@ class UserService {
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 {
final response = await http.delete(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.users}/$userId'),

View File

@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/user.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/services/auth_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/user_list.dart';
@ -21,10 +22,12 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
bool _isLoading = false;
String? _error;
List<AppUser> _admins = [];
String? _currentUserRole;
@override
void initState() {
super.initState();
_loadCurrentUserRole();
_loadAdmins();
}
@ -52,15 +55,44 @@ 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 {
final canEdit = _canEditAdmin(user);
final changed = await showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (dialogContext) {
return AdminCreateDialog(initialUser: user);
return AdminUserFormDialog(
initialUser: user,
adminMode: true,
withRelais: false,
readOnly: !canEdit,
);
},
);
if (changed == true) {
if (changed == true && canEdit) {
await _loadAdmins();
}
}
@ -82,17 +114,34 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
itemCount: filteredAdmins.length,
itemBuilder: (context, index) {
final user = filteredAdmins[index];
final isSuperAdmin = _isSuperAdmin(user);
final canEdit = _canEditAdmin(user);
return AdminUserCard(
title: user.fullName,
fallbackIcon: isSuperAdmin
? Icons.verified_user_outlined
: Icons.manage_accounts_outlined,
subtitleLines: [
user.email,
'Rôle : ${user.role}',
'Téléphone : ${user.telephone?.trim().isNotEmpty == true ? user.telephone : 'Non renseigné'}',
],
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: [
IconButton(
icon: const Icon(Icons.edit),
tooltip: 'Modifier',
icon: Icon(
canEdit ? Icons.edit_outlined : Icons.visibility_outlined,
),
tooltip: canEdit ? 'Modifier' : 'Consulter',
onPressed: () {
_openAdminEditDialog(user);
},

View File

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

View File

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

View File

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

View File

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