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 | |
| 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')
22 files changed, 1360 insertions, 10 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js index fbfb942..a757e61 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js @@ -1,3 +1,6 @@ +// First, on purpose: loading it takes an invitation link out of the address +// before anything else here can read, log or route on it (invite-link.js). +import { clearPending, loadPending } from './invite-link.js'; import { html, render, useState, useEffect, useLayoutEffect, useCallback, useRef, createContext, useContext, @@ -29,6 +32,7 @@ import { ProfilePage } from './profile-page.js'; import { ExplorePage } from './explore-page.js'; import { GroupName } from './group-name.js'; import { FirstRunPage, LoginPage, RegisterPage, ResetPasswordPage } from './auth-page.js'; +import { InvitePage, JoinByLink } from './invite-page.js'; // ── Constants ──────────────────────────────────────────────────────────────── @@ -536,6 +540,7 @@ function HomePage({ groups, notifications, onMarkRead, onPurge, allowPublicGroup <h2>${t('home.welcome')}</h2> <${NotificationFeed} notifications=${notifications} onMarkRead=${onMarkRead} onPurge=${onPurge} /> + <${JoinByLink} hubOrigin=${platform.hubOrigin()} /> <p class="page-message"> ${t('home.no_groups')} ${' '}${allowPublicGroups @@ -568,6 +573,7 @@ function HomePage({ groups, notifications, onMarkRead, onPurge, allowPublicGroup </a> `)} </div> + <${JoinByLink} hubOrigin=${platform.hubOrigin()} /> </div> `; } @@ -680,7 +686,8 @@ function App() { // flow signs in half-way and still has its progress and result to show. const onAuthForm = route === '/login' || route === '/register'; useEffect(() => { - if (user && onAuthForm) window.location.replace('#/'); + // An invitation waiting in this tab is where a sign-in was headed. + if (user && onAuthForm) window.location.replace(loadPending() ? '#/invite' : '#/'); }, [user, onAuthForm]); // A desktop build with a remembered device signs in without asking. Null // until it has tried, so nothing renders a sign-in form the user is about to @@ -1091,6 +1098,8 @@ function App() { // Revoked on the hub too, so a copy of the refresh token is worth // nothing. Read before the next line clears it. logoutOnHub(); + // An invitation belongs to whoever was about to use it, not to the tab. + clearPending(); setAuth(null); setUser(null); setGroups([]); @@ -1136,6 +1145,15 @@ function App() { : route === '/reset' ? html`<${ResetPasswordPage} onLogin=${authCtx.login} />` : html`<${LoginPage} onLogin=${authCtx.login} />`; + } else if (route === '/invite') { + // Signed in or not: signed out, it explains and sends to register or sign + // in; signed in, it asks the hub what the ticket is for. + page = html`<${InvitePage} user=${user} onJoined=${async () => { + try { + const data = await hubFetch('/v1/groups/mine', { token: user.token }); + setGroups(data.groups || []); + } catch { /* the group page fetches what it needs */ } + }} />`; } else if (!user) { page = html`<${LoginPage} onLogin=${authCtx.login} />`; } else if (route === '/search') { diff --git a/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js b/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js index f0af187..70c48f2 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js @@ -7,6 +7,7 @@ import { } from './hub-client.js'; import * as platform from './platform.js'; import { Icon } from './icon.js'; +import { loadPending } from './invite-link.js'; const PASSWORD_MIN_BITS = 60; const PASSWORD_MIN_LEN = 12; @@ -185,7 +186,8 @@ export function LoginPage({ onLogin }) { // Trimmed to match the hub's stored username and every client-side key // derivation (auth_key, bundle_key, recovery_key all fold the username in). await onLogin(name, password); - navigate('/'); + // Back to the invitation that sent them here, if one is waiting. + navigate(loadPending() ? '/invite' : '/'); } catch (err) { if (err.message === 'email_verification_required') { setPendingVerif(true); @@ -415,6 +417,8 @@ export function RegisterPage() { <p style="text-align:center; margin-bottom:16px; color:var(--text-secondary)"> ${t('register.verified_msg')} </p> + ${loadPending() && html` + <p style="text-align:center; margin-bottom:16px">${t('invite.after_register')}</p>`} <a href="#/login" style="display:block; text-align:center">${t('register.go_login')}</a> </div> </div> diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js index 4fdf5f8..a25c07b 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js @@ -16,6 +16,7 @@ import { FilePreview } from './files-app.js'; import { VideoPlayer } from './video-player.js'; import { GroupSettingsPanel } from './group-settings.js'; import { reportIndexPush } from './index-dock.js'; +import { clearPending, nodePkFromLink, pendingFor } from './invite-link.js'; /** * The group shell: everything a group's "applications" (Chat, Files, and @@ -398,6 +399,17 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, // `transport` holds the attempt in progress, and keeps whichever one // answers — so it is null after the loop exactly when none did. let transport = null, ack = null, lastErr = null; + // An invitation link for this group names the node that holds its + // code: that node is tried first, and only it is handed the code — + // the transport refuses any other (docs/MESHBAY_DESIGN.md §3.4). A + // code typed into the form takes precedence and goes as it always has. + const link = session.pendingJoinCode ? null : pendingFor(groupId); + const joinCode = session.pendingJoinCode || (link ? link.c : null); + const joinNodePk = link ? nodePkFromLink(link.n) : undefined; + if (link) { + nodesData.nodes.sort((a, b) => + (b.pk_node === joinNodePk) - (a.pk_node === joinNodePk)); + } for (const n of nodesData.nodes) { // The same base the API calls use: signaling is a hub endpoint like // any other, and two sources for one address is how they drift. @@ -417,7 +429,7 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, try { ack = await transport.connect( n.node_id, live, groupId, null, sessionKeys, session.bundleKey, - username, userId, session.pendingJoinCode, session.recoveryKey); + username, userId, joinCode, session.recoveryKey, joinNodePk); break; } catch (e) { lastErr = e; @@ -432,12 +444,18 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, // A refusal that names a state of *this browser* — a code to enter, // a passphrase, a device to approve — is the same answer from every // node, and the operator to act on is this one's. Trying the next - // node would only replace it with a less useful message. - if (e.reason && e.reason !== 'not_hosted') throw e; + // node would only replace it with a less useful message. The one + // exception besides `not_hosted` is a link naming another host. + if (e.reason && e.reason !== 'not_hosted' && e.reason !== 'link_other_node') { + throw e; + } } } if (!transport) throw (lastErr || new Error('no node served this group')); session.pendingJoinCode = null; + // In: the invitation has done its job, whether its code was spent now or + // the node already knew us. + if (link) clearPending(); if (cancelled) return; applyAck(ack); transport.onAppsEnabled = (apps) => setEnabledApps(apps); @@ -617,6 +635,13 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, // one-time code from the operator before it will hand over the group // key. Not an error to shout about — a step in joining. if (err.reason === 'code_required') setNeedsCode(true); + // The link's code was refused by the node that issued it — used, or + // cancelled. It will not work on another try; say so, and drop it. + if (err.reason === 'code_invalid' && pendingFor(groupId) + && !session.pendingJoinCode) { + clearPending(); + err.message = t('group.link_spent'); + } // The node has no bundle for us and this browser derived no key to make // one — the passphrase form below is the way in, not a support request. if (err.reason === 'no_keys') setNeedsPass(true); 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> diff --git a/packages/meshbay-hub/src/meshbay_hub/static/invite-link.js b/packages/meshbay-hub/src/meshbay_hub/static/invite-link.js new file mode 100644 index 0000000..f0b4836 --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/static/invite-link.js @@ -0,0 +1,125 @@ +/** + * Invitation links: their one shape, and the invitation waiting in this tab. + * + * https://<hub>/#/invite?v=1&g=<group>&t=<ticket>&n=<node key>&c=<CODE> + * + * docs/MESHBAY_DESIGN.md §3.4. Two secrets: the ticket is for the hub, which + * grants membership to the one account whose address it was sent to; the code + * is for the node the link names, and is never sent to the hub from here. + * + * Everything is after `#`, so no part of the link reaches the hub in a request. + * It is read the moment this module loads — before the router, before any + * `await` — and the address is rewritten to `#/invite`, so the code does not sit + * in the address bar, in a bookmark or in a screenshot. What was read is kept in + * this tab's `sessionStorage`: it has to survive registration, a reload and a + * sign-in, and nothing else. Not `localStorage`, which every tab shares and + * which outlives the tab. Cleared on success, refusal, expiry and sign-out. + * + * `api/invite_links.py` `invite_url` writes the same shape when the hub mails + * a link; `test_invite_link_client.py` holds the two to each other. + */ + +import * as platform from './platform.js'; + +const STORE_KEY = 'mb.pendingInvite'; +// A client-side bound on how long an unused invitation is carried. The node +// and the hub keep their own, shorter by default; this only stops a tab left +// open for a month from offering a link that died long ago. +const CARRY_MS = 30 * 24 * 3600 * 1000; + +const GROUP = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; +const TICKET = /^[A-Za-z0-9_-]{22}$/; +const NODE_PK = /^[A-Za-z0-9_-]{43}$/; +const CODE = /^[0-9A-Za-z]{4}-[0-9A-Za-z]{4}$/; + +/** + * `{g, t, n, c}` out of a link, a `#/invite?…` fragment or a pasted piece of + * text containing either — or null. Every field is checked for its exact shape, + * so nothing read here is ever more than an id, a token, a key and a code. + */ +export function parseInvite(text) { + const s = String(text || '').trim(); + const at = s.indexOf('/invite?'); + if (at < 0) return null; + const q = new URLSearchParams(s.slice(at + '/invite?'.length)); + const inv = { g: q.get('g') || '', t: q.get('t') || '', n: q.get('n') || '', + c: (q.get('c') || '').toUpperCase() }; + if (q.get('v') !== '1' || !GROUP.test(inv.g) || !TICKET.test(inv.t) + || !NODE_PK.test(inv.n) || !CODE.test(inv.c)) return null; + return inv; +} + +export function buildInviteLink(origin, { g, t, n, c }) { + return `${origin}/#/invite?v=1&g=${g}&t=${t}&n=${n}&c=${c}`; +} + +/** The origin a pasted link points at, or '' if it is not a URL at all. */ +export function linkOrigin(text) { + try { return new URL(String(text).trim()).origin; } catch { return ''; } +} + +/** A node key as the transport and the hub write it (standard base64). */ +export function nodePkFromLink(n) { + const b64 = n.replace(/-/g, '+').replace(/_/g, '/'); + return b64 + '='.repeat((4 - (b64.length % 4)) % 4); +} + +/** The same key as a link carries it: URL-safe, unpadded. */ +export function nodePkForLink(b64) { + return String(b64).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +export function savePending(inv) { + try { + sessionStorage.setItem(STORE_KEY, JSON.stringify({ ...inv, exp: Date.now() + CARRY_MS })); + } catch { /* storage refused: the link can be opened again */ } +} + +export function loadPending() { + try { + const inv = JSON.parse(sessionStorage.getItem(STORE_KEY) || 'null'); + if (!inv || !(inv.exp > Date.now()) || !parseInvite(buildInviteLink('', inv))) { + clearPending(); + return null; + } + return inv; + } catch { + return null; + } +} + +export function clearPending() { + try { sessionStorage.removeItem(STORE_KEY); } catch { /* nothing kept */ } +} + +/** The pending invitation, if it is for this group. */ +export function pendingFor(groupId) { + const inv = loadPending(); + return inv && inv.g === groupId ? inv : null; +} + +/** + * Take an invitation out of the address, keep it, and leave `#/invite` behind. + * + * Runs on load and on every `hashchange` — a link pasted into the address bar + * of a tab already open. `replaceState` fires no `hashchange`, so the router + * reads the cleaned address, never the one with the code in it. A malformed + * link is cleaned out of the address too; it is simply not kept. + */ +export function captureFromLocation() { + const hash = window.location.hash || ''; + if (!hash.startsWith('#/invite?')) return null; + const inv = parseInvite(hash); + window.history.replaceState( + null, '', window.location.pathname + window.location.search + '#/invite'); + if (inv) savePending(inv); + return inv; +} + +/** This hub's link for an invitation, for the person who creates it. */ +export function inviteLinkHere(inv) { + return buildInviteLink(platform.hubOrigin(), inv); +} + +captureFromLocation(); +window.addEventListener('hashchange', captureFromLocation); diff --git a/packages/meshbay-hub/src/meshbay_hub/static/invite-page.js b/packages/meshbay-hub/src/meshbay_hub/static/invite-page.js new file mode 100644 index 0000000..dd678aa --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/static/invite-page.js @@ -0,0 +1,153 @@ +/** + * `#/invite` — an invitation link, opened (docs/MESHBAY_DESIGN.md §3.4). + * + * By the time this renders, `invite-link.js` has taken the invitation out of + * the address and kept it in this tab. Signed out, the page says what is + * waiting and offers to register or sign in; both come back here. Signed in, it + * asks the hub what the ticket is for and shows it — **joining is a click, never + * automatic**, or a link would put anyone in any group without asking. + * + * Only the ticket goes to the hub from here. The code stays in the tab until + * the group page hands it to the one node the link names. + */ + +import { html, useCallback, useEffect, useState } from './vendor/htm-preact.js'; +import { t } from './i18n.js'; +import { hubFetch, navigate } from './hub-client.js'; +import { + clearPending, linkOrigin, loadPending, parseInvite, savePending, +} from './invite-link.js'; + +export function InvitePage({ user, onJoined }) { + const [inv] = useState(loadPending); + const [phase, setPhase] = useState(inv ? 'loading' : 'none'); + const [preview, setPreview] = useState(null); + const [error, setError] = useState(''); + + const refused = useCallback((err) => { + if (err.message === 'invite_other_account') { + // Kept: signing out and in again as the right account is the fix. + setPhase('other'); + } else if (err.message === 'invite_not_valid') { + clearPending(); + setPhase('invalid'); + } else { + setError(err.message); + setPhase('error'); + } + }, []); + + useEffect(() => { + if (!inv || !user) return undefined; + let cancelled = false; + hubFetch('/v1/invite-links/preview', { + method: 'POST', token: user.token, body: { ticket: inv.t }, + }) + .then((p) => { + if (cancelled) return; + // The group the hub names must be the group the link names. + if (p.group_id !== inv.g) { clearPending(); setPhase('invalid'); return; } + setPreview(p); + setPhase('confirm'); + }) + .catch((err) => { if (!cancelled) refused(err); }); + return () => { cancelled = true; }; + }, [inv, user, refused]); + + const join = useCallback(async () => { + setPhase('joining'); + try { + const r = await hubFetch('/v1/invite-links/redeem', { + method: 'POST', token: user.token, body: { ticket: inv.t }, + }); + if (r.group_id !== inv.g) { clearPending(); setPhase('invalid'); return; } + // The code is still pending: the group page takes it to the node. + if (onJoined) await onJoined(inv.g); + navigate(`/group/${inv.g}`); + } catch (err) { + refused(err); + } + }, [inv, user, onJoined, refused]); + + const ignore = useCallback(() => { + clearPending(); + navigate('/'); + }, []); + + let body; + if (phase === 'none') { + body = html`<p>${t('invite.none')}</p>`; + } else if (!user) { + body = html` + <p>${t('invite.signed_out')}</p> + <div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:16px"> + <a class="btn btn-primary" href="#/register">${t('invite.register')}</a> + <a class="btn btn-secondary" href="#/login">${t('invite.signin')}</a> + </div>`; + } else if (phase === 'loading' || phase === 'joining') { + body = html`<p>${phase === 'joining' ? t('invite.joining') : t('explore.loading')}</p>`; + } else if (phase === 'confirm' && preview) { + body = preview.already_member ? html` + <p>${t('invite.already_member', { group: preview.group_name })}</p> + <div style="margin-top:16px"> + <button class="btn btn-primary" onClick=${join}>${t('invite.open')}</button> + </div>` : html` + <p>${t('invite.confirm', { inviter: preview.inviter, group: preview.group_name })}</p> + <div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:16px"> + <button class="btn btn-primary" onClick=${join}>${t('invite.join')}</button> + <button class="btn btn-secondary" onClick=${ignore}>${t('invite.ignore')}</button> + </div>`; + } else if (phase === 'other') { + body = html` + <p class="error-msg">${t('invite.other_account')}</p> + <div style="margin-top:16px"> + <button class="btn btn-secondary" onClick=${ignore}>${t('invite.ignore')}</button> + </div>`; + } else if (phase === 'invalid') { + body = html`<p class="error-msg">${t('invite.invalid')}</p>`; + } else { + body = html`<p class="error-msg">${error}</p>`; + } + + return html` + <div class="page-center"> + <div class="card login-card"> + <h2>${t('invite.title')}</h2> + ${body} + </div> + </div>`; +} + +/** + * Paste a link instead of clicking it — the desktop application's way in, since + * a link in a mail opens the browser, and anyone's who copied rather than + * clicked. A link for another hub is refused here: its ticket means nothing to + * this one, and sending it would hand this hub someone else's secret. + */ +export function JoinByLink({ hubOrigin }) { + const [text, setText] = useState(''); + const [error, setError] = useState(''); + const onSubmit = useCallback((e) => { + e.preventDefault(); + const inv = parseInvite(text); + if (!inv) { setError(t('invite.paste_invalid')); return; } + const origin = linkOrigin(text); + if (origin && origin !== hubOrigin) { setError(t('invite.paste_other_hub')); return; } + savePending(inv); + setText(''); + setError(''); + navigate('/invite'); + }, [text, hubOrigin]); + + return html` + <form class="invite-form" style="margin:16px 0" onSubmit=${onSubmit}> + <h4>${t('invite.paste_title')}</h4> + <div style="display:flex;gap:8px"> + <input type="text" autocomplete="off" spellcheck="false" + placeholder=${t('invite.paste_placeholder')} + value=${text} onInput=${e => setText(e.target.value)} required /> + <button class="admin-btn" type="submit">${t('invite.paste_btn')}</button> + </div> + ${error && html`<p class="error-msg" style="margin-top:8px">${error}</p>`} + </form>`; +} diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js index 17a184e..90f5c00 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js @@ -687,6 +687,40 @@ export default { 'members.invite_email_sent': 'Eine E-Mail mit dem Code wurde an dieses Mitglied gesendet.', 'members.invite_email_failed': 'Die E-Mail konnte nicht gesendet werden — bitte teilen Sie den Code manuell mit.', 'members.invite_email_opt': 'Einladung per E-Mail senden (kann im Spam landen)', + 'members.link_title': "Per Link einladen", + 'members.link_hint': "Für jemanden, der vielleicht noch kein Konto hat. Der Link funktioniert einmal und nur für ein Konto mit dieser Adresse.", + 'members.link_email_placeholder': "E-Mail-Adresse", + 'members.link_btn': "Link erstellen", + 'members.link_ready': "Einladungslink für {email}:", + 'members.link_copy': "Kopieren", + 'members.link_copied': "Kopiert", + 'members.link_email_sent': "Der Link wurde per E-Mail gesendet.", + 'members.link_email_refused': "Die E-Mail konnte nicht gesendet werden — teilen Sie den Link selbst.", + 'members.links_pending': "Einladungslinks", + 'members.link_status_redeemed': "verwendet von {user}", + 'members.link_status_expired': "abgelaufen", + 'members.link_expires': "läuft ab am {date}", + 'members.link_cancel': "Abbrechen", + 'invite.title': "Sie wurden eingeladen", + 'invite.none': "In diesem Tab wartet keine Einladung. Öffnen Sie den erhaltenen Link erneut.", + 'invite.signed_out': "Jemand hat Sie in eine Gruppe auf diesem Hub eingeladen. Erstellen Sie ein Konto mit der E-Mail-Adresse, an die die Einladung ging, oder melden Sie sich an, falls Sie bereits eines haben.", + 'invite.register': "Konto erstellen", + 'invite.signin': "Anmelden", + 'invite.confirm': "{inviter} lädt Sie in {group} ein.", + 'invite.join': "Beitreten", + 'invite.ignore': "Ignorieren", + 'invite.open': "Gruppe öffnen", + 'invite.already_member': "Sie sind bereits Mitglied von {group}.", + 'invite.other_account': "Diese Einladung wurde an eine andere E-Mail-Adresse gesendet. Melden Sie sich mit dem Konto dieser Adresse an — Aliasse und Punkte müssen genau übereinstimmen.", + 'invite.invalid': "Diese Einladung ist nicht mehr gültig: Sie wurde verwendet, widerrufen oder ist abgelaufen. Bitten Sie um eine neue.", + 'invite.joining': "Beitritt…", + 'invite.after_register': "Melden Sie sich an, um der Gruppe beizutreten, in die Sie eingeladen wurden.", + 'invite.paste_title': "Mit einem Einladungslink beitreten", + 'invite.paste_placeholder': "Link hier einfügen", + 'invite.paste_btn': "Weiter", + 'invite.paste_invalid': "Das ist kein Einladungslink.", + 'invite.paste_other_hub': "Diese Einladung gilt für einen anderen Hub.", + 'group.link_spent': "Dieser Einladungslink ist auf dem Rechner, der die Gruppe hostet, nicht mehr gültig. Bitten Sie um einen neuen.", 'members.invite_code_hint': 'Sie können diesen Code auch über einen anderen Kanal teilen (z. B. SMS). ' + 'Die eingeladene Person gibt ihn ein, wenn sie diese Gruppe zum ersten Mal öffnet.', 'transfers.title': 'Übertragungen', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js index a58fdd6..2b55d08 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js @@ -803,6 +803,40 @@ export default { 'members.invite_email_sent': 'An email with the code has been sent to this member.', 'members.invite_email_failed': 'Could not send the email — share the code manually.', 'members.invite_email_opt': 'Send the invitation by e-mail (may land in spam)', + 'members.link_title': "Invite by link", + 'members.link_hint': "For someone who may not have an account yet. The link works once, and only for an account registered with this address.", + 'members.link_email_placeholder': "E-mail address", + 'members.link_btn': "Create link", + 'members.link_ready': "Invitation link for {email}:", + 'members.link_copy': "Copy", + 'members.link_copied': "Copied", + 'members.link_email_sent': "The link has been sent by e-mail.", + 'members.link_email_refused': "The e-mail could not be sent — share the link yourself.", + 'members.links_pending': "Invitation links", + 'members.link_status_redeemed': "used by {user}", + 'members.link_status_expired': "expired", + 'members.link_expires': "expires {date}", + 'members.link_cancel': "Cancel", + 'invite.title': "You have been invited", + 'invite.none': "There is no invitation waiting in this tab. Open the link you received again.", + 'invite.signed_out': "Someone invited you to a group on this hub. Create an account with the e-mail address the invitation was sent to, or sign in if you already have one.", + 'invite.register': "Create an account", + 'invite.signin': "Sign in", + 'invite.confirm': "{inviter} invites you to join {group}.", + 'invite.join': "Join", + 'invite.ignore': "Ignore", + 'invite.open': "Open the group", + 'invite.already_member': "You are already a member of {group}.", + 'invite.other_account': "This invitation was sent to another e-mail address. Sign in with the account registered with that address — aliases and dots must match exactly.", + 'invite.invalid': "This invitation is no longer valid: it has been used, cancelled or has expired. Ask for a new one.", + 'invite.joining': "Joining…", + 'invite.after_register': "Sign in to join the group you were invited to.", + 'invite.paste_title': "Join with an invitation link", + 'invite.paste_placeholder': "Paste the link here", + 'invite.paste_btn': "Continue", + 'invite.paste_invalid': "That is not an invitation link.", + 'invite.paste_other_hub': "This invitation is for another hub.", + 'group.link_spent': "This invitation link is no longer valid on the machine hosting the group. Ask for a new one.", 'members.invite_code_hint': 'You can also share this code via another channel (e.g. SMS). ' + 'They enter it the first time they open this group.', 'transfers.title': 'Transfers', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js index 09592ae..5265976 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js @@ -682,6 +682,40 @@ export default { 'members.invite_email_sent': 'Se ha enviado un correo con el código a este miembro.', 'members.invite_email_failed': 'No se pudo enviar el correo — comparta el código manualmente.', 'members.invite_email_opt': 'Enviar la invitación por correo (puede llegar a spam)', + 'members.link_title': "Invitar con un enlace", + 'members.link_hint': "Para alguien que quizá aún no tenga cuenta. El enlace sirve una vez, y solo para una cuenta registrada con esta dirección.", + 'members.link_email_placeholder': "Dirección de correo", + 'members.link_btn': "Crear enlace", + 'members.link_ready': "Enlace de invitación para {email}:", + 'members.link_copy': "Copiar", + 'members.link_copied': "Copiado", + 'members.link_email_sent': "El enlace se ha enviado por correo.", + 'members.link_email_refused': "No se pudo enviar el correo — comparta el enlace usted mismo.", + 'members.links_pending': "Enlaces de invitación", + 'members.link_status_redeemed': "usado por {user}", + 'members.link_status_expired': "caducado", + 'members.link_expires': "caduca el {date}", + 'members.link_cancel': "Cancelar", + 'invite.title': "Le han invitado", + 'invite.none': "No hay ninguna invitación esperando en esta pestaña. Vuelva a abrir el enlace que recibió.", + 'invite.signed_out': "Alguien le ha invitado a un grupo en este hub. Cree una cuenta con la dirección de correo a la que se envió la invitación, o inicie sesión si ya tiene una.", + 'invite.register': "Crear una cuenta", + 'invite.signin': "Iniciar sesión", + 'invite.confirm': "{inviter} le invita a unirse a {group}.", + 'invite.join': "Unirse", + 'invite.ignore': "Ignorar", + 'invite.open': "Abrir el grupo", + 'invite.already_member': "Ya es miembro de {group}.", + 'invite.other_account': "Esta invitación se envió a otra dirección de correo. Inicie sesión con la cuenta registrada con esa dirección — los alias y los puntos deben coincidir exactamente.", + 'invite.invalid': "Esta invitación ya no es válida: se ha usado, cancelado o ha caducado. Pida una nueva.", + 'invite.joining': "Uniéndose…", + 'invite.after_register': "Inicie sesión para unirse al grupo al que le invitaron.", + 'invite.paste_title': "Unirse con un enlace de invitación", + 'invite.paste_placeholder': "Pegue el enlace aquí", + 'invite.paste_btn': "Continuar", + 'invite.paste_invalid': "Eso no es un enlace de invitación.", + 'invite.paste_other_hub': "Esta invitación es para otro hub.", + 'group.link_spent': "Este enlace de invitación ya no es válido en la máquina que aloja el grupo. Pida uno nuevo.", 'members.invite_code_hint': 'También puede compartir este código por otro canal (ej. SMS). ' + 'Lo introduce la primera vez que abre este grupo.', 'transfers.title': 'Transferencias', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js index b77bf2b..b6b909e 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js @@ -685,6 +685,40 @@ export default { 'members.invite_email_sent': "Un e-mail avec le code a été envoyé à ce membre.", 'members.invite_email_failed': "Impossible d’envoyer l’e-mail — partagez le code manuellement.", 'members.invite_email_opt': 'Envoyer l’invitation par e-mail (risque d’arriver dans les spams)', + 'members.link_title': "Inviter par lien", + 'members.link_hint': "Pour quelqu’un qui n’a peut-être pas encore de compte. Le lien ne sert qu’une fois, et seulement pour un compte créé avec cette adresse.", + 'members.link_email_placeholder': "Adresse e-mail", + 'members.link_btn': "Créer le lien", + 'members.link_ready': "Lien d’invitation pour {email} :", + 'members.link_copy': "Copier", + 'members.link_copied': "Copié", + 'members.link_email_sent': "Le lien a été envoyé par e-mail.", + 'members.link_email_refused': "L’e-mail n’a pas pu être envoyé — partagez le lien vous-même.", + 'members.links_pending': "Liens d’invitation", + 'members.link_status_redeemed': "utilisé par {user}", + 'members.link_status_expired': "expiré", + 'members.link_expires': "expire le {date}", + 'members.link_cancel': "Annuler", + 'invite.title': "Vous êtes invité", + 'invite.none': "Aucune invitation n’attend dans cet onglet. Rouvrez le lien que vous avez reçu.", + 'invite.signed_out': "Quelqu’un vous a invité dans un groupe sur ce hub. Créez un compte avec l’adresse e-mail à laquelle l’invitation a été envoyée, ou connectez-vous si vous en avez déjà un.", + 'invite.register': "Créer un compte", + 'invite.signin': "Se connecter", + 'invite.confirm': "{inviter} vous invite à rejoindre {group}.", + 'invite.join': "Rejoindre", + 'invite.ignore': "Ignorer", + 'invite.open': "Ouvrir le groupe", + 'invite.already_member': "Vous êtes déjà membre de {group}.", + 'invite.other_account': "Cette invitation a été envoyée à une autre adresse e-mail. Connectez-vous avec le compte créé avec cette adresse — les alias et les points doivent correspondre exactement.", + 'invite.invalid': "Cette invitation n’est plus valable : elle a été utilisée, annulée ou a expiré. Demandez-en une nouvelle.", + 'invite.joining': "Adhésion…", + 'invite.after_register': "Connectez-vous pour rejoindre le groupe auquel vous avez été invité.", + 'invite.paste_title': "Rejoindre avec un lien d’invitation", + 'invite.paste_placeholder': "Collez le lien ici", + 'invite.paste_btn': "Continuer", + 'invite.paste_invalid': "Ce n’est pas un lien d’invitation.", + 'invite.paste_other_hub': "Cette invitation concerne un autre hub.", + 'group.link_spent': "Ce lien d’invitation n’est plus valable sur la machine qui héberge le groupe. Demandez-en un nouveau.", 'members.invite_code_hint': "Vous pouvez aussi transmettre ce code par un autre canal (ex. SMS). " + "La personne le saisit la première fois qu’elle ouvre ce groupe.", 'transfers.title': 'Transferts', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js index 2deb4fc..2a95bf7 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js @@ -685,6 +685,40 @@ export default { 'members.invite_email_sent': "Un'e-mail con il codice è stata inviata a questo membro.", 'members.invite_email_failed': "Impossibile inviare l'e-mail — condivida il codice manualmente.", 'members.invite_email_opt': 'Invia l’invito per e-mail (potrebbe finire nello spam)', + 'members.link_title': "Invita tramite link", + 'members.link_hint': "Per chi forse non ha ancora un account. Il link funziona una volta, e solo per un account registrato con questo indirizzo.", + 'members.link_email_placeholder': "Indirizzo e-mail", + 'members.link_btn': "Crea link", + 'members.link_ready': "Link d’invito per {email}:", + 'members.link_copy': "Copia", + 'members.link_copied': "Copiato", + 'members.link_email_sent': "Il link è stato inviato per e-mail.", + 'members.link_email_refused': "Impossibile inviare l’e-mail — condivida il link di persona.", + 'members.links_pending': "Link d’invito", + 'members.link_status_redeemed': "usato da {user}", + 'members.link_status_expired': "scaduto", + 'members.link_expires': "scade il {date}", + 'members.link_cancel': "Annulla", + 'invite.title': "È stato invitato", + 'invite.none': "Nessun invito in attesa in questa scheda. Riapra il link ricevuto.", + 'invite.signed_out': "Qualcuno l’ha invitata in un gruppo su questo hub. Crei un account con l’indirizzo e-mail a cui è stato inviato l’invito, o acceda se ne ha già uno.", + 'invite.register': "Crea un account", + 'invite.signin': "Accedi", + 'invite.confirm': "{inviter} la invita a unirsi a {group}.", + 'invite.join': "Unisciti", + 'invite.ignore': "Ignora", + 'invite.open': "Apri il gruppo", + 'invite.already_member': "È già membro di {group}.", + 'invite.other_account': "Questo invito è stato inviato a un altro indirizzo e-mail. Acceda con l’account registrato con quell’indirizzo — alias e punti devono corrispondere esattamente.", + 'invite.invalid': "Questo invito non è più valido: è stato usato, annullato o è scaduto. Ne chieda uno nuovo.", + 'invite.joining': "Adesione…", + 'invite.after_register': "Acceda per unirsi al gruppo a cui è stato invitato.", + 'invite.paste_title': "Unisciti con un link d’invito", + 'invite.paste_placeholder': "Incolli qui il link", + 'invite.paste_btn': "Continua", + 'invite.paste_invalid': "Questo non è un link d’invito.", + 'invite.paste_other_hub': "Questo invito è per un altro hub.", + 'group.link_spent': "Questo link d’invito non è più valido sulla macchina che ospita il gruppo. Ne chieda uno nuovo.", 'members.invite_code_hint': 'Può anche condividere questo codice tramite un altro canale (es. SMS). ' + 'Lo inserirà la prima volta che aprirà questo gruppo.', 'transfers.title': 'Trasferimenti', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js index 0b46994..4068e44 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js @@ -677,6 +677,40 @@ export default { 'members.invite_email_sent': 'このメンバーにコード付きのメールが送信されました。', 'members.invite_email_failed': 'メールを送信できませんでした。コードを手動で共有してください。', 'members.invite_email_opt': '招待をメールで送信(迷惑メールに入る場合があります)', + 'members.link_title': "リンクで招待", + 'members.link_hint': "まだアカウントを持っていない人向けです。リンクは1回だけ、このアドレスで登録したアカウントでのみ使えます。", + 'members.link_email_placeholder': "メールアドレス", + 'members.link_btn': "リンクを作成", + 'members.link_ready': "{email} への招待リンク:", + 'members.link_copy': "コピー", + 'members.link_copied': "コピーしました", + 'members.link_email_sent': "リンクをメールで送信しました。", + 'members.link_email_refused': "メールを送信できませんでした。リンクを直接共有してください。", + 'members.links_pending': "招待リンク", + 'members.link_status_redeemed': "{user} が使用", + 'members.link_status_expired': "期限切れ", + 'members.link_expires': "{date} に期限切れ", + 'members.link_cancel': "取り消す", + 'invite.title': "招待されています", + 'invite.none': "このタブで待機中の招待はありません。受け取ったリンクをもう一度開いてください。", + 'invite.signed_out': "このハブのグループに招待されています。招待が送られたメールアドレスでアカウントを作成するか、すでにお持ちならサインインしてください。", + 'invite.register': "アカウントを作成", + 'invite.signin': "サインイン", + 'invite.confirm': "{inviter} さんが {group} への参加に招待しています。", + 'invite.join': "参加", + 'invite.ignore': "無視", + 'invite.open': "グループを開く", + 'invite.already_member': "すでに {group} のメンバーです。", + 'invite.other_account': "この招待は別のメールアドレスに送られました。そのアドレスで登録したアカウントでサインインしてください。エイリアスやドットも完全に一致する必要があります。", + 'invite.invalid': "この招待は無効です。使用済み、取り消し済み、または期限切れです。新しい招待を依頼してください。", + 'invite.joining': "参加しています…", + 'invite.after_register': "招待されたグループに参加するにはサインインしてください。", + 'invite.paste_title': "招待リンクで参加", + 'invite.paste_placeholder': "ここにリンクを貼り付け", + 'invite.paste_btn': "続行", + 'invite.paste_invalid': "招待リンクではありません。", + 'invite.paste_other_hub': "この招待は別のハブのものです。", + 'group.link_spent': "この招待リンクは、グループをホストしているマシンでは無効になっています。新しいリンクを依頼してください。", 'members.invite_code_hint': 'このコードは別の手段(SMSなど)でも共有できます。' + '相手がこのグループを初めて開いたときに入力します。', 'transfers.title': '転送', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js index 310bb4d..edb6f6a 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js @@ -686,6 +686,40 @@ export default { 'members.invite_email_sent': 'Er is een e-mail met de code naar dit lid gestuurd.', 'members.invite_email_failed': 'Kon de e-mail niet verzenden — deel de code handmatig.', 'members.invite_email_opt': 'Uitnodiging per e-mail versturen (kan in spam belanden)', + 'members.link_title': "Uitnodigen via link", + 'members.link_hint': "Voor iemand die misschien nog geen account heeft. De link werkt één keer, en alleen voor een account met dit adres.", + 'members.link_email_placeholder': "E-mailadres", + 'members.link_btn': "Link maken", + 'members.link_ready': "Uitnodigingslink voor {email}:", + 'members.link_copy': "Kopiëren", + 'members.link_copied': "Gekopieerd", + 'members.link_email_sent': "De link is per e-mail verstuurd.", + 'members.link_email_refused': "De e-mail kon niet worden verstuurd — deel de link zelf.", + 'members.links_pending': "Uitnodigingslinks", + 'members.link_status_redeemed': "gebruikt door {user}", + 'members.link_status_expired': "verlopen", + 'members.link_expires': "verloopt op {date}", + 'members.link_cancel': "Annuleren", + 'invite.title': "U bent uitgenodigd", + 'invite.none': "Er wacht geen uitnodiging in dit tabblad. Open de ontvangen link opnieuw.", + 'invite.signed_out': "Iemand heeft u uitgenodigd voor een groep op deze hub. Maak een account aan met het e-mailadres waarnaar de uitnodiging is gestuurd, of meld u aan als u er al een hebt.", + 'invite.register': "Account maken", + 'invite.signin': "Aanmelden", + 'invite.confirm': "{inviter} nodigt u uit voor {group}.", + 'invite.join': "Deelnemen", + 'invite.ignore': "Negeren", + 'invite.open': "Groep openen", + 'invite.already_member': "U bent al lid van {group}.", + 'invite.other_account': "Deze uitnodiging is naar een ander e-mailadres gestuurd. Meld u aan met het account van dat adres — aliassen en punten moeten exact overeenkomen.", + 'invite.invalid': "Deze uitnodiging is niet meer geldig: ze is gebruikt, geannuleerd of verlopen. Vraag een nieuwe.", + 'invite.joining': "Deelnemen…", + 'invite.after_register': "Meld u aan om deel te nemen aan de groep waarvoor u bent uitgenodigd.", + 'invite.paste_title': "Deelnemen met een uitnodigingslink", + 'invite.paste_placeholder': "Plak de link hier", + 'invite.paste_btn': "Doorgaan", + 'invite.paste_invalid': "Dat is geen uitnodigingslink.", + 'invite.paste_other_hub': "Deze uitnodiging is voor een andere hub.", + 'group.link_spent': "Deze uitnodigingslink is niet meer geldig op de machine die de groep host. Vraag een nieuwe.", 'members.invite_code_hint': 'U kunt deze code ook via een ander kanaal delen (bijv. sms). ' + 'De code werkt één keer en gaat nooit via de hub.', 'transfers.title': 'Overdrachten', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js index 886f1c7..d042c48 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js @@ -698,6 +698,40 @@ export default { 'members.invite_email_sent': 'E-mail z kodem został wysłany do tego członka.', 'members.invite_email_failed': 'Nie udało się wysłać e-maila — przekaż kod ręcznie.', 'members.invite_email_opt': 'Wyślij zaproszenie e-mailem (może trafić do spamu)', + 'members.link_title': "Zaproś linkiem", + 'members.link_hint': "Dla kogoś, kto może jeszcze nie mieć konta. Link działa raz i tylko dla konta założonego na ten adres.", + 'members.link_email_placeholder': "Adres e-mail", + 'members.link_btn': "Utwórz link", + 'members.link_ready': "Link zaproszenia dla {email}:", + 'members.link_copy': "Kopiuj", + 'members.link_copied': "Skopiowano", + 'members.link_email_sent': "Link został wysłany e-mailem.", + 'members.link_email_refused': "Nie udało się wysłać e-maila — przekaż link samodzielnie.", + 'members.links_pending': "Linki zaproszeń", + 'members.link_status_redeemed': "użyty przez {user}", + 'members.link_status_expired': "wygasł", + 'members.link_expires': "wygasa {date}", + 'members.link_cancel': "Anuluj", + 'invite.title': "Otrzymano zaproszenie", + 'invite.none': "W tej karcie nie czeka żadne zaproszenie. Otwórz ponownie otrzymany link.", + 'invite.signed_out': "Ktoś zaprosił cię do grupy na tym hubie. Załóż konto na adres e-mail, na który wysłano zaproszenie, albo zaloguj się, jeśli już je masz.", + 'invite.register': "Załóż konto", + 'invite.signin': "Zaloguj się", + 'invite.confirm': "{inviter} zaprasza cię do {group}.", + 'invite.join': "Dołącz", + 'invite.ignore': "Ignoruj", + 'invite.open': "Otwórz grupę", + 'invite.already_member': "Jesteś już członkiem {group}.", + 'invite.other_account': "To zaproszenie wysłano na inny adres e-mail. Zaloguj się na konto założone na ten adres — aliasy i kropki muszą się dokładnie zgadzać.", + 'invite.invalid': "To zaproszenie jest już nieważne: zostało użyte, anulowane lub wygasło. Poproś o nowe.", + 'invite.joining': "Dołączanie…", + 'invite.after_register': "Zaloguj się, aby dołączyć do grupy, do której cię zaproszono.", + 'invite.paste_title': "Dołącz przez link zaproszenia", + 'invite.paste_placeholder': "Wklej tutaj link", + 'invite.paste_btn': "Dalej", + 'invite.paste_invalid': "To nie jest link zaproszenia.", + 'invite.paste_other_hub': "To zaproszenie dotyczy innego huba.", + 'group.link_spent': "Ten link zaproszenia jest już nieważny na komputerze, który hostuje grupę. Poproś o nowy.", 'members.invite_code_hint': 'Możesz też przekazać ten kod innym kanałem (np. SMS). ' + 'Kod działa jednorazowo i nigdy nie przechodzi przez hub.', 'transfers.title': 'Transfery', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js index 643548a..61e4d44 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js @@ -684,6 +684,40 @@ export default { 'members.invite_email_sent': 'Um e-mail com o código foi enviado a este membro.', 'members.invite_email_failed': 'Não foi possível enviar o e-mail — compartilhe o código manualmente.', 'members.invite_email_opt': 'Enviar o convite por e-mail (pode cair no spam)', + 'members.link_title': "Convidar por link", + 'members.link_hint': "Para alguém que talvez ainda não tenha conta. O link funciona uma vez, e só para uma conta registrada com este endereço.", + 'members.link_email_placeholder': "Endereço de e-mail", + 'members.link_btn': "Criar link", + 'members.link_ready': "Link de convite para {email}:", + 'members.link_copy': "Copiar", + 'members.link_copied': "Copiado", + 'members.link_email_sent': "O link foi enviado por e-mail.", + 'members.link_email_refused': "Não foi possível enviar o e-mail — compartilhe o link você mesmo.", + 'members.links_pending': "Links de convite", + 'members.link_status_redeemed': "usado por {user}", + 'members.link_status_expired': "expirado", + 'members.link_expires': "expira em {date}", + 'members.link_cancel': "Cancelar", + 'invite.title': "Você foi convidado", + 'invite.none': "Não há convite aguardando nesta aba. Abra novamente o link que recebeu.", + 'invite.signed_out': "Alguém convidou você para um grupo neste hub. Crie uma conta com o endereço de e-mail para o qual o convite foi enviado, ou entre se já tiver uma.", + 'invite.register': "Criar uma conta", + 'invite.signin': "Entrar", + 'invite.confirm': "{inviter} convida você para participar de {group}.", + 'invite.join': "Participar", + 'invite.ignore': "Ignorar", + 'invite.open': "Abrir o grupo", + 'invite.already_member': "Você já é membro de {group}.", + 'invite.other_account': "Este convite foi enviado para outro endereço de e-mail. Entre com a conta registrada com esse endereço — aliases e pontos devem coincidir exatamente.", + 'invite.invalid': "Este convite não é mais válido: foi usado, cancelado ou expirou. Peça um novo.", + 'invite.joining': "Entrando…", + 'invite.after_register': "Entre para participar do grupo para o qual foi convidado.", + 'invite.paste_title': "Participar com um link de convite", + 'invite.paste_placeholder': "Cole o link aqui", + 'invite.paste_btn': "Continuar", + 'invite.paste_invalid': "Isso não é um link de convite.", + 'invite.paste_other_hub': "Este convite é para outro hub.", + 'group.link_spent': "Este link de convite não é mais válido na máquina que hospeda o grupo. Peça um novo.", 'members.invite_code_hint': 'Você também pode compartilhar este código por outro canal (ex: SMS). ' + 'O código funciona uma única vez e nunca passa pelo hub.', 'transfers.title': 'Transferências', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js index 17adb8b..34fb439 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js @@ -666,6 +666,40 @@ export default { 'members.invite_email_sent': '包含验证码的邮件已发送给该成员。', 'members.invite_email_failed': '无法发送邮件——请手动分享验证码。', 'members.invite_email_opt': '通过电子邮件发送邀请(可能进入垃圾邮件)', + 'members.link_title': "通过链接邀请", + 'members.link_hint': "适用于可能还没有账户的人。该链接只能使用一次,且仅限使用此地址注册的账户。", + 'members.link_email_placeholder': "电子邮件地址", + 'members.link_btn': "创建链接", + 'members.link_ready': "发给 {email} 的邀请链接:", + 'members.link_copy': "复制", + 'members.link_copied': "已复制", + 'members.link_email_sent': "链接已通过电子邮件发送。", + 'members.link_email_refused': "无法发送电子邮件——请自行分享链接。", + 'members.links_pending': "邀请链接", + 'members.link_status_redeemed': "已由 {user} 使用", + 'members.link_status_expired': "已过期", + 'members.link_expires': "{date} 过期", + 'members.link_cancel': "取消", + 'invite.title': "您收到了邀请", + 'invite.none': "此标签页中没有待处理的邀请。请重新打开您收到的链接。", + 'invite.signed_out': "有人邀请您加入此中心上的一个群组。请使用收到邀请的电子邮件地址创建账户,如果已有账户请登录。", + 'invite.register': "创建账户", + 'invite.signin': "登录", + 'invite.confirm': "{inviter} 邀请您加入 {group}。", + 'invite.join': "加入", + 'invite.ignore': "忽略", + 'invite.open': "打开群组", + 'invite.already_member': "您已经是 {group} 的成员。", + 'invite.other_account': "此邀请发送到了另一个电子邮件地址。请使用该地址注册的账户登录——别名和点号必须完全一致。", + 'invite.invalid': "此邀请已失效:已被使用、取消或已过期。请索取新的邀请。", + 'invite.joining': "正在加入…", + 'invite.after_register': "登录以加入您受邀的群组。", + 'invite.paste_title': "使用邀请链接加入", + 'invite.paste_placeholder': "在此粘贴链接", + 'invite.paste_btn': "继续", + 'invite.paste_invalid': "这不是邀请链接。", + 'invite.paste_other_hub': "此邀请属于另一个中心。", + 'group.link_spent': "此邀请链接在托管该群组的机器上已失效。请索取新的链接。", 'members.invite_code_hint': '您也可以通过其他渠道(如短信)分享此验证码。' + '验证码仅可使用一次,且绝不会经过 hub。', 'transfers.title': '传输', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/platform.js b/packages/meshbay-hub/src/meshbay_hub/static/platform.js index cfa4d87..a3fb80b 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/platform.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/platform.js @@ -39,6 +39,17 @@ export function hubBase() { return bridge ? (bridge.hubBase() || '') : ''; } +/** + * The hub's own address, as somebody else would type it — for a link that is + * sent to another person, where "same origin" means nothing. In a browser the + * hub served this page, so it is the page's origin; in the app it is the + * configured hub. Decided here and nowhere else, like `hubBase()`. + */ +export function hubOrigin() { + const base = hubBase(); + return (base ? new URL(base).origin : window.location.origin); +} + /** Native-only capabilities. A browser renders none of what these gate. */ export const capabilities = { // Install, configure and drive a node running on this machine. diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js index c43422f..f6adaac 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js @@ -123,7 +123,7 @@ const ADMIN_OP_TYPES = new Set([ 'root_add', 'root_remove', 'root_update', 'root_eject', 'root_plug', 'app_directories', 'chat_directory', 'chat_link_preview', 'chat_epoch', 'search_listed', 'member_unpin', 'gek_rotate', 'group_attach', - 'group_detach', 'invite_create', + 'group_detach', 'invite_create', 'invite_link_create', 'invite_cancel', ]); // ── Diagnostic trace (opt-in, off by default) ─────────────────────────────── @@ -614,13 +614,14 @@ class MeshBayTransport { } async connect(nodeId, jwtToken, groupId, gekRaw, sessionKeys, bundleKey, username, - userId, joinCode, recoveryKey) { + userId, joinCode, recoveryKey, joinNodePk) { // Remembered for _reconnectLoop, which calls connect() again with these // same values (plus a freshly-fetched token and the identity connect() // itself settles on below) after the WebRTC connection is declared // "failed" — see the pc.onconnectionstatechange handler further down. this._connectArgs = { nodeId, groupId, gekRaw, bundleKey, username, userId, joinCode, recoveryKey, + joinNodePk, }; this._lastToken = jwtToken; // The constructor sets this once from whatever token the caller had at @@ -984,7 +985,16 @@ class MeshBayTransport { // This is the normal path for anyone who joined after the invite redesign — // no bundle is pre-stored for members any more. A code is needed only the // first time this node sees this account. - if (!gekRaw && this._sessionKeys && userId) { + // A code from an invitation link goes to the node the link names and to + // no other, and only once that node has proved the key in its challenge + // (docs/MESHBAY_DESIGN.md §3.4). Otherwise nothing is sent at all — not + // even a join without the code, which this node would answer by asking + // for one. + const linkRefusal = _linkJoinRefusal(joinNodePk, joinCode, this.nodePk, + this.nodePkProved); + if (linkRefusal) { + this._joinError = linkRefusal; + } else if (!gekRaw && this._sessionKeys && userId) { try { gekRaw = await this.joinGroup(userId, groupId, joinCode); } catch (e) { @@ -1240,7 +1250,8 @@ class MeshBayTransport { try { ack = await this.connect(args.nodeId, token, args.groupId, args.gekRaw, this._sessionKeys, args.bundleKey, args.username, - args.userId, args.joinCode); + args.userId, args.joinCode, undefined, + args.joinNodePk); } finally { this._inReconnectAttempt = false; } @@ -2627,6 +2638,34 @@ class MeshBayTransport { } /** + * A code bound to no account, for an invitation link (MNP 3.4). Signed like + * any invitation, and the subject the operator signs is the outcome: a link + * into this group, `link:<group>`, and nothing else. + */ + async createLinkInvite(groupId, signFn) { + const msg = await this._sendAndWait({ + type: 'invite_link_create', v: '0.1', group_id: groupId, + }); + if (msg.type === 'error') throw new Error(msg.detail); + if (msg.type === 'admin_challenge') { + return this._authorizeAdminOp(msg, 'invite_link_create', `link:${groupId}`, signFn); + } + return msg; + } + + /** Take back an unredeemed invitation link, by the handle it was issued with. */ + async cancelLinkInvite(inviteId, signFn) { + const msg = await this._sendAndWait({ + type: 'invite_cancel', v: '0.1', invite_id: inviteId, + }); + if (msg.type === 'error') throw new Error(msg.detail); + if (msg.type === 'admin_challenge') { + return this._authorizeAdminOp(msg, 'invite_cancel', inviteId, signFn); + } + return msg; + } + + /** * Ask the node to recognise us and hand over the group key. * * Sent when we hold no GEK for a group. `code` is needed only the first time @@ -3982,6 +4021,29 @@ function _hex(bytes) { } /** + * Why a code from an invitation link must not go to this node, or null. + * + * `link_other_node` is the caller's cue to try the next node the hub listed, + * as for `not_hosted`: the link names one node, and this is not it. An older + * node that cannot prove its key early is refused rather than trusted — it + * cannot have issued a link code anyway. + */ +function _linkJoinRefusal(joinNodePk, joinCode, nodePk, nodePkProved) { + if (!joinNodePk || !joinCode) return null; + if (nodePk !== joinNodePk) { + const err = new Error('This invitation was issued by another machine hosting this group.'); + err.reason = 'link_other_node'; + return err; + } + if (!nodePkProved) { + const err = new Error('This node is too old to accept invitation links.'); + err.reason = 'link_node_unproved'; + return err; + } + return null; +} + +/** * Whether `handshake_challenge` proves the key it announces (MNP 3.4). * * True when it carries a signature that verifies over this connection, false diff --git a/packages/meshbay-hub/tests/harness/invite_link_probe.py b/packages/meshbay-hub/tests/harness/invite_link_probe.py new file mode 100644 index 0000000..97ee893 --- /dev/null +++ b/packages/meshbay-hub/tests/harness/invite_link_probe.py @@ -0,0 +1,181 @@ +#!/usr/bin/env python3 +""" +An invitation link, opened in the real application. + +`test_invite_link_client.py` runs the link's functions one by one. What only the +running application can show is how they meet the router, the sign-in state and +the hub calls: that the code is out of the address before anything routes on +it, that a signed-out reader is sent to register with the invitation kept, and +that a signed-in reader is shown the invitation, joins with one click and lands +on the group — with the code never in a request to the hub. + +Loads the shipped `app.js` in a real browser with `fetch` stubbed, twice: + + signed_out — a link, no session + signed_in — the same link, a session; then the Join button is clicked + + invite_link_probe.py + +Prints JSON: one object per case. +""" + +import http.server +import json +import socketserver +import subprocess +import sys +import tempfile +import threading +import time +from pathlib import Path + +STATIC = Path(__file__).resolve().parents[2] / "src" / "meshbay_hub" / "static" +PORT = 8771 +RECORDS = [] +socketserver.TCPServer.allow_reuse_address = True + +GROUP = "0f8fad5b-d9cb-469f-a165-70867728950e" +TICKET = "AbCdEfGhIjKlMnOpQr-_12" +NODE = "A" * 43 +CODE = "K7P2-9WQX" +LINK = f"#/invite?v=1&g={GROUP}&t={TICKET}&n={NODE}&c={CODE}" + +PAGE = r"""<!doctype html><html><head><meta charset=utf-8></head><body> +<div id="app"></div> +<script type="module"> +const CASE = new URLSearchParams(location.search).get('case'); +const realFetch = window.fetch.bind(window); +const post = (o) => realFetch('/log', { method: 'POST', body: JSON.stringify(o) }); +const calls = []; +const json = (body, status = 200) => ({ + ok: status < 400, status, statusText: '', headers: new Headers(), + json: async () => body, text: async () => JSON.stringify(body), +}); +window.fetch = async (url, init = {}) => { + const u = String(url); + calls.push({ url: u, body: init.body ? String(init.body) : '' }); + if (u.includes('/v1/users/me/preferences')) return json({}); + if (u.includes('/v1/users/me')) return json({ user_id: 'u-1', role: 'user' }); + if (u.includes('/v1/groups/mine')) return json({ groups: [] }); + if (u.includes('/v1/invite-links/preview')) return json({ + group_id: '__GROUP__', group_name: 'Some Group', inviter: 'the-owner', + expires_at: '2099-01-01T00:00:00+00:00', already_member: false }); + if (u.includes('/v1/invite-links/redeem')) return json({ + group_id: '__GROUP__', group_name: 'Some Group' }); + if (u.includes('/nodes')) return json({ nodes: [] }); + return json({}); +}; +if (CASE === 'signed_in') { + localStorage.setItem('mb_auth', JSON.stringify({ + username: 'invitee-account', userId: 'u-1', token: 'tok', refreshToken: 'ref', + role: 'user' })); +} else { + localStorage.removeItem('mb_auth'); +} +sessionStorage.clear(); +history.replaceState(null, '', '/?case=' + CASE + '__LINK__'); + +const wait = (ms) => new Promise((r) => setTimeout(r, ms)); +const text = () => document.getElementById('app').innerText; +(async () => { + const out = { case: CASE }; + try { + await import('/app.js'); + await wait(1500); + out.hash_after_load = location.hash; + out.pending = JSON.parse(sessionStorage.getItem('mb.pendingInvite') || 'null'); + out.text_after_load = text().slice(0, 600); + if (CASE === 'signed_out') { + const reg = [...document.querySelectorAll('a')] + .find((a) => a.getAttribute('href') === '#/register'); + out.register_link = Boolean(reg); + if (reg) { reg.click(); await wait(500); } + out.hash_after_click = location.hash; + out.pending_after_click = Boolean(sessionStorage.getItem('mb.pendingInvite')); + } else { + // By its role, not its label: the browser's language picks the label. + const join = document.querySelector('.login-card button.btn-primary'); + out.join_button = Boolean(join); + if (join) { join.click(); await wait(1500); } + out.hash_after_click = location.hash; + out.redeem_bodies = calls.filter((c) => c.url.includes('/redeem')).map((c) => c.body); + } + out.code_in_a_hub_request = calls.some( + (c) => c.url.includes('__CODE__') || c.body.includes('__CODE__')); + out.hub_calls = calls.map((c) => c.url.replace(/^https?:\/\/[^/]+/, '')); + } catch (e) { + out.error = String(e && e.stack || e); + } + post(out); +})(); +</script></body></html> +""".replace("__GROUP__", GROUP).replace("__LINK__", LINK).replace("__CODE__", CODE) + + +class H(http.server.SimpleHTTPRequestHandler): + def log_message(self, *a): + pass + + def do_POST(self): + length = int(self.headers.get("Content-Length") or 0) + body = self.rfile.read(length) + if self.path == "/log": + RECORDS.append(json.loads(body.decode())) + self.send_response(204) + self.end_headers() + + def _send(self, body: bytes, ctype: str) -> None: + self.send_response(200) + self.send_header("Content-Type", ctype) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_GET(self): + path = self.path.split("?")[0] + if path == "/": + self._send(PAGE.encode(), "text/html; charset=utf-8") + return + asset = (STATIC / path.lstrip("/")).resolve() + if not str(asset).startswith(str(STATIC)) or not asset.is_file(): + self.send_response(404) + self.end_headers() + return + ctype = "text/javascript" if asset.suffix in (".js", ".mjs") else ( + "application/wasm" if asset.suffix == ".wasm" else "application/octet-stream") + self._send(asset.read_bytes(), ctype) + + +def _run(case: str) -> dict | None: + before = len(RECORDS) + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as profile: + proc = subprocess.Popen( + ["google-chrome", "--headless=new", "--disable-gpu", "--no-sandbox", + f"--user-data-dir={profile}", f"http://127.0.0.1:{PORT}/?case={case}"], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + for _ in range(300): + if len(RECORDS) > before: + break + time.sleep(0.1) + proc.terminate() + try: + proc.wait(timeout=10) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait() + return RECORDS[before] if len(RECORDS) > before else None + + +def main() -> int: + with socketserver.TCPServer(("127.0.0.1", PORT), H) as srv: + threading.Thread(target=srv.serve_forever, daemon=True).start() + results = [_run("signed_out"), _run("signed_in")] + if not all(results): + print(json.dumps({"error": "no measurement", "got": results}), file=sys.stderr) + return 1 + print(json.dumps(results, indent=1)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/packages/meshbay-hub/tests/test_invite_link_client.py b/packages/meshbay-hub/tests/test_invite_link_client.py new file mode 100644 index 0000000..c5c0101 --- /dev/null +++ b/packages/meshbay-hub/tests/test_invite_link_client.py @@ -0,0 +1,194 @@ +""" +The browser's half of an invitation link (docs/MESHBAY_DESIGN.md §3.4). + +Three properties, each run against the shipped code rather than restated: + +- **one shape.** The hub writes a link when it mails one (`invite_url`), the + page writes one when it shows one (`buildInviteLink`), and the page reads both + (`parseInvite`). A disagreement is a link that opens on nothing. +- **the code leaves the address at once, and the tab keeps it.** Run in node + against a stand-in `window`: `captureFromLocation` rewrites the address and + stores what it read, and a malformed link is cleaned out without being kept. +- **the code goes to the node the link names, and to no other.** The transport's + `_linkJoinRefusal` is what stops it; this runs it. + +The rest are read from the source, which is the evidence there is for them: the +hub is never handed the code except when the inviter ticked the mail box, the +capture is the first thing `app.js` loads, and signing out forgets the +invitation. +""" + +import base64 +import json +import re +import shutil +import subprocess +from pathlib import Path + +import pytest +from meshbay_hub import mail as mail_mod +from meshbay_hub.api import invite_links + +STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static" +LINK_JS = STATIC / "invite-link.js" + +pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node is not available") + +GROUP = "0f8fad5b-d9cb-469f-a165-70867728950e" +TICKET = "AbCdEfGhIjKlMnOpQr-_12" +NODE_PK_STD = base64.b64encode(bytes(range(32))).decode() # has '+', '/', '=' +CODE = "K7P2-9WQX" + + +def _module_body() -> str: + """invite-link.js with its import, its exports and its load-time capture + removed — the functions as shipped, runnable against a stand-in window.""" + src = LINK_JS.read_text(encoding="utf-8") + src = re.sub(r"^import .*?;\n", "", src, flags=re.M) + src = src.replace("export function", "function") + tail = "\ncaptureFromLocation();\nwindow.addEventListener('hashchange', captureFromLocation);\n" + assert src.endswith(tail), "invite-link.js no longer ends with its load-time capture" + return src[: -len(tail)] + + +def _run(tmp_path, script: str): + harness = tmp_path / "h.js" + harness.write_text(script) + out = subprocess.run(["node", str(harness)], capture_output=True, text=True, timeout=60) + assert out.returncode == 0, out.stderr + return json.loads(out.stdout) + + +_WINDOW = r""" +const store = new Map(); +globalThis.sessionStorage = { + getItem: (k) => (store.has(k) ? store.get(k) : null), + setItem: (k, v) => store.set(k, String(v)), + removeItem: (k) => store.delete(k), +}; +const replaced = []; +globalThis.window = { + location: { hash: '', pathname: '/', search: '' }, + history: { replaceState: (_s, _t, url) => replaced.push(url) }, + addEventListener() {}, +}; +const platform = { hubOrigin: () => 'https://hub.example' }; +""" + + +def test_one_shape_between_the_hub_and_the_page(tmp_path, monkeypatch): + monkeypatch.setattr(mail_mod, "_hub_url", "https://hub.example") + n = NODE_PK_STD.replace("+", "-").replace("/", "_").rstrip("=") + from_hub = invite_links.invite_url(GROUP, TICKET, n, CODE) + got = _run(tmp_path, _WINDOW + _module_body() + f""" + const fields = {{ g: '{GROUP}', t: '{TICKET}', + n: nodePkForLink('{NODE_PK_STD}'), c: '{CODE}' }}; + process.stdout.write(JSON.stringify({{ + parsed: parseInvite({json.dumps(from_hub)}), + built: buildInviteLink('https://hub.example', fields), + back: nodePkFromLink(fields.n), + lower: parseInvite({json.dumps(from_hub.replace(CODE, CODE.lower()))}), + }})); + """) + assert got["parsed"] == {"g": GROUP, "t": TICKET, "n": n, "c": CODE} + assert got["built"] == from_hub + assert got["back"] == NODE_PK_STD, "the key the transport compares must come back exact" + assert got["lower"]["c"] == CODE + + +@pytest.mark.parametrize("tamper", [ + lambda u: u.replace("v=1", "v=2"), + lambda u: u.replace(CODE, "K7P2-9WQ"), + lambda u: u.replace(CODE, "K7P2-9WQX<script>"), + lambda u: u.replace(TICKET, TICKET + "x"), + lambda u: u.replace(GROUP, "../../admin"), + lambda u: u.replace("&n=", "&m="), +]) +def test_anything_but_that_shape_is_not_an_invitation(tmp_path, tamper): + good = f"https://hub.example/#/invite?v=1&g={GROUP}&t={TICKET}&n={'A' * 43}&c={CODE}" + got = _run(tmp_path, _WINDOW + _module_body() + + f"process.stdout.write(JSON.stringify(parseInvite({json.dumps(tamper(good))})));") + assert got is None + + +def test_the_code_leaves_the_address_and_stays_in_the_tab(tmp_path): + good = f"#/invite?v=1&g={GROUP}&t={TICKET}&n={'A' * 43}&c={CODE}" + got = _run(tmp_path, _WINDOW + _module_body() + f""" + window.location.hash = {json.dumps(good)}; + const first = captureFromLocation(); + const kept = loadPending(); + window.location.hash = '#/invite?v=1&g=nope'; + captureFromLocation(); + const afterBad = loadPending(); + clearPending(); + process.stdout.write(JSON.stringify({{ + first: Boolean(first), replaced, kept, afterBad, cleared: loadPending(), + }})); + """) + assert got["first"] is True + assert got["replaced"] == ["/#/invite", "/#/invite"], ( + "both the good link and the malformed one must be taken out of the address") + assert got["kept"]["c"] == CODE and got["kept"]["g"] == GROUP + assert got["afterBad"]["t"] == TICKET, "a malformed link must not replace a good one" + assert got["cleared"] is None + + +def test_a_link_code_goes_to_the_node_the_link_names_and_no_other(tmp_path): + src = (STATIC / "transport.js").read_text(encoding="utf-8") + fn = re.search(r"^function _linkJoinRefusal\(.*?^\}", src, re.M | re.S) + assert fn, "transport.js no longer has _linkJoinRefusal" + got = _run(tmp_path, fn.group(0) + """ + const r = (...a) => { const e = _linkJoinRefusal(...a); return e ? e.reason : null; }; + process.stdout.write(JSON.stringify([ + r('KEY', 'K7P2-9WQX', 'KEY', true), + r('KEY', 'K7P2-9WQX', 'OTHER', true), + r('KEY', 'K7P2-9WQX', 'KEY', false), + r(undefined, 'K7P2-9WQX', 'OTHER', false), + r('KEY', null, 'OTHER', false), + ])); + """) + assert got == [None, "link_other_node", "link_node_unproved", None, None] + + +# ── Read from the source ───────────────────────────────────────────────────── + +def _code(name: str) -> str: + """The file without its comments — prose about the code is not the code.""" + src = (STATIC / name).read_text(encoding="utf-8") + src = re.sub(r"/\*.*?\*/", "", src, flags=re.S) + return "\n".join(line for line in src.splitlines() + if not line.strip().startswith("//")) + + +def test_the_capture_is_the_first_thing_the_app_loads(): + imports = re.findall(r"^import .*? from '([^']+)';", _code("app.js"), re.M | re.S) + assert imports and imports[0] == "./invite-link.js" + + +def test_the_invitation_page_never_sends_the_code_to_the_hub(): + page = _code("invite-page.js") + assert "inv.t" in page, "the check below is looking at the wrong names" + assert not re.search(r"\binv\.c\b|\binv\[.c.\]", page), ( + "invite-page.js reads the code; only the ticket is its to send") + + +def test_the_members_tab_sends_the_code_only_for_the_mail(): + settings = _code("group-settings.js") + sends = [m.start() for m in re.finditer(r"code: node\.code", settings)] + assert len(sends) == 1 + before = settings[settings.rfind("\n", 0, sends[0] - 200):sends[0]] + assert "inviteByEmail ?" in before, "the code reaches the hub only when the box asks" + + +def test_signing_out_forgets_the_invitation(): + app = _code("app.js") + logout = app[app.index("logout: () => {"):] + logout = logout[:logout.index("},")] + assert "clearPending()" in logout + + +def test_the_group_page_moves_on_from_another_host(): + page = _code("group-page.js") + loop = page[page.index("for (const n of nodesData.nodes)"):] + loop = loop[:loop.index("if (!transport)")] + assert "link_other_node" in loop diff --git a/packages/meshbay-hub/tests/test_invite_link_flow.py b/packages/meshbay-hub/tests/test_invite_link_flow.py new file mode 100644 index 0000000..42f461c --- /dev/null +++ b/packages/meshbay-hub/tests/test_invite_link_flow.py @@ -0,0 +1,61 @@ +""" +An invitation link, opened in the real application (harness/invite_link_probe.py). + +The functions behind a link are tested one by one in +`test_invite_link_client.py`; this is where they meet the router, the sign-in +state and the hub. Two readers: one with no account, who must be sent to +register with the invitation kept, and one signed in, who must be shown it, +join with one click and land on the group. For both, the code never appears in +a request to the hub — it is the node's, and the hub is only handed the ticket. +""" + +import json +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +HARNESS = Path(__file__).parent / "harness" / "invite_link_probe.py" + + +@pytest.fixture(scope="module") +def cases(): + if shutil.which("google-chrome") is None: + pytest.skip("Chrome is not available") + proc = subprocess.run([sys.executable, str(HARNESS)], + capture_output=True, text=True, timeout=180) + assert proc.returncode == 0, f"probe failed: {proc.stdout}{proc.stderr}" + out = {c["case"]: c for c in json.loads(proc.stdout)} + for c in out.values(): + assert "error" not in c, c["error"] + return out + + +@pytest.mark.parametrize("case", ["signed_out", "signed_in"]) +def test_the_code_is_out_of_the_address_and_kept_in_the_tab(cases, case): + c = cases[case] + assert c["hash_after_load"] == "#/invite" + assert c["pending"] and c["pending"]["c"] == "K7P2-9WQX" + + +@pytest.mark.parametrize("case", ["signed_out", "signed_in"]) +def test_the_code_never_reaches_the_hub(cases, case): + assert cases[case]["code_in_a_hub_request"] is False + + +def test_a_reader_with_no_account_is_sent_to_register_with_the_invitation_kept(cases): + c = cases["signed_out"] + assert c["register_link"] and c["hash_after_click"] == "#/register" + assert c["pending_after_click"] is True + assert not any("/invite-links/" in u for u in c["hub_calls"]), ( + "nothing about the invitation is asked of the hub before sign-in") + + +def test_a_signed_in_reader_joins_with_one_click_and_lands_on_the_group(cases): + c = cases["signed_in"] + assert "the-owner" in c["text_after_load"] and "Some Group" in c["text_after_load"] + assert c["join_button"] + assert c["redeem_bodies"] == ['{"ticket":"AbCdEfGhIjKlMnOpQr-_12"}'] + assert c["hash_after_click"] == "#/group/0f8fad5b-d9cb-469f-a165-70867728950e" diff --git a/packages/meshbay-hub/tests/test_signed_in_never_sees_the_login_form.py b/packages/meshbay-hub/tests/test_signed_in_never_sees_the_login_form.py index 547f7d4..0343ee2 100644 --- a/packages/meshbay-hub/tests/test_signed_in_never_sees_the_login_form.py +++ b/packages/meshbay-hub/tests/test_signed_in_never_sees_the_login_form.py @@ -56,5 +56,9 @@ def test_a_signed_in_person_on_the_form_is_sent_home_without_a_history_entry(app assert "const onAuthForm = route === '/login' || route === '/register';" in app_body effect = app_body[app_body.index("if (user && onAuthForm)"):] effect = effect[:effect.index("\n")] - assert "window.location.replace('#/')" in effect, ( + # `replace`, whichever the destination: home, or the invitation that sent + # them to sign in (invite-link.js). Assigning the hash would leave the form + # one Back away. + assert "window.location.replace(" in effect and "'#/'" in effect, ( "Back must not lead to the form again") + assert "window.location.hash" not in effect |