feat(F-083): completed feature

This commit is contained in:
chattie
2026-08-20 06:08:32 +02:00
parent 23e84efe80
commit 22ad15325f
20 changed files with 1041 additions and 25 deletions

View File

@@ -195,7 +195,26 @@ export default function CustomersPage() {
{c.createdAt ? new Date(c.createdAt).toLocaleDateString('es-ES') : '—'}
</td>
<td className="px-4 py-3.5">
<RowActions onEdit={() => router.push(`/customers/${c.id}`)} />
<div className="flex items-center gap-1">
<button
onClick={async () => {
setMsg(`Enviando enlace de recuperación a ${c.email}...`);
try {
await customersApi.sendPasswordResetLink(c.email);
setMsg(`✓ Enlace enviado a ${c.email}`);
} catch {
setMsg(`✗ No se pudo enviar el enlace a ${c.email}`);
} finally {
setTimeout(() => setMsg(''), 4000);
}
}}
title="Enviar enlace de recuperación de contraseña"
className="px-2 py-1 text-xs text-[#2D6A4F] hover:bg-green-50 rounded-lg transition-colors"
>
🔑
</button>
<RowActions onEdit={() => router.push(`/customers/${c.id}`)} />
</div>
</td>
</tr>
))}

View File

@@ -120,6 +120,8 @@ export const customersApi = {
api.patch<import('@/types').Customer>(`/api/users/${id}`, data),
create: (data: { email: string; password: string; displayName?: string; phone?: string }) =>
api.post<import('@/types').Customer>('/api/auth/register', data),
sendPasswordResetLink: (email: string) =>
api.post<{ ok: boolean }>('/api/auth/password-reset/request', { email }),
// ── Direcciones (FIX-18) ───────────────────────────────────────────────────
listAddresses: (id: string) =>
api.get<{ items: import('@/types').Address[] }>(`/api/users/${id}/addresses`),

View File

@@ -0,0 +1,20 @@
'use strict';
exports.up = async (pgm) => {
pgm.createTable('identity_password_reset_tokens', {
id: { type: 'uuid', primaryKey: true, default: pgm.func('gen_random_uuid()') },
user_id: { type: 'uuid', notNull: true, references: 'identity_users(id)', onDelete: 'CASCADE' },
token_hash: { type: 'text', notNull: true, unique: true },
expires_at: { type: 'timestamptz', notNull: true },
used_at: { type: 'timestamptz' },
request_ip: { type: 'text' },
request_user_agent: { type: 'text' },
created_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') },
});
pgm.createIndex('identity_password_reset_tokens', 'user_id');
pgm.createIndex('identity_password_reset_tokens', 'expires_at');
};
exports.down = async (pgm) => {
pgm.dropTable('identity_password_reset_tokens');
};

View File

@@ -177,12 +177,26 @@ export async function buildApp(deps: BuildAppDeps = {}): Promise<FastifyInstance
// Session resolution; created before identity registration so it can be passed
// to identity's /auth/me route.
const authenticate = createSessionAuthenticator(deps.pool);
const auditLogger = new AuditLogger(deps.pool as pg.Pool);
await app.register(async (instance) => {
await registerIdentityRoutes(instance, {
pool: deps.pool as pg.Pool,
cookieSecure: deps.cookieSecure,
authenticate,
passwordReset: {
audit: (entry) => {
// Best-effort audit; never block the request on audit failures.
void auditLogger
.log({
actorId: entry.userId ?? null,
action: entry.action,
target: entry.email ?? entry.userId ?? 'unknown',
metadata: { ip: entry.ip ?? null },
})
.catch(() => undefined);
},
},
});
});
@@ -370,7 +384,6 @@ export async function buildApp(deps: BuildAppDeps = {}): Promise<FastifyInstance
});
const rateLimiter = new RateLimiter();
const auditLogger = new AuditLogger(deps.pool);
await app.register(async (instance) => {
const broadcaster = getLogBroadcaster();
await registerSecurityRoutes(instance, {

View File

@@ -21,13 +21,24 @@ import { Argon2PasswordHasher } from '../infrastructure/argon2-password-hasher.j
import { PgUserRepository } from '../infrastructure/pg-user-repository.js';
import { PgSessionRepository } from '../infrastructure/pg-session-repository.js';
import { generateSessionToken, hashSessionToken } from '../infrastructure/session-token.js';
import type { PasswordHasher } from '../domain/ports.js';
import type {
PasswordHasher,
PasswordResetMailer,
PasswordResetTokenRepository,
ResetRateLimiter,
} from '../domain/ports.js';
import {
EmailAlreadyRegisteredError,
InvalidCredentialsError,
RateLimitedError,
} 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 { InvalidResetTokenError } from '../domain/password-reset.js';
export const SESSION_COOKIE_NAME = 'mdv_session';
@@ -40,6 +51,16 @@ export interface IdentityRoutesDeps {
rateLimiter?: LoginRateLimiter;
/** Session authenticator. Created by createSessionAuthenticator in build-app.ts. */
authenticate?: Authenticate;
/** Optional: enables POST /auth/password-reset/request and /confirm. */
passwordReset?: {
tokens?: PasswordResetTokenRepository;
rateLimiter?: ResetRateLimiter;
mailer?: PasswordResetMailer;
/** Build absolute reset URL from raw token. Defaults to storefront path. */
buildResetUrl?: (rawToken: string) => string;
/** Audit sink (defaults to no-op). */
audit?: (entry: { action: string; userId?: string; email?: string; ip?: string }) => void;
};
}
const credentialsSchema = z.object({
@@ -212,6 +233,137 @@ export async function registerIdentityRoutes(
throw error;
}
});
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 buildResetUrl =
pr.buildResetUrl ??
((token: string) => `/cuenta/restablecer?token=${encodeURIComponent(token)}`);
const requestReset = new RequestPasswordReset({
users: usersRepo,
tokens,
rateLimiter,
mailer,
buildResetUrl,
audit: pr.audit,
});
const confirmReset = new ConfirmPasswordReset({
tokens,
users: usersRepo,
hasher,
audit: pr.audit,
});
const requestSchema: FastifySchema = {
tags: ['Auth'],
summary: 'Request password reset',
description:
'Solicita un enlace de recuperación por email. Siempre responde 200 para evitar enumeración de usuarios.',
body: {
type: 'object',
required: ['email'],
properties: {
email: { type: 'string', format: 'email', maxLength: 255 },
},
},
response: { 200: { type: 'object', properties: { ok: { type: 'boolean' } } } },
};
const confirmSchema: FastifySchema = {
tags: ['Auth'],
summary: 'Confirm password reset',
description: 'Acepta un nuevo password usando el token recibido por email.',
body: {
type: 'object',
required: ['token', 'password'],
properties: {
token: { type: 'string', minLength: 16, maxLength: 128 },
password: { type: 'string', minLength: 8, maxLength: 128 },
},
},
response: { 200: { type: 'object', properties: { ok: { type: 'boolean' } } }, 400: errorSchema },
};
app.post(
'/auth/password-reset/request',
{ schema: requestSchema },
async (request, reply) => {
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;
}
},
);
}
}
/** Simple in-memory token-bucket rate limiter for the password reset endpoints. */
export class InMemoryResetRateLimiter implements ResetRateLimiter {
private buckets = new Map<string, { count: number; resetAt: number }>();
constructor(
private readonly windowMs = 60 * 60 * 1000,
private readonly limit = 5,
) {}
async allow(key: string): Promise<boolean> {
const now = Date.now();
const cur = this.buckets.get(key);
if (!cur || cur.resetAt <= now) {
this.buckets.set(key, { count: 1, resetAt: now + this.windowMs });
return true;
}
if (cur.count >= this.limit) return false;
cur.count += 1;
return true;
}
}
/** 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 {

View File

@@ -0,0 +1,131 @@
/**
* Password reset use cases.
*
* - RequestPasswordReset: always succeeds at the HTTP level to avoid user enumeration.
* Stores hashed token, rate-limited per IP and per email, triggers email.
* - ConfirmPasswordReset: validates the token, updates the password hash, marks token used.
*/
import { randomBytes } from 'node:crypto';
import { hashSessionToken } from '../infrastructure/session-token.js';
import {
InvalidResetTokenError,
RESET_TOKEN_TTL_MS,
isTokenUsable,
type PasswordResetToken,
} from '../domain/password-reset.js';
import type {
PasswordHasher,
PasswordResetTokenRepository,
ResetRateLimiter,
PasswordResetMailer,
UserRepository,
} from '../domain/ports.js';
import { normalizeEmail } from '../domain/user.js';
export interface RequestPasswordResetDeps {
users: UserRepository;
tokens: PasswordResetTokenRepository;
rateLimiter: ResetRateLimiter;
mailer: PasswordResetMailer;
/** Build the absolute URL the customer will click. */
buildResetUrl: (rawToken: string) => string;
/** Optional logger/audit sink. No-op by default. */
audit?: (entry: { action: string; userId?: string; email?: string; ip?: string }) => void;
/** Equalises timing when user does not exist. */
noUserDelay?: () => Promise<void>;
/** Locale for the email template. */
defaultLocale?: string;
}
export class RequestPasswordReset {
constructor(private readonly deps: RequestPasswordResetDeps) {}
async execute(input: { email: string; ip?: string; userAgent?: string }): Promise<void> {
const email = normalizeEmail(input.email);
const ipKey = `ip:${input.ip ?? 'unknown'}`;
const emailKey = `email:${email}`;
const [ipOk, emailOk] = await Promise.all([
this.deps.rateLimiter.allow(ipKey),
this.deps.rateLimiter.allow(emailKey),
]);
if (!ipOk || !emailOk) {
// Silently drop; do not reveal rate limiting state.
return;
}
const userWithHash = await this.deps.users.findByEmail(email);
if (!userWithHash) {
await (this.deps.noUserDelay?.() ?? defaultEqualiseDelay());
return;
}
await this.deps.tokens.invalidateAllForUser(userWithHash.id);
const rawToken = randomBytes(32).toString('hex');
const tokenHash = hashToken(rawToken);
const expiresAt = new Date(Date.now() + RESET_TOKEN_TTL_MS);
await this.deps.tokens.create({
userId: userWithHash.id,
tokenHash,
expiresAt,
requestIp: input.ip,
requestUserAgent: input.userAgent,
});
this.deps.audit?.({
action: 'password_reset.requested',
userId: userWithHash.id,
email,
ip: input.ip,
});
const resetUrl = this.deps.buildResetUrl(rawToken);
await this.deps.mailer.sendPasswordReset({
email,
resetUrl,
locale: this.deps.defaultLocale ?? 'es',
});
}
}
export interface ConfirmPasswordResetDeps {
tokens: PasswordResetTokenRepository;
users: UserRepository;
hasher: PasswordHasher;
audit?: (entry: { action: string; userId?: string; ip?: string }) => void;
}
export class ConfirmPasswordReset {
constructor(private readonly deps: ConfirmPasswordResetDeps) {}
async execute(input: { token: string; newPassword: string }): Promise<void> {
if (input.newPassword.length < 8 || input.newPassword.length > 128) {
throw new InvalidResetTokenError();
}
const tokenHash = hashToken(input.token);
const token = await this.deps.tokens.findByTokenHash(tokenHash);
if (!token || !isTokenUsable(token)) {
throw new InvalidResetTokenError();
}
const newHash = await this.deps.hasher.hash(input.newPassword);
await this.deps.users.updateUser(token.userId, { passwordHash: newHash });
await this.deps.tokens.markUsed(token.id);
this.deps.audit?.({
action: 'password_reset.confirmed',
userId: token.userId,
});
}
}
/** Same hash algorithm as session tokens. */
export const hashToken = hashSessionToken;
async function defaultEqualiseDelay(): Promise<void> {
// ~80ms equalises with the hasher path roughly. Tune if needed.
await new Promise((r) => setTimeout(r, 80));
}
// Re-export for tests
export type { PasswordResetToken };

View File

@@ -0,0 +1,26 @@
/**
* Password reset token domain. Pure types and rules.
*/
export interface PasswordResetToken {
id: string;
userId: string;
tokenHash: string;
expiresAt: Date;
usedAt: Date | null;
createdAt: Date;
}
export class InvalidResetTokenError extends Error {
constructor() {
super('Invalid or expired password reset token');
this.name = 'InvalidResetTokenError';
}
}
export function isTokenUsable(token: PasswordResetToken, now: Date = new Date()): boolean {
if (token.usedAt !== null) return false;
if (token.expiresAt.getTime() <= now.getTime()) return false;
return true;
}
export const RESET_TOKEN_TTL_MS = 60 * 60 * 1000; // 1 hour

View File

@@ -3,6 +3,7 @@
*/
import type { NewUser, User } from './user.js';
import type { Session } from './session.js';
import type { PasswordResetToken } from './password-reset.js';
export interface PasswordHasher {
hash(plain: string): Promise<string>;
@@ -29,3 +30,25 @@ export interface SessionRepository {
/** Revokes by token hash. Returns true when a live session was revoked. */
revokeByTokenHash(tokenHash: string): Promise<boolean>;
}
export interface PasswordResetTokenRepository {
invalidateAllForUser(userId: string): Promise<void>;
create(input: {
userId: string;
tokenHash: string;
expiresAt: Date;
requestIp?: string;
requestUserAgent?: string;
}): Promise<PasswordResetToken>;
findByTokenHash(tokenHash: string): Promise<PasswordResetToken | undefined>;
markUsed(id: string): Promise<void>;
}
export interface ResetRateLimiter {
/** Returns true if the action should proceed; false if it should be silently rejected. */
allow(key: string): Promise<boolean>;
}
export interface PasswordResetMailer {
sendPasswordReset(input: { email: string; resetUrl: string; locale?: string }): Promise<void>;
}

View File

@@ -0,0 +1,71 @@
import type pg from 'pg';
import type { PasswordResetToken } from '../domain/password-reset.js';
import type { PasswordResetTokenRepository } from '../domain/ports.js';
type Row = {
id: string;
user_id: string;
token_hash: string;
expires_at: Date;
used_at: Date | null;
created_at: Date;
};
function toDomain(row: Row): PasswordResetToken {
return {
id: row.id,
userId: row.user_id,
tokenHash: row.token_hash,
expiresAt: row.expires_at,
usedAt: row.used_at,
createdAt: row.created_at,
};
}
export class PgPasswordResetTokenRepository implements PasswordResetTokenRepository {
constructor(private readonly pool: pg.Pool) {}
async invalidateAllForUser(userId: string): Promise<void> {
await this.pool.query(
`UPDATE identity_password_reset_tokens
SET used_at = now()
WHERE user_id = $1 AND used_at IS NULL`,
[userId],
);
}
async create(input: {
userId: string;
tokenHash: string;
expiresAt: Date;
requestIp?: string;
requestUserAgent?: string;
}): Promise<PasswordResetToken> {
const result = await this.pool.query<Row>(
`INSERT INTO identity_password_reset_tokens
(user_id, token_hash, expires_at, request_ip, request_user_agent)
VALUES ($1, $2, $3, $4, $5)
RETURNING *`,
[input.userId, input.tokenHash, input.expiresAt, input.requestIp ?? null, input.requestUserAgent ?? null],
);
const row = result.rows[0];
if (!row) throw new Error('password reset token INSERT returned no row');
return toDomain(row);
}
async findByTokenHash(tokenHash: string): Promise<PasswordResetToken | undefined> {
const result = await this.pool.query<Row>(
`SELECT * FROM identity_password_reset_tokens WHERE token_hash = $1`,
[tokenHash],
);
const row = result.rows[0];
return row ? toDomain(row) : undefined;
}
async markUsed(id: string): Promise<void> {
await this.pool.query(
`UPDATE identity_password_reset_tokens SET used_at = now() WHERE id = $1 AND used_at IS NULL`,
[id],
);
}
}

View File

@@ -0,0 +1,215 @@
import { describe, it, expect, vi } from 'vitest';
import { RequestPasswordReset, ConfirmPasswordReset, hashToken } from '../application/password-reset.js';
import { InvalidResetTokenError, isTokenUsable, RESET_TOKEN_TTL_MS } from '../domain/password-reset.js';
import type {
UserRepository,
PasswordResetTokenRepository,
ResetRateLimiter,
PasswordResetMailer,
PasswordHasher,
} from '../domain/ports.js';
import type { PasswordResetToken } from '../domain/password-reset.js';
function makeToken(overrides: Partial<PasswordResetToken> = {}): PasswordResetToken {
return {
id: 'tok-1',
userId: 'user-1',
tokenHash: 'h',
expiresAt: new Date(Date.now() + RESET_TOKEN_TTL_MS),
usedAt: null,
createdAt: new Date(),
...overrides,
};
}
describe('password reset', () => {
it('hashToken is sha256 hex', () => {
const a = hashToken('hello');
const b = hashToken('hello');
const c = hashToken('hellp');
expect(a).toBe(b);
expect(a).not.toBe(c);
expect(a).toMatch(/^[0-9a-f]{64}$/);
});
it('isTokenUsable returns false for used tokens', () => {
const t = makeToken({ usedAt: new Date() });
expect(isTokenUsable(t)).toBe(false);
});
it('isTokenUsable returns false for expired tokens', () => {
const t = makeToken({ expiresAt: new Date(Date.now() - 1000) });
expect(isTokenUsable(t)).toBe(false);
});
it('RequestPasswordReset is silent when user does not exist (no enumeration)', async () => {
const users: UserRepository = {
create: vi.fn(),
findByEmail: vi.fn().mockResolvedValue(undefined),
findById: vi.fn(),
listUsers: vi.fn(),
updateUser: vi.fn(),
deleteUser: vi.fn(),
};
const tokens: PasswordResetTokenRepository = {
invalidateAllForUser: vi.fn(),
create: vi.fn(),
findByTokenHash: vi.fn(),
markUsed: vi.fn(),
};
const rateLimiter: ResetRateLimiter = { allow: vi.fn().mockResolvedValue(true) };
const mailer: PasswordResetMailer = { sendPasswordReset: vi.fn() };
const audit = vi.fn();
const useCase = new RequestPasswordReset({
users,
tokens,
rateLimiter,
mailer,
buildResetUrl: (t) => `https://app/r/${t}`,
audit,
});
await useCase.execute({ email: 'nobody@example.com' });
expect(tokens.create).not.toHaveBeenCalled();
expect(mailer.sendPasswordReset).not.toHaveBeenCalled();
expect(audit).not.toHaveBeenCalled();
});
it('RequestPasswordReset creates a token and emails when user exists', async () => {
const fakeUser = { id: 'u-1', email: 'a@b.com', role: 'customer' as const, passwordHash: 'x', createdAt: new Date() };
const users: UserRepository = {
create: vi.fn(),
findByEmail: vi.fn().mockResolvedValue(fakeUser),
findById: vi.fn(),
listUsers: vi.fn(),
updateUser: vi.fn(),
deleteUser: vi.fn(),
};
const tokens: PasswordResetTokenRepository = {
invalidateAllForUser: vi.fn().mockResolvedValue(undefined),
create: vi.fn().mockResolvedValue(makeToken()),
findByTokenHash: vi.fn(),
markUsed: vi.fn(),
};
const rateLimiter: ResetRateLimiter = { allow: vi.fn().mockResolvedValue(true) };
const mailer: PasswordResetMailer = { sendPasswordReset: vi.fn().mockResolvedValue(undefined) };
const audit = vi.fn();
const useCase = new RequestPasswordReset({
users,
tokens,
rateLimiter,
mailer,
buildResetUrl: (t) => `https://app/r/${t}`,
audit,
});
await useCase.execute({ email: 'a@b.com', ip: '127.0.0.1' });
expect(tokens.invalidateAllForUser).toHaveBeenCalledWith('u-1');
expect(tokens.create).toHaveBeenCalledOnce();
expect(mailer.sendPasswordReset).toHaveBeenCalledOnce();
expect(audit).toHaveBeenCalledWith(expect.objectContaining({ action: 'password_reset.requested' }));
});
it('RequestPasswordReset drops the request silently when rate limited', async () => {
const users: UserRepository = {
create: vi.fn(),
findByEmail: vi.fn(),
findById: vi.fn(),
listUsers: vi.fn(),
updateUser: vi.fn(),
deleteUser: vi.fn(),
};
const tokens: PasswordResetTokenRepository = {
invalidateAllForUser: vi.fn(),
create: vi.fn(),
findByTokenHash: vi.fn(),
markUsed: vi.fn(),
};
const rateLimiter: ResetRateLimiter = { allow: vi.fn().mockResolvedValue(false) };
const mailer: PasswordResetMailer = { sendPasswordReset: vi.fn() };
const useCase = new RequestPasswordReset({
users,
tokens,
rateLimiter,
mailer,
buildResetUrl: (t) => t,
});
await useCase.execute({ email: 'a@b.com' });
expect(tokens.create).not.toHaveBeenCalled();
expect(users.findByEmail).not.toHaveBeenCalled();
});
it('ConfirmPasswordReset updates password and marks token used on success', async () => {
const valid = makeToken({ userId: 'u-1', tokenHash: hashToken('tok') });
const tokens: PasswordResetTokenRepository = {
invalidateAllForUser: vi.fn(),
create: vi.fn(),
findByTokenHash: vi.fn().mockResolvedValue(valid),
markUsed: vi.fn().mockResolvedValue(undefined),
};
const users: UserRepository = {
create: vi.fn(),
findByEmail: vi.fn(),
findById: vi.fn(),
listUsers: vi.fn(),
updateUser: vi.fn().mockResolvedValue({ id: 'u-1', email: 'a', role: 'customer', createdAt: new Date() }),
deleteUser: vi.fn(),
};
const hasher: PasswordHasher = { hash: vi.fn().mockResolvedValue('NEWHASH'), verify: vi.fn() };
const audit = vi.fn();
const useCase = new ConfirmPasswordReset({ tokens, users, hasher, audit });
await useCase.execute({ token: 'tok', newPassword: 'newpassword123' });
expect(hasher.hash).toHaveBeenCalledWith('newpassword123');
expect(users.updateUser).toHaveBeenCalledWith('u-1', { passwordHash: 'NEWHASH' });
expect(tokens.markUsed).toHaveBeenCalledWith('tok-1');
expect(audit).toHaveBeenCalledWith(expect.objectContaining({ action: 'password_reset.confirmed' }));
});
it('ConfirmPasswordReset rejects expired/used/missing tokens', async () => {
const tokens: PasswordResetTokenRepository = {
invalidateAllForUser: vi.fn(),
create: vi.fn(),
findByTokenHash: vi.fn().mockResolvedValue(undefined),
markUsed: vi.fn(),
};
const users: UserRepository = {
create: vi.fn(),
findByEmail: vi.fn(),
findById: vi.fn(),
listUsers: vi.fn(),
updateUser: vi.fn(),
deleteUser: vi.fn(),
};
const hasher: PasswordHasher = { hash: vi.fn(), verify: vi.fn() };
const useCase = new ConfirmPasswordReset({ tokens, users, hasher });
await expect(
useCase.execute({ token: 'tok', newPassword: 'newpassword123' }),
).rejects.toBeInstanceOf(InvalidResetTokenError);
});
it('ConfirmPasswordReset rejects passwords shorter than 8 chars', async () => {
const tokens: PasswordResetTokenRepository = {
invalidateAllForUser: vi.fn(),
create: vi.fn(),
findByTokenHash: vi.fn(),
markUsed: vi.fn(),
};
const users: UserRepository = {
create: vi.fn(),
findByEmail: vi.fn(),
findById: vi.fn(),
listUsers: vi.fn(),
updateUser: vi.fn(),
deleteUser: vi.fn(),
};
const hasher: PasswordHasher = { hash: vi.fn(), verify: vi.fn() };
const useCase = new ConfirmPasswordReset({ tokens, users, hasher });
await expect(
useCase.execute({ token: 'tok', newPassword: 'short' }),
).rejects.toBeInstanceOf(InvalidResetTokenError);
});
});

View File

@@ -0,0 +1,19 @@
import { NextRequest, NextResponse } from 'next/server';
const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://127.0.0.1:3000';
/** Proxy to backend POST /auth/password-reset/confirm. */
export async function POST(req: NextRequest) {
const body = await req.text();
try {
const backendRes = await fetch(`${API}/auth/password-reset/confirm`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body,
});
const data = await backendRes.json().catch(() => null);
return NextResponse.json(data ?? { error: 'Bad response' }, { status: backendRes.status });
} catch {
return NextResponse.json({ error: 'Proxy error' }, { status: 502 });
}
}

View File

@@ -0,0 +1,112 @@
'use client';
import { useState, Suspense } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
function RestablecerForm() {
const router = useRouter();
const params = useSearchParams();
const token = params.get('token') ?? '';
const [password, setPassword] = useState('');
const [confirm, setConfirm] = useState('');
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const [done, setDone] = useState(false);
if (!token) {
return (
<div className="p-8 bg-white border border-gray-200 rounded-xl text-sm text-gray-700">
Enlace inválido. Solicita un nuevo enlace desde la página de inicio de sesión.
</div>
);
}
const submit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (password.length < 8) {
setError('La contraseña debe tener al menos 8 caracteres.');
return;
}
if (password !== confirm) {
setError('Las contraseñas no coinciden.');
return;
}
setSaving(true);
try {
const res = await fetch('/api/auth/password-reset/confirm', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token, password }),
});
if (!res.ok) {
const data = await res.json().catch(() => null);
setError(data?.message ?? 'Enlace inválido o caducado. Solicita uno nuevo.');
return;
}
setDone(true);
setTimeout(() => router.push('/cuenta/iniciar-sesion'), 2500);
} catch {
setError('Error de red. Inténtalo de nuevo.');
} finally {
setSaving(false);
}
};
if (done) {
return (
<div className="p-8 bg-white border border-gray-200 rounded-xl text-sm text-green-700">
Tu contraseña se ha actualizado. Te llevamos a iniciar sesión...
</div>
);
}
return (
<form onSubmit={submit} className="p-8 bg-white border border-gray-200 rounded-xl space-y-4 max-w-md w-full">
<div>
<h1 className="text-xl font-bold text-gray-900">Restablecer contraseña</h1>
<p className="text-sm text-gray-500 mt-1">Introduce tu nueva contraseña.</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Nueva contraseña</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
autoFocus
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Confirmar contraseña</label>
<input
type="password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
required
minLength={8}
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none"
/>
</div>
{error && <p className="text-sm text-red-600 bg-red-50 rounded-xl px-4 py-2">{error}</p>}
<button
type="submit"
disabled={saving}
className="w-full px-5 py-2.5 bg-[#2D6A4F] hover:bg-[#1B4332] disabled:opacity-50 text-white text-sm font-semibold rounded-xl transition-colors"
>
{saving ? 'Guardando...' : 'Restablecer contraseña'}
</button>
</form>
);
}
export default function RestablecerPage() {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
<Suspense fallback={<div className="text-gray-400 text-sm">Cargando...</div>}>
<RestablecerForm />
</Suspense>
</div>
);
}