diff options
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 |