managed TURN (mobile/cellular fix) + UI bug fixes
- server: /api/ice endpoint reads TURN creds from env (TURN_URLS/USERNAME/CREDENTIAL) - share/connect: load ICE config at page open - fixes: stop icon, bright chat notification, beep audio-unlock, customer screen cleanup on session end, Home link, Remember-me on agent login, Time spent fixed from 90 seconds to actual time spent
This commit is contained in:
+6
-9
@@ -1,13 +1,10 @@
|
||||
{
|
||||
"name": "remote-access-server",
|
||||
"version": "0.2.0",
|
||||
"description": "Backend platform: auth, MFA, RBAC, machine enrollment, signaling, audit logs",
|
||||
"name": "bizgaze-support-server",
|
||||
"version": "2.0.0",
|
||||
"description": "BizGaze Support — remote screen sharing: public landing, agent console, sessions, SSO + webhook for BizGaze integration",
|
||||
"main": "server.js",
|
||||
"type": "commonjs",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"test": "node test/e2e.js"
|
||||
},
|
||||
"scripts": { "start": "node server.js" },
|
||||
"engines": { "node": ">=22.5.0" },
|
||||
"dependencies": { "ws": "^8.18.0" }
|
||||
"dependencies": { "ws": "^8.18.0" },
|
||||
"optionalDependencies": { "nodemailer": "^6.9.14" }
|
||||
}
|
||||
|
||||
@@ -31,6 +31,14 @@
|
||||
.topbar2.show{display:flex;} #barStatus{font-weight:600;font-size:.9rem;color:var(--blue);}
|
||||
#endBtn{padding:.45rem 1rem;background:#fee2e2;color:#b91c1c;border:none;border-radius:8px;font-weight:600;cursor:pointer;}
|
||||
#video{width:100vw;height:calc(100vh - 46px);background:#0b1220;object-fit:contain;display:none;cursor:crosshair;outline:none;}
|
||||
.profile{position:relative}
|
||||
.profile .pbtn{display:flex;align-items:center;gap:.4rem;background:rgba(255,255,255,.14);color:#fff;border:1px solid #46598c;border-radius:10px;padding:.45rem .85rem;font-weight:600;font-size:.88rem;cursor:pointer}
|
||||
.profile .pbtn:hover{background:rgba(255,255,255,.24)}
|
||||
.profile .pmenu{position:absolute;right:0;top:calc(100% + 6px);background:#fff;border:1px solid #e6e9ef;border-radius:10px;box-shadow:0 10px 28px rgba(0,0,0,.18);min-width:190px;overflow:hidden;z-index:5000;display:none}
|
||||
.profile .pmenu.open{display:block}
|
||||
.profile .pmenu a{display:block;padding:.6rem .9rem;color:#1f2430;text-decoration:none;font-size:.9rem;cursor:pointer}
|
||||
.profile .pmenu a:hover{background:#f1f5f9}
|
||||
.profile .pmenu a.danger{color:#b91c1c;border-top:1px solid #eef1f6}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -43,13 +51,20 @@
|
||||
<video id="video" autoplay playsinline muted tabindex="0"></video>
|
||||
|
||||
<script>
|
||||
let ICE={iceServers:[{urls:'stun:stun.l.google.com:19302'}]};
|
||||
try{fetch('/api/ice').then(r=>r.ok?r.json():null).then(c=>{if(c&&c.iceServers)ICE=c;}).catch(()=>{});}catch(_){}
|
||||
function pEsc(s){return String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));}
|
||||
function profileHTML(name){return '<div class="profile"><button class="pbtn" id="pbtn">'+pEsc(name)+' <span style="font-size:.65rem">▾</span></button><div class="pmenu" id="pmenu"><a href="/console">Console / Dashboard</a><a id="plogout" class="danger">Logout</a></div></div>';}
|
||||
function wireProfile(){const btn=document.getElementById('pbtn'),menu=document.getElementById('pmenu');if(!btn)return;btn.onclick=(e)=>{e.stopPropagation();menu.classList.toggle('open');};document.addEventListener('click',()=>menu.classList.remove('open'));const lo=document.getElementById('plogout');if(lo)lo.onclick=async()=>{try{await fetch('/api/logout',{method:'POST'});}catch(_){}location.href='/';};}
|
||||
function makeBrandClickable(){document.querySelectorAll('.brandrow,.wordmark').forEach(el=>{el.style.cursor='pointer';el.addEventListener('click',()=>{location.href='/';});});}
|
||||
makeBrandClickable();
|
||||
const params=new URLSearchParams(location.search);
|
||||
const presetTicket=params.get('ticket')||'';
|
||||
const presetCode=params.get('code')||'';
|
||||
const card=document.getElementById('card'), wrap=document.getElementById('wrap'),
|
||||
agentChip=document.getElementById('agentChip'), bar=document.getElementById('bar'),
|
||||
topbar=document.getElementById('topbar'), video=document.getElementById('video'), barStatus=document.getElementById('barStatus');
|
||||
let ws,pc,inputChannel,sessionId,me=null;
|
||||
let ws,pc,inputChannel,chatChannel,sessionId,me=null;
|
||||
|
||||
async function api(path,body,method='POST'){
|
||||
const opt={method,headers:{'Content-Type':'application/json'}};
|
||||
@@ -73,12 +88,13 @@ function renderLogin(){
|
||||
<div class="sub">Sign in to start a support session. Your name is shown to the customer.</div>
|
||||
<span class="lbl">Email</span><input id="email" type="email" placeholder="you@bizgaze.com">
|
||||
<span class="lbl">Password</span><input id="pw" type="password" placeholder="password">
|
||||
<button class="btn" id="loginBtn">Sign in</button>
|
||||
<label style="display:flex;align-items:center;gap:.5rem;margin:.7rem 0;color:var(--ink);font-size:.9rem;cursor:pointer"><input type="checkbox" id="rememberMe" style="width:18px;height:18px;accent-color:var(--blue);margin:0"> Remember me on this device</label>
|
||||
<button class="btn" id="loginBtn" style="width:100%">Sign in</button>
|
||||
<div class="status err" id="err"></div>`;
|
||||
{
|
||||
const doSignIn=async()=>{
|
||||
try{
|
||||
await api('/api/login',{email:document.getElementById('email').value,password:document.getElementById('pw').value});
|
||||
await api('/api/login',{email:document.getElementById('email').value,password:document.getElementById('pw').value,remember:(document.getElementById('rememberMe')||{}).checked||false});
|
||||
me=await api('/api/me',null,'GET'); renderAgent();
|
||||
}catch(e){ document.getElementById('err').textContent=e.message; }
|
||||
};
|
||||
@@ -90,8 +106,7 @@ function renderLogin(){
|
||||
// ---- AGENT CONNECT ----
|
||||
function renderAgent(){
|
||||
const displayName = me.name || me.email;
|
||||
agentChip.innerHTML = `Signed in as <b>${esc(displayName)}</b><a id="logout">Log out</a>`;
|
||||
document.getElementById('logout').onclick=async()=>{ await api('/api/logout'); location.reload(); };
|
||||
agentChip.innerHTML = profileHTML(displayName); wireProfile();
|
||||
|
||||
card.innerHTML = `
|
||||
<h1>Start a support session</h1>
|
||||
@@ -123,6 +138,7 @@ function connectWS(){
|
||||
case 'code-pending': sessionId=m.sessionId; renderWaiting(); setupPeer(); break;
|
||||
case 'session-ready': if(statusEl)statusEl.textContent='Allowed — connecting…'; break;
|
||||
case 'offer': await pc.setRemoteDescription(new RTCSessionDescription(m.sdp));
|
||||
try{ const mic=await navigator.mediaDevices.getUserMedia({audio:true}); window.__mic=mic; mic.getAudioTracks().forEach(t=>pc.addTrack(t,mic)); }catch(e){}
|
||||
const ans=await pc.createAnswer(); await pc.setLocalDescription(ans);
|
||||
ws.send(JSON.stringify({type:'answer',sessionId,sdp:pc.localDescription})); break;
|
||||
case 'ice-candidate': if(m.candidate&&pc) await pc.addIceCandidate(new RTCIceCandidate(m.candidate)); break;
|
||||
@@ -146,6 +162,7 @@ function renderWaiting(){
|
||||
}
|
||||
|
||||
function renderEnded(msg){
|
||||
removeSessionUI();
|
||||
if(pc){ try{pc.close();}catch(e){} pc=null; }
|
||||
video.style.display='none'; bar.classList.remove('show');
|
||||
topbar.style.display='flex'; wrap.style.display='grid';
|
||||
@@ -156,12 +173,58 @@ function renderEnded(msg){
|
||||
document.getElementById('againBtn').onclick=()=>location.reload();
|
||||
}
|
||||
|
||||
|
||||
let chatOpen=false;
|
||||
const SVG_MIC='<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="2" width="6" height="11" rx="3"/><path d="M5 10a7 7 0 0 0 14 0"/><line x1="12" y1="19" x2="12" y2="22"/></svg>';
|
||||
const SVG_MICOFF='<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="3" y1="3" x2="21" y2="21"/><path d="M9 9v4a3 3 0 0 0 5 2.2"/><path d="M5 10a7 7 0 0 0 10.9 5.6"/><line x1="12" y1="19" x2="12" y2="22"/></svg>';
|
||||
const SVG_CHAT='<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>';
|
||||
const SVG_END='<svg viewBox="0 0 24 24" width="17" height="17" fill="#fff"><rect x="5" y="5" width="14" height="14" rx="2.5"/></svg>';
|
||||
function _btn(id,svg,label,bg){const b=document.createElement('button');b.id=id;b.innerHTML='<span style="display:inline-flex">'+svg+'</span><span>'+label+'</span>';b.style.cssText='display:inline-flex;align-items:center;gap:7px;border:none;border-radius:12px;padding:.62rem 1rem;font-weight:600;font-size:.9rem;cursor:pointer;color:#fff;background:'+bg+';transition:background .15s,transform .08s';b.onmouseenter=()=>b.style.transform='translateY(-1px)';b.onmouseleave=()=>b.style.transform='none';return b;}
|
||||
function buildBar(){
|
||||
if(document.getElementById('sessionBar'))return;
|
||||
const bar=document.createElement('div'); bar.id='sessionBar';
|
||||
bar.style.cssText='position:fixed;left:50%;bottom:18px;transform:translateX(-50%);z-index:2147483000;display:flex;gap:10px;align-items:center;background:rgba(15,23,42,.94);padding:8px 12px;border-radius:16px;box-shadow:0 10px 28px rgba(0,0,0,.35)';
|
||||
const mic=_btn('micBtn',SVG_MIC,'Mic','#2563eb');
|
||||
const chat=_btn('chatBtn',SVG_CHAT,'Chat','#475569');
|
||||
const end=_btn('endBtn2',SVG_END,'End','#dc2626');
|
||||
bar.appendChild(mic);bar.appendChild(chat);bar.appendChild(end);
|
||||
document.body.appendChild(bar);
|
||||
mic.onclick=()=>{const m=window.__mic;if(!m)return;const t=m.getAudioTracks()[0];if(!t)return;t.enabled=!t.enabled;mic.innerHTML='<span style="display:inline-flex">'+(t.enabled?SVG_MIC:SVG_MICOFF)+'</span><span>'+(t.enabled?'Mic':'Muted')+'</span>';mic.style.background=t.enabled?'#2563eb':'#6b7280';};
|
||||
chat.onclick=toggleChat;
|
||||
end.onclick=()=>{ try{ws.send(JSON.stringify({type:'end-session',sessionId,reason:'agent-ended'}));}catch(_){} };
|
||||
buildChatPanel();
|
||||
document.addEventListener('pointerdown',ensureAudio,{once:true});
|
||||
document.addEventListener('keydown',ensureAudio,{once:true});
|
||||
if('Notification' in window && Notification.permission==='default'){ try{Notification.requestPermission();}catch(_){}}
|
||||
}
|
||||
function buildChatPanel(){
|
||||
if(document.getElementById('chatPanel'))return;
|
||||
const p=document.createElement('div'); p.id='chatPanel';
|
||||
p.style.cssText='position:fixed;right:18px;bottom:84px;width:300px;max-width:92vw;height:360px;max-height:62vh;z-index:2147483001;background:#fff;border:1px solid #e6e9ef;border-radius:16px;box-shadow:0 14px 34px rgba(0,0,0,.28);display:none;flex-direction:column;overflow:hidden';
|
||||
p.innerHTML='<div style="background:#1F3B73;color:#fff;padding:.6rem .85rem;font-weight:600;font-size:.92rem;display:flex;justify-content:space-between;align-items:center">Chat <span id="chatClose" style="cursor:pointer;opacity:.85">✕</span></div><div id="chatMsgs" style="flex:1;overflow-y:auto;padding:.6rem;display:flex;flex-direction:column;gap:.4rem;font-size:.85rem"></div><div style="display:flex;gap:.4rem;padding:.5rem;border-top:1px solid #e6e9ef"><input id="chatInput" placeholder="Type a message..." style="flex:1;padding:.5rem;border:1px solid #e6e9ef;border-radius:8px;font-size:.85rem;outline:none"><button id="chatSend" style="background:#FFC708;border:none;border-radius:8px;padding:.5rem .85rem;font-weight:700;cursor:pointer">Send</button></div>';
|
||||
document.body.appendChild(p);
|
||||
document.getElementById('chatSend').onclick=sendChat;
|
||||
document.getElementById('chatClose').onclick=toggleChat;
|
||||
document.getElementById('chatInput').addEventListener('keydown',e=>{if(e.key==='Enter')sendChat();});
|
||||
}
|
||||
function toggleChat(){const p=document.getElementById('chatPanel');if(!p)return;chatOpen=!chatOpen;p.style.display=chatOpen?'flex':'none';const b=document.getElementById('chatBtn');if(chatOpen){b&&(b.style.background='#475569');const i=document.getElementById('chatInput');if(i)setTimeout(()=>i.focus(),50);}}
|
||||
function addChat(msg){const c=document.getElementById('chatMsgs');if(!c)return;const mine=msg.from==='__self';const w=document.createElement('div');w.style.cssText='max-width:85%;padding:.4rem .6rem;border-radius:10px;'+(mine?'align-self:flex-end;background:#EAF0FB;color:#16294f':'align-self:flex-start;background:#f1f5f9;color:#1f2430');w.innerHTML='<div style="font-size:.7rem;opacity:.65;margin-bottom:2px">'+esc(msg.name||'')+'</div>'+esc(msg.text);c.appendChild(w);c.scrollTop=c.scrollHeight;if(!mine)notifyMsg(msg);}
|
||||
function notifyMsg(msg){const b=document.getElementById('chatBtn');if(b&&!chatOpen){b.style.background='#FFC708';b.style.color='#1f2430';}toast((msg.name||'Message')+': '+msg.text);try{beep();}catch(_){}if('Notification' in window && Notification.permission==='granted' && (document.hidden||!chatOpen)){try{new Notification('New message from '+(msg.name||'support'),{body:msg.text});}catch(_){}}}
|
||||
function toast(text){let t=document.getElementById('msgToast');if(!t){t=document.createElement('div');t.id='msgToast';t.style.cssText='position:fixed;top:18px;left:50%;transform:translateX(-50%);z-index:2147483600;background:#16a34a;color:#fff;padding:.7rem 1.1rem;border-radius:12px;box-shadow:0 10px 26px rgba(0,0,0,.35);font-size:.92rem;font-weight:600;border:2px solid #0c7a36;max-width:82vw;transition:opacity .4s';document.body.appendChild(t);}t.innerHTML='\ud83d\udcac '+text;t.style.opacity='1';clearTimeout(window.__toastT);window.__toastT=setTimeout(()=>{t.style.opacity='0';},2800);}
|
||||
let __ac=null;
|
||||
function ensureAudio(){try{__ac=__ac||new (window.AudioContext||window.webkitAudioContext)();if(__ac.state==='suspended')__ac.resume();}catch(_){}}
|
||||
function beep(){ensureAudio();if(!__ac)return;try{const o=__ac.createOscillator(),g=__ac.createGain();o.type='sine';o.connect(g);g.connect(__ac.destination);const t0=__ac.currentTime;o.frequency.setValueAtTime(880,t0);o.frequency.setValueAtTime(660,t0+0.09);g.gain.setValueAtTime(0.0001,t0);g.gain.exponentialRampToValueAtTime(0.12,t0+0.02);g.gain.exponentialRampToValueAtTime(0.0001,t0+0.22);o.start(t0);o.stop(t0+0.24);}catch(_){}}
|
||||
try{['pointerdown','keydown','touchstart','click'].forEach(ev=>document.addEventListener(ev,ensureAudio,{passive:true}));}catch(_){}
|
||||
function sendChat(){const i=document.getElementById('chatInput');if(!i)return;const t=i.value.trim();if(!t)return;if(chatChannel&&chatChannel.readyState==='open'){chatChannel.send(JSON.stringify({name:(me&&(me.name||me.email))||'Support agent',text:t}));}addChat({from:'__self',name:'You',text:t});i.value='';}
|
||||
function removeSessionUI(){['sessionBar','chatPanel','remoteAudio','muteBtn','msgToast'].forEach(id=>{const e=document.getElementById(id);if(e)e.remove();});}
|
||||
|
||||
function setupPeer(){
|
||||
pc=new RTCPeerConnection({iceServers:[{urls:'stun:stun.l.google.com:19302'}]});
|
||||
pc=new RTCPeerConnection(ICE);
|
||||
inputChannel=pc.createDataChannel('input',{ordered:true});
|
||||
pc.ondatachannel=(ev)=>{ if(ev.channel.label==='chat'){ chatChannel=ev.channel; chatChannel.onmessage=(e)=>{try{addChat(JSON.parse(e.data));}catch(_){}}; } };
|
||||
pc.ontrack=(ev)=>{
|
||||
video.srcObject=ev.streams[0]; wrap.style.display='none'; topbar.style.display='none'; bar.classList.add('show'); video.style.display='block';
|
||||
barStatus.textContent='Connected — viewing the customer’s screen'; video.focus();
|
||||
if(ev.track.kind==='audio'){ let a=document.getElementById('remoteAudio'); if(!a){a=document.createElement('audio');a.id='remoteAudio';a.autoplay=true;document.body.appendChild(a);} a.srcObject=ev.streams[0]; return; }
|
||||
video.srcObject=ev.streams[0]; wrap.style.display='none'; topbar.style.display='none'; video.style.display='block'; video.focus(); buildBar();
|
||||
};
|
||||
pc.onicecandidate=(ev)=>{if(ev.candidate)ws.send(JSON.stringify({type:'ice-candidate',sessionId,candidate:ev.candidate}));};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>BizGaze Support — Staff Console</title>
|
||||
<style>
|
||||
:root{ --brand:#FFC708; --brand-d:#E0AC00; --blue:#1F3B73; --blue-d:#16294f; --blue-soft:#EAF0FB; --ink:#1f2430; --muted:#6b7280; --bg:#f6f8fb; --card:#fff; --line:#e6e9ef; --green:#16a34a; --red:#b91c1c; }
|
||||
*{box-sizing:border-box;}
|
||||
body{font-family:'Segoe UI',system-ui,sans-serif;background:var(--bg);color:var(--ink);margin:0;}
|
||||
header{background:var(--blue);padding:.75rem 1.5rem;display:flex;justify-content:space-between;align-items:center;}
|
||||
.brandrow{display:flex;align-items:center;gap:.6rem;}
|
||||
.logo{width:30px;height:30px;border-radius:8px;background:var(--brand);display:grid;place-items:center;font-weight:800;color:var(--blue);}
|
||||
.brand{font-weight:700;color:#fff;font-size:1.05rem;} .brand span{color:var(--brand);font-weight:600;}
|
||||
.who{color:#dbe4f5;font-size:.85rem;margin-right:.7rem;}
|
||||
main{max-width:1020px;margin:1.8rem auto;padding:0 1rem;}
|
||||
.card{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:1.5rem;margin-bottom:1.25rem;box-shadow:0 6px 18px rgba(20,30,60,.05);}
|
||||
h2{font-size:1rem;margin:0 0 1rem;color:var(--blue);}
|
||||
input,select{width:100%;padding:.6rem .7rem;border-radius:10px;border:2px solid var(--line);background:#fbfcfe;color:var(--ink);margin:.25rem 0;font-size:.92rem;}
|
||||
input:focus,select:focus{outline:none;border-color:var(--brand);}
|
||||
button{padding:.6rem 1.1rem;background:var(--brand);color:var(--ink);border:none;border-radius:10px;font-weight:700;cursor:pointer;font-size:.92rem;}
|
||||
button:hover{background:var(--brand-d);}
|
||||
button.ghost{background:transparent;color:#dbe4f5;border:1px solid #46598c;font-weight:600;}
|
||||
button.ghost:hover{background:var(--blue-d);}
|
||||
button.mini{padding:.32rem .6rem;font-size:.76rem;font-weight:600;background:#eef1f6;color:var(--blue);border:1px solid var(--line);}
|
||||
button.mini:hover{background:var(--blue-soft);}
|
||||
button.mini.danger{color:var(--red);}
|
||||
.row{display:flex;gap:.5rem;align-items:center;}
|
||||
.muted{color:var(--muted);font-size:.85rem;}
|
||||
table{width:100%;border-collapse:collapse;font-size:.88rem;}
|
||||
th{color:var(--muted);font-weight:600;font-size:.76rem;text-transform:uppercase;letter-spacing:.04em;}
|
||||
th,td{text-align:left;padding:.55rem .5rem;border-bottom:1px solid var(--line);}
|
||||
.pill{font-size:.74rem;font-weight:600;padding:.15rem .55rem;border-radius:99px;}
|
||||
.pill.on{background:#ecfdf3;color:#15803d;} .pill.off{background:#fee2e2;color:var(--red);}
|
||||
.hidden{display:none;}
|
||||
.tabs{display:flex;gap:.5rem;margin-bottom:1.2rem;}
|
||||
.tabs button{background:#eef1f6;color:var(--muted);font-weight:600;}
|
||||
.tabs button.active{background:var(--blue);color:#fff;}
|
||||
.quick{display:flex;align-items:center;justify-content:space-between;gap:1rem;background:linear-gradient(120deg,var(--blue),var(--blue-d));color:#fff;border:none;}
|
||||
.quick h2{color:#fff;margin:0 0 .25rem;}
|
||||
.quick p{margin:0;color:#cdd7ee;font-size:.88rem;}
|
||||
.quick a{background:var(--brand);color:var(--ink);text-decoration:none;font-weight:700;padding:.7rem 1.3rem;border-radius:10px;white-space:nowrap;}
|
||||
.quick a:hover{background:var(--brand-d);}
|
||||
.lbl{display:block;font-size:.74rem;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;margin:.7rem 0 .15rem;}
|
||||
.filters{display:flex;gap:.6rem;align-items:flex-end;flex-wrap:wrap;margin-bottom:1rem;}
|
||||
.filters .f{flex:1;min-width:140px;}
|
||||
.filters .lbl{margin:.1rem 0 .15rem;}
|
||||
.profile{position:relative}
|
||||
.profile .pbtn{display:flex;align-items:center;gap:.4rem;background:rgba(255,255,255,.14);color:#fff;border:1px solid #46598c;border-radius:10px;padding:.45rem .85rem;font-weight:600;font-size:.88rem;cursor:pointer}
|
||||
.profile .pbtn:hover{background:rgba(255,255,255,.24)}
|
||||
.profile .pmenu{position:absolute;right:0;top:calc(100% + 6px);background:#fff;border:1px solid #e6e9ef;border-radius:10px;box-shadow:0 10px 28px rgba(0,0,0,.18);min-width:190px;overflow:hidden;z-index:5000;display:none}
|
||||
.profile .pmenu.open{display:block}
|
||||
.profile .pmenu a{display:block;padding:.6rem .9rem;color:#1f2430;text-decoration:none;font-size:.9rem;cursor:pointer}
|
||||
.profile .pmenu a:hover{background:#f1f5f9}
|
||||
.profile .pmenu a.danger{color:#b91c1c;border-top:1px solid #eef1f6}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="brandrow"><img src="/logo.png" alt="" style="height:46px;width:auto;max-width:190px;border-radius:8px;object-fit:contain;background:#fff;padding:5px 12px;image-rendering:-webkit-optimize-contrast" onerror="this.replaceWith(Object.assign(document.createElement('div'),{className:'logo',textContent:'B'}))"><div class="brand">BizGaze <span>Support</span> <span style="color:#8ea3cf;font-weight:500;font-size:.85rem">· Console</span></div></div>
|
||||
<div class="row" id="hdrRight"></div>
|
||||
</header>
|
||||
<main id="app"></main>
|
||||
|
||||
<script>
|
||||
function pEsc(s){return String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));}
|
||||
function profileHTML(name){return '<div class="profile"><button class="pbtn" id="pbtn">'+pEsc(name)+' <span style="font-size:.65rem">▾</span></button><div class="pmenu" id="pmenu"><a href="/console">Console / Dashboard</a><a id="plogout" class="danger">Logout</a></div></div>';}
|
||||
function wireProfile(){const btn=document.getElementById('pbtn'),menu=document.getElementById('pmenu');if(!btn)return;btn.onclick=(e)=>{e.stopPropagation();menu.classList.toggle('open');};document.addEventListener('click',()=>menu.classList.remove('open'));const lo=document.getElementById('plogout');if(lo)lo.onclick=async()=>{try{await fetch('/api/logout',{method:'POST'});}catch(_){}location.href='/';};}
|
||||
function makeBrandClickable(){document.querySelectorAll('.brandrow,.wordmark').forEach(el=>{el.style.cursor='pointer';el.addEventListener('click',()=>{location.href='/';});});}
|
||||
makeBrandClickable();
|
||||
const app = document.getElementById('app');
|
||||
const hdrRight = document.getElementById('hdrRight');
|
||||
|
||||
async function api(path, body, method = 'POST') {
|
||||
const opt = { method, headers: { 'Content-Type': 'application/json' } };
|
||||
if (body) opt.body = JSON.stringify(body);
|
||||
const r = await fetch(path, opt);
|
||||
const data = await r.json().catch(() => ({}));
|
||||
if (!r.ok) throw new Error(data.error || 'request failed');
|
||||
return data;
|
||||
}
|
||||
|
||||
|
||||
function onEnter(ids, fn){ ids.forEach(id => { const el = document.getElementById(id); if (el) el.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); fn(); } }); }); }
|
||||
|
||||
function view(html) { app.innerHTML = html; }
|
||||
|
||||
// ---------- Auth ----------
|
||||
async function authView() {
|
||||
hdrRight.innerHTML = '';
|
||||
let regOpen = false;
|
||||
try { regOpen = (await api('/api/setup-state', null, 'GET')).registrationOpen; } catch {}
|
||||
view(`
|
||||
<div class="card" style="max-width:420px;margin:3rem auto">
|
||||
<div class="tabs">
|
||||
<button id="tabLogin" class="active">Sign in</button>
|
||||
${regOpen ? '<button id="tabReg">Register team</button>' : ''}
|
||||
</div>
|
||||
<div id="loginForm">
|
||||
<span class="lbl">Email</span>
|
||||
<input id="li_email" placeholder="you@bizgaze.com" type="email">
|
||||
<span class="lbl">Password</span>
|
||||
<input id="li_pw" placeholder="password" type="password">
|
||||
<label style="display:flex;align-items:center;gap:.5rem;margin:.7rem 0;color:var(--ink);font-size:.9rem;cursor:pointer"><input type="checkbox" id="li_remember" style="width:18px;height:18px;accent-color:var(--blue);margin:0"> Remember me on this device</label>
|
||||
<button id="li_btn" style="width:100%;margin-top:.5rem">Sign in</button>
|
||||
<p id="li_err" class="muted"></p>
|
||||
</div>
|
||||
${regOpen ? `<div id="regForm" class="hidden">
|
||||
<span class="lbl">Team name</span>
|
||||
<input id="rg_team" placeholder="e.g. BizGaze Support">
|
||||
<span class="lbl">Email</span>
|
||||
<input id="rg_email" placeholder="you@bizgaze.com" type="email">
|
||||
<span class="lbl">Password</span>
|
||||
<input id="rg_pw" placeholder="min 8 characters" type="password">
|
||||
<button id="rg_btn" style="width:100%;margin-top:1rem">Create team</button>
|
||||
<p id="rg_err" class="muted"></p>
|
||||
</div>` : ''}
|
||||
</div>`);
|
||||
document.getElementById('li_btn').onclick = doLogin;
|
||||
onEnter(['li_email','li_pw'], doLogin);
|
||||
if (regOpen) {
|
||||
document.getElementById('tabLogin').onclick = () => toggle(true);
|
||||
document.getElementById('tabReg').onclick = () => toggle(false);
|
||||
document.getElementById('rg_btn').onclick = doRegister;
|
||||
onEnter(['rg_team','rg_email','rg_pw'], doRegister);
|
||||
}
|
||||
function toggle(login) {
|
||||
document.getElementById('loginForm').classList.toggle('hidden', !login);
|
||||
document.getElementById('regForm').classList.toggle('hidden', login);
|
||||
document.getElementById('tabLogin').classList.toggle('active', login);
|
||||
const rt = document.getElementById('tabReg'); if (rt) rt.classList.toggle('active', !login);
|
||||
}
|
||||
}
|
||||
|
||||
async function doLogin() {
|
||||
try {
|
||||
const rem = document.getElementById('li_remember');
|
||||
await api('/api/login', { email: li_email.value, password: li_pw.value, remember: rem ? rem.checked : false });
|
||||
location.reload();
|
||||
} catch (e) { li_err.textContent = e.message; }
|
||||
}
|
||||
|
||||
async function doRegister() {
|
||||
try {
|
||||
await api('/api/register', { email: rg_email.value, password: rg_pw.value, teamName: rg_team.value });
|
||||
await api('/api/login', { email: rg_email.value, password: rg_pw.value });
|
||||
location.reload();
|
||||
} catch (e) { rg_err.textContent = e.message; }
|
||||
}
|
||||
|
||||
// ---------- Dashboard ----------
|
||||
let ME = null;
|
||||
async function dashboard(me) {
|
||||
ME = me;
|
||||
hdrRight.innerHTML = profileHTML((me.name||me.email)+' · '+me.role); wireProfile();
|
||||
view(`
|
||||
<div class="card quick">
|
||||
<div><h2>Start a support session</h2><p>Customer gives you their 6-digit code from the share page.</p></div>
|
||||
<a href="/connect">Open connect page →</a>
|
||||
</div>
|
||||
<div class="card" id="agentsCard">
|
||||
<h2>Agents</h2>
|
||||
<table id="agents"><thead><tr><th>Email</th><th>Display name</th><th>Role</th><th>Status</th><th style="width:280px"></th></tr></thead><tbody></tbody></table>
|
||||
<div class="row" style="margin-top:1rem;flex-wrap:wrap">
|
||||
<input id="agEmail" placeholder="agent email" style="max-width:200px">
|
||||
<input id="agName" placeholder="display name (from BizGaze)" style="max-width:210px">
|
||||
<input id="agPw" placeholder="temporary password" style="max-width:170px">
|
||||
<select id="agRole" style="max-width:140px">
|
||||
<option value="technician">technician</option><option value="admin">admin</option><option value="viewer">view-only</option>
|
||||
</select>
|
||||
<button id="agAdd">Add agent</button>
|
||||
</div>
|
||||
<p id="agOut" class="muted"></p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Session report</h2>
|
||||
<div class="filters">
|
||||
<div class="f"><span class="lbl">Agent</span><select id="fAgent"><option value="">All agents</option></select></div>
|
||||
<div class="f"><span class="lbl">From</span><input id="fFrom" type="date"></div>
|
||||
<div class="f"><span class="lbl">To</span><input id="fTo" type="date"></div>
|
||||
<button id="fApply">Apply</button>
|
||||
<button id="fExcel" class="mini" style="padding:.6rem .9rem">⬇ Excel</button>
|
||||
<button id="fPdf" class="mini" style="padding:.6rem .9rem">⬇ PDF</button>
|
||||
</div>
|
||||
<table id="report"><thead><tr><th>Date</th><th>Start time</th><th>Agent</th><th>Ticket</th><th>Time spent</th></tr></thead><tbody></tbody></table>
|
||||
<p id="repSummary" class="muted" style="margin-top:.6rem"></p>
|
||||
</div>`);
|
||||
|
||||
if (me.role !== 'admin') document.getElementById('agentsCard').style.display = 'none';
|
||||
else {
|
||||
document.getElementById('agAdd').onclick = addAgent;
|
||||
onEnter(['agEmail','agName','agPw'], addAgent);
|
||||
await loadAgents();
|
||||
}
|
||||
document.getElementById('fApply').onclick = loadReport;
|
||||
document.getElementById('fExcel').onclick = exportExcel;
|
||||
document.getElementById('fPdf').onclick = exportPdf;
|
||||
await populateAgentFilter();
|
||||
await loadReport();
|
||||
}
|
||||
|
||||
async function addAgent() {
|
||||
try {
|
||||
const r = await api('/api/users', { email: agEmail.value, name: agName.value, password: agPw.value, role: agRole.value });
|
||||
agOut.textContent = `Agent ${r.email} added. Share the email + temporary password — they sign in at /connect.`;
|
||||
agEmail.value = ''; agName.value = ''; agPw.value = '';
|
||||
loadAgents(); populateAgentFilter();
|
||||
} catch (e) { agOut.textContent = e.message; }
|
||||
}
|
||||
|
||||
async function loadAgents() {
|
||||
const rows = await api('/api/users', null, 'GET');
|
||||
document.querySelector('#agents tbody').innerHTML = rows.map((u) => `
|
||||
<tr>
|
||||
<td>${esc(u.email)}</td><td>${esc(u.name || '—')}</td><td>${esc(u.role)}</td>
|
||||
<td><span class="pill ${u.active === 0 ? 'off' : 'on'}">${u.active === 0 ? 'deactivated' : 'active'}</span></td>
|
||||
<td>
|
||||
<button class="mini" onclick="resetPw('${u.id}','${esc(u.email)}')">Reset password</button>
|
||||
<button class="mini" onclick="renameAgent('${u.id}','${esc(u.email)}')">Edit name</button>
|
||||
${u.id === ME.id ? '' : (u.active === 0
|
||||
? `<button class="mini" onclick="manage('${u.id}','activate')">Activate</button>`
|
||||
: `<button class="mini danger" onclick="manage('${u.id}','deactivate')">Deactivate</button>`)
|
||||
}
|
||||
${u.id === ME.id ? '' : `<button class="mini danger" onclick="delAgent('${u.id}','${esc(u.email)}')">Delete</button>`}
|
||||
</td>
|
||||
</tr>`).join('');
|
||||
}
|
||||
|
||||
window.resetPw = async (id, email) => {
|
||||
const pw = prompt(`New password for ${email} (min 8 characters):`);
|
||||
if (!pw) return;
|
||||
try { await api('/api/users/manage', { id, action: 'reset-password', password: pw }); agOut.textContent = `Password reset for ${email}. They were signed out everywhere.`; }
|
||||
catch (e) { agOut.textContent = e.message; }
|
||||
};
|
||||
window.renameAgent = async (id, email) => {
|
||||
const name = prompt(`Display name for ${email} (as in the BizGaze app):`);
|
||||
if (!name) return;
|
||||
try { await api('/api/users/manage', { id, action: 'rename', name }); loadAgents(); }
|
||||
catch (e) { agOut.textContent = e.message; }
|
||||
};
|
||||
window.manage = async (id, action) => {
|
||||
try { await api('/api/users/manage', { id, action }); loadAgents(); }
|
||||
catch (e) { agOut.textContent = e.message; }
|
||||
};
|
||||
window.delAgent = async (id, email) => {
|
||||
if (!confirm(`Delete ${email}? This cannot be undone. (Tip: Deactivate keeps their history.)`)) return;
|
||||
try { await api('/api/users/manage', { id, action: 'delete' }); loadAgents(); populateAgentFilter(); }
|
||||
catch (e) { agOut.textContent = e.message; }
|
||||
};
|
||||
|
||||
// ---------- Session report ----------
|
||||
async function populateAgentFilter() {
|
||||
try {
|
||||
const rows = await api('/api/users', null, 'GET');
|
||||
const sel = document.getElementById('fAgent');
|
||||
const cur = sel.value;
|
||||
sel.innerHTML = '<option value="">All agents</option>' + rows.map(u => `<option value="${esc(u.email)}">${esc(u.name || u.email)}</option>`).join('');
|
||||
sel.value = cur;
|
||||
} catch { /* non-admins cannot list agents; filter stays "All" */ }
|
||||
}
|
||||
|
||||
function fmtDuration(ms) {
|
||||
if (ms == null) return '—';
|
||||
const s = Math.round(ms / 1000);
|
||||
if (s < 60) return s + 's';
|
||||
const m = Math.floor(s / 60), r = s % 60;
|
||||
if (m < 60) return m + 'm ' + r + 's';
|
||||
return Math.floor(m / 60) + 'h ' + (m % 60) + 'm';
|
||||
}
|
||||
|
||||
let REPORT_ROWS = [];
|
||||
async function loadReport() {
|
||||
const q = new URLSearchParams();
|
||||
if (fAgent.value) q.set('agent', fAgent.value);
|
||||
if (fFrom.value) q.set('from', fFrom.value);
|
||||
if (fTo.value) q.set('to', fTo.value);
|
||||
const rows = await api('/api/report?' + q.toString(), null, 'GET');
|
||||
REPORT_ROWS = rows;
|
||||
document.querySelector('#report tbody').innerHTML = rows.map((r) => {
|
||||
const d = new Date(r.started_at);
|
||||
const dur = r.ended_at ? (r.ended_at - r.started_at) : null;
|
||||
return `<tr>
|
||||
<td>${d.toLocaleDateString()}</td>
|
||||
<td class="muted">${d.toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'})}</td>
|
||||
<td>${esc(r.agent_name || r.agent_email || '—')}</td>
|
||||
<td>${esc(r.ticket || 'Direct session')}</td>
|
||||
<td>${r.ended_at ? fmtDuration(dur) : '<span class="pill on">in progress</span>'}</td>
|
||||
</tr>`;
|
||||
}).join('') || '<tr><td colspan=5 class="muted">No sessions in this period.</td></tr>';
|
||||
const total = rows.reduce((a, r) => a + (r.ended_at ? r.ended_at - r.started_at : 0), 0);
|
||||
repSummary.textContent = rows.length ? `${rows.length} session(s) · total time ${fmtDuration(total)}` : '';
|
||||
}
|
||||
|
||||
function reportData() {
|
||||
return REPORT_ROWS.map((r) => {
|
||||
const d = new Date(r.started_at);
|
||||
return {
|
||||
date: d.toLocaleDateString(), start: d.toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'}),
|
||||
agent: r.agent_name || r.agent_email || '', ticket: r.ticket || 'Direct session',
|
||||
spent: r.ended_at ? fmtDuration(r.ended_at - r.started_at) : 'in progress',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function exportExcel() {
|
||||
const rows = reportData();
|
||||
if (!rows.length) { repSummary.textContent = 'Nothing to export for this period.'; return; }
|
||||
const head = ['Date','Start time','Agent','Ticket','Time spent'];
|
||||
const csvCell = (v) => '"' + String(v).replace(/"/g, '""') + '"';
|
||||
const csv = '\ufeff' + [head, ...rows.map(r => [r.date, r.start, r.agent, r.ticket, r.spent])]
|
||||
.map(line => line.map(csvCell).join(',')).join('\r\n');
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(new Blob([csv], { type: 'text/csv;charset=utf-8' }));
|
||||
a.download = 'session-report.csv';
|
||||
a.click(); URL.revokeObjectURL(a.href);
|
||||
}
|
||||
|
||||
function exportPdf() {
|
||||
const rows = reportData();
|
||||
if (!rows.length) { repSummary.textContent = 'Nothing to export for this period.'; return; }
|
||||
const period = (fFrom.value || 'start') + ' to ' + (fTo.value || 'today');
|
||||
const agentSel = fAgent.value || 'All agents';
|
||||
const w = window.open('', '_blank');
|
||||
w.document.write('<html><head><title>Session report</title><style>' +
|
||||
'body{font-family:Segoe UI,Arial,sans-serif;color:#1f2430;margin:32px}' +
|
||||
'h1{font-size:18px;color:#1F3B73;border-bottom:3px solid #FFC708;padding-bottom:6px}' +
|
||||
'.meta{color:#6b7280;font-size:12px;margin-bottom:14px}' +
|
||||
'table{width:100%;border-collapse:collapse;font-size:12px}' +
|
||||
'th{background:#1F3B73;color:#fff;text-align:left;padding:6px 8px}' +
|
||||
'td{padding:6px 8px;border-bottom:1px solid #e6e9ef}' +
|
||||
'</style></head><body>' +
|
||||
'<h1>BizGaze Support — Session report</h1>' +
|
||||
'<div class="meta">Agent: ' + esc(agentSel) + ' · Period: ' + esc(period) + ' · Generated ' + new Date().toLocaleString() + '</div>' +
|
||||
'<table><tr><th>Date</th><th>Start time</th><th>Agent</th><th>Ticket</th><th>Time spent</th></tr>' +
|
||||
rows.map(r => '<tr><td>' + [r.date, r.start, esc(r.agent), esc(r.ticket), r.spent].join('</td><td>') + '</td></tr>').join('') +
|
||||
'</table><div class="meta" style="margin-top:12px">' + esc(repSummary.textContent) + '</div></body></html>');
|
||||
w.document.close();
|
||||
w.onload = () => { w.print(); };
|
||||
}
|
||||
|
||||
function esc(s) { return String(s == null ? '' : s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); }
|
||||
|
||||
// ---------- Boot ----------
|
||||
(async function () {
|
||||
try { const me = await api('/api/me', null, 'GET'); dashboard(me); }
|
||||
catch { authView(); }
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+61
-319
@@ -3,333 +3,75 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>BizGaze Support — Console</title>
|
||||
<title>BizGaze Support</title>
|
||||
<style>
|
||||
:root{ --brand:#FFC708; --brand-d:#E0AC00; --blue:#1F3B73; --blue-d:#16294f; --blue-soft:#EAF0FB; --ink:#1f2430; --muted:#6b7280; --bg:#f6f8fb; --card:#fff; --line:#e6e9ef; --green:#16a34a; --red:#b91c1c; }
|
||||
:root{ --brand:#FFC708; --brand-d:#E0AC00; --blue:#1F3B73; --blue-d:#16294f; --blue-soft:#EAF0FB; --ink:#1f2430; --muted:#6b7280; --bg:#f6f8fb; --line:#e6e9ef; }
|
||||
*{box-sizing:border-box;}
|
||||
body{font-family:'Segoe UI',system-ui,sans-serif;background:var(--bg);color:var(--ink);margin:0;}
|
||||
header{background:var(--blue);padding:.75rem 1.5rem;display:flex;justify-content:space-between;align-items:center;}
|
||||
.brandrow{display:flex;align-items:center;gap:.6rem;}
|
||||
.logo{width:30px;height:30px;border-radius:8px;background:var(--brand);display:grid;place-items:center;font-weight:800;color:var(--blue);}
|
||||
.brand{font-weight:700;color:#fff;font-size:1.05rem;} .brand span{color:var(--brand);font-weight:600;}
|
||||
.who{color:#dbe4f5;font-size:.85rem;margin-right:.7rem;}
|
||||
main{max-width:1020px;margin:1.8rem auto;padding:0 1rem;}
|
||||
.card{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:1.5rem;margin-bottom:1.25rem;box-shadow:0 6px 18px rgba(20,30,60,.05);}
|
||||
h2{font-size:1rem;margin:0 0 1rem;color:var(--blue);}
|
||||
input,select{width:100%;padding:.6rem .7rem;border-radius:10px;border:2px solid var(--line);background:#fbfcfe;color:var(--ink);margin:.25rem 0;font-size:.92rem;}
|
||||
input:focus,select:focus{outline:none;border-color:var(--brand);}
|
||||
button{padding:.6rem 1.1rem;background:var(--brand);color:var(--ink);border:none;border-radius:10px;font-weight:700;cursor:pointer;font-size:.92rem;}
|
||||
button:hover{background:var(--brand-d);}
|
||||
button.ghost{background:transparent;color:#dbe4f5;border:1px solid #46598c;font-weight:600;}
|
||||
button.ghost:hover{background:var(--blue-d);}
|
||||
button.mini{padding:.32rem .6rem;font-size:.76rem;font-weight:600;background:#eef1f6;color:var(--blue);border:1px solid var(--line);}
|
||||
button.mini:hover{background:var(--blue-soft);}
|
||||
button.mini.danger{color:var(--red);}
|
||||
.row{display:flex;gap:.5rem;align-items:center;}
|
||||
.muted{color:var(--muted);font-size:.85rem;}
|
||||
table{width:100%;border-collapse:collapse;font-size:.88rem;}
|
||||
th{color:var(--muted);font-weight:600;font-size:.76rem;text-transform:uppercase;letter-spacing:.04em;}
|
||||
th,td{text-align:left;padding:.55rem .5rem;border-bottom:1px solid var(--line);}
|
||||
.pill{font-size:.74rem;font-weight:600;padding:.15rem .55rem;border-radius:99px;}
|
||||
.pill.on{background:#ecfdf3;color:#15803d;} .pill.off{background:#fee2e2;color:var(--red);}
|
||||
.hidden{display:none;}
|
||||
.tabs{display:flex;gap:.5rem;margin-bottom:1.2rem;}
|
||||
.tabs button{background:#eef1f6;color:var(--muted);font-weight:600;}
|
||||
.tabs button.active{background:var(--blue);color:#fff;}
|
||||
.quick{display:flex;align-items:center;justify-content:space-between;gap:1rem;background:linear-gradient(120deg,var(--blue),var(--blue-d));color:#fff;border:none;}
|
||||
.quick h2{color:#fff;margin:0 0 .25rem;}
|
||||
.quick p{margin:0;color:#cdd7ee;font-size:.88rem;}
|
||||
.quick a{background:var(--brand);color:var(--ink);text-decoration:none;font-weight:700;padding:.7rem 1.3rem;border-radius:10px;white-space:nowrap;}
|
||||
.quick a:hover{background:var(--brand-d);}
|
||||
.lbl{display:block;font-size:.74rem;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;margin:.7rem 0 .15rem;}
|
||||
.filters{display:flex;gap:.6rem;align-items:flex-end;flex-wrap:wrap;margin-bottom:1rem;}
|
||||
.filters .f{flex:1;min-width:140px;}
|
||||
.filters .lbl{margin:.1rem 0 .15rem;}
|
||||
body{font-family:'Segoe UI',system-ui,sans-serif;background:var(--bg);color:var(--ink);margin:0;min-height:100vh;display:flex;flex-direction:column;}
|
||||
header{background:var(--blue);padding:.85rem 1.5rem;display:flex;justify-content:space-between;align-items:center;}
|
||||
.brandrow{display:flex;align-items:center;gap:.7rem;}
|
||||
.brand{font-weight:700;color:#fff;font-size:1.1rem;} .brand span{color:var(--brand);font-weight:600;}
|
||||
.signin{color:#dbe4f5;text-decoration:none;font-size:.9rem;border:1px solid #46598c;border-radius:8px;padding:.45rem 1rem;}
|
||||
.signin:hover{background:var(--blue-d);}
|
||||
.wrap{flex:1;display:grid;place-items:center;padding:2.5rem 1rem;}
|
||||
.inner{max-width:780px;width:100%;text-align:center;}
|
||||
h1{color:var(--blue);font-size:1.8rem;margin:0 0 .4rem;}
|
||||
.sub{color:var(--muted);margin-bottom:2.2rem;}
|
||||
.choices{display:flex;gap:1.4rem;flex-wrap:wrap;justify-content:center;}
|
||||
.choice{flex:1;min-width:260px;max-width:340px;background:#fff;border:1px solid var(--line);border-radius:18px;padding:2.2rem 1.6rem;text-decoration:none;color:var(--ink);box-shadow:0 10px 30px rgba(20,30,60,.06);transition:transform .12s,box-shadow .12s;}
|
||||
.choice:hover{transform:translateY(-3px);box-shadow:0 16px 38px rgba(20,30,60,.12);border-color:var(--brand);}
|
||||
.icon{width:66px;height:66px;border-radius:18px;display:grid;place-items:center;margin:0 auto 1.1rem;}
|
||||
.icon.share{background:#FFF7DA;} .icon.connect{background:var(--blue-soft);}
|
||||
.icon svg{width:34px;height:34px;}
|
||||
.choice h3{margin:.2rem 0 .4rem;color:var(--blue);font-size:1.15rem;}
|
||||
.choice p{margin:0;color:var(--muted);font-size:.9rem;line-height:1.5;}
|
||||
.foot{color:var(--muted);font-size:.82rem;margin-top:2.4rem;}
|
||||
footer{text-align:center;color:#9aa3b2;font-size:.8rem;padding:1rem;}
|
||||
.profile{position:relative}
|
||||
.profile .pbtn{display:flex;align-items:center;gap:.4rem;background:rgba(255,255,255,.14);color:#fff;border:1px solid #46598c;border-radius:10px;padding:.45rem .85rem;font-weight:600;font-size:.88rem;cursor:pointer}
|
||||
.profile .pbtn:hover{background:rgba(255,255,255,.24)}
|
||||
.profile .pmenu{position:absolute;right:0;top:calc(100% + 6px);background:#fff;border:1px solid #e6e9ef;border-radius:10px;box-shadow:0 10px 28px rgba(0,0,0,.18);min-width:190px;overflow:hidden;z-index:5000;display:none}
|
||||
.profile .pmenu.open{display:block}
|
||||
.profile .pmenu a{display:block;padding:.6rem .9rem;color:#1f2430;text-decoration:none;font-size:.9rem;cursor:pointer}
|
||||
.profile .pmenu a:hover{background:#f1f5f9}
|
||||
.profile .pmenu a.danger{color:#b91c1c;border-top:1px solid #eef1f6}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="brandrow"><img src="/logo.png" alt="" style="height:46px;width:auto;max-width:190px;border-radius:8px;object-fit:contain;background:#fff;padding:5px 12px;image-rendering:-webkit-optimize-contrast" onerror="this.replaceWith(Object.assign(document.createElement('div'),{className:'logo',textContent:'B'}))"><div class="brand">BizGaze <span>Support</span> <span style="color:#8ea3cf;font-weight:500;font-size:.85rem">· Console</span></div></div>
|
||||
<div class="row"><span id="who" class="who"></span><button id="logoutBtn" class="ghost hidden">Log out</button></div>
|
||||
<div class="brandrow">
|
||||
<img src="/logo.png" alt="" style="height:40px;width:auto;max-width:170px;border-radius:8px;object-fit:contain;background:#fff;padding:4px 10px" onerror="this.style.display='none'">
|
||||
<div class="brand">BizGaze <span>Support</span></div>
|
||||
</div>
|
||||
<div id="authArea"><a class="signin" href="/console">Staff sign in</a></div>
|
||||
</header>
|
||||
<main id="app"></main>
|
||||
|
||||
<div class="wrap">
|
||||
<div class="inner">
|
||||
<h1>How can we help you today?</h1>
|
||||
<div class="sub">Secure remote support — no downloads, you stay in control.</div>
|
||||
<div class="choices">
|
||||
<a class="choice" href="/share">
|
||||
<div class="icon share"><svg viewBox="0 0 24 24" fill="none" stroke="#BA7515" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg></div>
|
||||
<h3>Share my screen</h3>
|
||||
<p>You need help. Get a one-time code and show your screen to a BizGaze support agent.</p>
|
||||
</a>
|
||||
<a class="choice" href="/connect">
|
||||
<div class="icon connect"><svg viewBox="0 0 24 24" fill="none" stroke="#1F3B73" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 17V7a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v10"/><path d="M2 21h20"/><path d="m9 9 3 3-3 3"/></svg></div>
|
||||
<h3>Connect to a screen</h3>
|
||||
<p>You're a support agent. Sign in, then enter the customer's code to view their screen.</p>
|
||||
</a>
|
||||
</div>
|
||||
<div class="foot">🔒 Screen sharing only starts after the customer approves it, and can be stopped anytime.</div>
|
||||
</div>
|
||||
</div>
|
||||
<footer>© BizGaze · Remote Support</footer>
|
||||
<script>
|
||||
const app = document.getElementById('app');
|
||||
const who = document.getElementById('who');
|
||||
const logoutBtn = document.getElementById('logoutBtn');
|
||||
|
||||
async function api(path, body, method = 'POST') {
|
||||
const opt = { method, headers: { 'Content-Type': 'application/json' } };
|
||||
if (body) opt.body = JSON.stringify(body);
|
||||
const r = await fetch(path, opt);
|
||||
const data = await r.json().catch(() => ({}));
|
||||
if (!r.ok) throw new Error(data.error || 'request failed');
|
||||
return data;
|
||||
}
|
||||
|
||||
logoutBtn.onclick = async () => { await api('/api/logout'); location.reload(); };
|
||||
|
||||
function onEnter(ids, fn){ ids.forEach(id => { const el = document.getElementById(id); if (el) el.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); fn(); } }); }); }
|
||||
|
||||
function view(html) { app.innerHTML = html; }
|
||||
|
||||
// ---------- Auth ----------
|
||||
function authView() {
|
||||
who.textContent = '';
|
||||
logoutBtn.classList.add('hidden');
|
||||
view(`
|
||||
<div class="card" style="max-width:420px;margin:3rem auto">
|
||||
<div class="tabs">
|
||||
<button id="tabLogin" class="active">Sign in</button>
|
||||
<button id="tabReg">Register team</button>
|
||||
</div>
|
||||
<div id="loginForm">
|
||||
<span class="lbl">Email</span>
|
||||
<input id="li_email" placeholder="you@bizgaze.com" type="email">
|
||||
<span class="lbl">Password</span>
|
||||
<input id="li_pw" placeholder="password" type="password">
|
||||
<button id="li_btn" style="width:100%;margin-top:1rem">Sign in</button>
|
||||
<p id="li_err" class="muted"></p>
|
||||
</div>
|
||||
<div id="regForm" class="hidden">
|
||||
<span class="lbl">Team name</span>
|
||||
<input id="rg_team" placeholder="e.g. BizGaze Support">
|
||||
<span class="lbl">Email</span>
|
||||
<input id="rg_email" placeholder="you@bizgaze.com" type="email">
|
||||
<span class="lbl">Password</span>
|
||||
<input id="rg_pw" placeholder="min 8 characters" type="password">
|
||||
<button id="rg_btn" style="width:100%;margin-top:1rem">Create team</button>
|
||||
<p id="rg_err" class="muted"></p>
|
||||
</div>
|
||||
</div>`);
|
||||
document.getElementById('tabLogin').onclick = () => { toggle(true); };
|
||||
document.getElementById('tabReg').onclick = () => { toggle(false); };
|
||||
function toggle(login) {
|
||||
document.getElementById('loginForm').classList.toggle('hidden', !login);
|
||||
document.getElementById('regForm').classList.toggle('hidden', login);
|
||||
document.getElementById('tabLogin').classList.toggle('active', login);
|
||||
document.getElementById('tabReg').classList.toggle('active', !login);
|
||||
}
|
||||
document.getElementById('li_btn').onclick = doLogin;
|
||||
document.getElementById('rg_btn').onclick = doRegister;
|
||||
onEnter(['li_email','li_pw'], doLogin);
|
||||
onEnter(['rg_team','rg_email','rg_pw'], doRegister);
|
||||
}
|
||||
|
||||
async function doLogin() {
|
||||
try {
|
||||
await api('/api/login', { email: li_email.value, password: li_pw.value });
|
||||
location.reload();
|
||||
} catch (e) { li_err.textContent = e.message; }
|
||||
}
|
||||
|
||||
async function doRegister() {
|
||||
try {
|
||||
await api('/api/register', { email: rg_email.value, password: rg_pw.value, teamName: rg_team.value });
|
||||
await api('/api/login', { email: rg_email.value, password: rg_pw.value });
|
||||
location.reload();
|
||||
} catch (e) { rg_err.textContent = e.message; }
|
||||
}
|
||||
|
||||
// ---------- Dashboard ----------
|
||||
let ME = null;
|
||||
async function dashboard(me) {
|
||||
ME = me;
|
||||
who.textContent = `${me.name || me.email} · ${me.role}`;
|
||||
logoutBtn.classList.remove('hidden');
|
||||
view(`
|
||||
<div class="card quick">
|
||||
<div><h2>Start a support session</h2><p>Customer gives you their 6-digit code from the share page.</p></div>
|
||||
<a href="/connect">Open connect page →</a>
|
||||
</div>
|
||||
<div class="card" id="agentsCard">
|
||||
<h2>Agents</h2>
|
||||
<table id="agents"><thead><tr><th>Email</th><th>Display name</th><th>Role</th><th>Status</th><th style="width:280px"></th></tr></thead><tbody></tbody></table>
|
||||
<div class="row" style="margin-top:1rem;flex-wrap:wrap">
|
||||
<input id="agEmail" placeholder="agent email" style="max-width:200px">
|
||||
<input id="agName" placeholder="display name (from BizGaze)" style="max-width:210px">
|
||||
<input id="agPw" placeholder="temporary password" style="max-width:170px">
|
||||
<select id="agRole" style="max-width:140px">
|
||||
<option value="technician">technician</option><option value="admin">admin</option><option value="viewer">view-only</option>
|
||||
</select>
|
||||
<button id="agAdd">Add agent</button>
|
||||
</div>
|
||||
<p id="agOut" class="muted"></p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Session report</h2>
|
||||
<div class="filters">
|
||||
<div class="f"><span class="lbl">Agent</span><select id="fAgent"><option value="">All agents</option></select></div>
|
||||
<div class="f"><span class="lbl">From</span><input id="fFrom" type="date"></div>
|
||||
<div class="f"><span class="lbl">To</span><input id="fTo" type="date"></div>
|
||||
<button id="fApply">Apply</button>
|
||||
<button id="fExcel" class="mini" style="padding:.6rem .9rem">⬇ Excel</button>
|
||||
<button id="fPdf" class="mini" style="padding:.6rem .9rem">⬇ PDF</button>
|
||||
</div>
|
||||
<table id="report"><thead><tr><th>Date</th><th>Start time</th><th>Agent</th><th>Ticket</th><th>Time spent</th></tr></thead><tbody></tbody></table>
|
||||
<p id="repSummary" class="muted" style="margin-top:.6rem"></p>
|
||||
</div>`);
|
||||
|
||||
if (me.role !== 'admin') document.getElementById('agentsCard').style.display = 'none';
|
||||
else {
|
||||
document.getElementById('agAdd').onclick = addAgent;
|
||||
onEnter(['agEmail','agName','agPw'], addAgent);
|
||||
await loadAgents();
|
||||
}
|
||||
document.getElementById('fApply').onclick = loadReport;
|
||||
document.getElementById('fExcel').onclick = exportExcel;
|
||||
document.getElementById('fPdf').onclick = exportPdf;
|
||||
await populateAgentFilter();
|
||||
await loadReport();
|
||||
}
|
||||
|
||||
async function addAgent() {
|
||||
try {
|
||||
const r = await api('/api/users', { email: agEmail.value, name: agName.value, password: agPw.value, role: agRole.value });
|
||||
agOut.textContent = `Agent ${r.email} added. Share the email + temporary password — they sign in at /connect.`;
|
||||
agEmail.value = ''; agName.value = ''; agPw.value = '';
|
||||
loadAgents(); populateAgentFilter();
|
||||
} catch (e) { agOut.textContent = e.message; }
|
||||
}
|
||||
|
||||
async function loadAgents() {
|
||||
const rows = await api('/api/users', null, 'GET');
|
||||
document.querySelector('#agents tbody').innerHTML = rows.map((u) => `
|
||||
<tr>
|
||||
<td>${esc(u.email)}</td><td>${esc(u.name || '—')}</td><td>${esc(u.role)}</td>
|
||||
<td><span class="pill ${u.active === 0 ? 'off' : 'on'}">${u.active === 0 ? 'deactivated' : 'active'}</span></td>
|
||||
<td>
|
||||
<button class="mini" onclick="resetPw('${u.id}','${esc(u.email)}')">Reset password</button>
|
||||
<button class="mini" onclick="renameAgent('${u.id}','${esc(u.email)}')">Edit name</button>
|
||||
${u.id === ME.id ? '' : (u.active === 0
|
||||
? `<button class="mini" onclick="manage('${u.id}','activate')">Activate</button>`
|
||||
: `<button class="mini danger" onclick="manage('${u.id}','deactivate')">Deactivate</button>`)
|
||||
}
|
||||
${u.id === ME.id ? '' : `<button class="mini danger" onclick="delAgent('${u.id}','${esc(u.email)}')">Delete</button>`}
|
||||
</td>
|
||||
</tr>`).join('');
|
||||
}
|
||||
|
||||
window.resetPw = async (id, email) => {
|
||||
const pw = prompt(`New password for ${email} (min 8 characters):`);
|
||||
if (!pw) return;
|
||||
try { await api('/api/users/manage', { id, action: 'reset-password', password: pw }); agOut.textContent = `Password reset for ${email}. They were signed out everywhere.`; }
|
||||
catch (e) { agOut.textContent = e.message; }
|
||||
};
|
||||
window.renameAgent = async (id, email) => {
|
||||
const name = prompt(`Display name for ${email} (as in the BizGaze app):`);
|
||||
if (!name) return;
|
||||
try { await api('/api/users/manage', { id, action: 'rename', name }); loadAgents(); }
|
||||
catch (e) { agOut.textContent = e.message; }
|
||||
};
|
||||
window.manage = async (id, action) => {
|
||||
try { await api('/api/users/manage', { id, action }); loadAgents(); }
|
||||
catch (e) { agOut.textContent = e.message; }
|
||||
};
|
||||
window.delAgent = async (id, email) => {
|
||||
if (!confirm(`Delete ${email}? This cannot be undone. (Tip: Deactivate keeps their history.)`)) return;
|
||||
try { await api('/api/users/manage', { id, action: 'delete' }); loadAgents(); populateAgentFilter(); }
|
||||
catch (e) { agOut.textContent = e.message; }
|
||||
};
|
||||
|
||||
// ---------- Session report ----------
|
||||
async function populateAgentFilter() {
|
||||
try {
|
||||
const rows = await api('/api/users', null, 'GET');
|
||||
const sel = document.getElementById('fAgent');
|
||||
const cur = sel.value;
|
||||
sel.innerHTML = '<option value="">All agents</option>' + rows.map(u => `<option value="${esc(u.email)}">${esc(u.name || u.email)}</option>`).join('');
|
||||
sel.value = cur;
|
||||
} catch { /* non-admins cannot list agents; filter stays "All" */ }
|
||||
}
|
||||
|
||||
function fmtDuration(ms) {
|
||||
if (ms == null) return '—';
|
||||
const s = Math.round(ms / 1000);
|
||||
if (s < 60) return s + 's';
|
||||
const m = Math.floor(s / 60), r = s % 60;
|
||||
if (m < 60) return m + 'm ' + r + 's';
|
||||
return Math.floor(m / 60) + 'h ' + (m % 60) + 'm';
|
||||
}
|
||||
|
||||
let REPORT_ROWS = [];
|
||||
async function loadReport() {
|
||||
const q = new URLSearchParams();
|
||||
if (fAgent.value) q.set('agent', fAgent.value);
|
||||
if (fFrom.value) q.set('from', fFrom.value);
|
||||
if (fTo.value) q.set('to', fTo.value);
|
||||
const rows = await api('/api/report?' + q.toString(), null, 'GET');
|
||||
REPORT_ROWS = rows;
|
||||
document.querySelector('#report tbody').innerHTML = rows.map((r) => {
|
||||
const d = new Date(r.started_at);
|
||||
const dur = r.ended_at ? (r.ended_at - r.started_at) : null;
|
||||
return `<tr>
|
||||
<td>${d.toLocaleDateString()}</td>
|
||||
<td class="muted">${d.toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'})}</td>
|
||||
<td>${esc(r.agent_name || r.agent_email || '—')}</td>
|
||||
<td>${esc(r.ticket || 'Direct session')}</td>
|
||||
<td>${r.ended_at ? fmtDuration(dur) : '<span class="pill on">in progress</span>'}</td>
|
||||
</tr>`;
|
||||
}).join('') || '<tr><td colspan=5 class="muted">No sessions in this period.</td></tr>';
|
||||
const total = rows.reduce((a, r) => a + (r.ended_at ? r.ended_at - r.started_at : 0), 0);
|
||||
repSummary.textContent = rows.length ? `${rows.length} session(s) · total time ${fmtDuration(total)}` : '';
|
||||
}
|
||||
|
||||
function reportData() {
|
||||
return REPORT_ROWS.map((r) => {
|
||||
const d = new Date(r.started_at);
|
||||
return {
|
||||
date: d.toLocaleDateString(), start: d.toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'}),
|
||||
agent: r.agent_name || r.agent_email || '', ticket: r.ticket || 'Direct session',
|
||||
spent: r.ended_at ? fmtDuration(r.ended_at - r.started_at) : 'in progress',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function exportExcel() {
|
||||
const rows = reportData();
|
||||
if (!rows.length) { repSummary.textContent = 'Nothing to export for this period.'; return; }
|
||||
const head = ['Date','Start time','Agent','Ticket','Time spent'];
|
||||
const csvCell = (v) => '"' + String(v).replace(/"/g, '""') + '"';
|
||||
const csv = '\ufeff' + [head, ...rows.map(r => [r.date, r.start, r.agent, r.ticket, r.spent])]
|
||||
.map(line => line.map(csvCell).join(',')).join('\r\n');
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(new Blob([csv], { type: 'text/csv;charset=utf-8' }));
|
||||
a.download = 'session-report.csv';
|
||||
a.click(); URL.revokeObjectURL(a.href);
|
||||
}
|
||||
|
||||
function exportPdf() {
|
||||
const rows = reportData();
|
||||
if (!rows.length) { repSummary.textContent = 'Nothing to export for this period.'; return; }
|
||||
const period = (fFrom.value || 'start') + ' to ' + (fTo.value || 'today');
|
||||
const agentSel = fAgent.value || 'All agents';
|
||||
const w = window.open('', '_blank');
|
||||
w.document.write('<html><head><title>Session report</title><style>' +
|
||||
'body{font-family:Segoe UI,Arial,sans-serif;color:#1f2430;margin:32px}' +
|
||||
'h1{font-size:18px;color:#1F3B73;border-bottom:3px solid #FFC708;padding-bottom:6px}' +
|
||||
'.meta{color:#6b7280;font-size:12px;margin-bottom:14px}' +
|
||||
'table{width:100%;border-collapse:collapse;font-size:12px}' +
|
||||
'th{background:#1F3B73;color:#fff;text-align:left;padding:6px 8px}' +
|
||||
'td{padding:6px 8px;border-bottom:1px solid #e6e9ef}' +
|
||||
'</style></head><body>' +
|
||||
'<h1>BizGaze Support — Session report</h1>' +
|
||||
'<div class="meta">Agent: ' + esc(agentSel) + ' · Period: ' + esc(period) + ' · Generated ' + new Date().toLocaleString() + '</div>' +
|
||||
'<table><tr><th>Date</th><th>Start time</th><th>Agent</th><th>Ticket</th><th>Time spent</th></tr>' +
|
||||
rows.map(r => '<tr><td>' + [r.date, r.start, esc(r.agent), esc(r.ticket), r.spent].join('</td><td>') + '</td></tr>').join('') +
|
||||
'</table><div class="meta" style="margin-top:12px">' + esc(repSummary.textContent) + '</div></body></html>');
|
||||
w.document.close();
|
||||
w.onload = () => { w.print(); };
|
||||
}
|
||||
|
||||
function esc(s) { return String(s == null ? '' : s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); }
|
||||
|
||||
// ---------- Boot ----------
|
||||
(async function () {
|
||||
try { const me = await api('/api/me', null, 'GET'); dashboard(me); }
|
||||
catch { authView(); }
|
||||
})();
|
||||
function pEsc(s){return String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));}
|
||||
function profileHTML(name){return '<div class="profile"><button class="pbtn" id="pbtn">'+pEsc(name)+' <span style="font-size:.65rem">▾</span></button><div class="pmenu" id="pmenu"><a href="/console">Console / Dashboard</a><a id="plogout" class="danger">Logout</a></div></div>';}
|
||||
function wireProfile(){const btn=document.getElementById('pbtn'),menu=document.getElementById('pmenu');if(!btn)return;btn.onclick=(e)=>{e.stopPropagation();menu.classList.toggle('open');};document.addEventListener('click',()=>menu.classList.remove('open'));const lo=document.getElementById('plogout');if(lo)lo.onclick=async()=>{try{await fetch('/api/logout',{method:'POST'});}catch(_){}location.href='/';};}
|
||||
function makeBrandClickable(){document.querySelectorAll('.brandrow,.wordmark').forEach(el=>{el.style.cursor='pointer';el.addEventListener('click',()=>{location.href='/';});});}
|
||||
makeBrandClickable();
|
||||
(async function(){try{const r=await fetch('/api/me');if(r.ok){const me=await r.json();document.getElementById('authArea').innerHTML=profileHTML(me.name||me.email);wireProfile();}}catch(_){}})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -33,10 +33,19 @@
|
||||
.indicator{position:fixed;top:0;left:0;right:0;background:#dc2626;color:#fff;text-align:center;padding:.5rem;font-size:.9rem;display:none;font-weight:600;z-index:9;}
|
||||
.indicator.show{display:block;}
|
||||
@media(max-width:860px){ .stage{flex-direction:column;} .brandpanel{padding:2rem;min-height:auto;} .mark{width:60px;height:60px;border-radius:16px;font-size:1.8rem;margin-bottom:.7rem;} .wordmark{font-size:1.5rem;} .tagline{display:none;} }
|
||||
.profile{position:relative}
|
||||
.profile .pbtn{display:flex;align-items:center;gap:.4rem;background:rgba(255,255,255,.14);color:#fff;border:1px solid #46598c;border-radius:10px;padding:.45rem .85rem;font-weight:600;font-size:.88rem;cursor:pointer}
|
||||
.profile .pbtn:hover{background:rgba(255,255,255,.24)}
|
||||
.profile .pmenu{position:absolute;right:0;top:calc(100% + 6px);background:#fff;border:1px solid #e6e9ef;border-radius:10px;box-shadow:0 10px 28px rgba(0,0,0,.18);min-width:190px;overflow:hidden;z-index:5000;display:none}
|
||||
.profile .pmenu.open{display:block}
|
||||
.profile .pmenu a{display:block;padding:.6rem .9rem;color:#1f2430;text-decoration:none;font-size:.9rem;cursor:pointer}
|
||||
.profile .pmenu a:hover{background:#f1f5f9}
|
||||
.profile .pmenu a.danger{color:#b91c1c;border-top:1px solid #eef1f6}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="indicator" id="indicator">● Your screen is being shared — close this tab anytime to stop</div>
|
||||
<a href="/" style="position:fixed;top:14px;left:16px;z-index:50;display:inline-flex;align-items:center;gap:6px;background:rgba(255,255,255,.92);color:#1F3B73;text-decoration:none;font-weight:600;font-size:.86rem;padding:.45rem .8rem;border-radius:10px;box-shadow:0 4px 12px rgba(0,0,0,.15)">← Home</a>
|
||||
<div class="stage">
|
||||
<div class="brandpanel">
|
||||
<img src="/logo.png" alt="" style="width:88px;height:88px;border-radius:22px;object-fit:contain;margin-bottom:1.2rem;background:#fff;padding:8px;box-shadow:0 12px 30px rgba(0,0,0,.25)" onerror="this.replaceWith(Object.assign(document.createElement('div'),{className:'mark',textContent:'B'}))">
|
||||
@@ -61,6 +70,13 @@
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
let ICE={iceServers:[{urls:'stun:stun.l.google.com:19302'}]};
|
||||
try{fetch('/api/ice').then(r=>r.ok?r.json():null).then(c=>{if(c&&c.iceServers)ICE=c;}).catch(()=>{});}catch(_){}
|
||||
function pEsc(s){return String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));}
|
||||
function profileHTML(name){return '<div class="profile"><button class="pbtn" id="pbtn">'+pEsc(name)+' <span style="font-size:.65rem">▾</span></button><div class="pmenu" id="pmenu"><a href="/console">Console / Dashboard</a><a id="plogout" class="danger">Logout</a></div></div>';}
|
||||
function wireProfile(){const btn=document.getElementById('pbtn'),menu=document.getElementById('pmenu');if(!btn)return;btn.onclick=(e)=>{e.stopPropagation();menu.classList.toggle('open');};document.addEventListener('click',()=>menu.classList.remove('open'));const lo=document.getElementById('plogout');if(lo)lo.onclick=async()=>{try{await fetch('/api/logout',{method:'POST'});}catch(_){}location.href='/';};}
|
||||
function makeBrandClickable(){document.querySelectorAll('.brandrow,.wordmark').forEach(el=>{el.style.cursor='pointer';el.addEventListener('click',()=>{location.href='/';});});}
|
||||
makeBrandClickable();
|
||||
const codeEl=document.getElementById('code'), statusEl=document.getElementById('status'),
|
||||
consentBox=document.getElementById('consentBox'), indicator=document.getElementById('indicator');
|
||||
const setStatus=(t,c='')=>{statusEl.textContent=t;statusEl.className='status '+c;};
|
||||
@@ -73,7 +89,7 @@ document.getElementById('copyBtn').onclick=async()=>{
|
||||
b.innerHTML='<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#16a34a" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>';
|
||||
setTimeout(()=>{b.innerHTML=old;},1500);
|
||||
};
|
||||
let ws,pc,localStream,sessionId;
|
||||
let ws,pc,localStream,chatChannel,sessionId;
|
||||
ws=new WebSocket((location.protocol==='https:'?'wss://':'ws://')+location.host+'/ws');
|
||||
ws.onopen=()=>ws.send(JSON.stringify({type:'share-create'}));
|
||||
ws.onmessage=async(e)=>{const m=JSON.parse(e.data);switch(m.type){
|
||||
@@ -82,10 +98,10 @@ ws.onmessage=async(e)=>{const m=JSON.parse(e.data);switch(m.type){
|
||||
case 'start-stream': sessionId=m.sessionId; await startStreaming(); break;
|
||||
case 'answer': if(pc) await pc.setRemoteDescription(new RTCSessionDescription(m.sdp)); break;
|
||||
case 'ice-candidate': if(m.candidate&&pc) await pc.addIceCandidate(new RTCIceCandidate(m.candidate)); break;
|
||||
case 'session-ended': teardown(); break;
|
||||
case 'session-ended': endShareSession('Your support agent ended the session. Tap below for a new code if you still need help.'); break;
|
||||
case 'error': setStatus(m.message,''); break;
|
||||
}};
|
||||
ws.onclose=()=>setStatus('Connection closed. Refresh the page to start again.');
|
||||
ws.onclose=()=>{ if(document.getElementById('sessionBar')||localStream){ endShareSession('The connection was closed.'); } else { setStatus('Connection closed. Refresh the page to start again.'); } };
|
||||
function onAgentConnected(m){
|
||||
const cw=document.querySelector('.codewrap');
|
||||
if(cw) cw.style.display='none';
|
||||
@@ -107,15 +123,73 @@ async function startStreaming(){
|
||||
try{ localStream=await navigator.mediaDevices.getDisplayMedia({video:{displaySurface:'monitor',frameRate:{ideal:30}},audio:false,monitorTypeSurfaces:'include'}); }
|
||||
catch(err){ try{ws.send(JSON.stringify({type:'end-session',sessionId,reason:'share-cancelled'}));}catch(e){} setStatus('Screen share was cancelled. Refresh the page to try again.'); return; }
|
||||
indicator.classList.add('show'); setStatus('You are now sharing your screen with your agent.','on');
|
||||
pc=new RTCPeerConnection({iceServers:[{urls:'stun:stun.l.google.com:19302'}]});
|
||||
pc=new RTCPeerConnection(ICE);
|
||||
try{ const mic=await navigator.mediaDevices.getUserMedia({audio:true}); window.__mic=mic; mic.getAudioTracks().forEach(t=>localStream.addTrack(t)); buildBar(); }catch(e){ buildBar(); }
|
||||
localStream.getTracks().forEach(t=>pc.addTrack(t,localStream));
|
||||
pc.ondatachannel=(ev)=>{ev.channel.onmessage=()=>{};};
|
||||
pc.ontrack=(ev)=>{ if(ev.track.kind==='audio'){ let a=document.getElementById('remoteAudio'); if(!a){a=document.createElement('audio');a.id='remoteAudio';a.autoplay=true;document.body.appendChild(a);} a.srcObject=ev.streams[0]; } };
|
||||
pc.onicecandidate=(ev)=>{if(ev.candidate)ws.send(JSON.stringify({type:'ice-candidate',sessionId,candidate:ev.candidate}));};
|
||||
chatChannel=pc.createDataChannel('chat',{ordered:true});
|
||||
chatChannel.onmessage=(e)=>{try{addChat(JSON.parse(e.data));}catch(_){}};
|
||||
const offer=await pc.createOffer(); await pc.setLocalDescription(offer);
|
||||
ws.send(JSON.stringify({type:'offer',sessionId,sdp:pc.localDescription}));
|
||||
localStream.getVideoTracks()[0].onended=()=>{ws.send(JSON.stringify({type:'end-session',sessionId,reason:'customer-ended'}));teardown();};
|
||||
}
|
||||
function teardown(){indicator.classList.remove('show');if(localStream){localStream.getTracks().forEach(t=>t.stop());localStream=null;}if(pc){pc.close();pc=null;}consentBox.innerHTML='';setStatus('Session ended. Refresh this page to get a new code.');}
|
||||
function endShareSession(msgText){
|
||||
removeSessionUI();
|
||||
indicator.classList.remove('show');
|
||||
if(window.__mic){try{window.__mic.getTracks().forEach(t=>t.stop());}catch(_){}window.__mic=null;}
|
||||
if(localStream){try{localStream.getTracks().forEach(t=>t.stop());}catch(_){}localStream=null;}
|
||||
if(pc){try{pc.close();}catch(_){}pc=null;}
|
||||
var card=document.querySelector('.panelside .card');
|
||||
if(card){ card.innerHTML='<h1 style="color:var(--blue)">Session ended</h1><div class="sub">'+esc(msgText||'The session has ended.')+'</div><button onclick="location.reload()" style="width:100%;margin-top:.4rem">Get a new code</button>'; }
|
||||
}
|
||||
function teardown(){indicator.classList.remove('show');removeSessionUI();if(window.__mic){window.__mic.getTracks().forEach(t=>t.stop());window.__mic=null;}if(localStream){localStream.getTracks().forEach(t=>t.stop());localStream=null;}if(pc){pc.close();pc=null;}consentBox.innerHTML='';setStatus('Session ended. Refresh this page to get a new code.');}
|
||||
|
||||
let chatOpen=false;
|
||||
const SVG_MIC='<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="2" width="6" height="11" rx="3"/><path d="M5 10a7 7 0 0 0 14 0"/><line x1="12" y1="19" x2="12" y2="22"/></svg>';
|
||||
const SVG_MICOFF='<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="3" y1="3" x2="21" y2="21"/><path d="M9 9v4a3 3 0 0 0 5 2.2"/><path d="M5 10a7 7 0 0 0 10.9 5.6"/><line x1="12" y1="19" x2="12" y2="22"/></svg>';
|
||||
const SVG_CHAT='<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>';
|
||||
const SVG_END='<svg viewBox="0 0 24 24" width="17" height="17" fill="#fff"><rect x="5" y="5" width="14" height="14" rx="2.5"/></svg>';
|
||||
function _btn(id,svg,label,bg){const b=document.createElement('button');b.id=id;b.innerHTML='<span style="display:inline-flex">'+svg+'</span><span>'+label+'</span>';b.style.cssText='display:inline-flex;align-items:center;gap:7px;border:none;border-radius:12px;padding:.62rem 1rem;font-weight:600;font-size:.9rem;cursor:pointer;color:#fff;background:'+bg+';transition:background .15s,transform .08s';b.onmouseenter=()=>b.style.transform='translateY(-1px)';b.onmouseleave=()=>b.style.transform='none';return b;}
|
||||
function buildBar(){
|
||||
if(document.getElementById('sessionBar'))return;
|
||||
const bar=document.createElement('div'); bar.id='sessionBar';
|
||||
bar.style.cssText='position:fixed;right:18px;bottom:18px;z-index:2147483000;display:flex;gap:10px;align-items:center;background:rgba(15,23,42,.94);padding:8px 12px;border-radius:16px;box-shadow:0 10px 28px rgba(0,0,0,.35)';
|
||||
const mic=_btn('micBtn',SVG_MIC,'Mic','#2563eb');
|
||||
const chat=_btn('chatBtn',SVG_CHAT,'Chat','#475569');
|
||||
const end=_btn('endBtn2',SVG_END,'Stop','#dc2626');
|
||||
bar.appendChild(mic);bar.appendChild(chat);bar.appendChild(end);
|
||||
document.body.appendChild(bar);
|
||||
mic.onclick=()=>{const m=window.__mic;if(!m)return;const t=m.getAudioTracks()[0];if(!t)return;t.enabled=!t.enabled;mic.innerHTML='<span style="display:inline-flex">'+(t.enabled?SVG_MIC:SVG_MICOFF)+'</span><span>'+(t.enabled?'Mic':'Muted')+'</span>';mic.style.background=t.enabled?'#2563eb':'#6b7280';};
|
||||
chat.onclick=toggleChat;
|
||||
end.onclick=()=>{ try{ws.send(JSON.stringify({type:'end-session',sessionId,reason:'customer-ended'}));}catch(_){} teardown(); };
|
||||
buildChatPanel();
|
||||
document.addEventListener('pointerdown',ensureAudio,{once:true});
|
||||
document.addEventListener('keydown',ensureAudio,{once:true});
|
||||
if('Notification' in window && Notification.permission==='default'){ try{Notification.requestPermission();}catch(_){}}
|
||||
}
|
||||
function buildChatPanel(){
|
||||
if(document.getElementById('chatPanel'))return;
|
||||
const p=document.createElement('div'); p.id='chatPanel';
|
||||
p.style.cssText='position:fixed;right:18px;bottom:84px;width:300px;max-width:92vw;height:360px;max-height:62vh;z-index:2147483001;background:#fff;border:1px solid #e6e9ef;border-radius:16px;box-shadow:0 14px 34px rgba(0,0,0,.28);display:none;flex-direction:column;overflow:hidden';
|
||||
p.innerHTML='<div style="background:#1F3B73;color:#fff;padding:.6rem .85rem;font-weight:600;font-size:.92rem;display:flex;justify-content:space-between;align-items:center">Chat <span id="chatClose" style="cursor:pointer;opacity:.85">✕</span></div><div id="chatMsgs" style="flex:1;overflow-y:auto;padding:.6rem;display:flex;flex-direction:column;gap:.4rem;font-size:.85rem"></div><div style="display:flex;gap:.4rem;padding:.5rem;border-top:1px solid #e6e9ef"><input id="chatInput" placeholder="Type a message..." style="flex:1;padding:.5rem;border:1px solid #e6e9ef;border-radius:8px;font-size:.85rem;outline:none"><button id="chatSend" style="background:#FFC708;border:none;border-radius:8px;padding:.5rem .85rem;font-weight:700;cursor:pointer">Send</button></div>';
|
||||
document.body.appendChild(p);
|
||||
document.getElementById('chatSend').onclick=sendChat;
|
||||
document.getElementById('chatClose').onclick=toggleChat;
|
||||
document.getElementById('chatInput').addEventListener('keydown',e=>{if(e.key==='Enter')sendChat();});
|
||||
}
|
||||
function toggleChat(){const p=document.getElementById('chatPanel');if(!p)return;chatOpen=!chatOpen;p.style.display=chatOpen?'flex':'none';const b=document.getElementById('chatBtn');if(chatOpen){b&&(b.style.background='#475569');const i=document.getElementById('chatInput');if(i)setTimeout(()=>i.focus(),50);}}
|
||||
function addChat(msg){const c=document.getElementById('chatMsgs');if(!c)return;const mine=msg.from==='__self';const w=document.createElement('div');w.style.cssText='max-width:85%;padding:.4rem .6rem;border-radius:10px;'+(mine?'align-self:flex-end;background:#EAF0FB;color:#16294f':'align-self:flex-start;background:#f1f5f9;color:#1f2430');w.innerHTML='<div style="font-size:.7rem;opacity:.65;margin-bottom:2px">'+esc(msg.name||'')+'</div>'+esc(msg.text);c.appendChild(w);c.scrollTop=c.scrollHeight;if(!mine)notifyMsg(msg);}
|
||||
function notifyMsg(msg){const b=document.getElementById('chatBtn');if(b&&!chatOpen){b.style.background='#FFC708';b.style.color='#1f2430';}toast((msg.name||'Message')+': '+msg.text);try{beep();}catch(_){}if('Notification' in window && Notification.permission==='granted' && (document.hidden||!chatOpen)){try{new Notification('New message from '+(msg.name||'support'),{body:msg.text});}catch(_){}}}
|
||||
function toast(text){let t=document.getElementById('msgToast');if(!t){t=document.createElement('div');t.id='msgToast';t.style.cssText='position:fixed;top:18px;left:50%;transform:translateX(-50%);z-index:2147483600;background:#16a34a;color:#fff;padding:.7rem 1.1rem;border-radius:12px;box-shadow:0 10px 26px rgba(0,0,0,.35);font-size:.92rem;font-weight:600;border:2px solid #0c7a36;max-width:82vw;transition:opacity .4s';document.body.appendChild(t);}t.innerHTML='\ud83d\udcac '+text;t.style.opacity='1';clearTimeout(window.__toastT);window.__toastT=setTimeout(()=>{t.style.opacity='0';},2800);}
|
||||
let __ac=null;
|
||||
function ensureAudio(){try{__ac=__ac||new (window.AudioContext||window.webkitAudioContext)();if(__ac.state==='suspended')__ac.resume();}catch(_){}}
|
||||
function beep(){ensureAudio();if(!__ac)return;try{const o=__ac.createOscillator(),g=__ac.createGain();o.type='sine';o.connect(g);g.connect(__ac.destination);const t0=__ac.currentTime;o.frequency.setValueAtTime(880,t0);o.frequency.setValueAtTime(660,t0+0.09);g.gain.setValueAtTime(0.0001,t0);g.gain.exponentialRampToValueAtTime(0.12,t0+0.02);g.gain.exponentialRampToValueAtTime(0.0001,t0+0.22);o.start(t0);o.stop(t0+0.24);}catch(_){}}
|
||||
try{['pointerdown','keydown','touchstart','click'].forEach(ev=>document.addEventListener(ev,ensureAudio,{passive:true}));}catch(_){}
|
||||
function sendChat(){const i=document.getElementById('chatInput');if(!i)return;const t=i.value.trim();if(!t)return;if(chatChannel&&chatChannel.readyState==='open'){chatChannel.send(JSON.stringify({name:'Customer',text:t}));}addChat({from:'__self',name:'You',text:t});i.value='';}
|
||||
function removeSessionUI(){['sessionBar','chatPanel','remoteAudio','muteBtn','msgToast'].forEach(id=>{const e=document.getElementById(id);if(e)e.remove();});}
|
||||
|
||||
function esc(s){return String(s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));}
|
||||
</script>
|
||||
</body>
|
||||
|
||||
+92
-9
@@ -11,7 +11,7 @@ const A = require('./auth');
|
||||
const PORT = process.env.PORT || 8090;
|
||||
const HTTPS_PORT = process.env.HTTPS_PORT || 8443;
|
||||
const PUBLIC_DIR = path.join(__dirname, 'public');
|
||||
const SESSION_TTL = 1000 * 60 * 60 * 12; // 12h
|
||||
const SESSION_TTL = 1000 * 60 * 60 * 24; // 24h auto-logout
|
||||
|
||||
// ---------- helpers ----------
|
||||
const now = () => Date.now();
|
||||
@@ -65,9 +65,12 @@ const route = (method, p, fn) => (routes[`${method} ${p}`] = fn);
|
||||
|
||||
// Register: creates a team + admin user. MFA must be set up before full access.
|
||||
route('POST', '/api/register', async (req, res) => {
|
||||
const anyUser = db.prepare('SELECT 1 FROM users LIMIT 1').get();
|
||||
if (anyUser && process.env.ALLOW_REGISTRATION !== '1')
|
||||
return json(res, 403, { error: 'Registration is closed. Contact your administrator.' });
|
||||
const { email, password, teamName } = await readBody(req);
|
||||
if (!email || !password) return json(res, 400, { error: 'email and password required' });
|
||||
if (db.prepare('SELECT 1 FROM users WHERE email=?').get(email))
|
||||
if (db.prepare('SELECT 1 FROM users WHERE email=? COLLATE NOCASE').get(email))
|
||||
return json(res, 409, { error: 'email already registered' });
|
||||
const teamId = A.id(), userId = A.id();
|
||||
const { hash, salt } = A.hashPassword(password);
|
||||
@@ -84,7 +87,7 @@ route('POST', '/api/register', async (req, res) => {
|
||||
// Verify MFA enrollment (confirm the user scanned the QR / entered code)
|
||||
route('POST', '/api/mfa/enable', async (req, res) => {
|
||||
const { email, code } = await readBody(req);
|
||||
const u = db.prepare('SELECT * FROM users WHERE email=?').get(email);
|
||||
const u = db.prepare('SELECT * FROM users WHERE email=? COLLATE NOCASE').get(email);
|
||||
if (!u) return json(res, 404, { error: 'no such user' });
|
||||
if (!A.verifyTotp(u.mfa_secret, code)) return json(res, 401, { error: 'invalid code' });
|
||||
db.prepare('UPDATE users SET mfa_enabled=1 WHERE id=?').run(u.id);
|
||||
@@ -93,15 +96,16 @@ route('POST', '/api/mfa/enable', async (req, res) => {
|
||||
|
||||
// Login step 1: email + password -> sets a session cookie (mfa not yet passed)
|
||||
route('POST', '/api/login', async (req, res) => {
|
||||
const { email, password } = await readBody(req);
|
||||
const u = db.prepare('SELECT * FROM users WHERE email=?').get(email);
|
||||
const { email, password, remember } = await readBody(req);
|
||||
const u = db.prepare('SELECT * FROM users WHERE email=? COLLATE NOCASE').get(email);
|
||||
if (!u || !A.verifyPassword(password, u.pw_salt, u.pw_hash))
|
||||
return json(res, 401, { error: 'invalid credentials' });
|
||||
if (u.active === 0) return json(res, 403, { error: 'This account has been deactivated' });
|
||||
const tok = A.token();
|
||||
const ttl = remember ? 1000 * 60 * 60 * 24 * 30 : SESSION_TTL; // 30 days if remembered, else 24h
|
||||
db.prepare('INSERT INTO sessions_auth (token,user_id,mfa_passed,created_at,expires_at) VALUES (?,?,1,?,?)')
|
||||
.run(tok, u.id, now(), now() + SESSION_TTL);
|
||||
res.setHeader('Set-Cookie', `sid=${tok}; HttpOnly; Path=/; Max-Age=${SESSION_TTL / 1000}`);
|
||||
.run(tok, u.id, now(), now() + ttl);
|
||||
res.setHeader('Set-Cookie', `sid=${tok}; HttpOnly; Path=/; Max-Age=${ttl / 1000}`);
|
||||
audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'login' });
|
||||
json(res, 200, { ok: true, mfaRequired: false });
|
||||
});
|
||||
@@ -126,12 +130,72 @@ route('POST', '/api/logout', async (req, res) => {
|
||||
json(res, 200, { ok: true });
|
||||
});
|
||||
|
||||
route('GET', '/api/setup-state', async (req, res) => {
|
||||
const anyUser = db.prepare('SELECT 1 FROM users LIMIT 1').get();
|
||||
json(res, 200, { registrationOpen: !anyUser || process.env.ALLOW_REGISTRATION === '1' });
|
||||
});
|
||||
|
||||
// ICE servers for WebRTC. Always includes a public STUN; adds managed TURN if
|
||||
// configured via env (TURN_URLS comma-separated, TURN_USERNAME, TURN_CREDENTIAL).
|
||||
// Sign up for a managed TURN service (Cloudflare / Metered / Twilio) and set these
|
||||
// three env vars — nothing to install or run on your side.
|
||||
route('GET', '/api/ice', async (req, res) => {
|
||||
const iceServers = [{ urls: 'stun:stun.l.google.com:19302' }];
|
||||
if (process.env.TURN_URLS) {
|
||||
iceServers.push({
|
||||
urls: process.env.TURN_URLS.split(',').map((u) => u.trim()).filter(Boolean),
|
||||
username: process.env.TURN_USERNAME || '',
|
||||
credential: process.env.TURN_CREDENTIAL || '',
|
||||
});
|
||||
}
|
||||
json(res, 200, { iceServers });
|
||||
});
|
||||
|
||||
route('GET', '/api/me', async (req, res) => {
|
||||
const u = currentUser(req);
|
||||
if (!u) return json(res, 401, { error: 'unauthorized' });
|
||||
json(res, 200, { id: u.id, email: u.email, role: u.role, teamId: u.team_id, name: u.name || null });
|
||||
});
|
||||
|
||||
// ---------- BizGaze SSO: agent arrives already logged in ----------
|
||||
route('GET', '/sso', async (req, res) => {
|
||||
if (!process.env.SSO_SECRET) { res.writeHead(503); return res.end('SSO not configured'); }
|
||||
const q = new URLSearchParams(req.url.split('?')[1] || '');
|
||||
const token = q.get('token') || '';
|
||||
const [payloadB64, sig] = token.split('.');
|
||||
const fail = (msg) => { res.writeHead(403, { 'Content-Type': 'text/plain' }); res.end(msg); };
|
||||
if (!payloadB64 || !sig) return fail('Invalid SSO token');
|
||||
const crypto = require('crypto');
|
||||
const expect = crypto.createHmac('sha256', process.env.SSO_SECRET).update(payloadB64).digest('base64url');
|
||||
const sigBuf = Buffer.from(sig), expBuf = Buffer.from(expect);
|
||||
if (sigBuf.length !== expBuf.length || !crypto.timingSafeEqual(sigBuf, expBuf)) return fail('Invalid SSO signature');
|
||||
let p; try { p = JSON.parse(Buffer.from(payloadB64, 'base64url').toString()); } catch { return fail('Invalid SSO payload'); }
|
||||
if (!p.email || !p.exp || p.exp < Math.floor(now() / 1000)) return fail('SSO token expired');
|
||||
let u = db.prepare('SELECT * FROM users WHERE email=? COLLATE NOCASE').get(p.email);
|
||||
if (!u) {
|
||||
const team = db.prepare('SELECT * FROM teams LIMIT 1').get();
|
||||
if (!team) return fail('No team configured');
|
||||
const userId = A.id();
|
||||
const { hash, salt } = A.hashPassword(A.token());
|
||||
const role = (p.role === 'admin' || p.role === 'viewer') ? p.role : 'technician';
|
||||
db.prepare(`INSERT INTO users (id,team_id,email,pw_hash,pw_salt,role,name,mfa_secret,mfa_enabled,created_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,0,?)`)
|
||||
.run(userId, team.id, p.email, hash, salt, role, (p.name || null), A.newMfaSecret(), now());
|
||||
u = db.prepare('SELECT * FROM users WHERE id=?').get(userId);
|
||||
audit({ team_id: team.id, user_id: userId, user_email: p.email, action: 'sso_user_created', detail: p.name || '' });
|
||||
} else if (p.name && p.name !== u.name) {
|
||||
db.prepare('UPDATE users SET name=? WHERE id=?').run(p.name, u.id);
|
||||
}
|
||||
if (u.active === 0) return fail('Account deactivated');
|
||||
const tok = A.token();
|
||||
db.prepare('INSERT INTO sessions_auth (token,user_id,mfa_passed,created_at,expires_at) VALUES (?,?,1,?,?)')
|
||||
.run(tok, u.id, now(), now() + SESSION_TTL);
|
||||
audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'login', detail: 'via BizGaze SSO' });
|
||||
const dest = '/connect' + (p.ticket ? ('?ticket=' + encodeURIComponent(p.ticket)) : '');
|
||||
res.writeHead(302, { 'Set-Cookie': `sid=${tok}; HttpOnly; Path=/; Max-Age=${SESSION_TTL / 1000}`, Location: dest });
|
||||
res.end();
|
||||
});
|
||||
|
||||
// Admin adds an agent login to their team
|
||||
route('POST', '/api/users', async (req, res) => {
|
||||
const u = currentUser(req);
|
||||
@@ -139,7 +203,7 @@ route('POST', '/api/users', async (req, res) => {
|
||||
if (u.role !== 'admin') return json(res, 403, { error: 'only admins can add agents' });
|
||||
const { email, password, name, role } = await readBody(req);
|
||||
if (!email || !password) return json(res, 400, { error: 'email and temporary password required' });
|
||||
if (db.prepare('SELECT 1 FROM users WHERE email=?').get(email))
|
||||
if (db.prepare('SELECT 1 FROM users WHERE email=? COLLATE NOCASE').get(email))
|
||||
return json(res, 409, { error: 'email already registered' });
|
||||
const userId = A.id();
|
||||
const { hash, salt } = A.hashPassword(password);
|
||||
@@ -263,6 +327,7 @@ const MIME = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css
|
||||
function serveStatic(req, res) {
|
||||
let p = req.url.split('?')[0];
|
||||
if (p === '/') p = '/index.html';
|
||||
if (p === '/console') p = '/console.html';
|
||||
if (p === '/share') p = '/share.html';
|
||||
if (p === '/connect') p = '/connect.html';
|
||||
const fp = path.join(PUBLIC_DIR, path.normalize(p));
|
||||
@@ -292,11 +357,14 @@ const liveSessions = new Map(); // sessionId -> { agentWs, viewerWs, machine,
|
||||
const pendingShares = new Map(); // code -> { sharerWs, sessionId } (no-install ad-hoc shares)
|
||||
|
||||
function onConnection(ws, req) {
|
||||
const hb = setInterval(() => {
|
||||
if (ws.readyState === 1) { try { ws.ping(); } catch {} } else { clearInterval(hb); }
|
||||
}, 25000);
|
||||
ws.on('message', (raw) => {
|
||||
let m; try { m = JSON.parse(raw); } catch { return; }
|
||||
handle(ws, m, req);
|
||||
});
|
||||
ws.on('close', () => cleanup(ws));
|
||||
ws.on('close', () => { clearInterval(hb); cleanup(ws); });
|
||||
}
|
||||
|
||||
const wss = new WebSocketServer({ server, path: '/ws' });
|
||||
@@ -404,10 +472,25 @@ function handle(ws, m, req) {
|
||||
}
|
||||
}
|
||||
|
||||
function notifyBizGaze(sessionId) {
|
||||
const url = process.env.BIZGAZE_WEBHOOK_URL;
|
||||
if (!url) return;
|
||||
try {
|
||||
const row = db.prepare('SELECT * FROM sessions_log WHERE id=?').get(sessionId);
|
||||
if (!row) return;
|
||||
const body = JSON.stringify({ event: 'session.ended', sessionId: row.id, agent_email: row.agent_email,
|
||||
agent_name: row.agent_name, ticket: row.ticket, started_at: row.started_at, ended_at: row.ended_at,
|
||||
duration_ms: row.ended_at ? row.ended_at - row.started_at : null });
|
||||
const crypto = require('crypto');
|
||||
const sig = process.env.SSO_SECRET ? crypto.createHmac('sha256', process.env.SSO_SECRET).update(body).digest('base64url') : '';
|
||||
fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-BizGaze-Signature': sig }, body }).catch(()=>{});
|
||||
} catch (e) {}
|
||||
}
|
||||
function endSession(sessionId, reason) {
|
||||
const sess = liveSessions.get(sessionId);
|
||||
if (!sess) return;
|
||||
try { db.prepare('UPDATE sessions_log SET ended_at=? WHERE id=? AND ended_at IS NULL').run(now(), sessionId); } catch (e) {}
|
||||
notifyBizGaze(sessionId);
|
||||
audit({ team_id: sess.machine.team_id, user_id: sess.user.id, user_email: sess.user.email, machine_id: sess.machine.id, machine_name: sess.machine.name, action: 'session_ended', detail: sess.ticket ? 'Ticket ' + sess.ticket : (sess.machine.id ? null : 'Direct session') });
|
||||
[sess.agentWs, sess.viewerWs].forEach((p) => {
|
||||
if (p && p.readyState === 1) p.send(JSON.stringify({ type: 'session-ended', sessionId, reason: reason || null }));
|
||||
|
||||
Reference in New Issue
Block a user