584 lines
17 KiB
Dart
584 lines
17 KiB
Dart
/// 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,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|