aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/invite-page.js
diff options
context:
space:
mode:
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.js153
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>`;
+}