aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js
diff options
context:
space:
mode:
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.js172
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>