From f6e80064ff8c4869a6ba2cef908cc54326948463 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Sun, 30 Aug 2026 23:57:57 +0200 Subject: refactor(ui): extract Settings, Profile and Admin pages from app.js Admin page is lazy-loaded so non-admin users never fetch it. Co-Authored-By: Claude Opus 4.6 --- .../src/meshbay_hub/static/admin-page.js | 454 +++++++++++++++++++++ 1 file changed, 454 insertions(+) create mode 100644 packages/meshbay-hub/src/meshbay_hub/static/admin-page.js (limited to 'packages/meshbay-hub/src/meshbay_hub/static/admin-page.js') diff --git a/packages/meshbay-hub/src/meshbay_hub/static/admin-page.js b/packages/meshbay-hub/src/meshbay_hub/static/admin-page.js new file mode 100644 index 0000000..dbe26ab --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/static/admin-page.js @@ -0,0 +1,454 @@ +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 +
+ + + + + + + + + + ${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 /> + +
+ `; +} -- cgit v1.2.3