Initial commit: unionflow-mobile-apps

Application Flutter complète (sans build artifacts).

Signed-off-by: lions dev Team
This commit is contained in:
dahoud
2026-03-15 16:30:08 +00:00
commit d094d6db9c
1790 changed files with 507435 additions and 0 deletions

View File

@@ -0,0 +1,183 @@
import 'dart:convert';
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 '../../../../core/config/environment.dart';
import '../../../../core/utils/logger.dart';
/// Configuration Keycloak centralisée
class KeycloakConfig {
static String get baseUrl => AppConfig.keycloakBaseUrl;
static const String realm = 'unionflow';
static const String clientId = 'unionflow-mobile';
static const String scopes = 'openid profile email roles';
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 Épuré & DRY
@lazySingleton
class KeycloakAuthService {
final Dio _dio = Dio();
final FlutterSecureStorage _storage = const FlutterSecureStorage(
aOptions: AndroidOptions(encryptedSharedPreferences: true),
iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock_this_device),
);
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 {
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),
);
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;
}
static Future<String?>? _refreshFuture;
/// 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;
}
}
Future<String?> _performRefresh() async {
final refresh = await _storage.read(key: _refreshK);
if (refresh == null) {
AppLogger.info('KeycloakAuthService: no refresh token available');
return null;
}
try {
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,
),
);
if (response.statusCode == 200) {
await _saveTokens(response.data);
AppLogger.info('KeycloakAuthService: token refreshed successfully');
return response.data['access_token'];
}
} 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é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 payload = JwtDecoder.decode(token);
final idPayload = JwtDecoder.decode(idToken);
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,
additionalPermissions: KeycloakRoleMapper.mapToPermissions(roles),
isActive: true,
lastLoginAt: DateTime.now(),
createdAt: DateTime.now(),
);
} catch (e, st) {
AppLogger.error('KeycloakAuthService: user parse error', error: e, stackTrace: st);
}
return null;
}
Future<void> logout() async {
await _storage.deleteAll();
AppLogger.info('KeycloakAuthService: session cleared');
}
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']);
}
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();
}
Future<String?> getValidToken() async {
final token = await _storage.read(key: _accessK);
if (token != null && !JwtDecoder.isExpired(token)) return token;
return await refreshToken();
}
}

View File

@@ -0,0 +1,400 @@
/// Mapper de Rôles Keycloak vers UserRole
/// Convertit les rôles Keycloak existants vers notre système de rôles sophistiqué
library keycloak_role_mapper;
import '../models/user_role.dart';
import '../models/permission_matrix.dart';
/// Service de mapping des rôles Keycloak
class KeycloakRoleMapper {
/// Mapping des rôles Keycloak vers UserRole
static const Map<String, UserRole> _keycloakToUserRole = {
// 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,
// Rôles de gestion
'RESPONSABLE_TECHNIQUE': UserRole.moderator,
'RESPONSABLE_MEMBRES': UserRole.moderator,
'TRESORIER': UserRole.moderator,
'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,
'MEMBRE_SIMPLE': UserRole.simpleMember,
'MEMBRE': UserRole.activeMember,
};
/// Mapping des rôles Keycloak vers permissions spécifiques
static const Map<String, List<String>> _keycloakToPermissions = {
'SUPER_ADMINISTRATEUR': [
// Permissions Super Admin - Accès total
PermissionMatrix.SYSTEM_ADMIN,
PermissionMatrix.SYSTEM_CONFIG,
PermissionMatrix.SYSTEM_SECURITY,
PermissionMatrix.ORG_CREATE,
PermissionMatrix.ORG_DELETE,
PermissionMatrix.ORG_CONFIG,
PermissionMatrix.MEMBERS_VIEW_ALL,
PermissionMatrix.MEMBERS_EDIT_ALL,
PermissionMatrix.MEMBERS_DELETE_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,
],
'ADMIN': [
// Permissions Super Admin - Accès total (compatibilité)
PermissionMatrix.SYSTEM_ADMIN,
PermissionMatrix.SYSTEM_CONFIG,
PermissionMatrix.SYSTEM_SECURITY,
PermissionMatrix.ORG_CREATE,
PermissionMatrix.ORG_DELETE,
PermissionMatrix.ORG_CONFIG,
PermissionMatrix.MEMBERS_VIEW_ALL,
PermissionMatrix.MEMBERS_EDIT_ALL,
PermissionMatrix.MEMBERS_DELETE_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,
],
'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,
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,
],
'PRESIDENT': [
// Permissions Président - Gestion organisation
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,
PermissionMatrix.COMM_SEND_ALL,
],
'RESPONSABLE_TECHNIQUE': [
// Permissions Responsable Technique
PermissionMatrix.SYSTEM_MONITORING,
PermissionMatrix.SYSTEM_MAINTENANCE,
PermissionMatrix.MEMBERS_VIEW_ALL,
PermissionMatrix.MEMBERS_EDIT_BASIC,
PermissionMatrix.EVENTS_VIEW_ALL,
PermissionMatrix.EVENTS_EDIT_ALL,
PermissionMatrix.DASHBOARD_VIEW,
PermissionMatrix.REPORTS_GENERATE,
],
'RESPONSABLE_MEMBRES': [
// Permissions Responsable Membres
PermissionMatrix.MEMBERS_VIEW_ALL,
PermissionMatrix.MEMBERS_EDIT_ALL,
PermissionMatrix.MEMBERS_DELETE_ALL,
PermissionMatrix.EVENTS_VIEW_ALL,
PermissionMatrix.EVENTS_EDIT_ALL,
PermissionMatrix.SOLIDARITY_VIEW_ALL,
PermissionMatrix.SOLIDARITY_EDIT_ALL,
PermissionMatrix.DASHBOARD_VIEW,
PermissionMatrix.REPORTS_GENERATE,
],
'TRESORIER': [
// Permissions Trésorier - Focus finances
PermissionMatrix.FINANCES_VIEW_ALL,
PermissionMatrix.FINANCES_EDIT_ALL,
PermissionMatrix.MEMBERS_VIEW_ALL,
PermissionMatrix.MEMBERS_EDIT_BASIC,
PermissionMatrix.EVENTS_VIEW_ALL,
PermissionMatrix.REPORTS_GENERATE,
PermissionMatrix.DASHBOARD_VIEW,
],
'SECRETAIRE': [
// Permissions Secrétaire - Communication et membres
PermissionMatrix.MEMBERS_VIEW_ALL,
PermissionMatrix.MEMBERS_EDIT_BASIC,
PermissionMatrix.EVENTS_VIEW_ALL,
PermissionMatrix.EVENTS_EDIT_ALL,
PermissionMatrix.COMM_SEND_ALL,
PermissionMatrix.COMM_MODERATE,
PermissionMatrix.DASHBOARD_VIEW,
],
'GESTIONNAIRE_MEMBRE': [
// Permissions Gestionnaire de Membres
PermissionMatrix.MEMBERS_VIEW_ALL,
PermissionMatrix.MEMBERS_EDIT_ALL,
PermissionMatrix.MEMBERS_CREATE,
PermissionMatrix.EVENTS_VIEW_ALL,
PermissionMatrix.SOLIDARITY_VIEW_ALL,
PermissionMatrix.DASHBOARD_VIEW,
PermissionMatrix.COMM_SEND_MEMBERS,
],
'ORGANISATEUR_EVENEMENT': [
// Permissions Organisateur d'Événements
PermissionMatrix.EVENTS_VIEW_ALL,
PermissionMatrix.EVENTS_EDIT_ALL,
PermissionMatrix.EVENTS_CREATE,
PermissionMatrix.MEMBERS_VIEW_ALL,
PermissionMatrix.SOLIDARITY_VIEW_ALL,
PermissionMatrix.DASHBOARD_VIEW,
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,
PermissionMatrix.MEMBERS_EDIT_OWN,
PermissionMatrix.EVENTS_VIEW_ALL,
PermissionMatrix.EVENTS_PARTICIPATE,
PermissionMatrix.EVENTS_CREATE,
PermissionMatrix.SOLIDARITY_VIEW_ALL,
PermissionMatrix.SOLIDARITY_PARTICIPATE,
PermissionMatrix.SOLIDARITY_CREATE,
PermissionMatrix.FINANCES_VIEW_OWN,
PermissionMatrix.DASHBOARD_VIEW,
PermissionMatrix.COMM_SEND_MEMBERS,
],
'MEMBRE_SIMPLE': [
// Permissions Membre Simple
PermissionMatrix.MEMBERS_VIEW_OWN,
PermissionMatrix.MEMBERS_EDIT_OWN,
PermissionMatrix.EVENTS_VIEW_PUBLIC,
PermissionMatrix.EVENTS_PARTICIPATE,
PermissionMatrix.SOLIDARITY_VIEW_PUBLIC,
PermissionMatrix.SOLIDARITY_PARTICIPATE,
PermissionMatrix.FINANCES_VIEW_OWN,
PermissionMatrix.DASHBOARD_VIEW,
],
'MEMBRE': [
// Permissions Membre Standard (compatibilité)
PermissionMatrix.MEMBERS_VIEW_OWN,
PermissionMatrix.MEMBERS_EDIT_OWN,
PermissionMatrix.EVENTS_VIEW_PUBLIC,
PermissionMatrix.EVENTS_PARTICIPATE,
PermissionMatrix.SOLIDARITY_VIEW_PUBLIC,
PermissionMatrix.SOLIDARITY_PARTICIPATE,
PermissionMatrix.FINANCES_VIEW_OWN,
PermissionMatrix.DASHBOARD_VIEW,
],
};
/// 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',
'RESPONSABLE_MEMBRES',
'TRESORIER',
'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 (normalized.contains(priorityRole)) {
return _keycloakToUserRole[priorityRole] ?? UserRole.simpleMember;
}
}
// Par défaut, visiteur si aucun rôle reconnu
return UserRole.visitor;
}
/// 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 normalized) {
final List<String>? rolePermissions = _keycloakToPermissions[role];
if (rolePermissions != null) {
permissions.addAll(rolePermissions);
}
}
// Ajouter les permissions de base pour tous les utilisateurs authentifiés
permissions.add(PermissionMatrix.DASHBOARD_VIEW);
permissions.add(PermissionMatrix.MEMBERS_VIEW_OWN);
return permissions.toList();
}
/// Vérifie si un rôle Keycloak est reconnu
static bool isValidKeycloakRole(String role) {
return _keycloakToUserRole.containsKey(role);
}
/// Récupère tous les rôles Keycloak supportés
static List<String> getSupportedKeycloakRoles() {
return _keycloakToUserRole.keys.toList();
}
/// Récupère le UserRole correspondant à un rôle Keycloak spécifique
static UserRole? getUserRoleForKeycloakRole(String keycloakRole) {
return _keycloakToUserRole[keycloakRole];
}
/// Récupère les permissions pour un rôle Keycloak spécifique
static List<String> getPermissionsForKeycloakRole(String keycloakRole) {
return _keycloakToPermissions[keycloakRole] ?? [];
}
/// Analyse détaillée du mapping des rôles
static Map<String, dynamic> analyzeRoleMapping(List<String> keycloakRoles) {
final UserRole primaryRole = mapToUserRole(keycloakRoles);
final List<String> permissions = mapToPermissions(keycloakRoles);
final Map<String, List<String>> roleBreakdown = {};
for (final String role in keycloakRoles) {
if (isValidKeycloakRole(role)) {
roleBreakdown[role] = getPermissionsForKeycloakRole(role);
}
}
return {
'keycloakRoles': keycloakRoles,
'primaryRole': primaryRole.name,
'primaryRoleDisplayName': primaryRole.displayName,
'totalPermissions': permissions.length,
'permissions': permissions,
'roleBreakdown': roleBreakdown,
'unrecognizedRoles': keycloakRoles
.where((role) => !isValidKeycloakRole(role))
.toList(),
};
}
/// Suggestions d'amélioration du mapping
static Map<String, dynamic> getMappingSuggestions(List<String> keycloakRoles) {
final List<String> unrecognized = keycloakRoles
.where((role) => !isValidKeycloakRole(role))
.toList();
final List<String> suggestions = [];
if (unrecognized.isNotEmpty) {
suggestions.add(
'Rôles non reconnus détectés: ${unrecognized.join(", ")}. '
'Considérez ajouter ces rôles au mapping ou les ignorer.',
);
}
if (keycloakRoles.isEmpty) {
suggestions.add(
'Aucun rôle Keycloak détecté. L\'utilisateur sera traité comme visiteur.',
);
}
final UserRole primaryRole = mapToUserRole(keycloakRoles);
if (primaryRole == UserRole.visitor && keycloakRoles.isNotEmpty) {
suggestions.add(
'L\'utilisateur a des rôles Keycloak mais est mappé comme visiteur. '
'Vérifiez la configuration du mapping.',
);
}
return {
'unrecognizedRoles': unrecognized,
'suggestions': suggestions,
'mappingHealth': suggestions.isEmpty ? 'excellent' : 'needs_attention',
};
}
}

