summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/app.js
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/app.js')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js187
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)} />`}