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:
2026-06-09 16:47:43 +05:30
bovenliggende 0fef3275bf
commit 3560e1756e
6 gewijzigde bestanden met toevoegingen van 659 en 350 verwijderingen
+92 -9
Bestand weergeven
@@ -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 }));