View File

@@ -0,0 +1,683 @@
/// Service d'Authentification Keycloak via WebView
///
/// Implémentation professionnelle et sécurisée de l'authentification OAuth2/OIDC
/// avec Keycloak utilisant WebView pour contourner les limitations HTTPS de flutter_appauth.
///
/// Fonctionnalités :
/// - Flow OAuth2 Authorization Code avec PKCE
/// - Gestion sécurisée des tokens JWT
/// - Support HTTP/HTTPS
/// - Gestion complète des erreurs et timeouts
/// - Validation rigoureuse des paramètres
/// - Logging détaillé pour le debugging
library keycloak_webview_auth_service;
import 'dart:async';
import 'dart:convert';
import 'dart:math';
import 'package:crypto/crypto.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:http/http.dart' as http;
import 'package:jwt_decoder/jwt_decoder.dart';
import '../models/user.dart';
import '../models/user_role.dart';
import 'keycloak_role_mapper.dart';
import '../../../../core/config/environment.dart';
/// Configuration Keycloak pour l'authentification WebView
class KeycloakWebViewConfig {
/// URL de base de l'instance 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';
/// URL de redirection après authentification
static const String redirectUrl = 'dev.lions.unionflow-mobile://auth/callback';
/// Scopes OAuth2 demandés
static const List<String> scopes = ['openid', 'profile', 'email', 'roles'];
/// Timeout pour les requêtes HTTP (en secondes)
static const int httpTimeoutSeconds = 30;
/// Timeout pour l'authentification WebView (en secondes)
static const int authTimeoutSeconds = 300; // 5 minutes
/// 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 jwksEndpoint =>
'$baseUrl/realms/$realm/protocol/openid-connect/certs';
}
/// Résultat de l'authentification WebView
class WebViewAuthResult {
final String accessToken;
final String idToken;
final String? refreshToken;
final int expiresIn;
final String tokenType;
final List<String> scopes;
const WebViewAuthResult({
required this.accessToken,
required this.idToken,
this.refreshToken,
required this.expiresIn,
required this.tokenType,
required this.scopes,
});
/// Création depuis la réponse token de Keycloak
factory WebViewAuthResult.fromTokenResponse(Map<String, dynamic> response) {
return WebViewAuthResult(
accessToken: response['access_token'] ?? '',
idToken: response['id_token'] ?? '',
refreshToken: response['refresh_token'],
expiresIn: response['expires_in'] ?? 3600,
tokenType: response['token_type'] ?? 'Bearer',
scopes: (response['scope'] as String?)?.split(' ') ?? [],
);
}
}
/// Exceptions spécifiques à l'authentification WebView
class KeycloakWebViewAuthException implements Exception {
final String message;
final String? code;
final dynamic originalError;
const KeycloakWebViewAuthException(
this.message, {
this.code,
this.originalError,
});
@override
String toString() => 'KeycloakWebViewAuthException: $message${code != null ? ' (Code: $code)' : ''}';
}
/// Service d'authentification Keycloak via WebView
///
/// Implémentation complète et sécurisée du flow OAuth2 Authorization Code avec PKCE
class KeycloakWebViewAuthService {
// Stockage sécurisé des tokens
static const FlutterSecureStorage _secureStorage = FlutterSecureStorage(
aOptions: AndroidOptions(
encryptedSharedPreferences: true,
),
iOptions: IOSOptions(
accessibility: KeychainAccessibility.first_unlock_this_device,
),
);
// Clés de stockage sécurisé
static const String _accessTokenKey = 'keycloak_webview_access_token';
static const String _idTokenKey = 'keycloak_webview_id_token';
static const String _refreshTokenKey = 'keycloak_webview_refresh_token';
static const String _userInfoKey = 'keycloak_webview_user_info';
static const String _authStateKey = 'keycloak_webview_auth_state';
// Client HTTP avec timeout configuré
static final http.Client _httpClient = http.Client();
/// Génère un code verifier PKCE sécurisé
static String _generateCodeVerifier() {
const String charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
final Random random = Random.secure();
return List.generate(128, (i) => charset[random.nextInt(charset.length)]).join();
}
/// Génère le code challenge PKCE à partir du verifier
static String _generateCodeChallenge(String verifier) {
final List<int> bytes = utf8.encode(verifier);
final Digest digest = sha256.convert(bytes);
return base64Url.encode(digest.bytes).replaceAll('=', '');
}
/// Génère un state sécurisé pour la protection CSRF
static String _generateState() {
final Random random = Random.secure();
final List<int> bytes = List.generate(32, (i) => random.nextInt(256));
return base64Url.encode(bytes).replaceAll('=', '');
}
/// Construit l'URL d'autorisation Keycloak avec tous les paramètres
static Future<Map<String, String>> _buildAuthorizationUrl() async {
final String codeVerifier = _generateCodeVerifier();
final String codeChallenge = _generateCodeChallenge(codeVerifier);
final String state = _generateState();
// Stocker les paramètres pour la validation ultérieure
await _secureStorage.write(
key: _authStateKey,
value: jsonEncode({
'code_verifier': codeVerifier,
'state': state,
'timestamp': DateTime.now().millisecondsSinceEpoch,
}),
);
final Map<String, String> params = {
'response_type': 'code',
'client_id': KeycloakWebViewConfig.clientId,
'redirect_uri': KeycloakWebViewConfig.redirectUrl,
'scope': KeycloakWebViewConfig.scopes.join(' '),
'state': state,
'code_challenge': codeChallenge,
'code_challenge_method': 'S256',
'kc_locale': 'fr',
'prompt': 'login',
};
final String queryString = params.entries
.map((e) => '${Uri.encodeComponent(e.key)}=${Uri.encodeComponent(e.value)}')
.join('&');
final String authUrl = '${KeycloakWebViewConfig.authorizationEndpoint}?$queryString';
debugPrint('🔐 URL d\'autorisation générée: $authUrl');
return {
'url': authUrl,
'state': state,
'code_verifier': codeVerifier,
};
}
/// Valide la réponse de redirection et extrait le code d'autorisation
static Future<String> _validateCallbackAndExtractCode(
String callbackUrl,
String expectedState,
) async {
debugPrint('🔍 Validation du callback: $callbackUrl');
final Uri uri = Uri.parse(callbackUrl);
// Vérifier que c'est bien notre URL de redirection
if (!callbackUrl.startsWith(KeycloakWebViewConfig.redirectUrl)) {
throw const KeycloakWebViewAuthException(
'URL de callback invalide',
code: 'INVALID_CALLBACK_URL',
);
}
// Vérifier la présence d'erreurs
final String? error = uri.queryParameters['error'];
if (error != null) {
final String? errorDescription = uri.queryParameters['error_description'];
throw KeycloakWebViewAuthException(
'Erreur d\'authentification: ${errorDescription ?? error}',
code: error,
);
}
// Valider le state pour la protection CSRF
final String? receivedState = uri.queryParameters['state'];
if (receivedState != expectedState) {
throw const KeycloakWebViewAuthException(
'State invalide - possible attaque CSRF',
code: 'INVALID_STATE',
);
}
// Extraire le code d'autorisation
final String? code = uri.queryParameters['code'];
if (code == null || code.isEmpty) {
throw const KeycloakWebViewAuthException(
'Code d\'autorisation manquant',
code: 'MISSING_AUTH_CODE',
);
}
debugPrint('✅ Code d\'autorisation extrait avec succès');
return code;
}
/// Échange le code d'autorisation contre des tokens
static Future<WebViewAuthResult> _exchangeCodeForTokens(
String authCode,
String codeVerifier,
) async {
debugPrint('🔄 Échange du code d\'autorisation contre les tokens...');
try {
final Map<String, String> body = {
'grant_type': 'authorization_code',
'client_id': KeycloakWebViewConfig.clientId,
'code': authCode,
'redirect_uri': KeycloakWebViewConfig.redirectUrl,
'code_verifier': codeVerifier,
};
final http.Response response = await _httpClient
.post(
Uri.parse(KeycloakWebViewConfig.tokenEndpoint),
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'application/json',
},
body: body,
)
.timeout(const Duration(seconds: KeycloakWebViewConfig.httpTimeoutSeconds));
debugPrint('📡 Réponse token endpoint: ${response.statusCode}');
if (response.statusCode != 200) {
final String errorBody = response.body;
debugPrint('❌ Erreur échange tokens: $errorBody');
Map<String, dynamic>? errorJson;
try {
errorJson = jsonDecode(errorBody);
} catch (e) {
// Ignore JSON parsing errors
}
final String errorMessage = errorJson?['error_description'] ??
errorJson?['error'] ??
'Erreur HTTP ${response.statusCode}';
throw KeycloakWebViewAuthException(
'Échec de l\'échange de tokens: $errorMessage',
code: errorJson?['error'],
);
}
final Map<String, dynamic> tokenResponse = jsonDecode(response.body);
// Valider la présence des tokens requis
if (!tokenResponse.containsKey('access_token') ||
!tokenResponse.containsKey('id_token')) {
throw const KeycloakWebViewAuthException(
'Tokens manquants dans la réponse',
code: 'MISSING_TOKENS',
);
}
debugPrint('✅ Tokens reçus avec succès');
return WebViewAuthResult.fromTokenResponse(tokenResponse);
} on TimeoutException {
throw const KeycloakWebViewAuthException(
'Timeout lors de l\'échange des tokens',
code: 'TIMEOUT',
);
} catch (e) {
if (e is KeycloakWebViewAuthException) rethrow;
throw KeycloakWebViewAuthException(
'Erreur lors de l\'échange des tokens: $e',
originalError: e,
);
}
}
/// Stocke les tokens de manière sécurisée
static Future<void> _storeTokens(WebViewAuthResult authResult) async {
debugPrint('💾 Stockage sécurisé des tokens...');
try {
await Future.wait([
_secureStorage.write(key: _accessTokenKey, value: authResult.accessToken),
_secureStorage.write(key: _idTokenKey, value: authResult.idToken),
if (authResult.refreshToken != null)
_secureStorage.write(key: _refreshTokenKey, value: authResult.refreshToken!),
]);
debugPrint('✅ Tokens stockés avec succès');
} catch (e) {
throw KeycloakWebViewAuthException(
'Erreur lors du stockage des tokens: $e',
originalError: e,
);
}
}
/// Valide et parse un token JWT
///
/// Stratégie de validation JWT :
/// - Côté mobile : vérification de l'expiration et de l'issuer uniquement.
/// La signature JWT n'est PAS vérifiée côté mobile car :
/// 1. Le token est envoyé au backend dans chaque requête API (header Authorization)
/// 2. Le backend Quarkus valide la signature via JWKS (quarkus-oidc)
/// 3. Toutes les communications passent par HTTPS en production
/// 4. Le token est stocké dans FlutterSecureStorage (EncryptedSharedPreferences / Keychain)
/// - Si une validation de signature locale est requise, implémenter via le
/// endpoint JWKS : `KeycloakWebViewConfig.jwksEndpoint`
static Map<String, dynamic> _parseAndValidateJWT(String token, String tokenType) {
try {
// Vérifier l'expiration
if (JwtDecoder.isExpired(token)) {
throw KeycloakWebViewAuthException(
'$tokenType expiré',
code: 'TOKEN_EXPIRED',
);
}
// Parser le payload
final Map<String, dynamic> payload = JwtDecoder.decode(token);
// Validations de base
if (payload['iss'] == null) {
throw const KeycloakWebViewAuthException(
'Token JWT invalide: issuer manquant',
code: 'INVALID_JWT',
);
}
// Vérifier l'issuer
final String expectedIssuer = '${KeycloakWebViewConfig.baseUrl}/realms/${KeycloakWebViewConfig.realm}';
if (payload['iss'] != expectedIssuer) {
throw KeycloakWebViewAuthException(
'Token JWT invalide: issuer incorrect (attendu: $expectedIssuer, reçu: ${payload['iss']})',
code: 'INVALID_ISSUER',
);
}
debugPrint('$tokenType validé avec succès');
return payload;
} catch (e) {
if (e is KeycloakWebViewAuthException) rethrow;
throw KeycloakWebViewAuthException(
'Erreur lors de la validation du $tokenType: $e',
originalError: e,
);
}
}
/// Méthode principale d'authentification
///
/// Retourne les paramètres nécessaires pour lancer la WebView d'authentification
static Future<Map<String, String>> prepareAuthentication() async {
debugPrint('🚀 Préparation de l\'authentification WebView...');
try {
// Nettoyer les données d'authentification précédentes
await clearAuthData();
// Générer l'URL d'autorisation avec PKCE
final Map<String, String> authParams = await _buildAuthorizationUrl();
debugPrint('✅ Authentification préparée avec succès');
return authParams;
} catch (e) {
throw KeycloakWebViewAuthException(
'Erreur lors de la préparation de l\'authentification: $e',
originalError: e,
);
}
}
/// Traite le callback de redirection et finalise l'authentification
static Future<User> handleAuthCallback(String callbackUrl) async {
debugPrint('🔄 Traitement du callback d\'authentification...');
debugPrint('📋 URL de callback: $callbackUrl');
try {
// Récupérer les paramètres d'authentification stockés
debugPrint('🔍 Récupération de l\'état d\'authentification...');
final String? authStateJson = await _secureStorage.read(key: _authStateKey);
if (authStateJson == null) {
debugPrint('❌ État d\'authentification manquant');
throw const KeycloakWebViewAuthException(
'État d\'authentification manquant',
code: 'MISSING_AUTH_STATE',
);
}
final Map<String, dynamic> authState = jsonDecode(authStateJson);
final String expectedState = authState['state'];
final String codeVerifier = authState['code_verifier'];
debugPrint('✅ État d\'authentification récupéré');
// Valider le callback et extraire le code
debugPrint('🔍 Validation du callback...');
final String authCode = await _validateCallbackAndExtractCode(
callbackUrl,
expectedState,
);
debugPrint('✅ Code d\'autorisation extrait: ${authCode.substring(0, 10)}...');
// Échanger le code contre des tokens
debugPrint('🔄 Échange du code contre les tokens...');
final WebViewAuthResult authResult = await _exchangeCodeForTokens(
authCode,
codeVerifier,
);
debugPrint('✅ Tokens reçus avec succès');
// Stocker les tokens
debugPrint('💾 Stockage des tokens...');
await _storeTokens(authResult);
debugPrint('✅ Tokens stockés');
// Créer l'utilisateur depuis les tokens
debugPrint('👤 Création de l\'utilisateur...');
final User user = await _createUserFromTokens(authResult);
debugPrint('✅ Utilisateur créé: ${user.fullName}');
// Nettoyer l'état d'authentification temporaire
await _secureStorage.delete(key: _authStateKey);
debugPrint('🎉 Authentification WebView terminée avec succès');
return user;
} catch (e, stackTrace) {
debugPrint('💥 Erreur lors du traitement du callback: $e');
debugPrint('📋 Stack trace: $stackTrace');
// Nettoyer en cas d'erreur
await _secureStorage.delete(key: _authStateKey);
if (e is KeycloakWebViewAuthException) rethrow;
throw KeycloakWebViewAuthException(
'Erreur lors du traitement du callback: $e',
originalError: e,
);
}
}
/// Crée un utilisateur depuis les tokens JWT
static Future<User> _createUserFromTokens(WebViewAuthResult authResult) async {
debugPrint('👤 Création de l\'utilisateur depuis les tokens...');
try {
// Parser et valider les tokens
final Map<String, dynamic> accessTokenPayload = _parseAndValidateJWT(
authResult.accessToken,
'Access Token',
);
final Map<String, dynamic> idTokenPayload = _parseAndValidateJWT(
authResult.idToken,
'ID Token',
);
// 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'] ?? '';
if (userId.isEmpty || email.isEmpty) {
throw const KeycloakWebViewAuthException(
'Informations utilisateur manquantes dans les tokens',
code: 'MISSING_USER_INFO',
);
}
// Extraire les rôles Keycloak
final List<String> keycloakRoles = _extractKeycloakRoles(accessTokenPayload);
// 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
final User user = User(
id: userId,
email: email,
firstName: firstName,
lastName: lastName,
primaryRole: primaryRole,
organizationContexts: const [],
additionalPermissions: permissions,
revokedPermissions: const [],
preferences: const UserPreferences(
language: 'fr',
theme: 'system',
notificationsEnabled: true,
emailNotifications: true,
pushNotifications: true,
dashboardLayout: 'adaptive',
timezone: 'Europe/Paris',
),
lastLoginAt: DateTime.now(),
createdAt: DateTime.now(),
isActive: true,
);
// Stocker les informations utilisateur
await _secureStorage.write(
key: _userInfoKey,
value: jsonEncode(user.toJson()),
);
debugPrint('✅ Utilisateur créé: ${user.fullName} (${user.primaryRole.displayName})');
return user;
} catch (e) {
if (e is KeycloakWebViewAuthException) rethrow;
throw KeycloakWebViewAuthException(
'Erreur lors de la création de l\'utilisateur: $e',
originalError: e,
);
}
}
/// Extrait les rôles Keycloak depuis le payload du token
static List<String> _extractKeycloakRoles(Map<String, dynamic> tokenPayload) {
try {
final List<String> roles = <String>[];
// Rôles realm
final Map<String, dynamic>? realmAccess = tokenPayload['realm_access'];
if (realmAccess != null && realmAccess['roles'] is List) {
roles.addAll(List<String>.from(realmAccess['roles']));
}
// Rôles client
final Map<String, dynamic>? resourceAccess = tokenPayload['resource_access'];
if (resourceAccess != null) {
final Map<String, dynamic>? clientAccess = resourceAccess[KeycloakWebViewConfig.clientId];
if (clientAccess != null && clientAccess['roles'] is List) {
roles.addAll(List<String>.from(clientAccess['roles']));
}
}
// Filtrer les rôles système
return roles.where((role) =>
!role.startsWith('default-roles-') &&
role != 'offline_access' &&
role != 'uma_authorization'
).toList();
} catch (e) {
debugPrint('💥 Erreur extraction rôles: $e');
return [];
}
}
/// Nettoie toutes les données d'authentification
static Future<void> clearAuthData() async {
debugPrint('🧹 Nettoyage des données d\'authentification...');
try {
await Future.wait([
_secureStorage.delete(key: _accessTokenKey),
_secureStorage.delete(key: _idTokenKey),
_secureStorage.delete(key: _refreshTokenKey),
_secureStorage.delete(key: _userInfoKey),
_secureStorage.delete(key: _authStateKey),
]);
debugPrint('✅ Données d\'authentification nettoyées');
} catch (e) {
debugPrint('⚠️ Erreur lors du nettoyage: $e');
}
}
/// 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é
return !JwtDecoder.isExpired(accessToken);
} catch (e) {
debugPrint('💥 Erreur vérification authentification: $e');
return false;
}
}
/// Récupère l'utilisateur authentifié
static Future<User?> getCurrentUser() async {
try {
final String? userInfoJson = await _secureStorage.read(key: _userInfoKey);
if (userInfoJson == null) {
return null;
}
final Map<String, dynamic> userJson = jsonDecode(userInfoJson);
return User.fromJson(userJson);
} catch (e) {
debugPrint('💥 Erreur récupération utilisateur: $e');
return null;
}
}
/// Déconnecte l'utilisateur
static Future<bool> logout() async {
debugPrint('🚪 Déconnexion de l\'utilisateur...');
try {
// Nettoyer les données locales
await clearAuthData();
debugPrint('✅ Déconnexion réussie');
return true;
} catch (e) {
debugPrint('💥 Erreur déconnexion: $e');
return false;
}
}
}

