Files
unionflow-mobile-apps/lib/features/authentication/presentation/pages/keycloak_webview_auth_page.dart
dahoud 70cbd1c873 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
2026-04-07 20:56:03 +00:00

245 lines
8.0 KiB
Dart

/// 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<KeycloakWebViewAuthPage> createState() => _KeycloakWebViewAuthPageState();
}
class _KeycloakWebViewAuthPageState extends State<KeycloakWebViewAuthPage> {
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<void> _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<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: 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,
),
),
],
),
],
),
),
);
}
}