Skip to main content

max / goingson

Onboard a group member with an invite code and a fingerprint to check Adds the invite path to the sharing settings: issue a code, watch the queue, confirm or revoke, and on the other side preview a code before accepting it. Replaces the two-channel exchange of an email plus a pasted public key. Redeeming grants nothing. The invitee lands in the admin's confirmation queue and the admin approves a fingerprint, which is the only form of a key a person is asked to compare, so the raw key never reaches the frontend.
Author: Max Johnson <me@maxj.phd> · 2026-08-07 00:20 UTC
Signed with PGP, not checked
Commit: 56e70af331a3148f308a6e0cff2d3f4e44bd23aa
Parent: 366fe58
5 files changed, +684 insertions, -34 deletions
@@ -292,6 +292,14 @@
292 292 $crate::commands::my_group_pubkey,
293 293 $crate::commands::group_add_member,
294 294 $crate::commands::group_remove_member,
295 + $crate::commands::group_create_invite,
296 + $crate::commands::group_list_invitations,
297 + $crate::commands::group_confirm_invitation,
298 + $crate::commands::group_revoke_invitation,
299 + $crate::commands::group_preview_invite,
300 + $crate::commands::group_accept_invite,
301 + $crate::commands::my_group_fingerprint,
302 + $crate::commands::group_pubkey_fingerprint,
295 303 $crate::commands::share_project,
296 304 $crate::commands::unshare_project,
297 305 // Themes
@@ -2251,6 +2251,103 @@
2251 2251 padding-top: var(--gap-section);
2252 2252 }
2253 2253
2254 + /* Key fingerprints. Monospaced and spaced out because their entire job is being
2255 + read aloud and compared character by character; a proportional font makes that
2256 + measurably harder and is how a mismatch gets waved through. */
2257 + .fingerprint-display {
2258 + font-family: var(--font-mono);
2259 + font-size: var(--font-size-sm);
2260 + letter-spacing: 0.06em;
2261 + color: var(--content);
2262 + background: var(--surface-sunken);
2263 + border: var(--border-width) solid var(--border);
2264 + border-radius: var(--radius-sm);
2265 + padding: var(--gap-peer);
2266 + overflow-wrap: anywhere;
2267 + user-select: all;
2268 + }
2269 + .fingerprint-inline {
2270 + margin-top: var(--gap-peer);
2271 + display: flex;
2272 + flex-direction: column;
2273 + gap: var(--gap-peer);
2274 + font-size: var(--font-size-sm);
2275 + }
2276 +
2277 + /* The invite queue inside an admin's group card. */
2278 + .group-invites {
2279 + border-top: var(--border-width) solid var(--border);
2280 + padding-top: var(--gap-section);
2281 + margin-bottom: var(--gap-section);
2282 + }
2283 + .group-subheading {
2284 + font-size: var(--font-size-sm);
2285 + font-weight: 500;
2286 + color: var(--content);
2287 + margin: 0 0 var(--gap-peer) 0;
2288 + }
2289 + .group-invite-empty {
2290 + font-size: var(--font-size-sm);
2291 + }
2292 + .group-invite-list {
2293 + margin-top: var(--gap-section);
2294 + display: flex;
2295 + flex-direction: column;
2296 + gap: var(--gap-peer);
2297 + }
2298 + .group-invite-row {
2299 + display: flex;
2300 + align-items: center;
2301 + justify-content: space-between;
2302 + gap: var(--gap-peer);
2303 + font-size: var(--font-size-sm);
2304 + }
2305 + /* An accepted invite is the one state asking the admin to do something, so it
2306 + gets the accent edge and the vertical layout; the rest are history. */
2307 + .group-invite-row--awaiting {
2308 + flex-direction: column;
2309 + align-items: stretch;
2310 + border-left: 3px solid var(--action);
2311 + background: var(--surface-sunken);
2312 + border-radius: var(--radius-sm);
2313 + padding: var(--gap-group);
2314 + }
2315 + .group-invite-body {
2316 + display: flex;
2317 + flex-direction: column;
2318 + gap: var(--gap-peer);
2319 + }
2320 + .group-invite-who {
2321 + font-weight: 500;
2322 + color: var(--content);
2323 + }
2324 + .group-invite-check {
2325 + margin: 0;
2326 + color: var(--content-muted);
2327 + }
2328 + .group-invite-actions {
2329 + display: flex;
2330 + flex-wrap: wrap;
2331 + gap: var(--gap-peer);
2332 + margin-top: var(--gap-peer);
2333 + }
2334 + .group-invite-state {
2335 + flex: 1 1 auto;
2336 + overflow-wrap: anywhere;
2337 + }
2338 +
2339 + .invite-preview {
2340 + border: var(--border-width) solid var(--border);
2341 + border-radius: var(--radius-sm);
2342 + background: var(--surface-raised);
2343 + padding: var(--gap-group);
2344 + margin-bottom: var(--gap-peer);
2345 + }
2346 + .invite-preview-line {
2347 + margin: 0 0 var(--gap-peer) 0;
2348 + color: var(--content);
2349 + }
2350 +
2254 2351 .sync-banner-title {
2255 2352 font-weight: 500;
2256 2353 margin: 0 0 var(--gap-peer) 0;
@@ -86,6 +86,21 @@
86 86 myPubkey: () => invoke('my_group_pubkey'), // Base64 identity key to hand an admin
87 87 addMember: (groupId, email, pubkey) => invoke('group_add_member', { groupId, email, pubkey }),
88 88 removeMember: (groupId, userId) => invoke('group_remove_member', { groupId, userId }),
89 +
90 + // Fingerprints. The short digest is the only form of a key a human is
91 + // meant to compare; the raw base64 exists for machines.
92 + myFingerprint: () => invoke('my_group_fingerprint'),
93 + fingerprintOf: (pubkey) => invoke('group_pubkey_fingerprint', { pubkey }),
94 +
95 + // Invitations. An invite replaces the email-plus-pasted-key exchange with
96 + // one code, and stops short of granting anything: redeeming lands the
97 + // invitee in the admin's confirmation queue.
98 + createInvite: (groupId, expiresInHours) => invoke('group_create_invite', { groupId, expiresInHours }),
99 + listInvitations: (groupId) => invoke('group_list_invitations', { groupId }),
100 + confirmInvitation: (groupId, invitationId) => invoke('group_confirm_invitation', { groupId, invitationId }),
101 + revokeInvitation: (groupId, invitationId) => invoke('group_revoke_invitation', { groupId, invitationId }),
102 + previewInvite: (token) => invoke('group_preview_invite', { token }),
103 + acceptInvite: (token) => invoke('group_accept_invite', { token }),
89 104 },
90 105
91 106 // Problems: the triage inbox. Read + triage only, because problems arrive from
@@ -1,13 +1,28 @@
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,7 +32,8 @@
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,50 +54,80 @@
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,11 +145,11 @@
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,21 +186,87 @@
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,18 +296,194 @@
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,6 +525,14 @@
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 });
@@ -12,7 +12,7 @@
12 12
13 13 use serde::Serialize;
14 14 use sqlx::SqlitePool;
15 - use synckit_client::{GroupId, SyncKitClient, UserId};
15 + use synckit_client::{GroupId, InvitationId, SyncKitClient, UserId, identity::IdentityPublicKey};
16 16 use tauri::State;
17 17 use tracing::instrument;
18 18 use uuid::Uuid;
@@ -163,6 +163,240 @@
163 163 Ok(true)
164 164 }
165 165
166 + // Invitations
167 +
168 + /// A freshly issued invite, for the admin to hand to the person joining.
169 + #[derive(Debug, Serialize)]
170 + #[serde(rename_all = "camelCase")]
171 + pub struct InviteDto {
172 + pub id: String,
173 + /// The one-use code. Returned once and never again: the server keeps only a
174 + /// hash of it.
175 + pub token: String,
176 + pub expires_at: String,
177 + }
178 +
179 + /// One invitation in the admin's queue.
180 + #[derive(Debug, Serialize)]
181 + #[serde(rename_all = "camelCase")]
182 + pub struct InvitationDto {
183 + pub id: String,
184 + /// `pending` | `accepted` | `redeemed` | `revoked` | `expired`.
185 + pub state: String,
186 + pub invitee_email: Option<String>,
187 + /// The invitee's key fingerprint, already rendered. The raw key is
188 + /// deliberately not sent to the frontend: the fingerprint is the only form a
189 + /// human is meant to compare, and shipping both invites showing the wrong one.
190 + pub invitee_fingerprint: Option<String>,
191 + pub expires_at: String,
192 + pub created_at: String,
193 + }
194 +
195 + /// What an invite code leads to, shown before accepting.
196 + #[derive(Debug, Serialize)]
197 + #[serde(rename_all = "camelCase")]
198 + pub struct InvitePreviewDto {
199 + pub group_name: String,
200 + pub inviter_email: String,
201 + pub redeemable: bool,
202 + pub state: String,
203 + pub expires_at: String,
204 + }
205 +
206 + /// `state` as the lowercase word the frontend switches on. `InvitationState` is
207 + /// `#[non_exhaustive]`, so this maps rather than deriving, and an unrecognised
208 + /// variant from a newer server reads as "unknown" instead of failing the list.
209 + fn invitation_state_str(state: synckit_client::InvitationState) -> &'static str {
210 + use synckit_client::InvitationState as S;
211 + match state {
212 + S::Pending => "pending",
213 + S::Accepted => "accepted",
214 + S::Redeemed => "redeemed",
215 + S::Revoked => "revoked",
216 + S::Expired => "expired",
217 + _ => "unknown",
218 + }
219 + }
220 +
221 + /// Issue an invite code for a group. Admin only.
222 + ///
223 + /// Replaces the two-channel exchange (their email, plus their pasted public key)
224 + /// with one code. It is not a bearer credential for membership: whoever redeems
225 + /// it lands in the admin's confirmation queue and gets nothing until the admin
226 + /// approves their key fingerprint.
227 + #[tauri::command]
228 + #[instrument(skip_all)]
229 + pub async fn group_create_invite(
230 + state: State<'_, Arc<AppState>>,
231 + group_id: String,
232 + expires_in_hours: Option<i64>,
233 + ) -> Result<InviteDto, ApiError> {
234 + let client = require_client(&state)?;
235 + let group = GroupId::new(parse_uuid("group id", &group_id)?);
236 + let invite = client
237 + .create_invitation(group, expires_in_hours)
238 + .await
239 + .map_api_err("Failed to create invite", ApiError::external_service)?;
240 + Ok(InviteDto {
241 + id: invite.id.to_string(),
242 + token: invite.token,
243 + expires_at: invite.expires_at.to_rfc3339(),
244 + })
245 + }
246 +
247 + /// List a group's invitations, newest first. Admin only.
248 + #[tauri::command]
249 + #[instrument(skip_all)]
250 + pub async fn group_list_invitations(
251 + state: State<'_, Arc<AppState>>,
252 + group_id: String,
253 + ) -> Result<Vec<InvitationDto>, ApiError> {
254 + let client = require_client(&state)?;
255 + let group = GroupId::new(parse_uuid("group id", &group_id)?);
256 + let invitations = client
257 + .list_invitations(group)
258 + .await
259 + .map_api_err("Failed to list invitations", ApiError::external_service)?;
260 +
261 + Ok(invitations
262 + .into_iter()
263 + .map(|i| InvitationDto {
264 + id: i.id.to_string(),
265 + state: invitation_state_str(i.state).to_string(),
266 + invitee_email: i.invitee_email,
267 + // A key that will not parse yields no fingerprint rather than a
268 + // placeholder: the admin must never be shown something
269 + // confirmable-looking that was not derived from a real key.
270 + invitee_fingerprint: i
271 + .invitee_pubkey
272 + .as_deref()
273 + .and_then(|k| IdentityPublicKey::fingerprint_of_base64(k).ok()),
274 + expires_at: i.expires_at.to_rfc3339(),
275 + created_at: i.created_at.to_rfc3339(),
276 + })
277 + .collect())
278 + }
279 +
280 + /// Confirm an accepted invitation, sealing the group key to the invitee. Admin
281 + /// only.
282 + ///
283 + /// The admin must have compared the fingerprint this panel shows against what the
284 + /// invitee reads out over some other channel first. That comparison is the whole
285 + /// security of the flow, not a formality.
286 + #[tauri::command]
287 + #[instrument(skip_all)]
288 + pub async fn group_confirm_invitation(
289 + state: State<'_, Arc<AppState>>,
290 + group_id: String,
291 + invitation_id: String,
292 + ) -> Result<bool, ApiError> {
293 + let client = require_client(&state)?;
294 + let group = GroupId::new(parse_uuid("group id", &group_id)?);
295 + let invitation = InvitationId::new(parse_uuid("invitation id", &invitation_id)?);
296 + client
297 + .confirm_invitation(group, invitation, None)
298 + .await
299 + .map_api_err("Failed to confirm invitation", ApiError::external_service)?;
300 + Ok(true)
301 + }
302 +
303 + /// Cancel an invitation, accepted or not. Admin only.
304 + #[tauri::command]
305 + #[instrument(skip_all)]
306 + pub async fn group_revoke_invitation(
307 + state: State<'_, Arc<AppState>>,
308 + group_id: String,
309 + invitation_id: String,
310 + ) -> Result<bool, ApiError> {
311 + let client = require_client(&state)?;
312 + let group = GroupId::new(parse_uuid("group id", &group_id)?);
313 + let invitation = InvitationId::new(parse_uuid("invitation id", &invitation_id)?);
314 + client
315 + .revoke_invitation(group, invitation)
316 + .await
317 + .map_api_err("Failed to revoke invitation", ApiError::external_service)?;
318 + Ok(true)
319 + }
320 +
321 + /// Show what an invite code leads to, without accepting it.
322 + #[tauri::command]
323 + #[instrument(skip_all)]
324 + pub async fn group_preview_invite(
325 + state: State<'_, Arc<AppState>>,
326 + token: String,
327 + ) -> Result<InvitePreviewDto, ApiError> {
328 + let client = require_client(&state)?;
329 + let preview = client
330 + .preview_invitation(&normalize_invite_token(&token))
331 + .await
332 + .map_api_err("Could not read that invite", ApiError::external_service)?;
333 + Ok(InvitePreviewDto {
334 + group_name: preview.group_name,
335 + inviter_email: preview.inviter_email,
336 + redeemable: preview.redeemable,
337 + state: invitation_state_str(preview.state).to_string(),
338 + expires_at: preview.expires_at.to_rfc3339(),
339 + })
340 + }
341 +
342 + /// Accept an invite by posting this device's identity public key against it.
343 + ///
344 + /// Returns having sent a key, not having joined. Group data appears only after
345 + /// the admin confirms the fingerprint, and the caller should say so.
346 + #[tauri::command]
347 + #[instrument(skip_all)]
348 + pub async fn group_accept_invite(
349 + state: State<'_, Arc<AppState>>,
350 + token: String,
351 + ) -> Result<bool, ApiError> {
352 + let client = require_client(&state)?;
353 + client
354 + .accept_invitation(&normalize_invite_token(&token))
355 + .await
356 + .map_api_err("Failed to accept invite", ApiError::external_service)?;
357 + Ok(true)
358 + }
359 +
360 + /// Pull the token out of whatever the user pasted.
361 + ///
362 + /// Today an invite travels as a bare code. Accepting a URL form too costs one
363 + /// line and means the codes issued now keep working if a landing page or a
364 + /// deep link is added later, without a second format to support.
365 + fn normalize_invite_token(input: &str) -> String {
366 + let trimmed = input.trim();
367 + let after_scheme = trimmed
368 + .rsplit_once("/invite/")
369 + .map_or(trimmed, |(_, tail)| tail);
370 + after_scheme
371 + .split(['?', '#'])
372 + .next()
373 + .unwrap_or(after_scheme)
374 + .trim()
375 + .to_string()
376 + }
377 +
378 + /// This user's own key fingerprint: what they read out to a group admin so the
379 + /// admin can confirm the key that arrived is the key they hold.
380 + #[tauri::command]
381 + #[instrument(skip_all)]
382 + pub async fn my_group_fingerprint(state: State<'_, Arc<AppState>>) -> Result<String, ApiError> {
383 + let client = require_client(&state)?;
384 + let pubkey = client
385 + .my_identity_public_key()
386 + .map_api_err("Encryption not set up", ApiError::bad_request)?;
387 + IdentityPublicKey::fingerprint_of_base64(&pubkey)
388 + .map_api_err("Could not read your identity key", ApiError::internal)
389 + }
390 +
391 + /// The fingerprint of a pasted public key, so the manual add-member path can
392 + /// show the same comparable value the invite path does.
393 + #[tauri::command]
394 + #[instrument(skip_all)]
395 + pub async fn group_pubkey_fingerprint(pubkey: String) -> Result<String, ApiError> {
396 + IdentityPublicKey::fingerprint_of_base64(pubkey.trim())
397 + .map_err(|_| ApiError::bad_request("That does not look like a public key"))
398 + }
399 +
166 400 // Project scope write path
167 401
168 402 /// Share a whole project into a group: stamp `group_id` across its entire subtree.