From f6e80064ff8c4869a6ba2cef908cc54326948463 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Sun, 30 Aug 2026 23:57:57 +0200 Subject: refactor(ui): extract Settings, Profile and Admin pages from app.js Admin page is lazy-loaded so non-admin users never fetch it. Co-Authored-By: Claude Opus 4.6 --- .../src/meshbay_hub/static/admin-page.js | 454 ++++++++++ packages/meshbay-hub/src/meshbay_hub/static/app.js | 963 +-------------------- .../src/meshbay_hub/static/profile-page.js | 200 +++++ .../src/meshbay_hub/static/settings-page.js | 316 +++++++ packages/meshbay-hub/tests/test_spa_ordering.py | 11 +- 5 files changed, 990 insertions(+), 954 deletions(-) create mode 100644 packages/meshbay-hub/src/meshbay_hub/static/admin-page.js create mode 100644 packages/meshbay-hub/src/meshbay_hub/static/profile-page.js create mode 100644 packages/meshbay-hub/src/meshbay_hub/static/settings-page.js (limited to 'packages') diff --git a/packages/meshbay-hub/src/meshbay_hub/static/admin-page.js b/packages/meshbay-hub/src/meshbay_hub/static/admin-page.js new file mode 100644 index 0000000..dbe26ab --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/static/admin-page.js @@ -0,0 +1,454 @@ +import { + html, useState, useEffect, useCallback, +} from './vendor/htm-preact.js'; +import { t } from './i18n.js'; +import { hubFetch } from './hub-client.js'; +import { GroupName } from './group-name.js'; + +// ── Admin Panel ───────────────────────────────────────────────────────────── + +export function AdminPage({ token, role }) { + const [tab, setTab] = useState('general'); + const [stats, setStats] = useState(null); + const [settings, setSettings] = useState(null); + const [settingsSaving, setSettingsSaving] = useState(false); + const [users, setUsers] = useState([]); + const [usersTotal, setUsersTotal] = useState(0); + const [userSearch, setUserSearch] = useState(''); + const [groups, setGroups] = useState([]); + const [groupsTotal, setGroupsTotal] = useState(0); + const [logs, setLogs] = useState([]); + const [logEvent, setLogEvent] = useState(''); + const [logOffset, setLogOffset] = useState(0); + const [blocklist, setBlocklist] = useState([]); + const [nodes, setNodes] = useState([]); + const [detailUser, setDetailUser] = useState(null); + const [error, setError] = useState(''); + + const headers = { Authorization: `Bearer ${token}` }; + + const loadStats = useCallback(async () => { + try { + const data = await hubFetch('/v1/admin/stats', { token }); + setStats(data); + } catch (e) { setError(e.message); } + }, [token]); + + const loadSettings = useCallback(async () => { + try { + const data = await hubFetch('/v1/admin/settings', { token }); + setSettings(data); + } catch (e) { setError(e.message); } + }, [token]); + + const saveSettings = useCallback(async (patch) => { + setSettingsSaving(true); + setError(''); + try { + // The response is the authoritative state — render that, not the + // optimistic value, so a rejected change never looks applied. + const data = await hubFetch('/v1/admin/settings', + { method: 'PATCH', body: patch, token }); + setSettings(data); + } catch (e) { setError(e.message); } + finally { setSettingsSaving(false); } + }, [token]); + + const loadUsers = useCallback(async (q = '') => { + try { + const data = await hubFetch(`/v1/admin/users?q=${encodeURIComponent(q)}&limit=100`, { token }); + setUsers(data.users); + setUsersTotal(data.total); + } catch (e) { setError(e.message); } + }, [token]); + + const loadGroups = useCallback(async () => { + try { + const data = await hubFetch('/v1/admin/groups?limit=100', { token }); + setGroups(data.groups); + setGroupsTotal(data.total); + } catch (e) { setError(e.message); } + }, [token]); + + const loadLogs = useCallback(async (event = '', offset = 0, append = false) => { + try { + let url = `/v1/admin/logs?limit=50&offset=${offset}`; + if (event) url += `&event=${encodeURIComponent(event)}`; + const data = await hubFetch(url, { token }); + setLogs(prev => append ? [...prev, ...data.logs] : data.logs); + } catch (e) { setError(e.message); } + }, [token]); + + const loadBlocklist = useCallback(async () => { + try { + const data = await hubFetch('/v1/admin/blocklist', { token }); + setBlocklist(data.entries); + } catch (e) { setError(e.message); } + }, [token]); + + useEffect(() => { + setError(''); + if (tab === 'general') loadSettings(); + else if (tab === 'stats') loadStats(); + else if (tab === 'users') loadUsers(userSearch); + else if (tab === 'groups') loadGroups(); + else if (tab === 'nodes') { + hubFetch('/v1/admin/nodes', { token }) + .then(d => setNodes(d.nodes || [])).catch(e => setError(e.message)); + } + else if (tab === 'logs') { setLogOffset(0); loadLogs(logEvent, 0); } + else if (tab === 'blocklist') loadBlocklist(); + }, [tab]); + + const deleteUser = useCallback(async (u) => { + // Suspension is the reversible tool and stays one click away; this one is + // not, so it names the account and says what it cannot reach. + if (!confirm(t('admin.delete_confirm', { user: u.username }))) return; + try { + await hubFetch(`/v1/admin/users/${u.id}`, { method: 'DELETE', token }); + loadUsers(userSearch); + } catch (err) { + alert(err.message); + } + }, [token, userSearch, loadUsers]); + + const patchUser = useCallback(async (userId, patch) => { + try { + await hubFetch(`/v1/admin/users/${userId}`, { method: 'PATCH', body: patch, token }); + loadUsers(userSearch); + if (detailUser && detailUser.id === userId) setDetailUser(null); + } catch (e) { setError(e.message); } + }, [token, userSearch, detailUser]); + + const patchGroup = useCallback(async (groupId, patch) => { + try { + await hubFetch(`/v1/admin/groups/${groupId}`, { method: 'PATCH', body: patch, token }); + loadGroups(); + } catch (e) { setError(e.message); } + }, [token]); + + const revokeGroup = useCallback(async (g) => { + // Suspending is the reversible tool and stays one click away; revoking + // pushes a signed revocation to every node hosting the group and there is + // no undo from here, so it names the group and asks first. + if (!confirm(t('admin.revoke_group_confirm', { group: g.name }))) return; + try { + await hubFetch('/v1/admin/revoke', + { method: 'POST', body: { target: 'group', target_id: g.id }, token }); + loadGroups(); + } catch (e) { setError(e.message); } + }, [token]); + + const showUserDetail = useCallback(async (userId) => { + try { + const data = await hubFetch(`/v1/admin/users/${userId}`, { token }); + setDetailUser(data); + } catch (e) { setError(e.message); } + }, [token]); + + const addToBlocklist = useCallback(async (hash, reason) => { + try { + await hubFetch('/v1/admin/blocklist', { method: 'POST', body: { content_hash: hash, reason }, token }); + loadBlocklist(); + } catch (e) { setError(e.message); } + }, [token]); + + const removeFromBlocklist = useCallback(async (hash) => { + try { + await hubFetch(`/v1/admin/blocklist/${hash}`, { method: 'DELETE', token }); + loadBlocklist(); + } catch (e) { setError(e.message); } + }, [token]); + + const TABS = ['general', 'stats', 'users', 'groups', 'nodes', 'logs', 'blocklist']; + const canEditSettings = role === 'admin'; + + return html` +
+

