aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/admin-page.js
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/admin-page.js')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/admin-page.js454
1 files changed, 454 insertions, 0 deletions
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`
+ <div>
+ <h2>${t('admin.title')}</h2>
+ ${error && html`<div class="error-msg" style="margin-bottom:12px">${error}</div>`}
+
+ <div class="admin-tabs">
+ ${TABS.map(k => html`
+ <button key=${k} class="admin-tab ${tab === k ? 'active' : ''}"
+ onClick=${() => setTab(k)}>${t('admin.tab_' + k)}</button>
+ `)}
+ </div>
+
+ ${tab === 'general' && settings && html`
+ <div class="settings-section">
+ <h3 class="settings-heading">${t('admin.general_groups_heading')}</h3>
+ <div class="settings-row">
+ <span class="settings-label">${t('admin.allow_public_groups_label')}</span>
+ <label class="toggle-switch ${!canEditSettings || settingsSaving ? 'toggle-switch-disabled' : ''}">
+ <input type="checkbox" checked=${settings.allow_public_groups}
+ disabled=${!canEditSettings || settingsSaving}
+ onChange=${e => saveSettings({ allow_public_groups: e.target.checked })} />
+ <span class="toggle-switch-track"><span class="toggle-switch-thumb"></span></span>
+ </label>
+ </div>
+ <p class="settings-hint">${t('admin.allow_public_groups_hint')}</p>
+ ${!canEditSettings && html`
+ <p class="settings-hint">${t('admin.settings_readonly')}</p>`}
+ </div>
+ `}
+
+ ${tab === 'stats' && stats && html`
+ <div class="admin-stats">
+ ${[['users', 'stat_users'], ['groups', 'stat_groups'],
+ ['nodes', 'stat_nodes'], ['online_nodes', 'stat_online']].map(([k, label]) => html`
+ <div class="stat-card" key=${k}>
+ <div class="stat-value">${stats[k]}</div>
+ <div class="stat-label">${t('admin.' + label)}</div>
+ </div>
+ `)}
+ </div>
+ `}
+
+ ${tab === 'users' && html`
+ <div class="admin-toolbar">
+ <input class="admin-search" type="text" placeholder="${t('admin.users_search')}"
+ value=${userSearch} onInput=${e => { setUserSearch(e.target.value); loadUsers(e.target.value); }} />
+ <span class="settings-value">${usersTotal} total</span>
+ </div>
+ <table class="admin-table">
+ <thead><tr>
+ <th>${t('admin.col_username')}</th>
+ <th>${t('admin.col_role')}</th>
+ <th>${t('admin.col_status')}</th>
+ <th>${t('admin.col_created')}</th>
+ <th>${t('admin.col_actions')}</th>
+ </tr></thead>
+ <tbody>
+ ${users.length === 0 && html`<tr><td colspan="5" class="admin-empty">${t('admin.no_users')}</td></tr>`}
+ ${users.map(u => html`
+ <tr key=${u.id}>
+ <td>${u.username}</td>
+ <td>
+ <select class="admin-role-select" value=${u.role}
+ onChange=${e => patchUser(u.id, { role: e.target.value })}>
+ <option value="user">user</option>
+ <option value="moderator">moderator</option>
+ <option value="admin">admin</option>
+ </select>
+ </td>
+ <td><span class="badge ${u.status === 'active' ? 'badge-ok' : u.status === 'suspended' ? 'badge-err' : ''}">${u.status}</span></td>
+ <td>${new Date(u.created_at).toLocaleDateString()}</td>
+ <td class="admin-actions">
+ <button class="admin-btn" onClick=${() => showUserDetail(u.id)}>${t('admin.btn_details')}</button>
+ ${u.status === 'active'
+ ? html`<button class="admin-btn danger" onClick=${() => patchUser(u.id, { status: 'suspended' })}>${t('admin.btn_suspend')}</button>`
+ : u.status === 'suspended'
+ ? html`<button class="admin-btn" onClick=${() => patchUser(u.id, { status: 'active' })}>${t('admin.btn_unsuspend')}</button>`
+ : null
+ }
+ ${u.status !== 'deleted' && html`
+ <button class="admin-btn danger"
+ onClick=${() => deleteUser(u)}>${t('admin.btn_delete')}</button>
+ `}
+ </td>
+ </tr>
+ `)}
+ </tbody>
+ </table>
+ `}
+
+ ${tab === 'groups' && html`
+ <div class="admin-toolbar">
+ <span class="settings-value">${groupsTotal} total</span>
+ </div>
+ <table class="admin-table">
+ <thead><tr>
+ <th>${t('admin.col_name')}</th>
+ <th>${t('admin.col_visibility')}</th>
+ <th>${t('admin.col_members')}</th>
+ <th>${t('admin.col_status')}</th>
+ <th>${t('admin.col_created')}</th>
+ <th>${t('admin.col_actions')}</th>
+ </tr></thead>
+ <tbody>
+ ${groups.length === 0 && html`<tr><td colspan="6" class="admin-empty">${t('admin.no_groups')}</td></tr>`}
+ ${groups.map(g => html`
+ <tr key=${g.id}>
+ <td><${GroupName} name=${g.name} owner=${g.owner_username} /></td>
+ <td><span class="badge">${g.visibility}</span></td>
+ <td>${g.member_count}</td>
+ <td><span class="badge ${g.status === 'active' ? 'badge-ok' : g.status === 'suspended' ? 'badge-err' : ''}">${g.status}</span></td>
+ <td>${new Date(g.created_at).toLocaleDateString()}</td>
+ <td class="admin-actions">
+ ${g.status === 'active'
+ ? html`<button class="admin-btn danger" onClick=${() => patchGroup(g.id, { status: 'suspended' })}>${t('admin.btn_suspend')}</button>`
+ : g.status === 'suspended'
+ ? html`<button class="admin-btn" onClick=${() => patchGroup(g.id, { status: 'active' })}>${t('admin.btn_unsuspend')}</button>`
+ : null
+ }
+ ${g.status !== 'revoked' && html`
+ <button class="admin-btn danger"
+ onClick=${() => revokeGroup(g)}>${t('admin.btn_revoke')}</button>
+ `}
+ </td>
+ </tr>
+ `)}
+ </tbody>
+ </table>
+ `}
+
+ ${tab === 'nodes' && html`
+ <p class="settings-hint" style="margin-bottom:10px">
+ ${t('admin.nodes_hint')}
+ </p>
+ <table class="admin-table">
+ <thead><tr>
+ <th>${t('admin.col_username')}</th>
+ <th>${t('admin.col_observed_ip')}</th>
+ <th>${t('admin.col_hint')}</th>
+ <th>${t('admin.col_last_seen')}</th>
+ <th>${t('admin.col_status')}</th>
+ </tr></thead>
+ <tbody>
+ ${nodes.length === 0 && html`
+ <tr><td colspan="5" class="admin-empty">${t('admin.no_nodes')}</td></tr>
+ `}
+ ${nodes.map(n => html`
+ <tr key=${n.id}>
+ <td>${n.username || n.user_id.slice(0, 8)}</td>
+ <td style="font-family:monospace">${n.observed_ip || '—'}</td>
+ <td style="font-family:monospace;color:var(--text-dim)">
+ ${n.endpoint_hint || '—'}
+ </td>
+ <td>${n.last_seen ? new Date(n.last_seen).toLocaleString() : '—'}</td>
+ <td>
+ <span class="badge ${n.online ? 'badge-ok' : ''}">
+ ${n.online ? t('admin.node_online') : t('admin.node_offline')}
+ </span>
+ </td>
+ </tr>
+ `)}
+ </tbody>
+ </table>
+ `}
+
+ ${tab === 'logs' && html`
+ <div class="admin-toolbar">
+ <select class="admin-select" value=${logEvent} onChange=${e => {
+ setLogEvent(e.target.value);
+ setLogOffset(0);
+ loadLogs(e.target.value, 0);
+ }}>
+ <option value="">${t('admin.filter_all')}</option>
+ ${['login', 'login_fail', 'account_create', 'token_refresh', 'group_create',
+ 'group_join', 'group_leave', 'node_announce', 'revoke_user', 'revoke_group',
+ 'admin_user_update', 'admin_group_update'].map(ev => html`
+ <option key=${ev} value=${ev}>${ev}</option>
+ `)}
+ </select>
+ </div>
+ <table class="admin-table">
+ <thead><tr>
+ <th>${t('admin.col_time')}</th>
+ <th>${t('admin.col_user')}</th>
+ <th>${t('admin.col_event')}</th>
+ <th>${t('admin.col_ip')}</th>
+ <th>${t('admin.col_detail')}</th>
+ </tr></thead>
+ <tbody>
+ ${logs.length === 0 && html`<tr><td colspan="5" class="admin-empty">${t('admin.no_logs')}</td></tr>`}
+ ${logs.map(lg => html`
+ <tr key=${lg.id}>
+ <td style="white-space:nowrap">${new Date(lg.timestamp).toLocaleString()}</td>
+ <td>${lg.username || ''}</td>
+ <td><span class="badge">${lg.event}</span></td>
+ <td>${lg.ip_address}</td>
+ <td>${lg.detail || ''}</td>
+ </tr>
+ `)}
+ </tbody>
+ </table>
+ ${logs.length > 0 && logs.length % 50 === 0 && html`
+ <button class="admin-btn admin-load-more" onClick=${() => {
+ const next = logOffset + 50;
+ setLogOffset(next);
+ loadLogs(logEvent, next, true);
+ }}>${t('admin.btn_load_more')}</button>
+ `}
+ `}
+
+ ${tab === 'blocklist' && html`
+ <${BlocklistForm} onAdd=${addToBlocklist} />
+ <table class="admin-table">
+ <thead><tr>
+ <th>${t('admin.col_hash')}</th>
+ <th>${t('admin.col_reason')}</th>
+ <th>${t('admin.col_date')}</th>
+ <th>${t('admin.col_added_by')}</th>
+ <th>${t('admin.col_actions')}</th>
+ </tr></thead>
+ <tbody>
+ ${blocklist.length === 0 && html`<tr><td colspan="5" class="admin-empty">${t('admin.no_blocked')}</td></tr>`}
+ ${blocklist.map(b => html`
+ <tr key=${b.hash}>
+ <td style="font-family:monospace;font-size:0.8em">${b.hash.slice(0, 16)}...</td>
+ <td>${b.reason}</td>
+ <td>${new Date(b.added_at).toLocaleDateString()}</td>
+ <td>${b.added_by || ''}</td>
+ <td>
+ <button class="admin-btn" onClick=${() => removeFromBlocklist(b.hash)}>${t('admin.btn_unblock')}</button>
+ </td>
+ </tr>
+ `)}
+ </tbody>
+ </table>
+ `}
+
+ ${detailUser && html`
+ <div class="admin-detail-overlay" onClick=${e => {
+ if (e.target.classList.contains('admin-detail-overlay')) setDetailUser(null);
+ }}>
+ <div class="admin-detail-card">
+ <h3>${t('admin.user_detail')}</h3>
+ ${[
+ ['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`
+ <div class="admin-detail-row" key=${label}>
+ <span class="admin-detail-label">${t(label)}</span>
+ <span class="admin-detail-value">${val}</span>
+ </div>
+ `)}
+ <button class="admin-btn" style="margin-top:16px;width:100%"
+ onClick=${() => setDetailUser(null)}>${t('admin.btn_close')}</button>
+ </div>
+ </div>
+ `}
+ </div>
+ `;
+}
+
+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`
+ <form class="blocklist-form" onSubmit=${submit}>
+ <input type="text" placeholder="${t('admin.hash_placeholder')}"
+ value=${hash} onInput=${e => setHash(e.target.value)}
+ pattern="[0-9a-f]{64}" required />
+ <input type="text" placeholder="${t('admin.reason_placeholder')}"
+ value=${reason} onInput=${e => setReason(e.target.value)} required />
+ <button class="admin-btn" type="submit">${t('admin.btn_block')}</button>
+ </form>
+ `;
+}