summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-23 15:15:35 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-23 15:15:35 +0200
commit9f02ee2c09652abf1308bdfa4a3eec4e9ca9ac83 (patch)
treeb13198a79a0965f254c828adba3eb41dd5e9a5b4 /packages/meshbay-hub/src/meshbay_hub/static/group-settings.js
parent8dc11dc05a35a5d64ba4d2c892ccc01c7bfae3da (diff)
downloadmeshbay-9f02ee2c09652abf1308bdfa4a3eec4e9ca9ac83.tar.gz
feat(hub): split the group UI into a pluggable "applications" architecture
GroupPage's 6620-line app.js carried Chat and Files wedged in directly, with no way to add another group-level app without touching the shell itself. It is now app.js (routing, non-group pages) plus nine focused files — apps.js (the registry), chat-app.js, files-app.js, video-player.js, group-page.js (the shell), group-settings.js, hub-client.js, icon.js and file-utils.js — with docs/apps.md as the checklist for adding one (Videos/Music/Photos are sketched there, not built). Node side gained the matching enablement mechanism, mirroring member_upload exactly: a roster setting, a signed apps_enabled op enforced by _has_admin_authority, exposed in the handshake ack. Operators toggle applications per group from Settings, which also gained a small reorder: Invite, Pairing, Applications, Shared directories, Uploads, danger zone, Your devices, Members. Two bugs surfaced during the split, both missing an import across the new file boundary and invisible to node --check or a module-load probe since they only throw when the code path actually runs: - group-page.js called onRefreshAuth on a stale-token handshake rejection, but app.js never imported refreshAccessToken from hub-client.js — so a brand new member (including a group's own creator) hit "Not a member of this group" and the retry silently failed, throwing before it could refresh the token. - chat-app.js called getLocale() for message timestamps without importing it from i18n.js. Opening Chat on a group with real messages threw mid- render; uncaught, that appears to wedge Preact's render scheduler, so every button on the page stopped responding until reload. Caught the second class of bug with a proper no-undef audit across all split files (a temporarily installed ESLint 9, since the system one is too old to parse this codebase's syntax) rather than trusting grep. 827 tests pass; 6 new ones cover the apps_enabled policy. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016SF6RKNBKg9qejmoMJ9ybA
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/group-settings.js')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-settings.js635
1 files changed, 635 insertions, 0 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js b/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js
new file mode 100644
index 0000000..a14fd21
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js
@@ -0,0 +1,635 @@
+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,
+ 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('');
+
+ 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 [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`<p class="page-message">${t('explore.loading')}</p>`;
+
+ const isOwner = Boolean(isAdmin);
+
+ return html`
+ <div class="members-panel">
+ ${error && html`<div class="error-msg" style="margin-bottom:12px">${error}</div>`}
+
+ ${/* Inviting needs the node: it is the node that wraps the group key and
+ issues the code, not the hub. Public groups admit anyone — no invite.
+ The form stays in the DOM so a brief reconnect does not destroy the
+ input the user is typing into — controls are disabled instead. */
+ isAdmin && group?.join_policy !== 'open' && html`
+ <div class="settings-section">
+ <h3 class="settings-heading">${t('members.invite_title')}</h3>
+ ${!connected ? html`
+ <p class="settings-hint">${t('group.offline_title')}</p>
+ ` : !operatorPaired ? html`
+ <p class="settings-hint">
+ ${isNodeAdmin ? t('members.invite_needs_pairing')
+ : t('members.invite_ask_operator')}
+ </p>
+ ` : ''}
+ <form onSubmit=${doInvite}>
+ ${inviteCode && html`
+ <div class="success-msg" style="margin-bottom:8px">
+ <p>${t('members.invite_code_ready', { user: inviteCode.username })}</p>
+ <p class="code-display">${inviteCode.code}</p>
+ <p>${t('members.invite_code_hint')}</p>
+ </div>
+ `}
+ <div class="form-row">
+ <input type="text" placeholder="${t('members.username_placeholder')}"
+ value=${inviteUser} onInput=${e => setInviteUser(e.target.value)}
+ disabled=${!connected || !operatorPaired} required />
+ <button class="admin-btn" type="submit"
+ disabled=${inviting || !connected || !operatorPaired}>
+ ${inviting ? '...' : t('members.invite_btn')}
+ </button>
+ </div>
+ </form>
+ </div>
+ `}
+
+ ${isNodeAdmin && !operatorPaired && connected && html`
+ <div class="settings-section">
+ <h3 class="settings-heading">${t('members.pair_title')}</h3>
+ <p class="settings-hint">${t('members.pair_hint')}</p>
+ ${pairStatus && html`
+ <p class=${pairStatus === 'paired' ? 'success-msg' : 'error-msg'}>
+ ${pairStatus === 'paired' ? t('members.pair_success') : pairStatus}
+ </p>
+ `}
+ <form class="form-row" onSubmit=${doPair}>
+ <input type="text" placeholder="XXXX-XXXX" class="code-input"
+ value=${pairCode} onInput=${e => setPairCode(e.target.value)} required />
+ <button class="admin-btn" type="submit" disabled=${pairing}>
+ ${pairing ? '...' : t('members.pair_btn')}
+ </button>
+ </form>
+ </div>
+ `}
+
+ ${/* Which group "applications" members see. New ones (Videos, Music,
+ Photos) show up here automatically as they register in apps.js —
+ nothing about this section changes to add one. */
+ isNodeAdmin && connected && html`
+ <div class="settings-section">
+ <h3 class="settings-heading">${t('members.apps_title')}</h3>
+ <p class="settings-hint">${t('members.apps_hint')}</p>
+ <ul class="apps-toggle-list">
+ ${APPS.map(a => html`
+ <li key=${a.key} class="settings-row">
+ <label class="settings-label">
+ <input type="checkbox" checked=${activeApps.includes(a.key)}
+ disabled=${appsBusy}
+ onChange=${() => toggleApp(a.key)} />
+ ${' '}${t(a.labelKey)}
+ </label>
+ </li>
+ `)}
+ </ul>
+ ${appsMsg && html`<p class="error-msg">${appsMsg}</p>`}
+ </div>
+ `}
+
+ ${/* Roots management (Electron-only, when node is local) */
+ nodeDetected && nodeRoots.length > 0 && html`
+ <div class="settings-section">
+ <h3 class="settings-heading">${t('settings_node.roots')}</h3>
+ ${nodeMsg && html`<p class="settings-hint">${nodeMsg}</p>`}
+ <div class="node-roots">
+ ${nodeRoots.map(r => html`
+ <div class="node-root ${!r.available ? 'node-root-unavailable' : ''}"
+ key=${r.name}>
+ <div class="node-root-info">
+ <span class="node-root-name">
+ <${Icon} name="folder" />
+ ${r.name}
+ </span>
+ ${r.upload && html`
+ <span class="node-root-badge">${t('node.upload_root')}</span>`}
+ ${!r.available && html`
+ <span class="node-root-badge node-root-badge-warn">
+ ${t('node.unavailable')}</span>`}
+ </div>
+ ${nodeRoots.length > 1 && !r.upload && html`
+ <button class="btn btn-small btn-danger"
+ disabled=${nodeBusy}
+ onClick=${async () => {
+ if (!confirm(t('node.root_remove_confirm', { name: r.name }))) return;
+ setNodeBusy(true); setNodeMsg('');
+ try {
+ await platform.node.call('DELETE',
+ '/api/groups/' + groupId + '/roots/' + encodeURIComponent(r.name));
+ await platform.node.call('POST', '/api/reload');
+ setNodeMsg(t('node.root_removed'));
+ await loadNodeInfo();
+ } catch (err) { setNodeMsg(platform.bridgeMessage(err)); }
+ finally { setNodeBusy(false); }
+ }}>
+ ${t('node.remove_root')}</button>`}
+ </div>
+ `)}
+ <button class="btn btn-small btn-secondary" style="margin-top:8px"
+ disabled=${nodeBusy}
+ onClick=${async () => {
+ const chosen = await platform.rootPicker.choose();
+ if (!chosen) return;
+ setNodeBusy(true); setNodeMsg('');
+ try {
+ await platform.node.call('POST',
+ '/api/groups/' + groupId + '/roots',
+ { path: chosen.path, name: chosen.name });
+ await platform.node.call('POST', '/api/reload');
+ setNodeMsg(t('node.root_added'));
+ await loadNodeInfo();
+ } catch (err) { setNodeMsg(platform.bridgeMessage(err)); }
+ finally { setNodeBusy(false); }
+ }}>
+ <${Icon} name="folder-plus" /> ${t('node.add_root')}
+ </button>
+ </div>
+ </div>
+ `}
+
+ ${/* Operator only, and only with a live connection: the node is what
+ holds and enforces this, so there is nothing to show or change
+ without one. */ isNodeAdmin && connected && html`
+ <div class="settings-section">
+ <h3 class="settings-heading">${t('members.uploads_title')}</h3>
+ <div class="settings-row">
+ <span class="settings-label">
+ ${memberUpload ? t('members.uploads_on') : t('members.uploads_off')}
+ </span>
+ <button class="admin-btn" disabled=${uploadBusy}
+ onClick=${() => setUploads(!memberUpload)}>
+ ${uploadBusy ? '...'
+ : (memberUpload ? t('members.uploads_disable')
+ : t('members.uploads_enable'))}
+ </button>
+ </div>
+ <p class="settings-hint">${t('members.uploads_hint')}</p>
+ ${uploadMsg && html`<p class="error-msg">${uploadMsg}</p>`}
+ </div>
+ `}
+
+ ${/* Upload toggle via loopback when MNP not connected */
+ nodeDetected && !connected && html`
+ <div class="settings-section">
+ <h3 class="settings-heading">${t('members.uploads_title')}</h3>
+ <div class="settings-row">
+ <span class="settings-label">
+ ${memberUpload ? t('members.uploads_on') : t('members.uploads_off')}
+ </span>
+ <button class="admin-btn" disabled=${nodeBusy}
+ onClick=${async () => {
+ setNodeBusy(true); setNodeMsg('');
+ try {
+ const newVal = !memberUpload;
+ await platform.node.call('PUT',
+ '/api/groups/' + groupId + '/member-upload',
+ { allowed: newVal });
+ if (onMemberUpload) onMemberUpload(newVal);
+ } catch (err) { setNodeMsg(platform.bridgeMessage(err)); }
+ finally { setNodeBusy(false); }
+ }}>
+ ${memberUpload ? t('members.uploads_disable')
+ : t('members.uploads_enable')}
+ </button>
+ </div>
+ <p class="settings-hint">${t('members.uploads_hint')}</p>
+ </div>
+ `}
+
+ ${/* Delete/leave — node detach first (reversible), then hub delete
+ (irreversible). */ html`
+ <div class="settings-section">
+ <h3 class="settings-heading">
+ ${isOwner ? t('group.delete_group') : t('group.leave')}
+ </h3>
+ <div class="settings-row">
+ <span class="settings-label">
+ ${isOwner ? t('members.danger_delete_hint')
+ : t('members.danger_leave_hint')}
+ </span>
+ ${isOwner
+ ? html`
+ <button class="admin-btn danger" onClick=${async () => {
+ if (!confirm(t('group.delete_group_confirm', { name: group.name }))) return;
+ try {
+ // Node detach first (reversible), then hub delete (irreversible)
+ if (nodeDetected && nodeGroupName) {
+ try {
+ await platform.node.call('POST', '/api/groups/detach',
+ { name: nodeGroupName });
+ } catch (detachErr) {
+ if (!confirm(t('settings_node.detach_failed_continue'))) return;
+ }
+ }
+ await hubFetch('/v1/groups/' + groupId, { method: 'DELETE', token });
+ navigate('/');
+ window.location.reload();
+ } catch (err) { setError(err.message); }
+ }}>${t('group.delete_group')}</button>
+ `
+ : html`
+ <button class="admin-btn danger" onClick=${async () => {
+ if (!confirm(t('group.leave_confirm', { name: group.name }))) return;
+ try {
+ await hubFetch('/v1/groups/' + groupId + '/leave',
+ { method: 'POST', token });
+ if (onLeft) onLeft(groupId);
+ } catch (err) { setError(err.message); }
+ }}>${t('group.leave')}</button>
+ `}
+ </div>
+ </div>
+ `}
+
+ ${connected && html`
+ <div class="settings-section">
+ <h3 class="settings-heading">${t('device.mine_title')}</h3>
+ <p class="settings-hint">${t('device.mine_hint')}</p>
+ ${deviceMsg && html`<p class="settings-hint">${deviceMsg}</p>`}
+ ${devices.length === 0
+ ? html`<p class="settings-hint">${t('device.mine_empty')}</p>`
+ : html`
+ <ul class="device-list">
+ ${devices.map(d => html`
+ <li class="device-row" key=${d.pk_ed25519}>
+ <span class="device-key">${d.pk_ed25519.slice(0, 16)}…</span>
+ <span class="device-meta">
+ ${d.is_this_one && html`
+ <span class="badge">${t('device.this_one')}</span>${' '}
+ `}
+ ${d.pinned_via}${d.label ? ' · ' + d.label : ''}
+ </span>
+ ${!d.is_this_one && devices.length > 1 && html`
+ <button class="admin-btn" onClick=${() => revokeDevice(d)}>
+ ${t('device.revoke')}
+ </button>
+ `}
+ </li>
+ `)}
+ </ul>
+ `}
+ <form onSubmit=${approveDevice} class="settings-subform">
+ <p class="settings-hint">${t('device.approve_hint')}</p>
+ <div class="form-row">
+ <input type="text" placeholder="XXXX-XXXX" class="code-input"
+ value=${approveCode} onInput=${e => setApproveCode(e.target.value)} />
+ <button class="admin-btn" type="submit">${t('device.approve_btn')}</button>
+ </div>
+ </form>
+ </div>
+ `}
+
+ <div class="settings-section">
+ <h3 class="settings-heading">
+ ${t('group.tab_members')} (${members.length})
+ </h3>
+ <table class="admin-table">
+ <thead>
+ <tr>
+ <th>${t('admin.col_username')}</th>
+ <th>${t('members.group_role')}</th>
+ <th></th>
+ </tr>
+ </thead>
+ <tbody>
+ ${members.map(m => html`
+ <tr key=${m.user_id}>
+ <td>${m.username}</td>
+ <td>
+ ${m.user_id === adminId
+ ? html`<span class="badge badge-owner">${t('members.owner')}</span>`
+ : html`<span class="badge">${t('members.member')}</span>`
+ }
+ </td>
+ <td class="admin-actions">
+ ${isAdmin && m.user_id !== adminId && html`
+ <button class="admin-btn danger" disabled=${removing === m.user_id}
+ onClick=${() => {
+ if (!confirm(t('members.remove_confirm', { user: m.username }))) return;
+ removeMember(m);
+ }}>
+ ${removing === m.user_id ? '...' : t('members.remove')}
+ </button>
+ `}
+ </td>
+ </tr>
+ `)}
+ </tbody>
+ </table>
+ ${isAdmin && members.length > 1 && html`
+ <p class="settings-hint">${t('members.remove_hint')}</p>
+ `}
+ </div>
+ </div>
+ `;
+}
+
+// ── Chat Panel ──────────────────────────────────────────────────────────
+
+/**
+ * Message text with its links made clickable.
+ *
+ * Only http and https, and built as elements rather than markup: a message is
+ * something another member wrote, so it must never become HTML. `javascript:`
+ * and `data:` are not matched at all, and the anchors carry noopener so the new
+ * tab cannot reach back into this one.
+ */
+const URL_RE = /\bhttps?:\/\/[^\s<>"']+/gi;
+
+
+export { GroupSettingsPanel };