${t('admin.title')}

+ ${error && html`
${error}
`} + +
+ ${TABS.map(k => html` + + `)} +
+ + ${tab === 'general' && settings && html` +
+

${t('admin.general_groups_heading')}

+
+ ${t('admin.allow_public_groups_label')} + +
+

${t('admin.allow_public_groups_hint')}

+ ${!canEditSettings && html` +

${t('admin.settings_readonly')}

`} +
+ `} + + ${tab === 'stats' && stats && html` +
+ ${[['users', 'stat_users'], ['groups', 'stat_groups'], + ['nodes', 'stat_nodes'], ['online_nodes', 'stat_online']].map(([k, label]) => html` +
+
${stats[k]}
+
${t('admin.' + label)}
+
+ `)} +
+ `} + + ${tab === 'users' && html` +
+ { setUserSearch(e.target.value); loadUsers(e.target.value); }} /> + ${usersTotal} total +
+ + + + + + + + + + ${users.length === 0 && html``} + ${users.map(u => html` + + + + + + + + `)} + +
${t('admin.col_username')}${t('admin.col_role')}${t('admin.col_status')}${t('admin.col_created')}${t('admin.col_actions')}
${t('admin.no_users')}
${u.username} + + ${u.status}${new Date(u.created_at).toLocaleDateString()} + + ${u.status === 'active' + ? html`` + : u.status === 'suspended' + ? html`` + : null + } + ${u.status !== 'deleted' && html` + + `} +
+ `} + + ${tab === 'groups' && html` +
+ ${groupsTotal} total +
+ + + + + + + + + + + ${groups.length === 0 && html``} + ${groups.map(g => html` + + + + + + + + + `)} + +
${t('admin.col_name')}${t('admin.col_visibility')}${t('admin.col_members')}${t('admin.col_status')}${t('admin.col_created')}${t('admin.col_actions')}
${t('admin.no_groups')}
<${GroupName} name=${g.name} owner=${g.owner_username} />${g.visibility}${g.member_count}${g.status}${new Date(g.created_at).toLocaleDateString()} + ${g.status === 'active' + ? html`` + : g.status === 'suspended' + ? html`` + : null + } + ${g.status !== 'revoked' && html` + + `} +
+ `} + + ${tab === 'nodes' && html` +

+ ${t('admin.nodes_hint')} +

+ + + + + + + + + + ${nodes.length === 0 && html` + + `} + ${nodes.map(n => html` + + + + + + + + `)} + +
${t('admin.col_username')}${t('admin.col_observed_ip')}${t('admin.col_hint')}${t('admin.col_last_seen')}${t('admin.col_status')}
${t('admin.no_nodes')}
${n.username || n.user_id.slice(0, 8)}${n.observed_ip || '—'} + ${n.endpoint_hint || '—'} + ${n.last_seen ? new Date(n.last_seen).toLocaleString() : '—'} + + ${n.online ? t('admin.node_online') : t('admin.node_offline')} + +
+ `} + + ${tab === 'logs' && html` +
+ +
+ + + + + + + + + + ${logs.length === 0 && html``} + ${logs.map(lg => html` + + + + + + + + `)} + +
${t('admin.col_time')}${t('admin.col_user')}${t('admin.col_event')}${t('admin.col_ip')}${t('admin.col_detail')}
${t('admin.no_logs')}
${new Date(lg.timestamp).toLocaleString()}${lg.username || ''}${lg.event}${lg.ip_address}${lg.detail || ''}
+ ${logs.length > 0 && logs.length % 50 === 0 && html` + + `} + `} + + ${tab === 'blocklist' && html` + <${BlocklistForm} onAdd=${addToBlocklist} /> + + + + + + + + + + ${blocklist.length === 0 && html``} + ${blocklist.map(b => html` + + + + + + + + `)} + +
${t('admin.col_hash')}${t('admin.col_reason')}${t('admin.col_date')}${t('admin.col_added_by')}${t('admin.col_actions')}
${t('admin.no_blocked')}
${b.hash.slice(0, 16)}...${b.reason}${new Date(b.added_at).toLocaleDateString()}${b.added_by || ''} + +
+ `} + + ${detailUser && html` +
{ + if (e.target.classList.contains('admin-detail-overlay')) setDetailUser(null); + }}> +
+

${t('admin.user_detail')}

+ ${[ + ['admin.col_username', detailUser.username], + ['admin.col_email', detailUser.email], + ['admin.col_role', detailUser.role], + ['admin.col_status', detailUser.status], + ['admin.col_created', new Date(detailUser.created_at).toLocaleString()], + ['admin.col_groups', detailUser.group_count], + ].map(([label, val]) => html` +
+ ${t(label)} + ${val} +
+ `)} + +
+
+ `} +
+ `; +} + +function BlocklistForm({ onAdd }) { + const [hash, setHash] = useState(''); + const [reason, setReason] = useState(''); + + const submit = (e) => { + e.preventDefault(); + if (hash.length === 64 && reason) { + onAdd(hash, reason); + setHash(''); + setReason(''); + } + }; + + return html` +
+ setHash(e.target.value)} + pattern="[0-9a-f]{64}" required /> + setReason(e.target.value)} required /> + +
+ `; +} 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); - }, []); +// ── Lazy-loaded Admin page (admin/moderator only) ───────────────────────── +let _AdminPage = null; +function LazyAdminPage(props) { + const [loaded, setLoaded] = useState(!!_AdminPage); 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); + if (!_AdminPage) { + import('./admin-page.js').then(m => { _AdminPage = m.AdminPage; setLoaded(true); }); } - }, [nodeKey, user.token]); - - return html` -
-

