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: '/',

View File

@@ -50,5 +50,8 @@ export interface ResetRateLimiter {
}
export interface PasswordResetMailer {
/** Optional readiness check; absent in test doubles and legacy adapters. */
isConfigured?: () => boolean;
assertReady?: () => Promise<void>;
sendPasswordReset(input: { email: string; resetUrl: string; locale?: string }): Promise<void>;
}

View File

@@ -0,0 +1,51 @@
import type pg from 'pg';
import type { PasswordResetMailer } from '../domain/ports.js';
import { SmtpPasswordResetMailer, type SmtpPasswordResetMailerOptions } from './smtp-password-reset-mailer.js';
const SMTP_KEYS = ['smtp_host', 'smtp_port', 'smtp_secure', 'smtp_user', 'smtp_pass', 'smtp_from'] as const;
/** Reads SMTP credentials from store_settings so admins can change them without a deploy. */
export class SettingsPasswordResetMailer implements PasswordResetMailer {
constructor(private readonly pool: pg.Pool) {}
isConfigured(): boolean {
return true;
}
async assertReady(): Promise<void> {
await this.readMailer();
}
async sendPasswordReset(input: { email: string; resetUrl: string; locale?: string }): Promise<void> {
const mailer = await this.readMailer();
await mailer.sendPasswordReset(input);
}
private async readMailer(): Promise<SmtpPasswordResetMailer> {
const result = await this.pool.query<{ key: string; value: string }>(
`SELECT key, value FROM store_settings WHERE key = ANY($1::text[])`,
[SMTP_KEYS],
);
const settings = Object.fromEntries(result.rows.map((row) => [row.key, row.value]));
const host = settings.smtp_host?.trim();
const user = settings.smtp_user?.trim();
const password = settings.smtp_pass;
const from = settings.smtp_from?.trim() || user;
if (!host || !user || !password || !from) {
throw new Error('SMTP is not configured in Ajustes → SMTP / Email');
}
const port = Number(settings.smtp_port || '465');
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error('SMTP port is invalid in Ajustes → SMTP / Email');
}
const options: SmtpPasswordResetMailerOptions = {
host,
port,
secure: settings.smtp_secure !== 'false' || port === 465,
user,
password,
from,
};
return new SmtpPasswordResetMailer(options);
}
}

View File

@@ -0,0 +1,90 @@
import nodemailer, { type Transporter } from 'nodemailer';
import type { PasswordResetMailer } from '../domain/ports.js';
export interface SmtpPasswordResetMailerOptions {
host: string;
port: number;
secure: boolean;
user: string;
password: string;
from: string;
}
/** Sends password-reset messages through an authenticated SMTP server. */
export class SmtpPasswordResetMailer implements PasswordResetMailer {
private readonly transporter: Transporter;
constructor(private readonly options: SmtpPasswordResetMailerOptions) {
this.transporter = nodemailer.createTransport({
host: options.host,
port: options.port,
secure: options.secure,
auth: { user: options.user, pass: options.password },
});
}
isConfigured(): boolean {
return true;
}
async assertReady(): Promise<void> {
return;
}
async sendPasswordReset(input: { email: string; resetUrl: string; locale?: string }): Promise<void> {
await this.transporter.sendMail({
from: this.options.from,
to: input.email,
subject: 'Restablece tu contraseña — Mercado de Vida',
text: [
'Has solicitado restablecer tu contraseña de Mercado de Vida.',
'',
`Abre este enlace para continuar: ${input.resetUrl}`,
'',
'Si no solicitaste este cambio, puedes ignorar este correo.',
'El enlace caduca en una hora y solo puede utilizarse una vez.',
].join('\n'),
html: [
'<p>Has solicitado restablecer tu contraseña de Mercado de Vida.</p>',
`<p><a href="${escapeHtml(input.resetUrl)}">Restablecer contraseña</a></p>`,
'<p>Si no solicitaste este cambio, puedes ignorar este correo.</p>',
'<p>El enlace caduca en una hora y solo puede utilizarse una vez.</p>',
].join(''),
});
}
}
export function createPasswordResetMailer(env: NodeJS.ProcessEnv = process.env): PasswordResetMailer {
const host = env.SMTP_HOST?.trim();
const user = env.SMTP_USER?.trim();
const password = env.SMTP_PASS;
const from = env.SMTP_FROM?.trim() || user;
if (!host || !user || !password || !from) {
return {
isConfigured: () => false,
async assertReady() {
throw new Error('SMTP is not configured; set SMTP_HOST, SMTP_USER, SMTP_PASS and SMTP_FROM');
},
async sendPasswordReset() {
throw new Error('SMTP is not configured; set SMTP_HOST, SMTP_USER, SMTP_PASS and SMTP_FROM');
},
};
}
const port = Number(env.SMTP_PORT ?? '587');
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error('SMTP_PORT must be a valid TCP port');
}
const secure = env.SMTP_SECURE === 'true' || port === 465;
return new SmtpPasswordResetMailer({ host, port, secure, user, password, from });
}
function escapeHtml(value: string): string {
return value.replace(/[&<>"']/g, (character) => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
})[character] ?? character);
}