feat(F-152): completed feature
This commit is contained in:
@@ -26,6 +26,7 @@ import type {
|
||||
PasswordResetMailer,
|
||||
PasswordResetTokenRepository,
|
||||
ResetRateLimiter,
|
||||
WelcomeMailer,
|
||||
} from '../domain/ports.js';
|
||||
import {
|
||||
EmailAlreadyRegisteredError,
|
||||
@@ -34,10 +35,7 @@ import {
|
||||
} from '../domain/errors.js';
|
||||
import { SESSION_TTL_MS } from '../domain/session.js';
|
||||
import { PgPasswordResetTokenRepository } from '../infrastructure/pg-password-reset-token-repository.js';
|
||||
import {
|
||||
ConfirmPasswordReset,
|
||||
RequestPasswordReset,
|
||||
} from '../application/password-reset.js';
|
||||
import { ConfirmPasswordReset, RequestPasswordReset } from '../application/password-reset.js';
|
||||
import { InvalidResetTokenError } from '../domain/password-reset.js';
|
||||
import { createPasswordResetMailer } from '../infrastructure/smtp-password-reset-mailer.js';
|
||||
|
||||
@@ -62,6 +60,8 @@ export interface IdentityRoutesDeps {
|
||||
/** Audit sink (defaults to no-op). */
|
||||
audit?: (entry: { action: string; userId?: string; email?: string; ip?: string }) => void;
|
||||
};
|
||||
/** Best-effort welcome email sent on account creation (F-152). */
|
||||
welcomeMailer?: WelcomeMailer;
|
||||
}
|
||||
|
||||
const credentialsSchema = z.object({
|
||||
@@ -80,6 +80,7 @@ export async function registerIdentityRoutes(
|
||||
const rateLimiter = deps.rateLimiter ?? new InMemoryLoginRateLimiter();
|
||||
|
||||
const registerUser = new RegisterUser(users, hasher);
|
||||
const welcomeMailer = deps.welcomeMailer;
|
||||
const login = new Login({
|
||||
users,
|
||||
sessions,
|
||||
@@ -185,6 +186,15 @@ export async function registerIdentityRoutes(
|
||||
const input = parseJson(credentialsSchema, request.body);
|
||||
try {
|
||||
const user = await registerUser.execute(input);
|
||||
// F-152: best-effort welcome email. Never blocks account creation; a
|
||||
// delivery failure is logged and swallowed.
|
||||
if (welcomeMailer) {
|
||||
void welcomeMailer
|
||||
.sendWelcome({ email: user.email })
|
||||
.catch((error) =>
|
||||
request.log.warn({ err: error, userId: user.id }, 'welcome_email_failed'),
|
||||
);
|
||||
}
|
||||
return reply
|
||||
.code(201)
|
||||
.send({ id: user.id, email: user.email, role: user.role, createdAt: user.createdAt });
|
||||
@@ -238,7 +248,10 @@ 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) }),
|
||||
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);
|
||||
@@ -255,7 +268,10 @@ export async function registerIdentityRoutes(
|
||||
const usersRepo = new PgUserRepository(deps.pool);
|
||||
const rateLimiter = pr.rateLimiter ?? new InMemoryResetRateLimiter();
|
||||
const mailer = pr.mailer ?? createPasswordResetMailer();
|
||||
const publicAppUrl = (process.env.PUBLIC_APP_URL ?? 'https://mercadodevida.es').replace(/\/$/, '');
|
||||
const publicAppUrl = (process.env.PUBLIC_APP_URL ?? 'https://mercadodevida.es').replace(
|
||||
/\/$/,
|
||||
'',
|
||||
);
|
||||
const buildResetUrl =
|
||||
pr.buildResetUrl ??
|
||||
((token: string) => `${publicAppUrl}/cuenta/restablecer?token=${encodeURIComponent(token)}`);
|
||||
@@ -302,56 +318,57 @@ export async function registerIdentityRoutes(
|
||||
password: { type: 'string', minLength: 8, maxLength: 128 },
|
||||
},
|
||||
},
|
||||
response: { 200: { type: 'object', properties: { ok: { type: 'boolean' } } }, 400: errorSchema },
|
||||
response: {
|
||||
200: { type: 'object', properties: { ok: { type: 'boolean' } } },
|
||||
400: errorSchema,
|
||||
},
|
||||
};
|
||||
|
||||
app.post(
|
||||
'/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,
|
||||
);
|
||||
const ip = (request.headers['x-forwarded-for'] as string | undefined)?.split(',')[0]?.trim()
|
||||
?? request.ip;
|
||||
const userAgent = request.headers['user-agent'];
|
||||
await requestReset.execute({ email: input.email, ip, userAgent });
|
||||
return reply.send({ ok: true });
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/auth/password-reset/confirm',
|
||||
{ schema: confirmSchema },
|
||||
async (request, reply) => {
|
||||
const input = parseJson(
|
||||
z.object({
|
||||
token: z.string().min(16).max(128),
|
||||
password: z.string().min(8).max(128),
|
||||
}),
|
||||
request.body,
|
||||
);
|
||||
app.post('/auth/password-reset/request', { schema: requestSchema }, async (request, reply) => {
|
||||
if (mailer.assertReady) {
|
||||
try {
|
||||
await confirmReset.execute({ token: input.token, newPassword: input.password });
|
||||
return reply.send({ ok: true });
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidResetTokenError) {
|
||||
throw new AppError(400, 'INVALID_RESET_TOKEN', 'Invalid or expired reset token');
|
||||
}
|
||||
throw error;
|
||||
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);
|
||||
const ip =
|
||||
(request.headers['x-forwarded-for'] as string | undefined)?.split(',')[0]?.trim() ??
|
||||
request.ip;
|
||||
const userAgent = request.headers['user-agent'];
|
||||
await requestReset.execute({ email: input.email, ip, userAgent });
|
||||
return reply.send({ ok: true });
|
||||
});
|
||||
|
||||
app.post('/auth/password-reset/confirm', { schema: confirmSchema }, async (request, reply) => {
|
||||
const input = parseJson(
|
||||
z.object({
|
||||
token: z.string().min(16).max(128),
|
||||
password: z.string().min(8).max(128),
|
||||
}),
|
||||
request.body,
|
||||
);
|
||||
try {
|
||||
await confirmReset.execute({ token: input.token, newPassword: input.password });
|
||||
return reply.send({ ok: true });
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidResetTokenError) {
|
||||
throw new AppError(400, 'INVALID_RESET_TOKEN', 'Invalid or expired reset token');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user