diff options
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>`; +} |