feat(F-099): completed feature

This commit is contained in:
chattie
2026-08-21 07:29:07 +02:00
parent 3f1d08382f
commit 5177a851aa
41 changed files with 922 additions and 131 deletions

View File

@@ -39,6 +39,7 @@ import {
RequestPasswordReset,
} from '../application/password-reset.js';
import { InvalidResetTokenError } from '../domain/password-reset.js';
import { createPasswordResetMailer } from '../infrastructure/smtp-password-reset-mailer.js';
export const SESSION_COOKIE_NAME = 'mdv_session';
@@ -234,15 +235,30 @@ export async function registerIdentityRoutes(
}
});
app.patch('/auth/me/password', async (request, reply) => {
const user = await deps.authenticate!(request);
const input = parseJson(
z.object({ currentPassword: z.string().min(1).max(128), newPassword: z.string().min(8).max(128) }),
request.body,
);
const record = await users.findByEmail(user.email);
if (!record || !(await hasher.verify(record.passwordHash, input.currentPassword))) {
throw new AppError(400, 'INVALID_CURRENT_PASSWORD', 'La contraseña actual no es válida');
}
await users.updateUser(user.id, { passwordHash: await hasher.hash(input.newPassword) });
return reply.send({ ok: true });
});
if (deps.passwordReset) {
const pr = deps.passwordReset;
const tokens = pr.tokens ?? new PgPasswordResetTokenRepository(deps.pool);
const usersRepo = new PgUserRepository(deps.pool);
const rateLimiter = pr.rateLimiter ?? new InMemoryResetRateLimiter();
const mailer = pr.mailer ?? new LoggingPasswordResetMailer();
const mailer = pr.mailer ?? createPasswordResetMailer();
const publicAppUrl = (process.env.PUBLIC_APP_URL ?? 'https://mercadodevida.es').replace(/\/$/, '');
const buildResetUrl =
pr.buildResetUrl ??
((token: string) => `/cuenta/restablecer?token=${encodeURIComponent(token)}`);
((token: string) => `${publicAppUrl}/cuenta/restablecer?token=${encodeURIComponent(token)}`);
const requestReset = new RequestPasswordReset({
users: usersRepo,
@@ -293,6 +309,15 @@ export async function registerIdentityRoutes(
'/auth/password-reset/request',
{ schema: requestSchema },
async (request, reply) => {
if (mailer.assertReady) {
try {
await mailer.assertReady();
} catch {
throw new AppError(422, 'EMAIL_DELIVERY_NOT_CONFIGURED', 'Configura SMTP en Ajustes → SMTP / Email');
}
} else if (mailer.isConfigured && !mailer.isConfigured()) {
throw new AppError(422, 'EMAIL_DELIVERY_NOT_CONFIGURED', 'Configura SMTP en Ajustes → SMTP / Email');
}
const input = parseJson(
z.object({ email: z.email().max(255) }),
request.body,
@@ -350,22 +375,6 @@ export class InMemoryResetRateLimiter implements ResetRateLimiter {
}
}
/** Logs the reset email to stdout; production should replace with a real provider. */
export class LoggingPasswordResetMailer implements PasswordResetMailer {
async sendPasswordReset(input: { email: string; resetUrl: string; locale?: string }): Promise<void> {
// eslint-disable-next-line no-console
console.log(
JSON.stringify({
level: 'info',
msg: 'password_reset_email',
to: input.email,
url: input.resetUrl,
locale: input.locale ?? 'es',
}),
);
}
}
function setSessionCookie(reply: FastifyReply, token: string, secure: boolean): void {
void reply.setCookie(SESSION_COOKIE_NAME, token, {
path: '/',