${t('profile.title')}

- -
-

${t('settings.profile')}

-
- ${t('settings.username')} - ${user.username} -
-
- ${t('settings.email')} - ${emailEditing - ? html` - 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" /> - - - ` - : html` - ${email || '—'} - - ` - } -
- ${emailStatus && html`

${emailStatus}

`} -
- -
-

${t('settings.node_key')}

-

${t('settings.node_key_desc')}

- ${currentNodeKey && html` -
- ${t('settings.node_key_current')} - ${currentNodeKey} -
- `} -
- setNodeKey(e.target.value)} - onKeyDown=${e => e.key === 'Enter' && submitNodeKey()} /> - -
- ${nodeKeyStatus && html` -

- ${nodeKeyStatus} -

- `} -
- -
-

${t('settings.node_pins')}

-

${t('settings.node_pins_hint')}

-
- ${t('settings.node_pins_count', { n: pinCount })} - -
-
- -
-

${t('settings.danger')}

-

${t('settings.delete_hint')}

- ${delError && html`

${delError}

`} - ${!delOpen - ? html`` - : html` -
-

${t('settings.delete_confirm')}

-
- setDelPass(e.target.value)} required /> - - -
-
- `} -
- -
- `; -} - -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` -
-

${t('settings.title')}

- -
-

