clay-server 2.8.2 → 2.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,631 @@
1
+ // admin.js — Admin management for multi-user mode (renders into server settings sections)
2
+ import { iconHtml, refreshIcons } from './icons.js';
3
+ import { showToast, copyToClipboard, escapeHtml } from './utils.js';
4
+
5
+ function showConfirmDialog(message, onConfirm) {
6
+ var modal = document.createElement("div");
7
+ modal.className = "admin-modal-overlay";
8
+ var html = '<div class="admin-modal">' +
9
+ '<div class="admin-modal-body" style="padding:20px 16px 16px">' +
10
+ '<p class="admin-modal-desc" style="margin:0;font-size:14px;color:var(--text)">' + escapeHtml(message) + '</p>' +
11
+ '</div>' +
12
+ '<div class="admin-modal-footer">' +
13
+ '<button class="admin-modal-save admin-modal-confirm-danger">Revoke</button>' +
14
+ '<button class="admin-modal-cancel">Cancel</button>' +
15
+ '</div></div>';
16
+ modal.innerHTML = html;
17
+ document.body.appendChild(modal);
18
+ refreshIcons(modal);
19
+ modal.querySelector(".admin-modal-cancel").addEventListener("click", function () { modal.remove(); });
20
+ modal.addEventListener("click", function (e) { if (e.target === modal) modal.remove(); });
21
+ modal.querySelector(".admin-modal-confirm-danger").addEventListener("click", function () {
22
+ modal.remove();
23
+ onConfirm();
24
+ });
25
+ }
26
+
27
+ var ctx = null;
28
+ var cachedUsers = [];
29
+ var cachedInvites = [];
30
+ var cachedProjects = [];
31
+ var meInfo = null;
32
+
33
+ // --- API helpers ---
34
+ function apiGet(url) {
35
+ return fetch(url).then(function (r) { return r.json(); });
36
+ }
37
+
38
+ function apiPost(url, body) {
39
+ return fetch(url, {
40
+ method: "POST",
41
+ headers: { "Content-Type": "application/json" },
42
+ body: JSON.stringify(body || {}),
43
+ }).then(function (r) { return r.json(); });
44
+ }
45
+
46
+ function apiPut(url, body) {
47
+ return fetch(url, {
48
+ method: "PUT",
49
+ headers: { "Content-Type": "application/json" },
50
+ body: JSON.stringify(body),
51
+ }).then(function (r) { return r.json(); });
52
+ }
53
+
54
+ function apiDelete(url) {
55
+ return fetch(url, { method: "DELETE" }).then(function (r) { return r.json(); });
56
+ }
57
+
58
+ // --- Init ---
59
+ export function initAdmin(appCtx) {
60
+ ctx = appCtx;
61
+ }
62
+
63
+ // Check if user is admin and multi-user mode is active
64
+ export function checkAdminAccess() {
65
+ return apiGet("/api/me").then(function (data) {
66
+ meInfo = data;
67
+ return data.multiUser && data.user && data.user.role === "admin";
68
+ }).catch(function () { return false; });
69
+ }
70
+
71
+ // --- Load admin section into a given body element ---
72
+ export function loadAdminSection(section, body) {
73
+ body.innerHTML = '<div class="admin-loading">Loading...</div>';
74
+ if (section === "admin-users") {
75
+ loadUsersTab(body);
76
+ } else if (section === "admin-invites") {
77
+ loadInvitesTab(body);
78
+ } else if (section === "admin-projects") {
79
+ loadProjectsTab(body);
80
+ } else if (section === "admin-smtp") {
81
+ loadSmtpTab(body);
82
+ }
83
+ }
84
+
85
+ // --- Users ---
86
+ function loadUsersTab(body) {
87
+ apiGet("/api/admin/users").then(function (data) {
88
+ cachedUsers = data.users || [];
89
+ renderUsersTab(body);
90
+ }).catch(function () {
91
+ body.innerHTML = '<div class="admin-error">Failed to load users</div>';
92
+ });
93
+ }
94
+
95
+ function renderUsersTab(body) {
96
+ var html = '<div class="admin-user-list">';
97
+ for (var i = 0; i < cachedUsers.length; i++) {
98
+ var u = cachedUsers[i];
99
+ var isMe = meInfo && meInfo.user && meInfo.user.id === u.id;
100
+ var created = new Date(u.createdAt).toLocaleDateString();
101
+ html += '<div class="admin-user-item">';
102
+ html += '<div class="admin-user-info">';
103
+ html += '<div class="admin-user-name">';
104
+ html += '<strong>' + escapeHtml(u.displayName || u.username) + '</strong>';
105
+ if (u.role === "admin") html += ' <span class="admin-badge">admin</span>';
106
+ if (isMe) html += ' <span class="admin-you-badge">you</span>';
107
+ html += '</div>';
108
+ html += '<div class="admin-user-meta">' + escapeHtml(u.username) + ' · joined ' + created + '</div>';
109
+ html += '</div>';
110
+ if (!isMe && u.role !== "admin") {
111
+ html += '<button class="admin-remove-btn" data-user-id="' + u.id + '" title="Remove user">' + iconHtml("trash-2") + '</button>';
112
+ }
113
+ html += '</div>';
114
+ }
115
+ html += '</div>';
116
+
117
+ body.innerHTML = html;
118
+ refreshIcons(body);
119
+
120
+ // Bind remove buttons
121
+ var removeBtns = body.querySelectorAll(".admin-remove-btn");
122
+ for (var j = 0; j < removeBtns.length; j++) {
123
+ removeBtns[j].addEventListener("click", function () {
124
+ var userId = this.dataset.userId;
125
+ var user = cachedUsers.find(function (u) { return u.id === userId; });
126
+ var name = user ? (user.displayName || user.username) : "this user";
127
+ if (confirm("Remove " + name + "? This cannot be undone.")) {
128
+ removeUser(userId, body);
129
+ }
130
+ });
131
+ }
132
+ }
133
+
134
+ function removeUser(userId, body) {
135
+ apiDelete("/api/admin/users/" + userId).then(function (data) {
136
+ if (data.ok) {
137
+ showToast("User removed");
138
+ loadUsersTab(body);
139
+ } else {
140
+ showToast(data.error || "Failed to remove user");
141
+ }
142
+ }).catch(function () {
143
+ showToast("Failed to remove user");
144
+ });
145
+ }
146
+
147
+ // --- Invites ---
148
+ function loadInvitesTab(body) {
149
+ apiGet("/api/admin/invites").then(function (data) {
150
+ cachedInvites = (data.invites || []).filter(function (inv) {
151
+ return !inv.used && inv.expiresAt > Date.now();
152
+ });
153
+ renderInvitesTab(body);
154
+ }).catch(function () {
155
+ body.innerHTML = '<div class="admin-error">Failed to load invites</div>';
156
+ });
157
+ }
158
+
159
+ function renderInvitesTab(body) {
160
+ var smtpEnabled = meInfo && meInfo.smtpEnabled;
161
+ var html = '<div class="admin-section-header">' +
162
+ '<div class="admin-header-btns">';
163
+ if (smtpEnabled) {
164
+ html += '<button class="admin-action-btn" id="admin-email-invite">' + iconHtml("mail") + ' Email Invite</button>';
165
+ }
166
+ html += '<button class="admin-action-btn admin-action-btn-secondary" id="admin-create-invite">' + iconHtml("plus") + ' Generate Link</button>' +
167
+ '</div></div>';
168
+
169
+ if (cachedInvites.length === 0) {
170
+ html += '<div class="admin-empty">No active invites. Generate one to add a new user.</div>';
171
+ } else {
172
+ html += '<div class="admin-invite-list">';
173
+ for (var i = 0; i < cachedInvites.length; i++) {
174
+ var inv = cachedInvites[i];
175
+ var expiresIn = Math.max(0, Math.ceil((inv.expiresAt - Date.now()) / (60 * 60 * 1000)));
176
+ html += '<div class="admin-invite-item">';
177
+ html += '<div class="admin-invite-info">';
178
+ html += '<code class="admin-invite-code">' + escapeHtml(inv.code.substring(0, 8)) + '...</code>';
179
+ if (inv.email) html += '<span class="admin-invite-email">' + escapeHtml(inv.email) + '</span>';
180
+ html += '<span class="admin-invite-expiry">expires in ' + expiresIn + 'h</span>';
181
+ html += '</div>';
182
+ html += '<div class="admin-invite-actions">';
183
+ html += '<button class="admin-copy-link-btn" data-code="' + escapeHtml(inv.code) + '" title="Copy link">' + iconHtml("copy") + '</button>';
184
+ html += '<button class="admin-revoke-btn" data-code="' + escapeHtml(inv.code) + '" title="Revoke">' + iconHtml("x") + '</button>';
185
+ html += '</div>';
186
+ html += '</div>';
187
+ }
188
+ html += '</div>';
189
+ }
190
+
191
+ body.innerHTML = html;
192
+ refreshIcons(body);
193
+
194
+ // Generate invite link
195
+ var createBtn = body.querySelector("#admin-create-invite");
196
+ if (createBtn) {
197
+ createBtn.addEventListener("click", function () {
198
+ createInvite(body);
199
+ });
200
+ }
201
+
202
+ // Email invite
203
+ var emailBtn = body.querySelector("#admin-email-invite");
204
+ if (emailBtn) {
205
+ emailBtn.addEventListener("click", function () {
206
+ showEmailInvitePrompt(body);
207
+ });
208
+ }
209
+
210
+ // Copy link buttons
211
+ var copyBtns = body.querySelectorAll(".admin-copy-link-btn");
212
+ for (var j = 0; j < copyBtns.length; j++) {
213
+ copyBtns[j].addEventListener("click", function () {
214
+ var code = this.dataset.code;
215
+ var url = location.origin + "/invite/" + code;
216
+ copyToClipboard(url).then(function () {
217
+ showToast("Invite link copied");
218
+ });
219
+ });
220
+ }
221
+
222
+ // Revoke buttons
223
+ var revokeBtns = body.querySelectorAll(".admin-revoke-btn");
224
+ for (var k = 0; k < revokeBtns.length; k++) {
225
+ revokeBtns[k].addEventListener("click", function () {
226
+ var code = this.dataset.code;
227
+ var btn = this;
228
+ showConfirmDialog("Revoke this invite? The link will no longer work.", function () {
229
+ btn.disabled = true;
230
+ fetch("/api/admin/invites/" + encodeURIComponent(code), { method: "DELETE" })
231
+ .then(function (r) { return r.json(); })
232
+ .then(function (d) {
233
+ if (d.ok) {
234
+ showToast("Invite revoked");
235
+ loadInvitesTab(body);
236
+ } else {
237
+ showToast(d.error || "Failed to revoke");
238
+ btn.disabled = false;
239
+ }
240
+ })
241
+ .catch(function () {
242
+ showToast("Failed to revoke invite");
243
+ btn.disabled = false;
244
+ });
245
+ });
246
+ });
247
+ }
248
+ }
249
+
250
+ function createInvite(body) {
251
+ apiPost("/api/admin/invites").then(function (data) {
252
+ if (data.ok && data.url) {
253
+ copyToClipboard(data.url).then(function () {
254
+ showToast("Invite link created and copied!");
255
+ }).catch(function () {
256
+ showToast("Invite created: " + data.url);
257
+ });
258
+ loadInvitesTab(body);
259
+ } else {
260
+ showToast(data.error || "Failed to create invite");
261
+ }
262
+ }).catch(function () {
263
+ showToast("Failed to create invite");
264
+ });
265
+ }
266
+
267
+ function showEmailInvitePrompt(body) {
268
+ var modal = document.createElement("div");
269
+ modal.className = "admin-modal-overlay";
270
+ var html = '<div class="admin-modal">' +
271
+ '<div class="admin-modal-header">' +
272
+ '<h3>Send Email Invite</h3>' +
273
+ '<button class="admin-modal-close">' + iconHtml("x") + '</button>' +
274
+ '</div>' +
275
+ '<div class="admin-modal-body">' +
276
+ '<p class="admin-modal-desc">Enter the email address to send an invitation to:</p>' +
277
+ '<input type="email" class="admin-smtp-input" id="admin-invite-email" placeholder="user@example.com" autocomplete="off">' +
278
+ '<div class="admin-smtp-error" id="admin-invite-error"></div>' +
279
+ '</div>' +
280
+ '<div class="admin-modal-footer">' +
281
+ '<button class="admin-modal-save" id="admin-invite-send">Send Invite</button>' +
282
+ '<button class="admin-modal-cancel">Cancel</button>' +
283
+ '</div></div>';
284
+ modal.innerHTML = html;
285
+ document.body.appendChild(modal);
286
+ refreshIcons(modal);
287
+
288
+ var emailInput = modal.querySelector("#admin-invite-email");
289
+ var sendBtn = modal.querySelector("#admin-invite-send");
290
+ var errorEl = modal.querySelector("#admin-invite-error");
291
+
292
+ modal.querySelector(".admin-modal-close").addEventListener("click", function () { modal.remove(); });
293
+ modal.querySelector(".admin-modal-cancel").addEventListener("click", function () { modal.remove(); });
294
+ modal.addEventListener("click", function (e) { if (e.target === modal) modal.remove(); });
295
+
296
+ emailInput.focus();
297
+ emailInput.addEventListener("keydown", function (e) {
298
+ if (e.key === "Enter") sendBtn.click();
299
+ });
300
+
301
+ sendBtn.addEventListener("click", function () {
302
+ var email = emailInput.value.trim();
303
+ if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
304
+ errorEl.textContent = "Enter a valid email address";
305
+ return;
306
+ }
307
+ sendBtn.disabled = true;
308
+ sendBtn.textContent = "Sending...";
309
+ errorEl.textContent = "";
310
+ apiPost("/api/admin/invites/email", { email: email }).then(function (data) {
311
+ if (data.ok) {
312
+ showToast("Invite sent to " + email);
313
+ modal.remove();
314
+ loadInvitesTab(body);
315
+ } else {
316
+ errorEl.textContent = data.error || "Failed to send invite";
317
+ sendBtn.disabled = false;
318
+ sendBtn.textContent = "Send Invite";
319
+ }
320
+ }).catch(function () {
321
+ errorEl.textContent = "Failed to send invite";
322
+ sendBtn.disabled = false;
323
+ sendBtn.textContent = "Send Invite";
324
+ });
325
+ });
326
+ }
327
+
328
+ // --- SMTP Configuration ---
329
+ function loadSmtpTab(body) {
330
+ apiGet("/api/admin/smtp").then(function (data) {
331
+ renderSmtpTab(body, data.smtp);
332
+ }).catch(function () {
333
+ body.innerHTML = '<div class="admin-error">Failed to load SMTP settings</div>';
334
+ });
335
+ }
336
+
337
+ function renderSmtpTab(body, cfg) {
338
+ var hasConfig = !!(cfg && cfg.host);
339
+ var html = '<div class="admin-smtp-form">';
340
+
341
+ var emailEnabled = !!(cfg && cfg.emailLoginEnabled);
342
+ if (hasConfig) {
343
+ html += '<div class="admin-smtp-status admin-smtp-status-ok">' + iconHtml("check-circle") + ' SMTP configured</div>';
344
+ } else {
345
+ html += '<div class="admin-smtp-status admin-smtp-status-off">' + iconHtml("mail-x") + ' SMTP not configured — using PIN-based login</div>';
346
+ }
347
+
348
+ html += '<div class="admin-smtp-fields">';
349
+ html += '<div class="admin-smtp-row">' +
350
+ '<label>SMTP Host</label>' +
351
+ '<input type="text" id="smtp-host" class="admin-smtp-input" placeholder="smtp.gmail.com" value="' + escapeHtml((cfg && cfg.host) || "") + '">' +
352
+ '</div>';
353
+ html += '<div class="admin-smtp-row-half">' +
354
+ '<div class="admin-smtp-row">' +
355
+ '<label>Port</label>' +
356
+ '<input type="number" id="smtp-port" class="admin-smtp-input" placeholder="587" value="' + ((cfg && cfg.port) || 587) + '">' +
357
+ '</div>' +
358
+ '<div class="admin-smtp-row">' +
359
+ '<label>Secure (TLS)</label>' +
360
+ '<label class="admin-smtp-toggle"><input type="checkbox" id="smtp-secure"' + (cfg && cfg.secure ? " checked" : "") + '><span>Use TLS/SSL</span></label>' +
361
+ '</div></div>';
362
+ html += '<div class="admin-smtp-row">' +
363
+ '<label>Username</label>' +
364
+ '<input type="text" id="smtp-user" class="admin-smtp-input" placeholder="you@gmail.com" value="' + escapeHtml((cfg && cfg.user) || "") + '" autocomplete="off">' +
365
+ '</div>';
366
+ html += '<div class="admin-smtp-row">' +
367
+ '<label>Password</label>' +
368
+ '<input type="password" id="smtp-pass" class="admin-smtp-input" placeholder="App password" value="' + escapeHtml((cfg && cfg.pass) || "") + '" autocomplete="off">' +
369
+ '</div>';
370
+ html += '<div class="admin-smtp-row">' +
371
+ '<label>From Address</label>' +
372
+ '<input type="text" id="smtp-from" class="admin-smtp-input" placeholder="Clay <noreply@example.com>" value="' + escapeHtml((cfg && cfg.from) || "") + '">' +
373
+ '</div>';
374
+ html += '</div>';
375
+
376
+ html += '<div class="admin-smtp-row" style="margin-top:16px;padding-top:16px;border-top:1px solid var(--border-color,#e5e7eb)">' +
377
+ '<label>Email Login (OTP)</label>' +
378
+ '<label class="admin-smtp-toggle"><input type="checkbox" id="smtp-email-login"' + (emailEnabled ? " checked" : "") + (hasConfig ? "" : " disabled") + '>' +
379
+ '<span>' + (hasConfig ? "Require email for user registration and enable OTP login" : "Configure SMTP first to enable") + '</span></label>' +
380
+ '</div>';
381
+
382
+ html += '<div class="admin-smtp-actions">';
383
+ html += '<button class="admin-action-btn" id="smtp-save">' + iconHtml("save") + ' Save</button>';
384
+ html += '<button class="admin-action-btn admin-action-btn-secondary" id="smtp-test">' + iconHtml("send") + ' Test Connection</button>';
385
+ if (hasConfig) {
386
+ html += '<button class="admin-action-btn admin-action-btn-danger" id="smtp-remove">' + iconHtml("trash-2") + ' Remove</button>';
387
+ }
388
+ html += '</div>';
389
+ html += '<div class="admin-smtp-error" id="smtp-error"></div>';
390
+ html += '<div class="admin-smtp-hint">For Gmail, use an <a href="https://support.google.com/accounts/answer/185833" target="_blank" rel="noopener">App Password</a>. Port 587 with TLS off uses STARTTLS. Port 465 with TLS on uses direct SSL.</div>';
391
+ html += '</div>';
392
+
393
+ body.innerHTML = html;
394
+ refreshIcons(body);
395
+
396
+ var errorEl = body.querySelector("#smtp-error");
397
+
398
+ function getFormData() {
399
+ return {
400
+ host: body.querySelector("#smtp-host").value.trim(),
401
+ port: parseInt(body.querySelector("#smtp-port").value, 10) || 587,
402
+ secure: body.querySelector("#smtp-secure").checked,
403
+ user: body.querySelector("#smtp-user").value.trim(),
404
+ pass: body.querySelector("#smtp-pass").value,
405
+ from: body.querySelector("#smtp-from").value.trim(),
406
+ emailLoginEnabled: body.querySelector("#smtp-email-login").checked,
407
+ };
408
+ }
409
+
410
+ // Save
411
+ body.querySelector("#smtp-save").addEventListener("click", function () {
412
+ var formData = getFormData();
413
+ if (!formData.host || !formData.user || !formData.pass || !formData.from) {
414
+ errorEl.textContent = "All fields are required";
415
+ errorEl.className = "admin-smtp-error admin-smtp-error-visible";
416
+ return;
417
+ }
418
+ var btn = this;
419
+ btn.disabled = true;
420
+ errorEl.textContent = "";
421
+ errorEl.className = "admin-smtp-error";
422
+ apiPost("/api/admin/smtp", formData).then(function (data) {
423
+ if (data.ok) {
424
+ showToast("SMTP settings saved");
425
+ loadSmtpTab(body);
426
+ } else {
427
+ errorEl.textContent = data.error || "Failed to save";
428
+ errorEl.className = "admin-smtp-error admin-smtp-error-visible";
429
+ btn.disabled = false;
430
+ }
431
+ }).catch(function () {
432
+ errorEl.textContent = "Failed to save settings";
433
+ errorEl.className = "admin-smtp-error admin-smtp-error-visible";
434
+ btn.disabled = false;
435
+ });
436
+ });
437
+
438
+ // Test
439
+ body.querySelector("#smtp-test").addEventListener("click", function () {
440
+ var formData = getFormData();
441
+ if (!formData.host || !formData.user || !formData.pass || !formData.from) {
442
+ errorEl.textContent = "Fill in all fields first";
443
+ errorEl.className = "admin-smtp-error admin-smtp-error-visible";
444
+ return;
445
+ }
446
+ var btn = this;
447
+ btn.disabled = true;
448
+ errorEl.textContent = "";
449
+ errorEl.className = "admin-smtp-error";
450
+ apiPost("/api/admin/smtp/test", formData).then(function (data) {
451
+ if (data.ok) {
452
+ showToast(data.message || "Test email sent!");
453
+ errorEl.textContent = "";
454
+ errorEl.className = "admin-smtp-error";
455
+ } else {
456
+ errorEl.textContent = data.error || "Test failed";
457
+ errorEl.className = "admin-smtp-error admin-smtp-error-visible";
458
+ }
459
+ btn.disabled = false;
460
+ }).catch(function () {
461
+ errorEl.textContent = "Connection failed";
462
+ errorEl.className = "admin-smtp-error admin-smtp-error-visible";
463
+ btn.disabled = false;
464
+ });
465
+ });
466
+
467
+ // Remove
468
+ var removeBtn = body.querySelector("#smtp-remove");
469
+ if (removeBtn) {
470
+ removeBtn.addEventListener("click", function () {
471
+ if (confirm("Remove SMTP configuration? Users will need to use PIN login.")) {
472
+ apiPost("/api/admin/smtp", { host: "", user: "", pass: "", from: "" }).then(function () {
473
+ showToast("SMTP configuration removed");
474
+ loadSmtpTab(body);
475
+ });
476
+ }
477
+ });
478
+ }
479
+ }
480
+
481
+ // --- Projects ---
482
+ function loadProjectsTab(body) {
483
+ var projectList = (ctx && ctx.projectList) || [];
484
+ cachedProjects = projectList;
485
+
486
+ if (projectList.length === 0) {
487
+ body.innerHTML = '<div class="admin-empty">No projects registered.</div>';
488
+ return;
489
+ }
490
+
491
+ var promises = projectList.map(function (p) {
492
+ return apiGet("/api/admin/projects/" + p.slug + "/access").then(function (access) {
493
+ return { slug: p.slug, title: p.title || p.project || p.slug, visibility: access.visibility || "public", allowedUsers: access.allowedUsers || [] };
494
+ }).catch(function () {
495
+ return { slug: p.slug, title: p.title || p.project || p.slug, visibility: "public", allowedUsers: [] };
496
+ });
497
+ });
498
+
499
+ Promise.all(promises).then(function (projectAccessList) {
500
+ renderProjectsTab(body, projectAccessList);
501
+ });
502
+ }
503
+
504
+ function renderProjectsTab(body, projectAccessList) {
505
+ var html = '<div class="admin-project-list">';
506
+ for (var i = 0; i < projectAccessList.length; i++) {
507
+ var p = projectAccessList[i];
508
+ var visClass = p.visibility === "private" ? "admin-vis-private" : "admin-vis-public";
509
+ html += '<div class="admin-project-item" data-slug="' + escapeHtml(p.slug) + '">';
510
+ html += '<div class="admin-project-info">';
511
+ html += '<div class="admin-project-name">' + escapeHtml(p.title) + '</div>';
512
+ html += '<div class="admin-project-slug">' + escapeHtml(p.slug) + '</div>';
513
+ html += '</div>';
514
+ html += '<div class="admin-project-controls">';
515
+ html += '<select class="admin-vis-select ' + visClass + '" data-slug="' + escapeHtml(p.slug) + '">';
516
+ html += '<option value="public"' + (p.visibility === "public" ? " selected" : "") + '>Public</option>';
517
+ html += '<option value="private"' + (p.visibility === "private" ? " selected" : "") + '>Private</option>';
518
+ html += '</select>';
519
+ if (p.visibility === "private") {
520
+ html += '<button class="admin-manage-users-btn" data-slug="' + escapeHtml(p.slug) + '">' + iconHtml("users") + '</button>';
521
+ }
522
+ html += '</div>';
523
+ html += '</div>';
524
+ }
525
+ html += '</div>';
526
+
527
+ body.innerHTML = html;
528
+ refreshIcons(body);
529
+
530
+ // Bind visibility selects
531
+ var visSelects = body.querySelectorAll(".admin-vis-select");
532
+ for (var j = 0; j < visSelects.length; j++) {
533
+ visSelects[j].addEventListener("change", function () {
534
+ var slug = this.dataset.slug;
535
+ var visibility = this.value;
536
+ setProjectVisibility(slug, visibility, body);
537
+ });
538
+ }
539
+
540
+ // Bind manage users buttons
541
+ var manageUserBtns = body.querySelectorAll(".admin-manage-users-btn");
542
+ for (var k = 0; k < manageUserBtns.length; k++) {
543
+ manageUserBtns[k].addEventListener("click", function () {
544
+ var slug = this.dataset.slug;
545
+ showProjectUsersModal(slug, body);
546
+ });
547
+ }
548
+ }
549
+
550
+ function setProjectVisibility(slug, visibility, body) {
551
+ apiPut("/api/admin/projects/" + slug + "/visibility", { visibility: visibility }).then(function (data) {
552
+ if (data.ok) {
553
+ showToast("Visibility updated");
554
+ loadProjectsTab(body);
555
+ } else {
556
+ showToast(data.error || "Failed to update visibility");
557
+ }
558
+ }).catch(function () {
559
+ showToast("Failed to update visibility");
560
+ });
561
+ }
562
+
563
+ function showProjectUsersModal(slug, parentBody) {
564
+ Promise.all([
565
+ apiGet("/api/admin/users"),
566
+ apiGet("/api/admin/projects/" + slug + "/access"),
567
+ ]).then(function (results) {
568
+ var allUsers = results[0].users || [];
569
+ var access = results[1];
570
+ var allowed = access.allowedUsers || [];
571
+
572
+ var modal = document.createElement("div");
573
+ modal.className = "admin-modal-overlay";
574
+
575
+ var html = '<div class="admin-modal">';
576
+ html += '<div class="admin-modal-header">';
577
+ html += '<h3>Manage Access: ' + escapeHtml(slug) + '</h3>';
578
+ html += '<button class="admin-modal-close">' + iconHtml("x") + '</button>';
579
+ html += '</div>';
580
+ html += '<div class="admin-modal-body">';
581
+ html += '<p class="admin-modal-desc">Select users who can access this private project:</p>';
582
+
583
+ for (var i = 0; i < allUsers.length; i++) {
584
+ var u = allUsers[i];
585
+ if (u.role === "admin") continue;
586
+ var checked = allowed.indexOf(u.id) >= 0 ? " checked" : "";
587
+ html += '<label class="admin-user-check">';
588
+ html += '<input type="checkbox" value="' + u.id + '"' + checked + '>';
589
+ html += '<span>' + escapeHtml(u.displayName || u.username) + ' <small>' + escapeHtml(u.username) + '</small></span>';
590
+ html += '</label>';
591
+ }
592
+
593
+ html += '</div>';
594
+ html += '<div class="admin-modal-footer">';
595
+ html += '<button class="admin-modal-save">Save</button>';
596
+ html += '<button class="admin-modal-cancel">Cancel</button>';
597
+ html += '</div>';
598
+ html += '</div>';
599
+
600
+ modal.innerHTML = html;
601
+ document.body.appendChild(modal);
602
+ refreshIcons(modal);
603
+
604
+ modal.querySelector(".admin-modal-close").addEventListener("click", function () { modal.remove(); });
605
+ modal.querySelector(".admin-modal-cancel").addEventListener("click", function () { modal.remove(); });
606
+ modal.addEventListener("click", function (e) {
607
+ if (e.target === modal) modal.remove();
608
+ });
609
+
610
+ modal.querySelector(".admin-modal-save").addEventListener("click", function () {
611
+ var checkboxes = modal.querySelectorAll('input[type="checkbox"]');
612
+ var selectedUsers = [];
613
+ for (var ci = 0; ci < checkboxes.length; ci++) {
614
+ if (checkboxes[ci].checked) selectedUsers.push(checkboxes[ci].value);
615
+ }
616
+ apiPut("/api/admin/projects/" + slug + "/users", { allowedUsers: selectedUsers }).then(function (data) {
617
+ if (data.ok) {
618
+ showToast("Project access updated");
619
+ modal.remove();
620
+ loadProjectsTab(parentBody);
621
+ } else {
622
+ showToast(data.error || "Failed to update access");
623
+ }
624
+ }).catch(function () {
625
+ showToast("Failed to update access");
626
+ });
627
+ });
628
+ }).catch(function () {
629
+ showToast("Failed to load project access info");
630
+ });
631
+ }
@@ -27,12 +27,16 @@ export function renderMarkdown(text) {
27
27
  }
28
28
 
29
29
  /**
30
- * parseEmojis no-op stub.
31
- * Emoji rendering is now handled by the Twemoji COLR font via CSS @font-face.
32
- * No DOM manipulation needed the font renders emoji glyphs directly.
33
- * This stub is kept so existing callers don't break.
30
+ * Parse Unicode emojis inside an element into Twemoji SVG images.
31
+ * Used only for UI chrome (icon strip, emoji picker, title bar) where
32
+ * img-based rendering is needed for sizing/drop-shadow effects.
33
+ * Chat area uses the Twemoji COLR font instead (no DOM manipulation).
34
34
  */
35
- export function parseEmojis() {}
35
+ var twemojiOpts = { folder: "svg", ext: ".svg", base: "https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/" };
36
+ export function parseEmojis(el) {
37
+ if (typeof twemoji === "undefined" || !el) return;
38
+ twemoji.parse(el, twemojiOpts);
39
+ }
36
40
 
37
41
  var langAliases = { jsonl: "json", dotenv: "bash" };
38
42