View File

@@ -0,0 +1,376 @@
/// Moteur de permissions ultra-performant avec cache intelligent
/// Vérifications contextuelles et audit trail intégré
library permission_engine;
import 'dart:async';
import 'package:flutter/foundation.dart';
import '../models/user.dart';
import '../models/user_role.dart';
import '../models/permission_matrix.dart';
/// Moteur de permissions haute performance avec cache multi-niveaux
///
/// Fonctionnalités :
/// - Cache mémoire ultra-rapide avec TTL
/// - Vérifications contextuelles avancées
/// - Audit trail automatique
/// - Support des permissions héritées
/// - Invalidation intelligente du cache
class PermissionEngine {
static final PermissionEngine _instance = PermissionEngine._internal();
factory PermissionEngine() => _instance;
PermissionEngine._internal();
/// Cache mémoire des permissions avec TTL
static final Map<String, _CachedPermission> _permissionCache = {};
/// Cache des permissions effectives par utilisateur
static final Map<String, _CachedUserPermissions> _userPermissionsCache = {};
/// Durée de vie du cache (5 minutes par défaut)
static const Duration _defaultCacheTTL = Duration(minutes: 5);
/// Durée de vie du cache pour les super admins (plus long)
static const Duration _superAdminCacheTTL = Duration(minutes: 15);
/// Compteur de hits/miss du cache pour monitoring
static int _cacheHits = 0;
static int _cacheMisses = 0;
/// Stream pour les événements d'audit
static final StreamController<PermissionAuditEvent> _auditController =
StreamController<PermissionAuditEvent>.broadcast();
/// Stream des événements d'audit
static Stream<PermissionAuditEvent> get auditStream => _auditController.stream;
/// Vérifie si un utilisateur a une permission spécifique
///
/// [user] - Utilisateur à vérifier
/// [permission] - Permission à vérifier
/// [organizationId] - Contexte organisationnel optionnel
/// [auditLog] - Activer l'audit trail (défaut: true)
static Future<bool> hasPermission(
User user,
String permission, {
String? organizationId,
bool auditLog = true,
}) async {
final cacheKey = _generateCacheKey(user.id, permission, organizationId);
// Vérification du cache
final cachedResult = _getCachedPermission(cacheKey);
if (cachedResult != null) {
_cacheHits++;
if (auditLog && !cachedResult.result) {
_logAuditEvent(user, permission, false, 'CACHED_DENIED', organizationId);
}
return cachedResult.result;
}
_cacheMisses++;
// Calcul de la permission
final result = await _computePermission(user, permission, organizationId);
// Mise en cache
_cachePermission(cacheKey, result, user.primaryRole);
// Audit trail
if (auditLog) {
_logAuditEvent(
user,
permission,
result,
result ? 'GRANTED' : 'DENIED',
organizationId,
);
}
return result;
}
/// Vérifie plusieurs permissions en une seule fois
static Future<Map<String, bool>> hasPermissions(
User user,
List<String> permissions, {
String? organizationId,
bool auditLog = true,
}) async {
final results = <String, bool>{};
// Traitement en parallèle pour les performances
final futures = permissions.map((permission) =>
hasPermission(user, permission, organizationId: organizationId, auditLog: auditLog)
.then((result) => MapEntry(permission, result))
);
final entries = await Future.wait(futures);
for (final entry in entries) {
results[entry.key] = entry.value;
}
return results;
}
/// Obtient toutes les permissions effectives d'un utilisateur
static Future<List<String>> getEffectivePermissions(
User user, {
String? organizationId,
}) async {
final cacheKey = '${user.id}_effective_${organizationId ?? 'global'}';
// Vérification du cache utilisateur
final cachedUserPermissions = _getCachedUserPermissions(cacheKey);
if (cachedUserPermissions != null) {
_cacheHits++;
return cachedUserPermissions.permissions;
}
_cacheMisses++;
// Calcul des permissions effectives
final permissions = user.getEffectivePermissions(organizationId: organizationId);
// Mise en cache
_cacheUserPermissions(cacheKey, permissions, user.primaryRole);
return permissions;
}
/// Vérifie si un utilisateur peut effectuer une action sur un domaine
static Future<bool> canPerformAction(
User user,
String domain,
String action, {
String scope = 'own',
String? organizationId,
}) async {
final permission = '$domain.$action.$scope';
return hasPermission(user, permission, organizationId: organizationId);
}
/// Invalide le cache pour un utilisateur spécifique
static void invalidateUserCache(String userId) {
final keysToRemove = <String>[];
// Invalider le cache des permissions
for (final key in _permissionCache.keys) {
if (key.startsWith('${userId}_')) {
keysToRemove.add(key);
}
}
for (final key in keysToRemove) {
_permissionCache.remove(key);
}
// Invalider le cache des permissions utilisateur
final userKeysToRemove = <String>[];
for (final key in _userPermissionsCache.keys) {
if (key.startsWith('${userId}_')) {
userKeysToRemove.add(key);
}
}
for (final key in userKeysToRemove) {
_userPermissionsCache.remove(key);
}
debugPrint('Cache invalidé pour l\'utilisateur: $userId');
}
/// Invalide tout le cache
static void invalidateAllCache() {
_permissionCache.clear();
_userPermissionsCache.clear();
debugPrint('Cache complet invalidé');
}
/// Obtient les statistiques du cache
static Map<String, dynamic> getCacheStats() {
final totalRequests = _cacheHits + _cacheMisses;
final hitRate = totalRequests > 0 ? (_cacheHits / totalRequests * 100) : 0.0;
return {
'cacheHits': _cacheHits,
'cacheMisses': _cacheMisses,
'hitRate': hitRate.toStringAsFixed(2),
'permissionCacheSize': _permissionCache.length,
'userPermissionsCacheSize': _userPermissionsCache.length,
};
}
/// Nettoie le cache expiré
static void cleanExpiredCache() {
final now = DateTime.now();
// Nettoyer le cache des permissions
_permissionCache.removeWhere((key, cached) => cached.expiresAt.isBefore(now));
// Nettoyer le cache des permissions utilisateur
_userPermissionsCache.removeWhere((key, cached) => cached.expiresAt.isBefore(now));
debugPrint('Cache expiré nettoyé');
}
// === MÉTHODES PRIVÉES ===
/// Calcule une permission sans cache
static Future<bool> _computePermission(
User user,
String permission,
String? organizationId,
) async {
// Vérification des permissions publiques
if (PermissionMatrix.isPublicPermission(permission)) {
return true;
}
// Vérification utilisateur actif
if (!user.isActive) return false;
// Vérification directe de l'utilisateur
if (user.hasPermission(permission, organizationId: organizationId)) {
return true;
}
// Vérifications contextuelles avancées
return _checkContextualPermissions(user, permission, organizationId);
}
/// 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 {
// Vérification contextuelle désactivée — endpoint non disponible.
return false;
}
/// Génère une clé de cache unique
static String _generateCacheKey(String userId, String permission, String? organizationId) {
return '${userId}_${permission}_${organizationId ?? 'global'}';
}
/// Obtient une permission depuis le cache
static _CachedPermission? _getCachedPermission(String key) {
final cached = _permissionCache[key];
if (cached != null && cached.expiresAt.isAfter(DateTime.now())) {
return cached;
}
if (cached != null) {
_permissionCache.remove(key);
}
return null;
}
/// Met en cache une permission
static void _cachePermission(String key, bool result, UserRole userRole) {
final ttl = userRole == UserRole.superAdmin ? _superAdminCacheTTL : _defaultCacheTTL;
_permissionCache[key] = _CachedPermission(
result: result,
expiresAt: DateTime.now().add(ttl),
);
}
/// Obtient les permissions utilisateur depuis le cache
static _CachedUserPermissions? _getCachedUserPermissions(String key) {
final cached = _userPermissionsCache[key];
if (cached != null && cached.expiresAt.isAfter(DateTime.now())) {
return cached;
}
if (cached != null) {
_userPermissionsCache.remove(key);
}
return null;
}
/// Met en cache les permissions utilisateur
static void _cacheUserPermissions(String key, List<String> permissions, UserRole userRole) {
final ttl = userRole == UserRole.superAdmin ? _superAdminCacheTTL : _defaultCacheTTL;
_userPermissionsCache[key] = _CachedUserPermissions(
permissions: permissions,
expiresAt: DateTime.now().add(ttl),
);
}
/// Enregistre un événement d'audit
static void _logAuditEvent(
User user,
String permission,
bool granted,
String reason,
String? organizationId,
) {
final event = PermissionAuditEvent(
userId: user.id,
userEmail: user.email,
permission: permission,
granted: granted,
reason: reason,
organizationId: organizationId,
timestamp: DateTime.now(),
);
_auditController.add(event);
}
}
/// Classe pour les permissions mises en cache
class _CachedPermission {
final bool result;
final DateTime expiresAt;
_CachedPermission({required this.result, required this.expiresAt});
}
/// Classe pour les permissions utilisateur mises en cache
class _CachedUserPermissions {
final List<String> permissions;
final DateTime expiresAt;
_CachedUserPermissions({required this.permissions, required this.expiresAt});
}
/// Événement d'audit des permissions
class PermissionAuditEvent {
final String userId;
final String userEmail;
final String permission;
final bool granted;
final String reason;
final String? organizationId;
final DateTime timestamp;
PermissionAuditEvent({
required this.userId,
required this.userEmail,
required this.permission,
required this.granted,
required this.reason,
this.organizationId,
required this.timestamp,
});
Map<String, dynamic> toJson() {
return {
'userId': userId,
'userEmail': userEmail,
'permission': permission,
'granted': granted,
'reason': reason,
'organizationId': organizationId,
'timestamp': timestamp.toIso8601String(),
};
}
}

