diff options
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.js | 172 |
1 files changed, 172 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 index 64ea3fa..121c51b 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js @@ -8,6 +8,7 @@ import { CollapsibleSection, ToggleSwitch } from './settings-ui.js'; import { hubFetch, navigate } from './hub-client.js'; import { availableApps, configurableApps } from './apps.js'; import * as platform from './platform.js'; +import { inviteLinkHere, nodePkForLink } from './invite-link.js'; // The account preference behind the "send by e-mail" box. The hub's // ALLOWED_PREF_KEYS must list it, or every toggle snaps back. @@ -893,6 +894,112 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, } }, [groupId, token, inviteUser, inviteByEmail, loadMembers, transportRef]); + // ── Invitation links (docs/MESHBAY_DESIGN.md §3.4) ────────────────── + // + // Two halves, in the order that leaves nothing half-made: the node's code + // first, then the hub's ticket bound to the address; a ticket the hub then + // refuses takes the code back with it, since a code nobody can reach the node + // with only occupies one of the group's twenty places. The code reaches the + // hub only when the box asks the hub to write the mail. + const [linkEmail, setLinkEmail] = useState(''); + const [linking, setLinking] = useState(false); + const [linkError, setLinkError] = useState(''); + const [newLink, setNewLink] = useState(null); + const [linkCopied, setLinkCopied] = useState(false); + const [links, setLinks] = useState([]); + + const linkSignFn = useCallback(() => { + const transport = transportRef && transportRef.current; + const sk = transport && transport.sessionKeys && transport.sessionKeys.skEdB64; + return (sk && window.MeshBayKeys) + ? (transcript) => window.MeshBayKeys.signBytes(sk, transcript) + : null; + }, [transportRef]); + + const loadLinks = useCallback(() => { + if (!(group && group.is_admin)) return; + hubFetch(`/v1/groups/${groupId}/invite-links`, { token }) + .then(data => setLinks(data.links || [])) + .catch(() => {}); + }, [groupId, token, group]); + + useEffect(() => { loadLinks(); }, [loadLinks]); + + const doCreateLink = useCallback(async (e) => { + e.preventDefault(); + const email = linkEmail.trim(); + if (!email) return; + setLinking(true); + setLinkError(''); + setNewLink(null); + setLinkCopied(false); + try { + const transport = transportRef && transportRef.current; + if (!transport || !transport.connected) { + throw new Error('Not connected to the node — it must be online to invite'); + } + const signFn = linkSignFn(); + const node = await transport.createLinkInvite(groupId, signFn); + const n = nodePkForLink(transport.nodePk); + let ticket; + try { + ticket = await hubFetch(`/v1/groups/${groupId}/invite-links`, { + method: 'POST', token, + body: { + email, expires_at: node.expires_at, node_invite_id: node.invite_id, + send_email: inviteByEmail, + ...(inviteByEmail ? { node_pk: n, code: node.code } : {}), + }, + }); + } catch (err) { + try { await transport.cancelLinkInvite(node.invite_id, signFn); } catch { /* expires */ } + throw err; + } + setNewLink({ + email, + link: inviteLinkHere({ g: groupId, t: ticket.ticket, n, c: node.code }), + emailStatus: ticket.email_status, + }); + setLinkEmail(''); + loadLinks(); + } catch (err) { + setLinkError(err.message); + } finally { + setLinking(false); + } + }, [groupId, token, linkEmail, inviteByEmail, transportRef, linkSignFn, loadLinks]); + + // Node half first, hub half regardless: a node that refuses (offline, or the + // code already expired there) must not leave the ticket open on the hub. + const cancelLink = useCallback(async (row) => { + setLinkError(''); + let nodeError = ''; + try { + const transport = transportRef && transportRef.current; + if (!transport || !transport.connected) throw new Error(t('group.offline_title')); + await transport.cancelLinkInvite(row.node_invite_id, linkSignFn()); + } catch (err) { + nodeError = err.message; + } + try { + await hubFetch(`/v1/groups/${groupId}/invite-links/${row.link_id}`, { + method: 'DELETE', token, + }); + } catch (err) { + nodeError = nodeError ? `${nodeError} — ${err.message}` : err.message; + } + if (nodeError) setLinkError(nodeError); + loadLinks(); + }, [groupId, token, transportRef, linkSignFn, loadLinks]); + + const copyLink = useCallback(async () => { + if (!newLink) return; + try { + await navigator.clipboard.writeText(newLink.link); + setLinkCopied(true); + } catch { /* the field is selectable */ } + }, [newLink]); + if (loading) return html`<p class="page-message">${t('explore.loading')}</p>`; const isOwner = Boolean(isAdmin); @@ -948,6 +1055,71 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, </div> `} + ${isAdmin && group?.join_policy !== 'open' && html` + <div class="settings-section"> + <h3 class="settings-heading">${t('members.link_title')}</h3> + <p class="settings-hint">${t('members.link_hint')}</p> + ${linkError && html`<div class="error-msg" style="margin-bottom:8px">${linkError}</div>`} + <form onSubmit=${doCreateLink}> + ${newLink && html` + <div class="success-msg" style="margin-bottom:8px"> + <p>${t('members.link_ready', { email: newLink.email })}</p> + <div class="form-row"> + <input type="text" readonly value=${newLink.link} + onFocus=${e => e.target.select()} /> + <button class="admin-btn" type="button" onClick=${copyLink}> + ${linkCopied ? t('members.link_copied') : t('members.link_copy')} + </button> + </div> + ${newLink.emailStatus === 'sent' + ? html`<p style="color:var(--success)">${t('members.link_email_sent')}</p>` + : newLink.emailStatus !== 'not_requested' + && html`<p style="color:var(--text-dim)">${t('members.link_email_refused')}</p>` + } + </div> + `} + <div class="form-row"> + <input type="email" placeholder=${t('members.link_email_placeholder')} + value=${linkEmail} onInput=${e => setLinkEmail(e.target.value)} + disabled=${!connected || !operatorPaired} required /> + <button class="admin-btn" type="submit" + disabled=${linking || !connected || !operatorPaired}> + ${linking ? '...' : t('members.link_btn')} + </button> + </div> + <label style="display:flex; gap:8px; align-items:flex-start; margin:6px 0 0; + font-size:0.88em; color:var(--text-secondary)"> + <input type="checkbox" checked=${inviteByEmail} + onChange=${e => toggleInviteByEmail(e.target.checked)} /> + <span>${t('members.invite_email_opt')}</span> + </label> + </form> + ${links.length > 0 && html` + <h4 style="margin-top:12px">${t('members.links_pending')}</h4> + <ul class="invite-links"> + ${links.map(l => html` + <li key=${l.link_id} style="display:flex;gap:8px;align-items:center; + flex-wrap:wrap;word-break:break-word;margin:4px 0"> + <span>${l.email}</span> + <span style="color:var(--text-dim)"> + ${l.status === 'redeemed' + ? t('members.link_status_redeemed', { user: l.redeemed_by || '' }) + : l.status === 'expired' + ? t('members.link_status_expired') + : t('members.link_expires', + { date: new Date(l.expires_at).toLocaleDateString() })} + </span> + ${l.status === 'pending' && html` + <button class="admin-btn" type="button" + disabled=${!connected || !operatorPaired} + onClick=${() => cancelLink(l)}>${t('members.link_cancel')}</button>`} + </li> + `)} + </ul> + `} + </div> + `} + ${isNodeAdmin && !operatorPaired && connected && html` <div class="settings-section"> <h3 class="settings-heading">${t('members.pair_title')}</h3> |