diff --git a/src/config/database.js b/src/config/database.js index 2a46a43..4190f1d 100644 --- a/src/config/database.js +++ b/src/config/database.js @@ -195,6 +195,16 @@ const ready = new Promise(resolve => { _readyResolve = resolve; }); sess TEXT NOT NULL, expired DATETIME NOT NULL ); + + CREATE TABLE IF NOT EXISTS password_reset_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + token TEXT UNIQUE NOT NULL, + expires_at DATETIME NOT NULL, + used INTEGER DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) + ); `); // Create indexes (sql.js doesn't support IF NOT EXISTS on indexes in all diff --git a/src/models/index.js b/src/models/index.js index fcf8889..69d9a10 100644 --- a/src/models/index.js +++ b/src/models/index.js @@ -45,6 +45,36 @@ const Users = { db.prepare('UPDATE users SET is_admin = 1 WHERE id = ?').run(userId); }, + setPassword(userId, newPassword) { + const hash = bcrypt.hashSync(newPassword, 12); + db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(hash, userId); + }, + + createPasswordResetToken(userId) { + const token = crypto.randomBytes(32).toString('hex'); + const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(); // 24 hours + // Invalidate any existing tokens for this user + db.prepare('UPDATE password_reset_tokens SET used = 1 WHERE user_id = ?').run(userId); + db.prepare('INSERT INTO password_reset_tokens (user_id, token, expires_at) VALUES (?, ?, ?)').run(userId, token, expiresAt); + return token; + }, + + findByResetToken(token) { + return db.prepare(` + SELECT prt.*, u.username FROM password_reset_tokens prt + JOIN users u ON prt.user_id = u.id + WHERE prt.token = ? AND prt.used = 0 AND prt.expires_at > datetime('now') + `).get(token); + }, + + consumeResetToken(token) { + db.prepare('UPDATE password_reset_tokens SET used = 1 WHERE token = ?').run(token); + }, + + getAllUsers() { + return db.prepare('SELECT id, username, is_admin, created_at FROM users ORDER BY username ASC').all(); + }, + getTotalPoints(userId) { const row = db.prepare('SELECT COALESCE(SUM(points_awarded), 0) as total FROM scans WHERE user_id = ?').get(userId); return row.total; @@ -233,6 +263,14 @@ const Packages = { db.prepare('UPDATE packages SET first_scan_image = ? WHERE id = ?').run(imagePath, packageId); }, + removeFirstScanImage(packageId) { + db.prepare('UPDATE packages SET first_scan_image = NULL WHERE id = ?').run(packageId); + }, + + clearHint(packageId) { + db.prepare('UPDATE packages SET last_scan_hint = NULL WHERE id = ?').run(packageId); + }, + updateLastScanHint(packageId, userId, hint) { db.prepare('UPDATE packages SET last_scanned_by = ?, last_scan_hint = ? WHERE id = ?').run(userId, hint, packageId); } diff --git a/src/routes/admin.js b/src/routes/admin.js index 24df95c..5cd16ca 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -1,7 +1,7 @@ const express = require('express'); const router = express.Router(); const { requireAdmin } = require('../middleware/auth'); -const { Hunts, Packages } = require('../models'); +const { Hunts, Packages, Users } = require('../models'); const { generateHuntPDF } = require('../utils/pdf'); // All admin routes require admin access @@ -10,7 +10,8 @@ router.use(requireAdmin); // Admin dashboard router.get('/', (req, res) => { const hunts = Hunts.getByCreator(req.session.userId); - res.render('admin/dashboard', { title: 'Admin Dashboard', hunts }); + const users = Users.getAllUsers(); + res.render('admin/dashboard', { title: 'Admin Dashboard', hunts, users, resetUrl: null, resetUsername: null }); }); // Create hunt form @@ -71,7 +72,8 @@ router.get('/hunts/:id', (req, res) => { if (!hunt) return res.status(404).render('error', { title: 'Not Found', message: 'Hunt not found.' }); const packages = Packages.getByHunt(hunt.id); - res.render('admin/manage-hunt', { title: `Manage: ${hunt.name}`, hunt, packages }); + const baseUrl = process.env.BASE_URL || `http://localhost:${process.env.PORT || 3000}`; + res.render('admin/manage-hunt', { title: `Manage: ${hunt.name}`, hunt, packages, baseUrl }); }); // Download PDF of QR codes @@ -93,4 +95,31 @@ router.get('/hunts/:id/pdf', async (req, res) => { } }); +// ─── Generate password reset URL ────────────────────────── +router.post('/reset-password', (req, res) => { + const { username } = req.body; + const user = Users.findByUsername(username); + + if (!user) { + const hunts = Hunts.getByCreator(req.session.userId); + const users = Users.getAllUsers(); + req.session.flash = { type: 'danger', message: `User "${username}" not found.` }; + return res.redirect('/admin'); + } + + const token = Users.createPasswordResetToken(user.id); + const baseUrl = process.env.BASE_URL || `http://localhost:${process.env.PORT || 3000}`; + const resetUrl = `${baseUrl}/auth/reset/${token}`; + + const hunts = Hunts.getByCreator(req.session.userId); + const users = Users.getAllUsers(); + res.render('admin/dashboard', { + title: 'Admin Dashboard', + hunts, + users, + resetUrl, + resetUsername: username + }); +}); + module.exports = router; diff --git a/src/routes/auth.js b/src/routes/auth.js index 8f3f328..462b9a6 100644 --- a/src/routes/auth.js +++ b/src/routes/auth.js @@ -79,4 +79,39 @@ router.get('/logout', (req, res) => { }); }); +// ─── Password reset (via admin-generated URL) ──────────── +router.get('/reset/:token', (req, res) => { + const tokenRecord = Users.findByResetToken(req.params.token); + if (!tokenRecord) { + return res.render('error', { title: 'Invalid Link', message: 'This password reset link is invalid or has expired.' }); + } + res.render('auth/reset', { title: 'Reset Password', token: req.params.token, username: tokenRecord.username, error: null }); +}); + +router.post('/reset/:token', (req, res) => { + const tokenRecord = Users.findByResetToken(req.params.token); + if (!tokenRecord) { + return res.render('error', { title: 'Invalid Link', message: 'This password reset link is invalid or has expired.' }); + } + + const { password, password_confirm } = req.body; + + if (!password || password.length < 6) { + return res.render('auth/reset', { title: 'Reset Password', token: req.params.token, username: tokenRecord.username, error: 'Password must be at least 6 characters.' }); + } + + if (password !== password_confirm) { + return res.render('auth/reset', { title: 'Reset Password', token: req.params.token, username: tokenRecord.username, error: 'Passwords do not match.' }); + } + + Users.setPassword(tokenRecord.user_id, password); + Users.consumeResetToken(req.params.token); + + // Log the user in + req.session.userId = tokenRecord.user_id; + req.session.username = tokenRecord.username; + req.session.isAdmin = false; // they can re-check on next load + res.redirect('/'); +}); + module.exports = router; diff --git a/src/routes/hunts.js b/src/routes/hunts.js index ea35f65..d9d4c05 100644 --- a/src/routes/hunts.js +++ b/src/routes/hunts.js @@ -48,6 +48,7 @@ router.get('/hunt/:shortName/:cardNumber', (req, res) => { const scanHistory = Packages.getScanHistory(pkg.id); const isFirstScanner = req.session && req.session.userId && fullPkg.first_scanned_by === req.session.userId; const isLastScanner = req.session && req.session.userId && fullPkg.last_scanned_by === req.session.userId; + const isAdmin = !!(req.session && req.session.isAdmin); res.render('loot/profile', { title: `Package ${fullPkg.card_number} of ${pkg.package_count} - ${fullPkg.hunt_name}`, @@ -55,6 +56,7 @@ router.get('/hunt/:shortName/:cardNumber', (req, res) => { scanHistory, isFirstScanner, isLastScanner, + isAdmin, packages_total: pkg.package_count }); }); diff --git a/src/routes/loot.js b/src/routes/loot.js index b4a6975..5492f98 100644 --- a/src/routes/loot.js +++ b/src/routes/loot.js @@ -64,6 +64,7 @@ router.get('/:shortName/:code', (req, res) => { const hunt = Hunts.findById(fullPkg.hunt_id); const isFirstScanner = fullPkg.first_scanned_by === req.session.userId; const isLastScanner = fullPkg.last_scanned_by === req.session.userId; + const isAdmin = !!(req.session && req.session.isAdmin); res.render('loot/scanned', { title: `Package ${fullPkg.card_number} of ${hunt.package_count} - ${fullPkg.hunt_name}`, @@ -72,6 +73,7 @@ router.get('/:shortName/:code', (req, res) => { scanHistory, isFirstScanner, isLastScanner, + isAdmin, packages_total: hunt.package_count }); }); @@ -103,11 +105,43 @@ router.post('/:shortName/:code/image', requireAuth, upload.single('image'), (req return res.redirect(`/hunt/${shortName}/${pkg.card_number}`); } + // Remove old image file if replacing + if (pkg.first_scan_image) { + const oldPath = path.resolve(uploadsDir, path.basename(pkg.first_scan_image)); + try { fs.unlinkSync(oldPath); } catch (e) { /* file may not exist */ } + } + const imagePath = `/uploads/${req.file.filename}`; Packages.updateFirstScanImage(pkg.id, imagePath); res.redirect(`/hunt/${shortName}/${pkg.card_number}`); }); +// ─── Delete/remove image (first scanner or admin) ──────── +router.post('/:shortName/:code/image/delete', requireAuth, (req, res) => { + const { shortName, code } = req.params; + const pkg = Packages.findByHuntAndCode(shortName, code); + + if (!pkg) { + return res.status(404).render('error', { title: 'Not Found', message: 'Package not found.' }); + } + + const isOwner = pkg.first_scanned_by === req.session.userId; + const isAdmin = !!(req.session && req.session.isAdmin); + + if (!isOwner && !isAdmin) { + return res.status(403).render('error', { title: 'Forbidden', message: 'You cannot delete this image.' }); + } + + // Remove file from disk + if (pkg.first_scan_image) { + const filePath = path.resolve(uploadsDir, path.basename(pkg.first_scan_image)); + try { fs.unlinkSync(filePath); } catch (e) { /* file may not exist */ } + } + + Packages.removeFirstScanImage(pkg.id); + res.redirect(`/hunt/${shortName}/${pkg.card_number}`); +}); + // ─── Update hint/message ────────────────────────────────── router.post('/:shortName/:code/hint', requireAuth, (req, res) => { const { shortName, code } = req.params; @@ -126,4 +160,18 @@ router.post('/:shortName/:code/hint', requireAuth, (req, res) => { res.redirect(`/hunt/${shortName}/${pkg.card_number}`); }); +// ─── Admin clear hint ───────────────────────────────── +router.post('/:shortName/:code/hint/delete', requireAuth, (req, res) => { + if (!req.session.isAdmin) { + return res.status(403).render('error', { title: 'Forbidden', message: 'Admin access required.' }); + } + const { shortName, code } = req.params; + const pkg = Packages.findByHuntAndCode(shortName, code); + if (!pkg) { + return res.status(404).render('error', { title: 'Not Found', message: 'Package not found.' }); + } + Packages.clearHint(pkg.id); + res.redirect(`/hunt/${shortName}/${pkg.card_number}`); +}); + module.exports = router; diff --git a/src/views/admin/dashboard.ejs b/src/views/admin/dashboard.ejs index 8977b1a..b691737 100644 --- a/src/views/admin/dashboard.ejs +++ b/src/views/admin/dashboard.ejs @@ -24,6 +24,34 @@ <% }) %> <% } %> + +
Generate a one-time password reset link for a user. The link expires in 24 hours.
+ + + <% if (typeof resetUrl !== 'undefined' && resetUrl) { %> +Reset link for <%= resetUsername %>:
+Send this link to the user. It expires in 24 hours and can only be used once.
+Choose a new password for <%= username %>
+ + <% if (error) { %> +