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
package/lib/sessions.js
CHANGED
|
@@ -2,10 +2,13 @@ var fs = require("fs");
|
|
|
2
2
|
var path = require("path");
|
|
3
3
|
var config = require("./config");
|
|
4
4
|
var utils = require("./utils");
|
|
5
|
+
var users = require("./users");
|
|
5
6
|
|
|
6
7
|
function createSessionManager(opts) {
|
|
7
8
|
var cwd = opts.cwd;
|
|
8
9
|
var send = opts.send; // function(obj) - broadcast to all clients
|
|
10
|
+
var sendTo = opts.sendTo || null; // function(ws, obj) - send to specific client
|
|
11
|
+
var sendEach = opts.sendEach || null; // function(fn) - call fn(ws) for each connected client
|
|
9
12
|
var sendAndRecord = null; // set after init via setSendAndRecord
|
|
10
13
|
|
|
11
14
|
// --- Multi-session state ---
|
|
@@ -80,6 +83,8 @@ function createSessionManager(opts) {
|
|
|
80
83
|
title: session.title,
|
|
81
84
|
createdAt: session.createdAt,
|
|
82
85
|
};
|
|
86
|
+
if (session.ownerId) metaObj.ownerId = session.ownerId;
|
|
87
|
+
if (session.sessionVisibility) metaObj.sessionVisibility = session.sessionVisibility;
|
|
83
88
|
if (session.lastRewindUuid) metaObj.lastRewindUuid = session.lastRewindUuid;
|
|
84
89
|
if (session.loop) metaObj.loop = session.loop;
|
|
85
90
|
var meta = JSON.stringify(metaObj);
|
|
@@ -87,7 +92,11 @@ function createSessionManager(opts) {
|
|
|
87
92
|
for (var i = 0; i < session.history.length; i++) {
|
|
88
93
|
lines.push(JSON.stringify(session.history[i]));
|
|
89
94
|
}
|
|
90
|
-
|
|
95
|
+
var sfPath = sessionFilePath(session.cliSessionId);
|
|
96
|
+
fs.writeFileSync(sfPath, lines.join("\n") + "\n");
|
|
97
|
+
if (process.platform !== "win32") {
|
|
98
|
+
try { fs.chmodSync(sfPath, 0o600); } catch (chmodErr) {}
|
|
99
|
+
}
|
|
91
100
|
} catch(e) {
|
|
92
101
|
console.error("[session] Failed to save session file:", e.message);
|
|
93
102
|
}
|
|
@@ -97,7 +106,11 @@ function createSessionManager(opts) {
|
|
|
97
106
|
if (!session.cliSessionId) return;
|
|
98
107
|
session.lastActivity = Date.now();
|
|
99
108
|
try {
|
|
100
|
-
|
|
109
|
+
var afPath = sessionFilePath(session.cliSessionId);
|
|
110
|
+
fs.appendFileSync(afPath, JSON.stringify(obj) + "\n");
|
|
111
|
+
if (process.platform !== "win32") {
|
|
112
|
+
try { fs.chmodSync(afPath, 0o600); } catch (chmodErr) {}
|
|
113
|
+
}
|
|
101
114
|
} catch(e) {
|
|
102
115
|
console.error("[session] Failed to append to session file:", e.message);
|
|
103
116
|
}
|
|
@@ -159,6 +172,8 @@ function createSessionManager(opts) {
|
|
|
159
172
|
lastRewindUuid: m.lastRewindUuid || null,
|
|
160
173
|
};
|
|
161
174
|
if (m.loop) session.loop = m.loop;
|
|
175
|
+
if (m.ownerId) session.ownerId = m.ownerId;
|
|
176
|
+
session.sessionVisibility = m.sessionVisibility || "shared";
|
|
162
177
|
sessions.set(localId, session);
|
|
163
178
|
}
|
|
164
179
|
}
|
|
@@ -176,32 +191,65 @@ function createSessionManager(opts) {
|
|
|
176
191
|
resolveLoopInfo = fn;
|
|
177
192
|
}
|
|
178
193
|
|
|
194
|
+
function mapSessionForClient(s, clientActiveId) {
|
|
195
|
+
var loop = s.loop ? Object.assign({}, s.loop) : null;
|
|
196
|
+
if (loop && loop.loopId && resolveLoopInfo) {
|
|
197
|
+
var info = resolveLoopInfo(loop.loopId);
|
|
198
|
+
if (info) {
|
|
199
|
+
if (info.name) loop.name = info.name;
|
|
200
|
+
if (info.source) loop.source = info.source;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
var isActive = (typeof clientActiveId === "number") ? s.localId === clientActiveId : s.localId === activeSessionId;
|
|
204
|
+
return {
|
|
205
|
+
id: s.localId,
|
|
206
|
+
cliSessionId: s.cliSessionId || null,
|
|
207
|
+
title: s.title || "New Session",
|
|
208
|
+
active: isActive,
|
|
209
|
+
isProcessing: s.isProcessing,
|
|
210
|
+
lastActivity: s.lastActivity || s.createdAt || 0,
|
|
211
|
+
loop: loop,
|
|
212
|
+
ownerId: s.ownerId || null,
|
|
213
|
+
sessionVisibility: s.sessionVisibility || "shared",
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function getVisibleSessions() {
|
|
218
|
+
var multiUser = users.isMultiUser();
|
|
219
|
+
return [...sessions.values()].filter(function (s) {
|
|
220
|
+
if (s.hidden) return false;
|
|
221
|
+
if (!multiUser) {
|
|
222
|
+
// Single-user mode: only show sessions without ownerId
|
|
223
|
+
return !s.ownerId;
|
|
224
|
+
}
|
|
225
|
+
// Multi-user mode: include all sessions (per-user filtering done by canAccessSession)
|
|
226
|
+
return true;
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
179
230
|
function broadcastSessionList() {
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
231
|
+
var allVisible = getVisibleSessions();
|
|
232
|
+
if (sendEach) {
|
|
233
|
+
// Per-client filtering (multi-user mode)
|
|
234
|
+
sendEach(function (ws, filterFn) {
|
|
235
|
+
var filtered = filterFn ? allVisible.filter(filterFn) : allVisible;
|
|
236
|
+
var clientActiveId = ws._clayActiveSession;
|
|
237
|
+
if (ws.readyState === 1) {
|
|
238
|
+
ws.send(JSON.stringify({
|
|
239
|
+
type: "session_list",
|
|
240
|
+
sessions: filtered.map(function (s) { return mapSessionForClient(s, clientActiveId); }),
|
|
241
|
+
}));
|
|
190
242
|
}
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
loop: loop,
|
|
199
|
-
};
|
|
200
|
-
}),
|
|
201
|
-
});
|
|
243
|
+
});
|
|
244
|
+
} else {
|
|
245
|
+
send({
|
|
246
|
+
type: "session_list",
|
|
247
|
+
sessions: allVisible.map(function (s) { return mapSessionForClient(s); }),
|
|
248
|
+
});
|
|
249
|
+
}
|
|
202
250
|
}
|
|
203
251
|
|
|
204
|
-
function createSession() {
|
|
252
|
+
function createSession(sessionOpts, targetWs) {
|
|
205
253
|
var localId = nextLocalId++;
|
|
206
254
|
var session = {
|
|
207
255
|
localId: localId,
|
|
@@ -219,9 +267,11 @@ function createSessionManager(opts) {
|
|
|
219
267
|
lastActivity: Date.now(),
|
|
220
268
|
history: [],
|
|
221
269
|
messageUUIDs: [],
|
|
270
|
+
ownerId: (sessionOpts && sessionOpts.ownerId) || null,
|
|
271
|
+
sessionVisibility: (sessionOpts && sessionOpts.sessionVisibility) || "shared",
|
|
222
272
|
};
|
|
223
273
|
sessions.set(localId, session);
|
|
224
|
-
switchSession(localId);
|
|
274
|
+
switchSession(localId, targetWs);
|
|
225
275
|
return session;
|
|
226
276
|
}
|
|
227
277
|
|
|
@@ -234,7 +284,8 @@ function createSessionManager(opts) {
|
|
|
234
284
|
return 0;
|
|
235
285
|
}
|
|
236
286
|
|
|
237
|
-
function replayHistory(session, fromIndex) {
|
|
287
|
+
function replayHistory(session, fromIndex, targetWs) {
|
|
288
|
+
var _send = (targetWs && sendTo) ? function (obj) { sendTo(targetWs, obj); } : send;
|
|
238
289
|
var total = session.history.length;
|
|
239
290
|
if (typeof fromIndex !== "number") {
|
|
240
291
|
if (total <= HISTORY_PAGE_SIZE) {
|
|
@@ -244,10 +295,10 @@ function createSessionManager(opts) {
|
|
|
244
295
|
}
|
|
245
296
|
}
|
|
246
297
|
|
|
247
|
-
|
|
298
|
+
_send({ type: "history_meta", total: total, from: fromIndex });
|
|
248
299
|
|
|
249
300
|
for (var i = fromIndex; i < total; i++) {
|
|
250
|
-
|
|
301
|
+
_send(session.history[i]);
|
|
251
302
|
}
|
|
252
303
|
|
|
253
304
|
// Find the last result message in the full history for accurate context data
|
|
@@ -266,27 +317,31 @@ function createSessionManager(opts) {
|
|
|
266
317
|
}
|
|
267
318
|
}
|
|
268
319
|
|
|
269
|
-
|
|
320
|
+
_send({ type: "history_done", lastUsage: lastUsage, lastModelUsage: lastModelUsage, lastCost: lastCost, lastStreamInputTokens: lastStreamInputTokens });
|
|
270
321
|
}
|
|
271
322
|
|
|
272
|
-
function switchSession(localId) {
|
|
323
|
+
function switchSession(localId, targetWs) {
|
|
273
324
|
var session = sessions.get(localId);
|
|
274
325
|
if (!session) return;
|
|
275
326
|
|
|
276
327
|
activeSessionId = localId;
|
|
277
|
-
|
|
328
|
+
|
|
329
|
+
// In multi-user mode with a specific client, only send to that client
|
|
330
|
+
var _send = (targetWs && sendTo) ? function (obj) { sendTo(targetWs, obj); } : send;
|
|
331
|
+
|
|
332
|
+
_send({ type: "session_switched", id: localId, cliSessionId: session.cliSessionId || null, loop: session.loop || null });
|
|
278
333
|
broadcastSessionList();
|
|
279
|
-
replayHistory(session);
|
|
334
|
+
replayHistory(session, undefined, targetWs);
|
|
280
335
|
|
|
281
336
|
if (session.isProcessing) {
|
|
282
|
-
|
|
337
|
+
_send({ type: "status", status: "processing" });
|
|
283
338
|
}
|
|
284
339
|
|
|
285
340
|
// Re-send any pending permission requests
|
|
286
341
|
var pendingIds = Object.keys(session.pendingPermissions);
|
|
287
342
|
for (var i = 0; i < pendingIds.length; i++) {
|
|
288
343
|
var p = session.pendingPermissions[pendingIds[i]];
|
|
289
|
-
|
|
344
|
+
_send({
|
|
290
345
|
type: "permission_request_pending",
|
|
291
346
|
requestId: p.requestId,
|
|
292
347
|
toolName: p.toolName,
|
|
@@ -297,7 +352,7 @@ function createSessionManager(opts) {
|
|
|
297
352
|
}
|
|
298
353
|
}
|
|
299
354
|
|
|
300
|
-
function deleteSession(localId) {
|
|
355
|
+
function deleteSession(localId, targetWs) {
|
|
301
356
|
var session = sessions.get(localId);
|
|
302
357
|
if (!session) return;
|
|
303
358
|
|
|
@@ -317,9 +372,9 @@ function createSessionManager(opts) {
|
|
|
317
372
|
if (activeSessionId === localId) {
|
|
318
373
|
var remaining = [...sessions.keys()];
|
|
319
374
|
if (remaining.length > 0) {
|
|
320
|
-
switchSession(remaining[remaining.length - 1]);
|
|
375
|
+
switchSession(remaining[remaining.length - 1], targetWs);
|
|
321
376
|
} else {
|
|
322
|
-
createSession();
|
|
377
|
+
createSession(null, targetWs);
|
|
323
378
|
}
|
|
324
379
|
} else {
|
|
325
380
|
broadcastSessionList();
|
|
@@ -344,7 +399,23 @@ function createSessionManager(opts) {
|
|
|
344
399
|
function doSendAndRecord(session, obj) {
|
|
345
400
|
session.history.push(obj);
|
|
346
401
|
appendToSessionFile(session, obj);
|
|
347
|
-
if (
|
|
402
|
+
if (sendEach) {
|
|
403
|
+
// Multi-user: send to clients whose active session matches this one
|
|
404
|
+
var data = JSON.stringify(obj);
|
|
405
|
+
var ioData = null;
|
|
406
|
+
sendEach(function (ws) {
|
|
407
|
+
if (ws._clayActiveSession === session.localId) {
|
|
408
|
+
if (ws.readyState === 1) ws.send(data);
|
|
409
|
+
} else if (session.isProcessing && !session._ioThrottle) {
|
|
410
|
+
if (!ioData) ioData = JSON.stringify({ type: "session_io", id: session.localId });
|
|
411
|
+
if (ws.readyState === 1) ws.send(ioData);
|
|
412
|
+
}
|
|
413
|
+
});
|
|
414
|
+
if (session.isProcessing && !session._ioThrottle && ioData) {
|
|
415
|
+
session._ioThrottle = true;
|
|
416
|
+
setTimeout(function () { session._ioThrottle = false; }, 80);
|
|
417
|
+
}
|
|
418
|
+
} else if (session.localId === activeSessionId) {
|
|
348
419
|
send(obj);
|
|
349
420
|
} else if (session.isProcessing && !session._ioThrottle) {
|
|
350
421
|
session._ioThrottle = true;
|
|
@@ -353,7 +424,7 @@ function createSessionManager(opts) {
|
|
|
353
424
|
}
|
|
354
425
|
}
|
|
355
426
|
|
|
356
|
-
function resumeSession(cliSessionId, opts) {
|
|
427
|
+
function resumeSession(cliSessionId, opts, targetWs) {
|
|
357
428
|
// If a session with this cliSessionId already exists, just switch to it
|
|
358
429
|
var existing = null;
|
|
359
430
|
sessions.forEach(function (s) {
|
|
@@ -361,7 +432,7 @@ function createSessionManager(opts) {
|
|
|
361
432
|
});
|
|
362
433
|
if (existing) {
|
|
363
434
|
existing.lastActivity = Date.now();
|
|
364
|
-
switchSession(existing.localId);
|
|
435
|
+
switchSession(existing.localId, targetWs);
|
|
365
436
|
return existing;
|
|
366
437
|
}
|
|
367
438
|
|
|
@@ -386,7 +457,7 @@ function createSessionManager(opts) {
|
|
|
386
457
|
};
|
|
387
458
|
sessions.set(localId, session);
|
|
388
459
|
saveSessionFile(session);
|
|
389
|
-
switchSession(localId);
|
|
460
|
+
switchSession(localId, targetWs);
|
|
390
461
|
return session;
|
|
391
462
|
}
|
|
392
463
|
|
|
@@ -460,6 +531,21 @@ function createSessionManager(opts) {
|
|
|
460
531
|
replayHistory: replayHistory,
|
|
461
532
|
searchSessions: searchSessions,
|
|
462
533
|
setResolveLoopInfo: setResolveLoopInfo,
|
|
534
|
+
setSessionVisibility: function (localId, visibility) {
|
|
535
|
+
var session = sessions.get(localId);
|
|
536
|
+
if (!session) return { error: "Session not found" };
|
|
537
|
+
session.sessionVisibility = visibility;
|
|
538
|
+
saveSessionFile(session);
|
|
539
|
+
broadcastSessionList();
|
|
540
|
+
return { ok: true };
|
|
541
|
+
},
|
|
542
|
+
setSessionOwner: function (localId, ownerId) {
|
|
543
|
+
var session = sessions.get(localId);
|
|
544
|
+
if (!session) return { error: "Session not found" };
|
|
545
|
+
session.ownerId = ownerId;
|
|
546
|
+
saveSessionFile(session);
|
|
547
|
+
return { ok: true };
|
|
548
|
+
},
|
|
463
549
|
};
|
|
464
550
|
}
|
|
465
551
|
|
package/lib/smtp.js
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
var nodemailer = require("nodemailer");
|
|
2
|
+
var crypto = require("crypto");
|
|
3
|
+
|
|
4
|
+
// --- OTP configuration ---
|
|
5
|
+
var OTP_EXPIRY_MS = 10 * 60 * 1000; // 10 minutes
|
|
6
|
+
var OTP_MAX_ATTEMPTS = 3;
|
|
7
|
+
var OTP_COOLDOWN_MS = 60 * 1000; // 1 request per minute per email
|
|
8
|
+
|
|
9
|
+
// --- In-memory OTP store ---
|
|
10
|
+
var otpStore = {}; // email → { code, expiresAt, attempts, createdAt }
|
|
11
|
+
|
|
12
|
+
// --- Transporter cache ---
|
|
13
|
+
var transporter = null;
|
|
14
|
+
|
|
15
|
+
// --- Users module (lazy-loaded to avoid circular deps) ---
|
|
16
|
+
var _users = null;
|
|
17
|
+
function getUsers() {
|
|
18
|
+
if (!_users) _users = require("./users");
|
|
19
|
+
return _users;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// --- SMTP config helpers ---
|
|
23
|
+
|
|
24
|
+
function getSmtpConfig() {
|
|
25
|
+
var data = getUsers().loadUsers();
|
|
26
|
+
return data.smtp || null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function saveSmtpConfig(config) {
|
|
30
|
+
var data = getUsers().loadUsers();
|
|
31
|
+
data.smtp = config;
|
|
32
|
+
getUsers().saveUsers(data);
|
|
33
|
+
resetTransporter();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function isSmtpConfigured() {
|
|
37
|
+
var cfg = getSmtpConfig();
|
|
38
|
+
return !!(cfg && cfg.host && cfg.user && cfg.pass && cfg.from);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function isEmailLoginEnabled() {
|
|
42
|
+
var cfg = getSmtpConfig();
|
|
43
|
+
return isSmtpConfigured() && !!(cfg && cfg.emailLoginEnabled);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// --- Transporter management ---
|
|
47
|
+
|
|
48
|
+
function createTransporter(cfg) {
|
|
49
|
+
return nodemailer.createTransport({
|
|
50
|
+
host: cfg.host,
|
|
51
|
+
port: cfg.port || 587,
|
|
52
|
+
secure: !!cfg.secure,
|
|
53
|
+
auth: { user: cfg.user, pass: cfg.pass },
|
|
54
|
+
connectionTimeout: 10000,
|
|
55
|
+
greetingTimeout: 10000,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function getTransporter() {
|
|
60
|
+
if (transporter) return transporter;
|
|
61
|
+
var cfg = getSmtpConfig();
|
|
62
|
+
if (!cfg) return null;
|
|
63
|
+
transporter = createTransporter(cfg);
|
|
64
|
+
return transporter;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function resetTransporter() {
|
|
68
|
+
if (transporter) {
|
|
69
|
+
try { transporter.close(); } catch (e) {}
|
|
70
|
+
}
|
|
71
|
+
transporter = null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// --- Test connection ---
|
|
75
|
+
|
|
76
|
+
function testConnection(cfg) {
|
|
77
|
+
var t = createTransporter(cfg);
|
|
78
|
+
return t.verify().then(function () {
|
|
79
|
+
try { t.close(); } catch (e) {}
|
|
80
|
+
return { ok: true };
|
|
81
|
+
}).catch(function (err) {
|
|
82
|
+
try { t.close(); } catch (e) {}
|
|
83
|
+
return { ok: false, error: err.message || "Connection failed" };
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function sendTestEmail(cfg, toEmail) {
|
|
88
|
+
var t = createTransporter(cfg);
|
|
89
|
+
return t.sendMail({
|
|
90
|
+
from: cfg.from,
|
|
91
|
+
to: toEmail,
|
|
92
|
+
subject: "Clay SMTP Test",
|
|
93
|
+
html: '<div style="font-family:system-ui,sans-serif;max-width:400px;margin:0 auto;padding:24px">' +
|
|
94
|
+
'<h2 style="color:#DA7756;margin:0 0 16px">Clay</h2>' +
|
|
95
|
+
'<p style="color:#333;margin:0">SMTP is configured correctly. You will receive login codes and invite emails at this address.</p>' +
|
|
96
|
+
'</div>',
|
|
97
|
+
}).then(function (info) {
|
|
98
|
+
try { t.close(); } catch (e) {}
|
|
99
|
+
return { ok: true, messageId: info.messageId };
|
|
100
|
+
}).catch(function (err) {
|
|
101
|
+
try { t.close(); } catch (e) {}
|
|
102
|
+
throw err;
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// --- Send email ---
|
|
107
|
+
|
|
108
|
+
function sendMail(to, subject, html) {
|
|
109
|
+
var t = getTransporter();
|
|
110
|
+
if (!t) return Promise.reject(new Error("SMTP not configured"));
|
|
111
|
+
var cfg = getSmtpConfig();
|
|
112
|
+
return t.sendMail({
|
|
113
|
+
from: cfg.from,
|
|
114
|
+
to: to,
|
|
115
|
+
subject: subject,
|
|
116
|
+
html: html,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// --- OTP generation and verification ---
|
|
121
|
+
|
|
122
|
+
function generateOtp() {
|
|
123
|
+
var bytes = crypto.randomBytes(3);
|
|
124
|
+
var num = ((bytes[0] << 16) | (bytes[1] << 8) | bytes[2]) % 1000000;
|
|
125
|
+
return String(num).padStart(6, "0");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function requestOtp(email) {
|
|
129
|
+
var key = email.toLowerCase();
|
|
130
|
+
var existing = otpStore[key];
|
|
131
|
+
if (existing && (Date.now() - existing.createdAt) < OTP_COOLDOWN_MS) {
|
|
132
|
+
var wait = Math.ceil((OTP_COOLDOWN_MS - (Date.now() - existing.createdAt)) / 1000);
|
|
133
|
+
return { error: "Please wait " + wait + " seconds before requesting a new code", retryAfter: wait };
|
|
134
|
+
}
|
|
135
|
+
var code = generateOtp();
|
|
136
|
+
otpStore[key] = {
|
|
137
|
+
code: code,
|
|
138
|
+
expiresAt: Date.now() + OTP_EXPIRY_MS,
|
|
139
|
+
attempts: 0,
|
|
140
|
+
createdAt: Date.now(),
|
|
141
|
+
};
|
|
142
|
+
return { ok: true, code: code };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function verifyOtp(email, code) {
|
|
146
|
+
var key = email.toLowerCase();
|
|
147
|
+
var entry = otpStore[key];
|
|
148
|
+
if (!entry) return { valid: false, error: "No code requested" };
|
|
149
|
+
if (Date.now() > entry.expiresAt) {
|
|
150
|
+
delete otpStore[key];
|
|
151
|
+
return { valid: false, error: "Code expired" };
|
|
152
|
+
}
|
|
153
|
+
entry.attempts++;
|
|
154
|
+
if (entry.attempts > OTP_MAX_ATTEMPTS) {
|
|
155
|
+
delete otpStore[key];
|
|
156
|
+
return { valid: false, error: "Too many attempts" };
|
|
157
|
+
}
|
|
158
|
+
// Timing-safe comparison
|
|
159
|
+
var a = Buffer.from(entry.code);
|
|
160
|
+
var b = Buffer.from(String(code).padStart(6, "0"));
|
|
161
|
+
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
|
|
162
|
+
var left = OTP_MAX_ATTEMPTS - entry.attempts;
|
|
163
|
+
return { valid: false, error: "Invalid code", attemptsLeft: left };
|
|
164
|
+
}
|
|
165
|
+
delete otpStore[key];
|
|
166
|
+
return { valid: true };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// --- Email templates ---
|
|
170
|
+
|
|
171
|
+
function sendOtpEmail(email, code) {
|
|
172
|
+
var subject = "Your Clay login code: " + code;
|
|
173
|
+
var html = '<div style="font-family:system-ui,sans-serif;max-width:400px;margin:0 auto;padding:24px">' +
|
|
174
|
+
'<h2 style="color:#DA7756;margin:0 0 16px">Clay</h2>' +
|
|
175
|
+
'<p style="color:#333;margin:0 0 16px">Your verification code is:</p>' +
|
|
176
|
+
'<div style="font-size:32px;font-weight:700;letter-spacing:8px;padding:16px 0;text-align:center;' +
|
|
177
|
+
'background:#f5f5f5;border-radius:8px;color:#333;font-family:monospace">' + code + '</div>' +
|
|
178
|
+
'<p style="color:#999;font-size:13px;margin:16px 0 0">This code expires in 10 minutes. If you didn\'t request this, you can ignore this email.</p>' +
|
|
179
|
+
'</div>';
|
|
180
|
+
return sendMail(email, subject, html);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function sendInviteEmail(email, inviteUrl, inviterName) {
|
|
184
|
+
var subject = "You've been invited to Clay";
|
|
185
|
+
var html = '<div style="font-family:system-ui,sans-serif;max-width:400px;margin:0 auto;padding:24px">' +
|
|
186
|
+
'<h2 style="color:#DA7756;margin:0 0 16px">Clay</h2>' +
|
|
187
|
+
'<p style="color:#333;margin:0 0 16px">' + (inviterName || "An admin") + ' has invited you to join Clay.</p>' +
|
|
188
|
+
'<p style="margin:0 0 16px"><a href="' + inviteUrl + '" style="display:inline-block;padding:12px 24px;' +
|
|
189
|
+
'background:#DA7756;color:#fff;text-decoration:none;border-radius:8px;font-weight:600">Accept Invite</a></p>' +
|
|
190
|
+
'<p style="color:#999;font-size:13px;margin:0">This invite link expires in 24 hours.</p>' +
|
|
191
|
+
'</div>';
|
|
192
|
+
return sendMail(email, subject, html);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// --- Cleanup expired OTPs ---
|
|
196
|
+
|
|
197
|
+
setInterval(function () {
|
|
198
|
+
var now = Date.now();
|
|
199
|
+
var keys = Object.keys(otpStore);
|
|
200
|
+
for (var i = 0; i < keys.length; i++) {
|
|
201
|
+
if (now > otpStore[keys[i]].expiresAt) {
|
|
202
|
+
delete otpStore[keys[i]];
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}, 60000);
|
|
206
|
+
|
|
207
|
+
module.exports = {
|
|
208
|
+
getSmtpConfig: getSmtpConfig,
|
|
209
|
+
saveSmtpConfig: saveSmtpConfig,
|
|
210
|
+
isSmtpConfigured: isSmtpConfigured,
|
|
211
|
+
isEmailLoginEnabled: isEmailLoginEnabled,
|
|
212
|
+
testConnection: testConnection,
|
|
213
|
+
sendTestEmail: sendTestEmail,
|
|
214
|
+
sendMail: sendMail,
|
|
215
|
+
resetTransporter: resetTransporter,
|
|
216
|
+
generateOtp: generateOtp,
|
|
217
|
+
requestOtp: requestOtp,
|
|
218
|
+
verifyOtp: verifyOtp,
|
|
219
|
+
sendOtpEmail: sendOtpEmail,
|
|
220
|
+
sendInviteEmail: sendInviteEmail,
|
|
221
|
+
};
|