${t('settings.downloads')}

- ${(downloads.SUPPORTED || platform.folder.available) && html` - - -
- - ${dlDir ? t('settings.dl_folder', - { name: dlDir.name || String(dlDir) }) - : t('settings.dl_no_folder')} - - - - ${dlDir && !dlDir.isDefault && html` - - `} - -
- ${dlError && html`

${dlError}

`} - `} -
- -
-

${t('settings.appearance')}

-
- ${t('settings.theme')} - -
-
- ${t('settings.language')} - -
-
- -
-

${t('settings.groups')}

-
- ${t('settings.notif_global_disable')} - -
- ${!globalMute && html` -

${t('settings.notif_global_hint')}

- ${groups.map(g => html` -
- ${g.name} - -
- `)} - `} -
- -
-

${t('settings.defaults')}

-
- ${t('settings.default_tab')} - -
-

${t('settings.default_tab_hint')}

-
- ${t('settings.music_keep_screen_on')} - -
-

${t('settings.music_keep_screen_on_hint')}

-
- - ${platform.isNative && html` -
-

${t('settings.hub_heading')}

-
- ${t('settings.hub_current')} - ${platform.hubBase() || '—'} -
-

${t('settings.hub_hint')}

-
{ - e.preventDefault(); - setHubError(''); - try { - await window.meshbay.setHubBase(hubInput.trim()); - } catch (err) { setHubError(platform.bridgeMessage(err)); } - }} style="display:flex;gap:8px"> - setHubInput(e.target.value)} /> - -
- ${hubError && html`

${hubError}

`} -
- `} - - ${keyBackend && html` -
-

${t('settings.keys_heading')}

-
- ${t('settings.keys_where')} - ${keyBackend} -
- ${keyBackend === 'unprotected_fallback' && html` -

${t('settings.keys_unprotected')}

- `} - ${keyBackend === 'unavailable' && html` -

${t('settings.keys_unavailable')}

- `} -
- `} - -
-

${t('settings.about')}

-
- ${t('settings.version')} - ${hubInfo ? hubInfo.hub : '—'} -
-
- ${t('settings.protocol')} - - ${hubInfo ? `MNP ${hubInfo.mnp} / MHP ${hubInfo.mhp}` : '—'} - -
-
-
- `; -} - -// ── 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` -
-

${t('admin.title')}

