feat(members): swipe par rôle + suppression SuperAdmin avec cascade UX

Swipe actions différenciées par rôle :
- SuperAdmin : → Reset MDP, ← Affecter Org
- OrgAdmin  : → Reset MDP, ← lifecycle selon statut (Suspendre/Activer/Réactiver)
  (masqué si cible = ORGADMIN/SUPERADMIN — cohérent avec guard backend)
- Autres rôles : → Reset MDP seulement

Suppression compte (SuperAdmin uniquement) :
- Nouveau callback onDeleteAccount dans MembersPage + MemberDetailPage
- Bouton rouge 'Supprimer ce compte' dans action sheet (zone destructive)
- Dialog de confirmation adaptatif dark/light avec badge admin si cible ORGADMIN
- Bouton caché si compte déjà désactivé (actif=false)
- Bannière 'Compte désactivé' visible sur page détail d'un compte soft-deleted
- BlocListener MembreDeleted : SnackBar + maybePop() + reload liste
- Bloc gère 409 Conflict (mono-admin) → MembresActionForbidden avec message backend

Nouvelles signatures :
- onLifecycleAction : (memberId, organisationId, action, motif) — inclut orgId
  pour permettre au SuperAdmin d'agir via l'org du membre lui-même
- 'actif' et 'roleCode' exposés dans la map via _convertMembreToMap
This commit is contained in:
dahoud
2026-04-15 20:14:08 +00:00
parent 55f84da49a
commit 36a903c80e
4 changed files with 1661 additions and 723 deletions

View File

