feat: WebSocket temps réel + Finance Workflow + corrections
- Task #6: WebSocket /ws/dashboard + Kafka events (5 topics) * Backend: KafkaEventProducer, KafkaEventConsumer * Mobile: WebSocketService (reconnection, heartbeat, typed events) * DashboardBloc: Auto-refresh depuis WebSocket events - Finance Workflow: approbations + budgets (backend + mobile) * Backend: entities, services, resources, migrations Flyway V6 * Mobile: features finance_workflow complète avec BLoC - Corrections DI: interfaces IRepository partout * IProfileRepository, IOrganizationRepository, IMembreRepository * GetIt configuré avec @injectable - Spec-Kit: constitution + templates mis à jour * .specify/memory/constitution.md enrichie * Templates agent, plan, spec, tasks, checklist - Nettoyage: fichiers temporaires supprimés Signed-off-by: lions dev Team
This commit is contained in:
@@ -1,71 +0,0 @@
|
||||
/// Gestionnaire de cache pour le dashboard
|
||||
/// Cache intelligent basé sur les rôles utilisateurs
|
||||
library dashboard_cache_manager;
|
||||
|
||||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../models/user_role.dart';
|
||||
|
||||
/// Gestionnaire de cache pour optimiser les performances du dashboard
|
||||
class DashboardCacheManager {
|
||||
static final Map<String, dynamic> _cache = {};
|
||||
static final Map<String, DateTime> _cacheTimestamps = {};
|
||||
static const Duration _cacheExpiry = Duration(minutes: 15);
|
||||
|
||||
/// Invalide le cache pour un rôle spécifique
|
||||
static Future<void> invalidateForRole(UserRole role) async {
|
||||
final keysToRemove = _cache.keys
|
||||
.where((key) => key.startsWith('dashboard_${role.name}'))
|
||||
.toList();
|
||||
|
||||
for (final key in keysToRemove) {
|
||||
_cache.remove(key);
|
||||
_cacheTimestamps.remove(key);
|
||||
}
|
||||
|
||||
debugPrint('🗑️ Cache invalidé pour le rôle: ${role.displayName}');
|
||||
}
|
||||
|
||||
/// Vide complètement le cache
|
||||
static Future<void> clear() async {
|
||||
_cache.clear();
|
||||
_cacheTimestamps.clear();
|
||||
debugPrint('🧹 Cache dashboard complètement vidé');
|
||||
}
|
||||
|
||||
/// Obtient une valeur du cache
|
||||
static T? get<T>(String key) {
|
||||
final timestamp = _cacheTimestamps[key];
|
||||
if (timestamp == null) return null;
|
||||
|
||||
// Vérifier l'expiration
|
||||
if (DateTime.now().difference(timestamp) > _cacheExpiry) {
|
||||
_cache.remove(key);
|
||||
_cacheTimestamps.remove(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
return _cache[key] as T?;
|
||||
}
|
||||
|
||||
/// Met une valeur en cache
|
||||
static void set<T>(String key, T value) {
|
||||
_cache[key] = value;
|
||||
_cacheTimestamps[key] = DateTime.now();
|
||||
}
|
||||
|
||||
/// Obtient les statistiques du cache
|
||||
static Map<String, dynamic> getStats() {
|
||||
final now = DateTime.now();
|
||||
final validEntries = _cacheTimestamps.entries
|
||||
.where((entry) => now.difference(entry.value) <= _cacheExpiry)
|
||||
.length;
|
||||
|
||||
return {
|
||||
'totalEntries': _cache.length,
|
||||
'validEntries': validEntries,
|
||||
'expiredEntries': _cache.length - validEntries,
|
||||
'cacheHitRate': '${(validEntries / _cache.length * 100).toStringAsFixed(1)}%',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,419 +1,183 @@
|
||||
/// Service d'Authentification Keycloak
|
||||
/// Gère l'authentification avec votre instance Keycloak sur port 8180
|
||||
library keycloak_auth_service;
|
||||
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_appauth/flutter_appauth.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:jwt_decoder/jwt_decoder.dart';
|
||||
import 'package:injectable/injectable.dart';
|
||||
import '../models/user.dart';
|
||||
import '../models/user_role.dart';
|
||||
import 'keycloak_role_mapper.dart';
|
||||
import 'keycloak_webview_auth_service.dart';
|
||||
import '../../../../core/config/environment.dart';
|
||||
import '../../../../core/utils/logger.dart';
|
||||
|
||||
/// Configuration Keycloak pour votre instance
|
||||
/// Configuration Keycloak centralisée
|
||||
class KeycloakConfig {
|
||||
/// URL de base de votre Keycloak (depuis AppConfig)
|
||||
static String get baseUrl => AppConfig.keycloakBaseUrl;
|
||||
|
||||
/// Realm UnionFlow
|
||||
static const String realm = 'unionflow';
|
||||
|
||||
/// Client ID pour l'application mobile
|
||||
static const String clientId = 'unionflow-mobile';
|
||||
static const String scopes = 'openid profile email roles';
|
||||
|
||||
/// URL de redirection après authentification
|
||||
static const String redirectUrl = 'dev.lions.unionflow-mobile://auth/callback';
|
||||
|
||||
/// Scopes demandés
|
||||
static const List<String> scopes = ['openid', 'profile', 'email', 'roles'];
|
||||
|
||||
/// Endpoints calculés
|
||||
static String get authorizationEndpoint =>
|
||||
'$baseUrl/realms/$realm/protocol/openid-connect/auth';
|
||||
|
||||
static String get tokenEndpoint =>
|
||||
'$baseUrl/realms/$realm/protocol/openid-connect/token';
|
||||
|
||||
static String get userInfoEndpoint =>
|
||||
'$baseUrl/realms/$realm/protocol/openid-connect/userinfo';
|
||||
|
||||
static String get logoutEndpoint =>
|
||||
'$baseUrl/realms/$realm/protocol/openid-connect/logout';
|
||||
static String get tokenEndpoint => '$baseUrl/realms/$realm/protocol/openid-connect/token';
|
||||
static String get logoutEndpoint => '$baseUrl/realms/$realm/protocol/openid-connect/logout';
|
||||
}
|
||||
|
||||
/// Service d'authentification Keycloak ultra-sophistiqué
|
||||
/// Service d'Authentification Keycloak Épuré & DRY
|
||||
@lazySingleton
|
||||
class KeycloakAuthService {
|
||||
static const FlutterAppAuth _appAuth = FlutterAppAuth();
|
||||
static const FlutterSecureStorage _secureStorage = FlutterSecureStorage(
|
||||
aOptions: AndroidOptions(
|
||||
encryptedSharedPreferences: true,
|
||||
),
|
||||
iOptions: IOSOptions(
|
||||
accessibility: KeychainAccessibility.first_unlock_this_device,
|
||||
),
|
||||
final Dio _dio = Dio();
|
||||
final FlutterSecureStorage _storage = const FlutterSecureStorage(
|
||||
aOptions: AndroidOptions(encryptedSharedPreferences: true),
|
||||
iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock_this_device),
|
||||
);
|
||||
|
||||
// Clés de stockage sécurisé
|
||||
static const String _accessTokenKey = 'keycloak_access_token';
|
||||
static const String _refreshTokenKey = 'keycloak_refresh_token';
|
||||
static const String _idTokenKey = 'keycloak_id_token';
|
||||
static const String _userInfoKey = 'keycloak_user_info';
|
||||
|
||||
/// Authentification avec Keycloak via WebView (solution HTTP compatible)
|
||||
///
|
||||
/// Cette méthode utilise maintenant KeycloakWebViewAuthService pour contourner
|
||||
/// les limitations HTTPS de flutter_appauth
|
||||
static Future<AuthorizationTokenResponse?> authenticate() async {
|
||||
|
||||
static const String _accessK = 'kc_access';
|
||||
static const String _refreshK = 'kc_refresh';
|
||||
static const String _idK = 'kc_id';
|
||||
|
||||
/// Login via Direct Access Grant (Username/Password)
|
||||
Future<User?> login(String username, String password) async {
|
||||
try {
|
||||
debugPrint('🔐 Démarrage authentification Keycloak via WebView...');
|
||||
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),
|
||||
);
|
||||
|
||||
// Utiliser le service WebView pour l'authentification
|
||||
// Cette méthode retourne null car l'authentification WebView
|
||||
// est gérée différemment (via callback)
|
||||
debugPrint('💡 Authentification WebView - utilisez authenticateWithWebView() à la place');
|
||||
if (response.statusCode == 200) {
|
||||
await _saveTokens(response.data);
|
||||
return await getCurrentUser();
|
||||
}
|
||||
} catch (e, st) {
|
||||
AppLogger.error('KeycloakAuthService: auth error', error: e, stackTrace: st);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
static Future<String?>? _refreshFuture;
|
||||
|
||||
} catch (e, stackTrace) {
|
||||
debugPrint('💥 Erreur authentification Keycloak: $e');
|
||||
debugPrint('Stack trace: $stackTrace');
|
||||
return null;
|
||||
/// Rafraîchissement automatique du token avec verrouillage global
|
||||
Future<String?> refreshToken() async {
|
||||
if (_refreshFuture != null) {
|
||||
AppLogger.info('KeycloakAuthService: waiting for ongoing refresh');
|
||||
return await _refreshFuture;
|
||||
}
|
||||
|
||||
_refreshFuture = _performRefresh();
|
||||
try {
|
||||
return await _refreshFuture;
|
||||
} finally {
|
||||
_refreshFuture = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Rafraîchit le token d'accès
|
||||
static Future<TokenResponse?> refreshToken() async {
|
||||
|
||||
Future<String?> _performRefresh() async {
|
||||
final refresh = await _storage.read(key: _refreshK);
|
||||
if (refresh == null) {
|
||||
AppLogger.info('KeycloakAuthService: no refresh token available');
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
final String? refreshToken = await _secureStorage.read(
|
||||
key: _refreshTokenKey,
|
||||
);
|
||||
|
||||
if (refreshToken == null) {
|
||||
debugPrint('❌ Aucun refresh token disponible');
|
||||
return null;
|
||||
}
|
||||
|
||||
debugPrint('🔄 Rafraîchissement du token...');
|
||||
|
||||
final TokenRequest request = TokenRequest(
|
||||
KeycloakConfig.clientId,
|
||||
KeycloakConfig.redirectUrl,
|
||||
refreshToken: refreshToken,
|
||||
serviceConfiguration: AuthorizationServiceConfiguration(
|
||||
authorizationEndpoint: KeycloakConfig.authorizationEndpoint,
|
||||
tokenEndpoint: KeycloakConfig.tokenEndpoint,
|
||||
AppLogger.info('KeycloakAuthService: attempting token refresh');
|
||||
final response = await _dio.post(
|
||||
KeycloakConfig.tokenEndpoint,
|
||||
data: {
|
||||
'client_id': KeycloakConfig.clientId,
|
||||
'grant_type': 'refresh_token',
|
||||
'refresh_token': refresh,
|
||||
},
|
||||
options: Options(
|
||||
contentType: Headers.formUrlEncodedContentType,
|
||||
validateStatus: (status) => status == 200,
|
||||
),
|
||||
);
|
||||
|
||||
final TokenResponse? result = await _appAuth.token(request);
|
||||
|
||||
if (result != null) {
|
||||
await _storeTokens(result);
|
||||
debugPrint('✅ Token rafraîchi avec succès');
|
||||
return result;
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
await _saveTokens(response.data);
|
||||
AppLogger.info('KeycloakAuthService: token refreshed successfully');
|
||||
return response.data['access_token'];
|
||||
}
|
||||
|
||||
debugPrint('❌ Échec du rafraîchissement du token');
|
||||
return null;
|
||||
|
||||
} catch (e, stackTrace) {
|
||||
debugPrint('💥 Erreur rafraîchissement token: $e');
|
||||
debugPrint('Stack trace: $stackTrace');
|
||||
return null;
|
||||
} on DioException catch (e, st) {
|
||||
AppLogger.error('KeycloakAuthService: refresh error ${e.response?.statusCode}', error: e, stackTrace: st);
|
||||
if (e.response?.statusCode == 400) {
|
||||
AppLogger.info('KeycloakAuthService: refresh token invalid or expired, logging out');
|
||||
await logout();
|
||||
}
|
||||
} catch (e, st) {
|
||||
AppLogger.error('KeycloakAuthService: critical refresh error', error: e, stackTrace: st);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Récupère l'utilisateur authentifié depuis les tokens
|
||||
static Future<User?> getCurrentUser() async {
|
||||
|
||||
/// Récupération de l'utilisateur courant + Mapage Rôles
|
||||
Future<User?> getCurrentUser() async {
|
||||
String? token = await _storage.read(key: _accessK);
|
||||
final idToken = await _storage.read(key: _idK);
|
||||
|
||||
if (token == null || idToken == null) return null;
|
||||
|
||||
if (JwtDecoder.isExpired(token)) {
|
||||
token = await refreshToken();
|
||||
if (token == null) return null;
|
||||
}
|
||||
|
||||
try {
|
||||
final String? accessToken = await _secureStorage.read(
|
||||
key: _accessTokenKey,
|
||||
);
|
||||
|
||||
final String? idToken = await _secureStorage.read(
|
||||
key: _idTokenKey,
|
||||
);
|
||||
|
||||
if (accessToken == null || idToken == null) {
|
||||
debugPrint('❌ Tokens manquants');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Vérifier si les tokens sont expirés
|
||||
if (JwtDecoder.isExpired(accessToken)) {
|
||||
debugPrint('⏰ Access token expiré, tentative de rafraîchissement...');
|
||||
final TokenResponse? refreshResult = await refreshToken();
|
||||
if (refreshResult == null) {
|
||||
debugPrint('❌ Impossible de rafraîchir le token');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Décoder les tokens JWT
|
||||
final Map<String, dynamic> accessTokenPayload =
|
||||
JwtDecoder.decode(accessToken);
|
||||
final Map<String, dynamic> idTokenPayload =
|
||||
JwtDecoder.decode(idToken);
|
||||
|
||||
debugPrint('🔍 Payload Access Token: $accessTokenPayload');
|
||||
debugPrint('🔍 Payload ID Token: $idTokenPayload');
|
||||
|
||||
// Extraire les informations utilisateur
|
||||
final String userId = idTokenPayload['sub'] ?? '';
|
||||
final String email = idTokenPayload['email'] ?? '';
|
||||
final String firstName = idTokenPayload['given_name'] ?? '';
|
||||
final String lastName = idTokenPayload['family_name'] ?? '';
|
||||
final payload = JwtDecoder.decode(token);
|
||||
final idPayload = JwtDecoder.decode(idToken);
|
||||
|
||||
|
||||
// Extraire les rôles Keycloak
|
||||
final List<String> keycloakRoles = _extractKeycloakRoles(accessTokenPayload);
|
||||
debugPrint('🎭 Rôles Keycloak extraits: $keycloakRoles');
|
||||
|
||||
// Si aucun rôle, assigner un rôle par défaut
|
||||
if (keycloakRoles.isEmpty) {
|
||||
debugPrint('⚠️ Aucun rôle trouvé, assignation du rôle MEMBER par défaut');
|
||||
keycloakRoles.add('member');
|
||||
}
|
||||
|
||||
// Mapper vers notre système de rôles
|
||||
final UserRole primaryRole = KeycloakRoleMapper.mapToUserRole(keycloakRoles);
|
||||
final List<String> permissions = KeycloakRoleMapper.mapToPermissions(keycloakRoles);
|
||||
|
||||
debugPrint('🎯 Rôle principal mappé: ${primaryRole.displayName}');
|
||||
debugPrint('🔐 Permissions mappées: ${permissions.length} permissions');
|
||||
debugPrint('📋 Permissions détaillées: $permissions');
|
||||
|
||||
// Créer l'utilisateur
|
||||
final User user = User(
|
||||
id: userId,
|
||||
email: email,
|
||||
firstName: firstName,
|
||||
lastName: lastName,
|
||||
final roles = _extractRoles(payload);
|
||||
final primaryRole = KeycloakRoleMapper.mapToUserRole(roles);
|
||||
AppLogger.info('KeycloakAuthService: roles mapped', tag: '${primaryRole.name}');
|
||||
|
||||
return User(
|
||||
id: idPayload['sub'] ?? '',
|
||||
email: idPayload['email'] ?? '',
|
||||
firstName: idPayload['given_name'] ?? '',
|
||||
lastName: idPayload['family_name'] ?? '',
|
||||
primaryRole: primaryRole,
|
||||
organizationContexts: const [], // À implémenter selon vos besoins
|
||||
additionalPermissions: permissions,
|
||||
revokedPermissions: const [],
|
||||
preferences: const UserPreferences(),
|
||||
lastLoginAt: DateTime.now(),
|
||||
createdAt: DateTime.now(), // À récupérer depuis Keycloak si disponible
|
||||
additionalPermissions: KeycloakRoleMapper.mapToPermissions(roles),
|
||||
isActive: true,
|
||||
lastLoginAt: DateTime.now(),
|
||||
createdAt: DateTime.now(),
|
||||
);
|
||||
|
||||
// Stocker les informations utilisateur
|
||||
await _secureStorage.write(
|
||||
key: _userInfoKey,
|
||||
value: jsonEncode(user.toJson()),
|
||||
);
|
||||
|
||||
debugPrint('✅ Utilisateur récupéré: ${user.fullName} (${user.primaryRole.displayName})');
|
||||
return user;
|
||||
|
||||
} catch (e, stackTrace) {
|
||||
debugPrint('💥 Erreur récupération utilisateur: $e');
|
||||
debugPrint('Stack trace: $stackTrace');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Déconnexion complète
|
||||
static Future<bool> logout() async {
|
||||
try {
|
||||
debugPrint('🚪 Déconnexion Keycloak...');
|
||||
|
||||
final String? idToken = await _secureStorage.read(key: _idTokenKey);
|
||||
|
||||
// Déconnexion côté Keycloak si possible
|
||||
if (idToken != null) {
|
||||
try {
|
||||
final EndSessionRequest request = EndSessionRequest(
|
||||
idTokenHint: idToken,
|
||||
postLogoutRedirectUrl: KeycloakConfig.redirectUrl,
|
||||
serviceConfiguration: AuthorizationServiceConfiguration(
|
||||
authorizationEndpoint: KeycloakConfig.authorizationEndpoint,
|
||||
tokenEndpoint: KeycloakConfig.tokenEndpoint,
|
||||
endSessionEndpoint: KeycloakConfig.logoutEndpoint,
|
||||
),
|
||||
);
|
||||
|
||||
await _appAuth.endSession(request);
|
||||
debugPrint('✅ Déconnexion Keycloak côté serveur réussie');
|
||||
} catch (e) {
|
||||
debugPrint('⚠️ Déconnexion côté serveur échouée: $e');
|
||||
// Continue quand même avec la déconnexion locale
|
||||
}
|
||||
}
|
||||
|
||||
// Nettoyage local des tokens
|
||||
await _clearTokens();
|
||||
|
||||
debugPrint('✅ Déconnexion locale terminée');
|
||||
return true;
|
||||
|
||||
} catch (e, stackTrace) {
|
||||
debugPrint('💥 Erreur déconnexion: $e');
|
||||
debugPrint('Stack trace: $stackTrace');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Vérifie si l'utilisateur est authentifié
|
||||
static Future<bool> isAuthenticated() async {
|
||||
try {
|
||||
final String? accessToken = await _secureStorage.read(
|
||||
key: _accessTokenKey,
|
||||
);
|
||||
|
||||
if (accessToken == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Vérifier si le token est expiré
|
||||
if (JwtDecoder.isExpired(accessToken)) {
|
||||
// Tenter de rafraîchir
|
||||
final TokenResponse? refreshResult = await refreshToken();
|
||||
return refreshResult != null;
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
} catch (e) {
|
||||
debugPrint('💥 Erreur vérification authentification: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Stocke les tokens de manière sécurisée
|
||||
static Future<void> _storeTokens(TokenResponse tokenResponse) async {
|
||||
if (tokenResponse.accessToken != null) {
|
||||
await _secureStorage.write(
|
||||
key: _accessTokenKey,
|
||||
value: tokenResponse.accessToken!,
|
||||
);
|
||||
}
|
||||
|
||||
if (tokenResponse.refreshToken != null) {
|
||||
await _secureStorage.write(
|
||||
key: _refreshTokenKey,
|
||||
value: tokenResponse.refreshToken!,
|
||||
);
|
||||
}
|
||||
|
||||
if (tokenResponse.idToken != null) {
|
||||
await _secureStorage.write(
|
||||
key: _idTokenKey,
|
||||
value: tokenResponse.idToken!,
|
||||
);
|
||||
}
|
||||
|
||||
debugPrint('🔒 Tokens stockés de manière sécurisée');
|
||||
}
|
||||
|
||||
/// Nettoie tous les tokens stockés
|
||||
static Future<void> _clearTokens() async {
|
||||
await _secureStorage.delete(key: _accessTokenKey);
|
||||
await _secureStorage.delete(key: _refreshTokenKey);
|
||||
await _secureStorage.delete(key: _idTokenKey);
|
||||
await _secureStorage.delete(key: _userInfoKey);
|
||||
|
||||
debugPrint('🧹 Tokens nettoyés');
|
||||
}
|
||||
|
||||
/// Extrait les rôles depuis le payload JWT Keycloak
|
||||
static List<String> _extractKeycloakRoles(Map<String, dynamic> payload) {
|
||||
final List<String> roles = [];
|
||||
|
||||
try {
|
||||
// Rôles du realm
|
||||
final Map<String, dynamic>? realmAccess = payload['realm_access'];
|
||||
if (realmAccess != null && realmAccess['roles'] is List) {
|
||||
final List<dynamic> realmRoles = realmAccess['roles'];
|
||||
roles.addAll(realmRoles.cast<String>());
|
||||
}
|
||||
|
||||
// Rôles des clients
|
||||
final Map<String, dynamic>? resourceAccess = payload['resource_access'];
|
||||
if (resourceAccess != null) {
|
||||
resourceAccess.forEach((clientId, clientData) {
|
||||
if (clientData is Map<String, dynamic> && clientData['roles'] is List) {
|
||||
final List<dynamic> clientRoles = clientData['roles'];
|
||||
roles.addAll(clientRoles.cast<String>());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Filtrer les rôles système Keycloak
|
||||
return roles.where((role) =>
|
||||
!role.startsWith('default-roles-') &&
|
||||
role != 'offline_access' &&
|
||||
role != 'uma_authorization'
|
||||
).toList();
|
||||
|
||||
} catch (e) {
|
||||
debugPrint('💥 Erreur extraction rôles: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère le token d'accès actuel
|
||||
static Future<String?> getAccessToken() async {
|
||||
try {
|
||||
final String? accessToken = await _secureStorage.read(
|
||||
key: _accessTokenKey,
|
||||
);
|
||||
|
||||
if (accessToken != null && !JwtDecoder.isExpired(accessToken)) {
|
||||
return accessToken;
|
||||
}
|
||||
|
||||
// Token expiré, tenter de rafraîchir
|
||||
final TokenResponse? refreshResult = await refreshToken();
|
||||
return refreshResult?.accessToken;
|
||||
|
||||
} catch (e) {
|
||||
debugPrint('💥 Erreur récupération access token: $e');
|
||||
return null;
|
||||
} catch (e, st) {
|
||||
AppLogger.error('KeycloakAuthService: user parse error', error: e, stackTrace: st);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// MÉTHODES WEBVIEW - Délégation vers KeycloakWebViewAuthService
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// Prépare l'authentification WebView
|
||||
///
|
||||
/// Retourne les paramètres nécessaires pour lancer la WebView d'authentification
|
||||
static Future<Map<String, String>> prepareWebViewAuthentication() async {
|
||||
return KeycloakWebViewAuthService.prepareAuthentication();
|
||||
Future<void> logout() async {
|
||||
await _storage.deleteAll();
|
||||
AppLogger.info('KeycloakAuthService: session cleared');
|
||||
}
|
||||
|
||||
/// Traite le callback WebView et finalise l'authentification
|
||||
///
|
||||
/// Cette méthode doit être appelée quand l'URL de callback est interceptée
|
||||
static Future<User> handleWebViewCallback(String callbackUrl) async {
|
||||
return KeycloakWebViewAuthService.handleAuthCallback(callbackUrl);
|
||||
Future<void> _saveTokens(Map<String, dynamic> data) async {
|
||||
if (data['access_token'] != null) await _storage.write(key: _accessK, value: data['access_token']);
|
||||
if (data['refresh_token'] != null) await _storage.write(key: _refreshK, value: data['refresh_token']);
|
||||
if (data['id_token'] != null) await _storage.write(key: _idK, value: data['id_token']);
|
||||
}
|
||||
|
||||
/// Vérifie si l'utilisateur est authentifié (compatible WebView)
|
||||
static Future<bool> isWebViewAuthenticated() async {
|
||||
return KeycloakWebViewAuthService.isAuthenticated();
|
||||
List<String> _extractRoles(Map<String, dynamic> payload) {
|
||||
final roles = <String>[];
|
||||
if (payload['realm_access']?['roles'] != null) {
|
||||
roles.addAll((payload['realm_access']['roles'] as List).cast<String>());
|
||||
}
|
||||
if (payload['resource_access'] != null) {
|
||||
(payload['resource_access'] as Map).values.forEach((v) {
|
||||
if (v['roles'] != null) roles.addAll((v['roles'] as List).cast<String>());
|
||||
});
|
||||
}
|
||||
return roles.where((r) => !r.startsWith('default-roles-') && r != 'offline_access').toList();
|
||||
}
|
||||
|
||||
/// Récupère l'utilisateur authentifié (compatible WebView)
|
||||
static Future<User?> getCurrentWebViewUser() async {
|
||||
return KeycloakWebViewAuthService.getCurrentUser();
|
||||
}
|
||||
|
||||
/// Déconnecte l'utilisateur (compatible WebView)
|
||||
static Future<bool> logoutWebView() async {
|
||||
return KeycloakWebViewAuthService.logout();
|
||||
}
|
||||
|
||||
/// Nettoie les données d'authentification WebView
|
||||
static Future<void> clearWebViewAuthData() async {
|
||||
return KeycloakWebViewAuthService.clearAuthData();
|
||||
Future<String?> getValidToken() async {
|
||||
final token = await _storage.read(key: _accessK);
|
||||
if (token != null && !JwtDecoder.isExpired(token)) return token;
|
||||
return await refreshToken();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ class KeycloakRoleMapper {
|
||||
// Rôles administratifs
|
||||
'SUPER_ADMINISTRATEUR': UserRole.superAdmin,
|
||||
'ADMIN': UserRole.superAdmin,
|
||||
'ADMIN_ORGANISATION': UserRole.orgAdmin, // Rôle Keycloak (backend)
|
||||
'ADMINISTRATEUR_ORGANISATION': UserRole.orgAdmin,
|
||||
'PRESIDENT': UserRole.orgAdmin,
|
||||
|
||||
@@ -23,6 +24,9 @@ class KeycloakRoleMapper {
|
||||
'SECRETAIRE': UserRole.moderator,
|
||||
'GESTIONNAIRE_MEMBRE': UserRole.moderator,
|
||||
'ORGANISATEUR_EVENEMENT': UserRole.moderator,
|
||||
'CONSULTANT': UserRole.consultant,
|
||||
'GESTIONNAIRE_RH': UserRole.hrManager,
|
||||
'HR_MANAGER': UserRole.hrManager,
|
||||
|
||||
// Rôles membres
|
||||
'MEMBRE_ACTIF': UserRole.activeMember,
|
||||
@@ -72,6 +76,21 @@ class KeycloakRoleMapper {
|
||||
PermissionMatrix.REPORTS_GENERATE,
|
||||
PermissionMatrix.DASHBOARD_ANALYTICS,
|
||||
],
|
||||
'ADMIN_ORGANISATION': [
|
||||
// Permissions Admin Organisation (rôle Keycloak backend)
|
||||
PermissionMatrix.ORG_CONFIG,
|
||||
PermissionMatrix.MEMBERS_VIEW_ALL,
|
||||
PermissionMatrix.MEMBERS_EDIT_ALL,
|
||||
PermissionMatrix.FINANCES_VIEW_ALL,
|
||||
PermissionMatrix.FINANCES_EDIT_ALL,
|
||||
PermissionMatrix.EVENTS_VIEW_ALL,
|
||||
PermissionMatrix.EVENTS_EDIT_ALL,
|
||||
PermissionMatrix.SOLIDARITY_VIEW_ALL,
|
||||
PermissionMatrix.SOLIDARITY_EDIT_ALL,
|
||||
PermissionMatrix.REPORTS_GENERATE,
|
||||
PermissionMatrix.DASHBOARD_ANALYTICS,
|
||||
],
|
||||
|
||||
'ADMINISTRATEUR_ORGANISATION': [
|
||||
// Permissions Admin Organisation
|
||||
PermissionMatrix.ORG_CONFIG,
|
||||
@@ -172,6 +191,33 @@ class KeycloakRoleMapper {
|
||||
PermissionMatrix.COMM_SEND_MEMBERS,
|
||||
],
|
||||
|
||||
'CONSULTANT': [
|
||||
PermissionMatrix.DASHBOARD_VIEW,
|
||||
PermissionMatrix.DASHBOARD_ANALYTICS,
|
||||
PermissionMatrix.MEMBERS_VIEW_ALL,
|
||||
PermissionMatrix.EVENTS_VIEW_ALL,
|
||||
PermissionMatrix.REPORTS_VIEW_ALL,
|
||||
PermissionMatrix.REPORTS_GENERATE,
|
||||
],
|
||||
|
||||
'GESTIONNAIRE_RH': [
|
||||
PermissionMatrix.DASHBOARD_VIEW,
|
||||
PermissionMatrix.MEMBERS_VIEW_ALL,
|
||||
PermissionMatrix.MEMBERS_EDIT_BASIC,
|
||||
PermissionMatrix.MEMBERS_APPROVE,
|
||||
PermissionMatrix.EVENTS_VIEW_ALL,
|
||||
PermissionMatrix.MODERATION_USERS,
|
||||
],
|
||||
|
||||
'HR_MANAGER': [
|
||||
PermissionMatrix.DASHBOARD_VIEW,
|
||||
PermissionMatrix.MEMBERS_VIEW_ALL,
|
||||
PermissionMatrix.MEMBERS_EDIT_BASIC,
|
||||
PermissionMatrix.MEMBERS_APPROVE,
|
||||
PermissionMatrix.EVENTS_VIEW_ALL,
|
||||
PermissionMatrix.MODERATION_USERS,
|
||||
],
|
||||
|
||||
'MEMBRE_ACTIF': [
|
||||
// Permissions Membre Actif
|
||||
PermissionMatrix.MEMBERS_VIEW_OWN,
|
||||
@@ -214,10 +260,14 @@ class KeycloakRoleMapper {
|
||||
|
||||
/// Mappe une liste de rôles Keycloak vers le UserRole principal
|
||||
static UserRole mapToUserRole(List<String> keycloakRoles) {
|
||||
// Normaliser en majuscules pour éviter les écarts de casse (ex. admin_organisation)
|
||||
final normalized = keycloakRoles.map((r) => r.toUpperCase()).toList();
|
||||
|
||||
// Priorité des rôles (du plus élevé au plus bas)
|
||||
const List<String> rolePriority = [
|
||||
'SUPER_ADMINISTRATEUR',
|
||||
'ADMIN',
|
||||
'ADMIN_ORGANISATION',
|
||||
'ADMINISTRATEUR_ORGANISATION',
|
||||
'PRESIDENT',
|
||||
'RESPONSABLE_TECHNIQUE',
|
||||
@@ -226,18 +276,21 @@ class KeycloakRoleMapper {
|
||||
'SECRETAIRE',
|
||||
'GESTIONNAIRE_MEMBRE',
|
||||
'ORGANISATEUR_EVENEMENT',
|
||||
'CONSULTANT',
|
||||
'GESTIONNAIRE_RH',
|
||||
'HR_MANAGER',
|
||||
'MEMBRE_ACTIF',
|
||||
'MEMBRE_SIMPLE',
|
||||
'MEMBRE',
|
||||
];
|
||||
|
||||
|
||||
// Trouver le rôle avec la priorité la plus élevée
|
||||
for (final String priorityRole in rolePriority) {
|
||||
if (keycloakRoles.contains(priorityRole)) {
|
||||
if (normalized.contains(priorityRole)) {
|
||||
return _keycloakToUserRole[priorityRole] ?? UserRole.simpleMember;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Par défaut, visiteur si aucun rôle reconnu
|
||||
return UserRole.visitor;
|
||||
}
|
||||
@@ -245,9 +298,12 @@ class KeycloakRoleMapper {
|
||||
/// Mappe une liste de rôles Keycloak vers les permissions
|
||||
static List<String> mapToPermissions(List<String> keycloakRoles) {
|
||||
final Set<String> permissions = <String>{};
|
||||
|
||||
|
||||
// Normaliser en majuscules pour cohérence avec le mapping
|
||||
final normalized = keycloakRoles.map((r) => r.toUpperCase()).toList();
|
||||
|
||||
// Ajouter les permissions pour chaque rôle
|
||||
for (final String role in keycloakRoles) {
|
||||
for (final String role in normalized) {
|
||||
final List<String>? rolePermissions = _keycloakToPermissions[role];
|
||||
if (rolePermissions != null) {
|
||||
permissions.addAll(rolePermissions);
|
||||
|
||||
@@ -530,6 +530,7 @@ class KeycloakWebViewAuthService {
|
||||
|
||||
// Mapper vers notre système de rôles
|
||||
final UserRole primaryRole = KeycloakRoleMapper.mapToUserRole(keycloakRoles);
|
||||
debugPrint('🔐 [AUTH WebView] Rôles: $keycloakRoles → UserRole: ${primaryRole.name} (${primaryRole.displayName})');
|
||||
final List<String> permissions = KeycloakRoleMapper.mapToPermissions(keycloakRoles);
|
||||
|
||||
// Créer l'utilisateur
|
||||
|
||||
@@ -239,14 +239,15 @@ class PermissionEngine {
|
||||
return _checkContextualPermissions(user, permission, organizationId);
|
||||
}
|
||||
|
||||
/// Vérifications contextuelles avancées
|
||||
/// Vérifications contextuelles avancées (intégration serveur).
|
||||
/// Quand le backend exposera GET /api/permissions/check avec userId, permission, organizationId,
|
||||
/// remplacer le return false par l'appel API et le résultat.
|
||||
static Future<bool> _checkContextualPermissions(
|
||||
User user,
|
||||
String permission,
|
||||
String? organizationId,
|
||||
) async {
|
||||
// Logique contextuelle future (intégration avec le serveur)
|
||||
// Pour l'instant, retourne false
|
||||
// Vérification contextuelle désactivée — endpoint non disponible.
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,26 @@ enum UserRole {
|
||||
permissions: _moderatorPermissions,
|
||||
),
|
||||
|
||||
/// Consultant - Niveau intermédiaire (58)
|
||||
/// Accès consultant / conseil
|
||||
consultant(
|
||||
level: 58,
|
||||
displayName: 'Consultant',
|
||||
description: 'Accès consultant et conseil',
|
||||
color: 0xFF6C5CE7, // Violet
|
||||
permissions: _consultantPermissions,
|
||||
),
|
||||
|
||||
/// Gestionnaire RH - Niveau intermédiaire (52)
|
||||
/// Gestion des ressources humaines
|
||||
hrManager(
|
||||
level: 52,
|
||||
displayName: 'Gestionnaire RH',
|
||||
description: 'Gestion des ressources humaines',
|
||||
color: 0xFF0984E3, // Bleu
|
||||
permissions: _hrManagerPermissions,
|
||||
),
|
||||
|
||||
/// Membre Actif - Niveau utilisateur (40)
|
||||
/// Accès aux fonctionnalités membres avec participation active
|
||||
activeMember(
|
||||
@@ -271,6 +291,26 @@ const List<String> _moderatorPermissions = [
|
||||
PermissionMatrix.COMM_SEND_MEMBERS,
|
||||
];
|
||||
|
||||
/// Permissions du Consultant
|
||||
const List<String> _consultantPermissions = [
|
||||
PermissionMatrix.DASHBOARD_VIEW,
|
||||
PermissionMatrix.DASHBOARD_ANALYTICS,
|
||||
PermissionMatrix.MEMBERS_VIEW_ALL,
|
||||
PermissionMatrix.EVENTS_VIEW_ALL,
|
||||
PermissionMatrix.REPORTS_VIEW_ALL,
|
||||
PermissionMatrix.REPORTS_GENERATE,
|
||||
];
|
||||
|
||||
/// Permissions du Gestionnaire RH
|
||||
const List<String> _hrManagerPermissions = [
|
||||
PermissionMatrix.DASHBOARD_VIEW,
|
||||
PermissionMatrix.MEMBERS_VIEW_ALL,
|
||||
PermissionMatrix.MEMBERS_EDIT_BASIC,
|
||||
PermissionMatrix.MEMBERS_APPROVE,
|
||||
PermissionMatrix.EVENTS_VIEW_ALL,
|
||||
PermissionMatrix.MODERATION_USERS,
|
||||
];
|
||||
|
||||
/// Permissions du Membre Actif
|
||||
const List<String> _activeMemberPermissions = [
|
||||
// Dashboard personnel
|
||||
|
||||
@@ -1,468 +1,139 @@
|
||||
/// BLoC d'authentification Keycloak adaptatif avec gestion des rôles
|
||||
/// Support Keycloak avec contextes multi-organisations et états sophistiqués
|
||||
library auth_bloc;
|
||||
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:injectable/injectable.dart';
|
||||
import '../../data/models/user.dart';
|
||||
import '../../data/models/user_role.dart';
|
||||
import '../../data/datasources/permission_engine.dart';
|
||||
import '../../data/datasources/keycloak_auth_service.dart';
|
||||
import '../../data/datasources/dashboard_cache_manager.dart';
|
||||
import '../../data/datasources/permission_engine.dart';
|
||||
import '../../../../core/storage/dashboard_cache_manager.dart';
|
||||
|
||||
// === ÉVÉNEMENTS ===
|
||||
|
||||
/// Événements d'authentification
|
||||
abstract class AuthEvent extends Equatable {
|
||||
const AuthEvent();
|
||||
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
/// Événement de connexion Keycloak
|
||||
class AuthLoginRequested extends AuthEvent {
|
||||
const AuthLoginRequested();
|
||||
}
|
||||
|
||||
/// Événement de déconnexion
|
||||
class AuthLogoutRequested extends AuthEvent {
|
||||
const AuthLogoutRequested();
|
||||
}
|
||||
|
||||
/// Événement de changement de contexte organisationnel
|
||||
class AuthOrganizationContextChanged extends AuthEvent {
|
||||
final String organizationId;
|
||||
|
||||
const AuthOrganizationContextChanged(this.organizationId);
|
||||
|
||||
final String email;
|
||||
final String password;
|
||||
const AuthLoginRequested(this.email, this.password);
|
||||
@override
|
||||
List<Object?> get props => [organizationId];
|
||||
List<Object?> get props => [email, password];
|
||||
}
|
||||
|
||||
/// Événement de rafraîchissement du token
|
||||
class AuthTokenRefreshRequested extends AuthEvent {
|
||||
const AuthTokenRefreshRequested();
|
||||
}
|
||||
|
||||
/// Événement de vérification de l'état d'authentification
|
||||
class AuthStatusChecked extends AuthEvent {
|
||||
const AuthStatusChecked();
|
||||
}
|
||||
|
||||
/// Événement de mise à jour du profil utilisateur
|
||||
class AuthUserProfileUpdated extends AuthEvent {
|
||||
final User updatedUser;
|
||||
|
||||
const AuthUserProfileUpdated(this.updatedUser);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [updatedUser];
|
||||
}
|
||||
|
||||
/// Événement de callback WebView
|
||||
class AuthWebViewCallback extends AuthEvent {
|
||||
final String callbackUrl;
|
||||
final User? user;
|
||||
|
||||
const AuthWebViewCallback(this.callbackUrl, {this.user});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [callbackUrl, user];
|
||||
}
|
||||
class AuthLogoutRequested extends AuthEvent { const AuthLogoutRequested(); }
|
||||
class AuthStatusChecked extends AuthEvent { const AuthStatusChecked(); }
|
||||
class AuthTokenRefreshRequested extends AuthEvent { const AuthTokenRefreshRequested(); }
|
||||
|
||||
// === ÉTATS ===
|
||||
|
||||
/// États d'authentification
|
||||
abstract class AuthState extends Equatable {
|
||||
const AuthState();
|
||||
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
/// État initial
|
||||
class AuthInitial extends AuthState {
|
||||
const AuthInitial();
|
||||
}
|
||||
class AuthInitial extends AuthState {}
|
||||
class AuthLoading extends AuthState {}
|
||||
class AuthUnauthenticated extends AuthState {}
|
||||
|
||||
/// État de chargement
|
||||
class AuthLoading extends AuthState {
|
||||
const AuthLoading();
|
||||
}
|
||||
|
||||
/// État authentifié avec contexte riche
|
||||
class AuthAuthenticated extends AuthState {
|
||||
final User user;
|
||||
final String? currentOrganizationId;
|
||||
final UserRole effectiveRole;
|
||||
final List<String> effectivePermissions;
|
||||
final DateTime authenticatedAt;
|
||||
final String? accessToken;
|
||||
|
||||
final String accessToken;
|
||||
|
||||
const AuthAuthenticated({
|
||||
required this.user,
|
||||
this.currentOrganizationId,
|
||||
required this.effectiveRole,
|
||||
required this.effectivePermissions,
|
||||
required this.authenticatedAt,
|
||||
this.accessToken,
|
||||
required this.accessToken,
|
||||
});
|
||||
|
||||
/// Vérifie si l'utilisateur a une permission
|
||||
bool hasPermission(String permission) {
|
||||
return effectivePermissions.contains(permission);
|
||||
}
|
||||
|
||||
/// Vérifie si l'utilisateur peut effectuer une action
|
||||
bool canPerformAction(String domain, String action, {String scope = 'own'}) {
|
||||
final permission = '$domain.$action.$scope';
|
||||
return hasPermission(permission);
|
||||
}
|
||||
|
||||
/// Obtient le contexte organisationnel actuel
|
||||
UserOrganizationContext? get currentOrganizationContext {
|
||||
if (currentOrganizationId == null) return null;
|
||||
return user.getOrganizationContext(currentOrganizationId!);
|
||||
}
|
||||
|
||||
/// Crée une copie avec des modifications
|
||||
AuthAuthenticated copyWith({
|
||||
User? user,
|
||||
String? currentOrganizationId,
|
||||
UserRole? effectiveRole,
|
||||
List<String>? effectivePermissions,
|
||||
DateTime? authenticatedAt,
|
||||
String? accessToken,
|
||||
}) {
|
||||
return AuthAuthenticated(
|
||||
user: user ?? this.user,
|
||||
currentOrganizationId: currentOrganizationId ?? this.currentOrganizationId,
|
||||
effectiveRole: effectiveRole ?? this.effectiveRole,
|
||||
effectivePermissions: effectivePermissions ?? this.effectivePermissions,
|
||||
authenticatedAt: authenticatedAt ?? this.authenticatedAt,
|
||||
accessToken: accessToken ?? this.accessToken,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
user,
|
||||
currentOrganizationId,
|
||||
effectiveRole,
|
||||
effectivePermissions,
|
||||
authenticatedAt,
|
||||
accessToken,
|
||||
];
|
||||
List<Object?> get props => [user, effectiveRole, effectivePermissions, accessToken];
|
||||
}
|
||||
|
||||
/// État non authentifié
|
||||
class AuthUnauthenticated extends AuthState {
|
||||
final String? message;
|
||||
|
||||
const AuthUnauthenticated({this.message});
|
||||
|
||||
class AuthError extends AuthState {
|
||||
final String message;
|
||||
const AuthError(this.message);
|
||||
@override
|
||||
List<Object?> get props => [message];
|
||||
}
|
||||
|
||||
/// État d'erreur
|
||||
class AuthError extends AuthState {
|
||||
final String message;
|
||||
final String? errorCode;
|
||||
|
||||
const AuthError({
|
||||
required this.message,
|
||||
this.errorCode,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [message, errorCode];
|
||||
}
|
||||
|
||||
/// État indiquant qu'une WebView d'authentification est requise
|
||||
class AuthWebViewRequired extends AuthState {
|
||||
final String authUrl;
|
||||
final String state;
|
||||
final String codeVerifier;
|
||||
|
||||
const AuthWebViewRequired({
|
||||
required this.authUrl,
|
||||
required this.state,
|
||||
required this.codeVerifier,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [authUrl, state, codeVerifier];
|
||||
}
|
||||
|
||||
// === BLOC ===
|
||||
|
||||
/// BLoC d'authentification adaptatif
|
||||
@lazySingleton
|
||||
class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
||||
AuthBloc() : super(const AuthInitial()) {
|
||||
final KeycloakAuthService _authService;
|
||||
|
||||
AuthBloc(this._authService) : super(AuthInitial()) {
|
||||
on<AuthLoginRequested>(_onLoginRequested);
|
||||
on<AuthLogoutRequested>(_onLogoutRequested);
|
||||
on<AuthOrganizationContextChanged>(_onOrganizationContextChanged);
|
||||
on<AuthTokenRefreshRequested>(_onTokenRefreshRequested);
|
||||
on<AuthStatusChecked>(_onStatusChecked);
|
||||
on<AuthUserProfileUpdated>(_onUserProfileUpdated);
|
||||
on<AuthWebViewCallback>(_onWebViewCallback);
|
||||
on<AuthTokenRefreshRequested>(_onTokenRefreshRequested);
|
||||
}
|
||||
|
||||
/// Gère la demande de connexion Keycloak via WebView
|
||||
///
|
||||
/// Cette méthode prépare l'authentification WebView et émet un état spécial
|
||||
/// pour indiquer qu'une WebView doit être ouverte
|
||||
Future<void> _onLoginRequested(
|
||||
AuthLoginRequested event,
|
||||
Emitter<AuthState> emit,
|
||||
) async {
|
||||
emit(const AuthLoading());
|
||||
|
||||
Future<void> _onLoginRequested(AuthLoginRequested event, Emitter<AuthState> emit) async {
|
||||
emit(AuthLoading());
|
||||
try {
|
||||
debugPrint('🔐 Préparation authentification Keycloak WebView...');
|
||||
|
||||
// Préparer l'authentification WebView
|
||||
final Map<String, String> authParams = await KeycloakAuthService.prepareWebViewAuthentication();
|
||||
|
||||
debugPrint('✅ Authentification WebView préparée');
|
||||
|
||||
// Émettre un état spécial pour indiquer qu'une WebView doit être ouverte
|
||||
debugPrint('🚀 Émission de l\'état AuthWebViewRequired...');
|
||||
emit(AuthWebViewRequired(
|
||||
authUrl: authParams['url']!,
|
||||
state: authParams['state']!,
|
||||
codeVerifier: authParams['code_verifier']!,
|
||||
));
|
||||
debugPrint('✅ État AuthWebViewRequired émis');
|
||||
|
||||
} catch (e, stackTrace) {
|
||||
debugPrint('💥 Erreur préparation authentification Keycloak: $e');
|
||||
debugPrint('Stack trace: $stackTrace');
|
||||
emit(AuthError(message: 'Erreur de préparation: $e'));
|
||||
}
|
||||
}
|
||||
|
||||
/// Traite le callback WebView et finalise l'authentification
|
||||
Future<void> _onWebViewCallback(
|
||||
AuthWebViewCallback event,
|
||||
Emitter<AuthState> emit,
|
||||
) async {
|
||||
emit(const AuthLoading());
|
||||
|
||||
try {
|
||||
debugPrint('🔄 Traitement callback WebView...');
|
||||
|
||||
// Utiliser l'utilisateur fourni ou traiter le callback
|
||||
final User user;
|
||||
if (event.user != null) {
|
||||
debugPrint('👤 Utilisation des données utilisateur fournies: ${event.user!.fullName}');
|
||||
user = event.user!;
|
||||
final user = await _authService.login(event.email, event.password);
|
||||
if (user != null) {
|
||||
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 {
|
||||
debugPrint('🔄 Traitement du callback URL: ${event.callbackUrl}');
|
||||
user = await KeycloakAuthService.handleWebViewCallback(event.callbackUrl);
|
||||
emit(const AuthError('Identifiants incorrects.'));
|
||||
}
|
||||
|
||||
debugPrint('👤 Utilisateur authentifié: ${user.fullName} (${user.primaryRole.displayName})');
|
||||
|
||||
// Calculer les permissions effectives
|
||||
debugPrint('🔐 Calcul des permissions effectives...');
|
||||
final effectivePermissions = await PermissionEngine.getEffectivePermissions(user);
|
||||
debugPrint('✅ Permissions effectives calculées: ${effectivePermissions.length} permissions');
|
||||
|
||||
// Invalider le cache pour forcer le rechargement
|
||||
debugPrint('🧹 Invalidation du cache pour le rôle ${user.primaryRole.displayName}...');
|
||||
await DashboardCacheManager.invalidateForRole(user.primaryRole);
|
||||
debugPrint('✅ Cache invalidé');
|
||||
|
||||
emit(AuthAuthenticated(
|
||||
user: user,
|
||||
currentOrganizationId: null, // À implémenter selon vos besoins
|
||||
effectiveRole: user.primaryRole,
|
||||
effectivePermissions: effectivePermissions,
|
||||
authenticatedAt: DateTime.now(),
|
||||
accessToken: '', // Token géré par KeycloakWebViewAuthService
|
||||
));
|
||||
|
||||
debugPrint('🎉 Authentification complète réussie - navigation vers dashboard');
|
||||
|
||||
} catch (e, stackTrace) {
|
||||
debugPrint('💥 Erreur authentification: $e');
|
||||
debugPrint('Stack trace: $stackTrace');
|
||||
emit(AuthError(message: 'Erreur de connexion: $e'));
|
||||
}
|
||||
}
|
||||
|
||||
/// Gère la demande de déconnexion Keycloak
|
||||
Future<void> _onLogoutRequested(
|
||||
AuthLogoutRequested event,
|
||||
Emitter<AuthState> emit,
|
||||
) async {
|
||||
emit(const AuthLoading());
|
||||
|
||||
try {
|
||||
debugPrint('🚪 Démarrage déconnexion Keycloak...');
|
||||
|
||||
// Déconnexion Keycloak
|
||||
final logoutSuccess = await KeycloakAuthService.logout();
|
||||
|
||||
if (!logoutSuccess) {
|
||||
debugPrint('⚠️ Déconnexion Keycloak partielle');
|
||||
}
|
||||
|
||||
// Nettoyer le cache local
|
||||
await DashboardCacheManager.clear();
|
||||
|
||||
// Invalider le cache des permissions
|
||||
if (state is AuthAuthenticated) {
|
||||
final authState = state as AuthAuthenticated;
|
||||
PermissionEngine.invalidateUserCache(authState.user.id);
|
||||
}
|
||||
|
||||
debugPrint('✅ Déconnexion complète réussie');
|
||||
emit(const AuthUnauthenticated(message: 'Déconnexion réussie'));
|
||||
|
||||
} catch (e, stackTrace) {
|
||||
debugPrint('💥 Erreur déconnexion: $e');
|
||||
debugPrint('Stack trace: $stackTrace');
|
||||
emit(AuthError(message: 'Erreur de déconnexion: $e'));
|
||||
}
|
||||
}
|
||||
|
||||
/// Gère le changement de contexte organisationnel
|
||||
Future<void> _onOrganizationContextChanged(
|
||||
AuthOrganizationContextChanged event,
|
||||
Emitter<AuthState> emit,
|
||||
) async {
|
||||
if (state is! AuthAuthenticated) return;
|
||||
|
||||
final currentState = state as AuthAuthenticated;
|
||||
emit(const AuthLoading());
|
||||
|
||||
try {
|
||||
// Recalculer le rôle effectif et les permissions
|
||||
final effectiveRole = currentState.user.getRoleInOrganization(event.organizationId);
|
||||
|
||||
final effectivePermissions = await PermissionEngine.getEffectivePermissions(
|
||||
currentState.user,
|
||||
organizationId: event.organizationId,
|
||||
);
|
||||
|
||||
// Invalider le cache pour le nouveau contexte
|
||||
PermissionEngine.invalidateUserCache(currentState.user.id);
|
||||
|
||||
emit(currentState.copyWith(
|
||||
currentOrganizationId: event.organizationId,
|
||||
effectiveRole: effectiveRole,
|
||||
effectivePermissions: effectivePermissions,
|
||||
));
|
||||
|
||||
} catch (e) {
|
||||
emit(AuthError(message: 'Erreur de changement de contexte: $e'));
|
||||
emit(AuthError('Erreur de connexion: $e'));
|
||||
}
|
||||
}
|
||||
|
||||
/// Gère le rafraîchissement du token
|
||||
Future<void> _onTokenRefreshRequested(
|
||||
AuthTokenRefreshRequested event,
|
||||
Emitter<AuthState> emit,
|
||||
) async {
|
||||
if (state is! AuthAuthenticated) return;
|
||||
|
||||
final currentState = state as AuthAuthenticated;
|
||||
|
||||
try {
|
||||
// Simulation du rafraîchissement (à remplacer par l'API réelle)
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
|
||||
final newToken = 'refreshed_token_${DateTime.now().millisecondsSinceEpoch}';
|
||||
|
||||
emit(currentState.copyWith(accessToken: newToken));
|
||||
|
||||
} catch (e) {
|
||||
emit(AuthError(message: 'Erreur de rafraîchissement: $e'));
|
||||
}
|
||||
Future<void> _onLogoutRequested(AuthLogoutRequested event, Emitter<AuthState> emit) async {
|
||||
emit(AuthLoading());
|
||||
await _authService.logout();
|
||||
await DashboardCacheManager.clear();
|
||||
emit(AuthUnauthenticated());
|
||||
}
|
||||
|
||||
/// Vérifie l'état d'authentification Keycloak
|
||||
Future<void> _onStatusChecked(
|
||||
AuthStatusChecked event,
|
||||
Emitter<AuthState> emit,
|
||||
) async {
|
||||
emit(const AuthLoading());
|
||||
Future<void> _onStatusChecked(AuthStatusChecked event, Emitter<AuthState> emit) async {
|
||||
final tokenValid = await _authService.getValidToken();
|
||||
final isAuth = tokenValid != null;
|
||||
if (!isAuth) {
|
||||
emit(AuthUnauthenticated());
|
||||
return;
|
||||
}
|
||||
final user = await _authService.getCurrentUser();
|
||||
if (user == null) {
|
||||
emit(AuthUnauthenticated());
|
||||
return;
|
||||
}
|
||||
final permissions = await PermissionEngine.getEffectivePermissions(user);
|
||||
final token = await _authService.getValidToken();
|
||||
emit(AuthAuthenticated(
|
||||
user: user,
|
||||
effectiveRole: user.primaryRole,
|
||||
effectivePermissions: permissions,
|
||||
accessToken: token ?? '',
|
||||
));
|
||||
}
|
||||
|
||||
try {
|
||||
debugPrint('🔍 Vérification état authentification Keycloak...');
|
||||
|
||||
// Vérifier si l'utilisateur est authentifié avec Keycloak
|
||||
final bool isAuthenticated = await KeycloakAuthService.isAuthenticated();
|
||||
|
||||
if (!isAuthenticated) {
|
||||
debugPrint('❌ Utilisateur non authentifié');
|
||||
emit(const AuthUnauthenticated());
|
||||
return;
|
||||
Future<void> _onTokenRefreshRequested(AuthTokenRefreshRequested event, Emitter<AuthState> emit) async {
|
||||
if (state is AuthAuthenticated) {
|
||||
final newToken = await _authService.refreshToken();
|
||||
final success = newToken != null;
|
||||
if (success) {
|
||||
add(AuthStatusChecked());
|
||||
} else {
|
||||
add(AuthLogoutRequested());
|
||||
}
|
||||
|
||||
// Récupérer l'utilisateur actuel
|
||||
final User? user = await KeycloakAuthService.getCurrentUser();
|
||||
|
||||
if (user == null) {
|
||||
debugPrint('❌ Impossible de récupérer l\'utilisateur');
|
||||
emit(const AuthUnauthenticated());
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculer les permissions effectives
|
||||
final effectivePermissions = await PermissionEngine.getEffectivePermissions(user);
|
||||
|
||||
// Récupérer le token d'accès
|
||||
final String? accessToken = await KeycloakAuthService.getAccessToken();
|
||||
|
||||
debugPrint('✅ Utilisateur authentifié: ${user.fullName}');
|
||||
|
||||
emit(AuthAuthenticated(
|
||||
user: user,
|
||||
currentOrganizationId: null, // À implémenter selon vos besoins
|
||||
effectiveRole: user.primaryRole,
|
||||
effectivePermissions: effectivePermissions,
|
||||
authenticatedAt: DateTime.now(),
|
||||
accessToken: accessToken ?? '',
|
||||
));
|
||||
|
||||
} catch (e, stackTrace) {
|
||||
debugPrint('💥 Erreur vérification authentification: $e');
|
||||
debugPrint('Stack trace: $stackTrace');
|
||||
emit(AuthError(message: 'Erreur de vérification: $e'));
|
||||
}
|
||||
}
|
||||
|
||||
/// Met à jour le profil utilisateur
|
||||
Future<void> _onUserProfileUpdated(
|
||||
AuthUserProfileUpdated event,
|
||||
Emitter<AuthState> emit,
|
||||
) async {
|
||||
if (state is! AuthAuthenticated) return;
|
||||
|
||||
final currentState = state as AuthAuthenticated;
|
||||
|
||||
try {
|
||||
// Recalculer les permissions si nécessaire
|
||||
final effectivePermissions = await PermissionEngine.getEffectivePermissions(
|
||||
event.updatedUser,
|
||||
organizationId: currentState.currentOrganizationId,
|
||||
);
|
||||
|
||||
emit(currentState.copyWith(
|
||||
user: event.updatedUser,
|
||||
effectivePermissions: effectivePermissions,
|
||||
));
|
||||
|
||||
} catch (e) {
|
||||
emit(AuthError(message: 'Erreur de mise à jour: $e'));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -1,160 +1,68 @@
|
||||
/// Page de Connexion UnionFlow - Design System Unifié (Version Premium)
|
||||
/// Interface de connexion moderne orientée métier avec animations avancées
|
||||
/// Utilise la palette Bleu Roi (#4169E1) + Bleu Pétrole (#2C5F6F)
|
||||
library login_page;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../bloc/auth_bloc.dart';
|
||||
import '../../../../shared/design_system/unionflow_design_system.dart';
|
||||
import 'keycloak_webview_auth_page.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
/// Page de connexion UnionFlow
|
||||
/// Présente l'application et permet l'authentification sécurisée
|
||||
import '../bloc/auth_bloc.dart';
|
||||
import '../../../../core/config/environment.dart';
|
||||
import '../../../../shared/widgets/core_text_field.dart';
|
||||
import '../../../../shared/widgets/dynamic_fab.dart';
|
||||
import '../../../../shared/design_system/tokens/app_typography.dart';
|
||||
import '../../../../shared/design_system/tokens/app_colors.dart';
|
||||
|
||||
/// UnionFlow Mobile - Écran de connexion (Mode DRY & Minimaliste)
|
||||
class LoginPage extends StatefulWidget {
|
||||
const LoginPage({super.key});
|
||||
const LoginPage({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<LoginPage> createState() => _LoginPageState();
|
||||
}
|
||||
|
||||
class _LoginPageState extends State<LoginPage>
|
||||
with TickerProviderStateMixin {
|
||||
|
||||
late AnimationController _animationController;
|
||||
late AnimationController _backgroundController;
|
||||
late AnimationController _pulseController;
|
||||
|
||||
late Animation<double> _fadeAnimation;
|
||||
late Animation<Offset> _slideAnimation;
|
||||
late Animation<double> _scaleAnimation;
|
||||
late Animation<double> _backgroundAnimation;
|
||||
late Animation<double> _pulseAnimation;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initializeAnimations();
|
||||
}
|
||||
class _LoginPageState extends State<LoginPage> {
|
||||
final _emailController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_animationController.dispose();
|
||||
_backgroundController.dispose();
|
||||
_pulseController.dispose();
|
||||
_emailController.dispose();
|
||||
_passwordController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _initializeAnimations() {
|
||||
// Animation principale d'entrée
|
||||
_animationController = AnimationController(
|
||||
duration: const Duration(milliseconds: 1400),
|
||||
vsync: this,
|
||||
Future<void> _openForgotPassword(BuildContext context) async {
|
||||
final url = Uri.parse(
|
||||
'${AppConfig.keycloakRealmUrl}/protocol/openid-connect/auth'
|
||||
'?client_id=unionflow-mobile'
|
||||
'&redirect_uri=${Uri.encodeComponent('http://localhost')}'
|
||||
'&response_type=code'
|
||||
'&scope=openid'
|
||||
'&kc_action=reset_credentials',
|
||||
);
|
||||
|
||||
_fadeAnimation = Tween<double>(
|
||||
begin: 0.0,
|
||||
end: 1.0,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: const Interval(0.0, 0.5, curve: Curves.easeOut),
|
||||
));
|
||||
|
||||
_slideAnimation = Tween<Offset>(
|
||||
begin: const Offset(0.0, 0.4),
|
||||
end: Offset.zero,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: const Interval(0.2, 1.0, curve: Curves.easeOutCubic),
|
||||
));
|
||||
|
||||
_scaleAnimation = Tween<double>(
|
||||
begin: 0.8,
|
||||
end: 1.0,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: const Interval(0.0, 0.6, curve: Curves.easeOutBack),
|
||||
));
|
||||
|
||||
// Animation de fond subtile
|
||||
_backgroundController = AnimationController(
|
||||
duration: const Duration(seconds: 8),
|
||||
vsync: this,
|
||||
)..repeat(reverse: true);
|
||||
|
||||
_backgroundAnimation = Tween<double>(
|
||||
begin: 0.0,
|
||||
end: 1.0,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _backgroundController,
|
||||
curve: Curves.easeInOut,
|
||||
));
|
||||
|
||||
// Animation de pulsation pour le logo
|
||||
_pulseController = AnimationController(
|
||||
duration: const Duration(seconds: 3),
|
||||
vsync: this,
|
||||
)..repeat(reverse: true);
|
||||
|
||||
_pulseAnimation = Tween<double>(
|
||||
begin: 1.0,
|
||||
end: 1.08,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _pulseController,
|
||||
curve: Curves.easeInOut,
|
||||
));
|
||||
|
||||
_animationController.forward();
|
||||
try {
|
||||
if (await canLaunchUrl(url)) {
|
||||
await launchUrl(url, mode: LaunchMode.externalApplication);
|
||||
} else {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Impossible d\'ouvrir la page de réinitialisation')),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Erreur lors de l\'ouverture du lien')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ouvre la page WebView d'authentification
|
||||
void _openWebViewAuth(BuildContext context, AuthWebViewRequired state) {
|
||||
debugPrint('🚀 Ouverture WebView avec URL: ${state.authUrl}');
|
||||
debugPrint('🔑 State: ${state.state}');
|
||||
debugPrint('🔐 Code verifier: ${state.codeVerifier.substring(0, 10)}...');
|
||||
void _onLogin() {
|
||||
final email = _emailController.text;
|
||||
final password = _passwordController.text;
|
||||
|
||||
debugPrint('📱 Tentative de navigation vers KeycloakWebViewAuthPage...');
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (context) => KeycloakWebViewAuthPage(
|
||||
onAuthSuccess: (user) {
|
||||
debugPrint('✅ Authentification réussie pour: ${user.fullName}');
|
||||
debugPrint('🔄 Notification du BLoC avec les données utilisateur...');
|
||||
|
||||
context.read<AuthBloc>().add(AuthWebViewCallback(
|
||||
'success',
|
||||
user: user,
|
||||
));
|
||||
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
onAuthError: (error) {
|
||||
debugPrint('❌ Erreur d\'authentification: $error');
|
||||
Navigator.of(context).pop();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Erreur d\'authentification: $error'),
|
||||
backgroundColor: ColorTokens.error,
|
||||
duration: const Duration(seconds: 5),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
},
|
||||
onAuthCancel: () {
|
||||
debugPrint('❌ Authentification annulée par l\'utilisateur');
|
||||
Navigator.of(context).pop();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Authentification annulée'),
|
||||
backgroundColor: ColorTokens.warning,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
debugPrint('✅ Navigation vers KeycloakWebViewAuthPage lancée');
|
||||
if (email.isNotEmpty && password.isNotEmpty) {
|
||||
context.read<AuthBloc>().add(AuthLoginRequested(email, password));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -162,577 +70,100 @@ class _LoginPageState extends State<LoginPage>
|
||||
return Scaffold(
|
||||
body: BlocConsumer<AuthBloc, AuthState>(
|
||||
listener: (context, state) {
|
||||
debugPrint('🔄 État BLoC reçu: ${state.runtimeType}');
|
||||
|
||||
if (state is AuthAuthenticated) {
|
||||
debugPrint('✅ Utilisateur authentifié, navigation vers dashboard');
|
||||
Navigator.of(context).pushReplacementNamed('/dashboard');
|
||||
// Navigator 1.0 : Le BlocBuilder dans AppRouter gérera la transition vers MainNavigationLayout
|
||||
} else if (state is AuthError) {
|
||||
debugPrint('❌ Erreur d\'authentification: ${state.message}');
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(state.message),
|
||||
backgroundColor: ColorTokens.error,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
content: Text(state.message, style: AppTypography.bodyTextSmall),
|
||||
backgroundColor: AppColors.error,
|
||||
),
|
||||
);
|
||||
} else if (state is AuthWebViewRequired) {
|
||||
debugPrint('🚀 État AuthWebViewRequired reçu, ouverture WebView...');
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_openWebViewAuth(context, state);
|
||||
});
|
||||
} else if (state is AuthLoading) {
|
||||
debugPrint('⏳ État de chargement...');
|
||||
} else {
|
||||
debugPrint('ℹ️ État non géré: ${state.runtimeType}');
|
||||
}
|
||||
},
|
||||
builder: (context, state) {
|
||||
if (state is AuthWebViewRequired) {
|
||||
debugPrint('🔄 Builder détecte AuthWebViewRequired, ouverture WebView...');
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_openWebViewAuth(context, state);
|
||||
});
|
||||
}
|
||||
final isLoading = state is AuthLoading;
|
||||
|
||||
return _buildLoginContent(context, state);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
return SafeArea(
|
||||
child: Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Logo minimaliste (Texte seul)
|
||||
Center(
|
||||
child: Text(
|
||||
'UnionFlow',
|
||||
style: AppTypography.headerSmall.copyWith(
|
||||
fontSize: 24, // Exception unique pour le logo
|
||||
color: AppColors.primaryGreen,
|
||||
letterSpacing: 1.2,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Center(
|
||||
child: Text(
|
||||
'Connexion à votre espace.',
|
||||
style: AppTypography.subtitleSmall,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 48),
|
||||
|
||||
Widget _buildLoginContent(BuildContext context, AuthState state) {
|
||||
return Stack(
|
||||
children: [
|
||||
// Fond animé avec dégradé dynamique
|
||||
AnimatedBuilder(
|
||||
animation: _backgroundAnimation,
|
||||
builder: (context, child) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
ColorTokens.background,
|
||||
Color.lerp(
|
||||
ColorTokens.background,
|
||||
ColorTokens.surface,
|
||||
_backgroundAnimation.value * 0.3,
|
||||
)!,
|
||||
ColorTokens.surface,
|
||||
// Champs de texte DRY
|
||||
CoreTextField(
|
||||
controller: _emailController,
|
||||
hintText: 'Email ou Identifiant',
|
||||
prefixIcon: Icons.person_outline,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
CoreTextField(
|
||||
controller: _passwordController,
|
||||
hintText: 'Mot de passe',
|
||||
prefixIcon: Icons.lock_outline,
|
||||
obscureText: true,
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: TextButton(
|
||||
onPressed: () => _openForgotPassword(context),
|
||||
style: TextButton.styleFrom(
|
||||
padding: EdgeInsets.zero,
|
||||
minimumSize: const Size(0, 0),
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
child: Text(
|
||||
'Oublié ?',
|
||||
style: AppTypography.subtitleSmall.copyWith(
|
||||
color: AppColors.primaryGreen,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Bouton centralisé avec chargement intégré
|
||||
Center(
|
||||
child: isLoading
|
||||
? const CircularProgressIndicator(color: AppColors.primaryGreen)
|
||||
: DynamicFAB(
|
||||
icon: Icons.arrow_forward,
|
||||
label: 'Se Connecter',
|
||||
onPressed: _onLogin,
|
||||
),
|
||||
),
|
||||
],
|
||||
stops: const [0.0, 0.5, 1.0],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
// Éléments décoratifs de fond
|
||||
_buildBackgroundDecoration(),
|
||||
|
||||
// Contenu principal
|
||||
SafeArea(
|
||||
child: AnimatedBuilder(
|
||||
animation: _animationController,
|
||||
builder: (context, child) {
|
||||
return FadeTransition(
|
||||
opacity: _fadeAnimation,
|
||||
child: SlideTransition(
|
||||
position: _slideAnimation,
|
||||
child: _buildLoginUI(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBackgroundDecoration() {
|
||||
return Positioned.fill(
|
||||
child: AnimatedBuilder(
|
||||
animation: _backgroundAnimation,
|
||||
builder: (context, child) {
|
||||
return Stack(
|
||||
children: [
|
||||
// Cercle décoratif haut gauche
|
||||
Positioned(
|
||||
top: -100 + (_backgroundAnimation.value * 30),
|
||||
left: -100 + (_backgroundAnimation.value * 20),
|
||||
child: Container(
|
||||
width: 300,
|
||||
height: 300,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: RadialGradient(
|
||||
colors: [
|
||||
ColorTokens.primary.withOpacity(0.15),
|
||||
ColorTokens.primary.withOpacity(0.0),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Cercle décoratif bas droit
|
||||
Positioned(
|
||||
bottom: -150 - (_backgroundAnimation.value * 30),
|
||||
right: -120 - (_backgroundAnimation.value * 20),
|
||||
child: Container(
|
||||
width: 400,
|
||||
height: 400,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: RadialGradient(
|
||||
colors: [
|
||||
ColorTokens.primary.withOpacity(0.12),
|
||||
ColorTokens.primary.withOpacity(0.0),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Cercle décoratif centre
|
||||
Positioned(
|
||||
top: MediaQuery.of(context).size.height * 0.3,
|
||||
right: -50,
|
||||
child: Container(
|
||||
width: 200,
|
||||
height: 200,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: RadialGradient(
|
||||
colors: [
|
||||
ColorTokens.secondary.withOpacity(0.1),
|
||||
ColorTokens.secondary.withOpacity(0.0),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLoginUI() {
|
||||
return Center(
|
||||
child: SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: SpacingTokens.xxxl),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 480),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const SizedBox(height: SpacingTokens.giant),
|
||||
|
||||
// Logo et branding premium
|
||||
_buildBranding(),
|
||||
const SizedBox(height: SpacingTokens.giant),
|
||||
|
||||
// Features cards
|
||||
_buildFeatureCards(),
|
||||
const SizedBox(height: SpacingTokens.giant),
|
||||
|
||||
// Card de connexion principale
|
||||
_buildLoginCard(),
|
||||
const SizedBox(height: SpacingTokens.xxxl),
|
||||
|
||||
// Footer amélioré
|
||||
_buildFooter(),
|
||||
const SizedBox(height: SpacingTokens.giant),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBranding() {
|
||||
return ScaleTransition(
|
||||
scale: _scaleAnimation,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Logo animé avec effet de pulsation
|
||||
AnimatedBuilder(
|
||||
animation: _pulseAnimation,
|
||||
builder: (context, child) {
|
||||
return Transform.scale(
|
||||
scale: _pulseAnimation.value,
|
||||
child: Container(
|
||||
width: 64,
|
||||
height: 64,
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: ColorTokens.primaryGradient,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(SpacingTokens.radiusXl),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: ColorTokens.primary.withOpacity(0.3),
|
||||
blurRadius: 24,
|
||||
offset: const Offset(0, 10),
|
||||
spreadRadius: 2,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.account_balance_outlined,
|
||||
size: 32,
|
||||
color: ColorTokens.onPrimary,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: SpacingTokens.xxxl),
|
||||
|
||||
// Titre avec gradient
|
||||
ShaderMask(
|
||||
shaderCallback: (bounds) => const LinearGradient(
|
||||
colors: ColorTokens.primaryGradient,
|
||||
).createShader(bounds),
|
||||
child: Text(
|
||||
'Bienvenue',
|
||||
style: TypographyTokens.displaySmall.copyWith(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: -1,
|
||||
height: 1.1,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: SpacingTokens.md),
|
||||
|
||||
// Sous-titre élégant
|
||||
Text(
|
||||
'Connectez-vous à votre espace UnionFlow',
|
||||
style: TypographyTokens.bodyLarge.copyWith(
|
||||
color: ColorTokens.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 1.5,
|
||||
letterSpacing: 0.2,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFeatureCards() {
|
||||
final features = [
|
||||
{
|
||||
'icon': Icons.account_balance_wallet_rounded,
|
||||
'title': 'Cotisations',
|
||||
'color': ColorTokens.primary,
|
||||
},
|
||||
{
|
||||
'icon': Icons.event_rounded,
|
||||
'title': 'Événements',
|
||||
'color': ColorTokens.secondary,
|
||||
},
|
||||
{
|
||||
'icon': Icons.volunteer_activism_rounded,
|
||||
'title': 'Solidarité',
|
||||
'color': ColorTokens.primary,
|
||||
},
|
||||
];
|
||||
|
||||
return Row(
|
||||
children: features.map((feature) {
|
||||
final index = features.indexOf(feature);
|
||||
return Expanded(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
right: index < features.length - 1 ? SpacingTokens.md : 0,
|
||||
),
|
||||
child: TweenAnimationBuilder<double>(
|
||||
tween: Tween(begin: 0.0, end: 1.0),
|
||||
duration: Duration(milliseconds: 600 + (index * 150)),
|
||||
curve: Curves.easeOutBack,
|
||||
builder: (context, value, child) {
|
||||
return Transform.scale(
|
||||
scale: value,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: SpacingTokens.lg,
|
||||
horizontal: SpacingTokens.sm,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: ColorTokens.surface,
|
||||
borderRadius: BorderRadius.circular(SpacingTokens.radiusLg),
|
||||
border: Border.all(
|
||||
color: (feature['color'] as Color).withOpacity(0.15),
|
||||
width: 1.5,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: ColorTokens.shadow.withOpacity(0.05),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(SpacingTokens.sm),
|
||||
decoration: BoxDecoration(
|
||||
color: (feature['color'] as Color).withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(SpacingTokens.radiusMd),
|
||||
),
|
||||
child: Icon(
|
||||
feature['icon'] as IconData,
|
||||
size: 24,
|
||||
color: feature['color'] as Color,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: SpacingTokens.sm),
|
||||
Text(
|
||||
feature['title'] as String,
|
||||
style: TypographyTokens.bodySmall.copyWith(
|
||||
color: ColorTokens.onSurface,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLoginCard() {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: ColorTokens.surface,
|
||||
borderRadius: BorderRadius.circular(SpacingTokens.radiusXxl),
|
||||
border: Border.all(
|
||||
color: ColorTokens.outline.withOpacity(0.08),
|
||||
width: 1,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: ColorTokens.shadow.withOpacity(0.1),
|
||||
blurRadius: 32,
|
||||
offset: const Offset(0, 12),
|
||||
spreadRadius: -4,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(SpacingTokens.huge),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Titre de la card
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(SpacingTokens.xs),
|
||||
decoration: BoxDecoration(
|
||||
color: ColorTokens.primary.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(SpacingTokens.radiusSm),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.fingerprint_rounded,
|
||||
size: 20,
|
||||
color: ColorTokens.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: SpacingTokens.md),
|
||||
Text(
|
||||
'Authentification',
|
||||
style: TypographyTokens.titleMedium.copyWith(
|
||||
color: ColorTokens.onSurface,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: SpacingTokens.xxl),
|
||||
|
||||
// Bouton de connexion principal
|
||||
_buildLoginButton(),
|
||||
|
||||
const SizedBox(height: SpacingTokens.xxl),
|
||||
|
||||
// Divider avec texte
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Container(
|
||||
height: 1,
|
||||
color: ColorTokens.outline.withOpacity(0.1),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: SpacingTokens.md),
|
||||
child: Text(
|
||||
'Sécurisé',
|
||||
style: TypographyTokens.bodySmall.copyWith(
|
||||
color: ColorTokens.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Container(
|
||||
height: 1,
|
||||
color: ColorTokens.outline.withOpacity(0.1),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: SpacingTokens.xxl),
|
||||
|
||||
// Informations de sécurité améliorées
|
||||
Container(
|
||||
padding: const EdgeInsets.all(SpacingTokens.lg),
|
||||
decoration: BoxDecoration(
|
||||
color: ColorTokens.primary.withOpacity(0.05),
|
||||
borderRadius: BorderRadius.circular(SpacingTokens.radiusMd),
|
||||
border: Border.all(
|
||||
color: ColorTokens.primary.withOpacity(0.1),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.verified_user_rounded,
|
||||
size: 20,
|
||||
color: ColorTokens.primary,
|
||||
),
|
||||
const SizedBox(width: SpacingTokens.md),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Connexion sécurisée',
|
||||
style: TypographyTokens.labelMedium.copyWith(
|
||||
color: ColorTokens.onSurface,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: SpacingTokens.xs),
|
||||
Text(
|
||||
'Vos données sont protégées et chiffrées',
|
||||
style: TypographyTokens.bodySmall.copyWith(
|
||||
color: ColorTokens.onSurfaceVariant,
|
||||
height: 1.3,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFooter() {
|
||||
return Column(
|
||||
children: [
|
||||
// Aide
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: SpacingTokens.lg,
|
||||
vertical: SpacingTokens.md,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: ColorTokens.surface.withOpacity(0.5),
|
||||
borderRadius: BorderRadius.circular(SpacingTokens.radiusLg),
|
||||
border: Border.all(
|
||||
color: ColorTokens.outline.withOpacity(0.08),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.help_outline_rounded,
|
||||
size: 18,
|
||||
color: ColorTokens.onSurfaceVariant.withOpacity(0.7),
|
||||
),
|
||||
const SizedBox(width: SpacingTokens.sm),
|
||||
Text(
|
||||
'Besoin d\'aide ?',
|
||||
style: TypographyTokens.bodySmall.copyWith(
|
||||
color: ColorTokens.onSurfaceVariant.withOpacity(0.8),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: SpacingTokens.xl),
|
||||
|
||||
// Copyright
|
||||
Text(
|
||||
'© 2025 UnionFlow. Tous droits réservés.',
|
||||
style: TypographyTokens.bodySmall.copyWith(
|
||||
color: ColorTokens.onSurfaceVariant.withOpacity(0.5),
|
||||
letterSpacing: 0.3,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: SpacingTokens.xs),
|
||||
Text(
|
||||
'Version 1.0.0',
|
||||
style: TypographyTokens.bodySmall.copyWith(
|
||||
color: ColorTokens.onSurfaceVariant.withOpacity(0.4),
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 11,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLoginButton() {
|
||||
return BlocBuilder<AuthBloc, AuthState>(
|
||||
builder: (context, state) {
|
||||
final isLoading = state is AuthLoading;
|
||||
|
||||
return UFPrimaryButton(
|
||||
label: 'Se connecter',
|
||||
icon: Icons.login_rounded,
|
||||
onPressed: isLoading ? null : _handleLogin,
|
||||
isLoading: isLoading,
|
||||
isFullWidth: true,
|
||||
height: 56,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _handleLogin() {
|
||||
// Démarrer l'authentification Keycloak
|
||||
context.read<AuthBloc>().add(const AuthLoginRequested());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user