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 apps/backend/src/controllers/auth/authConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { Prisma } from "@prisma/client";
import type { SignOptions } from "jsonwebtoken";

export const EMAIL_VERIFY_TTL_MS = 24 * 60 * 60 * 1000;
export const PASSWORD_RESET_TTL_MS = 60 * 60 * 1000; // 1 hour

export const JWT_SECRET = process.env.JWT_SECRET;
export const JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || JWT_SECRET;
Expand Down
6 changes: 3 additions & 3 deletions apps/backend/src/controllers/auth/emailTokens.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import crypto from "node:crypto";
import prisma from "../../prisma.js";
import { hashToken } from "./authHelpers.js";
import { EMAIL_VERIFY_TTL_MS } from "./authConfig.js";
import { EMAIL_VERIFY_TTL_MS, PASSWORD_RESET_TTL_MS } from "./authConfig.js";

export type EmailTokenPurpose = "verify_email";
export type EmailTokenPurpose = "verify_email" | "reset_password";

export { EMAIL_VERIFY_TTL_MS };
export { EMAIL_VERIFY_TTL_MS, PASSWORD_RESET_TTL_MS };

export const createEmailToken = async (
userId: string,
Expand Down
5 changes: 3 additions & 2 deletions apps/backend/src/controllers/auth/loginHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,11 @@ import { createSession, toSafeUserResponse } from "./authHelpers.js";

export const loginUser = async (req: Request, res: Response) => {
try {
const { login, password } = LoginSchema.parse(req.body);
const { login: identifier, password } = LoginSchema.parse(req.body);

const isEmail = identifier.includes("@");
const user = await prisma.user.findFirst({
where: { login },
where: isEmail ? { email: identifier } : { login: identifier },
include: userInclude,
});

Expand Down
102 changes: 102 additions & 0 deletions apps/backend/src/controllers/auth/passwordResetHandlers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import type { Request, Response } from "express";
import bcrypt from "bcrypt";
import { z } from "zod";
import prisma from "../../prisma.js";
import {
ForgotPasswordSchema,
ResetPasswordSchema,
} from "../../models/AuthSchema.js";
import { handleZodError } from "../../utils/helpers.js";
import { sendPasswordResetEmail } from "../../services/emailService.js";
import { saltRounds } from "./authConfig.js";
import {
createEmailToken,
consumeEmailToken,
PASSWORD_RESET_TTL_MS,
} from "./emailTokens.js";

// Generic response to prevent email enumeration.
const GENERIC_OK = {
message:
"If an account with that email exists, a password reset link is on its way.",
};

export const forgotPassword = async (req: Request, res: Response) => {
try {
const { email } = ForgotPasswordSchema.parse(req.body);

const user = await prisma.user.findUnique({ where: { email } });

// Always 200 — never reveal whether email is registered.
if (!user || !user.emailVerified) {
return res.status(200).json(GENERIC_OK);
}

// Google-only accounts have no local identity — they cannot reset a password.
const localIdentity = await prisma.authIdentity.findFirst({
where: { userId: user.id, provider: "local" },
});
if (!localIdentity) {
return res.status(200).json(GENERIC_OK);
}

const { rawToken } = await createEmailToken(
user.id,
"reset_password",
PASSWORD_RESET_TTL_MS,
);

try {
await sendPasswordResetEmail(email, rawToken);
} catch (mailError) {
console.error("Failed to send password reset email:", mailError);
}

return res.status(200).json(GENERIC_OK);
} catch (error) {
if (error instanceof z.ZodError) return handleZodError(res, error);
console.error("Forgot password error:", error);
return res.status(500).json({ error: "Server error." });
}
};

export const resetPassword = async (req: Request, res: Response) => {
try {
const { token, password } = ResetPasswordSchema.parse(req.body);

const result = await consumeEmailToken(token, "reset_password");

if (!result.ok) {
const message =
result.reason === "already_used"
? "This reset link has already been used."
: "Reset link is invalid or expired. Request a new one.";
return res.status(400).json({ error: message });
}

const hashed = await bcrypt.hash(password, saltRounds);

await prisma.$transaction(async (tx) => {
await tx.user.update({
where: { id: result.userId },
data: { password: hashed },
});
// Ensure local identity exists (handles Google-only -> adds password case).
await tx.authIdentity.upsert({
where: {
userId_provider: { userId: result.userId, provider: "local" },
},
create: { userId: result.userId, provider: "local" },
update: {},
});
});

return res
.status(200)
.json({ message: "Password has been reset. You can now sign in." });
} catch (error) {
if (error instanceof z.ZodError) return handleZodError(res, error);
console.error("Reset password error:", error);
return res.status(500).json({ error: "Server error." });
}
};
15 changes: 13 additions & 2 deletions apps/backend/src/models/AuthSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,14 @@ export const RegisterSchema = z.object({
.string()
.trim()
.min(3, "Login must be at least 3 characters")
.max(30),
.max(30)
.regex(/^[^@]+$/, "Login cannot contain @"),
password: z.string().min(6, "Password must be at least 6 characters"),
email: z.string().email("Invalid email format"),
});

export const LoginSchema = z.object({
login: z.string().trim().min(3, "Login is required"),
login: z.string().trim().min(1, "Login or email is required"),
password: z.string().min(1, "Password is required"),
});

Expand All @@ -45,6 +46,15 @@ export const DeleteAccountSchema = z.object({
password: z.string().min(1).optional(),
});

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(6, "Password must be at least 6 characters"),
});

// photoUrl accepts http(s) URLs and base64 image data URLs only; rejects
// arbitrary schemes (e.g. javascript:) so the value is always safe in <img src>.
const PHOTO_URL_REGEX =
Expand All @@ -56,6 +66,7 @@ export const UpdateProfileSchema = z.object({
.trim()
.min(3, "Login must be at least 3 characters")
.max(30)
.regex(/^[^@]+$/, "Login cannot contain @")
.optional(),
photoUrl: z
.string()
Expand Down
6 changes: 6 additions & 0 deletions apps/backend/src/router/authRouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ import {
deleteAccount,
updateProfile,
} from "../controllers/auth/profileHandlers.js";
import {
forgotPassword,
resetPassword,
} from "../controllers/auth/passwordResetHandlers.js";
import { protect } from "../middleware/authMiddleware.js";

authRouter.post("/register", registerUser);
Expand All @@ -37,5 +41,7 @@ authRouter.post("/logout-all", protect, logoutAllUserSessions);
authRouter.post("/set-password", protect, setPassword);
authRouter.patch("/me", protect, updateProfile);
authRouter.delete("/account", protect, deleteAccount);
authRouter.post("/forgot-password", forgotPassword);
authRouter.post("/reset-password", resetPassword);

export default authRouter;
32 changes: 31 additions & 1 deletion apps/backend/src/services/emailService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ const EMAIL_FROM =
process.env.EMAIL_FROM || "CoinRadar <no-reply@coinradar.local>";
const BREVO_API_KEY = process.env.BREVO_API_KEY;
const API_PUBLIC_URL = process.env.API_PUBLIC_URL || "http://localhost:4000";
const FRONTEND_URL = process.env.FRONTEND_URL || "http://localhost:5173";

export type EmailPurpose = "verify_email";
export type EmailPurpose = "verify_email" | "reset_password";

export interface SentEmailRecord {
to: string;
Expand Down Expand Up @@ -109,6 +110,35 @@ The link expires in 24 hours. If you did not register, ignore this email.`;
return dispatch({ to, subject, purpose: "verify_email", text, html, token });
};

export const sendPasswordResetEmail = async (to: string, token: string) => {
const link = `${FRONTEND_URL}?auth=reset_password&token=${encodeURIComponent(token)}`;
const subject = "Reset your CoinRadar password";
const text = `You requested a password reset for your CoinRadar account.

Click the link to set a new password:

${link}

The link expires in 1 hour. If you did not request this, ignore this email.`;
const html = wrapHtml(
"Reset your password",
`<p>You requested a password reset for your CoinRadar account.</p>
<p>Click the button to set a new password:</p>
<p><a href="${link}" style="display:inline-block;padding:12px 20px;background:#6d28d9;color:#fff;border-radius:8px;text-decoration:none">Reset password</a></p>
<p style="font-size:12px;color:#666">Or open this link:<br/>${link}</p>
<p>The link expires in 1 hour. If you did not request this, ignore this email.</p>`,
);

return dispatch({
to,
subject,
purpose: "reset_password",
text,
html,
token,
});
};

// Test helpers - only meaningful when NODE_ENV === "test".
export const __getCapturedEmails = (): readonly SentEmailRecord[] => captured;
export const __resetCapturedEmails = (): void => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ export function AccountSettingsPopup() {
{section === "password" && (
<PasswordSection
hasPassword={hasPassword}
userEmail={currentUser.email}
onDone={() => dispatch(closePopup())}
/>
)}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useState, type FormEvent } from "react";
import { useSetPasswordMutation } from "../auth.api";
import { useSetPasswordMutation, useForgotPasswordMutation } from "../auth.api";
import { PasswordField } from "../PasswordField";
import { extractServerError } from "../auth.utils";

Expand All @@ -8,18 +8,24 @@ const primaryButtonClass =

export function PasswordSection({
hasPassword,
userEmail,
onDone,
}: {
hasPassword: boolean;
userEmail?: string | null;
onDone: () => void;
}) {
const [oldPassword, setOldPassword] = useState("");
const [password, setPassword] = useState("");
const [confirm, setConfirm] = useState("");
const [formError, setFormError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [resetNotice, setResetNotice] = useState<string | null>(null);

const [setPasswordMutation, { isLoading, error, isError }] =
useSetPasswordMutation();
const [forgotPassword, { isLoading: isForgotLoading }] =
useForgotPasswordMutation();

const serverError = isError ? extractServerError(error) : null;

Expand Down Expand Up @@ -55,6 +61,17 @@ export function PasswordSection({
}
};

const handleForgotPassword = async () => {
if (!userEmail) return;
setResetNotice(null);
try {
await forgotPassword({ email: userEmail }).unwrap();
setResetNotice(`Reset link sent to ${userEmail}`);
} catch {
setResetNotice("Could not send reset link. Please try again.");
}
};

return (
<form onSubmit={handleSubmit} className="space-y-4">
<p className="text-sm opacity-80">
Expand All @@ -68,21 +85,40 @@ export function PasswordSection({
{successMessage}
</div>
)}
{resetNotice && (
<div className="p-3 text-sm text-purple-100 bg-purple-900/40 border border-purple-500/30 rounded-xl">
{resetNotice}
</div>
)}
{(formError || serverError) && (
<div className="p-3 text-sm text-red-200 bg-red-900/40 border border-red-500/30 rounded-xl">
{formError || serverError}
</div>
)}

{hasPassword && (
<PasswordField
label="Current password"
value={oldPassword}
onChange={(e) => setOldPassword(e.target.value)}
disabled={isLoading}
placeholder="Current password"
autoComplete="current-password"
/>
<div>
<PasswordField
label="Current password"
value={oldPassword}
onChange={(e) => setOldPassword(e.target.value)}
disabled={isLoading}
placeholder="Current password"
autoComplete="current-password"
/>
{userEmail && (
<div className="mt-1 text-right">
<button
type="button"
onClick={handleForgotPassword}
disabled={isForgotLoading}
className="text-xs opacity-60 hover:opacity-100 hover:underline underline-offset-4 decoration-purple-400 transition-opacity cursor-pointer disabled:cursor-not-allowed"
>
{isForgotLoading ? "Sending..." : "Forgot password?"}
</button>
</div>
)}
</div>
)}

<PasswordField
Expand Down
Loading
Loading