- ${error && html`
${error}
`} - -
- ${TABS.map(k => html` - - `)} -
- - ${tab === 'general' && settings && html` -
-

${t('admin.general_groups_heading')}

-
- ${t('admin.allow_public_groups_label')} - -
-

${t('admin.allow_public_groups_hint')}

- ${!canEditSettings && html` -

${t('admin.settings_readonly')}

`} -
- `} - - ${tab === 'stats' && stats && html` -
- ${[['users', 'stat_users'], ['groups', 'stat_groups'], - ['nodes', 'stat_nodes'], ['online_nodes', 'stat_online']].map(([k, label]) => html` -
-
${stats[k]}
-
${t('admin.' + label)}
-
- `)} -
- `} - - ${tab === 'users' && html` -
- { setUserSearch(e.target.value); loadUsers(e.target.value); }} /> - ${usersTotal} total -
- - - - - - - - - - ${users.length === 0 && html``} - ${users.map(u => html` - - - - - - - - `)} - -
${t('admin.col_username')}${t('admin.col_role')}${t('admin.col_status')}${t('admin.col_created')}${t('admin.col_actions')}
${t('admin.no_users')}
${u.username} - - ${u.status}${new Date(u.created_at).toLocaleDateString()} - - ${u.status === 'active' - ? html`` - : u.status === 'suspended' - ? html`` - : null - } - ${u.status !== 'deleted' && html` - - `} -
- `} - - ${tab === 'groups' && html` -
- ${groupsTotal} total -
- - - - - - - - - - - ${groups.length === 0 && html``} - ${groups.map(g => html` - - - - - - - - - `)} - -
${t('admin.col_name')}${t('admin.col_visibility')}${t('admin.col_members')}${t('admin.col_status')}${t('admin.col_created')}${t('admin.col_actions')}
${t('admin.no_groups')}
<${GroupName} name=${g.name} owner=${g.owner_username} />${g.visibility}${g.member_count}${g.status}${new Date(g.created_at).toLocaleDateString()} - ${g.status === 'active' - ? html`` - : g.status === 'suspended' - ? html`` - : null - } - ${g.status !== 'revoked' && html` - - `} -
- `} - - ${tab === 'nodes' && html` -

- ${t('admin.nodes_hint')} -

- - - - - - - - - - ${nodes.length === 0 && html` - - `} - ${nodes.map(n => html` - - - - - - - - `)} - -
${t('admin.col_username')}${t('admin.col_observed_ip')}${t('admin.col_hint')}${t('admin.col_last_seen')}${t('admin.col_status')}
${t('admin.no_nodes')}
${n.username || n.user_id.slice(0, 8)}${n.observed_ip || '—'} - ${n.endpoint_hint || '—'} - ${n.last_seen ? new Date(n.last_seen).toLocaleString() : '—'} - - ${n.online ? t('admin.node_online') : t('admin.node_offline')} - -
- `} - - ${tab === 'logs' && html` -
- -
- - - - - - - - - - ${logs.length === 0 && html``} - ${logs.map(lg => html` - - - - - - - - `)} - -
${t('admin.col_time')}${t('admin.col_user')}${t('admin.col_event')}${t('admin.col_ip')}${t('admin.col_detail')}
${t('admin.no_logs')}
${new Date(lg.timestamp).toLocaleString()}${lg.username || ''}${lg.event}${lg.ip_address}${lg.detail || ''}
- ${logs.length > 0 && logs.length % 50 === 0 && html` - - `} - `} - - ${tab === 'blocklist' && html` - <${BlocklistForm} onAdd=${addToBlocklist} /> - - - - - - - - - - ${blocklist.length === 0 && html``} - ${blocklist.map(b => html` - - - - - - - - `)} - -
${t('admin.col_hash')}${t('admin.col_reason')}${t('admin.col_date')}${t('admin.col_added_by')}${t('admin.col_actions')}
${t('admin.no_blocked')}
${b.hash.slice(0, 16)}...${b.reason}${new Date(b.added_at).toLocaleDateString()}${b.added_by || ''} - -
- `} - - ${detailUser && html` -
{ - if (e.target.classList.contains('admin-detail-overlay')) setDetailUser(null); - }}> -
-

${t('admin.user_detail')}

- ${[ - ['admin.col_username', detailUser.username], - ['admin.col_email', detailUser.email], - ['admin.col_role', detailUser.role], - ['admin.col_status', detailUser.status], - ['admin.col_created', new Date(detailUser.created_at).toLocaleString()], - ['admin.col_groups', detailUser.group_count], - ].map(([label, val]) => html` -
- ${t(label)} - ${val} -
- `)} - -
-
- `} -
- `; -} - -function BlocklistForm({ onAdd }) { - const [hash, setHash] = useState(''); - const [reason, setReason] = useState(''); - - const submit = (e) => { - e.preventDefault(); - if (hash.length === 64 && reason) { - onAdd(hash, reason); - setHash(''); - setReason(''); - } - }; - - return html` -
- setHash(e.target.value)} - pattern="[0-9a-f]{64}" required /> - setReason(e.target.value)} required /> - -
- `; + if (!loaded) return html`
+

