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));
} on DioException catch (e) {
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(
message: _getNetworkErrorMessage(e),
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 ──────────────────────────────────
Future<void> _onInviterMembre(
@@ -629,6 +649,11 @@ class MembresBloc extends Bloc<MembresEvent, MembresState> {
));
} on DioException catch (e) {
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(
message: _getNetworkErrorMessage(e),
code: e.response?.statusCode.toString(),
@@ -656,6 +681,11 @@ class MembresBloc extends Bloc<MembresEvent, MembresState> {
));
} on DioException catch (e) {
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(
message: _getNetworkErrorMessage(e),
code: e.response?.statusCode.toString(),
@@ -683,6 +713,11 @@ class MembresBloc extends Bloc<MembresEvent, MembresState> {
));
} on DioException catch (e) {
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(
message: _getNetworkErrorMessage(e),
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
class MembresValidationError extends MembresError {
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:get_it/get_it.dart';
import '../../../../shared/design_system/tokens/color_tokens.dart';
import '../../../../shared/widgets/error_widget.dart';
import '../../../../shared/widgets/loading_widget.dart';
import '../../../../core/utils/logger.dart';
@@ -60,13 +61,31 @@ class MembersPageConnected extends StatelessWidget {
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
if (state is MembresError) {
final bloc = context.read<MembresBloc>();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.message),
backgroundColor: Colors.red,
backgroundColor: ColorTokens.error,
duration: const Duration(seconds: 4),
action: SnackBarAction(
label: 'Réessayer',
@@ -84,13 +103,31 @@ class MembersPageConnected extends StatelessWidget {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Membre activé avec succès'),
backgroundColor: Colors.green,
backgroundColor: ColorTokens.success,
duration: Duration(seconds: 3),
),
);
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
if (state is MotDePasseReinitialise) {
showCredentialsDialog(context, state.membre);
@@ -101,7 +138,7 @@ class MembersPageConnected extends StatelessWidget {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Membre affecté à l\'organisation avec succès'),
backgroundColor: Colors.green,
backgroundColor: ColorTokens.success,
duration: Duration(seconds: 3),
),
);
@@ -111,25 +148,25 @@ class MembersPageConnected extends StatelessWidget {
// Lifecycle adhésion : succès + rechargement
if (state is MembreInvite) {
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));
}
if (state is AdhesionActivee) {
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));
}
if (state is AdhesionSuspendue) {
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));
}
if (state is MembreRadie) {
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));
}
@@ -141,7 +178,7 @@ class MembersPageConnected extends StatelessWidget {
// État initial
if (state is MembresInitial) {
return Container(
color: const Color(0xFFF8F9FA),
color: null,
child: const Center(
child: AppLoadingWidget(message: 'Initialisation...'),
),
@@ -151,7 +188,7 @@ class MembersPageConnected extends StatelessWidget {
// État de chargement
if (state is MembresLoading) {
return Container(
color: const Color(0xFFF8F9FA),
color: null,
child: const Center(
child: AppLoadingWidget(message: 'Chargement des membres...'),
),
@@ -162,7 +199,7 @@ class MembersPageConnected extends StatelessWidget {
if (state is MembresRefreshing) {
// Affiche un indicateur pendant le rafraîchissement
return Container(
color: const Color(0xFFF8F9FA),
color: null,
child: const Center(
child: AppLoadingWidget(message: 'Actualisation...'),
),
@@ -172,7 +209,7 @@ class MembersPageConnected extends StatelessWidget {
// Après création : on recharge la liste (listener a dispatché LoadMembres)
if (state is MembreCreated) {
return Container(
color: const Color(0xFFF8F9FA),
color: null,
child: const Center(
child: AppLoadingWidget(message: 'Actualisation...'),
),
@@ -216,25 +253,35 @@ class MembersPageConnected extends StatelessWidget {
context.read<MembresBloc>().add(AffecterOrganisation(memberId, orgId));
}
: null,
onLifecycleAction: organisationId != null
? (memberId, action, motif) {
final bloc = context.read<MembresBloc>();
switch (action) {
case 'inviter':
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;
}
// Suppression définitive — SuperAdmin uniquement (organisationId == null)
// Backend : DELETE /api/membres/{id} @RolesAllowed({ ADMIN, SUPER_ADMIN })
onDeleteAccount: organisationId == null
? (memberId) {
context.read<MembresBloc>().add(DeleteMembre(memberId));
}
: 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) {
AppLogger.error('MembersPageConnected: Erreur réseau', error: state.message);
return Container(
color: const Color(0xFFF8F9FA),
color: null,
child: NetworkErrorWidget(
onRetry: () {
AppLogger.userAction('Retry load membres after network error');
@@ -256,7 +303,7 @@ class MembersPageConnected extends StatelessWidget {
if (state is MembresError) {
AppLogger.error('MembersPageConnected: Erreur', error: state.message);
return Container(
color: const Color(0xFFF8F9FA),
color: null,
child: AppErrorWidget(
message: state.message,
onRetry: () {
@@ -270,7 +317,7 @@ class MembersPageConnected extends StatelessWidget {
// État par défaut (ne devrait jamais arriver)
AppLogger.warning('MembersPageConnected: État non géré: ${state.runtimeType}');
return Container(
color: const Color(0xFFF8F9FA),
color: null,
child: const Center(
child: AppLoadingWidget(message: 'Chargement...'),
),
@@ -292,6 +339,8 @@ class MembersPageConnected extends StatelessWidget {
'id': membre.id ?? '',
'name': membre.nomComplet,
'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),
'status': _mapStatutToString(membre.statut),
'joinDate': membre.dateAdhesion,
@@ -323,6 +372,10 @@ class MembersPageConnected extends StatelessWidget {
'fonctionBureau': membre.fonctionBureau,
'numeroMembre': membre.numeroMembre,
'cotisationAJour': membre.cotisationAJour,
'organisationNom': membre.organisationNom,
'statutKyc': membre.statutKyc?.name,
'niveauVigilanceKyc': membre.niveauVigilanceKyc?.name,
'statutMembre': membre.statut?.name,
// Propriétés calculées
'initiales': membre.initiales,