aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-30 23:57:57 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-30 23:57:57 +0200
commitf6e80064ff8c4869a6ba2cef908cc54326948463 (patch)
tree1a04c004ed8006ec8c486a5c8a4d632ee257d395
parent3706dc15ff3be7acf2ade77977a9fca7a8b4efc5 (diff)
downloadmeshbay-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>
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/admin-page.js454
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js963
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/profile-page.js200
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/settings-page.js316
-rw-r--r--packages/meshbay-hub/tests/test_spa_ordering.py11
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)]