feat(F-099): completed feature
This commit is contained in:
@@ -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: '/',
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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) => ({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
})[character] ?? character);
|
||||
}
|
||||
Reference in New Issue
Block a user