Frontend (prod web): - Image builder Flutter 3.29.3 (Dockerfile + workflow CI alignés) - web/index.html: bootstrap Flutter 3.29 (flutter_bootstrap.js), scripts pdf.js conservés - Dockerfile: rm html avant COPY pour éviter l’index « Welcome to nginx » - nginx: server_name _ pour Traefik - env: sur le web, API = Uri.base.origin si pas de API_BASE_URL (évite mixed content http/https) - auth: message d’erreur réseau avec type/détail si non-Exception - CGU/PDF: commentaire sans mention Flutter 3.19 obsolète - pubspec.lock: résolution dépendances Flutter 3.29 Base de données & doc: - BDD.sql: validations.commentaire, valide_par, FK ON DELETE SET NULL + commentaire - patch 2026-04-17: valide_par avec ON DELETE SET NULL - patch 2026-04-18: FK validations en SET NULL sur bases existantes - seed: statut validation « valide » (enum) - FK_POLICIES.md: valide_par - doc workflow: Flutter 3.29.3 Made-with: Cursor
409 lines
14 KiB
Dart
409 lines
14 KiB
Dart
import 'dart:convert';
|
||
import 'dart:io';
|
||
import 'dart:typed_data';
|
||
|
||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||
import 'package:http/http.dart' as http;
|
||
import 'package:shared_preferences/shared_preferences.dart';
|
||
import '../models/user.dart';
|
||
import '../models/am_registration_data.dart';
|
||
import '../models/user_registration_data.dart';
|
||
import '../utils/parent_registration_payload.dart';
|
||
import 'api/api_config.dart';
|
||
import 'api/tokenService.dart';
|
||
import '../utils/nir_utils.dart';
|
||
|
||
class AuthService {
|
||
static const String _currentUserKey = 'current_user';
|
||
|
||
static String? _basenameFromPath(String path) {
|
||
final s = path.replaceAll(r'\', '/');
|
||
final i = s.lastIndexOf('/');
|
||
final name = i >= 0 ? s.substring(i + 1) : s;
|
||
return name.isEmpty ? null : name;
|
||
}
|
||
|
||
static String _imageMimeForBytes(Uint8List bytes) {
|
||
if (bytes.length >= 8 &&
|
||
bytes[0] == 0x89 &&
|
||
bytes[1] == 0x50 &&
|
||
bytes[2] == 0x4E &&
|
||
bytes[3] == 0x47) {
|
||
return 'image/png';
|
||
}
|
||
return 'image/jpeg';
|
||
}
|
||
|
||
/// Connexion de l'utilisateur
|
||
/// Retourne l'utilisateur connecté avec le flag changement_mdp_obligatoire
|
||
static Future<AppUser> login(String email, String password) async {
|
||
try {
|
||
final response = await http.post(
|
||
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.login}'),
|
||
headers: ApiConfig.headers,
|
||
body: jsonEncode({
|
||
'email': email,
|
||
'password': password,
|
||
}),
|
||
);
|
||
|
||
if (response.statusCode == 200 || response.statusCode == 201) {
|
||
final data = jsonDecode(response.body);
|
||
// API renvoie access_token / refresh_token (snake_case)
|
||
final accessToken = data['access_token'] as String? ?? data['accessToken'] as String?;
|
||
final refreshToken = data['refresh_token'] as String? ?? data['refreshToken'] as String?;
|
||
if (accessToken == null) throw Exception('Token absent dans la réponse serveur');
|
||
|
||
await TokenService.saveToken(accessToken);
|
||
await TokenService.saveRefreshToken(refreshToken ?? '');
|
||
|
||
final user = await _fetchUserProfile(accessToken);
|
||
|
||
// Stocker l'utilisateur en cache
|
||
await _saveCurrentUser(user);
|
||
|
||
return user;
|
||
} else {
|
||
final error = jsonDecode(response.body);
|
||
throw Exception(error['message'] ?? 'Erreur de connexion');
|
||
}
|
||
} catch (e) {
|
||
if (e is Exception) rethrow;
|
||
// Erreurs non-Exception (ex. certains cas côté web) : afficher le détail pour le diagnostic
|
||
throw Exception(
|
||
'Erreur réseau: impossible de se connecter au serveur (${e.runtimeType}: $e)',
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Récupère le profil utilisateur via /auth/me
|
||
static Future<AppUser> _fetchUserProfile(String token) async {
|
||
try {
|
||
final response = await http.get(
|
||
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.authMe}'),
|
||
headers: ApiConfig.authHeaders(token),
|
||
);
|
||
|
||
if (response.statusCode == 200) {
|
||
final data = jsonDecode(response.body);
|
||
return AppUser.fromJson(data);
|
||
} else {
|
||
throw Exception('Erreur lors de la récupération du profil');
|
||
}
|
||
} catch (e) {
|
||
if (e is Exception) rethrow;
|
||
throw Exception('Erreur réseau: impossible de récupérer le profil');
|
||
}
|
||
}
|
||
|
||
/// Changement de mot de passe obligatoire
|
||
static Future<void> changePasswordRequired({
|
||
required String currentPassword,
|
||
required String newPassword,
|
||
}) async {
|
||
final token = await TokenService.getToken();
|
||
if (token == null) {
|
||
throw Exception('Non authentifié');
|
||
}
|
||
|
||
try {
|
||
final response = await http.post(
|
||
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.changePasswordRequired}'),
|
||
headers: ApiConfig.authHeaders(token),
|
||
body: jsonEncode({
|
||
'mot_de_passe_actuel': currentPassword,
|
||
'nouveau_mot_de_passe': newPassword,
|
||
'confirmation_mot_de_passe': newPassword,
|
||
}),
|
||
);
|
||
|
||
if (response.statusCode != 200 && response.statusCode != 201) {
|
||
final error = jsonDecode(response.body);
|
||
throw Exception(error['message'] ?? 'Erreur lors du changement de mot de passe');
|
||
}
|
||
|
||
// Après le changement de MDP, rafraîchir le profil utilisateur
|
||
final user = await _fetchUserProfile(token);
|
||
await _saveCurrentUser(user);
|
||
} catch (e) {
|
||
if (e is Exception) rethrow;
|
||
throw Exception('Erreur réseau: impossible de changer le mot de passe');
|
||
}
|
||
}
|
||
|
||
/// Déconnexion de l'utilisateur
|
||
static Future<void> logout() async {
|
||
await TokenService.clearAll();
|
||
final prefs = await SharedPreferences.getInstance();
|
||
await prefs.remove(_currentUserKey);
|
||
}
|
||
|
||
/// Vérifie si l'utilisateur est connecté
|
||
static Future<bool> isLoggedIn() async {
|
||
final token = await TokenService.getToken();
|
||
return token != null;
|
||
}
|
||
|
||
/// Récupère l'utilisateur connecté depuis le cache
|
||
static Future<AppUser?> getCurrentUser() async {
|
||
final prefs = await SharedPreferences.getInstance();
|
||
final userJson = prefs.getString(_currentUserKey);
|
||
|
||
if (userJson != null) {
|
||
return AppUser.fromJson(jsonDecode(userJson));
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/// Sauvegarde l'utilisateur actuel en cache
|
||
static Future<void> _saveCurrentUser(AppUser user) async {
|
||
final prefs = await SharedPreferences.getInstance();
|
||
await prefs.setString(_currentUserKey, jsonEncode(user.toJson()));
|
||
}
|
||
|
||
/// Inscription AM complète (POST /auth/register/am).
|
||
/// En cas de succès (201), aucune donnée utilisateur retournée ; rediriger vers login.
|
||
static Future<void> registerAM(AmRegistrationData data) async {
|
||
if (data.agreementDate == null) {
|
||
throw Exception('La date d\'obtention de l\'agrément est requise.');
|
||
}
|
||
if (data.placesAvailable == null) {
|
||
throw Exception('Le nombre de places disponibles est requis.');
|
||
}
|
||
final lieuVille = data.birthCity.trim();
|
||
final lieuPays = data.birthCountry.trim();
|
||
if (lieuVille.length < 2 || lieuPays.length < 2) {
|
||
throw Exception(
|
||
'La ville et le pays de naissance sont requis (au moins 2 caractères chacun).',
|
||
);
|
||
}
|
||
String? photoBase64;
|
||
String? photoFilename;
|
||
|
||
if (data.photoBytes != null && data.photoBytes!.isNotEmpty) {
|
||
final mime = _imageMimeForBytes(data.photoBytes!);
|
||
photoBase64 = 'data:$mime;base64,${base64Encode(data.photoBytes!)}';
|
||
final fn = (data.photoFilename ?? '').trim();
|
||
photoFilename = fn.isNotEmpty ? fn : 'photo_am.jpg';
|
||
} else if (data.photoPath != null &&
|
||
data.photoPath!.isNotEmpty &&
|
||
!data.photoPath!.startsWith('assets/')) {
|
||
if (!kIsWeb) {
|
||
try {
|
||
final file = File(data.photoPath!);
|
||
if (await file.exists()) {
|
||
final bytes = await file.readAsBytes();
|
||
final mime = _imageMimeForBytes(bytes);
|
||
photoBase64 = 'data:$mime;base64,${base64Encode(bytes)}';
|
||
photoFilename =
|
||
_basenameFromPath(data.photoPath!) ?? 'photo_am.jpg';
|
||
}
|
||
} catch (_) {}
|
||
}
|
||
}
|
||
|
||
if (photoBase64 == null || photoBase64.isEmpty) {
|
||
throw Exception('Une photo de profil est requise pour finaliser l’inscription.');
|
||
}
|
||
|
||
final body = <String, dynamic>{
|
||
'email': data.email,
|
||
'prenom': data.firstName,
|
||
'nom': data.lastName,
|
||
'telephone': data.phone,
|
||
'adresse': data.streetAddress.isNotEmpty ? data.streetAddress : null,
|
||
'code_postal': data.postalCode.isNotEmpty ? data.postalCode : null,
|
||
'ville': data.city.isNotEmpty ? data.city : null,
|
||
'photo_base64': photoBase64,
|
||
'photo_filename': photoFilename ?? 'photo_am.jpg',
|
||
'consentement_photo': data.photoConsent,
|
||
'date_naissance': data.dateOfBirth != null
|
||
? '${data.dateOfBirth!.year}-${data.dateOfBirth!.month.toString().padLeft(2, '0')}-${data.dateOfBirth!.day.toString().padLeft(2, '0')}'
|
||
: null,
|
||
'lieu_naissance_ville': lieuVille,
|
||
'lieu_naissance_pays': lieuPays,
|
||
'nir': normalizeNir(data.nir),
|
||
'numero_agrement': data.agrementNumber,
|
||
'date_agrement':
|
||
'${data.agreementDate!.year}-${data.agreementDate!.month.toString().padLeft(2, '0')}-${data.agreementDate!.day.toString().padLeft(2, '0')}',
|
||
'capacite_accueil': data.capacity ?? 1,
|
||
'places_disponibles': data.placesAvailable!,
|
||
'biographie': data.presentationText.isNotEmpty ? data.presentationText : null,
|
||
'acceptation_cgu': data.cguAccepted,
|
||
'acceptation_privacy': data.cguAccepted,
|
||
};
|
||
|
||
final String encodedBody;
|
||
try {
|
||
encodedBody = jsonEncode(body);
|
||
} catch (_) {
|
||
throw Exception(
|
||
'Impossible de préparer l’envoi (données trop volumineuses ou invalides). '
|
||
'Réessayez avec une photo plus légère.',
|
||
);
|
||
}
|
||
|
||
late final http.Response response;
|
||
try {
|
||
response = await http.post(
|
||
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.registerAM}'),
|
||
headers: ApiConfig.headers,
|
||
body: encodedBody,
|
||
);
|
||
} on http.ClientException {
|
||
throw Exception(
|
||
'Connexion à ${ApiConfig.baseUrl} impossible. Vérifiez votre réseau, '
|
||
'un pare-feu ou un bloqueur, puis réessayez.',
|
||
);
|
||
}
|
||
|
||
if (response.statusCode == 200 || response.statusCode == 201) {
|
||
return;
|
||
}
|
||
|
||
final decoded = _tryDecodeJsonMap(response.body);
|
||
final message = _extractErrorMessage(decoded, response.statusCode);
|
||
throw Exception(message);
|
||
}
|
||
|
||
/// Inscription parent complète (POST /auth/register/parent).
|
||
/// Succès : 201, pas de session — rediriger vers le login.
|
||
static Future<void> registerParent(UserRegistrationData data) async {
|
||
final validationError = ParentRegistrationPayload.validateForApi(data);
|
||
if (validationError != null) {
|
||
throw Exception(validationError);
|
||
}
|
||
|
||
final body = ParentRegistrationPayload.toJson(data);
|
||
final String encodedBody;
|
||
try {
|
||
encodedBody = jsonEncode(body);
|
||
} catch (_) {
|
||
throw Exception(
|
||
'Impossible de préparer l’envoi (données trop volumineuses ou invalides). '
|
||
'Réessayez avec une photo plus légère ou sans caractères inhabituels.',
|
||
);
|
||
}
|
||
|
||
late final http.Response response;
|
||
try {
|
||
response = await http.post(
|
||
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.registerParent}'),
|
||
headers: ApiConfig.headers,
|
||
body: encodedBody,
|
||
);
|
||
} on http.ClientException {
|
||
throw Exception(
|
||
'Connexion à ${ApiConfig.baseUrl} impossible. Vérifiez votre réseau, '
|
||
'un pare-feu ou un bloqueur, puis réessayez.',
|
||
);
|
||
}
|
||
|
||
if (response.statusCode == 200 || response.statusCode == 201) {
|
||
return;
|
||
}
|
||
|
||
final decoded = _tryDecodeJsonMap(response.body);
|
||
var message = _extractErrorMessage(decoded, response.statusCode);
|
||
message = _humanizeParentRegistrationHttpError(message, response.statusCode);
|
||
throw Exception(message);
|
||
}
|
||
|
||
static Map<String, dynamic>? _tryDecodeJsonMap(String body) {
|
||
if (body.isEmpty) return null;
|
||
try {
|
||
final decoded = jsonDecode(body);
|
||
if (decoded is Map) {
|
||
return Map<String, dynamic>.from(decoded);
|
||
}
|
||
return null;
|
||
} catch (_) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/// Aplatit `message` (string, liste, ou objet Nest / AllExceptionsFilter).
|
||
static String? _flattenApiMessageField(dynamic field) {
|
||
if (field == null) return null;
|
||
if (field is String) {
|
||
final t = field.trim();
|
||
return t.isEmpty ? null : t;
|
||
}
|
||
if (field is List) {
|
||
final parts = field
|
||
.map(_flattenApiMessageField)
|
||
.whereType<String>()
|
||
.where((s) => s.isNotEmpty)
|
||
.toList();
|
||
if (parts.isEmpty) return null;
|
||
return parts.join('. ');
|
||
}
|
||
if (field is Map) {
|
||
if (field['message'] != null) {
|
||
final inner = _flattenApiMessageField(field['message']);
|
||
if (inner != null) return inner;
|
||
}
|
||
final err = field['error'];
|
||
if (err is String && err.trim().isNotEmpty) return err.trim();
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/// Extrait le message d'erreur des réponses NestJS (message string, array, ou objet).
|
||
static String _extractErrorMessage(dynamic decoded, int statusCode) {
|
||
const fallback = 'Erreur lors de l\'inscription';
|
||
if (decoded == null || decoded is! Map) {
|
||
return '$fallback ($statusCode)';
|
||
}
|
||
final map = decoded;
|
||
final fromMessage = _flattenApiMessageField(map['message']);
|
||
if (fromMessage != null) return fromMessage;
|
||
final err = map['error'];
|
||
if (err is String && err.trim().isNotEmpty) return err.trim();
|
||
return '$fallback ($statusCode)';
|
||
}
|
||
|
||
/// Remplace « Internal server error » (souvent contrainte SQL / 500) par un texte utile à l’inscription.
|
||
static String _humanizeParentRegistrationHttpError(String msg, int statusCode) {
|
||
if (statusCode == 409) return msg;
|
||
|
||
final lower = msg.toLowerCase().trim();
|
||
final looksLikeGenericServerError = lower == 'internal server error' ||
|
||
lower == 'internal server error.' ||
|
||
lower.contains('internal server error');
|
||
|
||
if (statusCode >= 500 && looksLikeGenericServerError) {
|
||
return 'Impossible d\'enregistrer votre demande pour le moment. '
|
||
'Souvent, cela signifie que cette adresse e-mail est déjà utilisée : '
|
||
'connectez-vous ou utilisez une autre adresse pour le parent principal '
|
||
'(et pour le co-parent si vous en indiquez un). '
|
||
'Si le problème continue, réessayez plus tard ou contactez le support.';
|
||
}
|
||
|
||
if (statusCode >= 500) {
|
||
if (lower.contains('duplicate') ||
|
||
lower.contains('unique constraint') ||
|
||
lower.contains('23505') ||
|
||
lower.contains('already exist')) {
|
||
return 'Cette adresse e-mail semble déjà enregistrée. '
|
||
'Essayez de vous connecter ou modifiez l\'adresse du parent ou du co-parent.';
|
||
}
|
||
}
|
||
return msg;
|
||
}
|
||
|
||
/// Rafraîchit le profil utilisateur depuis l'API
|
||
static Future<AppUser?> refreshCurrentUser() async {
|
||
final token = await TokenService.getToken();
|
||
if (token == null) return null;
|
||
|
||
try {
|
||
final user = await _fetchUserProfile(token);
|
||
await _saveCurrentUser(user);
|
||
return user;
|
||
} catch (e) {
|
||
return null;
|
||
}
|
||
}
|
||
} |