View File

@@ -0,0 +1,212 @@
/// Système de permissions granulaires ultra-sophistiqué
/// Plus de 50 permissions atomiques avec héritage intelligent
library permission_matrix;
/// Matrice de permissions atomiques pour contrôle granulaire
///
/// Chaque permission suit la convention : `domain.action.scope`
/// Exemples : `members.edit.own`, `finances.view.all`, `system.admin.global`
class PermissionMatrix {
// === PERMISSIONS SYSTÈME ===
static const String SYSTEM_ADMIN = 'system.admin.global';
static const String SYSTEM_CONFIG = 'system.config.global';
static const String SYSTEM_MONITORING = 'system.monitoring.view';
static const String SYSTEM_BACKUP = 'system.backup.manage';
static const String SYSTEM_SECURITY = 'system.security.manage';
static const String SYSTEM_AUDIT = 'system.audit.view';
static const String SYSTEM_LOGS = 'system.logs.view';
static const String SYSTEM_MAINTENANCE = 'system.maintenance.execute';
// === PERMISSIONS ORGANISATION ===
static const String ORG_CREATE = 'organization.create.global';
static const String ORG_DELETE = 'organization.delete.own';
static const String ORG_CONFIG = 'organization.config.own';
static const String ORG_BRANDING = 'organization.branding.manage';
static const String ORG_SETTINGS = 'organization.settings.manage';
static const String ORG_PERMISSIONS = 'organization.permissions.manage';
static const String ORG_WORKFLOWS = 'organization.workflows.manage';
static const String ORG_INTEGRATIONS = 'organization.integrations.manage';
// === PERMISSIONS DASHBOARD ===
static const String DASHBOARD_VIEW = 'dashboard.view.own';
static const String DASHBOARD_ADMIN = 'dashboard.admin.view';
static const String DASHBOARD_ANALYTICS = 'dashboard.analytics.view';
static const String DASHBOARD_REPORTS = 'dashboard.reports.generate';
static const String DASHBOARD_EXPORT = 'dashboard.export.data';
static const String DASHBOARD_CUSTOMIZE = 'dashboard.customize.layout';
// === PERMISSIONS MEMBRES ===
static const String MEMBERS_VIEW_ALL = 'members.view.all';
static const String MEMBERS_VIEW_OWN = 'members.view.own';
static const String MEMBERS_CREATE = 'members.create.organization';
static const String MEMBERS_EDIT_ALL = 'members.edit.all';
static const String MEMBERS_EDIT_OWN = 'members.edit.own';
static const String MEMBERS_EDIT_BASIC = 'members.edit.basic';
static const String MEMBERS_DELETE = 'members.delete.organization';
static const String MEMBERS_DELETE_ALL = 'members.delete.all';
static const String MEMBERS_APPROVE = 'members.approve.requests';
static const String MEMBERS_SUSPEND = 'members.suspend.organization';
static const String MEMBERS_EXPORT = 'members.export.data';
static const String MEMBERS_IMPORT = 'members.import.data';
static const String MEMBERS_COMMUNICATE = 'members.communicate.all';
// === PERMISSIONS FINANCES ===
static const String FINANCES_VIEW_ALL = 'finances.view.all';
static const String FINANCES_VIEW_OWN = 'finances.view.own';
static const String FINANCES_EDIT_ALL = 'finances.edit.all';
static const String FINANCES_MANAGE = 'finances.manage.organization';
static const String FINANCES_APPROVE = 'finances.approve.transactions';
static const String FINANCES_REPORTS = 'finances.reports.generate';
static const String FINANCES_BUDGET = 'finances.budget.manage';
static const String FINANCES_AUDIT = 'finances.audit.access';
// === PERMISSIONS ÉVÉNEMENTS ===
static const String EVENTS_VIEW_ALL = 'events.view.all';
static const String EVENTS_VIEW_PUBLIC = 'events.view.public';
static const String EVENTS_CREATE = 'events.create.organization';
static const String EVENTS_EDIT_ALL = 'events.edit.all';
static const String EVENTS_EDIT_OWN = 'events.edit.own';
static const String EVENTS_DELETE = 'events.delete.organization';
static const String EVENTS_PARTICIPATE = 'events.participate.public';
static const String EVENTS_MODERATE = 'events.moderate.organization';
static const String EVENTS_ANALYTICS = 'events.analytics.view';
// === PERMISSIONS SOLIDARITÉ ===
static const String SOLIDARITY_VIEW_ALL = 'solidarity.view.all';
static const String SOLIDARITY_VIEW_OWN = 'solidarity.view.own';
static const String SOLIDARITY_VIEW_PUBLIC = 'solidarity.view.public';
static const String SOLIDARITY_CREATE = 'solidarity.create.request';
static const String SOLIDARITY_EDIT_ALL = 'solidarity.edit.all';
static const String SOLIDARITY_APPROVE = 'solidarity.approve.requests';
static const String SOLIDARITY_PARTICIPATE = 'solidarity.participate.actions';
static const String SOLIDARITY_MANAGE = 'solidarity.manage.organization';
static const String SOLIDARITY_FUND = 'solidarity.fund.manage';
// === PERMISSIONS COMMUNICATION ===
static const String COMM_SEND_ALL = 'communication.send.all';
static const String COMM_SEND_MEMBERS = 'communication.send.members';
static const String COMM_MODERATE = 'communication.moderate.organization';
static const String COMM_BROADCAST = 'communication.broadcast.organization';
static const String COMM_TEMPLATES = 'communication.templates.manage';
// === PERMISSIONS RAPPORTS ===
static const String REPORTS_VIEW_ALL = 'reports.view.all';
static const String REPORTS_GENERATE = 'reports.generate.organization';
static const String REPORTS_EXPORT = 'reports.export.data';
static const String REPORTS_SCHEDULE = 'reports.schedule.automated';
// === PERMISSIONS MODÉRATION ===
static const String MODERATION_CONTENT = 'moderation.content.manage';
static const String MODERATION_USERS = 'moderation.users.manage';
static const String MODERATION_REPORTS = 'moderation.reports.handle';
/// Toutes les permissions disponibles dans le système
static const List<String> ALL_PERMISSIONS = [
// Système
SYSTEM_ADMIN, SYSTEM_CONFIG, SYSTEM_MONITORING, SYSTEM_BACKUP,
SYSTEM_SECURITY, SYSTEM_AUDIT, SYSTEM_LOGS, SYSTEM_MAINTENANCE,
// Organisation
ORG_CREATE, ORG_DELETE, ORG_CONFIG, ORG_BRANDING, ORG_SETTINGS,
ORG_PERMISSIONS, ORG_WORKFLOWS, ORG_INTEGRATIONS,
// Dashboard
DASHBOARD_VIEW, DASHBOARD_ADMIN, DASHBOARD_ANALYTICS, DASHBOARD_REPORTS,
DASHBOARD_EXPORT, DASHBOARD_CUSTOMIZE,
// Membres
MEMBERS_VIEW_ALL, MEMBERS_VIEW_OWN, MEMBERS_CREATE, MEMBERS_EDIT_ALL,
MEMBERS_EDIT_OWN, MEMBERS_DELETE, MEMBERS_APPROVE, MEMBERS_SUSPEND,
MEMBERS_EXPORT, MEMBERS_IMPORT, MEMBERS_COMMUNICATE,
// Finances
FINANCES_VIEW_ALL, FINANCES_VIEW_OWN, FINANCES_MANAGE, FINANCES_APPROVE,
FINANCES_REPORTS, FINANCES_BUDGET, FINANCES_AUDIT,
// Événements
EVENTS_VIEW_ALL, EVENTS_VIEW_PUBLIC, EVENTS_CREATE, EVENTS_EDIT_ALL,
EVENTS_EDIT_OWN, EVENTS_DELETE, EVENTS_MODERATE, EVENTS_ANALYTICS,
// Solidarité
SOLIDARITY_VIEW_ALL, SOLIDARITY_VIEW_OWN, SOLIDARITY_CREATE,
SOLIDARITY_APPROVE, SOLIDARITY_MANAGE, SOLIDARITY_FUND,
// Communication
COMM_SEND_ALL, COMM_SEND_MEMBERS, COMM_MODERATE, COMM_BROADCAST,
COMM_TEMPLATES,
// Rapports
REPORTS_VIEW_ALL, REPORTS_GENERATE, REPORTS_EXPORT, REPORTS_SCHEDULE,
// Modération
MODERATION_CONTENT, MODERATION_USERS, MODERATION_REPORTS,
];
/// Permissions publiques (accessibles sans authentification)
static const List<String> PUBLIC_PERMISSIONS = [
EVENTS_VIEW_PUBLIC,
];
/// Vérifie si une permission est publique
static bool isPublicPermission(String permission) {
return PUBLIC_PERMISSIONS.contains(permission);
}
/// Obtient le domaine d'une permission (partie avant le premier point)
static String getDomain(String permission) {
return permission.split('.').first;
}
/// Obtient l'action d'une permission (partie du milieu)
static String getAction(String permission) {
final parts = permission.split('.');
return parts.length > 1 ? parts[1] : '';
}
/// Obtient la portée d'une permission (partie après le dernier point)
static String getScope(String permission) {
return permission.split('.').last;
}
/// Vérifie si une permission implique une autre (héritage)
static bool implies(String higherPermission, String lowerPermission) {
// Exemple : 'members.edit.all' implique 'members.view.all'
final higherParts = higherPermission.split('.');
final lowerParts = lowerPermission.split('.');
if (higherParts.length != 3 || lowerParts.length != 3) return false;
// Même domaine requis
if (higherParts[0] != lowerParts[0]) return false;
// Vérification des implications d'actions
return _actionImplies(higherParts[1], lowerParts[1]) &&
_scopeImplies(higherParts[2], lowerParts[2]);
}
/// Vérifie si une action implique une autre
static bool _actionImplies(String higherAction, String lowerAction) {
const actionHierarchy = {
'admin': ['manage', 'edit', 'create', 'delete', 'view'],
'manage': ['edit', 'create', 'delete', 'view'],
'edit': ['view'],
'create': ['view'],
'delete': ['view'],
};
return actionHierarchy[higherAction]?.contains(lowerAction) ??
higherAction == lowerAction;
}
/// Vérifie si une portée implique une autre
static bool _scopeImplies(String higherScope, String lowerScope) {
const scopeHierarchy = {
'global': ['all', 'organization', 'own'],
'all': ['organization', 'own'],
'organization': ['own'],
};
return scopeHierarchy[higherScope]?.contains(lowerScope) ??
higherScope == lowerScope;
}
}

