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.
- package/README.md +2 -0
- package/bin/cli.js +122 -2
- package/lib/config.js +20 -1
- package/lib/daemon.js +40 -0
- package/lib/pages.js +670 -27
- package/lib/project.js +267 -16
- package/lib/public/app.js +74 -14
- package/lib/public/css/admin.css +576 -0
- package/lib/public/css/icon-strip.css +1 -0
- package/lib/public/css/menus.css +16 -11
- package/lib/public/css/overlays.css +2 -4
- package/lib/public/css/sidebar.css +49 -0
- package/lib/public/css/title-bar.css +45 -1
- package/lib/public/index.html +38 -8
- package/lib/public/modules/admin.js +631 -0
- package/lib/public/modules/markdown.js +9 -5
- package/lib/public/modules/profile.js +21 -0
- package/lib/public/modules/project-settings.js +4 -1
- package/lib/public/modules/server-settings.js +13 -0
- package/lib/public/modules/sidebar.js +111 -5
- package/lib/public/style.css +1 -0
- package/lib/push.js +6 -0
- package/lib/server.js +1075 -27
- package/lib/sessions.js +127 -41
- package/lib/smtp.js +221 -0
- package/lib/users.js +459 -0
- package/package.json +2 -1
|
@@ -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
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
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
|
-
|
|
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
|
|