/* ============================================================ PST Archive — Admin panel - Login (with MFA) - User CRUD: list, create, delete, change role, reset password - MFA: begin enrollment, scan QR, confirm 6-digit code, disable ============================================================ */ const API = "/api"; const state = { user: null, users: [], folders: [], files: [], mfaTarget: null, folderEdit: null, // {id, name} if renaming, null if creating activeTab: "users", permissions: { users: [], folders: [], grants: [] }, permView: "by-user", // "by-user" or "by-folder" }; const $ = (id) => document.getElementById(id); // ---------- helpers ---------- function fmtDate(iso) { if (!iso) return "—"; try { const d = new Date(iso); if (isNaN(d)) return iso; return d.toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" }); } catch { return iso; } } function fmtBytes(n) { if (!n && n !== 0) return "—"; const units = ["B", "KB", "MB", "GB", "TB"]; let i = 0; while (n >= 1024 && i < units.length - 1) { n /= 1024; i++; } return `${n.toFixed(i ? 1 : 0)} ${units[i]}`; } function fmtDateTime(iso) { if (!iso) return "never"; try { const d = new Date(iso); if (isNaN(d)) return iso; return d.toLocaleString("en-US", { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }); } catch { return iso; } } function toast(msg, ms = 3000) { const el = $("toast"); el.textContent = msg; el.classList.remove("hidden"); clearTimeout(toast._t); toast._t = setTimeout(() => el.classList.add("hidden"), ms); } function escapeHtml(s) { if (s == null) return ""; return String(s) .replace(/&/g, "&").replace(//g, ">") .replace(/"/g, """).replace(/'/g, "'"); } // ---------- API ---------- async function api(path, opts = {}) { const res = await fetch(API + path, { credentials: "same-origin", ...opts }); let body = null; try { body = await res.json(); } catch {} if (!res.ok) { const err = new Error((body && body.detail) || res.statusText); err.status = res.status; err.body = body || {}; throw err; } return body; } // ---------- screens ---------- function showScreen(which) { ["setupNotice", "loginScreen", "adminApp"].forEach((id) => { $(id).classList.toggle("hidden", id !== which); }); } // ---------- bootstrap ---------- // The administration section runs inside the main application and shares its // session cookie. There is no separate admin login: access is reached via the // "Administration" button in the main app, which is only shown to admins. // If the visitor is not authenticated, or is authenticated but not an admin, // we send them back to the main app. async function bootstrap() { try { const { user } = await api("/me"); if (user.role !== "admin") { // Authenticated but not an admin — no access to administration. window.location.replace("/"); return; } state.user = user; enterAdmin(); } catch (e) { if (e.status === 401) { // Not signed in — go to the main app to log in. window.location.replace("/"); return; } toast("Connection problem — please retry."); } } // ---------- login ---------- async function handleLogin(e) { e.preventDefault(); const form = e.target; const errEl = $("loginError"); errEl.classList.add("hidden"); const payload = { username: form.username.value.trim(), password: form.password.value, }; const mfa = form.totp_code.value.trim(); if (mfa) payload.totp_code = mfa; try { const { user } = await api("/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); state.user = user; enterAdmin(); } catch (err) { if (err.status === 401 && err.body && err.body.mfa_required) { $("mfaField").classList.remove("hidden"); setTimeout(() => form.totp_code.focus(), 50); errEl.textContent = "Enter your authenticator code to continue."; errEl.classList.remove("hidden"); return; } errEl.textContent = err.message || "Login failed."; errEl.classList.remove("hidden"); } } async function handleLogout() { try { await api("/logout", { method: "POST" }); } catch {} state.user = null; // Session is shared with the main app; return there. window.location.replace("/"); } function enterAdmin() { showScreen("adminApp"); $("userChip").textContent = `${state.user.username} · admin`; loadUsers(); } // ---------- users ---------- async function loadUsers() { try { const users = await api("/admin/users"); state.users = users; renderUsers(); } catch (e) { if (e.status === 401) { handleLogout(); return; } toast(`Failed to load users: ${e.message}`); } } function renderUsers() { const tbody = $("userTableBody"); $("userCount").textContent = `${state.users.length} user${state.users.length === 1 ? "" : "s"} · ` + `${state.users.filter(u => u.role === "admin").length} admin${state.users.filter(u => u.role === "admin").length === 1 ? "" : "s"}`; if (!state.users.length) { tbody.innerHTML = 'No users yet.'; return; } tbody.innerHTML = state.users.map((u) => { const isMe = u.id === state.user.id; return ` ${escapeHtml(u.username)} ${isMe ? 'you' : ""} ${escapeHtml(u.role)} ${u.mfa_enabled ? "enabled" : "disabled"} ${fmtDate(u.created_at)} ${fmtDateTime(u.last_login)}
`; }).join(""); tbody.querySelectorAll("[data-action]").forEach((el) => { el.addEventListener("click", handleAction); }); } async function handleAction(e) { const action = e.currentTarget.dataset.action; const id = e.currentTarget.dataset.id; const user = state.users.find((u) => u.id === id); if (!user) return; switch (action) { case "toggle-role": await toggleRole(user); break; case "password": openResetPassword(user); break; case "mfa": openMfaModal(user); break; case "delete": await deleteUser(user); break; } } async function toggleRole(user) { const newRole = user.role === "admin" ? "user" : "admin"; if (!confirm(`Change ${user.username}'s role from "${user.role}" to "${newRole}"?`)) return; try { await api(`/admin/users/${user.id}/role`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ role: newRole }), }); toast(`${user.username} is now ${newRole}`); loadUsers(); } catch (err) { toast(`Role change failed: ${err.message}`); } } async function deleteUser(user) { if (!confirm(`Permanently delete user "${user.username}"?\n\nThis cannot be undone.`)) return; try { await api(`/admin/users/${user.id}`, { method: "DELETE" }); toast(`${user.username} deleted`); loadUsers(); } catch (err) { toast(`Delete failed: ${err.message}`); } } // ---------- create user modal ---------- function openCreateUser() { $("createUserForm").reset(); $("createError").classList.add("hidden"); $("createUserModal").classList.remove("hidden"); setTimeout(() => $("createUserForm").username.focus(), 50); } async function handleCreateUser(e) { e.preventDefault(); const form = e.target; const errEl = $("createError"); errEl.classList.add("hidden"); const payload = { username: form.username.value.trim(), password: form.password.value, role: form.role.value, }; try { await api("/admin/users", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); toast(`User "${payload.username}" created`); closeModals(); loadUsers(); } catch (err) { errEl.textContent = err.message || "Create failed."; errEl.classList.remove("hidden"); } } // ---------- reset password modal ---------- function openResetPassword(user) { $("resetPasswordSubject").textContent = `Setting new password for ${user.username}`; $("resetPasswordForm").reset(); $("resetError").classList.add("hidden"); $("resetPasswordForm").dataset.userId = user.id; $("resetPasswordModal").classList.remove("hidden"); setTimeout(() => $("resetPasswordForm").password.focus(), 50); } async function handleResetPassword(e) { e.preventDefault(); const form = e.target; const errEl = $("resetError"); errEl.classList.add("hidden"); const userId = form.dataset.userId; try { await api(`/admin/users/${userId}/password`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ password: form.password.value }), }); toast("Password updated"); closeModals(); } catch (err) { errEl.textContent = err.message || "Reset failed."; errEl.classList.remove("hidden"); } } // ---------- MFA modal ---------- function openMfaModal(user) { state.mfaTarget = { user_id: user.id, username: user.username, mfa_enabled: user.mfa_enabled }; $("mfaSubject").textContent = `${user.username}`; $("mfaEnrollPanel").classList.add("hidden"); renderMfaPanel(); $("mfaModal").classList.remove("hidden"); } function renderMfaPanel() { const enabled = state.mfaTarget.mfa_enabled; const statusEl = $("mfaCurrentStatus"); statusEl.className = "mfa-status" + (enabled ? " enabled" : ""); statusEl.innerHTML = enabled ? `Enabled This user is required to enter a TOTP code at login.` : `Disabled This user signs in with password only.`; const actions = $("mfaActions"); actions.innerHTML = enabled ? ` ` : ``; actions.querySelectorAll("[data-mfa-action]").forEach((b) => { b.addEventListener("click", () => handleMfaAction(b.dataset.mfaAction)); }); } async function handleMfaAction(action) { const userId = state.mfaTarget.user_id; if (action === "disable") { if (!confirm(`Disable MFA for ${state.mfaTarget.username}?`)) return; try { await api(`/admin/users/${userId}/mfa/disable`, { method: "POST" }); toast("MFA disabled"); state.mfaTarget.mfa_enabled = false; renderMfaPanel(); loadUsers(); } catch (err) { toast(`Disable failed: ${err.message}`); } return; } // enroll or re-enroll — generate a fresh secret try { const res = await api(`/admin/users/${userId}/mfa/begin`, { method: "POST" }); $("mfaSecretText").textContent = res.secret; // Add cache-busting param so the QR re-renders with the new secret $("mfaQrImage").src = `${API}/admin/users/${userId}/mfa/qr?t=${Date.now()}`; $("mfaEnrollPanel").classList.remove("hidden"); $("mfaConfirmError").classList.add("hidden"); $("mfaConfirmForm").reset(); setTimeout(() => $("mfaConfirmForm").totp_code.focus(), 100); } catch (err) { toast(`Enrollment failed: ${err.message}`); } } async function handleMfaConfirm(e) { e.preventDefault(); const form = e.target; const errEl = $("mfaConfirmError"); errEl.classList.add("hidden"); const userId = state.mfaTarget.user_id; try { await api(`/admin/users/${userId}/mfa/confirm`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ totp_code: form.totp_code.value.trim() }), }); toast(`MFA enabled for ${state.mfaTarget.username}`); state.mfaTarget.mfa_enabled = true; $("mfaEnrollPanel").classList.add("hidden"); renderMfaPanel(); loadUsers(); } catch (err) { errEl.textContent = err.message || "Confirmation failed."; errEl.classList.remove("hidden"); } } // ---------- modals ---------- function closeModals() { document.querySelectorAll(".modal").forEach((m) => m.classList.add("hidden")); } // ---------- Theme ---------- function initTheme() { let theme = "light"; try { const saved = localStorage.getItem("pst-theme"); if (saved === "light" || saved === "dark") { theme = saved; } else if (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches) { theme = "dark"; } } catch {} document.documentElement.dataset.theme = theme; } function toggleTheme() { const current = document.documentElement.dataset.theme === "dark" ? "dark" : "light"; const next = current === "dark" ? "light" : "dark"; document.documentElement.dataset.theme = next; try { localStorage.setItem("pst-theme", next); } catch {} } initTheme(); // ---------- Tabs ---------- function switchTab(tab) { state.activeTab = tab; document.querySelectorAll(".admin-tab").forEach((el) => { el.classList.toggle("active", el.dataset.tab === tab); }); document.querySelectorAll(".admin-panel").forEach((el) => { el.classList.toggle("hidden", el.dataset.panel !== tab); }); if (tab === "users") loadUsers(); else if (tab === "folders") loadFolders(); else if (tab === "files") loadFiles(); else if (tab === "permissions") loadPermissions(); } // ---------- Folder management ---------- async function loadFolders() { try { const folders = await api("/admin/folders"); state.folders = folders; renderFolders(); } catch (e) { if (e.status === 401) { handleLogout(); return; } toast(`Failed to load folders: ${e.message}`); } } function renderFolders() { const tbody = $("folderTableBody"); $("folderCount").textContent = `${state.folders.length} folder${state.folders.length === 1 ? "" : "s"}`; if (!state.folders.length) { tbody.innerHTML = 'No folders yet. Click "+ New folder" to create one.'; return; } tbody.innerHTML = state.folders.map((f) => ` ${escapeHtml(f.name)} ${f.file_count} ${fmtDate(f.created_at)}
`).join(""); tbody.querySelectorAll("[data-folder-action]").forEach((el) => { el.addEventListener("click", handleFolderAction); }); } function handleFolderAction(e) { const action = e.currentTarget.dataset.folderAction; const id = e.currentTarget.dataset.id; const folder = state.folders.find((f) => f.id === id); if (!folder) return; if (action === "rename") openFolderModal(folder); else if (action === "delete") deleteFolder(folder); } function openFolderModal(folder = null) { state.folderEdit = folder; $("folderForm").reset(); $("folderError").classList.add("hidden"); if (folder) { $("folderModalTitle").textContent = "Rename folder"; $("folderSubmitBtn").textContent = "Save changes"; $("folderForm").name.value = folder.name; } else { $("folderModalTitle").textContent = "New folder"; $("folderSubmitBtn").textContent = "Create folder"; } $("folderModal").classList.remove("hidden"); setTimeout(() => $("folderForm").name.focus(), 50); } async function handleFolderSubmit(e) { e.preventDefault(); const form = e.target; const errEl = $("folderError"); errEl.classList.add("hidden"); const name = form.name.value.trim(); try { if (state.folderEdit) { await api(`/admin/folders/${state.folderEdit.id}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name }), }); toast("Folder renamed."); } else { await api("/admin/folders", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name }), }); toast("Folder created."); } closeModals(); loadFolders(); if (state.activeTab === "files") loadFiles(); if (state.activeTab === "permissions") loadPermissions(); } catch (err) { errEl.textContent = err.message || "Save failed."; errEl.classList.remove("hidden"); } } async function deleteFolder(folder) { const msg = folder.file_count > 0 ? `Delete folder "${folder.name}"?\n\n${folder.file_count} archive${folder.file_count === 1 ? "" : "s"} will be moved to Uncategorized.` : `Delete folder "${folder.name}"?`; if (!confirm(msg)) return; try { await api(`/admin/folders/${folder.id}`, { method: "DELETE" }); toast("Folder deleted."); loadFolders(); if (state.activeTab === "files") loadFiles(); if (state.activeTab === "permissions") loadPermissions(); } catch (err) { toast(`Delete failed: ${err.message}`); } } // ---------- File management ---------- async function loadFiles() { try { const files = await api("/admin/pst-files"); state.files = files; renderFiles(); } catch (e) { if (e.status === 401) { handleLogout(); return; } toast(`Failed to load files: ${e.message}`); } } function renderFiles() { const tbody = $("fileTableBody"); const total = state.files.length; const totalSize = state.files.reduce((s, f) => s + (f.size_bytes || 0), 0); $("fileCount").textContent = total ? `${total} file${total === 1 ? "" : "s"} · ${fmtBytes(totalSize)} total` : "no files"; if (!total) { tbody.innerHTML = 'No PST files have been uploaded yet.'; return; } tbody.innerHTML = state.files.map((f) => ` ${escapeHtml(f.original_name)} ${f.folder_name ? escapeHtml(f.folder_name) : "Uncategorized"} ${fmtBytes(f.size_bytes)} ${f.status} ${f.status === "ready" ? (f.message_count || 0).toLocaleString() : "—"} ${fmtDate(f.uploaded_at)}
`).join(""); tbody.querySelectorAll("[data-file-action]").forEach((el) => { el.addEventListener("click", handleFileAction); }); } function handleFileAction(e) { const action = e.currentTarget.dataset.fileAction; const id = e.currentTarget.dataset.id; const file = state.files.find((f) => f.id === id); if (!file) return; if (action === "delete") deleteFile(file); } async function deleteFile(file) { if (!confirm(`Permanently delete "${file.original_name}"?\n\nThis removes the PST file and all ${(file.message_count || 0).toLocaleString()} indexed messages. Cannot be undone.`)) return; try { await api(`/admin/pst-files/${file.id}`, { method: "DELETE" }); toast("File deleted."); loadFiles(); if (state.activeTab === "folders") loadFolders(); // counts change } catch (err) { toast(`Delete failed: ${err.message}`); } } // ---------- Permissions ---------- async function loadPermissions() { try { const data = await api("/admin/permissions"); state.permissions = data; renderPermissions(); } catch (e) { if (e.status === 401) { handleLogout(); return; } toast(`Failed to load permissions: ${e.message}`); } } function hasGrant(userId, folderId) { return state.permissions.grants.some( (g) => g.user_id === userId && g.folder_id === folderId ); } function renderPermissions() { const { users, folders } = state.permissions; const nonAdmins = users.filter((u) => u.role !== "admin"); const adminCount = users.length - nonAdmins.length; $("permissionsMeta").textContent = `${users.length} user${users.length === 1 ? "" : "s"}` + ` (${adminCount} admin${adminCount === 1 ? "" : "s"}) · ` + `${folders.length} folder${folders.length === 1 ? "" : "s"}`; const thead = $("permMatrixHead"); const tbody = $("permMatrixBody"); if (!folders.length) { thead.innerHTML = ""; tbody.innerHTML = ` No folders yet. Create one in the Folders tab to start assigning access. `; return; } if (state.permView === "by-user") { renderPermByUser(thead, tbody, users, folders); } else { renderPermByFolder(thead, tbody, users, folders, nonAdmins); } } function renderPermByUser(thead, tbody, users, folders) { // Rows = users, Columns = folders thead.innerHTML = ` User ${folders.map((f) => `${escapeHtml(f.name)}`).join("")} `; tbody.innerHTML = users.map((u) => { const isAdmin = u.role === "admin"; const rowClass = isAdmin ? "admin-row" : ""; return ` ${escapeHtml(u.username)} ${isAdmin ? 'admin' : ""} ${folders.map((f) => { if (isAdmin) { return ``; } const checked = hasGrant(u.id, f.id) ? "checked" : ""; return ` `; }).join("")} `; }).join(""); bindPermToggles(); } function renderPermByFolder(thead, tbody, users, folders, nonAdmins) { // Rows = folders, Columns = non-admin users (admins don't need toggles) if (!nonAdmins.length) { thead.innerHTML = ""; tbody.innerHTML = ` No non-admin users exist yet. Create one in the Users tab to start assigning access. `; return; } thead.innerHTML = ` Folder ${nonAdmins.map((u) => `${escapeHtml(u.username)}`).join("")} `; tbody.innerHTML = folders.map((f) => ` ${escapeHtml(f.name)} ${nonAdmins.map((u) => { const checked = hasGrant(u.id, f.id) ? "checked" : ""; return ` `; }).join("")} `).join(""); bindPermToggles(); } function bindPermToggles() { document.querySelectorAll("[data-perm-toggle]").forEach((cb) => { cb.addEventListener("change", handlePermToggle); }); } async function handlePermToggle(e) { const cb = e.currentTarget; const userId = cb.dataset.user; const folderId = cb.dataset.folder; const granted = cb.checked; // Optimistically update local state if (granted) { if (!hasGrant(userId, folderId)) { state.permissions.grants.push({ user_id: userId, folder_id: folderId, granted_at: new Date().toISOString(), }); } } else { state.permissions.grants = state.permissions.grants.filter( (g) => !(g.user_id === userId && g.folder_id === folderId) ); } cb.disabled = true; try { await api(`/admin/permissions/${userId}/${folderId}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ granted }), }); const folder = state.permissions.folders.find((f) => f.id === folderId); const user = state.permissions.users.find((u) => u.id === userId); toast( granted ? `Granted "${user?.username}" access to "${folder?.name}"` : `Revoked "${user?.username}" access to "${folder?.name}"` ); } catch (err) { // Revert on failure cb.checked = !granted; if (granted) { state.permissions.grants = state.permissions.grants.filter( (g) => !(g.user_id === userId && g.folder_id === folderId) ); } else { state.permissions.grants.push({ user_id: userId, folder_id: folderId, granted_at: new Date().toISOString(), }); } toast(`Failed: ${err.message}`); } finally { cb.disabled = false; } } function switchPermView(view) { state.permView = view; document.querySelectorAll(".view-btn").forEach((b) => { b.classList.toggle("active", b.dataset.view === view); }); renderPermissions(); } // ---------- init ---------- function init() { $("loginForm").addEventListener("submit", handleLogin); $("logoutBtn").addEventListener("click", handleLogout); const backBtn = $("backToAppBtn"); if (backBtn) backBtn.addEventListener("click", () => { window.location.href = "/"; }); $("newUserBtn").addEventListener("click", openCreateUser); $("createUserForm").addEventListener("submit", handleCreateUser); $("resetPasswordForm").addEventListener("submit", handleResetPassword); $("mfaConfirmForm").addEventListener("submit", handleMfaConfirm); // Tab switching document.querySelectorAll(".admin-tab").forEach((el) => { el.addEventListener("click", () => switchTab(el.dataset.tab)); }); // Permissions view switcher (by-user / by-folder) document.querySelectorAll(".view-btn").forEach((el) => { el.addEventListener("click", () => switchPermView(el.dataset.view)); }); // Folder modal $("newFolderBtn").addEventListener("click", () => openFolderModal(null)); $("folderForm").addEventListener("submit", handleFolderSubmit); ["themeToggle", "themeToggleFloat"].forEach((id) => { const el = document.getElementById(id); if (el) el.addEventListener("click", toggleTheme); }); document.querySelectorAll("[data-close]").forEach((el) => { el.addEventListener("click", closeModals); }); document.querySelectorAll(".modal").forEach((m) => { m.addEventListener("click", (e) => { if (e.target === m) closeModals(); }); }); document.addEventListener("keydown", (e) => { if (e.key === "Escape") closeModals(); }); bootstrap(); } document.addEventListener("DOMContentLoaded", init);