diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-23 18:05:14 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-23 18:05:14 +0200 |
| commit | 35a7764db3f58a93c32206cb3ce74bb2f03967e7 (patch) | |
| tree | d2acb2a07ee6dc2f4241fcc785c2860d57263892 /packages/meshbay-hub/src/meshbay_hub/static/group-settings.js | |
| parent | 998f9c69308ee88fac36cfb77dfb6d07c6fa926a (diff) | |
| download | meshbay-35a7764db3f58a93c32206cb3ce74bb2f03967e7.tar.gz | |
feat(hub): open, create and join invitation links in the interface
#/invite takes the link out of the address on load and keeps it in the
tab through registration and sign-in; joining is one click, only the
ticket goes to the hub, and the code goes only to the node the link
names once it has signed its challenge. Members tab gains "Invite by
link" (shared e-mail box, pending list, cancel both halves); home page
takes a pasted link. Browser probe drives the real app, signed out and in.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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> |