diff --git a/README.md b/README.md index cc29abc..adbd023 100644 --- a/README.md +++ b/README.md @@ -30,8 +30,6 @@ The Lost & Found Community Platform (Lost No More) is a web application that hel --- -## 🛠️ Tech Stack - - **Frontend**: React + Tailwind CSS - **Auth / Database / Storage**: Firebase — the app uses Firebase Authentication for user sign-in, Cloud Firestore for application data, and Firebase Storage for item images. - **Backend**: Node.js + Express — a lightweight API server is included (health endpoints). The frontend currently communicates directly with Firebase; the backend contains minimal Express code and `firebase-admin` is available in package.json for optional server-side admin tasks. diff --git a/backend/email.js b/backend/email.js new file mode 100644 index 0000000..8c2be31 --- /dev/null +++ b/backend/email.js @@ -0,0 +1,60 @@ +const nodemailer = require('nodemailer'); + +// Create a reusable transporter object using SMTP transport if env vars are present +function createTransporter() { + const { SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS } = process.env; + if (SMTP_HOST && SMTP_PORT && SMTP_USER && SMTP_PASS) { + return nodemailer.createTransport({ + host: SMTP_HOST, + port: Number(SMTP_PORT), + secure: Number(SMTP_PORT) === 465, // true for 465, false for other ports + auth: { + user: SMTP_USER, + pass: SMTP_PASS, + }, + }); + } + return null; +} + +const transporter = createTransporter(); + +/** + * Send a verification code email. + * In development (or when SMTP is not configured), this will log the email to console instead. + * @param {string} toEmail - Recipient email address + * @param {string} code - Verification code + * @param {object} options - Additional options + */ +async function sendVerificationCodeEmail(toEmail, code, options = {}) { + const from = process.env.FROM_EMAIL || 'no-reply@lost-found.local'; + const subject = options.subject || 'Your verification code'; + const appName = options.appName || 'Lost & Found'; + + const text = `Hello, + +Your ${appName} verification code is: ${code} +This code will expire in 10 minutes. + +If you did not request this, please ignore this email.`; + const html = ` +
+

${appName} Verification

+

Your verification code is:

+

${code}

+

This code will expire in 10 minutes.

+
+

If you did not request this, please ignore this email.

