import {
html, useState, useEffect, useCallback,
} from './vendor/htm-preact.js';
import { t } from './i18n.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 [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);
} catch (e) { setError(e.message); }
}, [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);
} 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();
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 (!confirm(t('admin.delete_confirm', { user: u.username }))) return;
try {
await hubFetch(`/v1/admin/users/${u.id}`, { method: 'DELETE', token });
loadUsers(userSearch);
} catch (err) {
alert(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 (!confirm(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]);
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')}
`}
`}
${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)}
`)}
`}
${tab === 'users' && html`
{ setUserSearch(e.target.value); loadUsers(e.target.value); }} />
${usersTotal} total
| ${t('admin.col_username')} |
${t('admin.col_role')} |
${t('admin.col_status')} |
${t('admin.col_created')} |
${t('admin.col_actions')} |
${users.length === 0 && html`| ${t('admin.no_users')} |
`}
${users.map(u => html`
| ${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
| ${t('admin.col_name')} |
${t('admin.col_visibility')} |
${t('admin.col_members')} |
${t('admin.col_status')} |
${t('admin.col_created')} |
${t('admin.col_actions')} |
${groups.length === 0 && html`| ${t('admin.no_groups')} |
`}
${groups.map(g => html`
| <${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')}
| ${t('admin.col_username')} |
${t('admin.col_observed_ip')} |
${t('admin.col_hint')} |
${t('admin.col_last_seen')} |
${t('admin.col_status')} |
${nodes.length === 0 && html`
| ${t('admin.no_nodes')} |
`}
${nodes.map(n => html`
| ${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`
| ${t('admin.col_time')} |
${t('admin.col_user')} |
${t('admin.col_event')} |
${t('admin.col_ip')} |
${t('admin.col_detail')} |
${logs.length === 0 && html`| ${t('admin.no_logs')} |
`}
${logs.map(lg => html`
| ${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} />
| ${t('admin.col_hash')} |
${t('admin.col_reason')} |
${t('admin.col_date')} |
${t('admin.col_added_by')} |
${t('admin.col_actions')} |
${blocklist.length === 0 && html`| ${t('admin.no_blocked')} |
`}
${blocklist.map(b => html`
| ${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`
`;
}