`; + 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` +
+

${t('profile.title')}

+ +
+

${t('settings.profile')}

+
+ ${t('settings.username')} + ${user.username} +
+
+ ${t('settings.email')} + ${emailEditing + ? html` + 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" /> + + + ` + : html` + ${email || '—'} + + ` + } +
+ ${emailStatus && html`

${emailStatus}

`} +
+ +
+

${t('settings.node_key')}

+

${t('settings.node_key_desc')}

+ ${currentNodeKey && html` +
+ ${t('settings.node_key_current')} + ${currentNodeKey} +
+ `} +
+ setNodeKey(e.target.value)} + onKeyDown=${e => e.key === 'Enter' && submitNodeKey()} /> + +
+ ${nodeKeyStatus && html` +

+ ${nodeKeyStatus} +

+ `} +
+ +
+

${t('settings.node_pins')}

+

${t('settings.node_pins_hint')}

+
+ ${t('settings.node_pins_count', { n: pinCount })} + +
+
+ +
+

${t('settings.danger')}

+

${t('settings.delete_hint')}

+ ${delError && html`

${delError}

`} + ${!delOpen + ? html`` + : html` +
+

${t('settings.delete_confirm')}

+
+ setDelPass(e.target.value)} required /> + + +
+
+ `} +
+ +
+ `; +} 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` +
+

${t('settings.title')}

+ +
+

${t('settings.downloads')}

+ ${(downloads.SUPPORTED || platform.folder.available) && html` + + +
+ + ${dlDir ? t('settings.dl_folder', + { name: dlDir.name || String(dlDir) }) + : t('settings.dl_no_folder')} + + + + ${dlDir && !dlDir.isDefault && html` + + `} + +
+ ${dlError && html`

${dlError}

`} + `} +
+ +
+

${t('settings.appearance')}

+
+ ${t('settings.theme')} + +
+
+ ${t('settings.language')} + +
+
+ +
+

${t('settings.groups')}

+
+ ${t('settings.notif_global_disable')} + +
+ ${!globalMute && html` +

${t('settings.notif_global_hint')}

+ ${groups.map(g => html` +
+ ${g.name} + +
+ `)} + `} +
+ +
+

${t('settings.defaults')}

+
+ ${t('settings.default_tab')} + +
+

${t('settings.default_tab_hint')}

+
+ ${t('settings.music_keep_screen_on')} + +
+

${t('settings.music_keep_screen_on_hint')}

+
+ + ${platform.isNative && html` +
+

${t('settings.hub_heading')}

+
+ ${t('settings.hub_current')} + ${platform.hubBase() || '—'} +
+

${t('settings.hub_hint')}

+
{ + e.preventDefault(); + setHubError(''); + try { + await window.meshbay.setHubBase(hubInput.trim()); + } catch (err) { setHubError(platform.bridgeMessage(err)); } + }} style="display:flex;gap:8px"> + setHubInput(e.target.value)} /> + +
+ ${hubError && html`

${hubError}

`} +
+ `} + + ${keyBackend && html` +
+

${t('settings.keys_heading')}

+
+ ${t('settings.keys_where')} + ${keyBackend} +
+ ${keyBackend === 'unprotected_fallback' && html` +

${t('settings.keys_unprotected')}

+ `} + ${keyBackend === 'unavailable' && html` +

${t('settings.keys_unavailable')}

+ `} +
+ `} + +
+

${t('settings.about')}

+
+ ${t('settings.version')} + ${hubInfo ? hubInfo.hub : '—'} +
+
+ ${t('settings.protocol')} + + ${hubInfo ? `MNP ${hubInfo.mnp} / MHP ${hubInfo.mhp}` : '—'} + +
+
+
+ `; +} 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)] -- cgit v1.2.3