fix(mobile): URL changement mdp corrigée + v3.0 — multi-org, AppAuth, sécurité prod
Auth: - profile_repository.dart: /api/auth/change-password → /api/membres/auth/change-password Multi-org (Phase 3): - OrgSelectorPage, OrgSwitcherBloc, OrgSwitcherEntry - org_context_service.dart: headers X-Active-Organisation-Id + X-Active-Role Navigation: - MorePage: navigation conditionnelle par typeOrganisation - Suppression adaptive_navigation (remplacé par main_navigation_layout) Auth AppAuth: - keycloak_webview_auth_service: fixes AppAuth Android - AuthBloc: gestion REAUTH_REQUIS + premierLoginComplet Onboarding: - Nouveaux états: payment_method_page, onboarding_shared_widgets - SouscriptionStatusModel mis à jour StatutValidationSouscription Android: - build.gradle: ProGuard/R8, network_security_config - Gradle wrapper mis à jour
This commit is contained in:
@@ -126,10 +126,11 @@ class KeycloakWebViewAuthService {
|
||||
),
|
||||
);
|
||||
|
||||
// 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';
|
||||
// Clés de stockage sécurisé — alignées avec KeycloakAuthService pour éviter IC-03
|
||||
// KeycloakAuthService lit 'kc_access' / 'kc_refresh' / 'kc_id' ; ApiClient aussi.
|
||||
static const String _accessTokenKey = 'kc_access';
|
||||
static const String _idTokenKey = 'kc_id';
|
||||
static const String _refreshTokenKey = 'kc_refresh';
|
||||
static const String _userInfoKey = 'keycloak_webview_user_info';
|
||||
static const String _authStateKey = 'keycloak_webview_auth_state';
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import '../../data/models/user_role.dart';
|
||||
import '../../data/datasources/keycloak_auth_service.dart';
|
||||
import '../../data/datasources/permission_engine.dart';
|
||||
import '../../../../core/config/environment.dart';
|
||||
import '../../../../core/network/org_context_service.dart';
|
||||
import '../../../../core/storage/dashboard_cache_manager.dart';
|
||||
import '../../../../core/utils/logger.dart';
|
||||
import '../../../../core/di/injection.dart';
|
||||
@@ -87,16 +88,32 @@ class AuthPendingOnboarding extends AuthState {
|
||||
List<Object?> get props => [onboardingState, souscriptionId, organisationId, typeOrganisation];
|
||||
}
|
||||
|
||||
// Nouvel événement : auto-select l'org active après login (pour membres mono-org)
|
||||
class AuthOrgContextInitRequested extends AuthEvent {
|
||||
final String organisationId;
|
||||
final String organisationNom;
|
||||
final String? type;
|
||||
const AuthOrgContextInitRequested({
|
||||
required this.organisationId,
|
||||
required this.organisationNom,
|
||||
this.type,
|
||||
});
|
||||
@override
|
||||
List<Object?> get props => [organisationId, organisationNom, type];
|
||||
}
|
||||
|
||||
// === BLOC ===
|
||||
@lazySingleton
|
||||
class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
||||
final KeycloakAuthService _authService;
|
||||
final OrgContextService _orgContextService;
|
||||
|
||||
AuthBloc(this._authService) : super(AuthInitial()) {
|
||||
AuthBloc(this._authService, this._orgContextService) : super(AuthInitial()) {
|
||||
on<AuthLoginRequested>(_onLoginRequested);
|
||||
on<AuthLogoutRequested>(_onLogoutRequested);
|
||||
on<AuthStatusChecked>(_onStatusChecked);
|
||||
on<AuthTokenRefreshRequested>(_onTokenRefreshRequested);
|
||||
on<AuthOrgContextInitRequested>(_onOrgContextInit);
|
||||
}
|
||||
|
||||
Future<void> _onLoginRequested(AuthLoginRequested event, Emitter<AuthState> emit) async {
|
||||
@@ -185,9 +202,22 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
||||
emit(AuthLoading());
|
||||
await _authService.logout();
|
||||
await DashboardCacheManager.clear();
|
||||
_orgContextService.clear();
|
||||
emit(AuthUnauthenticated());
|
||||
}
|
||||
|
||||
Future<void> _onOrgContextInit(
|
||||
AuthOrgContextInitRequested event,
|
||||
Emitter<AuthState> emit,
|
||||
) async {
|
||||
_orgContextService.setActiveOrganisation(
|
||||
organisationId: event.organisationId,
|
||||
nom: event.organisationNom,
|
||||
type: event.type,
|
||||
);
|
||||
AppLogger.info('AuthBloc: contexte org initialisé → ${event.organisationNom}');
|
||||
}
|
||||
|
||||
Future<void> _onStatusChecked(AuthStatusChecked event, Emitter<AuthState> emit) async {
|
||||
final tokenValid = await _authService.getValidToken();
|
||||
final isAuth = tokenValid != null;
|
||||
@@ -276,9 +306,18 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
||||
///
|
||||
/// Si le rôle est [UserRole.orgAdmin] et que [organizationContexts] est vide,
|
||||
/// appelle GET /api/organisations/mes pour récupérer les organisations de l'admin.
|
||||
/// Auto-initialise [OrgContextService] si une seule organisation.
|
||||
Future<User> _enrichUserWithOrgContext(User user) async {
|
||||
if (user.primaryRole != UserRole.orgAdmin ||
|
||||
user.organizationContexts.isNotEmpty) {
|
||||
// Auto-select le premier contexte existant si pas encore de contexte actif
|
||||
if (!_orgContextService.hasContext && user.organizationContexts.isNotEmpty) {
|
||||
final first = user.organizationContexts.first;
|
||||
_orgContextService.setActiveOrganisation(
|
||||
organisationId: first.organizationId,
|
||||
nom: first.organizationName,
|
||||
);
|
||||
}
|
||||
return user;
|
||||
}
|
||||
try {
|
||||
@@ -296,7 +335,15 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
return contexts.isEmpty ? user : user.copyWith(organizationContexts: contexts);
|
||||
if (contexts.isEmpty) return user;
|
||||
// Auto-select si une seule organisation
|
||||
if (contexts.length == 1 && !_orgContextService.hasContext) {
|
||||
_orgContextService.setActiveOrganisation(
|
||||
organisationId: contexts.first.organizationId,
|
||||
nom: contexts.first.organizationName,
|
||||
);
|
||||
}
|
||||
return user.copyWith(organizationContexts: contexts);
|
||||
} catch (e) {
|
||||
AppLogger.warning('AuthBloc: impossible de charger le contexte org: $e');
|
||||
return user;
|
||||
|
||||
@@ -1,327 +1,121 @@
|
||||
/// Page d'Authentification UnionFlow
|
||||
///
|
||||
/// Interface utilisateur pour la connexion sécurisée
|
||||
/// avec gestion complète des états et des erreurs.
|
||||
/// Interface utilisateur pour la connexion sécurisée via AppAuth (RFC 8252).
|
||||
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 'package:flutter_appauth/flutter_appauth.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:jwt_decoder/jwt_decoder.dart';
|
||||
import '../../data/datasources/keycloak_auth_service.dart';
|
||||
import '../../data/datasources/keycloak_role_mapper.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
|
||||
/// Page d'authentification Keycloak via AppAuth
|
||||
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
|
||||
this.timeoutSeconds = 300,
|
||||
});
|
||||
|
||||
@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;
|
||||
class _KeycloakWebViewAuthPageState extends State<KeycloakWebViewAuthPage> {
|
||||
bool _loading = true;
|
||||
String? _errorMessage;
|
||||
double _loadingProgress = 0.0;
|
||||
|
||||
|
||||
|
||||
// Paramètres d'authentification
|
||||
String? _authUrl;
|
||||
static const _appAuth = FlutterAppAuth();
|
||||
static const _storage = FlutterSecureStorage(
|
||||
aOptions: AndroidOptions(encryptedSharedPreferences: true),
|
||||
iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock_this_device),
|
||||
);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initializeAnimations();
|
||||
_initializeAuthentication();
|
||||
_authenticate();
|
||||
}
|
||||
|
||||
@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 {
|
||||
Future<void> _authenticate() 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,
|
||||
final result = await _appAuth.authorizeAndExchangeCode(
|
||||
AuthorizationTokenRequest(
|
||||
KeycloakConfig.clientId,
|
||||
'dev.lions.unionflow-mobile://auth/callback',
|
||||
serviceConfiguration: AuthorizationServiceConfiguration(
|
||||
authorizationEndpoint:
|
||||
'${KeycloakConfig.baseUrl}/realms/${KeycloakConfig.realm}/protocol/openid-connect/auth',
|
||||
tokenEndpoint: KeycloakConfig.tokenEndpoint,
|
||||
),
|
||||
scopes: ['openid', 'profile', 'email', 'roles', 'offline_access'],
|
||||
additionalParameters: {'kc_locale': 'fr'},
|
||||
allowInsecureConnections: true,
|
||||
),
|
||||
);
|
||||
|
||||
// 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.';
|
||||
if (result?.accessToken == null) {
|
||||
_onError('Authentification annulée ou échouée.');
|
||||
return;
|
||||
}
|
||||
|
||||
_handleError(errorMessage);
|
||||
await _storage.write(key: 'kc_access', value: result!.accessToken);
|
||||
if (result.refreshToken != null) {
|
||||
await _storage.write(key: 'kc_refresh', value: result.refreshToken);
|
||||
}
|
||||
if (result.idToken != null) {
|
||||
await _storage.write(key: 'kc_id', value: result.idToken);
|
||||
}
|
||||
|
||||
final accessPayload = JwtDecoder.decode(result.accessToken!);
|
||||
final idPayload = result.idToken != null ? JwtDecoder.decode(result.idToken!) : accessPayload;
|
||||
|
||||
final roles = _extractRoles(accessPayload);
|
||||
final primaryRole = KeycloakRoleMapper.mapToUserRole(roles);
|
||||
|
||||
final user = 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(),
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
setState(() => _loading = false);
|
||||
Future.delayed(const Duration(milliseconds: 300), () {
|
||||
widget.onAuthSuccess(user);
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
_onError('Erreur d\'authentification: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Gère les erreurs
|
||||
void _handleError(String error) {
|
||||
setState(() {
|
||||
_authState = KeycloakWebViewAuthState.error;
|
||||
_errorMessage = error;
|
||||
});
|
||||
|
||||
_timeoutTimer?.cancel();
|
||||
|
||||
// Vibration pour indiquer l'erreur
|
||||
void _onError(String error) {
|
||||
HapticFeedback.lightImpact();
|
||||
|
||||
if (mounted) setState(() { _loading = false; _errorMessage = error; });
|
||||
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 {
|
||||
@@ -329,92 +123,45 @@ class _KeycloakWebViewAuthPageState extends State<KeycloakWebViewAuthPage>
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
@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',
|
||||
appBar: 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,
|
||||
),
|
||||
],
|
||||
bottom: _buildProgressIndicator(),
|
||||
),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: _handleCancel,
|
||||
tooltip: 'Annuler',
|
||||
),
|
||||
),
|
||||
body: _errorMessage != null ? _buildErrorView() : _buildLoadingView(),
|
||||
);
|
||||
}
|
||||
|
||||
/// 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() {
|
||||
Widget _buildLoadingView() {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
@@ -422,95 +169,14 @@ class _KeycloakWebViewAuthPageState extends State<KeycloakWebViewAuthPage>
|
||||
const CircularProgressIndicator(),
|
||||
const SizedBox(height: SpacingTokens.xl),
|
||||
Text(
|
||||
'Initialisation...',
|
||||
style: TypographyTokens.bodyLarge.copyWith(
|
||||
color: ColorTokens.onSurface,
|
||||
),
|
||||
'Connexion en cours...',
|
||||
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,
|
||||
@@ -526,19 +192,11 @@ class _KeycloakWebViewAuthPageState extends State<KeycloakWebViewAuthPage>
|
||||
color: ColorTokens.error,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
_authState == KeycloakWebViewAuthState.timeout
|
||||
? Icons.access_time
|
||||
: Icons.error_outline,
|
||||
color: ColorTokens.onError,
|
||||
size: 48,
|
||||
),
|
||||
child: const Icon(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',
|
||||
'Erreur de connexion',
|
||||
style: TypographyTokens.headlineSmall.copyWith(
|
||||
color: ColorTokens.onSurface,
|
||||
fontWeight: FontWeight.w600,
|
||||
@@ -557,7 +215,10 @@ class _KeycloakWebViewAuthPageState extends State<KeycloakWebViewAuthPage>
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
ElevatedButton.icon(
|
||||
onPressed: _initializeAuthentication,
|
||||
onPressed: () {
|
||||
setState(() { _loading = true; _errorMessage = null; });
|
||||
_authenticate();
|
||||
},
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('Réessayer'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
|
||||
@@ -4,7 +4,6 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:local_auth/local_auth.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../bloc/auth_bloc.dart';
|
||||
@@ -20,16 +19,11 @@ class LoginPage extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _LoginPageState extends State<LoginPage> with TickerProviderStateMixin {
|
||||
final _emailController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
|
||||
late final AnimationController _fadeController;
|
||||
late final AnimationController _slideController;
|
||||
late final Animation<double> _fadeAnim;
|
||||
late final Animation<Offset> _slideAnim;
|
||||
|
||||
bool _obscurePassword = true;
|
||||
bool _rememberMe = false;
|
||||
bool _biometricAvailable = false;
|
||||
|
||||
final _localAuth = LocalAuthentication();
|
||||
@@ -50,15 +44,12 @@ class _LoginPageState extends State<LoginPage> with TickerProviderStateMixin {
|
||||
_fadeController.forward();
|
||||
_slideController.forward();
|
||||
_checkBiometrics();
|
||||
_loadSavedCredentials();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_fadeController.dispose();
|
||||
_slideController.dispose();
|
||||
_emailController.dispose();
|
||||
_passwordController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -70,17 +61,6 @@ class _LoginPageState extends State<LoginPage> with TickerProviderStateMixin {
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> _loadSavedCredentials() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final remember = prefs.getBool('uf_remember_me') ?? false;
|
||||
if (remember && mounted) {
|
||||
setState(() {
|
||||
_rememberMe = true;
|
||||
_emailController.text = prefs.getString('uf_saved_email') ?? '';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _authenticateBiometric() async {
|
||||
try {
|
||||
final ok = await _localAuth.authenticate(
|
||||
@@ -88,12 +68,7 @@ class _LoginPageState extends State<LoginPage> with TickerProviderStateMixin {
|
||||
options: const AuthenticationOptions(stickyAuth: true, biometricOnly: false),
|
||||
);
|
||||
if (ok && mounted) {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final email = prefs.getString('uf_saved_email') ?? '';
|
||||
final pass = prefs.getString('uf_saved_pass') ?? '';
|
||||
if (email.isNotEmpty && pass.isNotEmpty) {
|
||||
context.read<AuthBloc>().add(AuthLoginRequested(email, pass));
|
||||
}
|
||||
context.read<AuthBloc>().add(const AuthStatusChecked());
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
@@ -110,24 +85,8 @@ class _LoginPageState extends State<LoginPage> with TickerProviderStateMixin {
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> _onLogin() async {
|
||||
final email = _emailController.text.trim();
|
||||
final password = _passwordController.text;
|
||||
if (email.isEmpty || password.isEmpty) return;
|
||||
|
||||
if (_rememberMe) {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool('uf_remember_me', true);
|
||||
await prefs.setString('uf_saved_email', email);
|
||||
await prefs.setString('uf_saved_pass', password);
|
||||
} else {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove('uf_remember_me');
|
||||
await prefs.remove('uf_saved_email');
|
||||
await prefs.remove('uf_saved_pass');
|
||||
}
|
||||
|
||||
if (mounted) context.read<AuthBloc>().add(AuthLoginRequested(email, password));
|
||||
void _onLogin() {
|
||||
context.read<AuthBloc>().add(const AuthLoginRequested());
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -267,51 +226,27 @@ class _LoginPageState extends State<LoginPage> with TickerProviderStateMixin {
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
_GlassTextField(
|
||||
controller: _emailController,
|
||||
hint: 'Email ou identifiant',
|
||||
icon: Icons.person_outline_rounded,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
_GlassTextField(
|
||||
controller: _passwordController,
|
||||
hint: 'Mot de passe',
|
||||
icon: Icons.lock_outline_rounded,
|
||||
isPassword: true,
|
||||
obscure: _obscurePassword,
|
||||
onToggleObscure: () => setState(() => _obscurePassword = !_obscurePassword),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Remember me + Forgot password
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
_RememberMeToggle(
|
||||
value: _rememberMe,
|
||||
onChanged: (v) => setState(() => _rememberMe = v),
|
||||
// Forgot password
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: TextButton(
|
||||
onPressed: _openForgotPassword,
|
||||
style: TextButton.styleFrom(
|
||||
padding: EdgeInsets.zero,
|
||||
minimumSize: Size.zero,
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
TextButton(
|
||||
onPressed: _openForgotPassword,
|
||||
style: TextButton.styleFrom(
|
||||
padding: EdgeInsets.zero,
|
||||
minimumSize: Size.zero,
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
child: Text(
|
||||
'Mot de passe oublié ?',
|
||||
style: GoogleFonts.roboto(
|
||||
fontSize: 12,
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w600,
|
||||
decoration: TextDecoration.underline,
|
||||
decorationColor: Colors.white.withOpacity(0.7),
|
||||
),
|
||||
child: Text(
|
||||
'Mot de passe oublié ?',
|
||||
style: GoogleFonts.roboto(
|
||||
fontSize: 12,
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w600,
|
||||
decoration: TextDecoration.underline,
|
||||
decorationColor: Colors.white.withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
@@ -361,108 +296,6 @@ class _LoginPageState extends State<LoginPage> with TickerProviderStateMixin {
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Sous-composants privés
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class _GlassTextField extends StatelessWidget {
|
||||
const _GlassTextField({
|
||||
required this.controller,
|
||||
required this.hint,
|
||||
required this.icon,
|
||||
this.keyboardType,
|
||||
this.isPassword = false,
|
||||
this.obscure = false,
|
||||
this.onToggleObscure,
|
||||
});
|
||||
|
||||
final TextEditingController controller;
|
||||
final String hint;
|
||||
final IconData icon;
|
||||
final TextInputType? keyboardType;
|
||||
final bool isPassword;
|
||||
final bool obscure;
|
||||
final VoidCallback? onToggleObscure;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.13),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.white.withOpacity(0.28), width: 1),
|
||||
),
|
||||
child: TextField(
|
||||
controller: controller,
|
||||
obscureText: isPassword && obscure,
|
||||
keyboardType: keyboardType,
|
||||
style: GoogleFonts.roboto(fontSize: 15, color: Colors.white),
|
||||
decoration: InputDecoration(
|
||||
hintText: hint,
|
||||
hintStyle: GoogleFonts.roboto(fontSize: 14.5, color: Colors.white.withOpacity(0.48)),
|
||||
prefixIcon: Icon(icon, color: Colors.white54, size: 20),
|
||||
suffixIcon: isPassword
|
||||
? IconButton(
|
||||
icon: Icon(
|
||||
obscure ? Icons.visibility_outlined : Icons.visibility_off_outlined,
|
||||
color: Colors.white54,
|
||||
size: 20,
|
||||
),
|
||||
onPressed: onToggleObscure,
|
||||
)
|
||||
: null,
|
||||
border: InputBorder.none,
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 10, horizontal: 4),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RememberMeToggle extends StatelessWidget {
|
||||
const _RememberMeToggle({required this.value, required this.onChanged});
|
||||
|
||||
final bool value;
|
||||
final ValueChanged<bool> onChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: () => onChanged(!value),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: Checkbox(
|
||||
value: value,
|
||||
onChanged: (v) => onChanged(v ?? false),
|
||||
fillColor: WidgetStateProperty.resolveWith((s) {
|
||||
if (s.contains(WidgetState.selected)) return Colors.white;
|
||||
return Colors.transparent;
|
||||
}),
|
||||
checkColor: const Color(0xFF2E7D32),
|
||||
side: const BorderSide(color: Colors.white60, width: 1.5),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)),
|
||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 7),
|
||||
Text(
|
||||
'Se souvenir de moi',
|
||||
style: GoogleFonts.roboto(
|
||||
fontSize: 12,
|
||||
color: Colors.white.withOpacity(0.78),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Painters
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user