/// Page d'Authentification UnionFlow /// /// Interface utilisateur pour la connexion sécurisée via AppAuth (RFC 8252). library keycloak_webview_auth_page; import 'package:flutter/material.dart'; import 'package:flutter/services.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'; /// Page d'authentification Keycloak via AppAuth class KeycloakWebViewAuthPage extends StatefulWidget { final Function(User user) onAuthSuccess; final Function(String error) onAuthError; final VoidCallback? onAuthCancel; final int timeoutSeconds; const KeycloakWebViewAuthPage({ super.key, required this.onAuthSuccess, required this.onAuthError, this.onAuthCancel, this.timeoutSeconds = 300, }); @override State createState() => _KeycloakWebViewAuthPageState(); } class _KeycloakWebViewAuthPageState extends State { bool _loading = true; String? _errorMessage; 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(); _authenticate(); } Future _authenticate() async { try { final result = await _appAuth.authorizeAndExchangeCode( AuthorizationTokenRequest( KeycloakConfig.clientId, 'dev.lions.unionflow-mobile://auth/callback', serviceConfiguration: AuthorizationServiceConfiguration( authorizationEndpoint: '${KeycloakConfig.baseUrl}/realms/${KeycloakConfig.realm}/protocol/openid-connect/auth', tokenEndpoint: KeycloakConfig.tokenEndpoint, ), scopes: ['openid', 'profile', 'email', 'roles', 'offline_access'], additionalParameters: {'kc_locale': 'fr'}, allowInsecureConnections: true, ), ); if (result?.accessToken == null) { _onError('Authentification annulée ou échouée.'); return; } 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'); } } void _onError(String error) { HapticFeedback.lightImpact(); if (mounted) setState(() { _loading = false; _errorMessage = error; }); widget.onAuthError(error); } void _handleCancel() { if (widget.onAuthCancel != null) { widget.onAuthCancel!(); } else { Navigator.of(context).pop(); } } List _extractRoles(Map payload) { final roles = []; if (payload['realm_access']?['roles'] != null) { roles.addAll((payload['realm_access']['roles'] as List).cast()); } if (payload['resource_access'] != null) { (payload['resource_access'] as Map).values.forEach((v) { if (v['roles'] != null) roles.addAll((v['roles'] as List).cast()); }); } return roles.where((r) => !r.startsWith('default-roles-') && r != 'offline_access').toList(); } @override Widget build(BuildContext context) { return Scaffold( backgroundColor: ColorTokens.surface, 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, ), ), leading: IconButton( icon: const Icon(Icons.close), onPressed: _handleCancel, tooltip: 'Annuler', ), ), body: _errorMessage != null ? _buildErrorView() : _buildLoadingView(), ); } Widget _buildLoadingView() { return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const CircularProgressIndicator(), const SizedBox(height: SpacingTokens.xl), Text( 'Connexion en cours...', style: TypographyTokens.bodyLarge.copyWith(color: ColorTokens.onSurface), ), ], ), ); } 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: const Icon(Icons.error_outline, color: ColorTokens.onError, size: 48), ), const SizedBox(height: SpacingTokens.xxxl), Text( '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: () { setState(() { _loading = true; _errorMessage = null; }); _authenticate(); }, 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, ), ), ], ), ], ), ), ); } }