Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .nvmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
22.14.0
18 changes: 18 additions & 0 deletions src/features/auth/application/dtos/auth.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,24 @@ export const authResponseSchema = z.object({
token: z.string(),
});

export const forgotPasswordSchema = z.object({
email: z.string().email("Invalid email format"),
});

export const resetPasswordSchema = z.object({
token: z.string().min(1, "Token is required"),
password: z
.string()
.min(8, "Password must be at least 8 characters")
.max(100, "Password must not exceed 100 characters")
.regex(/[A-Z]/, "Password must contain at least one uppercase letter")
.regex(/[a-z]/, "Password must contain at least one lowercase letter")
.regex(/[0-9]/, "Password must contain at least one number")
.regex(/[@$!%*?&.]/, "Password must contain at least one special character"),
});

export type LoginDTO = z.infer<typeof loginSchema>;
export type RegisterDTO = z.infer<typeof registerSchema>;
export type AuthResponse = z.infer<typeof authResponseSchema>;
export type ForgotPasswordDTO = z.infer<typeof forgotPasswordSchema>;
export type ResetPasswordDTO = z.infer<typeof resetPasswordSchema>;
134 changes: 133 additions & 1 deletion src/features/auth/application/services/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,22 @@ import { createHandler } from "@/core/infrastructure/lib/handler.wrapper,";
import {
LoginRoute,
RegisterRoute,
ForgotPasswordRoute,
ResetPasswordRoute,
} from "@/auth/infrastructure/controllers/auth.routes";
import { hash, verify } from "@/shared/utils/crypto.util";
import { generateToken } from "@/shared/utils/jwt.util";
import { EmailService } from "@/email/application/services/email.service";
import * as HttpStatusCodes from "stoker/http-status-codes";
import { randomBytes } from "crypto";

export class AuthService {
private static instance: AuthService;
private emailService: EmailService;

constructor(private readonly userRepository: IUserRepository) {}
constructor(private readonly userRepository: IUserRepository) {
this.emailService = EmailService.getInstance();
}

public static getInstance(userRepository: IUserRepository): AuthService {
if (!AuthService.instance) {
Expand Down Expand Up @@ -131,4 +138,129 @@ export class AuthService {
HttpStatusCodes.CREATED
);
});

forgotPassword = createHandler<ForgotPasswordRoute>(async (c) => {
const { email } = c.req.valid("json");

const user = await this.userRepository.findByEmail(email);
if (!user || !user.active) {
return c.json(
{
success: false,
data: null,
message: "If the email exists, a recovery link will be sent",
},
HttpStatusCodes.OK
);
}

const token = randomBytes(32).toString("hex");
const expires = new Date(Date.now() + 3600000);

await this.userRepository.setRecoveryToken(user.id, token, expires);

const resetLink = `${process.env.FRONTEND_URL || "http://localhost:3001"}/reset-password?token=${token}`;

const emailContent = `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 30px; text-align: center; border-radius: 10px 10px 0 0; }
.content { background: #f9f9f9; padding: 30px; border-radius: 0 0 10px 10px; }
.button { display: inline-block; padding: 12px 30px; background: #667eea; color: white; text-decoration: none; border-radius: 5px; margin: 20px 0; }
.footer { text-align: center; margin-top: 20px; color: #666; font-size: 12px; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🔐 FoppyAI</h1>
<p>Recuperación de Contraseña</p>
</div>
<div class="content">
<h2>Hola ${user.name},</h2>
<p>Hemos recibido una solicitud para restablecer la contraseña de tu cuenta en FoppyAI.</p>
<p>Para continuar con el proceso, haz clic en el siguiente botón:</p>
<center>
<a href="${resetLink}" class="button">Restablecer Contraseña</a>
</center>
<p>O copia y pega este enlace en tu navegador:</p>
<p style="background: #fff; padding: 10px; border-radius: 5px; word-break: break-all;">${resetLink}</p>
<p><strong>Este enlace expirará en 1 hora.</strong></p>
<p>Si no solicitaste este cambio, puedes ignorar este correo. Tu contraseña permanecerá sin cambios.</p>
</div>
<div class="footer">
<p>© 2025 FoppyAI - Sistema de Gestión Financiera</p>
<p>Este es un correo automático, por favor no respondas.</p>
</div>
</div>
</body>
</html>
`;

try {
await this.emailService.sendSimpleEmail(
email,
"Recuperación de Contraseña - FoppyAI",
emailContent,
{ isHtml: true }
);
} catch (error) {
console.error("Error sending email:", error);
}

return c.json(
{
success: true,
data: null,
message: "If the email exists, a recovery link will be sent",
},
HttpStatusCodes.OK
);
});

resetPassword = createHandler<ResetPasswordRoute>(async (c) => {
const { token, password } = c.req.valid("json");

const user = await this.userRepository.findByRecoveryToken(token);
if (!user) {
return c.json(
{
success: false,
data: null,
message: "Invalid or expired token",
},
HttpStatusCodes.BAD_REQUEST
);
}

if (!user.recoveryTokenExpires || user.recoveryTokenExpires < new Date()) {
await this.userRepository.clearRecoveryToken(user.id);
return c.json(
{
success: false,
data: null,
message: "Token has expired. Please request a new password reset",
},
HttpStatusCodes.BAD_REQUEST
);
}

const passwordHash = await hash(password);
await this.userRepository.update(user.id, { passwordHash });
await this.userRepository.clearRecoveryToken(user.id);

return c.json(
{
success: true,
data: null,
message: "Password has been reset successfully",
},
HttpStatusCodes.OK
);
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ const authService = AuthService.getInstance(userRepository);

const router = createRouter()
.openapi(routes.login, authService.login)
.openapi(routes.register, authService.register);
.openapi(routes.register, authService.register)
.openapi(routes.forgotPassword, authService.forgotPassword)
.openapi(routes.resetPassword, authService.resetPassword);

export default router;
66 changes: 66 additions & 0 deletions src/features/auth/infrastructure/controllers/auth.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import {
authResponseSchema,
loginSchema,
registerSchema,
forgotPasswordSchema,
resetPasswordSchema,
} from "../../application/dtos/auth.dto";
import { z } from "zod";

Expand Down Expand Up @@ -85,5 +87,69 @@ export const register = createRoute({
},
});

export const forgotPassword = createRoute({
path: "/auth/forgot-password",
method: "post",
tags,
request: {
body: jsonContentRequired(forgotPasswordSchema, "Forgot password request"),
},
responses: {
[HttpStatusCodes.OK]: {
content: {
"application/json": {
schema: z.object({
success: z.boolean(),
data: z.null(),
message: z.string(),
}),
},
},
description: "Password recovery email sent",
},
[HttpStatusCodes.NOT_FOUND]: {
content: {
"application/json": {
schema: errorResponseSchema,
},
},
description: "User not found",
},
},
});

export const resetPassword = createRoute({
path: "/auth/reset-password",
method: "post",
tags,
request: {
body: jsonContentRequired(resetPasswordSchema, "Reset password request"),
},
responses: {
[HttpStatusCodes.OK]: {
content: {
"application/json": {
schema: z.object({
success: z.boolean(),
data: z.null(),
message: z.string(),
}),
},
},
description: "Password reset successful",
},
[HttpStatusCodes.BAD_REQUEST]: {
content: {
"application/json": {
schema: errorResponseSchema,
},
},
description: "Invalid or expired token",
},
},
});

export type LoginRoute = typeof login;
export type RegisterRoute = typeof register;
export type ForgotPasswordRoute = typeof forgotPassword;
export type ResetPasswordRoute = typeof resetPassword;