diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-28 02:51:00 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-28 02:51:00 +0200 |
| commit | b5b4f188a39fc96c4d32e67151e067b1add6dcfc (patch) | |
| tree | ce0c4ff78044a56c0882d7ba94fbea436f0e83c3 /packages/meshbay-hub/src/meshbay_hub/static/app.js | |
| parent | e1f1b65cfac031096e4bae24ccf102ca0dbb86d9 (diff) | |
| download | meshbay-b5b4f188a39fc96c4d32e67151e067b1add6dcfc.tar.gz | |
feat(hub): let a hub admin disable public groups instance-wide
A new General tab in Administration carries one switch, allow_public_groups,
stored in a hub_settings key/value table (runtime-editable, unlike hub.toml).
Default is on; an absent row means on, so an upgrade changes nothing.
Enforcement is server-side on every hub-mediated path, not just the SPA:
- create_group refuses visibility=public (403), staff included
- list_public_groups the directory returns nothing (local + federated)
- join_group open-joining a public group is refused
- group_online_nodes a non-member of a public group is handed no node
- signaling.webrtc_offer drops the "node hosts an open group" fallback
- federation.export_directory advertises nothing to peer hubs
The switch is read live, so flipping it back restores every path. Existing
members of a group that predates the switch keep their membership row and
their access — this is plan A, not a purge. GET /v1/hub/info exposes the
flag (unauthenticated) so the create-group form and the sidebar's "Public
groups" link render correctly.
Also in the admin Groups tab: a Revoke action beside Suspend. Suspend is the
reversible hub flag; Revoke calls POST /v1/admin/revoke, which sets
status=revoked and broadcasts a signed revocation every node enforces
(denylist + dropped live sessions). It is confirm-guarded and names the group.
And a message fix the revoke work surfaced: group_online_nodes, join_group and
webrtc_offer answered "Group is suspended" for any non-active status. They now
report the real state, so a member of a revoked group is told "Group is
revoked" rather than something reversible-sounding.
Tests: test_public_groups_toggle.py (10) covers the switch end to end and the
five enforcement paths; test_revocation.py gains the status-message assertion.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018gKJ85aZyvEwarXMFzFEwi
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/app.js')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/app.js | 187 |
1 files changed, 133 insertions, 54 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js index 446360c..a380033 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js @@ -299,7 +299,8 @@ function Nav({ user, theme, onThemeChange, onLogout, onMenuToggle, unreadCount, // ── Sidebar ────────────────────────────────────────────────────────────────── -function Sidebar({ groups, presence, indexProgressPct, route, menuOpen, role, hasNodeKey }) { +function Sidebar({ groups, presence, indexProgressPct, route, menuOpen, role, hasNodeKey, + allowPublicGroups = true }) { const isStaff = role === 'moderator' || role === 'admin'; return html` <aside class="sidebar ${menuOpen ? 'open' : ''}"> @@ -312,8 +313,9 @@ function Sidebar({ groups, presence, indexProgressPct, route, menuOpen, role, ha `} <div class="sidebar-section"> <div class="sidebar-heading">${t('sidebar.discover')}</div> - <a class="sidebar-item ${route === '/explore' ? 'active' : ''}" - href="#/explore"><${Icon} name="globe" /> ${t('sidebar.public_groups')}</a> + ${allowPublicGroups && html` + <a class="sidebar-item ${route === '/explore' ? 'active' : ''}" + href="#/explore"><${Icon} name="globe" /> ${t('sidebar.public_groups')}</a>`} <a class="sidebar-item ${route === '/search' ? 'active' : ''}" href="#/search"><${Icon} name="search" /> ${t('sidebar.search')}</a> </div> @@ -740,9 +742,12 @@ function CreateGroupPage(props) { return html`<${CreateGroupFormSimple} ...${props} />`; } -function CreateGroupFormSimple({ token, onCreated }) { +function CreateGroupFormSimple({ token, onCreated, allowPublicGroups = true }) { const [name, setName] = useState(''); const [description, setDescription] = useState(''); + // Only ever anything other than 'invite' when the hub allows public groups — + // the join-policy section is not rendered otherwise, so there is nothing to + // set it 'open'. const [joinPolicy, setJoinPolicy] = useState('invite'); const [error, setError] = useState(''); const [loading, setLoading] = useState(false); @@ -793,29 +798,31 @@ function CreateGroupFormSimple({ token, onCreated }) { </div> </div> - <div class="settings-section"> - <h3 class="settings-heading">${t('create_group.join_policy')}</h3> - <div class="choice-list"> - <label class="choice ${joinPolicy === 'invite' ? 'selected' : ''}"> - <input type="radio" name="join_policy" checked=${joinPolicy === 'invite'} - onChange=${() => setJoinPolicy('invite')} /> - <${Icon} name="lock" cls="choice-icon" /> - <span class="choice-text"> - <span class="choice-title">${t('create_group.invite')}</span> - <span class="choice-desc">${t('create_group.invite_desc')}</span> - </span> - </label> - <label class="choice ${joinPolicy === 'open' ? 'selected' : ''}"> - <input type="radio" name="join_policy" checked=${joinPolicy === 'open'} - onChange=${() => setJoinPolicy('open')} /> - <${Icon} name="globe" cls="choice-icon" /> - <span class="choice-text"> - <span class="choice-title">${t('create_group.open')}</span> - <span class="choice-desc">${t('create_group.open_desc')}</span> - </span> - </label> + ${allowPublicGroups && html` + <div class="settings-section"> + <h3 class="settings-heading">${t('create_group.join_policy')}</h3> + <div class="choice-list"> + <label class="choice ${joinPolicy === 'invite' ? 'selected' : ''}"> + <input type="radio" name="join_policy" checked=${joinPolicy === 'invite'} + onChange=${() => setJoinPolicy('invite')} /> + <${Icon} name="lock" cls="choice-icon" /> + <span class="choice-text"> + <span class="choice-title">${t('create_group.invite')}</span> + <span class="choice-desc">${t('create_group.invite_desc')}</span> + </span> + </label> + <label class="choice ${joinPolicy === 'open' ? 'selected' : ''}"> + <input type="radio" name="join_policy" checked=${joinPolicy === 'open'} + onChange=${() => setJoinPolicy('open')} /> + <${Icon} name="globe" cls="choice-icon" /> + <span class="choice-text"> + <span class="choice-title">${t('create_group.open')}</span> + <span class="choice-desc">${t('create_group.open_desc')}</span> + </span> + </label> + </div> </div> - </div> + `} <button class="btn-primary" type="submit" disabled=${loading}> ${loading ? t('create_group.creating') : t('create_group.submit')} @@ -827,7 +834,7 @@ function CreateGroupFormSimple({ token, onCreated }) { // ── Create Group Wizard (Electron-only) ───────────────────────────────────── -function CreateGroupWizard({ token, username, onCreated }) { +function CreateGroupWizard({ token, username, onCreated, allowPublicGroups = true }) { const [step, setStep] = useState(0); // 0=node check, 1=details, 2=setup, 3=done const [nodeStatus, setNodeStatus] = useState(null); // null=loading, object=result const [nodeStarting, setNodeStarting] = useState(false); @@ -836,6 +843,8 @@ function CreateGroupWizard({ token, username, onCreated }) { // Step 1 fields const [name, setName] = useState(''); const [description, setDescription] = useState(''); + // See CreateGroupFormSimple: stays 'invite' unless the hub allows public + // groups, since the join-policy section is not rendered otherwise. const [joinPolicy, setJoinPolicy] = useState('invite'); const [roots, setRoots] = useState([]); const [uploadIdx, setUploadIdx] = useState(0); @@ -1133,29 +1142,31 @@ function CreateGroupWizard({ token, username, onCreated }) { </div> </div> - <div class="settings-section"> - <h3 class="settings-heading">${t('create_group.join_policy')}</h3> - <div class="choice-list"> - <label class="choice ${joinPolicy === 'invite' ? 'selected' : ''}"> - <input type="radio" name="join_policy" checked=${joinPolicy === 'invite'} - onChange=${() => setJoinPolicy('invite')} /> - <${Icon} name="lock" cls="choice-icon" /> - <span class="choice-text"> - <span class="choice-title">${t('create_group.invite')}</span> - <span class="choice-desc">${t('create_group.invite_desc')}</span> - </span> - </label> - <label class="choice ${joinPolicy === 'open' ? 'selected' : ''}"> - <input type="radio" name="join_policy" checked=${joinPolicy === 'open'} - onChange=${() => setJoinPolicy('open')} /> - <${Icon} name="globe" cls="choice-icon" /> - <span class="choice-text"> - <span class="choice-title">${t('create_group.open')}</span> - <span class="choice-desc">${t('create_group.open_desc')}</span> - </span> - </label> + ${allowPublicGroups && html` + <div class="settings-section"> + <h3 class="settings-heading">${t('create_group.join_policy')}</h3> + <div class="choice-list"> + <label class="choice ${joinPolicy === 'invite' ? 'selected' : ''}"> + <input type="radio" name="join_policy" checked=${joinPolicy === 'invite'} + onChange=${() => setJoinPolicy('invite')} /> + <${Icon} name="lock" cls="choice-icon" /> + <span class="choice-text"> + <span class="choice-title">${t('create_group.invite')}</span> + <span class="choice-desc">${t('create_group.invite_desc')}</span> + </span> + </label> + <label class="choice ${joinPolicy === 'open' ? 'selected' : ''}"> + <input type="radio" name="join_policy" checked=${joinPolicy === 'open'} + onChange=${() => setJoinPolicy('open')} /> + <${Icon} name="globe" cls="choice-icon" /> + <span class="choice-text"> + <span class="choice-title">${t('create_group.open')}</span> + <span class="choice-desc">${t('create_group.open_desc')}</span> + </span> + </label> + </div> </div> - </div> + `} <div class="settings-section"> <h3 class="settings-heading">${t('members.apps_title')}</h3> @@ -1895,9 +1906,11 @@ function SettingsPage({ user, theme, onThemeChange, groups, onPrefsChange }) { // ── Admin Panel ───────────────────────────────────────────────────────────── -function AdminPage({ token }) { - const [tab, setTab] = useState('stats'); +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(''); @@ -1920,6 +1933,26 @@ function AdminPage({ token }) { } 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 }); @@ -1954,7 +1987,8 @@ function AdminPage({ token }) { useEffect(() => { setError(''); - if (tab === 'stats') loadStats(); + if (tab === 'general') loadSettings(); + else if (tab === 'stats') loadStats(); else if (tab === 'users') loadUsers(userSearch); else if (tab === 'groups') loadGroups(); else if (tab === 'nodes') { @@ -1992,6 +2026,18 @@ function AdminPage({ token }) { } 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 }); @@ -2013,7 +2059,8 @@ function AdminPage({ token }) { } catch (e) { setError(e.message); } }, [token]); - const TABS = ['stats', 'users', 'groups', 'nodes', 'logs', 'blocklist']; + const TABS = ['general', 'stats', 'users', 'groups', 'nodes', 'logs', 'blocklist']; + const canEditSettings = role === 'admin'; return html` <div> @@ -2027,6 +2074,23 @@ function AdminPage({ token }) { `)} </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="settings-value" style="cursor:pointer"> + <input type="checkbox" checked=${settings.allow_public_groups} + disabled=${!canEditSettings || settingsSaving} + onChange=${e => saveSettings({ allow_public_groups: e.target.checked })} /> + </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'], @@ -2116,6 +2180,10 @@ function AdminPage({ token }) { ? 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> `)} @@ -2917,6 +2985,11 @@ function App() { const [notifDisabled, setNotifDisabled] = useState(false); const [userPrefs, setUserPrefs] = useState({}); const [hasNodeKey, setHasNodeKey] = useState(false); + // Instance policy, fetched once, unauthenticated. `null` until it answers; + // treat unknown as "allowed" so a slow hub never blocks a legitimate private + // group — the hub refuses a public one server-side regardless. + const [hubInfo, setHubInfo] = useState(null); + const allowPublicGroups = !hubInfo || hubInfo.allow_public_groups !== false; const resolved = resolveTheme(theme); @@ -2952,6 +3025,10 @@ function App() { localStorage.setItem(THEME_KEY, theme); }, [theme, resolved]); + useEffect(() => { + hubFetch('/v1/hub/info').then(setHubInfo).catch(() => {}); + }, []); + const fetchNotifications = useCallback(() => { if (!user || notifDisabled) { setNotifications([]); setUnreadCount(0); return; @@ -3196,6 +3273,7 @@ function App() { myGroupIds=${groups.map(g => g.id)} />`; } else if (route === '/create-group') { page = html`<${CreateGroupPage} token=${user.token} username=${user.username} + allowPublicGroups=${allowPublicGroups} onCreated=${() => { hubFetch('/v1/groups/mine', { token: user.token }) .then(data => setGroups(data.groups || [])) @@ -3216,7 +3294,7 @@ function App() { onLeft=${handleLeftGroup} />`; } else if (route === '/admin') { page = (user.role === 'moderator' || user.role === 'admin') - ? html`<${AdminPage} token=${user.token} />` + ? html`<${AdminPage} token=${user.token} role=${user.role} />` : html`<${HomePage} groups=${groups} notifications=${notifications} onMarkRead=${markRead} onPurge=${purgeNotifications} />`; } else if (route === '/settings') { @@ -3254,6 +3332,7 @@ function App() { route=${route} menuOpen=${menuOpen} role=${user.role} + allowPublicGroups=${allowPublicGroups} hasNodeKey=${hasNodeKey} />`} ${menuOpen && html`<div class="overlay visible" onClick=${() => setMenuOpen(false)} />`} |