QOL improvements
All checks were successful
Build Images and Deploy / Update-PROD-Stack (push) Successful in 28s

This commit is contained in:
2026-02-28 01:14:50 -05:00
parent 79ee7064a8
commit bdb6d5ee25
11 changed files with 264 additions and 4 deletions

View File

@@ -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

View File

@@ -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);
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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
});
});

View File

@@ -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;

View File

@@ -24,6 +24,34 @@
</a>
<% }) %>
<% } %>
<h2 style="margin-top: 2rem; margin-bottom: 1rem;">Password Reset</h2>
<div class="card">
<p style="color: var(--muted); font-size: 0.9rem; margin-bottom: 1rem;">Generate a one-time password reset link for a user. The link expires in 24 hours.</p>
<form method="POST" action="/admin/reset-password" style="display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: flex-end;">
<div class="form-group" style="margin-bottom: 0; flex: 1; min-width: 180px;">
<label>Username</label>
<select name="username" class="form-control" required>
<option value="">Select user...</option>
<% if (typeof users !== 'undefined' && users) { users.forEach(u => { %>
<option value="<%= u.username %>"><%= u.username %><%= u.is_admin ? ' (admin)' : '' %></option>
<% }); } %>
</select>
</div>
<button type="submit" class="btn btn-primary">Generate Reset Link</button>
</form>
<% if (typeof resetUrl !== 'undefined' && resetUrl) { %>
<div style="margin-top: 1rem; padding: 1rem; background: var(--body-bg); border-radius: 8px;">
<p style="margin: 0 0 0.5rem; font-weight: 600;">Reset link for <strong><%= resetUsername %></strong>:</p>
<div style="display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap;">
<input type="text" class="form-control" value="<%= resetUrl %>" id="reset-url" readonly style="font-family: monospace; font-size: 0.85rem; flex: 1; min-width: 200px;">
<button class="btn btn-sm btn-outline" onclick="document.getElementById('reset-url').select();navigator.clipboard.writeText(document.getElementById('reset-url').value).then(()=>{this.textContent='Copied!';setTimeout(()=>this.textContent='Copy',1500)})">Copy</button>
</div>
<p style="font-size: 0.8rem; color: var(--muted); margin: 0.5rem 0 0;">Send this link to the user. It expires in 24 hours and can only be used once.</p>
</div>
<% } %>
</div>
</div>
<%- include('../partials/footer') %>

View File

@@ -49,7 +49,7 @@
<th>Scans</th>
<th>First Scanner</th>
<th>Last Scanner</th>
<th>Link</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@@ -62,6 +62,7 @@
<td><% if (pkg.last_scanner_name) { %><a href="/player/<%= pkg.last_scanner_name %>"><%= pkg.last_scanner_name %></a><% } else { %>---<% } %></td>
<td>
<a href="/hunt/<%= hunt.short_name %>/<%= pkg.card_number %>" class="btn btn-sm btn-outline">View</a>
<button class="btn btn-sm btn-outline" onclick="navigator.clipboard.writeText('<%= baseUrl %>/loot/<%= hunt.short_name %>/<%= pkg.unique_code %>').then(()=>{this.textContent='Copied!';setTimeout(()=>this.textContent='Copy Link',1500)})">Copy Link</button>
</td>
</tr>
<% }) %>

29
src/views/auth/reset.ejs Normal file
View File

@@ -0,0 +1,29 @@
<%- include('../partials/header') %>
<div class="container-narrow">
<div class="card">
<h1 style="text-align: center; margin-bottom: 0.5rem;">Reset Password</h1>
<p style="text-align: center; color: var(--muted); margin-bottom: 1.5rem;">Choose a new password for <strong><%= username %></strong></p>
<% if (error) { %>
<div class="alert alert-danger"><%= error %></div>
<% } %>
<form method="POST" action="/auth/reset/<%= token %>">
<div class="form-group">
<label for="password">New Password</label>
<input type="password" id="password" name="password" class="form-control" required
minlength="6" autocomplete="new-password" autofocus>
<span class="form-hint">Minimum 6 characters</span>
</div>
<div class="form-group">
<label for="password_confirm">Confirm Password</label>
<input type="password" id="password_confirm" name="password_confirm" class="form-control" required
minlength="6" autocomplete="new-password">
</div>
<button type="submit" class="btn btn-primary" style="width: 100%;">Set New Password</button>
</form>
</div>
</div>
<%- include('../partials/footer') %>

