aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/app.js
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/app.js')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js369
1 files changed, 353 insertions, 16 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js
index 2179e99..d36a0a1 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js
@@ -113,7 +113,8 @@ function Nav({ user, theme, onThemeToggle, onLogout, onMenuToggle }) {
// ── Sidebar ──────────────────────────────────────────────────────────────────
-function Sidebar({ groups, route, menuOpen }) {
+function Sidebar({ groups, route, menuOpen, role }) {
+ const isStaff = role === 'moderator' || role === 'admin';
return html`
<aside class="sidebar ${menuOpen ? 'open' : ''}">
<div class="sidebar-section">
@@ -135,6 +136,10 @@ function Sidebar({ groups, route, menuOpen }) {
href="#/explore">${t('sidebar.public_groups')}</a>
<a class="sidebar-item ${route === '/settings' ? 'active' : ''}"
href="#/settings">${t('sidebar.settings')}</a>
+ ${isStaff && html`
+ <a class="sidebar-item ${route === '/admin' ? 'active' : ''}"
+ href="#/admin">${t('sidebar.admin')}</a>
+ `}
</div>
</aside>
`;
@@ -994,6 +999,338 @@ function SettingsPage({ user, theme, onThemeChange }) {
`;
}
+// ── Admin Panel ─────────────────────────────────────────────────────────────
+
+function AdminPage({ token }) {
+ const [tab, setTab] = useState('stats');
+ const [stats, setStats] = 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 [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 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 === 'stats') loadStats();
+ else if (tab === 'users') loadUsers(userSearch);
+ else if (tab === 'groups') loadGroups();
+ else if (tab === 'logs') { setLogOffset(0); loadLogs(logEvent, 0); }
+ else if (tab === 'blocklist') loadBlocklist();
+ }, [tab]);
+
+ 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 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 = ['stats', 'users', 'groups', 'logs', 'blocklist'];
+
+ 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 === '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}</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
+ }
+ </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>${g.name}</td>
+ <td><span class="badge">${g.visibility}</span></td>
+ <td>${g.member_count}</td>
+ <td><span class="badge">${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
+ }
+ </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_event')}</th>
+ <th>${t('admin.col_ip')}</th>
+ <th>${t('admin.col_detail')}</th>
+ </tr></thead>
+ <tbody>
+ ${logs.length === 0 && html`<tr><td colspan="4" 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><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>
+ `;
+}
+
// ── App ──────────────────────────────────────────────────────────────────────
function App() {
@@ -1026,29 +1363,24 @@ function App() {
const authCtx = {
user,
login: async (username, password) => {
+ let token, refreshToken;
if (window.MeshBayKeys) {
const data = await window.MeshBayKeys.loginAndRecover(username, password);
_sessionKeys = { skXB64: data.skXB64, skEdB64: data.skEdB64 };
- const u = {
- username,
- token: data.accessToken,
- refreshToken: data.refreshToken,
- };
- setUser(u);
- saveAuth(u);
+ token = data.accessToken;
+ refreshToken = data.refreshToken;
} else {
const data = await hubFetch('/v1/users/login', {
method: 'POST',
body: { username, password },
});
- const u = {
- username,
- token: data.access_token,
- refreshToken: data.refresh_token,
- };
- setUser(u);
- saveAuth(u);
+ token = data.access_token;
+ refreshToken = data.refresh_token;
}
+ const me = await hubFetch('/v1/users/me', { token });
+ const u = { username, token, refreshToken, role: me.role };
+ setUser(u);
+ saveAuth(u);
},
logout: () => {
setUser(null);
@@ -1073,6 +1405,10 @@ function App() {
page = html`<${GroupPage}
groupId=${groupId} group=${group} token=${user.token}
username=${user.username} />`;
+ } else if (route === '/admin') {
+ page = (user.role === 'moderator' || user.role === 'admin')
+ ? html`<${AdminPage} token=${user.token} />`
+ : html`<${HomePage} groups=${groups} />`;
} else if (route === '/settings') {
page = html`<${SettingsPage} user=${user} theme=${theme}
onThemeChange=${setTheme} />`;
@@ -1092,7 +1428,8 @@ function App() {
${user && html`<${Sidebar}
groups=${groups}
route=${route}
- menuOpen=${menuOpen} />`}
+ menuOpen=${menuOpen}
+ role=${user.role} />`}
${menuOpen && html`<div class="overlay visible"
onClick=${() => setMenuOpen(false)} />`}
<main class="main">