import { LoginDialog } from './components/login_dialog.js';
import { firebaseAuth, signOut, onAuthStateChanged } from './firebase.js';
const LOCAL_MODE = document.querySelector('meta[name="local-mode"]').content === 'true';
let authToken = null;
let loginDialog = null;
let _currentAdminId = null; // ID of the signed-in admin, to prevent self-deactivation
// ── Tab switching ─────────────────────────────────────────────────────────────
const tabs = document.querySelectorAll('.account-tab[data-tab]');
const panels = document.querySelectorAll('.account-panel');
const _panelInited = new Set();
tabs.forEach(tab => {
tab.addEventListener('click', () => {
const target = tab.dataset.tab;
tabs.forEach(t => t.classList.toggle('active', t.dataset.tab === target));
panels.forEach(p => p.classList.toggle('active', p.id === 'tab-' + target));
closeDrawer();
closeProjectDrawer();
closeFolderDrawer();
closeEmbedDrawer();
closeSubDrawer();
if (target === 'users' && !_panelInited.has('users')) {
_panelInited.add('users');
initUsersPanel();
}
if (target === 'projects' && !_panelInited.has('projects')) {
_panelInited.add('projects');
initProjectsPanel();
}
if (target === 'folders' && !_panelInited.has('folders')) {
_panelInited.add('folders');
initFoldersPanel();
}
if (target === 'embeds' && !_panelInited.has('embeds')) {
_panelInited.add('embeds');
initEmbedsPanel();
}
if (target === 'subscriptions' && !_panelInited.has('subscriptions')) {
_panelInited.add('subscriptions');
initSubsPanel();
}
if (target === 'analytics' && !_panelInited.has('analytics')) {
_panelInited.add('analytics');
initAnalyticsPanel();
}
if (target === 'dispatch' && !_panelInited.has('dispatch')) {
_panelInited.add('dispatch');
initDispatchPanel();
}
if (target === 'schema' && !_panelInited.has('schema')) {
_panelInited.add('schema');
initSchemaPanel();
}
});
});
document.getElementById('adminBackBtn').addEventListener('click', () => {
window.location.href = '/app';
});
// ── UI state helpers ──────────────────────────────────────────────────────────
/**
* Reveals the admin page UI and populates the header with the current admin user's info.
* @param {object} user - The admin user object from the API.
*/
function showAdminPage(user) {
_currentAdminId = user.id;
document.getElementById('adminLoading').style.display = 'none';
document.getElementById('adminPage').style.display = '';
const initials = (user.display_name || user.email || '?')
.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase();
document.getElementById('adminHeaderAvatar').textContent = initials;
document.getElementById('adminHeaderName').textContent = user.display_name || user.email;
_panelInited.add('analytics');
initAnalyticsPanel();
}
/** Hides the loading spinner overlay. */
function hideLoading() {
document.getElementById('adminLoading').style.display = 'none';
}
// ── Logout ────────────────────────────────────────────────────────────────────
document.getElementById('adminLogoutBtn').addEventListener('click', async () => {
document.getElementById('adminPage').style.display = 'none';
document.getElementById('adminLoading').style.display = '';
authToken = null;
if (firebaseAuth) await signOut(firebaseAuth);
});
// ── Shared helpers ────────────────────────────────────────────────────────────
/**
* Escapes HTML special characters in a string.
* @param {*} s - Value to escape (coerced to string).
* @returns {string} HTML-safe string.
*/
function esc(s) {
return String(s ?? '')
.replace(/&/g, '&').replace(/</g, '<')
.replace(/>/g, '>').replace(/"/g, '"');
}
/**
* Formats an ISO timestamp as a short local date string.
* @param {string|null} iso - ISO 8601 date string, or null/empty.
* @returns {string} Formatted date, or '—' if not provided.
*/
function fmtDate(iso) {
if (!iso) return '—';
return new Date(iso).toLocaleDateString(undefined, {
year: 'numeric', month: 'short', day: 'numeric',
});
}
/**
* Formats an ISO timestamp as a full local date and time string.
* @param {string|null} iso - ISO 8601 date string, or null/empty.
* @returns {string} Formatted date-time, or '—' if not provided.
*/
function fmtDatetime(iso) {
if (!iso) return '—';
return new Date(iso).toLocaleString(undefined, {
year: 'numeric', month: 'short', day: 'numeric',
hour: '2-digit', minute: '2-digit',
});
}
// ── Status toggle (shared between table and drawer) ───────────────────────────
/**
* PATCH is_active for a user, update _users, and refresh the table.
* @param {string} userId - ID of the target user.
* @param {boolean} activate - Desired active state.
* @param {HTMLElement} btn - The button that triggered the action (disabled while in-flight).
*/
async function doSetUserActive(userId, activate, btn) {
btn.disabled = true;
btn.textContent = '…';
try {
const headers = { 'Content-Type': 'application/json' };
if (authToken) headers['X-Auth-Token'] = authToken;
const resp = await fetch(`/api/admin/users/${encodeURIComponent(userId)}/active`, {
method: 'PATCH', headers,
body: JSON.stringify({ is_active: activate }),
});
if (!resp.ok) throw new Error(resp.status);
// Patch local list data and re-render the table
const listUser = _users.find(u => u.id === userId);
if (listUser) listUser.is_active = activate;
renderTable();
// If this user's drawer is open, patch the cached full user and re-render it
if (_drawerUser?.id === userId) {
_drawerUser.is_active = activate;
renderDrawerContent();
}
} catch {
btn.disabled = false;
btn.textContent = activate ? 'Activate' : 'Deactivate';
}
}
// ── Users panel ───────────────────────────────────────────────────────────────
let _users = [];
let _sortCol = 'created_at';
let _sortDir = 'desc';
let _groupBy = '';
const COLS = [
{ key: 'email', label: 'Email', minWidth: '180px' },
{ key: 'display_name', label: 'Name', minWidth: '130px' },
{ key: 'is_active', label: 'Status', minWidth: '80px' },
{ key: 'auth_provider', label: 'Provider', minWidth: '85px' },
{ key: 'subscription_name', label: 'Subscription', minWidth: '110px' },
{ key: 'project_count', label: 'Projects', minWidth: '75px' },
{ key: 'folder_count', label: 'Folders', minWidth: '70px' },
{ key: 'embed_count', label: 'Embeds', minWidth: '70px' },
{ key: 'last_login_at', label: 'Last Login', minWidth: '110px' },
{ key: 'created_at', label: 'Created', minWidth: '110px' },
{ key: 'modified_at', label: 'Modified', minWidth: '110px' },
{ key: 'id', label: 'User ID', minWidth: '100px' },
{ key: 'external_id', label: 'Firebase UID', minWidth: '110px' },
{ key: '_actions', label: '', minWidth: '170px', noSort: true },
];
const GROUP_OPTIONS = [
{ value: '', label: 'None' },
{ value: 'is_active', label: 'Status' },
{ value: 'auth_provider', label: 'Provider' },
{ value: 'subscription_name',label: 'Subscription' },
];
/**
* Renders a user table cell as an HTML string for the given column.
* @param {object} col - Column descriptor from COLS.
* @param {object} user - User data object.
* @returns {string} Inner HTML for the cell.
*/
function fmtCell(col, user) {
const v = user[col.key];
if (col.key === 'is_active') {
const statusBadge = v
? '<span class="admin-badge admin-badge--active">Active</span>'
: '<span class="admin-badge admin-badge--inactive">Inactive</span>';
const adminBadge = user.is_admin
? '<span class="admin-badge admin-badge--admin">Admin</span>'
: '';
return `<span class="admin-badge-group">${statusBadge}${adminBadge}</span>`;
}
if (col.key === 'last_login_at' || col.key === 'created_at' || col.key === 'modified_at') {
return `<span title="${esc(v ?? '')}">${fmtDate(v)}</span>`;
}
if (col.key === 'subscription_name') {
return v
? `<span>${esc(v)}</span>`
: '<span class="admin-cell-muted">—</span>';
}
if (col.key === 'project_count' || col.key === 'folder_count' || col.key === 'embed_count') {
return `<span class="admin-cell-mono">${v ?? 0}</span>`;
}
if (col.key === 'id' || col.key === 'external_id') {
if (!v) return '<span class="admin-cell-muted">—</span>';
const trunc = v.length > 14 ? v.slice(0, 14) + '…' : v;
return `<span class="admin-cell-mono admin-cell-trunc" title="${esc(v)}">${esc(trunc)}</span>`;
}
if (col.key === '_actions') {
const action = user.is_active ? 'deactivate' : 'activate';
const label = user.is_active ? 'Deactivate' : 'Activate';
const cls = user.is_active ? 'btn btn-danger admin-btn-sm' : 'btn btn-primary admin-btn-sm';
const toggleBtn = `<button class="${cls}" data-action="${action}" data-user-id="${esc(user.id)}">${label}</button>`;
const deleteBtn = `<button class="btn btn-danger admin-btn-sm" data-action="delete" data-user-id="${esc(user.id)}" data-user-label="${esc(user.display_name || user.email)}">Delete</button>`;
return `<span style="display:inline-flex;gap:0.3rem">${toggleBtn}${deleteBtn}</span>`;
}
if (!v && v !== false) return '<span class="admin-cell-muted">—</span>';
return `<span>${esc(v)}</span>`;
}
/**
* Returns a human-readable label for the current group-by field value.
* @param {*} value - The raw group field value.
* @returns {string} Display label for the group.
*/
function getGroupLabel(value) {
if (_groupBy === 'is_active') return value ? 'Active' : 'Inactive';
if (_groupBy === 'subscription_name') return value ? String(value) : 'No subscription';
return value ? String(value) : '—';
}
/**
* Null-safe comparator for table sorting, handles booleans and strings.
* @param {*} a - First value.
* @param {*} b - Second value.
* @returns {number} Negative, zero, or positive sort order.
*/
function cmpValues(a, b) {
if (a == null && b == null) return 0;
if (a == null) return 1;
if (b == null) return -1;
if (typeof a === 'boolean') return a === b ? 0 : (a ? -1 : 1);
return String(a).localeCompare(String(b), undefined, { sensitivity: 'base', numeric: true });
}
/**
* Returns a sorted copy of the _users array based on the current sort column and direction.
* @returns {object[]} Sorted array of user objects.
*/
function sortedUsers() {
return [..._users].sort((a, b) => {
const cmp = cmpValues(a[_sortCol], b[_sortCol]);
return _sortDir === 'asc' ? cmp : -cmp;
});
}
/** Rebuild and inject the table HTML, then wire up interaction handlers. */
function renderTable() {
const wrap = document.getElementById('adminUsersTableWrap');
const count = document.getElementById('adminUsersCount');
count.textContent = `${_users.length} user${_users.length !== 1 ? 's' : ''}`;
const sorted = sortedUsers();
const thead = COLS.map(c => {
if (c.noSort) return `<th style="min-width:${c.minWidth}"></th>`;
const active = c.key === _sortCol;
const arrow = active ? (_sortDir === 'asc' ? ' ↑' : ' ↓') : '';
return `<th class="${active ? 'sorted' : ''}" data-col="${c.key}" style="min-width:${c.minWidth}">${c.label}${arrow}</th>`;
}).join('');
let tbody = '';
if (_groupBy) {
const groups = new Map();
for (const user of sorted) {
const label = getGroupLabel(user[_groupBy]);
if (!groups.has(label)) groups.set(label, []);
groups.get(label).push(user);
}
for (const [label, rows] of groups) {
tbody += `<tr class="admin-group-row"><td colspan="${COLS.length}">${esc(label)} · ${rows.length}</td></tr>`;
for (const user of rows) {
tbody += `<tr class="admin-table-row" data-user-id="${esc(user.id)}">${COLS.map(c => `<td>${fmtCell(c, user)}</td>`).join('')}</tr>`;
}
}
} else {
for (const user of sorted) {
tbody += `<tr class="admin-table-row" data-user-id="${esc(user.id)}">${COLS.map(c => `<td>${fmtCell(c, user)}</td>`).join('')}</tr>`;
}
}
if (!tbody) {
tbody = `<tr><td colspan="${COLS.length}" class="admin-table-empty">No users found.</td></tr>`;
}
wrap.innerHTML = `
<table class="admin-table">
<thead><tr>${thead}</tr></thead>
<tbody>${tbody}</tbody>
</table>`;
// Sort header clicks
wrap.querySelectorAll('thead th[data-col]').forEach(th => {
th.addEventListener('click', () => {
const col = th.dataset.col;
_sortDir = (_sortCol === col && _sortDir === 'asc') ? 'desc' : 'asc';
_sortCol = col;
renderTable();
});
});
// Restore selected highlight if a drawer is open
if (_drawerUser) _setUserRowSelected(_drawerUser.id);
}
/** Fetches the user list from the API and re-renders the table. */
async function fetchUsers() {
const btn = document.getElementById('adminUsersRefreshBtn');
btn.disabled = true;
btn.textContent = 'Refreshing…';
try {
const headers = {};
if (authToken) headers['X-Auth-Token'] = authToken;
const resp = await fetch('/api/admin/users', { headers });
if (!resp.ok) throw new Error(resp.status);
_users = await resp.json();
renderTable();
} catch {
document.getElementById('adminUsersCount').textContent = 'Failed to load users.';
} finally {
btn.disabled = false;
btn.textContent = 'Refresh';
}
}
/** Builds and injects the users panel toolbar and table into the DOM, then fetches data. */
function initUsersPanel() {
const section = document.getElementById('tab-users');
// Toolbar
const toolbar = document.createElement('div');
toolbar.className = 'admin-toolbar';
const countEl = document.createElement('span');
countEl.id = 'adminUsersCount';
countEl.className = 'admin-row-count';
countEl.textContent = 'Loading…';
const groupLabelEl = document.createElement('label');
groupLabelEl.className = 'admin-toolbar-label';
groupLabelEl.textContent = 'Group by';
const groupSelect = document.createElement('select');
groupSelect.className = 'admin-group-select';
GROUP_OPTIONS.forEach(opt => {
const o = document.createElement('option');
o.value = opt.value;
o.textContent = opt.label;
groupSelect.appendChild(o);
});
groupSelect.addEventListener('change', () => {
_groupBy = groupSelect.value;
renderTable();
});
const refreshBtn = document.createElement('button');
refreshBtn.id = 'adminUsersRefreshBtn';
refreshBtn.className = 'btn btn-ghost';
refreshBtn.textContent = 'Refresh';
refreshBtn.addEventListener('click', fetchUsers);
toolbar.appendChild(countEl);
toolbar.appendChild(groupLabelEl);
toolbar.appendChild(groupSelect);
toolbar.appendChild(refreshBtn);
const tableWrap = document.createElement('div');
tableWrap.id = 'adminUsersTableWrap';
tableWrap.className = 'admin-table-wrap';
// Delegated click handler for both row-open and action buttons
tableWrap.addEventListener('click', async (e) => {
const btn = e.target.closest('button[data-action]');
if (btn && !btn.disabled) {
if (btn.dataset.action === 'delete') {
openDeleteConfirm(btn.dataset.userId, btn.dataset.userLabel);
} else {
await doSetUserActive(btn.dataset.userId, btn.dataset.action === 'activate', btn);
}
return;
}
// Row click → open detail drawer
const row = e.target.closest('tr.admin-table-row[data-user-id]');
if (row) openDrawer(row.dataset.userId);
});
section.appendChild(toolbar);
section.appendChild(tableWrap);
fetchUsers();
}
// ── Projects panel ────────────────────────────────────────────────────────────
let _projects = [];
let _projSortCol = 'modified_at';
let _projSortDir = 'desc';
let _projGroupBy = '';
const PROJ_COLS = [
{ key: 'name', label: 'Name', minWidth: '180px' },
{ key: 'owner', label: 'Owner', minWidth: '130px' },
{ key: 'folder', label: 'Folder', minWidth: '110px' },
{ key: 'has_transcript', label: 'Transcript', minWidth: '90px' },
{ key: 'audio_format', label: 'Format', minWidth: '70px' },
{ key: 'duration', label: 'Duration', minWidth: '80px' },
{ key: 'embed_count', label: 'Embeds', minWidth: '70px' },
{ key: 'any_with_link', label: 'Public', minWidth: '70px' },
{ key: 'modified_at', label: 'Modified', minWidth: '110px' },
{ key: 'created_at', label: 'Created', minWidth: '110px' },
{ key: 'id', label: 'Project ID', minWidth: '100px' },
{ key: '_actions', label: '', minWidth: '175px', noSort: true },
];
const PROJ_GROUP_OPTIONS = [
{ value: '', label: 'None' },
{ value: 'owner', label: 'Owner' },
{ value: 'folder', label: 'Folder' },
{ value: 'has_transcript',label: 'Transcript' },
{ value: 'audio_format', label: 'Format' },
{ value: 'any_with_link', label: 'Public' },
];
/**
* Formats a duration in seconds as m:ss or h:mm:ss.
* @param {number|null} secs - Duration in seconds.
* @returns {string} Formatted duration string, or '—' if not provided.
*/
function fmtDuration(secs) {
if (secs == null || secs < 0) return '—';
const s = Math.round(secs);
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
if (h > 0) return `${h}:${String(m).padStart(2,'0')}:${String(sec).padStart(2,'0')}`;
return `${m}:${String(sec).padStart(2,'0')}`;
}
/**
* Renders a projects table cell as an HTML string for the given column.
* @param {object} col - Column descriptor from PROJ_COLS.
* @param {object} proj - Project data object.
* @returns {string} Inner HTML for the cell.
*/
function fmtProjCell(col, proj) {
const v = proj[col.key];
if (col.key === 'has_transcript') {
return v
? '<span class="admin-badge admin-badge--active">Yes</span>'
: '<span class="admin-cell-muted">—</span>';
}
if (col.key === 'any_with_link') {
const projBadge = v ? '<span class="admin-badge admin-badge--admin">Public</span>' : '';
const folderBadge = proj.folder_is_public
? '<span class="admin-badge admin-badge--active">Public Folder</span>' : '';
const badges = projBadge || folderBadge
? `<span class="admin-badge-group">${projBadge}${folderBadge}</span>`
: '<span class="admin-cell-muted">—</span>';
return badges;
}
if (col.key === 'duration') {
return `<span class="admin-cell-mono">${fmtDuration(v)}</span>`;
}
if (col.key === 'embed_count') {
return `<span class="admin-cell-mono">${v ?? 0}</span>`;
}
if (col.key === 'audio_format') {
return v ? `<span class="admin-cell-mono">${esc(v.toUpperCase())}</span>`
: '<span class="admin-cell-muted">—</span>';
}
if (col.key === 'modified_at' || col.key === 'created_at') {
return `<span title="${esc(v ?? '')}">${fmtDate(v)}</span>`;
}
if (col.key === 'folder') {
return v ? `<span class="admin-cell-muted">${esc(v)}</span>`
: '<span class="admin-cell-muted">root</span>';
}
if (col.key === 'id') {
if (!v) return '<span class="admin-cell-muted">—</span>';
const trunc = v.length > 14 ? v.slice(0, 14) + '…' : v;
return `<span class="admin-cell-mono admin-cell-trunc" title="${esc(v)}">${esc(trunc)}</span>`;
}
if (col.key === '_actions') {
const canEdit = proj.owner_id === null || proj.owner_id === _currentAdminId;
const disabled = canEdit ? '' : ' disabled title="You do not own this project"';
const pubBtn = proj.any_with_link
? `<button class="btn btn-ghost admin-btn-sm" data-proj-action="make-private" data-proj-id="${esc(proj.id)}"${disabled}>Make Private</button>`
: `<button class="btn btn-ghost admin-btn-sm" data-proj-action="make-public" data-proj-id="${esc(proj.id)}"${disabled}>Make Public</button>`;
const delBtn = `<button class="btn btn-danger admin-btn-sm" data-proj-action="delete"
data-proj-id="${esc(proj.id)}"
data-proj-label="${esc(proj.name)}"${disabled}>Delete</button>`;
return `<span style="display:inline-flex;gap:0.3rem">${pubBtn}${delBtn}</span>`;
}
if (!v && v !== false) return '<span class="admin-cell-muted">—</span>';
return `<span>${esc(v)}</span>`;
}
/**
* Returns a sorted copy of the _projects array based on the current sort column and direction.
* @returns {object[]} Sorted array of project objects.
*/
function sortedProjects() {
return [..._projects].sort((a, b) => {
const cmp = cmpValues(a[_projSortCol], b[_projSortCol]);
return _projSortDir === 'asc' ? cmp : -cmp;
});
}
/** Rebuilds and injects the projects table HTML, then wires up interaction handlers. */
function renderProjectsTable() {
const wrap = document.getElementById('adminProjectsTableWrap');
const count = document.getElementById('adminProjectsCount');
count.textContent = `${_projects.length} project${_projects.length !== 1 ? 's' : ''}`;
const sorted = sortedProjects();
const thead = PROJ_COLS.map(c => {
if (c.noSort) return `<th style="min-width:${c.minWidth}"></th>`;
const active = c.key === _projSortCol;
const arrow = active ? (_projSortDir === 'asc' ? ' ↑' : ' ↓') : '';
return `<th class="${active ? 'sorted' : ''}" data-col="${c.key}" style="min-width:${c.minWidth}">${c.label}${arrow}</th>`;
}).join('');
let tbody = '';
if (_projGroupBy) {
const groups = new Map();
for (const proj of sorted) {
const gv = proj[_projGroupBy];
const label = _projGroupBy === 'has_transcript' ? (gv ? 'Has transcript' : 'No transcript')
: _projGroupBy === 'any_with_link' ? (gv ? 'Public' : 'Private')
: (gv ? String(gv) : '—');
if (!groups.has(label)) groups.set(label, []);
groups.get(label).push(proj);
}
for (const [label, rows] of groups) {
tbody += `<tr class="admin-group-row"><td colspan="${PROJ_COLS.length}">${esc(label)} · ${rows.length}</td></tr>`;
for (const proj of rows) {
tbody += `<tr class="admin-table-row" data-proj-id="${esc(proj.id)}">${PROJ_COLS.map(c => `<td>${fmtProjCell(c, proj)}</td>`).join('')}</tr>`;
}
}
} else {
for (const proj of sorted) {
tbody += `<tr class="admin-table-row" data-proj-id="${esc(proj.id)}">${PROJ_COLS.map(c => `<td>${fmtProjCell(c, proj)}</td>`).join('')}</tr>`;
}
}
if (!tbody) {
tbody = `<tr><td colspan="${PROJ_COLS.length}" class="admin-table-empty">No projects found.</td></tr>`;
}
wrap.innerHTML = `
<table class="admin-table">
<thead><tr>${thead}</tr></thead>
<tbody>${tbody}</tbody>
</table>`;
wrap.querySelectorAll('thead th[data-col]').forEach(th => {
th.addEventListener('click', () => {
const col = th.dataset.col;
_projSortDir = (_projSortCol === col && _projSortDir === 'asc') ? 'desc' : 'asc';
_projSortCol = col;
renderProjectsTable();
});
});
// Restore selected highlight if a drawer is open
if (_drawerProject) _setProjRowSelected(_drawerProject.id);
}
/** Fetches the project list from the API and re-renders the table. */
async function fetchProjects() {
const btn = document.getElementById('adminProjectsRefreshBtn');
btn.disabled = true;
btn.textContent = 'Refreshing…';
try {
const headers = {};
if (authToken) headers['X-Auth-Token'] = authToken;
const resp = await fetch('/api/admin/projects', { headers });
if (!resp.ok) throw new Error(resp.status);
_projects = await resp.json();
renderProjectsTable();
} catch {
document.getElementById('adminProjectsCount').textContent = 'Failed to load projects.';
} finally {
btn.disabled = false;
btn.textContent = 'Refresh';
}
}
/** Builds and injects the projects panel toolbar and table into the DOM, then fetches data. */
function initProjectsPanel() {
const section = document.getElementById('tab-projects');
section.innerHTML = '';
const toolbar = document.createElement('div');
toolbar.className = 'admin-toolbar';
const countEl = document.createElement('span');
countEl.id = 'adminProjectsCount';
countEl.className = 'admin-row-count';
countEl.textContent = 'Loading…';
const groupLabelEl = document.createElement('label');
groupLabelEl.className = 'admin-toolbar-label';
groupLabelEl.textContent = 'Group by';
const groupSelect = document.createElement('select');
groupSelect.className = 'admin-group-select';
PROJ_GROUP_OPTIONS.forEach(opt => {
const o = document.createElement('option');
o.value = opt.value;
o.textContent = opt.label;
groupSelect.appendChild(o);
});
groupSelect.addEventListener('change', () => {
_projGroupBy = groupSelect.value;
renderProjectsTable();
});
const refreshBtn = document.createElement('button');
refreshBtn.id = 'adminProjectsRefreshBtn';
refreshBtn.className = 'btn btn-ghost';
refreshBtn.textContent = 'Refresh';
refreshBtn.addEventListener('click', fetchProjects);
toolbar.appendChild(countEl);
toolbar.appendChild(groupLabelEl);
toolbar.appendChild(groupSelect);
toolbar.appendChild(refreshBtn);
const tableWrap = document.createElement('div');
tableWrap.id = 'adminProjectsTableWrap';
tableWrap.className = 'admin-table-wrap';
tableWrap.addEventListener('click', (e) => {
const btn = e.target.closest('button[data-proj-action]');
if (btn) {
if (btn.disabled) return;
const { projAction, projId, projLabel } = btn.dataset;
if (projAction === 'delete') {
openDeleteProjectConfirm(projId, projLabel);
} else if (projAction === 'make-public' || projAction === 'make-private') {
doSetProjectPublic(projId, projAction === 'make-public', btn);
}
return;
}
const row = e.target.closest('tr.admin-table-row[data-proj-id]');
if (row) openProjectDrawer(row.dataset.projId);
});
section.appendChild(toolbar);
section.appendChild(tableWrap);
fetchProjects();
}
/**
* Opens the delete confirmation dialog for a project.
* @param {string} projectId - ID of the project to delete.
* @param {string} label - Display name of the project shown in the confirmation message.
*/
function openDeleteProjectConfirm(projectId, label) {
_confirmMsg.textContent = `Permanently delete project "${label}"? This will delete all associated files and cannot be undone.`;
_confirmOverlay.classList.add('open');
_confirmOkBtn.onclick = () => {
closeConfirm();
doDeleteProject(projectId);
};
}
/**
* Sends a DELETE request for the given project and removes it from the local list.
* @param {string} projectId - ID of the project to delete.
*/
async function doDeleteProject(projectId) {
try {
const headers = { 'Content-Type': 'application/json' };
if (authToken) headers['X-Auth-Token'] = authToken;
const resp = await fetch(`/api/admin/projects/${encodeURIComponent(projectId)}`, {
method: 'DELETE', headers,
});
if (!resp.ok) throw new Error(resp.status);
_projects = _projects.filter(p => p.id !== projectId);
if (_drawerProject?.id === projectId) closeProjectDrawer();
renderProjectsTable();
} catch {
// Silently ignore — user can retry
}
}
/**
* Toggles the public/private state of a project via PATCH and updates the local list.
* @param {string} projectId - ID of the project to update.
* @param {boolean} makePublic - True to make the project public, false to make it private.
* @param {HTMLElement} btn - The button that triggered the action (disabled while in-flight).
*/
async function doSetProjectPublic(projectId, makePublic, btn) {
btn.disabled = true;
btn.textContent = '…';
try {
const headers = { 'Content-Type': 'application/json' };
if (authToken) headers['X-Auth-Token'] = authToken;
const resp = await fetch(`/api/admin/projects/${encodeURIComponent(projectId)}/public`, {
method: 'PATCH', headers,
body: JSON.stringify({ public: makePublic }),
});
if (!resp.ok) throw new Error(resp.status);
const proj = _projects.find(p => p.id === projectId);
if (proj) {
proj.any_with_link = makePublic;
if (makePublic) { proj.owner = '—'; proj.owner_id = null; }
}
renderProjectsTable();
} catch {
btn.disabled = false;
btn.textContent = makePublic ? 'Make Public' : 'Make Private';
}
}
// ── Project detail drawer ─────────────────────────────────────────────────────
let _drawerProject = null;
const _projDrawerOverlay = document.getElementById('projectDrawerOverlay');
const _projDrawerPanel = document.getElementById('projectDrawerPanel');
/**
* Applies or clears the selected-row highlight in the projects table.
* @param {string|null} projectId - Project ID to highlight, or null to clear.
*/
function _setProjRowSelected(projectId) {
document.querySelectorAll('#adminProjectsTableWrap tr.admin-table-row').forEach(r => {
r.classList.toggle('admin-table-row--selected', !!projectId && r.dataset.projId === projectId);
});
}
/**
* Opens the project detail drawer for the given project.
* @param {string} projectId - ID of the project to display.
*/
function openProjectDrawer(projectId) {
const proj = _projects.find(p => p.id === projectId);
if (!proj) return;
if (_drawerUser) closeDrawer();
_drawerProject = proj;
renderProjectDrawerContent();
_projDrawerOverlay.classList.add('open');
_setProjRowSelected(projectId);
}
/** Closes the project detail drawer and clears the selection highlight. */
function closeProjectDrawer() {
_projDrawerOverlay.classList.remove('open');
_drawerProject = null;
_setProjRowSelected(null);
}
/** Re-renders the project drawer panel from _drawerProject. */
function renderProjectDrawerContent() {
const proj = _drawerProject;
if (!proj) return;
const canEdit = proj.owner_id === null || proj.owner_id === _currentAdminId;
// Header badges
const transcriptBadge = proj.has_transcript
? '<span class="admin-badge admin-badge--active">Transcript</span>' : '';
const publicBadge = proj.any_with_link
? '<span class="admin-badge admin-badge--admin">Public</span>' : '';
const folderBadge = proj.folder_is_public
? '<span class="admin-badge admin-badge--active">Public Folder</span>' : '';
const badgeGroup = (transcriptBadge || publicBadge || folderBadge)
? `<div class="admin-badge-group" style="margin-top:0.4rem">${transcriptBadge}${publicBadge}${folderBadge}</div>`
: '';
// Actions
const pubToggle = proj.any_with_link
? `<button class="btn btn-ghost" id="projDrawerPubBtn" data-action="make-private" data-proj-id="${esc(proj.id)}">Make Private</button>`
: `<button class="btn btn-ghost" id="projDrawerPubBtn" data-action="make-public" data-proj-id="${esc(proj.id)}">Make Public</button>`;
const deleteBtn = `<button class="btn btn-danger" id="projDrawerDelBtn" data-proj-id="${esc(proj.id)}" data-proj-label="${esc(proj.name)}">Delete project</button>`;
const actionsSection = canEdit ? `
<div class="user-drawer-section">
<div class="user-drawer-section-title">Actions</div>
<div style="display:flex;gap:0.5rem;flex-wrap:wrap">
${pubToggle}
${deleteBtn}
</div>
</div>` : '';
_projDrawerPanel.innerHTML = `
<div class="user-drawer-header">
<div class="user-drawer-identity" style="padding-top:0.1rem">
<div class="user-drawer-name">${esc(proj.name)}</div>
<div class="user-drawer-email">${esc(proj.owner || '—')}</div>
${badgeGroup}
</div>
<button class="user-drawer-close" id="projDrawerClose" title="Close">
<span class="icon icon-close" style="width:12px;height:12px;"></span>
</button>
</div>
<div class="user-drawer-body">
<div class="user-drawer-section">
<div class="user-drawer-section-title">Identity</div>
${drawerRow('Project ID', proj.id, true)}
${drawerRow('Owner', proj.owner || '—')}
${drawerRow('Folder', proj.folder || 'root')}
</div>
<div class="user-drawer-section">
<div class="user-drawer-section-title">Audio</div>
${drawerRow('Format', proj.audio_format ? proj.audio_format.toUpperCase() : '—')}
${drawerRow('Duration', fmtDuration(proj.duration))}
${drawerRow('Sample rate', proj.sample_rate > 0 ? `${proj.sample_rate} Hz` : '—')}
${drawerRow('MP3 copy', proj.has_audio_mp3 ? 'Yes' : 'No')}
</div>
<div class="user-drawer-section">
<div class="user-drawer-section-title">Content</div>
${drawerRow('Transcript', proj.has_transcript ? 'Yes' : 'No')}
${drawerRow('Embeds', String(proj.embed_count))}
</div>
<div class="user-drawer-section">
<div class="user-drawer-section-title">Timestamps</div>
${drawerRow('Created', fmtDatetime(proj.created_at))}
${drawerRow('Modified', fmtDatetime(proj.modified_at))}
</div>
${actionsSection}
</div>`;
document.getElementById('projDrawerClose').addEventListener('click', closeProjectDrawer);
const pubBtn = document.getElementById('projDrawerPubBtn');
if (pubBtn) {
pubBtn.addEventListener('click', async () => {
const makePublic = pubBtn.dataset.action === 'make-public';
await doSetProjectPublic(proj.id, makePublic, pubBtn);
// Re-render drawer with updated state
_drawerProject = _projects.find(p => p.id === proj.id) ?? _drawerProject;
renderProjectDrawerContent();
});
}
const delBtn = document.getElementById('projDrawerDelBtn');
if (delBtn) {
delBtn.addEventListener('click', () => {
openDeleteProjectConfirm(delBtn.dataset.projId, delBtn.dataset.projLabel);
});
}
}
// ── User detail drawer ────────────────────────────────────────────────────────
let _drawerUser = null; // Full user object (includes preferences after fetch)
const _drawerOverlay = document.getElementById('userDrawerOverlay');
const _drawerPanel = document.getElementById('userDrawerPanel');
/**
* Applies or clears the selected-row highlight in the users table.
* @param {string|null} userId - User ID to highlight, or null to clear.
*/
function _setUserRowSelected(userId) {
document.querySelectorAll('#adminUsersTableWrap tr.admin-table-row').forEach(r => {
r.classList.toggle('admin-table-row--selected', !!userId && r.dataset.userId === userId);
});
}
/**
* Opens the user detail drawer, immediately showing list data then fetching the full record.
* @param {string} userId - ID of the user to display.
*/
function openDrawer(userId) {
const listUser = _users.find(u => u.id === userId);
if (!listUser) return;
if (_drawerProject) closeProjectDrawer();
// Show immediately with data we already have (no preferences yet)
_drawerUser = { ...listUser };
renderDrawerContent();
_drawerOverlay.classList.add('open');
_setUserRowSelected(userId);
// Fetch full record (includes preferences)
const headers = {};
if (authToken) headers['X-Auth-Token'] = authToken;
fetch(`/api/admin/users/${encodeURIComponent(userId)}`, { headers })
.then(r => r.ok ? r.json() : null)
.then(full => {
if (full && _drawerUser?.id === userId) {
_drawerUser = full;
renderDrawerContent();
}
})
.catch(() => {});
}
/** Closes the user detail drawer and clears the selection highlight. */
function closeDrawer() {
_drawerOverlay.classList.remove('open');
_drawerUser = null;
_setUserRowSelected(null);
}
/** Re-render the drawer panel from _drawerUser. */
function renderDrawerContent() {
const user = _drawerUser;
if (!user) return;
const isSelf = user.id === _currentAdminId;
const initials = (user.display_name || user.email || '?')
.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase();
const statusBadge = user.is_active
? '<span class="admin-badge admin-badge--active">Active</span>'
: '<span class="admin-badge admin-badge--inactive">Inactive</span>';
const adminBadge = user.is_admin
? '<span class="admin-badge admin-badge--admin">Admin</span>'
: '';
// Preferences section
const hasPrefs = user.preferences != null;
const prefsHtml = hasPrefs
? renderPreferences(user.preferences)
: '<p class="admin-detail-hint">Loading…</p>';
_drawerPanel.innerHTML = `
<div class="user-drawer-header">
<div class="user-drawer-avatar">${esc(initials)}</div>
<div class="user-drawer-identity">
<div class="user-drawer-name">${esc(user.display_name || '—')}</div>
<div class="user-drawer-email">${esc(user.email || '—')}</div>
<div class="admin-badge-group" style="margin-top:0.4rem">${statusBadge}${adminBadge}</div>
</div>
<button class="user-drawer-close" id="userDrawerClose" title="Close">
<span class="icon icon-close" style="width:12px;height:12px;"></span>
</button>
</div>
<div class="user-drawer-body">
<div class="user-drawer-section">
<div class="user-drawer-section-title">Identity</div>
${drawerRow('User ID', user.id, true)}
${drawerRow('Firebase UID', user.external_id, true)}
${drawerRow('Provider', user.auth_provider)}
</div>
<div class="user-drawer-section">
<div class="user-drawer-section-title">Account</div>
${drawerRow('Created', fmtDatetime(user.created_at))}
${drawerRow('Last login', fmtDatetime(user.last_login_at))}
${drawerRow('Modified', fmtDatetime(user.modified_at))}
${user.subscription_id
? drawerRow('Subscription', user.subscription_name ?? user.subscription_id, !user.subscription_name)
: ''}
</div>
${!isSelf ? `
<div class="user-drawer-section">
<div class="user-drawer-section-title">Actions</div>
<div style="display:flex;gap:0.5rem;flex-wrap:wrap">
<button class="${user.is_active ? 'btn btn-danger' : 'btn btn-primary'}"
id="drawerToggleBtn"
data-action="${user.is_active ? 'deactivate' : 'activate'}"
data-user-id="${esc(user.id)}">
${user.is_active ? 'Deactivate account' : 'Activate account'}
</button>
<button class="btn btn-danger" id="drawerDeleteBtn" data-user-id="${esc(user.id)}">
Delete account
</button>
</div>
</div>` : ''}
<div class="user-drawer-section">
<div class="user-drawer-section-title">Settings</div>
${prefsHtml}
</div>
</div>`;
document.getElementById('userDrawerClose').addEventListener('click', closeDrawer);
const toggleBtn = document.getElementById('drawerToggleBtn');
if (toggleBtn) {
toggleBtn.addEventListener('click', () => {
doSetUserActive(toggleBtn.dataset.userId, toggleBtn.dataset.action === 'activate', toggleBtn);
});
}
const deleteBtn = document.getElementById('drawerDeleteBtn');
if (deleteBtn) {
deleteBtn.addEventListener('click', () => {
openDeleteConfirm(deleteBtn.dataset.userId, user.display_name || user.email);
});
}
}
/**
* Renders a label/value row for the drawer.
* @param {string} label - Display label.
* @param {string} value - Value to display.
* @param {boolean} mono - Whether to render the value in monospace.
* @returns {string} HTML string for the row.
*/
function drawerRow(label, value, mono = false) {
const valHtml = value
? `<span class="${mono ? 'admin-cell-mono' : ''} user-drawer-row-value-text">${esc(value)}</span>`
: '<span class="admin-cell-muted">—</span>';
return `
<div class="user-drawer-row">
<span class="user-drawer-row-label">${esc(label)}</span>
<span class="user-drawer-row-value">${valHtml}</span>
</div>`;
}
const STARTUP_LABELS = { last_project: 'Open last project', home_screen: 'Show home screen' };
const KNOWN_PREF_KEYS = new Set([
'theme', 'startup_behavior', 'autosave', 'undo_queue_size',
'custom_themes', 'custom_colors_light', 'custom_colors_dark',
'subscription_since', 'survey_answers',
]);
/**
* Renders the preferences object as a series of drawer rows.
* @param {object} prefs - User preferences object.
* @returns {string} HTML string of drawer rows, or a hint message if empty.
*/
function renderPreferences(prefs) {
if (!prefs || !Object.keys(prefs).length) {
return '<p class="admin-detail-hint">No preferences saved.</p>';
}
const rows = [];
if (prefs.theme !== undefined)
rows.push(drawerRow('Theme', prefs.theme));
if (prefs.startup_behavior !== undefined)
rows.push(drawerRow('On startup', STARTUP_LABELS[prefs.startup_behavior] ?? prefs.startup_behavior));
if (prefs.autosave !== undefined)
rows.push(drawerRow('Autosave', prefs.autosave ? 'Enabled' : 'Disabled'));
if (prefs.undo_queue_size !== undefined)
rows.push(drawerRow('Undo queue', `${prefs.undo_queue_size} steps`));
if (prefs.custom_themes?.length)
rows.push(drawerRow('Custom themes', String(prefs.custom_themes.length)));
const hasLightColors = prefs.custom_colors_light && Object.keys(prefs.custom_colors_light).length;
const hasDarkColors = prefs.custom_colors_dark && Object.keys(prefs.custom_colors_dark).length;
if (hasLightColors || hasDarkColors)
rows.push(drawerRow('Custom colors', [
hasLightColors ? 'light' : '',
hasDarkColors ? 'dark' : '',
].filter(Boolean).join(', ')));
if (prefs.subscription_since)
rows.push(drawerRow('Subscriber since', fmtDate(prefs.subscription_since)));
if (prefs.survey_answers && Object.keys(prefs.survey_answers).length) {
for (const [k, v] of Object.entries(prefs.survey_answers)) {
if (v) rows.push(drawerRow(k.replace(/_/g, ' '), String(v)));
}
}
// Any unrecognised keys
for (const [k, v] of Object.entries(prefs)) {
if (!KNOWN_PREF_KEYS.has(k)) {
const display = typeof v === 'object' ? JSON.stringify(v) : String(v);
rows.push(drawerRow(k, display));
}
}
return rows.join('');
}
// ── Folders panel ─────────────────────────────────────────────────────────────
let _folders = [];
let _fldSortCol = 'created_at';
let _fldSortDir = 'desc';
let _fldGroupBy = '';
const FLD_COLS = [
{ key: 'name', label: 'Name', minWidth: '160px' },
{ key: 'owner', label: 'Owner', minWidth: '160px' },
{ key: 'parent_name', label: 'Parent', minWidth: '120px' },
{ key: 'is_public', label: 'Visibility', minWidth: '100px' },
{ key: 'project_count', label: 'Projects', minWidth: '75px' },
{ key: 'subfolder_count', label: 'Subfolders', minWidth: '80px' },
{ key: 'created_at', label: 'Created', minWidth: '110px' },
{ key: 'id', label: 'Folder ID', minWidth: '100px' },
{ key: '_actions', label: '', minWidth: '175px', noSort: true },
];
const FLD_GROUP_OPTIONS = [
{ value: '', label: 'None' },
{ value: 'owner', label: 'Owner' },
{ value: 'parent_name', label: 'Parent' },
{ value: 'is_public', label: 'Visibility'},
];
/**
* Renders a folders table cell as an HTML string for the given column.
* @param {object} col - Column descriptor from FLD_COLS.
* @param {object} fld - Folder data object.
* @returns {string} Inner HTML for the cell.
*/
function fmtFldCell(col, fld) {
const v = fld[col.key];
if (col.key === 'is_public') {
return v
? '<span class="admin-badge admin-badge--admin">Public</span>'
: '<span class="admin-cell-muted">Private</span>';
}
if (col.key === 'project_count' || col.key === 'subfolder_count') {
return `<span class="admin-cell-mono">${v ?? 0}</span>`;
}
if (col.key === 'created_at') {
return `<span title="${esc(v ?? '')}">${fmtDate(v)}</span>`;
}
if (col.key === 'parent_name') {
return v ? `<span class="admin-cell-muted">${esc(v)}</span>`
: '<span class="admin-cell-muted">root</span>';
}
if (col.key === 'id') {
const trunc = v && v.length > 14 ? v.slice(0, 14) + '…' : v;
return v
? `<span class="admin-cell-mono admin-cell-trunc" title="${esc(v)}">${esc(trunc)}</span>`
: '<span class="admin-cell-muted">—</span>';
}
if (col.key === '_actions') {
const canEdit = fld.owner_id === null || fld.owner_id === _currentAdminId;
const disabled = canEdit ? '' : ' disabled title="You do not own this folder"';
const pubBtn = fld.is_public
? `<button class="btn btn-ghost admin-btn-sm" data-fld-action="make-private" data-fld-id="${esc(fld.id)}"${disabled}>Make Private</button>`
: `<button class="btn btn-ghost admin-btn-sm" data-fld-action="make-public" data-fld-id="${esc(fld.id)}"${disabled}>Make Public</button>`;
const delBtn = `<button class="btn btn-danger admin-btn-sm" data-fld-action="delete"
data-fld-id="${esc(fld.id)}"
data-fld-label="${esc(fld.name)}"${disabled}>Delete</button>`;
return `<span style="display:inline-flex;gap:0.3rem">${pubBtn}${delBtn}</span>`;
}
if (!v && v !== false) return '<span class="admin-cell-muted">—</span>';
return `<span>${esc(v)}</span>`;
}
/**
* Returns a sorted copy of the _folders array based on the current sort column and direction.
* @returns {object[]} Sorted array of folder objects.
*/
function sortedFolders() {
return [..._folders].sort((a, b) => {
const cmp = cmpValues(a[_fldSortCol], b[_fldSortCol]);
return _fldSortDir === 'asc' ? cmp : -cmp;
});
}
/** Rebuilds and injects the folders table HTML, then wires up interaction handlers. */
function renderFoldersTable() {
const wrap = document.getElementById('adminFoldersTableWrap');
const count = document.getElementById('adminFoldersCount');
count.textContent = `${_folders.length} folder${_folders.length !== 1 ? 's' : ''}`;
const sorted = sortedFolders();
const thead = FLD_COLS.map(c => {
if (c.noSort) return `<th style="min-width:${c.minWidth}"></th>`;
const active = c.key === _fldSortCol;
const arrow = active ? (_fldSortDir === 'asc' ? ' ↑' : ' ↓') : '';
return `<th class="${active ? 'sorted' : ''}" data-col="${c.key}" style="min-width:${c.minWidth}">${c.label}${arrow}</th>`;
}).join('');
let tbody = '';
if (_fldGroupBy) {
const groups = new Map();
for (const fld of sorted) {
const gv = fld[_fldGroupBy];
const label = _fldGroupBy === 'is_public' ? (gv ? 'Public' : 'Private')
: (gv ? String(gv) : '—');
if (!groups.has(label)) groups.set(label, []);
groups.get(label).push(fld);
}
for (const [label, rows] of groups) {
tbody += `<tr class="admin-group-row"><td colspan="${FLD_COLS.length}">${esc(label)} · ${rows.length}</td></tr>`;
for (const fld of rows) {
tbody += `<tr class="admin-table-row" data-fld-id="${esc(fld.id)}">${FLD_COLS.map(c => `<td>${fmtFldCell(c, fld)}</td>`).join('')}</tr>`;
}
}
} else {
for (const fld of sorted) {
tbody += `<tr class="admin-table-row" data-fld-id="${esc(fld.id)}">${FLD_COLS.map(c => `<td>${fmtFldCell(c, fld)}</td>`).join('')}</tr>`;
}
}
if (!tbody) {
tbody = `<tr><td colspan="${FLD_COLS.length}" class="admin-table-empty">No folders found.</td></tr>`;
}
wrap.innerHTML = `
<table class="admin-table">
<thead><tr>${thead}</tr></thead>
<tbody>${tbody}</tbody>
</table>`;
wrap.querySelectorAll('thead th[data-col]').forEach(th => {
th.addEventListener('click', () => {
const col = th.dataset.col;
_fldSortDir = (_fldSortCol === col && _fldSortDir === 'asc') ? 'desc' : 'asc';
_fldSortCol = col;
renderFoldersTable();
});
});
if (_drawerFolder) _setFldRowSelected(_drawerFolder.id);
}
/** Fetches the folder list from the API and re-renders the table. */
async function fetchFolders() {
const btn = document.getElementById('adminFoldersRefreshBtn');
btn.disabled = true;
btn.textContent = 'Refreshing…';
try {
const headers = {};
if (authToken) headers['X-Auth-Token'] = authToken;
const resp = await fetch('/api/admin/folders', { headers });
if (!resp.ok) throw new Error(resp.status);
_folders = await resp.json();
renderFoldersTable();
} catch {
document.getElementById('adminFoldersCount').textContent = 'Failed to load folders.';
} finally {
btn.disabled = false;
btn.textContent = 'Refresh';
}
}
/** Builds and injects the folders panel toolbar and table into the DOM, then fetches data. */
function initFoldersPanel() {
const section = document.getElementById('tab-folders');
section.innerHTML = '<h2 class="account-panel-title">Folders</h2>';
const toolbar = document.createElement('div');
toolbar.className = 'admin-toolbar';
const countEl = document.createElement('span');
countEl.id = 'adminFoldersCount';
countEl.className = 'admin-row-count';
countEl.textContent = 'Loading…';
const groupLabelEl = document.createElement('label');
groupLabelEl.className = 'admin-toolbar-label';
groupLabelEl.textContent = 'Group by';
const groupSelect = document.createElement('select');
groupSelect.className = 'admin-group-select';
FLD_GROUP_OPTIONS.forEach(opt => {
const o = document.createElement('option');
o.value = opt.value;
o.textContent = opt.label;
groupSelect.appendChild(o);
});
groupSelect.addEventListener('change', () => {
_fldGroupBy = groupSelect.value;
renderFoldersTable();
});
const refreshBtn = document.createElement('button');
refreshBtn.id = 'adminFoldersRefreshBtn';
refreshBtn.className = 'btn btn-ghost';
refreshBtn.textContent = 'Refresh';
refreshBtn.addEventListener('click', fetchFolders);
toolbar.appendChild(countEl);
toolbar.appendChild(groupLabelEl);
toolbar.appendChild(groupSelect);
toolbar.appendChild(refreshBtn);
const tableWrap = document.createElement('div');
tableWrap.id = 'adminFoldersTableWrap';
tableWrap.className = 'admin-table-wrap';
tableWrap.addEventListener('click', (e) => {
const btn = e.target.closest('button[data-fld-action]');
if (btn) {
if (btn.disabled) return;
const { fldAction, fldId, fldLabel } = btn.dataset;
if (fldAction === 'delete') {
openDeleteFolderConfirm(fldId, fldLabel);
} else if (fldAction === 'make-public' || fldAction === 'make-private') {
doSetFolderPublic(fldId, fldAction === 'make-public', btn);
}
return;
}
const row = e.target.closest('tr.admin-table-row[data-fld-id]');
if (row) openFolderDrawer(row.dataset.fldId);
});
section.appendChild(toolbar);
section.appendChild(tableWrap);
fetchFolders();
}
/**
* Opens the delete confirmation dialog for a folder.
* @param {string} folderId - ID of the folder to delete.
* @param {string} label - Display name of the folder shown in the confirmation message.
*/
function openDeleteFolderConfirm(folderId, label) {
_confirmMsg.textContent = `Permanently delete folder "${label}"? All subfolders and projects inside will also be deleted. This cannot be undone.`;
_confirmOverlay.classList.add('open');
_confirmOkBtn.onclick = () => {
closeConfirm();
doDeleteFolder(folderId);
};
}
/**
* Sends a DELETE request for the given folder and removes it from the local list.
* @param {string} folderId - ID of the folder to delete.
*/
async function doDeleteFolder(folderId) {
try {
const headers = { 'Content-Type': 'application/json' };
if (authToken) headers['X-Auth-Token'] = authToken;
const resp = await fetch(`/api/admin/folders/${encodeURIComponent(folderId)}`, {
method: 'DELETE', headers,
});
if (!resp.ok) throw new Error(resp.status);
_folders = _folders.filter(f => f.id !== folderId);
if (_drawerFolder?.id === folderId) closeFolderDrawer();
renderFoldersTable();
} catch {
// Silently ignore — user can retry
}
}
/**
* Toggles the public/private state of a folder via PATCH and updates the local list.
* @param {string} folderId - ID of the folder to update.
* @param {boolean} makePublic - True to make the folder public, false to make it private.
* @param {HTMLElement} btn - The button that triggered the action (disabled while in-flight).
*/
async function doSetFolderPublic(folderId, makePublic, btn) {
btn.disabled = true;
btn.textContent = '…';
try {
const headers = { 'Content-Type': 'application/json' };
if (authToken) headers['X-Auth-Token'] = authToken;
const resp = await fetch(`/api/admin/folders/${encodeURIComponent(folderId)}/public`, {
method: 'PATCH', headers,
body: JSON.stringify({ public: makePublic }),
});
if (!resp.ok) throw new Error(resp.status);
const fld = _folders.find(f => f.id === folderId);
if (fld) fld.is_public = makePublic;
if (_drawerFolder?.id === folderId) {
_drawerFolder.is_public = makePublic;
renderFolderDrawerContent();
}
renderFoldersTable();
} catch {
btn.disabled = false;
btn.textContent = makePublic ? 'Make Public' : 'Make Private';
}
}
// ── Folder detail drawer ──────────────────────────────────────────────────────
let _drawerFolder = null;
const _fldDrawerOverlay = document.getElementById('folderDrawerOverlay');
const _fldDrawerPanel = document.getElementById('folderDrawerPanel');
/**
* Applies or clears the selected-row highlight in the folders table.
* @param {string|null} folderId - Folder ID to highlight, or null to clear.
*/
function _setFldRowSelected(folderId) {
document.querySelectorAll('#adminFoldersTableWrap tr.admin-table-row').forEach(r => {
r.classList.toggle('admin-table-row--selected', !!folderId && r.dataset.fldId === folderId);
});
}
/**
* Opens the folder detail drawer for the given folder.
* @param {string} folderId - ID of the folder to display.
*/
function openFolderDrawer(folderId) {
const fld = _folders.find(f => f.id === folderId);
if (!fld) return;
if (_drawerUser) closeDrawer();
if (_drawerProject) closeProjectDrawer();
_drawerFolder = fld;
renderFolderDrawerContent();
_fldDrawerOverlay.classList.add('open');
_setFldRowSelected(folderId);
}
/** Closes the folder detail drawer and clears the selection highlight. */
function closeFolderDrawer() {
_fldDrawerOverlay.classList.remove('open');
_drawerFolder = null;
_setFldRowSelected(null);
}
/** Re-renders the folder drawer panel from _drawerFolder. */
function renderFolderDrawerContent() {
const fld = _drawerFolder;
if (!fld) return;
const canEdit = fld.owner_id === null || fld.owner_id === _currentAdminId;
const publicBadge = fld.is_public
? '<span class="admin-badge admin-badge--admin">Public</span>' : '';
const badgeGroup = publicBadge
? `<div class="admin-badge-group" style="margin-top:0.4rem">${publicBadge}</div>` : '';
const pubToggle = fld.is_public
? `<button class="btn btn-ghost" id="fldDrawerPubBtn" data-action="make-private" data-fld-id="${esc(fld.id)}">Make Private</button>`
: `<button class="btn btn-ghost" id="fldDrawerPubBtn" data-action="make-public" data-fld-id="${esc(fld.id)}">Make Public</button>`;
const deleteBtn = `<button class="btn btn-danger" id="fldDrawerDelBtn" data-fld-id="${esc(fld.id)}" data-fld-label="${esc(fld.name)}">Delete folder</button>`;
const actionsSection = canEdit ? `
<div class="user-drawer-section">
<div class="user-drawer-section-title">Actions</div>
<div style="display:flex;gap:0.5rem;flex-wrap:wrap">
${pubToggle}
${deleteBtn}
</div>
</div>` : '';
_fldDrawerPanel.innerHTML = `
<div class="user-drawer-header">
<div class="user-drawer-identity" style="padding-top:0.1rem">
<div class="user-drawer-name">${esc(fld.name)}</div>
<div class="user-drawer-email">${esc(fld.owner || '—')}</div>
${badgeGroup}
</div>
<button class="user-drawer-close" id="fldDrawerClose" title="Close">
<span class="icon icon-close" style="width:12px;height:12px;"></span>
</button>
</div>
<div class="user-drawer-body">
<div class="user-drawer-section">
<div class="user-drawer-section-title">Identity</div>
${drawerRow('Folder ID', fld.id, true)}
${drawerRow('Parent', fld.parent_name || 'root' )}
${drawerRow('Owner', fld.owner || '—' )}
</div>
<div class="user-drawer-section">
<div class="user-drawer-section-title">Contents</div>
${drawerRow('Projects', String(fld.project_count) )}
${drawerRow('Subfolders', String(fld.subfolder_count))}
</div>
<div class="user-drawer-section">
<div class="user-drawer-section-title">Timestamps</div>
${drawerRow('Created', fmtDatetime(fld.created_at))}
</div>
${actionsSection}
</div>`;
document.getElementById('fldDrawerClose').addEventListener('click', closeFolderDrawer);
const pubBtn = document.getElementById('fldDrawerPubBtn');
if (pubBtn) {
pubBtn.addEventListener('click', async () => {
const makePublic = pubBtn.dataset.action === 'make-public';
await doSetFolderPublic(fld.id, makePublic, pubBtn);
});
}
const delBtn = document.getElementById('fldDrawerDelBtn');
if (delBtn) {
delBtn.addEventListener('click', () => {
openDeleteFolderConfirm(delBtn.dataset.fldId, delBtn.dataset.fldLabel);
});
}
}
// ── Embeds panel ──────────────────────────────────────────────────────────────
let _embeds = [];
let _embSortCol = 'created_at';
let _embSortDir = 'desc';
let _embGroupBy = '';
const EMB_COLS = [
{ key: 'id', label: 'Embed ID', minWidth: '120px' },
{ key: 'project_name', label: 'Project', minWidth: '160px' },
{ key: 'owner', label: 'Owner', minWidth: '160px' },
{ key: 'invoke_count', label: 'Invocations',minWidth: '90px' },
{ key: 'created_at', label: 'Created', minWidth: '110px' },
{ key: '_actions', label: '', minWidth: '80px', noSort: true },
];
const EMB_GROUP_OPTIONS = [
{ value: '', label: 'None' },
{ value: 'owner', label: 'Owner' },
{ value: 'project_name', label: 'Project' },
];
/**
* Renders an embeds table cell as an HTML string for the given column.
* @param {object} col - Column descriptor from EMB_COLS.
* @param {object} emb - Embed data object.
* @returns {string} Inner HTML for the cell.
*/
function fmtEmbCell(col, emb) {
const v = emb[col.key];
if (col.key === 'id') {
const trunc = v && v.length > 16 ? v.slice(0, 16) + '…' : v;
return `<span class="admin-cell-mono admin-cell-trunc" title="${esc(v)}">${esc(trunc)}</span>`;
}
if (col.key === 'invoke_count') {
return `<span class="admin-cell-mono">${v ?? 0}</span>`;
}
if (col.key === 'created_at') {
return `<span title="${esc(v ?? '')}">${fmtDate(v)}</span>`;
}
if (col.key === '_actions') {
return `<button class="btn btn-danger admin-btn-sm" data-emb-action="delete"
data-emb-id="${esc(emb.id)}"
data-emb-label="${esc(emb.id.slice(0, 8))}…">Delete</button>`;
}
if (!v) return '<span class="admin-cell-muted">—</span>';
return `<span>${esc(v)}</span>`;
}
/**
* Returns a sorted copy of the _embeds array based on the current sort column and direction.
* @returns {object[]} Sorted array of embed objects.
*/
function sortedEmbeds() {
return [..._embeds].sort((a, b) => {
const cmp = cmpValues(a[_embSortCol], b[_embSortCol]);
return _embSortDir === 'asc' ? cmp : -cmp;
});
}
/** Rebuilds and injects the embeds table HTML, then wires up interaction handlers. */
function renderEmbedsTable() {
const wrap = document.getElementById('adminEmbedsTableWrap');
const count = document.getElementById('adminEmbedsCount');
count.textContent = `${_embeds.length} embed${_embeds.length !== 1 ? 's' : ''}`;
const sorted = sortedEmbeds();
const thead = EMB_COLS.map(c => {
if (c.noSort) return `<th style="min-width:${c.minWidth}"></th>`;
const active = c.key === _embSortCol;
const arrow = active ? (_embSortDir === 'asc' ? ' ↑' : ' ↓') : '';
return `<th class="${active ? 'sorted' : ''}" data-col="${c.key}" style="min-width:${c.minWidth}">${c.label}${arrow}</th>`;
}).join('');
let tbody = '';
if (_embGroupBy) {
const groups = new Map();
for (const emb of sorted) {
const label = emb[_embGroupBy] || '—';
if (!groups.has(label)) groups.set(label, []);
groups.get(label).push(emb);
}
for (const [label, rows] of groups) {
tbody += `<tr class="admin-group-row"><td colspan="${EMB_COLS.length}">${esc(label)} · ${rows.length}</td></tr>`;
for (const emb of rows) {
tbody += `<tr class="admin-table-row" data-emb-id="${esc(emb.id)}">${EMB_COLS.map(c => `<td>${fmtEmbCell(c, emb)}</td>`).join('')}</tr>`;
}
}
} else {
for (const emb of sorted) {
tbody += `<tr class="admin-table-row" data-emb-id="${esc(emb.id)}">${EMB_COLS.map(c => `<td>${fmtEmbCell(c, emb)}</td>`).join('')}</tr>`;
}
}
if (!tbody) {
tbody = `<tr><td colspan="${EMB_COLS.length}" class="admin-table-empty">No embeds found.</td></tr>`;
}
wrap.innerHTML = `
<table class="admin-table">
<thead><tr>${thead}</tr></thead>
<tbody>${tbody}</tbody>
</table>`;
wrap.querySelectorAll('thead th[data-col]').forEach(th => {
th.addEventListener('click', () => {
const col = th.dataset.col;
_embSortDir = (_embSortCol === col && _embSortDir === 'asc') ? 'desc' : 'asc';
_embSortCol = col;
renderEmbedsTable();
});
});
if (_drawerEmbed) _setEmbRowSelected(_drawerEmbed.id);
}
/** Fetches the embed list from the API and re-renders the table. */
async function fetchEmbeds() {
const btn = document.getElementById('adminEmbedsRefreshBtn');
btn.disabled = true;
btn.textContent = 'Refreshing…';
try {
const headers = {};
if (authToken) headers['X-Auth-Token'] = authToken;
const resp = await fetch('/api/admin/embeds', { headers });
if (!resp.ok) throw new Error(resp.status);
_embeds = await resp.json();
renderEmbedsTable();
} catch {
document.getElementById('adminEmbedsCount').textContent = 'Failed to load embeds.';
} finally {
btn.disabled = false;
btn.textContent = 'Refresh';
}
}
/** Builds and injects the embeds panel toolbar and table into the DOM, then fetches data. */
function initEmbedsPanel() {
const section = document.getElementById('tab-embeds');
section.innerHTML = '<h2 class="account-panel-title">Embeds</h2>';
const toolbar = document.createElement('div');
toolbar.className = 'admin-toolbar';
const countEl = document.createElement('span');
countEl.id = 'adminEmbedsCount';
countEl.className = 'admin-row-count';
countEl.textContent = 'Loading…';
const groupLabelEl = document.createElement('label');
groupLabelEl.className = 'admin-toolbar-label';
groupLabelEl.textContent = 'Group by';
const groupSelect = document.createElement('select');
groupSelect.className = 'admin-group-select';
EMB_GROUP_OPTIONS.forEach(opt => {
const o = document.createElement('option');
o.value = opt.value;
o.textContent = opt.label;
groupSelect.appendChild(o);
});
groupSelect.addEventListener('change', () => {
_embGroupBy = groupSelect.value;
renderEmbedsTable();
});
const refreshBtn = document.createElement('button');
refreshBtn.id = 'adminEmbedsRefreshBtn';
refreshBtn.className = 'btn btn-ghost';
refreshBtn.textContent = 'Refresh';
refreshBtn.addEventListener('click', fetchEmbeds);
toolbar.appendChild(countEl);
toolbar.appendChild(groupLabelEl);
toolbar.appendChild(groupSelect);
toolbar.appendChild(refreshBtn);
const tableWrap = document.createElement('div');
tableWrap.id = 'adminEmbedsTableWrap';
tableWrap.className = 'admin-table-wrap';
tableWrap.addEventListener('click', (e) => {
const btn = e.target.closest('button[data-emb-action]');
if (btn) {
if (btn.disabled) return;
if (btn.dataset.embAction === 'delete') {
openDeleteEmbedConfirm(btn.dataset.embId, btn.dataset.embLabel);
}
return;
}
const row = e.target.closest('tr.admin-table-row[data-emb-id]');
if (row) openEmbedDrawer(row.dataset.embId);
});
section.appendChild(toolbar);
section.appendChild(tableWrap);
fetchEmbeds();
}
/**
* Opens the delete confirmation dialog for an embed.
* @param {string} embedId - ID of the embed to delete.
* @param {string} label - Truncated embed ID shown in the confirmation message.
*/
function openDeleteEmbedConfirm(embedId, label) {
_confirmMsg.textContent = `Permanently delete embed "${label}"? Any pages using this embed will stop working.`;
_confirmOverlay.classList.add('open');
_confirmOkBtn.onclick = () => {
closeConfirm();
doDeleteEmbed(embedId);
};
}
/**
* Sends a DELETE request for the given embed and removes it from the local list.
* @param {string} embedId - ID of the embed to delete.
*/
async function doDeleteEmbed(embedId) {
try {
const headers = { 'Content-Type': 'application/json' };
if (authToken) headers['X-Auth-Token'] = authToken;
const resp = await fetch(`/api/admin/embeds/${encodeURIComponent(embedId)}`, {
method: 'DELETE', headers,
});
if (!resp.ok) throw new Error(resp.status);
_embeds = _embeds.filter(e => e.id !== embedId);
if (_drawerEmbed?.id === embedId) closeEmbedDrawer();
renderEmbedsTable();
} catch {
// Silently ignore — user can retry
}
}
// ── Embed detail drawer ───────────────────────────────────────────────────────
let _drawerEmbed = null;
const _embDrawerOverlay = document.getElementById('embedDrawerOverlay');
const _embDrawerPanel = document.getElementById('embedDrawerPanel');
/**
* Applies or clears the selected-row highlight in the embeds table.
* @param {string|null} embedId - Embed ID to highlight, or null to clear.
*/
function _setEmbRowSelected(embedId) {
document.querySelectorAll('#adminEmbedsTableWrap tr.admin-table-row').forEach(r => {
r.classList.toggle('admin-table-row--selected', !!embedId && r.dataset.embId === embedId);
});
}
/**
* Opens the embed detail drawer for the given embed.
* @param {string} embedId - ID of the embed to display.
*/
function openEmbedDrawer(embedId) {
const emb = _embeds.find(e => e.id === embedId);
if (!emb) return;
if (_drawerUser) closeDrawer();
if (_drawerProject) closeProjectDrawer();
if (_drawerFolder) closeFolderDrawer();
_drawerEmbed = emb;
renderEmbedDrawerContent();
_embDrawerOverlay.classList.add('open');
_setEmbRowSelected(embedId);
}
/** Closes the embed detail drawer and clears the selection highlight. */
function closeEmbedDrawer() {
_embDrawerOverlay.classList.remove('open');
_drawerEmbed = null;
_setEmbRowSelected(null);
}
/** Re-renders the embed drawer panel from _drawerEmbed. */
function renderEmbedDrawerContent() {
const emb = _drawerEmbed;
if (!emb) return;
_embDrawerPanel.innerHTML = `
<div class="user-drawer-header">
<div class="user-drawer-identity" style="padding-top:0.1rem">
<div class="user-drawer-name" style="font-size:var(--fs-hint)">${esc(emb.id)}</div>
<div class="user-drawer-email">${esc(emb.owner || '—')}</div>
</div>
<button class="user-drawer-close" id="embDrawerClose" title="Close">
<span class="icon icon-close" style="width:12px;height:12px;"></span>
</button>
</div>
<div class="user-drawer-body">
<div class="user-drawer-section">
<div class="user-drawer-section-title">Identity</div>
${drawerRow('Embed ID', emb.id, true)}
${drawerRow('Project', emb.project_name )}
${drawerRow('Project ID', emb.project_id, true)}
${drawerRow('Owner', emb.owner )}
</div>
<div class="user-drawer-section">
<div class="user-drawer-section-title">Stats</div>
${drawerRow('Invocations', String(emb.invoke_count ?? 0))}
${drawerRow('Created', fmtDatetime(emb.created_at) )}
</div>
<div class="user-drawer-section">
<div class="user-drawer-section-title">Actions</div>
<button class="btn btn-danger" id="embDrawerDelBtn"
data-emb-id="${esc(emb.id)}">Delete embed</button>
</div>
</div>`;
document.getElementById('embDrawerClose').addEventListener('click', closeEmbedDrawer);
document.getElementById('embDrawerDelBtn').addEventListener('click', () => {
openDeleteEmbedConfirm(emb.id, emb.id.slice(0, 8) + '…');
});
}
// ── Subscriptions panel ───────────────────────────────────────────────────────
let _subs = [];
let _subSortCol = 'sort_order';
let _subSortDir = 'asc';
let _subGroupBy = '';
const SUB_GROUP_OPTIONS = [
{ value: '', label: 'None' },
{ value: 'billing_period', label: 'Billing' },
];
const SUB_COLS = [
{ key: 'display_name', label: 'Display Name', minWidth: '110px' },
{ key: 'name', label: 'Key', minWidth: '120px' },
{ key: 'billing_period', label: 'Billing', minWidth: '80px' },
{ key: 'sort_order', label: 'Order', minWidth: '60px' },
{ key: 'price', label: 'Price / mo', minWidth: '90px' },
{ key: 'user_count', label: 'Users', minWidth: '65px' },
{ key: 'modified_at', label: 'Modified', minWidth: '110px' },
{ key: '_actions', label: '', minWidth: '80px', noSort: true },
];
/**
* Extracts a flat sortable value for virtual subscription columns.
* @param {string} col - Column key.
* @param {object} sub - Subscription tier object.
* @returns {*} Sortable value for the column.
*/
function _subVal(col, sub) {
if (col === 'display_name') return sub.features?.display_name ?? sub.name;
if (col === 'sort_order') return sub.features?.sort_order ?? 0;
if (col === 'price') return sub.features?.price_usd_month ?? 0;
return sub[col];
}
/**
* Renders a subscriptions table cell as an HTML string for the given column key.
* @param {string} col - Column key string.
* @param {object} sub - Subscription tier data object.
* @returns {string} Inner HTML for the cell.
*/
function fmtSubCell(col, sub) {
if (col === 'display_name') {
const dn = sub.features?.display_name;
return dn ? `<span>${esc(dn)}</span>` : '<span class="admin-cell-muted">—</span>';
}
if (col === 'sort_order') {
const v = sub.features?.sort_order;
return v != null ? `<span class="admin-cell-mono">${v}</span>` : '<span class="admin-cell-muted">—</span>';
}
if (col === 'price') {
const p = sub.features?.price_usd_month;
return p != null ? `<span class="admin-cell-mono">$${Number(p).toFixed(2)}</span>`
: '<span class="admin-cell-muted">—</span>';
}
if (col === 'user_count') {
return `<span class="admin-cell-mono">${sub.user_count ?? 0}</span>`;
}
if (col === 'billing_period') {
const v = sub[col];
return v ? `<span class="admin-badge admin-badge--active" style="text-transform:none;letter-spacing:0">${esc(v)}</span>`
: '<span class="admin-cell-muted">—</span>';
}
if (col === 'modified_at') {
return `<span title="${esc(sub.modified_at ?? '')}">${fmtDate(sub.modified_at)}</span>`;
}
if (col === '_actions') {
return `<button class="btn btn-danger admin-btn-sm" data-sub-action="delete"
data-sub-id="${esc(sub.id)}"
data-sub-label="${esc(sub.features?.display_name ?? sub.name)}">Delete</button>`;
}
const v = sub[col];
if (!v && v !== 0) return '<span class="admin-cell-muted">—</span>';
return `<span>${esc(v)}</span>`;
}
/**
* Returns a sorted copy of the _subs array based on the current sort column and direction.
* @returns {object[]} Sorted array of subscription tier objects.
*/
function sortedSubs() {
return [..._subs].sort((a, b) => {
const cmp = cmpValues(_subVal(_subSortCol, a), _subVal(_subSortCol, b));
return _subSortDir === 'asc' ? cmp : -cmp;
});
}
/** Rebuilds and injects the subscriptions table HTML, then wires up interaction handlers. */
function renderSubsTable() {
const wrap = document.getElementById('adminSubsTableWrap');
const count = document.getElementById('adminSubsCount');
count.textContent = `${_subs.length} tier${_subs.length !== 1 ? 's' : ''}`;
const sorted = sortedSubs();
const thead = SUB_COLS.map(c => {
if (c.noSort) return `<th style="min-width:${c.minWidth}"></th>`;
const active = c.key === _subSortCol;
const arrow = active ? (_subSortDir === 'asc' ? ' ↑' : ' ↓') : '';
return `<th class="${active ? 'sorted' : ''}" data-col="${c.key}" style="min-width:${c.minWidth}">${c.label}${arrow}</th>`;
}).join('');
let tbody = '';
if (_subGroupBy) {
const groups = new Map();
for (const sub of sorted) {
const label = _subVal(_subGroupBy, sub) || '—';
if (!groups.has(label)) groups.set(label, []);
groups.get(label).push(sub);
}
for (const [label, rows] of groups) {
tbody += `<tr class="admin-group-row"><td colspan="${SUB_COLS.length}">${esc(String(label))} · ${rows.length}</td></tr>`;
for (const sub of rows) {
tbody += `<tr class="admin-table-row" data-sub-id="${esc(sub.id)}">${SUB_COLS.map(c => `<td>${fmtSubCell(c.key, sub)}</td>`).join('')}</tr>`;
}
}
} else {
for (const sub of sorted) {
tbody += `<tr class="admin-table-row" data-sub-id="${esc(sub.id)}">${SUB_COLS.map(c => `<td>${fmtSubCell(c.key, sub)}</td>`).join('')}</tr>`;
}
}
if (!tbody) tbody = `<tr><td colspan="${SUB_COLS.length}" class="admin-table-empty">No subscription tiers found.</td></tr>`;
wrap.innerHTML = `
<table class="admin-table">
<thead><tr>${thead}</tr></thead>
<tbody>${tbody}</tbody>
</table>`;
wrap.querySelectorAll('thead th[data-col]').forEach(th => {
th.addEventListener('click', () => {
const col = th.dataset.col;
_subSortDir = (_subSortCol === col && _subSortDir === 'asc') ? 'desc' : 'asc';
_subSortCol = col;
renderSubsTable();
});
});
if (_drawerSub) _setSubRowSelected(_drawerSub?.id ?? null);
}
/** Fetches the subscription tier list from the API and re-renders the table. */
async function fetchSubs() {
const btn = document.getElementById('adminSubsRefreshBtn');
btn.disabled = true;
btn.textContent = 'Refreshing…';
try {
const headers = {};
if (authToken) headers['X-Auth-Token'] = authToken;
const resp = await fetch('/api/admin/subscriptions', { headers });
if (!resp.ok) throw new Error(resp.status);
_subs = await resp.json();
renderSubsTable();
} catch {
document.getElementById('adminSubsCount').textContent = 'Failed to load tiers.';
} finally {
btn.disabled = false;
btn.textContent = 'Refresh';
}
}
/** Builds and injects the subscriptions panel toolbar and table into the DOM, then fetches data. */
function initSubsPanel() {
const section = document.getElementById('tab-subscriptions');
section.innerHTML = '<h2 class="account-panel-title">Subscriptions</h2>';
const toolbar = document.createElement('div');
toolbar.className = 'admin-toolbar';
const countEl = document.createElement('span');
countEl.id = 'adminSubsCount';
countEl.className = 'admin-row-count';
countEl.textContent = 'Loading…';
const groupLabelEl = document.createElement('label');
groupLabelEl.className = 'admin-toolbar-label';
groupLabelEl.textContent = 'Group by';
const groupSelect = document.createElement('select');
groupSelect.className = 'admin-group-select';
SUB_GROUP_OPTIONS.forEach(opt => {
const o = document.createElement('option');
o.value = opt.value;
o.textContent = opt.label;
groupSelect.appendChild(o);
});
groupSelect.addEventListener('change', () => {
_subGroupBy = groupSelect.value;
renderSubsTable();
});
const newBtn = document.createElement('button');
newBtn.className = 'btn btn-primary';
newBtn.textContent = '+ New Tier';
newBtn.addEventListener('click', () => openSubDrawer(null));
const refreshBtn = document.createElement('button');
refreshBtn.id = 'adminSubsRefreshBtn';
refreshBtn.className = 'btn btn-ghost';
refreshBtn.textContent = 'Refresh';
refreshBtn.addEventListener('click', fetchSubs);
toolbar.appendChild(countEl);
toolbar.appendChild(groupLabelEl);
toolbar.appendChild(groupSelect);
toolbar.appendChild(newBtn);
toolbar.appendChild(refreshBtn);
const tableWrap = document.createElement('div');
tableWrap.id = 'adminSubsTableWrap';
tableWrap.className = 'admin-table-wrap';
tableWrap.addEventListener('click', (e) => {
const btn = e.target.closest('button[data-sub-action]');
if (btn) {
if (btn.disabled) return;
if (btn.dataset.subAction === 'delete') {
openDeleteSubConfirm(btn.dataset.subId, btn.dataset.subLabel);
}
return;
}
const row = e.target.closest('tr.admin-table-row[data-sub-id]');
if (row) openSubDrawer(row.dataset.subId);
});
section.appendChild(toolbar);
section.appendChild(tableWrap);
fetchSubs();
}
/**
* Opens the delete confirmation dialog for a subscription tier.
* @param {string} tierId - ID of the tier to delete.
* @param {string} label - Display name of the tier shown in the confirmation message.
*/
function openDeleteSubConfirm(tierId, label) {
const sub = _subs.find(s => s.id === tierId);
const userWarn = sub?.user_count
? ` ${sub.user_count} user(s) are currently on this tier and will lose their subscription.`
: '';
_confirmMsg.textContent = `Permanently delete the "${label}" tier?${userWarn} This cannot be undone.`;
_confirmOverlay.classList.add('open');
_confirmOkBtn.onclick = () => {
closeConfirm();
doDeleteSub(tierId);
};
}
/**
* Sends a DELETE request for the given subscription tier and removes it from the local list.
* @param {string} tierId - ID of the tier to delete.
*/
async function doDeleteSub(tierId) {
try {
const headers = { 'Content-Type': 'application/json' };
if (authToken) headers['X-Auth-Token'] = authToken;
const resp = await fetch(`/api/admin/subscriptions/${encodeURIComponent(tierId)}`, {
method: 'DELETE', headers,
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
alert(err.error || 'Delete failed.');
return;
}
_subs = _subs.filter(s => s.id !== tierId);
if (_drawerSub?.id === tierId) closeSubDrawer();
renderSubsTable();
} catch {
// Silently ignore
}
}
// ── Subscription edit drawer ──────────────────────────────────────────────────
let _drawerSub = null; // null = new, object = editing existing
let _subIsNew = false;
const _subDrawerOverlay = document.getElementById('subDrawerOverlay');
const _subDrawerPanel = document.getElementById('subDrawerPanel');
/**
* Applies or clears the selected-row highlight in the subscriptions table.
* @param {string|null} subId - Subscription tier ID to highlight, or null to clear.
*/
function _setSubRowSelected(subId) {
document.querySelectorAll('#adminSubsTableWrap tr.admin-table-row').forEach(r => {
r.classList.toggle('admin-table-row--selected', !!subId && r.dataset.subId === subId);
});
}
/**
* Opens the subscription edit drawer for an existing tier or for creating a new one.
* @param {string|null} subId - ID of the subscription tier to edit, or null to create a new tier.
*/
function openSubDrawer(subId) {
if (_drawerUser) closeDrawer();
if (_drawerProject) closeProjectDrawer();
if (_drawerFolder) closeFolderDrawer();
if (_drawerEmbed) closeEmbedDrawer();
_subIsNew = subId === null;
_drawerSub = _subIsNew ? null : (_subs.find(s => s.id === subId) ?? null);
renderSubDrawerContent();
_subDrawerOverlay.classList.add('open');
_setSubRowSelected(_subIsNew ? null : subId);
}
/** Closes the subscription edit drawer and clears the selection highlight. */
function closeSubDrawer() {
_subDrawerOverlay.classList.remove('open');
_drawerSub = null;
_setSubRowSelected(null);
}
/** Re-renders the subscription edit drawer panel from _drawerSub. */
function renderSubDrawerContent() {
const sub = _drawerSub;
const title = _subIsNew ? 'New Tier' : (sub?.features?.display_name ?? sub?.name ?? 'Edit Tier');
const name = sub?.name ?? '';
const desc = sub?.description ?? '';
const billing = sub?.billing_period ?? 'monthly';
const featJson = JSON.stringify(sub?.features ?? {}, null, 2);
const userCount = sub?.user_count ?? 0;
_subDrawerPanel.innerHTML = `
<div class="user-drawer-header">
<div class="user-drawer-identity" style="padding-top:0.1rem">
<div class="user-drawer-name">${esc(title)}</div>
${!_subIsNew ? `<div class="user-drawer-email">${userCount} user${userCount !== 1 ? 's' : ''}</div>` : ''}
</div>
<button class="user-drawer-close" id="subDrawerClose" title="Close">
<span class="icon icon-close" style="width:12px;height:12px;"></span>
</button>
</div>
<div class="user-drawer-body">
<div class="user-drawer-section">
<div class="user-drawer-section-title">Details</div>
<div class="sub-form-group">
<label class="sub-form-label" for="subFormName">Name (key)</label>
<input class="sub-form-input" id="subFormName" type="text"
value="${esc(name)}" placeholder="e.g. pro_monthly">
</div>
<div class="sub-form-group">
<label class="sub-form-label" for="subFormDesc">Description</label>
<input class="sub-form-input" id="subFormDesc" type="text"
value="${esc(desc)}" placeholder="Short description">
</div>
<div class="sub-form-group">
<label class="sub-form-label" for="subFormBilling">Billing Period</label>
<select class="sub-form-input" id="subFormBilling">
<option value="monthly" ${billing === 'monthly' ? 'selected' : ''}>Monthly</option>
<option value="yearly" ${billing === 'yearly' ? 'selected' : ''}>Yearly</option>
</select>
</div>
</div>
<div class="user-drawer-section">
<div class="user-drawer-section-title">Features (JSON)</div>
<textarea class="sub-features-textarea" id="subFormFeatures">${esc(featJson)}</textarea>
<p class="sub-form-error" id="subFormError"></p>
</div>
<div class="user-drawer-section">
<div class="user-drawer-section-title">Actions</div>
<div style="display:flex;gap:0.5rem;flex-wrap:wrap;align-items:center">
<button class="btn btn-primary" id="subFormSave">
${_subIsNew ? 'Create' : 'Save changes'}
</button>
<button class="btn btn-ghost" id="subFormCancel">Cancel</button>
${!_subIsNew ? `<button class="btn btn-danger" id="subFormDelete"
data-sub-id="${esc(sub.id)}"
data-sub-label="${esc(sub.features?.display_name ?? sub.name)}">
Delete tier
</button>` : ''}
</div>
<p class="sub-form-error" id="subSaveError"></p>
</div>
</div>`;
document.getElementById('subDrawerClose').addEventListener('click', closeSubDrawer);
document.getElementById('subFormCancel').addEventListener('click', closeSubDrawer);
document.getElementById('subFormSave').addEventListener('click', () => doSaveSub());
const delBtn = document.getElementById('subFormDelete');
if (delBtn) {
delBtn.addEventListener('click', () => {
openDeleteSubConfirm(delBtn.dataset.subId, delBtn.dataset.subLabel);
});
}
}
/** Validates the subscription form and POSTs or PATCHes the tier via the API. */
async function doSaveSub() {
const nameVal = document.getElementById('subFormName').value.trim();
const descVal = document.getElementById('subFormDesc').value.trim();
const billingVal = document.getElementById('subFormBilling').value;
const featRaw = document.getElementById('subFormFeatures').value;
const featEl = document.getElementById('subFormFeatures');
const errorEl = document.getElementById('subFormError');
const saveErrEl = document.getElementById('subSaveError');
// Validate JSON
let featObj;
try {
featObj = JSON.parse(featRaw);
featEl.classList.remove('error');
errorEl.textContent = '';
} catch (e) {
featEl.classList.add('error');
errorEl.textContent = `Invalid JSON: ${e.message}`;
return;
}
if (!nameVal) {
saveErrEl.textContent = 'Name is required.';
return;
}
const saveBtn = document.getElementById('subFormSave');
saveBtn.disabled = true;
saveBtn.textContent = 'Saving…';
saveErrEl.textContent = '';
try {
const headers = { 'Content-Type': 'application/json' };
if (authToken) headers['X-Auth-Token'] = authToken;
const body = JSON.stringify({
name: nameVal, description: descVal,
billing_period: billingVal, features: featObj,
});
let resp;
if (_subIsNew) {
resp = await fetch('/api/admin/subscriptions', { method: 'POST', headers, body });
} else {
resp = await fetch(`/api/admin/subscriptions/${encodeURIComponent(_drawerSub.id)}`,
{ method: 'PATCH', headers, body });
}
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
saveErrEl.textContent = err.error || 'Save failed.';
return;
}
const saved = await resp.json();
saved.user_count = _subIsNew ? 0 : (_drawerSub?.user_count ?? 0);
if (_subIsNew) {
_subs.push(saved);
} else {
const idx = _subs.findIndex(s => s.id === saved.id);
if (idx !== -1) _subs[idx] = saved;
}
_drawerSub = saved;
_subIsNew = false;
renderSubsTable();
renderSubDrawerContent(); // refresh header (new name/user count)
_setSubRowSelected(saved.id);
} catch {
saveErrEl.textContent = 'Unexpected error. Please try again.';
} finally {
const btn = document.getElementById('subFormSave');
if (btn) { btn.disabled = false; btn.textContent = _subIsNew ? 'Create' : 'Save changes'; }
}
}
// ── Click-outside to close drawers ────────────────────────────────────────────
document.addEventListener('click', (e) => {
// Clicks inside an open drawer or on a table row are handled elsewhere
if (e.target.closest('tr.admin-table-row')) return;
if (_drawerUser && !_drawerPanel.contains(e.target)) closeDrawer();
if (_drawerProject && !_projDrawerPanel.contains(e.target)) closeProjectDrawer();
if (_drawerFolder && !_fldDrawerPanel.contains(e.target)) closeFolderDrawer();
if (_drawerEmbed && !_embDrawerPanel.contains(e.target)) closeEmbedDrawer();
if (_drawerSub && !_subDrawerPanel.contains(e.target)) closeSubDrawer();
});
// ── Delete user confirm dialog ────────────────────────────────────────────────
const _confirmOverlay = document.getElementById('adminConfirmOverlay');
const _confirmMsg = document.getElementById('adminConfirmMsg');
const _confirmOkBtn = document.getElementById('adminConfirmOk');
const _confirmCancelBtn = document.getElementById('adminConfirmCancel');
let _confirmCallback = null;
_confirmCancelBtn.addEventListener('click', closeConfirm);
_confirmOverlay.addEventListener('click', (e) => {
if (e.target === _confirmOverlay) closeConfirm();
});
/**
* Opens the delete confirmation dialog for a user account.
* @param {string} userId - ID of the user to delete.
* @param {string} displayLabel - Display name or email shown in the confirmation message.
*/
function openDeleteConfirm(userId, displayLabel) {
_confirmMsg.textContent = `Permanently delete "${displayLabel}"? This cannot be undone. Their projects and folders will remain but become ownerless.`;
_confirmCallback = () => doDeleteUser(userId);
_confirmOverlay.classList.add('open');
_confirmOkBtn.onclick = () => {
closeConfirm();
if (_confirmCallback) _confirmCallback();
_confirmCallback = null;
};
}
/** Closes the confirmation dialog and clears any pending callback. */
function closeConfirm() {
_confirmOverlay.classList.remove('open');
_confirmCallback = null;
}
/**
* Deletes a user account, removes them from _users, closes the drawer, and re-renders the table.
* @param {string} userId - ID of the user to delete.
*/
async function doDeleteUser(userId) {
try {
const headers = { 'Content-Type': 'application/json' };
if (authToken) headers['X-Auth-Token'] = authToken;
const resp = await fetch(`/api/admin/users/${encodeURIComponent(userId)}`, {
method: 'DELETE', headers,
});
if (!resp.ok) throw new Error(resp.status);
_users = _users.filter(u => u.id !== userId);
if (_drawerUser?.id === userId) closeDrawer();
renderTable();
} catch {
// Silently ignore — user can retry
}
}
// ── Analytics panel ───────────────────────────────────────────────────────────
const ANALYTICS_METRICS = [
{ key: 'total_users', label: 'Total Users', color: '#60a5fa' },
{ key: 'active_users_30d', label: 'Active Users (30d)', color: '#34d399' },
{ key: 'total_subscribers', label: 'Paid Subscribers', color: '#2dd4bf' },
{ key: 'total_projects', label: 'Total Projects', color: '#a78bfa' },
{ key: 'transcribed_projects', label: 'Transcribed Projects', color: '#fb923c' },
{ key: 'total_audio_seconds', label: 'Audio Duration (hrs)', color: '#f472b6' },
{ key: 'total_embeds', label: 'Total Embeds', color: '#facc15' },
{ key: 'total_embed_views', label: 'Embed Views', color: '#38bdf8' },
{ key: 'storage_bytes', label: 'Storage', color: '#4ade80' },
{ key: 'mrr', label: 'MRR ($)', color: '#f87171' },
];
let _analyticsSnapshots = [];
let _analyticsChart = null;
let _analyticsVisibleKeys = new Set(ANALYTICS_METRICS.map(m => m.key));
let _analyticsRange = 'all'; // '7d' | '30d' | '90d' | '1y' | 'all' | 'custom'
let _analyticsUnit = 'auto'; // 'auto' | 'day' | 'week' | 'month'
let _analyticsStartDate = ''; // ISO date string (custom range)
let _analyticsEndDate = ''; // ISO date string (custom range)
const ANALYTICS_RANGES = [
{ value: '7d', label: 'Last 7 days' },
{ value: '30d', label: 'Last 30 days' },
{ value: '90d', label: 'Last 90 days' },
{ value: '1y', label: 'Last year' },
{ value: 'all', label: 'All time' },
{ value: 'custom',label: 'Custom…' },
];
const ANALYTICS_UNITS = [
{ value: 'auto', label: 'Auto' },
{ value: 'day', label: 'Day' },
{ value: 'week', label: 'Week' },
{ value: 'month', label: 'Month' },
];
/**
* Returns the {min, max} Date objects for the current analytics range selection.
* @returns {{ min: Date|null, max: Date|null }} Axis bounds; null means unbounded.
*/
function _getAnalyticsAxisRange() {
const now = new Date();
switch (_analyticsRange) {
case '7d': return { min: new Date(now - 7 * 86400000), max: now };
case '30d': return { min: new Date(now - 30 * 86400000), max: now };
case '90d': return { min: new Date(now - 90 * 86400000), max: now };
case '1y': return { min: new Date(now - 365 * 86400000), max: now };
case 'all': return { min: null, max: null };
case 'custom': return {
min: _analyticsStartDate ? new Date(_analyticsStartDate) : null,
max: _analyticsEndDate ? new Date(_analyticsEndDate + 'T23:59:59') : null,
};
}
return { min: null, max: null };
}
/**
* Returns analytics snapshots filtered to the current date range.
* @returns {object[]} Filtered array of snapshot objects.
*/
function _getFilteredSnapshots() {
const { min, max } = _getAnalyticsAxisRange();
return _analyticsSnapshots.filter(s => {
const d = new Date(s.recorded_at);
if (min && d < min) return false;
if (max && d > max) return false;
return true;
});
}
/**
* Formats an analytics metric value for display on a summary card.
* @param {string} key - Metric key (e.g. 'total_audio_seconds', 'storage_bytes').
* @param {number|null} value - Raw metric value.
* @returns {string} Human-readable formatted value.
*/
function _fmtAnalyticsCard(key, value) {
if (value == null) return '—';
switch (key) {
case 'total_audio_seconds': {
const h = value / 3600;
return h >= 1 ? h.toFixed(1) + ' hr' : (value / 60).toFixed(1) + ' min';
}
case 'storage_bytes': return _fmtBytes(value);
case 'mrr':
case 'prev_month_mrr':
case 'est_next_month_mrr': return '$' + Number(value).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
default: return Number(value).toLocaleString();
}
}
/**
* Formats a byte count as a human-readable string (B, KB, MB, GB).
* @param {number} b - Byte count.
* @returns {string} Formatted size string.
*/
function _fmtBytes(b) {
if (b >= 1e9) return (b / 1e9).toFixed(2) + ' GB';
if (b >= 1e6) return (b / 1e6).toFixed(1) + ' MB';
if (b >= 1e3) return (b / 1e3).toFixed(0) + ' KB';
return b + ' B';
}
/**
* Converts a hex colour string and alpha to an rgba() CSS string.
* @param {string} hex - Six-digit hex colour (e.g. '#60a5fa').
* @param {number} alpha - Alpha value between 0 and 1.
* @returns {string} CSS rgba() colour string.
*/
function _hexToRgba(hex, alpha) {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return `rgba(${r},${g},${b},${alpha})`;
}
/** Synchronises the metric dropdown value to reflect the current _analyticsVisibleKeys state. */
function _syncMetricDropdown() {
const sel = document.getElementById('adminAnalyticsMetricSelect');
if (!sel) return;
const allVisible = ANALYTICS_METRICS.every(m => _analyticsVisibleKeys.has(m.key));
const isSingle = _analyticsVisibleKeys.size === 1;
let customOpt = sel.querySelector('option[value="custom"]');
if (!allVisible && !isSingle) {
if (!customOpt) {
customOpt = document.createElement('option');
customOpt.value = 'custom';
customOpt.textContent = 'Custom';
sel.appendChild(customOpt);
}
sel.value = 'custom';
} else {
customOpt?.remove();
sel.value = allVisible ? 'all' : [..._analyticsVisibleKeys][0];
}
}
/**
* Toggles visibility of a metric on the analytics chart.
* @param {string} key - Metric key to toggle.
*/
function _toggleMetric(key) {
if (_analyticsVisibleKeys.has(key)) {
if (_analyticsVisibleKeys.size <= 1) return; // keep at least one visible
_analyticsVisibleKeys.delete(key);
} else {
_analyticsVisibleKeys.add(key);
}
_syncMetricDropdown();
const latest = _analyticsSnapshots[_analyticsSnapshots.length - 1];
if (latest) renderAnalyticsCards(latest.data);
renderAnalyticsChart();
}
/**
* Highlights a single metric dataset on the chart, dimming all others.
* @param {string} key - Metric key to highlight.
*/
function _highlightChartMetric(key) {
if (!_analyticsChart || _analyticsVisibleKeys.size <= 1) return;
const visibleMetrics = ANALYTICS_METRICS.filter(m => _analyticsVisibleKeys.has(m.key));
_analyticsChart.data.datasets.forEach((ds, i) => {
const m = visibleMetrics[i];
if (!m) return;
const active = m.key === key;
ds.borderColor = active ? m.color : _hexToRgba(m.color, 0.15);
ds.backgroundColor = active ? m.color + '33' : 'transparent';
ds.borderWidth = active ? 3 : 1;
ds.order = active ? 0 : 1;
});
_analyticsChart.update('none');
}
/** Resets all chart datasets to their default colours after a hover highlight. */
function _resetChartHighlight() {
if (!_analyticsChart) return;
const visibleMetrics = ANALYTICS_METRICS.filter(m => _analyticsVisibleKeys.has(m.key));
_analyticsChart.data.datasets.forEach((ds, i) => {
const m = visibleMetrics[i];
if (!m) return;
ds.borderColor = m.color;
ds.backgroundColor = visibleMetrics.length === 1 ? m.color + '33' : m.color + '22';
ds.borderWidth = 2;
ds.order = 0;
});
_analyticsChart.update('none');
}
/**
* Converts a raw metric value to the unit used on the chart axis.
* @param {string} key - Metric key.
* @param {number} value - Raw metric value.
* @returns {number} Converted value (e.g. seconds → hours, bytes → GB).
*/
function _toChartValue(key, value) {
switch (key) {
case 'total_audio_seconds': return value / 3600;
case 'storage_bytes': return value / 1e9;
default: return value;
}
}
/** Builds and injects the analytics panel UI (toolbar, cards, chart, subscribers table) into the DOM. */
function initAnalyticsPanel() {
const section = document.getElementById('tab-analytics');
const toolbar = document.createElement('div');
toolbar.className = 'admin-toolbar';
const statusEl = document.createElement('span');
statusEl.id = 'adminAnalyticsStatus';
statusEl.className = 'admin-row-count';
statusEl.textContent = 'Loading…';
const refreshBtn = document.createElement('button');
refreshBtn.id = 'adminAnalyticsRefreshBtn';
refreshBtn.className = 'btn btn-ghost';
refreshBtn.textContent = 'Refresh';
refreshBtn.addEventListener('click', fetchAnalytics);
const collectBtn = document.createElement('button');
collectBtn.id = 'adminAnalyticsCollectBtn';
collectBtn.className = 'btn btn-ghost';
collectBtn.textContent = 'Collect Now';
collectBtn.addEventListener('click', collectAnalyticsNow);
toolbar.appendChild(statusEl);
toolbar.appendChild(refreshBtn);
toolbar.appendChild(collectBtn);
const cardsGrid = document.createElement('div');
cardsGrid.id = 'adminAnalyticsCards';
cardsGrid.className = 'analytics-cards';
const chartSection = document.createElement('div');
chartSection.className = 'analytics-chart-section';
// ── Row 1: metric / range / unit selects ──────────────────────────────────
const chartToolbar = document.createElement('div');
chartToolbar.className = 'admin-toolbar';
/**
* Creates a labelled select element and appends it to the chart toolbar.
* @param {string} id - Element ID for the select.
* @param {string} labelText - Label text displayed before the select.
* @param {Array<{value:string,label:string}>} options - Option list.
* @param {string} current - Currently selected value.
* @param {function} onChange - Callback invoked with the new value on change.
*/
function makeSelect(id, labelText, options, current, onChange) {
const lbl = document.createElement('label');
lbl.className = 'admin-toolbar-label';
lbl.textContent = labelText;
const sel = document.createElement('select');
sel.id = id;
sel.className = 'admin-group-select';
options.forEach(o => {
const opt = document.createElement('option');
opt.value = o.value;
opt.textContent = o.label;
opt.selected = o.value === current;
sel.appendChild(opt);
});
sel.addEventListener('change', () => onChange(sel.value));
chartToolbar.appendChild(lbl);
chartToolbar.appendChild(sel);
}
makeSelect('adminAnalyticsMetricSelect', 'Metric',
[{ value: 'all', label: 'All Metrics' }, ...ANALYTICS_METRICS.map(m => ({ value: m.key, label: m.label }))],
'all',
v => {
if (v === 'all') {
_analyticsVisibleKeys = new Set(ANALYTICS_METRICS.map(m => m.key));
} else if (v !== 'custom') {
_analyticsVisibleKeys = new Set([v]);
}
_refreshAnalyticsView();
}
);
makeSelect('adminAnalyticsRangeSelect', 'Range', ANALYTICS_RANGES, _analyticsRange, v => {
_analyticsRange = v;
customDateRow.style.display = v === 'custom' ? 'flex' : 'none';
renderAnalyticsChart();
});
makeSelect('adminAnalyticsUnitSelect', 'Unit', ANALYTICS_UNITS, _analyticsUnit, v => {
_analyticsUnit = v;
renderAnalyticsChart();
});
// ── Row 2: custom date range (hidden unless range === 'custom') ────────────
const customDateRow = document.createElement('div');
customDateRow.className = 'analytics-date-row';
customDateRow.style.display = _analyticsRange === 'custom' ? 'flex' : 'none';
/**
* Creates a labelled date input and appends it to the custom date row.
* @param {string} id - Element ID for the input.
* @param {string} labelText - Label text displayed before the input.
* @param {string} stateKey - Either 'start' or 'end' to determine which state variable to update.
*/
function makeDateInput(id, labelText, stateKey) {
const lbl = document.createElement('label');
lbl.className = 'admin-toolbar-label';
lbl.textContent = labelText;
lbl.htmlFor = id;
const inp = document.createElement('input');
inp.type = 'date';
inp.id = id;
inp.className = 'analytics-date-input';
inp.value = stateKey === 'start' ? _analyticsStartDate : _analyticsEndDate;
inp.addEventListener('change', () => {
if (stateKey === 'start') _analyticsStartDate = inp.value;
else _analyticsEndDate = inp.value;
renderAnalyticsChart();
});
customDateRow.appendChild(lbl);
customDateRow.appendChild(inp);
}
makeDateInput('adminAnalyticsFrom', 'From', 'start');
makeDateInput('adminAnalyticsTo', 'To', 'end');
const canvasWrap = document.createElement('div');
canvasWrap.className = 'analytics-chart-wrap';
const canvas = document.createElement('canvas');
canvas.id = 'adminAnalyticsChart';
canvasWrap.appendChild(canvas);
chartSection.appendChild(chartToolbar);
chartSection.appendChild(customDateRow);
chartSection.appendChild(canvasWrap);
const subscribersSection = document.createElement('div');
subscribersSection.id = 'adminAnalyticsSubscribers';
subscribersSection.className = 'analytics-subscribers-section';
const revenueHero = document.createElement('div');
revenueHero.id = 'adminAnalyticsRevenueHero';
revenueHero.className = 'analytics-revenue-hero';
section.appendChild(toolbar);
section.appendChild(revenueHero);
section.appendChild(cardsGrid);
section.appendChild(chartSection);
section.appendChild(subscribersSection);
fetchAnalytics();
}
/** Fetches all analytics snapshots from the API and triggers a full re-render. */
async function fetchAnalytics() {
const statusEl = document.getElementById('adminAnalyticsStatus');
const refreshBtn = document.getElementById('adminAnalyticsRefreshBtn');
if (refreshBtn) { refreshBtn.disabled = true; refreshBtn.textContent = 'Refreshing…'; }
try {
const headers = {};
if (authToken) headers['X-Auth-Token'] = authToken;
const resp = await fetch('/api/admin/analytics', { headers });
if (!resp.ok) throw new Error(resp.status);
_analyticsSnapshots = await resp.json();
renderAnalytics();
} catch {
if (statusEl) statusEl.textContent = 'Failed to load analytics.';
} finally {
if (refreshBtn) { refreshBtn.disabled = false; refreshBtn.textContent = 'Refresh'; }
}
}
/** Triggers an immediate analytics collection via the API then refreshes the display. */
async function collectAnalyticsNow() {
const statusEl = document.getElementById('adminAnalyticsStatus');
const collectBtn = document.getElementById('adminAnalyticsCollectBtn');
if (collectBtn) { collectBtn.disabled = true; collectBtn.textContent = 'Collecting…'; }
try {
const headers = { 'Content-Type': 'application/json' };
if (authToken) headers['X-Auth-Token'] = authToken;
const resp = await fetch('/api/admin/analytics/collect', { method: 'POST', headers });
if (!resp.ok) throw new Error(resp.status);
await fetchAnalytics();
} catch {
if (statusEl) statusEl.textContent = 'Collection failed.';
} finally {
if (collectBtn) { collectBtn.disabled = false; collectBtn.textContent = 'Collect Now'; }
}
}
/** Updates the analytics status bar and triggers the full analytics view refresh. */
function renderAnalytics() {
const statusEl = document.getElementById('adminAnalyticsStatus');
if (!_analyticsSnapshots.length) {
if (statusEl) statusEl.textContent = 'No data yet — run collect-analytics to generate the first snapshot.';
const cards = document.getElementById('adminAnalyticsCards');
if (cards) cards.innerHTML = '';
return;
}
const latest = _analyticsSnapshots[_analyticsSnapshots.length - 1];
const d = new Date(latest.recorded_at);
const when = d.toLocaleDateString([], { month: 'short', day: 'numeric', year: 'numeric' })
+ ' · ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
const count = _analyticsSnapshots.length;
if (statusEl) statusEl.textContent =
`Last snapshot: ${when} — ${count} snapshot${count !== 1 ? 's' : ''} total`;
_refreshAnalyticsView();
}
/** Refreshes all analytics view components (revenue hero, cards, subscribers table, chart). */
function _refreshAnalyticsView() {
if (!_analyticsSnapshots.length) return;
const latest = _analyticsSnapshots[_analyticsSnapshots.length - 1];
renderRevenueHero(latest.data);
renderAnalyticsCards(latest.data);
renderSubscribersTable(latest.data);
renderAnalyticsChart();
}
/**
* Renders the MRR revenue hero cards from the latest analytics snapshot data.
* @param {object} data - Analytics snapshot data object containing mrr fields.
*/
function renderRevenueHero(data) {
const hero = document.getElementById('adminAnalyticsRevenueHero');
if (!hero) return;
const fmtUsd = v => v != null
? '$' + Number(v).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
: '—';
const items = [
{ label: 'Current MRR', value: fmtUsd(data?.mrr), color: '#f87171' },
{ label: 'Previous Month MRR', value: fmtUsd(data?.prev_month_mrr), color: '#fbbf24' },
{ label: 'Est. Next Month MRR', value: fmtUsd(data?.est_next_month_mrr), color: '#c084fc' },
];
hero.innerHTML = items.map((item, i) => `
<div class="analytics-revenue-card${i === 0 ? ' analytics-revenue-card--primary' : ''}" style="border-color:${item.color}">
<div class="analytics-revenue-label">${item.label}</div>
<div class="analytics-revenue-value" style="color:${item.color}">${item.value}</div>
</div>
`).join('');
}
/**
* Renders the subscribers-by-tier breakdown table from the latest analytics snapshot data.
* @param {object} data - Analytics snapshot data object containing subscribers_by_tier.
*/
function renderSubscribersTable(data) {
const section = document.getElementById('adminAnalyticsSubscribers');
if (!section) return;
const tiers = data?.subscribers_by_tier ?? [];
if (!tiers.length) { section.innerHTML = ''; return; }
const totalMrr = tiers.reduce((s, t) => s + (t.mrr ?? 0), 0);
const totalSubs = tiers.reduce((s, t) => s + (t.count ?? 0), 0);
const fmtUsd = v => '$' + Number(v).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
const rows = tiers.map(t => {
const billingLabel = t.billing_period === 'monthly' ? 'Monthly'
: t.billing_period === 'yearly' ? 'Yearly (÷12 MRR)'
: '—';
const priceLabel = t.monthly_price > 0 ? fmtUsd(t.monthly_price) + '/mo' : '—';
return `<tr>
<td>${esc(t.tier)}</td>
<td class="admin-cell-muted">${billingLabel}</td>
<td>${t.count.toLocaleString()}</td>
<td class="admin-cell-muted">${priceLabel}</td>
<td>${t.mrr > 0 ? fmtUsd(t.mrr) : '—'}</td>
</tr>`;
}).join('');
section.innerHTML = `
<div class="analytics-section-title">Subscribers by Tier</div>
<div class="admin-table-wrap analytics-subscribers-table-wrap">
<table class="admin-table">
<thead><tr>
<th>Tier</th><th>Billing</th><th>Subscribers</th>
<th>Monthly Price (MRR basis)</th><th>MRR</th>
</tr></thead>
<tbody>
${rows}
<tr class="analytics-subscribers-total">
<td colspan="2" class="admin-cell-muted">Total</td>
<td>${totalSubs.toLocaleString()}</td>
<td></td>
<td>${fmtUsd(totalMrr)}</td>
</tr>
</tbody>
</table>
</div>`;
}
/**
* Renders the analytics metric summary cards from the latest snapshot data.
* @param {object} data - Analytics snapshot data object.
*/
function renderAnalyticsCards(data) {
const grid = document.getElementById('adminAnalyticsCards');
if (!grid) return;
grid.innerHTML = '';
ANALYTICS_METRICS.forEach(m => {
const visible = _analyticsVisibleKeys.has(m.key);
const card = document.createElement('div');
card.className = 'analytics-card' + (visible ? '' : ' analytics-card--disabled');
card.style.borderColor = visible ? m.color : '';
card.title = visible ? 'Click to hide' : 'Click to show';
const labelEl = document.createElement('div');
labelEl.className = 'analytics-card-label';
labelEl.textContent = m.label;
const valueEl = document.createElement('div');
valueEl.className = 'analytics-card-value';
valueEl.textContent = _fmtAnalyticsCard(m.key, data?.[m.key]);
card.appendChild(labelEl);
card.appendChild(valueEl);
card.addEventListener('click', () => _toggleMetric(m.key));
card.addEventListener('mouseenter', () => _highlightChartMetric(m.key));
card.addEventListener('mouseleave', () => _resetChartHighlight());
grid.appendChild(card);
});
}
/** Destroys and rebuilds the analytics Chart.js line chart from the current snapshots and filters. */
function renderAnalyticsChart() {
const canvas = document.getElementById('adminAnalyticsChart');
if (!canvas || !_analyticsSnapshots.length) return;
const filtered = _getFilteredSnapshots();
const style = getComputedStyle(document.documentElement);
const gridColor = style.getPropertyValue('--border').trim() || 'rgba(255,255,255,0.1)';
const textColor = style.getPropertyValue('--muted').trim() || '#888';
const surface = style.getPropertyValue('--surface').trim() || '#1a1a2e';
const tickFont = { family: 'IBM Plex Mono', size: 11 };
if (_analyticsChart) { _analyticsChart.destroy(); _analyticsChart = null; }
const { min: axisMin, max: axisMax } = _getAnalyticsAxisRange();
const xAxis = {
type: 'time',
time: {
...((_analyticsUnit !== 'auto') ? { unit: _analyticsUnit } : {}),
tooltipFormat: 'MMM d, yyyy',
displayFormats: { day: 'MMM d', week: 'MMM d', month: 'MMM yyyy', year: 'yyyy' },
},
...(axisMin ? { min: axisMin.toISOString() } : {}),
...(axisMax ? { max: axisMax.toISOString() } : {}),
ticks: { color: textColor, font: tickFont },
grid: { color: gridColor },
};
const visibleMetrics = ANALYTICS_METRICS.filter(m => _analyticsVisibleKeys.has(m.key));
const isMulti = visibleMetrics.length > 1;
let datasets, yAxis, legendCfg, tooltipCallbacks;
if (isMulti) {
const rawByMetric = visibleMetrics.map(m =>
filtered.map(s => _toChartValue(m.key, s.data?.[m.key] ?? 0))
);
datasets = visibleMetrics.map((m, i) => {
const raw = rawByMetric[i];
const lo = Math.min(...raw);
const hi = Math.max(...raw);
const range = hi - lo || 1;
return {
label: m.label,
data: filtered.map((s, j) => ({ x: new Date(s.recorded_at), y: (raw[j] - lo) / range })),
borderColor: m.color,
backgroundColor: m.color + '22',
borderWidth: 2,
pointRadius: 3,
pointHoverRadius: 5,
tension: 0.3,
fill: false,
};
});
yAxis = {
min: -0.05, max: 1.05,
afterBuildTicks: scale => {
scale.ticks = [0, 0.2, 0.4, 0.6, 0.8, 1.0].map(v => ({ value: v }));
},
ticks: { color: textColor, font: tickFont, callback: v => v.toFixed(1) },
grid: { color: gridColor },
};
legendCfg = { display: true, labels: { color: textColor, font: tickFont, boxWidth: 12, padding: 10 } };
tooltipCallbacks = {
label: ctx => {
const m = visibleMetrics[ctx.datasetIndex];
const snap = filtered[ctx.dataIndex];
return ` ${m.label}: ${_fmtAnalyticsCard(m.key, snap?.data?.[m.key] ?? null)}`;
},
};
} else {
const m = visibleMetrics[0];
datasets = [{
label: m.label,
data: filtered.map(s => ({ x: new Date(s.recorded_at), y: _toChartValue(m.key, s.data?.[m.key] ?? 0) })),
borderColor: m.color,
backgroundColor: m.color + '33',
borderWidth: 2,
pointRadius: 3,
pointHoverRadius: 5,
tension: 0.3,
fill: true,
}];
yAxis = { beginAtZero: true, grace: '5%', ticks: { color: textColor, font: tickFont }, grid: { color: gridColor } };
legendCfg = { display: false };
tooltipCallbacks = {};
}
_analyticsChart = new Chart(canvas, {
type: 'line',
data: { datasets },
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: legendCfg,
tooltip: {
backgroundColor: surface,
borderColor: gridColor,
borderWidth: 1,
titleColor: textColor,
bodyColor: textColor,
callbacks: tooltipCallbacks,
},
},
scales: { x: xAxis, y: yAxis },
},
});
}
// ── Dispatch ──────────────────────────────────────────────────────────────────
async function initDispatchPanel() {
const container = document.getElementById('dispatchPanelContent');
container.innerHTML = `
<div class="dispatch-form">
<div class="sub-form-group">
<label class="sub-form-label" for="dispatchAudience">Audience</label>
<div class="dispatch-audience-group">
<label class="dispatch-radio-label">
<input type="radio" name="dispatchAudience" value="all" checked>
All accounts
</label>
<label class="dispatch-radio-label">
<input type="radio" name="dispatchAudience" value="updates_only">
Opted-in to updates only
</label>
<label class="dispatch-radio-label">
<input type="radio" name="dispatchAudience" value="admins">
Test (Admins)
</label>
</div>
<div class="dispatch-count" id="dispatchCount">—</div>
</div>
<div class="sub-form-group">
<label class="sub-form-label" for="dispatchSubject">Subject</label>
<input class="sub-form-input" type="text" id="dispatchSubject" maxlength="200" placeholder="Email subject line">
</div>
<div class="sub-form-group">
<label class="sub-form-label">Format</label>
<div class="dispatch-audience-group">
<label class="dispatch-radio-label">
<input type="radio" name="dispatchFormat" value="text" checked>
Plain text
</label>
<label class="dispatch-radio-label">
<input type="radio" name="dispatchFormat" value="html">
HTML
</label>
</div>
<div class="dispatch-format-hint" id="dispatchFormatHint"></div>
</div>
<div class="dispatch-body-row">
<div class="sub-form-group dispatch-body-group">
<label class="sub-form-label" for="dispatchBody">Message</label>
<textarea class="sub-features-textarea dispatch-body" id="dispatchBody"
placeholder="Write your message here. Separate paragraphs with a blank line."></textarea>
</div>
<div class="sub-form-group dispatch-snippet-group" id="dispatchSnippetGroup" style="display:none;">
<label class="sub-form-label">Button snippets</label>
<div class="dispatch-snippet-label">Primary (blue)</div>
<div class="dispatch-snippet-wrap">
<pre class="dispatch-snippet" id="dispatchSnippetPrimary"></pre>
<button class="dispatch-snippet-copy" id="dispatchSnippetCopyPrimary" title="Copy">Copy</button>
</div>
<div class="dispatch-snippet-label">Secondary (grey)</div>
<div class="dispatch-snippet-wrap">
<pre class="dispatch-snippet" id="dispatchSnippetSecondary"></pre>
<button class="dispatch-snippet-copy" id="dispatchSnippetCopySecondary" title="Copy">Copy</button>
</div>
</div>
</div>
<div class="dispatch-actions">
<span class="dispatch-status" id="dispatchStatus"></span>
<button class="btn btn-primary" id="dispatchSendBtn">Send Email</button>
</div>
</div>`;
async function fetchCount() {
const audience = container.querySelector('input[name="dispatchAudience"]:checked').value;
const countEl = document.getElementById('dispatchCount');
countEl.textContent = '…';
try {
const headers = {};
if (authToken) headers['X-Auth-Token'] = authToken;
const resp = await fetch(`/api/admin/dispatch/count?audience=${audience}`, { headers });
if (resp.ok) {
const { count } = await resp.json();
countEl.textContent = `${count} recipient${count !== 1 ? 's' : ''}`;
} else {
countEl.textContent = 'Error fetching count';
}
} catch {
countEl.textContent = 'Error fetching count';
}
}
container.querySelectorAll('input[name="dispatchAudience"]').forEach(r => {
r.addEventListener('change', fetchCount);
});
const hintEl = document.getElementById('dispatchFormatHint');
const bodyEl = document.getElementById('dispatchBody');
const snippetGroup = document.getElementById('dispatchSnippetGroup');
const snippetPrimaryEl = document.getElementById('dispatchSnippetPrimary');
const snippetSecondaryEl = document.getElementById('dispatchSnippetSecondary');
const htmlHint = 'HTML is embedded inside the standard email card. Use inline styles for buttons and links.';
const textHint = 'Blank lines become paragraph breaks.';
const SNIPPET_PRIMARY = `<p style="margin:0 0 24px;text-align:center;">
<a href="https://example.com"
target="_blank"
style="display:inline-block;padding:11px 24px;background:#3b82f6;
color:#ffffff;text-decoration:none;border-radius:4px;
font-family:ui-monospace,'IBM Plex Mono',monospace;
font-size:13px;font-weight:600;letter-spacing:0.04em;
mso-padding-alt:0;">
Button label
</a>
</p>`;
const SNIPPET_SECONDARY = `<p style="margin:0 0 24px;text-align:center;">
<a href="https://example.com"
target="_blank"
style="display:inline-block;padding:11px 24px;background:#52525b;
color:#ffffff;text-decoration:none;border-radius:4px;
font-family:ui-monospace,'IBM Plex Mono',monospace;
font-size:13px;font-weight:600;letter-spacing:0.04em;
mso-padding-alt:0;">
Button label
</a>
</p>`;
function updateFormatHint() {
const fmt = container.querySelector('input[name="dispatchFormat"]:checked').value;
hintEl.textContent = fmt === 'html' ? htmlHint : textHint;
bodyEl.placeholder = fmt === 'html'
? '<p>Your message here.</p>'
: 'Write your message here. Separate paragraphs with a blank line.';
snippetGroup.style.display = fmt === 'html' ? '' : 'none';
snippetPrimaryEl.textContent = SNIPPET_PRIMARY;
snippetSecondaryEl.textContent = SNIPPET_SECONDARY;
}
function makeCopyHandler(btnId, text) {
document.getElementById(btnId).addEventListener('click', () => {
navigator.clipboard.writeText(text).then(() => {
const btn = document.getElementById(btnId);
btn.textContent = 'Copied!';
setTimeout(() => { btn.textContent = 'Copy'; }, 1500);
});
});
}
makeCopyHandler('dispatchSnippetCopyPrimary', SNIPPET_PRIMARY);
makeCopyHandler('dispatchSnippetCopySecondary', SNIPPET_SECONDARY);
container.querySelectorAll('input[name="dispatchFormat"]').forEach(r => {
r.addEventListener('change', updateFormatHint);
});
updateFormatHint();
document.getElementById('dispatchSendBtn').addEventListener('click', async () => {
const audience = container.querySelector('input[name="dispatchAudience"]:checked').value;
const format = container.querySelector('input[name="dispatchFormat"]:checked').value;
const subject = document.getElementById('dispatchSubject').value.trim();
const body = document.getElementById('dispatchBody').value.trim();
const statusEl = document.getElementById('dispatchStatus');
const sendBtn = document.getElementById('dispatchSendBtn');
if (!subject) { statusEl.textContent = 'Subject is required.'; statusEl.className = 'dispatch-status dispatch-status--error'; return; }
if (!body) { statusEl.textContent = 'Message body is required.'; statusEl.className = 'dispatch-status dispatch-status--error'; return; }
const countEl = document.getElementById('dispatchCount');
const countText = countEl.textContent;
if (!confirm(`Send to ${countText}?\n\nSubject: ${subject}`)) return;
sendBtn.disabled = true;
statusEl.textContent = 'Sending…';
statusEl.className = 'dispatch-status';
try {
const headers = { 'Content-Type': 'application/json' };
if (authToken) headers['X-Auth-Token'] = authToken;
const resp = await fetch('/api/admin/dispatch', {
method: 'POST',
headers,
body: JSON.stringify({ subject, body, audience, format }),
});
const data = await resp.json();
if (resp.ok) {
statusEl.textContent = `Sent to ${data.sent} recipient${data.sent !== 1 ? 's' : ''}.`;
statusEl.className = 'dispatch-status dispatch-status--ok';
document.getElementById('dispatchSubject').value = '';
document.getElementById('dispatchBody').value = '';
} else {
statusEl.textContent = data.error || 'Send failed.';
statusEl.className = 'dispatch-status dispatch-status--error';
}
} catch {
statusEl.textContent = 'Network error.';
statusEl.className = 'dispatch-status dispatch-status--error';
} finally {
sendBtn.disabled = false;
}
});
fetchCount();
}
// ── Schema diagram ────────────────────────────────────────────────────────────
/** Builds and renders the database schema ER diagram into the schema panel container. */
function initSchemaPanel() {
const container = document.getElementById('adminSchemaDiagram');
const definition = `
erDiagram
USERS {
uuid id PK
text external_id
text auth_provider
text email
text display_name
boolean is_active
uuid subscription_id FK
jsonb preferences
timestamptz last_login_at
timestamptz created_at
timestamptz modified_at
}
SUBSCRIPTIONS {
uuid id PK
text name
text description
text billing_period
jsonb features
timestamptz created_at
timestamptz modified_at
}
FOLDERS {
uuid id PK
text name
uuid parent_id FK
uuid owner_id FK
jsonb permissions
timestamptz created_at
}
PROJECTS {
uuid id PK
text name
text version
uuid folder_id FK
uuid owner_id FK
text audio_format
boolean has_audio_mp3
boolean has_transcript
boolean has_peaks
jsonb speakers
jsonb permissions
integer sample_rate
double_precision duration
timestamptz created_at
timestamptz modified_at
}
EMBEDS {
uuid id PK
uuid project_id FK
uuid owner_id FK
integer invoke_count
timestamptz created_at
}
SUBSCRIPTIONS ||--o{ USERS : "subscription_id"
USERS ||--o{ PROJECTS : "owner_id"
USERS ||--o{ FOLDERS : "owner_id"
USERS ||--o{ EMBEDS : "owner_id"
FOLDERS ||--o{ FOLDERS : "parent_id"
FOLDERS ||--o{ PROJECTS : "folder_id"
PROJECTS ||--o{ EMBEDS : "project_id"
ANALYTICS {
uuid id PK
jsonb data
timestamptz recorded_at
}
`.trim();
const pre = document.createElement('pre');
pre.className = 'mermaid';
pre.textContent = definition;
container.appendChild(pre);
if (window.mermaid) {
const isDark = document.documentElement.dataset.theme !== 'light';
mermaid.initialize({
startOnLoad: false,
theme: isDark ? 'dark' : 'default',
er: { layoutDirection: 'LR' },
});
mermaid.run({ nodes: [pre] });
}
}
// ── Auth ──────────────────────────────────────────────────────────────────────
if (LOCAL_MODE) {
fetch('/api/admin/me').then(async r => {
if (!r.ok) { hideLoading(); return; }
showAdminPage(await r.json());
}).catch(() => hideLoading());
} else if (!firebaseAuth) {
window.location.href = '/';
} else {
loginDialog = new LoginDialog();
let _pendingError = null;
onAuthStateChanged(firebaseAuth, async (fbUser) => {
if (!fbUser) {
hideLoading();
document.getElementById('adminPage').style.display = 'none';
loginDialog.openRequired();
if (_pendingError) {
loginDialog.showConnectError(_pendingError);
_pendingError = null;
}
return;
}
authToken = await fbUser.getIdToken();
let resp;
try {
resp = await fetch('/api/admin/me', { headers: { 'X-Auth-Token': authToken } });
} catch {
_pendingError = 'Could not connect to server.';
authToken = null;
await signOut(firebaseAuth);
return;
}
if (!resp.ok) {
_pendingError = resp.status === 403
? 'This account does not have admin access.'
: 'Server error. Please try again.';
authToken = null;
await signOut(firebaseAuth);
return;
}
loginDialog.close();
showAdminPage(await resp.json());
});
}