petitspas/frontend/lib/services/auth_service.dart
Julien Martin d550e57cb8 fix(inscription-am): étape 1/2 obligatoires alignées API (min 2 car., photo)
- Étape 1 : prénom/nom min 2 caractères (minPersonNameLength sur PersonalInfoFormScreen pour AM)

- Étape 2 : ville/pays naissance min 2 ; photo réelle + consentement obligatoires ; isStep2Complete strict

- registerAM : lieu naissance trim + toujours envoyer photo_base64 ; lieu_* non null si valide

- Étape 4 : blocage envoi si isRegistrationComplete faux

Made-with: Cursor
2026-04-13 13:32:25 +02:00

406 lines
14 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
throw Exception('Erreur réseau: impossible de se connecter au serveur');
}
}
/// 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 linscription.');
}
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 lenvoi (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 lenvoi (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 à linscription.
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;
}
}
}