Embed admin_id claim in impersonation JWTs and add a backend /api/admin/unimpersonate endpoint that re-issues the admin token from that claim. The admin token no longer needs to be stored in sessionStorage, eliminating the risk of token theft via XSS. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
156 lines
5.2 KiB
JavaScript
156 lines
5.2 KiB
JavaScript
// admin.js — admin user management page
|
|
|
|
let resetTargetId = null;
|
|
let deleteTargetId = null;
|
|
let currentAdminId = null;
|
|
|
|
document.addEventListener('DOMContentLoaded', async () => {
|
|
// Verify admin access
|
|
const user = Auth.getUser();
|
|
if (!user || !user.is_admin) {
|
|
window.location.href = '/';
|
|
return;
|
|
}
|
|
currentAdminId = parseInt(user.sub);
|
|
await loadUsers();
|
|
});
|
|
|
|
async function loadUsers() {
|
|
try {
|
|
const users = await API.get('/api/admin/users');
|
|
renderUsers(users);
|
|
} catch (err) {
|
|
showMessage(document.getElementById('msg'), err.message, 'error');
|
|
}
|
|
}
|
|
|
|
function renderUsers(users) {
|
|
const tbody = document.getElementById('users-body');
|
|
|
|
if (!users.length) {
|
|
tbody.innerHTML = '<tr class="empty-row"><td colspan="5">No users found.</td></tr>';
|
|
return;
|
|
}
|
|
|
|
tbody.innerHTML = users.map(u => {
|
|
const isSelf = u.id === currentAdminId;
|
|
const roleLabel = u.is_admin
|
|
? '<span class="badge badge-admin">Admin</span>'
|
|
: '<span class="badge badge-user">User</span>';
|
|
const statusLabel = u.is_disabled
|
|
? '<span class="badge badge-disabled">Disabled</span>'
|
|
: '<span class="badge badge-active">Active</span>';
|
|
const created = new Date(u.created_at).toLocaleDateString();
|
|
|
|
const toggleBtn = u.is_disabled
|
|
? `<button class="btn btn-sm btn-primary" onclick="toggleUser(${u.id}, false)">Enable</button>`
|
|
: `<button class="btn btn-sm btn-ghost" onclick="toggleUser(${u.id}, true)" ${isSelf ? 'disabled title="Cannot disable yourself"' : ''}>Disable</button>`;
|
|
|
|
const impersonateBtn = !isSelf
|
|
? `<button class="btn btn-sm btn-ghost" onclick="impersonateUser(${u.id})">Login As</button>`
|
|
: '';
|
|
|
|
const deleteBtn = !isSelf
|
|
? `<button class="btn btn-sm btn-danger" data-username="${escHtml(u.username)}" onclick="showDeleteModal(${u.id}, this.dataset.username)">Delete</button>`
|
|
: '';
|
|
|
|
return `<tr>
|
|
<td><strong>${escHtml(u.username)}</strong></td>
|
|
<td>${roleLabel}</td>
|
|
<td>${statusLabel}</td>
|
|
<td>${created}</td>
|
|
<td class="actions" style="display:flex;gap:0.35rem;flex-wrap:wrap">
|
|
<button class="btn btn-sm btn-ghost" data-username="${escHtml(u.username)}" onclick="showResetModal(${u.id}, this.dataset.username)">Reset PW</button>
|
|
${toggleBtn}
|
|
${impersonateBtn}
|
|
${deleteBtn}
|
|
</td>
|
|
</tr>`;
|
|
}).join('');
|
|
}
|
|
|
|
|
|
function showResetModal(id, username) {
|
|
resetTargetId = id;
|
|
document.getElementById('reset-username').textContent = username;
|
|
document.getElementById('reset-password').value = '';
|
|
document.getElementById('reset-msg').className = 'message';
|
|
document.getElementById('reset-modal').style.display = 'flex';
|
|
document.getElementById('reset-password').focus();
|
|
}
|
|
|
|
function hideResetModal() {
|
|
document.getElementById('reset-modal').style.display = 'none';
|
|
resetTargetId = null;
|
|
}
|
|
|
|
async function submitReset() {
|
|
const password = document.getElementById('reset-password').value;
|
|
const msgEl = document.getElementById('reset-msg');
|
|
|
|
if (password.length < 6) {
|
|
msgEl.textContent = 'Password must be at least 6 characters';
|
|
msgEl.className = 'message error visible';
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await API.post(`/api/admin/users/${resetTargetId}/reset-password`, { new_password: password });
|
|
msgEl.textContent = 'Password reset successfully.';
|
|
msgEl.className = 'message success visible';
|
|
setTimeout(hideResetModal, 1200);
|
|
} catch (err) {
|
|
msgEl.textContent = err.message;
|
|
msgEl.className = 'message error visible';
|
|
}
|
|
}
|
|
|
|
async function toggleUser(id, disable) {
|
|
const action = disable ? 'disable' : 'enable';
|
|
try {
|
|
await API.post(`/api/admin/users/${id}/${action}`, {});
|
|
await loadUsers();
|
|
} catch (err) {
|
|
showMessage(document.getElementById('msg'), err.message, 'error');
|
|
}
|
|
}
|
|
|
|
async function impersonateUser(id) {
|
|
try {
|
|
const data = await API.post(`/api/admin/users/${id}/impersonate`, {});
|
|
Auth.setToken(data.access_token);
|
|
window.location.href = '/';
|
|
} catch (err) {
|
|
showMessage(document.getElementById('msg'), err.message, 'error');
|
|
}
|
|
}
|
|
|
|
function showDeleteModal(id, username) {
|
|
deleteTargetId = id;
|
|
document.getElementById('delete-username').textContent = username;
|
|
document.getElementById('delete-modal').style.display = 'flex';
|
|
}
|
|
|
|
function hideDeleteModal() {
|
|
document.getElementById('delete-modal').style.display = 'none';
|
|
deleteTargetId = null;
|
|
}
|
|
|
|
async function submitDelete() {
|
|
try {
|
|
await API.del(`/api/admin/users/${deleteTargetId}`);
|
|
hideDeleteModal();
|
|
showMessage(document.getElementById('msg'), 'User deleted.');
|
|
await loadUsers();
|
|
} catch (err) {
|
|
hideDeleteModal();
|
|
showMessage(document.getElementById('msg'), err.message, 'error');
|
|
}
|
|
}
|
|
|
|
// Close modals on overlay click
|
|
document.addEventListener('click', (e) => {
|
|
if (e.target.id === 'reset-modal') hideResetModal();
|
|
if (e.target.id === 'delete-modal') hideDeleteModal();
|
|
});
|