View File

@@ -31,6 +31,21 @@
<div class="card">
<div class="card-header">&#x1F4F8; First Finder's Photo</div>
<img src="<%= pkg.first_scan_image %>" alt="Package photo" class="package-image">
<% if (isFirstScanner) { %>
<div style="display: flex; gap: 0.5rem; margin-top: 0.75rem; flex-wrap: wrap;">
<form method="POST" action="/loot/<%= pkg.hunt_short_name %>/<%= pkg.unique_code %>/image/delete" style="margin: 0;">
<button type="submit" class="btn btn-sm btn-danger" onclick="return confirm('Remove your photo?')">Remove Photo</button>
</form>
<form method="POST" action="/loot/<%= pkg.hunt_short_name %>/<%= pkg.unique_code %>/image" enctype="multipart/form-data" style="display: flex; gap: 0.5rem; align-items: center; margin: 0;">
<input type="file" name="image" accept="image/*" class="form-control" style="max-width: 220px; padding: 0.3rem;" required>
<button type="submit" class="btn btn-sm btn-primary">Replace</button>
</form>
</div>
<% } else if (typeof isAdmin !== 'undefined' && isAdmin) { %>
<form method="POST" action="/loot/<%= pkg.hunt_short_name %>/<%= pkg.unique_code %>/image/delete" style="margin-top: 0.75rem;">
<button type="submit" class="btn btn-sm btn-danger" onclick="return confirm('Admin: permanently delete this image?')">Admin Delete</button>
</form>
<% } %>
</div>
<% } %>
@@ -66,6 +81,11 @@
<button type="submit" class="btn btn-primary btn-sm">Save Hint</button>
</form>
<% } %>
<% if (typeof isAdmin !== 'undefined' && isAdmin && pkg.last_scan_hint) { %>
<form method="POST" action="/loot/<%= pkg.hunt_short_name %>/<%= pkg.unique_code %>/hint/delete" style="margin-top: 0.75rem;">
<button type="submit" class="btn btn-sm btn-danger" onclick="return confirm('Admin: clear this hint?')">Admin Clear</button>
</form>
<% } %>
</div>
<% if (scanHistory.length > 0) { %>

View File

@@ -45,6 +45,21 @@
<div class="card">
<div class="card-header">First Finder's Photo</div>
<img src="<%= pkg.first_scan_image %>" alt="Package photo" class="package-image">
<% if (isFirstScanner) { %>
<div style="display: flex; gap: 0.5rem; margin-top: 0.75rem; flex-wrap: wrap;">
<form method="POST" action="/loot/<%= pkg.hunt_short_name %>/<%= pkg.unique_code %>/image/delete" style="margin: 0;">
<button type="submit" class="btn btn-sm btn-danger" onclick="return confirm('Remove your photo?')">Remove Photo</button>
</form>
<form method="POST" action="/loot/<%= pkg.hunt_short_name %>/<%= pkg.unique_code %>/image" enctype="multipart/form-data" style="display: flex; gap: 0.5rem; align-items: center; margin: 0;">
<input type="file" name="image" accept="image/*" class="form-control" style="max-width: 220px; padding: 0.3rem;" required>
<button type="submit" class="btn btn-sm btn-primary">Replace</button>
</form>
</div>
<% } else if (typeof isAdmin !== 'undefined' && isAdmin) { %>
<form method="POST" action="/loot/<%= pkg.hunt_short_name %>/<%= pkg.unique_code %>/image/delete" style="margin-top: 0.75rem;">
<button type="submit" class="btn btn-sm btn-danger" onclick="return confirm('Admin: permanently delete this image?')">Admin Delete</button>
</form>
<% } %>
</div>
<% } %>
@@ -81,6 +96,11 @@
<button type="submit" class="btn btn-primary btn-sm">Save Hint</button>
</form>
<% } %>
<% if (typeof isAdmin !== 'undefined' && isAdmin && pkg.last_scan_hint) { %>
<form method="POST" action="/loot/<%= pkg.hunt_short_name %>/<%= pkg.unique_code %>/hint/delete" style="margin-top: 0.75rem;">
<button type="submit" class="btn btn-sm btn-danger" onclick="return confirm('Admin: clear this hint?')">Admin Clear</button>
</form>
<% } %>
</div>
<%/* Scan history */%>