clay-server 2.11.0-beta.7 → 2.11.0-beta.9
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/lib/pages.js +36 -0
- package/lib/project.js +20 -1
- package/lib/public/app.js +100 -0
- package/lib/public/css/admin.css +43 -0
- package/lib/public/modules/admin.js +131 -1
- package/lib/server.js +106 -2
- package/lib/users.js +29 -0
- package/package.json +1 -1
package/lib/pages.js
CHANGED
|
@@ -954,6 +954,7 @@ function multiUserLoginPageHtml() {
|
|
|
954
954
|
'body:JSON.stringify({username:usernameEl.value,pin:pinEl.value})})' +
|
|
955
955
|
'.then(function(r){return r.json()})' +
|
|
956
956
|
'.then(function(d){' +
|
|
957
|
+
'if(d.ok&&d.mustChangePin){showChangePinOverlay();return}' +
|
|
957
958
|
'if(d.ok){location.reload();return}' +
|
|
958
959
|
'if(d.locked){var boxes=document.querySelectorAll(".pin-digit");' +
|
|
959
960
|
'for(var i=0;i<boxes.length;i++)boxes[i].disabled=true;' +
|
|
@@ -964,6 +965,41 @@ function multiUserLoginPageHtml() {
|
|
|
964
965
|
'errs[1].textContent=msg;resetPin()})' +
|
|
965
966
|
'.catch(function(){errs[1].textContent="Connection error";btns[1].disabled=false})}' +
|
|
966
967
|
'btns[1].onclick=doLogin;' +
|
|
968
|
+
|
|
969
|
+
// Force PIN change overlay
|
|
970
|
+
'function showChangePinOverlay(){' +
|
|
971
|
+
'var ov=document.createElement("div");ov.className="c";' +
|
|
972
|
+
'ov.style.cssText="position:fixed;inset:0;background:var(--bg,#0e0e10);z-index:9999;display:flex;align-items:center;justify-content:center";' +
|
|
973
|
+
'ov.innerHTML=\'<div style="width:100%;max-width:380px;padding:24px"><h1>Set your new PIN</h1>\'+' +
|
|
974
|
+
'\'<div class="sub">Your temporary PIN has expired. Please set a new 6-digit PIN to continue.</div>\'+' +
|
|
975
|
+
'\'<div id="new-pin-boxes" class="pin-boxes">\'+' +
|
|
976
|
+
'\'<input class="pin-digit" type="tel" maxlength="1" inputmode="numeric" autocomplete="off">\'+' +
|
|
977
|
+
'\'<input class="pin-digit" type="tel" maxlength="1" inputmode="numeric" autocomplete="off">\'+' +
|
|
978
|
+
'\'<input class="pin-digit" type="tel" maxlength="1" inputmode="numeric" autocomplete="off">\'+' +
|
|
979
|
+
'\'<input class="pin-digit" type="tel" maxlength="1" inputmode="numeric" autocomplete="off">\'+' +
|
|
980
|
+
'\'<input class="pin-digit" type="tel" maxlength="1" inputmode="numeric" autocomplete="off">\'+' +
|
|
981
|
+
'\'<input class="pin-digit" type="tel" maxlength="1" inputmode="numeric" autocomplete="off">\'+' +
|
|
982
|
+
'\'</div><input type="hidden" id="new-pin">\'+' +
|
|
983
|
+
'\'<button class="btn" id="save-new-pin" disabled style="margin-top:20px">Save PIN</button>\'+' +
|
|
984
|
+
'\'<div class="err" id="new-pin-err"></div></div>\';' +
|
|
985
|
+
'document.body.appendChild(ov);' +
|
|
986
|
+
'var newPinEl=ov.querySelector("#new-pin");' +
|
|
987
|
+
'var saveBtn=ov.querySelector("#save-new-pin");' +
|
|
988
|
+
'var errEl=ov.querySelector("#new-pin-err");' +
|
|
989
|
+
'initPinBoxes("new-pin-boxes","new-pin",function(){if(!saveBtn.disabled)doSavePin()});' +
|
|
990
|
+
'var nb=ov.querySelectorAll(".pin-digit");' +
|
|
991
|
+
'for(var i=0;i<nb.length;i++)nb[i].addEventListener("input",function(){saveBtn.disabled=newPinEl.value.length!==6});' +
|
|
992
|
+
'function doSavePin(){' +
|
|
993
|
+
'saveBtn.disabled=true;errEl.textContent="";' +
|
|
994
|
+
'fetch("/api/user/pin",{method:"PUT",headers:{"Content-Type":"application/json"},' +
|
|
995
|
+
'body:JSON.stringify({newPin:newPinEl.value})})' +
|
|
996
|
+
'.then(function(r){return r.json()})' +
|
|
997
|
+
'.then(function(d){' +
|
|
998
|
+
'if(d.ok){location.reload();return}' +
|
|
999
|
+
'errEl.textContent=d.error||"Failed to save PIN";saveBtn.disabled=false})' +
|
|
1000
|
+
'.catch(function(){errEl.textContent="Connection error";saveBtn.disabled=false})}' +
|
|
1001
|
+
'saveBtn.onclick=doSavePin}' +
|
|
1002
|
+
|
|
967
1003
|
'</script></div></body></html>';
|
|
968
1004
|
}
|
|
969
1005
|
|
package/lib/project.js
CHANGED
|
@@ -1138,7 +1138,20 @@ function createProjectContext(opts) {
|
|
|
1138
1138
|
}
|
|
1139
1139
|
}
|
|
1140
1140
|
}
|
|
1141
|
-
if
|
|
1141
|
+
// Auto-create a session if none exist for this client
|
|
1142
|
+
var autoCreated = false;
|
|
1143
|
+
if (!active) {
|
|
1144
|
+
var autoOpts = {};
|
|
1145
|
+
if (wsUser) autoOpts.ownerId = wsUser.id;
|
|
1146
|
+
active = sm.createSession(autoOpts, ws);
|
|
1147
|
+
autoCreated = true;
|
|
1148
|
+
}
|
|
1149
|
+
if (active && !autoCreated) {
|
|
1150
|
+
// Backfill ownerId for legacy sessions restored without one
|
|
1151
|
+
if (!active.ownerId && wsUser) {
|
|
1152
|
+
active.ownerId = wsUser.id;
|
|
1153
|
+
sm.saveSessionFile(active);
|
|
1154
|
+
}
|
|
1142
1155
|
ws._clayActiveSession = active.localId;
|
|
1143
1156
|
sendTo(ws, { type: "session_switched", id: active.localId, cliSessionId: active.cliSessionId || null, loop: active.loop || null });
|
|
1144
1157
|
|
|
@@ -2902,6 +2915,12 @@ function createProjectContext(opts) {
|
|
|
2902
2915
|
var session = getSessionForWs(ws);
|
|
2903
2916
|
if (!session) return;
|
|
2904
2917
|
|
|
2918
|
+
// Backfill ownerId for legacy sessions restored without one
|
|
2919
|
+
if (!session.ownerId && ws._clayUser) {
|
|
2920
|
+
session.ownerId = ws._clayUser.id;
|
|
2921
|
+
sm.saveSessionFile(session);
|
|
2922
|
+
}
|
|
2923
|
+
|
|
2905
2924
|
var userMsg = { type: "user_message", text: msg.text || "" };
|
|
2906
2925
|
if (msg.images && msg.images.length > 0) {
|
|
2907
2926
|
userMsg.imageCount = msg.images.length;
|
package/lib/public/app.js
CHANGED
|
@@ -3957,6 +3957,105 @@ import { initAdmin, checkAdminAccess } from './modules/admin.js';
|
|
|
3957
3957
|
basePath: basePath,
|
|
3958
3958
|
});
|
|
3959
3959
|
|
|
3960
|
+
// --- Force PIN change overlay (for admin-created accounts with temp PIN) ---
|
|
3961
|
+
function showForceChangePinOverlay() {
|
|
3962
|
+
var ov = document.createElement("div");
|
|
3963
|
+
ov.id = "force-change-pin-overlay";
|
|
3964
|
+
ov.style.cssText = "position:fixed;inset:0;background:var(--bg,#0e0e10);z-index:99999;display:flex;align-items:center;justify-content:center;flex-direction:column";
|
|
3965
|
+
ov.innerHTML = '<div style="width:100%;max-width:380px;padding:24px;text-align:center">' +
|
|
3966
|
+
'<h2 style="margin:0 0 8px;color:var(--text,#fff);font-size:22px">Set your new PIN</h2>' +
|
|
3967
|
+
'<p style="margin:0 0 24px;color:var(--text-secondary,#aaa);font-size:14px">Your temporary PIN has expired. Please set a new 6-digit PIN to continue.</p>' +
|
|
3968
|
+
'<div style="display:flex;gap:8px;justify-content:center;margin-bottom:16px" id="fcp-boxes">' +
|
|
3969
|
+
'<input class="fcp-digit" type="tel" maxlength="1" inputmode="numeric" autocomplete="off" style="width:44px;height:52px;text-align:center;font-size:22px;font-weight:600;border:2px solid var(--border,#333);border-radius:10px;background:var(--bg-secondary,#1a1a1e);color:var(--text,#fff);outline:none">' +
|
|
3970
|
+
'<input class="fcp-digit" type="tel" maxlength="1" inputmode="numeric" autocomplete="off" style="width:44px;height:52px;text-align:center;font-size:22px;font-weight:600;border:2px solid var(--border,#333);border-radius:10px;background:var(--bg-secondary,#1a1a1e);color:var(--text,#fff);outline:none">' +
|
|
3971
|
+
'<input class="fcp-digit" type="tel" maxlength="1" inputmode="numeric" autocomplete="off" style="width:44px;height:52px;text-align:center;font-size:22px;font-weight:600;border:2px solid var(--border,#333);border-radius:10px;background:var(--bg-secondary,#1a1a1e);color:var(--text,#fff);outline:none">' +
|
|
3972
|
+
'<input class="fcp-digit" type="tel" maxlength="1" inputmode="numeric" autocomplete="off" style="width:44px;height:52px;text-align:center;font-size:22px;font-weight:600;border:2px solid var(--border,#333);border-radius:10px;background:var(--bg-secondary,#1a1a1e);color:var(--text,#fff);outline:none">' +
|
|
3973
|
+
'<input class="fcp-digit" type="tel" maxlength="1" inputmode="numeric" autocomplete="off" style="width:44px;height:52px;text-align:center;font-size:22px;font-weight:600;border:2px solid var(--border,#333);border-radius:10px;background:var(--bg-secondary,#1a1a1e);color:var(--text,#fff);outline:none">' +
|
|
3974
|
+
'<input class="fcp-digit" type="tel" maxlength="1" inputmode="numeric" autocomplete="off" style="width:44px;height:52px;text-align:center;font-size:22px;font-weight:600;border:2px solid var(--border,#333);border-radius:10px;background:var(--bg-secondary,#1a1a1e);color:var(--text,#fff);outline:none">' +
|
|
3975
|
+
'</div>' +
|
|
3976
|
+
'<button id="fcp-save" disabled style="width:100%;padding:12px;border:none;border-radius:10px;background:var(--accent,#7c3aed);color:#fff;font-size:15px;font-weight:600;cursor:pointer;opacity:0.5">Save PIN</button>' +
|
|
3977
|
+
'<div id="fcp-err" style="margin-top:12px;color:#ef4444;font-size:13px"></div>' +
|
|
3978
|
+
'</div>';
|
|
3979
|
+
document.body.appendChild(ov);
|
|
3980
|
+
|
|
3981
|
+
var digits = ov.querySelectorAll(".fcp-digit");
|
|
3982
|
+
var saveBtn = ov.querySelector("#fcp-save");
|
|
3983
|
+
var errEl = ov.querySelector("#fcp-err");
|
|
3984
|
+
|
|
3985
|
+
function getPin() {
|
|
3986
|
+
var pin = "";
|
|
3987
|
+
for (var i = 0; i < digits.length; i++) pin += digits[i].value;
|
|
3988
|
+
return pin;
|
|
3989
|
+
}
|
|
3990
|
+
|
|
3991
|
+
function updateBtn() {
|
|
3992
|
+
var ready = getPin().length === 6;
|
|
3993
|
+
saveBtn.disabled = !ready;
|
|
3994
|
+
saveBtn.style.opacity = ready ? "1" : "0.5";
|
|
3995
|
+
}
|
|
3996
|
+
|
|
3997
|
+
for (var i = 0; i < digits.length; i++) {
|
|
3998
|
+
(function (idx) {
|
|
3999
|
+
digits[idx].addEventListener("input", function () {
|
|
4000
|
+
var val = this.value.replace(/\D/g, "");
|
|
4001
|
+
this.value = val.substring(0, 1);
|
|
4002
|
+
if (val && idx < digits.length - 1) digits[idx + 1].focus();
|
|
4003
|
+
updateBtn();
|
|
4004
|
+
});
|
|
4005
|
+
digits[idx].addEventListener("keydown", function (e) {
|
|
4006
|
+
if (e.key === "Backspace" && !this.value && idx > 0) {
|
|
4007
|
+
digits[idx - 1].focus();
|
|
4008
|
+
digits[idx - 1].value = "";
|
|
4009
|
+
updateBtn();
|
|
4010
|
+
}
|
|
4011
|
+
if (e.key === "Enter" && !saveBtn.disabled) doSave();
|
|
4012
|
+
e.stopPropagation();
|
|
4013
|
+
});
|
|
4014
|
+
digits[idx].addEventListener("keyup", function (e) { e.stopPropagation(); });
|
|
4015
|
+
digits[idx].addEventListener("keypress", function (e) { e.stopPropagation(); });
|
|
4016
|
+
digits[idx].addEventListener("paste", function (e) {
|
|
4017
|
+
e.preventDefault();
|
|
4018
|
+
var text = (e.clipboardData || window.clipboardData).getData("text").replace(/\D/g, "").substring(0, 6);
|
|
4019
|
+
for (var j = 0; j < text.length && (idx + j) < digits.length; j++) {
|
|
4020
|
+
digits[idx + j].value = text[j];
|
|
4021
|
+
}
|
|
4022
|
+
if (text.length > 0) {
|
|
4023
|
+
var focusIdx = Math.min(idx + text.length, digits.length - 1);
|
|
4024
|
+
digits[focusIdx].focus();
|
|
4025
|
+
}
|
|
4026
|
+
updateBtn();
|
|
4027
|
+
});
|
|
4028
|
+
})(i);
|
|
4029
|
+
}
|
|
4030
|
+
digits[0].focus();
|
|
4031
|
+
|
|
4032
|
+
function doSave() {
|
|
4033
|
+
var pin = getPin();
|
|
4034
|
+
if (pin.length !== 6) return;
|
|
4035
|
+
saveBtn.disabled = true;
|
|
4036
|
+
saveBtn.style.opacity = "0.5";
|
|
4037
|
+
errEl.textContent = "";
|
|
4038
|
+
fetch("/api/user/pin", {
|
|
4039
|
+
method: "PUT",
|
|
4040
|
+
headers: { "Content-Type": "application/json" },
|
|
4041
|
+
body: JSON.stringify({ newPin: pin }),
|
|
4042
|
+
}).then(function (r) { return r.json(); }).then(function (d) {
|
|
4043
|
+
if (d.ok) {
|
|
4044
|
+
ov.remove();
|
|
4045
|
+
return;
|
|
4046
|
+
}
|
|
4047
|
+
errEl.textContent = d.error || "Failed to save PIN";
|
|
4048
|
+
saveBtn.disabled = false;
|
|
4049
|
+
saveBtn.style.opacity = "1";
|
|
4050
|
+
}).catch(function () {
|
|
4051
|
+
errEl.textContent = "Connection error";
|
|
4052
|
+
saveBtn.disabled = false;
|
|
4053
|
+
saveBtn.style.opacity = "1";
|
|
4054
|
+
});
|
|
4055
|
+
}
|
|
4056
|
+
saveBtn.addEventListener("click", doSave);
|
|
4057
|
+
}
|
|
4058
|
+
|
|
3960
4059
|
// --- Admin (multi-user mode) ---
|
|
3961
4060
|
var isMultiUserMode = false;
|
|
3962
4061
|
var myUserId = null;
|
|
@@ -3966,6 +4065,7 @@ import { initAdmin, checkAdminAccess } from './modules/admin.js';
|
|
|
3966
4065
|
fetch("/api/me").then(function (r) { return r.json(); }).then(function (d) {
|
|
3967
4066
|
if (d.multiUser) isMultiUserMode = true;
|
|
3968
4067
|
if (d.user && d.user.id) myUserId = d.user.id;
|
|
4068
|
+
if (d.mustChangePin) showForceChangePinOverlay();
|
|
3969
4069
|
}).catch(function () {});
|
|
3970
4070
|
// Hide server settings and update controls for non-admin users in multi-user mode
|
|
3971
4071
|
checkAdminAccess().then(function (isAdmin) {
|
package/lib/public/css/admin.css
CHANGED
|
@@ -607,6 +607,49 @@
|
|
|
607
607
|
color: var(--accent);
|
|
608
608
|
}
|
|
609
609
|
|
|
610
|
+
/* --- Temp PIN display (admin-created user credentials) --- */
|
|
611
|
+
.admin-temp-pin-box {
|
|
612
|
+
background: var(--bg-alt);
|
|
613
|
+
border: 1px solid var(--border);
|
|
614
|
+
border-radius: 10px;
|
|
615
|
+
padding: 12px 16px;
|
|
616
|
+
margin-top: 12px;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
.admin-temp-pin-row {
|
|
620
|
+
display: flex;
|
|
621
|
+
align-items: center;
|
|
622
|
+
justify-content: space-between;
|
|
623
|
+
padding: 6px 0;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
.admin-temp-pin-row + .admin-temp-pin-row {
|
|
627
|
+
border-top: 1px solid var(--border);
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
.admin-temp-pin-label {
|
|
631
|
+
font-size: 12px;
|
|
632
|
+
font-weight: 600;
|
|
633
|
+
color: var(--text-muted);
|
|
634
|
+
text-transform: uppercase;
|
|
635
|
+
letter-spacing: 0.3px;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
.admin-temp-pin-value {
|
|
639
|
+
font-size: 14px;
|
|
640
|
+
color: var(--text);
|
|
641
|
+
background: var(--bg);
|
|
642
|
+
padding: 4px 10px;
|
|
643
|
+
border-radius: 6px;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
.admin-temp-pin-highlight {
|
|
647
|
+
font-size: 18px;
|
|
648
|
+
font-weight: 700;
|
|
649
|
+
letter-spacing: 3px;
|
|
650
|
+
color: var(--accent);
|
|
651
|
+
}
|
|
652
|
+
|
|
610
653
|
/* --- Session visibility indicator --- */
|
|
611
654
|
.session-private-icon {
|
|
612
655
|
display: inline-flex;
|
|
@@ -93,7 +93,11 @@ function loadUsersTab(body) {
|
|
|
93
93
|
}
|
|
94
94
|
|
|
95
95
|
function renderUsersTab(body) {
|
|
96
|
-
var html = '<div class="admin-
|
|
96
|
+
var html = '<div class="admin-section-header">' +
|
|
97
|
+
'<div class="admin-header-btns">' +
|
|
98
|
+
'<button class="admin-action-btn" id="admin-add-user">' + iconHtml("user-plus") + ' Add User</button>' +
|
|
99
|
+
'</div></div>';
|
|
100
|
+
html += '<div class="admin-user-list">';
|
|
97
101
|
for (var i = 0; i < cachedUsers.length; i++) {
|
|
98
102
|
var u = cachedUsers[i];
|
|
99
103
|
var isMe = meInfo && meInfo.user && meInfo.user.id === u.id;
|
|
@@ -117,6 +121,14 @@ function renderUsersTab(body) {
|
|
|
117
121
|
body.innerHTML = html;
|
|
118
122
|
refreshIcons(body);
|
|
119
123
|
|
|
124
|
+
// Bind add user button
|
|
125
|
+
var addUserBtn = body.querySelector("#admin-add-user");
|
|
126
|
+
if (addUserBtn) {
|
|
127
|
+
addUserBtn.addEventListener("click", function () {
|
|
128
|
+
showAddUserModal(body);
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
120
132
|
// Bind remove buttons
|
|
121
133
|
var removeBtns = body.querySelectorAll(".admin-remove-btn");
|
|
122
134
|
for (var j = 0; j < removeBtns.length; j++) {
|
|
@@ -131,6 +143,124 @@ function renderUsersTab(body) {
|
|
|
131
143
|
}
|
|
132
144
|
}
|
|
133
145
|
|
|
146
|
+
function showAddUserModal(body) {
|
|
147
|
+
var modal = document.createElement("div");
|
|
148
|
+
modal.className = "admin-modal-overlay";
|
|
149
|
+
var html = '<div class="admin-modal">' +
|
|
150
|
+
'<div class="admin-modal-header">' +
|
|
151
|
+
'<h3>Add User</h3>' +
|
|
152
|
+
'<button class="admin-modal-close">' + iconHtml("x") + '</button>' +
|
|
153
|
+
'</div>' +
|
|
154
|
+
'<div class="admin-modal-body">' +
|
|
155
|
+
'<p class="admin-modal-desc">Create a new user account. A temporary 6-digit PIN will be generated automatically. The user must change it on first login.</p>' +
|
|
156
|
+
'<div class="admin-smtp-row"><label>Username</label>' +
|
|
157
|
+
'<input type="text" class="admin-smtp-input" id="admin-new-username" placeholder="username" autocomplete="off" maxlength="100"></div>' +
|
|
158
|
+
'<div class="admin-smtp-row"><label>Display Name</label>' +
|
|
159
|
+
'<input type="text" class="admin-smtp-input" id="admin-new-displayname" placeholder="Display Name (optional)" autocomplete="off" maxlength="30"></div>' +
|
|
160
|
+
'<div class="admin-smtp-row"><label>Email</label>' +
|
|
161
|
+
'<input type="email" class="admin-smtp-input" id="admin-new-email" placeholder="user@example.com (optional)" autocomplete="off"></div>' +
|
|
162
|
+
'<div class="admin-smtp-row"><label>Role</label>' +
|
|
163
|
+
'<select class="admin-smtp-input" id="admin-new-role"><option value="user">User</option><option value="admin">Admin</option></select></div>' +
|
|
164
|
+
'<div class="admin-smtp-error" id="admin-new-user-error"></div>' +
|
|
165
|
+
'</div>' +
|
|
166
|
+
'<div class="admin-modal-footer">' +
|
|
167
|
+
'<button class="admin-modal-save" id="admin-new-user-create">Create User</button>' +
|
|
168
|
+
'<button class="admin-modal-cancel">Cancel</button>' +
|
|
169
|
+
'</div></div>';
|
|
170
|
+
modal.innerHTML = html;
|
|
171
|
+
document.body.appendChild(modal);
|
|
172
|
+
refreshIcons(modal);
|
|
173
|
+
|
|
174
|
+
var usernameInput = modal.querySelector("#admin-new-username");
|
|
175
|
+
var displayNameInput = modal.querySelector("#admin-new-displayname");
|
|
176
|
+
var emailInput = modal.querySelector("#admin-new-email");
|
|
177
|
+
var roleSelect = modal.querySelector("#admin-new-role");
|
|
178
|
+
var createBtn = modal.querySelector("#admin-new-user-create");
|
|
179
|
+
var errorEl = modal.querySelector("#admin-new-user-error");
|
|
180
|
+
|
|
181
|
+
modal.querySelector(".admin-modal-close").addEventListener("click", function () { modal.remove(); });
|
|
182
|
+
modal.querySelector(".admin-modal-cancel").addEventListener("click", function () { modal.remove(); });
|
|
183
|
+
modal.addEventListener("click", function (e) { if (e.target === modal) modal.remove(); });
|
|
184
|
+
|
|
185
|
+
usernameInput.focus();
|
|
186
|
+
usernameInput.addEventListener("keydown", function (e) {
|
|
187
|
+
if (e.key === "Enter") createBtn.click();
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
createBtn.addEventListener("click", function () {
|
|
191
|
+
var username = usernameInput.value.trim();
|
|
192
|
+
if (!username) {
|
|
193
|
+
errorEl.textContent = "Username is required";
|
|
194
|
+
errorEl.className = "admin-smtp-error admin-smtp-error-visible";
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
createBtn.disabled = true;
|
|
198
|
+
createBtn.textContent = "Creating...";
|
|
199
|
+
errorEl.textContent = "";
|
|
200
|
+
errorEl.className = "admin-smtp-error";
|
|
201
|
+
apiPost("/api/admin/users", {
|
|
202
|
+
username: username,
|
|
203
|
+
displayName: displayNameInput.value.trim() || username,
|
|
204
|
+
email: emailInput.value.trim() || null,
|
|
205
|
+
role: roleSelect.value,
|
|
206
|
+
}).then(function (data) {
|
|
207
|
+
if (data.ok) {
|
|
208
|
+
modal.remove();
|
|
209
|
+
showTempPinModal(data.user, data.tempPin);
|
|
210
|
+
loadUsersTab(body);
|
|
211
|
+
} else {
|
|
212
|
+
errorEl.textContent = data.error || "Failed to create user";
|
|
213
|
+
errorEl.className = "admin-smtp-error admin-smtp-error-visible";
|
|
214
|
+
createBtn.disabled = false;
|
|
215
|
+
createBtn.textContent = "Create User";
|
|
216
|
+
}
|
|
217
|
+
}).catch(function () {
|
|
218
|
+
errorEl.textContent = "Failed to create user";
|
|
219
|
+
errorEl.className = "admin-smtp-error admin-smtp-error-visible";
|
|
220
|
+
createBtn.disabled = false;
|
|
221
|
+
createBtn.textContent = "Create User";
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function showTempPinModal(user, tempPin) {
|
|
227
|
+
var modal = document.createElement("div");
|
|
228
|
+
modal.className = "admin-modal-overlay";
|
|
229
|
+
var html = '<div class="admin-modal">' +
|
|
230
|
+
'<div class="admin-modal-header">' +
|
|
231
|
+
'<h3>User Created</h3>' +
|
|
232
|
+
'<button class="admin-modal-close">' + iconHtml("x") + '</button>' +
|
|
233
|
+
'</div>' +
|
|
234
|
+
'<div class="admin-modal-body">' +
|
|
235
|
+
'<p class="admin-modal-desc">Account for <strong>' + escapeHtml(user.displayName || user.username) + '</strong> has been created. Share these credentials with the user:</p>' +
|
|
236
|
+
'<div class="admin-temp-pin-box">' +
|
|
237
|
+
'<div class="admin-temp-pin-row"><span class="admin-temp-pin-label">Username</span><code class="admin-temp-pin-value">' + escapeHtml(user.username) + '</code></div>' +
|
|
238
|
+
'<div class="admin-temp-pin-row"><span class="admin-temp-pin-label">Temporary PIN</span><code class="admin-temp-pin-value admin-temp-pin-highlight">' + escapeHtml(tempPin) + '</code></div>' +
|
|
239
|
+
'</div>' +
|
|
240
|
+
'<p class="admin-modal-desc" style="margin-top:12px;color:var(--text-secondary);font-size:12px;">This PIN is one-time use. The user will be prompted to set a new PIN on first login.</p>' +
|
|
241
|
+
'</div>' +
|
|
242
|
+
'<div class="admin-modal-footer">' +
|
|
243
|
+
'<button class="admin-modal-save" id="admin-copy-credentials">Copy Credentials</button>' +
|
|
244
|
+
'<button class="admin-modal-cancel">Close</button>' +
|
|
245
|
+
'</div></div>';
|
|
246
|
+
modal.innerHTML = html;
|
|
247
|
+
document.body.appendChild(modal);
|
|
248
|
+
refreshIcons(modal);
|
|
249
|
+
|
|
250
|
+
modal.querySelector(".admin-modal-close").addEventListener("click", function () { modal.remove(); });
|
|
251
|
+
modal.querySelector(".admin-modal-cancel").addEventListener("click", function () { modal.remove(); });
|
|
252
|
+
modal.addEventListener("click", function (e) { if (e.target === modal) modal.remove(); });
|
|
253
|
+
|
|
254
|
+
modal.querySelector("#admin-copy-credentials").addEventListener("click", function () {
|
|
255
|
+
var text = "Username: " + user.username + "\nTemporary PIN: " + tempPin;
|
|
256
|
+
copyToClipboard(text).then(function () {
|
|
257
|
+
showToast("Credentials copied to clipboard");
|
|
258
|
+
}).catch(function () {
|
|
259
|
+
showToast("Username: " + user.username + " / PIN: " + tempPin);
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
134
264
|
function removeUser(userId, body) {
|
|
135
265
|
apiDelete("/api/admin/users/" + userId).then(function (data) {
|
|
136
266
|
if (data.ok) {
|
package/lib/server.js
CHANGED
|
@@ -569,11 +569,13 @@ function createServer(opts) {
|
|
|
569
569
|
}
|
|
570
570
|
clearPinFailures(ip);
|
|
571
571
|
var session = createMultiUserSession(user.id, tlsOptions);
|
|
572
|
+
var loginResp = { ok: true, user: { id: user.id, username: user.username, role: user.role } };
|
|
573
|
+
if (user.mustChangePin) loginResp.mustChangePin = true;
|
|
572
574
|
res.writeHead(200, {
|
|
573
575
|
"Set-Cookie": session.cookie,
|
|
574
576
|
"Content-Type": "application/json",
|
|
575
577
|
});
|
|
576
|
-
res.end(JSON.stringify(
|
|
578
|
+
res.end(JSON.stringify(loginResp));
|
|
577
579
|
} catch (e) {
|
|
578
580
|
res.writeHead(400, { "Content-Type": "application/json" });
|
|
579
581
|
res.end('{"error":"Invalid request"}');
|
|
@@ -959,6 +961,45 @@ function createServer(opts) {
|
|
|
959
961
|
return;
|
|
960
962
|
}
|
|
961
963
|
|
|
964
|
+
// Change own PIN (multi-user mode)
|
|
965
|
+
if (req.method === "PUT" && fullUrl === "/api/user/pin") {
|
|
966
|
+
if (!users.isMultiUser()) {
|
|
967
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
968
|
+
res.end('{"error":"Not found"}');
|
|
969
|
+
return;
|
|
970
|
+
}
|
|
971
|
+
var mu = getMultiUserFromReq(req);
|
|
972
|
+
if (!mu) {
|
|
973
|
+
res.writeHead(401, { "Content-Type": "application/json" });
|
|
974
|
+
res.end('{"error":"unauthorized"}');
|
|
975
|
+
return;
|
|
976
|
+
}
|
|
977
|
+
var body = "";
|
|
978
|
+
req.on("data", function (chunk) { body += chunk; });
|
|
979
|
+
req.on("end", function () {
|
|
980
|
+
try {
|
|
981
|
+
var data = JSON.parse(body);
|
|
982
|
+
if (!data.newPin || typeof data.newPin !== "string" || !/^\d{6}$/.test(data.newPin)) {
|
|
983
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
984
|
+
res.end('{"error":"PIN must be exactly 6 digits"}');
|
|
985
|
+
return;
|
|
986
|
+
}
|
|
987
|
+
var result = users.updateUserPin(mu.id, data.newPin);
|
|
988
|
+
if (result.error) {
|
|
989
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
990
|
+
res.end(JSON.stringify({ error: result.error }));
|
|
991
|
+
return;
|
|
992
|
+
}
|
|
993
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
994
|
+
res.end('{"ok":true}');
|
|
995
|
+
} catch (e) {
|
|
996
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
997
|
+
res.end('{"error":"Invalid request"}');
|
|
998
|
+
}
|
|
999
|
+
});
|
|
1000
|
+
return;
|
|
1001
|
+
}
|
|
1002
|
+
|
|
962
1003
|
// --- Admin API endpoints (multi-user mode only) ---
|
|
963
1004
|
|
|
964
1005
|
// List all users (admin only)
|
|
@@ -1016,6 +1057,67 @@ function createServer(opts) {
|
|
|
1016
1057
|
return;
|
|
1017
1058
|
}
|
|
1018
1059
|
|
|
1060
|
+
// Create user (admin only) — generates a temporary PIN that must be changed on first login
|
|
1061
|
+
if (req.method === "POST" && fullUrl === "/api/admin/users") {
|
|
1062
|
+
if (!users.isMultiUser()) {
|
|
1063
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
1064
|
+
res.end('{"error":"Not found"}');
|
|
1065
|
+
return;
|
|
1066
|
+
}
|
|
1067
|
+
var mu = getMultiUserFromReq(req);
|
|
1068
|
+
if (!mu || mu.role !== "admin") {
|
|
1069
|
+
res.writeHead(403, { "Content-Type": "application/json" });
|
|
1070
|
+
res.end('{"error":"Admin access required"}');
|
|
1071
|
+
return;
|
|
1072
|
+
}
|
|
1073
|
+
var body = "";
|
|
1074
|
+
req.on("data", function (chunk) { body += chunk; });
|
|
1075
|
+
req.on("end", function () {
|
|
1076
|
+
try {
|
|
1077
|
+
var data = JSON.parse(body);
|
|
1078
|
+
if (!data.username || typeof data.username !== "string" || data.username.trim().length < 1) {
|
|
1079
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1080
|
+
res.end('{"error":"Username is required"}');
|
|
1081
|
+
return;
|
|
1082
|
+
}
|
|
1083
|
+
var result = users.createUserByAdmin({
|
|
1084
|
+
username: data.username.trim(),
|
|
1085
|
+
displayName: data.displayName ? data.displayName.trim() : data.username.trim(),
|
|
1086
|
+
email: data.email ? data.email.trim() : null,
|
|
1087
|
+
role: data.role === "admin" ? "admin" : "user",
|
|
1088
|
+
});
|
|
1089
|
+
if (result.error) {
|
|
1090
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1091
|
+
res.end(JSON.stringify({ error: result.error }));
|
|
1092
|
+
return;
|
|
1093
|
+
}
|
|
1094
|
+
// Auto-provision Linux account if OS users mode is enabled
|
|
1095
|
+
if (osUsers && !result.user.linuxUser) {
|
|
1096
|
+
var provision = provisionLinuxUser(result.user.username);
|
|
1097
|
+
if (provision.ok) {
|
|
1098
|
+
users.updateLinuxUser(result.user.id, provision.linuxUser);
|
|
1099
|
+
if (onUserProvisioned) onUserProvisioned(result.user.id, provision.linuxUser);
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1103
|
+
res.end(JSON.stringify({
|
|
1104
|
+
ok: true,
|
|
1105
|
+
user: {
|
|
1106
|
+
id: result.user.id,
|
|
1107
|
+
username: result.user.username,
|
|
1108
|
+
displayName: result.user.displayName,
|
|
1109
|
+
role: result.user.role,
|
|
1110
|
+
},
|
|
1111
|
+
tempPin: result.tempPin,
|
|
1112
|
+
}));
|
|
1113
|
+
} catch (e) {
|
|
1114
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1115
|
+
res.end('{"error":"Invalid request"}');
|
|
1116
|
+
}
|
|
1117
|
+
});
|
|
1118
|
+
return;
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1019
1121
|
// Set Linux user mapping (admin only, OS-level multi-user)
|
|
1020
1122
|
if (req.method === "PUT" && fullUrl.match(/^\/api\/admin\/users\/[^/]+\/linux-user$/)) {
|
|
1021
1123
|
if (!users.isMultiUser()) {
|
|
@@ -1478,8 +1580,10 @@ function createServer(opts) {
|
|
|
1478
1580
|
res.end('{"error":"unauthorized"}');
|
|
1479
1581
|
return;
|
|
1480
1582
|
}
|
|
1583
|
+
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 } };
|
|
1584
|
+
if (mu.mustChangePin) meResp.mustChangePin = true;
|
|
1481
1585
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1482
|
-
res.end(JSON.stringify(
|
|
1586
|
+
res.end(JSON.stringify(meResp));
|
|
1483
1587
|
return;
|
|
1484
1588
|
}
|
|
1485
1589
|
|
package/lib/users.js
CHANGED
|
@@ -140,6 +140,7 @@ function createUser(opts) {
|
|
|
140
140
|
displayName: opts.displayName || opts.username,
|
|
141
141
|
pinHash: hashPin(opts.pin),
|
|
142
142
|
role: opts.role || "user",
|
|
143
|
+
mustChangePin: !!opts.mustChangePin,
|
|
143
144
|
createdAt: Date.now(),
|
|
144
145
|
linuxUser: opts.linuxUser || null,
|
|
145
146
|
profile: opts.profile || {
|
|
@@ -258,6 +259,7 @@ function updateUserPin(userId, newPin) {
|
|
|
258
259
|
for (var i = 0; i < data.users.length; i++) {
|
|
259
260
|
if (data.users[i].id === userId) {
|
|
260
261
|
data.users[i].pinHash = hashPin(newPin);
|
|
262
|
+
data.users[i].mustChangePin = false;
|
|
261
263
|
saveUsers(data);
|
|
262
264
|
return { ok: true };
|
|
263
265
|
}
|
|
@@ -265,6 +267,31 @@ function updateUserPin(userId, newPin) {
|
|
|
265
267
|
return { error: "User not found" };
|
|
266
268
|
}
|
|
267
269
|
|
|
270
|
+
// Generate a random 6-digit PIN
|
|
271
|
+
function generatePin() {
|
|
272
|
+
var digits = "";
|
|
273
|
+
var bytes = crypto.randomBytes(6);
|
|
274
|
+
for (var i = 0; i < 6; i++) {
|
|
275
|
+
digits += (bytes[i] % 10).toString();
|
|
276
|
+
}
|
|
277
|
+
return digits;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Admin creates a user with a temporary PIN (must be changed on first login)
|
|
281
|
+
function createUserByAdmin(opts) {
|
|
282
|
+
var tempPin = generatePin();
|
|
283
|
+
var result = createUser({
|
|
284
|
+
username: opts.username,
|
|
285
|
+
displayName: opts.displayName || opts.username,
|
|
286
|
+
email: opts.email || null,
|
|
287
|
+
pin: tempPin,
|
|
288
|
+
role: opts.role || "user",
|
|
289
|
+
mustChangePin: true,
|
|
290
|
+
});
|
|
291
|
+
if (result.error) return result;
|
|
292
|
+
return { ok: true, user: result.user, tempPin: tempPin };
|
|
293
|
+
}
|
|
294
|
+
|
|
268
295
|
// --- Linux user mapping (OS-level multi-user) ---
|
|
269
296
|
|
|
270
297
|
function updateLinuxUser(userId, linuxUsername) {
|
|
@@ -506,4 +533,6 @@ module.exports = {
|
|
|
506
533
|
canAccessSession: canAccessSession,
|
|
507
534
|
getOtherUsers: getOtherUsers,
|
|
508
535
|
updateLinuxUser: updateLinuxUser,
|
|
536
|
+
generatePin: generatePin,
|
|
537
|
+
createUserByAdmin: createUserByAdmin,
|
|
509
538
|
};
|