View File

@@ -0,0 +1,359 @@
/// Modèles de données utilisateur avec contexte et permissions
/// Support des relations multi-organisations et permissions contextuelles
library user_models;
import 'package:equatable/equatable.dart';
import 'user_role.dart';
/// Modèle utilisateur principal avec contexte multi-organisations
///
/// Supporte les utilisateurs ayant des rôles différents dans plusieurs organisations
/// avec des permissions contextuelles et des préférences personnalisées
class User extends Equatable {
/// Identifiant unique de l'utilisateur
final String id;
/// Informations personnelles
final String email;
final String firstName;
final String lastName;
final String? avatar;
final String? phone;
/// Rôle principal de l'utilisateur (le plus élevé)
final UserRole primaryRole;
/// Contextes organisationnels (rôles dans différentes organisations)
final List<UserOrganizationContext> organizationContexts;
/// Permissions supplémentaires accordées spécifiquement
final List<String> additionalPermissions;
/// Permissions révoquées spécifiquement
final List<String> revokedPermissions;
/// Préférences utilisateur
final UserPreferences preferences;
/// Métadonnées
final DateTime createdAt;
final DateTime lastLoginAt;
final bool isActive;
final bool isVerified;
/// Constructeur du modèle utilisateur
const User({
required this.id,
required this.email,
required this.firstName,
required this.lastName,
required this.primaryRole,
this.avatar,
this.phone,
this.organizationContexts = const [],
this.additionalPermissions = const [],
this.revokedPermissions = const [],
this.preferences = const UserPreferences(),
required this.createdAt,
required this.lastLoginAt,
this.isActive = true,
this.isVerified = false,
});
/// Nom complet de l'utilisateur
String get fullName => '$firstName $lastName';
/// Initiales de l'utilisateur
String get initials => '${firstName[0]}${lastName[0]}'.toUpperCase();
/// Vérifie si l'utilisateur a une permission dans le contexte actuel
bool hasPermission(String permission, {String? organizationId}) {
// Vérification des permissions révoquées
if (revokedPermissions.contains(permission)) return false;
// Vérification des permissions additionnelles
if (additionalPermissions.contains(permission)) return true;
// Vérification du rôle principal
if (primaryRole.hasPermission(permission)) return true;
// Vérification dans le contexte organisationnel spécifique
if (organizationId != null) {
final context = getOrganizationContext(organizationId);
if (context?.role.hasPermission(permission) == true) return true;
}
// Vérification dans tous les contextes organisationnels
return organizationContexts.any((context) =>
context.role.hasPermission(permission));
}
/// Obtient le contexte organisationnel pour une organisation
UserOrganizationContext? getOrganizationContext(String organizationId) {
try {
return organizationContexts.firstWhere(
(context) => context.organizationId == organizationId,
);
} catch (e) {
return null;
}
}
/// Obtient le rôle dans une organisation spécifique
UserRole getRoleInOrganization(String organizationId) {
final context = getOrganizationContext(organizationId);
return context?.role ?? primaryRole;
}
/// Vérifie si l'utilisateur est membre d'une organisation
bool isMemberOfOrganization(String organizationId) {
return organizationContexts.any(
(context) => context.organizationId == organizationId,
);
}
/// Obtient toutes les permissions effectives de l'utilisateur
List<String> getEffectivePermissions({String? organizationId}) {
final permissions = <String>{};
// Permissions du rôle principal
permissions.addAll(primaryRole.getEffectivePermissions());
// Permissions des contextes organisationnels
if (organizationId != null) {
final context = getOrganizationContext(organizationId);
if (context != null) {
permissions.addAll(context.role.getEffectivePermissions());
}
} else {
for (final context in organizationContexts) {
permissions.addAll(context.role.getEffectivePermissions());
}
}
// Permissions additionnelles
permissions.addAll(additionalPermissions);
// Retirer les permissions révoquées
permissions.removeAll(revokedPermissions);
return permissions.toList()..sort();
}
/// Crée une copie de l'utilisateur avec des modifications
User copyWith({
String? email,
String? firstName,
String? lastName,
String? avatar,
String? phone,
UserRole? primaryRole,
List<UserOrganizationContext>? organizationContexts,
List<String>? additionalPermissions,
List<String>? revokedPermissions,
UserPreferences? preferences,
DateTime? lastLoginAt,
bool? isActive,
bool? isVerified,
}) {
return User(
id: id,
email: email ?? this.email,
firstName: firstName ?? this.firstName,
lastName: lastName ?? this.lastName,
avatar: avatar ?? this.avatar,
phone: phone ?? this.phone,
primaryRole: primaryRole ?? this.primaryRole,
organizationContexts: organizationContexts ?? this.organizationContexts,
additionalPermissions: additionalPermissions ?? this.additionalPermissions,
revokedPermissions: revokedPermissions ?? this.revokedPermissions,
preferences: preferences ?? this.preferences,
createdAt: createdAt,
lastLoginAt: lastLoginAt ?? this.lastLoginAt,
isActive: isActive ?? this.isActive,
isVerified: isVerified ?? this.isVerified,
);
}
/// Conversion vers Map pour sérialisation
Map<String, dynamic> toJson() {
return {
'id': id,
'email': email,
'firstName': firstName,
'lastName': lastName,
'avatar': avatar,
'phone': phone,
'primaryRole': primaryRole.name,
'organizationContexts': organizationContexts.map((c) => c.toJson()).toList(),
'additionalPermissions': additionalPermissions,
'revokedPermissions': revokedPermissions,
'preferences': preferences.toJson(),
'createdAt': createdAt.toIso8601String(),
'lastLoginAt': lastLoginAt.toIso8601String(),
'isActive': isActive,
'isVerified': isVerified,
};
}
/// Création depuis Map pour désérialisation
factory User.fromJson(Map<String, dynamic> json) {
return User(
id: json['id'],
email: json['email'],
firstName: json['firstName'],
lastName: json['lastName'],
avatar: json['avatar'],
phone: json['phone'],
primaryRole: UserRole.fromString(json['primaryRole']) ?? UserRole.visitor,
organizationContexts: (json['organizationContexts'] as List?)
?.map((c) => UserOrganizationContext.fromJson(c))
.toList() ?? [],
additionalPermissions: List<String>.from(json['additionalPermissions'] ?? []),
revokedPermissions: List<String>.from(json['revokedPermissions'] ?? []),
preferences: UserPreferences.fromJson(json['preferences'] ?? {}),
createdAt: DateTime.parse(json['createdAt']),
lastLoginAt: DateTime.parse(json['lastLoginAt']),
isActive: json['isActive'] ?? true,
isVerified: json['isVerified'] ?? false,
);
}
@override
List<Object?> get props => [
id, email, firstName, lastName, avatar, phone, primaryRole,
organizationContexts, additionalPermissions, revokedPermissions,
preferences, createdAt, lastLoginAt, isActive, isVerified,
];
}
/// Contexte organisationnel d'un utilisateur
///
/// Définit le rôle et les permissions spécifiques dans une organisation
class UserOrganizationContext extends Equatable {
/// Identifiant de l'organisation
final String organizationId;
/// Nom de l'organisation
final String organizationName;
/// Rôle de l'utilisateur dans cette organisation
final UserRole role;
/// Permissions spécifiques dans cette organisation
final List<String> specificPermissions;
/// Date d'adhésion à l'organisation
final DateTime joinedAt;
/// Statut dans l'organisation
final bool isActive;
/// Constructeur du contexte organisationnel
const UserOrganizationContext({
required this.organizationId,
required this.organizationName,
required this.role,
this.specificPermissions = const [],
required this.joinedAt,
this.isActive = true,
});
/// Conversion vers Map
Map<String, dynamic> toJson() {
return {
'organizationId': organizationId,
'organizationName': organizationName,
'role': role.name,
'specificPermissions': specificPermissions,
'joinedAt': joinedAt.toIso8601String(),
'isActive': isActive,
};
}
/// Création depuis Map
factory UserOrganizationContext.fromJson(Map<String, dynamic> json) {
return UserOrganizationContext(
organizationId: json['organizationId'],
organizationName: json['organizationName'],
role: UserRole.fromString(json['role']) ?? UserRole.visitor,
specificPermissions: List<String>.from(json['specificPermissions'] ?? []),
joinedAt: DateTime.parse(json['joinedAt']),
isActive: json['isActive'] ?? true,
);
}
@override
List<Object?> get props => [
organizationId, organizationName, role, specificPermissions, joinedAt, isActive,
];
}
/// Préférences utilisateur personnalisables
class UserPreferences extends Equatable {
/// Langue préférée
final String language;
/// Thème préféré
final String theme;
/// Notifications activées
final bool notificationsEnabled;
/// Notifications par email
final bool emailNotifications;
/// Notifications push
final bool pushNotifications;
/// Layout du dashboard préféré
final String dashboardLayout;
/// Timezone
final String timezone;
/// Constructeur des préférences
const UserPreferences({
this.language = 'fr',
this.theme = 'system',
this.notificationsEnabled = true,
this.emailNotifications = true,
this.pushNotifications = true,
this.dashboardLayout = 'default',
this.timezone = 'Europe/Paris',
});
/// Conversion vers Map
Map<String, dynamic> toJson() {
return {
'language': language,
'theme': theme,
'notificationsEnabled': notificationsEnabled,
'emailNotifications': emailNotifications,
'pushNotifications': pushNotifications,
'dashboardLayout': dashboardLayout,
'timezone': timezone,
};
}
/// Création depuis Map
factory UserPreferences.fromJson(Map<String, dynamic> json) {
return UserPreferences(
language: json['language'] ?? 'fr',
theme: json['theme'] ?? 'system',
notificationsEnabled: json['notificationsEnabled'] ?? true,
emailNotifications: json['emailNotifications'] ?? true,
pushNotifications: json['pushNotifications'] ?? true,
dashboardLayout: json['dashboardLayout'] ?? 'default',
timezone: json['timezone'] ?? 'Europe/Paris',
);
}
@override
List<Object?> get props => [
language, theme, notificationsEnabled, emailNotifications,
pushNotifications, dashboardLayout, timezone,
];
}

