import { html, useState, useEffect, useCallback, useRef, } from './vendor/htm-preact.js'; import { t } from './i18n.js'; import * as platform from './platform.js'; import { Icon } from './icon.js'; // ── Node management (D5) ──────────────────────────────────────────────────── // // Electron-only: talks to the local node daemon via its loopback HTTP API // (platform.node.call), not over MNP/WebRTC. The MNP protocol types remain // for potential future browser-side use. function NodeServicePanel({ onChanged }) { const [info, setInfo] = useState(null); const [busy, setBusy] = useState(''); const [err, setErr] = useState(''); const refresh = useCallback(async () => { try { const r = await platform.node.service.status(); setInfo(r); setErr(''); } catch (e) { setErr(platform.bridgeMessage(e)); } }, []); useEffect(() => { if (!platform.node.service.available) return; refresh(); const timer = setInterval(refresh, 5000); return () => clearInterval(timer); }, [refresh]); const act = useCallback(async (name, fn) => { setBusy(name); setErr(''); try { await fn(); await refresh(); if (onChanged) onChanged(); } catch (e) { setErr(platform.bridgeMessage(e)); } finally { setBusy(''); } }, [refresh, onChanged]); // "off" / "signin" / "service" -- derived from the status payload, no new // backend field needed: mode/autostart already distinguish all three. const startupMode = (i) => { if (!i) return 'off'; if (i.mode === 'service') return i.mode; return i.autostart ? 'signin' : 'off'; }; // Switching mode itself — the installer's own radio page only runs once, // at install time, so this is the only way back in if service mode was // declined there, or out if it is no longer wanted. One elevation, task + // firewall together, same script the installer runs. // // The two mechanisms are mutually exclusive by construction here: never // both installed at once, which would start the daemon twice (once at // boot via the Scheduled Task, again at sign-in via the Startup .vbs). // Always remove whichever one is currently active before installing the // target, so every transition -- not just the two that used to be // separate toggles -- keeps that invariant. const changeStartupMode = useCallback((target) => { const current = startupMode(info); if (target === current) return; act('startupMode', async () => { if (current === 'service') await platform.node.serviceMode.remove(); else if (current === 'signin') await platform.node.autostart.remove(); if (target === 'service') await platform.node.serviceMode.install(); else if (target === 'signin') await platform.node.autostart.install(); }); }, [act, info]); if (!platform.node.service.available) return null; if (!info || info.supported === false) { return html`
${' '}${t('node.service_checking')}
`; } const KNOWN_STATES = ['active', 'inactive', 'failed', 'activating', 'deactivating']; const stateKey = KNOWN_STATES.includes(info.activeState) ? info.activeState : 'unknown'; const running = info.activeState === 'active' || info.activeState === 'activating'; const dot = info.activeState === 'active' ? 'online' : info.activeState === 'failed' ? 'offline' : 'unknown'; const label = info.installed ? t('node.service_state_' + stateKey) : t('node.service_not_installed'); // Own row, below the status/actions card rather than a further item // crammed into its flex-wrap line -- that (plus two independent toggles // for what is really one choice) is what made this a mess before. const showStartupRow = (platform.node.autostart.available || platform.node.serviceMode.available) && typeof info.mode === 'string'; return html`
${label}
${err && html`
${err}
`}
${info.installed && html` `}
${info.mode === 'service' && html`

${t('node.service_mode_hint')}

`}
${showStartupRow && html`
${t('node.startup_mode_label')}
${busy === 'startupMode' && html`

${t('node.startup_mode_updating')}

`} ${!info.canElevate && html`

${t('node.startup_mode_service_unavailable_hint')}

`} `}
`; } async function nodeCall(method, path, body) { return platform.node.call(method, path, body); } // Hand a generated file to the user: native Save As on the desktop, a blob // download link everywhere else. Small text only (CSV export) — not the // large-file download path in file-utils.js. async function saveCsv(filename, text) { const bytes = new TextEncoder().encode(text); try { const target = await platform.nativeSave(filename, { auto: false }); if (target && target.writable) { if (target.open) await target.open(); await target.writable.write(bytes); await target.writable.close(); return; } } catch { /* fall through to the blob path */ } const url = URL.createObjectURL(new Blob([bytes], { type: 'text/csv' })); const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } export function NodePage({ groups }) { const [status, setStatus] = useState('idle'); const [error, setError] = useState(''); const [nodeGroups, setNodeGroups] = useState([]); const [busy, setBusy] = useState(false); const [actionMsg, setActionMsg] = useState(''); const [roster, setRoster] = useState(null); const [rosterGroup, setRosterGroup] = useState(''); const [denylist, setDenylist] = useState(null); const [showDenylist, setShowDenylist] = useState(false); const [nodeSettings, setNodeSettings] = useState(null); const [editSettings, setEditSettings] = useState(null); const [savingSettings, setSavingSettings] = useState(false); const [editStun, setEditStun] = useState(null); const [savingStun, setSavingStun] = useState(false); const [stunInput, setStunInput] = useState(''); const [editIce, setEditIce] = useState(null); const [savingIce, setSavingIce] = useState(false); const [iceInput, setIceInput] = useState(''); const [operatorPaired, setOperatorPaired] = useState(false); const [pairBusy, setPairBusy] = useState(false); const [pairStatus, setPairStatus] = useState(''); const [nodeInfo, setNodeInfo] = useState(null); const [peers, setPeers] = useState(null); const [audit, setAudit] = useState(null); const [auditEvent, setAuditEvent] = useState(''); const [auditPageSize, setAuditPageSize] = useState(100); const [auditPage, setAuditPage] = useState(0); const [auditHasMore, setAuditHasMore] = useState(false); const [auditExporting, setAuditExporting] = useState(false); const [cacheCount, setCacheCount] = useState(null); const [nodeRoster, setNodeRoster] = useState(null); const [unlinkBusy, setUnlinkBusy] = useState(false); const [tab, setTab] = useState('overview'); const fetchStatus = useCallback(async () => { setStatus('connecting'); setError(''); try { const detection = await platform.node.detect(); if (!detection.detected) { setError(t('node.offline')); setStatus('error'); return; } const result = await nodeCall('GET', '/api/groups'); setNodeGroups(result.groups || []); setOperatorPaired(!!result.operator_paired); setNodeSettings(result.settings || null); try { setNodeInfo(await nodeCall('GET', '/api/status')); } catch {} setStatus('connected'); } catch (err) { setError(platform.bridgeMessage(err)); setStatus('error'); } }, []); useEffect(() => { fetchStatus(); }, [fetchStatus]); useEffect(() => { if (nodeSettings && !editSettings) setEditSettings({ ...nodeSettings }); if (nodeSettings && !editStun) setEditStun([...(nodeSettings.stun_servers || [])]); if (nodeSettings && !editIce) setEditIce([...(nodeSettings.ice_interfaces || [])]); }, [nodeSettings]); const refresh = useCallback(async () => { try { const result = await nodeCall('GET', '/api/groups'); setNodeGroups(result.groups || []); setOperatorPaired(!!result.operator_paired); setNodeSettings(result.settings || null); try { setNodeInfo(await nodeCall('GET', '/api/status')); } catch {} } catch {} }, []); const addRoot = useCallback(async (groupId) => { const chosen = await platform.rootPicker.choose(); if (!chosen) return; setBusy(true); setActionMsg(''); try { await nodeCall('POST', `/api/groups/${groupId}/roots`, { path: chosen.path }); await nodeCall('POST', '/api/reload'); await refresh(); setActionMsg(t('node.root_added')); } catch (err) { setActionMsg(platform.bridgeMessage(err)); } finally { setBusy(false); } }, [refresh]); const removeRoot = useCallback(async (groupId, rootName) => { if (!confirm(t('node.root_remove_confirm', { name: rootName }))) return; setBusy(true); setActionMsg(''); try { await nodeCall('DELETE', `/api/groups/${groupId}/roots/${encodeURIComponent(rootName)}`); await nodeCall('POST', '/api/reload'); await refresh(); setActionMsg(t('node.root_removed')); } catch (err) { setActionMsg(platform.bridgeMessage(err)); } finally { setBusy(false); } }, [refresh]); const loadRoster = useCallback(async (groupId) => { setBusy(true); try { const result = await nodeCall('GET', `/api/roster?group_id=${groupId}`); setRoster(result); setRosterGroup(groupId); } catch (err) { setActionMsg(platform.bridgeMessage(err)); } finally { setBusy(false); } }, []); const unpinMember = useCallback(async (userId) => { if (!confirm(t('node.unpin_confirm', { name: userId }))) return; setBusy(true); setActionMsg(''); try { await nodeCall('POST', `/api/members/${userId}/unpin`); setActionMsg(t('node.unpin_done')); if (rosterGroup) await loadRoster(rosterGroup); } catch (err) { setActionMsg(platform.bridgeMessage(err)); } finally { setBusy(false); } }, [rosterGroup, loadRoster]); const rotateGek = useCallback(async (groupId) => { if (!confirm(t('node.gek_rotate_confirm'))) return; setBusy(true); setActionMsg(''); try { await nodeCall('POST', `/api/groups/${groupId}/gek?rotate=true`); setActionMsg(t('node.gek_rotated')); } catch (err) { setActionMsg(platform.bridgeMessage(err)); } finally { setBusy(false); } }, []); const loadDenylist = useCallback(async () => { setBusy(true); try { const result = await nodeCall('GET', '/api/denylist'); setDenylist(result); setShowDenylist(true); } catch (err) { setActionMsg(platform.bridgeMessage(err)); } finally { setBusy(false); } }, []); const clearDenylist = useCallback(async (subject) => { const label = subject || t('node.denylist_clear_all'); if (!confirm(t('node.denylist_clear_confirm', { subject: label }))) return; setBusy(true); setActionMsg(''); try { await nodeCall('POST', `/api/denylist/clear?subject=${encodeURIComponent(subject)}`); setActionMsg(t('node.denylist_cleared')); await loadDenylist(); } catch (err) { setActionMsg(platform.bridgeMessage(err)); } finally { setBusy(false); } }, [loadDenylist]); const detachGroup = useCallback(async (name) => { if (!confirm(t('node.detach_confirm', { name }))) return; setBusy(true); setActionMsg(''); try { await nodeCall('POST', '/api/groups/detach', { name }); await nodeCall('POST', '/api/reload'); setActionMsg(t('node.detached')); await refresh(); } catch (err) { setActionMsg(platform.bridgeMessage(err)); } finally { setBusy(false); } }, [refresh]); const reloadConfig = useCallback(async () => { setBusy(true); setActionMsg(''); try { await nodeCall('POST', '/api/reload'); setActionMsg(t('node.reloaded')); await refresh(); } catch (err) { setActionMsg(platform.bridgeMessage(err)); } finally { setBusy(false); } }, [refresh]); const saveSettings = useCallback(async () => { if (!editSettings) return; setSavingSettings(true); setActionMsg(''); try { await nodeCall('PUT', '/api/node-settings', editSettings); setNodeSettings({ ...editSettings }); setActionMsg(t('node.settings_saved')); } catch (err) { setActionMsg(platform.bridgeMessage(err)); } finally { setSavingSettings(false); } }, [editSettings]); const saveStun = useCallback(async () => { if (!editStun) return; setSavingStun(true); setActionMsg(''); try { await nodeCall('PUT', '/api/node-settings', { stun_servers: editStun }); setNodeSettings(s => s ? { ...s, stun_servers: [...editStun] } : s); setActionMsg(t('node.stun_saved')); } catch (err) { setActionMsg(platform.bridgeMessage(err)); } finally { setSavingStun(false); } }, [editStun]); const addStunServer = useCallback(() => { const url = stunInput.trim(); if (!url) return; if (!url.startsWith('stun:')) { setActionMsg(t('node.stun_invalid')); return; } if (editStun && editStun.includes(url)) { setActionMsg(t('node.stun_duplicate')); return; } setEditStun(s => [...(s || []), url]); setStunInput(''); setActionMsg(''); }, [stunInput, editStun]); const removeStunServer = useCallback((idx) => { setEditStun(s => s.filter((_, i) => i !== idx)); }, []); const moveStunServer = useCallback((idx, dir) => { setEditStun(s => { const a = [...s]; const target = idx + dir; if (target < 0 || target >= a.length) return a; [a[idx], a[target]] = [a[target], a[idx]]; return a; }); }, []); const resetStunDefaults = useCallback(() => { // Keep in step with meshbay_node.config.DEFAULT_STUN_SERVERS. setEditStun([ 'stun:stun.l.google.com:19302', 'stun:stun1.l.google.com:19302', 'stun:stun.cloudflare.com:3478', ]); setActionMsg(''); }, []); const saveIce = useCallback(async () => { if (!editIce) return; setSavingIce(true); setActionMsg(''); try { await nodeCall('PUT', '/api/node-settings', { ice_interfaces: editIce }); setNodeSettings(s => s ? { ...s, ice_interfaces: [...editIce] } : s); setActionMsg(t('node.ice_saved')); } catch (err) { setActionMsg(platform.bridgeMessage(err)); } finally { setSavingIce(false); } }, [editIce]); const addIceInterface = useCallback(() => { const name = iceInput.trim(); if (!name) return; if (editIce && editIce.includes(name)) { setActionMsg(t('node.ice_duplicate')); return; } setEditIce(s => [...(s || []), name]); setIceInput(''); setActionMsg(''); }, [iceInput, editIce]); const removeIceInterface = useCallback((idx) => { setEditIce(s => s.filter((_, i) => i !== idx)); }, []); const resetIceAuto = useCallback(() => { setEditIce([]); setActionMsg(''); }, []); const doPairOperator = useCallback(async () => { setPairBusy(true); setPairStatus(''); try { const result = await nodeCall('POST', '/api/operator/pair'); if (result && result.code) { await platform.node.setPairingCode(result.code); } setPairStatus('paired'); setOperatorPaired(true); await refresh(); } catch (err) { setPairStatus(platform.bridgeMessage(err)); } finally { setPairBusy(false); } }, [refresh]); const loadPeers = useCallback(async () => { setBusy(true); try { const r = await nodeCall('GET', '/api/peers'); setPeers(r.peers || []); } catch (err) { setActionMsg(platform.bridgeMessage(err)); } finally { setBusy(false); } }, []); const loadAudit = useCallback(async () => { setBusy(true); try { const offset = auditPage * auditPageSize; let path = `/api/audit?limit=${auditPageSize}&offset=${offset}`; if (auditEvent) path += `&event=${encodeURIComponent(auditEvent)}`; const r = await nodeCall('GET', path); setAudit(r.entries || []); setAuditHasMore(!!r.has_more); } catch (err) { setActionMsg(platform.bridgeMessage(err)); } finally { setBusy(false); } }, [auditEvent, auditPage, auditPageSize]); const exportAuditCsv = useCallback(async () => { setAuditExporting(true); setActionMsg(''); try { // Every entry that matches the current event filter, not just one page. // Walk the log newest-first in blocks; de-dupe by id so an event written // mid-export (which shifts rows to a higher offset) cannot duplicate one. const PAGE = 1000; const MAX_PAGES = 1000; // 1M-row stop, so a bug cannot spin forever const evq = auditEvent ? `&event=${encodeURIComponent(auditEvent)}` : ''; const seen = new Set(); const rows = []; for (let page = 0; page < MAX_PAGES; page++) { const r = await nodeCall( 'GET', `/api/audit?limit=${PAGE}&offset=${page * PAGE}${evq}`); const batch = r.entries || []; for (const e of batch) { if (!seen.has(e.id)) { seen.add(e.id); rows.push(e); } } if (!r.has_more || batch.length === 0) break; } const cols = ['timestamp', 'event', 'user', 'user_id', 'ip', 'group', 'group_id', 'detail']; const esc = (v) => { const s = v == null ? '' : String(v); return /[",\n\r]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s; }; const lines = [cols.join(',')]; for (const e of rows) { lines.push([ new Date(e.timestamp * 1000).toISOString(), e.event, e.username || '', e.user_id || '', e.ip || '', e.group_name || '', e.group_id || '', e.detail || '', ].map(esc).join(',')); } const csv = lines.join('\r\n') + '\r\n'; const stamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-'); await saveCsv(`node-audit-${stamp}.csv`, csv); setActionMsg(t('node.audit_exported', { n: rows.length })); } catch (err) { setActionMsg(platform.bridgeMessage(err)); } finally { setAuditExporting(false); } }, [auditEvent]); const loadCache = useCallback(async () => { setBusy(true); try { const r = await nodeCall('GET', '/api/index-cache'); setCacheCount(r.count ?? 0); } catch (err) { setActionMsg(platform.bridgeMessage(err)); } finally { setBusy(false); } }, []); const pruneCache = useCallback(async () => { setBusy(true); setActionMsg(''); try { const r = await nodeCall('POST', '/api/index-cache/prune'); setCacheCount(r.kept ?? null); setActionMsg(t('node.maintenance_pruned', { n: r.removed ?? 0 })); } catch (err) { setActionMsg(platform.bridgeMessage(err)); } finally { setBusy(false); } }, []); const loadNodeRoster = useCallback(async () => { setBusy(true); try { const r = await nodeCall('GET', '/api/roster'); setNodeRoster(r); } catch (err) { setActionMsg(platform.bridgeMessage(err)); } finally { setBusy(false); } }, []); const unpinNodeMember = useCallback(async (userId) => { if (!confirm(t('node.unpin_confirm', { name: userId }))) return; setBusy(true); setActionMsg(''); try { await nodeCall('POST', `/api/members/${userId}/unpin`); setActionMsg(t('node.unpin_done')); await loadNodeRoster(); } catch (err) { setActionMsg(platform.bridgeMessage(err)); } finally { setBusy(false); } }, [loadNodeRoster]); const unlinkNode = useCallback(async () => { if (!confirm(t('node.unlink_confirm'))) return; setUnlinkBusy(true); setActionMsg(''); try { await nodeCall('DELETE', '/api/unlink'); setActionMsg(t('node.unlink_done')); await refresh(); } catch (err) { setActionMsg(platform.bridgeMessage(err)); } finally { setUnlinkBusy(false); } }, [refresh]); // Each read-only tab loads itself on open — no Load button. useEffect(() => { if (status !== 'connected') return; if (tab === 'overview') loadCache(); else if (tab === 'roster') loadNodeRoster(); else if (tab === 'peers') loadPeers(); }, [tab, status, loadCache, loadNodeRoster, loadPeers]); // Audit reloads on tab open and whenever the filter or page changes // (loadAudit's identity tracks auditEvent / auditPage / auditPageSize). useEffect(() => { if (status === 'connected' && tab === 'audit') loadAudit(); }, [tab, status, loadAudit]); if (status === 'idle' || status === 'connecting') { return html`

