diff --git a/src/config/database.js b/src/config/database.js index 521a0fe..35b6381 100644 --- a/src/config/database.js +++ b/src/config/database.js @@ -226,7 +226,8 @@ const ready = new Promise(resolve => { _readyResolve = resolve; }); const migrations = [ 'ALTER TABLE users ADD COLUMN is_organizer INTEGER DEFAULT 0', 'ALTER TABLE hunts ADD COLUMN start_date DATETIME', - 'ALTER TABLE hunts ADD COLUMN hidden_until_start INTEGER DEFAULT 0' + 'ALTER TABLE hunts ADD COLUMN hidden_until_start INTEGER DEFAULT 0', + 'ALTER TABLE users ADD COLUMN display_name TEXT' ]; for (const m of migrations) { try { _db.run(m); } catch (e) { /* column already exists */ } diff --git a/src/middleware/auth.js b/src/middleware/auth.js index bda4b75..a7c7685 100644 --- a/src/middleware/auth.js +++ b/src/middleware/auth.js @@ -36,6 +36,7 @@ function loadUser(req, res, next) { res.locals.currentUser = { id: req.session.userId, username: req.session.username, + displayName: req.session.displayName || req.session.username, isAdmin: req.session.isAdmin, isOrganizer: req.session.isOrganizer }; diff --git a/src/models/index.js b/src/models/index.js index 75a96d2..173b677 100644 --- a/src/models/index.js +++ b/src/models/index.js @@ -24,8 +24,8 @@ function getPointsForScanNumber(scanNumber) { const Users = { create(username, password) { const hash = bcrypt.hashSync(password, 12); - const stmt = db.prepare('INSERT INTO users (username, password_hash) VALUES (?, ?)'); - const result = stmt.run(username, hash); + const stmt = db.prepare('INSERT INTO users (username, password_hash, display_name) VALUES (?, ?, ?)'); + const result = stmt.run(username, hash, username); return result.lastInsertRowid; }, @@ -34,7 +34,7 @@ const Users = { }, findById(id) { - return db.prepare('SELECT id, username, is_admin, is_organizer, created_at FROM users WHERE id = ?').get(id); + return db.prepare('SELECT id, username, display_name, is_admin, is_organizer, created_at FROM users WHERE id = ?').get(id); }, verifyPassword(user, password) { @@ -58,6 +58,10 @@ const Users = { db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(hash, userId); }, + setDisplayName(userId, displayName) { + db.prepare('UPDATE users SET display_name = ? WHERE id = ?').run(displayName, userId); + }, + createPasswordResetToken(userId) { const token = crypto.randomBytes(32).toString('hex'); const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(); // 24 hours @@ -80,7 +84,7 @@ const Users = { }, getAllUsers() { - return db.prepare("SELECT id, username, is_admin, is_organizer, created_at FROM users WHERE username NOT LIKE '[deleted_%]' ORDER BY username ASC").all(); + return db.prepare("SELECT id, username, COALESCE(display_name, username) as display_name, is_admin, is_organizer, created_at FROM users WHERE password_hash != '' ORDER BY COALESCE(display_name, username) ASC").all(); }, getTotalPoints(userId) { @@ -139,9 +143,8 @@ const Users = { }, deleteUser(userId) { - const placeholder = '[deleted_' + userId + ']'; - db.prepare('UPDATE users SET username = ?, password_hash = ?, is_admin = 0, is_organizer = 0 WHERE id = ?') - .run(placeholder, '', userId); + db.prepare('UPDATE users SET display_name = ?, password_hash = ?, is_admin = 0, is_organizer = 0 WHERE id = ?') + .run('[deleted]', '', userId); db.prepare('UPDATE password_reset_tokens SET used = 1 WHERE user_id = ?').run(userId); db.prepare("DELETE FROM sessions WHERE sess LIKE ?").run('%"userId":' + userId + '%'); } @@ -184,7 +187,7 @@ const Hunts = { }, getAll() { - return db.prepare('SELECT h.*, u.username as creator_name FROM hunts h JOIN users u ON h.created_by = u.id ORDER BY h.created_at DESC').all(); + return db.prepare('SELECT h.*, COALESCE(u.display_name, u.username) as creator_name FROM hunts h JOIN users u ON h.created_by = u.id ORDER BY h.created_at DESC').all(); }, getByCreator(userId) { @@ -198,7 +201,7 @@ const Hunts = { getLeaderboard(huntId, limit = null, offset = 0) { let sql = ` - SELECT u.id, u.username, SUM(s.points_awarded) as total_points, COUNT(s.id) as scans + SELECT u.id, u.username, COALESCE(u.display_name, u.username) as display_name, SUM(s.points_awarded) as total_points, COUNT(s.id) as scans FROM scans s JOIN users u ON s.user_id = u.id JOIN packages p ON s.package_id = p.id @@ -269,7 +272,7 @@ const Hunts = { const totalPackages = db.prepare('SELECT package_count FROM hunts WHERE id = ?').get(huntId).package_count; const totalPoints = db.prepare('SELECT COALESCE(SUM(s.points_awarded), 0) as total FROM scans s JOIN packages p ON s.package_id = p.id WHERE p.hunt_id = ?').get(huntId).total; const topFinders = db.prepare(` - SELECT u.username, SUM(s.points_awarded) as points, COUNT(s.id) as finds + SELECT u.username, COALESCE(u.display_name, u.username) as display_name, SUM(s.points_awarded) as points, COUNT(s.id) as finds FROM scans s JOIN users u ON s.user_id = u.id JOIN packages p ON s.package_id = p.id @@ -277,7 +280,7 @@ const Hunts = { GROUP BY u.id ORDER BY points DESC LIMIT 5 `).all(huntId); const recentScans = db.prepare(` - SELECT s.scanned_at, s.points_awarded, u.username, p.card_number + SELECT s.scanned_at, s.points_awarded, u.username, COALESCE(u.display_name, u.username) as display_name, p.card_number FROM scans s JOIN users u ON s.user_id = u.id JOIN packages p ON s.package_id = p.id @@ -315,8 +318,10 @@ const Packages = { getByHunt(huntId) { return db.prepare(` SELECT p.*, - u1.username as first_scanner_name, - u2.username as last_scanner_name + COALESCE(u1.display_name, u1.username) as first_scanner_name, + u1.username as first_scanner_username, + COALESCE(u2.display_name, u2.username) as last_scanner_name, + u2.username as last_scanner_username FROM packages p LEFT JOIN users u1 ON p.first_scanned_by = u1.id LEFT JOIN users u2 ON p.last_scanned_by = u2.id @@ -329,8 +334,10 @@ const Packages = { return db.prepare(` SELECT p.*, h.name as hunt_name, h.short_name as hunt_short_name, h.id as hunt_id, - u1.username as first_scanner_name, - u2.username as last_scanner_name + COALESCE(u1.display_name, u1.username) as first_scanner_name, + u1.username as first_scanner_username, + COALESCE(u2.display_name, u2.username) as last_scanner_name, + u2.username as last_scanner_username FROM packages p JOIN hunts h ON p.hunt_id = h.id LEFT JOIN users u1 ON p.first_scanned_by = u1.id @@ -341,7 +348,7 @@ const Packages = { getScanHistory(packageId) { return db.prepare(` - SELECT s.*, u.username + SELECT s.*, u.username, COALESCE(u.display_name, u.username) as display_name FROM scans s JOIN users u ON s.user_id = u.id WHERE s.package_id = ? @@ -410,7 +417,7 @@ const Scans = { getGlobalLeaderboard(limit = null, offset = 0) { let sql = ` - SELECT u.id, u.username, SUM(s.points_awarded) as total_points, COUNT(s.id) as scans + SELECT u.id, u.username, COALESCE(u.display_name, u.username) as display_name, SUM(s.points_awarded) as total_points, COUNT(s.id) as scans FROM scans s JOIN users u ON s.user_id = u.id WHERE s.points_awarded > 0 @@ -429,7 +436,7 @@ const Scans = { getRecentActivity(limit = 5) { return db.prepare(` SELECT s.points_awarded, s.scanned_at, - u.username, + u.username, COALESCE(u.display_name, u.username) as display_name, p.card_number, h.name as hunt_name, h.short_name as hunt_short_name, h.package_count FROM scans s diff --git a/src/routes/auth.js b/src/routes/auth.js index e66a6a3..75afee7 100644 --- a/src/routes/auth.js +++ b/src/routes/auth.js @@ -20,6 +20,7 @@ router.post('/login', (req, res) => { req.session.userId = user.id; req.session.username = user.username; + req.session.displayName = user.display_name || user.username; req.session.isAdmin = !!user.is_admin; req.session.isOrganizer = !!user.is_organizer; @@ -63,6 +64,7 @@ router.post('/register', (req, res) => { const userId = Users.create(username, password); req.session.userId = userId; req.session.username = username; + req.session.displayName = username; req.session.isAdmin = false; req.session.isOrganizer = false; req.session.flash = { type: 'success', message: 'Account created! Welcome to Loot Hunt.' }; @@ -113,6 +115,7 @@ router.post('/reset/:token', (req, res) => { // Log the user in req.session.userId = tokenRecord.user_id; req.session.username = tokenRecord.username; + req.session.displayName = tokenRecord.username; req.session.isAdmin = false; // they can re-check on next load res.redirect('/'); }); diff --git a/src/routes/hunts.js b/src/routes/hunts.js index 48139cc..c28f179 100644 --- a/src/routes/hunts.js +++ b/src/routes/hunts.js @@ -136,6 +136,25 @@ router.post('/player/:username/password', requireAuth, (req, res) => { res.redirect(`/player/${user.username}`); }); +// ─── Update display name ────────────────────────────────── +router.post('/player/:username/display-name', requireAuth, (req, res) => { + const user = Users.findByUsername(req.params.username); + if (!user || user.id !== req.session.userId) { + return res.status(403).render('error', { title: 'Forbidden', message: 'You can only change your own display name.' }); + } + + const displayName = (req.body.display_name || '').trim(); + if (displayName.length < 2 || displayName.length > 32) { + req.session.flash = { type: 'danger', message: 'Display name must be 2-32 characters.' }; + return res.redirect(`/player/${user.username}`); + } + + Users.setDisplayName(user.id, displayName); + req.session.displayName = displayName; + req.session.flash = { type: 'success', message: 'Display name updated.' }; + res.redirect(`/player/${user.username}`); +}); + // ─── Delete own account ─────────────────────────────────── router.post('/player/:username/delete', requireAuth, (req, res) => { const user = Users.findByUsername(req.params.username); diff --git a/src/views/admin/dashboard.ejs b/src/views/admin/dashboard.ejs index e9280e3..b5277c3 100644 --- a/src/views/admin/dashboard.ejs +++ b/src/views/admin/dashboard.ejs @@ -44,7 +44,7 @@ @@ -74,7 +74,7 @@
<% if (typeof users !== 'undefined' && users) { users.filter(u => !u.is_admin).forEach(u => { %>"<%= pkg.last_scan_hint %>"
-Left by <%= pkg.last_scanner_name %>
+Left by <%= pkg.last_scanner_name %>
<% } else { %>No hint has been left yet.
<% } %> @@ -105,7 +105,7 @@ <% scanHistory.forEach((scan, i) => { %>"<%= pkg.last_scan_hint %>"
-Left by <%= pkg.last_scanner_name %>
+Left by <%= pkg.last_scanner_name %>
<% } else { %>No hint has been left yet.
<% } %> @@ -121,7 +121,7 @@ <% scanHistory.forEach((scan, i) => { %>Joined <%= new Date(profile.created_at).toLocaleDateString() %>
+<%= profile.username %> · Joined <%= new Date(profile.created_at).toLocaleDateString() %>
This is the name shown on leaderboards, scan history, and your profile. Your username (<%= profile.username %>) is used for login and URLs.
+ +