diff --git a/lib/app/router/app_router.dart b/lib/app/router/app_router.dart index 351c93a..fd3ec98 100644 --- a/lib/app/router/app_router.dart +++ b/lib/app/router/app_router.dart @@ -68,13 +68,13 @@ class AppRouter { onboardingState: state.onboardingState, organisationId: state.organisationId ?? '', souscriptionId: state.souscriptionId, + typeOrganisation: state.typeOrganisation, ); } else { return const LoginPage(); } }, ), - '/dashboard': (context) => const MainNavigationLayout(), '/login': (context) => const LoginPage(), '/about': (context) => const AboutPage(), '/help': (context) => const HelpSupportPage(), @@ -85,10 +85,9 @@ class AppRouter { '/solidarity': (context) => const DemandesAidePageWrapper(), '/reports': (context) => const ReportsPageWrapper(), '/finances': (context) => const CotisationsPageWrapper(), - '/my-finances': (context) => const CotisationsPageWrapper(), - '/moderation': (context) => const AdhesionsPageWrapper(), - '/communication': (context) => const ConversationsPage(), - '/org-settings': (context) => const SystemSettingsPage(), + '/adhesions': (context) => const AdhesionsPageWrapper(), + '/messages': (context) => const ConversationsPage(), + '/settings': (context) => const SystemSettingsPage(), '/analytics': (context) { final authState = context.read().state; if (authState is AuthAuthenticated) { @@ -102,12 +101,7 @@ class AppRouter { } return const LoginPage(); }, - '/security': (context) => const SystemSettingsPage(), - '/system-admin': (context) => const MainNavigationLayout(), '/global-users': (context) => const UserManagementPage(), - '/messages': (context) => const ConversationsPage(), - '/public-events': (context) => const EventsPageWrapper(), - '/contact': (context) => const HelpSupportPage(), '/approvals': (context) => const PendingApprovalsPage(), '/budgets': (context) => const BudgetsListPage(), }; diff --git a/lib/features/authentication/data/datasources/keycloak_auth_service.dart b/lib/features/authentication/data/datasources/keycloak_auth_service.dart index f205718..f8e5899 100644 --- a/lib/features/authentication/data/datasources/keycloak_auth_service.dart +++ b/lib/features/authentication/data/datasources/keycloak_auth_service.dart @@ -1,4 +1,5 @@ import 'package:dio/dio.dart'; +import 'package:flutter_appauth/flutter_appauth.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:jwt_decoder/jwt_decoder.dart'; import 'package:injectable/injectable.dart'; @@ -22,6 +23,7 @@ class KeycloakConfig { @lazySingleton class KeycloakAuthService { final Dio _dio = Dio(); + final FlutterAppAuth _appAuth = const FlutterAppAuth(); final FlutterSecureStorage _storage = const FlutterSecureStorage( aOptions: AndroidOptions(encryptedSharedPreferences: true), iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock_this_device), @@ -31,23 +33,28 @@ class KeycloakAuthService { static const String _refreshK = 'kc_refresh'; static const String _idK = 'kc_id'; - /// Login via Direct Access Grant (Username/Password) - Future login(String username, String password) async { + /// Login via Authorization Code Flow + PKCE (AppAuth) + Future loginWithAppAuth() async { try { - final response = await _dio.post( - KeycloakConfig.tokenEndpoint, - data: { - 'client_id': KeycloakConfig.clientId, - 'grant_type': 'password', - 'username': username, - 'password': password, - 'scope': KeycloakConfig.scopes, - }, - options: Options(contentType: Headers.formUrlEncodedContentType), + final result = await _appAuth.authorizeAndExchangeCode( + AuthorizationTokenRequest( + KeycloakConfig.clientId, + 'dev.lions.unionflow-mobile://auth/callback', + serviceConfiguration: AuthorizationServiceConfiguration( + authorizationEndpoint: '${KeycloakConfig.baseUrl}/realms/${KeycloakConfig.realm}/protocol/openid-connect/auth', + tokenEndpoint: KeycloakConfig.tokenEndpoint, + ), + scopes: ['openid', 'profile', 'email', 'roles', 'offline_access'], + additionalParameters: {'kc_locale': 'fr'}, + allowInsecureConnections: true, + ), ); - - if (response.statusCode == 200) { - await _saveTokens(response.data); + if (result?.accessToken != null) { + await _saveTokens({ + 'access_token': result!.accessToken, + 'refresh_token': result.refreshToken, + 'id_token': result.idToken, + }); return await getCurrentUser(); } } catch (e, st) { @@ -202,6 +209,9 @@ class KeycloakAuthService { souscriptionId: data['souscriptionId'] as String?, waveSessionId: data['waveSessionId'] as String?, organisationId: data['organisationId'] as String?, + typeOrganisation: data['typeOrganisation'] as String?, + premierLoginComplet: (data['premierLoginComplet'] as bool?) ?? false, + reAuthRequired: (data['reAuthRequired'] as bool?) ?? false, ); } } catch (e) { @@ -218,6 +228,11 @@ class AuthStatusResult { final String? souscriptionId; final String? waveSessionId; final String? organisationId; + final String? typeOrganisation; + /// true si le premier login vient d'être complété (token à rafraîchir pour avoir MEMBRE/MEMBRE_ACTIF) + final bool premierLoginComplet; + /// true si une réauthentification est requise (UPDATE_PASSWORD vient d'être assigné sur un ancien compte) + final bool reAuthRequired; const AuthStatusResult({ required this.statutCompte, @@ -225,6 +240,9 @@ class AuthStatusResult { this.souscriptionId, this.waveSessionId, this.organisationId, + this.typeOrganisation, + this.premierLoginComplet = false, + this.reAuthRequired = false, }); bool get isActive => statutCompte == 'ACTIF'; diff --git a/lib/features/authentication/presentation/bloc/auth_bloc.dart b/lib/features/authentication/presentation/bloc/auth_bloc.dart index 7d17706..907a2fb 100644 --- a/lib/features/authentication/presentation/bloc/auth_bloc.dart +++ b/lib/features/authentication/presentation/bloc/auth_bloc.dart @@ -19,11 +19,7 @@ abstract class AuthEvent extends Equatable { } class AuthLoginRequested extends AuthEvent { - final String email; - final String password; - const AuthLoginRequested(this.email, this.password); - @override - List get props => [email, password]; + const AuthLoginRequested(); } class AuthLogoutRequested extends AuthEvent { const AuthLogoutRequested(); } @@ -80,13 +76,15 @@ 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]; + List get props => [onboardingState, souscriptionId, organisationId, typeOrganisation]; } // === BLOC === @@ -104,10 +102,30 @@ class AuthBloc extends Bloc { Future _onLoginRequested(AuthLoginRequested event, Emitter emit) async { emit(AuthLoading()); try { - final rawUser = await _authService.login(event.email, event.password); + var rawUser = await _authService.loginWithAppAuth(); if (rawUser != null) { // Vérification du statut du compte UnionFlow (indépendant de Keycloak) - final status = await _authService.getAuthStatus(AppConfig.apiBaseUrl); + 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) @@ -120,6 +138,7 @@ class AuthBloc extends Bloc { onboardingState: status.onboardingState, souscriptionId: status.souscriptionId, organisationId: orgId, + typeOrganisation: status.typeOrganisation, )); return; } @@ -133,7 +152,17 @@ class AuthBloc extends Bloc { return; } - final user = await _enrichUserWithOrgContext(rawUser); + // 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); @@ -175,6 +204,15 @@ class AuthBloc extends Bloc { // 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 ?? @@ -185,6 +223,7 @@ class AuthBloc extends Bloc { onboardingState: status.onboardingState, souscriptionId: status.souscriptionId, organisationId: orgId, + typeOrganisation: status.typeOrganisation ?? 'ASSOCIATION', )); return; } @@ -198,7 +237,17 @@ class AuthBloc extends Bloc { return; } - final user = await _enrichUserWithOrgContext(rawUser); + // 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(