Merge remote-tracking branch 'origin/master' into master

Résolution conflit: Suppression de frontend/lib/navigation/app_router.dart
(fichier obsolète remplacé par frontend/lib/config/app_router.dart)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
MARTIN Julien 2026-02-03 16:12:29 +01:00
commit acb8e72a7c
7 changed files with 557 additions and 40 deletions

View File

@ -11,7 +11,14 @@ import { ValidationPipe } from '@nestjs/common';
async function bootstrap() { async function bootstrap() {
const app = await NestFactory.create(AppModule, const app = await NestFactory.create(AppModule,
{ logger: ['error', 'warn', 'log', 'debug', 'verbose'] }); { logger: ['error', 'warn', 'log', 'debug', 'verbose'] });
app.enableCors();
// Configuration CORS pour autoriser les requêtes depuis localhost (dev) et production
app.enableCors({
origin: true, // Autorise toutes les origines (dev) - à restreindre en prod
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'Accept'],
credentials: true,
});
app.useGlobalPipes( app.useGlobalPipes(
new ValidationPipe({ new ValidationPipe({

View File

@ -2,7 +2,7 @@ class Env {
// Base URL de l'API, surchargeable à la compilation via --dart-define=API_BASE_URL // Base URL de l'API, surchargeable à la compilation via --dart-define=API_BASE_URL
static const String apiBaseUrl = String.fromEnvironment( static const String apiBaseUrl = String.fromEnvironment(
'API_BASE_URL', 'API_BASE_URL',
defaultValue: 'https://ynov.ptits-pas.fr', defaultValue: 'https://app.ptits-pas.fr',
); );
// Construit une URL vers l'API v1 à partir d'un chemin (commençant par '/') // Construit une URL vers l'API v1 à partir d'un chemin (commençant par '/')

View File

@ -4,6 +4,7 @@ class AppUser {
final String role; final String role;
final DateTime createdAt; final DateTime createdAt;
final DateTime updatedAt; final DateTime updatedAt;
final bool changementMdpObligatoire;
AppUser({ AppUser({
required this.id, required this.id,
@ -11,6 +12,7 @@ class AppUser {
required this.role, required this.role,
required this.createdAt, required this.createdAt,
required this.updatedAt, required this.updatedAt,
this.changementMdpObligatoire = false,
}); });
factory AppUser.fromJson(Map<String, dynamic> json) { factory AppUser.fromJson(Map<String, dynamic> json) {
@ -20,6 +22,7 @@ class AppUser {
role: json['role'] as String, role: json['role'] as String,
createdAt: DateTime.parse(json['createdAt'] as String), createdAt: DateTime.parse(json['createdAt'] as String),
updatedAt: DateTime.parse(json['updatedAt'] as String), updatedAt: DateTime.parse(json['updatedAt'] as String),
changementMdpObligatoire: json['changement_mdp_obligatoire'] as bool? ?? false,
); );
} }
@ -30,6 +33,7 @@ class AppUser {
'role': role, 'role': role,
'createdAt': createdAt.toIso8601String(), 'createdAt': createdAt.toIso8601String(),
'updatedAt': updatedAt.toIso8601String(), 'updatedAt': updatedAt.toIso8601String(),
'changement_mdp_obligatoire': changementMdpObligatoire,
}; };
} }
} }

View File

@ -4,9 +4,10 @@ import 'package:google_fonts/google_fonts.dart';
import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import 'package:p_tits_pas/services/bug_report_service.dart'; import 'package:p_tits_pas/services/bug_report_service.dart';
import 'package:go_router/go_router.dart';
import '../../widgets/image_button.dart'; import '../../widgets/image_button.dart';
import '../../widgets/custom_app_text_field.dart'; import '../../widgets/custom_app_text_field.dart';
import '../../services/auth_service.dart';
import '../../widgets/auth/change_password_dialog.dart';
class LoginScreen extends StatefulWidget { class LoginScreen extends StatefulWidget {
const LoginScreen({super.key}); const LoginScreen({super.key});
@ -20,6 +21,9 @@ class _LoginPageState extends State<LoginScreen> {
final _emailController = TextEditingController(); final _emailController = TextEditingController();
final _passwordController = TextEditingController(); final _passwordController = TextEditingController();
bool _isLoading = false;
String? _errorMessage;
@override @override
void dispose() { void dispose() {
_emailController.dispose(); _emailController.dispose();
@ -41,12 +45,80 @@ class _LoginPageState extends State<LoginScreen> {
if (value == null || value.isEmpty) { if (value == null || value.isEmpty) {
return 'Veuillez entrer votre mot de passe'; return 'Veuillez entrer votre mot de passe';
} }
if (value.length < 6) {
return 'Le mot de passe doit contenir au moins 6 caractères';
}
return null; return null;
} }
/// Gère la connexion de l'utilisateur
Future<void> _handleLogin() async {
// Réinitialiser le message d'erreur
setState(() {
_errorMessage = null;
});
if (!(_formKey.currentState?.validate() ?? false)) {
return;
}
setState(() {
_isLoading = true;
});
try {
// Appeler le service d'authentification
final user = await AuthService.login(
_emailController.text.trim(),
_passwordController.text,
);
if (!mounted) return;
// Vérifier si l'utilisateur doit changer son mot de passe
if (user.changementMdpObligatoire) {
if (!mounted) return;
// Afficher la modale de changement de mot de passe (non-dismissible)
final result = await showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (context) => const ChangePasswordDialog(),
);
// Si le changement de mot de passe a réussi, rafraîchir l'utilisateur
if (result == true && mounted) {
await AuthService.refreshCurrentUser();
}
}
if (!mounted) return;
// Rediriger selon le rôle de l'utilisateur
_redirectUserByRole(user.role);
} catch (e) {
setState(() {
_isLoading = false;
_errorMessage = e.toString().replaceAll('Exception: ', '');
});
}
}
/// Redirige l'utilisateur selon son rôle
void _redirectUserByRole(String role) {
switch (role.toLowerCase()) {
case 'super_admin':
case 'gestionnaire':
Navigator.pushReplacementNamed(context, '/admin-dashboard');
break;
case 'parent':
Navigator.pushReplacementNamed(context, '/parent-dashboard');
break;
case 'assistante_maternelle':
Navigator.pushReplacementNamed(context, '/am-dashboard');
break;
default:
Navigator.pushReplacementNamed(context, '/home');
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
@ -136,19 +208,45 @@ class _LoginPageState extends State<LoginScreen> {
], ],
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
// Message d'erreur
if (_errorMessage != null)
Container(
padding: const EdgeInsets.all(12),
margin: const EdgeInsets.only(bottom: 15),
decoration: BoxDecoration(
color: Colors.red[50],
borderRadius: BorderRadius.circular(10),
border: Border.all(color: Colors.red[300]!),
),
child: Row(
children: [
Icon(Icons.error_outline, color: Colors.red[700], size: 20),
const SizedBox(width: 10),
Expanded(
child: Text(
_errorMessage!,
style: GoogleFonts.merienda(
fontSize: 12,
color: Colors.red[700],
),
),
),
],
),
),
// Bouton centré // Bouton centré
Center( Center(
child: ImageButton( child: _isLoading
? const CircularProgressIndicator()
: ImageButton(
bg: 'assets/images/btn_green.png', bg: 'assets/images/btn_green.png',
width: 300, width: 300,
height: 40, height: 40,
text: 'Se connecter', text: 'Se connecter',
textColor: const Color(0xFF2D6A4F), textColor: const Color(0xFF2D6A4F),
onPressed: () { onPressed: _handleLogin,
if (_formKey.currentState?.validate() ?? false) {
// TODO: Implémenter la logique de connexion
}
},
), ),
), ),
const SizedBox(height: 10), const SizedBox(height: 10),

View File

@ -1,11 +1,13 @@
class ApiConfig { class ApiConfig {
// static const String baseUrl = 'http://localhost:3000/api/v1/'; // static const String baseUrl = 'http://localhost:3000/api/v1/';
static const String baseUrl = 'https://ynov.ptits-pas.fr/api/v1'; static const String baseUrl = 'https://app.ptits-pas.fr/api/v1';
// Auth endpoints // Auth endpoints
static const String login = '/auth/login'; static const String login = '/auth/login';
static const String register = '/auth/register'; static const String register = '/auth/register';
static const String refreshToken = '/auth/refresh'; static const String refreshToken = '/auth/refresh';
static const String authMe = '/auth/me';
static const String changePasswordRequired = '/auth/change-password-required';
// Users endpoints // Users endpoints
static const String users = '/users'; static const String users = '/users';

View File

@ -1,42 +1,146 @@
import 'dart:convert'; import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import '../models/user.dart'; import '../models/user.dart';
import 'api/api_config.dart';
import 'api/tokenService.dart';
class AuthService { class AuthService {
static const String _usersKey = 'users'; static const String _currentUserKey = 'current_user';
static const String _parentsKey = 'parents';
static const String _childrenKey = 'children';
// Méthode pour se connecter (mode démonstration) /// Connexion de l'utilisateur
/// Retourne l'utilisateur connecté avec le flag changement_mdp_obligatoire
static Future<AppUser> login(String email, String password) async { static Future<AppUser> login(String email, String password) async {
await Future.delayed(const Duration(seconds: 1)); // Simule un délai de traitement try {
throw Exception('Mode démonstration - Connexion désactivée'); 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);
// Stocker les tokens
await TokenService.saveToken(data['accessToken']);
await TokenService.saveRefreshToken(data['refreshToken']);
// Récupérer le profil utilisateur pour avoir toutes les infos
final user = await _fetchUserProfile(data['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');
}
} }
// Méthode pour s'inscrire (mode démonstration) /// Récupère le profil utilisateur via /auth/me
static Future<AppUser> register({ static Future<AppUser> _fetchUserProfile(String token) async {
required String email, try {
required String password, final response = await http.get(
required String firstName, Uri.parse('${ApiConfig.baseUrl}${ApiConfig.authMe}'),
required String lastName, headers: ApiConfig.authHeaders(token),
required String role, );
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 { }) async {
await Future.delayed(const Duration(seconds: 1)); // Simule un délai de traitement final token = await TokenService.getToken();
throw Exception('Mode démonstration - Inscription désactivée'); if (token == null) {
throw Exception('Non authentifié');
} }
// Méthode pour se déconnecter (mode démonstration) try {
final response = await http.post(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.changePasswordRequired}'),
headers: ApiConfig.authHeaders(token),
body: jsonEncode({
'currentPassword': currentPassword,
'newPassword': 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 { static Future<void> logout() async {
// Ne fait rien en mode démonstration await TokenService.clearAll();
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_currentUserKey);
} }
// Méthode pour vérifier si l'utilisateur est connecté (mode démonstration) /// Vérifie si l'utilisateur est connecté
static Future<bool> isLoggedIn() async { static Future<bool> isLoggedIn() async {
return false; // Toujours non connecté en mode démonstration final token = await TokenService.getToken();
return token != null;
} }
// Méthode pour récupérer l'utilisateur connecté (mode démonstration) /// Récupère l'utilisateur connecté depuis le cache
static Future<AppUser?> getCurrentUser() async { static Future<AppUser?> getCurrentUser() async {
return null; // Aucun utilisateur en mode démonstration 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()));
}
/// 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;
}
} }
} }

View File

@ -0,0 +1,302 @@
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import '../../services/auth_service.dart';
import '../../widgets/custom_app_text_field.dart';
import '../../widgets/image_button.dart';
/// Dialogue modal bloquant pour le changement de mot de passe obligatoire
/// Utilisé après la première connexion quand changement_mdp_obligatoire = true
class ChangePasswordDialog extends StatefulWidget {
const ChangePasswordDialog({super.key});
@override
State<ChangePasswordDialog> createState() => _ChangePasswordDialogState();
}
class _ChangePasswordDialogState extends State<ChangePasswordDialog> {
final _formKey = GlobalKey<FormState>();
final _currentPasswordController = TextEditingController();
final _newPasswordController = TextEditingController();
final _confirmPasswordController = TextEditingController();
bool _isLoading = false;
String? _errorMessage;
@override
void dispose() {
_currentPasswordController.dispose();
_newPasswordController.dispose();
_confirmPasswordController.dispose();
super.dispose();
}
String? _validateCurrentPassword(String? value) {
if (value == null || value.isEmpty) {
return 'Veuillez entrer votre mot de passe actuel';
}
return null;
}
String? _validateNewPassword(String? value) {
if (value == null || value.isEmpty) {
return 'Veuillez entrer un nouveau mot de passe';
}
if (value.length < 8) {
return 'Le mot de passe doit contenir au moins 8 caractères';
}
if (!RegExp(r'[A-Z]').hasMatch(value)) {
return 'Le mot de passe doit contenir au moins une majuscule';
}
if (!RegExp(r'[a-z]').hasMatch(value)) {
return 'Le mot de passe doit contenir au moins une minuscule';
}
if (!RegExp(r'[0-9]').hasMatch(value)) {
return 'Le mot de passe doit contenir au moins un chiffre';
}
return null;
}
String? _validateConfirmPassword(String? value) {
if (value == null || value.isEmpty) {
return 'Veuillez confirmer votre nouveau mot de passe';
}
if (value != _newPasswordController.text) {
return 'Les mots de passe ne correspondent pas';
}
return null;
}
Future<void> _handleSubmit() async {
// Réinitialiser le message d'erreur
setState(() {
_errorMessage = null;
});
if (!(_formKey.currentState?.validate() ?? false)) {
return;
}
setState(() {
_isLoading = true;
});
try {
await AuthService.changePasswordRequired(
currentPassword: _currentPasswordController.text,
newPassword: _newPasswordController.text,
);
if (mounted) {
// Fermer le dialogue avec succès
Navigator.of(context).pop(true);
// Afficher un message de succès
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Mot de passe modifié avec succès',
style: GoogleFonts.merienda(),
),
backgroundColor: Colors.green,
),
);
}
} catch (e) {
setState(() {
_isLoading = false;
_errorMessage = e.toString().replaceAll('Exception: ', '');
});
}
}
@override
Widget build(BuildContext context) {
return PopScope(
// Empêcher la fermeture du dialogue avec le bouton retour
canPop: false,
child: Dialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
child: Container(
constraints: const BoxConstraints(maxWidth: 500),
padding: const EdgeInsets.all(30),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 20,
offset: const Offset(0, 10),
),
],
),
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Titre
Text(
'Changement de mot de passe obligatoire',
style: GoogleFonts.merienda(
fontSize: 20,
fontWeight: FontWeight.bold,
color: const Color(0xFF2D6A4F),
),
textAlign: TextAlign.center,
),
const SizedBox(height: 10),
// Message d'explication
Text(
'Pour des raisons de sécurité, vous devez changer votre mot de passe avant de continuer.',
style: GoogleFonts.merienda(
fontSize: 14,
color: Colors.black87,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 25),
// Champ mot de passe actuel
CustomAppTextField(
controller: _currentPasswordController,
labelText: 'Mot de passe actuel',
hintText: 'Votre mot de passe actuel',
obscureText: true,
validator: _validateCurrentPassword,
style: CustomAppTextFieldStyle.lavande,
fieldHeight: 53,
fieldWidth: double.infinity,
enabled: !_isLoading,
),
const SizedBox(height: 15),
// Champ nouveau mot de passe
CustomAppTextField(
controller: _newPasswordController,
labelText: 'Nouveau mot de passe',
hintText: 'Minimum 8 caractères',
obscureText: true,
validator: _validateNewPassword,
style: CustomAppTextFieldStyle.jaune,
fieldHeight: 53,
fieldWidth: double.infinity,
enabled: !_isLoading,
),
const SizedBox(height: 15),
// Champ confirmation mot de passe
CustomAppTextField(
controller: _confirmPasswordController,
labelText: 'Confirmer le mot de passe',
hintText: 'Retapez le nouveau mot de passe',
obscureText: true,
validator: _validateConfirmPassword,
style: CustomAppTextFieldStyle.lavande,
fieldHeight: 53,
fieldWidth: double.infinity,
enabled: !_isLoading,
),
const SizedBox(height: 10),
// Critères du mot de passe
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.grey[100],
borderRadius: BorderRadius.circular(10),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Le mot de passe doit contenir :',
style: GoogleFonts.merienda(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
),
const SizedBox(height: 5),
_buildPasswordCriteria('Au moins 8 caractères'),
_buildPasswordCriteria('Au moins une majuscule'),
_buildPasswordCriteria('Au moins une minuscule'),
_buildPasswordCriteria('Au moins un chiffre'),
],
),
),
// Message d'erreur
if (_errorMessage != null) ...[
const SizedBox(height: 15),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.red[50],
borderRadius: BorderRadius.circular(10),
border: Border.all(color: Colors.red[300]!),
),
child: Row(
children: [
Icon(Icons.error_outline, color: Colors.red[700], size: 20),
const SizedBox(width: 10),
Expanded(
child: Text(
_errorMessage!,
style: GoogleFonts.merienda(
fontSize: 12,
color: Colors.red[700],
),
),
),
],
),
),
],
const SizedBox(height: 25),
// Bouton de validation
Center(
child: _isLoading
? const CircularProgressIndicator()
: ImageButton(
bg: 'assets/images/btn_green.png',
width: 250,
height: 40,
text: 'Changer le mot de passe',
textColor: const Color(0xFF2D6A4F),
onPressed: _handleSubmit,
),
),
],
),
),
),
),
);
}
Widget _buildPasswordCriteria(String text) {
return Padding(
padding: const EdgeInsets.only(top: 3, left: 5),
child: Row(
children: [
const Icon(Icons.check_circle_outline, size: 14, color: Colors.green),
const SizedBox(width: 8),
Text(
text,
style: GoogleFonts.merienda(
fontSize: 11,
color: Colors.black87,
),
),
],
),
);
}
}