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 => { %> - <%= u.username %> + <%= u.display_name %> <%= u.is_organizer ? 'Organizer' : 'Player' %>
diff --git a/src/views/admin/manage-hunt.ejs b/src/views/admin/manage-hunt.ejs index fe951de..0046ad9 100644 --- a/src/views/admin/manage-hunt.ejs +++ b/src/views/admin/manage-hunt.ejs @@ -65,7 +65,7 @@ <% stats.topFinders.forEach(f => { %> - <%= f.username %> + <%= f.display_name %> <%= f.finds %> +<%= f.points %> @@ -84,7 +84,7 @@ <% stats.recentScans.forEach(s => { %> <%= s.card_number %> - <%= s.username %> + <%= s.display_name %> +<%= s.points_awarded %> @@ -117,8 +117,8 @@ <%= pkg.card_number %> <%= pkg.unique_code %> <%= pkg.scan_count %> - <% if (pkg.first_scanner_name) { %><%= pkg.first_scanner_name %><% } else { %>---<% } %> - <% if (pkg.last_scanner_name) { %><%= pkg.last_scanner_name %><% } else { %>---<% } %> + <% if (pkg.first_scanner_name) { %><%= pkg.first_scanner_name %><% } else { %>---<% } %> + <% if (pkg.last_scanner_name) { %><%= pkg.last_scanner_name %><% } else { %>---<% } %>
View diff --git a/src/views/home.ejs b/src/views/home.ejs index d977967..4713d07 100644 --- a/src/views/home.ejs +++ b/src/views/home.ejs @@ -74,7 +74,7 @@
+<%= a.points_awarded %>
-
<%= a.username %> found #<%= a.card_number %> in <%= a.hunt_name %>
+
<%= a.display_name %> found #<%= a.card_number %> in <%= a.hunt_name %>
diff --git a/src/views/hunt/leaderboard.ejs b/src/views/hunt/leaderboard.ejs index 65e30e3..a2c5b30 100644 --- a/src/views/hunt/leaderboard.ejs +++ b/src/views/hunt/leaderboard.ejs @@ -28,7 +28,7 @@ <% const rank = (typeof offset !== 'undefined' ? offset : 0) + i + 1; %> <%= rank %> - <%= entry.username %> + <%= entry.display_name %> <%= entry.total_points %> <%= entry.scans %> diff --git a/src/views/leaderboard/global.ejs b/src/views/leaderboard/global.ejs index 2b2f010..04e8805 100644 --- a/src/views/leaderboard/global.ejs +++ b/src/views/leaderboard/global.ejs @@ -26,7 +26,7 @@ <% if (rank === 1) { %>🥇<% } else if (rank === 2) { %>🥈<% } else if (rank === 3) { %>🥉<% } else { %><%= rank %><% } %> - <%= entry.username %> + <%= entry.display_name %> <%= entry.total_points %> <%= entry.scans %> diff --git a/src/views/loot/profile.ejs b/src/views/loot/profile.ejs index a55b125..5f3c276 100644 --- a/src/views/loot/profile.ejs +++ b/src/views/loot/profile.ejs @@ -17,11 +17,11 @@
Total Scans
-
<% if (pkg.first_scanner_name) { %><%= pkg.first_scanner_name %><% } else { %>---<% } %>
+
<% if (pkg.first_scanner_name) { %><%= pkg.first_scanner_name %><% } else { %>---<% } %>
First Finder
-
<% if (pkg.last_scanner_name) { %><%= pkg.last_scanner_name %><% } else { %>---<% } %>
+
<% if (pkg.last_scanner_name) { %><%= pkg.last_scanner_name %><% } else { %>---<% } %>
Most Recent
@@ -67,7 +67,7 @@
💬 Package Hint
<% if (pkg.last_scan_hint) { %>

"<%= 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) => { %> <%= i + 1 %> - <%= scan.username %> + <%= scan.display_name %> <% if (scan.points_awarded > 0) { %>+<%= scan.points_awarded %><% } else { %>0<% } %> diff --git a/src/views/loot/scanned.ejs b/src/views/loot/scanned.ejs index 89f8be2..2c002aa 100644 --- a/src/views/loot/scanned.ejs +++ b/src/views/loot/scanned.ejs @@ -31,11 +31,11 @@
Total Scans
-
<% if (pkg.first_scanner_name) { %><%= pkg.first_scanner_name %><% } else { %>---<% } %>
+
<% if (pkg.first_scanner_name) { %><%= pkg.first_scanner_name %><% } else { %>---<% } %>
First Finder
-
<% if (pkg.last_scanner_name) { %><%= pkg.last_scanner_name %><% } else { %>---<% } %>
+
<% if (pkg.last_scanner_name) { %><%= pkg.last_scanner_name %><% } else { %>---<% } %>
Most Recent
@@ -82,7 +82,7 @@
💬 Package Hint
<% if (pkg.last_scan_hint) { %>

"<%= 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) => { %> <%= i + 1 %> - <%= scan.username %> + <%= scan.display_name %> <% if (scan.points_awarded > 0) { %>+<%= scan.points_awarded %><% } else { %>0<% } %> diff --git a/src/views/partials/header.ejs b/src/views/partials/header.ejs index 762a46d..11538a9 100644 --- a/src/views/partials/header.ejs +++ b/src/views/partials/header.ejs @@ -35,7 +35,7 @@ <% } else if (currentUser.isOrganizer) { %>
  • Organizer
  • <% } %> -
  • Logout (<%= currentUser.username %>)
  • +
  • Logout (<%= currentUser.displayName %>)
  • <% } else { %>
  • Login
  • Register
  • diff --git a/src/views/player/profile.ejs b/src/views/player/profile.ejs index 7c39707..047a43c 100644 --- a/src/views/player/profile.ejs +++ b/src/views/player/profile.ejs @@ -2,8 +2,8 @@
    -

    👤 <%= profile.username %>

    -

    Joined <%= new Date(profile.created_at).toLocaleDateString() %>

    +

    👤 <%= profile.display_name || profile.username %>

    +

    <%= profile.username %> · Joined <%= new Date(profile.created_at).toLocaleDateString() %>

    @@ -88,6 +88,17 @@
    <% if (typeof isOwnProfile !== 'undefined' && isOwnProfile) { %> +
    +
    ✏️ Display Name
    +

    This is the name shown on leaderboards, scan history, and your profile. Your username (<%= profile.username %>) is used for login and URLs.

    +
    +
    + +
    + +
    +
    +
    🔒 Change Password