```
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:
parent
9438330f22
commit
041f0f2ea1
|
|
@ -0,0 +1 @@
|
||||||
|
ALTER TABLE users ADD COLUMN disabled INTEGER NOT NULL DEFAULT 0;
|
||||||
|
|
@ -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; }
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
};
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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 %>">
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue