From cbf44587aa248895aeb4ed1cd57097bdbdbd7dea Mon Sep 17 00:00:00 2001 From: AI Bot Date: Thu, 17 Sep 2026 08:49:11 +0530 Subject: [PATCH] feat: User management, SMTP email activation and password resets --- backend/database.js | 12 +++ backend/mail.js | 28 ++++++ backend/server.js | 147 ++++++++++++++++++++++++++++- src/App.tsx | 4 + src/components/AuthModal.tsx | 51 ++++++---- src/pages/Activate.tsx | 76 +++++++++++++++ src/pages/Admin.tsx | 176 +++++++++++++++++++++++++++++------ src/pages/ResetPassword.tsx | 124 ++++++++++++++++++++++++ src/store/authStore.ts | 4 +- 9 files changed, 571 insertions(+), 51 deletions(-) create mode 100644 src/pages/Activate.tsx create mode 100644 src/pages/ResetPassword.tsx 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 = ` +

Welcome to Titan 3D!

+

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 = ` +

Password Reset Request

+

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() { {/* Public Routes */} } /> + } /> + } /> {/* Protected Routes (User) */} }> diff --git a/src/components/AuthModal.tsx b/src/components/AuthModal.tsx index 510d42b..c3f37e2 100644 --- a/src/components/AuthModal.tsx +++ b/src/components/AuthModal.tsx @@ -26,10 +26,13 @@ export function AuthModal() { setSuccess(''); try { - if (isRegister) { + if (authMode === 'forgot-password') { + await api.forgotPassword(email); + setSuccess('If an account exists, a reset link has been sent to your email.'); + } else if (isRegister) { const res = await api.register(email, password); if (res.status === 'pending') { - setSuccess(res.message || 'Account created successfully! Please contact an admin to activate your account.'); + setSuccess(res.message || 'Account created successfully! Please check your email for the activation link.'); setEmail(''); setPassword(''); } else { @@ -84,10 +87,10 @@ export function AuthModal() {

- {isRegister ? 'Create an Account' : 'Welcome Back'} + {authMode === 'forgot-password' ? 'Reset Password' : isRegister ? 'Create an Account' : 'Welcome Back'}

- {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() { /> -
- - setPassword(e.target.value)} - className="w-full bg-zinc-950/50 border border-zinc-700/50 rounded-xl py-3 pl-10 pr-4 text-white placeholder-zinc-500 focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all" - /> -
+ {authMode !== 'forgot-password' && ( +
+ + setPassword(e.target.value)} + className="w-full bg-zinc-950/50 border border-zinc-700/50 rounded-xl py-3 pl-10 pr-4 text-white placeholder-zinc-500 focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all" + /> +
+ )} + + {authMode === 'login' && ( +
+ +
+ )}
- {isRegister ? ( + {authMode === 'forgot-password' ? ( +

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 ( +
+
+ {status === 'loading' && ( +
+ +

Activating Account...

+

Please wait while we verify your token.

+
+ )} + + {status === 'success' && ( +
+ +

Success!

+

{message}

+ +
+ )} + + {status === 'error' && ( +
+ +

Activation Failed

+

{message}

+ +
+ )} +
+
+ ); +} diff --git a/src/pages/Admin.tsx b/src/pages/Admin.tsx index 9d54b72..c270829 100644 --- a/src/pages/Admin.tsx +++ b/src/pages/Admin.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react'; import { useAuthStore } from '../store/authStore'; import { useNavigate } from 'react-router-dom'; -import { ArrowLeft, ShieldAlert, Loader2, Save } from 'lucide-react'; +import { ArrowLeft, ShieldAlert, Loader2, Save, Trash2, KeyRound, Plus, X } from 'lucide-react'; import { api } from '../lib/api'; interface UserProfile { @@ -18,19 +18,22 @@ export default function Admin() { const { profile } = useAuthStore(); const [users, setUsers] = useState([]); const [loading, setLoading] = useState(true); + const [showAddUser, setShowAddUser] = useState(false); + const [newEmail, setNewEmail] = useState(''); + const [newRole, setNewRole] = useState('user'); + + const fetchUsers = async () => { + if (profile?.role !== 'admin') return; + try { + const data = await api.getUsers(); + setUsers(data.users || []); + } catch (error) { + console.error(error); + } + setLoading(false); + }; useEffect(() => { - const fetchUsers = async () => { - if (profile?.role !== 'admin') return; - try { - const data = await api.getUsers(); - setUsers(data.users || []); - } catch (error) { - console.error(error); - } - setLoading(false); - }; - fetchUsers(); }, [profile]); @@ -48,6 +51,41 @@ export default function Admin() { } }; + const handleDelete = async (id: string) => { + if (!confirm("Are you sure you want to delete this user? All their projects will be permanently deleted as well.")) return; + try { + await api.deleteUser(id); + setUsers(users.filter(u => u.id !== id)); + alert("User deleted successfully."); + } catch (error: any) { + alert("Failed to delete user: " + error.message); + } + }; + + const handleResetPassword = async (id: string) => { + if (!confirm("Send a password reset email to this user?")) return; + try { + await api.triggerPasswordReset(id); + alert("Password reset email sent."); + } catch (error: any) { + alert("Failed to send reset email: " + error.message); + } + }; + + const handleAddUser = async (e: React.FormEvent) => { + e.preventDefault(); + if (!newEmail) return; + try { + await api.addUser({ email: newEmail, role: newRole }); + alert("User created and activation email sent."); + setShowAddUser(false); + setNewEmail(''); + fetchUsers(); + } catch (error: any) { + alert("Failed to create user: " + error.message); + } + }; + if (profile?.role !== 'admin') { return (
@@ -62,8 +100,8 @@ export default function Admin() { } return ( -
-
+
+
-

Admin Control Panel

+
+

Admin Control Panel

+ +
-

User Limit Management

+

User Management

{loading ? ( @@ -96,7 +142,13 @@ export default function Admin() { {users.map(u => ( - + ))} @@ -104,11 +156,64 @@ export default function Admin() { )}
+ + {showAddUser && ( +
+
+
+

Add New User

+ +
+
+
+ + setNewEmail(e.target.value)} + className="w-full bg-zinc-950 border border-zinc-800 rounded-xl px-4 py-3 text-white focus:outline-none focus:border-primary transition-colors" + placeholder="user@example.com" + required + /> +
+
+ + +
+ +
+
+
+ )}
); } -const UserRow = ({ user, onUpdate }: { user: UserProfile, onUpdate: (u: UserProfile) => void }) => { +const UserRow = ({ + user, + onUpdate, + onDelete, + onResetPassword +}: { + user: UserProfile, + onUpdate: (u: UserProfile) => void, + onDelete: (id: string) => void, + onResetPassword: (id: string) => void +}) => { const [role, setRole] = useState(user.role); const [isActive, setIsActive] = useState(user.is_active); const [credits, setCredits] = useState(user.api_credits); @@ -153,7 +258,7 @@ const UserRow = ({ user, onUpdate }: { user: UserProfile, onUpdate: (u: UserProf type="number" value={credits} onChange={e => setCredits(Number(e.target.value))} - className="w-24 bg-zinc-950 border border-zinc-700 rounded px-2 py-1 text-white focus:outline-none focus:border-primary" + className="w-20 bg-zinc-950 border border-zinc-700 rounded px-2 py-1 text-white focus:outline-none focus:border-primary" /> @@ -161,16 +266,33 @@ const UserRow = ({ user, onUpdate }: { user: UserProfile, onUpdate: (u: UserProf type="number" value={storage} onChange={e => setStorage(Number(e.target.value))} - className="w-24 bg-zinc-950 border border-zinc-700 rounded px-2 py-1 text-white focus:outline-none focus:border-primary" + className="w-20 bg-zinc-950 border border-zinc-700 rounded px-2 py-1 text-white focus:outline-none focus:border-primary" /> - - + +
+ + + +
); diff --git a/src/pages/ResetPassword.tsx b/src/pages/ResetPassword.tsx new file mode 100644 index 0000000..5904067 --- /dev/null +++ b/src/pages/ResetPassword.tsx @@ -0,0 +1,124 @@ +import { useState } from 'react'; +import { useSearchParams, useNavigate } from 'react-router-dom'; +import { Loader2, KeyRound } from 'lucide-react'; +import { api } from '../lib/api'; + +export default function ResetPassword() { + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + const token = searchParams.get('token'); + + const [password, setPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [loading, setLoading] = useState(false); + const [message, setMessage] = useState(''); + const [error, setError] = useState(''); + const [success, setSuccess] = useState(false); + + if (!token) { + return ( +
+
+

Invalid Link

+

The password reset token is missing or invalid.

+ +
+
+ ); + } + + const handleReset = async (e: React.FormEvent) => { + e.preventDefault(); + if (password !== confirmPassword) { + setError('Passwords do not match.'); + return; + } + if (password.length < 6) { + setError('Password must be at least 6 characters.'); + return; + } + + setLoading(true); + setError(''); + + try { + await api.resetPassword(token, password); + setSuccess(true); + setMessage('Your password has been reset successfully!'); + } catch (err: any) { + setError(err.message || 'Failed to reset password.'); + } finally { + setLoading(false); + } + }; + + return ( +
+
+
+
+ +
+
+ +

Reset Password

+ + {success ? ( +
+

{message}

+ +
+ ) : ( +
+

Enter your new password below.

+ + {error && ( +
+ {error} +
+ )} + +
+ + setPassword(e.target.value)} + className="w-full bg-zinc-950 border border-zinc-800 rounded-xl px-4 py-3 text-white focus:outline-none focus:border-primary transition-colors" + placeholder="••••••••" + required + /> +
+ +
+ + setConfirmPassword(e.target.value)} + className="w-full bg-zinc-950 border border-zinc-800 rounded-xl px-4 py-3 text-white focus:outline-none focus:border-primary transition-colors" + placeholder="••••••••" + required + /> +
+ + +
+ )} +
+
+ ); +} diff --git a/src/store/authStore.ts b/src/store/authStore.ts index 1613050..f774ca3 100644 --- a/src/store/authStore.ts +++ b/src/store/authStore.ts @@ -18,9 +18,9 @@ interface AuthState { signOut: () => void; setProfile: (profile: UserProfile) => void; isAuthModalOpen: boolean; - openAuthModal: (mode?: 'login' | 'register') => void; + openAuthModal: (mode?: 'login' | 'register' | 'forgot-password') => void; closeAuthModal: () => void; - authMode: 'login' | 'register'; + authMode: 'login' | 'register' | 'forgot-password'; } export const useAuthStore = create((set) => ({