diff --git a/backend/database.js b/backend/database.js index 647b53d..7741c0e 100644 --- a/backend/database.js +++ b/backend/database.js @@ -44,6 +44,18 @@ const initDB = async () => { // Ignore if it already exists } + try { + await client.query(`ALTER TABLE profiles ADD COLUMN activation_token TEXT`); + } catch (err) { + // Ignore if it already exists + } + + try { + await client.query(`ALTER TABLE profiles ADD COLUMN reset_token TEXT`); + } catch (err) { + // Ignore if it already exists + } + await client.query(` CREATE TABLE IF NOT EXISTS projects ( id TEXT PRIMARY KEY, diff --git a/backend/mail.js b/backend/mail.js index 7102b95..b4fc269 100644 --- a/backend/mail.js +++ b/backend/mail.js @@ -40,7 +40,35 @@ const sendMail = async (to, subject, text, html = '') => { } }; +const sendActivationEmail = async (email, token, baseUrl) => { + const activationLink = `${baseUrl}/activate?token=${token}`; + const subject = "Activate your Titan 3D Account"; + const html = ` +
Please click the link below to activate your account:
+ ${activationLink} +If you did not request this, please ignore this email.
+ `; + const text = `Welcome to Titan 3D! Please navigate to the following link to activate your account: ${activationLink}`; + return sendMail(email, subject, text, html); +}; + +const sendPasswordResetEmail = async (email, token, baseUrl) => { + const resetLink = `${baseUrl}/reset-password?token=${token}`; + const subject = "Reset your Titan 3D Password"; + const html = ` +Please click the link below to securely reset your password:
+ ${resetLink} +If you did not request this, please ignore this email.
+ `; + const text = `Please navigate to the following link to reset your password: ${resetLink}`; + return sendMail(email, subject, text, html); +}; + module.exports = { sendMail, + sendActivationEmail, + sendPasswordResetEmail, transporter }; diff --git a/backend/server.js b/backend/server.js index 2e62225..c961dbc 100644 --- a/backend/server.js +++ b/backend/server.js @@ -11,8 +11,10 @@ const pdfParse = require('pdf-parse'); const { GoogleGenerativeAI } = require('@google/generative-ai'); const axios = require('axios'); +const crypto = require('crypto'); + const { db, run, get, all } = require('./database'); -const { sendMail } = require('./mail'); +const { sendMail, sendActivationEmail, sendPasswordResetEmail } = require('./mail'); const app = express(); app.use(cors()); @@ -70,13 +72,26 @@ app.post('/api/auth/register', async (req, res) => { const role = parseInt(countRow.count) === 0 ? 'admin' : 'user'; const is_active = parseInt(countRow.count) === 0 ? true : false; + let activation_token = null; + if (!is_active) { + activation_token = crypto.randomBytes(32).toString('hex'); + } + await run( - 'INSERT INTO profiles (id, email, password_hash, role, api_credits, storage_limit_mb, is_active) VALUES (?, ?, ?, ?, ?, ?, ?)', - [id, email, hash, role, 10, 500, is_active] + 'INSERT INTO profiles (id, email, password_hash, role, api_credits, storage_limit_mb, is_active, activation_token) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', + [id, email, hash, role, 10, 500, is_active, activation_token] ); if (is_active === false) { - return res.json({ status: 'pending', message: 'Account created. Please contact an administrator to activate your account.' }); + // Send activation email + const baseUrl = req.headers.origin || 'http://localhost:5173'; + try { + await sendActivationEmail(email, activation_token, baseUrl); + } catch (err) { + console.error('Failed to send activation email', err); + // We still return pending, maybe they can resend later + } + return res.json({ status: 'pending', message: 'Account created. Please check your email for the activation link.' }); } const token = jwt.sign({ id, email, role }, JWT_SECRET, { expiresIn: '7d' }); @@ -87,6 +102,21 @@ app.post('/api/auth/register', async (req, res) => { } }); +// 1.5. Auth Activate +app.post('/api/auth/activate', async (req, res) => { + const { token } = req.body; + if (!token) return res.status(400).json({ error: 'Activation token required' }); + try { + const user = await get('SELECT * FROM profiles WHERE activation_token = ?', [token]); + if (!user) return res.status(400).json({ error: 'Invalid or expired activation token' }); + + await run('UPDATE profiles SET is_active = true, activation_token = NULL WHERE id = ?', [user.id]); + res.json({ success: true, message: 'Account activated successfully.' }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + // 2. Auth Login app.post('/api/auth/login', async (req, res) => { const { email, password } = req.body; @@ -221,6 +251,53 @@ app.get('/api/users', authenticate, async (req, res) => { } }); +// 7.1. Add user (Admin only) +app.post('/api/users', authenticate, async (req, res) => { + if (req.user.role !== 'admin') return res.status(403).json({ error: 'Forbidden' }); + const { email, role, api_credits, storage_limit_mb } = req.body; + if (!email) return res.status(400).json({ error: 'Email required' }); + + try { + const existing = await get('SELECT * FROM profiles WHERE email = ?', [email]); + if (existing) return res.status(400).json({ error: 'User already exists' }); + + // Generate random temp password (they will reset it anyway) + const tempPassword = crypto.randomBytes(16).toString('hex'); + const hash = await bcrypt.hash(tempPassword, 10); + const id = uuidv4(); + const activation_token = crypto.randomBytes(32).toString('hex'); + + await run( + 'INSERT INTO profiles (id, email, password_hash, role, api_credits, storage_limit_mb, is_active, activation_token) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', + [id, email, hash, role || 'user', api_credits || 10, storage_limit_mb || 500, false, activation_token] + ); + + const baseUrl = req.headers.origin || 'http://localhost:5173'; + try { + await sendActivationEmail(email, activation_token, baseUrl); + } catch (err) { + console.error('Failed to send activation email', err); + } + + res.json({ success: true, message: 'User created and activation email sent.' }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// 7.2. Delete user (Admin only) +app.delete('/api/users/:id', authenticate, async (req, res) => { + if (req.user.role !== 'admin') return res.status(403).json({ error: 'Forbidden' }); + try { + // Cascade delete projects for the user + await run('DELETE FROM projects WHERE user_id = ?', [req.params.id]); + await run('DELETE FROM profiles WHERE id = ?', [req.params.id]); + res.json({ success: true }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + // 8. Update user (Admin only) app.put('/api/users/:id', authenticate, async (req, res) => { if (req.user.role !== 'admin') return res.status(403).json({ error: 'Forbidden' }); @@ -233,6 +310,68 @@ app.put('/api/users/:id', authenticate, async (req, res) => { } }); +// 8.1. Admin trigger reset password for user +app.post('/api/users/:id/reset-password', authenticate, async (req, res) => { + if (req.user.role !== 'admin') return res.status(403).json({ error: 'Forbidden' }); + try { + const user = await get('SELECT * FROM profiles WHERE id = ?', [req.params.id]); + if (!user) return res.status(404).json({ error: 'User not found' }); + + const reset_token = crypto.randomBytes(32).toString('hex'); + await run('UPDATE profiles SET reset_token = ? WHERE id = ?', [reset_token, user.id]); + + const baseUrl = req.headers.origin || 'http://localhost:5173'; + try { + await sendPasswordResetEmail(user.email, reset_token, baseUrl); + } catch (err) { + console.error('Failed to send reset email', err); + } + + res.json({ success: true, message: 'Password reset email sent to user.' }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// 8.2. Forgot Password (User request) +app.post('/api/auth/forgot-password', async (req, res) => { + const { email } = req.body; + if (!email) return res.status(400).json({ error: 'Email required' }); + try { + const user = await get('SELECT * FROM profiles WHERE email = ?', [email]); + if (user) { + const reset_token = crypto.randomBytes(32).toString('hex'); + await run('UPDATE profiles SET reset_token = ? WHERE id = ?', [reset_token, user.id]); + const baseUrl = req.headers.origin || 'http://localhost:5173'; + try { + await sendPasswordResetEmail(user.email, reset_token, baseUrl); + } catch (err) { + console.error('Failed to send reset email', err); + } + } + // Always return success to prevent email enumeration + res.json({ success: true, message: 'If an account exists, a reset link has been sent.' }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// 8.3. Reset Password (User submission) +app.post('/api/auth/reset-password', async (req, res) => { + const { token, newPassword } = req.body; + if (!token || !newPassword) return res.status(400).json({ error: 'Token and new password required' }); + try { + const user = await get('SELECT * FROM profiles WHERE reset_token = ?', [token]); + if (!user) return res.status(400).json({ error: 'Invalid or expired reset token' }); + + const hash = await bcrypt.hash(newPassword, 10); + await run('UPDATE profiles SET password_hash = ?, reset_token = NULL WHERE id = ?', [hash, user.id]); + res.json({ success: true, message: 'Password has been reset successfully.' }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + // 9. Deduct API credit app.post('/api/users/deduct-credit', authenticate, async (req, res) => { try { diff --git a/src/App.tsx b/src/App.tsx index 234e985..5be46c5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -9,6 +9,8 @@ import Home from './pages/Home'; import Dashboard from './pages/Dashboard'; import Admin from './pages/Admin'; import SparkPlug from './pages/SparkPlug'; +import Activate from './pages/Activate'; +import ResetPassword from './pages/ResetPassword'; import { AuthModal } from './components/AuthModal'; export default function App() { @@ -32,6 +34,8 @@ export default function App() {- {isRegister ? 'Sign up to start building 3D scenes.' : 'Sign in to access your projects.'} + {authMode === 'forgot-password' ? 'Enter your email to receive a reset link.' : isRegister ? 'Sign up to start building 3D scenes.' : 'Sign in to access your projects.'}
{error && ( @@ -116,18 +119,28 @@ export function AuthModal() { /> -Remember your password?
+ ) : isRegister ? (Already have an account?
) : (Don't have an account?
diff --git a/src/pages/Activate.tsx b/src/pages/Activate.tsx new file mode 100644 index 0000000..5a0f55d --- /dev/null +++ b/src/pages/Activate.tsx @@ -0,0 +1,76 @@ +import { useEffect, useState } from 'react'; +import { useSearchParams, useNavigate } from 'react-router-dom'; +import { Loader2, CheckCircle, XCircle } from 'lucide-react'; +import { api } from '../lib/api'; + +export default function Activate() { + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + const token = searchParams.get('token'); + + const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading'); + const [message, setMessage] = useState(''); + + useEffect(() => { + if (!token) { + setStatus('error'); + setMessage('Invalid or missing activation token.'); + return; + } + + const activateAccount = async () => { + try { + await api.activateUser(token); + setStatus('success'); + setMessage('Your account has been successfully activated!'); + } catch (error: any) { + setStatus('error'); + setMessage(error.message || 'Failed to activate account.'); + } + }; + + activateAccount(); + }, [token]); + + return ( +Please wait while we verify your token.
+{message}
+ +{message}
+ +The password reset token is missing or invalid.
+ +{message}
+ +