Skip to main content

max / goingson

9.9 KB · 245 lines History Blame Raw
1 /**
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.
6 *
7 * Backend: SyncKit group commands (Groups p4 / GO -> SyncStore M4). Admin
8 * commands round-trip to the server via the SyncKit client; listing members is
9 * admin-only (the server gates it), so member panels render only for groups you
10 * administer; a listMembers failure is treated as "not the admin".
11 */
12
13 (function() {
14 'use strict';
15 const esc = GoingsOn.utils.escapeHtml;
16 const escAttr = GoingsOn.utils.escapeAttrValue;
17
18 /**
19 * 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).
21 * @param {HTMLElement} container
22 */
23 async function renderSharingSection(container) {
24 let groups;
25 try {
26 groups = await GoingsOn.api.groups.list();
27 } catch (err) {
28 // The commands require a configured sync client; guide the user there.
29 container.innerHTML = `
30 <div class="settings-section">
31 <h3 class="settings-heading">Sharing</h3>
32 <p class="text-secondary">Connect cloud sync to share projects with a group.</p>
33 <div class="sync-section-actions">
34 <button class="btn btn-secondary" data-act="settings.showSection" data-a1="sync">Go to Cloud Sync</button>
35 </div>
36 </div>
37 `;
38 return;
39 }
40
41 // Your public key is what an admin needs to add you. Non-fatal if
42 // encryption is not set up yet.
43 let pubkey = null;
44 try {
45 pubkey = await GoingsOn.api.groups.myPubkey();
46 } catch (_) {}
47
48 // Members are admin-only; fetch per group and swallow the not-admin error.
49 const cards = await Promise.all(groups.map(async (g) => {
50 let members = null;
51 try {
52 members = await GoingsOn.api.groups.listMembers(g.id);
53 } catch (_) {}
54 return renderGroupCard(g, members);
55 }));
56
57 const pubkeyBlock = pubkey
58 ? `
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="form-input" id="my-group-pubkey" value="${escAttr(pubkey)}" readonly>
62 </div>
63 <div class="sync-section-actions">
64 <button class="btn btn-secondary" data-act="settings.copyMyGroupPubkey">Copy public key</button>
65 </div>
66 `
67 : `<p class="text-secondary">Set up sync encryption first. Your sharing key is created with it.</p>`;
68
69 const groupsBlock = groups.length
70 ? cards.join('')
71 : `<p class="text-secondary">You are not in any groups yet. Create one to share a project.</p>`;
72
73 container.innerHTML = `
74 <div class="settings-section">
75 <h3 class="settings-heading">Sharing</h3>
76
77 <div class="settings-section-block">
78 <h4 class="settings-subheading">Your public key</h4>
79 ${pubkeyBlock}
80 </div>
81
82 <div class="settings-section-block">
83 <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>
85 <div class="form-group">
86 <input type="text" class="form-input" id="new-group-name" placeholder="Group name" maxlength="100">
87 </div>
88 <div class="sync-section-actions">
89 <button class="btn btn-primary" data-act="settings.createGroup">Create Group</button>
90 </div>
91 </div>
92
93 <div class="settings-section-block">
94 <h4 class="settings-subheading">Your groups</h4>
95 ${groupsBlock}
96 </div>
97 </div>
98 `;
99 }
100
101 /**
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.
105 */
106 function renderGroupCard(group, members) {
107 const gid = escAttr(group.id);
108
109 if (members === null) {
110 return `
111 <div class="group-card">
112 <div class="group-card-header">
113 <span class="group-card-name">${esc(group.name)}</span>
114 <span class="group-card-role text-secondary">Member</span>
115 </div>
116 </div>
117 `;
118 }
119
120 const memberRows = members.map((m) => {
121 const isAdmin = m.role === 'admin';
122 const removeBtn = isAdmin
123 ? ''
124 : `<button class="btn btn-small btn-danger" data-act="settings.removeGroupMember" data-a1="${gid}" data-a2="${escAttr(m.userId)}" data-a3="${escAttr(m.email)}">Remove</button>`;
125 return `
126 <div class="group-member-row">
127 <span class="group-member-email">${esc(m.email)}</span>
128 <span class="group-member-role text-secondary">${esc(m.role)}</span>
129 ${removeBtn}
130 </div>
131 `;
132 }).join('');
133
134 return `
135 <div class="group-card">
136 <div class="group-card-header">
137 <span class="group-card-name">${esc(group.name)}</span>
138 <span class="group-card-role text-secondary">Admin</span>
139 </div>
140 <div class="group-member-list">
141 ${memberRows || '<p class="text-secondary">No members yet.</p>'}
142 </div>
143 <div class="group-add-member">
144 <div class="form-group">
145 <input type="text" class="form-input" id="add-member-email-${gid}" placeholder="Member email">
146 </div>
147 <div class="form-group">
148 <textarea class="form-textarea" id="add-member-pubkey-${gid}" placeholder="Paste the member's public key"></textarea>
149 </div>
150 <div class="sync-section-actions">
151 <button class="btn btn-secondary" data-act="settings.addGroupMember" data-a1="${gid}">Add Member</button>
152 </div>
153 </div>
154 </div>
155 `;
156 }
157
158 /**
159 * Re-render the sharing section if the settings overlay is open. Called after
160 * every mutation so the panel reflects server state.
161 */
162 function refreshSharingSection() {
163 const overlay = document.getElementById('settings-overlay');
164 const container = document.getElementById('settings-content');
165 if (overlay && !overlay.classList.contains('hidden') && container) {
166 return renderSharingSection(container);
167 }
168 }
169
170 async function createGroup() {
171 const input = document.getElementById('new-group-name');
172 const name = input?.value.trim();
173 if (!name) {
174 GoingsOn.ui.showToast('Enter a group name', 'error');
175 return;
176 }
177 try {
178 await GoingsOn.api.groups.create(name);
179 GoingsOn.groups.invalidate(); // New group name must resolve in badges.
180 GoingsOn.ui.showToast(`Group "${name}" created`);
181 refreshSharingSection();
182 } catch (err) {
183 GoingsOn.ui.showToast('Failed to create group: ' + GoingsOn.utils.getErrorMessage(err), 'error');
184 }
185 }
186
187 async function copyMyGroupPubkey() {
188 const input = document.getElementById('my-group-pubkey');
189 if (!input) return;
190 const value = input.value;
191 try {
192 await navigator.clipboard.writeText(value);
193 GoingsOn.ui.showToast('Public key copied');
194 } 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');
199 }
200 }
201
202 async function addGroupMember(groupId) {
203 const email = document.getElementById(`add-member-email-${groupId}`)?.value.trim();
204 const pubkey = document.getElementById(`add-member-pubkey-${groupId}`)?.value.trim();
205 if (!email || !pubkey) {
206 GoingsOn.ui.showToast('Enter the member email and their public key', 'error');
207 return;
208 }
209 try {
210 await GoingsOn.api.groups.addMember(groupId, email, pubkey);
211 GoingsOn.ui.showToast(`Added ${email}`);
212 refreshSharingSection();
213 } catch (err) {
214 GoingsOn.ui.showToast('Failed to add member: ' + GoingsOn.utils.getErrorMessage(err), 'error');
215 }
216 }
217
218 async function removeGroupMember(groupId, userId, email) {
219 const confirmed = await GoingsOn.ui.showConfirmDialog(
220 'Remove member',
221 `Remove ${email} from this group? They keep whatever they already synced but see nothing new.`,
222 { confirmText: 'Remove', danger: true }
223 );
224 if (!confirmed) return;
225 try {
226 await GoingsOn.api.groups.removeMember(groupId, userId);
227 GoingsOn.ui.showToast(`Removed ${email}`);
228 refreshSharingSection();
229 } catch (err) {
230 GoingsOn.ui.showToast('Failed to remove member: ' + GoingsOn.utils.getErrorMessage(err), 'error');
231 }
232 }
233
234 // Populate GoingsOn Namespace
235
236 Object.assign(GoingsOn.settings, {
237 renderSharingSection,
238 createGroup,
239 copyMyGroupPubkey,
240 addGroupMember,
241 removeGroupMember,
242 });
243
244 })();
245