3m-portal/admin.js
Vadym Samoilenko 53a85c788d Add full auth system: SQLite sessions, email invites, admin console
- 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>
2026-05-05 11:26:40 +01:00

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';
}
});
}