first commit
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>BizGaze Support — Agent Console</title>
|
||||
<style>
|
||||
:root{ --brand:#FFC708; --brand-d:#E0AC00; --blue:#1F3B73; --blue-d:#16294f; --ink:#1f2430; --muted:#6b7280; --bg:#f6f8fb; --card:#fff; --line:#e6e9ef; }
|
||||
*{box-sizing:border-box;}
|
||||
body{font-family:'Segoe UI',system-ui,sans-serif;background:var(--bg);color:var(--ink);margin:0;}
|
||||
.topbar{background:var(--blue);padding:.7rem 1.2rem;display:flex;align-items:center;justify-content:space-between;gap:.6rem;}
|
||||
.brandrow{display:flex;align-items:center;gap:.6rem;}
|
||||
.logo{width:28px;height:28px;border-radius:7px;background:var(--brand);display:grid;place-items:center;font-weight:800;color:var(--blue);}
|
||||
.brand{font-weight:700;color:#fff;} .brand span{color:var(--brand);font-weight:600;}
|
||||
.agentchip{color:#dbe4f5;font-size:.85rem;}
|
||||
.agentchip b{color:#fff;} .agentchip a{color:var(--brand);text-decoration:none;margin-left:.5rem;cursor:pointer;}
|
||||
.wrap{min-height:calc(100vh - 50px);display:grid;place-items:center;padding:1.5rem;}
|
||||
.card{background:var(--card);border:1px solid var(--line);border-radius:18px;padding:2.2rem;max-width:430px;width:100%;box-shadow:0 10px 30px rgba(20,30,60,.06);}
|
||||
h1{font-size:1.35rem;margin:.1rem 0 .3rem;color:var(--blue);text-align:center;}
|
||||
.sub{color:var(--muted);font-size:.92rem;margin-bottom:1.3rem;text-align:center;}
|
||||
.lbl{display:block;font-size:.74rem;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;margin:.85rem 0 .25rem;}
|
||||
input{width:100%;padding:.7rem;border-radius:10px;border:2px solid var(--line);background:#fbfcfe;color:var(--ink);font-size:1rem;}
|
||||
input:focus{outline:none;border-color:var(--brand);}
|
||||
input.code{font-size:1.7rem;letter-spacing:.35rem;text-align:center;}
|
||||
.btn{margin-top:1.1rem;width:100%;padding:.85rem;background:var(--brand);color:var(--ink);border:none;border-radius:10px;font-weight:700;font-size:1.02rem;cursor:pointer;}
|
||||
.btn:hover{background:var(--brand-d);}
|
||||
.status{color:var(--muted);font-size:.88rem;margin-top:.9rem;text-align:center;min-height:1.1em;}
|
||||
.err{color:#b91c1c;}
|
||||
.prefill{background:#EAF0FB;border:1px solid #c7d6f0;border-radius:8px;padding:.5rem .7rem;font-size:.85rem;color:var(--blue-d);margin-top:.25rem;}
|
||||
.topbar2{background:var(--card);border-bottom:1px solid var(--line);padding:.5rem 1rem;display:none;justify-content:space-between;align-items:center;}
|
||||
.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;}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="topbar" id="topbar">
|
||||
<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></div></div>
|
||||
<div class="agentchip" id="agentChip"></div>
|
||||
</div>
|
||||
<div class="topbar2" id="bar"><span id="barStatus"></span><button id="endBtn">End session</button></div>
|
||||
<div class="wrap" id="wrap"><div class="card" id="card"></div></div>
|
||||
<video id="video" autoplay playsinline muted tabindex="0"></video>
|
||||
|
||||
<script>
|
||||
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;
|
||||
|
||||
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(); } }); }); }
|
||||
|
||||
// ---- boot: are we a logged-in agent? ----
|
||||
(async function boot(){
|
||||
try{ me=await api('/api/me',null,'GET'); renderAgent(); }
|
||||
catch{ renderLogin(); }
|
||||
})();
|
||||
|
||||
// ---- LOGIN ----
|
||||
function renderLogin(){
|
||||
agentChip.textContent='';
|
||||
card.innerHTML = `
|
||||
<h1>Agent sign in</h1>
|
||||
<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>
|
||||
<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});
|
||||
me=await api('/api/me',null,'GET'); renderAgent();
|
||||
}catch(e){ document.getElementById('err').textContent=e.message; }
|
||||
};
|
||||
document.getElementById('loginBtn').onclick=doSignIn;
|
||||
onEnter(['email','pw'], doSignIn);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 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(); };
|
||||
|
||||
card.innerHTML = `
|
||||
<h1>Start a support session</h1>
|
||||
<div class="sub">Ask the customer to open the share page and read you their code.</div>
|
||||
<span class="lbl">Ticket number (optional)</span>
|
||||
<input id="ticketInput" maxlength="40" placeholder="e.g. TKT-1042 — leave blank for a direct session" value="${esc(presetTicket)}" ${presetTicket?'readonly':''}>
|
||||
${presetTicket?'<div class="prefill">Linked from service request '+esc(presetTicket)+'</div>':''}
|
||||
<span class="lbl">Session code from customer</span>
|
||||
<input id="codeInput" class="code" maxlength="6" inputmode="numeric" placeholder="000000" value="${esc(presetCode)}">
|
||||
<button class="btn" id="connectBtn">Connect</button>
|
||||
<div class="status" id="status">Enter the customer's code to begin.</div>`;
|
||||
connectWS();
|
||||
document.getElementById('connectBtn').onclick=startConnect;
|
||||
onEnter(['ticketInput','codeInput'], startConnect);
|
||||
}
|
||||
|
||||
async function startConnect(){
|
||||
const statusEl=document.getElementById('status'); statusEl.className='status';
|
||||
const ticket=document.getElementById('ticketInput').value.trim();
|
||||
const code=document.getElementById('codeInput').value.trim();
|
||||
if(!/^\d{6}$/.test(code)){ statusEl.textContent='Please enter the 6-digit code.'; return; }
|
||||
statusEl.textContent='Connecting…';
|
||||
ws.send(JSON.stringify({type:'code-connect',code,ticket}));
|
||||
}
|
||||
|
||||
function connectWS(){
|
||||
ws=new WebSocket((location.protocol==='https:'?'wss://':'ws://')+location.host+'/ws');
|
||||
ws.onmessage=async(e)=>{const m=JSON.parse(e.data);const statusEl=document.getElementById('status');switch(m.type){
|
||||
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));
|
||||
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;
|
||||
case 'session-denied': renderEnded('The customer declined the request.'); break;
|
||||
case 'session-ended': {
|
||||
const msgs={'share-cancelled':'The customer cancelled the screen selection. Ask them to refresh their page for a new code.',
|
||||
'customer-ended':'The customer stopped sharing their screen. Ask them to refresh their page for a new code.',
|
||||
'agent-ended':'You ended the session.'};
|
||||
renderEnded(msgs[m.reason]||'The session has ended.'); break;
|
||||
}
|
||||
case 'error': if(statusEl){statusEl.className='status err';statusEl.textContent=m.message;} break;
|
||||
}};
|
||||
}
|
||||
function renderWaiting(){
|
||||
card.innerHTML=`
|
||||
<h1>Waiting for the customer…</h1>
|
||||
<div class="sub">Code accepted. The customer has been asked to tap <b>Allow</b> on their screen.</div>
|
||||
<div class="status" id="status">Waiting for the customer to tap Allow…</div>
|
||||
<button class="btn" id="cancelBtn" style="background:#eef1f6;color:#1F3B73">Cancel</button>`;
|
||||
document.getElementById('cancelBtn').onclick=()=>{ try{ws.send(JSON.stringify({type:'end-session',sessionId}));}catch(e){} location.reload(); };
|
||||
}
|
||||
|
||||
function renderEnded(msg){
|
||||
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';
|
||||
card.innerHTML=`
|
||||
<h1>Session ended</h1>
|
||||
<div class="sub">${esc(msg)}</div>
|
||||
<button class="btn" id="againBtn">Start a new session</button>`;
|
||||
document.getElementById('againBtn').onclick=()=>location.reload();
|
||||
}
|
||||
|
||||
function setupPeer(){
|
||||
pc=new RTCPeerConnection({iceServers:[{urls:'stun:stun.l.google.com:19302'}]});
|
||||
inputChannel=pc.createDataChannel('input',{ordered:true});
|
||||
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();
|
||||
};
|
||||
pc.onicecandidate=(ev)=>{if(ev.candidate)ws.send(JSON.stringify({type:'ice-candidate',sessionId,candidate:ev.candidate}));};
|
||||
}
|
||||
const send=(o)=>{if(inputChannel&&inputChannel.readyState==='open')inputChannel.send(JSON.stringify(o));};
|
||||
const rel=(e)=>{const r=video.getBoundingClientRect();return{x:(e.clientX-r.left)/r.width,y:(e.clientY-r.top)/r.height};};
|
||||
let lm=0;
|
||||
video.addEventListener('mousemove',e=>{const t=performance.now();if(t-lm<30)return;lm=t;send({kind:'mousemove',...rel(e)});});
|
||||
video.addEventListener('mousedown',e=>{video.focus();send({kind:'mousedown',button:e.button,...rel(e)});});
|
||||
video.addEventListener('mouseup',e=>send({kind:'mouseup',button:e.button,...rel(e)}));
|
||||
video.addEventListener('dblclick',e=>send({kind:'dblclick',...rel(e)}));
|
||||
video.addEventListener('wheel',e=>{e.preventDefault();send({kind:'scroll',dx:e.deltaX,dy:e.deltaY});},{passive:false});
|
||||
video.addEventListener('contextmenu',e=>e.preventDefault());
|
||||
video.addEventListener('keydown',e=>{e.preventDefault();send({kind:'keydown',key:e.key,code:e.code});});
|
||||
video.addEventListener('keyup',e=>{e.preventDefault();send({kind:'keyup',key:e.key,code:e.code});});
|
||||
document.getElementById('endBtn').onclick=()=>{ws.send(JSON.stringify({type:'end-session',sessionId,reason:'agent-ended'}));};
|
||||
function esc(s){return String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,102 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Browser Host — Remote Access</title>
|
||||
<style>
|
||||
body { font-family: system-ui, sans-serif; background:#0f172a; color:#e2e8f0; margin:0; padding:1.5rem; }
|
||||
.card { max-width:560px; margin:0 auto; background:#1e293b; border-radius:12px; padding:1.5rem; }
|
||||
h1 { font-size:1.15rem; margin:0 0 1rem; }
|
||||
input { width:100%; padding:.7rem; border-radius:8px; border:1px solid #334155; background:#0f172a; color:#e2e8f0; margin:.3rem 0; }
|
||||
button { padding:.7rem 1.3rem; background:#3b82f6; color:#fff; border:none; border-radius:8px; font-weight:600; cursor:pointer; }
|
||||
.status { background:#0f172a; padding:.7rem 1rem; border-radius:8px; margin:1rem 0; font-size:.9rem; }
|
||||
.status.on { background:#14532d; } .status.warn { background:#7c2d12; }
|
||||
.consent { border:1px solid #3b82f6; border-radius:10px; padding:1rem; margin-top:1rem; }
|
||||
.grant { background:#22c55e; color:#052e16; } .deny { background:#ef4444; margin-left:.5rem; }
|
||||
.muted { color:#94a3b8; font-size:.82rem; }
|
||||
#log { font-family:monospace; font-size:.72rem; color:#64748b; height:120px; overflow-y:auto; margin-top:1rem; background:#020617; padding:.6rem; border-radius:8px; }
|
||||
.indicator { position:fixed; bottom:0; left:0; right:0; background:#b91c1c; color:#fff; text-align:center; padding:.4rem; font-size:.85rem; display:none; }
|
||||
.indicator.show { display:block; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1>🖥️ Browser Host (no install)</h1>
|
||||
<p class="muted">Shares this screen with a technician. Paste the enroll token from the console and click Go online.</p>
|
||||
<input id="token" placeholder="enroll token">
|
||||
<button id="goBtn">Go online</button>
|
||||
<div id="status" class="status">Idle.</div>
|
||||
<div id="consentBox"></div>
|
||||
<div id="log"></div>
|
||||
</div>
|
||||
<div id="indicator" class="indicator">● A technician is viewing this screen</div>
|
||||
|
||||
<script>
|
||||
const statusEl = document.getElementById('status');
|
||||
const consentBox = document.getElementById('consentBox');
|
||||
const indicator = document.getElementById('indicator');
|
||||
const logEl = document.getElementById('log');
|
||||
const log = (t) => { const d=document.createElement('div'); d.textContent=t; logEl.appendChild(d); logEl.scrollTop=logEl.scrollHeight; };
|
||||
const setStatus = (t,c='') => { statusEl.textContent=t; statusEl.className='status '+c; };
|
||||
|
||||
let ws, pc, localStream, sessionId;
|
||||
|
||||
document.getElementById('goBtn').onclick = () => {
|
||||
const token = document.getElementById('token').value.trim();
|
||||
if (!token) return setStatus('Enter the enroll token first.', 'warn');
|
||||
connect(token);
|
||||
};
|
||||
|
||||
function connect(token) {
|
||||
setStatus('Connecting to server…');
|
||||
ws = new WebSocket((location.protocol==='https:'?'wss://':'ws://') + location.host + '/ws');
|
||||
ws.onopen = () => ws.send(JSON.stringify({ type:'agent-hello', enrollToken: token }));
|
||||
ws.onmessage = (e) => handle(JSON.parse(e.data));
|
||||
ws.onclose = () => setStatus('Disconnected.', 'warn');
|
||||
}
|
||||
|
||||
async function handle(m) {
|
||||
switch (m.type) {
|
||||
case 'agent-registered': setStatus(`Online as "${m.name}". Waiting for a session.`, 'on'); log('registered: '+m.name); break;
|
||||
case 'session-request': m.unattended ? grant(m.sessionId) : showConsent(m); break;
|
||||
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 'error': setStatus('Server: '+m.message, 'warn'); break;
|
||||
}
|
||||
}
|
||||
|
||||
function showConsent(m) {
|
||||
consentBox.innerHTML = `<div class="consent"><b>${esc(m.technician)}</b> wants to view this screen.
|
||||
<div style="margin-top:.6rem"><button class="grant" id="g">Allow</button><button class="deny" id="d">Deny</button></div></div>`;
|
||||
document.getElementById('g').onclick = () => { consentBox.innerHTML=''; grant(m.sessionId); };
|
||||
document.getElementById('d').onclick = () => { consentBox.innerHTML=''; ws.send(JSON.stringify({type:'consent',sessionId:m.sessionId,granted:false})); };
|
||||
}
|
||||
const grant = (sid) => ws.send(JSON.stringify({ type:'consent', sessionId:sid, granted:true }));
|
||||
|
||||
async function startStreaming() {
|
||||
setStatus('Sharing screen…', 'on'); indicator.classList.add('show');
|
||||
try { localStream = await navigator.mediaDevices.getDisplayMedia({ video:{frameRate:{ideal:30}}, audio:false }); }
|
||||
catch (err) { log('getDisplayMedia failed: '+err.message); setStatus('Screen capture cancelled/failed.', 'warn'); return; }
|
||||
pc = new RTCPeerConnection({ iceServers:[{urls:'stun:stun.l.google.com:19302'}] });
|
||||
localStream.getTracks().forEach(t => pc.addTrack(t, localStream));
|
||||
pc.ondatachannel = (ev) => { ev.channel.onmessage = (msg) => { let e; try{e=JSON.parse(msg.data)}catch{return} if(e.kind!=='mousemove') log('remote input: '+e.kind+' '+(e.key||'')); }; };
|
||||
pc.onicecandidate = (ev) => { if (ev.candidate) ws.send(JSON.stringify({type:'ice-candidate',sessionId,candidate:ev.candidate})); };
|
||||
const offer = await pc.createOffer(); await pc.setLocalDescription(offer);
|
||||
ws.send(JSON.stringify({ type:'offer', sessionId, sdp: pc.localDescription }));
|
||||
log('sent offer');
|
||||
localStream.getVideoTracks()[0].onended = () => teardown();
|
||||
}
|
||||
|
||||
function teardown() {
|
||||
indicator.classList.remove('show');
|
||||
if (localStream) { localStream.getTracks().forEach(t=>t.stop()); localStream=null; }
|
||||
if (pc) { pc.close(); pc=null; }
|
||||
setStatus('Session ended. Still online, waiting.', 'on');
|
||||
}
|
||||
function esc(s){return String(s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,335 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>BizGaze Support — 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;}
|
||||
</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>
|
||||
</header>
|
||||
<main id="app"></main>
|
||||
|
||||
<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(); }
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
@@ -0,0 +1,122 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>BizGaze Support — Share your screen</title>
|
||||
<style>
|
||||
:root{ --brand:#FFC708; --brand-d:#E0AC00; --blue:#1F3B73; --blue-d:#16294f; --blue-soft:#EAF0FB; --ink:#1f2430; --muted:#6b7280; --bg:#f6f8fb; --card:#ffffff; --line:#e6e9ef; }
|
||||
*{box-sizing:border-box;}
|
||||
body{font-family:'Segoe UI',system-ui,sans-serif;background:var(--bg);color:var(--ink);margin:0;}
|
||||
.stage{display:flex;min-height:100vh;}
|
||||
.brandpanel{flex:1;background:linear-gradient(160deg,var(--blue),var(--blue-d));color:#fff;display:flex;flex-direction:column;justify-content:center;align-items:center;text-align:center;padding:2.5rem;}
|
||||
.mark{width:88px;height:88px;border-radius:22px;background:var(--brand);display:grid;place-items:center;font-weight:800;font-size:2.6rem;color:var(--blue);margin-bottom:1.2rem;box-shadow:0 12px 30px rgba(0,0,0,.25);}
|
||||
.wordmark{font-size:2.2rem;font-weight:800;letter-spacing:.01em;}
|
||||
.wordmark span{color:var(--brand);}
|
||||
.tagline{color:#cdd7ee;margin-top:.6rem;font-size:1rem;max-width:300px;line-height:1.5;}
|
||||
.panelside{flex:1;display:flex;align-items:center;justify-content:center;padding:2rem;}
|
||||
.card{background:var(--card);border:1px solid var(--line);border-radius:18px;padding:2.4rem;max-width:440px;width:100%;text-align:center;box-shadow:0 10px 30px rgba(20,30,60,.06);}
|
||||
h1{font-size:1.45rem;margin:.2rem 0 .4rem;color:var(--blue);}
|
||||
.sub{color:var(--muted);font-size:.97rem;line-height:1.5;margin-bottom:1.6rem;}
|
||||
.codewrap{background:#fffdf2;border:2px dashed var(--brand);border-radius:14px;padding:1.2rem;}
|
||||
.codelabel{font-size:.78rem;letter-spacing:.08em;text-transform:uppercase;color:var(--muted);margin-bottom:.3rem;}
|
||||
.code{font-size:3rem;letter-spacing:.5rem;font-weight:800;color:var(--ink);}
|
||||
.status{margin-top:1.3rem;padding:.7rem 1rem;border-radius:10px;background:#f1f5f9;color:#475569;font-size:.92rem;}
|
||||
.status.on{background:#ecfdf3;color:#15803d;}
|
||||
.consent{margin-top:1.3rem;border:1px solid #c7d6f0;background:var(--blue-soft);border-left:5px solid var(--blue);border-radius:12px;padding:1.3rem;text-align:left;color:var(--blue-d);}
|
||||
.consent .who{font-weight:700;color:var(--blue);}
|
||||
.btns{margin-top:1rem;display:flex;gap:.6rem;}
|
||||
button{flex:1;padding:.8rem 1rem;border:none;border-radius:10px;font-weight:700;font-size:.98rem;cursor:pointer;}
|
||||
.grant{background:var(--brand);color:var(--ink);} .grant:hover{background:var(--brand-d);}
|
||||
.deny{background:#fff;color:var(--blue);border:1px solid #c7d6f0;} .deny:hover{background:#f3f6fc;}
|
||||
.foot{color:var(--muted);font-size:.8rem;margin-top:1.4rem;}
|
||||
.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;} }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="indicator" id="indicator">● Your screen is being shared — close this tab anytime to stop</div>
|
||||
<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'}))">
|
||||
<div class="wordmark">BizGaze <span>Support</span></div>
|
||||
<div class="tagline">Secure, instant remote support — no downloads, you stay in control.</div>
|
||||
</div>
|
||||
<div class="panelside">
|
||||
<div class="card">
|
||||
<h1>Let's get you connected</h1>
|
||||
<div class="sub">Share the code below with your BizGaze support agent.</div>
|
||||
<div class="codewrap">
|
||||
<div class="codelabel">Your session code</div>
|
||||
<div style="display:flex;align-items:center;justify-content:center;gap:.7rem">
|
||||
<div class="code" id="code">······</div>
|
||||
<button id="copyBtn" title="Click to copy" aria-label="Click to copy" style="flex:0 0 auto;width:38px;height:38px;padding:0;background:#fff;border:1px solid var(--brand);color:var(--blue);border-radius:8px;cursor:pointer;display:inline-flex;align-items:center;justify-content:center"><svg id="copyIcon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="status" class="status">Preparing your code…</div>
|
||||
<div id="consentBox"></div>
|
||||
<div class="foot">🔒 You stay in control. The agent can only see your screen after you tap Allow, and you can stop anytime.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
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;};
|
||||
document.getElementById('copyBtn').onclick=async()=>{
|
||||
const code=codeEl.textContent.trim();
|
||||
if(!/^\d{6}$/.test(code)) return;
|
||||
try{ await navigator.clipboard.writeText(code); }
|
||||
catch(e){ const ta=document.createElement('textarea');ta.value=code;document.body.appendChild(ta);ta.select();document.execCommand('copy');ta.remove(); }
|
||||
const b=document.getElementById('copyBtn'); const old=b.innerHTML;
|
||||
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;
|
||||
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){
|
||||
case 'share-code': codeEl.textContent=m.code; setStatus('Waiting for your agent to enter the code…'); break;
|
||||
case 'share-request': onAgentConnected(m); break;
|
||||
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 'error': setStatus(m.message,''); break;
|
||||
}};
|
||||
ws.onclose=()=>setStatus('Connection closed. Refresh the page to start again.');
|
||||
function onAgentConnected(m){
|
||||
const cw=document.querySelector('.codewrap');
|
||||
if(cw) cw.style.display='none';
|
||||
setStatus('Your agent has connected. Please respond below.','on');
|
||||
showConsent(m);
|
||||
}
|
||||
function showConsent(m){
|
||||
const name=(m.technician&&m.technician.trim())?m.technician:'Your support agent';
|
||||
consentBox.innerHTML='<div class="consent">Your support agent <span class="who">'+esc(name)+'</span> would like to view your screen to help you.'+
|
||||
'<div class="btns"><button class="grant" id="g">Allow</button><button class="deny" id="d">Not now</button></div></div>';
|
||||
const allow=()=>{consentBox.innerHTML='';document.removeEventListener('keydown',onKey);ws.send(JSON.stringify({type:'consent',sessionId:m.sessionId,granted:true}));};
|
||||
const onKey=(e)=>{if(e.key==='Enter'){e.preventDefault();allow();}};
|
||||
document.addEventListener('keydown',onKey);
|
||||
document.getElementById('g').onclick=allow;
|
||||
document.getElementById('d').onclick=()=>{consentBox.innerHTML='';document.removeEventListener('keydown',onKey);ws.send(JSON.stringify({type:'consent',sessionId:m.sessionId,granted:false}));setStatus('Connection declined. Refresh this page if you need a new code.');};
|
||||
}
|
||||
async function startStreaming(){
|
||||
setStatus('In the popup: click the screen preview so it is selected, then press Share.','on');
|
||||
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'}]});
|
||||
localStream.getTracks().forEach(t=>pc.addTrack(t,localStream));
|
||||
pc.ondatachannel=(ev)=>{ev.channel.onmessage=()=>{};};
|
||||
pc.onicecandidate=(ev)=>{if(ev.candidate)ws.send(JSON.stringify({type:'ice-candidate',sessionId,candidate:ev.candidate}));};
|
||||
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 esc(s){return String(s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,107 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Remote Session</title>
|
||||
<style>
|
||||
body { font-family: system-ui, sans-serif; background: #0f172a; color: #e2e8f0; margin: 0; }
|
||||
header { background: #1e293b; padding: 0.6rem 1rem; display: flex; justify-content: space-between; align-items: center; }
|
||||
#status { font-size: 0.9rem; color: #94a3b8; }
|
||||
#video { width: 100vw; height: calc(100vh - 48px); background: #020617; object-fit: contain; cursor: crosshair; display: block; outline: none; }
|
||||
button { padding: 0.5rem 1rem; background: #334155; color: #fff; border: none; border-radius: 6px; cursor: pointer; }
|
||||
a { color: #3b82f6; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div id="status">Connecting…</div>
|
||||
<div>
|
||||
<a href="/">← Console</a>
|
||||
<button id="endBtn">End session</button>
|
||||
</div>
|
||||
</header>
|
||||
<video id="video" autoplay playsinline muted tabindex="0"></video>
|
||||
|
||||
<script>
|
||||
const params = new URLSearchParams(location.search);
|
||||
const machineId = params.get('machine');
|
||||
const machineName = params.get('name') || 'remote PC';
|
||||
const statusEl = document.getElementById('status');
|
||||
const video = document.getElementById('video');
|
||||
|
||||
let pc, inputChannel, sessionId;
|
||||
const ws = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/ws');
|
||||
|
||||
const setStatus = (t) => (statusEl.textContent = t);
|
||||
|
||||
ws.onopen = () => {
|
||||
setStatus(`Requesting access to ${machineName}…`);
|
||||
ws.send(JSON.stringify({ type: 'viewer-connect', machineId }));
|
||||
};
|
||||
|
||||
ws.onmessage = async (e) => {
|
||||
const m = JSON.parse(e.data);
|
||||
switch (m.type) {
|
||||
case 'session-pending':
|
||||
sessionId = m.sessionId;
|
||||
setStatus(`Waiting for ${machineName} to grant consent…`);
|
||||
break;
|
||||
case 'session-denied':
|
||||
setStatus('Consent denied by the remote user.');
|
||||
break;
|
||||
case 'session-ready':
|
||||
setStatus('Consent granted. Establishing connection…');
|
||||
setupPeer();
|
||||
break;
|
||||
case 'offer':
|
||||
await pc.setRemoteDescription(new RTCSessionDescription(m.sdp));
|
||||
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;
|
||||
case 'session-ended':
|
||||
setStatus('Session ended.');
|
||||
video.srcObject = null;
|
||||
break;
|
||||
case 'error':
|
||||
setStatus('Error: ' + m.message);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
function setupPeer() {
|
||||
pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] });
|
||||
inputChannel = pc.createDataChannel('input', { ordered: true });
|
||||
pc.ontrack = (ev) => {
|
||||
video.srcObject = ev.streams[0];
|
||||
setStatus(`Connected to ${machineName} — controlling. Click the screen to send input.`);
|
||||
video.focus();
|
||||
};
|
||||
pc.onicecandidate = (ev) => {
|
||||
if (ev.candidate) ws.send(JSON.stringify({ type: 'ice-candidate', sessionId, candidate: ev.candidate }));
|
||||
};
|
||||
}
|
||||
|
||||
// ---- input capture (normalized coords) ----
|
||||
const send = (o) => { if (inputChannel && inputChannel.readyState === 'open') inputChannel.send(JSON.stringify(o)); };
|
||||
const rel = (e) => { const r = video.getBoundingClientRect(); return { x: (e.clientX - r.left) / r.width, y: (e.clientY - r.top) / r.height }; };
|
||||
let lastMove = 0;
|
||||
video.addEventListener('mousemove', (e) => { const t = performance.now(); if (t - lastMove < 30) return; lastMove = t; send({ kind: 'mousemove', ...rel(e) }); });
|
||||
video.addEventListener('mousedown', (e) => { video.focus(); send({ kind: 'mousedown', button: e.button, ...rel(e) }); });
|
||||
video.addEventListener('mouseup', (e) => send({ kind: 'mouseup', button: e.button, ...rel(e) }));
|
||||
video.addEventListener('dblclick', (e) => send({ kind: 'dblclick', ...rel(e) }));
|
||||
video.addEventListener('wheel', (e) => { e.preventDefault(); send({ kind: 'scroll', dx: e.deltaX, dy: e.deltaY }); }, { passive: false });
|
||||
video.addEventListener('contextmenu', (e) => e.preventDefault());
|
||||
video.addEventListener('keydown', (e) => { e.preventDefault(); send({ kind: 'keydown', key: e.key, code: e.code, ctrl: e.ctrlKey, alt: e.altKey, shift: e.shiftKey, meta: e.metaKey }); });
|
||||
video.addEventListener('keyup', (e) => { e.preventDefault(); send({ kind: 'keyup', key: e.key, code: e.code }); });
|
||||
|
||||
document.getElementById('endBtn').onclick = () => {
|
||||
ws.send(JSON.stringify({ type: 'end-session', sessionId }));
|
||||
setTimeout(() => (location.href = '/'), 300);
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user