Compare commits

..

2 Commits

Author SHA1 Message Date
dahoud
cf2866a0aa fix(UI-02): guards rôle sur pages organisations — FAB, édition, suppression SUPER_ADMIN only
- OrgTypesPage: FAB création, tap/édition/suppression type visibles uniquement SUPER_ADMIN
- OrganizationDetailPage: bouton édition visible orgAdmin+superAdmin, suppression SUPER_ADMIN only, section Actions cachée si non autorisé
2026-04-11 01:27:42 +00:00
dahoud
04896349d6 fix: BUG-02 IC-03 session expirée sur Workflow Financier et Messagerie
Ajoute KeycloakAuthService.getValidAccessToken() qui vérifie l'expiration
du JWT et rafraîchit automatiquement avant de retourner le token.

FinanceWorkflowRemoteDatasource et MessagingRemoteDatasource injectent
désormais KeycloakAuthService au lieu de FlutterSecureStorage directement,
ce qui évite d'envoyer un Bearer expiré et d'obtenir un 401 silencieux.
2026-04-10 20:53:28 +00:00
5 changed files with 86 additions and 37 deletions

View File

@@ -119,6 +119,17 @@ class KeycloakAuthService {
return null; return null;
} }
/// Retourne un access token valide, en rafraîchissant automatiquement si expiré.
/// Utilisé par les datasources pour éviter d'envoyer un Bearer null ou expiré.
Future<String?> getValidAccessToken() async {
String? token = await _storage.read(key: _accessK);
if (token == null) return null;
if (JwtDecoder.isExpired(token)) {
token = await refreshToken();
}
return token;
}
/// Récupération de l'utilisateur courant + Mapage Rôles /// Récupération de l'utilisateur courant + Mapage Rôles
Future<User?> getCurrentUser() async { Future<User?> getCurrentUser() async {
String? token = await _storage.read(key: _accessK); String? token = await _storage.read(key: _accessK);

View File

@@ -4,10 +4,10 @@ library messaging_remote_datasource;
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:injectable/injectable.dart'; import 'package:injectable/injectable.dart';
import '../../../../core/config/environment.dart'; import '../../../../core/config/environment.dart';
import '../../../../core/error/exceptions.dart'; import '../../../../core/error/exceptions.dart';
import '../../../authentication/data/datasources/keycloak_auth_service.dart';
import '../models/message_model.dart'; import '../models/message_model.dart';
import '../models/conversation_model.dart'; import '../models/conversation_model.dart';
import '../../domain/entities/message.dart'; import '../../domain/entities/message.dart';
@@ -15,16 +15,16 @@ import '../../domain/entities/message.dart';
@lazySingleton @lazySingleton
class MessagingRemoteDatasource { class MessagingRemoteDatasource {
final http.Client client; final http.Client client;
final FlutterSecureStorage secureStorage; final KeycloakAuthService authService;
MessagingRemoteDatasource({ MessagingRemoteDatasource({
required this.client, required this.client,
required this.secureStorage, required this.authService,
}); });
/// Headers HTTP avec authentification /// Headers HTTP avec authentification — rafraîchit le token si expiré (fix IC-03)
Future<Map<String, String>> _getHeaders() async { Future<Map<String, String>> _getHeaders() async {
final token = await secureStorage.read(key: 'kc_access'); final token = await authService.getValidAccessToken();
return { return {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Accept': 'application/json', 'Accept': 'application/json',

View File

@@ -3,26 +3,26 @@ library finance_workflow_remote_datasource;
import 'dart:convert'; import 'dart:convert';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:injectable/injectable.dart'; import 'package:injectable/injectable.dart';
import '../../../../core/config/environment.dart'; import '../../../../core/config/environment.dart';
import '../../../../core/error/exceptions.dart'; import '../../../../core/error/exceptions.dart';
import '../../../authentication/data/datasources/keycloak_auth_service.dart';
import '../models/transaction_approval_model.dart'; import '../models/transaction_approval_model.dart';
import '../models/budget_model.dart'; import '../models/budget_model.dart';
@lazySingleton @lazySingleton
class FinanceWorkflowRemoteDatasource { class FinanceWorkflowRemoteDatasource {
final http.Client client; final http.Client client;
final FlutterSecureStorage secureStorage; final KeycloakAuthService authService;
FinanceWorkflowRemoteDatasource({ FinanceWorkflowRemoteDatasource({
required this.client, required this.client,
required this.secureStorage, required this.authService,
}); });
/// Headers HTTP avec authentification /// Headers HTTP avec authentification — rafraîchit le token si expiré (fix IC-03)
Future<Map<String, String>> _getHeaders() async { Future<Map<String, String>> _getHeaders() async {
final token = await secureStorage.read(key: 'kc_access'); final token = await authService.getValidAccessToken();
return { return {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Accept': 'application/json', 'Accept': 'application/json',

View File

@@ -5,6 +5,8 @@ import '../../../../shared/widgets/core_card.dart';
import '../../../../core/di/injection.dart'; import '../../../../core/di/injection.dart';
import '../../bloc/org_types_bloc.dart'; import '../../bloc/org_types_bloc.dart';
import '../../domain/entities/type_reference_entity.dart'; import '../../domain/entities/type_reference_entity.dart';
import '../../../../features/authentication/presentation/bloc/auth_bloc.dart';
import '../../../../features/authentication/data/models/user_role.dart';
class OrgTypesPage extends StatelessWidget { class OrgTypesPage extends StatelessWidget {
const OrgTypesPage({super.key}); const OrgTypesPage({super.key});
@@ -77,11 +79,17 @@ class _OrgTypesViewState extends State<_OrgTypesView> {
); );
}, },
), ),
floatingActionButton: FloatingActionButton.small( floatingActionButton: Builder(builder: (ctx) {
onPressed: () => _showTypeForm(context, null), final authState = ctx.read<AuthBloc>().state;
backgroundColor: AppColors.primaryGreen, final isSuperAdmin = authState is AuthAuthenticated &&
child: const Icon(Icons.add, color: Colors.white), authState.effectiveRole == UserRole.superAdmin;
), if (!isSuperAdmin) return const SizedBox.shrink();
return FloatingActionButton.small(
onPressed: () => _showTypeForm(context, null),
backgroundColor: AppColors.primaryGreen,
child: const Icon(Icons.add, color: Colors.white),
);
}),
); );
} }
@@ -96,12 +104,15 @@ class _OrgTypesViewState extends State<_OrgTypesView> {
Widget _buildTypeCard(BuildContext context, TypeReferenceEntity type, OrgTypesState state) { Widget _buildTypeCard(BuildContext context, TypeReferenceEntity type, OrgTypesState state) {
final isOperating = state is OrgTypeOperating; final isOperating = state is OrgTypeOperating;
final color = _parseColor(type.couleur) ?? AppColors.primaryGreen; final color = _parseColor(type.couleur) ?? AppColors.primaryGreen;
final authState = context.read<AuthBloc>().state;
final isSuperAdmin = authState is AuthAuthenticated &&
authState.effectiveRole == UserRole.superAdmin;
return Opacity( return Opacity(
opacity: isOperating ? 0.6 : 1.0, opacity: isOperating ? 0.6 : 1.0,
child: CoreCard( child: CoreCard(
margin: EdgeInsets.zero, margin: EdgeInsets.zero,
onTap: (!type.estSysteme && !isOperating) ? () => _showTypeForm(context, type) : null, onTap: (isSuperAdmin && !type.estSysteme && !isOperating) ? () => _showTypeForm(context, type) : null,
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
border: Border(left: BorderSide(color: color, width: 3)), border: Border(left: BorderSide(color: color, width: 3)),
@@ -163,7 +174,7 @@ class _OrgTypesViewState extends State<_OrgTypesView> {
], ],
), ),
), ),
if (!type.estSysteme && !isOperating) ...[ if (isSuperAdmin && !type.estSysteme && !isOperating) ...[
IconButton( IconButton(
icon: const Icon(Icons.edit_outlined, size: 16), icon: const Icon(Icons.edit_outlined, size: 16),
color: AppColors.textSecondaryLight, color: AppColors.textSecondaryLight,
@@ -187,6 +198,10 @@ class _OrgTypesViewState extends State<_OrgTypesView> {
} }
Widget _buildEmptyState(BuildContext context) { Widget _buildEmptyState(BuildContext context) {
final authState = context.read<AuthBloc>().state;
final isSuperAdmin = authState is AuthAuthenticated &&
authState.effectiveRole == UserRole.superAdmin;
return Center( return Center(
child: Padding( child: Padding(
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(24),
@@ -201,22 +216,26 @@ class _OrgTypesViewState extends State<_OrgTypesView> {
), ),
const SizedBox(height: 6), const SizedBox(height: 6),
Text( Text(
'Créez votre premier type d\'organisation', isSuperAdmin
? 'Créez votre premier type d\'organisation'
: 'Aucun type d\'organisation disponible',
style: TextStyle(fontSize: 12, color: Colors.grey[500]), style: TextStyle(fontSize: 12, color: Colors.grey[500]),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
const SizedBox(height: 16), if (isSuperAdmin) ...[
ElevatedButton.icon( const SizedBox(height: 16),
onPressed: () => _showTypeForm(context, null), ElevatedButton.icon(
icon: const Icon(Icons.add, size: 16), onPressed: () => _showTypeForm(context, null),
label: const Text('Créer un type'), icon: const Icon(Icons.add, size: 16),
style: ElevatedButton.styleFrom( label: const Text('Créer un type'),
backgroundColor: AppColors.primaryGreen, style: ElevatedButton.styleFrom(
foregroundColor: Colors.white, backgroundColor: AppColors.primaryGreen,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
), ),
), ],
], ],
), ),
), ),

View File

@@ -51,7 +51,14 @@ class _OrganizationDetailPageState extends State<OrganizationDetailPage> {
title: const Text('Détail Organisation'), title: const Text('Détail Organisation'),
elevation: 0, elevation: 0,
actions: [ actions: [
IconButton(onPressed: _showEditPage, icon: const Icon(Icons.edit), tooltip: 'Modifier'), Builder(builder: (ctx) {
final authState = ctx.read<AuthBloc>().state;
final canEdit = authState is AuthAuthenticated &&
(authState.effectiveRole == UserRole.superAdmin ||
authState.effectiveRole == UserRole.orgAdmin);
if (!canEdit) return const SizedBox.shrink();
return IconButton(onPressed: _showEditPage, icon: const Icon(Icons.edit), tooltip: 'Modifier');
}),
Builder(builder: (ctx) { Builder(builder: (ctx) {
final authState = ctx.read<AuthBloc>().state; final authState = ctx.read<AuthBloc>().state;
final isSuperAdmin = authState is AuthAuthenticated && final isSuperAdmin = authState is AuthAuthenticated &&
@@ -314,6 +321,16 @@ class _OrganizationDetailPageState extends State<OrganizationDetailPage> {
// ── Actions ───────────────────────────────────────────────────────────────── // ── Actions ─────────────────────────────────────────────────────────────────
Widget _buildActionsCard(OrganizationModel org) { Widget _buildActionsCard(OrganizationModel org) {
final authState = context.read<AuthBloc>().state;
final isSuperAdmin = authState is AuthAuthenticated &&
authState.effectiveRole == UserRole.superAdmin;
final canEdit = authState is AuthAuthenticated &&
(authState.effectiveRole == UserRole.superAdmin ||
authState.effectiveRole == UserRole.orgAdmin);
// Aucune action autorisée → ne pas afficher la section
if (!canEdit) return const SizedBox.shrink();
return _buildCard('Actions', Icons.bolt, [ return _buildCard('Actions', Icons.bolt, [
Row(children: [ Row(children: [
Expanded(child: ElevatedButton.icon( Expanded(child: ElevatedButton.icon(
@@ -322,13 +339,15 @@ class _OrganizationDetailPageState extends State<OrganizationDetailPage> {
label: const Text('Modifier'), label: const Text('Modifier'),
style: ElevatedButton.styleFrom(backgroundColor: AppColors.primaryGreen, foregroundColor: Colors.white), style: ElevatedButton.styleFrom(backgroundColor: AppColors.primaryGreen, foregroundColor: Colors.white),
)), )),
const SizedBox(width: 12), if (isSuperAdmin) ...[
Expanded(child: OutlinedButton.icon( const SizedBox(width: 12),
onPressed: () => _showDeleteConfirmation(org), Expanded(child: OutlinedButton.icon(
icon: const Icon(Icons.delete), onPressed: () => _showDeleteConfirmation(org),
label: const Text('Supprimer'), icon: const Icon(Icons.delete),
style: OutlinedButton.styleFrom(foregroundColor: Colors.red, side: const BorderSide(color: Colors.red)), label: const Text('Supprimer'),
)), style: OutlinedButton.styleFrom(foregroundColor: Colors.red, side: const BorderSide(color: Colors.red)),
)),
],
]), ]),
]); ]);
} }