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
245 lines
8.0 KiB
Dart
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,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|