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'; // 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' }); const API = (url, opts = {}) => fetch('/api' + url, { headers: headers(), ...opts }).then(r => 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'; } // Build the org-grouped workspace `; let currentOrg = null; for (const w of list) { const org = w.organization_name || '—'; if (org !== currentOrg) { if (currentOrg !== null) html += ''; html += ``; currentOrg = org; } html += ``; } if (currentOrg !== null) html += ''; return html; } // Workspace cell for one user row. Editable ${optionsHtml} `; } 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.all_users')}

${t('common.loading')}

${t('admin.plans')}

${t('common.loading')}

${t('admin.system')}

${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, }); }); loadUsers(); loadPlans(); loadSystem(); } async function loadUsers() { const el = document.getElementById('allUsersTable'); try { const [users, plans, me] = await Promise.all([ API('/auth/users'), fetch('/api/subscription/plans').then(r => r.json()), api.getMe().catch(() => ({})), // workspace-picker source (same as Add User modal) ]); const currentUser = JSON.parse(localStorage.getItem('user') || '{}'); // Build the org-grouped workspace options ONCE, reuse per row. const wsOptionsHtml = buildWorkspaceOptions(Array.isArray(me?.accessible_workspaces) ? me.accessible_workspaces : []); el.innerHTML = `
${users.map(u => ` ${workspaceCell(u, wsOptionsHtml)} `).join('')}
${t('admin.col.user')} ${t('admin.col.auth')} ${t('admin.col.last_login')} ${t('admin.col.role')} ${t('admin.col.plan')} ${t('admin.col.workspace')} ${t('admin.col.actions')}
${u.name || u.email}
${u.email}
${u.auth_provider} ${u.last_login ? new Date(u.last_login * 1000).toLocaleString() : t('common.never')} ${u.auth_provider === 'local' && u.id !== currentUser.id ? `` : ''} ${!isPlatformAdmin(u) ? `` : `${t('admin.owner')}`}

${t('admin.total_users', { n: users.length })}

`; el.querySelectorAll('[data-role-user]').forEach(select => { select.onchange = async () => { try { await API(`/auth/users/${select.dataset.roleUser}/role`, { method: 'PUT', body: JSON.stringify({ role: select.value }) }); showToast(t('admin.toast.role_updated'), 'success'); } catch (err) { showToast(err.message, 'error'); loadUsers(); } }; }); el.querySelectorAll('[data-plan-user]').forEach(select => { select.onchange = async () => { try { await API('/subscription/assign', { method: 'POST', body: JSON.stringify({ user_id: select.dataset.planUser, plan_id: select.value }) }); showToast(t('admin.toast.plan_updated'), 'success'); } catch (err) { showToast(err.message, 'error'); loadUsers(); } }; }); // Workspace move/assign (editable rows only: a 'user' with 0 or 1 membership). // Set the current selection per row (the shared options string carries no // per-row `selected`), then move/assign on change. Picking "Unassigned" or // the same workspace is a no-op so a stray pick can't strip a membership. el.querySelectorAll('[data-ws-user]').forEach(select => { select.value = select.dataset.current || ''; select.onchange = async () => { const wsId = select.value; const current = select.dataset.current || ''; if (!wsId || wsId === current) { select.value = current; return; } try { const r = await API(`/admin/users/${select.dataset.wsUser}/workspace`, { method: 'PUT', body: JSON.stringify({ workspaceId: wsId }) }); if (r && r.error) { showToast(r.error, 'error'); loadUsers(); return; } showToast(t('admin.toast.workspace_updated'), 'success'); loadUsers(); } catch (err) { showToast(err.message, 'error'); loadUsers(); } }; }); // Reset password handlers el.querySelectorAll('[data-reset-pw-user]').forEach(btn => { btn.onclick = async () => { const email = btn.dataset.userEmail; const pw = prompt(t('admin.prompt_reset_password', { email })); if (pw === null) return; if (pw.length < 8) { showToast(t('admin.toast.password_min_8'), 'error'); return; } try { await api.resetUserPassword(btn.dataset.resetPwUser, pw); showToast(t('admin.toast.password_reset'), 'success'); } catch (err) { showToast(err.message, 'error'); } }; }); el.querySelectorAll('[data-delete-user]').forEach(btn => { let confirming = false; btn.onclick = async () => { if (confirming) { try { await api.deleteUser(btn.dataset.deleteUser); showToast(t('admin.toast.user_removed'), 'success'); loadUsers(); } catch (err) { showToast(err.message, 'error'); } return; } confirming = true; btn.textContent = t('admin.confirm'); btn.style.background = 'var(--danger)'; btn.style.color = 'white'; setTimeout(() => { confirming = false; btn.textContent = t('admin.remove'); btn.style.background = ''; btn.style.color = ''; }, 3000); }; }); } catch (err) { el.innerHTML = `

${esc(err.message)}

`; } } async function loadPlans() { const el = document.getElementById('plansTable'); try { const plans = await fetch('/api/subscription/plans').then(r => r.json()); el.innerHTML = `
${plans.map(p => ` `).join('')}
${t('admin.col.plan')} ${t('admin.col.devices')} ${t('admin.col.storage')} ${t('admin.col.monthly')} ${t('admin.col.yearly')}
${p.display_name} ${p.max_devices === -1 ? t('admin.unlimited') : p.max_devices} ${p.max_storage_mb === -1 ? t('admin.unlimited') : p.max_storage_mb >= 1024 ? (p.max_storage_mb/1024)+'GB' : p.max_storage_mb+'MB'} ${p.price_monthly > 0 ? '$'+p.price_monthly : t('admin.free')} ${p.price_yearly > 0 ? '$'+p.price_yearly : '-'}
`; } catch (err) { el.innerHTML = `

${esc(err.message)}

`; } } async function loadSystem() { const el = document.getElementById('systemInfo'); try { const version = await fetch('/api/version').then(r => r.json()); const token = localStorage.getItem('token'); el.innerHTML = `
${t('admin.version')}
${version.version}
${t('admin.frontend_hash')}
${version.hash}
${t('admin.download_db_backup')} ${t('admin.server_status')}
`; } catch (err) { el.innerHTML = `

${esc(err.message)}

`; } } export function cleanup() {}