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:
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,7 +372,11 @@ 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,
|
||||
'age': membre.age,
|
||||
|
||||
Reference in New Issue
Block a user