diff --git a/backend/src/entities/users.entity.ts b/backend/src/entities/users.entity.ts index 6382e4b..398af51 100644 --- a/backend/src/entities/users.entity.ts +++ b/backend/src/entities/users.entity.ts @@ -119,6 +119,13 @@ export class Users { @Column({ type: 'timestamptz', nullable: true, name: 'token_creation_mdp_expire_le' }) token_creation_mdp_expire_le?: Date; + /** Ticket #127 — réinitialisation mot de passe oublié (distinct de token_creation_mdp / inscription) */ + @Column({ nullable: true, name: 'password_reset_token', length: 255 }) + password_reset_token?: string; + + @Column({ type: 'timestamptz', nullable: true, name: 'password_reset_expires' }) + password_reset_expires?: Date; + /** Token pour reprise après refus (lien email), ticket #110 */ @Column({ nullable: true, name: 'token_reprise', length: 255 }) token_reprise?: string; diff --git a/backend/src/modules/mail/mail.service.spec.ts b/backend/src/modules/mail/mail.service.spec.ts index 03eb5b0..4f47a6a 100644 --- a/backend/src/modules/mail/mail.service.spec.ts +++ b/backend/src/modules/mail/mail.service.spec.ts @@ -68,4 +68,20 @@ describe('MailService (ticket #28)', () => { expect(html).toContain('2025-000001'); expect(html).toContain(`https://app.test/create-password?token=${encodeURIComponent(token)}`); }); + + it('sendPasswordResetEmail utilise Handlebars + lien /reset-password', async () => { + const token = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'; + await service.sendPasswordResetEmail({ + email: 'u@test.fr', + prenom: 'Marie', + nom: 'Curie', + token, + }); + expect(sendEmailSpy).toHaveBeenCalledTimes(1); + const [, subject, html] = sendEmailSpy.mock.calls[0]; + expect(subject).toContain('Réinitialisation'); + expect(html).toContain('Marie'); + expect(html).toContain(`https://app.test/reset-password?token=${encodeURIComponent(token)}`); + expect(html).not.toContain('create-password'); + }); }); diff --git a/backend/src/modules/mail/mail.service.ts b/backend/src/modules/mail/mail.service.ts index 79521c8..b2baa85 100644 --- a/backend/src/modules/mail/mail.service.ts +++ b/backend/src/modules/mail/mail.service.ts @@ -238,4 +238,31 @@ export class MailService { await this.sendEmail(to, subject, html); } + + /** + * Ticket #127 — lien application `/reset-password?token=` (pas create-password). + */ + async sendPasswordResetEmail(recipient: { + email: string; + prenom: string; + nom: string; + token: string; + }): Promise { + const appName = this.configService.get('app_name', "P'titsPas"); + const appUrl = (this.configService.get('app_url', 'https://app.ptits-pas.fr') || '').replace(/\/+$/, ''); + const resetPasswordUrl = `${appUrl}/reset-password?token=${encodeURIComponent(recipient.token)}`; + + const filePath = join(__dirname, 'templates', 'password-reset.hbs'); + const source = readFileSync(filePath, 'utf8'); + const compiled = Handlebars.compile(source); + const html = compiled({ + prenom: recipient.prenom || '', + nom: recipient.nom || '', + appName, + resetPasswordUrl, + }) as string; + + const subject = `${appName} — Réinitialisation de votre mot de passe`; + await this.sendEmail(recipient.email, subject, html); + } } diff --git a/backend/src/modules/mail/templates/password-reset.hbs b/backend/src/modules/mail/templates/password-reset.hbs new file mode 100644 index 0000000..dce95e3 --- /dev/null +++ b/backend/src/modules/mail/templates/password-reset.hbs @@ -0,0 +1,12 @@ +
+

Bonjour {{prenom}} {{nom}},

+

Vous avez demandé à réinitialiser votre mot de passe sur {{appName}}.

+

Cliquez sur le bouton ci-dessous pour en choisir un nouveau. Ce lien est valable une durée limitée.

+
+ Réinitialiser mon mot de passe +
+

Si le bouton ne fonctionne pas, copiez ce lien dans votre navigateur :
{{{resetPasswordUrl}}}

+

Si vous n'êtes pas à l'origine de cette demande, vous pouvez ignorer cet e-mail.

+
+

Cet email a été envoyé automatiquement par {{appName}}. Merci de ne pas y répondre.

+
diff --git a/backend/src/routes/auth/auth.controller.spec.ts b/backend/src/routes/auth/auth.controller.spec.ts index e1b11aa..cad4c47 100644 --- a/backend/src/routes/auth/auth.controller.spec.ts +++ b/backend/src/routes/auth/auth.controller.spec.ts @@ -9,6 +9,8 @@ describe('AuthController', () => { const authServiceMock = { verifyCreatePasswordToken: jest.fn(), createPasswordWithToken: jest.fn(), + requestPasswordReset: jest.fn(), + resetPasswordWithResetToken: jest.fn(), }; beforeEach(async () => { @@ -70,4 +72,39 @@ describe('AuthController', () => { }); expect(authServiceMock.createPasswordWithToken).toHaveBeenCalledWith('tok-123', 'Password1'); }); + + it('forgot-password délègue au service et renvoie le message générique', async () => { + authServiceMock.requestPasswordReset.mockResolvedValue({ + message: 'Si cette adresse est associée…', + }); + await expect(controller.forgotPassword({ email: 'user@test.fr' })).resolves.toEqual({ + message: 'Si cette adresse est associée…', + }); + expect(authServiceMock.requestPasswordReset).toHaveBeenCalledWith('user@test.fr'); + }); + + it('rejects reset-password when confirmation mismatches', async () => { + await expect( + controller.resetPassword({ + token: 'tok', + password: 'Password1', + password_confirmation: 'Password2', + }), + ).rejects.toThrow(BadRequestException); + expect(authServiceMock.resetPasswordWithResetToken).not.toHaveBeenCalled(); + }); + + it('calls reset-password when payload is valid', async () => { + authServiceMock.resetPasswordWithResetToken.mockResolvedValue({ + message: 'Mot de passe mis à jour.', + }); + await expect( + controller.resetPassword({ + token: 'tok-123', + password: 'Password1', + password_confirmation: 'Password1', + }), + ).resolves.toEqual({ message: 'Mot de passe mis à jour.' }); + expect(authServiceMock.resetPasswordWithResetToken).toHaveBeenCalledWith('tok-123', 'Password1'); + }); }); diff --git a/backend/src/routes/auth/auth.controller.ts b/backend/src/routes/auth/auth.controller.ts index 2d7942d..7e2c6d3 100644 --- a/backend/src/routes/auth/auth.controller.ts +++ b/backend/src/routes/auth/auth.controller.ts @@ -1,4 +1,17 @@ -import { Body, Controller, Get, Patch, Post, Query, Req, UnauthorizedException, BadRequestException, UseGuards } from '@nestjs/common'; +import { + Body, + Controller, + Get, + HttpCode, + HttpStatus, + Patch, + Post, + Query, + Req, + UnauthorizedException, + BadRequestException, + UseGuards, +} from '@nestjs/common'; import { LoginDto } from './dto/login.dto'; import { AuthService } from './auth.service'; import { Public } from 'src/common/decorators/public.decorator'; @@ -8,6 +21,8 @@ import { RegisterAMCompletDto } from './dto/register-am-complet.dto'; import { RegisterAmResponseDto } from './dto/register-am-response.dto'; import { ChangePasswordRequiredDto } from './dto/change-password.dto'; import { CreatePasswordDto } from './dto/create-password.dto'; +import { ForgotPasswordDto } from './dto/forgot-password.dto'; +import { ResetPasswordDto } from './dto/reset-password.dto'; import { ApiBearerAuth, ApiOperation, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger'; import { AuthGuard } from 'src/common/guards/auth.guard'; import type { Request } from 'express'; @@ -133,6 +148,36 @@ export class AuthController { return this.authService.createPasswordWithToken(dto.token, dto.password); } + @Public() + @Post('forgot-password') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'Demande de réinitialisation du mot de passe (ticket #127)', + description: + 'Réponse identique que l’e-mail existe ou non (anti-énumération). Si un compte avec mot de passe existe, un e-mail avec lien /reset-password est envoyé.', + }) + @ApiResponse({ status: 200, description: 'Message générique' }) + async forgotPassword(@Body() dto: ForgotPasswordDto) { + return this.authService.requestPasswordReset(dto.email ?? ''); + } + + @Public() + @Post('reset-password') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'Réinitialiser le mot de passe via token e-mail (ticket #127)', + description: 'Distinct de POST /auth/create-password (inscription). Utilise password_reset_token.', + }) + @ApiResponse({ status: 200, description: 'Mot de passe mis à jour' }) + @ApiResponse({ status: 400, description: 'Confirmation ou règles de mot de passe' }) + @ApiResponse({ status: 404, description: 'Token invalide, expiré, ou déjà utilisé' }) + async resetPassword(@Body() dto: ResetPasswordDto) { + if (dto.password !== dto.password_confirmation) { + throw new BadRequestException('Les mots de passe ne correspondent pas'); + } + return this.authService.resetPasswordWithResetToken(dto.token, dto.password); + } + @Get('me') @UseGuards(AuthGuard) @ApiBearerAuth('access-token') diff --git a/backend/src/routes/auth/auth.service.spec.ts b/backend/src/routes/auth/auth.service.spec.ts index 03812b4..0361563 100644 --- a/backend/src/routes/auth/auth.service.spec.ts +++ b/backend/src/routes/auth/auth.service.spec.ts @@ -18,18 +18,38 @@ describe('AuthService (#118 create-password API)', () => { const usersRepoMock = { findOne: jest.fn(), createQueryBuilder: jest.fn(), + update: jest.fn(), + }; + + const mailServiceMock = { + sendPasswordResetEmail: jest.fn().mockResolvedValue(undefined), + }; + + const appConfigMock = { + get: jest.fn().mockReturnValue(7), }; beforeEach(async () => { jest.clearAllMocks(); + const qbChain: any = {}; + qbChain.where = jest.fn().mockReturnValue(qbChain); + qbChain.select = jest.fn().mockReturnValue(qbChain); + qbChain.getOne = jest.fn().mockResolvedValue(null); + qbChain.update = jest.fn().mockReturnValue(qbChain); + qbChain.set = jest.fn().mockReturnValue(qbChain); + qbChain.andWhere = jest.fn().mockReturnValue(qbChain); + qbChain.execute = jest.fn().mockResolvedValue({ affected: 0 }); + usersRepoMock.createQueryBuilder.mockReturnValue(qbChain); + usersRepoMock.update.mockResolvedValue({ affected: 1, raw: [] }); + const module: TestingModule = await Test.createTestingModule({ providers: [ AuthService, { provide: UserService, useValue: {} }, { provide: JwtService, useValue: {} }, { provide: ConfigService, useValue: { get: jest.fn() } }, - { provide: AppConfigService, useValue: {} }, - { provide: MailService, useValue: {} }, + { provide: AppConfigService, useValue: appConfigMock }, + { provide: MailService, useValue: mailServiceMock }, { provide: NumeroDossierService, useValue: {} }, { provide: getRepositoryToken(Parents), useValue: {} }, { provide: getRepositoryToken(Users), useValue: usersRepoMock }, @@ -61,4 +81,59 @@ describe('AuthService (#118 create-password API)', () => { message: 'Token valide', }); }); + + describe('#127 forgot / reset password', () => { + it('requestPasswordReset returns generic message when email unknown', async () => { + const r = await service.requestPasswordReset('unknown@test.fr'); + expect(r.message).toContain('Si cette adresse'); + expect(mailServiceMock.sendPasswordResetEmail).not.toHaveBeenCalled(); + }); + + it('requestPasswordReset sends mail when user has password', async () => { + usersRepoMock.createQueryBuilder.mockReturnValueOnce({ + where: jest.fn().mockReturnThis(), + select: jest.fn().mockReturnThis(), + getOne: jest.fn().mockResolvedValue({ + id: 'u1', + email: 'a@test.fr', + prenom: 'A', + nom: 'B', + password: 'hashed', + }), + }); + const r = await service.requestPasswordReset('a@test.fr'); + expect(r.message).toContain('Si cette adresse'); + expect(usersRepoMock.update).toHaveBeenCalled(); + expect(mailServiceMock.sendPasswordResetEmail).toHaveBeenCalledWith( + expect.objectContaining({ email: 'a@test.fr', token: expect.any(String) }), + ); + }); + + it('resetPasswordWithResetToken throws when token unknown', async () => { + usersRepoMock.findOne.mockResolvedValue(null); + await expect(service.resetPasswordWithResetToken('bad', 'Password1')).rejects.toThrow( + NotFoundException, + ); + }); + + it('resetPasswordWithResetToken succeeds when update affects row', async () => { + usersRepoMock.findOne.mockResolvedValue({ + id: 'u1', + password: 'oldhash', + password_reset_expires: new Date(Date.now() + 60_000), + }); + const exec = jest.fn().mockResolvedValue({ affected: 1 }); + const chain: any = {}; + chain.update = jest.fn().mockReturnValue(chain); + chain.set = jest.fn().mockReturnValue(chain); + chain.where = jest.fn().mockReturnValue(chain); + chain.andWhere = jest.fn().mockReturnValue(chain); + chain.execute = exec; + usersRepoMock.createQueryBuilder.mockReturnValueOnce(chain); + + const r = await service.resetPasswordWithResetToken('good-token', 'Password1'); + expect(r.message.toLowerCase()).toContain('mis à jour'); + expect(exec).toHaveBeenCalled(); + }); + }); }); diff --git a/backend/src/routes/auth/auth.service.ts b/backend/src/routes/auth/auth.service.ts index 506c22c..b291009 100644 --- a/backend/src/routes/auth/auth.service.ts +++ b/backend/src/routes/auth/auth.service.ts @@ -249,6 +249,124 @@ export class AuthService { }; } + /** Réponse unique pour POST forgot-password (anti-énumération), ticket #127 */ + private static readonly FORGOT_PASSWORD_GENERIC_MESSAGE = + 'Si cette adresse est associée à un compte pour lequel un mot de passe existe, vous recevrez un e-mail avec les instructions.'; + + /** + * Ticket #127 — demande de réinitialisation (e-mail avec lien /reset-password). + * Toujours la même réponse 200 ; n’utilise pas token_creation_mdp (#118 inscription). + */ + async requestPasswordReset(rawEmail: string): Promise<{ message: string }> { + const generic = { message: AuthService.FORGOT_PASSWORD_GENERIC_MESSAGE }; + const email = (rawEmail ?? '').trim().toLowerCase(); + if (!email) { + return generic; + } + + const user = await this.usersRepo + .createQueryBuilder('u') + .where('LOWER(TRIM(u.email)) = :email', { email }) + .select(['u.id', 'u.email', 'u.prenom', 'u.nom', 'u.password']) + .getOne(); + + if (!user?.password) { + return generic; + } + + const expiryDaysRaw = this.appConfigService.get('password_reset_token_expiry_days', 7); + const expiryDays = Number(expiryDaysRaw) > 0 ? Number(expiryDaysRaw) : 7; + const expires = new Date(); + expires.setDate(expires.getDate() + expiryDays); + + const token = crypto.randomUUID(); + + await this.usersRepo.update( + { id: user.id }, + { + password_reset_token: token, + password_reset_expires: expires, + }, + ); + + try { + await this.mailService.sendPasswordResetEmail({ + email: user.email, + prenom: user.prenom ?? '', + nom: user.nom ?? '', + token, + }); + } catch (err) { + this.logger.error( + '[requestPasswordReset] Échec envoi e-mail (réponse inchangée pour anti-énumération)', + err, + ); + } + + return generic; + } + + /** + * Ticket #127 — consomme password_reset_token (distinct de create-password / token_creation_mdp). + */ + async resetPasswordWithResetToken(token: string, plainPassword: string): Promise<{ message: string }> { + const cleanedToken = token?.trim(); + if (!cleanedToken) { + throw new NotFoundException('Token invalide, expiré, ou déjà utilisé.'); + } + + const existingUser = await this.usersRepo.findOne({ + where: { password_reset_token: cleanedToken }, + select: ['id', 'password', 'password_reset_expires'], + }); + + if (!existingUser?.password) { + throw new NotFoundException('Token invalide, expiré, ou déjà utilisé.'); + } + + if (!existingUser.password_reset_expires || existingUser.password_reset_expires <= new Date()) { + await this.usersRepo + .createQueryBuilder() + .update(Users) + .set({ + password_reset_token: () => 'NULL', + password_reset_expires: () => 'NULL', + }) + .where('id = :id', { id: existingUser.id }) + .execute(); + throw new NotFoundException('Token invalide, expiré, ou déjà utilisé.'); + } + + const sel = await bcrypt.genSalt(12); + const hashedPassword = await bcrypt.hash(plainPassword, sel); + + const updateResult = await this.usersRepo + .createQueryBuilder() + .update(Users) + .set({ + password: hashedPassword, + password_reset_token: () => 'NULL', + password_reset_expires: () => 'NULL', + changement_mdp_obligatoire: false, + }) + .where('password_reset_token = :token', { token: cleanedToken }) + .andWhere('password_reset_expires > NOW()') + .execute(); + + if (!updateResult.affected) { + this.logger.warn( + '[resetPasswordWithResetToken] Token non consommé (course concurrente ou token invalidé)', + ); + throw new NotFoundException('Token invalide, expiré, ou déjà utilisé.'); + } + + this.logger.log('[resetPasswordWithResetToken] Mot de passe réinitialisé avec succès'); + + return { + message: 'Mot de passe mis à jour. Vous pouvez vous connecter.', + }; + } + /** * Inscription utilisateur OBSOLÈTE - Utiliser inscrireParentComplet() ou registerAM() * @deprecated diff --git a/backend/src/routes/auth/dto/forgot-password.dto.ts b/backend/src/routes/auth/dto/forgot-password.dto.ts new file mode 100644 index 0000000..dcd15ce --- /dev/null +++ b/backend/src/routes/auth/dto/forgot-password.dto.ts @@ -0,0 +1,20 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; +import { IsOptional, IsString, MaxLength } from 'class-validator'; + +/** + * Ticket #127 — pas de @IsEmail / @MinLength stricts : éviter 400 sur saisies vides ou bizarres ; + * le service renvoie toujours la même réponse 200 (anti-énumération). + */ +export class ForgotPasswordDto { + @ApiProperty({ + example: 'parent@example.com', + required: false, + description: 'E-mail (trim + lowercase via transform). Absent ou vide → même réponse générique.', + }) + @IsOptional() + @IsString() + @MaxLength(320) + @Transform(({ value }) => (typeof value === 'string' ? value.trim().toLowerCase() : '')) + email?: string; +} diff --git a/backend/src/routes/auth/dto/reset-password.dto.ts b/backend/src/routes/auth/dto/reset-password.dto.ts new file mode 100644 index 0000000..47844d0 --- /dev/null +++ b/backend/src/routes/auth/dto/reset-password.dto.ts @@ -0,0 +1,25 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString, MinLength, Matches } from 'class-validator'; + +/** Ticket #127 — mêmes règles que [CreatePasswordDto] (inscription #118). */ +export class ResetPasswordDto { + @ApiProperty({ description: 'Token UUID reçu par e-mail (lien reset-password)' }) + @IsString() + @MinLength(1, { message: 'Token manquant' }) + token: string; + + @ApiProperty({ + description: 'Nouveau mot de passe (min 8 caractères, 1 majuscule, 1 chiffre)', + minLength: 8, + }) + @IsString() + @MinLength(8, { message: 'Le mot de passe doit contenir au moins 8 caractères' }) + @Matches(/^(?=.*[A-Z])(?=.*\d)/, { + message: 'Le mot de passe doit contenir au moins une majuscule et un chiffre', + }) + password: string; + + @ApiProperty({ description: 'Confirmation du nouveau mot de passe' }) + @IsString() + password_confirmation: string; +} diff --git a/database/BDD.sql b/database/BDD.sql index da1a210..b7cd431 100644 --- a/database/BDD.sql +++ b/database/BDD.sql @@ -62,6 +62,9 @@ CREATE TABLE utilisateurs ( date_consentement_photo TIMESTAMPTZ, token_creation_mdp VARCHAR(255), -- Token pour créer MDP après validation token_creation_mdp_expire_le TIMESTAMPTZ, -- Expiration 7 jours + -- Ticket #127 : réinitialisation mot de passe oublié (distinct de token_creation_mdp) + password_reset_token VARCHAR(255) NULL, + password_reset_expires TIMESTAMPTZ NULL, changement_mdp_obligatoire BOOLEAN DEFAULT false, cree_le TIMESTAMPTZ DEFAULT now(), modifie_le TIMESTAMPTZ DEFAULT now(), @@ -76,6 +79,10 @@ CREATE INDEX idx_utilisateurs_token_creation_mdp ON utilisateurs(token_creation_mdp) WHERE token_creation_mdp IS NOT NULL; +CREATE INDEX idx_utilisateurs_password_reset_token + ON utilisateurs(password_reset_token) + WHERE password_reset_token IS NOT NULL; + -- ========================================================== -- Table : assistantes_maternelles -- ========================================================== @@ -411,6 +418,13 @@ ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS token_reprise VARCHAR(255) NUL ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS token_reprise_expire_le TIMESTAMPTZ NULL; CREATE INDEX IF NOT EXISTS idx_utilisateurs_token_reprise ON utilisateurs(token_reprise) WHERE token_reprise IS NOT NULL; +-- ========================================================== +-- Ticket #127 : Mot de passe oublié (token dédié, ≠ inscription) +-- ========================================================== +ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS password_reset_token VARCHAR(255) NULL; +ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS password_reset_expires TIMESTAMPTZ NULL; +CREATE INDEX IF NOT EXISTS idx_utilisateurs_password_reset_token ON utilisateurs(password_reset_token) WHERE password_reset_token IS NOT NULL; + -- Lieu de naissance (aligné CREATE TABLE utilisateurs — idempotent si colonnes déjà présentes) ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS lieu_naissance_ville VARCHAR(100) NULL; ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS lieu_naissance_pays VARCHAR(100) NULL; diff --git a/frontend/lib/config/app_router.dart b/frontend/lib/config/app_router.dart index 8db296c..4dfe66e 100644 --- a/frontend/lib/config/app_router.dart +++ b/frontend/lib/config/app_router.dart @@ -19,6 +19,8 @@ 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/auth/forgot_password_screen.dart'; +import '../screens/auth/reset_password_screen.dart'; import '../screens/home/home_screen.dart'; import '../screens/administrateurs/admin_dashboardScreen.dart'; import '../screens/gestionnaire/gestionnaire_dashboard_screen.dart'; @@ -55,6 +57,15 @@ class AppRouter { builder: (BuildContext context, GoRouterState state) => CreatePasswordScreen(token: state.uri.queryParameters['token']), ), + GoRoute( + path: '/forgot-password', + builder: (BuildContext context, GoRouterState state) => const ForgotPasswordScreen(), + ), + GoRoute( + path: '/reset-password', + builder: (BuildContext context, GoRouterState state) => + ResetPasswordScreen(token: state.uri.queryParameters['token']), + ), GoRoute( path: '/home', builder: (BuildContext context, GoRouterState state) => const HomeScreen(), diff --git a/frontend/lib/screens/auth/create_password_screen.dart b/frontend/lib/screens/auth/create_password_screen.dart index f9fe096..1326eb4 100644 --- a/frontend/lib/screens/auth/create_password_screen.dart +++ b/frontend/lib/screens/auth/create_password_screen.dart @@ -1,244 +1,33 @@ 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'; +import 'password_token_form_screen.dart'; -class CreatePasswordScreen extends StatefulWidget { +class CreatePasswordScreen extends StatelessWidget { 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'), - ), - ], + return PasswordTokenFormScreen( + token: token, + title: 'Créer votre mot de passe', + submitLabel: 'Valider', + successMessage: 'Mot de passe créé avec succès. Vous pouvez vous connecter.', + invalidTokenTitle: 'Création du mot de passe', + verifyToken: AuthService.verifyCreatePasswordToken, + onSubmit: ({ + required String token, + required String password, + required String passwordConfirmation, + }) { + return AuthService.createPasswordWithToken( + token: token, + password: password, + passwordConfirmation: passwordConfirmation, + ); + }, ); } } diff --git a/frontend/lib/screens/auth/forgot_password_screen.dart b/frontend/lib/screens/auth/forgot_password_screen.dart new file mode 100644 index 0000000..ca0342b --- /dev/null +++ b/frontend/lib/screens/auth/forgot_password_screen.dart @@ -0,0 +1,160 @@ +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 '../../utils/email_utils.dart'; +import '../../widgets/image_button.dart'; + +/// Demande de lien de réinitialisation (ticket #127). +class ForgotPasswordScreen extends StatefulWidget { + const ForgotPasswordScreen({super.key}); + + @override + State createState() => _ForgotPasswordScreenState(); +} + +class _ForgotPasswordScreenState extends State { + final _formKey = GlobalKey(); + final _emailCtrl = TextEditingController(); + + bool _submitting = false; + String? _fieldError; + + @override + void dispose() { + _emailCtrl.dispose(); + super.dispose(); + } + + String? _validateEmail(String? value) { + return validateEmail(value, allowEmpty: false); + } + + Future _submit() async { + if (_submitting) return; + setState(() => _fieldError = null); + if (!(_formKey.currentState?.validate() ?? false)) return; + + setState(() => _submitting = true); + try { + await AuthService.requestForgotPassword(_emailCtrl.text); + if (!mounted) return; + setState(() => _submitting = false); + await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text('Demande enregistrée', style: GoogleFonts.merienda(fontWeight: FontWeight.w700)), + content: Text( + 'Si un compte correspond à cette adresse, vous recevrez un e-mail ' + 'avec un lien pour réinitialiser votre mot de passe.', + style: GoogleFonts.merienda(), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(), + child: Text('OK', style: GoogleFonts.merienda(color: const Color(0xFF2D6A4F))), + ), + ], + ), + ); + if (mounted) context.go('/login'); + } catch (e) { + if (!mounted) return; + setState(() { + _submitting = false; + _fieldError = 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: 520), + 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: Form( + key: _formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Mot de passe oublié', + style: GoogleFonts.merienda(fontSize: 28, fontWeight: FontWeight.w700), + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + Text( + 'Indiquez l’adresse e-mail de votre compte. Nous vous enverrons un lien si elle est reconnue.', + style: GoogleFonts.merienda(fontSize: 14, color: Colors.black87), + textAlign: TextAlign.center, + ), + const SizedBox(height: 20), + TextFormField( + controller: _emailCtrl, + keyboardType: TextInputType.emailAddress, + autocorrect: false, + textInputAction: TextInputAction.done, + onFieldSubmitted: (_) => _submit(), + validator: _validateEmail, + decoration: const InputDecoration( + labelText: 'E-mail', + border: OutlineInputBorder(), + ), + ), + if (_fieldError != null) ...[ + const SizedBox(height: 12), + Text( + _fieldError!, + style: GoogleFonts.merienda(color: Colors.red.shade700, fontSize: 13), + ), + ], + const SizedBox(height: 20), + _submitting + ? const Center(child: CircularProgressIndicator()) + : ImageButton( + bg: 'assets/images/bg_green.png', + width: double.infinity, + height: 44, + text: 'Envoyer le lien', + textColor: const Color(0xFF2D6A4F), + onPressed: _submit, + ), + const SizedBox(height: 12), + TextButton( + onPressed: () => context.go('/login'), + child: Text( + 'Retour à la connexion', + style: GoogleFonts.merienda( + color: const Color(0xFF2D6A4F), + decoration: TextDecoration.underline, + ), + ), + ), + ], + ), + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/frontend/lib/screens/auth/login_screen.dart b/frontend/lib/screens/auth/login_screen.dart index 4ad0b22..feb5476 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: () { - context.go('/create-password'); + context.go('/forgot-password'); }, child: Text( 'Mot de passe oublié ?', @@ -618,7 +618,7 @@ class _LoginPageState extends State { ), const SizedBox(height: 12), TextButton( - onPressed: () => context.go('/create-password'), + onPressed: () => context.go('/forgot-password'), child: Text( 'Mot de passe oublié ?', style: GoogleFonts.merienda( diff --git a/frontend/lib/screens/auth/password_token_form_screen.dart b/frontend/lib/screens/auth/password_token_form_screen.dart new file mode 100644 index 0000000..63b7722 --- /dev/null +++ b/frontend/lib/screens/auth/password_token_form_screen.dart @@ -0,0 +1,296 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import '../../widgets/image_button.dart'; + +typedef TokenPasswordSubmit = Future Function({ + required String token, + required String password, + required String passwordConfirmation, +}); + +typedef TokenVerifier = Future Function(String token); +typedef ErrorMapper = String Function(String rawError); + +/// Ecran commun pour les flows mot de passe basés sur un token URL. +class PasswordTokenFormScreen extends StatefulWidget { + final String? token; + final String title; + final String? subtitle; + final String submitLabel; + final String successMessage; + final String invalidTokenTitle; + final String invalidTokenMessage; + final TokenPasswordSubmit onSubmit; + final TokenVerifier? verifyToken; + final ErrorMapper? mapError; + + const PasswordTokenFormScreen({ + super.key, + required this.token, + required this.title, + this.subtitle, + required this.submitLabel, + required this.successMessage, + required this.invalidTokenTitle, + this.invalidTokenMessage = 'Lien invalide ou expiré.', + required this.onSubmit, + this.verifyToken, + this.mapError, + }); + + @override + State createState() => _PasswordTokenFormScreenState(); +} + +class _PasswordTokenFormScreenState extends State { + final _formKey = GlobalKey(); + final _passwordCtrl = TextEditingController(); + final _confirmCtrl = TextEditingController(); + + bool _checkingToken = false; + bool _submitting = false; + bool _tokenValid = false; + String? _message; + + String get _token => (widget.token ?? '').trim(); + + @override + void initState() { + super.initState(); + _initTokenState(); + } + + @override + void dispose() { + _passwordCtrl.dispose(); + _confirmCtrl.dispose(); + super.dispose(); + } + + void _initTokenState() { + if (_token.isEmpty) { + _tokenValid = false; + _checkingToken = false; + return; + } + if (widget.verifyToken == null) { + _tokenValid = true; + _checkingToken = false; + return; + } + _checkingToken = true; + _verifyToken(); + } + + Future _verifyToken() async { + try { + final ok = await widget.verifyToken!(_token); + if (!mounted) return; + setState(() { + _checkingToken = false; + _tokenValid = ok; + }); + } catch (e) { + if (!mounted) return; + final raw = e.toString().replaceAll('Exception: ', ''); + setState(() { + _checkingToken = false; + _tokenValid = false; + _message = widget.mapError?.call(raw) ?? raw; + }); + } + } + + 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 _submit() async { + if (_submitting || !_tokenValid) return; + if (!(_formKey.currentState?.validate() ?? false)) return; + setState(() { + _submitting = true; + _message = null; + }); + try { + await widget.onSubmit( + token: _token, + password: _passwordCtrl.text, + passwordConfirmation: _confirmCtrl.text, + ); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(widget.successMessage, style: GoogleFonts.merienda()), + ), + ); + context.go('/login'); + } catch (e) { + if (!mounted) return; + final raw = e.toString().replaceAll('Exception: ', ''); + setState(() { + _submitting = false; + _message = widget.mapError?.call(raw) ?? raw; + }); + } + } + + @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(context) + : _buildInvalidToken(), + ), + ), + ), + ), + ), + ); + } + + Widget _buildForm(BuildContext context) { + return Form( + key: _formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + widget.title, + style: GoogleFonts.merienda(fontSize: 28, fontWeight: FontWeight.w700), + textAlign: TextAlign.center, + ), + if (widget.subtitle != null && widget.subtitle!.trim().isNotEmpty) ...[ + const SizedBox(height: 10), + Text( + widget.subtitle!, + style: GoogleFonts.merienda(fontSize: 14, color: Colors.black87), + textAlign: TextAlign.center, + ), + ], + const SizedBox(height: 18), + TextFormField( + controller: _passwordCtrl, + obscureText: true, + textInputAction: TextInputAction.next, + onFieldSubmitted: (_) => FocusScope.of(context).nextFocus(), + validator: _validatePassword, + decoration: const InputDecoration( + labelText: 'Mot de passe', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 14), + TextFormField( + controller: _confirmCtrl, + obscureText: true, + textInputAction: TextInputAction.done, + onFieldSubmitted: (_) => _submit(), + validator: _validateConfirmation, + decoration: const InputDecoration( + labelText: 'Confirmer le mot de passe', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 10), + Text( + '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, + fontSize: 13, + ), + ), + ], + const SizedBox(height: 16), + _submitting + ? const Center(child: CircularProgressIndicator()) + : ImageButton( + bg: 'assets/images/bg_green.png', + width: double.infinity, + height: 44, + text: widget.submitLabel, + textColor: const Color(0xFF2D6A4F), + onPressed: _submit, + ), + ], + ), + ); + } + + Widget _buildInvalidToken() { + final msg = _message ?? widget.invalidTokenMessage; + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + widget.invalidTokenTitle, + 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/reset_password_screen.dart b/frontend/lib/screens/auth/reset_password_screen.dart new file mode 100644 index 0000000..ecfb06a --- /dev/null +++ b/frontend/lib/screens/auth/reset_password_screen.dart @@ -0,0 +1,43 @@ +import 'package:flutter/material.dart'; + +import '../../services/auth_service.dart'; +import 'password_token_form_screen.dart'; + +/// Réinitialisation du mot de passe via lien e-mail (ticket #127, distinct de [CreatePasswordScreen]). +class ResetPasswordScreen extends StatelessWidget { + final String? token; + + const ResetPasswordScreen({super.key, this.token}); + + String _neutralResetFeedback(String raw) { + final l = raw.toLowerCase(); + if (l.contains('token') || l.contains('invalide') || l.contains('expir')) { + return 'Lien invalide ou expiré.'; + } + return raw; + } + + @override + Widget build(BuildContext context) { + return PasswordTokenFormScreen( + token: token, + title: 'Nouveau mot de passe', + subtitle: 'Choisissez un mot de passe pour votre compte.', + submitLabel: 'Enregistrer', + successMessage: 'Mot de passe mis à jour. Vous pouvez vous connecter.', + invalidTokenTitle: 'Réinitialisation', + mapError: _neutralResetFeedback, + onSubmit: ({ + required String token, + required String password, + required String passwordConfirmation, + }) { + return AuthService.resetPasswordWithToken( + token: token, + password: password, + passwordConfirmation: passwordConfirmation, + ); + }, + ); + } +} diff --git a/frontend/lib/services/api/api_config.dart b/frontend/lib/services/api/api_config.dart index 34c88a2..11d6a28 100644 --- a/frontend/lib/services/api/api_config.dart +++ b/frontend/lib/services/api/api_config.dart @@ -46,6 +46,9 @@ class ApiConfig { static const String changePasswordRequired = '/auth/change-password-required'; static const String verifyCreatePasswordToken = '/auth/verify-token'; static const String createPassword = '/auth/create-password'; + /// Ticket #127 — mot de passe oublié (back à livrer en parallèle). + static const String forgotPassword = '/auth/forgot-password'; + static const String resetPassword = '/auth/reset-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 321d2c4..94f5d28 100644 --- a/frontend/lib/services/auth_service.dart +++ b/frontend/lib/services/auth_service.dart @@ -12,6 +12,7 @@ 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'; @@ -186,6 +187,77 @@ class AuthService { 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 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 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); + } + /// Déconnexion de l'utilisateur static Future logout() async { await TokenService.clearAll();