${t('node.title')}

<${NodeServicePanel} onChanged=${fetchStatus} />

${' '}${t('status.connecting')}

`; } if (status === 'error') { return html`

${t('node.title')}

<${NodeServicePanel} onChanged=${fetchStatus} />

${error}

`; } return html`

${t('node.title')}

<${NodeServicePanel} onChanged=${fetchStatus} /> ${actionMsg && html`
${actionMsg}
`} ${!operatorPaired && html`

${t('node.pair_needed')}

${pairStatus === 'paired' ? html`

${t('node.pair_success')}

` : pairStatus ? html`

${pairStatus}

` : null}
`}
${['overview', 'groups', 'roster', 'peers', 'audit', 'settings'].map(k => html` `)}
${tab === 'overview' && nodeInfo && html`
${t('node.overview')}
${t('node.overview_version')}${nodeInfo.version || '—'}
${t('node.overview_node_id')} ${nodeInfo.endpoint_hint || nodeInfo.pk_node_ed25519 || '—'}
${t('node.overview_quic_port')}${nodeInfo.quic_port || '—'}
${t('node.overview_hub')}${nodeInfo.hub_url || '—'}
`} ${tab === 'groups' && (() => { const hubIds = new Set((groups || []).map(g => g.id)); return nodeGroups.map(g => { const stale = !hubIds.has(g.id); return html`

${g.name}${stale ? html` ${t('node.stale')}` : ''}

${t('node.files', { n: g.file_count })} ${t('node.peers', { n: g.peers })} ${!g.has_gek && html` ${t('node.no_gek')}`}
${t('node.roots')}
${(g.roots || []).map(r => html`
<${Icon} name="folder" /> ${r.name} ${r.writable && html` ${t('node.root_rw')}`} ${r.removable && html` ${t('node.removable')}`} ${r.ejected ? html` ${t('group.root_ejected')}` : !r.available && html` ${t('node.unavailable')}`}
${/* Removing a writable root is allowed now — several can be writable, and a group with none is a valid read-only group. The last root is still the one that cannot go. */''} ${(g.roots || []).length > 1 && html` `}
`)}
${t('node.gek')} ${g.has_gek ? (g.visibility !== 'public' ? html` ` : html`

${t('node.gek_public_hint')}

`) : html`

${t('node.gek_init_hint')}

`}
${t('node.roster')}
${roster && rosterGroup === g.id && html`
${(roster.members || []).length === 0 ? html`

${t('node.roster_empty')}

` : html` ${(roster.members || []).map(m => html` `)}
${t('node.roster_user')} ${t('node.roster_role')} ${t('node.roster_status')} ${t('node.roster_via')}
${m.username || m.user_id.slice(0, 12)} ${m.role} ${m.status} ${m.pinned_via || ''} ${m.pk_ed25519 && html` `}
`} ${(roster.invites || []).length > 0 && html`

${t('node.roster_invites', { n: roster.invites.length })}

`}
`}
${stale && html`
`}
`; }); })()} ${tab === 'roster' && html`
${t('node.node_roster')}

${t('node.node_roster_hint')}

${!nodeRoster ? html`

${t('node.loading')}

` : (nodeRoster.members || []).length === 0 ? html`

${t('node.roster_empty')}

` : html`
${(nodeRoster.members || []).map(m => html` `)}
${t('node.roster_user')} ${t('node.roster_role')} ${t('node.roster_status')} ${t('node.roster_scope')} ${t('node.roster_via')}
${m.username || m.user_id.slice(0, 12)} ${m.role} ${m.status} ${m.group_id ? m.group_id.slice(0, 8) : t('node.roster_scope_node')} ${m.pinned_via || ''} ${m.pk_ed25519 && html` `}
`}
`} ${tab === 'peers' && html`
${t('node.peers_title')} ${!peers ? html`

${t('node.loading')}

` : peers.length === 0 ? html`

${t('node.peers_empty')}

` : html`
${peers.map(p => html` `)}
${t('node.peers_user')} ${t('node.peers_ip')} ${t('node.peers_group')} ${t('node.peers_state')}
${p.username || (p.user_id || '').slice(0, 12) || '—'} ${p.remote_ip || '—'} ${p.group_name || (p.group_id ? p.group_id.slice(0, 8) : '—')} ${p.state || '—'}
`}
`} ${tab === 'audit' && html`
${t('node.audit_title')}
${!audit ? html`

${t('node.loading')}

` : audit.length === 0 ? html`

${t('node.audit_empty')}

` : html`
${audit.map(e => html` `)}
${t('node.audit_time')} ${t('node.audit_event')} ${t('node.audit_user')} ${t('node.audit_ip')} ${t('node.audit_group')} ${t('node.audit_detail')}
${new Date(e.timestamp * 1000).toLocaleString()} ${e.event} ${e.username || (e.user_id || '').slice(0, 8)} ${e.ip || '—'} ${e.group_name || (e.group_id ? e.group_id.slice(0, 8) : '—')} ${e.detail || ''}
`} ${audit && html`
${t('node.audit_page', { n: auditPage + 1 })}
`}
`} ${tab === 'overview' && html`
${t('node.maintenance')} ${cacheCount === null ? html`

${t('node.loading')}

` : html`

${t('node.maintenance_cache', { n: cacheCount })}

`}
`} ${tab === 'settings' && editSettings && html`
${t('node.settings')}

${t('node.settings_edit_hint')}

`} ${tab === 'settings' && editStun && html`
${t('node.stun_servers')}

${t('node.stun_hint')}

${editStun.length === 0 ? html`

${t('node.stun_empty')}

` : html`
${editStun.map((url, idx) => html`
${url}
`)}
`}
setStunInput(e.target.value)} onKeyDown=${e => { if (e.key === 'Enter') addStunServer(); }} />
`} ${tab === 'settings' && editIce && html`
${t('node.ice_interfaces')}

${editIce.length === 0 ? t('node.ice_auto_hint') : t('node.ice_manual_hint')}

${editIce.length > 0 && html`
${editIce.map((name, idx) => html`
${name}
`)}
`}
setIceInput(e.target.value)} onKeyDown=${e => { if (e.key === 'Enter') addIceInterface(); }} />
${editIce.length > 0 && html` `}
`} ${tab === 'settings' && html`
${t('node.denylist')}
${showDenylist && denylist && html`
${denylist.count === 0 ? html`

${t('node.denylist_empty')}

` : html`

${t('node.denylist_count', { n: denylist.count })}

${(denylist.users || []).map(u => html`
user: ${u}
`)} ${(denylist.groups || []).map(g => html`
group: ${g}
`)} ${(denylist.jtis || []).map(j => html`
token: ${j.slice(0, 16)}
`)} `}
`}
`} ${tab === 'settings' && nodeInfo && nodeInfo.status !== 'waiting_for_node_key' && html`
${t('node.unlink_title')}

${t('node.unlink_hint')}

`}
`; }