import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:equatable/equatable.dart'; import 'package:injectable/injectable.dart'; import '../../data/models/user.dart'; import '../../data/models/user_role.dart'; import '../../data/datasources/keycloak_auth_service.dart'; import '../../data/datasources/permission_engine.dart'; import '../../../../core/config/environment.dart'; import '../../../../core/storage/dashboard_cache_manager.dart'; import '../../../../core/utils/logger.dart'; import '../../../../core/di/injection.dart'; import '../../../organizations/domain/repositories/organization_repository.dart'; // === ÉVÉNEMENTS === abstract class AuthEvent extends Equatable { const AuthEvent(); @override List get props => []; } class AuthLoginRequested extends AuthEvent { const AuthLoginRequested(); } class AuthLogoutRequested extends AuthEvent { const AuthLogoutRequested(); } class AuthStatusChecked extends AuthEvent { const AuthStatusChecked(); } class AuthTokenRefreshRequested extends AuthEvent { const AuthTokenRefreshRequested(); } // === ÉTATS === abstract class AuthState extends Equatable { const AuthState(); @override List get props => []; } class AuthInitial extends AuthState {} class AuthLoading extends AuthState {} class AuthUnauthenticated extends AuthState {} class AuthAuthenticated extends AuthState { final User user; final UserRole effectiveRole; final List effectivePermissions; final String accessToken; const AuthAuthenticated({ required this.user, required this.effectiveRole, required this.effectivePermissions, required this.accessToken, }); @override List get props => [user, effectiveRole, effectivePermissions, accessToken]; } class AuthError extends AuthState { final String message; const AuthError(this.message); @override List get props => [message]; } /// Compte bloqué (SUSPENDU ou DESACTIVE) — déconnexion + message. class AuthAccountNotActive extends AuthState { final String statutCompte; final String message; const AuthAccountNotActive({required this.statutCompte, required this.message}); @override List get props => [statutCompte, message]; } /// Compte EN_ATTENTE_VALIDATION — l'OrgAdmin doit compléter l'onboarding. /// On ne déconnecte PAS pour permettre les appels API de souscription. class AuthPendingOnboarding extends AuthState { final String onboardingState; // NO_SUBSCRIPTION | AWAITING_PAYMENT | PAYMENT_INITIATED | AWAITING_VALIDATION final String? souscriptionId; final String? organisationId; final String? typeOrganisation; const AuthPendingOnboarding({ required this.onboardingState, this.souscriptionId, this.organisationId, this.typeOrganisation, }); @override List get props => [onboardingState, souscriptionId, organisationId, typeOrganisation]; } // === BLOC === @lazySingleton class AuthBloc extends Bloc { final KeycloakAuthService _authService; AuthBloc(this._authService) : super(AuthInitial()) { on(_onLoginRequested); on(_onLogoutRequested); on(_onStatusChecked); on(_onTokenRefreshRequested); } Future _onLoginRequested(AuthLoginRequested event, Emitter emit) async { emit(AuthLoading()); try { var rawUser = await _authService.loginWithAppAuth(); if (rawUser != null) { // Vérification du statut du compte UnionFlow (indépendant de Keycloak) var status = await _authService.getAuthStatus(AppConfig.apiBaseUrl); // Ancien compte détecté : UPDATE_PASSWORD vient d'être assigné dans Keycloak. // Déclencher une nouvelle authentification AppAuth pour afficher l'écran de changement. if (status != null && status.reAuthRequired) { AppLogger.info('AuthBloc: réauthentification requise (ancien compte), re-déclenchement AppAuth'); await _authService.logout(); final reAuthUser = await _authService.loginWithAppAuth(); if (reAuthUser == null) { emit(const AuthError('Connexion annulée.')); return; } rawUser = reAuthUser; status = await _authService.getAuthStatus(AppConfig.apiBaseUrl); // Garde-fou : si toujours reAuthRequired après la seconde tentative → erreur de config if (status != null && status.reAuthRequired) { AppLogger.error('AuthBloc: reAuthRequired persistant après réauthentification'); emit(const AuthError('Erreur de configuration du compte. Contactez votre administrateur.')); return; } } if (status != null && status.isPendingOnboarding) { // OrgAdmin en attente → rediriger vers l'onboarding (sans déconnecter) final user = await _enrichUserWithOrgContext(rawUser); final orgId = status.organisationId ?? (user.organizationContexts.isNotEmpty ? user.organizationContexts.first.organizationId : null); emit(AuthPendingOnboarding( onboardingState: status.onboardingState, souscriptionId: status.souscriptionId, organisationId: orgId, typeOrganisation: status.typeOrganisation, )); return; } if (status != null && status.isBlocked) { await _authService.logout(); emit(AuthAccountNotActive( statutCompte: status.statutCompte, message: _messageForStatut(status.statutCompte), )); return; } // Si premier login venant d'être complété, rafraîchir le token pour obtenir // les rôles MEMBRE + MEMBRE_ACTIF assignés par le backend lors de l'activation. User user; if (status != null && status.premierLoginComplet) { await _authService.refreshToken(); final refreshedRawUser = await _authService.getCurrentUser(); user = await _enrichUserWithOrgContext(refreshedRawUser ?? rawUser); } else { user = await _enrichUserWithOrgContext(rawUser); } final permissions = await PermissionEngine.getEffectivePermissions(user); final token = await _authService.getValidToken(); await DashboardCacheManager.invalidateForRole(user.primaryRole); emit(AuthAuthenticated( user: user, effectiveRole: user.primaryRole, effectivePermissions: permissions, accessToken: token ?? '', )); } else { emit(const AuthError('Identifiants incorrects.')); } } catch (e) { emit(AuthError('Erreur de connexion: $e')); } } Future _onLogoutRequested(AuthLogoutRequested event, Emitter emit) async { emit(AuthLoading()); await _authService.logout(); await DashboardCacheManager.clear(); emit(AuthUnauthenticated()); } Future _onStatusChecked(AuthStatusChecked event, Emitter emit) async { final tokenValid = await _authService.getValidToken(); final isAuth = tokenValid != null; if (!isAuth) { emit(AuthUnauthenticated()); return; } final rawUser = await _authService.getCurrentUser(); if (rawUser == null) { emit(AuthUnauthenticated()); return; } // Vérification du statut du compte (au redémarrage de l'app) final status = await _authService.getAuthStatus(AppConfig.apiBaseUrl); // Ancien compte sans UPDATE_PASSWORD : effacer la session locale et renvoyer vers le login. // L'utilisateur sera invité à se reconnecter — Keycloak affichera l'écran de changement de mot de passe. if (status != null && status.reAuthRequired) { AppLogger.info('AuthBloc: réauthentification requise au démarrage (ancien compte)'); await _authService.logout(); emit(AuthUnauthenticated()); return; } if (status != null && status.isPendingOnboarding) { final user = await _enrichUserWithOrgContext(rawUser); final orgId = status.organisationId ?? (user.organizationContexts.isNotEmpty ? user.organizationContexts.first.organizationId : null); emit(AuthPendingOnboarding( onboardingState: status.onboardingState, souscriptionId: status.souscriptionId, organisationId: orgId, typeOrganisation: status.typeOrganisation ?? 'ASSOCIATION', )); return; } if (status != null && status.isBlocked) { await _authService.logout(); emit(AuthAccountNotActive( statutCompte: status.statutCompte, message: _messageForStatut(status.statutCompte), )); return; } // Si premier login venant d'être complété, rafraîchir le token pour obtenir // les rôles MEMBRE + MEMBRE_ACTIF assignés par le backend lors de l'activation. User user; if (status != null && status.premierLoginComplet) { await _authService.refreshToken(); final refreshedRawUser = await _authService.getCurrentUser(); user = await _enrichUserWithOrgContext(refreshedRawUser ?? rawUser); } else { user = await _enrichUserWithOrgContext(rawUser); } final permissions = await PermissionEngine.getEffectivePermissions(user); final token = await _authService.getValidToken(); emit(AuthAuthenticated( user: user, effectiveRole: user.primaryRole, effectivePermissions: permissions, accessToken: token ?? '', )); } /// Retourne un message lisible selon le statut du compte. String _messageForStatut(String statut) { switch (statut) { case 'EN_ATTENTE_VALIDATION': return 'Votre compte est en attente de validation par un administrateur. Vous serez notifié dès que votre accès sera activé.'; case 'SUSPENDU': return 'Votre compte a été suspendu temporairement. Contactez votre administrateur pour plus d\'informations.'; case 'DESACTIVE': return 'Votre compte a été désactivé. Contactez votre administrateur.'; default: return 'Votre compte n\'est pas encore actif. Contactez votre administrateur.'; } } /// Enrichit le contexte organisationnel pour les AdminOrganisation. /// /// Si le rôle est [UserRole.orgAdmin] et que [organizationContexts] est vide, /// appelle GET /api/organisations/mes pour récupérer les organisations de l'admin. Future _enrichUserWithOrgContext(User user) async { if (user.primaryRole != UserRole.orgAdmin || user.organizationContexts.isNotEmpty) { return user; } try { final orgRepo = getIt(); final orgs = await orgRepo.getMesOrganisations(); if (orgs.isEmpty) return user; final contexts = orgs .where((o) => o.id != null && o.id!.isNotEmpty) .map( (o) => UserOrganizationContext( organizationId: o.id!, organizationName: o.nom, role: UserRole.orgAdmin, joinedAt: DateTime.now(), ), ) .toList(); return contexts.isEmpty ? user : user.copyWith(organizationContexts: contexts); } catch (e) { AppLogger.warning('AuthBloc: impossible de charger le contexte org: $e'); return user; } } Future _onTokenRefreshRequested(AuthTokenRefreshRequested event, Emitter emit) async { if (state is AuthAuthenticated) { final newToken = await _authService.refreshToken(); final success = newToken != null; if (success) { add(AuthStatusChecked()); } else { add(AuthLogoutRequested()); } } } }