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.
+
+
+ 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 */}
+
+
+ {/* 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 && (
+
+ )}
+
+ {/* 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