diff --git a/server/package.json b/server/package.json index 97dd71d..ab6c9bb 100644 --- a/server/package.json +++ b/server/package.json @@ -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" } } diff --git a/server/public/connect.html b/server/public/connect.html index d78ecd6..321b9a8 100644 --- a/server/public/connect.html +++ b/server/public/connect.html @@ -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} @@ -43,13 +51,20 @@ + + diff --git a/server/public/index.html b/server/public/index.html index 946a2a2..8634bd0 100644 --- a/server/public/index.html +++ b/server/public/index.html @@ -3,333 +3,75 @@ -BizGaze Support — Console +BizGaze Support
-
BizGaze Support · Console
-
+
+ +
BizGaze Support
+
+
-
- +
+
+

How can we help you today?

+
Secure remote support — no downloads, you stay in control.
+ +
🔒 Screen sharing only starts after the customer approves it, and can be stopped anytime.
+
+
+ diff --git a/server/public/share.html b/server/public/share.html index e7dab2e..032f8fa 100644 --- a/server/public/share.html +++ b/server/public/share.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}
● Your screen is being shared — close this tab anytime to stop
+← Home
@@ -61,6 +70,13 @@
diff --git a/server/server.js b/server/server.js index 42c88a2..d675b01 100644 --- a/server/server.js +++ b/server/server.js @@ -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 }));