feat(#127): mot de passe oublié — flux complet (API + web + e-mail)
- API publique POST /auth/forgot-password et /auth/reset-password (jeton password_reset_*, anti-énumération, e-mail P'titsPas) - BDD: colonnes password_reset_token / password_reset_expires + index - Front: écrans /forgot-password, /reset-password, lien login, mutualisation formulaires par token - Template e-mail + alignement couleur; tests unitaires (auth, mail) Merge depuis develop: branche feature/127. Made-with: Cursor
This commit is contained in:
parent
7765350d9a
commit
18f3a7f276
@ -119,6 +119,13 @@ export class Users {
|
|||||||
@Column({ type: 'timestamptz', nullable: true, name: 'token_creation_mdp_expire_le' })
|
@Column({ type: 'timestamptz', nullable: true, name: 'token_creation_mdp_expire_le' })
|
||||||
token_creation_mdp_expire_le?: Date;
|
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 */
|
/** Token pour reprise après refus (lien email), ticket #110 */
|
||||||
@Column({ nullable: true, name: 'token_reprise', length: 255 })
|
@Column({ nullable: true, name: 'token_reprise', length: 255 })
|
||||||
token_reprise?: string;
|
token_reprise?: string;
|
||||||
|
|||||||
@ -68,4 +68,20 @@ describe('MailService (ticket #28)', () => {
|
|||||||
expect(html).toContain('2025-000001');
|
expect(html).toContain('2025-000001');
|
||||||
expect(html).toContain(`https://app.test/create-password?token=${encodeURIComponent(token)}`);
|
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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -238,4 +238,31 @@ export class MailService {
|
|||||||
|
|
||||||
await this.sendEmail(to, subject, html);
|
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<void> {
|
||||||
|
const appName = this.configService.get<string>('app_name', "P'titsPas");
|
||||||
|
const appUrl = (this.configService.get<string>('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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
12
backend/src/modules/mail/templates/password-reset.hbs
Normal file
12
backend/src/modules/mail/templates/password-reset.hbs
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||||
|
<h2 style="color: #4CAF50;">Bonjour {{prenom}} {{nom}},</h2>
|
||||||
|
<p>Vous avez demandé à <strong>réinitialiser votre mot de passe</strong> sur <strong>{{appName}}</strong>.</p>
|
||||||
|
<p>Cliquez sur le bouton ci-dessous pour en choisir un nouveau. Ce lien est valable une durée limitée.</p>
|
||||||
|
<div style="text-align: center; margin: 30px 0;">
|
||||||
|
<a href="{{{resetPasswordUrl}}}" style="background-color: #4CAF50; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px; font-weight: bold;">Réinitialiser mon mot de passe</a>
|
||||||
|
</div>
|
||||||
|
<p style="color: #666; font-size: 13px;">Si le bouton ne fonctionne pas, copiez ce lien dans votre navigateur :<br /><span style="word-break: break-all;">{{{resetPasswordUrl}}}</span></p>
|
||||||
|
<p style="color: #666; font-size: 12px;">Si vous n'êtes pas à l'origine de cette demande, vous pouvez ignorer cet e-mail.</p>
|
||||||
|
<hr style="border: 1px solid #eee; margin: 20px 0;" />
|
||||||
|
<p style="color: #666; font-size: 12px;">Cet email a été envoyé automatiquement par {{appName}}. Merci de ne pas y répondre.</p>
|
||||||
|
</div>
|
||||||
@ -9,6 +9,8 @@ describe('AuthController', () => {
|
|||||||
const authServiceMock = {
|
const authServiceMock = {
|
||||||
verifyCreatePasswordToken: jest.fn(),
|
verifyCreatePasswordToken: jest.fn(),
|
||||||
createPasswordWithToken: jest.fn(),
|
createPasswordWithToken: jest.fn(),
|
||||||
|
requestPasswordReset: jest.fn(),
|
||||||
|
resetPasswordWithResetToken: jest.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
@ -70,4 +72,39 @@ describe('AuthController', () => {
|
|||||||
});
|
});
|
||||||
expect(authServiceMock.createPasswordWithToken).toHaveBeenCalledWith('tok-123', 'Password1');
|
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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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 { LoginDto } from './dto/login.dto';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { Public } from 'src/common/decorators/public.decorator';
|
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 { RegisterAmResponseDto } from './dto/register-am-response.dto';
|
||||||
import { ChangePasswordRequiredDto } from './dto/change-password.dto';
|
import { ChangePasswordRequiredDto } from './dto/change-password.dto';
|
||||||
import { CreatePasswordDto } from './dto/create-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 { ApiBearerAuth, ApiOperation, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||||
import { AuthGuard } from 'src/common/guards/auth.guard';
|
import { AuthGuard } from 'src/common/guards/auth.guard';
|
||||||
import type { Request } from 'express';
|
import type { Request } from 'express';
|
||||||
@ -133,6 +148,36 @@ export class AuthController {
|
|||||||
return this.authService.createPasswordWithToken(dto.token, dto.password);
|
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')
|
@Get('me')
|
||||||
@UseGuards(AuthGuard)
|
@UseGuards(AuthGuard)
|
||||||
@ApiBearerAuth('access-token')
|
@ApiBearerAuth('access-token')
|
||||||
|
|||||||
@ -18,18 +18,38 @@ describe('AuthService (#118 create-password API)', () => {
|
|||||||
const usersRepoMock = {
|
const usersRepoMock = {
|
||||||
findOne: jest.fn(),
|
findOne: jest.fn(),
|
||||||
createQueryBuilder: jest.fn(),
|
createQueryBuilder: jest.fn(),
|
||||||
|
update: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mailServiceMock = {
|
||||||
|
sendPasswordResetEmail: jest.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
|
||||||
|
const appConfigMock = {
|
||||||
|
get: jest.fn().mockReturnValue(7),
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
jest.clearAllMocks();
|
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({
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
providers: [
|
providers: [
|
||||||
AuthService,
|
AuthService,
|
||||||
{ provide: UserService, useValue: {} },
|
{ provide: UserService, useValue: {} },
|
||||||
{ provide: JwtService, useValue: {} },
|
{ provide: JwtService, useValue: {} },
|
||||||
{ provide: ConfigService, useValue: { get: jest.fn() } },
|
{ provide: ConfigService, useValue: { get: jest.fn() } },
|
||||||
{ provide: AppConfigService, useValue: {} },
|
{ provide: AppConfigService, useValue: appConfigMock },
|
||||||
{ provide: MailService, useValue: {} },
|
{ provide: MailService, useValue: mailServiceMock },
|
||||||
{ provide: NumeroDossierService, useValue: {} },
|
{ provide: NumeroDossierService, useValue: {} },
|
||||||
{ provide: getRepositoryToken(Parents), useValue: {} },
|
{ provide: getRepositoryToken(Parents), useValue: {} },
|
||||||
{ provide: getRepositoryToken(Users), useValue: usersRepoMock },
|
{ provide: getRepositoryToken(Users), useValue: usersRepoMock },
|
||||||
@ -61,4 +81,59 @@ describe('AuthService (#118 create-password API)', () => {
|
|||||||
message: 'Token valide',
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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<number>('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()
|
* Inscription utilisateur OBSOLÈTE - Utiliser inscrireParentComplet() ou registerAM()
|
||||||
* @deprecated
|
* @deprecated
|
||||||
|
|||||||
20
backend/src/routes/auth/dto/forgot-password.dto.ts
Normal file
20
backend/src/routes/auth/dto/forgot-password.dto.ts
Normal file
@ -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;
|
||||||
|
}
|
||||||
25
backend/src/routes/auth/dto/reset-password.dto.ts
Normal file
25
backend/src/routes/auth/dto/reset-password.dto.ts
Normal file
@ -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;
|
||||||
|
}
|
||||||
@ -62,6 +62,9 @@ CREATE TABLE utilisateurs (
|
|||||||
date_consentement_photo TIMESTAMPTZ,
|
date_consentement_photo TIMESTAMPTZ,
|
||||||
token_creation_mdp VARCHAR(255), -- Token pour créer MDP après validation
|
token_creation_mdp VARCHAR(255), -- Token pour créer MDP après validation
|
||||||
token_creation_mdp_expire_le TIMESTAMPTZ, -- Expiration 7 jours
|
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,
|
changement_mdp_obligatoire BOOLEAN DEFAULT false,
|
||||||
cree_le TIMESTAMPTZ DEFAULT now(),
|
cree_le TIMESTAMPTZ DEFAULT now(),
|
||||||
modifie_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)
|
ON utilisateurs(token_creation_mdp)
|
||||||
WHERE token_creation_mdp IS NOT NULL;
|
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
|
-- 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;
|
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;
|
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)
|
-- 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_ville VARCHAR(100) NULL;
|
||||||
ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS lieu_naissance_pays VARCHAR(100) NULL;
|
ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS lieu_naissance_pays VARCHAR(100) NULL;
|
||||||
|
|||||||
@ -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_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/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/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';
|
||||||
@ -55,6 +57,15 @@ class AppRouter {
|
|||||||
builder: (BuildContext context, GoRouterState state) =>
|
builder: (BuildContext context, GoRouterState state) =>
|
||||||
CreatePasswordScreen(token: state.uri.queryParameters['token']),
|
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(
|
GoRoute(
|
||||||
path: '/home',
|
path: '/home',
|
||||||
builder: (BuildContext context, GoRouterState state) => const HomeScreen(),
|
builder: (BuildContext context, GoRouterState state) => const HomeScreen(),
|
||||||
|
|||||||
@ -1,244 +1,33 @@
|
|||||||
import 'package:flutter/material.dart';
|
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 '../../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;
|
final String? token;
|
||||||
|
|
||||||
const CreatePasswordScreen({super.key, this.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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return PasswordTokenFormScreen(
|
||||||
body: Container(
|
token: token,
|
||||||
decoration: const BoxDecoration(
|
title: 'Créer votre mot de passe',
|
||||||
image: DecorationImage(
|
submitLabel: 'Valider',
|
||||||
image: AssetImage('assets/images/paper2.png'),
|
successMessage: 'Mot de passe créé avec succès. Vous pouvez vous connecter.',
|
||||||
fit: BoxFit.cover,
|
invalidTokenTitle: 'Création du mot de passe',
|
||||||
repeat: ImageRepeat.repeat,
|
verifyToken: AuthService.verifyCreatePasswordToken,
|
||||||
),
|
onSubmit: ({
|
||||||
),
|
required String token,
|
||||||
child: Center(
|
required String password,
|
||||||
child: ConstrainedBox(
|
required String passwordConfirmation,
|
||||||
constraints: const BoxConstraints(maxWidth: 560),
|
}) {
|
||||||
child: Card(
|
return AuthService.createPasswordWithToken(
|
||||||
color: const Color(0xF4FFFFFF),
|
token: token,
|
||||||
margin: const EdgeInsets.all(24),
|
password: password,
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
passwordConfirmation: passwordConfirmation,
|
||||||
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'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
160
frontend/lib/screens/auth/forgot_password_screen.dart
Normal file
160
frontend/lib/screens/auth/forgot_password_screen.dart
Normal file
@ -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<ForgotPasswordScreen> createState() => _ForgotPasswordScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
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<void> _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<void>(
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -355,7 +355,7 @@ class _LoginPageState extends State<LoginScreen> {
|
|||||||
Center(
|
Center(
|
||||||
child: TextButton(
|
child: TextButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
context.go('/create-password');
|
context.go('/forgot-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: () => context.go('/create-password'),
|
onPressed: () => context.go('/forgot-password'),
|
||||||
child: Text(
|
child: Text(
|
||||||
'Mot de passe oublié ?',
|
'Mot de passe oublié ?',
|
||||||
style: GoogleFonts.merienda(
|
style: GoogleFonts.merienda(
|
||||||
|
|||||||
296
frontend/lib/screens/auth/password_token_form_screen.dart
Normal file
296
frontend/lib/screens/auth/password_token_form_screen.dart
Normal file
@ -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<void> Function({
|
||||||
|
required String token,
|
||||||
|
required String password,
|
||||||
|
required String passwordConfirmation,
|
||||||
|
});
|
||||||
|
|
||||||
|
typedef TokenVerifier = Future<bool> 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<PasswordTokenFormScreen> createState() => _PasswordTokenFormScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PasswordTokenFormScreenState extends State<PasswordTokenFormScreen> {
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
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<void> _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<void> _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'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
43
frontend/lib/screens/auth/reset_password_screen.dart
Normal file
43
frontend/lib/screens/auth/reset_password_screen.dart
Normal file
@ -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,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -46,6 +46,9 @@ class ApiConfig {
|
|||||||
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 verifyCreatePasswordToken = '/auth/verify-token';
|
||||||
static const String createPassword = '/auth/create-password';
|
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
|
// Users endpoints
|
||||||
static const String users = '/users';
|
static const String users = '/users';
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import '../utils/parent_registration_payload.dart';
|
|||||||
import 'api/api_config.dart';
|
import 'api/api_config.dart';
|
||||||
import 'api/tokenService.dart';
|
import 'api/tokenService.dart';
|
||||||
import '../utils/nir_utils.dart';
|
import '../utils/nir_utils.dart';
|
||||||
|
import '../utils/email_utils.dart';
|
||||||
|
|
||||||
class AuthService {
|
class AuthService {
|
||||||
static const String _currentUserKey = 'current_user';
|
static const String _currentUserKey = 'current_user';
|
||||||
@ -186,6 +187,77 @@ class AuthService {
|
|||||||
throw Exception(_extractErrorMessage(decoded, response.statusCode));
|
throw Exception(_extractErrorMessage(decoded, response.statusCode));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Demande de réinitialisation du mot de passe (ticket #127).
|
||||||
|
/// Réponse **toujours neutre** côté UX si le serveur répond (anti-énumération des comptes).
|
||||||
|
static Future<void> requestForgotPassword(String email) async {
|
||||||
|
final cleaned = normalizeEmailText(email);
|
||||||
|
if (cleaned.isEmpty) {
|
||||||
|
throw Exception('Veuillez saisir votre adresse e-mail.');
|
||||||
|
}
|
||||||
|
late final http.Response response;
|
||||||
|
try {
|
||||||
|
response = await http.post(
|
||||||
|
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.forgotPassword}'),
|
||||||
|
headers: ApiConfig.headers,
|
||||||
|
body: jsonEncode({'email': cleaned}),
|
||||||
|
);
|
||||||
|
} on http.ClientException {
|
||||||
|
throw Exception(
|
||||||
|
'Connexion à ${ApiConfig.baseUrl} impossible. Vérifiez votre réseau puis réessayez.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (response.statusCode >= 500) {
|
||||||
|
final decoded = _tryDecodeJsonMap(response.body);
|
||||||
|
throw Exception(_extractErrorMessage(decoded, response.statusCode));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Réinitialise le mot de passe via le lien e-mail (ticket #127).
|
||||||
|
static Future<void> resetPasswordWithToken({
|
||||||
|
required String token,
|
||||||
|
required String password,
|
||||||
|
required String passwordConfirmation,
|
||||||
|
}) async {
|
||||||
|
final cleaned = token.trim();
|
||||||
|
if (cleaned.isEmpty) {
|
||||||
|
throw Exception('Lien invalide ou expiré.');
|
||||||
|
}
|
||||||
|
late final http.Response response;
|
||||||
|
try {
|
||||||
|
response = await http.post(
|
||||||
|
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.resetPassword}'),
|
||||||
|
headers: ApiConfig.headers,
|
||||||
|
body: jsonEncode({
|
||||||
|
'token': cleaned,
|
||||||
|
'password': password,
|
||||||
|
'password_confirmation': passwordConfirmation,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} on http.ClientException {
|
||||||
|
throw Exception(
|
||||||
|
'Connexion à ${ApiConfig.baseUrl} impossible. Vérifiez votre réseau puis réessayez.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.statusCode == 200 || response.statusCode == 201) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (response.statusCode == 404) {
|
||||||
|
throw Exception('Lien invalide ou expiré.');
|
||||||
|
}
|
||||||
|
final decoded = _tryDecodeJsonMap(response.body);
|
||||||
|
final msg = _extractErrorMessage(decoded, response.statusCode);
|
||||||
|
final lower = msg.toLowerCase();
|
||||||
|
if (response.statusCode == 400 &&
|
||||||
|
(lower.contains('token') ||
|
||||||
|
lower.contains('invalide') ||
|
||||||
|
lower.contains('expiré') ||
|
||||||
|
lower.contains('expire'))) {
|
||||||
|
throw Exception('Lien invalide ou expiré.');
|
||||||
|
}
|
||||||
|
throw Exception(msg);
|
||||||
|
}
|
||||||
|
|
||||||
/// 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();
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user