petitspas/frontend/lib/services/auth_service.dart
Julien Martin 671da71752 feat(#112): reprise après refus via lien e-mail (/reprise?token=)
Branche le flux front : chargement du dossier, session reprise, wizards
parent/AM préremplis et resoumission via reprise-resoumettre.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-16 16:28:41 +02:00

622 lines
22 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 '../models/reprise_dossier.dart';
import '../utils/parent_registration_payload.dart';
import 'api/api_config.dart';
import 'api/tokenService.dart';
import '../utils/nir_utils.dart';
import '../utils/email_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');
}
}
/// Vérifie qu'un token de création de mot de passe est encore valide.
/// Retourne `true` si l'API renvoie 200 ; `false` si 404.
static Future<bool> verifyCreatePasswordToken(String token) async {
final cleaned = token.trim();
if (cleaned.isEmpty) return false;
try {
final uri = Uri.parse(
'${ApiConfig.baseUrl}${ApiConfig.verifyCreatePasswordToken}?token=${Uri.encodeQueryComponent(cleaned)}',
);
final response = await http.get(uri, headers: ApiConfig.headers);
if (response.statusCode == 200) return true;
if (response.statusCode == 404) return false;
final decoded = _tryDecodeJsonMap(response.body);
throw Exception(_extractErrorMessage(decoded, response.statusCode));
} on http.ClientException {
throw Exception(
'Connexion à ${ApiConfig.baseUrl} impossible. Vérifiez votre réseau puis réessayez.',
);
}
}
/// Crée le mot de passe initial via token email.
static Future<void> createPasswordWithToken({
required String token,
required String password,
required String passwordConfirmation,
}) async {
final cleaned = token.trim();
if (cleaned.isEmpty) {
throw Exception('Lien invalide ou expiré.');
}
late final http.Response response;
try {
response = await http.post(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.createPassword}'),
headers: ApiConfig.headers,
body: jsonEncode({
'token': cleaned,
'password': password,
'password_confirmation': passwordConfirmation,
}),
);
} on http.ClientException {
throw Exception(
'Connexion à ${ApiConfig.baseUrl} impossible. Vérifiez votre réseau puis réessayez.',
);
}
if (response.statusCode == 200 || response.statusCode == 201) {
return;
}
final decoded = _tryDecodeJsonMap(response.body);
throw Exception(_extractErrorMessage(decoded, response.statusCode));
}
/// Demande de réinitialisation du mot de passe (ticket #127).
/// Réponse **toujours neutre** côté UX si le serveur répond (anti-énumération des comptes).
static Future<void> requestForgotPassword(String email) async {
final cleaned = normalizeEmailText(email);
if (cleaned.isEmpty) {
throw Exception('Veuillez saisir votre adresse e-mail.');
}
late final http.Response response;
try {
response = await http.post(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.forgotPassword}'),
headers: ApiConfig.headers,
body: jsonEncode({'email': cleaned}),
);
} on http.ClientException {
throw Exception(
'Connexion à ${ApiConfig.baseUrl} impossible. Vérifiez votre réseau puis réessayez.',
);
}
if (response.statusCode >= 500) {
final decoded = _tryDecodeJsonMap(response.body);
throw Exception(_extractErrorMessage(decoded, response.statusCode));
}
}
/// Réinitialise le mot de passe via le lien e-mail (ticket #127).
static Future<void> resetPasswordWithToken({
required String token,
required String password,
required String passwordConfirmation,
}) async {
final cleaned = token.trim();
if (cleaned.isEmpty) {
throw Exception('Lien invalide ou expiré.');
}
late final http.Response response;
try {
response = await http.post(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.resetPassword}'),
headers: ApiConfig.headers,
body: jsonEncode({
'token': cleaned,
'password': password,
'password_confirmation': passwordConfirmation,
}),
);
} on http.ClientException {
throw Exception(
'Connexion à ${ApiConfig.baseUrl} impossible. Vérifiez votre réseau puis réessayez.',
);
}
if (response.statusCode == 200 || response.statusCode == 201) {
return;
}
if (response.statusCode == 404) {
throw Exception('Lien invalide ou expiré.');
}
final decoded = _tryDecodeJsonMap(response.body);
final msg = _extractErrorMessage(decoded, response.statusCode);
final lower = msg.toLowerCase();
if (response.statusCode == 400 &&
(lower.contains('token') ||
lower.contains('invalide') ||
lower.contains('expiré') ||
lower.contains('expire'))) {
throw Exception('Lien invalide ou expiré.');
}
throw Exception(msg);
}
/// Charge le dossier pour reprise (lien e-mail). GET /auth/reprise-dossier. Ticket #112.
static Future<RepriseDossier> getRepriseDossier(String token) async {
final cleaned = token.trim();
if (cleaned.isEmpty) {
throw Exception('Lien invalide ou expiré.');
}
final uri = Uri.parse(
'${ApiConfig.baseUrl}${ApiConfig.repriseDossier}',
).replace(queryParameters: {'token': cleaned});
late final http.Response response;
try {
response = await http.get(uri, headers: ApiConfig.headers);
} on http.ClientException {
throw Exception(
'Connexion à ${ApiConfig.baseUrl} impossible. Vérifiez votre réseau puis réessayez.',
);
}
if (response.statusCode == 404) {
throw Exception('Lien invalide ou expiré.');
}
if (response.statusCode != 200) {
final decoded = _tryDecodeJsonMap(response.body);
throw Exception(_extractErrorMessage(decoded, response.statusCode));
}
final decoded = jsonDecode(response.body);
if (decoded is! Map<String, dynamic>) {
throw Exception('Réponse invalide du serveur.');
}
return RepriseDossier.fromJson(decoded);
}
/// Resoumission après refus. PATCH /auth/reprise-resoumettre. Ticket #112.
static Future<void> resoumettreReprise({
required String token,
String? prenom,
String? nom,
String? telephone,
String? adresse,
String? ville,
String? codePostal,
String? photoUrl,
}) async {
final cleaned = token.trim();
if (cleaned.isEmpty) {
throw Exception('Lien invalide ou expiré.');
}
final body = <String, dynamic>{'token': cleaned};
if (prenom != null && prenom.trim().isNotEmpty) body['prenom'] = prenom.trim();
if (nom != null && nom.trim().isNotEmpty) body['nom'] = nom.trim();
if (telephone != null && telephone.trim().isNotEmpty) {
body['telephone'] = telephone.trim();
}
if (adresse != null && adresse.trim().isNotEmpty) {
body['adresse'] = adresse.trim();
}
if (ville != null && ville.trim().isNotEmpty) body['ville'] = ville.trim();
if (codePostal != null && codePostal.trim().isNotEmpty) {
body['code_postal'] = codePostal.trim();
}
if (photoUrl != null && photoUrl.trim().isNotEmpty) {
body['photo_url'] = photoUrl.trim();
}
late final http.Response response;
try {
response = await http.patch(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.repriseResoumettre}'),
headers: ApiConfig.headers,
body: jsonEncode(body),
);
} on http.ClientException {
throw Exception(
'Connexion à ${ApiConfig.baseUrl} impossible. Vérifiez votre réseau puis réessayez.',
);
}
if (response.statusCode == 404) {
throw Exception('Lien invalide ou expiré.');
}
if (response.statusCode == 200 || response.statusCode == 201) {
return;
}
final decoded = _tryDecodeJsonMap(response.body);
throw Exception(_extractErrorMessage(decoded, response.statusCode));
}
/// 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;
}
}
}