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