clay-server 2.13.0-beta.2 → 2.13.0-beta.4
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/bin/cli.js +48 -0
- package/lib/daemon.js +16 -3
- package/lib/notes.js +1 -1
- package/lib/project.js +275 -7
- package/lib/public/app.js +250 -34
- package/lib/public/css/admin.css +71 -0
- package/lib/public/css/command-palette.css +319 -0
- package/lib/public/css/icon-strip.css +19 -3
- package/lib/public/css/input.css +2 -1
- package/lib/public/css/loop.css +26 -0
- package/lib/public/css/mates.css +73 -0
- package/lib/public/css/overlays.css +3 -0
- package/lib/public/css/scheduler.css +29 -5
- package/lib/public/css/sidebar.css +28 -6
- package/lib/public/css/sticky-notes.css +61 -12
- package/lib/public/css/title-bar.css +24 -30
- package/lib/public/index.html +48 -13
- package/lib/public/modules/admin.js +109 -1
- package/lib/public/modules/command-palette.js +549 -0
- package/lib/public/modules/input.js +10 -2
- package/lib/public/modules/mate-wizard.js +17 -6
- package/lib/public/modules/scheduler.js +56 -64
- package/lib/public/modules/session-search.js +6 -2
- package/lib/public/modules/sidebar.js +128 -72
- package/lib/public/modules/sticky-notes.js +37 -0
- package/lib/public/style.css +1 -0
- package/lib/scheduler.js +4 -0
- package/lib/sdk-bridge.js +10 -8
- package/lib/server.js +334 -7
- package/lib/sessions.js +5 -0
- package/lib/users.js +64 -0
- package/lib/worktree.js +2 -2
- package/package.json +1 -1
package/lib/server.js
CHANGED
|
@@ -402,6 +402,91 @@ function createServer(opts) {
|
|
|
402
402
|
var getRemovedProjects = opts.getRemovedProjects || function () { return []; };
|
|
403
403
|
|
|
404
404
|
var authToken = pinHash || null;
|
|
405
|
+
|
|
406
|
+
// --- Admin password recovery (in-memory, one-time) ---
|
|
407
|
+
var recovery = null; // { urlPath, password }
|
|
408
|
+
|
|
409
|
+
function setRecovery(urlPath, password) {
|
|
410
|
+
recovery = { urlPath: urlPath, password: password };
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function clearRecovery() {
|
|
414
|
+
recovery = null;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function recoveryPageHtml() {
|
|
418
|
+
return '<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">'
|
|
419
|
+
+ '<title>Admin Password Recovery</title>'
|
|
420
|
+
+ '<style>'
|
|
421
|
+
+ '*{margin:0;padding:0;box-sizing:border-box}'
|
|
422
|
+
+ 'body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#0a0a0a;color:#e5e5e5;display:flex;align-items:center;justify-content:center;min-height:100vh}'
|
|
423
|
+
+ '.card{background:#171717;border:1px solid #262626;border-radius:12px;padding:32px;width:100%;max-width:380px}'
|
|
424
|
+
+ 'h1{font-size:18px;font-weight:600;margin-bottom:4px}'
|
|
425
|
+
+ '.sub{font-size:13px;color:#737373;margin-bottom:24px}'
|
|
426
|
+
+ 'label{display:block;font-size:13px;color:#a3a3a3;margin-bottom:6px}'
|
|
427
|
+
+ 'input{width:100%;padding:10px 12px;background:#0a0a0a;border:1px solid #333;border-radius:8px;color:#e5e5e5;font-size:14px;outline:none;margin-bottom:16px}'
|
|
428
|
+
+ 'input:focus{border-color:#7c3aed}'
|
|
429
|
+
+ 'button{width:100%;padding:10px;background:#7c3aed;color:#fff;border:none;border-radius:8px;font-size:14px;font-weight:500;cursor:pointer}'
|
|
430
|
+
+ 'button:hover{background:#6d28d9}'
|
|
431
|
+
+ 'button:disabled{opacity:.5;cursor:not-allowed}'
|
|
432
|
+
+ '.error{color:#ef4444;font-size:13px;margin-bottom:12px;display:none}'
|
|
433
|
+
+ '.success{text-align:center;color:#22c55e;font-size:15px}'
|
|
434
|
+
+ '.hidden{display:none}'
|
|
435
|
+
+ '</style></head><body>'
|
|
436
|
+
+ '<div class="card">'
|
|
437
|
+
+ '<div id="step-verify">'
|
|
438
|
+
+ '<h1>Admin Recovery</h1>'
|
|
439
|
+
+ '<p class="sub">Enter the recovery password shown in your terminal.</p>'
|
|
440
|
+
+ '<div id="err-verify" class="error"></div>'
|
|
441
|
+
+ '<label for="recovery-pw">Recovery password</label>'
|
|
442
|
+
+ '<input id="recovery-pw" type="text" autocomplete="off" spellcheck="false" autofocus>'
|
|
443
|
+
+ '<button id="btn-verify">Verify</button>'
|
|
444
|
+
+ '</div>'
|
|
445
|
+
+ '<div id="step-reset" class="hidden">'
|
|
446
|
+
+ '<h1>Reset Admin PIN</h1>'
|
|
447
|
+
+ '<p class="sub">Enter a new 6-digit PIN for the admin account.</p>'
|
|
448
|
+
+ '<div id="err-reset" class="error"></div>'
|
|
449
|
+
+ '<label for="new-pin">New PIN</label>'
|
|
450
|
+
+ '<input id="new-pin" type="password" maxlength="6" pattern="\\d{6}" inputmode="numeric" placeholder="6 digits">'
|
|
451
|
+
+ '<label for="confirm-pin">Confirm PIN</label>'
|
|
452
|
+
+ '<input id="confirm-pin" type="password" maxlength="6" pattern="\\d{6}" inputmode="numeric" placeholder="6 digits">'
|
|
453
|
+
+ '<button id="btn-reset">Reset PIN</button>'
|
|
454
|
+
+ '</div>'
|
|
455
|
+
+ '<div id="step-done" class="hidden">'
|
|
456
|
+
+ '<p class="success">PIN has been reset successfully. You can now log in with your new PIN.</p>'
|
|
457
|
+
+ '</div>'
|
|
458
|
+
+ '</div>'
|
|
459
|
+
+ '<script>'
|
|
460
|
+
+ 'var pw="";\n'
|
|
461
|
+
+ 'document.getElementById("btn-verify").onclick=function(){\n'
|
|
462
|
+
+ ' var el=document.getElementById("recovery-pw");\n'
|
|
463
|
+
+ ' pw=el.value.trim();\n'
|
|
464
|
+
+ ' if(!pw)return;\n'
|
|
465
|
+
+ ' this.disabled=true;\n'
|
|
466
|
+
+ ' fetch(location.pathname,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({step:"verify",password:pw})})\n'
|
|
467
|
+
+ ' .then(function(r){return r.json()}).then(function(d){\n'
|
|
468
|
+
+ ' if(d.ok){document.getElementById("step-verify").classList.add("hidden");document.getElementById("step-reset").classList.remove("hidden");document.getElementById("new-pin").focus()}\n'
|
|
469
|
+
+ ' else{var e=document.getElementById("err-verify");e.textContent=d.error||"Invalid password";e.style.display="block";document.getElementById("btn-verify").disabled=false}\n'
|
|
470
|
+
+ ' }).catch(function(){document.getElementById("btn-verify").disabled=false})\n'
|
|
471
|
+
+ '};\n'
|
|
472
|
+
+ 'document.getElementById("recovery-pw").addEventListener("keydown",function(e){if(e.key==="Enter")document.getElementById("btn-verify").click()});\n'
|
|
473
|
+
+ 'document.getElementById("btn-reset").onclick=function(){\n'
|
|
474
|
+
+ ' var pin=document.getElementById("new-pin").value;\n'
|
|
475
|
+
+ ' var confirm=document.getElementById("confirm-pin").value;\n'
|
|
476
|
+
+ ' var errEl=document.getElementById("err-reset");\n'
|
|
477
|
+
+ ' if(!/^\\d{6}$/.test(pin)){errEl.textContent="PIN must be exactly 6 digits";errEl.style.display="block";return}\n'
|
|
478
|
+
+ ' if(pin!==confirm){errEl.textContent="PINs do not match";errEl.style.display="block";return}\n'
|
|
479
|
+
+ ' this.disabled=true;errEl.style.display="none";\n'
|
|
480
|
+
+ ' fetch(location.pathname,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({step:"reset",password:pw,pin:pin})})\n'
|
|
481
|
+
+ ' .then(function(r){return r.json()}).then(function(d){\n'
|
|
482
|
+
+ ' if(d.ok){document.getElementById("step-reset").classList.add("hidden");document.getElementById("step-done").classList.remove("hidden")}\n'
|
|
483
|
+
+ ' else{errEl.textContent=d.error||"Failed";errEl.style.display="block";document.getElementById("btn-reset").disabled=false}\n'
|
|
484
|
+
+ ' }).catch(function(){document.getElementById("btn-reset").disabled=false})\n'
|
|
485
|
+
+ '};\n'
|
|
486
|
+
+ 'document.getElementById("confirm-pin").addEventListener("keydown",function(e){if(e.key==="Enter")document.getElementById("btn-reset").click()});\n'
|
|
487
|
+
+ '</script></body></html>';
|
|
488
|
+
}
|
|
489
|
+
|
|
405
490
|
var realVersion = require("../package.json").version;
|
|
406
491
|
var currentVersion = debug ? "0.0.9" : realVersion;
|
|
407
492
|
|
|
@@ -457,6 +542,70 @@ function createServer(opts) {
|
|
|
457
542
|
setSecurityHeaders(res);
|
|
458
543
|
var fullUrl = req.url.split("?")[0];
|
|
459
544
|
|
|
545
|
+
// --- Admin password recovery ---
|
|
546
|
+
if (recovery && fullUrl === "/recover/" + recovery.urlPath) {
|
|
547
|
+
if (req.method === "GET") {
|
|
548
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
549
|
+
res.end(recoveryPageHtml());
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
if (req.method === "POST") {
|
|
553
|
+
var ip = req.socket.remoteAddress || "";
|
|
554
|
+
var remaining = checkPinRateLimit(ip);
|
|
555
|
+
if (remaining !== null) {
|
|
556
|
+
res.writeHead(429, { "Content-Type": "application/json" });
|
|
557
|
+
res.end(JSON.stringify({ ok: false, locked: true, retryAfter: remaining }));
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
var body = "";
|
|
561
|
+
req.on("data", function (chunk) { body += chunk; });
|
|
562
|
+
req.on("end", function () {
|
|
563
|
+
try {
|
|
564
|
+
var data = JSON.parse(body);
|
|
565
|
+
if (data.step === "verify") {
|
|
566
|
+
if (!data.password || data.password !== recovery.password) {
|
|
567
|
+
recordPinFailure(ip);
|
|
568
|
+
res.writeHead(401, { "Content-Type": "application/json" });
|
|
569
|
+
res.end('{"error":"Invalid recovery password"}');
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
clearPinFailures(ip);
|
|
573
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
574
|
+
res.end('{"ok":true}');
|
|
575
|
+
} else if (data.step === "reset") {
|
|
576
|
+
if (!data.password || data.password !== recovery.password) {
|
|
577
|
+
res.writeHead(401, { "Content-Type": "application/json" });
|
|
578
|
+
res.end('{"error":"Invalid recovery password"}');
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
if (!data.pin || !/^\d{6}$/.test(data.pin)) {
|
|
582
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
583
|
+
res.end('{"error":"PIN must be exactly 6 digits"}');
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
var admin = users.findAdmin();
|
|
587
|
+
if (!admin) {
|
|
588
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
589
|
+
res.end('{"error":"No admin account found"}');
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
users.updateUserPin(admin.id, data.pin);
|
|
593
|
+
recovery = null;
|
|
594
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
595
|
+
res.end('{"ok":true}');
|
|
596
|
+
} else {
|
|
597
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
598
|
+
res.end('{"error":"Invalid step"}');
|
|
599
|
+
}
|
|
600
|
+
} catch (e) {
|
|
601
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
602
|
+
res.end('{"error":"Invalid request"}');
|
|
603
|
+
}
|
|
604
|
+
});
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
460
609
|
// Global auth endpoint
|
|
461
610
|
if (req.method === "POST" && req.url === "/auth") {
|
|
462
611
|
var ip = req.socket.remoteAddress || "";
|
|
@@ -1238,6 +1387,42 @@ function createServer(opts) {
|
|
|
1238
1387
|
return;
|
|
1239
1388
|
}
|
|
1240
1389
|
|
|
1390
|
+
// Update user permissions (admin only)
|
|
1391
|
+
if (req.method === "PUT" && fullUrl.match(/^\/api\/admin\/users\/[^/]+\/permissions$/)) {
|
|
1392
|
+
if (!users.isMultiUser()) {
|
|
1393
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
1394
|
+
res.end('{"error":"Not found"}');
|
|
1395
|
+
return;
|
|
1396
|
+
}
|
|
1397
|
+
var mu = getMultiUserFromReq(req);
|
|
1398
|
+
if (!mu || mu.role !== "admin") {
|
|
1399
|
+
res.writeHead(403, { "Content-Type": "application/json" });
|
|
1400
|
+
res.end('{"error":"Admin access required"}');
|
|
1401
|
+
return;
|
|
1402
|
+
}
|
|
1403
|
+
var urlParts = fullUrl.split("/");
|
|
1404
|
+
var targetUserId = urlParts[4]; // /api/admin/users/{userId}/permissions
|
|
1405
|
+
var body = "";
|
|
1406
|
+
req.on("data", function(chunk) { body += chunk; });
|
|
1407
|
+
req.on("end", function() {
|
|
1408
|
+
try {
|
|
1409
|
+
var parsed = JSON.parse(body);
|
|
1410
|
+
var result = users.updateUserPermissions(targetUserId, parsed.permissions || {});
|
|
1411
|
+
if (result.error) {
|
|
1412
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1413
|
+
res.end(JSON.stringify({ error: result.error }));
|
|
1414
|
+
} else {
|
|
1415
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1416
|
+
res.end(JSON.stringify({ ok: true, permissions: result.permissions }));
|
|
1417
|
+
}
|
|
1418
|
+
} catch (e) {
|
|
1419
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1420
|
+
res.end('{"error":"Invalid request body"}');
|
|
1421
|
+
}
|
|
1422
|
+
});
|
|
1423
|
+
return;
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1241
1426
|
// Create invite (admin only)
|
|
1242
1427
|
if (req.method === "POST" && fullUrl === "/api/admin/invites") {
|
|
1243
1428
|
if (!users.isMultiUser()) {
|
|
@@ -1498,6 +1683,11 @@ function createServer(opts) {
|
|
|
1498
1683
|
return;
|
|
1499
1684
|
}
|
|
1500
1685
|
var projSlug = fullUrl.split("/")[4];
|
|
1686
|
+
if (projSlug.indexOf("--") !== -1) {
|
|
1687
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1688
|
+
res.end('{"error":"Worktree projects inherit parent visibility"}');
|
|
1689
|
+
return;
|
|
1690
|
+
}
|
|
1501
1691
|
var body = "";
|
|
1502
1692
|
req.on("data", function (chunk) { body += chunk; });
|
|
1503
1693
|
req.on("end", function () {
|
|
@@ -1543,6 +1733,11 @@ function createServer(opts) {
|
|
|
1543
1733
|
return;
|
|
1544
1734
|
}
|
|
1545
1735
|
var projSlug = fullUrl.split("/")[4];
|
|
1736
|
+
if (projSlug.indexOf("--") !== -1) {
|
|
1737
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1738
|
+
res.end('{"error":"Worktree projects inherit parent settings"}');
|
|
1739
|
+
return;
|
|
1740
|
+
}
|
|
1546
1741
|
var body = "";
|
|
1547
1742
|
req.on("data", function (chunk) { body += chunk; });
|
|
1548
1743
|
req.on("end", function () {
|
|
@@ -1590,6 +1785,11 @@ function createServer(opts) {
|
|
|
1590
1785
|
return;
|
|
1591
1786
|
}
|
|
1592
1787
|
var projSlug = fullUrl.split("/")[4];
|
|
1788
|
+
if (projSlug.indexOf("--") !== -1) {
|
|
1789
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1790
|
+
res.end('{"error":"Worktree projects inherit parent settings"}');
|
|
1791
|
+
return;
|
|
1792
|
+
}
|
|
1593
1793
|
var body = "";
|
|
1594
1794
|
req.on("data", function (chunk) { body += chunk; });
|
|
1595
1795
|
req.on("end", function () {
|
|
@@ -1651,6 +1851,97 @@ function createServer(opts) {
|
|
|
1651
1851
|
return;
|
|
1652
1852
|
}
|
|
1653
1853
|
|
|
1854
|
+
// Command palette: cross-project session search
|
|
1855
|
+
if (req.method === "GET" && fullUrl === "/api/palette/search") {
|
|
1856
|
+
var paletteUser = null;
|
|
1857
|
+
if (users.isMultiUser()) {
|
|
1858
|
+
paletteUser = getMultiUserFromReq(req);
|
|
1859
|
+
if (!paletteUser) {
|
|
1860
|
+
res.writeHead(401, { "Content-Type": "application/json" });
|
|
1861
|
+
res.end('{"error":"unauthorized"}');
|
|
1862
|
+
return;
|
|
1863
|
+
}
|
|
1864
|
+
}
|
|
1865
|
+
var pqs = req.url.indexOf("?") >= 0 ? req.url.substring(req.url.indexOf("?")) : "";
|
|
1866
|
+
var pQuery = new URLSearchParams(pqs).get("q") || "";
|
|
1867
|
+
var pResults = [];
|
|
1868
|
+
projects.forEach(function (pCtx, pSlug) {
|
|
1869
|
+
var status = pCtx.getStatus();
|
|
1870
|
+
if (status.isMate) return;
|
|
1871
|
+
if (status.isWorktree) return;
|
|
1872
|
+
// Access check
|
|
1873
|
+
if (paletteUser && onGetProjectAccess) {
|
|
1874
|
+
var pAccess = onGetProjectAccess(pSlug);
|
|
1875
|
+
if (pAccess && !pAccess.error && !users.canAccessProject(paletteUser.id, pAccess)) return;
|
|
1876
|
+
}
|
|
1877
|
+
var sm = pCtx.sm;
|
|
1878
|
+
sm.sessions.forEach(function (session) {
|
|
1879
|
+
if (session.hidden) return;
|
|
1880
|
+
// Session access check
|
|
1881
|
+
if (paletteUser) {
|
|
1882
|
+
if (users.isMultiUser()) {
|
|
1883
|
+
var sAccess = onGetProjectAccess ? onGetProjectAccess(pSlug) : null;
|
|
1884
|
+
if (!users.canAccessSession(paletteUser.id, session, sAccess)) return;
|
|
1885
|
+
}
|
|
1886
|
+
} else {
|
|
1887
|
+
// Single-user: skip sessions with ownerId
|
|
1888
|
+
if (session.ownerId) return;
|
|
1889
|
+
}
|
|
1890
|
+
if (!pQuery) {
|
|
1891
|
+
// Recent mode: return all sessions sorted by lastActivity
|
|
1892
|
+
pResults.push({
|
|
1893
|
+
projectSlug: pSlug,
|
|
1894
|
+
projectTitle: status.title || status.project,
|
|
1895
|
+
projectIcon: status.icon || null,
|
|
1896
|
+
sessionId: session.localId,
|
|
1897
|
+
sessionTitle: session.title || "New Session",
|
|
1898
|
+
lastActivity: session.lastActivity || session.createdAt || 0,
|
|
1899
|
+
matchType: null,
|
|
1900
|
+
snippet: null
|
|
1901
|
+
});
|
|
1902
|
+
} else {
|
|
1903
|
+
// Search mode: match title and content
|
|
1904
|
+
var q = pQuery.toLowerCase();
|
|
1905
|
+
var titleMatch = (session.title || "New Session").toLowerCase().indexOf(q) !== -1;
|
|
1906
|
+
var contentSnippet = null;
|
|
1907
|
+
for (var hi = 0; hi < session.history.length; hi++) {
|
|
1908
|
+
var entry = session.history[hi];
|
|
1909
|
+
if ((entry.type === "delta" || entry.type === "user_message") && entry.text) {
|
|
1910
|
+
var lowerText = entry.text.toLowerCase();
|
|
1911
|
+
var matchIdx = lowerText.indexOf(q);
|
|
1912
|
+
if (matchIdx !== -1) {
|
|
1913
|
+
var snippetStart = Math.max(0, matchIdx - 20);
|
|
1914
|
+
var snippetEnd = Math.min(entry.text.length, matchIdx + pQuery.length + 20);
|
|
1915
|
+
contentSnippet = (snippetStart > 0 ? "..." : "") +
|
|
1916
|
+
entry.text.substring(snippetStart, snippetEnd) +
|
|
1917
|
+
(snippetEnd < entry.text.length ? "..." : "");
|
|
1918
|
+
break;
|
|
1919
|
+
}
|
|
1920
|
+
}
|
|
1921
|
+
}
|
|
1922
|
+
if (titleMatch || contentSnippet) {
|
|
1923
|
+
pResults.push({
|
|
1924
|
+
projectSlug: pSlug,
|
|
1925
|
+
projectTitle: status.title || status.project,
|
|
1926
|
+
projectIcon: status.icon || null,
|
|
1927
|
+
sessionId: session.localId,
|
|
1928
|
+
sessionTitle: session.title || "New Session",
|
|
1929
|
+
lastActivity: session.lastActivity || session.createdAt || 0,
|
|
1930
|
+
matchType: titleMatch && contentSnippet ? "both" : titleMatch ? "title" : "content",
|
|
1931
|
+
snippet: contentSnippet
|
|
1932
|
+
});
|
|
1933
|
+
}
|
|
1934
|
+
}
|
|
1935
|
+
});
|
|
1936
|
+
});
|
|
1937
|
+
// Sort by lastActivity descending, limit to 30
|
|
1938
|
+
pResults.sort(function (a, b) { return b.lastActivity - a.lastActivity; });
|
|
1939
|
+
if (pResults.length > 30) pResults = pResults.slice(0, 30);
|
|
1940
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1941
|
+
res.end(JSON.stringify({ results: pResults }));
|
|
1942
|
+
return;
|
|
1943
|
+
}
|
|
1944
|
+
|
|
1654
1945
|
// Multi-user info endpoint (who am I?)
|
|
1655
1946
|
if (req.method === "GET" && fullUrl === "/api/me") {
|
|
1656
1947
|
if (!users.isMultiUser()) {
|
|
@@ -1665,12 +1956,28 @@ function createServer(opts) {
|
|
|
1665
1956
|
return;
|
|
1666
1957
|
}
|
|
1667
1958
|
var meResp = { multiUser: true, smtpEnabled: smtp.isSmtpConfigured(), emailLoginEnabled: smtp.isEmailLoginEnabled(), user: { id: mu.id, username: mu.username, email: mu.email || null, displayName: mu.displayName, role: mu.role } };
|
|
1959
|
+
meResp.permissions = users.getEffectivePermissions(mu, osUsers);
|
|
1668
1960
|
if (mu.mustChangePin) meResp.mustChangePin = true;
|
|
1669
1961
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1670
1962
|
res.end(JSON.stringify(meResp));
|
|
1671
1963
|
return;
|
|
1672
1964
|
}
|
|
1673
1965
|
|
|
1966
|
+
// Skills proxy: permission gate
|
|
1967
|
+
if (fullUrl === "/api/skills" || fullUrl.startsWith("/api/skills/") || fullUrl.startsWith("/api/skills?")) {
|
|
1968
|
+
if (users.isMultiUser()) {
|
|
1969
|
+
var skMu = getMultiUserFromReq(req);
|
|
1970
|
+
if (skMu) {
|
|
1971
|
+
var skPerms = users.getEffectivePermissions(skMu, osUsers);
|
|
1972
|
+
if (!skPerms.skills) {
|
|
1973
|
+
res.writeHead(403, { "Content-Type": "application/json" });
|
|
1974
|
+
res.end('{"error":"Skills access is not permitted"}');
|
|
1975
|
+
return;
|
|
1976
|
+
}
|
|
1977
|
+
}
|
|
1978
|
+
}
|
|
1979
|
+
}
|
|
1980
|
+
|
|
1674
1981
|
// Skills proxy: leaderboard list
|
|
1675
1982
|
if (req.method === "GET" && fullUrl === "/api/skills") {
|
|
1676
1983
|
var qs = req.url.indexOf("?") >= 0 ? req.url.substring(req.url.indexOf("?")) : "";
|
|
@@ -1776,17 +2083,32 @@ function createServer(opts) {
|
|
|
1776
2083
|
if (projects.size > 0) {
|
|
1777
2084
|
var targetSlug = null;
|
|
1778
2085
|
var reqUser = users.isMultiUser() ? getMultiUserFromReq(req) : null;
|
|
1779
|
-
|
|
1780
|
-
|
|
2086
|
+
// Check for last-visited project cookie
|
|
2087
|
+
var lastProject = parseCookies(req)["clay_last_project"];
|
|
2088
|
+
if (lastProject && projects.has(lastProject)) {
|
|
1781
2089
|
if (reqUser && onGetProjectAccess) {
|
|
1782
|
-
var
|
|
1783
|
-
if (
|
|
1784
|
-
targetSlug =
|
|
2090
|
+
var lpAccess = onGetProjectAccess(lastProject);
|
|
2091
|
+
if (lpAccess && !lpAccess.error && users.canAccessProject(reqUser.id, lpAccess)) {
|
|
2092
|
+
targetSlug = lastProject;
|
|
1785
2093
|
}
|
|
1786
2094
|
} else {
|
|
1787
|
-
targetSlug =
|
|
2095
|
+
targetSlug = lastProject;
|
|
1788
2096
|
}
|
|
1789
|
-
}
|
|
2097
|
+
}
|
|
2098
|
+
// Fall back to first accessible project
|
|
2099
|
+
if (!targetSlug) {
|
|
2100
|
+
projects.forEach(function (ctx, s) {
|
|
2101
|
+
if (targetSlug) return;
|
|
2102
|
+
if (reqUser && onGetProjectAccess) {
|
|
2103
|
+
var access = onGetProjectAccess(s);
|
|
2104
|
+
if (access && !access.error && users.canAccessProject(reqUser.id, access)) {
|
|
2105
|
+
targetSlug = s;
|
|
2106
|
+
}
|
|
2107
|
+
} else {
|
|
2108
|
+
targetSlug = s;
|
|
2109
|
+
}
|
|
2110
|
+
});
|
|
2111
|
+
}
|
|
1790
2112
|
if (targetSlug) {
|
|
1791
2113
|
res.writeHead(302, { "Location": "/p/" + targetSlug + "/" });
|
|
1792
2114
|
res.end();
|
|
@@ -1858,6 +2180,9 @@ function createServer(opts) {
|
|
|
1858
2180
|
return;
|
|
1859
2181
|
}
|
|
1860
2182
|
|
|
2183
|
+
// Set last-visited project cookie for root redirect
|
|
2184
|
+
res.setHeader("Set-Cookie", "clay_last_project=" + slug + "; Path=/; SameSite=Strict; Max-Age=31536000" + (tlsOptions ? "; Secure" : ""));
|
|
2185
|
+
|
|
1861
2186
|
// Multi-user: check project access for HTTP requests
|
|
1862
2187
|
if (users.isMultiUser() && onGetProjectAccess) {
|
|
1863
2188
|
var httpUser = getMultiUserFromReq(req);
|
|
@@ -2668,6 +2993,8 @@ function createServer(opts) {
|
|
|
2668
2993
|
setProjectTitle: setProjectTitle,
|
|
2669
2994
|
setProjectIcon: setProjectIcon,
|
|
2670
2995
|
setAuthToken: setAuthToken,
|
|
2996
|
+
setRecovery: setRecovery,
|
|
2997
|
+
clearRecovery: clearRecovery,
|
|
2671
2998
|
broadcastAll: broadcastAll,
|
|
2672
2999
|
destroyAll: destroyAll,
|
|
2673
3000
|
};
|
package/lib/sessions.js
CHANGED
|
@@ -360,6 +360,11 @@ function createSessionManager(opts) {
|
|
|
360
360
|
targetWs._clayActiveSession = localId;
|
|
361
361
|
// Clear unread for this session (multi-user)
|
|
362
362
|
if (targetWs._clayUnread) targetWs._clayUnread[localId] = 0;
|
|
363
|
+
} else if (sendEach) {
|
|
364
|
+
// No specific target: update all connected clients (server-initiated switch)
|
|
365
|
+
sendEach(function (ws) {
|
|
366
|
+
ws._clayActiveSession = localId;
|
|
367
|
+
});
|
|
363
368
|
}
|
|
364
369
|
// Clear unread for single-user mode
|
|
365
370
|
singleUserUnread[localId] = 0;
|
package/lib/users.js
CHANGED
|
@@ -6,6 +6,29 @@ var { CONFIG_DIR } = require("./config");
|
|
|
6
6
|
|
|
7
7
|
var USERS_FILE = path.join(CONFIG_DIR, "users.json");
|
|
8
8
|
|
|
9
|
+
// --- Per-user RBAC permissions (default values for regular users) ---
|
|
10
|
+
var DEFAULT_PERMISSIONS = {
|
|
11
|
+
terminal: false,
|
|
12
|
+
fileBrowser: false,
|
|
13
|
+
createProject: true,
|
|
14
|
+
deleteProject: false,
|
|
15
|
+
skills: true,
|
|
16
|
+
sessionDelete: false,
|
|
17
|
+
scheduledTasks: false,
|
|
18
|
+
projectSettings: false,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
var ALL_PERMISSIONS = {
|
|
22
|
+
terminal: true,
|
|
23
|
+
fileBrowser: true,
|
|
24
|
+
createProject: true,
|
|
25
|
+
deleteProject: true,
|
|
26
|
+
skills: true,
|
|
27
|
+
sessionDelete: true,
|
|
28
|
+
scheduledTasks: true,
|
|
29
|
+
projectSettings: true,
|
|
30
|
+
};
|
|
31
|
+
|
|
9
32
|
// --- Default data ---
|
|
10
33
|
|
|
11
34
|
function defaultData() {
|
|
@@ -223,6 +246,7 @@ function getAllUsers() {
|
|
|
223
246
|
createdAt: u.createdAt,
|
|
224
247
|
profile: u.profile,
|
|
225
248
|
linuxUser: u.linuxUser || null,
|
|
249
|
+
permissions: u.permissions || null,
|
|
226
250
|
};
|
|
227
251
|
});
|
|
228
252
|
}
|
|
@@ -539,6 +563,43 @@ function removeDmHidden(userId, targetUserId) {
|
|
|
539
563
|
return [];
|
|
540
564
|
}
|
|
541
565
|
|
|
566
|
+
// --- RBAC permissions ---
|
|
567
|
+
|
|
568
|
+
function getEffectivePermissions(user, osUsersMode) {
|
|
569
|
+
// OS-mode users with linuxUser are exempt from RBAC
|
|
570
|
+
if (osUsersMode && user && user.linuxUser) return ALL_PERMISSIONS;
|
|
571
|
+
// Admin always has full permissions
|
|
572
|
+
if (user && user.role === "admin") return ALL_PERMISSIONS;
|
|
573
|
+
// Merge stored permissions with defaults (handles missing keys for forward-compat)
|
|
574
|
+
var stored = (user && user.permissions) || {};
|
|
575
|
+
var result = {};
|
|
576
|
+
var keys = Object.keys(DEFAULT_PERMISSIONS);
|
|
577
|
+
for (var i = 0; i < keys.length; i++) {
|
|
578
|
+
var k = keys[i];
|
|
579
|
+
result[k] = stored[k] !== undefined ? stored[k] : DEFAULT_PERMISSIONS[k];
|
|
580
|
+
}
|
|
581
|
+
return result;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function updateUserPermissions(userId, permissions) {
|
|
585
|
+
var data = loadUsers();
|
|
586
|
+
for (var i = 0; i < data.users.length; i++) {
|
|
587
|
+
if (data.users[i].id === userId) {
|
|
588
|
+
// Validate: only allow known permission keys with boolean values
|
|
589
|
+
var clean = {};
|
|
590
|
+
var keys = Object.keys(DEFAULT_PERMISSIONS);
|
|
591
|
+
for (var j = 0; j < keys.length; j++) {
|
|
592
|
+
var k = keys[j];
|
|
593
|
+
clean[k] = permissions[k] === true;
|
|
594
|
+
}
|
|
595
|
+
data.users[i].permissions = clean;
|
|
596
|
+
saveUsers(data);
|
|
597
|
+
return { ok: true, permissions: clean };
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
return { error: "User not found" };
|
|
601
|
+
}
|
|
602
|
+
|
|
542
603
|
// --- Project access helpers ---
|
|
543
604
|
|
|
544
605
|
function canAccessProject(userId, project) {
|
|
@@ -621,6 +682,9 @@ module.exports = {
|
|
|
621
682
|
updateLinuxUser: updateLinuxUser,
|
|
622
683
|
generatePin: generatePin,
|
|
623
684
|
createUserByAdmin: createUserByAdmin,
|
|
685
|
+
DEFAULT_PERMISSIONS: DEFAULT_PERMISSIONS,
|
|
686
|
+
getEffectivePermissions: getEffectivePermissions,
|
|
687
|
+
updateUserPermissions: updateUserPermissions,
|
|
624
688
|
getDmFavorites: getDmFavorites,
|
|
625
689
|
addDmFavorite: addDmFavorite,
|
|
626
690
|
removeDmFavorite: removeDmFavorite,
|
package/lib/worktree.js
CHANGED
|
@@ -75,9 +75,9 @@ function scanWorktrees(projectPath) {
|
|
|
75
75
|
|
|
76
76
|
// Create a new worktree inside the parent project directory
|
|
77
77
|
// Returns { ok, path, error }
|
|
78
|
-
function createWorktree(projectPath, branchName, baseBranch) {
|
|
78
|
+
function createWorktree(projectPath, branchName, dirName, baseBranch) {
|
|
79
79
|
var resolvedParent = path.resolve(projectPath);
|
|
80
|
-
var wtPath = path.join(resolvedParent, branchName);
|
|
80
|
+
var wtPath = path.join(resolvedParent, dirName || branchName);
|
|
81
81
|
var base = baseBranch || "main";
|
|
82
82
|
// Try creating with -b (new branch)
|
|
83
83
|
try {
|