feat(#118): création du mot de passe depuis le lien e-mail

Livre le parcours complet « lien e-mail → page web → mot de passe initial » :
- API : vérification du token (comportement neutre si invalide/expiré) et création du mot de passe ; tests associés.
- Front : route /create-password, appels verify-token et create-password, validation alignée sur le backend, retour login après succès.
- Web : stratégie d’URL en path pour que les deep links /create-password?token=… fonctionnent sans redirection vers #/login.

Refs: #118
Made-with: Cursor
This commit is contained in:
MARTIN Julien 2026-04-23 16:45:47 +02:00
parent dff108c7d5
commit 46d0f82f54
11 changed files with 421 additions and 6 deletions

View File

@ -1,12 +1,24 @@
import { BadRequestException } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { AuthController } from './auth.controller'; import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { UserService } from '../user/user.service';
describe('AuthController', () => { describe('AuthController', () => {
let controller: AuthController; let controller: AuthController;
const authServiceMock = {
verifyCreatePasswordToken: jest.fn(),
createPasswordWithToken: jest.fn(),
};
beforeEach(async () => { beforeEach(async () => {
jest.clearAllMocks();
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
controllers: [AuthController], controllers: [AuthController],
providers: [
{ provide: AuthService, useValue: authServiceMock },
{ provide: UserService, useValue: {} },
],
}).compile(); }).compile();
controller = module.get<AuthController>(AuthController); controller = module.get<AuthController>(AuthController);
@ -15,4 +27,47 @@ describe('AuthController', () => {
it('should be defined', () => { it('should be defined', () => {
expect(controller).toBeDefined(); 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');
});
}); });

View File

@ -1,12 +1,41 @@
import { NotFoundException } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing'; 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 { 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; let service: AuthService;
const usersRepoMock = {
findOne: jest.fn(),
createQueryBuilder: jest.fn(),
};
beforeEach(async () => { beforeEach(async () => {
jest.clearAllMocks();
const module: TestingModule = await Test.createTestingModule({ 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(); }).compile();
service = module.get<AuthService>(AuthService); service = module.get<AuthService>(AuthService);
@ -15,4 +44,21 @@ describe('AuthService', () => {
it('should be defined', () => { it('should be defined', () => {
expect(service).toBeDefined(); 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',
});
});
}); });

View File

@ -140,7 +140,9 @@ export class AuthService {
async verifyCreatePasswordToken(token: string) { async verifyCreatePasswordToken(token: string) {
const cleanedToken = token?.trim(); const cleanedToken = token?.trim();
if (!cleanedToken) { 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({ const user = await this.usersRepo.findOne({

View File

@ -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_step2_screen.dart';
import '../screens/auth/am_register_step3_screen.dart'; import '../screens/auth/am_register_step3_screen.dart';
import '../screens/auth/am_register_step4_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/home/home_screen.dart';
import '../screens/administrateurs/admin_dashboardScreen.dart'; import '../screens/administrateurs/admin_dashboardScreen.dart';
import '../screens/gestionnaire/gestionnaire_dashboard_screen.dart'; import '../screens/gestionnaire/gestionnaire_dashboard_screen.dart';
@ -49,6 +50,11 @@ class AppRouter {
path: '/register-choice', path: '/register-choice',
builder: (BuildContext context, GoRouterState state) => const RegisterChoiceScreen(), builder: (BuildContext context, GoRouterState state) => const RegisterChoiceScreen(),
), ),
GoRoute(
path: '/create-password',
builder: (BuildContext context, GoRouterState state) =>
CreatePasswordScreen(token: state.uri.queryParameters['token']),
),
GoRoute( GoRoute(
path: '/home', path: '/home',
builder: (BuildContext context, GoRouterState state) => const HomeScreen(), builder: (BuildContext context, GoRouterState state) => const HomeScreen(),

View File

@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_web_plugins/url_strategy.dart';
import 'package:google_fonts/google_fonts.dart'; import 'package:google_fonts/google_fonts.dart';
import 'package:flutter_localizations/flutter_localizations.dart'; // Import pour la localisation import 'package:flutter_localizations/flutter_localizations.dart'; // Import pour la localisation
// import 'package:provider/provider.dart'; // Supprimer Provider // 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 // import 'theme/theme_provider.dart'; // Supprimer ThemeProvider
void main() { void main() {
// Permet les URLs web propres (/create-password?token=...) sans hash.
usePathUrlStrategy();
runApp(const MyApp()); // Exécution simple runApp(const MyApp()); // Exécution simple
} }

View File

@ -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<CreatePasswordScreen> createState() => _CreatePasswordScreenState();
}
class _CreatePasswordScreenState extends State<CreatePasswordScreen> {
final _formKey = GlobalKey<FormState>();
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<void> _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<void> _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'),
),
],
);
}
}

View File

@ -355,7 +355,7 @@ class _LoginPageState extends State<LoginScreen> {
Center( Center(
child: TextButton( child: TextButton(
onPressed: () { onPressed: () {
// TODO: Implémenter la logique de récupération de mot de passe context.go('/create-password');
}, },
child: Text( child: Text(
'Mot de passe oublié ?', 'Mot de passe oublié ?',
@ -618,7 +618,7 @@ class _LoginPageState extends State<LoginScreen> {
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
TextButton( TextButton(
onPressed: () {/* TODO */}, onPressed: () => context.go('/create-password'),
child: Text( child: Text(
'Mot de passe oublié ?', 'Mot de passe oublié ?',
style: GoogleFonts.merienda( style: GoogleFonts.merienda(

View File

@ -44,6 +44,8 @@ class ApiConfig {
static const String refreshToken = '/auth/refresh'; static const String refreshToken = '/auth/refresh';
static const String authMe = '/auth/me'; static const String authMe = '/auth/me';
static const String changePasswordRequired = '/auth/change-password-required'; static const String changePasswordRequired = '/auth/change-password-required';
static const String verifyCreatePasswordToken = '/auth/verify-token';
static const String createPassword = '/auth/create-password';
// Users endpoints // Users endpoints
static const String users = '/users'; static const String users = '/users';

View File

@ -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<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));
}
/// Déconnexion de l'utilisateur /// Déconnexion de l'utilisateur
static Future<void> logout() async { static Future<void> logout() async {
await TokenService.clearAll(); await TokenService.clearAll();

View File

@ -177,7 +177,7 @@ packages:
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
flutter_web_plugins: flutter_web_plugins:
dependency: transitive dependency: "direct main"
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"

View File

@ -9,6 +9,8 @@ environment:
dependencies: dependencies:
flutter: flutter:
sdk: flutter sdk: flutter
flutter_web_plugins:
sdk: flutter
flutter_localizations: flutter_localizations:
sdk: flutter sdk: flutter
provider: ^6.1.1 provider: ^6.1.1