diff --git a/README.md b/README.md index f77add8..de290d5 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,10 @@ A digital alternate reality game — find and scan hidden QR codes in real life ## How It Works -1. **Organizers** create a "hunt" and generate printable QR code cards -2. **Players** scan hidden QR codes to earn points — first find gets the most points -3. **Leaderboards** track top players per hunt and globally +1. **Admins** create hunts, manage all content, assign roles, and reset passwords +2. **Organizers** create and manage their own hunts with printable QR code cards +3. **Players** scan hidden QR codes to earn points — first find gets the most points +4. **Leaderboards** track top players per hunt and globally ### Point System @@ -19,10 +20,34 @@ A digital alternate reality game — find and scan hidden QR codes in real life Players earn points only once per package. Re-scanning lets you update the package hint. +### Roles + +| Capability | Admin | Organizer | Player | +|---|---|---|---| +| Create hunts | Yes | Yes | No | +| Manage/edit/delete own hunts | Yes | Yes | No | +| Manage other users' hunts | Yes | No | No | +| Download QR code PDFs | Yes | Own hunts | No | +| Password reset | Yes | No | No | +| Delete any image / clear hints | Yes | No | No | +| Assign organizer role | Yes | No | No | + +### Features + +- **QR Code Cards** — Printable Avery 5371 format PDFs with double-sided backs +- **First Finder Photos** — First scanner can upload/replace an image per package +- **Hints** — Most recent scanner can leave a message for the next finder +- **Player Profiles** — Stats, rank, hunt breakdown, and recent activity +- **Dark Mode** — Toggle with system preference detection and localStorage persistence +- **Relative Timestamps** — "3h ago" format with full date on hover +- **Flash Messages** — Feedback on actions (create, edit, delete, upload, etc.) +- **Paginated Leaderboards** — 25 per page with navigation controls +- **Admin Dashboard** — Hunt stats, top finders, discovery rate, recent scans, role management + ## Tech Stack - **Node.js** + Express -- **SQLite** via better-sqlite3 +- **SQLite** via sql.js (WASM) - **EJS** templates - **PDFKit** + qrcode for printable QR sheets - **Docker** deployment via Portainer @@ -75,17 +100,22 @@ src/ ├── app.js # Express application entry point ├── setup-admin.js # CLI tool to create/promote admin users ├── config/ -│ └── database.js # SQLite initialization & schema +│ └── database.js # SQLite (sql.js) initialization, schema & migrations ├── middleware/ -│ └── auth.js # Auth & admin middleware +│ └── auth.js # Auth, admin & organizer middleware ├── models/ │ └── index.js # All database operations ├── routes/ -│ ├── auth.js # Login/register/logout -│ ├── admin.js # Hunt management & PDF download +│ ├── auth.js # Login/register/logout/password reset +│ ├── admin.js # Hunt management, PDF download, roles, password reset │ ├── loot.js # QR scan handling, image upload, hints -│ └── hunts.js # Public hunt profiles & leaderboards +│ └── hunts.js # Public hunt profiles, leaderboards, player profiles ├── utils/ -│ └── pdf.js # QR code PDF generation +│ └── pdf.js # Avery 5371 QR code PDF generation └── views/ # EJS templates +public/ +├── css/ +│ └── style.css # Styles with dark mode support +└── js/ + └── timeago.js # Relative timestamp formatting ``` diff --git a/src/config/database.js b/src/config/database.js index 4190f1d..0210145 100644 --- a/src/config/database.js +++ b/src/config/database.js @@ -144,6 +144,7 @@ const ready = new Promise(resolve => { _readyResolve = resolve; }); username TEXT UNIQUE NOT NULL COLLATE NOCASE, password_hash TEXT NOT NULL, is_admin INTEGER DEFAULT 0, + is_organizer INTEGER DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); @@ -221,6 +222,14 @@ const ready = new Promise(resolve => { _readyResolve = resolve; }); try { _db.run(idx); } catch (e) { /* index may already exist */ } } + // Migrations — add columns to existing databases + const migrations = [ + 'ALTER TABLE users ADD COLUMN is_organizer INTEGER DEFAULT 0' + ]; + for (const m of migrations) { + try { _db.run(m); } catch (e) { /* column already exists */ } + } + save(); // persist initial schema _readyResolve(); })(); diff --git a/src/middleware/auth.js b/src/middleware/auth.js index fd89338..bda4b75 100644 --- a/src/middleware/auth.js +++ b/src/middleware/auth.js @@ -20,12 +20,24 @@ function requireAdmin(req, res, next) { res.redirect('/auth/login'); } +function requireOrganizerOrAdmin(req, res, next) { + if (req.session && req.session.userId && (req.session.isAdmin || req.session.isOrganizer)) { + return next(); + } + if (req.session && req.session.userId) { + return res.status(403).render('error', { title: 'Forbidden', message: 'You do not have access to this page.' }); + } + req.session.returnTo = req.originalUrl; + res.redirect('/auth/login'); +} + function loadUser(req, res, next) { if (req.session && req.session.userId) { res.locals.currentUser = { id: req.session.userId, username: req.session.username, - isAdmin: req.session.isAdmin + isAdmin: req.session.isAdmin, + isOrganizer: req.session.isOrganizer }; } else { res.locals.currentUser = null; @@ -34,4 +46,4 @@ function loadUser(req, res, next) { next(); } -module.exports = { requireAuth, requireAdmin, loadUser }; +module.exports = { requireAuth, requireAdmin, requireOrganizerOrAdmin, loadUser }; diff --git a/src/models/index.js b/src/models/index.js index 4c89c8e..60ff7f1 100644 --- a/src/models/index.js +++ b/src/models/index.js @@ -34,7 +34,7 @@ const Users = { }, findById(id) { - return db.prepare('SELECT id, username, is_admin, created_at FROM users WHERE id = ?').get(id); + return db.prepare('SELECT id, username, is_admin, is_organizer, created_at FROM users WHERE id = ?').get(id); }, verifyPassword(user, password) { @@ -45,6 +45,14 @@ const Users = { db.prepare('UPDATE users SET is_admin = 1 WHERE id = ?').run(userId); }, + makeOrganizer(userId) { + db.prepare('UPDATE users SET is_organizer = 1 WHERE id = ?').run(userId); + }, + + removeOrganizer(userId) { + db.prepare('UPDATE users SET is_organizer = 0 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); @@ -72,7 +80,7 @@ const Users = { }, getAllUsers() { - return db.prepare('SELECT id, username, is_admin, created_at FROM users ORDER BY username ASC').all(); + return db.prepare('SELECT id, username, is_admin, is_organizer, created_at FROM users ORDER BY username ASC').all(); }, getTotalPoints(userId) { diff --git a/src/routes/admin.js b/src/routes/admin.js index 65c9f5e..b5019c5 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -1,17 +1,32 @@ const express = require('express'); const router = express.Router(); -const { requireAdmin } = require('../middleware/auth'); +const { requireAdmin, requireOrganizerOrAdmin } = require('../middleware/auth'); const { Hunts, Packages, Users } = require('../models'); const { generateHuntPDF } = require('../utils/pdf'); -// All admin routes require admin access -router.use(requireAdmin); +// Helper: check if user owns this hunt (or is admin) +function requireHuntAccess(req, res, next) { + const hunt = Hunts.findById(req.params.id); + if (!hunt) return res.status(404).render('error', { title: 'Not Found', message: 'Hunt not found.' }); + // Admins can access any hunt; organizers only their own + if (req.session.isAdmin || hunt.created_by === req.session.userId) { + req.hunt = hunt; + return next(); + } + return res.status(403).render('error', { title: 'Forbidden', message: 'You can only manage your own hunts.' }); +} -// Admin dashboard +// All admin routes require at least organizer access +router.use(requireOrganizerOrAdmin); + +// Admin/Organizer dashboard router.get('/', (req, res) => { const hunts = Hunts.getByCreator(req.session.userId); - const users = Users.getAllUsers(); - res.render('admin/dashboard', { title: 'Admin Dashboard', hunts, users, resetUrl: null, resetUsername: null }); + const isAdmin = !!req.session.isAdmin; + + // Only admins see the full user list and password reset + const users = isAdmin ? Users.getAllUsers() : []; + res.render('admin/dashboard', { title: isAdmin ? 'Admin Dashboard' : 'Organizer Dashboard', hunts, users, resetUrl: null, resetUsername: null, isAdmin }); }); // Create hunt form @@ -68,9 +83,8 @@ router.post('/hunts', (req, res) => { }); // Manage hunt -router.get('/hunts/:id', (req, res) => { - const hunt = Hunts.findById(req.params.id); - if (!hunt) return res.status(404).render('error', { title: 'Not Found', message: 'Hunt not found.' }); +router.get('/hunts/:id', requireHuntAccess, (req, res) => { + const hunt = req.hunt; const packages = Packages.getByHunt(hunt.id); const baseUrl = process.env.BASE_URL || `http://localhost:${process.env.PORT || 3000}`; @@ -79,16 +93,14 @@ router.get('/hunts/:id', (req, res) => { }); // Edit hunt form -router.get('/hunts/:id/edit', (req, res) => { - const hunt = Hunts.findById(req.params.id); - if (!hunt) return res.status(404).render('error', { title: 'Not Found', message: 'Hunt not found.' }); +router.get('/hunts/:id/edit', requireHuntAccess, (req, res) => { + const hunt = req.hunt; res.render('admin/edit-hunt', { title: `Edit: ${hunt.name}`, hunt, error: null }); }); // Update hunt -router.post('/hunts/:id/edit', (req, res) => { - const hunt = Hunts.findById(req.params.id); - if (!hunt) return res.status(404).render('error', { title: 'Not Found', message: 'Hunt not found.' }); +router.post('/hunts/:id/edit', requireHuntAccess, (req, res) => { + const hunt = req.hunt; const { name, description, expiry_date } = req.body; if (!name || !name.trim()) { @@ -101,9 +113,8 @@ router.post('/hunts/:id/edit', (req, res) => { }); // Delete hunt -router.post('/hunts/:id/delete', (req, res) => { - const hunt = Hunts.findById(req.params.id); - if (!hunt) return res.status(404).render('error', { title: 'Not Found', message: 'Hunt not found.' }); +router.post('/hunts/:id/delete', requireHuntAccess, (req, res) => { + const hunt = req.hunt; Hunts.delete(hunt.id); req.session.flash = { type: 'success', message: `Hunt "${hunt.name}" deleted.` }; @@ -111,9 +122,8 @@ router.post('/hunts/:id/delete', (req, res) => { }); // Download PDF of QR codes -router.get('/hunts/:id/pdf', async (req, res) => { - const hunt = Hunts.findById(req.params.id); - if (!hunt) return res.status(404).render('error', { title: 'Not Found', message: 'Hunt not found.' }); +router.get('/hunts/:id/pdf', requireHuntAccess, async (req, res) => { + const hunt = req.hunt; const packages = Packages.getByHunt(hunt.id); const baseUrl = process.env.BASE_URL || `http://localhost:${process.env.PORT || 3000}`; @@ -129,8 +139,32 @@ router.get('/hunts/:id/pdf', async (req, res) => { } }); -// ─── Generate password reset URL ────────────────────────── -router.post('/reset-password', (req, res) => { +// ─── Manage user roles (admin only) ─────────────────────── +router.post('/users/:id/role', requireAdmin, (req, res) => { + const userId = parseInt(req.params.id, 10); + const user = Users.findById(userId); + if (!user) { + req.session.flash = { type: 'danger', message: 'User not found.' }; + return res.redirect('/admin'); + } + if (user.is_admin) { + req.session.flash = { type: 'danger', message: 'Cannot change role of an admin.' }; + return res.redirect('/admin'); + } + + const { role } = req.body; + if (role === 'organizer') { + Users.makeOrganizer(userId); + req.session.flash = { type: 'success', message: `${user.username} is now an Organizer.` }; + } else { + Users.removeOrganizer(userId); + req.session.flash = { type: 'success', message: `${user.username} is no longer an Organizer.` }; + } + res.redirect('/admin'); +}); + +// ─── Generate password reset URL (admin only) ──────────── +router.post('/reset-password', requireAdmin, (req, res) => { const { username } = req.body; const user = Users.findByUsername(username); @@ -152,7 +186,8 @@ router.post('/reset-password', (req, res) => { hunts, users, resetUrl, - resetUsername: username + resetUsername: username, + isAdmin: true }); }); diff --git a/src/routes/auth.js b/src/routes/auth.js index bf535a5..e66a6a3 100644 --- a/src/routes/auth.js +++ b/src/routes/auth.js @@ -21,6 +21,7 @@ router.post('/login', (req, res) => { req.session.userId = user.id; req.session.username = user.username; req.session.isAdmin = !!user.is_admin; + req.session.isOrganizer = !!user.is_organizer; const returnTo = req.session.returnTo || '/'; delete req.session.returnTo; @@ -63,6 +64,7 @@ router.post('/register', (req, res) => { req.session.userId = userId; req.session.username = username; req.session.isAdmin = false; + req.session.isOrganizer = false; req.session.flash = { type: 'success', message: 'Account created! Welcome to Loot Hunt.' }; const returnTo = req.session.returnTo || '/'; diff --git a/src/views/admin/dashboard.ejs b/src/views/admin/dashboard.ejs index b691737..5aaf0b8 100644 --- a/src/views/admin/dashboard.ejs +++ b/src/views/admin/dashboard.ejs @@ -2,7 +2,7 @@
Generate a one-time password reset link for a user. The link expires in 24 hours.
@@ -34,7 +35,7 @@Grant or revoke the Organizer role. Organizers can create hunts and manage their own hunts only.
+| User | Role | Action |
|---|---|---|
| <%= u.username %> | +<%= u.is_organizer ? 'Organizer' : 'Player' %> | ++ <% if (u.is_organizer) { %> + + <% } else { %> + + <% } %> + | +