import { html, useState, useEffect, useCallback, } from './vendor/htm-preact.js'; import { t } from './i18n.js'; import { ask, tell } from './ask.js'; import { hubFetch } from './hub-client.js'; import { GroupName } from './group-name.js'; // ── Admin Panel ───────────────────────────────────────────────────────────── export function AdminPage({ token, role }) { const [tab, setTab] = useState('general'); const [stats, setStats] = useState(null); const [settings, setSettings] = useState(null); const [settingsSaving, setSettingsSaving] = useState(false); const [mailStatus, setMailStatus] = useState(null); // Edited values live here until Save, so a half-typed number is never sent // and a rejected one never looks applied. const [mailDraft, setMailDraft] = useState(null); const [loginDraft, setLoginDraft] = useState(null); const [sessionDraft, setSessionDraft] = useState(null); const [users, setUsers] = useState([]); const [usersTotal, setUsersTotal] = useState(0); const [userSearch, setUserSearch] = useState(''); const [groups, setGroups] = useState([]); const [groupsTotal, setGroupsTotal] = useState(0); const [logs, setLogs] = useState([]); const [logEvent, setLogEvent] = useState(''); const [logOffset, setLogOffset] = useState(0); const [blocklist, setBlocklist] = useState([]); const [nodes, setNodes] = useState([]); const [detailUser, setDetailUser] = useState(null); const [error, setError] = useState(''); const headers = { Authorization: `Bearer ${token}` }; const loadStats = useCallback(async () => { try { const data = await hubFetch('/v1/admin/stats', { token }); setStats(data); } catch (e) { setError(e.message); } }, [token]); const loadSettings = useCallback(async () => { try { const data = await hubFetch('/v1/admin/settings', { token }); setSettings(data); setMailDraft({ ...data.mail }); setLoginDraft({ ...data.login }); setSessionDraft({ ...data.session }); } catch (e) { setError(e.message); } try { setMailStatus(await hubFetch('/v1/admin/mail', { token })); } catch { /* the panel still works without the live figure */ } }, [token]); const saveSettings = useCallback(async (patch) => { setSettingsSaving(true); setError(''); try { // The response is the authoritative state — render that, not the // optimistic value, so a rejected change never looks applied. const data = await hubFetch('/v1/admin/settings', { method: 'PATCH', body: patch, token }); setSettings(data); // The hub clamps what it was given, so the draft is reset from the // answer rather than left showing a number that was not stored. setMailDraft({ ...data.mail }); setLoginDraft({ ...data.login }); setSessionDraft({ ...data.session }); if (patch.mail) { try { setMailStatus(await hubFetch('/v1/admin/mail', { token })); } catch { /* leave the previous figure rather than blanking it */ } } } catch (e) { setError(e.message); } finally { setSettingsSaving(false); } }, [token]); const loadUsers = useCallback(async (q = '') => { try { const data = await hubFetch(`/v1/admin/users?q=${encodeURIComponent(q)}&limit=100`, { token }); setUsers(data.users); setUsersTotal(data.total); } catch (e) { setError(e.message); } }, [token]); const loadGroups = useCallback(async () => { try { const data = await hubFetch('/v1/admin/groups?limit=100', { token }); setGroups(data.groups); setGroupsTotal(data.total); } catch (e) { setError(e.message); } }, [token]); const loadLogs = useCallback(async (event = '', offset = 0, append = false) => { try { let url = `/v1/admin/logs?limit=50&offset=${offset}`; if (event) url += `&event=${encodeURIComponent(event)}`; const data = await hubFetch(url, { token }); setLogs(prev => append ? [...prev, ...data.logs] : data.logs); } catch (e) { setError(e.message); } }, [token]); const loadBlocklist = useCallback(async () => { try { const data = await hubFetch('/v1/admin/blocklist', { token }); setBlocklist(data.entries); } catch (e) { setError(e.message); } }, [token]); useEffect(() => { setError(''); if (tab === 'general') loadSettings(); else if (tab === 'stats') { loadStats(); hubFetch('/v1/admin/mail', { token }) .then(setMailStatus).catch(() => { /* the cards still render */ }); } else if (tab === 'users') loadUsers(userSearch); else if (tab === 'groups') loadGroups(); else if (tab === 'nodes') { hubFetch('/v1/admin/nodes', { token }) .then(d => setNodes(d.nodes || [])).catch(e => setError(e.message)); } else if (tab === 'logs') { setLogOffset(0); loadLogs(logEvent, 0); } else if (tab === 'blocklist') loadBlocklist(); }, [tab]); const deleteUser = useCallback(async (u) => { // Suspension is the reversible tool and stays one click away; this one is // not, so it names the account and says what it cannot reach. if (!await ask(t('admin.delete_confirm', { user: u.username }))) return; try { await hubFetch(`/v1/admin/users/${u.id}`, { method: 'DELETE', token }); loadUsers(userSearch); } catch (err) { tell(err.message); } }, [token, userSearch, loadUsers]); const patchUser = useCallback(async (userId, patch) => { try { await hubFetch(`/v1/admin/users/${userId}`, { method: 'PATCH', body: patch, token }); loadUsers(userSearch); if (detailUser && detailUser.id === userId) setDetailUser(null); } catch (e) { setError(e.message); } }, [token, userSearch, detailUser]); const patchGroup = useCallback(async (groupId, patch) => { try { await hubFetch(`/v1/admin/groups/${groupId}`, { method: 'PATCH', body: patch, token }); loadGroups(); } catch (e) { setError(e.message); } }, [token]); const revokeGroup = useCallback(async (g) => { // Suspending is the reversible tool and stays one click away; revoking // pushes a signed revocation to every node hosting the group and there is // no undo from here, so it names the group and asks first. if (!await ask(t('admin.revoke_group_confirm', { group: g.name }))) return; try { await hubFetch('/v1/admin/revoke', { method: 'POST', body: { target: 'group', target_id: g.id }, token }); loadGroups(); } catch (e) { setError(e.message); } }, [token]); const showUserDetail = useCallback(async (userId) => { try { const data = await hubFetch(`/v1/admin/users/${userId}`, { token }); setDetailUser(data); } catch (e) { setError(e.message); } }, [token]); const addToBlocklist = useCallback(async (hash, reason) => { try { await hubFetch('/v1/admin/blocklist', { method: 'POST', body: { content_hash: hash, reason }, token }); loadBlocklist(); } catch (e) { setError(e.message); } }, [token]); const removeFromBlocklist = useCallback(async (hash) => { try { await hubFetch(`/v1/admin/blocklist/${hash}`, { method: 'DELETE', token }); loadBlocklist(); } catch (e) { setError(e.message); } }, [token]); // The order the fields are shown in, and the only keys the panel will send. // The hub refuses anything outside its own list as well — a form is not where // that decision belongs. const MAIL_FIELDS = [ 'hourly_budget', 'hourly_reserved_for_recovery', 'destination_daily_cap', 'destination_cooldown_seconds', 'invite_link_daily_cap', 'verification_resend_cooldown', 'reset_cooldown', 'email_change_cooldown', ]; const LOGIN_FIELDS = ['max_failures', 'lockout_minutes']; const SESSION_FIELDS = ['browser_idle_hours', 'refresh_idle_hours', 'max_hours']; // Only what changed, and only what is a number: an empty field is someone // mid-edit, not a request to set zero. const changedNumbers = (fields, draft, stored) => Object.fromEntries(fields .filter(k => draft[k] !== '' && draft[k] !== null && Number(draft[k]) !== stored[k]) .map(k => [k, Number(draft[k])])); const TABS = ['general', 'stats', 'users', 'groups', 'nodes', 'logs', 'blocklist']; const canEditSettings = role === 'admin'; return html`
${t('admin.allow_public_groups_hint')}
${!canEditSettings && html`${t('admin.settings_readonly')}
`}${t('admin.mail_hint')}
${t('admin.mail_state_is_in_stats')}
${mailDraft && MAIL_FIELDS.map(key => html`${t('admin.login_hint')}
${loginDraft && LOGIN_FIELDS.map(key => html`${t('admin.session_hint')}
${sessionDraft && SESSION_FIELDS.map(key => html`${t('admin.mail_state_hint')}
| ${t('admin.col_username')} | ${t('admin.col_role')} | ${t('admin.col_status')} | ${t('admin.col_created')} | ${t('admin.col_actions')} |
|---|---|---|---|---|
| ${t('admin.no_users')} | ||||
| ${u.username} | ${u.status} | ${new Date(u.created_at).toLocaleDateString()} | ${u.status === 'active' ? html`` : u.status === 'suspended' ? html`` : null } ${u.status !== 'deleted' && html` `} | |
| ${t('admin.col_name')} | ${t('admin.col_visibility')} | ${t('admin.col_members')} | ${t('admin.col_status')} | ${t('admin.col_created')} | ${t('admin.col_actions')} |
|---|---|---|---|---|---|
| ${t('admin.no_groups')} | |||||
| <${GroupName} name=${g.name} owner=${g.owner_username} /> | ${g.visibility} | ${g.member_count} | ${g.status} | ${new Date(g.created_at).toLocaleDateString()} | ${g.status === 'active' ? html`` : g.status === 'suspended' ? html`` : null } ${g.status !== 'revoked' && html` `} |
${t('admin.nodes_hint')}
| ${t('admin.col_username')} | ${t('admin.col_observed_ip')} | ${t('admin.col_hint')} | ${t('admin.col_last_seen')} | ${t('admin.col_status')} |
|---|---|---|---|---|
| ${t('admin.no_nodes')} | ||||
| ${n.username || n.user_id.slice(0, 8)} | ${n.observed_ip || '—'} | ${n.endpoint_hint || '—'} | ${n.last_seen ? new Date(n.last_seen).toLocaleString() : '—'} | ${n.online ? t('admin.node_online') : t('admin.node_offline')} |
| ${t('admin.col_time')} | ${t('admin.col_user')} | ${t('admin.col_event')} | ${t('admin.col_ip')} | ${t('admin.col_detail')} |
|---|---|---|---|---|
| ${t('admin.no_logs')} | ||||
| ${new Date(lg.timestamp).toLocaleString()} | ${lg.username || ''} | ${lg.event} | ${lg.ip_address} | ${lg.detail || ''} |
| ${t('admin.col_hash')} | ${t('admin.col_reason')} | ${t('admin.col_date')} | ${t('admin.col_added_by')} | ${t('admin.col_actions')} |
|---|---|---|---|---|
| ${t('admin.no_blocked')} | ||||
| ${b.hash.slice(0, 16)}... | ${b.reason} | ${new Date(b.added_at).toLocaleDateString()} | ${b.added_by || ''} | |