+
+ `; + + if (!transporter) { + console.log('Email transport not configured. Would send email:', { toEmail, subject, text }); + return { mocked: true }; + } + + const info = await transporter.sendMail({ from, to: toEmail, subject, text, html }); + return info; +} + +module.exports = { sendVerificationCodeEmail }; diff --git a/backend/index.js b/backend/index.js index 54b1b25..cba6755 100644 --- a/backend/index.js +++ b/backend/index.js @@ -109,6 +109,7 @@ app.locals.storage = admin.storage(); app.use('/api/items', require('./routes/items')); app.use('/api/messages', require('./routes/messages')); app.use('/api/notifications', require('./routes/notifications')); +app.use('/api/verification', require('./routes/verification')); app.use('/api/users', require('./routes/users')); // 404 handler - catch any routes that don't exist diff --git a/backend/package-lock.json b/backend/package-lock.json index 6575d18..a77ba7c 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -16,6 +16,7 @@ "firebase": "^12.0.0", "firebase-admin": "^13.4.0", "multer": "^2.0.2", + "nodemailer": "^6.9.15", "uuid": "^11.1.0" }, "devDependencies": { @@ -2552,6 +2553,15 @@ "node": ">= 6.13.0" } }, + "node_modules/nodemailer": { + "version": "6.10.1", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz", + "integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/nodemon": { "version": "3.1.10", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.10.tgz", diff --git a/backend/package.json b/backend/package.json index 853c968..9084cf4 100644 --- a/backend/package.json +++ b/backend/package.json @@ -26,6 +26,7 @@ "express": "^4.21.2", "firebase": "^12.0.0", "firebase-admin": "^13.4.0", + "nodemailer": "^6.9.15", "multer": "^2.0.2", "uuid": "^11.1.0" }, diff --git a/backend/routes/verification.js b/backend/routes/verification.js new file mode 100644 index 0000000..4afb1d1 --- /dev/null +++ b/backend/routes/verification.js @@ -0,0 +1,134 @@ +const express = require('express'); +const crypto = require('node:crypto'); +const authenticate = require('../middleware/auth'); +const { sendVerificationCodeEmail } = require('../email'); + +const router = express.Router(); + +// Helper to generate a numeric code of given length +function generateNumericCode(length = 6) { + const min = Math.pow(10, length - 1); + const max = Math.pow(10, length) - 1; + return String(Math.floor(Math.random() * (max - min + 1)) + min); +} + +function hashCode(code, salt) { + return crypto.createHash('sha256').update(`${code}:${salt}`).digest('hex'); +} + +// Request a verification code sent to user's university email +router.post('/request-code', authenticate, async (req, res) => { + try { + const db = req.app.locals.db; + const { upi } = req.body || {}; + const user = req.user; // Firebase decoded token + + if (!upi || typeof upi !== 'string' || upi.trim().length < 3) { + return res.status(400).json({ message: 'Invalid UPI' }); + } + + // Build university email from UPI (assumption per requirements) + const email = `${upi}@aucklanduni.ac.nz`; + + const usersRef = db.collection('users').doc(user.uid); + const userSnap = await usersRef.get(); + if (!userSnap.exists) { + return res.status(404).json({ message: 'User not found' }); + } + + const now = new Date(); + const expiresAt = new Date(now.getTime() + 10 * 60 * 1000); // 10 minutes + const code = generateNumericCode(4); // As per example 5678 + const salt = crypto.randomBytes(8).toString('hex'); + const codeHash = hashCode(code, salt); + + // Store pending verification state on the user document + await usersRef.set({ + upi, + verification: { + method: 'email', + target: email, + codeHash, + salt, + expiresAt: expiresAt.toISOString(), + attempts: 0, + status: 'pending', + requestedAt: now.toISOString(), + } + }, { merge: true }); + + // Send the email + await sendVerificationCodeEmail(email, code, { appName: 'Lost & Found' }); + + return res.json({ message: 'Verification code sent', target: email, expiresAt }); + } catch (err) { + console.error('request-code error:', err); + return res.status(500).json({ message: 'Failed to send verification code' }); + } +}); + +// Verify a code +router.post('/verify-code', authenticate, async (req, res) => { + try { + const db = req.app.locals.db; + const { code } = req.body || {}; + const user = req.user; + + if (!code || typeof code !== 'string') { + return res.status(400).json({ message: 'Code is required' }); + } + + const usersRef = db.collection('users').doc(user.uid); + const userSnap = await usersRef.get(); + if (!userSnap.exists) { + return res.status(404).json({ message: 'User not found' }); + } + + const data = userSnap.data() || {}; + const vf = data.verification; + if (!vf || !vf.codeHash || !vf.salt || !vf.expiresAt) { + return res.status(400).json({ message: 'No pending verification' }); + } + + // Check expiry + const now = new Date(); + const exp = new Date(vf.expiresAt); + if (now > exp) { + await usersRef.set({ verification: { ...vf, status: 'expired' } }, { merge: true }); + return res.status(400).json({ message: 'Code expired' }); + } + + // Check attempts limit + const attempts = Number(vf.attempts || 0); + if (attempts >= 5) { + await usersRef.set({ verification: { ...vf, status: 'locked' } }, { merge: true }); + return res.status(429).json({ message: 'Too many attempts. Please request a new code.' }); + } + + // Verify code + const attemptedHash = hashCode(code, vf.salt); + if (attemptedHash !== vf.codeHash) { + await usersRef.set({ verification: { ...vf, attempts: attempts + 1 } }, { merge: true }); + return res.status(400).json({ message: 'Invalid code' }); + } + + // Mark verified and clear sensitive fields + await usersRef.set({ + isVerified: true, + trustBadge: 'verified', + verification: { + method: 'email', + target: vf.target, + status: 'verified', + verifiedAt: new Date().toISOString() + } + }, { merge: true }); + + return res.json({ message: 'Verification successful', isVerified: true }); + } catch (err) { + console.error('verify-code error:', err); + return res.status(500).json({ message: 'Failed to verify code' }); + } +}); + +module.exports = router; diff --git a/frontend/src/components/ui/ProfileBadge.js b/frontend/src/components/ui/ProfileBadge.js index 424a38b..2b6c107 100644 --- a/frontend/src/components/ui/ProfileBadge.js +++ b/frontend/src/components/ui/ProfileBadge.js @@ -2,9 +2,10 @@ import React from 'react' export function ProfileBadge({ children, variant = 'default', className = "" }) { const baseClasses = "inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium" - const variantClasses = variant === 'outline' - ? "border border-gray-200 text-gray-700" - : "bg-gray-100 text-gray-800" + let variantClasses = "bg-gray-100 text-gray-800"; + if (variant === 'outline') variantClasses = "border border-gray-200 text-gray-700"; + if (variant === 'success') variantClasses = "bg-green-100 text-green-800"; + if (variant === 'danger') variantClasses = "bg-red-100 text-red-800"; return ( diff --git a/frontend/src/firebase/firestore.js b/frontend/src/firebase/firestore.js index 02929d2..f4cc411 100644 --- a/frontend/src/firebase/firestore.js +++ b/frontend/src/firebase/firestore.js @@ -393,4 +393,49 @@ export async function updateItem(itemId, updateData) { console.error('Error updating item:', error); throw error; } +} + +/** + * Update user's UPI in Firestore + * @param {string} userId - The user's UID + * @param {string} upi - The UPI to save + * @returns {Promise} + */ +export async function updateUserUpi(userId, upi) { + try { + const userRef = doc(db, 'users', userId); + await updateDoc(userRef, { + upi: upi, + updatedAt: new Date() + }); + console.log('User UPI updated successfully:', userId, upi); + } catch (error) { + console.error('Error updating user UPI:', error); + throw error; + } +} + +/** + * Generate and save a random 4-digit verification code to Firestore + * @param {string} userId - The user's UID + * @returns {Promise} The generated code + */ +export async function generateAndSaveVerificationCode(userId) { + try { + // Generate random 4-digit code + const code = Math.floor(1000 + Math.random() * 9000).toString(); + + const userRef = doc(db, 'users', userId); + await updateDoc(userRef, { + verificationCode: code, + codeGeneratedAt: new Date(), + updatedAt: new Date() + }); + + console.log('Verification code saved to Firestore:', code); + return code; + } catch (error) { + console.error('Error saving verification code:', error); + throw error; + } } \ No newline at end of file diff --git a/frontend/src/pages/ProfilePage.js b/frontend/src/pages/ProfilePage.js index 32ac457..6c2f50a 100644 --- a/frontend/src/pages/ProfilePage.js +++ b/frontend/src/pages/ProfilePage.js @@ -5,7 +5,7 @@ import { doc, getDoc, updateDoc } from "firebase/firestore" import { db } from "../firebase/config" import { LogOut, Trash2, Edit3, X, Camera } from "lucide-react" import PropTypes from 'prop-types' -import { getUserPosts, formatTimestamp, updateItemStatus, updateItem } from "../firebase/firestore" +import { getUserPosts, formatTimestamp, updateItemStatus, updateItem, getUserProfile, updateUserUpi, generateAndSaveVerificationCode } from "../firebase/firestore" import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card" import { ProfileBadge } from '../components/ui/ProfileBadge' import { extractDate, normalizeTimestamp } from "../services/firestoreNormalizer" @@ -169,13 +169,19 @@ export default function ProfilePage() { title: '', description: '', category: '', - location: '', - kind: '', + location: '', // only for campus locations + kind: '', // 'lost' or 'found' imageUrl: '', date: '', time: '' }) const [error, setError] = useState(null) + const [profile, setProfile] = useState(null) + const [upiInput, setUpiInput] = useState("") + const [codeInput, setCodeInput] = useState("") + const [sendingCode, setSendingCode] = useState(false) + const [verifying, setVerifying] = useState(false) + const [verificationMessage, setVerificationMessage] = useState("") // New state for profile picture functionality const [userData, setUserData] = useState(null) @@ -220,7 +226,10 @@ export default function ProfilePage() { // Fetch user posts const posts = await getUserPosts(currentUser.uid) + const prof = await getUserProfile(currentUser.uid) setMyPosts(posts) + setProfile(prof) + setUpiInput(prof?.upi || "") } catch (err) { console.error('Error fetching user data:', err) setError('Failed to load your data. Please try again.') @@ -532,11 +541,120 @@ export default function ProfilePage() { Trust & Verification - Unverified + {profile?.isVerified ? ( + Verified + ) : ( + Unverified + )} - - Connect university SSO to verify identity and earn trust badges for faster claims and higher credibility. + +

Verify your identity with your UPI to earn a trust badge. We'll send a 4-digit code to your university email.

+
+
+ + setUpiInput(e.target.value)} + placeholder="e.g. hlee345" + className="w-full border rounded px-3 py-2 text-sm" + /> +
+
+ +
+
+ +
+ setCodeInput(e.target.value)} + placeholder="4-digit code" + className="w-full border rounded px-3 py-2 text-sm" + /> + +
+
+
+ {verificationMessage && ( +

{verificationMessage}

+ )}