- Real email/password login backed by SQLite (better-sqlite3) - HttpOnly cookie sessions with 8h sliding TTL - Admin role: invite users via Mailgun magic-link, manage roles/status - Per-user One2Edit username mapping for job filtering - Self-service forgot-password / reset-password via email - Admin console (admin.html) with user table, invite modal, row actions - New pages: change-password, forgot-password, reset-password, accept-invite - Gated /api proxy: requires valid session, anti-hijack sessionId check - Bootstrap initial admins from INITIAL_ADMINS env var on first boot - Remove Oliver login button, SSO buttons, and legacy api.js/login.js - deploy.sh: add build-essential (for native module), npm install, data dir Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
269 lines
9.9 KiB
JavaScript
269 lines
9.9 KiB
JavaScript
let currentUserId = null;
|
|
let users = [];
|
|
|
|
function formatDate(ts) {
|
|
if (!ts) return 'Never';
|
|
const d = new Date(ts < 1e12 ? ts * 1000 : ts);
|
|
return Number.isNaN(d.getTime()) ? String(ts) : d.toLocaleString();
|
|
}
|
|
|
|
function el(tag, cls, text) {
|
|
const node = document.createElement(tag);
|
|
if (cls) node.className = cls;
|
|
if (text !== undefined) node.textContent = text;
|
|
return node;
|
|
}
|
|
|
|
function badge(cls, text) {
|
|
return el('span', cls, text);
|
|
}
|
|
|
|
function showBanner(type, msg) {
|
|
const id = type === 'error' ? 'adminError' : 'adminSuccess';
|
|
const node = document.getElementById(id);
|
|
node.textContent = msg;
|
|
node.style.display = 'block';
|
|
setTimeout(() => { node.style.display = 'none'; }, 5000);
|
|
}
|
|
|
|
document.addEventListener('DOMContentLoaded', async () => {
|
|
try {
|
|
const res = await fetch('/api/auth/me');
|
|
if (!res.ok) { window.location.href = 'login.html'; return; }
|
|
const me = await res.json();
|
|
if (me.mustChangePassword) { window.location.href = 'change-password.html'; return; }
|
|
if (me.role !== 'admin') { window.location.href = 'dashboard.html'; return; }
|
|
currentUserId = me.id;
|
|
} catch {
|
|
window.location.href = 'login.html';
|
|
return;
|
|
}
|
|
|
|
await loadUsers();
|
|
setupInviteModal();
|
|
});
|
|
|
|
async function loadUsers() {
|
|
document.getElementById('usersLoading').style.display = 'block';
|
|
document.getElementById('usersTableWrap').style.display = 'none';
|
|
|
|
try {
|
|
const res = await fetch('/api/admin/users');
|
|
if (!res.ok) throw new Error('Failed to load users');
|
|
users = await res.json();
|
|
renderTable();
|
|
} catch (err) {
|
|
showBanner('error', err.message || 'Failed to load users');
|
|
} finally {
|
|
document.getElementById('usersLoading').style.display = 'none';
|
|
}
|
|
}
|
|
|
|
function renderTable() {
|
|
const tbody = document.getElementById('usersTableBody');
|
|
const activeAdmins = users.filter(u => u.role === 'admin' && u.isActive).length;
|
|
|
|
tbody.textContent = '';
|
|
|
|
users.forEach(u => {
|
|
const isSelf = u.id === currentUserId;
|
|
const isLastAdmin = u.role === 'admin' && activeAdmins <= 1;
|
|
const row = document.createElement('tr');
|
|
if (!u.isActive) row.classList.add('row-inactive');
|
|
|
|
// Email cell
|
|
const emailTd = el('td');
|
|
emailTd.appendChild(document.createTextNode(u.email));
|
|
if (!u.hasPassword) {
|
|
const b = badge('role-badge', 'Pending');
|
|
b.style.background = '#f0ad00';
|
|
b.style.color = '#fff';
|
|
b.style.marginLeft = '6px';
|
|
emailTd.appendChild(b);
|
|
}
|
|
|
|
// O2E username
|
|
const o2eTd = el('td', '', u.one2editUsername);
|
|
|
|
// Role badge
|
|
const roleTd = el('td');
|
|
roleTd.appendChild(
|
|
u.role === 'admin'
|
|
? badge('role-badge role-admin', 'Admin')
|
|
: badge('role-badge role-user', 'User')
|
|
);
|
|
|
|
// Status badge
|
|
const statusTd = el('td');
|
|
statusTd.appendChild(
|
|
u.isActive
|
|
? badge('status-badge status-ready', 'Active')
|
|
: badge('status-badge status-progress', 'Inactive')
|
|
);
|
|
|
|
// Last login
|
|
const loginTd = el('td', '', formatDate(u.lastLoginAt));
|
|
|
|
// Actions
|
|
const actionTd = el('td', 'action-cell');
|
|
buildActionButtons(u, isSelf, isLastAdmin).forEach(btn => actionTd.appendChild(btn));
|
|
|
|
row.append(emailTd, o2eTd, roleTd, statusTd, loginTd, actionTd);
|
|
tbody.appendChild(row);
|
|
});
|
|
|
|
document.getElementById('usersTableWrap').style.display = 'block';
|
|
}
|
|
|
|
function makeBtn(text, cls, action, disabled) {
|
|
const btn = el('button', `btn-action ${cls}`, text);
|
|
if (disabled) btn.disabled = true;
|
|
btn.dataset.action = action;
|
|
return btn;
|
|
}
|
|
|
|
function buildActionButtons(u, isSelf, isLastAdmin) {
|
|
const btns = [];
|
|
|
|
if (!u.hasPassword) {
|
|
btns.push(makeBtn('Resend Invite', 'btn-secondary', 'resend-invite', false));
|
|
}
|
|
|
|
btns.push(makeBtn('Reset Password', 'btn-secondary', 'reset-password', false));
|
|
|
|
if (u.role === 'user') {
|
|
btns.push(makeBtn('Promote to Admin', 'btn-promote', 'promote', false));
|
|
} else {
|
|
btns.push(makeBtn('Demote to User', 'btn-demote', 'demote', isSelf || isLastAdmin));
|
|
}
|
|
|
|
if (u.isActive) {
|
|
btns.push(makeBtn('Deactivate', 'btn-deactivate', 'deactivate', isSelf));
|
|
} else {
|
|
btns.push(makeBtn('Activate', 'btn-activate', 'activate', false));
|
|
}
|
|
|
|
btns.forEach(btn => btn.addEventListener('click', () => handleAction(btn.dataset.action, u)));
|
|
return btns;
|
|
}
|
|
|
|
async function handleAction(action, user) {
|
|
if (action === 'resend-invite') {
|
|
if (!confirm(`Resend invite to ${user.email}?`)) return;
|
|
const res = await apiFetch(`/api/admin/users/${user.id}/resend-invite`, 'POST');
|
|
if (res.ok) { showBanner('success', `Invite resent to ${user.email}`); await loadUsers(); }
|
|
else { showBanner('error', (await res.json().catch(() => ({}))).error || 'Failed to resend invite'); }
|
|
|
|
} else if (action === 'reset-password') {
|
|
if (!confirm(`Send a password reset email to ${user.email}?`)) return;
|
|
const res = await apiFetch(`/api/admin/users/${user.id}/reset-password`, 'POST');
|
|
if (res.ok) { showBanner('success', `Password reset email sent to ${user.email}`); }
|
|
else { showBanner('error', (await res.json().catch(() => ({}))).error || 'Failed to send reset email'); }
|
|
|
|
} else if (action === 'promote') {
|
|
if (!confirm(`Promote ${user.email} to Admin?`)) return;
|
|
await patchUser(user.id, { role: 'admin' });
|
|
|
|
} else if (action === 'demote') {
|
|
if (!confirm(`Demote ${user.email} to User?`)) return;
|
|
await patchUser(user.id, { role: 'user' });
|
|
|
|
} else if (action === 'deactivate') {
|
|
if (!confirm(`Deactivate ${user.email}? They will no longer be able to log in.`)) return;
|
|
await patchUser(user.id, { isActive: false });
|
|
|
|
} else if (action === 'activate') {
|
|
await patchUser(user.id, { isActive: true });
|
|
}
|
|
}
|
|
|
|
async function patchUser(id, fields) {
|
|
const res = await apiFetch(`/api/admin/users/${id}`, 'PATCH', fields);
|
|
if (res.ok) { await loadUsers(); }
|
|
else { showBanner('error', (await res.json().catch(() => ({}))).error || 'Update failed'); }
|
|
}
|
|
|
|
async function apiFetch(url, method = 'GET', body) {
|
|
const opts = { method, headers: { 'Content-Type': 'application/json' } };
|
|
if (body) opts.body = JSON.stringify(body);
|
|
try {
|
|
return await fetch(url, opts);
|
|
} catch {
|
|
return { ok: false, json: async () => ({ error: 'Network error' }) };
|
|
}
|
|
}
|
|
|
|
function setupInviteModal() {
|
|
const modal = document.getElementById('inviteModal');
|
|
const openBtn = document.getElementById('inviteBtn');
|
|
const closeBtn = document.getElementById('inviteModalClose');
|
|
const cancelBtn = document.getElementById('inviteModalCancel');
|
|
const submitBtn = document.getElementById('inviteSubmitBtn');
|
|
|
|
const open = () => {
|
|
document.getElementById('inviteEmail').value = '';
|
|
document.getElementById('inviteO2EUser').value = '';
|
|
document.getElementById('inviteRole').value = 'user';
|
|
document.getElementById('inviteError').style.display = 'none';
|
|
document.getElementById('inviteSuccess').style.display = 'none';
|
|
document.getElementById('inviteFormWrap').style.display = 'block';
|
|
document.getElementById('inviteFooter').style.display = 'flex';
|
|
modal.style.display = 'flex';
|
|
document.getElementById('inviteEmail').focus();
|
|
};
|
|
|
|
const close = () => { modal.style.display = 'none'; };
|
|
|
|
openBtn.addEventListener('click', open);
|
|
closeBtn.addEventListener('click', close);
|
|
cancelBtn.addEventListener('click', close);
|
|
modal.addEventListener('click', e => { if (e.target === modal) close(); });
|
|
document.addEventListener('keydown', e => { if (e.key === 'Escape') close(); });
|
|
|
|
submitBtn.addEventListener('click', async () => {
|
|
const email = document.getElementById('inviteEmail').value.trim();
|
|
const o2eUsername = document.getElementById('inviteO2EUser').value.trim();
|
|
const role = document.getElementById('inviteRole').value;
|
|
const errEl = document.getElementById('inviteError');
|
|
const successEl = document.getElementById('inviteSuccess');
|
|
const spinner = document.getElementById('inviteSubmitSpinner');
|
|
const submitText = document.getElementById('inviteSubmitText');
|
|
|
|
errEl.style.display = 'none';
|
|
|
|
if (!email || !o2eUsername) {
|
|
errEl.textContent = 'Email and One2Edit username are required.';
|
|
errEl.style.display = 'block';
|
|
return;
|
|
}
|
|
|
|
submitBtn.disabled = true;
|
|
submitText.textContent = 'Sending...';
|
|
spinner.style.display = 'inline-block';
|
|
|
|
try {
|
|
const res = await apiFetch('/api/admin/users', 'POST', { email, one2editUsername: o2eUsername, role });
|
|
const data = await res.json().catch(() => ({}));
|
|
|
|
if (!res.ok) {
|
|
errEl.textContent = data.error || 'Failed to create user';
|
|
errEl.style.display = 'block';
|
|
return;
|
|
}
|
|
|
|
document.getElementById('inviteFormWrap').style.display = 'none';
|
|
document.getElementById('inviteFooter').style.display = 'none';
|
|
successEl.textContent = data.inviteSent
|
|
? `Invite sent to ${data.user.email}`
|
|
: 'User created but email failed — use Resend Invite.';
|
|
successEl.style.display = 'block';
|
|
|
|
await loadUsers();
|
|
setTimeout(close, 3000);
|
|
} finally {
|
|
submitBtn.disabled = false;
|
|
submitText.textContent = 'Send Invite';
|
|
spinner.style.display = 'none';
|
|
}
|
|
});
|
|
}
|