diff --git a/schema.sql b/schema.sql index ae14724..22df9b7 100644 --- a/schema.sql +++ b/schema.sql @@ -180,3 +180,17 @@ CREATE TABLE page_media ( CREATE INDEX idx_page_media_page ON page_media(page); +-- Password reset tokens +CREATE TABLE password_reset_tokens ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL, + token_hash VARCHAR(128) NOT NULL, + expires_at TIMESTAMP WITH TIME ZONE NOT NULL, + used BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE INDEX idx_password_reset_tokens_hash ON password_reset_tokens(token_hash); +CREATE INDEX idx_password_reset_tokens_user_id ON password_reset_tokens(user_id); + diff --git a/src/pages/api/auth/forgot-password.js b/src/pages/api/auth/forgot-password.js new file mode 100644 index 0000000..67dff7e --- /dev/null +++ b/src/pages/api/auth/forgot-password.js @@ -0,0 +1,131 @@ +import crypto from 'crypto' +import { dbOneOrNone, dbNone } from 'src/lib/database' +import { sendMail } from 'src/lib/mailer' +import rateLimit from 'src/lib/rateLimit' + +const limiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 5 }) + +/** + * POST /api/auth/forgot-password + * + * Generates a secure password-reset token, stores its SHA-256 hash, + * and emails the raw token to the user. Always returns 200 to avoid + * leaking whether an email exists. + * + * Body: { email: string } + */ +export default async function handler(req, res) { + if (req.method !== 'POST') { + return res.status(405).json({ error: 'Method not allowed' }) + } + + // Rate-limit by IP + const { ok } = limiter.check(req) + if (!ok) { + return res.status(429).json({ error: 'Too many requests. Please try again later.' }) + } + + const { email } = req.body + + if (!email || typeof email !== 'string') { + return res.status(400).json({ error: 'Email is required' }) + } + + const trimmedEmail = email.trim().toLowerCase() + const emailRegex = /^[^\s@]+@[^\s@.]+(\.[^\s@.]+)+$/ + if (trimmedEmail.length > 254 || !emailRegex.test(trimmedEmail)) { + return res.status(400).json({ error: 'Invalid email format' }) + } + + try { + // Always respond with success to avoid email enumeration + const successMsg = 'If an account with that email exists, a password reset link has been sent.' + + const user = await dbOneOrNone( + 'SELECT id, name FROM users WHERE LOWER(email) = $1', + [trimmedEmail] + ) + + if (!user) { + return res.status(200).json({ message: successMsg }) + } + + // Invalidate any existing unused tokens for this user + await dbNone( + 'UPDATE password_reset_tokens SET used = TRUE WHERE user_id = $1 AND used = FALSE', + [user.id] + ) + + // Generate a cryptographically secure token + const rawToken = crypto.randomBytes(32).toString('hex') + + // Store only the SHA-256 hash in DB + const tokenHash = crypto.createHash('sha256').update(rawToken).digest('hex') + + // Token expires in 1 hour + const expiresAt = new Date(Date.now() + 60 * 60 * 1000) + + await dbNone( + 'INSERT INTO password_reset_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)', + [user.id, tokenHash, expiresAt] + ) + + // Build reset URL + const baseUrl = process.env.NEXTAUTH_URL || process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000' + const resetUrl = `${baseUrl}/reset-password?token=${rawToken}` + + // Send email + await sendMail({ + to: trimmedEmail, + subject: 'Reset your Citronics 2026 password', + html: buildResetEmailHtml(user.name, resetUrl), + text: `Hi ${user.name},\n\nYou requested a password reset. Use this link (valid for 1 hour):\n\n${resetUrl}\n\nIf you didn't request this, ignore this email.\n\nCitronics 2026` + }) + + return res.status(200).json({ message: successMsg }) + } catch (err) { + console.error('[ForgotPassword] Error:', err) + + return res.status(500).json({ error: 'Something went wrong. Please try again later.' }) + } +} + +function buildResetEmailHtml(name, resetUrl) { + return ` + + + + + + +
+ + + +
+

Citronics 2026

+
+

Password Reset

+

+ Hi ${name}, we received a request to reset your password. + Click the button below to choose a new one. This link expires in 1 hour. +

+ + +
+ + Reset Password + +
+

If the button doesn’t work, copy and paste this URL:

+

${resetUrl}

+
+

+ If you didn’t request this, you can safely ignore this email. Your password won’t change. +

+
+
+ + ` +} diff --git a/src/pages/api/auth/register.js b/src/pages/api/auth/register.js index 3168070..e67c919 100644 --- a/src/pages/api/auth/register.js +++ b/src/pages/api/auth/register.js @@ -47,8 +47,8 @@ export default async function handler(req, res) { if (!college?.trim()) return res.status(400).json({ error: 'College name is required' }) if (!city?.trim()) return res.status(400).json({ error: 'City is required' }) - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ - if (!emailRegex.test(email)) return res.status(400).json({ error: 'Invalid email format' }) + const emailRegex = /^[^\s@]+@[^\s@.]+(\.[^\s@.]+)+$/ + if (email.length > 254 || !emailRegex.test(email)) return res.status(400).json({ error: 'Invalid email format' }) if (phone && !/^\+?[\d\s-]{7,20}$/.test(phone)) { return res.status(400).json({ error: 'Invalid phone number' }) diff --git a/src/pages/api/auth/reset-password.js b/src/pages/api/auth/reset-password.js new file mode 100644 index 0000000..be1b4a1 --- /dev/null +++ b/src/pages/api/auth/reset-password.js @@ -0,0 +1,85 @@ +import crypto from 'crypto' +import bcrypt from 'bcryptjs' +import { dbOneOrNone, dbTx } from 'src/lib/database' +import rateLimit from 'src/lib/rateLimit' + +const limiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 10 }) + +/** + * POST /api/auth/reset-password + * + * Validates the reset token, checks expiry, and updates the user's password. + * The raw token is hashed with SHA-256 and compared against the stored hash. + * + * Body: { token: string, password: string } + */ +export default async function handler(req, res) { + if (req.method !== 'POST') { + return res.status(405).json({ error: 'Method not allowed' }) + } + + const { ok } = limiter.check(req) + if (!ok) { + return res.status(429).json({ error: 'Too many requests. Please try again later.' }) + } + + const { token, password } = req.body + + if (!token || typeof token !== 'string') { + return res.status(400).json({ error: 'Reset token is required' }) + } + + if (!password || password.length < 6) { + return res.status(400).json({ error: 'Password must be at least 6 characters' }) + } + + try { + const tokenHash = crypto.createHash('sha256').update(token).digest('hex') + + const resetRecord = await dbOneOrNone( + `SELECT prt.id, prt.user_id, prt.expires_at, prt.used, u.email + FROM password_reset_tokens prt + JOIN users u ON u.id = prt.user_id + WHERE prt.token_hash = $1`, + [tokenHash] + ) + + if (!resetRecord) { + return res.status(400).json({ error: 'Invalid or expired reset link. Please request a new one.' }) + } + + if (resetRecord.used) { + return res.status(400).json({ error: 'This reset link has already been used. Please request a new one.' }) + } + + if (new Date(resetRecord.expires_at) < new Date()) { + return res.status(400).json({ error: 'This reset link has expired. Please request a new one.' }) + } + + // Hash the new password + const passwordHash = await bcrypt.hash(password, 12) + + // Update password & mark token used in a transaction + await dbTx(async t => { + await t.none( + 'UPDATE users SET password_hash = $1, updated_at = NOW() WHERE id = $2', + [passwordHash, resetRecord.user_id] + ) + await t.none( + 'UPDATE password_reset_tokens SET used = TRUE WHERE id = $1', + [resetRecord.id] + ) + // Invalidate all other unused tokens for this user + await t.none( + 'UPDATE password_reset_tokens SET used = TRUE WHERE user_id = $1 AND id != $2 AND used = FALSE', + [resetRecord.user_id, resetRecord.id] + ) + }) + + return res.status(200).json({ message: 'Password has been reset successfully. You can now sign in.' }) + } catch (err) { + console.error('[ResetPassword] Error:', err) + + return res.status(500).json({ error: 'Something went wrong. Please try again later.' }) + } +} diff --git a/src/pages/forgot-password/index.js b/src/pages/forgot-password/index.js new file mode 100644 index 0000000..cd261b2 --- /dev/null +++ b/src/pages/forgot-password/index.js @@ -0,0 +1,189 @@ +import { useState } from 'react' +import { useRouter } from 'next/router' + +// MUI +import Box from '@mui/material/Box' +import Typography from '@mui/material/Typography' +import CustomTextField from 'src/components/mui/TextField' +import Button from '@mui/material/Button' +import InputAdornment from '@mui/material/InputAdornment' +import Alert from '@mui/material/Alert' +import CircularProgress from '@mui/material/CircularProgress' +import Collapse from '@mui/material/Collapse' +import { alpha } from '@mui/material/styles' + +// Icons & config +import Icon from 'src/components/Icon' +import themeConfig from 'src/configs/themeConfig' +import { useAppPalette } from 'src/components/palette' + +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + +const ForgotPasswordPage = () => { + const router = useRouter() + const c = useAppPalette() + + const [email, setEmail] = useState('') + const [error, setError] = useState('') + const [serverMsg, setServerMsg] = useState('') + const [serverError, setServerError] = useState('') + const [loading, setLoading] = useState(false) + + const handleSubmit = async e => { + e.preventDefault() + setServerMsg('') + setServerError('') + + const trimmed = email.trim() + if (!trimmed) { setError('Email is required'); return } + if (!EMAIL_RE.test(trimmed)) { setError('Enter a valid email address'); return } + + setLoading(true) + + try { + const res = await fetch('/api/auth/forgot-password', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: trimmed }) + }) + + const data = await res.json() + + if (res.ok) { + setServerMsg(data.message) + } else { + setServerError(data.error || 'Something went wrong.') + } + } catch { + setServerError('Network error. Please try again.') + } + + setLoading(false) + } + + return ( + + + + {/* Back to login */} + + + + + {/* Logo (mobile) */} + + + + + {/* Icon */} + + + + + + + {/* Header */} + + + Forgot Password? + + + Enter your email and we'll send you a link to reset your password. + + + + {/* Success message */} + + + {serverMsg} + + + + {/* Error message */} + + setServerError('')}> + {serverError} + + + + {/* Form */} +
+ { setEmail(e.target.value); if (error) setError(''); if (serverError) setServerError('') }} + error={!!error} + helperText={error || ' '} + disabled={loading} + autoFocus + placeholder='you@example.com' + InputProps={{ + startAdornment: ( + + + + ) + }} + /> + + + + {/* Back to sign in */} + + + Remember your password?{' '} + router.push('/login')} + sx={{ + all: 'unset', display: 'inline', cursor: 'pointer', + color: 'primary.main', fontWeight: 700, fontSize: 'inherit', fontFamily: 'inherit', + '&:hover, &:focus-visible': { textDecoration: 'underline' }, + '&:focus-visible': { outline: '2px solid', outlineColor: 'primary.main', outlineOffset: 2, borderRadius: '2px' } + }} + > + Sign In + + + + +
+
+ ) +} + +ForgotPasswordPage.getLayout = page => page +ForgotPasswordPage.guestGuard = true +ForgotPasswordPage.authGuard = false + +export default ForgotPasswordPage diff --git a/src/pages/login/index.js b/src/pages/login/index.js index 40acfa8..098d48e 100644 --- a/src/pages/login/index.js +++ b/src/pages/login/index.js @@ -622,33 +622,50 @@ const LoginPage = () => { )} {isExistingUser ? ( - /* Existing user: password only */ - - - - ), - endAdornment: ( - - setShowPassword(!showPassword)} edge='end'> - {showPassword ? : } - - - ) - }} - /> + /* Existing user: password + forgot link */ + + + + + ), + endAdornment: ( + + setShowPassword(!showPassword)} edge='end'> + {showPassword ? : } + + + ) + }} + /> + + router.push('/forgot-password')} + sx={{ + all: 'unset', display: 'inline', cursor: 'pointer', + color: 'primary.main', fontWeight: 600, fontSize: '0.78rem', fontFamily: 'inherit', + '&:hover, &:focus-visible': { textDecoration: 'underline' }, + '&:focus-visible': { outline: '2px solid', outlineColor: 'primary.main', outlineOffset: 2, borderRadius: '2px' } + }} + > + Forgot Password? + + + ) : ( /* New user: 2-column grid on sm+ */ diff --git a/src/pages/reset-password/index.js b/src/pages/reset-password/index.js new file mode 100644 index 0000000..a78b57e --- /dev/null +++ b/src/pages/reset-password/index.js @@ -0,0 +1,256 @@ +import { useState } from 'react' +import { useRouter } from 'next/router' + +// MUI +import Box from '@mui/material/Box' +import Typography from '@mui/material/Typography' +import CustomTextField from 'src/components/mui/TextField' +import Button from '@mui/material/Button' +import InputAdornment from '@mui/material/InputAdornment' +import IconButton from '@mui/material/IconButton' +import Alert from '@mui/material/Alert' +import CircularProgress from '@mui/material/CircularProgress' +import Collapse from '@mui/material/Collapse' +import { alpha } from '@mui/material/styles' + +// Icons & config +import Icon from 'src/components/Icon' +import themeConfig from 'src/configs/themeConfig' +import { useAppPalette } from 'src/components/palette' + +const ResetPasswordPage = () => { + const router = useRouter() + const c = useAppPalette() + const { token } = router.query + + const [password, setPassword] = useState('') + const [confirmPassword, setConfirmPassword] = useState('') + const [showPassword, setShowPassword] = useState(false) + const [showConfirm, setShowConfirm] = useState(false) + const [errors, setErrors] = useState({}) + const [serverMsg, setServerMsg] = useState('') + const [serverError, setServerError] = useState('') + const [loading, setLoading] = useState(false) + + const validate = () => { + const errs = {} + if (!password) errs.password = 'Password is required' + else if (password.length < 6) errs.password = 'Must be at least 6 characters' + if (!confirmPassword) errs.confirm = 'Please confirm your password' + else if (password !== confirmPassword) errs.confirm = 'Passwords do not match' + setErrors(errs) + + return Object.keys(errs).length === 0 + } + + const handleSubmit = async e => { + e.preventDefault() + setServerMsg('') + setServerError('') + + if (!validate()) return + + if (!token) { + setServerError('Invalid reset link. Please request a new one from the forgot-password page.') + + return + } + + setLoading(true) + + try { + const res = await fetch('/api/auth/reset-password', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token, password }) + }) + + const data = await res.json() + + if (res.ok) { + setServerMsg(data.message) + // Redirect to login after 3 seconds + setTimeout(() => router.push('/login'), 3000) + } else { + setServerError(data.error || 'Something went wrong.') + } + } catch { + setServerError('Network error. Please try again.') + } + + setLoading(false) + } + + // No token in URL + if (router.isReady && !token) { + return ( + + + + + + Invalid Reset Link + + This link is missing a reset token. Please request a new password reset. + + + + + ) + } + + return ( + + + + {/* Back to login */} + + + + + {/* Logo (mobile) */} + + + + + {/* Icon */} + + + + + + + {/* Header */} + + + Reset Password + + + Choose a new password for your account. + + + + {/* Success */} + + + {serverMsg} Redirecting to login... + + + + {/* Error */} + + setServerError('')}> + {serverError} + + + + {/* Form — hide after success */} + {!serverMsg && ( +
+ + { setPassword(e.target.value); if (errors.password) setErrors(p => ({ ...p, password: '' })) }} + error={!!errors.password} helperText={errors.password || ' '} + disabled={loading} autoFocus placeholder='Min 6 characters' + InputProps={{ + startAdornment: , + endAdornment: ( + + setShowPassword(!showPassword)} edge='end'> + {showPassword ? : } + + + ) + }} + /> + { setConfirmPassword(e.target.value); if (errors.confirm) setErrors(p => ({ ...p, confirm: '' })) }} + error={!!errors.confirm} helperText={errors.confirm || ' '} + disabled={loading} placeholder='Re-enter your password' + InputProps={{ + startAdornment: , + endAdornment: ( + + setShowConfirm(!showConfirm)} edge='end'> + {showConfirm ? : } + + + ) + }} + /> + + +
+ )} + + {/* Request new link */} + {serverError && ( + + + Need a new link?{' '} + router.push('/forgot-password')} + sx={{ + all: 'unset', display: 'inline', cursor: 'pointer', + color: 'primary.main', fontWeight: 700, fontSize: 'inherit', fontFamily: 'inherit', + '&:hover, &:focus-visible': { textDecoration: 'underline' } + }} + > + Request Password Reset + + + + )} + +
+
+ ) +} + +ResetPasswordPage.getLayout = page => page +ResetPasswordPage.guestGuard = true +ResetPasswordPage.authGuard = false + +export default ResetPasswordPage