first commit
This commit is contained in:
203
unionflow-mobile-apps/lib/core/auth/bloc/auth_bloc.dart
Normal file
203
unionflow-mobile-apps/lib/core/auth/bloc/auth_bloc.dart
Normal file
@@ -0,0 +1,203 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:injectable/injectable.dart';
|
||||
import '../models/auth_state.dart';
|
||||
import '../services/auth_service.dart';
|
||||
import '../services/auth_api_service.dart';
|
||||
import 'auth_event.dart';
|
||||
|
||||
/// BLoC pour gérer l'authentification
|
||||
@singleton
|
||||
class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
||||
final AuthService _authService;
|
||||
late StreamSubscription<AuthState> _authStateSubscription;
|
||||
|
||||
AuthBloc(this._authService) : super(const AuthState.unknown()) {
|
||||
// Écouter les changements d'état du service
|
||||
_authStateSubscription = _authService.authStateStream.listen(
|
||||
(authState) => add(AuthStateChanged(authState)),
|
||||
);
|
||||
|
||||
// Gestionnaires d'événements
|
||||
on<AuthInitializeRequested>(_onInitializeRequested);
|
||||
on<AuthLoginRequested>(_onLoginRequested);
|
||||
on<AuthLogoutRequested>(_onLogoutRequested);
|
||||
on<AuthTokenRefreshRequested>(_onTokenRefreshRequested);
|
||||
on<AuthSessionExpired>(_onSessionExpired);
|
||||
on<AuthStatusCheckRequested>(_onStatusCheckRequested);
|
||||
on<AuthErrorCleared>(_onErrorCleared);
|
||||
on<AuthStateChanged>(_onStateChanged);
|
||||
}
|
||||
|
||||
/// Initialisation de l'authentification
|
||||
Future<void> _onInitializeRequested(
|
||||
AuthInitializeRequested event,
|
||||
Emitter<AuthState> emit,
|
||||
) async {
|
||||
emit(const AuthState.checking());
|
||||
|
||||
try {
|
||||
await _authService.initialize();
|
||||
} catch (e) {
|
||||
emit(AuthState.error('Erreur d\'initialisation: $e'));
|
||||
}
|
||||
}
|
||||
|
||||
/// Gestion de la connexion
|
||||
Future<void> _onLoginRequested(
|
||||
AuthLoginRequested event,
|
||||
Emitter<AuthState> emit,
|
||||
) async {
|
||||
emit(state.copyWith(isLoading: true, errorMessage: null));
|
||||
|
||||
try {
|
||||
await _authService.login(event.loginRequest);
|
||||
// L'état sera mis à jour par le stream du service
|
||||
} on AuthApiException catch (e) {
|
||||
emit(state.copyWith(
|
||||
isLoading: false,
|
||||
errorMessage: e.message,
|
||||
));
|
||||
} catch (e) {
|
||||
emit(state.copyWith(
|
||||
isLoading: false,
|
||||
errorMessage: 'Erreur de connexion: $e',
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// Gestion de la déconnexion
|
||||
Future<void> _onLogoutRequested(
|
||||
AuthLogoutRequested event,
|
||||
Emitter<AuthState> emit,
|
||||
) async {
|
||||
emit(state.copyWith(isLoading: true));
|
||||
|
||||
try {
|
||||
await _authService.logout();
|
||||
// L'état sera mis à jour par le stream du service
|
||||
} catch (e) {
|
||||
// Même en cas d'erreur, on considère que la déconnexion locale a réussi
|
||||
emit(const AuthState.unauthenticated());
|
||||
}
|
||||
}
|
||||
|
||||
/// Gestion du rafraîchissement de token
|
||||
Future<void> _onTokenRefreshRequested(
|
||||
AuthTokenRefreshRequested event,
|
||||
Emitter<AuthState> emit,
|
||||
) async {
|
||||
// Le rafraîchissement est géré automatiquement par le service
|
||||
// Cet événement peut être utilisé pour forcer un rafraîchissement manuel
|
||||
try {
|
||||
// Le service gère déjà le rafraîchissement automatique
|
||||
// On peut ajouter ici une logique spécifique si nécessaire
|
||||
} catch (e) {
|
||||
emit(AuthState.error('Erreur lors du rafraîchissement: $e'));
|
||||
}
|
||||
}
|
||||
|
||||
/// Gestion de l'expiration de session
|
||||
Future<void> _onSessionExpired(
|
||||
AuthSessionExpired event,
|
||||
Emitter<AuthState> emit,
|
||||
) async {
|
||||
emit(const AuthState.expired());
|
||||
|
||||
// Optionnel: essayer un rafraîchissement automatique
|
||||
try {
|
||||
await _authService.logout();
|
||||
} catch (e) {
|
||||
// Ignorer les erreurs de déconnexion lors de l'expiration
|
||||
}
|
||||
}
|
||||
|
||||
/// Vérification du statut d'authentification
|
||||
Future<void> _onStatusCheckRequested(
|
||||
AuthStatusCheckRequested event,
|
||||
Emitter<AuthState> emit,
|
||||
) async {
|
||||
// Utiliser l'état actuel du service
|
||||
final currentServiceState = _authService.currentState;
|
||||
if (currentServiceState != state) {
|
||||
emit(currentServiceState);
|
||||
}
|
||||
}
|
||||
|
||||
/// Nettoyage des erreurs
|
||||
void _onErrorCleared(
|
||||
AuthErrorCleared event,
|
||||
Emitter<AuthState> emit,
|
||||
) {
|
||||
if (state.errorMessage != null) {
|
||||
emit(state.copyWith(errorMessage: null));
|
||||
}
|
||||
}
|
||||
|
||||
/// Mise à jour depuis le service d'authentification
|
||||
void _onStateChanged(
|
||||
AuthStateChanged event,
|
||||
Emitter<AuthState> emit,
|
||||
) {
|
||||
final newState = event.authState as AuthState;
|
||||
|
||||
// Émettre le nouvel état seulement s'il a changé
|
||||
if (newState != state) {
|
||||
emit(newState);
|
||||
}
|
||||
}
|
||||
|
||||
/// Vérifie si l'utilisateur est connecté
|
||||
bool get isAuthenticated => state.isAuthenticated;
|
||||
|
||||
/// Récupère l'utilisateur actuel
|
||||
get currentUser => state.user;
|
||||
|
||||
/// Vérifie si l'utilisateur a un rôle spécifique
|
||||
bool hasRole(String role) {
|
||||
return _authService.hasRole(role);
|
||||
}
|
||||
|
||||
/// Vérifie si l'utilisateur a un des rôles spécifiés
|
||||
bool hasAnyRole(List<String> roles) {
|
||||
return _authService.hasAnyRole(roles);
|
||||
}
|
||||
|
||||
/// Vérifie si la session expire bientôt
|
||||
bool get isSessionExpiringSoon => state.isExpiringSoon;
|
||||
|
||||
/// Récupère le message d'erreur formaté
|
||||
String? get errorMessage {
|
||||
final error = state.errorMessage;
|
||||
if (error == null) return null;
|
||||
|
||||
// Formatage des messages d'erreur pour l'utilisateur
|
||||
if (error.contains('network') || error.contains('connexion')) {
|
||||
return 'Problème de connexion. Vérifiez votre réseau.';
|
||||
}
|
||||
|
||||
if (error.contains('401') || error.contains('Identifiants')) {
|
||||
return 'Email ou mot de passe incorrect.';
|
||||
}
|
||||
|
||||
if (error.contains('403')) {
|
||||
return 'Accès non autorisé.';
|
||||
}
|
||||
|
||||
if (error.contains('timeout')) {
|
||||
return 'Délai d\'attente dépassé. Réessayez.';
|
||||
}
|
||||
|
||||
if (error.contains('server') || error.contains('500')) {
|
||||
return 'Erreur serveur temporaire. Réessayez plus tard.';
|
||||
}
|
||||
|
||||
return error;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() {
|
||||
_authStateSubscription.cancel();
|
||||
return super.close();
|
||||
}
|
||||
}
|
||||
60
unionflow-mobile-apps/lib/core/auth/bloc/auth_event.dart
Normal file
60
unionflow-mobile-apps/lib/core/auth/bloc/auth_event.dart
Normal file
@@ -0,0 +1,60 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import '../models/login_request.dart';
|
||||
|
||||
/// Événements d'authentification
|
||||
abstract class AuthEvent extends Equatable {
|
||||
const AuthEvent();
|
||||
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
/// Initialiser l'authentification
|
||||
class AuthInitializeRequested extends AuthEvent {
|
||||
const AuthInitializeRequested();
|
||||
}
|
||||
|
||||
/// Demande de connexion
|
||||
class AuthLoginRequested extends AuthEvent {
|
||||
final LoginRequest loginRequest;
|
||||
|
||||
const AuthLoginRequested(this.loginRequest);
|
||||
|
||||
@override
|
||||
List<Object> get props => [loginRequest];
|
||||
}
|
||||
|
||||
/// Demande de déconnexion
|
||||
class AuthLogoutRequested extends AuthEvent {
|
||||
const AuthLogoutRequested();
|
||||
}
|
||||
|
||||
/// Demande de rafraîchissement de token
|
||||
class AuthTokenRefreshRequested extends AuthEvent {
|
||||
const AuthTokenRefreshRequested();
|
||||
}
|
||||
|
||||
/// Session expirée
|
||||
class AuthSessionExpired extends AuthEvent {
|
||||
const AuthSessionExpired();
|
||||
}
|
||||
|
||||
/// Vérification de l'état d'authentification
|
||||
class AuthStatusCheckRequested extends AuthEvent {
|
||||
const AuthStatusCheckRequested();
|
||||
}
|
||||
|
||||
/// Réinitialisation de l'erreur
|
||||
class AuthErrorCleared extends AuthEvent {
|
||||
const AuthErrorCleared();
|
||||
}
|
||||
|
||||
/// Changement d'état depuis le service
|
||||
class AuthStateChanged extends AuthEvent {
|
||||
final dynamic authState;
|
||||
|
||||
const AuthStateChanged(this.authState);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [authState];
|
||||
}
|
||||
74
unionflow-mobile-apps/lib/core/auth/bloc/temp_auth_bloc.dart
Normal file
74
unionflow-mobile-apps/lib/core/auth/bloc/temp_auth_bloc.dart
Normal file
@@ -0,0 +1,74 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../models/auth_state.dart';
|
||||
import '../services/temp_auth_service.dart';
|
||||
import 'auth_event.dart';
|
||||
|
||||
/// BLoC temporaire pour test sans injection de dépendances
|
||||
class TempAuthBloc extends Bloc<AuthEvent, AuthState> {
|
||||
final TempAuthService _authService;
|
||||
late StreamSubscription<AuthState> _authStateSubscription;
|
||||
|
||||
TempAuthBloc(this._authService) : super(const AuthState.unknown()) {
|
||||
_authStateSubscription = _authService.authStateStream.listen(
|
||||
(authState) => add(AuthStateChanged(authState)),
|
||||
);
|
||||
|
||||
on<AuthInitializeRequested>(_onInitializeRequested);
|
||||
on<AuthLoginRequested>(_onLoginRequested);
|
||||
on<AuthLogoutRequested>(_onLogoutRequested);
|
||||
on<AuthErrorCleared>(_onErrorCleared);
|
||||
on<AuthStateChanged>(_onStateChanged);
|
||||
}
|
||||
|
||||
Future<void> _onInitializeRequested(
|
||||
AuthInitializeRequested event,
|
||||
Emitter<AuthState> emit,
|
||||
) async {
|
||||
await _authService.initialize();
|
||||
}
|
||||
|
||||
Future<void> _onLoginRequested(
|
||||
AuthLoginRequested event,
|
||||
Emitter<AuthState> emit,
|
||||
) async {
|
||||
try {
|
||||
await _authService.login(event.loginRequest);
|
||||
} catch (e) {
|
||||
emit(AuthState.error(e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onLogoutRequested(
|
||||
AuthLogoutRequested event,
|
||||
Emitter<AuthState> emit,
|
||||
) async {
|
||||
await _authService.logout();
|
||||
}
|
||||
|
||||
void _onErrorCleared(
|
||||
AuthErrorCleared event,
|
||||
Emitter<AuthState> emit,
|
||||
) {
|
||||
if (state.errorMessage != null) {
|
||||
emit(state.copyWith(errorMessage: null));
|
||||
}
|
||||
}
|
||||
|
||||
void _onStateChanged(
|
||||
AuthStateChanged event,
|
||||
Emitter<AuthState> emit,
|
||||
) {
|
||||
final newState = event.authState as AuthState;
|
||||
if (newState != state) {
|
||||
emit(newState);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() {
|
||||
_authStateSubscription.cancel();
|
||||
_authService.dispose();
|
||||
return super.close();
|
||||
}
|
||||
}
|
||||
140
unionflow-mobile-apps/lib/core/auth/models/auth_state.dart
Normal file
140
unionflow-mobile-apps/lib/core/auth/models/auth_state.dart
Normal file
@@ -0,0 +1,140 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'user_info.dart';
|
||||
|
||||
/// États d'authentification possibles
|
||||
enum AuthStatus {
|
||||
unknown, // État initial
|
||||
checking, // Vérification en cours
|
||||
authenticated,// Utilisateur connecté
|
||||
unauthenticated, // Utilisateur non connecté
|
||||
expired, // Session expirée
|
||||
error, // Erreur d'authentification
|
||||
}
|
||||
|
||||
/// État d'authentification de l'application
|
||||
class AuthState extends Equatable {
|
||||
final AuthStatus status;
|
||||
final UserInfo? user;
|
||||
final String? accessToken;
|
||||
final String? refreshToken;
|
||||
final DateTime? expiresAt;
|
||||
final String? errorMessage;
|
||||
final bool isLoading;
|
||||
|
||||
const AuthState({
|
||||
this.status = AuthStatus.unknown,
|
||||
this.user,
|
||||
this.accessToken,
|
||||
this.refreshToken,
|
||||
this.expiresAt,
|
||||
this.errorMessage,
|
||||
this.isLoading = false,
|
||||
});
|
||||
|
||||
/// État initial inconnu
|
||||
const AuthState.unknown() : this(status: AuthStatus.unknown);
|
||||
|
||||
/// État de vérification
|
||||
const AuthState.checking() : this(
|
||||
status: AuthStatus.checking,
|
||||
isLoading: true,
|
||||
);
|
||||
|
||||
/// État authentifié
|
||||
const AuthState.authenticated({
|
||||
required UserInfo user,
|
||||
required String accessToken,
|
||||
required String refreshToken,
|
||||
required DateTime expiresAt,
|
||||
}) : this(
|
||||
status: AuthStatus.authenticated,
|
||||
user: user,
|
||||
accessToken: accessToken,
|
||||
refreshToken: refreshToken,
|
||||
expiresAt: expiresAt,
|
||||
isLoading: false,
|
||||
);
|
||||
|
||||
/// État non authentifié
|
||||
const AuthState.unauthenticated({String? errorMessage}) : this(
|
||||
status: AuthStatus.unauthenticated,
|
||||
errorMessage: errorMessage,
|
||||
isLoading: false,
|
||||
);
|
||||
|
||||
/// État de session expirée
|
||||
const AuthState.expired() : this(
|
||||
status: AuthStatus.expired,
|
||||
isLoading: false,
|
||||
);
|
||||
|
||||
/// État d'erreur
|
||||
const AuthState.error(String errorMessage) : this(
|
||||
status: AuthStatus.error,
|
||||
errorMessage: errorMessage,
|
||||
isLoading: false,
|
||||
);
|
||||
|
||||
/// Vérifie si l'utilisateur est connecté
|
||||
bool get isAuthenticated => status == AuthStatus.authenticated;
|
||||
|
||||
/// Vérifie si la session est valide
|
||||
bool get isSessionValid {
|
||||
if (!isAuthenticated || expiresAt == null) return false;
|
||||
return DateTime.now().isBefore(expiresAt!);
|
||||
}
|
||||
|
||||
/// Vérifie si la session expire bientôt
|
||||
bool get isExpiringSoon {
|
||||
if (!isAuthenticated || expiresAt == null) return false;
|
||||
final threshold = DateTime.now().add(const Duration(minutes: 5));
|
||||
return expiresAt!.isBefore(threshold);
|
||||
}
|
||||
|
||||
/// Crée une copie avec des modifications
|
||||
AuthState copyWith({
|
||||
AuthStatus? status,
|
||||
UserInfo? user,
|
||||
String? accessToken,
|
||||
String? refreshToken,
|
||||
DateTime? expiresAt,
|
||||
String? errorMessage,
|
||||
bool? isLoading,
|
||||
}) {
|
||||
return AuthState(
|
||||
status: status ?? this.status,
|
||||
user: user ?? this.user,
|
||||
accessToken: accessToken ?? this.accessToken,
|
||||
refreshToken: refreshToken ?? this.refreshToken,
|
||||
expiresAt: expiresAt ?? this.expiresAt,
|
||||
errorMessage: errorMessage ?? this.errorMessage,
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
);
|
||||
}
|
||||
|
||||
/// Crée une copie en effaçant les données sensibles
|
||||
AuthState clearSensitiveData() {
|
||||
return AuthState(
|
||||
status: status,
|
||||
user: user,
|
||||
errorMessage: errorMessage,
|
||||
isLoading: isLoading,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
status,
|
||||
user,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
expiresAt,
|
||||
errorMessage,
|
||||
isLoading,
|
||||
];
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'AuthState(status: $status, user: ${user?.email}, isLoading: $isLoading, error: $errorMessage)';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// Modèle de requête de connexion
|
||||
class LoginRequest extends Equatable {
|
||||
final String email;
|
||||
final String password;
|
||||
final bool rememberMe;
|
||||
|
||||
const LoginRequest({
|
||||
required this.email,
|
||||
required this.password,
|
||||
this.rememberMe = false,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'email': email,
|
||||
'password': password,
|
||||
'rememberMe': rememberMe,
|
||||
};
|
||||
}
|
||||
|
||||
factory LoginRequest.fromJson(Map<String, dynamic> json) {
|
||||
return LoginRequest(
|
||||
email: json['email'] ?? '',
|
||||
password: json['password'] ?? '',
|
||||
rememberMe: json['rememberMe'] ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
LoginRequest copyWith({
|
||||
String? email,
|
||||
String? password,
|
||||
bool? rememberMe,
|
||||
}) {
|
||||
return LoginRequest(
|
||||
email: email ?? this.email,
|
||||
password: password ?? this.password,
|
||||
rememberMe: rememberMe ?? this.rememberMe,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [email, password, rememberMe];
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'LoginRequest(email: $email, rememberMe: $rememberMe)';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'user_info.dart';
|
||||
|
||||
/// Modèle de réponse de connexion
|
||||
class LoginResponse extends Equatable {
|
||||
final String accessToken;
|
||||
final String refreshToken;
|
||||
final String tokenType;
|
||||
final DateTime expiresAt;
|
||||
final DateTime refreshExpiresAt;
|
||||
final UserInfo user;
|
||||
|
||||
const LoginResponse({
|
||||
required this.accessToken,
|
||||
required this.refreshToken,
|
||||
required this.tokenType,
|
||||
required this.expiresAt,
|
||||
required this.refreshExpiresAt,
|
||||
required this.user,
|
||||
});
|
||||
|
||||
/// Vérifie si le token d'accès est expiré
|
||||
bool get isAccessTokenExpired {
|
||||
return DateTime.now().isAfter(expiresAt);
|
||||
}
|
||||
|
||||
/// Vérifie si le refresh token est expiré
|
||||
bool get isRefreshTokenExpired {
|
||||
return DateTime.now().isAfter(refreshExpiresAt);
|
||||
}
|
||||
|
||||
/// Vérifie si le token expire dans les prochaines minutes
|
||||
bool isExpiringSoon({int minutes = 5}) {
|
||||
final threshold = DateTime.now().add(Duration(minutes: minutes));
|
||||
return expiresAt.isBefore(threshold);
|
||||
}
|
||||
|
||||
factory LoginResponse.fromJson(Map<String, dynamic> json) {
|
||||
return LoginResponse(
|
||||
accessToken: json['accessToken'] ?? '',
|
||||
refreshToken: json['refreshToken'] ?? '',
|
||||
tokenType: json['tokenType'] ?? 'Bearer',
|
||||
expiresAt: json['expiresAt'] != null
|
||||
? DateTime.parse(json['expiresAt'])
|
||||
: DateTime.now().add(const Duration(minutes: 15)),
|
||||
refreshExpiresAt: json['refreshExpiresAt'] != null
|
||||
? DateTime.parse(json['refreshExpiresAt'])
|
||||
: DateTime.now().add(const Duration(days: 7)),
|
||||
user: UserInfo.fromJson(json['user'] ?? {}),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'accessToken': accessToken,
|
||||
'refreshToken': refreshToken,
|
||||
'tokenType': tokenType,
|
||||
'expiresAt': expiresAt.toIso8601String(),
|
||||
'refreshExpiresAt': refreshExpiresAt.toIso8601String(),
|
||||
'user': user.toJson(),
|
||||
};
|
||||
}
|
||||
|
||||
LoginResponse copyWith({
|
||||
String? accessToken,
|
||||
String? refreshToken,
|
||||
String? tokenType,
|
||||
DateTime? expiresAt,
|
||||
DateTime? refreshExpiresAt,
|
||||
UserInfo? user,
|
||||
}) {
|
||||
return LoginResponse(
|
||||
accessToken: accessToken ?? this.accessToken,
|
||||
refreshToken: refreshToken ?? this.refreshToken,
|
||||
tokenType: tokenType ?? this.tokenType,
|
||||
expiresAt: expiresAt ?? this.expiresAt,
|
||||
refreshExpiresAt: refreshExpiresAt ?? this.refreshExpiresAt,
|
||||
user: user ?? this.user,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
accessToken,
|
||||
refreshToken,
|
||||
tokenType,
|
||||
expiresAt,
|
||||
refreshExpiresAt,
|
||||
user,
|
||||
];
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'LoginResponse(tokenType: $tokenType, user: ${user.email}, expiresAt: $expiresAt)';
|
||||
}
|
||||
}
|
||||
5
unionflow-mobile-apps/lib/core/auth/models/models.dart
Normal file
5
unionflow-mobile-apps/lib/core/auth/models/models.dart
Normal file
@@ -0,0 +1,5 @@
|
||||
// Export all auth models
|
||||
export 'auth_state.dart';
|
||||
export 'login_request.dart';
|
||||
export 'login_response.dart';
|
||||
export 'user_info.dart';
|
||||
90
unionflow-mobile-apps/lib/core/auth/models/user_info.dart
Normal file
90
unionflow-mobile-apps/lib/core/auth/models/user_info.dart
Normal file
@@ -0,0 +1,90 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// Modèle des informations utilisateur
|
||||
class UserInfo extends Equatable {
|
||||
final String id;
|
||||
final String email;
|
||||
final String firstName;
|
||||
final String lastName;
|
||||
final String role;
|
||||
final String? profilePicture;
|
||||
final bool isActive;
|
||||
|
||||
const UserInfo({
|
||||
required this.id,
|
||||
required this.email,
|
||||
required this.firstName,
|
||||
required this.lastName,
|
||||
required this.role,
|
||||
this.profilePicture,
|
||||
required this.isActive,
|
||||
});
|
||||
|
||||
String get fullName => '$firstName $lastName';
|
||||
|
||||
String get initials {
|
||||
final f = firstName.isNotEmpty ? firstName[0] : '';
|
||||
final l = lastName.isNotEmpty ? lastName[0] : '';
|
||||
return '$f$l'.toUpperCase();
|
||||
}
|
||||
|
||||
factory UserInfo.fromJson(Map<String, dynamic> json) {
|
||||
return UserInfo(
|
||||
id: json['id'] ?? '',
|
||||
email: json['email'] ?? '',
|
||||
firstName: json['firstName'] ?? '',
|
||||
lastName: json['lastName'] ?? '',
|
||||
role: json['role'] ?? 'membre',
|
||||
profilePicture: json['profilePicture'],
|
||||
isActive: json['isActive'] ?? true,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'email': email,
|
||||
'firstName': firstName,
|
||||
'lastName': lastName,
|
||||
'role': role,
|
||||
'profilePicture': profilePicture,
|
||||
'isActive': isActive,
|
||||
};
|
||||
}
|
||||
|
||||
UserInfo copyWith({
|
||||
String? id,
|
||||
String? email,
|
||||
String? firstName,
|
||||
String? lastName,
|
||||
String? role,
|
||||
String? profilePicture,
|
||||
bool? isActive,
|
||||
}) {
|
||||
return UserInfo(
|
||||
id: id ?? this.id,
|
||||
email: email ?? this.email,
|
||||
firstName: firstName ?? this.firstName,
|
||||
lastName: lastName ?? this.lastName,
|
||||
role: role ?? this.role,
|
||||
profilePicture: profilePicture ?? this.profilePicture,
|
||||
isActive: isActive ?? this.isActive,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
id,
|
||||
email,
|
||||
firstName,
|
||||
lastName,
|
||||
role,
|
||||
profilePicture,
|
||||
isActive,
|
||||
];
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'UserInfo(id: $id, email: $email, fullName: $fullName, role: $role, isActive: $isActive)';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
import 'dart:convert';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:injectable/injectable.dart';
|
||||
import '../../../core/network/dio_client.dart';
|
||||
import '../models/login_request.dart';
|
||||
import '../models/login_response.dart';
|
||||
|
||||
/// Service API pour l'authentification
|
||||
@singleton
|
||||
class AuthApiService {
|
||||
final DioClient _dioClient;
|
||||
late final Dio _dio;
|
||||
|
||||
AuthApiService(this._dioClient) {
|
||||
_dio = _dioClient.dio;
|
||||
}
|
||||
|
||||
/// Effectue la connexion utilisateur
|
||||
Future<LoginResponse> login(LoginRequest request) async {
|
||||
try {
|
||||
final response = await _dio.post(
|
||||
'/api/auth/login',
|
||||
data: request.toJson(),
|
||||
options: Options(
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
// Désactiver l'interceptor d'auth pour cette requête
|
||||
extra: {'skipAuth': true},
|
||||
),
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
return LoginResponse.fromJson(response.data);
|
||||
} else {
|
||||
throw AuthApiException(
|
||||
'Erreur de connexion',
|
||||
statusCode: response.statusCode,
|
||||
response: response.data,
|
||||
);
|
||||
}
|
||||
} on DioException catch (e) {
|
||||
throw _handleDioException(e);
|
||||
} catch (e) {
|
||||
throw AuthApiException('Erreur inattendue lors de la connexion: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Rafraîchit le token d'accès
|
||||
Future<LoginResponse> refreshToken(String refreshToken) async {
|
||||
try {
|
||||
final response = await _dio.post(
|
||||
'/api/auth/refresh',
|
||||
data: {'refreshToken': refreshToken},
|
||||
options: Options(
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
extra: {'skipAuth': true},
|
||||
),
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
return LoginResponse.fromJson(response.data);
|
||||
} else {
|
||||
throw AuthApiException(
|
||||
'Erreur lors du rafraîchissement du token',
|
||||
statusCode: response.statusCode,
|
||||
response: response.data,
|
||||
);
|
||||
}
|
||||
} on DioException catch (e) {
|
||||
throw _handleDioException(e);
|
||||
} catch (e) {
|
||||
throw AuthApiException('Erreur inattendue lors du rafraîchissement: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Effectue la déconnexion
|
||||
Future<void> logout(String? refreshToken) async {
|
||||
try {
|
||||
await _dio.post(
|
||||
'/api/auth/logout',
|
||||
data: refreshToken != null ? {'refreshToken': refreshToken} : {},
|
||||
options: Options(
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
extra: {'skipAuth': true},
|
||||
),
|
||||
);
|
||||
} on DioException catch (e) {
|
||||
// Ignorer les erreurs de déconnexion côté serveur
|
||||
// La déconnexion locale est plus importante
|
||||
print('Erreur lors de la déconnexion serveur: ${e.message}');
|
||||
} catch (e) {
|
||||
print('Erreur inattendue lors de la déconnexion: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Valide un token côté serveur
|
||||
Future<bool> validateToken(String accessToken) async {
|
||||
try {
|
||||
final response = await _dio.get(
|
||||
'/api/auth/validate',
|
||||
options: Options(
|
||||
headers: {
|
||||
'Authorization': 'Bearer $accessToken',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
extra: {'skipAuth': true},
|
||||
),
|
||||
);
|
||||
|
||||
return response.statusCode == 200;
|
||||
} on DioException catch (e) {
|
||||
if (e.response?.statusCode == 401) {
|
||||
return false;
|
||||
}
|
||||
throw _handleDioException(e);
|
||||
} catch (e) {
|
||||
throw AuthApiException('Erreur lors de la validation du token: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère les informations de l'API d'authentification
|
||||
Future<Map<String, dynamic>> getAuthInfo() async {
|
||||
try {
|
||||
final response = await _dio.get(
|
||||
'/api/auth/info',
|
||||
options: Options(
|
||||
extra: {'skipAuth': true},
|
||||
),
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
return response.data as Map<String, dynamic>;
|
||||
} else {
|
||||
throw AuthApiException(
|
||||
'Erreur lors de la récupération des informations',
|
||||
statusCode: response.statusCode,
|
||||
);
|
||||
}
|
||||
} on DioException catch (e) {
|
||||
throw _handleDioException(e);
|
||||
} catch (e) {
|
||||
throw AuthApiException('Erreur inattendue: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Gestion centralisée des erreurs Dio
|
||||
AuthApiException _handleDioException(DioException e) {
|
||||
switch (e.type) {
|
||||
case DioExceptionType.connectionTimeout:
|
||||
case DioExceptionType.sendTimeout:
|
||||
case DioExceptionType.receiveTimeout:
|
||||
return AuthApiException(
|
||||
'Délai d\'attente dépassé. Vérifiez votre connexion internet.',
|
||||
type: AuthErrorType.timeout,
|
||||
);
|
||||
|
||||
case DioExceptionType.connectionError:
|
||||
return AuthApiException(
|
||||
'Impossible de se connecter au serveur. Vérifiez votre connexion internet.',
|
||||
type: AuthErrorType.network,
|
||||
);
|
||||
|
||||
case DioExceptionType.badResponse:
|
||||
final statusCode = e.response?.statusCode;
|
||||
final data = e.response?.data;
|
||||
|
||||
switch (statusCode) {
|
||||
case 400:
|
||||
return AuthApiException(
|
||||
_extractErrorMessage(data) ?? 'Données de requête invalides',
|
||||
statusCode: statusCode,
|
||||
type: AuthErrorType.validation,
|
||||
response: data,
|
||||
);
|
||||
|
||||
case 401:
|
||||
return AuthApiException(
|
||||
_extractErrorMessage(data) ?? 'Identifiants invalides',
|
||||
statusCode: statusCode,
|
||||
type: AuthErrorType.unauthorized,
|
||||
response: data,
|
||||
);
|
||||
|
||||
case 403:
|
||||
return AuthApiException(
|
||||
_extractErrorMessage(data) ?? 'Accès interdit',
|
||||
statusCode: statusCode,
|
||||
type: AuthErrorType.forbidden,
|
||||
response: data,
|
||||
);
|
||||
|
||||
case 429:
|
||||
return AuthApiException(
|
||||
'Trop de tentatives. Veuillez réessayer plus tard.',
|
||||
statusCode: statusCode,
|
||||
type: AuthErrorType.rateLimited,
|
||||
response: data,
|
||||
);
|
||||
|
||||
case 500:
|
||||
case 502:
|
||||
case 503:
|
||||
case 504:
|
||||
return AuthApiException(
|
||||
'Erreur serveur temporaire. Veuillez réessayer.',
|
||||
statusCode: statusCode,
|
||||
type: AuthErrorType.server,
|
||||
response: data,
|
||||
);
|
||||
|
||||
default:
|
||||
return AuthApiException(
|
||||
_extractErrorMessage(data) ?? 'Erreur serveur inconnue',
|
||||
statusCode: statusCode,
|
||||
response: data,
|
||||
);
|
||||
}
|
||||
|
||||
case DioExceptionType.cancel:
|
||||
return AuthApiException(
|
||||
'Requête annulée',
|
||||
type: AuthErrorType.cancelled,
|
||||
);
|
||||
|
||||
default:
|
||||
return AuthApiException(
|
||||
'Erreur réseau: ${e.message}',
|
||||
type: AuthErrorType.unknown,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Extrait le message d'erreur de la réponse
|
||||
String? _extractErrorMessage(dynamic data) {
|
||||
if (data == null) return null;
|
||||
|
||||
if (data is Map<String, dynamic>) {
|
||||
return data['message'] ?? data['error'] ?? data['detail'];
|
||||
}
|
||||
|
||||
if (data is String) {
|
||||
try {
|
||||
final json = jsonDecode(data) as Map<String, dynamic>;
|
||||
return json['message'] ?? json['error'] ?? json['detail'];
|
||||
} catch (_) {
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Types d'erreurs d'authentification
|
||||
enum AuthErrorType {
|
||||
validation,
|
||||
unauthorized,
|
||||
forbidden,
|
||||
timeout,
|
||||
network,
|
||||
server,
|
||||
rateLimited,
|
||||
cancelled,
|
||||
unknown,
|
||||
}
|
||||
|
||||
/// Exception spécifique à l'API d'authentification
|
||||
class AuthApiException implements Exception {
|
||||
final String message;
|
||||
final int? statusCode;
|
||||
final AuthErrorType type;
|
||||
final dynamic response;
|
||||
|
||||
const AuthApiException(
|
||||
this.message, {
|
||||
this.statusCode,
|
||||
this.type = AuthErrorType.unknown,
|
||||
this.response,
|
||||
});
|
||||
|
||||
bool get isNetworkError =>
|
||||
type == AuthErrorType.network ||
|
||||
type == AuthErrorType.timeout;
|
||||
|
||||
bool get isServerError => type == AuthErrorType.server;
|
||||
|
||||
bool get isClientError =>
|
||||
type == AuthErrorType.validation ||
|
||||
type == AuthErrorType.unauthorized ||
|
||||
type == AuthErrorType.forbidden;
|
||||
|
||||
bool get isRetryable =>
|
||||
isNetworkError ||
|
||||
isServerError ||
|
||||
type == AuthErrorType.rateLimited;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'AuthApiException: $message ${statusCode != null ? '(Status: $statusCode)' : ''}';
|
||||
}
|
||||
}
|
||||
318
unionflow-mobile-apps/lib/core/auth/services/auth_service.dart
Normal file
318
unionflow-mobile-apps/lib/core/auth/services/auth_service.dart
Normal file
@@ -0,0 +1,318 @@
|
||||
import 'dart:async';
|
||||
import 'package:injectable/injectable.dart';
|
||||
import 'package:jwt_decoder/jwt_decoder.dart';
|
||||
import '../models/auth_state.dart';
|
||||
import '../models/login_request.dart';
|
||||
import '../models/login_response.dart';
|
||||
import '../models/user_info.dart';
|
||||
import '../storage/secure_token_storage.dart';
|
||||
import 'auth_api_service.dart';
|
||||
import '../../network/auth_interceptor.dart';
|
||||
import '../../network/dio_client.dart';
|
||||
|
||||
/// Service principal d'authentification
|
||||
@singleton
|
||||
class AuthService {
|
||||
final SecureTokenStorage _tokenStorage;
|
||||
final AuthApiService _apiService;
|
||||
final AuthInterceptor _authInterceptor;
|
||||
final DioClient _dioClient;
|
||||
|
||||
// Stream controllers pour notifier les changements d'état
|
||||
final _authStateController = StreamController<AuthState>.broadcast();
|
||||
final _tokenRefreshController = StreamController<void>.broadcast();
|
||||
|
||||
// Timers pour la gestion automatique des tokens
|
||||
Timer? _tokenRefreshTimer;
|
||||
Timer? _sessionExpiryTimer;
|
||||
|
||||
// État actuel
|
||||
AuthState _currentState = const AuthState.unknown();
|
||||
|
||||
AuthService(
|
||||
this._tokenStorage,
|
||||
this._apiService,
|
||||
this._authInterceptor,
|
||||
this._dioClient,
|
||||
) {
|
||||
_initializeAuthInterceptor();
|
||||
}
|
||||
|
||||
// Getters
|
||||
Stream<AuthState> get authStateStream => _authStateController.stream;
|
||||
AuthState get currentState => _currentState;
|
||||
bool get isAuthenticated => _currentState.isAuthenticated;
|
||||
UserInfo? get currentUser => _currentState.user;
|
||||
|
||||
/// Initialise l'interceptor d'authentification
|
||||
void _initializeAuthInterceptor() {
|
||||
_authInterceptor.setCallbacks(
|
||||
onTokenRefreshNeeded: () => _refreshTokenSilently(),
|
||||
onAuthenticationFailed: () => logout(),
|
||||
);
|
||||
_dioClient.addAuthInterceptor(_authInterceptor);
|
||||
}
|
||||
|
||||
/// Initialise le service d'authentification
|
||||
Future<void> initialize() async {
|
||||
_updateState(const AuthState.checking());
|
||||
|
||||
try {
|
||||
// Vérifier si des tokens existent
|
||||
final hasTokens = await _tokenStorage.hasAuthData();
|
||||
if (!hasTokens) {
|
||||
_updateState(const AuthState.unauthenticated());
|
||||
return;
|
||||
}
|
||||
|
||||
// Récupérer les données d'authentification
|
||||
final authData = await _tokenStorage.getAuthData();
|
||||
if (authData == null) {
|
||||
_updateState(const AuthState.unauthenticated());
|
||||
return;
|
||||
}
|
||||
|
||||
// Vérifier si les tokens sont expirés
|
||||
if (authData.isRefreshTokenExpired) {
|
||||
await _tokenStorage.clearAuthData();
|
||||
_updateState(const AuthState.unauthenticated());
|
||||
return;
|
||||
}
|
||||
|
||||
// Si le token d'accès est expiré, essayer de le rafraîchir
|
||||
if (authData.isAccessTokenExpired) {
|
||||
await _refreshToken();
|
||||
return;
|
||||
}
|
||||
|
||||
// Valider le token côté serveur
|
||||
final isValid = await _validateTokenWithServer(authData.accessToken);
|
||||
if (!isValid) {
|
||||
await _refreshToken();
|
||||
return;
|
||||
}
|
||||
|
||||
// Tout est OK, restaurer la session
|
||||
_updateState(AuthState.authenticated(
|
||||
user: authData.user,
|
||||
accessToken: authData.accessToken,
|
||||
refreshToken: authData.refreshToken,
|
||||
expiresAt: authData.expiresAt,
|
||||
));
|
||||
|
||||
_scheduleTokenRefresh();
|
||||
_scheduleSessionExpiry();
|
||||
|
||||
} catch (e) {
|
||||
print('Erreur lors de l\'initialisation de l\'auth: $e');
|
||||
await _tokenStorage.clearAuthData();
|
||||
_updateState(AuthState.error('Erreur d\'initialisation: $e'));
|
||||
}
|
||||
}
|
||||
|
||||
/// Connecte un utilisateur
|
||||
Future<void> login(LoginRequest request) async {
|
||||
_updateState(_currentState.copyWith(isLoading: true));
|
||||
|
||||
try {
|
||||
// Appel API de connexion
|
||||
final response = await _apiService.login(request);
|
||||
|
||||
// Sauvegarder les tokens
|
||||
await _tokenStorage.saveAuthData(response);
|
||||
|
||||
// Mettre à jour l'état
|
||||
_updateState(AuthState.authenticated(
|
||||
user: response.user,
|
||||
accessToken: response.accessToken,
|
||||
refreshToken: response.refreshToken,
|
||||
expiresAt: response.expiresAt,
|
||||
));
|
||||
|
||||
// Programmer les rafraîchissements
|
||||
_scheduleTokenRefresh();
|
||||
_scheduleSessionExpiry();
|
||||
|
||||
} on AuthApiException catch (e) {
|
||||
_updateState(AuthState.error(e.message));
|
||||
rethrow;
|
||||
} catch (e) {
|
||||
final errorMessage = 'Erreur de connexion: $e';
|
||||
_updateState(AuthState.error(errorMessage));
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Déconnecte l'utilisateur
|
||||
Future<void> logout() async {
|
||||
try {
|
||||
// Récupérer le refresh token pour l'invalider côté serveur
|
||||
final refreshToken = await _tokenStorage.getRefreshToken();
|
||||
|
||||
// Appel API de déconnexion (optionnel)
|
||||
await _apiService.logout(refreshToken);
|
||||
} catch (e) {
|
||||
print('Erreur lors de la déconnexion serveur: $e');
|
||||
}
|
||||
|
||||
// Nettoyage local (toujours fait)
|
||||
await _tokenStorage.clearAuthData();
|
||||
_cancelTimers();
|
||||
_updateState(const AuthState.unauthenticated());
|
||||
}
|
||||
|
||||
/// Rafraîchit le token d'accès
|
||||
Future<void> _refreshToken() async {
|
||||
try {
|
||||
final refreshToken = await _tokenStorage.getRefreshToken();
|
||||
if (refreshToken == null) {
|
||||
throw Exception('Aucun refresh token disponible');
|
||||
}
|
||||
|
||||
// Vérifier si le refresh token est expiré
|
||||
final refreshExpiresAt = await _tokenStorage.getRefreshTokenExpirationDate();
|
||||
if (refreshExpiresAt != null && DateTime.now().isAfter(refreshExpiresAt)) {
|
||||
throw Exception('Refresh token expiré');
|
||||
}
|
||||
|
||||
// Appel API de refresh
|
||||
final response = await _apiService.refreshToken(refreshToken);
|
||||
|
||||
// Mettre à jour le stockage
|
||||
await _tokenStorage.updateAccessToken(response.accessToken, response.expiresAt);
|
||||
|
||||
// Mettre à jour l'état
|
||||
if (_currentState.isAuthenticated) {
|
||||
_updateState(_currentState.copyWith(
|
||||
accessToken: response.accessToken,
|
||||
expiresAt: response.expiresAt,
|
||||
));
|
||||
} else {
|
||||
_updateState(AuthState.authenticated(
|
||||
user: response.user,
|
||||
accessToken: response.accessToken,
|
||||
refreshToken: response.refreshToken,
|
||||
expiresAt: response.expiresAt,
|
||||
));
|
||||
}
|
||||
|
||||
// Reprogrammer les timers
|
||||
_scheduleTokenRefresh();
|
||||
|
||||
} catch (e) {
|
||||
print('Erreur lors du rafraîchissement du token: $e');
|
||||
await logout();
|
||||
}
|
||||
}
|
||||
|
||||
/// Rafraîchit le token silencieusement (sans changer l'état de loading)
|
||||
Future<void> _refreshTokenSilently() async {
|
||||
try {
|
||||
await _refreshToken();
|
||||
_tokenRefreshController.add(null);
|
||||
} catch (e) {
|
||||
print('Erreur lors du rafraîchissement silencieux: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Valide un token côté serveur
|
||||
Future<bool> _validateTokenWithServer(String accessToken) async {
|
||||
try {
|
||||
return await _apiService.validateToken(accessToken);
|
||||
} catch (e) {
|
||||
print('Erreur lors de la validation du token: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Programme le rafraîchissement automatique du token
|
||||
void _scheduleTokenRefresh() {
|
||||
_tokenRefreshTimer?.cancel();
|
||||
|
||||
if (!_currentState.isAuthenticated || _currentState.expiresAt == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Rafraîchir 5 minutes avant l'expiration
|
||||
final refreshTime = _currentState.expiresAt!.subtract(const Duration(minutes: 5));
|
||||
final delay = refreshTime.difference(DateTime.now());
|
||||
|
||||
if (delay.isNegative) {
|
||||
// Le token expire bientôt, rafraîchir immédiatement
|
||||
_refreshTokenSilently();
|
||||
return;
|
||||
}
|
||||
|
||||
_tokenRefreshTimer = Timer(delay, () => _refreshTokenSilently());
|
||||
}
|
||||
|
||||
/// Programme l'expiration de la session
|
||||
void _scheduleSessionExpiry() {
|
||||
_sessionExpiryTimer?.cancel();
|
||||
|
||||
if (!_currentState.isAuthenticated || _currentState.expiresAt == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final delay = _currentState.expiresAt!.difference(DateTime.now());
|
||||
if (delay.isNegative) {
|
||||
logout();
|
||||
return;
|
||||
}
|
||||
|
||||
_sessionExpiryTimer = Timer(delay, () {
|
||||
_updateState(const AuthState.expired());
|
||||
});
|
||||
}
|
||||
|
||||
/// Annule tous les timers
|
||||
void _cancelTimers() {
|
||||
_tokenRefreshTimer?.cancel();
|
||||
_sessionExpiryTimer?.cancel();
|
||||
_tokenRefreshTimer = null;
|
||||
_sessionExpiryTimer = null;
|
||||
}
|
||||
|
||||
/// Met à jour l'état et notifie les listeners
|
||||
void _updateState(AuthState newState) {
|
||||
_currentState = newState;
|
||||
_authStateController.add(newState);
|
||||
}
|
||||
|
||||
/// Nettoie les ressources
|
||||
void dispose() {
|
||||
_cancelTimers();
|
||||
_authStateController.close();
|
||||
_tokenRefreshController.close();
|
||||
}
|
||||
|
||||
/// Vérifie les permissions de l'utilisateur
|
||||
bool hasRole(String role) {
|
||||
return _currentState.user?.role == role;
|
||||
}
|
||||
|
||||
/// Vérifie si l'utilisateur a un des rôles spécifiés
|
||||
bool hasAnyRole(List<String> roles) {
|
||||
final userRole = _currentState.user?.role;
|
||||
return userRole != null && roles.contains(userRole);
|
||||
}
|
||||
|
||||
/// Décode un token JWT (utilitaire)
|
||||
Map<String, dynamic>? decodeToken(String token) {
|
||||
try {
|
||||
return JwtDecoder.decode(token);
|
||||
} catch (e) {
|
||||
print('Erreur lors du décodage du token: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Vérifie si un token est expiré
|
||||
bool isTokenExpired(String token) {
|
||||
try {
|
||||
return JwtDecoder.isExpired(token);
|
||||
} catch (e) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../models/auth_state.dart';
|
||||
import '../models/login_request.dart';
|
||||
import '../models/user_info.dart';
|
||||
|
||||
/// Service d'authentification temporaire pour test sans dépendances
|
||||
class TempAuthService {
|
||||
final _authStateController = StreamController<AuthState>.broadcast();
|
||||
AuthState _currentState = const AuthState.unknown();
|
||||
|
||||
Stream<AuthState> get authStateStream => _authStateController.stream;
|
||||
AuthState get currentState => _currentState;
|
||||
bool get isAuthenticated => _currentState.isAuthenticated;
|
||||
UserInfo? get currentUser => _currentState.user;
|
||||
|
||||
Future<void> initialize() async {
|
||||
_updateState(const AuthState.checking());
|
||||
|
||||
// Simuler une vérification
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
|
||||
_updateState(const AuthState.unauthenticated());
|
||||
}
|
||||
|
||||
Future<void> login(LoginRequest request) async {
|
||||
_updateState(_currentState.copyWith(isLoading: true));
|
||||
|
||||
try {
|
||||
// Simulation d'appel API
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
|
||||
// Vérification simple pour la démo
|
||||
if (request.email == 'admin@unionflow.dev' && request.password == 'admin123') {
|
||||
final user = UserInfo(
|
||||
id: '1',
|
||||
email: request.email,
|
||||
firstName: 'Admin',
|
||||
lastName: 'UnionFlow',
|
||||
role: 'admin',
|
||||
isActive: true,
|
||||
);
|
||||
|
||||
_updateState(AuthState.authenticated(
|
||||
user: user,
|
||||
accessToken: 'fake_access_token',
|
||||
refreshToken: 'fake_refresh_token',
|
||||
expiresAt: DateTime.now().add(const Duration(hours: 1)),
|
||||
));
|
||||
} else {
|
||||
throw Exception('Identifiants invalides');
|
||||
}
|
||||
} catch (e) {
|
||||
_updateState(AuthState.error(e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> logout() async {
|
||||
_updateState(const AuthState.unauthenticated());
|
||||
}
|
||||
|
||||
void _updateState(AuthState newState) {
|
||||
_currentState = newState;
|
||||
_authStateController.add(newState);
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_authStateController.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../models/auth_state.dart';
|
||||
import '../models/login_request.dart';
|
||||
import '../models/user_info.dart';
|
||||
|
||||
/// Service d'authentification ultra-simple sans aucune dépendance externe
|
||||
class UltraSimpleAuthService {
|
||||
final _authStateController = StreamController<AuthState>.broadcast();
|
||||
AuthState _currentState = const AuthState.unknown();
|
||||
|
||||
Stream<AuthState> get authStateStream => _authStateController.stream;
|
||||
AuthState get currentState => _currentState;
|
||||
bool get isAuthenticated => _currentState.isAuthenticated;
|
||||
UserInfo? get currentUser => _currentState.user;
|
||||
|
||||
Future<void> initialize() async {
|
||||
_updateState(const AuthState.checking());
|
||||
|
||||
// Simuler une vérification
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
|
||||
_updateState(const AuthState.unauthenticated());
|
||||
}
|
||||
|
||||
Future<void> login(LoginRequest request) async {
|
||||
_updateState(_currentState.copyWith(isLoading: true));
|
||||
|
||||
try {
|
||||
// Simulation d'appel API
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
|
||||
// Vérification simple pour la démo
|
||||
if (request.email == 'admin@unionflow.dev' && request.password == 'admin123') {
|
||||
final user = UserInfo(
|
||||
id: '1',
|
||||
email: request.email,
|
||||
firstName: 'Admin',
|
||||
lastName: 'UnionFlow',
|
||||
role: 'admin',
|
||||
isActive: true,
|
||||
);
|
||||
|
||||
_updateState(AuthState.authenticated(
|
||||
user: user,
|
||||
accessToken: 'fake_access_token_${DateTime.now().millisecondsSinceEpoch}',
|
||||
refreshToken: 'fake_refresh_token_${DateTime.now().millisecondsSinceEpoch}',
|
||||
expiresAt: DateTime.now().add(const Duration(hours: 1)),
|
||||
));
|
||||
} else if (request.email == 'president@lions.org' && request.password == 'admin123') {
|
||||
final user = UserInfo(
|
||||
id: '2',
|
||||
email: request.email,
|
||||
firstName: 'Jean',
|
||||
lastName: 'Dupont',
|
||||
role: 'président',
|
||||
isActive: true,
|
||||
);
|
||||
|
||||
_updateState(AuthState.authenticated(
|
||||
user: user,
|
||||
accessToken: 'fake_access_token_${DateTime.now().millisecondsSinceEpoch}',
|
||||
refreshToken: 'fake_refresh_token_${DateTime.now().millisecondsSinceEpoch}',
|
||||
expiresAt: DateTime.now().add(const Duration(hours: 1)),
|
||||
));
|
||||
} else {
|
||||
throw Exception('Identifiants invalides');
|
||||
}
|
||||
} catch (e) {
|
||||
_updateState(AuthState.error(e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> logout() async {
|
||||
_updateState(const AuthState.unauthenticated());
|
||||
}
|
||||
|
||||
void _updateState(AuthState newState) {
|
||||
_currentState = newState;
|
||||
_authStateController.add(newState);
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_authStateController.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import 'dart:convert';
|
||||
import '../models/login_response.dart';
|
||||
import '../models/user_info.dart';
|
||||
|
||||
/// Service de stockage en mémoire des tokens (temporaire pour contourner Java 21)
|
||||
class MemoryTokenStorage {
|
||||
static final MemoryTokenStorage _instance = MemoryTokenStorage._internal();
|
||||
factory MemoryTokenStorage() => _instance;
|
||||
MemoryTokenStorage._internal();
|
||||
|
||||
// Stockage en mémoire
|
||||
final Map<String, String> _storage = {};
|
||||
|
||||
static const String _accessTokenKey = 'access_token';
|
||||
static const String _refreshTokenKey = 'refresh_token';
|
||||
static const String _userInfoKey = 'user_info';
|
||||
static const String _expiresAtKey = 'expires_at';
|
||||
static const String _refreshExpiresAtKey = 'refresh_expires_at';
|
||||
|
||||
/// Sauvegarde les données d'authentification
|
||||
Future<void> saveAuthData(LoginResponse loginResponse) async {
|
||||
try {
|
||||
_storage[_accessTokenKey] = loginResponse.accessToken;
|
||||
_storage[_refreshTokenKey] = loginResponse.refreshToken;
|
||||
_storage[_userInfoKey] = jsonEncode(loginResponse.user.toJson());
|
||||
_storage[_expiresAtKey] = loginResponse.expiresAt.toIso8601String();
|
||||
_storage[_refreshExpiresAtKey] = loginResponse.refreshExpiresAt.toIso8601String();
|
||||
} catch (e) {
|
||||
throw StorageException('Erreur lors de la sauvegarde des données d\'authentification: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère le token d'accès
|
||||
Future<String?> getAccessToken() async {
|
||||
return _storage[_accessTokenKey];
|
||||
}
|
||||
|
||||
/// Récupère le refresh token
|
||||
Future<String?> getRefreshToken() async {
|
||||
return _storage[_refreshTokenKey];
|
||||
}
|
||||
|
||||
/// Récupère les informations utilisateur
|
||||
Future<UserInfo?> getUserInfo() async {
|
||||
try {
|
||||
final userJson = _storage[_userInfoKey];
|
||||
if (userJson == null) return null;
|
||||
|
||||
final userMap = jsonDecode(userJson) as Map<String, dynamic>;
|
||||
return UserInfo.fromJson(userMap);
|
||||
} catch (e) {
|
||||
throw StorageException('Erreur lors de la récupération des informations utilisateur: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère la date d'expiration du token d'accès
|
||||
Future<DateTime?> getTokenExpirationDate() async {
|
||||
try {
|
||||
final expiresAtString = _storage[_expiresAtKey];
|
||||
if (expiresAtString == null) return null;
|
||||
|
||||
return DateTime.parse(expiresAtString);
|
||||
} catch (e) {
|
||||
throw StorageException('Erreur lors de la récupération de la date d\'expiration: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère la date d'expiration du refresh token
|
||||
Future<DateTime?> getRefreshTokenExpirationDate() async {
|
||||
try {
|
||||
final expiresAtString = _storage[_refreshExpiresAtKey];
|
||||
if (expiresAtString == null) return null;
|
||||
|
||||
return DateTime.parse(expiresAtString);
|
||||
} catch (e) {
|
||||
throw StorageException('Erreur lors de la récupération de la date d\'expiration du refresh token: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Vérifie si l'utilisateur est authentifié
|
||||
Future<bool> hasValidToken() async {
|
||||
final token = await getAccessToken();
|
||||
if (token == null) return false;
|
||||
|
||||
final expirationDate = await getTokenExpirationDate();
|
||||
if (expirationDate == null) return false;
|
||||
|
||||
return DateTime.now().isBefore(expirationDate);
|
||||
}
|
||||
|
||||
/// Efface toutes les données d'authentification
|
||||
Future<void> clearAll() async {
|
||||
_storage.clear();
|
||||
}
|
||||
|
||||
/// Met à jour uniquement les tokens
|
||||
Future<void> updateTokens({
|
||||
required String accessToken,
|
||||
required String refreshToken,
|
||||
required DateTime expiresAt,
|
||||
required DateTime refreshExpiresAt,
|
||||
}) async {
|
||||
_storage[_accessTokenKey] = accessToken;
|
||||
_storage[_refreshTokenKey] = refreshToken;
|
||||
_storage[_expiresAtKey] = expiresAt.toIso8601String();
|
||||
_storage[_refreshExpiresAtKey] = refreshExpiresAt.toIso8601String();
|
||||
}
|
||||
}
|
||||
|
||||
/// Exception personnalisée pour les erreurs de stockage
|
||||
class StorageException implements Exception {
|
||||
final String message;
|
||||
StorageException(this.message);
|
||||
|
||||
@override
|
||||
String toString() => 'StorageException: $message';
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
import 'dart:convert';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:injectable/injectable.dart';
|
||||
import '../models/login_response.dart';
|
||||
import '../models/user_info.dart';
|
||||
|
||||
/// Service de stockage sécurisé des tokens d'authentification
|
||||
@singleton
|
||||
class SecureTokenStorage {
|
||||
static const String _accessTokenKey = 'access_token';
|
||||
static const String _refreshTokenKey = 'refresh_token';
|
||||
static const String _userInfoKey = 'user_info';
|
||||
static const String _expiresAtKey = 'expires_at';
|
||||
static const String _refreshExpiresAtKey = 'refresh_expires_at';
|
||||
static const String _biometricEnabledKey = 'biometric_enabled';
|
||||
|
||||
// Utilise SharedPreferences temporairement pour Android
|
||||
Future<SharedPreferences> get _prefs => SharedPreferences.getInstance();
|
||||
|
||||
/// Sauvegarde les données d'authentification
|
||||
Future<void> saveAuthData(LoginResponse loginResponse) async {
|
||||
try {
|
||||
final prefs = await _prefs;
|
||||
await Future.wait([
|
||||
prefs.setString(_accessTokenKey, loginResponse.accessToken),
|
||||
prefs.setString(_refreshTokenKey, loginResponse.refreshToken),
|
||||
prefs.setString(_userInfoKey, jsonEncode(loginResponse.user.toJson())),
|
||||
prefs.setString(_expiresAtKey, loginResponse.expiresAt.toIso8601String()),
|
||||
prefs.setString(_refreshExpiresAtKey, loginResponse.refreshExpiresAt.toIso8601String()),
|
||||
]);
|
||||
} catch (e) {
|
||||
throw StorageException('Erreur lors de la sauvegarde des données d\'authentification: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère le token d'accès
|
||||
Future<String?> getAccessToken() async {
|
||||
try {
|
||||
final prefs = await _prefs;
|
||||
return prefs.getString(_accessTokenKey);
|
||||
} catch (e) {
|
||||
throw StorageException('Erreur lors de la récupération du token d\'accès: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère le refresh token
|
||||
Future<String?> getRefreshToken() async {
|
||||
try {
|
||||
final prefs = await _prefs;
|
||||
return prefs.getString(_refreshTokenKey);
|
||||
} catch (e) {
|
||||
throw StorageException('Erreur lors de la récupération du refresh token: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère les informations utilisateur
|
||||
Future<UserInfo?> getUserInfo() async {
|
||||
try {
|
||||
final prefs = await _prefs;
|
||||
final userJson = prefs.getString(_userInfoKey);
|
||||
if (userJson == null) return null;
|
||||
|
||||
final userMap = jsonDecode(userJson) as Map<String, dynamic>;
|
||||
return UserInfo.fromJson(userMap);
|
||||
} catch (e) {
|
||||
throw StorageException('Erreur lors de la récupération des informations utilisateur: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère la date d'expiration du token d'accès
|
||||
Future<DateTime?> getTokenExpirationDate() async {
|
||||
try {
|
||||
final prefs = await _prefs;
|
||||
final expiresAtString = prefs.getString(_expiresAtKey);
|
||||
if (expiresAtString == null) return null;
|
||||
|
||||
return DateTime.parse(expiresAtString);
|
||||
} catch (e) {
|
||||
throw StorageException('Erreur lors de la récupération de la date d\'expiration: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère la date d'expiration du refresh token
|
||||
Future<DateTime?> getRefreshTokenExpirationDate() async {
|
||||
try {
|
||||
final expiresAtString = await _storage.read(key: _refreshExpiresAtKey);
|
||||
if (expiresAtString == null) return null;
|
||||
|
||||
return DateTime.parse(expiresAtString);
|
||||
} catch (e) {
|
||||
throw StorageException('Erreur lors de la récupération de la date d\'expiration du refresh token: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère toutes les données d'authentification
|
||||
Future<LoginResponse?> getAuthData() async {
|
||||
try {
|
||||
final results = await Future.wait([
|
||||
getAccessToken(),
|
||||
getRefreshToken(),
|
||||
getUserInfo(),
|
||||
getTokenExpirationDate(),
|
||||
getRefreshTokenExpirationDate(),
|
||||
]);
|
||||
|
||||
final accessToken = results[0] as String?;
|
||||
final refreshToken = results[1] as String?;
|
||||
final userInfo = results[2] as UserInfo?;
|
||||
final expiresAt = results[3] as DateTime?;
|
||||
final refreshExpiresAt = results[4] as DateTime?;
|
||||
|
||||
if (accessToken == null ||
|
||||
refreshToken == null ||
|
||||
userInfo == null ||
|
||||
expiresAt == null ||
|
||||
refreshExpiresAt == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return LoginResponse(
|
||||
accessToken: accessToken,
|
||||
refreshToken: refreshToken,
|
||||
tokenType: 'Bearer',
|
||||
expiresAt: expiresAt,
|
||||
refreshExpiresAt: refreshExpiresAt,
|
||||
user: userInfo,
|
||||
);
|
||||
} catch (e) {
|
||||
throw StorageException('Erreur lors de la récupération des données d\'authentification: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Met à jour le token d'accès
|
||||
Future<void> updateAccessToken(String accessToken, DateTime expiresAt) async {
|
||||
try {
|
||||
await Future.wait([
|
||||
_storage.write(key: _accessTokenKey, value: accessToken),
|
||||
_storage.write(key: _expiresAtKey, value: expiresAt.toIso8601String()),
|
||||
]);
|
||||
} catch (e) {
|
||||
throw StorageException('Erreur lors de la mise à jour du token d\'accès: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Vérifie si les données d'authentification existent
|
||||
Future<bool> hasAuthData() async {
|
||||
try {
|
||||
final accessToken = await _storage.read(key: _accessTokenKey);
|
||||
final refreshToken = await _storage.read(key: _refreshTokenKey);
|
||||
return accessToken != null && refreshToken != null;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Vérifie si les tokens sont expirés
|
||||
Future<bool> areTokensExpired() async {
|
||||
try {
|
||||
final expiresAt = await getTokenExpirationDate();
|
||||
final refreshExpiresAt = await getRefreshTokenExpirationDate();
|
||||
|
||||
if (expiresAt == null || refreshExpiresAt == null) return true;
|
||||
|
||||
final now = DateTime.now();
|
||||
return refreshExpiresAt.isBefore(now);
|
||||
} catch (e) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Vérifie si le token d'accès expire bientôt
|
||||
Future<bool> isAccessTokenExpiringSoon({int minutes = 5}) async {
|
||||
try {
|
||||
final expiresAt = await getTokenExpirationDate();
|
||||
if (expiresAt == null) return true;
|
||||
|
||||
final threshold = DateTime.now().add(Duration(minutes: minutes));
|
||||
return expiresAt.isBefore(threshold);
|
||||
} catch (e) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Efface toutes les données d'authentification
|
||||
Future<void> clearAuthData() async {
|
||||
try {
|
||||
await Future.wait([
|
||||
_storage.delete(key: _accessTokenKey),
|
||||
_storage.delete(key: _refreshTokenKey),
|
||||
_storage.delete(key: _userInfoKey),
|
||||
_storage.delete(key: _expiresAtKey),
|
||||
_storage.delete(key: _refreshExpiresAtKey),
|
||||
]);
|
||||
} catch (e) {
|
||||
throw StorageException('Erreur lors de l\'effacement des données d\'authentification: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Active/désactive l'authentification biométrique
|
||||
Future<void> setBiometricEnabled(bool enabled) async {
|
||||
try {
|
||||
await _storage.write(key: _biometricEnabledKey, value: enabled.toString());
|
||||
} catch (e) {
|
||||
throw StorageException('Erreur lors de la configuration biométrique: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Vérifie si l'authentification biométrique est activée
|
||||
Future<bool> isBiometricEnabled() async {
|
||||
try {
|
||||
final enabled = await _storage.read(key: _biometricEnabledKey);
|
||||
return enabled == 'true';
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Efface toutes les données stockées
|
||||
Future<void> clearAll() async {
|
||||
try {
|
||||
await _storage.deleteAll();
|
||||
} catch (e) {
|
||||
throw StorageException('Erreur lors de l\'effacement de toutes les données: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Vérifie si le stockage sécurisé est disponible
|
||||
Future<bool> isAvailable() async {
|
||||
try {
|
||||
await _storage.containsKey(key: 'test');
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Exception liée au stockage
|
||||
class StorageException implements Exception {
|
||||
final String message;
|
||||
|
||||
const StorageException(this.message);
|
||||
|
||||
@override
|
||||
String toString() => 'StorageException: $message';
|
||||
}
|
||||
74
unionflow-mobile-apps/lib/core/constants/app_constants.dart
Normal file
74
unionflow-mobile-apps/lib/core/constants/app_constants.dart
Normal file
@@ -0,0 +1,74 @@
|
||||
class AppConstants {
|
||||
// API Configuration
|
||||
static const String baseUrl = 'http://localhost:8099'; // Backend UnionFlow
|
||||
static const String apiVersion = '/api/v1';
|
||||
|
||||
// Timeout
|
||||
static const Duration connectTimeout = Duration(seconds: 30);
|
||||
static const Duration receiveTimeout = Duration(seconds: 30);
|
||||
|
||||
// Storage Keys
|
||||
static const String authTokenKey = 'auth_token';
|
||||
static const String refreshTokenKey = 'refresh_token';
|
||||
static const String userDataKey = 'user_data';
|
||||
static const String appSettingsKey = 'app_settings';
|
||||
|
||||
// API Endpoints
|
||||
static const String loginEndpoint = '/auth/login';
|
||||
static const String refreshEndpoint = '/auth/refresh';
|
||||
static const String membresEndpoint = '/membres';
|
||||
static const String cotisationsEndpoint = '/finance/cotisations';
|
||||
static const String evenementsEndpoint = '/evenements';
|
||||
static const String statistiquesEndpoint = '/statistiques';
|
||||
|
||||
// App Configuration
|
||||
static const String appName = 'UnionFlow';
|
||||
static const String appVersion = '2.0.0';
|
||||
static const int maxRetryAttempts = 3;
|
||||
|
||||
// Pagination
|
||||
static const int defaultPageSize = 20;
|
||||
static const int maxPageSize = 100;
|
||||
|
||||
// File Upload
|
||||
static const int maxFileSize = 10 * 1024 * 1024; // 10MB
|
||||
static const List<String> allowedImageTypes = ['jpg', 'jpeg', 'png', 'gif'];
|
||||
static const List<String> allowedDocumentTypes = ['pdf', 'doc', 'docx'];
|
||||
|
||||
// Chart Colors
|
||||
static const List<String> chartColors = [
|
||||
'#2196F3', '#4CAF50', '#FF9800', '#F44336',
|
||||
'#9C27B0', '#00BCD4', '#8BC34A', '#FFEB3B'
|
||||
];
|
||||
}
|
||||
|
||||
class ApiEndpoints {
|
||||
// Authentication
|
||||
static const String login = '/auth/login';
|
||||
static const String logout = '/auth/logout';
|
||||
static const String register = '/auth/register';
|
||||
static const String refreshToken = '/auth/refresh';
|
||||
static const String forgotPassword = '/auth/forgot-password';
|
||||
|
||||
// Membres
|
||||
static const String membres = '/membres';
|
||||
static const String membreProfile = '/membres/profile';
|
||||
static const String membreSearch = '/membres/search';
|
||||
static const String membreStats = '/membres/statistiques';
|
||||
|
||||
// Cotisations
|
||||
static const String cotisations = '/finance/cotisations';
|
||||
static const String cotisationsPay = '/finance/cotisations/payer';
|
||||
static const String cotisationsHistory = '/finance/cotisations/historique';
|
||||
static const String cotisationsStats = '/finance/cotisations/statistiques';
|
||||
|
||||
// Événements
|
||||
static const String evenements = '/evenements';
|
||||
static const String evenementParticipants = '/evenements/{id}/participants';
|
||||
static const String evenementDocuments = '/evenements/{id}/documents';
|
||||
|
||||
// Dashboard
|
||||
static const String dashboardStats = '/dashboard/statistiques';
|
||||
static const String dashboardCharts = '/dashboard/charts';
|
||||
static const String dashboardNotifications = '/dashboard/notifications';
|
||||
}
|
||||
45
unionflow-mobile-apps/lib/core/di/injection.config.dart
Normal file
45
unionflow-mobile-apps/lib/core/di/injection.config.dart
Normal file
@@ -0,0 +1,45 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
// **************************************************************************
|
||||
// InjectableConfigGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// ignore_for_file: unnecessary_lambdas
|
||||
// ignore_for_file: lines_longer_than_80_chars
|
||||
// coverage:ignore-file
|
||||
|
||||
import 'package:get_it/get_it.dart' as _i1;
|
||||
import 'package:injectable/injectable.dart' as _i2;
|
||||
|
||||
import '../auth/bloc/auth_bloc.dart' as _i7;
|
||||
import '../auth/services/auth_api_service.dart' as _i4;
|
||||
import '../auth/services/auth_service.dart' as _i6;
|
||||
import '../auth/storage/secure_token_storage.dart' as _i3;
|
||||
import '../network/auth_interceptor.dart' as _i5;
|
||||
import '../network/dio_client.dart' as _i8;
|
||||
|
||||
extension GetItInjectableX on _i1.GetIt {
|
||||
// initializes the registration of main-scope dependencies inside of GetIt
|
||||
Future<_i1.GetIt> init({
|
||||
String? environment,
|
||||
_i2.EnvironmentFilter? environmentFilter,
|
||||
}) async {
|
||||
final gh = _i2.GetItHelper(
|
||||
this,
|
||||
environment,
|
||||
environmentFilter,
|
||||
);
|
||||
gh.singleton<_i3.SecureTokenStorage>(() => _i3.SecureTokenStorage());
|
||||
gh.singleton<_i8.DioClient>(() => _i8.DioClient());
|
||||
gh.singleton<_i5.AuthInterceptor>(() => _i5.AuthInterceptor(gh<_i3.SecureTokenStorage>()));
|
||||
gh.singleton<_i4.AuthApiService>(() => _i4.AuthApiService(gh<_i8.DioClient>()));
|
||||
gh.singleton<_i6.AuthService>(() => _i6.AuthService(
|
||||
gh<_i3.SecureTokenStorage>(),
|
||||
gh<_i4.AuthApiService>(),
|
||||
gh<_i5.AuthInterceptor>(),
|
||||
gh<_i8.DioClient>(),
|
||||
));
|
||||
gh.singleton<_i7.AuthBloc>(() => _i7.AuthBloc(gh<_i6.AuthService>()));
|
||||
return this;
|
||||
}
|
||||
}
|
||||
19
unionflow-mobile-apps/lib/core/di/injection.dart
Normal file
19
unionflow-mobile-apps/lib/core/di/injection.dart
Normal file
@@ -0,0 +1,19 @@
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:injectable/injectable.dart';
|
||||
|
||||
import 'injection.config.dart';
|
||||
|
||||
/// Instance globale de GetIt pour l'injection de dépendances
|
||||
final GetIt getIt = GetIt.instance;
|
||||
|
||||
/// Configure l'injection de dépendances
|
||||
@InjectableInit()
|
||||
Future<void> configureDependencies() async {
|
||||
await getIt.init();
|
||||
}
|
||||
|
||||
/// Réinitialise les dépendances (utile pour les tests)
|
||||
Future<void> resetDependencies() async {
|
||||
await getIt.reset();
|
||||
await configureDependencies();
|
||||
}
|
||||
122
unionflow-mobile-apps/lib/core/errors/failures.dart
Normal file
122
unionflow-mobile-apps/lib/core/errors/failures.dart
Normal file
@@ -0,0 +1,122 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
abstract class Failure extends Equatable {
|
||||
const Failure({required this.message, this.code});
|
||||
|
||||
final String message;
|
||||
final String? code;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [message, code];
|
||||
}
|
||||
|
||||
class ServerFailure extends Failure {
|
||||
const ServerFailure({
|
||||
required super.message,
|
||||
super.code,
|
||||
this.statusCode,
|
||||
});
|
||||
|
||||
final int? statusCode;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [message, code, statusCode];
|
||||
}
|
||||
|
||||
class NetworkFailure extends Failure {
|
||||
const NetworkFailure({
|
||||
required super.message,
|
||||
super.code = 'NETWORK_ERROR',
|
||||
});
|
||||
}
|
||||
|
||||
class AuthFailure extends Failure {
|
||||
const AuthFailure({
|
||||
required super.message,
|
||||
super.code = 'AUTH_ERROR',
|
||||
});
|
||||
}
|
||||
|
||||
class ValidationFailure extends Failure {
|
||||
const ValidationFailure({
|
||||
required super.message,
|
||||
super.code = 'VALIDATION_ERROR',
|
||||
this.field,
|
||||
});
|
||||
|
||||
final String? field;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [message, code, field];
|
||||
}
|
||||
|
||||
class CacheFailure extends Failure {
|
||||
const CacheFailure({
|
||||
required super.message,
|
||||
super.code = 'CACHE_ERROR',
|
||||
});
|
||||
}
|
||||
|
||||
class UnknownFailure extends Failure {
|
||||
const UnknownFailure({
|
||||
required super.message,
|
||||
super.code = 'UNKNOWN_ERROR',
|
||||
});
|
||||
}
|
||||
|
||||
// Extension pour convertir les exceptions en failures
|
||||
extension ExceptionToFailure on Exception {
|
||||
Failure toFailure() {
|
||||
if (this is NetworkException) {
|
||||
final ex = this as NetworkException;
|
||||
return NetworkFailure(message: ex.message);
|
||||
} else if (this is ServerException) {
|
||||
final ex = this as ServerException;
|
||||
return ServerFailure(
|
||||
message: ex.message,
|
||||
statusCode: ex.statusCode,
|
||||
);
|
||||
} else if (this is AuthException) {
|
||||
final ex = this as AuthException;
|
||||
return AuthFailure(message: ex.message);
|
||||
} else if (this is ValidationException) {
|
||||
final ex = this as ValidationException;
|
||||
return ValidationFailure(
|
||||
message: ex.message,
|
||||
field: ex.field,
|
||||
);
|
||||
} else if (this is CacheException) {
|
||||
final ex = this as CacheException;
|
||||
return CacheFailure(message: ex.message);
|
||||
}
|
||||
return UnknownFailure(message: toString());
|
||||
}
|
||||
}
|
||||
|
||||
// Exceptions personnalisées
|
||||
class NetworkException implements Exception {
|
||||
const NetworkException(this.message);
|
||||
final String message;
|
||||
}
|
||||
|
||||
class ServerException implements Exception {
|
||||
const ServerException(this.message, {this.statusCode});
|
||||
final String message;
|
||||
final int? statusCode;
|
||||
}
|
||||
|
||||
class AuthException implements Exception {
|
||||
const AuthException(this.message);
|
||||
final String message;
|
||||
}
|
||||
|
||||
class ValidationException implements Exception {
|
||||
const ValidationException(this.message, {this.field});
|
||||
final String message;
|
||||
final String? field;
|
||||
}
|
||||
|
||||
class CacheException implements Exception {
|
||||
const CacheException(this.message);
|
||||
final String message;
|
||||
}
|
||||
167
unionflow-mobile-apps/lib/core/network/auth_interceptor.dart
Normal file
167
unionflow-mobile-apps/lib/core/network/auth_interceptor.dart
Normal file
@@ -0,0 +1,167 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:injectable/injectable.dart';
|
||||
import '../auth/storage/secure_token_storage.dart';
|
||||
|
||||
/// Interceptor pour gérer l'authentification automatique
|
||||
@singleton
|
||||
class AuthInterceptor extends Interceptor {
|
||||
final SecureTokenStorage _tokenStorage;
|
||||
|
||||
// Callback pour déclencher le refresh token
|
||||
void Function()? onTokenRefreshNeeded;
|
||||
|
||||
// Callback pour déconnecter l'utilisateur
|
||||
void Function()? onAuthenticationFailed;
|
||||
|
||||
AuthInterceptor(this._tokenStorage);
|
||||
|
||||
@override
|
||||
void onRequest(RequestOptions options, RequestInterceptorHandler handler) async {
|
||||
// Ignorer l'authentification pour certaines routes
|
||||
if (_shouldSkipAuth(options)) {
|
||||
handler.next(options);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Récupérer le token d'accès
|
||||
final accessToken = await _tokenStorage.getAccessToken();
|
||||
|
||||
if (accessToken != null) {
|
||||
// Vérifier si le token expire bientôt
|
||||
final isExpiringSoon = await _tokenStorage.isAccessTokenExpiringSoon();
|
||||
|
||||
if (isExpiringSoon) {
|
||||
// Déclencher le refresh token si nécessaire
|
||||
onTokenRefreshNeeded?.call();
|
||||
}
|
||||
|
||||
// Ajouter le token à l'en-tête Authorization
|
||||
options.headers['Authorization'] = 'Bearer $accessToken';
|
||||
}
|
||||
|
||||
handler.next(options);
|
||||
} catch (e) {
|
||||
// En cas d'erreur, continuer sans token
|
||||
print('Erreur lors de la récupération du token: $e');
|
||||
handler.next(options);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void onResponse(Response response, ResponseInterceptorHandler handler) {
|
||||
// Traitement des réponses réussies
|
||||
handler.next(response);
|
||||
}
|
||||
|
||||
@override
|
||||
void onError(DioException err, ErrorInterceptorHandler handler) async {
|
||||
// Gestion des erreurs d'authentification
|
||||
if (err.response?.statusCode == 401) {
|
||||
await _handle401Error(err, handler);
|
||||
} else if (err.response?.statusCode == 403) {
|
||||
await _handle403Error(err, handler);
|
||||
} else {
|
||||
handler.next(err);
|
||||
}
|
||||
}
|
||||
|
||||
/// Gère les erreurs 401 (Non autorisé)
|
||||
Future<void> _handle401Error(DioException err, ErrorInterceptorHandler handler) async {
|
||||
try {
|
||||
// Vérifier si on a un refresh token valide
|
||||
final refreshToken = await _tokenStorage.getRefreshToken();
|
||||
final refreshExpiresAt = await _tokenStorage.getRefreshTokenExpirationDate();
|
||||
|
||||
if (refreshToken != null &&
|
||||
refreshExpiresAt != null &&
|
||||
DateTime.now().isBefore(refreshExpiresAt)) {
|
||||
|
||||
// Tentative de refresh du token
|
||||
onTokenRefreshNeeded?.call();
|
||||
|
||||
// Attendre un peu pour laisser le temps au refresh
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
// Retry de la requête originale avec le nouveau token
|
||||
final newAccessToken = await _tokenStorage.getAccessToken();
|
||||
if (newAccessToken != null) {
|
||||
final newRequest = await _retryRequest(err.requestOptions, newAccessToken);
|
||||
handler.resolve(newRequest);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Si le refresh n'est pas possible ou a échoué, déconnecter l'utilisateur
|
||||
await _tokenStorage.clearAuthData();
|
||||
onAuthenticationFailed?.call();
|
||||
|
||||
} catch (e) {
|
||||
print('Erreur lors de la gestion de l\'erreur 401: $e');
|
||||
await _tokenStorage.clearAuthData();
|
||||
onAuthenticationFailed?.call();
|
||||
}
|
||||
|
||||
handler.next(err);
|
||||
}
|
||||
|
||||
/// Gère les erreurs 403 (Interdit)
|
||||
Future<void> _handle403Error(DioException err, ErrorInterceptorHandler handler) async {
|
||||
// L'utilisateur n'a pas les permissions suffisantes
|
||||
// On peut logger cela ou rediriger vers une page d'erreur
|
||||
print('Accès interdit (403) pour: ${err.requestOptions.path}');
|
||||
handler.next(err);
|
||||
}
|
||||
|
||||
/// Retry une requête avec un nouveau token
|
||||
Future<Response> _retryRequest(RequestOptions options, String newAccessToken) async {
|
||||
final dio = Dio();
|
||||
|
||||
// Copier les options originales
|
||||
final newOptions = Options(
|
||||
method: options.method,
|
||||
headers: {
|
||||
...options.headers,
|
||||
'Authorization': 'Bearer $newAccessToken',
|
||||
},
|
||||
extra: {'skipAuth': true}, // Éviter la récursion infinie
|
||||
);
|
||||
|
||||
// Effectuer la nouvelle requête
|
||||
return await dio.request(
|
||||
options.path,
|
||||
data: options.data,
|
||||
queryParameters: options.queryParameters,
|
||||
options: newOptions,
|
||||
);
|
||||
}
|
||||
|
||||
/// Détermine si l'authentification doit être ignorée pour une requête
|
||||
bool _shouldSkipAuth(RequestOptions options) {
|
||||
// Ignorer l'auth pour les routes publiques
|
||||
final publicPaths = [
|
||||
'/api/auth/login',
|
||||
'/api/auth/refresh',
|
||||
'/api/auth/info',
|
||||
'/api/auth/register',
|
||||
'/api/health',
|
||||
];
|
||||
|
||||
// Vérifier si le path est dans la liste des routes publiques
|
||||
final isPublicPath = publicPaths.any((path) => options.path.contains(path));
|
||||
|
||||
// Vérifier si l'option skipAuth est activée
|
||||
final skipAuth = options.extra['skipAuth'] == true;
|
||||
|
||||
return isPublicPath || skipAuth;
|
||||
}
|
||||
|
||||
/// Configuration des callbacks
|
||||
void setCallbacks({
|
||||
void Function()? onTokenRefreshNeeded,
|
||||
void Function()? onAuthenticationFailed,
|
||||
}) {
|
||||
this.onTokenRefreshNeeded = onTokenRefreshNeeded;
|
||||
this.onAuthenticationFailed = onAuthenticationFailed;
|
||||
}
|
||||
}
|
||||
113
unionflow-mobile-apps/lib/core/network/dio_client.dart
Normal file
113
unionflow-mobile-apps/lib/core/network/dio_client.dart
Normal file
@@ -0,0 +1,113 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:injectable/injectable.dart';
|
||||
import 'package:pretty_dio_logger/pretty_dio_logger.dart';
|
||||
import 'auth_interceptor.dart';
|
||||
|
||||
/// Configuration centralisée du client HTTP Dio
|
||||
@singleton
|
||||
class DioClient {
|
||||
late final Dio _dio;
|
||||
|
||||
DioClient() {
|
||||
_dio = Dio();
|
||||
_setupInterceptors();
|
||||
_configureOptions();
|
||||
}
|
||||
|
||||
Dio get dio => _dio;
|
||||
|
||||
void _configureOptions() {
|
||||
_dio.options = BaseOptions(
|
||||
// URL de base de l'API
|
||||
baseUrl: 'http://localhost:8081', // Adresse de votre API Quarkus
|
||||
|
||||
// Timeouts
|
||||
connectTimeout: const Duration(seconds: 30),
|
||||
receiveTimeout: const Duration(seconds: 30),
|
||||
sendTimeout: const Duration(seconds: 30),
|
||||
|
||||
// Headers par défaut
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'User-Agent': 'UnionFlow-Mobile/1.0.0',
|
||||
},
|
||||
|
||||
// Validation des codes de statut
|
||||
validateStatus: (status) {
|
||||
return status != null && status < 500;
|
||||
},
|
||||
|
||||
// Suivre les redirections
|
||||
followRedirects: true,
|
||||
maxRedirects: 3,
|
||||
|
||||
// Politique de persistance des cookies
|
||||
persistentConnection: true,
|
||||
|
||||
// Format de réponse par défaut
|
||||
responseType: ResponseType.json,
|
||||
);
|
||||
}
|
||||
|
||||
void _setupInterceptors() {
|
||||
// Interceptor de logging (seulement en debug)
|
||||
_dio.interceptors.add(
|
||||
PrettyDioLogger(
|
||||
requestHeader: true,
|
||||
requestBody: true,
|
||||
responseBody: true,
|
||||
responseHeader: false,
|
||||
error: true,
|
||||
compact: true,
|
||||
maxWidth: 90,
|
||||
filter: (options, args) {
|
||||
// Ne pas logger les mots de passe
|
||||
if (options.path.contains('/auth/login')) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Interceptor d'authentification (sera injecté plus tard)
|
||||
// Il sera ajouté dans AuthService pour éviter les dépendances circulaires
|
||||
}
|
||||
|
||||
/// Ajoute l'interceptor d'authentification
|
||||
void addAuthInterceptor(AuthInterceptor authInterceptor) {
|
||||
_dio.interceptors.add(authInterceptor);
|
||||
}
|
||||
|
||||
/// Configure l'URL de base
|
||||
void setBaseUrl(String baseUrl) {
|
||||
_dio.options.baseUrl = baseUrl;
|
||||
}
|
||||
|
||||
/// Ajoute un header global
|
||||
void addHeader(String key, String value) {
|
||||
_dio.options.headers[key] = value;
|
||||
}
|
||||
|
||||
/// Supprime un header global
|
||||
void removeHeader(String key) {
|
||||
_dio.options.headers.remove(key);
|
||||
}
|
||||
|
||||
/// Configure les timeouts
|
||||
void setTimeout({
|
||||
Duration? connect,
|
||||
Duration? receive,
|
||||
Duration? send,
|
||||
}) {
|
||||
if (connect != null) _dio.options.connectTimeout = connect;
|
||||
if (receive != null) _dio.options.receiveTimeout = receive;
|
||||
if (send != null) _dio.options.sendTimeout = send;
|
||||
}
|
||||
|
||||
/// Nettoie et ferme le client
|
||||
void dispose() {
|
||||
_dio.close();
|
||||
}
|
||||
}
|
||||
111
unionflow-mobile-apps/lib/core/utils/responsive_utils.dart
Normal file
111
unionflow-mobile-apps/lib/core/utils/responsive_utils.dart
Normal file
@@ -0,0 +1,111 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Utilitaires pour rendre l'app responsive
|
||||
class ResponsiveUtils {
|
||||
static late MediaQueryData _mediaQueryData;
|
||||
static late double screenWidth;
|
||||
static late double screenHeight;
|
||||
static late double blockSizeHorizontal;
|
||||
static late double blockSizeVertical;
|
||||
static late double safeAreaHorizontal;
|
||||
static late double safeAreaVertical;
|
||||
static late double safeBlockHorizontal;
|
||||
static late double safeBlockVertical;
|
||||
static late double textScaleFactor;
|
||||
|
||||
static void init(BuildContext context) {
|
||||
_mediaQueryData = MediaQuery.of(context);
|
||||
screenWidth = _mediaQueryData.size.width;
|
||||
screenHeight = _mediaQueryData.size.height;
|
||||
|
||||
blockSizeHorizontal = screenWidth / 100;
|
||||
blockSizeVertical = screenHeight / 100;
|
||||
|
||||
final safeAreaPadding = _mediaQueryData.padding;
|
||||
safeAreaHorizontal = screenWidth - safeAreaPadding.left - safeAreaPadding.right;
|
||||
safeAreaVertical = screenHeight - safeAreaPadding.top - safeAreaPadding.bottom;
|
||||
|
||||
safeBlockHorizontal = safeAreaHorizontal / 100;
|
||||
safeBlockVertical = safeAreaVertical / 100;
|
||||
|
||||
textScaleFactor = _mediaQueryData.textScaleFactor;
|
||||
}
|
||||
|
||||
// Responsive width
|
||||
static double wp(double percentage) => blockSizeHorizontal * percentage;
|
||||
|
||||
// Responsive height
|
||||
static double hp(double percentage) => blockSizeVertical * percentage;
|
||||
|
||||
// Responsive font size (basé sur la largeur)
|
||||
static double fs(double percentage) => safeBlockHorizontal * percentage;
|
||||
|
||||
// Responsive spacing
|
||||
static double sp(double percentage) => safeBlockHorizontal * percentage;
|
||||
|
||||
// Responsive padding/margin
|
||||
static EdgeInsets paddingAll(double percentage) =>
|
||||
EdgeInsets.all(sp(percentage));
|
||||
|
||||
static EdgeInsets paddingSymmetric({double? horizontal, double? vertical}) =>
|
||||
EdgeInsets.symmetric(
|
||||
horizontal: horizontal != null ? sp(horizontal) : 0,
|
||||
vertical: vertical != null ? hp(vertical) : 0,
|
||||
);
|
||||
|
||||
static EdgeInsets paddingOnly({
|
||||
double? left,
|
||||
double? top,
|
||||
double? right,
|
||||
double? bottom,
|
||||
}) =>
|
||||
EdgeInsets.only(
|
||||
left: left != null ? sp(left) : 0,
|
||||
top: top != null ? hp(top) : 0,
|
||||
right: right != null ? sp(right) : 0,
|
||||
bottom: bottom != null ? hp(bottom) : 0,
|
||||
);
|
||||
|
||||
// Adaptive values based on screen size
|
||||
static double adaptive({
|
||||
required double small, // < 600px (phones)
|
||||
required double medium, // 600-900px (tablets)
|
||||
required double large, // > 900px (desktop)
|
||||
}) {
|
||||
if (screenWidth < 600) return small;
|
||||
if (screenWidth < 900) return medium;
|
||||
return large;
|
||||
}
|
||||
|
||||
// Check device type
|
||||
static bool get isMobile => screenWidth < 600;
|
||||
static bool get isTablet => screenWidth >= 600 && screenWidth < 900;
|
||||
static bool get isDesktop => screenWidth >= 900;
|
||||
|
||||
// Responsive border radius
|
||||
static BorderRadius borderRadius(double percentage) =>
|
||||
BorderRadius.circular(sp(percentage));
|
||||
|
||||
// Responsive icon size
|
||||
static double iconSize(double percentage) =>
|
||||
adaptive(
|
||||
small: sp(percentage),
|
||||
medium: sp(percentage * 0.9),
|
||||
large: sp(percentage * 0.8),
|
||||
);
|
||||
}
|
||||
|
||||
// Extension pour faciliter l'utilisation
|
||||
extension ResponsiveExtension on num {
|
||||
// Width percentage
|
||||
double get wp => ResponsiveUtils.wp(toDouble());
|
||||
|
||||
// Height percentage
|
||||
double get hp => ResponsiveUtils.hp(toDouble());
|
||||
|
||||
// Font size
|
||||
double get fs => ResponsiveUtils.fs(toDouble());
|
||||
|
||||
// Spacing
|
||||
double get sp => ResponsiveUtils.sp(toDouble());
|
||||
}
|
||||
Reference in New Issue
Block a user