1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
|
/**
* `#/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_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 === '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>`;
}
|