diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-30 23:57:57 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-30 23:57:57 +0200 |
| commit | f6e80064ff8c4869a6ba2cef908cc54326948463 (patch) | |
| tree | 1a04c004ed8006ec8c486a5c8a4d632ee257d395 /packages/meshbay-hub | |
| parent | 3706dc15ff3be7acf2ade77977a9fca7a8b4efc5 (diff) | |
| download | meshbay-f6e80064ff8c4869a6ba2cef908cc54326948463.tar.gz | |
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 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub')
5 files changed, 990 insertions, 954 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> + `; +} diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js index 611cb27..99eb7ab 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js @@ -5,7 +5,6 @@ import { import { t, getLocale, setLocale, initLocale, LOCALES } from './i18n.js'; import { ZipStream, entriesUnder } from './zipstream.js'; import { transfers, formatSpeed } from './transfers.js'; -import * as downloads from './downloads.js'; import * as platform from './platform.js'; import { Icon } from './icon.js'; import { formatSize } from './file-utils.js'; @@ -20,6 +19,8 @@ import { SearchPage, ConnectionPool } from './search-page.js'; import { MusicPlayerBar } from './music-player.js'; import { GroupName } from './group-name.js'; import { APPS } from './apps.js'; +import { SettingsPage } from './settings-page.js'; +import { ProfilePage } from './profile-page.js'; // ── Constants ──────────────────────────────────────────────────────────────── @@ -1325,959 +1326,19 @@ const THEME_OPTIONS = ['light', 'dark', 'system']; // ── Profile Page ──────────────────────────────────────────────────────────── // -// Split out of Settings: these four are about *you* — who the account is, the -// node you operate, the node identities this browser has pinned, and closing -// the account. Settings is about how the application behaves. Mixing them put -// an irreversible button two scrolls under a theme picker. - -function ProfilePage({ user, onLogout }) { - const [nodeKey, setNodeKey] = useState(''); - const [currentNodeKey, setCurrentNodeKey] = useState(null); - const [nodeKeyStatus, setNodeKeyStatus] = useState(''); - const [nodeKeyLoading, setNodeKeyLoading] = useState(false); - const [email, setEmail] = useState(''); - const [emailDraft, setEmailDraft] = useState(''); - const [emailEditing, setEmailEditing] = useState(false); - const [emailSaving, setEmailSaving] = useState(false); - const [emailStatus, setEmailStatus] = useState(''); - const [pinCount, setPinCount] = useState( - () => (window.MeshBayTransport?.pinnedNodeCount?.() ?? 0)); - const [delOpen, setDelOpen] = useState(false); - const [delPass, setDelPass] = useState(''); - const [delError, setDelError] = useState(''); - const [deleting, setDeleting] = useState(false); - - const deleteAccount = useCallback(async (e) => { - e.preventDefault(); - setDeleting(true); - setDelError(''); - try { - const authKey = await window.MeshBayKeys.deriveAuthKey(delPass, user.username); - await hubFetch('/v1/users/me', { - method: 'DELETE', token: user.token, body: { auth_key: authKey }, - }); - onLogout(); - } catch (err) { - setDelError(err.message); - } finally { - setDeleting(false); - } - }, [delPass, user]); - - const clearPins = useCallback(() => { - window.MeshBayTransport?.clearNodePin?.(); - setPinCount(window.MeshBayTransport?.pinnedNodeCount?.() ?? 0); - }, []); - - useEffect(() => { - hubFetch('/v1/users/me', { token: user.token }) - .then(data => { - if (data.email) { setEmail(data.email); setEmailDraft(data.email); } - }) - .catch(() => {}); - hubFetch(`/v1/users/${user.username}/pubkeys`, { token: user.token }) - .then(data => { - if (data.pk_node_ed25519) setCurrentNodeKey(data.pk_node_ed25519); - }) - .catch(() => {}); - }, [user.username, user.token]); - - const saveEmail = useCallback(async () => { - const val = emailDraft.trim(); - if (!val || val === email) { setEmailEditing(false); return; } - setEmailSaving(true); - setEmailStatus(''); - try { - await hubFetch('/v1/users/me', { - method: 'PATCH', token: user.token, body: { email: val }, - }); - setEmail(val); - setEmailEditing(false); - setEmailStatus(t('settings.email_saved')); - setTimeout(() => setEmailStatus(''), 3000); - } catch (e) { - setEmailStatus(e.message); - } finally { - setEmailSaving(false); - } - }, [emailDraft, email, user.token]); - - const submitNodeKey = useCallback(async () => { - const key = nodeKey.trim(); - if (!key) return; - setNodeKeyLoading(true); - setNodeKeyStatus(''); - try { - await hubFetch('/v1/users/me/node_key', { - method: 'PUT', token: user.token, - body: { pk_node_ed25519: key }, - }); - setCurrentNodeKey(key); - setNodeKey(''); - setNodeKeyStatus(t('settings.node_key_success')); - } catch (e) { - setNodeKeyStatus(e.message); - } finally { - setNodeKeyLoading(false); - } - }, [nodeKey, user.token]); - - return html` - <div> - <h2>${t('profile.title')}</h2> - - <div class="settings-section"> - <h3 class="settings-heading">${t('settings.profile')}</h3> - <div class="settings-row"> - <span class="settings-label">${t('settings.username')}</span> - <span class="settings-value">${user.username}</span> - </div> - <div class="settings-row"> - <span class="settings-label">${t('settings.email')}</span> - ${emailEditing - ? html`<span style="display:flex;gap:8px;align-items:center"> - <input type="email" value=${emailDraft} - onInput=${e => setEmailDraft(e.target.value)} - onKeyDown=${e => e.key === 'Enter' && saveEmail()} - style="font-size:0.9em;padding:4px 8px;border:1px solid var(--border);border-radius:4px" /> - <button class="admin-btn" onClick=${saveEmail} - disabled=${emailSaving}>${t('settings.email_save')}</button> - <button class="btn-secondary" onClick=${() => { - setEmailEditing(false); setEmailDraft(email); - }}>${t('settings.cancel')}</button> - </span>` - : html`<span style="display:flex;gap:8px;align-items:center"> - <span class="settings-value">${email || '—'}</span> - <button class="link-btn" onClick=${() => setEmailEditing(true)}> - <${Icon} name="pencil" /></button> - </span>` - } - </div> - ${emailStatus && html`<p class="settings-hint" style="margin-top:4px">${emailStatus}</p>`} - </div> - - <div class="settings-section"> - <h3 class="settings-heading">${t('settings.node_key')}</h3> - <p style="font-size:0.85em;color:var(--text-dim);margin-bottom:8px">${t('settings.node_key_desc')}</p> - ${currentNodeKey && html` - <div class="settings-row" style="margin-top:8px"> - <span class="settings-label">${t('settings.node_key_current')}</span> - <code class="settings-value" style="font-size:0.8em;word-break:break-all">${currentNodeKey}</code> - </div> - `} - <div style="display:flex;gap:8px;margin-top:10px;align-items:center"> - <input type="text" class="admin-search" style="flex:1;font-family:monospace;font-size:0.85em" - placeholder=${t('settings.node_key_placeholder')} - value=${nodeKey} onInput=${e => setNodeKey(e.target.value)} - onKeyDown=${e => e.key === 'Enter' && submitNodeKey()} /> - <button class="admin-btn" onClick=${submitNodeKey} - disabled=${nodeKeyLoading || !nodeKey.trim()}> - ${t('settings.node_key_submit')} - </button> - </div> - ${nodeKeyStatus && html` - <p style="margin-top:6px;font-size:0.85em;color:${nodeKeyStatus === t('settings.node_key_success') ? 'var(--success)' : 'var(--error)'}"> - ${nodeKeyStatus} - </p> - `} - </div> - - <div class="settings-section"> - <h3 class="settings-heading">${t('settings.node_pins')}</h3> - <p class="settings-hint">${t('settings.node_pins_hint')}</p> - <div class="settings-row"> - <span class="settings-label">${t('settings.node_pins_count', { n: pinCount })}</span> - <button class="btn-secondary" onClick=${clearPins} disabled=${pinCount === 0}> - ${t('settings.node_pins_clear')} - </button> - </div> - </div> - - <div class="settings-section"> - <h3 class="settings-heading">${t('settings.danger')}</h3> - <p class="settings-hint">${t('settings.delete_hint')}</p> - ${delError && html`<p class="error-msg">${delError}</p>`} - ${!delOpen - ? html`<button class="btn-danger" onClick=${() => setDelOpen(true)}> - ${t('settings.delete_account')} - </button>` - : html` - <form onSubmit=${deleteAccount}> - <p class="settings-hint">${t('settings.delete_confirm')}</p> - <div style="display:flex;gap:8px;margin-top:8px"> - <input type="password" placeholder=${t('login.password')} - autocomplete="current-password" - value=${delPass} onInput=${e => setDelPass(e.target.value)} required /> - <button class="btn-danger" type="submit" disabled=${deleting}> - ${deleting ? '…' : t('settings.delete_confirm_btn')} - </button> - <button class="btn-secondary" type="button" - onClick=${() => { setDelOpen(false); setDelPass(''); setDelError(''); }}> - ${t('settings.cancel')} - </button> - </div> - </form> - `} - </div> - - </div> - `; -} - -function SettingsPage({ user, theme, onThemeChange, groups, onPrefsChange }) { - const [locale, setLoc] = useState(getLocale); - const [muted, setMuted] = useState( - () => Object.fromEntries((groups || []).map(g => [g.id, !!g.muted]))); - const [globalMute, setGlobalMute] = useState(false); - const [defaultTab, setDefaultTab] = useState('chat'); - // Off by default (musicbay.md §2.2): the ordinary expectation, matching - // Spotify/Deezer, is that the phone locks on its own idle timer while - // listening. This is for whoever would rather trade battery for it — - // e.g. to ride out the WebRTC screen-lock reconnect gap without waiting - // on the automatic recovery at all. - const [keepScreenOnAudio, setKeepScreenOnAudio] = useState(false); - - const onLocaleChange = useCallback((e) => { - const code = e.target.value; - setLocale(code); - setLoc(code); - window.location.reload(); - }, []); - - const onThemeSelect = useCallback((e) => { - onThemeChange(e.target.value); - }, [onThemeChange]); +// ── Lazy-loaded Admin page (admin/moderator only) ───────────────────────── +let _AdminPage = null; +function LazyAdminPage(props) { + const [loaded, setLoaded] = useState(!!_AdminPage); useEffect(() => { - hubFetch('/v1/users/me/preferences', { token: user.token }) - .then(prefs => { - if (prefs.notifications_disabled === 'true') setGlobalMute(true); - if (prefs.default_tab) setDefaultTab(prefs.default_tab); - if (prefs.music_keep_screen_on === 'true') setKeepScreenOnAudio(true); - }) - .catch(() => {}); - }, [user.token]); - - const toggleKeepScreenOnAudio = useCallback(async () => { - const next = !keepScreenOnAudio; - setKeepScreenOnAudio(next); - try { - await hubFetch('/v1/users/me/preferences/music_keep_screen_on', { - method: 'PUT', token: user.token, - body: { value: next ? 'true' : 'false' }, - }); - // A string, matching what a fresh page load reads from the hub - // (prefs.music_keep_screen_on === 'true' above) — music-player.js - // compares against that same string, and userPrefs is one shared bag - // fed from both this immediate update and that load. - if (onPrefsChange) onPrefsChange({ music_keep_screen_on: next ? 'true' : 'false' }); - } catch (err) { - setKeepScreenOnAudio(!next); - } - }, [keepScreenOnAudio, user.token, onPrefsChange]); - - const toggleGlobalMute = useCallback(async () => { - const next = !globalMute; - setGlobalMute(next); - try { - await hubFetch('/v1/users/me/preferences/notifications_disabled', { - method: 'PUT', token: user.token, - body: { value: next ? 'true' : 'false' }, - }); - if (onPrefsChange) onPrefsChange({ notifications_disabled: next }); - } catch (err) { - setGlobalMute(!next); - } - }, [globalMute, user.token, onPrefsChange]); - - const toggleMute = useCallback(async (gid) => { - const next = !muted[gid]; - setMuted(prev => ({ ...prev, [gid]: next })); - try { - await hubFetch(`/v1/groups/${gid}/mute`, { - method: 'POST', token: user.token, body: { muted: next }, - }); - } catch (err) { - setMuted(prev => ({ ...prev, [gid]: !next })); - } - }, [muted, user.token]); - - const changeDefaultTab = useCallback(async (e) => { - const val = e.target.value; - setDefaultTab(val); - try { - await hubFetch('/v1/users/me/preferences/default_tab', { - method: 'PUT', token: user.token, - body: { value: val }, - }); - if (onPrefsChange) onPrefsChange({ default_tab: val }); - } catch { setDefaultTab(defaultTab); } - }, [defaultTab, user.token, onPrefsChange]); - - const [dlMode, setDlMode] = useState(() => downloads.getMode()); - const [dlDir, setDlDir] = useState(null); - const [dlError, setDlError] = useState(''); - // Read from the hub rather than written here: the two constants that used to - // sit in this markup said 0.1.0 and MNP 0.1 long after both had moved on. - const [hubInfo, setHubInfo] = useState(null); - // On a desktop build, whether the OS is really holding the keys. Electron's - // safeStorage falls back to a fixed key when no keyring is running — a - // headless session, a minimal desktop — and does it silently. Somebody who - // believes the OS is protecting their keys deserves to be told when it is not. - const [keyBackend, setKeyBackend] = useState(''); - // Changing the hub after the first run. Without this a typo on the first - // screen was permanent: the prompt only appears when no hub is set, so a - // wrong one left editing a JSON file by hand as the only way out. - const [hubInput, setHubInput] = useState(''); - const [hubError, setHubError] = useState(''); - useEffect(() => { - if (!platform.secrets.available) return; - platform.secrets.backend().then(setKeyBackend).catch(() => {}); - }, []); - - useEffect(() => { - hubFetch('/v1/hub/version').then(setHubInfo).catch(() => {}); - }, []); - - useEffect(() => { - // The desktop build remembers a path; the browser remembers a handle. Both - // answer "where do downloads go", and the row below renders either. - if (platform.folder.available) platform.folder.get().then(setDlDir); - else downloads.savedDirectory().then(setDlDir); - }, []); - - const pickFolder = useCallback(async () => { - try { - if (platform.folder.available) { - const dir = await platform.folder.choose(); - if (dir) setDlDir(dir); - return; - } - const handle = await downloads.chooseDirectory(); - setDlDir(handle); - } catch (err) { - // Reported where the folder controls are. This used to be written into - // the node-key status, two sections away, where nobody was looking. - if (err.name !== 'AbortError') setDlError(err.message); + if (!_AdminPage) { + import('./admin-page.js').then(m => { _AdminPage = m.AdminPage; setLoaded(true); }); } }, []); - - - return html` - <div> - <h2>${t('settings.title')}</h2> - - <div class="settings-section"> - <h3 class="settings-heading">${t('settings.downloads')}</h3> - ${(downloads.SUPPORTED || platform.folder.available) && html` - <label class="settings-choice"> - <input type="radio" name="dlmode" checked=${dlMode === 'auto'} - onChange=${() => { downloads.setMode('auto'); setDlMode('auto'); }} /> - <span> - <strong>${t('settings.dl_auto')}</strong> - <span class="settings-hint">${t('settings.dl_auto_hint')}</span> - </span> - </label> - <label class="settings-choice"> - <input type="radio" name="dlmode" checked=${dlMode === 'ask'} - onChange=${() => { downloads.setMode('ask'); setDlMode('ask'); }} /> - <span> - <strong>${t('settings.dl_ask')}</strong> - <span class="settings-hint">${t('settings.dl_ask_hint')}</span> - </span> - </label> - <div class="settings-row" style="margin-top:10px"> - <span class="settings-label"> - ${dlDir ? t('settings.dl_folder', - { name: dlDir.name || String(dlDir) }) - : t('settings.dl_no_folder')} - </span> - <span> - <button class="admin-btn" onClick=${pickFolder}> - ${dlDir ? t('settings.dl_change') : t('settings.dl_choose')} - </button> - ${dlDir && !dlDir.isDefault && html` - <button class="btn-secondary" onClick=${async () => { - if (platform.folder.available) await platform.folder.forget(); - else await downloads.forgetDirectory(); - setDlDir(null); - }}>${t('settings.dl_forget')}</button> - `} - </span> - </div> - ${dlError && html`<p class="error-msg">${dlError}</p>`} - `} - </div> - - <div class="settings-section"> - <h3 class="settings-heading">${t('settings.appearance')}</h3> - <div class="settings-row"> - <span class="settings-label">${t('settings.theme')}</span> - <select class="settings-select" value=${theme} onChange=${onThemeSelect}> - <option value="light">${t('settings.theme_light')}</option> - <option value="dark">${t('settings.theme_dark')}</option> - <option value="system">${t('settings.theme_system')}</option> - </select> - </div> - <div class="settings-row"> - <span class="settings-label">${t('settings.language')}</span> - <select class="settings-select" value=${locale} onChange=${onLocaleChange}> - ${LOCALES.map(l => html` - <option key=${l.code} value=${l.code}>${l.name}</option> - `)} - </select> - </div> - </div> - - <div class="settings-section"> - <h3 class="settings-heading">${t('settings.groups')}</h3> - <div class="settings-row"> - <span class="settings-label">${t('settings.notif_global_disable')}</span> - <label class="toggle-switch"> - <input type="checkbox" checked=${globalMute} - onChange=${toggleGlobalMute} /> - <span class="toggle-switch-track"><span class="toggle-switch-thumb"></span></span> - </label> - </div> - ${!globalMute && html` - <p class="settings-hint" style="margin-bottom:8px">${t('settings.notif_global_hint')}</p> - ${groups.map(g => html` - <div class="settings-row" key=${g.id}> - <span class="settings-label">${g.name}</span> - <label class="toggle-switch"> - <input type="checkbox" checked=${!muted[g.id]} - onChange=${() => toggleMute(g.id)} /> - <span class="toggle-switch-track"><span class="toggle-switch-thumb"></span></span> - </label> - </div> - `)} - `} - </div> - - <div class="settings-section"> - <h3 class="settings-heading">${t('settings.defaults')}</h3> - <div class="settings-row"> - <span class="settings-label">${t('settings.default_tab')}</span> - <select class="settings-select" value=${defaultTab} - onChange=${changeDefaultTab}> - ${APPS.map((a) => html`<option key=${a.key} value=${a.key}>${t(a.labelKey)}</option>`)} - <option value="settings">${t('group.tab_settings')}</option> - </select> - </div> - <p class="settings-hint">${t('settings.default_tab_hint')}</p> - <div class="settings-row"> - <span class="settings-label">${t('settings.music_keep_screen_on')}</span> - <label class="toggle-switch"> - <input type="checkbox" checked=${keepScreenOnAudio} - onChange=${toggleKeepScreenOnAudio} /> - <span class="toggle-switch-track"><span class="toggle-switch-thumb"></span></span> - </label> - </div> - <p class="settings-hint">${t('settings.music_keep_screen_on_hint')}</p> - </div> - - ${platform.isNative && html` - <div class="settings-section"> - <h3 class="settings-heading">${t('settings.hub_heading')}</h3> - <div class="settings-row"> - <span class="settings-label">${t('settings.hub_current')}</span> - <span class="settings-value">${platform.hubBase() || '—'}</span> - </div> - <p class="settings-hint">${t('settings.hub_hint')}</p> - <form onSubmit=${async (e) => { - e.preventDefault(); - setHubError(''); - try { - await window.meshbay.setHubBase(hubInput.trim()); - } catch (err) { setHubError(platform.bridgeMessage(err)); } - }} style="display:flex;gap:8px"> - <input type="text" placeholder=${platform.hubBase()} - value=${hubInput} onInput=${e => setHubInput(e.target.value)} /> - <button class="admin-btn" type="submit">${t('settings.hub_change')}</button> - </form> - ${hubError && html`<p class="error-msg">${hubError}</p>`} - </div> - `} - - ${keyBackend && html` - <div class="settings-section"> - <h3 class="settings-heading">${t('settings.keys_heading')}</h3> - <div class="settings-row"> - <span class="settings-label">${t('settings.keys_where')}</span> - <span class="settings-value">${keyBackend}</span> - </div> - ${keyBackend === 'unprotected_fallback' && html` - <p class="error-msg">${t('settings.keys_unprotected')}</p> - `} - ${keyBackend === 'unavailable' && html` - <p class="error-msg">${t('settings.keys_unavailable')}</p> - `} - </div> - `} - - <div class="settings-section"> - <h3 class="settings-heading">${t('settings.about')}</h3> - <div class="settings-row"> - <span class="settings-label">${t('settings.version')}</span> - <span class="settings-value">${hubInfo ? hubInfo.hub : '—'}</span> - </div> - <div class="settings-row"> - <span class="settings-label">${t('settings.protocol')}</span> - <span class="settings-value"> - ${hubInfo ? `MNP ${hubInfo.mnp} / MHP ${hubInfo.mhp}` : '—'} - </span> - </div> - </div> - </div> - `; -} - -// ── Admin Panel ───────────────────────────────────────────────────────────── - -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> - `; + if (!loaded) return html`<div class="page-content"> + <p class="page-message"><span class="spinner"></span></p></div>`; + return html`<${_AdminPage} ...${props} />`; } // ── Lazy-loaded Node page (Electron only) ─────────────────────────────────── @@ -2670,7 +1731,7 @@ function App() { onPlayQueue=${handlePlayQueue} onStopMusic=${handleStopMusic} />`; } else if (route === '/admin') { page = (user.role === 'moderator' || user.role === 'admin') - ? html`<${AdminPage} token=${user.token} role=${user.role} />` + ? html`<${LazyAdminPage} token=${user.token} role=${user.role} />` : html`<${HomePage} groups=${groups} notifications=${notifications} onMarkRead=${markRead} onPurge=${purgeNotifications} />`; } else if (route === '/settings') { diff --git a/packages/meshbay-hub/src/meshbay_hub/static/profile-page.js b/packages/meshbay-hub/src/meshbay_hub/static/profile-page.js new file mode 100644 index 0000000..3d3e578 --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/static/profile-page.js @@ -0,0 +1,200 @@ +import { + html, useState, useEffect, useCallback, +} from './vendor/htm-preact.js'; +import { t } from './i18n.js'; +import { Icon } from './icon.js'; +import { hubFetch } from './hub-client.js'; + +export function ProfilePage({ user, onLogout }) { + const [nodeKey, setNodeKey] = useState(''); + const [currentNodeKey, setCurrentNodeKey] = useState(null); + const [nodeKeyStatus, setNodeKeyStatus] = useState(''); + const [nodeKeyLoading, setNodeKeyLoading] = useState(false); + const [email, setEmail] = useState(''); + const [emailDraft, setEmailDraft] = useState(''); + const [emailEditing, setEmailEditing] = useState(false); + const [emailSaving, setEmailSaving] = useState(false); + const [emailStatus, setEmailStatus] = useState(''); + const [pinCount, setPinCount] = useState( + () => (window.MeshBayTransport?.pinnedNodeCount?.() ?? 0)); + const [delOpen, setDelOpen] = useState(false); + const [delPass, setDelPass] = useState(''); + const [delError, setDelError] = useState(''); + const [deleting, setDeleting] = useState(false); + + const deleteAccount = useCallback(async (e) => { + e.preventDefault(); + setDeleting(true); + setDelError(''); + try { + const authKey = await window.MeshBayKeys.deriveAuthKey(delPass, user.username); + await hubFetch('/v1/users/me', { + method: 'DELETE', token: user.token, body: { auth_key: authKey }, + }); + onLogout(); + } catch (err) { + setDelError(err.message); + } finally { + setDeleting(false); + } + }, [delPass, user]); + + const clearPins = useCallback(() => { + window.MeshBayTransport?.clearNodePin?.(); + setPinCount(window.MeshBayTransport?.pinnedNodeCount?.() ?? 0); + }, []); + + useEffect(() => { + hubFetch('/v1/users/me', { token: user.token }) + .then(data => { + if (data.email) { setEmail(data.email); setEmailDraft(data.email); } + }) + .catch(() => {}); + hubFetch(`/v1/users/${user.username}/pubkeys`, { token: user.token }) + .then(data => { + if (data.pk_node_ed25519) setCurrentNodeKey(data.pk_node_ed25519); + }) + .catch(() => {}); + }, [user.username, user.token]); + + const saveEmail = useCallback(async () => { + const val = emailDraft.trim(); + if (!val || val === email) { setEmailEditing(false); return; } + setEmailSaving(true); + setEmailStatus(''); + try { + await hubFetch('/v1/users/me', { + method: 'PATCH', token: user.token, body: { email: val }, + }); + setEmail(val); + setEmailEditing(false); + setEmailStatus(t('settings.email_saved')); + setTimeout(() => setEmailStatus(''), 3000); + } catch (e) { + setEmailStatus(e.message); + } finally { + setEmailSaving(false); + } + }, [emailDraft, email, user.token]); + + const submitNodeKey = useCallback(async () => { + const key = nodeKey.trim(); + if (!key) return; + setNodeKeyLoading(true); + setNodeKeyStatus(''); + try { + await hubFetch('/v1/users/me/node_key', { + method: 'PUT', token: user.token, + body: { pk_node_ed25519: key }, + }); + setCurrentNodeKey(key); + setNodeKey(''); + setNodeKeyStatus(t('settings.node_key_success')); + } catch (e) { + setNodeKeyStatus(e.message); + } finally { + setNodeKeyLoading(false); + } + }, [nodeKey, user.token]); + + return html` + <div> + <h2>${t('profile.title')}</h2> + + <div class="settings-section"> + <h3 class="settings-heading">${t('settings.profile')}</h3> + <div class="settings-row"> + <span class="settings-label">${t('settings.username')}</span> + <span class="settings-value">${user.username}</span> + </div> + <div class="settings-row"> + <span class="settings-label">${t('settings.email')}</span> + ${emailEditing + ? html`<span style="display:flex;gap:8px;align-items:center"> + <input type="email" value=${emailDraft} + onInput=${e => setEmailDraft(e.target.value)} + onKeyDown=${e => e.key === 'Enter' && saveEmail()} + style="font-size:0.9em;padding:4px 8px;border:1px solid var(--border);border-radius:4px" /> + <button class="admin-btn" onClick=${saveEmail} + disabled=${emailSaving}>${t('settings.email_save')}</button> + <button class="btn-secondary" onClick=${() => { + setEmailEditing(false); setEmailDraft(email); + }}>${t('settings.cancel')}</button> + </span>` + : html`<span style="display:flex;gap:8px;align-items:center"> + <span class="settings-value">${email || '—'}</span> + <button class="link-btn" onClick=${() => setEmailEditing(true)}> + <${Icon} name="pencil" /></button> + </span>` + } + </div> + ${emailStatus && html`<p class="settings-hint" style="margin-top:4px">${emailStatus}</p>`} + </div> + + <div class="settings-section"> + <h3 class="settings-heading">${t('settings.node_key')}</h3> + <p style="font-size:0.85em;color:var(--text-dim);margin-bottom:8px">${t('settings.node_key_desc')}</p> + ${currentNodeKey && html` + <div class="settings-row" style="margin-top:8px"> + <span class="settings-label">${t('settings.node_key_current')}</span> + <code class="settings-value" style="font-size:0.8em;word-break:break-all">${currentNodeKey}</code> + </div> + `} + <div style="display:flex;gap:8px;margin-top:10px;align-items:center"> + <input type="text" class="admin-search" style="flex:1;font-family:monospace;font-size:0.85em" + placeholder=${t('settings.node_key_placeholder')} + value=${nodeKey} onInput=${e => setNodeKey(e.target.value)} + onKeyDown=${e => e.key === 'Enter' && submitNodeKey()} /> + <button class="admin-btn" onClick=${submitNodeKey} + disabled=${nodeKeyLoading || !nodeKey.trim()}> + ${t('settings.node_key_submit')} + </button> + </div> + ${nodeKeyStatus && html` + <p style="margin-top:6px;font-size:0.85em;color:${nodeKeyStatus === t('settings.node_key_success') ? 'var(--success)' : 'var(--error)'}"> + ${nodeKeyStatus} + </p> + `} + </div> + + <div class="settings-section"> + <h3 class="settings-heading">${t('settings.node_pins')}</h3> + <p class="settings-hint">${t('settings.node_pins_hint')}</p> + <div class="settings-row"> + <span class="settings-label">${t('settings.node_pins_count', { n: pinCount })}</span> + <button class="btn-secondary" onClick=${clearPins} disabled=${pinCount === 0}> + ${t('settings.node_pins_clear')} + </button> + </div> + </div> + + <div class="settings-section"> + <h3 class="settings-heading">${t('settings.danger')}</h3> + <p class="settings-hint">${t('settings.delete_hint')}</p> + ${delError && html`<p class="error-msg">${delError}</p>`} + ${!delOpen + ? html`<button class="btn-danger" onClick=${() => setDelOpen(true)}> + ${t('settings.delete_account')} + </button>` + : html` + <form onSubmit=${deleteAccount}> + <p class="settings-hint">${t('settings.delete_confirm')}</p> + <div style="display:flex;gap:8px;margin-top:8px"> + <input type="password" placeholder=${t('login.password')} + autocomplete="current-password" + value=${delPass} onInput=${e => setDelPass(e.target.value)} required /> + <button class="btn-danger" type="submit" disabled=${deleting}> + ${deleting ? '…' : t('settings.delete_confirm_btn')} + </button> + <button class="btn-secondary" type="button" + onClick=${() => { setDelOpen(false); setDelPass(''); setDelError(''); }}> + ${t('settings.cancel')} + </button> + </div> + </form> + `} + </div> + + </div> + `; +} diff --git a/packages/meshbay-hub/src/meshbay_hub/static/settings-page.js b/packages/meshbay-hub/src/meshbay_hub/static/settings-page.js new file mode 100644 index 0000000..c6a73da --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/static/settings-page.js @@ -0,0 +1,316 @@ +import { + html, useState, useEffect, useCallback, +} from './vendor/htm-preact.js'; +import { t, getLocale, setLocale, LOCALES } from './i18n.js'; +import * as downloads from './downloads.js'; +import * as platform from './platform.js'; +import { hubFetch } from './hub-client.js'; +import { APPS } from './apps.js'; + +export function SettingsPage({ user, theme, onThemeChange, groups, onPrefsChange }) { + const [locale, setLoc] = useState(getLocale); + const [muted, setMuted] = useState( + () => Object.fromEntries((groups || []).map(g => [g.id, !!g.muted]))); + const [globalMute, setGlobalMute] = useState(false); + const [defaultTab, setDefaultTab] = useState('chat'); + // Off by default (musicbay.md §2.2): the ordinary expectation, matching + // Spotify/Deezer, is that the phone locks on its own idle timer while + // listening. This is for whoever would rather trade battery for it — + // e.g. to ride out the WebRTC screen-lock reconnect gap without waiting + // on the automatic recovery at all. + const [keepScreenOnAudio, setKeepScreenOnAudio] = useState(false); + + const onLocaleChange = useCallback((e) => { + const code = e.target.value; + setLocale(code); + setLoc(code); + window.location.reload(); + }, []); + + const onThemeSelect = useCallback((e) => { + onThemeChange(e.target.value); + }, [onThemeChange]); + + useEffect(() => { + hubFetch('/v1/users/me/preferences', { token: user.token }) + .then(prefs => { + if (prefs.notifications_disabled === 'true') setGlobalMute(true); + if (prefs.default_tab) setDefaultTab(prefs.default_tab); + if (prefs.music_keep_screen_on === 'true') setKeepScreenOnAudio(true); + }) + .catch(() => {}); + }, [user.token]); + + const toggleKeepScreenOnAudio = useCallback(async () => { + const next = !keepScreenOnAudio; + setKeepScreenOnAudio(next); + try { + await hubFetch('/v1/users/me/preferences/music_keep_screen_on', { + method: 'PUT', token: user.token, + body: { value: next ? 'true' : 'false' }, + }); + // A string, matching what a fresh page load reads from the hub + // (prefs.music_keep_screen_on === 'true' above) — music-player.js + // compares against that same string, and userPrefs is one shared bag + // fed from both this immediate update and that load. + if (onPrefsChange) onPrefsChange({ music_keep_screen_on: next ? 'true' : 'false' }); + } catch (err) { + setKeepScreenOnAudio(!next); + } + }, [keepScreenOnAudio, user.token, onPrefsChange]); + + const toggleGlobalMute = useCallback(async () => { + const next = !globalMute; + setGlobalMute(next); + try { + await hubFetch('/v1/users/me/preferences/notifications_disabled', { + method: 'PUT', token: user.token, + body: { value: next ? 'true' : 'false' }, + }); + if (onPrefsChange) onPrefsChange({ notifications_disabled: next }); + } catch (err) { + setGlobalMute(!next); + } + }, [globalMute, user.token, onPrefsChange]); + + const toggleMute = useCallback(async (gid) => { + const next = !muted[gid]; + setMuted(prev => ({ ...prev, [gid]: next })); + try { + await hubFetch(`/v1/groups/${gid}/mute`, { + method: 'POST', token: user.token, body: { muted: next }, + }); + } catch (err) { + setMuted(prev => ({ ...prev, [gid]: !next })); + } + }, [muted, user.token]); + + const changeDefaultTab = useCallback(async (e) => { + const val = e.target.value; + setDefaultTab(val); + try { + await hubFetch('/v1/users/me/preferences/default_tab', { + method: 'PUT', token: user.token, + body: { value: val }, + }); + if (onPrefsChange) onPrefsChange({ default_tab: val }); + } catch { setDefaultTab(defaultTab); } + }, [defaultTab, user.token, onPrefsChange]); + + const [dlMode, setDlMode] = useState(() => downloads.getMode()); + const [dlDir, setDlDir] = useState(null); + const [dlError, setDlError] = useState(''); + // Read from the hub rather than written here: the two constants that used to + // sit in this markup said 0.1.0 and MNP 0.1 long after both had moved on. + const [hubInfo, setHubInfo] = useState(null); + // On a desktop build, whether the OS is really holding the keys. Electron's + // safeStorage falls back to a fixed key when no keyring is running — a + // headless session, a minimal desktop — and does it silently. Somebody who + // believes the OS is protecting their keys deserves to be told when it is not. + const [keyBackend, setKeyBackend] = useState(''); + // Changing the hub after the first run. Without this a typo on the first + // screen was permanent: the prompt only appears when no hub is set, so a + // wrong one left editing a JSON file by hand as the only way out. + const [hubInput, setHubInput] = useState(''); + const [hubError, setHubError] = useState(''); + useEffect(() => { + if (!platform.secrets.available) return; + platform.secrets.backend().then(setKeyBackend).catch(() => {}); + }, []); + + useEffect(() => { + hubFetch('/v1/hub/version').then(setHubInfo).catch(() => {}); + }, []); + + useEffect(() => { + // The desktop build remembers a path; the browser remembers a handle. Both + // answer "where do downloads go", and the row below renders either. + if (platform.folder.available) platform.folder.get().then(setDlDir); + else downloads.savedDirectory().then(setDlDir); + }, []); + + const pickFolder = useCallback(async () => { + try { + if (platform.folder.available) { + const dir = await platform.folder.choose(); + if (dir) setDlDir(dir); + return; + } + const handle = await downloads.chooseDirectory(); + setDlDir(handle); + } catch (err) { + // Reported where the folder controls are. This used to be written into + // the node-key status, two sections away, where nobody was looking. + if (err.name !== 'AbortError') setDlError(err.message); + } + }, []); + + + return html` + <div> + <h2>${t('settings.title')}</h2> + + <div class="settings-section"> + <h3 class="settings-heading">${t('settings.downloads')}</h3> + ${(downloads.SUPPORTED || platform.folder.available) && html` + <label class="settings-choice"> + <input type="radio" name="dlmode" checked=${dlMode === 'auto'} + onChange=${() => { downloads.setMode('auto'); setDlMode('auto'); }} /> + <span> + <strong>${t('settings.dl_auto')}</strong> + <span class="settings-hint">${t('settings.dl_auto_hint')}</span> + </span> + </label> + <label class="settings-choice"> + <input type="radio" name="dlmode" checked=${dlMode === 'ask'} + onChange=${() => { downloads.setMode('ask'); setDlMode('ask'); }} /> + <span> + <strong>${t('settings.dl_ask')}</strong> + <span class="settings-hint">${t('settings.dl_ask_hint')}</span> + </span> + </label> + <div class="settings-row" style="margin-top:10px"> + <span class="settings-label"> + ${dlDir ? t('settings.dl_folder', + { name: dlDir.name || String(dlDir) }) + : t('settings.dl_no_folder')} + </span> + <span> + <button class="admin-btn" onClick=${pickFolder}> + ${dlDir ? t('settings.dl_change') : t('settings.dl_choose')} + </button> + ${dlDir && !dlDir.isDefault && html` + <button class="btn-secondary" onClick=${async () => { + if (platform.folder.available) await platform.folder.forget(); + else await downloads.forgetDirectory(); + setDlDir(null); + }}>${t('settings.dl_forget')}</button> + `} + </span> + </div> + ${dlError && html`<p class="error-msg">${dlError}</p>`} + `} + </div> + + <div class="settings-section"> + <h3 class="settings-heading">${t('settings.appearance')}</h3> + <div class="settings-row"> + <span class="settings-label">${t('settings.theme')}</span> + <select class="settings-select" value=${theme} onChange=${onThemeSelect}> + <option value="light">${t('settings.theme_light')}</option> + <option value="dark">${t('settings.theme_dark')}</option> + <option value="system">${t('settings.theme_system')}</option> + </select> + </div> + <div class="settings-row"> + <span class="settings-label">${t('settings.language')}</span> + <select class="settings-select" value=${locale} onChange=${onLocaleChange}> + ${LOCALES.map(l => html` + <option key=${l.code} value=${l.code}>${l.name}</option> + `)} + </select> + </div> + </div> + + <div class="settings-section"> + <h3 class="settings-heading">${t('settings.groups')}</h3> + <div class="settings-row"> + <span class="settings-label">${t('settings.notif_global_disable')}</span> + <label class="toggle-switch"> + <input type="checkbox" checked=${globalMute} + onChange=${toggleGlobalMute} /> + <span class="toggle-switch-track"><span class="toggle-switch-thumb"></span></span> + </label> + </div> + ${!globalMute && html` + <p class="settings-hint" style="margin-bottom:8px">${t('settings.notif_global_hint')}</p> + ${groups.map(g => html` + <div class="settings-row" key=${g.id}> + <span class="settings-label">${g.name}</span> + <label class="toggle-switch"> + <input type="checkbox" checked=${!muted[g.id]} + onChange=${() => toggleMute(g.id)} /> + <span class="toggle-switch-track"><span class="toggle-switch-thumb"></span></span> + </label> + </div> + `)} + `} + </div> + + <div class="settings-section"> + <h3 class="settings-heading">${t('settings.defaults')}</h3> + <div class="settings-row"> + <span class="settings-label">${t('settings.default_tab')}</span> + <select class="settings-select" value=${defaultTab} + onChange=${changeDefaultTab}> + ${APPS.map((a) => html`<option key=${a.key} value=${a.key}>${t(a.labelKey)}</option>`)} + <option value="settings">${t('group.tab_settings')}</option> + </select> + </div> + <p class="settings-hint">${t('settings.default_tab_hint')}</p> + <div class="settings-row"> + <span class="settings-label">${t('settings.music_keep_screen_on')}</span> + <label class="toggle-switch"> + <input type="checkbox" checked=${keepScreenOnAudio} + onChange=${toggleKeepScreenOnAudio} /> + <span class="toggle-switch-track"><span class="toggle-switch-thumb"></span></span> + </label> + </div> + <p class="settings-hint">${t('settings.music_keep_screen_on_hint')}</p> + </div> + + ${platform.isNative && html` + <div class="settings-section"> + <h3 class="settings-heading">${t('settings.hub_heading')}</h3> + <div class="settings-row"> + <span class="settings-label">${t('settings.hub_current')}</span> + <span class="settings-value">${platform.hubBase() || '—'}</span> + </div> + <p class="settings-hint">${t('settings.hub_hint')}</p> + <form onSubmit=${async (e) => { + e.preventDefault(); + setHubError(''); + try { + await window.meshbay.setHubBase(hubInput.trim()); + } catch (err) { setHubError(platform.bridgeMessage(err)); } + }} style="display:flex;gap:8px"> + <input type="text" placeholder=${platform.hubBase()} + value=${hubInput} onInput=${e => setHubInput(e.target.value)} /> + <button class="admin-btn" type="submit">${t('settings.hub_change')}</button> + </form> + ${hubError && html`<p class="error-msg">${hubError}</p>`} + </div> + `} + + ${keyBackend && html` + <div class="settings-section"> + <h3 class="settings-heading">${t('settings.keys_heading')}</h3> + <div class="settings-row"> + <span class="settings-label">${t('settings.keys_where')}</span> + <span class="settings-value">${keyBackend}</span> + </div> + ${keyBackend === 'unprotected_fallback' && html` + <p class="error-msg">${t('settings.keys_unprotected')}</p> + `} + ${keyBackend === 'unavailable' && html` + <p class="error-msg">${t('settings.keys_unavailable')}</p> + `} + </div> + `} + + <div class="settings-section"> + <h3 class="settings-heading">${t('settings.about')}</h3> + <div class="settings-row"> + <span class="settings-label">${t('settings.version')}</span> + <span class="settings-value">${hubInfo ? hubInfo.hub : '—'}</span> + </div> + <div class="settings-row"> + <span class="settings-label">${t('settings.protocol')}</span> + <span class="settings-value"> + ${hubInfo ? `MNP ${hubInfo.mnp} / MHP ${hubInfo.mhp}` : '—'} + </span> + </div> + </div> + </div> + `; +} diff --git a/packages/meshbay-hub/tests/test_spa_ordering.py b/packages/meshbay-hub/tests/test_spa_ordering.py index d00b9a0..ba042d1 100644 --- a/packages/meshbay-hub/tests/test_spa_ordering.py +++ b/packages/meshbay-hub/tests/test_spa_ordering.py @@ -115,9 +115,9 @@ def test_the_ack_still_verifies_the_announced_node_key(): # The group-page refactor split what used to be one app.js into one file per # "application" (chat-app.js, files-app.js, video-player.js) plus -# group-settings.js and the group shell itself, group-page.js. AdminPage and -# App() stayed in app.js. `_component` below is told which file to read a -# given top-level component from. +# group-settings.js and the group shell itself, group-page.js. +# `_component` below is told which file to read a given top-level +# component from. APP = STATIC / "app.js" COMPONENT_FILES = { "GroupSettingsPanel": STATIC / "group-settings.js", @@ -125,6 +125,7 @@ COMPONENT_FILES = { "ChatPanel": STATIC / "chat-app.js", "FilesPanel": STATIC / "files-app.js", "VideoPlayer": STATIC / "video-player.js", + "AdminPage": STATIC / "admin-page.js", } @@ -132,8 +133,12 @@ def _component(name: str) -> str: """The source of one top-level `function Name(...)`, up to the next one.""" source = COMPONENT_FILES.get(name, APP).read_text() start = source.find(f"\nfunction {name}(") + if start == -1: + start = source.find(f"\nexport function {name}(") assert start != -1, f"{name} is gone — update this test" end = source.find("\nfunction ", start + 1) + if end == -1: + end = source.find("\nexport function ", start + 1) return source[start:end if end != -1 else len(source)] |