diff --git a/apps/backend/src/controllers/auth/authConfig.ts b/apps/backend/src/controllers/auth/authConfig.ts index afd3ed1..345c3e9 100644 --- a/apps/backend/src/controllers/auth/authConfig.ts +++ b/apps/backend/src/controllers/auth/authConfig.ts @@ -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; diff --git a/apps/backend/src/controllers/auth/emailTokens.ts b/apps/backend/src/controllers/auth/emailTokens.ts index 83cfc4b..062c556 100644 --- a/apps/backend/src/controllers/auth/emailTokens.ts +++ b/apps/backend/src/controllers/auth/emailTokens.ts @@ -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, diff --git a/apps/backend/src/controllers/auth/loginHandler.ts b/apps/backend/src/controllers/auth/loginHandler.ts index e0fa444..d935eec 100644 --- a/apps/backend/src/controllers/auth/loginHandler.ts +++ b/apps/backend/src/controllers/auth/loginHandler.ts @@ -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, }); diff --git a/apps/backend/src/controllers/auth/passwordResetHandlers.ts b/apps/backend/src/controllers/auth/passwordResetHandlers.ts new file mode 100644 index 0000000..4c530d9 --- /dev/null +++ b/apps/backend/src/controllers/auth/passwordResetHandlers.ts @@ -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." }); + } +}; diff --git a/apps/backend/src/models/AuthSchema.ts b/apps/backend/src/models/AuthSchema.ts index 06a2c5b..a8dcf33 100644 --- a/apps/backend/src/models/AuthSchema.ts +++ b/apps/backend/src/models/AuthSchema.ts @@ -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"), }); @@ -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 . const PHOTO_URL_REGEX = @@ -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() diff --git a/apps/backend/src/router/authRouter.ts b/apps/backend/src/router/authRouter.ts index fc4e11b..f616d24 100644 --- a/apps/backend/src/router/authRouter.ts +++ b/apps/backend/src/router/authRouter.ts @@ -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); @@ -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; diff --git a/apps/backend/src/services/emailService.ts b/apps/backend/src/services/emailService.ts index b01ec28..5acf954 100644 --- a/apps/backend/src/services/emailService.ts +++ b/apps/backend/src/services/emailService.ts @@ -2,8 +2,9 @@ const EMAIL_FROM = process.env.EMAIL_FROM || "CoinRadar "; 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; @@ -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", + `

You requested a password reset for your CoinRadar account.

+

Click the button to set a new password:

+

Reset password

+

Or open this link:
${link}

+

The link expires in 1 hour. If you did not request this, ignore this email.

