| 1 |
1 |
|
/**
|
| 2 |
2 |
|
* GoingsOn - Sharing (Groups) Module
|
| 3 |
|
- |
* Shared-workspace admin: your identity public key, the groups you belong to,
|
| 4 |
|
- |
* and (for groups you administer) member management. Sharing a project into a
|
| 5 |
|
- |
* group lives on the project itself; this section is the group side of it.
|
|
3 |
+ |
* Shared-workspace admin: your identity key, the groups you belong to, member
|
|
4 |
+ |
* management for groups you administer, and the invite flow in both directions.
|
|
5 |
+ |
* Sharing a project into a group lives on the project itself; this section is the
|
|
6 |
+ |
* group side of it.
|
| 6 |
7 |
|
*
|
| 7 |
8 |
|
* Backend: SyncKit group commands (Groups p4 / GO -> SyncStore M4). Admin
|
| 8 |
9 |
|
* commands round-trip to the server via the SyncKit client; listing members is
|
| 9 |
10 |
|
* admin-only (the server gates it), so member panels render only for groups you
|
| 10 |
11 |
|
* administer; a listMembers failure is treated as "not the admin".
|
|
12 |
+ |
*
|
|
13 |
+ |
* ## Why the confirm step exists
|
|
14 |
+ |
*
|
|
15 |
+ |
* An invite code removes the copy-paste, not the decision. The invitee posts
|
|
16 |
+ |
* their public key against the code and lands in the admin's queue; the admin
|
|
17 |
+ |
* compares the key's fingerprint against what the invitee reads out over some
|
|
18 |
+ |
* other channel, and only then is the group key sealed to it. That comparison is
|
|
19 |
+ |
* the only thing standing between a server able to substitute a public key and a
|
|
20 |
+ |
* grant to the group's content key, so the UI puts the fingerprint in front of
|
|
21 |
+ |
* the admin rather than offering a one-click approve.
|
|
22 |
+ |
*
|
|
23 |
+ |
* Fingerprints, never raw keys, wherever a human is meant to compare: nobody
|
|
24 |
+ |
* checks 44 characters of base64, so a UI that shows one is claiming a
|
|
25 |
+ |
* verification it does not get.
|
| 11 |
26 |
|
*/
|
| 12 |
27 |
|
|
| 13 |
28 |
|
(function() {
|
| 17 |
32 |
|
|
| 18 |
33 |
|
/**
|
| 19 |
34 |
|
* Render the sharing section into a settings container. Loads the groups you
|
| 20 |
|
- |
* belong to, your public key, and each group's members (admin-only).
|
|
35 |
+ |
* belong to, your own key, each group's members (admin-only) and each group's
|
|
36 |
+ |
* invitations (admin-only).
|
| 21 |
37 |
|
* @param {HTMLElement} container
|
| 22 |
38 |
|
*/
|
| 23 |
39 |
|
async function renderSharingSection(container) {
|
| 38 |
54 |
|
return;
|
| 39 |
55 |
|
}
|
| 40 |
56 |
|
|
| 41 |
|
- |
// Your public key is what an admin needs to add you. Non-fatal if
|
| 42 |
|
- |
// encryption is not set up yet.
|
|
57 |
+ |
// Your key identifies you to an admin. Non-fatal if encryption is not set
|
|
58 |
+ |
// up yet: everything else on this screen still renders.
|
| 43 |
59 |
|
let pubkey = null;
|
|
60 |
+ |
let fingerprint = null;
|
| 44 |
61 |
|
try {
|
| 45 |
62 |
|
pubkey = await GoingsOn.api.groups.myPubkey();
|
|
63 |
+ |
fingerprint = await GoingsOn.api.groups.myFingerprint();
|
| 46 |
64 |
|
} catch (_) {}
|
| 47 |
65 |
|
|
| 48 |
|
- |
// Members are admin-only; fetch per group and swallow the not-admin error.
|
|
66 |
+ |
// Members and invitations are both admin-only; fetch per group and
|
|
67 |
+ |
// swallow the not-admin error, which is how a group renders as
|
|
68 |
+ |
// member-only rather than erroring.
|
| 49 |
69 |
|
const cards = await Promise.all(groups.map(async (g) => {
|
| 50 |
70 |
|
let members = null;
|
|
71 |
+ |
let invitations = [];
|
| 51 |
72 |
|
try {
|
| 52 |
73 |
|
members = await GoingsOn.api.groups.listMembers(g.id);
|
| 53 |
74 |
|
} catch (_) {}
|
| 54 |
|
- |
return renderGroupCard(g, members);
|
|
75 |
+ |
if (members !== null) {
|
|
76 |
+ |
try {
|
|
77 |
+ |
invitations = await GoingsOn.api.groups.listInvitations(g.id);
|
|
78 |
+ |
} catch (_) {}
|
|
79 |
+ |
}
|
|
80 |
+ |
return renderGroupCard(g, members, invitations);
|
| 55 |
81 |
|
}));
|
| 56 |
82 |
|
|
| 57 |
|
- |
const pubkeyBlock = pubkey
|
|
83 |
+ |
const identityBlock = fingerprint
|
| 58 |
84 |
|
? `
|
| 59 |
|
- |
<p class="settings-desc">Share this public key with a group admin so they can add you. It is not a secret.</p>
|
| 60 |
|
- |
<div class="form-group">
|
| 61 |
|
- |
<input type="text" class="field" id="my-group-pubkey" value="${escAttr(pubkey)}" readonly>
|
| 62 |
|
- |
</div>
|
|
85 |
+ |
<p class="settings-desc">Read this fingerprint to a group admin so they can confirm the key that reaches them is yours. It is not a secret.</p>
|
|
86 |
+ |
<div class="fingerprint-display" id="my-group-fingerprint">${esc(fingerprint)}</div>
|
| 63 |
87 |
|
<div class="sync-section-actions">
|
| 64 |
|
- |
<button class="button button--secondary" data-act="settings.copyMyGroupPubkey">Copy public key</button>
|
|
88 |
+ |
<button class="button button--secondary" data-act="settings.copyMyFingerprint">Copy fingerprint</button>
|
| 65 |
89 |
|
</div>
|
| 66 |
|
- |
`
|
|
90 |
+ |
<details class="settings-disclosure">
|
|
91 |
+ |
<summary class="settings-disclosure-toggle">Show the full key</summary>
|
|
92 |
+ |
<p class="form-hint">Only needed if an admin is adding you by hand instead of sending an invite.</p>
|
|
93 |
+ |
<div class="form-group">
|
|
94 |
+ |
<input type="text" class="field" id="my-group-pubkey" value="${escAttr(pubkey || '')}" readonly>
|
|
95 |
+ |
</div>
|
|
96 |
+ |
<div class="sync-section-actions">
|
|
97 |
+ |
<button class="button button--secondary" data-act="settings.copyMyGroupPubkey">Copy public key</button>
|
|
98 |
+ |
</div>
|
|
99 |
+ |
</details>
|
|
100 |
+ |
`
|
| 67 |
101 |
|
: `<p class="text-secondary">Set up sync encryption first. Your sharing key is created with it.</p>`;
|
| 68 |
102 |
|
|
| 69 |
103 |
|
const groupsBlock = groups.length
|
| 70 |
104 |
|
? cards.join('')
|
| 71 |
|
- |
: `<p class="text-secondary">You are not in any groups yet. Create one to share a project.</p>`;
|
|
105 |
+ |
: `<p class="text-secondary">You are not in any groups yet. Create one to share a project, or join one with an invite code.</p>`;
|
| 72 |
106 |
|
|
| 73 |
107 |
|
container.innerHTML = `
|
| 74 |
108 |
|
<div class="settings-section">
|
| 75 |
109 |
|
<h3 class="settings-heading">Sharing</h3>
|
| 76 |
110 |
|
|
| 77 |
111 |
|
<div class="settings-section-block">
|
| 78 |
|
- |
<h4 class="settings-subheading">Your public key</h4>
|
| 79 |
|
- |
${pubkeyBlock}
|
|
112 |
+ |
<h4 class="settings-subheading">Your key</h4>
|
|
113 |
+ |
${identityBlock}
|
|
114 |
+ |
</div>
|
|
115 |
+ |
|
|
116 |
+ |
<div class="settings-section-block">
|
|
117 |
+ |
<h4 class="settings-subheading">Join a group</h4>
|
|
118 |
+ |
<p class="settings-desc">Paste an invite code from a group admin. You will send them your key; they confirm it before you see anything.</p>
|
|
119 |
+ |
<div class="form-group">
|
|
120 |
+ |
<input type="text" class="field" id="join-invite-token" placeholder="Invite code" autocomplete="off">
|
|
121 |
+ |
</div>
|
|
122 |
+ |
<div id="join-invite-preview"></div>
|
|
123 |
+ |
<div class="sync-section-actions">
|
|
124 |
+ |
<button class="button button--secondary" data-act="settings.previewInvite">Check code</button>
|
|
125 |
+ |
</div>
|
| 80 |
126 |
|
</div>
|
| 81 |
127 |
|
|
| 82 |
128 |
|
<div class="settings-section-block">
|
| 83 |
129 |
|
<h4 class="settings-subheading">Create a group</h4>
|
| 84 |
|
- |
<p class="settings-desc">You become the group admin and can add members and share projects into it.</p>
|
|
130 |
+ |
<p class="settings-desc">You become the group admin and can invite members and share projects into it.</p>
|
| 85 |
131 |
|
<div class="form-group">
|
| 86 |
132 |
|
<input type="text" class="field" id="new-group-name" placeholder="Group name" maxlength="100">
|
| 87 |
133 |
|
</div>
|
| 99 |
145 |
|
}
|
| 100 |
146 |
|
|
| 101 |
147 |
|
/**
|
| 102 |
|
- |
* One group card. When `members` is null the caller is not the group admin,
|
| 103 |
|
- |
* so only the name and a member badge render; otherwise the admin member
|
| 104 |
|
- |
* panel (list + add + remove) renders.
|
|
148 |
+ |
* One group card. When `members` is null the caller is not the group admin, so
|
|
149 |
+ |
* only the name and a member badge render; otherwise the admin panel (members,
|
|
150 |
+ |
* invitations, manual add) renders.
|
| 105 |
151 |
|
*/
|
| 106 |
|
- |
function renderGroupCard(group, members) {
|
|
152 |
+ |
function renderGroupCard(group, members, invitations) {
|
| 107 |
153 |
|
const gid = escAttr(group.id);
|
| 108 |
154 |
|
|
| 109 |
155 |
|
if (members === null) {
|
| 140 |
186 |
|
<div class="group-member-list">
|
| 141 |
187 |
|
${memberRows || '<p class="text-secondary">No members yet.</p>'}
|
| 142 |
188 |
|
</div>
|
| 143 |
|
- |
<div class="group-add-member">
|
|
189 |
+ |
|
|
190 |
+ |
<div class="group-invites">
|
|
191 |
+ |
<h5 class="group-subheading">Invites</h5>
|
|
192 |
+ |
<div class="sync-section-actions">
|
|
193 |
+ |
<button class="button button--primary" data-act="settings.createGroupInvite" data-a1="${gid}">Create invite code</button>
|
|
194 |
+ |
</div>
|
|
195 |
+ |
${renderInvitationList(group.id, invitations)}
|
|
196 |
+ |
</div>
|
|
197 |
+ |
|
|
198 |
+ |
<details class="settings-disclosure group-add-member">
|
|
199 |
+ |
<summary class="settings-disclosure-toggle">Add a member by hand</summary>
|
|
200 |
+ |
<p class="form-hint">Needs their account email and the public key they read off their own Sharing screen. An invite code avoids both.</p>
|
| 144 |
201 |
|
<div class="form-group">
|
| 145 |
202 |
|
<input type="text" class="field" id="add-member-email-${gid}" placeholder="Member email">
|
| 146 |
203 |
|
</div>
|
| 147 |
204 |
|
<div class="form-group">
|
| 148 |
|
- |
<textarea class="field" id="add-member-pubkey-${gid}" placeholder="Paste the member's public key"></textarea>
|
|
205 |
+ |
<textarea class="field" id="add-member-pubkey-${gid}" placeholder="Paste the member's public key"
|
|
206 |
+ |
data-change="settings.previewPastedKey" data-a1="${gid}"></textarea>
|
|
207 |
+ |
<div class="fingerprint-inline" id="add-member-fp-${gid}"></div>
|
| 149 |
208 |
|
</div>
|
| 150 |
209 |
|
<div class="sync-section-actions">
|
| 151 |
210 |
|
<button class="button button--secondary" data-act="settings.addGroupMember" data-a1="${gid}">Add Member</button>
|
| 152 |
211 |
|
</div>
|
| 153 |
|
- |
</div>
|
|
212 |
+ |
</details>
|
| 154 |
213 |
|
</div>
|
| 155 |
214 |
|
`;
|
| 156 |
215 |
|
}
|
| 157 |
216 |
|
|
|
217 |
+ |
/**
|
|
218 |
+ |
* The admin's invitation queue for one group.
|
|
219 |
+ |
*
|
|
220 |
+ |
* Accepted invitations sort to the top and carry the fingerprint plus the
|
|
221 |
+ |
* confirm action, because they are the only ones asking the admin for
|
|
222 |
+ |
* anything. Everything else is history and renders as a flat line.
|
|
223 |
+ |
*/
|
|
224 |
+ |
function renderInvitationList(groupId, invitations) {
|
|
225 |
+ |
if (!invitations || !invitations.length) {
|
|
226 |
+ |
return `<p class="text-secondary group-invite-empty">No invites outstanding.</p>`;
|
|
227 |
+ |
}
|
|
228 |
+ |
const gid = escAttr(groupId);
|
|
229 |
+ |
|
|
230 |
+ |
const awaiting = invitations.filter(i => i.state === 'accepted');
|
|
231 |
+ |
const others = invitations.filter(i => i.state !== 'accepted');
|
|
232 |
+ |
|
|
233 |
+ |
const awaitingRows = awaiting.map(i => `
|
|
234 |
+ |
<div class="group-invite-row group-invite-row--awaiting">
|
|
235 |
+ |
<div class="group-invite-body">
|
|
236 |
+ |
<div class="group-invite-who">${esc(i.inviteeEmail || 'Someone')} accepted</div>
|
|
237 |
+ |
<p class="group-invite-check">
|
|
238 |
+ |
Confirm this fingerprint with them over a call or a chat you trust,
|
|
239 |
+ |
then approve. Do not approve a fingerprint you have not heard from them.
|
|
240 |
+ |
</p>
|
|
241 |
+ |
<div class="fingerprint-display">${esc(i.inviteeFingerprint || 'Key could not be read')}</div>
|
|
242 |
+ |
</div>
|
|
243 |
+ |
<div class="group-invite-actions">
|
|
244 |
+ |
${i.inviteeFingerprint
|
|
245 |
+ |
? `<button class="button button--sm button--primary" data-act="settings.confirmInvitation" data-a1="${gid}" data-a2="${escAttr(i.id)}" data-a3="${escAttr(i.inviteeEmail || 'this person')}">Fingerprint matches, add them</button>`
|
|
246 |
+ |
: ''}
|
|
247 |
+ |
<button class="button button--sm button--danger" data-act="settings.revokeInvitation" data-a1="${gid}" data-a2="${escAttr(i.id)}">Discard</button>
|
|
248 |
+ |
</div>
|
|
249 |
+ |
</div>
|
|
250 |
+ |
`).join('');
|
|
251 |
+ |
|
|
252 |
+ |
const otherRows = others.map(i => {
|
|
253 |
+ |
const label = i.state === 'pending'
|
|
254 |
+ |
? `Outstanding, expires ${new Date(i.expiresAt).toLocaleDateString()}`
|
|
255 |
+ |
: `${i.state.charAt(0).toUpperCase()}${i.state.slice(1)}${i.inviteeEmail ? ' - ' + i.inviteeEmail : ''}`;
|
|
256 |
+ |
const revoke = i.state === 'pending'
|
|
257 |
+ |
? `<button class="button button--sm button--secondary" data-act="settings.revokeInvitation" data-a1="${gid}" data-a2="${escAttr(i.id)}">Revoke</button>`
|
|
258 |
+ |
: '';
|
|
259 |
+ |
return `
|
|
260 |
+ |
<div class="group-invite-row">
|
|
261 |
+ |
<span class="group-invite-state text-secondary">${esc(label)}</span>
|
|
262 |
+ |
${revoke}
|
|
263 |
+ |
</div>
|
|
264 |
+ |
`;
|
|
265 |
+ |
}).join('');
|
|
266 |
+ |
|
|
267 |
+ |
return `<div class="group-invite-list">${awaitingRows}${otherRows}</div>`;
|
|
268 |
+ |
}
|
|
269 |
+ |
|
| 158 |
270 |
|
/**
|
| 159 |
271 |
|
* Re-render the sharing section if the settings overlay is open. Called after
|
| 160 |
272 |
|
* every mutation so the panel reflects server state.
|
| 184 |
296 |
|
}
|
| 185 |
297 |
|
}
|
| 186 |
298 |
|
|
|
299 |
+ |
/** Copy helper shared by the key and fingerprint buttons. */
|
|
300 |
+ |
async function copyText(value, label, selectEl) {
|
|
301 |
+ |
try {
|
|
302 |
+ |
await navigator.clipboard.writeText(value);
|
|
303 |
+ |
GoingsOn.ui.showToast(`${label} copied`);
|
|
304 |
+ |
} catch (_) {
|
|
305 |
+ |
// Clipboard API unavailable; select the field so the user can copy it.
|
|
306 |
+ |
if (selectEl) {
|
|
307 |
+ |
selectEl.focus();
|
|
308 |
+ |
selectEl.select();
|
|
309 |
+ |
}
|
|
310 |
+ |
GoingsOn.ui.showToast('Press Ctrl/Cmd+C to copy', 'info');
|
|
311 |
+ |
}
|
|
312 |
+ |
}
|
|
313 |
+ |
|
| 187 |
314 |
|
async function copyMyGroupPubkey() {
|
| 188 |
315 |
|
const input = document.getElementById('my-group-pubkey');
|
| 189 |
316 |
|
if (!input) return;
|
| 190 |
|
- |
const value = input.value;
|
|
317 |
+ |
await copyText(input.value, 'Public key', input);
|
|
318 |
+ |
}
|
|
319 |
+ |
|
|
320 |
+ |
async function copyMyFingerprint() {
|
|
321 |
+ |
const el = document.getElementById('my-group-fingerprint');
|
|
322 |
+ |
if (!el) return;
|
|
323 |
+ |
await copyText(el.textContent.trim(), 'Fingerprint', null);
|
|
324 |
+ |
}
|
|
325 |
+ |
|
|
326 |
+ |
// Invites, admin side
|
|
327 |
+ |
|
|
328 |
+ |
/**
|
|
329 |
+ |
* Issue an invite code and show it once.
|
|
330 |
+ |
*
|
|
331 |
+ |
* Once is literal: the server keeps only a hash, so this modal is the only
|
|
332 |
+ |
* place the code ever exists. It says so, because an admin who closes it
|
|
333 |
+ |
* expecting to find the code in the list later would have to issue a second
|
|
334 |
+ |
* one and leave the first live.
|
|
335 |
+ |
*/
|
|
336 |
+ |
async function createGroupInvite(groupId) {
|
|
337 |
+ |
let invite;
|
| 191 |
338 |
|
try {
|
| 192 |
|
- |
await navigator.clipboard.writeText(value);
|
| 193 |
|
- |
GoingsOn.ui.showToast('Public key copied');
|
|
339 |
+ |
invite = await GoingsOn.api.groups.createInvite(groupId, null);
|
|
340 |
+ |
} catch (err) {
|
|
341 |
+ |
GoingsOn.ui.showToast('Failed to create invite: ' + GoingsOn.utils.getErrorMessage(err), 'error');
|
|
342 |
+ |
return;
|
|
343 |
+ |
}
|
|
344 |
+ |
|
|
345 |
+ |
const expires = new Date(invite.expiresAt).toLocaleString();
|
|
346 |
+ |
GoingsOn.ui.openModal('Invite code', `
|
|
347 |
+ |
<p class="settings-desc">Send this to the person you are inviting. It works once and expires ${esc(expires)}.</p>
|
|
348 |
+ |
<div class="form-group">
|
|
349 |
+ |
<input type="text" class="field" id="new-invite-token" value="${escAttr(invite.token)}" readonly>
|
|
350 |
+ |
</div>
|
|
351 |
+ |
<p class="form-hint">
|
|
352 |
+ |
This is the only time the code is shown. It does not let them in on its own:
|
|
353 |
+ |
when they redeem it you will be asked to confirm their key fingerprint.
|
|
354 |
+ |
</p>
|
|
355 |
+ |
<div class="sync-section-actions">
|
|
356 |
+ |
<button class="button button--primary" data-act="settings.copyInviteToken">Copy code</button>
|
|
357 |
+ |
</div>
|
|
358 |
+ |
`);
|
|
359 |
+ |
refreshSharingSection();
|
|
360 |
+ |
}
|
|
361 |
+ |
|
|
362 |
+ |
async function copyInviteToken() {
|
|
363 |
+ |
const input = document.getElementById('new-invite-token');
|
|
364 |
+ |
if (!input) return;
|
|
365 |
+ |
await copyText(input.value, 'Invite code', input);
|
|
366 |
+ |
}
|
|
367 |
+ |
|
|
368 |
+ |
/**
|
|
369 |
+ |
* Approve an accepted invitation. Confirms once more in words, because this
|
|
370 |
+ |
* is the click that hands over the group key and the fingerprint check it
|
|
371 |
+ |
* depends on happens outside the app where the UI cannot verify it happened.
|
|
372 |
+ |
*/
|
|
373 |
+ |
async function confirmInvitation(groupId, invitationId, who) {
|
|
374 |
+ |
const confirmed = await GoingsOn.ui.showConfirmDialog(
|
|
375 |
+ |
'Add this member',
|
|
376 |
+ |
`Have you checked that fingerprint with ${who} directly, somewhere other than this app? Approving seals the group key to it.`,
|
|
377 |
+ |
{ confirmText: 'Yes, add them' }
|
|
378 |
+ |
);
|
|
379 |
+ |
if (!confirmed) return;
|
|
380 |
+ |
try {
|
|
381 |
+ |
await GoingsOn.api.groups.confirmInvitation(groupId, invitationId);
|
|
382 |
+ |
GoingsOn.ui.showToast(`Added ${who}`);
|
|
383 |
+ |
refreshSharingSection();
|
|
384 |
+ |
} catch (err) {
|
|
385 |
+ |
GoingsOn.ui.showToast('Failed to add member: ' + GoingsOn.utils.getErrorMessage(err), 'error');
|
|
386 |
+ |
}
|
|
387 |
+ |
}
|
|
388 |
+ |
|
|
389 |
+ |
async function revokeInvitation(groupId, invitationId) {
|
|
390 |
+ |
try {
|
|
391 |
+ |
await GoingsOn.api.groups.revokeInvitation(groupId, invitationId);
|
|
392 |
+ |
GoingsOn.ui.showToast('Invite revoked');
|
|
393 |
+ |
refreshSharingSection();
|
|
394 |
+ |
} catch (err) {
|
|
395 |
+ |
GoingsOn.ui.showToast('Failed to revoke invite: ' + GoingsOn.utils.getErrorMessage(err), 'error');
|
|
396 |
+ |
}
|
|
397 |
+ |
}
|
|
398 |
+ |
|
|
399 |
+ |
// Invites, invitee side
|
|
400 |
+ |
|
|
401 |
+ |
/**
|
|
402 |
+ |
* Look up a pasted code and show what it leads to before committing.
|
|
403 |
+ |
*
|
|
404 |
+ |
* A preview step rather than a straight accept: the invitee gets to see which
|
|
405 |
+ |
* group and which admin, and can check the admin's fingerprint against what
|
|
406 |
+ |
* they were told, which is the other half of the mutual check.
|
|
407 |
+ |
*/
|
|
408 |
+ |
async function previewInvite() {
|
|
409 |
+ |
const token = document.getElementById('join-invite-token')?.value.trim();
|
|
410 |
+ |
const target = document.getElementById('join-invite-preview');
|
|
411 |
+ |
if (!target) return;
|
|
412 |
+ |
if (!token) {
|
|
413 |
+ |
GoingsOn.ui.showToast('Paste an invite code first', 'error');
|
|
414 |
+ |
return;
|
|
415 |
+ |
}
|
|
416 |
+ |
|
|
417 |
+ |
let preview;
|
|
418 |
+ |
try {
|
|
419 |
+ |
preview = await GoingsOn.api.groups.previewInvite(token);
|
|
420 |
+ |
} catch (err) {
|
|
421 |
+ |
target.innerHTML = `<p class="text-danger">${esc(GoingsOn.utils.getErrorMessage(err))}</p>`;
|
|
422 |
+ |
return;
|
|
423 |
+ |
}
|
|
424 |
+ |
|
|
425 |
+ |
if (!preview.redeemable) {
|
|
426 |
+ |
target.innerHTML = `<p class="text-danger">That code is ${esc(preview.state)}. Ask the admin for a new one.</p>`;
|
|
427 |
+ |
return;
|
|
428 |
+ |
}
|
|
429 |
+ |
|
|
430 |
+ |
target.innerHTML = `
|
|
431 |
+ |
<div class="invite-preview">
|
|
432 |
+ |
<p class="invite-preview-line">
|
|
433 |
+ |
<strong>${esc(preview.groupName)}</strong>, invited by ${esc(preview.inviterEmail)}
|
|
434 |
+ |
</p>
|
|
435 |
+ |
<p class="form-hint">
|
|
436 |
+ |
Accepting sends them your key. You will not see the group's data until they
|
|
437 |
+ |
confirm your fingerprint, so read it to them if they ask.
|
|
438 |
+ |
</p>
|
|
439 |
+ |
<div class="sync-section-actions">
|
|
440 |
+ |
<button class="button button--primary" data-act="settings.acceptInvite">Send my key</button>
|
|
441 |
+ |
</div>
|
|
442 |
+ |
</div>
|
|
443 |
+ |
`;
|
|
444 |
+ |
}
|
|
445 |
+ |
|
|
446 |
+ |
/**
|
|
447 |
+ |
* Accept: post this device's public key against the code.
|
|
448 |
+ |
*
|
|
449 |
+ |
* The toast deliberately does not say "joined". Nothing has been granted yet,
|
|
450 |
+ |
* and a message claiming otherwise would have the invitee waiting for data
|
|
451 |
+ |
* that cannot arrive until the admin acts.
|
|
452 |
+ |
*/
|
|
453 |
+ |
async function acceptInvite() {
|
|
454 |
+ |
const token = document.getElementById('join-invite-token')?.value.trim();
|
|
455 |
+ |
if (!token) return;
|
|
456 |
+ |
try {
|
|
457 |
+ |
await GoingsOn.api.groups.acceptInvite(token);
|
|
458 |
+ |
} catch (err) {
|
|
459 |
+ |
GoingsOn.ui.showToast('Could not accept: ' + GoingsOn.utils.getErrorMessage(err), 'error');
|
|
460 |
+ |
return;
|
|
461 |
+ |
}
|
|
462 |
+ |
GoingsOn.ui.showToast('Key sent. The admin confirms your fingerprint before you see the group.', 'info', { duration: 8000 });
|
|
463 |
+ |
refreshSharingSection();
|
|
464 |
+ |
}
|
|
465 |
+ |
|
|
466 |
+ |
// Manual add path
|
|
467 |
+ |
|
|
468 |
+ |
/**
|
|
469 |
+ |
* Render the fingerprint of a key as it is pasted, so the manual path offers
|
|
470 |
+ |
* the same comparable value the invite path does instead of a wall of base64.
|
|
471 |
+ |
*/
|
|
472 |
+ |
async function previewPastedKey(groupId) {
|
|
473 |
+ |
const field = document.getElementById(`add-member-pubkey-${groupId}`);
|
|
474 |
+ |
const out = document.getElementById(`add-member-fp-${groupId}`);
|
|
475 |
+ |
if (!field || !out) return;
|
|
476 |
+ |
const pubkey = field.value.trim();
|
|
477 |
+ |
if (!pubkey) {
|
|
478 |
+ |
out.textContent = '';
|
|
479 |
+ |
return;
|
|
480 |
+ |
}
|
|
481 |
+ |
try {
|
|
482 |
+ |
const fp = await GoingsOn.api.groups.fingerprintOf(pubkey);
|
|
483 |
+ |
out.innerHTML = `<span class="fingerprint-display">${esc(fp)}</span>
|
|
484 |
+ |
<span class="form-hint">Check this with them before adding.</span>`;
|
| 194 |
485 |
|
} catch (_) {
|
| 195 |
|
- |
// Clipboard API unavailable; select the field so the user can copy it.
|
| 196 |
|
- |
input.focus();
|
| 197 |
|
- |
input.select();
|
| 198 |
|
- |
GoingsOn.ui.showToast('Press Ctrl/Cmd+C to copy', 'info');
|
|
486 |
+ |
out.innerHTML = `<span class="text-danger">That does not look like a public key.</span>`;
|
| 199 |
487 |
|
}
|
| 200 |
488 |
|
}
|
| 201 |
489 |
|
|
| 237 |
525 |
|
renderSharingSection,
|
| 238 |
526 |
|
createGroup,
|
| 239 |
527 |
|
copyMyGroupPubkey,
|
|
528 |
+ |
copyMyFingerprint,
|
|
529 |
+ |
createGroupInvite,
|
|
530 |
+ |
copyInviteToken,
|
|
531 |
+ |
confirmInvitation,
|
|
532 |
+ |
revokeInvitation,
|
|
533 |
+ |
previewInvite,
|
|
534 |
+ |
acceptInvite,
|
|
535 |
+ |
previewPastedKey,
|
| 240 |
536 |
|
addGroupMember,
|
| 241 |
537 |
|
removeGroupMember,
|
| 242 |
538 |
|
});
|