View File

@@ -0,0 +1,359 @@
/// Système de rôles utilisateurs avec hiérarchie intelligente
/// 6 niveaux de rôles avec permissions héritées et contextuelles
library user_role;
import 'permission_matrix.dart';
/// Énumération des rôles utilisateurs avec hiérarchie et permissions
///
/// Chaque rôle a un niveau numérique pour faciliter les comparaisons
/// et une liste de permissions spécifiques avec héritage intelligent
enum UserRole {
/// Super Administrateur - Niveau système (100)
/// Accès complet à toutes les fonctionnalités multi-organisations
superAdmin(
level: 100,
displayName: 'Super Administrateur',
description: 'Accès complet système et multi-organisations',
color: 0xFF6C5CE7, // Violet sophistiqué
permissions: _superAdminPermissions,
),
/// Administrateur d'Organisation - Niveau organisation (80)
/// Gestion complète de son organisation uniquement
orgAdmin(
level: 80,
displayName: 'Administrateur',
description: 'Gestion complète de l\'organisation',
color: 0xFF0984E3, // Bleu corporate
permissions: _orgAdminPermissions,
),
/// Modérateur/Gestionnaire - Niveau intermédiaire (60)
/// Gestion partielle selon permissions accordées
moderator(
level: 60,
displayName: 'Modérateur',
description: 'Gestion partielle et modération',
color: 0xFFE17055, // Orange focus
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(
level: 40,
displayName: 'Membre Actif',
description: 'Participation active aux activités',
color: 0xFF00B894, // Vert communauté
permissions: _activeMemberPermissions,
),
/// Membre Simple - Niveau basique (20)
/// Accès limité aux informations personnelles
simpleMember(
level: 20,
displayName: 'Membre',
description: 'Accès aux informations de base',
color: 0xFF00CEC9, // Teal simple
permissions: _simpleMemberPermissions,
),
/// Visiteur/Invité - Niveau public (0)
/// Accès aux informations publiques uniquement
visitor(
level: 0,
displayName: 'Visiteur',
description: 'Accès aux informations publiques',
color: 0xFF6C5CE7, // Indigo accueillant
permissions: _visitorPermissions,
);
/// Constructeur du rôle avec toutes ses propriétés
const UserRole({
required this.level,
required this.displayName,
required this.description,
required this.color,
required this.permissions,
});
/// Niveau numérique du rôle (0-100)
final int level;
/// Nom d'affichage du rôle
final String displayName;
/// Description détaillée du rôle
final String description;
/// Couleur thématique du rôle (format 0xFFRRGGBB)
final int color;
/// Liste des permissions spécifiques au rôle
final List<String> permissions;
/// Vérifie si ce rôle a un niveau supérieur ou égal à un autre
bool hasLevelOrAbove(UserRole other) => level >= other.level;
/// Vérifie si ce rôle a un niveau strictement supérieur à un autre
bool hasLevelAbove(UserRole other) => level > other.level;
/// Vérifie si ce rôle possède une permission spécifique
bool hasPermission(String permission) {
// Vérification directe
if (permissions.contains(permission)) return true;
// Vérification par héritage (permissions impliquées)
return permissions.any((p) => PermissionMatrix.implies(p, permission));
}
/// Obtient toutes les permissions effectives (directes + héritées)
List<String> getEffectivePermissions() {
final effective = <String>{};
// Ajouter les permissions directes
effective.addAll(permissions);
// Ajouter les permissions impliquées
for (final permission in permissions) {
for (final allPermission in PermissionMatrix.ALL_PERMISSIONS) {
if (PermissionMatrix.implies(permission, allPermission)) {
effective.add(allPermission);
}
}
}
return effective.toList()..sort();
}
/// Vérifie si ce rôle peut effectuer une action sur un domaine
bool canPerformAction(String domain, String action, {String scope = 'own'}) {
final permission = '$domain.$action.$scope';
return hasPermission(permission);
}
/// Obtient le rôle à partir de son nom
static UserRole? fromString(String roleName) {
return UserRole.values.firstWhere(
(role) => role.name == roleName,
orElse: () => UserRole.visitor,
);
}
/// Obtient tous les rôles avec un niveau inférieur ou égal
List<UserRole> getSubordinateRoles() {
return UserRole.values.where((role) => role.level < level).toList();
}
/// Obtient tous les rôles avec un niveau supérieur ou égal
List<UserRole> getSuperiorRoles() {
return UserRole.values.where((role) => role.level >= level).toList();
}
}
// === DÉFINITIONS DES PERMISSIONS PAR RÔLE ===
/// Permissions du Super Administrateur (accès complet)
const List<String> _superAdminPermissions = [
// Toutes les permissions système
PermissionMatrix.SYSTEM_ADMIN,
PermissionMatrix.SYSTEM_CONFIG,
PermissionMatrix.SYSTEM_MONITORING,
PermissionMatrix.SYSTEM_BACKUP,
PermissionMatrix.SYSTEM_SECURITY,
PermissionMatrix.SYSTEM_AUDIT,
PermissionMatrix.SYSTEM_LOGS,
PermissionMatrix.SYSTEM_MAINTENANCE,
// Gestion globale des organisations
PermissionMatrix.ORG_CREATE,
PermissionMatrix.ORG_DELETE,
PermissionMatrix.ORG_CONFIG,
// Accès complet aux dashboards
PermissionMatrix.DASHBOARD_ADMIN,
PermissionMatrix.DASHBOARD_ANALYTICS,
PermissionMatrix.DASHBOARD_REPORTS,
PermissionMatrix.DASHBOARD_EXPORT,
// Gestion complète des membres
PermissionMatrix.MEMBERS_VIEW_ALL,
PermissionMatrix.MEMBERS_EDIT_ALL,
PermissionMatrix.MEMBERS_DELETE,
PermissionMatrix.MEMBERS_EXPORT,
PermissionMatrix.MEMBERS_IMPORT,
// Accès complet aux finances
PermissionMatrix.FINANCES_VIEW_ALL,
PermissionMatrix.FINANCES_MANAGE,
PermissionMatrix.FINANCES_AUDIT,
// Tous les rapports
PermissionMatrix.REPORTS_VIEW_ALL,
PermissionMatrix.REPORTS_GENERATE,
PermissionMatrix.REPORTS_EXPORT,
PermissionMatrix.REPORTS_SCHEDULE,
];
/// Permissions de l'Administrateur d'Organisation
const List<String> _orgAdminPermissions = [
// Configuration organisation
PermissionMatrix.ORG_CONFIG,
PermissionMatrix.ORG_BRANDING,
PermissionMatrix.ORG_SETTINGS,
PermissionMatrix.ORG_PERMISSIONS,
PermissionMatrix.ORG_WORKFLOWS,
// Dashboard organisation
PermissionMatrix.DASHBOARD_VIEW,
PermissionMatrix.DASHBOARD_ANALYTICS,
PermissionMatrix.DASHBOARD_REPORTS,
PermissionMatrix.DASHBOARD_CUSTOMIZE,
// Gestion des membres
PermissionMatrix.MEMBERS_VIEW_ALL,
PermissionMatrix.MEMBERS_CREATE,
PermissionMatrix.MEMBERS_EDIT_ALL,
PermissionMatrix.MEMBERS_APPROVE,
PermissionMatrix.MEMBERS_SUSPEND,
PermissionMatrix.MEMBERS_COMMUNICATE,
// Gestion financière
PermissionMatrix.FINANCES_VIEW_ALL,
PermissionMatrix.FINANCES_MANAGE,
PermissionMatrix.FINANCES_REPORTS,
PermissionMatrix.FINANCES_BUDGET,
// Gestion des événements
PermissionMatrix.EVENTS_VIEW_ALL,
PermissionMatrix.EVENTS_CREATE,
PermissionMatrix.EVENTS_EDIT_ALL,
PermissionMatrix.EVENTS_DELETE,
PermissionMatrix.EVENTS_ANALYTICS,
// Gestion de la solidarité
PermissionMatrix.SOLIDARITY_VIEW_ALL,
PermissionMatrix.SOLIDARITY_APPROVE,
PermissionMatrix.SOLIDARITY_MANAGE,
PermissionMatrix.SOLIDARITY_FUND,
// Communication
PermissionMatrix.COMM_SEND_ALL,
PermissionMatrix.COMM_BROADCAST,
PermissionMatrix.COMM_TEMPLATES,
// Rapports organisation
PermissionMatrix.REPORTS_GENERATE,
PermissionMatrix.REPORTS_EXPORT,
];
/// Permissions du Modérateur
const List<String> _moderatorPermissions = [
// Dashboard limité
PermissionMatrix.DASHBOARD_VIEW,
// Modération des membres
PermissionMatrix.MEMBERS_VIEW_ALL,
PermissionMatrix.MEMBERS_APPROVE,
PermissionMatrix.MODERATION_USERS,
// Modération du contenu
PermissionMatrix.MODERATION_CONTENT,
PermissionMatrix.MODERATION_REPORTS,
// Événements limités
PermissionMatrix.EVENTS_VIEW_ALL,
PermissionMatrix.EVENTS_MODERATE,
// Communication modérée
PermissionMatrix.COMM_MODERATE,
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
PermissionMatrix.DASHBOARD_VIEW,
// Profil personnel
PermissionMatrix.MEMBERS_VIEW_OWN,
PermissionMatrix.MEMBERS_EDIT_OWN,
// Finances personnelles
PermissionMatrix.FINANCES_VIEW_OWN,
// Événements
PermissionMatrix.EVENTS_VIEW_ALL,
PermissionMatrix.EVENTS_CREATE,
PermissionMatrix.EVENTS_EDIT_OWN,
// Solidarité
PermissionMatrix.SOLIDARITY_VIEW_ALL,
PermissionMatrix.SOLIDARITY_CREATE,
];
/// Permissions du Membre Simple
const List<String> _simpleMemberPermissions = [
// Dashboard basique
PermissionMatrix.DASHBOARD_VIEW,
// Profil personnel uniquement
PermissionMatrix.MEMBERS_VIEW_OWN,
PermissionMatrix.MEMBERS_EDIT_OWN,
// Finances personnelles
PermissionMatrix.FINANCES_VIEW_OWN,
// Événements publics
PermissionMatrix.EVENTS_VIEW_PUBLIC,
// Solidarité consultation
PermissionMatrix.SOLIDARITY_VIEW_OWN,
];
/// Permissions du Visiteur
const List<String> _visitorPermissions = [
// Événements publics uniquement
PermissionMatrix.EVENTS_VIEW_PUBLIC,
];

View File

@@ -0,0 +1,139 @@
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/storage/dashboard_cache_manager.dart';
// === ÉVÉNEMENTS ===
abstract class AuthEvent extends Equatable {
const AuthEvent();
@override
List<Object?> get props => [];
}
class AuthLoginRequested extends AuthEvent {
final String email;
final String password;
const AuthLoginRequested(this.email, this.password);
@override
List<Object?> get props => [email, password];
}
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<Object?> 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<String> effectivePermissions;
final String accessToken;
const AuthAuthenticated({
required this.user,
required this.effectiveRole,
required this.effectivePermissions,
required this.accessToken,
});
@override
List<Object?> get props => [user, effectiveRole, effectivePermissions, accessToken];
}
class AuthError extends AuthState {
final String message;
const AuthError(this.message);
@override
List<Object?> get props => [message];
}
// === BLOC ===
@lazySingleton
class AuthBloc extends Bloc<AuthEvent, AuthState> {
final KeycloakAuthService _authService;
AuthBloc(this._authService) : super(AuthInitial()) {
on<AuthLoginRequested>(_onLoginRequested);
on<AuthLogoutRequested>(_onLogoutRequested);
on<AuthStatusChecked>(_onStatusChecked);
on<AuthTokenRefreshRequested>(_onTokenRefreshRequested);
}
Future<void> _onLoginRequested(AuthLoginRequested event, Emitter<AuthState> emit) async {
emit(AuthLoading());
try {
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 {
emit(const AuthError('Identifiants incorrects.'));
}
} catch (e) {
emit(AuthError('Erreur de connexion: $e'));
}
}
Future<void> _onLogoutRequested(AuthLogoutRequested event, Emitter<AuthState> emit) async {
emit(AuthLoading());
await _authService.logout();
await DashboardCacheManager.clear();
emit(AuthUnauthenticated());
}
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 ?? '',
));
}
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());
}
}
}
}

