feat: implement hard-delete functionality for user accounts and update user management UI
All checks were successful
Build Images and Deploy / Update-PROD-Stack (push) Successful in 30s

This commit is contained in:
2026-03-20 14:23:01 -04:00
parent b7f3394448
commit 64764b652d
3 changed files with 44 additions and 7 deletions

View File

@@ -84,7 +84,11 @@ const Users = {
},
getAllUsers() {
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();
return db.prepare("SELECT id, username, COALESCE(display_name, username) as display_name, password_hash, is_admin, is_organizer, created_at FROM users ORDER BY password_hash = '' ASC, COALESCE(display_name, username) ASC").all();
},
isDeleted(user) {
return user.password_hash === '';
},
getTotalPoints(userId) {
@@ -150,6 +154,15 @@ const Users = {
.run(scrambled, '[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 + '%');
},
hardDeleteUser(userId) {
db.prepare('DELETE FROM scans WHERE user_id = ?').run(userId);
db.prepare('UPDATE packages SET first_scanned_by = NULL, first_scan_image = NULL WHERE first_scanned_by = ?').run(userId);
db.prepare('UPDATE packages SET last_scanned_by = NULL, last_scan_hint = NULL WHERE last_scanned_by = ?').run(userId);
db.prepare('DELETE FROM password_reset_tokens WHERE user_id = ?').run(userId);
db.prepare("DELETE FROM sessions WHERE sess LIKE ?").run('%"userId":' + userId + '%');
db.prepare('DELETE FROM users WHERE id = ?').run(userId);
}
};

View File

@@ -190,6 +190,24 @@ router.post('/users/:id/delete', requireAdmin, (req, res) => {
res.redirect('/admin');
});
// ─── Hard-delete user account (admin only) ────────────────
router.post('/users/:id/hard-delete', 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 hard-delete an admin account.' };
return res.redirect('/admin');
}
Users.hardDeleteUser(userId);
req.session.flash = { type: 'success', message: `Account "${user.display_name || user.username}" has been permanently removed.` };
res.redirect('/admin');
});
// ─── Generate password reset URL (admin only) ────────────
router.post('/reset-password', requireAdmin, (req, res) => {
const { username } = req.body;

View File

@@ -43,7 +43,7 @@
<label>Username</label>
<select name="username" class="form-control" required>
<option value="">Select user...</option>
<% if (typeof users !== 'undefined' && users) { users.forEach(u => { %>
<% if (typeof users !== 'undefined' && users) { users.filter(u => u.password_hash !== '').forEach(u => { %>
<option value=\"<%= u.username %>\"><%= u.display_name %><%= u.is_admin ? ' (admin)' : u.is_organizer ? ' (organizer)' : '' %></option>
<% }); } %>
</select>
@@ -72,12 +72,17 @@
<tr><th>User</th><th>Role</th><th>Action</th></tr>
</thead>
<tbody>
<% if (typeof users !== 'undefined' && users) { users.filter(u => !u.is_admin).forEach(u => { %>
<tr>
<td><a href="/player/<%= u.username %>"><%= u.display_name %></a></td>
<td><%= u.is_organizer ? 'Organizer' : 'Player' %></td>
<% if (typeof users !== 'undefined' && users) { users.filter(u => !u.is_admin).forEach(u => { const deleted = u.password_hash === ''; %>
<tr<%= deleted ? ' style="opacity: 0.5;"' : '' %>>
<td><% if (!deleted) { %><a href="/player/<%= u.username %>"><%= u.display_name %></a><% } else { %><span style="color: var(--muted);"><%= u.display_name %></span><% } %></td>
<td><%= deleted ? 'Deleted' : u.is_organizer ? 'Organizer' : 'Player' %></td>
<td>
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap;">
<% if (deleted) { %>
<form method="POST" action="/admin/users/<%= u.id %>/hard-delete" style="margin:0;">
<button type="submit" class="btn btn-sm btn-danger" onclick="return confirm('PERMANENTLY remove this user and ALL their scan data? This cannot be undone.')">Hard Delete</button>
</form>
<% } else { %>
<% if (u.is_organizer) { %>
<form method="POST" action="/admin/users/<%= u.id %>/role" style="margin:0;">
<input type="hidden" name="role" value="player">
@@ -90,8 +95,9 @@
</form>
<% } %>
<form method="POST" action="/admin/users/<%= u.id %>/delete" style="margin:0;">
<button type="submit" class="btn btn-sm btn-danger" onclick="return confirm('Permanently delete the account &quot;<%= u.username %>&quot;? Their data will be anonymized.')">Delete</button>
<button type="submit" class="btn btn-sm btn-danger" onclick="return confirm('Delete the account &quot;<%= u.display_name %>&quot;? Their data will be anonymized.')">Delete</button>
</form>
<% } %>
</div>
</td>
</tr>