@javierfrancoleal/dsh-supervisor 1.0.13 → 1.0.15
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/dashboard.html +22 -8
- package/package.json +1 -1
- package/server.js +23 -5
package/dashboard.html
CHANGED
|
@@ -75,6 +75,13 @@ pre{background:#0f172a;color:#d7e3f4;border-radius:12px;padding:12px;font-size:1
|
|
|
75
75
|
.tabs button.active{background:var(--blue);color:#fff;border-color:var(--blue)}
|
|
76
76
|
table{width:100%;border-collapse:collapse;font-size:14px}
|
|
77
77
|
td,th{border-bottom:1px solid var(--line);padding:9px 6px;text-align:left}
|
|
78
|
+
#splash{position:fixed;inset:0;background:linear-gradient(135deg,#4D6BFE,#7c5cff);display:flex;align-items:center;justify-content:center;z-index:100;transition:opacity .5s,visibility .5s}
|
|
79
|
+
#splash.done{opacity:0;visibility:hidden}
|
|
80
|
+
.splash-inner{text-align:center;color:#fff;padding:20px}
|
|
81
|
+
.splash-inner b{display:block;font-size:22px;margin-top:14px;letter-spacing:.02em}
|
|
82
|
+
.splash-inner span{font-size:12.5px;opacity:.85}
|
|
83
|
+
@media (prefers-reduced-motion: no-preference){.splash-inner svg path{stroke-dasharray:100;stroke-dashoffset:100;animation:drawStroke 1.2s cubic-bezier(.4,0,.2,1) forwards}}
|
|
84
|
+
@media (prefers-reduced-motion: reduce){#splash{display:none}}
|
|
78
85
|
.hidden{display:none}
|
|
79
86
|
.kban{background:#fef3c7;border:1px solid #f59e0b;color:#92400e;border-radius:12px;padding:12px 14px;margin:10px 0;font-weight:700;font-size:15px}
|
|
80
87
|
.chip{background:rgba(255,255,255,.18);border:1px solid rgba(255,255,255,.35);border-radius:999px;padding:5px 12px;font-size:13px;font-weight:600}
|
|
@@ -141,6 +148,10 @@ body.wall pre{max-height:40vh}
|
|
|
141
148
|
</style>
|
|
142
149
|
</head>
|
|
143
150
|
<body>
|
|
151
|
+
<div id="splash"><div class="splash-inner">
|
|
152
|
+
<svg width="96" height="96" viewBox="0 0 64 64" aria-hidden="true"><defs><linearGradient id="splg" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#ffffff"/><stop offset="1" stop-color="#e0e7ff"/></linearGradient></defs><path pathLength="100" d="M10 32 C10 22 16 14 32 14 C48 14 54 22 54 32 C54 42 48 50 32 50 C20 50 12.5 45.2 10.5 38.5 C14 42.5 20 41 25.4 34.8 A6.6 6.6 0 0 1 38.6 34.8 C38.6 31.4 36.6 28.2 32 22.5 C27.4 28.2 25.4 31.4 25.4 34.8" fill="none" stroke="url(#splg)" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
|
153
|
+
<b>Le Veilleux</b><span id="splashTag">The AI works. The human watches, decides, reviews.</span>
|
|
154
|
+
</div></div>
|
|
144
155
|
<header>
|
|
145
156
|
<h1 id="appTitle"><svg width="26" height="26" viewBox="0 0 64 64" aria-hidden="true"><path d="M10 32 C10 22 16 14 32 14 C48 14 54 22 54 32 C54 42 48 50 32 50 C20 50 12.5 45.2 10.5 38.5 C14 42.5 20 41 25.4 34.8 A6.6 6.6 0 0 1 38.6 34.8 C38.6 31.4 36.6 28.2 32 22.5 C27.4 28.2 25.4 31.4 25.4 34.8" fill="none" stroke="#ffffff" stroke-width="7" stroke-linecap="round" stroke-linejoin="round"/></svg> <span style="vertical-align:5px">Le Veilleux</span></h1>
|
|
146
157
|
<span class="who" id="who"></span>
|
|
@@ -193,7 +204,7 @@ body.wall pre{max-height:40vh}
|
|
|
193
204
|
<table id="usersTable"><thead><tr><th id="thUser">User</th><th id="thRole">Role</th></tr></thead><tbody></tbody></table>
|
|
194
205
|
<h2 style="margin-top:14px" id="addWorkerTitle">Add worker</h2>
|
|
195
206
|
<div id="suggestUsers" class="row"></div>
|
|
196
|
-
<input id="newUser" placeholder="User name"><input id="newPass" type="password" placeholder="Password">
|
|
207
|
+
<input id="newUser" placeholder="User name"><input id="newPass" type="password" placeholder="Password" minlength="8">
|
|
197
208
|
<input id="newLabel" placeholder="Role label (e.g. Cook)">
|
|
198
209
|
<select id="newRole"><option id="optEmployee" value="employee">employee</option><option id="optLead" value="lead">lead</option><option id="optOwner" value="owner">owner</option></select>
|
|
199
210
|
<button class="btn" id="addUserBtn">Add worker</button>
|
|
@@ -328,6 +339,7 @@ $("darkBtn").onclick=()=>{darkOn=!darkOn;try{localStorage.setItem("dsh-dark",dar
|
|
|
328
339
|
$("soundBtn").onclick=()=>{soundOn=!soundOn;try{localStorage.setItem("dsh-sound",soundOn?"1":"0");}catch(e){}applyToggles();};
|
|
329
340
|
try{darkOn=localStorage.getItem("dsh-dark")==="1";soundOn=localStorage.getItem("dsh-sound")!=="0";}catch(e){}
|
|
330
341
|
applyToggles();
|
|
342
|
+
setTimeout(()=>{$("splash").classList.add("done");},1500);
|
|
331
343
|
async function reRenderDynamic(){
|
|
332
344
|
if(!me)return;
|
|
333
345
|
if(me.role==="owner"){loadMetrics($("ownerMetrics"));loadUsers();loadUsage();loadField();renderOrders();renderSuggest();}
|
|
@@ -374,6 +386,7 @@ function panelLinks(){const u=me?encodeURIComponent(me.name):"",r=me?encodeURICo
|
|
|
374
386
|
function applyLang(){
|
|
375
387
|
try{localStorage.setItem("dsh-lang",cur);}catch(e){}
|
|
376
388
|
$("welcomeClose").onclick=()=>{$("welcomeCard").style.display="none";try{localStorage.setItem("lv-welcome","1");}catch(e){}};
|
|
389
|
+
$("splashTag").textContent=t("tagline");
|
|
377
390
|
$("welcomeTitle").textContent=t("welcomeTitle");$("welcomeBody").textContent=t("welcomeBody");$("wallNote").textContent=t("wallNote");
|
|
378
391
|
$("loginTitle").textContent=t("login");$("tagline").textContent=t("tagline");$("loginUser").placeholder=t("user");
|
|
379
392
|
["loginUser","loginPass"].forEach(id=>$(id).addEventListener("input",()=>$("loginError").classList.add("hidden")));$("loginPass").placeholder=t("password");
|
|
@@ -414,18 +427,19 @@ const LVL={employee:"lvlEmployee",lead:"lvlLead",owner:"lvlOwner"};
|
|
|
414
427
|
function setWho(){const w=$("who");w.textContent=me?(me.label||me.name):"";const c=$("idChip");if(me){c.style.display="inline-block";c.textContent=me.name.slice(0,1).toUpperCase()+" · "+(me.label||me.name)+" · "+t(LVL[me.role]||"lvlEmployee");}else{c.style.display="none";}$("logoutBtn").style.display=me?"inline-block":"none";}
|
|
415
428
|
function toast(m){const el=$("toast");el.textContent=m;el.classList.add("show");clearTimeout(el.__t);el.__t=setTimeout(()=>el.classList.remove("show"),2200);}
|
|
416
429
|
function skel(n){let out="";for(let i=0;i<n;i++)out+='<div class="skel" style="height:38px;margin:8px 0"></div>';return out;}
|
|
430
|
+
const esc=(s)=>String(s==null?"":s).replace(/[&<>"']/g,(c)=>({"&":"&","<":"<",">":">","\"":""","'":"'"}[c]));
|
|
417
431
|
function empty(ico,txt){return '<div class="empty"><span class="ico">'+ico+'</span>'+txt+'</div>';}
|
|
418
432
|
function showMsg(x){$("msg").textContent=x;toast(x);setTimeout(()=>$("msg").textContent="",2500);}
|
|
419
433
|
function countUp(el,target,dur){dur=dur||650;const t0=performance.now();const step=(now)=>{const p=Math.min(1,(now-t0)/dur);el.textContent=Math.round(target*p).toLocaleString();if(p<1)requestAnimationFrame(step);};requestAnimationFrame(step);}
|
|
420
434
|
async function loadMetrics(into){into.innerHTML=skel(2);const m=await api("/api/metrics");into.innerHTML="";const cards=[[t("reports"),m.reports],[t("week"),m.reportsWeek],[t("feedback"),m.feedback],[t("workersActive"),m.workers]];for(const [l,v] of cards){const d=document.createElement("div");d.className="metric";d.innerHTML='<div class="v">–</div><div class="l">'+l+'</div>';into.appendChild(d);if(typeof v==="number")countUp(d.querySelector(".v"),v);}}
|
|
421
435
|
async function loadUsage(){const tb=$("usageTable")?$("usageTable").querySelector("tbody"):null;if(!tb)return;tb.innerHTML='<tr><td colspan="4">'+skel(2)+'</td></tr>';const r=await api("/api/usage");tb.innerHTML="";
|
|
422
436
|
if(!(r.rows||[]).length){tb.innerHTML='<tr><td colspan="4">'+empty("📊",t("noUsage"))+'</td></tr>';return;}
|
|
423
|
-
(r.rows||[]).forEach(x=>{const tr=document.createElement("tr");tr.innerHTML="<td>"+x.worker+"</td><td>"+x.sessions+"</td><td>"+(x.tokens||0).toLocaleString()+"</td><td>$"+(x.cost||0).toFixed(2)+"</td>";tb.appendChild(tr);});}
|
|
437
|
+
(r.rows||[]).forEach(x=>{const tr=document.createElement("tr");tr.innerHTML="<td>"+esc(x.worker)+"</td><td>"+x.sessions+"</td><td>"+(x.tokens||0).toLocaleString()+"</td><td>$"+(x.cost||0).toFixed(2)+"</td>";tb.appendChild(tr);});}
|
|
424
438
|
function orderRow(o,canUpdate){
|
|
425
439
|
const d=document.createElement("div");d.className="order";
|
|
426
440
|
const stat=document.createElement("span");stat.className="stat";const ICON={sent:"📤",prep:"🍳",ready:"✅",delivered:"🛎️"};
|
|
427
441
|
stat.textContent=(ICON[o.status]||"📤")+" "+t(o.status||"sent");
|
|
428
|
-
d.innerHTML="<b>"+(o.table?((PACK&&PACK.tableLabel&&PACK.tableLabel[cur]?PACK.tableLabel[cur]:"Table")+" "+o.table+" · "):"")+o.person+"</b>";
|
|
442
|
+
d.innerHTML="<b>"+(o.table?((PACK&&PACK.tableLabel&&PACK.tableLabel[cur]?PACK.tableLabel[cur]:"Table")+" "+o.table+" · "):"")+esc(o.person)+"</b>";
|
|
429
443
|
const txt=document.createElement("span");txt.className="txt";txt.textContent=o.request;
|
|
430
444
|
d.appendChild(txt);d.appendChild(stat);
|
|
431
445
|
if(canUpdate&&o.status!=="delivered"){
|
|
@@ -473,10 +487,10 @@ function renderSummary(live){
|
|
|
473
487
|
for(const p of live.speakers){
|
|
474
488
|
const d=document.createElement("div");d.className="person";
|
|
475
489
|
const done=p.requests.filter(r=>r.status==="confirmed").length;
|
|
476
|
-
let h='<div class="pname">'+t("person")+" "+(p.id.replace("p",""))+" — "+p.name+'<span class="pcnt">('+done+'/'+p.requests.length+')</span></div>';
|
|
490
|
+
let h='<div class="pname">'+t("person")+" "+(p.id.replace("p",""))+" — "+esc(p.name)+'<span class="pcnt">('+done+'/'+p.requests.length+')</span></div>';
|
|
477
491
|
for(const r of p.requests){
|
|
478
492
|
const confirmed=r.status==="confirmed";
|
|
479
|
-
h+='<div class="req"><span class="txt">'+r.text+'</span>'+
|
|
493
|
+
h+='<div class="req"><span class="txt">'+esc(r.text)+'</span>'+
|
|
480
494
|
'<span class="tier tier-'+r.tier+'">'+t("tier"+r.tier)+'</span>'+
|
|
481
495
|
'<button class="confirmBtn'+(confirmed?" done":"")+'" data-req="'+r.id+'">'+(confirmed?t("confirmed"):t("confirm"))+'</button></div>'+
|
|
482
496
|
(confirmed?'<div class="sentline">✓ '+t("sentNote")+'</div>':'');
|
|
@@ -521,9 +535,9 @@ $("loginBtn").onclick=async()=>{
|
|
|
521
535
|
function logout(){api("/api/logout",{});token=null;me=null;sessionStorage.clear();if(livePoll)clearInterval(livePoll);if(kitchenPoll)clearInterval(kitchenPoll);setWho();show("loginView");}
|
|
522
536
|
$("logoutBtn").onclick=logout;
|
|
523
537
|
const sb=$("switchBtn");if(sb)sb.onclick=logout;
|
|
524
|
-
async function loadUsers(){const tb=$("usersTable").querySelector("tbody");tb.innerHTML='<tr><td colspan="2">'+skel(2)+'</td></tr>';const r=await api("/api/users");tb.innerHTML="";(r.users||[]).forEach(u=>{const tr=document.createElement("tr");tr.innerHTML="<td>"+u.name+"</td><td>"+(u.label||u.name)+" · "+t(LVL[u.role]||"lvlEmployee")+"</td>";tb.appendChild(tr);});}
|
|
538
|
+
async function loadUsers(){const tb=$("usersTable").querySelector("tbody");tb.innerHTML='<tr><td colspan="2">'+skel(2)+'</td></tr>';const r=await api("/api/users");tb.innerHTML="";(r.users||[]).forEach(u=>{const tr=document.createElement("tr");tr.innerHTML="<td>"+esc(u.name)+"</td><td>"+esc(u.label||u.name)+" · "+t(LVL[u.role]||"lvlEmployee")+"</td>";tb.appendChild(tr);});}
|
|
525
539
|
$("addUserBtn").onclick=async()=>{const r=await api("/api/users",{name:$("newUser").value,password:$("newPass").value,role:$("newRole").value,label:$("newLabel").value});showMsg(r.error?"Error: "+r.error:t("workerAdded"));if(!r.error){loadUsers();$("newUser").value=$("newPass").value="";}};
|
|
526
|
-
async function loadField(){$("reports").innerHTML=skel(2);$("feedback").innerHTML=skel(1);$("trail").innerHTML=skel(1);const rc=(await api("/api/reports")).content;if(rc){$("reports").innerHTML="<pre>"+rc+"</pre>";}else{$("reports").innerHTML=empty("📋",t("noReports"));}const fc=(await api("/api/feedback")).content;if(fc){$("feedback").innerHTML="<pre>"+fc+"</pre>";}else{$("feedback").innerHTML=empty("💬",t("feedbackQueue"));}const live=await api("/api/live");const ib=(live.inbox||[]).map(x=>"[•] "+x.worker+": "+x.text).join("\n");if(ib){$("trail").innerHTML="<pre>"+ib+"</pre>";}else{$("trail").innerHTML=empty("🗒️",t("conversationTrail"));}}
|
|
540
|
+
async function loadField(){$("reports").innerHTML=skel(2);$("feedback").innerHTML=skel(1);$("trail").innerHTML=skel(1);const rc=(await api("/api/reports")).content;if(rc){$("reports").innerHTML="<pre>"+esc(rc)+"</pre>";}else{$("reports").innerHTML=empty("📋",t("noReports"));}const fc=(await api("/api/feedback")).content;if(fc){$("feedback").innerHTML="<pre>"+esc(fc)+"</pre>";}else{$("feedback").innerHTML=empty("💬",t("feedbackQueue"));}const live=await api("/api/live");const ib=(live.inbox||[]).map(x=>"[•] "+esc(x.worker)+": "+esc(x.text)).join("\n");if(ib){$("trail").innerHTML="<pre>"+ib+"</pre>";}else{$("trail").innerHTML=empty("🗒️",t("conversationTrail"));}}
|
|
527
541
|
async function loadLead(){$("leadReports").textContent=(await api("/api/reports")).content||"(empty)";$("leadFeedback").textContent=(await api("/api/feedback")).content||"(empty)";}
|
|
528
542
|
$("refreshReports").onclick=loadField;$("leadRefresh").onclick=loadLead;
|
|
529
543
|
const ar=$("autoRefresh");
|
|
@@ -558,7 +572,7 @@ $("empFbBtn").onclick=async()=>{const r=await api("/api/feedback",{worker:me.nam
|
|
|
558
572
|
const st=sessionStorage.getItem("dsh-sup-token");
|
|
559
573
|
if(st){token=st;me=JSON.parse(sessionStorage.getItem("dsh-sup-me")||"null");(async()=>{const r=await api("/api/me");if(!r||!r.name){logout();return;}enter();})();}
|
|
560
574
|
else setWho();
|
|
561
|
-
async function wallTick(){try{const r=await (await fetch("/api/wall")).json();const cards=[[t("week"),r.reportsWeek],[t("ordersTitle"),r.orders],[t("feedback"),r.feedback],[t("workersActive"),r.workers],[t("reportsTotal"),r.reports]];$("wallCards").innerHTML=cards.map(c=>'<div class="wall-card"><div class="v">'+(c[1]===undefined?"–":c[1])+'</div><div class="l">'+c[0]+'</div></div>').join("");const ticks=[...(r.ordersTail||[]).map(x=>"🔔 "+x),...(r.feedbackTail||"").split("\n").filter(Boolean).slice(-4)];$("wallFeed").innerHTML=ticks.length?ticks.map(x=>'<div class="tick">'+x+'</div>').join(""):'<div class="tick dim">'+t("noActivity")+'</div>';try{const s=await (await fetch("/api/sector")).json();$("wallSector").textContent=(s&&s.id)?s.id:"";}catch(e){}}catch(e){$("wallFeed").innerHTML='<div class="tick dim">'+t("cannotReach")+'</div>';}}
|
|
575
|
+
async function wallTick(){try{const r=await (await fetch("/api/wall")).json();const cards=[[t("week"),r.reportsWeek],[t("ordersTitle"),r.orders],[t("feedback"),r.feedback],[t("workersActive"),r.workers],[t("reportsTotal"),r.reports]];$("wallCards").innerHTML=cards.map(c=>'<div class="wall-card"><div class="v">'+(c[1]===undefined?"–":c[1])+'</div><div class="l">'+c[0]+'</div></div>').join("");const ticks=[...(r.ordersTail||[]).map(x=>"🔔 "+esc(x)),...(r.feedbackTail||"").split("\n").filter(Boolean).slice(-4).map(esc)];$("wallFeed").innerHTML=ticks.length?ticks.map(x=>'<div class="tick">'+x+'</div>').join(""):'<div class="tick dim">'+t("noActivity")+'</div>';try{const s=await (await fetch("/api/sector")).json();$("wallSector").textContent=(s&&s.id)?s.id:"";}catch(e){}}catch(e){$("wallFeed").innerHTML='<div class="tick dim">'+t("cannotReach")+'</div>';}}
|
|
562
576
|
function clockTick(){const d=new Date();$("wallClock").textContent=d.toLocaleTimeString([],{hour:"2-digit",minute:"2-digit"});$("wallDate").textContent=d.toLocaleDateString([],{weekday:"short",day:"numeric",month:"short"});}
|
|
563
577
|
if(location.search.indexOf("wall=1")>=0){
|
|
564
578
|
document.body.classList.add("wall");show("wallView");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@javierfrancoleal/dsh-supervisor",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.15",
|
|
4
4
|
"description": "The Veilleux central control plane of the J.Franco agentic workflow suite: user accounts, the live conversation flow (listen → per-person summary → confirm → orders), telemetry, and the universal sector dashboard with 10 languages. Created by J.Franco.",
|
|
5
5
|
"author": "J.Franco",
|
|
6
6
|
"license": "MIT",
|
package/server.js
CHANGED
|
@@ -46,6 +46,18 @@ const PRICE_PER_MILLION = Number(process.env.DSH_PRICE_PER_MILLION || 0.28);
|
|
|
46
46
|
const TLS_KEY = process.env.DSH_TLS_KEY || path.join(DATA_DIR, "certs", "key.pem");
|
|
47
47
|
const TLS_CERT = process.env.DSH_TLS_CERT || path.join(DATA_DIR, "certs", "cert.pem");
|
|
48
48
|
const SESSION_TTL = 8 * 60 * 60 * 1000;
|
|
49
|
+
// Login brute-force protection: per-IP escalating lockout.
|
|
50
|
+
const loginAttempts = new Map();
|
|
51
|
+
const MAX_FAILS = 8, BASE_LOCK_MS = 60 * 1000;
|
|
52
|
+
function loginBlocked(ip) {
|
|
53
|
+
const a = loginAttempts.get(ip);
|
|
54
|
+
if (!a) return null;
|
|
55
|
+
if (a.lockUntil && Date.now() < a.lockUntil) return Math.ceil((a.lockUntil - Date.now()) / 1000);
|
|
56
|
+
if (a.fails >= MAX_FAILS) { a.lockUntil = Date.now() + BASE_LOCK_MS * Math.pow(2, a.lockCount || 0); a.lockCount = (a.lockCount || 0) + 1; a.fails = 0; return Math.ceil((a.lockUntil - Date.now()) / 1000); }
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
function loginFail(ip) { const a = loginAttempts.get(ip) || { fails: 0 }; a.fails += 1; loginAttempts.set(ip, a); }
|
|
60
|
+
function loginOk(ip) { loginAttempts.delete(ip); }
|
|
49
61
|
|
|
50
62
|
// ---------- SQLite storage (--db) ----------
|
|
51
63
|
let db = null;
|
|
@@ -73,6 +85,7 @@ if (ARGS[0] === "init") {
|
|
|
73
85
|
fs.mkdirSync(DATA_DIR, { recursive: true });
|
|
74
86
|
if (db) { db.exec("CREATE TABLE IF NOT EXISTS users (name TEXT PRIMARY KEY, salt TEXT, hash TEXT, role TEXT, label TEXT, created TEXT)"); try { db.exec("ALTER TABLE users ADD COLUMN label TEXT"); } catch {} }
|
|
75
87
|
const add = (name, pw, role, label) => {
|
|
88
|
+
if (String(pw).length < 8) { console.error("init: password for " + name + " must be at least 8 characters"); process.exit(1); }
|
|
76
89
|
const salt = crypto.randomBytes(16).toString("hex");
|
|
77
90
|
const hash = crypto.scryptSync(pw, salt, 64).toString("hex");
|
|
78
91
|
if (db) db.prepare("INSERT OR REPLACE INTO users (name,salt,hash,role,label,created) VALUES (?,?,?,?,?,?)").run(name, salt, hash, role, label || name, new Date().toISOString());
|
|
@@ -240,26 +253,30 @@ const CORS = {
|
|
|
240
253
|
"Access-Control-Allow-Methods": "GET,POST,OPTIONS",
|
|
241
254
|
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
|
242
255
|
};
|
|
243
|
-
function send(res, code, obj) {
|
|
256
|
+
function send(res, code, obj, pub) { const h = { "Content-Type": "application/json; charset=utf-8" }; if (pub) Object.assign(h, CORS); res.writeHead(code, h); res.end(JSON.stringify(obj)); }
|
|
244
257
|
|
|
245
258
|
// ---------- API ----------
|
|
246
259
|
async function api(req, res, pathname) {
|
|
247
260
|
if (req.method === "OPTIONS") { res.writeHead(204, CORS); return res.end(); }
|
|
248
261
|
if (pathname === "/api/login" && req.method === "POST") {
|
|
262
|
+
const ip = (req.headers["x-forwarded-for"] || req.socket.remoteAddress || "?").toString().split(",")[0].trim();
|
|
263
|
+
const wait = loginBlocked(ip);
|
|
264
|
+
if (wait) return send(res, 429, { error: "too many attempts — retry in " + wait + " s" });
|
|
249
265
|
const b = await json(req);
|
|
250
266
|
const users = loadUsers();
|
|
251
267
|
const u = users[b.user];
|
|
252
|
-
if (!u || hashPw(String(b.password || ""), u.salt) !== u.hash) return send(res, 401, { error: "bad credentials" });
|
|
268
|
+
if (!u || hashPw(String(b.password || ""), u.salt) !== u.hash) { loginFail(ip); return send(res, 401, { error: "bad credentials" }); }
|
|
269
|
+
loginOk(ip);
|
|
253
270
|
const token = issue(b.user, u.role, u.label);
|
|
254
271
|
res.setHeader("Set-Cookie", "dsh_sup=" + token + "; Path=/; HttpOnly; SameSite=Lax");
|
|
255
|
-
return send(res, 200, { token, name: b.user, role: u.role });
|
|
272
|
+
return send(res, 200, { token, name: b.user, role: u.role }, true);
|
|
256
273
|
}
|
|
257
274
|
if (pathname === "/api/logout" && req.method === "POST") { const s = auth(req); if (s) for (const [t, v] of sessions) if (v.name === s.name) sessions.delete(t); return send(res, 200, { ok: true }); }
|
|
258
275
|
if (pathname === "/api/me") { const s = auth(req); return send(res, 200, s ? { name: s.name, role: s.role, label: s.label || s.name } : { error: "unauthorized" }); }
|
|
259
276
|
if (pathname === "/api/feedback" && req.method === "POST") {
|
|
260
277
|
const b = await json(req);
|
|
261
278
|
appendFeedback(b.worker, b.role, b.category, b.text);
|
|
262
|
-
return send(res, 200, { ok: true });
|
|
279
|
+
return send(res, 200, { ok: true }, true);
|
|
263
280
|
}
|
|
264
281
|
if (pathname === "/api/feedback" && req.method === "GET") { if (!managerOnly(res, req)) return; return send(res, 200, { content: readFeedback(200) }); }
|
|
265
282
|
if (pathname === "/api/report" && req.method === "POST") {
|
|
@@ -306,7 +323,7 @@ async function api(req, res, pathname) {
|
|
|
306
323
|
if (pathname === "/api/wall" && req.method === "GET") {
|
|
307
324
|
const m = metrics();
|
|
308
325
|
const lv = liveRead();
|
|
309
|
-
return send(res, 200, { ...m, orders: (lv.orders || []).length, ordersTail: (lv.orders || []).slice(-3).map((o) => (o.table ? "M" + o.table + " · " : "") + o.person + " — " + o.request), feedbackTail: readFeedback(60) });
|
|
326
|
+
return send(res, 200, { ...m, orders: (lv.orders || []).length, ordersTail: (lv.orders || []).slice(-3).map((o) => (o.table ? "M" + o.table + " · " : "") + o.person + " — " + o.request), feedbackTail: readFeedback(60) }, true);
|
|
310
327
|
}
|
|
311
328
|
// Live conversation flow: worker screens, per-person summary, orders to
|
|
312
329
|
// the kitchen and the central system (the supervisor itself).
|
|
@@ -405,6 +422,7 @@ async function api(req, res, pathname) {
|
|
|
405
422
|
if (!ownerOnly(res, req)) return;
|
|
406
423
|
const b = await json(req);
|
|
407
424
|
if (!b.name || !b.password) return send(res, 400, { error: "name and password required" });
|
|
425
|
+
if (String(b.password).length < 8) return send(res, 400, { error: "password must be at least 8 characters" });
|
|
408
426
|
const u = loadUsers();
|
|
409
427
|
if (u[b.name]) return send(res, 409, { error: "user exists" });
|
|
410
428
|
const salt = crypto.randomBytes(16).toString("hex");
|