diff --git a/backend/src/routes/auth/auth.controller.spec.ts b/backend/src/routes/auth/auth.controller.spec.ts index 27a31e6..e1b11aa 100644 --- a/backend/src/routes/auth/auth.controller.spec.ts +++ b/backend/src/routes/auth/auth.controller.spec.ts @@ -1,12 +1,24 @@ +import { BadRequestException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { AuthController } from './auth.controller'; +import { AuthService } from './auth.service'; +import { UserService } from '../user/user.service'; describe('AuthController', () => { let controller: AuthController; + const authServiceMock = { + verifyCreatePasswordToken: jest.fn(), + createPasswordWithToken: jest.fn(), + }; beforeEach(async () => { + jest.clearAllMocks(); const module: TestingModule = await Test.createTestingModule({ controllers: [AuthController], + providers: [ + { provide: AuthService, useValue: authServiceMock }, + { provide: UserService, useValue: {} }, + ], }).compile(); controller = module.get(AuthController); @@ -15,4 +27,47 @@ describe('AuthController', () => { it('should be defined', () => { expect(controller).toBeDefined(); }); + + it('forwards token verification to auth service', async () => { + authServiceMock.verifyCreatePasswordToken.mockResolvedValue({ + valid: true, + message: 'Token valide', + }); + + await expect(controller.verifyCreatePasswordToken('tok-123')).resolves.toEqual({ + valid: true, + message: 'Token valide', + }); + expect(authServiceMock.verifyCreatePasswordToken).toHaveBeenCalledWith('tok-123'); + }); + + it('rejects create-password when confirmation mismatches', async () => { + await expect( + controller.createPassword({ + token: 'tok-123', + password: 'Password1', + password_confirmation: 'Password2', + }), + ).rejects.toThrow(BadRequestException); + expect(authServiceMock.createPasswordWithToken).not.toHaveBeenCalled(); + }); + + it('calls auth service when create-password payload is valid', async () => { + authServiceMock.createPasswordWithToken.mockResolvedValue({ + message: 'Mot de passe créé avec succès. Vous pouvez maintenant vous connecter.', + userId: 'user-1', + }); + + await expect( + controller.createPassword({ + token: 'tok-123', + password: 'Password1', + password_confirmation: 'Password1', + }), + ).resolves.toEqual({ + message: 'Mot de passe créé avec succès. Vous pouvez maintenant vous connecter.', + userId: 'user-1', + }); + expect(authServiceMock.createPasswordWithToken).toHaveBeenCalledWith('tok-123', 'Password1'); + }); }); diff --git a/backend/src/routes/auth/auth.service.spec.ts b/backend/src/routes/auth/auth.service.spec.ts index 800ab66..03812b4 100644 --- a/backend/src/routes/auth/auth.service.spec.ts +++ b/backend/src/routes/auth/auth.service.spec.ts @@ -1,12 +1,41 @@ +import { NotFoundException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; +import { JwtService } from '@nestjs/jwt'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { ConfigService } from '@nestjs/config'; import { AuthService } from './auth.service'; +import { UserService } from '../user/user.service'; +import { AppConfigService } from 'src/modules/config/config.service'; +import { MailService } from 'src/modules/mail/mail.service'; +import { NumeroDossierService } from 'src/modules/numero-dossier/numero-dossier.service'; +import { Parents } from 'src/entities/parents.entity'; +import { Users } from 'src/entities/users.entity'; +import { Children } from 'src/entities/children.entity'; +import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity'; -describe('AuthService', () => { +describe('AuthService (#118 create-password API)', () => { let service: AuthService; + const usersRepoMock = { + findOne: jest.fn(), + createQueryBuilder: jest.fn(), + }; beforeEach(async () => { + jest.clearAllMocks(); const module: TestingModule = await Test.createTestingModule({ - providers: [AuthService], + providers: [ + AuthService, + { provide: UserService, useValue: {} }, + { provide: JwtService, useValue: {} }, + { provide: ConfigService, useValue: { get: jest.fn() } }, + { provide: AppConfigService, useValue: {} }, + { provide: MailService, useValue: {} }, + { provide: NumeroDossierService, useValue: {} }, + { provide: getRepositoryToken(Parents), useValue: {} }, + { provide: getRepositoryToken(Users), useValue: usersRepoMock }, + { provide: getRepositoryToken(Children), useValue: {} }, + { provide: getRepositoryToken(AssistanteMaternelle), useValue: {} }, + ], }).compile(); service = module.get(AuthService); @@ -15,4 +44,21 @@ describe('AuthService', () => { it('should be defined', () => { expect(service).toBeDefined(); }); + + it('returns 404-like error when verify token is missing', async () => { + await expect(service.verifyCreatePasswordToken('')).rejects.toThrow(NotFoundException); + }); + + it('returns valid=true when token exists and is not expired', async () => { + usersRepoMock.findOne.mockResolvedValue({ + id: 'u1', + password: null, + token_creation_mdp_expire_le: new Date(Date.now() + 60_000), + }); + + await expect(service.verifyCreatePasswordToken('tok-123')).resolves.toEqual({ + valid: true, + message: 'Token valide', + }); + }); }); diff --git a/backend/src/routes/auth/auth.service.ts b/backend/src/routes/auth/auth.service.ts index bced215..506c22c 100644 --- a/backend/src/routes/auth/auth.service.ts +++ b/backend/src/routes/auth/auth.service.ts @@ -140,7 +140,9 @@ export class AuthService { async verifyCreatePasswordToken(token: string) { const cleanedToken = token?.trim(); if (!cleanedToken) { - throw new BadRequestException('Token manquant'); + // #118: côté front, un token absent doit être traité comme lien invalide/expiré. + // On renvoie donc la même réponse neutre 404 que pour les autres cas invalides. + throw new NotFoundException('Token invalide, expiré, ou déjà utilisé.'); } const user = await this.usersRepo.findOne({ diff --git a/frontend/lib/config/app_router.dart b/frontend/lib/config/app_router.dart index 074262f..8db296c 100644 --- a/frontend/lib/config/app_router.dart +++ b/frontend/lib/config/app_router.dart @@ -18,6 +18,7 @@ import '../screens/auth/am_register_step1_screen.dart'; import '../screens/auth/am_register_step2_screen.dart'; import '../screens/auth/am_register_step3_screen.dart'; import '../screens/auth/am_register_step4_screen.dart'; +import '../screens/auth/create_password_screen.dart'; import '../screens/home/home_screen.dart'; import '../screens/administrateurs/admin_dashboardScreen.dart'; import '../screens/gestionnaire/gestionnaire_dashboard_screen.dart'; @@ -49,6 +50,11 @@ class AppRouter { path: '/register-choice', builder: (BuildContext context, GoRouterState state) => const RegisterChoiceScreen(), ), + GoRoute( + path: '/create-password', + builder: (BuildContext context, GoRouterState state) => + CreatePasswordScreen(token: state.uri.queryParameters['token']), + ), GoRoute( path: '/home', builder: (BuildContext context, GoRouterState state) => const HomeScreen(), diff --git a/frontend/lib/main.dart b/frontend/lib/main.dart index 4478ce1..2a60d50 100644 --- a/frontend/lib/main.dart +++ b/frontend/lib/main.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter_web_plugins/url_strategy.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; // Import pour la localisation // import 'package:provider/provider.dart'; // Supprimer Provider @@ -7,6 +8,8 @@ import 'config/app_router.dart'; // <-- Importer le bon routeur (GoRouter) // import 'theme/theme_provider.dart'; // Supprimer ThemeProvider void main() { + // Permet les URLs web propres (/create-password?token=...) sans hash. + usePathUrlStrategy(); runApp(const MyApp()); // Exécution simple } diff --git a/frontend/lib/screens/auth/create_password_screen.dart b/frontend/lib/screens/auth/create_password_screen.dart new file mode 100644 index 0000000..f9fe096 --- /dev/null +++ b/frontend/lib/screens/auth/create_password_screen.dart @@ -0,0 +1,244 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import '../../services/auth_service.dart'; +import '../../widgets/image_button.dart'; + +class CreatePasswordScreen extends StatefulWidget { + final String? token; + + const CreatePasswordScreen({super.key, this.token}); + + @override + State createState() => _CreatePasswordScreenState(); +} + +class _CreatePasswordScreenState extends State { + final _formKey = GlobalKey(); + final _passwordCtrl = TextEditingController(); + final _confirmCtrl = TextEditingController(); + + bool _checkingToken = true; + bool _submitting = false; + bool _tokenValid = false; + String? _message; + + String get _token => (widget.token ?? '').trim(); + + @override + void initState() { + super.initState(); + _checkToken(); + } + + @override + void dispose() { + _passwordCtrl.dispose(); + _confirmCtrl.dispose(); + super.dispose(); + } + + bool _isStrongPassword(String v) { + if (v.length < 8) return false; + final hasUpper = RegExp(r'[A-Z]').hasMatch(v); + final hasDigit = RegExp(r'\d').hasMatch(v); + return hasUpper && hasDigit; + } + + String? _validatePassword(String? value) { + final v = value ?? ''; + if (v.isEmpty) return 'Veuillez saisir un mot de passe.'; + if (!_isStrongPassword(v)) { + return 'Minimum 8 caractères, avec au moins 1 majuscule et 1 chiffre.'; + } + return null; + } + + String? _validateConfirmation(String? value) { + final v = value ?? ''; + if (v.isEmpty) return 'Veuillez confirmer le mot de passe.'; + if (v != _passwordCtrl.text) return 'Les mots de passe ne correspondent pas.'; + return null; + } + + Future _checkToken() async { + if (_token.isEmpty) { + setState(() { + _checkingToken = false; + _tokenValid = false; + }); + return; + } + try { + final ok = await AuthService.verifyCreatePasswordToken(_token); + if (!mounted) return; + setState(() { + _checkingToken = false; + _tokenValid = ok; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _checkingToken = false; + _tokenValid = false; + _message = e.toString().replaceAll('Exception: ', ''); + }); + } + } + + Future _submit() async { + if (_submitting || !_tokenValid) return; + if (!(_formKey.currentState?.validate() ?? false)) return; + setState(() { + _submitting = true; + _message = null; + }); + try { + await AuthService.createPasswordWithToken( + token: _token, + password: _passwordCtrl.text, + passwordConfirmation: _confirmCtrl.text, + ); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Mot de passe créé avec succès. Vous pouvez vous connecter.', + style: GoogleFonts.merienda(), + ), + ), + ); + context.go('/login'); + } catch (e) { + if (!mounted) return; + setState(() { + _submitting = false; + _message = e.toString().replaceAll('Exception: ', ''); + }); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Container( + decoration: const BoxDecoration( + image: DecorationImage( + image: AssetImage('assets/images/paper2.png'), + fit: BoxFit.cover, + repeat: ImageRepeat.repeat, + ), + ), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 560), + child: Card( + color: const Color(0xF4FFFFFF), + margin: const EdgeInsets.all(24), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + child: Padding( + padding: const EdgeInsets.all(24), + child: _checkingToken + ? const Center(child: CircularProgressIndicator()) + : _tokenValid + ? _buildForm() + : _buildInvalidToken(), + ), + ), + ), + ), + ), + ); + } + + Widget _buildForm() { + return Form( + key: _formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Créer votre mot de passe', + style: GoogleFonts.merienda(fontSize: 28, fontWeight: FontWeight.w700), + textAlign: TextAlign.center, + ), + const SizedBox(height: 18), + TextFormField( + controller: _passwordCtrl, + obscureText: true, + validator: _validatePassword, + decoration: const InputDecoration( + labelText: 'Nouveau mot de passe', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 14), + TextFormField( + controller: _confirmCtrl, + obscureText: true, + validator: _validateConfirmation, + decoration: const InputDecoration( + labelText: 'Confirmer le mot de passe', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 10), + Text( + 'Le mot de passe doit contenir au moins 8 caractères, dont 1 majuscule et 1 chiffre.', + style: GoogleFonts.merienda(fontSize: 12, color: Colors.black54), + ), + if (_message != null) ...[ + const SizedBox(height: 12), + Text( + _message!, + style: GoogleFonts.merienda(color: Colors.red.shade700), + ), + ], + const SizedBox(height: 16), + _submitting + ? const Center(child: CircularProgressIndicator()) + : ImageButton( + bg: 'assets/images/bg_green.png', + width: double.infinity, + height: 44, + text: 'Valider', + textColor: const Color(0xFF2D6A4F), + onPressed: _submit, + ), + ], + ), + ); + } + + Widget _buildInvalidToken() { + final msg = _message ?? 'Lien invalide ou expiré.'; + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Création du mot de passe', + style: GoogleFonts.merienda(fontSize: 28, fontWeight: FontWeight.w700), + textAlign: TextAlign.center, + ), + const SizedBox(height: 20), + Text( + msg, + style: GoogleFonts.merienda(fontSize: 16), + textAlign: TextAlign.center, + ), + const SizedBox(height: 20), + ImageButton( + bg: 'assets/images/bg_green.png', + width: double.infinity, + height: 44, + text: 'Retour à la connexion', + textColor: const Color(0xFF2D6A4F), + onPressed: () => context.go('/login'), + ), + ], + ); + } +} diff --git a/frontend/lib/screens/auth/login_screen.dart b/frontend/lib/screens/auth/login_screen.dart index 16ee6d9..4ad0b22 100644 --- a/frontend/lib/screens/auth/login_screen.dart +++ b/frontend/lib/screens/auth/login_screen.dart @@ -355,7 +355,7 @@ class _LoginPageState extends State { Center( child: TextButton( onPressed: () { - // TODO: Implémenter la logique de récupération de mot de passe + context.go('/create-password'); }, child: Text( 'Mot de passe oublié ?', @@ -618,7 +618,7 @@ class _LoginPageState extends State { ), const SizedBox(height: 12), TextButton( - onPressed: () {/* TODO */}, + onPressed: () => context.go('/create-password'), child: Text( 'Mot de passe oublié ?', style: GoogleFonts.merienda( diff --git a/frontend/lib/services/api/api_config.dart b/frontend/lib/services/api/api_config.dart index b3fe61d..34c88a2 100644 --- a/frontend/lib/services/api/api_config.dart +++ b/frontend/lib/services/api/api_config.dart @@ -44,6 +44,8 @@ class ApiConfig { static const String refreshToken = '/auth/refresh'; static const String authMe = '/auth/me'; static const String changePasswordRequired = '/auth/change-password-required'; + static const String verifyCreatePasswordToken = '/auth/verify-token'; + static const String createPassword = '/auth/create-password'; // Users endpoints static const String users = '/users'; diff --git a/frontend/lib/services/auth_service.dart b/frontend/lib/services/auth_service.dart index 629329b..321d2c4 100644 --- a/frontend/lib/services/auth_service.dart +++ b/frontend/lib/services/auth_service.dart @@ -131,6 +131,61 @@ class AuthService { } } + /// 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 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 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)); + } + /// Déconnexion de l'utilisateur static Future logout() async { await TokenService.clearAll(); diff --git a/frontend/pubspec.lock b/frontend/pubspec.lock index 752a6b7..09ded02 100644 --- a/frontend/pubspec.lock +++ b/frontend/pubspec.lock @@ -177,7 +177,7 @@ packages: source: sdk version: "0.0.0" flutter_web_plugins: - dependency: transitive + dependency: "direct main" description: flutter source: sdk version: "0.0.0" diff --git a/frontend/pubspec.yaml b/frontend/pubspec.yaml index d9f9b57..0f2e0aa 100644 --- a/frontend/pubspec.yaml +++ b/frontend/pubspec.yaml @@ -9,6 +9,8 @@ environment: dependencies: flutter: sdk: flutter + flutter_web_plugins: + sdk: flutter flutter_localizations: sdk: flutter provider: ^6.1.1