feat(users): implement user disabling functionality with admin controls

- Add `disabled` column to users table via migration
- Update login flow to reject disabled accounts with 403 error
- Introduce new `/admin/users` route for admin-only user management (disable/enable, verify email, delete with content)
- Extend users service to support listing all users, toggling disabled status, admin verification, and cascading deletion of user's songs/playlists
- Add admin UI for managing users with visual indication of disabled accounts (opacity reduction)
- Update navigation to include "Users" link for admins
```
This commit is contained in:
Brian Fertig 2026-04-10 20:32:57 -06:00
parent 9438330f22
commit 041f0f2ea1
8 changed files with 173 additions and 3 deletions

View File

@ -0,0 +1 @@
ALTER TABLE users ADD COLUMN disabled INTEGER NOT NULL DEFAULT 0;

View File

@ -484,6 +484,7 @@ button.linklike.danger:hover { color: #fff; text-shadow: 0 0 6px var(--danger);
} }
.data-table tr:hover td { background: rgba(0, 229, 255, 0.04); } .data-table tr:hover td { background: rgba(0, 229, 255, 0.04); }
.data-table .actions { white-space: nowrap; font-size: 0.85rem; } .data-table .actions { white-space: nowrap; font-size: 0.85rem; }
.data-table tr.row-disabled td { opacity: 0.45; }
.track-list { padding-left: 0; list-style: none; } .track-list { padding-left: 0; list-style: none; }
.track-list li { margin: 0; display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; padding: 0.35rem 0.5rem; border-radius: 4px; border-top: 2px solid transparent; border-bottom: 2px solid transparent; transition: background 0.15s; } .track-list li { margin: 0; display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; padding: 0.35rem 0.5rem; border-radius: 4px; border-top: 2px solid transparent; border-bottom: 2px solid transparent; transition: background 0.15s; }

View File

@ -71,6 +71,7 @@ app.use((req, _res, next) => {
app.use('/', require('./routes/public')); app.use('/', require('./routes/public'));
app.use('/', require('./routes/auth')(csrfSynchronisedProtection)); app.use('/', require('./routes/auth')(csrfSynchronisedProtection));
app.use('/account', require('./routes/account')(csrfSynchronisedProtection)); app.use('/account', require('./routes/account')(csrfSynchronisedProtection));
app.use('/admin/users', require('./routes/users-admin')(csrfSynchronisedProtection));
app.use('/admin', require('./routes/admin')(csrfSynchronisedProtection)); app.use('/admin', require('./routes/admin')(csrfSynchronisedProtection));
// 404. // 404.

View File

@ -64,10 +64,14 @@ module.exports = function authRoutes(csrfProtection) {
if (!parsed.success) { if (!parsed.success) {
return res.status(400).render('auth/login', { title: 'Log in', values: req.body, error: 'Invalid input.' }); return res.status(400).render('auth/login', { title: 'Log in', values: req.body, error: 'Invalid input.' });
} }
const user = await users.verifyCredentials(parsed.data.email, parsed.data.password); const result = await users.verifyCredentials(parsed.data.email, parsed.data.password);
if (!user) { if (!result) {
return res.status(401).render('auth/login', { title: 'Log in', values: req.body, error: 'Invalid email or password.' }); return res.status(401).render('auth/login', { title: 'Log in', values: req.body, error: 'Invalid email or password.' });
} }
if (result.disabled) {
return res.status(403).render('auth/login', { title: 'Log in', values: req.body, error: 'This account has been disabled.' });
}
const user = result;
if (!user.emailVerified) { if (!user.emailVerified) {
req.session.pendingVerificationEmail = user.email; req.session.pendingVerificationEmail = user.email;
return res.redirect('/verify-email?pending=1'); return res.redirect('/verify-email?pending=1');

71
src/routes/users-admin.js Normal file
View File

@ -0,0 +1,71 @@
const path = require('path');
const fs = require('fs');
const express = require('express');
const { requireAdmin } = require('../middleware/auth');
const users = require('../services/users');
const mediaDir = process.env.MEDIA_DIR || path.join(__dirname, '..', '..', 'media');
function unlinkQuiet(absPath) {
fs.unlink(absPath, () => {});
}
module.exports = function usersAdminRoutes(csrfProtection) {
const router = express.Router();
router.use(requireAdmin);
router.get('/', (req, res) => {
res.render('admin/users', { title: 'Users', userList: users.listAllUsers() });
});
router.post('/:id/disable', csrfProtection, (req, res, next) => {
try {
const id = Number(req.params.id);
if (id === req.session.user.id) {
req.flash('error', 'You cannot disable your own account.');
return res.redirect('/admin/users');
}
users.setDisabled(id, true);
req.flash('success', 'Account disabled.');
res.redirect('/admin/users');
} catch (err) { next(err); }
});
router.post('/:id/enable', csrfProtection, (req, res, next) => {
try {
users.setDisabled(Number(req.params.id), false);
req.flash('success', 'Account enabled.');
res.redirect('/admin/users');
} catch (err) { next(err); }
});
router.post('/:id/verify', csrfProtection, (req, res, next) => {
try {
users.markVerifiedAdmin(Number(req.params.id));
req.flash('success', 'Email marked as verified.');
res.redirect('/admin/users');
} catch (err) { next(err); }
});
router.post('/:id/delete', csrfProtection, (req, res, next) => {
try {
const id = Number(req.params.id);
if (id === req.session.user.id) {
req.flash('error', 'You cannot delete your own account.');
return res.redirect('/admin/users');
}
const { songs, playlists } = users.deleteUserWithContent(id);
for (const s of songs) {
if (s.audio_path) unlinkQuiet(path.join(mediaDir, s.audio_path));
if (s.cover_path) unlinkQuiet(path.join(mediaDir, s.cover_path));
}
for (const p of playlists) {
if (p.cover_path) unlinkQuiet(path.join(mediaDir, p.cover_path));
}
req.flash('success', 'User and all their content deleted.');
res.redirect('/admin/users');
} catch (err) { next(err); }
});
return router;
};

View File

@ -21,6 +21,13 @@ const findVerifTokenStmt = db.prepare(
`SELECT * FROM email_verifications `SELECT * FROM email_verifications
WHERE token = ? AND expires_at > datetime('now')` WHERE token = ? AND expires_at > datetime('now')`
); );
const listAllUsersStmt = db.prepare('SELECT * FROM users ORDER BY created_at ASC');
const setDisabledStmt = db.prepare('UPDATE users SET disabled = ? WHERE id = ?');
const deleteUserStmt = db.prepare('DELETE FROM users WHERE id = ?');
const songsByUserStmt = db.prepare('SELECT * FROM songs WHERE uploaded_by = ?');
const playlistsByUserStmt = db.prepare('SELECT * FROM playlists WHERE created_by = ?');
const deleteSongsByUserStmt = db.prepare('DELETE FROM songs WHERE uploaded_by = ?');
const deletePlaylistsByUserStmt = db.prepare('DELETE FROM playlists WHERE created_by = ?');
function publicView(row) { function publicView(row) {
if (!row) return null; if (!row) return null;
@ -28,6 +35,7 @@ function publicView(row) {
id: row.id, email: row.email, id: row.id, email: row.email,
displayName: row.display_name, role: row.role, displayName: row.display_name, role: row.role,
emailVerified: row.email_verified === 1, emailVerified: row.email_verified === 1,
disabled: row.disabled === 1,
}; };
} }
@ -47,6 +55,7 @@ async function createUser({ email, displayName, password }) {
async function verifyCredentials(email, password) { async function verifyCredentials(email, password) {
const row = findByEmailStmt.get(email.trim().toLowerCase()); const row = findByEmailStmt.get(email.trim().toLowerCase());
if (!row) return null; if (!row) return null;
if (row.disabled) return { disabled: true };
const ok = await argon2.verify(row.password_hash, password); const ok = await argon2.verify(row.password_hash, password);
return ok ? publicView(row) : null; return ok ? publicView(row) : null;
} }
@ -91,6 +100,32 @@ function verifyEmailToken(token) {
return { ok: true, userId: row.user_id }; return { ok: true, userId: row.user_id };
} }
function listAllUsers() {
return listAllUsersStmt.all().map(publicView);
}
function setDisabled(id, disabled) {
setDisabledStmt.run(disabled ? 1 : 0, id);
}
function markVerifiedAdmin(id) {
db.transaction(() => {
markVerifiedStmt.run(id);
deleteVerifTokensStmt.run(id);
})();
}
function deleteUserWithContent(id) {
const userSongs = songsByUserStmt.all(id);
const userPlaylists = playlistsByUserStmt.all(id);
db.transaction(() => {
deleteSongsByUserStmt.run(id);
deletePlaylistsByUserStmt.run(id);
deleteUserStmt.run(id);
})();
return { songs: userSongs, playlists: userPlaylists };
}
module.exports = { module.exports = {
createUser, createUser,
verifyCredentials, verifyCredentials,
@ -100,4 +135,8 @@ module.exports = {
changePassword, changePassword,
createVerificationToken, createVerificationToken,
verifyEmailToken, verifyEmailToken,
listAllUsers,
setDisabled,
markVerifiedAdmin,
deleteUserWithContent,
}; };

50
src/views/admin/users.ejs Normal file
View File

@ -0,0 +1,50 @@
<h1>Users</h1>
<table class="data-table">
<thead>
<tr>
<th>Display Name</th>
<th>Email</th>
<th>Role</th>
<th>Verified</th>
<th>Status</th>
<th class="actions">Actions</th>
</tr>
</thead>
<tbody>
<% userList.forEach((u) => { %>
<tr class="<%= u.disabled ? 'row-disabled' : '' %>">
<td><%= u.displayName %></td>
<td class="muted small"><%= u.email %></td>
<td><%= u.role %></td>
<td><%= u.emailVerified ? '✓' : '✗' %></td>
<td><%= u.disabled ? 'Disabled' : 'Active' %></td>
<td class="actions">
<% if (!u.emailVerified) { %>
<form method="post" action="/admin/users/<%= u.id %>/verify" class="inline-form">
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
<button type="submit" class="btn-small">Verify</button>
</form>
<% } %>
<% if (u.id !== user.id) { %>
<% if (u.disabled) { %>
<form method="post" action="/admin/users/<%= u.id %>/enable" class="inline-form">
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
<button type="submit" class="btn-small">Enable</button>
</form>
<% } else { %>
<form method="post" action="/admin/users/<%= u.id %>/disable" class="inline-form">
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
<button type="submit" class="btn-small">Disable</button>
</form>
<% } %>
<form method="post" action="/admin/users/<%= u.id %>/delete" class="inline-form"
onsubmit="return confirm('Delete <%= u.displayName.replace(/'/g, "\\'") %> and ALL their content? This cannot be undone.')">
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
<button type="submit" class="btn-small danger">Delete</button>
</form>
<% } %>
</td>
</tr>
<% }) %>
</tbody>
</table>

View File

@ -5,7 +5,10 @@
<a href="/songs" class="<%= currentPath && currentPath.startsWith('/songs') ? 'active' : '' %>">Songs</a> <a href="/songs" class="<%= currentPath && currentPath.startsWith('/songs') ? 'active' : '' %>">Songs</a>
<a href="/playlists" class="<%= currentPath && currentPath.startsWith('/playlists') ? 'active' : '' %>">Playlists</a> <a href="/playlists" class="<%= currentPath && currentPath.startsWith('/playlists') ? 'active' : '' %>">Playlists</a>
<% if (user) { %> <% if (user) { %>
<a href="/admin/songs" class="<%= currentPath && currentPath.startsWith('/admin') ? 'active' : '' %>">My Music</a> <a href="/admin/songs" class="<%= currentPath && currentPath.startsWith('/admin') && !currentPath.startsWith('/admin/users') ? 'active' : '' %>">My Music</a>
<% if (user.role === 'admin') { %>
<a href="/admin/users" class="<%= currentPath && currentPath.startsWith('/admin/users') ? 'active' : '' %>">Users</a>
<% } %>
<a href="/account">Account</a> <a href="/account">Account</a>
<form method="post" action="/logout" class="inline-form"> <form method="post" action="/logout" class="inline-form">
<input type="hidden" name="_csrf" value="<%= csrfToken %>"> <input type="hidden" name="_csrf" value="<%= csrfToken %>">