`, + ); + + 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 => { diff --git a/apps/frontend/src/modules/Auth/AccountSettingsPopup/AccountSettingsPopup.tsx b/apps/frontend/src/modules/Auth/AccountSettingsPopup/AccountSettingsPopup.tsx index d59949e..bb78dcd 100644 --- a/apps/frontend/src/modules/Auth/AccountSettingsPopup/AccountSettingsPopup.tsx +++ b/apps/frontend/src/modules/Auth/AccountSettingsPopup/AccountSettingsPopup.tsx @@ -67,6 +67,7 @@ export function AccountSettingsPopup() { {section === "password" && ( dispatch(closePopup())} /> )} diff --git a/apps/frontend/src/modules/Auth/AccountSettingsPopup/PasswordSection.tsx b/apps/frontend/src/modules/Auth/AccountSettingsPopup/PasswordSection.tsx index 7a33b89..c469721 100644 --- a/apps/frontend/src/modules/Auth/AccountSettingsPopup/PasswordSection.tsx +++ b/apps/frontend/src/modules/Auth/AccountSettingsPopup/PasswordSection.tsx @@ -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"; @@ -8,9 +8,11 @@ const primaryButtonClass = export function PasswordSection({ hasPassword, + userEmail, onDone, }: { hasPassword: boolean; + userEmail?: string | null; onDone: () => void; }) { const [oldPassword, setOldPassword] = useState(""); @@ -18,8 +20,12 @@ export function PasswordSection({ const [confirm, setConfirm] = useState(""); const [formError, setFormError] = useState(null); const [successMessage, setSuccessMessage] = useState(null); + const [resetNotice, setResetNotice] = useState(null); + const [setPasswordMutation, { isLoading, error, isError }] = useSetPasswordMutation(); + const [forgotPassword, { isLoading: isForgotLoading }] = + useForgotPasswordMutation(); const serverError = isError ? extractServerError(error) : null; @@ -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 (

@@ -68,6 +85,11 @@ export function PasswordSection({ {successMessage} )} + {resetNotice && ( +

+ {resetNotice} +
+ )} {(formError || serverError) && (
{formError || serverError} @@ -75,14 +97,28 @@ export function PasswordSection({ )} {hasPassword && ( - setOldPassword(e.target.value)} - disabled={isLoading} - placeholder="Current password" - autoComplete="current-password" - /> +
+ setOldPassword(e.target.value)} + disabled={isLoading} + placeholder="Current password" + autoComplete="current-password" + /> + {userEmail && ( +
+ +
+ )} +
)} >; @@ -148,6 +149,10 @@ export function AuthPopup() { ); } + if (stage === "forgot") { + return setStage("signin")} />; + } + return (

@@ -163,7 +168,7 @@ export function AuthPopup() {
{formErrors.login && (

@@ -214,6 +223,18 @@ export function AuthPopup() { error={formErrors.password} /> + {isLoginMode && ( +

+ +
+ )} +
+
+ ); + } + + return ( +
+

+ Forgot password? +

+

+ Enter your email and we'll send you a reset link. +

+ + {serverError && ( +
+ {serverError} +
+ )} + + +
+ + ) => + setEmail(e.target.value) + } + disabled={isLoading} + className={inputClass} + placeholder="Enter your email address" + autoComplete="email" + /> +
+ +
+ +
+ + + +
+ ); +} diff --git a/apps/frontend/src/modules/Auth/AuthPopup/ResetPasswordPopup.tsx b/apps/frontend/src/modules/Auth/AuthPopup/ResetPasswordPopup.tsx new file mode 100644 index 0000000..132dd14 --- /dev/null +++ b/apps/frontend/src/modules/Auth/AuthPopup/ResetPasswordPopup.tsx @@ -0,0 +1,111 @@ +import { useState, type FormEvent } from "react"; +import { useResetPasswordMutation } from "../auth.api"; +import { PasswordField } from "../PasswordField"; +import { extractServerError } from "../auth.utils"; +import { useAppDispatch } from "../../../store"; +import { closePopup } from "../../../portals/popup.slice"; + +const primaryButtonClass = + "w-full py-4 rounded-xl font-bold text-white text-lg shadow-lg transform active:scale-95 transition-all duration-200 cursor-pointer hover:shadow-purple-500/30 hover:-translate-y-1 disabled:opacity-60 disabled:cursor-not-allowed disabled:hover:translate-y-0"; + +interface Props { + token: string; +} + +export function ResetPasswordPopup({ token }: Props) { + const dispatch = useAppDispatch(); + const [password, setPassword] = useState(""); + const [confirm, setConfirm] = useState(""); + const [formError, setFormError] = useState(null); + const [success, setSuccess] = useState(false); + + const [resetPassword, { isLoading, error, isError }] = + useResetPasswordMutation(); + + const serverError = isError ? extractServerError(error) : null; + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + setFormError(null); + + if (password.length < 6) { + setFormError("Password must be at least 6 characters."); + return; + } + if (password !== confirm) { + setFormError("Passwords do not match."); + return; + } + + try { + await resetPassword({ token, password }).unwrap(); + setSuccess(true); + setTimeout(() => dispatch(closePopup()), 2000); + } catch { + // error shown via serverError + } + }; + + if (success) { + return ( +
+
+

+ Password updated +

+

+ Your password has been reset. You can now sign in with your new + password. +

+
+ ); + } + + return ( +
+

+ New password +

+

+ Choose a strong password for your account. +

+ + {(formError || serverError) && ( +
+ {formError || serverError} +
+ )} + +
+ setPassword(e.target.value)} + disabled={isLoading} + placeholder="At least 6 characters" + autoComplete="new-password" + /> + + setConfirm(e.target.value)} + disabled={isLoading} + placeholder="Repeat new password" + autoComplete="new-password" + /> + +
+ +
+ +
+ ); +} diff --git a/apps/frontend/src/modules/Auth/AuthQueryParamToast.tsx b/apps/frontend/src/modules/Auth/AuthQueryParamToast.tsx index d45e9d0..73eb310 100644 --- a/apps/frontend/src/modules/Auth/AuthQueryParamToast.tsx +++ b/apps/frontend/src/modules/Auth/AuthQueryParamToast.tsx @@ -2,6 +2,8 @@ import { useEffect, useState } from "react"; import { AnimatePresence, motion } from "framer-motion"; import { useAppDispatch } from "../../store"; import { authApi } from "./auth.api"; +import { openPopup } from "../../portals/popup.slice"; +import { ResetPasswordPopup } from "./AuthPopup/ResetPasswordPopup"; type Tone = "success" | "info" | "error"; interface Notice { @@ -47,6 +49,32 @@ export function AuthQueryParamToast() { const authParam = params.get("auth"); if (!authParam) return; + if (authParam === "reset_password") { + const token = params.get("token") ?? ""; + params.delete("auth"); + params.delete("token"); + const next = + window.location.pathname + + (params.toString() ? `?${params.toString()}` : "") + + window.location.hash; + window.history.replaceState({}, "", next); + + if (token) { + dispatch( + openPopup({ + title: "Reset Password", + children: , + }), + ); + } else { + setNotice({ + tone: "error", + text: "Reset link is invalid or expired. Request a new one from the sign-in popup.", + }); + } + return; + } + const matched = MESSAGES[authParam]; if (matched) setNotice(matched); diff --git a/apps/frontend/src/modules/Auth/auth.api.ts b/apps/frontend/src/modules/Auth/auth.api.ts index 9f7d3f3..580ed4f 100644 --- a/apps/frontend/src/modules/Auth/auth.api.ts +++ b/apps/frontend/src/modules/Auth/auth.api.ts @@ -165,6 +165,25 @@ export const authApi = createApi({ }, invalidatesTags: ["User"], }), + + forgotPassword: builder.mutation<{ message: string }, { email: string }>({ + query: (body) => ({ + url: "auth/forgot-password", + method: "POST", + body, + }), + }), + + resetPassword: builder.mutation< + { message: string }, + { token: string; password: string } + >({ + query: (body) => ({ + url: "auth/reset-password", + method: "POST", + body, + }), + }), }), }); @@ -178,4 +197,6 @@ export const { useSetPasswordMutation, useDeleteAccountMutation, useUpdateProfileMutation, + useForgotPasswordMutation, + useResetPasswordMutation, } = authApi; diff --git a/apps/frontend/src/modules/Auth/auth.schema.ts b/apps/frontend/src/modules/Auth/auth.schema.ts index efba6cb..2324ffe 100644 --- a/apps/frontend/src/modules/Auth/auth.schema.ts +++ b/apps/frontend/src/modules/Auth/auth.schema.ts @@ -24,13 +24,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"), });