QOL improvements
All checks were successful
Build Images and Deploy / Update-PROD-Stack (push) Successful in 28s
All checks were successful
Build Images and Deploy / Update-PROD-Stack (push) Successful in 28s
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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') %>
|
||||
|
||||
@@ -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
29
src/views/auth/reset.ejs
Normal 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') %>
|
||||
@@ -31,6 +31,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>
|
||||
<% } %>
|
||||
|
||||
@@ -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) { %>
|
||||
|
||||
@@ -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 */%>
|
||||
|
||||
Reference in New Issue
Block a user