@@ -278,6 +278,14 @@ class MembresBloc extends Bloc<MembresEvent, MembresState> {
emit(MembreDeleted(event.id)); emit(MembreDeleted(event.id));
} on DioException catch (e) { } on DioException catch (e) {
if (e.type == DioExceptionType.cancel) return; if (e.type == DioExceptionType.cancel) return;
// 409 Conflict = mono-admin orphelinage détecté par le backend.
// Émettre MembresActionForbidden avec le message métier du backend.
if (e.response?.statusCode == 409 || e.response?.statusCode == 403) {
final msg = _extractErrorMessage(e)
?? 'Suppression impossible : vérifiez que le membre n\'est pas le seul admin d\'une organisation.';
emit(MembresActionForbidden(message: msg, error: e));
return;
}
emit(MembresNetworkError( emit(MembresNetworkError(
message: _getNetworkErrorMessage(e), message: _getNetworkErrorMessage(e),
code: e.response?.statusCode.toString(), code: e.response?.statusCode.toString(),
@@ -582,6 +590,18 @@ class MembresBloc extends Bloc<MembresEvent, MembresState> {
} }
} }
/// Extrait le message d'erreur du corps JSON d'une réponse HTTP (champ "error"
/// ou "message"). Retourne null si non disponible.
String? _extractErrorMessage(DioException e) {
try {
final data = e.response?.data;
if (data is Map) {
return (data['message'] ?? data['error'])?.toString();
}
} catch (_) {}
return null;
}
// ── Handlers cycle de vie des adhésions ────────────────────────────────── // ── Handlers cycle de vie des adhésions ──────────────────────────────────
Future<void> _onInviterMembre( Future<void> _onInviterMembre(
@@ -629,6 +649,11 @@ class MembresBloc extends Bloc<MembresEvent, MembresState> {
)); ));
} on DioException catch (e) { } on DioException catch (e) {
if (e.type == DioExceptionType.cancel) return; if (e.type == DioExceptionType.cancel) return;
if (e.response?.statusCode == 403) {
final msg = _extractErrorMessage(e) ?? 'Action non autorisée sur ce membre.';
emit(MembresActionForbidden(message: msg, error: e));
return;
}
emit(MembresNetworkError( emit(MembresNetworkError(
message: _getNetworkErrorMessage(e), message: _getNetworkErrorMessage(e),
code: e.response?.statusCode.toString(), code: e.response?.statusCode.toString(),
@@ -656,6 +681,11 @@ class MembresBloc extends Bloc<MembresEvent, MembresState> {
)); ));
} on DioException catch (e) { } on DioException catch (e) {
if (e.type == DioExceptionType.cancel) return; if (e.type == DioExceptionType.cancel) return;
if (e.response?.statusCode == 403) {
final msg = _extractErrorMessage(e) ?? 'Action non autorisée sur ce membre.';
emit(MembresActionForbidden(message: msg, error: e));
return;
}
emit(MembresNetworkError( emit(MembresNetworkError(
message: _getNetworkErrorMessage(e), message: _getNetworkErrorMessage(e),
code: e.response?.statusCode.toString(), code: e.response?.statusCode.toString(),
@@ -683,6 +713,11 @@ class MembresBloc extends Bloc<MembresEvent, MembresState> {
)); ));
} on DioException catch (e) { } on DioException catch (e) {
if (e.type == DioExceptionType.cancel) return; if (e.type == DioExceptionType.cancel) return;
if (e.response?.statusCode == 403) {
final msg = _extractErrorMessage(e) ?? 'Action non autorisée sur ce membre.';
emit(MembresActionForbidden(message: msg, error: e));
return;
}
emit(MembresNetworkError( emit(MembresNetworkError(
message: _getNetworkErrorMessage(e), message: _getNetworkErrorMessage(e),
code: e.response?.statusCode.toString(), code: e.response?.statusCode.toString(),

View File

@@ -248,6 +248,12 @@ class MembresNetworkError extends MembresError {
}); });
} }
/// Accès refusé (HTTP 403) sur une action lifecycle (radier, suspendre, archiver).
/// Distinct de MembresNetworkError pour permettre un message utilisateur ciblé.
class MembresActionForbidden extends MembresError {
const MembresActionForbidden({required super.message, super.code = '403', super.error});
}
/// État d'erreur de validation /// État d'erreur de validation
class MembresValidationError extends MembresError { class MembresValidationError extends MembresError {
final Map<String, String> validationErrors; final Map<String, String> validationErrors;

View File

@@ -8,6 +8,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:get_it/get_it.dart'; import 'package:get_it/get_it.dart';
import '../../../../shared/design_system/tokens/color_tokens.dart';
import '../../../../shared/widgets/error_widget.dart'; import '../../../../shared/widgets/error_widget.dart';
import '../../../../shared/widgets/loading_widget.dart'; import '../../../../shared/widgets/loading_widget.dart';
import '../../../../core/utils/logger.dart'; import '../../../../core/utils/logger.dart';
@@ -60,13 +61,31 @@ class MembersPageConnected extends StatelessWidget {
context.read<MembresBloc>().add(LoadMembres(refresh: true, organisationId: organisationId)); context.read<MembresBloc>().add(LoadMembres(refresh: true, organisationId: organisationId));
} }
// Accès refusé sur une action lifecycle (403) — SnackBar ciblé + rechargement liste
if (state is MembresActionForbidden) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(children: [
const Icon(Icons.block_outlined, color: Colors.white, size: 18),
const SizedBox(width: 8),
Expanded(child: Text(state.message)),
]),
backgroundColor: ColorTokens.error,
duration: const Duration(seconds: 5),
),
);
// Recharger pour revenir à l'état MembresLoaded (évite la page d'erreur plein écran)
context.read<MembresBloc>().add(LoadMembres(organisationId: organisationId));
return;
}
// Gestion des erreurs avec SnackBar // Gestion des erreurs avec SnackBar
if (state is MembresError) { if (state is MembresError) {
final bloc = context.read<MembresBloc>(); final bloc = context.read<MembresBloc>();
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text(state.message), content: Text(state.message),
backgroundColor: Colors.red, backgroundColor: ColorTokens.error,
duration: const Duration(seconds: 4), duration: const Duration(seconds: 4),
action: SnackBarAction( action: SnackBarAction(
label: 'Réessayer', label: 'Réessayer',
@@ -84,13 +103,31 @@ class MembersPageConnected extends StatelessWidget {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( const SnackBar(
content: Text('Membre activé avec succès'), content: Text('Membre activé avec succès'),
backgroundColor: Colors.green, backgroundColor: ColorTokens.success,
duration: Duration(seconds: 3), duration: Duration(seconds: 3),
), ),
); );
context.read<MembresBloc>().add(LoadMembres(refresh: true, organisationId: organisationId)); context.read<MembresBloc>().add(LoadMembres(refresh: true, organisationId: organisationId));
} }
// Après suppression définitive : feedback + pop la page de détail si ouverte + rechargement
if (state is MembreDeleted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(children: const [
Icon(Icons.delete_forever, color: Colors.white, size: 18),
SizedBox(width: 8),
Expanded(child: Text('Compte supprimé avec succès')),
]),
backgroundColor: ColorTokens.error,
duration: const Duration(seconds: 3),
),
);
// Si on est dans MemberDetailPage, revenir à la liste (pop unique)
Navigator.of(context).maybePop();
context.read<MembresBloc>().add(LoadMembres(refresh: true, organisationId: organisationId));
}
// Après reset mot de passe : afficher le dialog credentials // Après reset mot de passe : afficher le dialog credentials
if (state is MotDePasseReinitialise) { if (state is MotDePasseReinitialise) {
showCredentialsDialog(context, state.membre); showCredentialsDialog(context, state.membre);
@@ -101,7 +138,7 @@ class MembersPageConnected extends StatelessWidget {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( const SnackBar(
content: Text('Membre affecté à l\'organisation avec succès'), content: Text('Membre affecté à l\'organisation avec succès'),
backgroundColor: Colors.green, backgroundColor: ColorTokens.success,
duration: Duration(seconds: 3), duration: Duration(seconds: 3),
), ),
); );
@@ -111,25 +148,25 @@ class MembersPageConnected extends StatelessWidget {
// Lifecycle adhésion : succès + rechargement // Lifecycle adhésion : succès + rechargement
if (state is MembreInvite) { if (state is MembreInvite) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Invitation envoyée'), backgroundColor: Colors.blue, duration: Duration(seconds: 3)), const SnackBar(content: Text('Invitation envoyée'), backgroundColor: ColorTokens.info, duration: Duration(seconds: 3)),
); );
context.read<MembresBloc>().add(LoadMembres(refresh: true, organisationId: organisationId)); context.read<MembresBloc>().add(LoadMembres(refresh: true, organisationId: organisationId));
} }
if (state is AdhesionActivee) { if (state is AdhesionActivee) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Adhésion activée'), backgroundColor: Colors.green, duration: Duration(seconds: 3)), const SnackBar(content: Text('Adhésion activée'), backgroundColor: ColorTokens.success, duration: Duration(seconds: 3)),
); );
context.read<MembresBloc>().add(LoadMembres(refresh: true, organisationId: organisationId)); context.read<MembresBloc>().add(LoadMembres(refresh: true, organisationId: organisationId));
} }
if (state is AdhesionSuspendue) { if (state is AdhesionSuspendue) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Adhésion suspendue'), backgroundColor: Colors.orange, duration: Duration(seconds: 3)), const SnackBar(content: Text('Adhésion suspendue'), backgroundColor: ColorTokens.warning, duration: Duration(seconds: 3)),
); );
context.read<MembresBloc>().add(LoadMembres(refresh: true, organisationId: organisationId)); context.read<MembresBloc>().add(LoadMembres(refresh: true, organisationId: organisationId));
} }
if (state is MembreRadie) { if (state is MembreRadie) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Membre radié de l\'organisation'), backgroundColor: Colors.red, duration: Duration(seconds: 3)), const SnackBar(content: Text('Membre radié de l\'organisation'), backgroundColor: ColorTokens.error, duration: Duration(seconds: 3)),
); );
context.read<MembresBloc>().add(LoadMembres(refresh: true, organisationId: organisationId)); context.read<MembresBloc>().add(LoadMembres(refresh: true, organisationId: organisationId));
} }
@@ -141,7 +178,7 @@ class MembersPageConnected extends StatelessWidget {
// État initial // État initial
if (state is MembresInitial) { if (state is MembresInitial) {
return Container( return Container(
color: const Color(0xFFF8F9FA), color: null,
child: const Center( child: const Center(
child: AppLoadingWidget(message: 'Initialisation...'), child: AppLoadingWidget(message: 'Initialisation...'),
), ),
@@ -151,7 +188,7 @@ class MembersPageConnected extends StatelessWidget {
// État de chargement // État de chargement
if (state is MembresLoading) { if (state is MembresLoading) {
return Container( return Container(
color: const Color(0xFFF8F9FA), color: null,
child: const Center( child: const Center(
child: AppLoadingWidget(message: 'Chargement des membres...'), child: AppLoadingWidget(message: 'Chargement des membres...'),
), ),
@@ -162,7 +199,7 @@ class MembersPageConnected extends StatelessWidget {
if (state is MembresRefreshing) { if (state is MembresRefreshing) {
// Affiche un indicateur pendant le rafraîchissement // Affiche un indicateur pendant le rafraîchissement
return Container( return Container(
color: const Color(0xFFF8F9FA), color: null,
child: const Center( child: const Center(
child: AppLoadingWidget(message: 'Actualisation...'), child: AppLoadingWidget(message: 'Actualisation...'),
), ),
@@ -172,7 +209,7 @@ class MembersPageConnected extends StatelessWidget {
// Après création : on recharge la liste (listener a dispatché LoadMembres) // Après création : on recharge la liste (listener a dispatché LoadMembres)
if (state is MembreCreated) { if (state is MembreCreated) {
return Container( return Container(
color: const Color(0xFFF8F9FA), color: null,
child: const Center( child: const Center(
child: AppLoadingWidget(message: 'Actualisation...'), child: AppLoadingWidget(message: 'Actualisation...'),
), ),
@@ -216,25 +253,35 @@ class MembersPageConnected extends StatelessWidget {
context.read<MembresBloc>().add(AffecterOrganisation(memberId, orgId)); context.read<MembresBloc>().add(AffecterOrganisation(memberId, orgId));
} }
: null, : null,
onLifecycleAction: organisationId != null // Suppression définitive — SuperAdmin uniquement (organisationId == null)
? (memberId, action, motif) { // Backend : DELETE /api/membres/{id} @RolesAllowed({ ADMIN, SUPER_ADMIN })
final bloc = context.read<MembresBloc>(); onDeleteAccount: organisationId == null
switch (action) { ? (memberId) {
case 'inviter': context.read<MembresBloc>().add(DeleteMembre(memberId));
bloc.add(InviterMembre(membreId: memberId, organisationId: organisationId!));
break;
case 'activer':
bloc.add(ActiverAdhesion(membreId: memberId, organisationId: organisationId!, motif: motif));
break;
case 'suspendre':
bloc.add(SuspendrAdhesion(membreId: memberId, organisationId: organisationId!, motif: motif));
break;
case 'radier':
bloc.add(RadierAdhesion(membreId: memberId, organisationId: organisationId!, motif: motif));
break;
}
} }
: null, : null,
// onLifecycleAction est toujours fourni.
// orgAdmin : organisationId fixe (contexte org).
// superAdmin : organisationId provient du membre lui-même (passé par _lifecycleButtons).
onLifecycleAction: (memberId, targetOrgId, action, motif) {
final bloc = context.read<MembresBloc>();
// En contexte orgAdmin on utilise l'org fixe, en superAdmin l'org du membre
final effectiveOrgId = organisationId ?? targetOrgId;
switch (action) {
case 'inviter':
bloc.add(InviterMembre(membreId: memberId, organisationId: effectiveOrgId));
break;
case 'activer':
bloc.add(ActiverAdhesion(membreId: memberId, organisationId: effectiveOrgId, motif: motif));
break;
case 'suspendre':
bloc.add(SuspendrAdhesion(membreId: memberId, organisationId: effectiveOrgId, motif: motif));
break;
case 'radier':
bloc.add(RadierAdhesion(membreId: memberId, organisationId: effectiveOrgId, motif: motif));
break;
}
},
); );
} }
@@ -242,7 +289,7 @@ class MembersPageConnected extends StatelessWidget {
if (state is MembresNetworkError) { if (state is MembresNetworkError) {
AppLogger.error('MembersPageConnected: Erreur réseau', error: state.message); AppLogger.error('MembersPageConnected: Erreur réseau', error: state.message);
return Container( return Container(
color: const Color(0xFFF8F9FA), color: null,
child: NetworkErrorWidget( child: NetworkErrorWidget(
onRetry: () { onRetry: () {
AppLogger.userAction('Retry load membres after network error'); AppLogger.userAction('Retry load membres after network error');
@@ -256,7 +303,7 @@ class MembersPageConnected extends StatelessWidget {
if (state is MembresError) { if (state is MembresError) {
AppLogger.error('MembersPageConnected: Erreur', error: state.message); AppLogger.error('MembersPageConnected: Erreur', error: state.message);
return Container( return Container(
color: const Color(0xFFF8F9FA), color: null,
child: AppErrorWidget( child: AppErrorWidget(
message: state.message, message: state.message,
onRetry: () { onRetry: () {
@@ -270,7 +317,7 @@ class MembersPageConnected extends StatelessWidget {
// État par défaut (ne devrait jamais arriver) // État par défaut (ne devrait jamais arriver)
AppLogger.warning('MembersPageConnected: État non géré: ${state.runtimeType}'); AppLogger.warning('MembersPageConnected: État non géré: ${state.runtimeType}');
return Container( return Container(
color: const Color(0xFFF8F9FA), color: null,
child: const Center( child: const Center(
child: AppLoadingWidget(message: 'Chargement...'), child: AppLoadingWidget(message: 'Chargement...'),
), ),
@@ -292,6 +339,8 @@ class MembersPageConnected extends StatelessWidget {
'id': membre.id ?? '', 'id': membre.id ?? '',
'name': membre.nomComplet, 'name': membre.nomComplet,
'email': membre.email, 'email': membre.email,
'actif': membre.actif, // flag global désactivation (soft delete)
'roleCode': membre.role, // code brut ex: ORGADMIN, SUPERADMIN — utilisé pour les checks de permission
'role': _mapRoleToString(membre.role), 'role': _mapRoleToString(membre.role),
'status': _mapStatutToString(membre.statut), 'status': _mapStatutToString(membre.statut),
'joinDate': membre.dateAdhesion, 'joinDate': membre.dateAdhesion,
@@ -323,7 +372,11 @@ class MembersPageConnected extends StatelessWidget {
'fonctionBureau': membre.fonctionBureau, 'fonctionBureau': membre.fonctionBureau,
'numeroMembre': membre.numeroMembre, 'numeroMembre': membre.numeroMembre,
'cotisationAJour': membre.cotisationAJour, 'cotisationAJour': membre.cotisationAJour,
'organisationNom': membre.organisationNom,
'statutKyc': membre.statutKyc?.name,
'niveauVigilanceKyc': membre.niveauVigilanceKyc?.name,
'statutMembre': membre.statut?.name,
// Propriétés calculées // Propriétés calculées
'initiales': membre.initiales, 'initiales': membre.initiales,
'age': membre.age, 'age': membre.age,