import { html, useState, useEffect, useCallback, } from './vendor/htm-preact.js'; import { t } from './i18n.js'; import { Icon } from './icon.js'; import { hubFetch, navigate } from './hub-client.js'; import { APPS } from './apps.js'; import * as platform from './platform.js'; // ── Members Panel ──────────────────────────────────────────────────────── /** * Everything about the group that is not its files or its chat. * * Was "Members", which was a list with three unrelated forms stacked on top of * it and the group's own controls somewhere else entirely — leaving or deleting * a group lived in the header, beside its title. One tab now, in sections, with * the roster last: it is the part that grows without limit, and burying the * controls under two hundred names is how a tab stops being usable. */ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, isNodeAdmin, userId, operatorPaired, connected, memberUpload, onMemberUpload, enabledApps, onEnabledApps, scanSettings, onScanSettings, onPaired, onLeft }) { const [members, setMembers] = useState([]); const [adminId, setAdminId] = useState(''); const [loading, setLoading] = useState(true); const [inviteUser, setInviteUser] = useState(''); const [inviting, setInviting] = useState(false); const [error, setError] = useState(''); // Node loopback state (Electron-only) const [nodeDetected, setNodeDetected] = useState(false); const [nodeRoots, setNodeRoots] = useState([]); const [nodeGroupName, setNodeGroupName] = useState(''); const [nodeBusy, setNodeBusy] = useState(false); const [nodeMsg, setNodeMsg] = useState(''); // Bytes-based indexing progress while a newly added directory is being // scanned — same source as the Create Group wizard's step, see // platform.watchIndexProgress. const [nodeIndexProgress, setNodeIndexProgress] = useState(null); const loadNodeInfo = useCallback(async () => { if (!platform.node.available) return; try { const detect = await platform.node.detect(); if (!detect.detected) { setNodeDetected(false); return; } setNodeDetected(true); const data = await platform.node.call('GET', '/api/groups'); const groups = data.groups || []; const ng = groups.find(g => g.id === groupId); if (ng) { setNodeRoots(ng.roots || []); setNodeGroupName(ng.name || ''); } } catch { setNodeDetected(false); } }, [groupId]); useEffect(() => { loadNodeInfo(); }, [loadNodeInfo]); const [inviteCode, setInviteCode] = useState(null); const [pairCode, setPairCode] = useState(''); const [pairStatus, setPairStatus] = useState(''); const [pairing, setPairing] = useState(false); // Your own devices on this node. Not a members feature — it is beside them // because this is where a live connection to the node exists. const [devices, setDevices] = useState([]); const [approveCode, setApproveCode] = useState(''); const [deviceMsg, setDeviceMsg] = useState(''); // Pairing lives here rather than in Settings because this is where a live // connection to the node exists — and it is offered only when the node itself // says this account is its operator (is_node_admin comes from the authenticated // handshake_ack, not from the hub). const loadDevices = useCallback(async () => { const transport = transportRef.current; if (!transport || !transport.connected) return; try { const out = await transport.listDevices(); setDevices(out.devices); } catch { /* a node that has none says so by listing none */ } }, [transportRef]); useEffect(() => { loadDevices(); }, [loadDevices]); const approveDevice = useCallback(async (e) => { e.preventDefault(); const code = approveCode.trim(); if (!code) return; setDeviceMsg(''); try { await transportRef.current.approveDevice(userId, code); setApproveCode(''); setDeviceMsg(t('device.approved')); await loadDevices(); } catch (err) { setDeviceMsg(err.message); } }, [approveCode, userId, transportRef, loadDevices]); const revokeDevice = useCallback(async (device) => { if (!confirm(t('device.revoke_confirm'))) return; setDeviceMsg(''); try { await transportRef.current.revokeDevice( userId, device.pk_ed25519, device.pk_x25519 || ''); await loadDevices(); } catch (err) { setDeviceMsg(err.message); } }, [userId, transportRef, loadDevices]); const doPair = useCallback(async (e) => { e.preventDefault(); const code = pairCode.trim(); if (!code) return; setPairing(true); setPairStatus(''); try { const transport = transportRef && transportRef.current; if (!transport || !transport.connected) throw new Error('Not connected to the node'); await transport.pairOperator(userId, code); setPairCode(''); setPairStatus('paired'); // The node has pinned this key as an operator key; the form has nothing // left to do. It used to stay put through a refresh, because what governed // it was the account, which pairing does not change. if (onPaired) onPaired(); } catch (err) { setPairStatus(err.message); } finally { setPairing(false); } }, [pairCode, transportRef, userId]); const [uploadBusy, setUploadBusy] = useState(false); const [uploadMsg, setUploadMsg] = useState(''); /** * Close or open uploading for everyone who is not the operator. * * Signed, like removing a member: the node refuses an unsigned instruction, * so this is a request to the node rather than a decision taken here. The * button does not move until the node has said it did it. */ const setUploads = useCallback(async (allowed) => { const transport = transportRef && transportRef.current; setUploadMsg(''); setUploadBusy(true); try { if (!transport || !transport.connected) { throw new Error('Not connected to the node'); } const sk = transport.sessionKeys && transport.sessionKeys.skEdB64; const signFn = (sk && window.MeshBayKeys) ? (transcript) => window.MeshBayKeys.signBytes(sk, transcript) : null; await transport.setMemberUpload(allowed, signFn); if (onMemberUpload) onMemberUpload(allowed); } catch (err) { setUploadMsg(err.message); } finally { setUploadBusy(false); } }, [transportRef, onMemberUpload]); const [appsBusy, setAppsBusy] = useState(false); const [appsMsg, setAppsMsg] = useState(''); const activeApps = enabledApps && enabledApps.length ? enabledApps : APPS.map(a => a.key); /** * Toggle one app in or out of the group's enabled set. Same shape as * `setUploads`: signed, and the checkbox does not move until the node has * said it did it. Refuses to submit an empty set client-side — the node * refuses it too, but there is no reason to make a round trip to learn that. */ const toggleApp = useCallback(async (key) => { const next = activeApps.includes(key) ? activeApps.filter(k => k !== key) : [...activeApps, key]; if (next.length === 0) { setAppsMsg(t('members.apps_need_one')); return; } const transport = transportRef && transportRef.current; setAppsMsg(''); setAppsBusy(true); try { if (!transport || !transport.connected) { throw new Error('Not connected to the node'); } const sk = transport.sessionKeys && transport.sessionKeys.skEdB64; const signFn = (sk && window.MeshBayKeys) ? (transcript) => window.MeshBayKeys.signBytes(sk, transcript) : null; await transport.setAppsEnabled(next, signFn); if (onEnabledApps) onEnabledApps(next); } catch (err) { setAppsMsg(err.message); } finally { setAppsBusy(false); } }, [transportRef, onEnabledApps, activeApps]); const [scanBusy, setScanBusy] = useState(false); const [scanMsg, setScanMsg] = useState(''); const [reconcileMinutes, setReconcileMinutes] = useState( scanSettings ? Math.round(scanSettings.reconcile_interval_secs / 60) : 10); const [debounceSeconds, setDebounceSeconds] = useState( scanSettings ? Math.round(scanSettings.debounce_secs) : 2); // The node is the source of truth; once it has answered, the fields track // it rather than whatever this browser guessed before connecting. useEffect(() => { if (!scanSettings) return; setReconcileMinutes(Math.round(scanSettings.reconcile_interval_secs / 60)); setDebounceSeconds(Math.round(scanSettings.debounce_secs)); }, [scanSettings]); /** * How often the reconciliation backstop runs, and how long a changed file * is left alone before being hashed. Same shape as toggleApp: signed, and * the fields do not claim success until the node has confirmed it. */ const saveScanSettings = useCallback(async () => { const transport = transportRef && transportRef.current; setScanMsg(''); setScanBusy(true); try { if (!transport || !transport.connected) { throw new Error('Not connected to the node'); } const sk = transport.sessionKeys && transport.sessionKeys.skEdB64; const signFn = (sk && window.MeshBayKeys) ? (transcript) => window.MeshBayKeys.signBytes(sk, transcript) : null; await transport.setScanSettings(reconcileMinutes * 60, debounceSeconds, signFn); const applied = { reconcile_interval_secs: reconcileMinutes * 60, debounce_secs: debounceSeconds, }; if (onScanSettings) onScanSettings(applied); setScanMsg(t('settings_node.scan_saved')); } catch (err) { setScanMsg(err.message); } finally { setScanBusy(false); } }, [transportRef, onScanSettings, reconcileMinutes, debounceSeconds]); const [removing, setRemoving] = useState(''); /** * Take someone out of this group: both halves, in the order that fails safe. * * The node first, because that is the half that stops the group key being * wrapped for them; if the hub removal then fails, they are a member on paper * with no key. The other order would leave them able to reach a node that * still serves them. */ const removeMember = useCallback(async (member) => { const transport = transportRef && transportRef.current; setError(''); setRemoving(member.user_id); try { if (platform.node.available) { try { await platform.node.call('POST', `/api/members/${member.user_id}/revoke?group_id=${groupId}`); } catch { /* best effort — node may not host this group */ } try { await platform.node.call('POST', `/api/members/${member.user_id}/unpin`); } catch { /* best effort */ } } else if (transport && transport.connected && operatorPaired) { const sk = transport.sessionKeys && transport.sessionKeys.skEdB64; const signFn = (sk && window.MeshBayKeys) ? (transcript) => window.MeshBayKeys.signBytes(sk, transcript) : null; await transport.revokeMember(member.user_id, signFn); } await hubFetch(`/v1/groups/${groupId}/members/${member.username}`, { method: 'DELETE', token, }); loadMembers(); } catch (err) { setError(err.message); } finally { setRemoving(''); } }, [groupId, token, transportRef, operatorPaired]); const loadMembers = useCallback(() => { setLoading(true); hubFetch(`/v1/groups/${groupId}/members`, { token }) .then(data => { setMembers(data.members || []); setAdminId(data.admin_id || ''); }) .catch(() => {}) .finally(() => setLoading(false)); }, [groupId, token]); useEffect(() => { loadMembers(); }, [loadMembers]); const isAdmin = group && group.is_admin; const doInvite = useCallback(async (e) => { e.preventDefault(); if (!inviteUser.trim()) return; setInviting(true); setError(''); setInviteCode(null); try { const transport = transportRef && transportRef.current; const username = inviteUser.trim(); if (!transport || !transport.connected) { throw new Error('Not connected to the node — it must be online to invite'); } // The hub is asked for the account id, and nothing else. It is no longer // asked for the invitee's public key: the node wraps the group key itself, // for a key the invitee proves possession of when they connect (H3). A hub // that answered with the wrong account here would produce an invite whose // code it never learns — the code goes to a human, out of band. const account = await hubFetch(`/v1/users/${username}/pubkeys`, { token }); // Signed with the identity this node pinned for us — the only one it // will accept, and the only one we hold here. const sk = transport.sessionKeys && transport.sessionKeys.skEdB64; const signFn = (sk && window.MeshBayKeys) ? (transcript) => window.MeshBayKeys.signBytes(sk, transcript) : null; const result = await transport.createInvite( account.user_id, groupId, username, signFn); // Membership on the hub is what lets them reach the node at all; the code // is what gets them the key. await hubFetch(`/v1/groups/${groupId}/members/${username}`, { method: 'POST', token, body: {}, }); setInviteCode({ username, code: result.code, expires: result.expires_at }); setInviteUser(''); loadMembers(); } catch (err) { setError(err.message); } finally { setInviting(false); } }, [groupId, token, inviteUser, loadMembers, transportRef]); if (loading) return html`
`; const isOwner = Boolean(isAdmin); return html`${t('group.offline_title')}
` : !operatorPaired ? html`${isNodeAdmin ? t('members.invite_needs_pairing') : t('members.invite_ask_operator')}
` : ''}${t('members.pair_hint')}
${pairStatus && html`${pairStatus === 'paired' ? t('members.pair_success') : pairStatus}
`}${t('members.apps_hint')}
${appsMsg}
`}${t('settings_node.scan_hint')}
${scanMsg}
`}${nodeMsg}
`}${t('members.uploads_hint')}
${uploadMsg && html`${uploadMsg}
`}${t('members.uploads_hint')}
${t('device.mine_hint')}
${deviceMsg && html`${deviceMsg}
`} ${devices.length === 0 ? html`${t('device.mine_empty')}
` : html`| ${t('admin.col_username')} | ${t('members.group_role')} | |
|---|---|---|
| ${m.username} | ${m.user_id === adminId ? html`${t('members.owner')}` : html`${t('members.member')}` } | ${isAdmin && m.user_id !== adminId && html` `} |
${t('members.remove_hint')}
`}