View File

@@ -0,0 +1,583 @@
/// Page d'Authentification UnionFlow
///
/// Interface utilisateur pour la connexion sécurisée
/// avec gestion complète des états et des erreurs.
library keycloak_webview_auth_page;
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:webview_flutter/webview_flutter.dart';
import '../../data/datasources/keycloak_webview_auth_service.dart';
import '../../data/models/user.dart';
import '../../../../shared/design_system/tokens/color_tokens.dart';
import '../../../../shared/design_system/tokens/spacing_tokens.dart';
import '../../../../shared/design_system/tokens/typography_tokens.dart';
/// États de l'authentification WebView
enum KeycloakWebViewAuthState {
/// Initialisation en cours
initializing,
/// Chargement de la page d'authentification
loading,
/// Page d'authentification affichée
ready,
/// Authentification en cours
authenticating,
/// Authentification réussie
success,
/// Erreur d'authentification
error,
/// Timeout
timeout,
}
/// Page d'authentification Keycloak avec WebView
class KeycloakWebViewAuthPage extends StatefulWidget {
/// Callback appelé en cas de succès d'authentification
final Function(User user) onAuthSuccess;
/// Callback appelé en cas d'erreur
final Function(String error) onAuthError;
/// Callback appelé en cas d'annulation
final VoidCallback? onAuthCancel;
/// Timeout pour l'authentification (en secondes)
final int timeoutSeconds;
const KeycloakWebViewAuthPage({
super.key,
required this.onAuthSuccess,
required this.onAuthError,
this.onAuthCancel,
this.timeoutSeconds = 300, // 5 minutes par défaut
});
@override
State<KeycloakWebViewAuthPage> createState() => _KeycloakWebViewAuthPageState();
}
class _KeycloakWebViewAuthPageState extends State<KeycloakWebViewAuthPage>
with TickerProviderStateMixin {
// Contrôleurs et état
late WebViewController _webViewController;
late AnimationController _progressAnimationController;
late Animation<double> _progressAnimation;
Timer? _timeoutTimer;
// État de l'authentification
KeycloakWebViewAuthState _authState = KeycloakWebViewAuthState.initializing;
String? _errorMessage;
double _loadingProgress = 0.0;
// Paramètres d'authentification
String? _authUrl;
@override
void initState() {
super.initState();
_initializeAnimations();
_initializeAuthentication();
}
@override
void dispose() {
_progressAnimationController.dispose();
_timeoutTimer?.cancel();
super.dispose();
}
/// Initialise les animations
void _initializeAnimations() {
_progressAnimationController = AnimationController(
duration: const Duration(milliseconds: 300),
vsync: this,
);
_progressAnimation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(CurvedAnimation(
parent: _progressAnimationController,
curve: Curves.easeInOut,
));
}
/// Initialise l'authentification
Future<void> _initializeAuthentication() async {
try {
debugPrint('🚀 Initialisation de l\'authentification WebView...');
setState(() {
_authState = KeycloakWebViewAuthState.initializing;
});
// Préparer l'authentification
final Map<String, String> authParams =
await KeycloakWebViewAuthService.prepareAuthentication();
_authUrl = authParams['url'];
if (_authUrl == null) {
throw Exception('URL d\'authentification manquante');
}
// Initialiser la WebView
await _initializeWebView();
// Démarrer le timer de timeout
_startTimeoutTimer();
debugPrint('✅ Authentification initialisée avec succès');
} catch (e) {
debugPrint('💥 Erreur initialisation authentification: $e');
_handleError('Erreur d\'initialisation: $e');
}
}
/// Initialise la WebView
Future<void> _initializeWebView() async {
_webViewController = WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..setBackgroundColor(ColorTokens.surface)
..setNavigationDelegate(
NavigationDelegate(
onProgress: _onLoadingProgress,
onPageStarted: _onPageStarted,
onPageFinished: _onPageFinished,
onWebResourceError: _onWebResourceError,
onNavigationRequest: _onNavigationRequest,
),
);
// Charger l'URL d'authentification
if (_authUrl != null) {
await _webViewController.loadRequest(Uri.parse(_authUrl!));
setState(() {
_authState = KeycloakWebViewAuthState.loading;
});
}
}
/// Démarre le timer de timeout
void _startTimeoutTimer() {
_timeoutTimer = Timer(Duration(seconds: widget.timeoutSeconds), () {
if (_authState != KeycloakWebViewAuthState.success) {
debugPrint('⏰ Timeout d\'authentification atteint');
_handleTimeout();
}
});
}
/// Gère la progression du chargement
void _onLoadingProgress(int progress) {
setState(() {
_loadingProgress = progress / 100.0;
});
if (progress == 100) {
_progressAnimationController.forward();
}
}
/// Gère le début du chargement d'une page
void _onPageStarted(String url) {
debugPrint('📄 Chargement de la page: $url');
setState(() {
_loadingProgress = 0.0;
});
_progressAnimationController.reset();
}
/// Gère la fin du chargement d'une page
void _onPageFinished(String url) {
debugPrint('✅ Page chargée: $url');
setState(() {
if (_authState == KeycloakWebViewAuthState.loading) {
_authState = KeycloakWebViewAuthState.ready;
}
});
}
/// Gère les erreurs de ressources web
void _onWebResourceError(WebResourceError error) {
debugPrint('💥 Erreur WebView: ${error.description}');
// Ignorer certaines erreurs non critiques
if (error.errorCode == -999) { // Code d'erreur pour annulation
return;
}
_handleError('Erreur de chargement: ${error.description}');
}
/// Gère les requêtes de navigation
NavigationDecision _onNavigationRequest(NavigationRequest request) {
final String url = request.url;
debugPrint('🔗 Navigation vers: $url');
// Vérifier si c'est notre URL de callback
if (url.startsWith('dev.lions.unionflow-mobile://auth/callback')) {
debugPrint('🎯 URL de callback détectée: $url');
_handleAuthCallback(url);
return NavigationDecision.prevent;
}
// Vérifier d'autres patterns de callback possibles
if (url.contains('code=') && url.contains('state=')) {
debugPrint('🎯 Callback potentiel détecté (avec code et state): $url');
_handleAuthCallback(url);
return NavigationDecision.prevent;
}
return NavigationDecision.navigate;
}
/// Traite le callback d'authentification
Future<void> _handleAuthCallback(String callbackUrl) async {
try {
setState(() {
_authState = KeycloakWebViewAuthState.authenticating;
});
debugPrint('🔄 Traitement du callback d\'authentification...');
debugPrint('📋 URL de callback reçue: $callbackUrl');
// Traiter le callback via le service
final User user = await KeycloakWebViewAuthService.handleAuthCallback(callbackUrl);
setState(() {
_authState = KeycloakWebViewAuthState.success;
});
// Annuler le timer de timeout
_timeoutTimer?.cancel();
debugPrint('🎉 Authentification réussie pour: ${user.fullName}');
debugPrint('👤 Rôle: ${user.primaryRole.displayName}');
debugPrint('🔐 Permissions: ${user.additionalPermissions.length}');
// Notifier le succès avec un délai pour l'animation
Future.delayed(const Duration(milliseconds: 500), () {
widget.onAuthSuccess(user);
});
} catch (e, stackTrace) {
debugPrint('💥 Erreur traitement callback: $e');
debugPrint('📋 Stack trace: $stackTrace');
// Essayer de donner plus d'informations sur l'erreur
String errorMessage = 'Erreur d\'authentification: $e';
if (e.toString().contains('MISSING_AUTH_STATE')) {
errorMessage = 'Session expirée. Veuillez réessayer.';
} else if (e.toString().contains('INVALID_STATE')) {
errorMessage = 'Erreur de sécurité. Veuillez réessayer.';
} else if (e.toString().contains('MISSING_AUTH_CODE')) {
errorMessage = 'Code d\'autorisation manquant. Veuillez réessayer.';
}
_handleError(errorMessage);
}
}
/// Gère les erreurs
void _handleError(String error) {
setState(() {
_authState = KeycloakWebViewAuthState.error;
_errorMessage = error;
});
_timeoutTimer?.cancel();
// Vibration pour indiquer l'erreur
HapticFeedback.lightImpact();
widget.onAuthError(error);
}
/// Gère le timeout
void _handleTimeout() {
setState(() {
_authState = KeycloakWebViewAuthState.timeout;
_errorMessage = 'Timeout d\'authentification atteint';
});
HapticFeedback.lightImpact();
widget.onAuthError('Timeout d\'authentification');
}
/// Gère l'annulation
void _handleCancel() {
debugPrint('❌ Authentification annulée par l\'utilisateur');
_timeoutTimer?.cancel();
if (widget.onAuthCancel != null) {
widget.onAuthCancel!();
} else {
Navigator.of(context).pop();
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: ColorTokens.surface,
appBar: _buildAppBar(),
body: _buildBody(),
);
}
/// Construit l'AppBar
PreferredSizeWidget _buildAppBar() {
return AppBar(
backgroundColor: ColorTokens.primary,
foregroundColor: ColorTokens.onPrimary,
elevation: 0,
title: Text(
'Connexion Sécurisée',
style: TypographyTokens.headlineSmall.copyWith(
color: ColorTokens.onPrimary,
fontWeight: FontWeight.w600,
),
),
leading: IconButton(
icon: const Icon(Icons.close),
onPressed: _handleCancel,
tooltip: 'Annuler',
),
actions: [
if (_authState == KeycloakWebViewAuthState.ready)
IconButton(
icon: const Icon(Icons.refresh),
onPressed: () => _webViewController.reload(),
tooltip: 'Actualiser',
),
],
bottom: _buildProgressIndicator(),
);
}
/// Construit l'indicateur de progression
PreferredSizeWidget? _buildProgressIndicator() {
if (_authState == KeycloakWebViewAuthState.loading ||
_authState == KeycloakWebViewAuthState.authenticating) {
return PreferredSize(
preferredSize: const Size.fromHeight(4.0),
child: AnimatedBuilder(
animation: _progressAnimation,
builder: (context, child) {
return LinearProgressIndicator(
value: _authState == KeycloakWebViewAuthState.authenticating
? null
: _loadingProgress,
backgroundColor: ColorTokens.onPrimary.withOpacity(0.3),
valueColor: const AlwaysStoppedAnimation<Color>(ColorTokens.onPrimary),
);
},
),
);
}
return null;
}
/// Construit le corps de la page
Widget _buildBody() {
switch (_authState) {
case KeycloakWebViewAuthState.initializing:
return _buildInitializingView();
case KeycloakWebViewAuthState.loading:
case KeycloakWebViewAuthState.ready:
return _buildWebView();
case KeycloakWebViewAuthState.authenticating:
return _buildAuthenticatingView();
case KeycloakWebViewAuthState.success:
return _buildSuccessView();
case KeycloakWebViewAuthState.error:
case KeycloakWebViewAuthState.timeout:
return _buildErrorView();
}
}
/// Vue d'initialisation
Widget _buildInitializingView() {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const CircularProgressIndicator(),
const SizedBox(height: SpacingTokens.xl),
Text(
'Initialisation...',
style: TypographyTokens.bodyLarge.copyWith(
color: ColorTokens.onSurface,
),
),
],
),
);
}
/// Vue WebView
Widget _buildWebView() {
return WebViewWidget(controller: _webViewController);
}
/// Vue d'authentification en cours
Widget _buildAuthenticatingView() {
return Container(
color: ColorTokens.surface,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const CircularProgressIndicator(),
const SizedBox(height: SpacingTokens.xxxl),
Text(
'Connexion en cours...',
style: TypographyTokens.headlineSmall.copyWith(
color: ColorTokens.onSurface,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: SpacingTokens.xl),
Text(
'Veuillez patienter pendant que nous\nvérifions vos informations.',
textAlign: TextAlign.center,
style: TypographyTokens.bodyMedium.copyWith(
color: ColorTokens.onSurface.withOpacity(0.7),
),
),
],
),
),
);
}
/// Vue de succès
Widget _buildSuccessView() {
return Container(
color: ColorTokens.surface,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 80,
height: 80,
decoration: const BoxDecoration(
color: Colors.green,
shape: BoxShape.circle,
),
child: const Icon(
Icons.check,
color: Colors.white,
size: 48,
),
),
const SizedBox(height: SpacingTokens.xxxl),
Text(
'Connexion réussie !',
style: TypographyTokens.headlineSmall.copyWith(
color: ColorTokens.onSurface,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: SpacingTokens.xl),
Text(
'Redirection vers l\'application...',
style: TypographyTokens.bodyMedium.copyWith(
color: ColorTokens.onSurface.withOpacity(0.7),
),
),
],
),
),
);
}
/// Vue d'erreur
Widget _buildErrorView() {
return Container(
color: ColorTokens.surface,
padding: const EdgeInsets.all(SpacingTokens.xxxl),
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 80,
height: 80,
decoration: const BoxDecoration(
color: ColorTokens.error,
shape: BoxShape.circle,
),
child: Icon(
_authState == KeycloakWebViewAuthState.timeout
? Icons.access_time
: Icons.error_outline,
color: ColorTokens.onError,
size: 48,
),
),
const SizedBox(height: SpacingTokens.xxxl),
Text(
_authState == KeycloakWebViewAuthState.timeout
? 'Délai d\'attente dépassé'
: 'Erreur de connexion',
style: TypographyTokens.headlineSmall.copyWith(
color: ColorTokens.onSurface,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: SpacingTokens.xl),
Text(
_errorMessage ?? 'Une erreur inattendue s\'est produite',
textAlign: TextAlign.center,
style: TypographyTokens.bodyMedium.copyWith(
color: ColorTokens.onSurface.withOpacity(0.7),
),
),
const SizedBox(height: SpacingTokens.huge),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
ElevatedButton.icon(
onPressed: _initializeAuthentication,
icon: const Icon(Icons.refresh),
label: const Text('Réessayer'),
style: ElevatedButton.styleFrom(
backgroundColor: ColorTokens.primary,
foregroundColor: ColorTokens.onPrimary,
),
),
OutlinedButton.icon(
onPressed: _handleCancel,
icon: const Icon(Icons.close),
label: const Text('Annuler'),
style: OutlinedButton.styleFrom(
foregroundColor: ColorTokens.onSurface,
),
),
],
),
],
),
),
);
}
}

View File

@@ -0,0 +1,169 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:url_launcher/url_launcher.dart';
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({Key? key}) : super(key: key);
@override
State<LoginPage> createState() => _LoginPageState();
}
class _LoginPageState extends State<LoginPage> {
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
@override
void dispose() {
_emailController.dispose();
_passwordController.dispose();
super.dispose();
}
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',
);
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')),
);
}
}
}
void _onLogin() {
final email = _emailController.text;
final password = _passwordController.text;
if (email.isNotEmpty && password.isNotEmpty) {
context.read<AuthBloc>().add(AuthLoginRequested(email, password));
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: BlocConsumer<AuthBloc, AuthState>(
listener: (context, state) {
if (state is AuthAuthenticated) {
// Navigator 1.0 : Le BlocBuilder dans AppRouter gérera la transition vers MainNavigationLayout
} else if (state is AuthError) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.message, style: AppTypography.bodyTextSmall),
backgroundColor: AppColors.error,
),
);
}
},
builder: (context, state) {
final isLoading = state is AuthLoading;
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),
// 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,
),
),
],
),
),
),
);
},
),
);
}
}