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.title')}

${error && html`
${error}
`}
${TABS.map(k => html` `)}
${tab === 'general' && settings && html`

${t('admin.general_groups_heading')}

${t('admin.allow_public_groups_label')}

${t('admin.allow_public_groups_hint')}

${!canEditSettings && html`

${t('admin.settings_readonly')}

`}

${t('admin.mail_heading')}

${t('admin.mail_hint')}

${t('admin.mail_state_is_in_stats')}

${mailDraft && MAIL_FIELDS.map(key => html`
${t('admin.mail_' + key)} setMailDraft(d => ({ ...d, [key]: e.target.value }))} />
`)} ${canEditSettings && mailDraft && html`
`}
${settings.login && html`

${t('admin.login_heading')}

${t('admin.login_hint')}

${loginDraft && LOGIN_FIELDS.map(key => html`
${t('admin.login_' + key)} setLoginDraft(d => ({ ...d, [key]: e.target.value }))} />
`)} ${canEditSettings && loginDraft && html`
`}

${t('admin.session_heading')}

${t('admin.session_hint')}

${sessionDraft && SESSION_FIELDS.map(key => html`
${t('admin.session_' + key)} setSessionDraft(d => ({ ...d, [key]: e.target.value }))} />
`)} ${canEditSettings && sessionDraft && html`
`}
`} `} ${tab === 'stats' && stats && html`
${[['users', 'stat_users'], ['groups', 'stat_groups'], ['nodes', 'stat_nodes'], ['online_nodes', 'stat_online']].map(([k, label]) => html`
${stats[k]}
${t('admin.' + label)}
`)}
${mailStatus && html`

${t('admin.mail_heading')}

${mailStatus.all_exhausted && html`
${t('admin.mail_alert_all')}
`} ${!mailStatus.all_exhausted && mailStatus.general_exhausted && html`
${t('admin.mail_alert_general')}
`}
${mailStatus.hourly_used} / ${mailStatus.hourly_budget}
${t('admin.mail_this_hour')}
${mailStatus.general_remaining}
${t('admin.mail_left_signups')}
${mailStatus.recovery_remaining}
${t('admin.mail_left_recovery')}
${mailStatus.recipients_tracked}
${t('admin.mail_recipients')}

${t('admin.mail_state_hint')}

`} `} ${tab === 'users' && html`
{ setUserSearch(e.target.value); loadUsers(e.target.value); }} /> ${usersTotal} total
${users.length === 0 && html``} ${users.map(u => html` `)}
${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` `}
`} ${tab === 'groups' && html`
${groupsTotal} total
${groups.length === 0 && html``} ${groups.map(g => 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` `}
`} ${tab === 'nodes' && html`

${t('admin.nodes_hint')}

${nodes.length === 0 && html` `} ${nodes.map(n => html` `)}
${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')}
`} ${tab === 'logs' && html`
${logs.length === 0 && html``} ${logs.map(lg => html` `)}
${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 || ''}
${logs.length > 0 && logs.length % 50 === 0 && html` `} `} ${tab === 'blocklist' && html` <${BlocklistForm} onAdd=${addToBlocklist} /> ${blocklist.length === 0 && html``} ${blocklist.map(b => html` `)}
${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 || ''}
`} ${detailUser && html`
{ if (e.target.classList.contains('admin-detail-overlay')) setDetailUser(null); }}>

${t('admin.user_detail')}

${[ ['admin.col_username', detailUser.username], ['admin.col_email', detailUser.email], ['admin.col_role', detailUser.role], ['admin.col_status', detailUser.status], ['admin.col_created', new Date(detailUser.created_at).toLocaleString()], ['admin.col_groups', detailUser.group_count], ].map(([label, val]) => html`
${t(label)} ${val}
`)}
`}
`; } function BlocklistForm({ onAdd }) { const [hash, setHash] = useState(''); const [reason, setReason] = useState(''); const submit = (e) => { e.preventDefault(); if (hash.length === 64 && reason) { onAdd(hash, reason); setHash(''); setReason(''); } }; return html`
setHash(e.target.value)} pattern="[0-9a-f]{64}" required /> setReason(e.target.value)} required />
`; }