import { api } from '../api.js';
import { showToast } from '../components/toast.js';
import { esc, isPlatformAdmin } from '../utils.js';
import { t } from '../i18n.js';
import { openAddUserModal } from '../components/workspace-members-add-user-modal.js';
import { openManageWorkspacesModal } from '../components/admin-user-workspaces-modal.js';
import { openCreateOrgModal } from '../components/admin-create-org-modal.js';
import { openTypeToConfirmModal } from '../components/type-to-confirm-modal.js';
// Reuse the members view's server-error -> friendly-string mapper (handles the
// 409 duplicate-email / weak-password / invalid-email cases) so we don't fork a
// second mapper.
import { mapMutationError } from './workspace-members.js';
const headers = () => ({ Authorization: `Bearer ${localStorage.getItem('token')}`, 'Content-Type': 'application/json' });
// A refused request must reject, not resolve.
//
// This helper used to end in `.then(r => r.json())`, so a 403/404/500 body resolved as an ordinary
// value and the surrounding try/catch was unreachable — every handler took the failure for success.
// Concretely: deleting a built-in layout template showed "Layout deleted" while the server had
// returned 403 and the template was still there, and a rejected platform-role change showed "Role
// updated" while the dropdown kept displaying a value the server refused (its revert lives only in
// the dead catch). The shared client in api.js has always thrown on !res.ok; these local copies did
// not. Same contract now, including the 401 session-expiry reload.
const API = (url, opts = {}) => fetch('/api' + url, { headers: headers(), ...opts }).then(async (r) => {
if (r.status === 401) { localStorage.removeItem('token'); window.location.reload(); throw new Error('Session expired'); }
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.error || `Request failed (${r.status})`); }
return r.json();
});
// #14: the platform user-management dropdown manages users.role (the
// PLATFORM-level role) only - workspace/org roles are managed in the members
// views. Options are the current model; the legacy 'admin'/'superadmin' strings
// were normalized away. #13 adds 'platform_operator' (cross-org staff).
const PLATFORM_ROLE_OPTIONS = ['user', 'platform_operator', 'platform_admin'];
// Platform staff have cross-org access (no single workspace), so the Workspace
// column shows read-only "Platform (all)" for them. Note utils.isPlatformAdmin
// only covers admin/superadmin; operators are staff here too.
function isPlatformStaffRole(role) {
return role === 'platform_admin' || role === 'superadmin' || role === 'platform_operator';
}
// Short summary of a user's workspace membership for the Users-table cell.
// Platform staff have cross-org access (not per-workspace membership) -> "Platform
// (all)". Otherwise: Unassigned (0), the workspace name (1), or "N workspaces".
function workspaceSummary(u) {
if (isPlatformStaffRole(u.role)) return t('admin.workspace.platform_all');
const count = u.workspace_count || 0;
if (count === 0) return t('admin.workspace.unassigned');
if (count === 1) return esc(u.workspace_name || '');
return t('admin.workspace.multi', { n: count });
}
// Workspace cell: a summary + a "Manage" button that opens the full membership
// modal (add/remove workspaces, set per-workspace role). Manage is offered for
// everyone, including staff (you can grant them explicit memberships too).
function workspaceCell(u) {
return `
${workspaceSummary(u)}
`;
}
export async function render(container) {
const user = JSON.parse(localStorage.getItem('user') || '{}');
if (!isPlatformAdmin(user)) {
container.innerHTML = `
${t('admin.access_denied')}
${t('admin.access_denied_desc')}
`;
return;
}
container.innerHTML = `
${t('admin.title')}
${t('admin.subtitle')}
${t('admin.sso_only.title')}
${t('admin.sso_only.desc')}
${t('common.loading')}
${t('admin.all_users')}
${t('common.loading')}
${t('admin.orgs.title')}
${t('admin.orgs.desc')}
${t('common.loading')}
${t('admin.branding.title')}
${t('admin.branding.desc')}
${t('common.loading')}
${t('admin.plans')}
${t('common.loading')}
${t('admin.system')}
${t('common.loading')}
Status endpoint
${t('common.loading')}
`;
// Add User (#10): platform admin provisions a user into ANY workspace. The
// page is platform_admin-gated; the modal opens in picker mode (no fixed
// workspace) so the admin chooses the target org/workspace. The endpoint
// additionally enforces canAdminWorkspace (platform_admin passes everywhere).
document.getElementById('adminAddUserBtn')?.addEventListener('click', () => {
openAddUserModal(null, {
onSuccess: (result) => {
showToast(t('members.success.user_created', { email: result.email }), 'success');
loadUsers();
},
mapError: mapMutationError,
});
});
// Create Organization (#35): platform admin provisions a new customer org +
// its first workspace (owned by the admin). The modal reloads on success so
// the new org shows up in the switcher.
document.getElementById('adminCreateOrgBtn')?.addEventListener('click', () => {
openCreateOrgModal({
onSuccess: (result) => showToast(t('admin.create_org.success', { name: result.name }), 'success'),
});
});
loadUsers();
loadOrgs();
loadSsoOnlyRequests();
loadBranding();
loadPlans();
loadSystem();
loadStatusDebug();
}
// #36: list organizations with owner + resource counts; platform admin can
// cascade-delete an org or an individual workspace (type-the-name confirm).
/*
* Pending "stop requiring single sign-on" requests.
*
* The notification email tells the operator to review this under Admin, and for a while it did not
* exist — the only way to approve was curl, while the customer sat locked out. The section hides
* itself when there is nothing pending so it is never noise.
*/
async function loadSsoOnlyRequests() {
const section = document.getElementById('ssoOnlySection');
const host = document.getElementById('ssoOnlyRequests');
if (!section || !host) return;
// NB: `api` is a map of named methods, not a generic client — there is no api.get(), and calling
// one silently hid this whole section behind the catch below.
const authed = (path, init = {}) => fetch(`/api${path}`, {
...init,
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}`, ...(init.headers || {}) },
});
let requests = [];
try {
const res = await authed('/organizations/sso-only/removal-requests');
if (!res.ok) throw new Error(String(res.status));
requests = (await res.json()).requests || [];
} catch {
section.style.display = 'none';
return;
}
// Clear as well as hide: leaving the last decided request in the tree kept its live
// Approve/Reject listeners attached to a request that no longer exists.
if (!requests.length) { host.innerHTML = ''; section.style.display = 'none'; return; }
section.style.display = '';
host.innerHTML = requests.map((r) => `
`; }
}
// #146: toggle /api/status debug-metrics exposure. Mirrors loadBranding's
// load-then-save pattern; takes effect on the next status poll (no restart).
async function loadStatusDebug() {
const el = document.getElementById('statusDebugForm');
if (!el) return;
let enabled = false;
try { enabled = (await api.adminGetStatusDebug()).enabled; }
catch (e) { el.innerHTML = `
${esc(e.message || 'Failed to load')}
`; return; }
el.innerHTML = `
Adds internal limiter/prune/OTA counters to the public status endpoint. Off by default.
`;
document.getElementById('statusDebugChk').onchange = async (e) => {
const chk = e.target;
chk.disabled = true;
try { await api.adminSetStatusDebug(chk.checked); showToast('Status debug ' + (chk.checked ? 'enabled' : 'disabled'), 'success'); }
catch (err) { showToast(err.message, 'error'); chk.checked = !chk.checked; }
finally { chk.disabled = false; }
};
}
async function loadPlans() {
const el = document.getElementById('plansTable');
try {
// Admin endpoint, not /api/subscription/plans: that one filters `active = 1` because it feeds
// the pricing page, so a deliberately hidden plan (a comped or beta tier) was invisible to the
// operator too. Here we want every plan, plus who is actually on each one.
const { plans, orphaned } = await api.adminListPlans();
el.innerHTML = `