15 Commits

Author SHA1 Message Date
Sravan e9e5c7f406 fix(pwa): white icon tile for contrast + cache-bust icon URLs (v2)
Logo was dark-on-blue (low contrast); now centered on a white tile like the
header treatment. Icon URLs versioned (?v=2) so browsers/installs fetch the new
ones. Build marker -> pwa2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 15:02:11 +05:30
Sravan a427be9b6f fix(cache): send Cache-Control: no-store on all JSON/404 responses
Prevents a 404 (e.g. /manifest.json fetched before deploy) from being cached on
a device and persisting after the file exists — the cause of the manifest 404
on mobile but not desktop.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 17:58:23 +05:30
Sravan b576ed372a feat(pwa): installable app (Add to Home Screen) for Android + iOS
- manifest.json (standalone display, theme color, maskable icons 192/512).
- generated square icons + apple-touch-icon (180) from the logo.
- apple-mobile-web-app + theme-color meta in home.html.
- sw.js gets a no-op fetch handler so it meets installability criteria (still
  no caching). static.js serves .json/.webmanifest with correct MIME.
- Installing as a PWA also unlocks Web Push on iOS (Apple requires Add to Home Screen).
Build marker -> pwa1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 17:13:01 +05:30
Sravan f4a23ae805 fix(cache): serve HTML with no-store so deploys reach browsers without a hard refresh
Browsers were serving a cached old home.html on normal reloads (only incognito/
hard-refresh got the new one). HTML now sends Cache-Control: no-store; versioned
assets keep ETag revalidation. Bumps build marker to push4.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 16:30:17 +05:30
Sravan f7ddb2e7ae fix(push): wait for active SW before subscribe + log every step (subscribe was failing silently)
subscribePush() swallowed all errors, so if pushManager.subscribe() failed
(e.g. called before the service worker was active) nobody ever subscribed and
there was no trace. Now: await serviceWorker.ready before subscribing, and
console.log/warn each step so the real failure is visible. Server send path
verified independently (web-push builds valid VAPID requests).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 13:57:03 +05:30
Sravan 5edb3fa241 fix(chat): dedup sent message in sendMessage too (WS echo can beat the POST response)
The server echoes the sender's own message over WS before returning the HTTP
response, so onChatMessage could append it before sendMessage's await resolved,
then sendMessage appended again -> double. Both append paths now dedup by id.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 13:39:42 +05:30
Sravan 88d7657364 chore: add build marker (window.__BUILD) to home.html for deploy verification
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 12:38:35 +05:30
Sravan 1272b81cee feat(push): Web Push notifications for backgrounded/closed/mobile tabs
Page-level Notifications can't fire when a tab is frozen/closed (and never on
mobile), which is why recipients on another tab/app got nothing. Adds a
notification-only service worker (sw.js, no caching) + Web Push:

- push.js: optional web-push wrapper (no-op unless web-push installed AND
  VAPID_PUBLIC_KEY/VAPID_PRIVATE_KEY set -> app unaffected if unconfigured).
- push_subscriptions table + R.pushSubs repo (upsert by endpoint, prune dead).
- /api/push/vapid|subscribe|unsubscribe; DM + group message routes also send a
  Web Push to recipients.
- Client registers /sw.js, subscribes when permission granted; hidden-tab popups
  are left to push to avoid double-notifying (pushActive flag); SW suppresses the
  OS popup when a tab is visible. Removes the old code that unregistered SWs.

Requires (prod, once): npm install + VAPID_PUBLIC_KEY/VAPID_PRIVATE_KEY/VAPID_SUBJECT env.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 21:58:49 +05:30
Sravan d50d4bde47 fix(icons): proper end-call (hang-up) glyph + cache-bust icons.js (v3)
- callEnd is now a rotated-handset hang-up icon (was a phone-off placeholder).
- All pages reference /icons.js?v=3 so browsers/proxies fetch the corrected
  file instead of a stale cached copy (fixes 'old end icon' + icons not
  appearing until a re-render when an old/404 icons.js was cached).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 18:47:24 +05:30
Sravan 1f4516d69b fix(chat): dedup own echoed message so sent messages don't show twice
Server echoes your own message back over WS (multi-tab/device sync) and
sendMessage already appended it optimistically; onChatMessage now skips the
append if the id is already in the thread.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 18:06:00 +05:30
Sravan fcd6a60baa fix(prod): add missing public/icons.js (was untracked -> 404 in prod)
icons.js was never committed (untracked, lost from disk), so every page
404'd /icons.js and stalled at Loading. Restored from commit e05a788 and
added 16 icons referenced by current code but absent in that snapshot
(bell, bold, italic, strikethrough, code, list, listOrdered, type, crown,
checkCheck, calendarX, calendarClock, fileText, record, callEnd, settings).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 17:41:44 +05:30
Sravan bda63b6f0a Merge origin/master (TURN/coturn + BizGaze-only login) into feature tree
Resolved conflicts in routes.js and share.html: kept the dev tree's superset
(ALLOW_LOCAL_LOGIN dev escape, avatar sync, richer login errors) which already
includes the incoming production BizGaze-only behavior; took the more descriptive
incoming comments. Restored 5 untracked modules (chat, calls, directory,
reminders, webhooks) that were missing from disk — required by routes/signaling.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 16:27:59 +05:30
Sravan 27355cec76 BizGaze Connect: chat, meetings, recordings, mobile, directory + UI fixes
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 16:15:29 +05:30
Sravan 0a739ee2fd Merge branch 'hotfix/bizgaze-only-login' of Sravan/BizGaze_Remote into master 2026-06-16 09:21:35 +00:00
Sravan caba3b3a21 Merge branch 'hotfix/bizgaze-only-login' of Sravan/BizGaze_Remote into master 2026-06-16 05:24:28 +00:00
36 changed files with 4726 additions and 205 deletions
+1
View File
@@ -32,6 +32,7 @@ out/
# Runtime media (created at startup by config.js) # Runtime media (created at startup by config.js)
server/recordings/ server/recordings/
server/transcripts/ server/transcripts/
server/uploads/
# OS files # OS files
.DS_Store .DS_Store
+13 -4
View File
@@ -133,10 +133,19 @@ viewer CONTROLS the remote screen** (reuses the WebRTC `inputChannel` + OS input
- [x] **`Authorization: Bearer <token>`** accepted in `currentUser()` across HTTP + WS, alongside the - [x] **`Authorization: Bearer <token>`** accepted in `currentUser()` across HTTP + WS, alongside the
cookie (session.js `tokenFromReq`); `/api/login` now also returns the `token` for native clients. cookie (session.js `tokenFromReq`); `/api/login` now also returns the `token` for native clients.
WS upgrades carry the token in the Authorization header (native) or `?access_token=` (browser fallback). WS upgrades carry the token in the Authorization header (native) or `?access_token=` (browser fallback).
- [ ] **Refresh tokens** (short access token + long refresh) so native apps stay signed in safely. - [x] **Refresh tokens** `/api/v1/auth/refresh` exchanges a long-lived (90d) refresh token for a
- [ ] **API keys** table + middleware (scoped per *tenant*, hashed at rest). fresh access token, with **rotation** (old token revoked on use) + replay rejection. Stored as a
- [ ] **Push-notification hooks** (APNs/FCM) for incoming sessions/calls on mobile. SHA-256 hash (`refresh_tokens` table). Login returns one; logout/deactivate/reset/delete revoke them.
- [ ] **OIDC/JWT** SSO; per-tenant **webhook subscriptions** with retries. Web/cookie path unchanged.
- [x] **API keys** — admin-managed (`POST/GET /api/v1/keys`, `POST /api/v1/keys/revoke`), per-tenant,
scoped (`report:read`, `audit:read`), `bzc_`-prefixed, SHA-256 hashed at rest, shown once. Accepted via
`X-API-Key` or `Authorization: Bearer bzc_…` on `/api/v1/report` + `/api/v1/audit` with scope enforcement.
- [x] **Per-tenant webhook subscriptions** — admin-managed (`/api/v1/webhooks` create/list/delete +
`/webhooks/events`), each with its own signing secret; events `session.started`/`session.ended`
delivered HMAC-SHA256-signed (`X-BizGaze-Signature`) with in-memory retries. Legacy global
`BIZGAZE_WEBHOOK_URL` still works. (webhooks.js + signaling emits.)
- [ ] **Push-notification hooks** (APNs/FCM) for incoming sessions/calls on mobile — needs Apple/Google creds to test.
- [ ] **OIDC/JWT** SSO — needs an identity provider to test against.
### Phase 3 — Licensing + scale (last, per priority) ### Phase 3 — Licensing + scale (last, per priority)
- [ ] Elevate **tenant → `organization`** (additive migration: add `organizations`, keep `team_id` - [ ] Elevate **tenant → `organization`** (additive migration: add `organizations`, keep `team_id`
+6 -2
View File
@@ -111,8 +111,12 @@ First registered user becomes admin; registration then closes (unless ALLOW_REGI
the dev team for: shared secret, token format (JWT preferred), SSO start URL, the dev team for: shared secret, token format (JWT preferred), SSO start URL,
signup URL, role mapping. (See BizGaze-Connect-SSO-SPEC.md.) signup URL, role mapping. (See BizGaze-Connect-SSO-SPEC.md.)
2. **New post-login home (NEXT TASK)** — see below. 2. **New post-login home (NEXT TASK)** — see below.
3. **Persistent chat** (Slack-style 1:1 + group messaging between registered users) — large new system. 3. **Persistent chat** — 1:1 messaging is BUILT (messages table, `/api/v1/messages/*`, live delivery
4. **Meetings** (multi-party video) — large new system (needs SFU or mesh). over `/ws` via `chat-hello`/`chat-message`, wired into home.html). Group chat is the remaining part.
4. **Meetings** (multi-party video) — **mesh (P2P) MVP BUILT**: in-memory rooms + signaling
(`meeting-create/join/signal/leave` in signaling.js), video-grid UI in home.html's Meeting tab
(start/join by 6-digit code, mic/cam toggles, leave). Good for small groups; **SFU upgrade** is
the next step for larger rooms.
5. **Downloadable Android app** — the only way to support phone screen-sharing. 5. **Downloadable Android app** — the only way to support phone screen-sharing.
## NEXT TASK: new post-login home (start with a mockup) ## NEXT TASK: new post-login home (start with a mockup)
+3 -1
View File
@@ -15,6 +15,8 @@ function verifyPassword(password, salt, expectedHash) {
// ---- Random tokens ---- // ---- Random tokens ----
const token = (bytes = 24) => crypto.randomBytes(bytes).toString('hex'); const token = (bytes = 24) => crypto.randomBytes(bytes).toString('hex');
const id = () => crypto.randomBytes(8).toString('hex'); const id = () => crypto.randomBytes(8).toString('hex');
// Deterministic hash for storing high-value tokens (e.g. refresh tokens) at rest.
const hashToken = (t) => crypto.createHash('sha256').update(String(t)).digest('hex');
const numericCode = (digits = 6) => const numericCode = (digits = 6) =>
String(crypto.randomInt(0, 10 ** digits)).padStart(digits, '0'); String(crypto.randomInt(0, 10 ** digits)).padStart(digits, '0');
@@ -70,6 +72,6 @@ function otpauthUrl(secret, email, issuer = 'RemoteAccess') {
} }
module.exports = { module.exports = {
hashPassword, verifyPassword, token, id, numericCode, hashPassword, verifyPassword, token, id, hashToken, numericCode,
newMfaSecret, totp, verifyTotp, otpauthUrl, newMfaSecret, totp, verifyTotp, otpauthUrl,
}; };
+15
View File
@@ -9,6 +9,20 @@
function loginUrl() { return process.env.BIZGAZE_LOGIN_URL || ''; } function loginUrl() { return process.env.BIZGAZE_LOGIN_URL || ''; }
const isEnabled = () => !!loginUrl(); const isEnabled = () => !!loginUrl();
// Origin of the BizGaze app (e.g. https://c02.bizgaze.app), derived from the login URL.
function loginOrigin() { try { return new URL(loginUrl()).origin; } catch { return ''; } }
// Build an absolute profile-photo URL from the session payload. BizGaze returns a
// relative path like "_files/documents/.../x.jpg" plus an asset/app base; we try the
// asset host first, then the app host, then the login origin. Absolute URLs pass through.
function photoUrlFrom(s) {
const raw = s.photoUrl || s.PhotoUrl || s.photo || s.profilePic || s.imageUrl || '';
if (!raw || typeof raw !== 'string') return null;
if (/^https?:\/\//i.test(raw)) return raw;
const base = String(s.assetUrl || s.appUrl || loginOrigin() || '').replace(/\/+$/, '');
return base ? base + '/' + raw.replace(/^\/+/, '') : null;
}
async function validateLogin(username, password) { async function validateLogin(username, password) {
const url = loginUrl(); const url = loginUrl();
if (!url) return { ok: false, configured: false }; if (!url) return { ok: false, configured: false };
@@ -30,6 +44,7 @@ async function validateLogin(username, password) {
return { return {
ok: true, configured: true, ok: true, configured: true,
name: s.name || null, name: s.name || null,
avatarUrl: photoUrlFrom(s),
isAdmin: !!s.isAdmin, isAdmin: !!s.isAdmin,
tenantRef: s.tenantId != null ? String(s.tenantId) : null, // BizGaze tenant (org) id tenantRef: s.tenantId != null ? String(s.tenantId) : null, // BizGaze tenant (org) id
bizgazeUserId: s.userId != null ? String(s.userId) : null, bizgazeUserId: s.userId != null ? String(s.userId) : null,
+151
View File
@@ -0,0 +1,151 @@
// Shared group calls: one live call per group. Members join without a code; the call
// ends (with a duration line in the chat) when the last participant's mesh room empties.
const fs = require('fs');
const path = require('path');
const R = require('./repos');
const A = require('./auth');
const CHAT = require('./chat');
const { TRANS_DIR } = require('./config');
const { meetingRooms, groupCalls, roomToGroupCall, dmCalls, roomToDmCall, roomHost, transcriptBuffers, transcriptSubs } = require('./presence');
const now = () => Date.now();
const pairKey = (a, b) => [a, b].sort().join('|');
// Resolve a room's meeting context (group / scheduled meeting / title) for labelling recordings.
function meetingContext(room) {
const ctx = { groupId: null, meetingId: null, title: 'Meeting' };
try {
const sched = R.scheduledMeetings.byCode(room);
if (sched) { ctx.meetingId = sched.id; ctx.groupId = sched.group_id || null; ctx.title = sched.title || 'Meeting'; }
} catch (_) {}
if (!ctx.groupId) { const gid = roomToGroupCall.get(room); if (gid) ctx.groupId = gid; }
if (ctx.groupId && ctx.title === 'Meeting') { try { const g = R.conversations.byId(ctx.groupId); if (g) ctx.title = g.name || 'Group'; } catch (_) {} }
if (!ctx.groupId && !ctx.meetingId && roomToDmCall.has(room)) ctx.title = 'Direct Call';
return ctx;
}
// Save the FULL shared conversation transcript as a PRIVATE copy for each subscriber. onlyUserId
// finalizes just that subscriber (on their leave / opt-out); omit to flush all remaining (room end).
// Must run BEFORE endCallByRoom (which clears the room→meeting maps meetingContext relies on).
function finalizeTranscript(room, onlyUserId) {
const subs = transcriptSubs.get(room); if (!subs || !subs.size) { if (!onlyUserId) { transcriptBuffers.delete(room); transcriptSubs.delete(room); } return; }
const buf = transcriptBuffers.get(room) || [];
const ids = onlyUserId ? (subs.has(onlyUserId) ? [onlyUserId] : []) : [...subs];
if (ids.length && buf.length) {
const ctx = meetingContext(room);
const lines = buf.map((s) => { const ts = new Date(s.t); const hh = String(ts.getHours()).padStart(2, '0'), mm = String(ts.getMinutes()).padStart(2, '0'); return '[' + hh + ':' + mm + '] ' + s.speaker + ': ' + s.text; });
const body = ctx.title + ' — transcript\n' + new Date(buf[0].t).toLocaleString() + '\n\n' + lines.join('\n') + '\n';
for (const uid of ids) {
let user = null; try { user = R.users.byId(uid); } catch (_) {}
if (!user) { subs.delete(uid); continue; }
const id = A.id(); const file = 'm_' + id + '.txt';
try { fs.writeFileSync(path.join(TRANS_DIR, file), body); } catch (e) { continue; }
// groupId null → private to its creator (see canSeeRec / /mrec auth).
R.recordings.create({ id, teamId: user.team_id, room, groupId: null, meetingId: ctx.meetingId, title: ctx.title, kind: 'transcript', file, mime: 'text/plain', size: null, durationMs: null, createdBy: uid, createdByName: user.name || user.email });
subs.delete(uid);
}
} else { ids.forEach((uid) => subs.delete(uid)); }
if (!subs.size) { transcriptBuffers.delete(room); transcriptSubs.delete(room); } // last subscriber done
}
function fmtDur(ms) { const s = Math.max(0, Math.round(ms / 1000)); const m = Math.floor(s / 60); return m ? (m + 'm ' + (s % 60) + 's') : (s + 's'); }
function broadcast(group, evt) { try { for (const mid of R.conversations.members(group)) CHAT.pushToUser(mid, evt); } catch (_) {} }
// Post a centered activity line into the group (system sender → no ping on clients).
function postSystem(group, teamId, text) {
const id = A.id();
R.messages.send({ id, teamId, senderId: '__system__', recipientId: '', body: text, conversationId: group });
const m = R.messages.byId(id);
broadcast(group, { type: 'chat-message', message: { id: m.id, from: '__system__', conversation_id: group, body: m.body, created_at: m.created_at, system: true } });
}
function startGroupCall(group, teamId, user) {
const existing = groupCalls.get(group);
if (existing) return { room: existing.room, active: true, already: true };
let room; do { room = A.numericCode(6); } while (meetingRooms.has(room));
meetingRooms.set(room, new Map());
const call = { room, startedAt: now(), startedBy: user.id, startedByName: user.name || user.email };
// Log the call as a meeting so it appears under Past meetings (history) with the group name.
try { const hid = A.id(); R.scheduledMeetings.create({ id: hid, teamId, groupId: group, roomCode: room, title: 'Group call', description: null, scheduledAt: now(), createdBy: user.id }); call.historyId = hid; call.teamId = teamId; } catch (_) {}
groupCalls.set(group, call); roomToGroupCall.set(room, group); roomHost.set(room, user.id); // creator = host
postSystem(group, teamId, '📞 ' + call.startedByName + ' started a group call');
let gName = 'Group'; try { const g = R.conversations.byId(group); if (g) gName = g.name || 'Group'; } catch (_) {}
broadcast(group, { type: 'group-call', group, active: true, room, by: user.id, startedByName: call.startedByName, groupName: gName });
return { room, active: true };
}
// Called from signaling when a mesh room empties — ends the group call if this room was one.
function endGroupCallByRoom(room) {
const group = roomToGroupCall.get(room);
if (!group) return;
const call = groupCalls.get(group);
roomToGroupCall.delete(room); groupCalls.delete(group); roomHost.delete(room);
if (call) {
let teamId = call.teamId; try { const g = R.conversations.byId(group); if (g) { teamId = g.team_id; postSystem(group, g.team_id, '📞 Group call ended · ' + fmtDur(now() - call.startedAt)); } } catch (_) {}
if (call.historyId && teamId) { try { R.scheduledMeetings.end(call.historyId, teamId); } catch (_) {} } // mark the history row past
broadcast(group, { type: 'group-call', group, active: false, room });
}
}
// 1:1 (DM) call. Notifies both parties (state + a chat line) so the callee sees "Join".
function startDmCall(me, otherId, teamId) {
const key = pairKey(me.id, otherId);
const existing = dmCalls.get(key);
if (existing) return { room: existing.room, active: true, already: true };
let room; do { room = A.numericCode(6); } while (meetingRooms.has(room));
meetingRooms.set(room, new Map());
const byName = me.name || me.email;
const call = { room, startedAt: now(), startedBy: me.id, startedByName: byName, users: [me.id, otherId], teamId };
// Log to history (both participants) so the call shows under Past meetings with its transcript.
try { const hid = A.id(); R.scheduledMeetings.create({ id: hid, teamId, groupId: null, roomCode: room, title: 'Direct Call', description: null, scheduledAt: now(), createdBy: me.id, participants: [me.id, otherId] }); call.historyId = hid; } catch (_) {}
dmCalls.set(key, call); roomToDmCall.set(room, key); roomHost.set(room, me.id); // caller = host
// A viewer-relative activity line: the caller sees "You started a call", the callee sees the name.
const mid = A.id();
R.messages.send({ id: mid, teamId, senderId: me.id, recipientId: otherId, body: '📞 Started a call', msgType: 'call-start' });
const m = R.messages.byId(mid); const dto = { id: m.id, from: me.id, to: otherId, conversation_id: null, body: m.body, created_at: m.created_at, system: true, evt: 'call-start', byName };
try { CHAT.pushToUser(otherId, { type: 'chat-message', message: dto }); } catch (_) {}
try { CHAT.pushToUser(me.id, { type: 'chat-message', message: dto }); } catch (_) {}
try { CHAT.pushToUser(otherId, { type: 'dm-call', active: true, room, with: me.id, by: me.id, byName }); } catch (_) {}
try { CHAT.pushToUser(me.id, { type: 'dm-call', active: true, room, with: otherId, by: me.id, byName }); } catch (_) {}
return { room, active: true };
}
function endDmCallByRoom(room, silent) {
const key = roomToDmCall.get(room); if (!key) return;
const call = dmCalls.get(key);
roomToDmCall.delete(room); dmCalls.delete(key); roomHost.delete(room);
if (!call) return;
if (call.historyId && call.teamId) { try { R.scheduledMeetings.end(call.historyId, call.teamId); } catch (_) {} } // mark history past
// "Call ended · duration" activity line in the DM (shown to both) — skipped on decline.
if (!silent) try {
const mid = A.id(); const body = '📞 Call ended · ' + fmtDur(now() - call.startedAt);
R.messages.send({ id: mid, teamId: call.teamId, senderId: call.startedBy, recipientId: call.users.find((u) => u !== call.startedBy) || '', body, msgType: 'call-end' });
const m = R.messages.byId(mid); const dto = { id: m.id, from: call.startedBy, to: m.recipient_id, conversation_id: null, body, created_at: m.created_at, system: true, evt: 'call-end' };
call.users.forEach((uid) => { try { CHAT.pushToUser(uid, { type: 'chat-message', message: dto }); } catch (_) {} });
} catch (_) {}
call.users.forEach((uid, i) => { try { CHAT.pushToUser(uid, { type: 'dm-call', active: false, with: call.users[1 - i], room }); } catch (_) {} });
}
// Called from signaling when any mesh room empties.
function endCallByRoom(room) { endGroupCallByRoom(room); endDmCallByRoom(room); }
// Callee declines a 1:1 call: post "Call declined" into the DM, drop the waiting caller, end it.
function declineDmCall(room, byUser) {
const key = roomToDmCall.get(room); if (!key) return { ok: false };
const call = dmCalls.get(key); if (!call) return { ok: false };
const callerId = call.users.find((id) => id !== byUser.id) || call.startedBy;
try {
const mid = A.id();
R.messages.send({ id: mid, teamId: byUser.team_id, senderId: byUser.id, recipientId: callerId, body: '📞 Call declined', msgType: 'call-end' });
const mm = R.messages.byId(mid); const dto = { id: mm.id, from: byUser.id, to: callerId, conversation_id: null, body: mm.body, created_at: mm.created_at, system: true, evt: 'call-end' };
CHAT.pushToUser(callerId, { type: 'chat-message', message: dto });
CHAT.pushToUser(byUser.id, { type: 'chat-message', message: dto });
} catch (_) {}
// Drop the caller who's still waiting in the (otherwise empty) mesh room.
const peers = meetingRooms.get(room);
if (peers) { for (const [, p] of peers) { if (p.ws.readyState === 1) { try { p.ws.send(JSON.stringify({ type: 'meeting-ended' })); } catch (_) {} p._meetingRoom = null; } } meetingRooms.delete(room); }
endDmCallByRoom(room, true); // silent: we already posted "Call declined"
return { ok: true };
}
module.exports = { startGroupCall, startDmCall, endGroupCallByRoom, endDmCallByRoom, endCallByRoom, declineDmCall, finalizeTranscript, meetingContext, fmtDur, pairKey };
+31
View File
@@ -0,0 +1,31 @@
// Chat presence + real-time delivery. A logged-in user opens a WebSocket and sends
// `chat-hello`; signaling.js registers the socket here. Messages are persisted over HTTP
// (routes.js) and pushed live to the recipient's sockets via pushToUser().
const { chatClients } = require('./presence');
function register(userId, ws) {
if (!chatClients.has(userId)) chatClients.set(userId, new Set());
chatClients.get(userId).add(ws);
ws._chatUserId = userId;
}
function unregister(ws) {
const id = ws && ws._chatUserId;
if (!id) return;
const set = chatClients.get(id);
if (set) { set.delete(ws); if (!set.size) chatClients.delete(id); }
}
function isOnline(userId) {
const s = chatClients.get(userId);
return !!(s && s.size);
}
function pushToUser(userId, obj) {
const s = chatClients.get(userId);
if (!s) return;
const data = JSON.stringify(obj);
for (const ws of s) { if (ws.readyState === 1) { try { ws.send(data); } catch (_) {} } }
}
module.exports = { register, unregister, isOnline, pushToUser };
+5 -1
View File
@@ -5,8 +5,10 @@ const path = require('path');
const PUBLIC_DIR = path.join(__dirname, 'public'); const PUBLIC_DIR = path.join(__dirname, 'public');
const REC_DIR = path.join(__dirname, 'recordings'); const REC_DIR = path.join(__dirname, 'recordings');
const TRANS_DIR = path.join(__dirname, 'transcripts'); const TRANS_DIR = path.join(__dirname, 'transcripts');
const UPLOADS_DIR = path.join(__dirname, 'uploads');
try { fs.mkdirSync(REC_DIR, { recursive: true }); } catch (e) {} try { fs.mkdirSync(REC_DIR, { recursive: true }); } catch (e) {}
try { fs.mkdirSync(TRANS_DIR, { recursive: true }); } catch (e) {} try { fs.mkdirSync(TRANS_DIR, { recursive: true }); } catch (e) {}
try { fs.mkdirSync(UPLOADS_DIR, { recursive: true }); } catch (e) {}
module.exports = { module.exports = {
PORT: process.env.PORT || 8090, PORT: process.env.PORT || 8090,
@@ -14,5 +16,7 @@ module.exports = {
PUBLIC_DIR, PUBLIC_DIR,
REC_DIR, REC_DIR,
TRANS_DIR, TRANS_DIR,
SESSION_TTL: 1000 * 60 * 60 * 24, // 24h auto-logout UPLOADS_DIR,
SESSION_TTL: 1000 * 60 * 60 * 24, // 24h access-token / cookie lifetime
REFRESH_TTL: 1000 * 60 * 60 * 24 * 90, // 90d refresh-token lifetime (native clients)
}; };
+220
View File
@@ -81,4 +81,224 @@ CREATE TABLE IF NOT EXISTS sessions_log (
try { db.exec('ALTER TABLE sessions_log ADD COLUMN recording TEXT'); } catch (e) { /* exists */ } try { db.exec('ALTER TABLE sessions_log ADD COLUMN recording TEXT'); } catch (e) { /* exists */ }
try { db.exec('ALTER TABLE sessions_log ADD COLUMN transcript TEXT'); } catch (e) { /* exists */ } try { db.exec('ALTER TABLE sessions_log ADD COLUMN transcript TEXT'); } catch (e) { /* exists */ }
// Refresh tokens for native (desktop/mobile) clients: long-lived, rotated on use,
// stored as a SHA-256 hash so a DB leak doesn't expose usable tokens.
db.exec(`
CREATE TABLE IF NOT EXISTS refresh_tokens (
token_hash TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
revoked INTEGER NOT NULL DEFAULT 0
);
`);
// API keys for third-party / system integrations (machine-to-machine, no human login).
// Scoped per tenant; the key is stored as a SHA-256 hash (plaintext shown once at creation).
db.exec(`
CREATE TABLE IF NOT EXISTS api_keys (
id TEXT PRIMARY KEY,
team_id TEXT NOT NULL,
name TEXT,
key_hash TEXT NOT NULL UNIQUE,
scopes TEXT NOT NULL DEFAULT '',
created_by TEXT,
created_at INTEGER NOT NULL,
last_used_at INTEGER,
revoked INTEGER NOT NULL DEFAULT 0
);
`);
// Outbound webhook subscriptions: per-tenant endpoints that receive signed event
// callbacks (session.started / session.ended). Each has its own signing secret.
db.exec(`
CREATE TABLE IF NOT EXISTS webhooks (
id TEXT PRIMARY KEY,
team_id TEXT NOT NULL,
url TEXT NOT NULL,
secret TEXT NOT NULL,
events TEXT NOT NULL DEFAULT '',
active INTEGER NOT NULL DEFAULT 1,
created_by TEXT,
created_at INTEGER NOT NULL,
last_status INTEGER,
last_error TEXT,
last_at INTEGER
);
`);
// Persistent 1:1 chat between users in the same team.
db.exec(`
CREATE TABLE IF NOT EXISTS messages (
id TEXT PRIMARY KEY,
team_id TEXT NOT NULL,
sender_id TEXT NOT NULL,
recipient_id TEXT NOT NULL,
body TEXT NOT NULL,
created_at INTEGER NOT NULL,
read_at INTEGER
);
CREATE INDEX IF NOT EXISTS idx_messages_pair ON messages(team_id, sender_id, recipient_id, created_at);
CREATE INDEX IF NOT EXISTS idx_messages_unread ON messages(team_id, recipient_id, sender_id, read_at);
`);
// Migration: a message can quote/reply to another message.
try { db.exec('ALTER TABLE messages ADD COLUMN reply_to TEXT'); } catch (e) { /* exists */ }
// Emoji reactions on messages (one row per user+message+emoji; toggling adds/removes).
db.exec(`
CREATE TABLE IF NOT EXISTS message_reactions (
message_id TEXT NOT NULL,
user_id TEXT NOT NULL,
emoji TEXT NOT NULL,
created_at INTEGER NOT NULL,
PRIMARY KEY (message_id, user_id, emoji)
);
`);
// File attachments for chat messages (file bytes stored on disk at uploads/<id>).
db.exec(`
CREATE TABLE IF NOT EXISTS attachments (
id TEXT PRIMARY KEY,
team_id TEXT NOT NULL,
uploader_id TEXT NOT NULL,
name TEXT NOT NULL,
mime TEXT,
size INTEGER,
created_at INTEGER NOT NULL
);
`);
try { db.exec('ALTER TABLE messages ADD COLUMN attachment_id TEXT'); } catch (e) { /* exists */ }
// Group conversations + membership. (1:1 DMs keep using sender_id/recipient_id directly;
// group messages set conversation_id instead, with recipient_id left blank.)
db.exec(`
CREATE TABLE IF NOT EXISTS conversations (
id TEXT PRIMARY KEY,
team_id TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'group',
name TEXT,
created_by TEXT,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS conversation_members (
conversation_id TEXT NOT NULL,
user_id TEXT NOT NULL,
last_read_at INTEGER NOT NULL DEFAULT 0,
joined_at INTEGER NOT NULL,
PRIMARY KEY (conversation_id, user_id)
);
`);
try { db.exec('ALTER TABLE messages ADD COLUMN conversation_id TEXT'); } catch (e) { /* exists */ }
try { db.exec('CREATE INDEX IF NOT EXISTS idx_messages_conv ON messages(conversation_id, created_at)'); } catch (e) {}
// Group admins: 1 = this member is an admin (multiple admins allowed). Creator seeded as admin.
try { db.exec('ALTER TABLE conversation_members ADD COLUMN admin INTEGER NOT NULL DEFAULT 0'); } catch (e) { /* exists */ }
try { db.exec('UPDATE conversation_members SET admin=1 WHERE user_id IN (SELECT created_by FROM conversations WHERE conversations.id=conversation_members.conversation_id) AND admin=0'); } catch (e) {}
// Avatars: a user's profile picture (BizGaze photo URL) and a group's uploaded image
// (an attachment id, served via /files/<id> with group-membership auth).
try { db.exec('ALTER TABLE users ADD COLUMN avatar_url TEXT'); } catch (e) { /* exists */ }
try { db.exec('ALTER TABLE conversations ADD COLUMN avatar_id TEXT'); } catch (e) { /* exists */ }
// @mentions on a (group) message: JSON array of mentioned user ids, and/or the literal
// "everyone" for @everyone/@all. Used to highlight and notify mentioned members.
try { db.exec('ALTER TABLE messages ADD COLUMN mentions TEXT'); } catch (e) { /* exists */ }
// Delivered receipt for DMs (double tick): set when the recipient's client acknowledges.
try { db.exec('ALTER TABLE messages ADD COLUMN delivered_at INTEGER'); } catch (e) { /* exists */ }
// Group setting: when 1, only the creator can add/remove members.
try { db.exec('ALTER TABLE conversations ADD COLUMN admin_only INTEGER NOT NULL DEFAULT 0'); } catch (e) { /* exists */ }
// Polls live within a group conversation, attached to a message (the poll's question is
// the message body). options is a JSON array of option strings; votes are one row each.
try { db.exec('ALTER TABLE messages ADD COLUMN poll_id TEXT'); } catch (e) { /* exists */ }
// Activity/event lines (e.g. 'call-start','call-end') render as centered system messages.
try { db.exec('ALTER TABLE messages ADD COLUMN msg_type TEXT'); } catch (e) { /* exists */ }
db.exec(`
CREATE TABLE IF NOT EXISTS polls (
id TEXT PRIMARY KEY,
team_id TEXT NOT NULL,
conversation_id TEXT NOT NULL,
message_id TEXT,
question TEXT NOT NULL,
options TEXT NOT NULL,
multi INTEGER NOT NULL DEFAULT 0,
closed INTEGER NOT NULL DEFAULT 0,
created_by TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS poll_votes (
poll_id TEXT NOT NULL,
user_id TEXT NOT NULL,
option_idx INTEGER NOT NULL,
created_at INTEGER NOT NULL,
PRIMARY KEY (poll_id, user_id, option_idx)
);
`);
// Scheduled meetings/calls. Each carries a stable room_code so a scheduled call can be
// joined later (the live mesh room is created on first join). group_id is optional — a
// scheduled meeting may target a specific group conversation or be standalone.
db.exec(`
CREATE TABLE IF NOT EXISTS scheduled_meetings (
id TEXT PRIMARY KEY,
team_id TEXT NOT NULL,
group_id TEXT,
room_code TEXT NOT NULL,
title TEXT NOT NULL,
description TEXT,
scheduled_at INTEGER NOT NULL,
created_by TEXT NOT NULL,
created_at INTEGER NOT NULL,
ended_at INTEGER
);
CREATE INDEX IF NOT EXISTS idx_sched_team ON scheduled_meetings(team_id, scheduled_at);
CREATE INDEX IF NOT EXISTS idx_sched_code ON scheduled_meetings(room_code);
`);
// Invited participants (JSON array of user ids) + a one-shot "10-min reminder sent" flag.
try { db.exec('ALTER TABLE scheduled_meetings ADD COLUMN participants TEXT'); } catch (e) { /* exists */ }
try { db.exec('ALTER TABLE scheduled_meetings ADD COLUMN reminded INTEGER NOT NULL DEFAULT 0'); } catch (e) { /* exists */ }
// Cancelled meetings are kept (shown as "Cancelled"), not deleted.
try { db.exec('ALTER TABLE scheduled_meetings ADD COLUMN cancelled INTEGER NOT NULL DEFAULT 0'); } catch (e) { /* exists */ }
try { db.exec('ALTER TABLE scheduled_meetings ADD COLUMN duration_mins INTEGER'); } catch (e) { /* exists */ }
// Weekly recurrence: JSON array of weekdays (0=Sun..6=Sat), or null for a one-off.
try { db.exec('ALTER TABLE scheduled_meetings ADD COLUMN recurrence TEXT'); } catch (e) { /* exists */ }
// Meeting recordings & transcripts. Video bytes live in recordings/m_<id>.webm, transcript text
// in transcripts/m_<id>.txt. Tied to a room (and group/scheduled meeting when applicable) so they
// surface under "Past meetings". kind = 'video' | 'transcript'.
db.exec(`
CREATE TABLE IF NOT EXISTS recordings (
id TEXT PRIMARY KEY,
team_id TEXT NOT NULL,
room TEXT,
group_id TEXT,
meeting_id TEXT,
title TEXT,
kind TEXT NOT NULL,
file TEXT,
mime TEXT,
size INTEGER,
duration_ms INTEGER,
created_by TEXT,
created_by_name TEXT,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_rec_team ON recordings(team_id, created_at);
CREATE INDEX IF NOT EXISTS idx_rec_room ON recordings(room);
`);
// Web Push subscriptions (one per browser/device per user) for background/closed-tab
// notifications. endpoint is unique; p256dh+auth are the encryption keys from the browser.
db.exec(`
CREATE TABLE IF NOT EXISTS push_subscriptions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
endpoint TEXT NOT NULL UNIQUE,
p256dh TEXT NOT NULL,
auth TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_push_user ON push_subscriptions(user_id);
`);
module.exports = db; module.exports = db;
+57
View File
@@ -0,0 +1,57 @@
// BizGaze user-directory search (cross-tenant). The auth token is kept SERVER-SIDE only — the
// browser calls /api/directory/search and never sees the token. Configure via env in production:
// BIZGAZE_DIRECTORY_URL (base, the search term is appended url-encoded)
// BIZGAZE_DIRECTORY_TOKEN (the "stat ..." Authorization header value)
const DEFAULT_URL = 'https://app.bizgaze.com/apis/v4/bizgaze/integrations/users_chatsearch/get_usersforchatsearch/searchterm/';
const DEFAULT_TOKEN = 'stat 3cd2e190b4db448496ae316b155d2441';
function baseUrl() { return process.env.BIZGAZE_DIRECTORY_URL || DEFAULT_URL; }
function token() { return process.env.BIZGAZE_DIRECTORY_TOKEN || DEFAULT_TOKEN; }
function enabled() { return !!(baseUrl() && token()); }
// Pull a field from an object by any of several case-insensitive key names.
function field(o, names) {
const keys = Object.keys(o || {});
for (const want of names) { for (const k of keys) { if (k.toLowerCase() === want) { const v = o[k]; if (v != null && v !== '') return String(v); } } }
return '';
}
// BizGaze responses vary (raw array, or wrapped in Result/data, sometimes a JSON string). Normalize.
function toArray(data) {
let d = data;
if (typeof d === 'string') { try { d = JSON.parse(d); } catch (_) { return []; } }
if (Array.isArray(d)) return d;
if (d && typeof d === 'object') {
for (const key of ['Result', 'result', 'data', 'Data', 'records', 'Records', 'items', 'Items']) {
if (d[key] != null) { let v = d[key]; if (typeof v === 'string') { try { v = JSON.parse(v); } catch (_) {} } if (Array.isArray(v)) return v; }
}
}
return [];
}
function pick(o) {
return {
id: field(o, ['userid', 'id', 'contactid', 'partyid', 'recordid']),
name: field(o, ['fullname', 'name', 'displayname', 'username', 'contactname', 'firstname']),
email: field(o, ['email', 'emailaddress', 'emailid', 'mail']),
phone: field(o, ['mobile', 'mobilenumber', 'phone', 'phonenumber', 'contactno', 'contactnumber']),
avatar: field(o, ['photourl', 'photo', 'avatar', 'imageurl', 'profilepic', 'profileimage']),
org: field(o, ['organization', 'organisation', 'company', 'tenantname', 'orgname']),
};
}
async function search(term) {
if (!enabled() || !term || term.trim().length < 2) return [];
const url = baseUrl() + encodeURIComponent(term.trim());
const ctrl = new AbortController();
const to = setTimeout(() => ctrl.abort(), 8000);
try {
const r = await fetch(url, { headers: { Authorization: token(), Accept: 'application/json' }, signal: ctrl.signal });
if (!r.ok) return [];
const data = await r.json().catch(() => null);
return toArray(data).map(pick).filter((x) => x.name || x.email || x.phone).slice(0, 25);
} catch (_) { return []; }
finally { clearTimeout(to); }
}
module.exports = { search, enabled };
+3 -1
View File
@@ -2,7 +2,9 @@
const now = () => Date.now(); const now = () => Date.now();
const json = (res, code, body) => { const json = (res, code, body) => {
res.writeHead(code, { 'Content-Type': 'application/json' }); // no-store: API/JSON responses (and 404s) must never be cached — a cached 404 for an asset
// like /manifest.json would otherwise persist on a device even after the file is deployed.
res.writeHead(code, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
res.end(JSON.stringify(body)); res.end(JSON.stringify(body));
}; };
+192 -4
View File
@@ -1,17 +1,205 @@
{ {
"name": "remote-access-server", "name": "bizgaze-support-server",
"version": "0.2.0", "version": "2.0.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "remote-access-server", "name": "bizgaze-support-server",
"version": "0.2.0", "version": "2.0.0",
"dependencies": { "dependencies": {
"web-push": "^3.6.7",
"ws": "^8.18.0" "ws": "^8.18.0"
}, },
"engines": { "engines": {
"node": ">=22.5.0" "node": ">=22.5.0"
},
"optionalDependencies": {
"nodemailer": "^6.9.14"
}
},
"node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"node_modules/asn1.js": {
"version": "5.4.1",
"resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
"integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
"license": "MIT",
"dependencies": {
"bn.js": "^4.0.0",
"inherits": "^2.0.1",
"minimalistic-assert": "^1.0.0",
"safer-buffer": "^2.1.0"
}
},
"node_modules/bn.js": {
"version": "4.12.3",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz",
"integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==",
"license": "MIT"
},
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/http_ece": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz",
"integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==",
"license": "MIT",
"engines": {
"node": ">=16"
}
},
"node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
"license": "MIT",
"dependencies": {
"jwa": "^2.0.1",
"safe-buffer": "^5.0.1"
}
},
"node_modules/minimalistic-assert": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
"license": "ISC"
},
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/nodemailer": {
"version": "6.10.1",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz",
"integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==",
"license": "MIT-0",
"optional": true,
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/web-push": {
"version": "3.6.7",
"resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz",
"integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==",
"license": "MPL-2.0",
"dependencies": {
"asn1.js": "^5.3.0",
"http_ece": "1.2.0",
"https-proxy-agent": "^7.0.0",
"jws": "^4.0.0",
"minimist": "^1.2.5"
},
"bin": {
"web-push": "src/cli.js"
},
"engines": {
"node": ">= 16"
} }
}, },
"node_modules/ws": { "node_modules/ws": {
+13 -4
View File
@@ -3,8 +3,17 @@
"version": "2.0.0", "version": "2.0.0",
"description": "BizGaze Support — remote screen sharing: public landing, agent console, sessions, SSO + webhook for BizGaze integration", "description": "BizGaze Support — remote screen sharing: public landing, agent console, sessions, SSO + webhook for BizGaze integration",
"main": "server.js", "main": "server.js",
"scripts": { "start": "node server.js" }, "scripts": {
"engines": { "node": ">=22.5.0" }, "start": "node server.js"
"dependencies": { "ws": "^8.18.0" }, },
"optionalDependencies": { "nodemailer": "^6.9.14" } "engines": {
"node": ">=22.5.0"
},
"dependencies": {
"web-push": "^3.6.7",
"ws": "^8.18.0"
},
"optionalDependencies": {
"nodemailer": "^6.9.14"
}
} }
+9
View File
@@ -4,4 +4,13 @@ module.exports = {
onlineAgents: new Map(), // machineId -> { ws, machine } onlineAgents: new Map(), // machineId -> { ws, machine }
liveSessions: new Map(), // sessionId -> { agentWs, viewerWs, machine, user } liveSessions: new Map(), // sessionId -> { agentWs, viewerWs, machine, user }
pendingShares: new Map(), // code -> { sharerWs, sessionId } (no-install ad-hoc shares) pendingShares: new Map(), // code -> { sharerWs, sessionId } (no-install ad-hoc shares)
chatClients: new Map(), // userId -> Set<ws> (a user may have several tabs/devices open)
meetingRooms: new Map(), // roomCode -> Map(peerId -> { ws, name }) (mesh meetings MVP)
groupCalls: new Map(), // groupId -> { room, startedAt, startedBy, startedByName } (shared group calls)
roomToGroupCall: new Map(),// roomCode -> groupId (end a group call when its room empties)
dmCalls: new Map(), // pairKey "a|b" -> { room, startedAt, startedBy, startedByName, users:[a,b] }
roomToDmCall: new Map(), // roomCode -> pairKey (end a 1:1 call when its room empties)
roomHost: new Map(), // roomCode -> userId (the meeting creator = host; transferable in-call)
transcriptBuffers: new Map(),// roomCode -> [{ t, speaker, text }] (shared full-conversation buffer)
transcriptSubs: new Map(), // roomCode -> Set(userId) (who wants a private copy of the transcript)
}; };
Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

+17 -7
View File
@@ -31,6 +31,10 @@
.topbar2.show{display:flex;} #barStatus{font-weight:600;font-size:.9rem;color:var(--blue);} .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;} #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;} #video{width:100vw;height:calc(100vh - 46px);background:#0b1220;object-fit:contain;display:none;cursor:crosshair;outline:none;}
/* Control bar sits vertically on the RIGHT; reserve a thin right strip so the screen uses the full
height/width otherwise (maximised view) and the icons never overlay the shared content. */
body.has-bar #video{width:calc(100vw - 76px);}
body.has-bar{background:#0b1220;}
.profile{position:relative} .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{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 .pbtn:hover{background:rgba(255,255,255,.24)}
@@ -53,10 +57,11 @@
html.embed #homeLink{display:none!important;} html.embed #homeLink{display:none!important;}
html.embed #video{height:100vh!important;} html.embed #video{height:100vh!important;}
</style> </style>
<script src="/icons.js?v=3"></script>
</head> </head>
<body> <body>
<script>if(new URLSearchParams(location.search).get('embed')==='1')document.documentElement.classList.add('embed');</script> <script>if(new URLSearchParams(location.search).get('embed')==='1')document.documentElement.classList.add('embed');</script>
<a href="/home" id="homeLink">&#8592; Home</a> <a href="/home" id="homeLink"><span data-ic="arrowLeft" data-sz="16"></span> Home</a>
<div class="topbar" id="topbar"> <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="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 class="agentchip" id="agentChip"></div>
@@ -163,7 +168,8 @@ function connectWS(){
case 'code-pending': sessionId=m.sessionId; renderWaiting(); setupPeer(); break; case 'code-pending': sessionId=m.sessionId; renderWaiting(); setupPeer(); break;
case 'session-ready': if(statusEl)statusEl.textContent='Allowed — connecting…'; break; case 'session-ready': if(statusEl)statusEl.textContent='Allowed — connecting…'; break;
case 'offer': await pc.setRemoteDescription(new RTCSessionDescription(m.sdp)); 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){} // Acquire the agent mic once; on renegotiation (e.g. customer unmutes) just answer.
if(!window.__mic){ 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); const ans=await pc.createAnswer(); await pc.setLocalDescription(ans);
ws.send(JSON.stringify({type:'answer',sessionId,sdp:pc.localDescription})); break; 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 'ice-candidate': if(m.candidate&&pc) await pc.addIceCandidate(new RTCIceCandidate(m.candidate)); break;
@@ -191,6 +197,8 @@ function renderEnded(msg){
bzcSession(false); bzcSession(false);
try{ stopRecording(); }catch(_){} try{ stopRecording(); }catch(_){}
removeSessionUI(); removeSessionUI();
document.body.classList.remove('has-bar');
if(window.__mic){ try{ window.__mic.getTracks().forEach(t=>t.stop()); }catch(_){} window.__mic=null; } // release mic so the tab's recording dot clears
if(pc){ try{pc.close();}catch(e){} pc=null; } if(pc){ try{pc.close();}catch(e){} pc=null; }
video.style.display='none'; bar.classList.remove('show'); video.style.display='none'; bar.classList.remove('show');
topbar.style.display='flex'; wrap.style.display='grid'; topbar.style.display='flex'; wrap.style.display='grid';
@@ -207,7 +215,7 @@ 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_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_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_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>'; const SVG_END='<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><g transform="rotate(135 12 12)"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/></g></svg>';
const SVG_REC='<svg viewBox="0 0 24 24" width="16" height="16"><circle cx="12" cy="12" r="7" fill="#ef4444"/></svg>'; const SVG_REC='<svg viewBox="0 0 24 24" width="16" height="16"><circle cx="12" cy="12" r="7" fill="#ef4444"/></svg>';
const SVG_RECSTOP='<svg viewBox="0 0 24 24" width="15" height="15" fill="#fff"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>'; const SVG_RECSTOP='<svg viewBox="0 0 24 24" width="15" height="15" fill="#fff"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>';
let mediaRecorder=null, recChunks=[], recCtx=null; let mediaRecorder=null, recChunks=[], recCtx=null;
@@ -296,20 +304,21 @@ function stopRecording(){
showRecTimer(false); showRecTimer(false);
try{ws.send(JSON.stringify({type:'recording',sessionId,on:false}));}catch(_){} try{ws.send(JSON.stringify({type:'recording',sessionId,on:false}));}catch(_){}
} }
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 _btn(id,svg,label,bg){const b=document.createElement('button');b.id=id;b.title=label;b.setAttribute('aria-label',label);b.innerHTML='<span style="display:inline-flex">'+svg+'</span>';b.style.cssText='display:inline-flex;align-items:center;justify-content:center;width:48px;height:48px;border:none;border-radius:50%;cursor:pointer;color:#fff;background:'+bg+';box-shadow:0 2px 6px rgba(0,0,0,.25);transition:background .15s,transform .08s';b.onmouseenter=()=>b.style.transform='translateY(-2px)';b.onmouseleave=()=>b.style.transform='none';return b;}
function buildBar(){ function buildBar(){
if(document.getElementById('sessionBar'))return; if(document.getElementById('sessionBar'))return;
{ const hl=document.getElementById('homeLink'); if(hl) hl.style.display='none'; } { const hl=document.getElementById('homeLink'); if(hl) hl.style.display='none'; }
bzcSession(true); bzcSession(true);
const bar=document.createElement('div'); bar.id='sessionBar'; 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)'; bar.style.cssText='position:fixed;right:14px;top:50%;transform:translateY(-50%);z-index:2147483000;display:flex;flex-direction:column;gap:10px;align-items:center;background:rgba(15,23,42,.94);padding:12px 8px;border-radius:16px;box-shadow:0 10px 28px rgba(0,0,0,.35)';
const mic=_btn('micBtn',SVG_MIC,'Mic','#2563eb'); const mic=_btn('micBtn',SVG_MIC,'Mic','#2563eb');
const chat=_btn('chatBtn',SVG_CHAT,'Chat','#475569'); const chat=_btn('chatBtn',SVG_CHAT,'Chat','#475569');
const rec=_btn('recBtn',SVG_REC,'','#0ea5e9'); rec.title='Record'; rec.querySelectorAll('span').forEach((s,i)=>{ if(i>0) s.remove(); }); const rec=_btn('recBtn',SVG_REC,'','#0ea5e9'); rec.title='Record'; rec.querySelectorAll('span').forEach((s,i)=>{ if(i>0) s.remove(); });
const end=_btn('endBtn2',SVG_END,'End','#dc2626'); const end=_btn('endBtn2',SVG_END,'End','#dc2626');
bar.appendChild(mic);bar.appendChild(chat);bar.appendChild(rec);bar.appendChild(end); bar.appendChild(mic);bar.appendChild(chat);bar.appendChild(rec);bar.appendChild(end);
document.body.appendChild(bar); 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';}; document.body.classList.add('has-bar'); // reserve space so the bar never overlays the shared screen
mic.onclick=()=>{const m=window.__mic;if(!m)return;const t=m.getAudioTracks()[0];if(!t)return;t.enabled=!t.enabled;mic.title=t.enabled?'Mute':'Unmute';mic.innerHTML='<span style="display:inline-flex">'+(t.enabled?SVG_MIC:SVG_MICOFF)+'</span>';mic.style.background=t.enabled?'#2563eb':'#6b7280';};
chat.onclick=toggleChat; chat.onclick=toggleChat;
rec.onclick=()=>{ if(mediaRecorder&&mediaRecorder.state==='recording') stopRecording(); else startRecording(); }; rec.onclick=()=>{ if(mediaRecorder&&mediaRecorder.state==='recording') stopRecording(); else startRecording(); };
end.onclick=()=>{ try{ws.send(JSON.stringify({type:'end-session',sessionId,reason:'agent-ended'}));}catch(_){} }; end.onclick=()=>{ try{ws.send(JSON.stringify({type:'end-session',sessionId,reason:'agent-ended'}));}catch(_){} };
@@ -321,7 +330,7 @@ function buildBar(){
function buildChatPanel(){ function buildChatPanel(){
if(document.getElementById('chatPanel'))return; if(document.getElementById('chatPanel'))return;
const p=document.createElement('div'); p.id='chatPanel'; 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.style.cssText='position:fixed;right:88px;bottom:18px;width:300px;max-width:80vw;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">&#10005;</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>'; 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">&#10005;</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.body.appendChild(p);
document.getElementById('chatSend').onclick=sendChat; document.getElementById('chatSend').onclick=sendChat;
@@ -368,5 +377,6 @@ video.addEventListener('keyup',e=>{e.preventDefault();send({kind:'keyup',key:e.k
document.getElementById('endBtn').onclick=()=>{ws.send(JSON.stringify({type:'end-session',sessionId,reason:'agent-ended'}));}; 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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));} function esc(s){return String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));}
</script> </script>
<script>(function(){var s=document.createElement('style');s.textContent='.ic{display:inline-block;vertical-align:middle}';document.head.appendChild(s);document.querySelectorAll('[data-ic]').forEach(function(e){e.insertAdjacentHTML('afterbegin',window.ic(e.getAttribute('data-ic'),+e.getAttribute('data-sz')||16));});})();</script>
</body> </body>
</html> </html>
+100 -5
View File
@@ -28,6 +28,11 @@
th,td{text-align:left;padding:.55rem .5rem;border-bottom:1px solid var(--line);} 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{font-size:.74rem;font-weight:600;padding:.15rem .55rem;border-radius:99px;}
.pill.on{background:#ecfdf3;color:#15803d;} .pill.on{background:#ecfdf3;color:#15803d;}
.pill.off{background:#fee2e2;color:var(--red);}
.reveal{margin-top:1rem;background:#f1f7ec;border:1px solid #cfe8bf;border-radius:10px;padding:.8rem 1rem;}
.reveal code{flex:1;word-break:break-all;background:#fff;border:1px solid var(--line);border-radius:8px;padding:.5rem .6rem;font-size:.85rem;}
.chk{display:flex;align-items:center;gap:.4rem;font-size:.85rem;}
.chk input{width:16px;height:16px;margin:0;accent-color:var(--blue);}
.hidden{display:none;} .hidden{display:none;}
.tabs{display:flex;gap:.5rem;margin-bottom:1.2rem;} .tabs{display:flex;gap:.5rem;margin-bottom:1.2rem;}
.tabs button{background:#eef1f6;color:var(--muted);font-weight:600;} .tabs button{background:#eef1f6;color:var(--muted);font-weight:600;}
@@ -64,7 +69,9 @@
.profile .pmenu a{display:block;padding:.6rem .9rem;color:#1f2430;text-decoration:none;font-size:.9rem;cursor:pointer} .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:hover{background:#f1f5f9}
.profile .pmenu a.danger{color:#b91c1c;border-top:1px solid #eef1f6} .profile .pmenu a.danger{color:#b91c1c;border-top:1px solid #eef1f6}
.ic{display:inline-block;vertical-align:middle}
</style> </style>
<script src="/icons.js?v=3"></script>
</head> </head>
<body> <body>
<header> <header>
@@ -188,21 +195,109 @@ async function dashboard(me) {
<div class="f"><span class="lbl">From</span><input id="fFrom" type="date"></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> <div class="f"><span class="lbl">To</span><input id="fTo" type="date"></div>
<button id="fApply">Apply</button> <button id="fApply">Apply</button>
<button id="fExcel" class="mini" style="padding:.6rem .9rem"> Excel</button> <button id="fExcel" class="mini" style="padding:.6rem .9rem">${ic('download',15)} Excel</button>
<button id="fPdf" class="mini" style="padding:.6rem .9rem"> PDF</button> <button id="fPdf" class="mini" style="padding:.6rem .9rem">${ic('download',15)} PDF</button>
</div> </div>
${IS_ADMIN ? '<input id="repSearch" class="srch" placeholder="Search by agent or ticket">' : ''} ${IS_ADMIN ? '<input id="repSearch" class="srch" placeholder="Search by agent or ticket">' : ''}
<table id="report"><thead><tr><th>Date</th><th>Start time</th>${IS_ADMIN ? '<th>Agent</th>' : ''}<th>Ticket</th><th>Time spent</th><th>Recording / Transcript</th></tr></thead><tbody></tbody></table> <table id="report"><thead><tr><th>Date</th><th>Start time</th>${IS_ADMIN ? '<th>Agent</th>' : ''}<th>Ticket</th><th>Time spent</th><th>Recording / Transcript</th></tr></thead><tbody></tbody></table>
<div id="repPager" class="pager"></div> <div id="repPager" class="pager"></div>
<p id="repSummary" class="muted" style="margin-top:.6rem"></p> <p id="repSummary" class="muted" style="margin-top:.6rem"></p>
</div>`); </div>
${IS_ADMIN ? `
<div class="card" id="keysCard">
<h2>API keys <span class="muted" style="font-weight:400;font-size:.8rem;text-transform:none;letter-spacing:0">— let other systems read your data programmatically</span></h2>
<table id="keys"><thead><tr><th>Name</th><th>Scopes</th><th>Created</th><th>Last used</th><th>Status</th><th></th></tr></thead><tbody></tbody></table>
<div class="row" style="margin-top:1rem;flex-wrap:wrap;align-items:flex-end;gap:1rem">
<div><span class="lbl">Name</span><input id="kName" placeholder="e.g. Partner X" style="max-width:200px"></div>
<label class="chk"><input type="checkbox" id="kReport" checked> report:read</label>
<label class="chk"><input type="checkbox" id="kAudit"> audit:read</label>
<button id="kAdd">Generate key</button>
</div>
<div id="kOut"></div>
</div>
<div class="card" id="hooksCard">
<h2>Webhooks <span class="muted" style="font-weight:400;font-size:.8rem;text-transform:none;letter-spacing:0">— signed event callbacks to your systems</span></h2>
<table id="hooks"><thead><tr><th>Endpoint</th><th>Events</th><th>Status</th><th>Last delivery</th><th></th></tr></thead><tbody></tbody></table>
<div class="row" style="margin-top:1rem;flex-wrap:wrap;align-items:flex-end;gap:1rem">
<div style="flex:1;min-width:240px"><span class="lbl">Endpoint URL</span><input id="hUrl" placeholder="https://your-system.example.com/webhook"></div>
<label class="chk"><input type="checkbox" id="hStarted" checked> session.started</label>
<label class="chk"><input type="checkbox" id="hEnded" checked> session.ended</label>
<button id="hAdd">Add webhook</button>
</div>
<div id="hOut"></div>
</div>` : ''}`);
document.getElementById('fApply').onclick = loadReport; document.getElementById('fApply').onclick = loadReport;
document.getElementById('fExcel').onclick = exportExcel; document.getElementById('fExcel').onclick = exportExcel;
document.getElementById('fPdf').onclick = exportPdf; document.getElementById('fPdf').onclick = exportPdf;
if (IS_ADMIN) await populateAgentFilter(); if (IS_ADMIN) await populateAgentFilter();
await loadReport(); await loadReport();
if (IS_ADMIN) {
document.getElementById('kAdd').onclick = createKey;
document.getElementById('hAdd').onclick = createHook;
await loadKeys();
await loadHooks();
}
} }
// ---------- Integrations: API keys + webhooks (admin) ----------
function fmtTs(ms){ return ms ? new Date(ms).toLocaleString() : '—'; }
function revealBox(label, value, note){
return '<div class="reveal"><div class="lbl" style="margin:0 0 .3rem">'+esc(label)+' — copy now</div>'
+ '<div style="display:flex;gap:.5rem;align-items:center"><code id="revealVal">'+esc(value)+'</code>'
+ '<button class="mini" id="copyReveal">Copy</button></div>'
+ '<div class="muted" style="margin-top:.4rem;font-size:.78rem">'+esc(note)+'</div></div>';
}
function wireCopy(){ const b=document.getElementById('copyReveal'); if(!b)return; b.onclick=async()=>{ try{ await navigator.clipboard.writeText(document.getElementById('revealVal').textContent); }catch(_){} b.textContent='Copied'; setTimeout(()=>{b.textContent='Copy';},1500); }; }
async function loadKeys(){
let rows=[]; try{ rows = await api('/api/keys', null, 'GET'); }catch(e){ return; }
document.querySelector('#keys tbody').innerHTML = rows.length ? rows.map(k=>`
<tr style="${k.revoked?'opacity:.5':''}">
<td>${esc(k.name||'—')}</td>
<td class="muted">${esc(k.scopes||'')}</td>
<td>${fmtTs(k.created_at)}</td>
<td>${fmtTs(k.last_used_at)}</td>
<td>${k.revoked?'<span class="pill off">revoked</span>':'<span class="pill on">active</span>'}</td>
<td>${k.revoked?'':`<button class="mini danger" onclick="revokeKey('${k.id}')">Revoke</button>`}</td>
</tr>`).join('') : '<tr><td colspan=6 class="muted">No API keys yet.</td></tr>';
}
async function createKey(){
const scopes=[]; if(document.getElementById('kReport').checked)scopes.push('report:read'); if(document.getElementById('kAudit').checked)scopes.push('audit:read');
if(!scopes.length){ document.getElementById('kOut').innerHTML='<p class="muted">Select at least one scope.</p>'; return; }
try{
const r = await api('/api/keys', { name: document.getElementById('kName').value, scopes }, 'POST');
document.getElementById('kName').value='';
document.getElementById('kOut').innerHTML = revealBox('API key', r.key, "Send this to the integrator. It won't be shown again — revoke and re-issue if lost.");
wireCopy(); loadKeys();
}catch(e){ document.getElementById('kOut').innerHTML='<p class="muted">'+esc(e.message)+'</p>'; }
}
window.revokeKey = async (id)=>{ if(!confirm('Revoke this API key? Integrations using it will stop working.'))return; try{ await api('/api/keys/revoke',{id},'POST'); loadKeys(); }catch(e){} };
async function loadHooks(){
let rows=[]; try{ rows = await api('/api/webhooks', null, 'GET'); }catch(e){ return; }
document.querySelector('#hooks tbody').innerHTML = rows.length ? rows.map(h=>`
<tr style="${h.active?'':'opacity:.5'}">
<td style="max-width:280px;overflow:hidden;text-overflow:ellipsis" class="muted">${esc(h.url)}</td>
<td class="muted">${esc(h.events||'')}</td>
<td>${h.last_status==null?'<span class="muted">—</span>':(h.last_status?'<span class="pill on">ok</span>':'<span class="pill off">failing</span>')}</td>
<td>${fmtTs(h.last_at)}${h.last_error?' <span class="muted" title="'+esc(h.last_error)+'">'+ic('alertTriangle',13)+'</span>':''}</td>
<td><button class="mini danger" onclick="deleteHook('${h.id}')">Delete</button></td>
</tr>`).join('') : '<tr><td colspan=5 class="muted">No webhooks yet.</td></tr>';
}
async function createHook(){
const events=[]; if(document.getElementById('hStarted').checked)events.push('session.started'); if(document.getElementById('hEnded').checked)events.push('session.ended');
const url=document.getElementById('hUrl').value.trim();
if(!/^https?:\/\//i.test(url)){ document.getElementById('hOut').innerHTML='<p class="muted">Enter a valid http(s) URL.</p>'; return; }
if(!events.length){ document.getElementById('hOut').innerHTML='<p class="muted">Select at least one event.</p>'; return; }
try{
const r = await api('/api/webhooks', { url, events }, 'POST');
document.getElementById('hUrl').value='';
document.getElementById('hOut').innerHTML = revealBox('Signing secret', r.secret, 'Verify the X-BizGaze-Signature header (HMAC-SHA256 of the body) with this. Shown once.');
wireCopy(); loadHooks();
}catch(e){ document.getElementById('hOut').innerHTML='<p class="muted">'+esc(e.message)+'</p>'; }
}
window.deleteHook = async (id)=>{ if(!confirm('Delete this webhook?'))return; try{ await api('/api/webhooks/delete',{id},'POST'); loadHooks(); }catch(e){} };
const PER_PAGE = 5; const PER_PAGE = 5;
function pagerHTML(page, pages, total, fn){ function pagerHTML(page, pages, total, fn){
if (total <= PER_PAGE) return total ? `<span>${total} total</span>` : ''; if (total <= PER_PAGE) return total ? `<span>${total} total</span>` : '';
@@ -241,8 +336,8 @@ function reportRowHTML(r){
<td>${esc(r.ticket || 'Direct session')}</td> <td>${esc(r.ticket || 'Direct session')}</td>
<td>${r.ended_at ? fmtDuration(dur) : '<span class="pill on">in progress</span>'}</td> <td>${r.ended_at ? fmtDuration(dur) : '<span class="pill on">in progress</span>'}</td>
<td>${[ <td>${[
r.recording ? `<a class="mini" style="text-decoration:none;display:inline-block;padding:.32rem .6rem;margin:1px" href="/recordings/${esc(r.recording)}" download> Video</a>` : '', r.recording ? `<a class="mini" style="text-decoration:none;display:inline-block;padding:.32rem .6rem;margin:1px" href="/recordings/${esc(r.recording)}" download>${ic('download',14)} Video</a>` : '',
r.transcript ? `<a class="mini" style="text-decoration:none;display:inline-block;padding:.32rem .6rem;margin:1px" href="/transcripts/${esc(r.transcript)}" download> Text</a>` : '' r.transcript ? `<a class="mini" style="text-decoration:none;display:inline-block;padding:.32rem .6rem;margin:1px" href="/transcripts/${esc(r.transcript)}" download>${ic('download',14)} Text</a>` : ''
].join('') || '<span class="muted">—</span>'}</td> ].join('') || '<span class="muted">—</span>'}</td>
</tr>`; </tr>`;
} }
+1961 -101
View File
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -19,10 +19,11 @@
.indicator { position:fixed; bottom:0; left:0; right:0; background:#b91c1c; color:#fff; text-align:center; padding:.4rem; font-size:.85rem; display:none; } .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; } .indicator.show { display:block; }
</style> </style>
<script src="/icons.js?v=3"></script>
</head> </head>
<body> <body>
<div class="card"> <div class="card">
<h1>🖥️ Browser Host (no install)</h1> <h1><span data-ic="monitor" data-sz="22"></span> 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> <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"> <input id="token" placeholder="enroll token">
<button id="goBtn">Go online</button> <button id="goBtn">Go online</button>
@@ -98,5 +99,6 @@ function teardown() {
} }
function esc(s){return String(s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));} function esc(s){return String(s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));}
</script> </script>
<script>(function(){var s=document.createElement('style');s.textContent='.ic{display:inline-block;vertical-align:middle}';document.head.appendChild(s);document.querySelectorAll('[data-ic]').forEach(function(e){e.insertAdjacentHTML('afterbegin',window.ic(e.getAttribute('data-ic'),+e.getAttribute('data-sz')||16));});})();</script>
</body> </body>
</html> </html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

+65
View File
@@ -0,0 +1,65 @@
// Shared icon set (Lucide — modern line icons). Use ic('name', size) for any UI icon.
// Add new icons here so the whole app stays visually consistent.
(function () {
const P = {
chat: '<path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z"/>',
screenShare: '<path d="M13 3H4a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-3"/><path d="M8 21h8"/><path d="M12 17v4"/><path d="m17 8 5-5"/><path d="M17 3h5v5"/>',
wifi: '<path d="M12 20h.01"/><path d="M8.5 16.4a5 5 0 0 1 7 0"/><path d="M5 12.9a10 10 0 0 1 14 0"/><path d="M2 8.8a15 15 0 0 1 20 0"/>',
video: '<path d="m22 8-6 4 6 4V8Z"/><rect width="14" height="12" x="2" y="6" rx="2" ry="2"/>',
paperclip: '<path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48"/>',
smile: '<circle cx="12" cy="12" r="10"/><path d="M8 14s1.5 2 4 2 4-2 4-2"/><line x1="9" x2="9.01" y1="9" y2="9"/><line x1="15" x2="15.01" y1="9" y2="9"/>',
smilePlus: '<path d="M22 11v1a10 10 0 1 1-9-10"/><path d="M8 14s1.5 2 4 2 4-2 4-2"/><line x1="9" x2="9.01" y1="9" y2="9"/><line x1="15" x2="15.01" y1="9" y2="9"/><path d="M16 5h6"/><path d="M19 2v6"/>',
reply: '<polyline points="9 14 4 9 9 4"/><path d="M20 20v-7a4 4 0 0 0-4-4H4"/>',
info: '<circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/>',
x: '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
arrowLeft: '<path d="m12 19-7-7 7-7"/><path d="M19 12H5"/>',
users: '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
userPlus: '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><line x1="19" x2="19" y1="8" y2="14"/><line x1="22" x2="16" y1="11" y2="11"/>',
send: '<path d="M14.54 21.69a.5.5 0 0 0 .94-.03l6.5-19a.5.5 0 0 0-.64-.63l-19 6.5a.5.5 0 0 0-.02.93l7.93 3.18a2 2 0 0 1 1.1 1.11z"/><path d="m21.85 2.15-10.94 10.94"/>',
search: '<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>',
edit: '<path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/>',
trash: '<path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>',
logOut: '<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" x2="9" y1="12" y2="12"/>',
plus: '<path d="M5 12h14"/><path d="M12 5v14"/>',
check: '<path d="M20 6 9 17l-5-5"/>',
download: '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" x2="12" y1="15" y2="3"/>',
copy: '<rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>',
file: '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/>',
mic: '<path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" x2="12" y1="19" y2="22"/>',
micOff: '<line x1="2" x2="22" y1="2" y2="22"/><path d="M18.89 13.23A7.12 7.12 0 0 0 19 12v-2"/><path d="M5 10v2a7 7 0 0 0 12 5"/><path d="M15 9.34V5a3 3 0 0 0-5.68-1.33"/><path d="M9 9v3a3 3 0 0 0 5.12 2.12"/><line x1="12" x2="12" y1="19" y2="22"/>',
camera: '<path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z"/><circle cx="12" cy="13" r="3"/>',
cameraOff: '<line x1="2" x2="22" y1="2" y2="22"/><path d="M7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h12"/><path d="M9.5 4h5L17 7h3a2 2 0 0 1 2 2v7.5"/>',
phoneOff: '<path d="M10.68 13.31a16 16 0 0 0 3.41 2.6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7 2 2 0 0 1 1.72 2v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.42 19.42 0 0 1-3.33-2.67m-2.67-3.34a19.79 19.79 0 0 1-3.07-8.63A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91"/><line x1="2" x2="22" y1="2" y2="22"/>',
phone: '<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/>',
calendar: '<path d="M8 2v4"/><path d="M16 2v4"/><rect width="18" height="18" x="3" y="4" rx="2"/><path d="M3 10h18"/>',
pencil: '<path d="M21.17 6.83a2.83 2.83 0 0 0-4-4L3.84 16.17a2 2 0 0 0-.5.83l-1.32 4.35a.5.5 0 0 0 .62.62l4.35-1.32a2 2 0 0 0 .83-.5z"/><path d="m15 5 4 4"/>',
chevronDown: '<path d="m6 9 6 6 6-6"/>',
layoutDashboard:'<rect width="7" height="9" x="3" y="3" rx="1"/><rect width="7" height="5" x="14" y="3" rx="1"/><rect width="7" height="9" x="14" y="12" rx="1"/><rect width="7" height="5" x="3" y="16" rx="1"/>',
arrowRight: '<path d="M5 12h14"/><path d="m12 5 7 7-7 7"/>',
alertTriangle:'<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3z"/><path d="M12 9v4"/><path d="M12 17h.01"/>',
lock: '<rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
monitor: '<rect width="20" height="14" x="2" y="3" rx="2"/><line x1="8" x2="16" y1="21" y2="21"/><line x1="12" x2="12" y1="17" y2="21"/>',
barChart: '<path d="M3 3v16a2 2 0 0 0 2 2h16"/><rect x="7" y="11" width="3" height="6" rx="1"/><rect x="12" y="7" width="3" height="10" rx="1"/><rect x="17" y="13" width="3" height="4" rx="1"/>',
bell: '<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/>',
bold: '<path d="M14 12a4 4 0 0 0 0-8H6v8"/><path d="M15 20a4 4 0 0 0 0-8H6v8Z"/>',
italic: '<line x1="19" x2="10" y1="4" y2="4"/><line x1="14" x2="5" y1="20" y2="20"/><line x1="15" x2="9" y1="4" y2="20"/>',
strikethrough:'<path d="M16 4H9a3 3 0 0 0-2.83 4"/><path d="M14 12a4 4 0 0 1 0 8H6"/><line x1="4" x2="20" y1="12" y2="12"/>',
code: '<polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/>',
list: '<line x1="8" x2="21" y1="6" y2="6"/><line x1="8" x2="21" y1="12" y2="12"/><line x1="8" x2="21" y1="18" y2="18"/><line x1="3" x2="3.01" y1="6" y2="6"/><line x1="3" x2="3.01" y1="12" y2="12"/><line x1="3" x2="3.01" y1="18" y2="18"/>',
listOrdered: '<line x1="10" x2="21" y1="6" y2="6"/><line x1="10" x2="21" y1="12" y2="12"/><line x1="10" x2="21" y1="18" y2="18"/><path d="M4 6h1v4"/><path d="M4 10h2"/><path d="M6 18H4c0-1 2-2 2-3s-1-1.5-2-1"/>',
type: '<polyline points="4 7 4 4 20 4 20 7"/><line x1="9" x2="15" y1="20" y2="20"/><line x1="12" x2="12" y1="4" y2="20"/>',
crown: '<path d="m2 4 3 12h14l3-12-6 7-4-7-4 7-6-7z"/><path d="M5 20h14"/>',
checkCheck: '<path d="M18 6 7 17l-5-5"/><path d="m22 10-7.5 7.5L13 16"/>',
calendarX: '<path d="M8 2v4"/><path d="M16 2v4"/><rect width="18" height="18" x="3" y="4" rx="2"/><path d="M3 10h18"/><path d="m14 14-4 4"/><path d="m10 14 4 4"/>',
calendarClock:'<path d="M21 7.5V6a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h3.5"/><path d="M16 2v4"/><path d="M8 2v4"/><path d="M3 10h5"/><circle cx="16" cy="16" r="6"/><path d="M16 14v2l1.5 1"/>',
fileText: '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M16 13H8"/><path d="M16 17H8"/><path d="M10 9H8"/>',
record: '<circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="3.5" fill="currentColor"/>',
callEnd: '<g transform="rotate(135 12 12)"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/></g>',
settings: '<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>',
};
window.ICON = P;
window.ic = function (name, size) {
const s = size || 18;
return '<svg class="ic" width="' + s + '" height="' + s + '" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' + (P[name] || '') + '</svg>';
};
})();
+6 -4
View File
@@ -17,9 +17,9 @@
.inner{max-width:780px;width:100%;text-align:center;} .inner{max-width:780px;width:100%;text-align:center;}
h1{color:var(--blue);font-size:1.8rem;margin:0 0 .4rem;} h1{color:var(--blue);font-size:1.8rem;margin:0 0 .4rem;}
.sub{color:var(--muted);margin-bottom:2.2rem;} .sub{color:var(--muted);margin-bottom:2.2rem;}
.ssobtn{display:inline-flex;align-items:center;justify-content:center;gap:.6rem;background:var(--blue);color:#fff;text-decoration:none;font-weight:700;font-size:1.02rem;padding:.95rem 2rem;border-radius:12px;box-shadow:0 10px 26px rgba(31,59,115,.28);transition:transform .12s,box-shadow .12s,background .12s;} .ssobtn{display:inline-flex;align-items:center;justify-content:center;gap:.6rem;background:var(--brand);color:var(--blue);text-decoration:none;font-weight:700;font-size:1.02rem;padding:.95rem 2rem;border-radius:12px;box-shadow:0 10px 26px rgba(224,172,0,.32);transition:transform .12s,box-shadow .12s,background .12s;}
.ssobtn:hover{transform:translateY(-2px);box-shadow:0 16px 34px rgba(31,59,115,.34);background:var(--blue-d);} .ssobtn:hover{transform:translateY(-2px);box-shadow:0 16px 34px rgba(224,172,0,.4);background:var(--brand-d);}
.ssobtn .bmark{width:26px;height:26px;border-radius:7px;background:var(--brand);color:var(--blue);display:grid;place-items:center;font-weight:800;font-size:.9rem;} .ssobtn .bmark{width:26px;height:26px;border-radius:7px;background:var(--blue);color:var(--brand);display:grid;place-items:center;font-weight:800;font-size:.9rem;}
.divider{display:flex;align-items:center;gap:1rem;color:var(--muted);font-size:.85rem;max-width:360px;margin:1.8rem auto;} .divider{display:flex;align-items:center;gap:1rem;color:var(--muted);font-size:.85rem;max-width:360px;margin:1.8rem auto;}
.divider::before,.divider::after{content:"";flex:1;height:1px;background:var(--line);} .divider::before,.divider::after{content:"";flex:1;height:1px;background:var(--line);}
.choices{display:flex;gap:1.4rem;flex-wrap:wrap;justify-content:center;} .choices{display:flex;gap:1.4rem;flex-wrap:wrap;justify-content:center;}
@@ -41,6 +41,7 @@
.profile .pmenu a:hover{background:#f1f5f9} .profile .pmenu a:hover{background:#f1f5f9}
.profile .pmenu a.danger{color:#b91c1c;border-top:1px solid #eef1f6} .profile .pmenu a.danger{color:#b91c1c;border-top:1px solid #eef1f6}
</style> </style>
<script src="/icons.js?v=3"></script>
</head> </head>
<body> <body>
<header> <header>
@@ -63,7 +64,7 @@
<div><h3>Share my screen</h3><p>Get a one-time code and show your screen to a BizGaze support agent — no login, no download.</p></div> <div><h3>Share my screen</h3><p>Get a one-time code and show your screen to a BizGaze support agent — no login, no download.</p></div>
</a> </a>
</div> </div>
<div class="foot">🔒 Screen sharing only starts after you approve it, and can be stopped anytime.</div> <div class="foot"><span data-ic="lock" data-sz="14"></span> Screen sharing only starts after you approve it, and can be stopped anytime.</div>
</div> </div>
</div> </div>
<footer>© BizGaze · Remote Support</footer> <footer>© BizGaze · Remote Support</footer>
@@ -81,5 +82,6 @@ makeBrandClickable();
const dv=document.querySelector('.divider'); if(dv) dv.textContent='need to help someone? share your screen'; const dv=document.querySelector('.divider'); if(dv) dv.textContent='need to help someone? share your screen';
}}catch(_){}})(); }}catch(_){}})();
</script> </script>
<script>(function(){var s=document.createElement('style');s.textContent='.ic{display:inline-block;vertical-align:middle}';document.head.appendChild(s);document.querySelectorAll('[data-ic]').forEach(function(e){e.insertAdjacentHTML('afterbegin',window.ic(e.getAttribute('data-ic'),+e.getAttribute('data-sz')||16));});})();</script>
</body> </body>
</html> </html>
+16
View File
@@ -0,0 +1,16 @@
{
"name": "BizGaze Connect",
"short_name": "Connect",
"description": "Chat, screen share, and video meetings for the BizGaze ecosystem.",
"start_url": "/home",
"scope": "/",
"display": "standalone",
"orientation": "portrait-primary",
"background_color": "#1F3B73",
"theme_color": "#1F3B73",
"icons": [
{ "src": "/icon-192.png?v=2", "sizes": "192x192", "type": "image/png", "purpose": "any" },
{ "src": "/icon-512.png?v=2", "sizes": "512x512", "type": "image/png", "purpose": "any" },
{ "src": "/icon-512.png?v=2", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
]
}
+27 -14
View File
@@ -20,7 +20,7 @@
.sub{color:var(--muted);font-size:.97rem;line-height:1.5;margin-bottom:1.6rem;} .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;} .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;} .codelabel{font-size:.78rem;letter-spacing:.08em;text-transform:uppercase;color:var(--muted);margin-bottom:.3rem;}
.code{font-size:clamp(1.9rem,12vw,3rem);letter-spacing:clamp(.18rem,3vw,.5rem);font-weight:800;color:var(--ink);white-space:nowrap;overflow:hidden;text-overflow:clip;} .code{font-size:clamp(1.6rem,9vw,2.6rem);letter-spacing:clamp(.1rem,2vw,.4rem);padding-left:clamp(.1rem,2vw,.4rem);font-weight:800;color:var(--ink);white-space:nowrap;}
.status{margin-top:1.3rem;padding:.7rem 1rem;border-radius:10px;background:#f1f5f9;color:#475569;font-size:.92rem;} .status{margin-top:1.3rem;padding:.7rem 1rem;border-radius:10px;background:#f1f5f9;color:#475569;font-size:.92rem;}
.status.on{background:#ecfdf3;color:#15803d;} .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{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);}
@@ -46,11 +46,12 @@
.profile .pmenu a:hover{background:#f1f5f9} .profile .pmenu a:hover{background:#f1f5f9}
.profile .pmenu a.danger{color:#b91c1c;border-top:1px solid #eef1f6} .profile .pmenu a.danger{color:#b91c1c;border-top:1px solid #eef1f6}
</style> </style>
<script src="/icons.js?v=3"></script>
</head> </head>
<body> <body>
<script>if(new URLSearchParams(location.search).get('embed')==='1')document.documentElement.classList.add('embed');</script> <script>if(new URLSearchParams(location.search).get('embed')==='1')document.documentElement.classList.add('embed');</script>
<div class="indicator" id="indicator">● Your screen is being shared — close this tab anytime to stop</div> <div class="indicator" id="indicator">● Your screen is being shared — close this tab anytime to stop</div>
<a href="/" id="homeLink" 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)">&#8592; Home</a> <a href="/" id="homeLink" 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)"><span data-ic="arrowLeft" data-sz="16"></span> Home</a>
<div class="stage"> <div class="stage">
<div class="brandpanel"> <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'}))"> <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'}))">
@@ -65,12 +66,12 @@
<div class="codelabel">Your session code</div> <div class="codelabel">Your session code</div>
<div style="display:flex;align-items:center;justify-content:center;gap:.7rem"> <div style="display:flex;align-items:center;justify-content:center;gap:.7rem">
<div class="code" id="code">······</div> <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> <button id="copyBtn" title="Click to copy" aria-label="Click to copy" style="flex:0 0 auto;width:28px;height:28px;padding:0;background:#fff;border:1px solid var(--brand);color:var(--blue);border-radius:7px;cursor:pointer;display:inline-flex;align-items:center;justify-content:center"><svg id="copyIcon" width="14" height="14" 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> </div>
<div id="status" class="status">Preparing your code…</div> <div id="status" class="status">Preparing your code…</div>
<div id="consentBox"></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 class="foot"><span data-ic="lock" data-sz="14"></span> You stay in control. The agent can only see your screen after you tap Allow, and you can stop anytime.</div>
</div> </div>
</div> </div>
</div> </div>
@@ -98,7 +99,7 @@ document.getElementById('copyBtn').onclick=async()=>{
try{ await navigator.clipboard.writeText(code); } 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(); } 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; 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>'; b.innerHTML='<svg width="14" height="14" 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); setTimeout(()=>{b.innerHTML=old;},1500);
}; };
let ws,pc,localStream,chatChannel,sessionId; let ws,pc,localStream,chatChannel,sessionId;
@@ -144,7 +145,9 @@ function showConsent(m){
async function beginCapture(){ async function beginCapture(){
try{ localStream=await navigator.mediaDevices.getDisplayMedia({video:{displaySurface:'monitor',frameRate:{ideal:30}},audio:false,monitorTypeSurfaces:'include'}); } try{ localStream=await navigator.mediaDevices.getDisplayMedia({video:{displaySurface:'monitor',frameRate:{ideal:30}},audio:false,monitorTypeSurfaces:'include'}); }
catch(err){ return false; } catch(err){ return false; }
try{ const mic=await navigator.mediaDevices.getUserMedia({audio:true}); window.__mic=mic; mic.getAudioTracks().forEach(t=>localStream.addTrack(t)); }catch(e){} // Mic is OFF by default — we do NOT prompt for it here. Asking for the screen and the
// mic at once confused customers and silently cancelled the share. The mic permission
// is requested only when the customer taps Unmute (see the bar's mic button).
try{ ensureIce(); }catch(_){} try{ ensureIce(); }catch(_){}
return true; return true;
} }
@@ -152,11 +155,10 @@ async function startStreaming(){
// If the Allow tap already captured the screen (mobile path), reuse it. // If the Allow tap already captured the screen (mobile path), reuse it.
if(!localStream){ if(!localStream){
await ensureIce(); await ensureIce();
let mic=null; try{ mic=await navigator.mediaDevices.getUserMedia({audio:true}); }catch(e){ mic=null; }
setStatus('In the popup: choose your screen, then tap Share / Start.','on'); setStatus('In the popup: choose your screen, then tap Share / Start.','on');
// Screen only — mic stays off until the customer taps Unmute (avoids the dual prompt).
try{ localStream=await navigator.mediaDevices.getDisplayMedia({video:{displaySurface:'monitor',frameRate:{ideal:30}},audio:false,monitorTypeSurfaces:'include'}); } try{ localStream=await navigator.mediaDevices.getDisplayMedia({video:{displaySurface:'monitor',frameRate:{ideal:30}},audio:false,monitorTypeSurfaces:'include'}); }
catch(err){ if(mic){try{mic.getTracks().forEach(t=>t.stop());}catch(_){}} 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; } 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; }
if(mic){ window.__mic=mic; try{mic.getAudioTracks().forEach(t=>localStream.addTrack(t));}catch(_){} }
} }
await ensureIce(); await ensureIce();
indicator.classList.add('show'); setStatus('You are now sharing your screen with your agent.','on'); bzcSession(true); indicator.classList.add('show'); setStatus('You are now sharing your screen with your agent.','on'); bzcSession(true);
@@ -225,18 +227,28 @@ 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_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_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_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>'; const SVG_END='<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><g transform="rotate(135 12 12)"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/></g></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 _btn(id,svg,label,bg){const b=document.createElement('button');b.id=id;b.title=label;b.setAttribute('aria-label',label);b.innerHTML='<span style="display:inline-flex">'+svg+'</span>';b.style.cssText='display:inline-flex;align-items:center;justify-content:center;width:48px;height:48px;border:none;border-radius:50%;cursor:pointer;color:#fff;background:'+bg+';box-shadow:0 2px 6px rgba(0,0,0,.25);transition:background .15s,transform .08s';b.onmouseenter=()=>b.style.transform='translateY(-2px)';b.onmouseleave=()=>b.style.transform='none';return b;}
function buildBar(){ function buildBar(){
if(document.getElementById('sessionBar'))return; if(document.getElementById('sessionBar'))return;
const bar=document.createElement('div'); bar.id='sessionBar'; 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)'; 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 mic=_btn('micBtn',SVG_MICOFF,'Muted','#6b7280');
const chat=_btn('chatBtn',SVG_CHAT,'Chat','#475569'); const chat=_btn('chatBtn',SVG_CHAT,'Chat','#475569');
const end=_btn('endBtn2',SVG_END,'Stop','#dc2626'); const end=_btn('endBtn2',SVG_END,'End','#dc2626');
bar.appendChild(mic);bar.appendChild(chat);bar.appendChild(end); bar.appendChild(mic);bar.appendChild(chat);bar.appendChild(end);
document.body.appendChild(bar); 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';}; const setMic=(on)=>{mic.title=on?'Mute':'Unmute';mic.innerHTML='<span style="display:inline-flex">'+(on?SVG_MIC:SVG_MICOFF)+'</span>';mic.style.background=on?'#2563eb':'#6b7280';};
mic.onclick=async()=>{
if(!window.__mic){
// First unmute: NOW request mic permission, add the track, and renegotiate.
let m; try{ m=await navigator.mediaDevices.getUserMedia({audio:true}); }catch(e){ setStatus('Microphone permission was blocked. Allow it in the browser to talk.'); return; }
window.__mic=m; const t=m.getAudioTracks()[0];
try{ localStream.addTrack(t); if(pc){ pc.addTrack(t,localStream); const offer=await pc.createOffer(); await pc.setLocalDescription(offer); ws.send(JSON.stringify({type:'offer',sessionId,sdp:pc.localDescription})); } }catch(_){}
setMic(true); return;
}
const t=window.__mic.getAudioTracks()[0]; if(!t) return; t.enabled=!t.enabled; setMic(t.enabled);
};
chat.onclick=toggleChat; chat.onclick=toggleChat;
end.onclick=()=>{ try{ws.send(JSON.stringify({type:'end-session',sessionId,reason:'customer-ended'}));}catch(_){} teardown(); }; end.onclick=()=>{ try{ws.send(JSON.stringify({type:'end-session',sessionId,reason:'customer-ended'}));}catch(_){} teardown(); };
buildChatPanel(); buildChatPanel();
@@ -267,5 +279,6 @@ function removeSessionUI(){['sessionBar','chatPanel','remoteAudio','muteBtn','ms
function esc(s){return String(s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));} function esc(s){return String(s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));}
</script> </script>
<script>(function(){var s=document.createElement('style');s.textContent='.ic{display:inline-block;vertical-align:middle}';document.head.appendChild(s);document.querySelectorAll('[data-ic]').forEach(function(e){e.insertAdjacentHTML('afterbegin',window.ic(e.getAttribute('data-ic'),+e.getAttribute('data-sz')||16));});})();</script>
</body> </body>
</html> </html>
+46
View File
@@ -0,0 +1,46 @@
// BizGaze Connect service worker — NOTIFICATIONS ONLY.
// Intentionally has NO 'fetch' handler and NO caching, so it can never serve a stale
// version of the app. Its only job is to show push notifications when the page is in
// the background / frozen / closed, and to open the right chat when one is clicked.
self.addEventListener('install', (e) => { self.skipWaiting(); });
self.addEventListener('activate', (e) => { e.waitUntil(self.clients.claim()); });
// No-op fetch handler: present only so the app meets PWA installability criteria. It never
// calls respondWith(), so the browser performs its normal network fetch — NO caching, so this
// can never serve a stale app.
self.addEventListener('fetch', () => {});
self.addEventListener('push', (event) => {
let d = {};
try { d = event.data ? event.data.json() : {}; } catch (_) {}
const title = d.title || 'BizGaze Connect';
const options = {
body: d.body || '',
icon: '/logo.png',
badge: '/logo.png',
tag: d.tag || undefined, // collapse repeats from the same chat
renotify: !!d.tag,
data: { kind: d.kind || '', id: d.id || '' },
};
event.waitUntil((async () => {
// If a BizGaze tab is currently VISIBLE, the page itself alerts the user (ping / in-page
// popup) — skip the OS popup to avoid a double. Only show when no tab is visible
// (another tab/app, minimized, or closed) — exactly when the page can't alert.
const clientsArr = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
const visible = clientsArr.some((c) => c.visibilityState === 'visible');
if (visible) return;
await self.registration.showNotification(title, options);
})());
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
const { kind, id } = event.notification.data || {};
const url = '/home' + (id ? ('?openKind=' + encodeURIComponent(kind || 'dm') + '&openId=' + encodeURIComponent(id)) : '');
event.waitUntil((async () => {
const all = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
for (const c of all) {
if (c.url.includes('/home')) { try { await c.focus(); c.postMessage({ type: 'open-chat', kind, id }); return; } catch (_) {} }
}
try { await self.clients.openWindow(url); } catch (_) {}
})());
});
+3 -1
View File
@@ -11,12 +11,13 @@
button { padding: 0.5rem 1rem; background: #334155; color: #fff; border: none; border-radius: 6px; cursor: pointer; } button { padding: 0.5rem 1rem; background: #334155; color: #fff; border: none; border-radius: 6px; cursor: pointer; }
a { color: #3b82f6; } a { color: #3b82f6; }
</style> </style>
<script src="/icons.js?v=3"></script>
</head> </head>
<body> <body>
<header> <header>
<div id="status">Connecting…</div> <div id="status">Connecting…</div>
<div> <div>
<a href="/"> Console</a> <a href="/"><span data-ic="arrowLeft" data-sz="16"></span> Console</a>
<button id="endBtn">End session</button> <button id="endBtn">End session</button>
</div> </div>
</header> </header>
@@ -103,5 +104,6 @@ document.getElementById('endBtn').onclick = () => {
setTimeout(() => (location.href = '/'), 300); setTimeout(() => (location.href = '/'), 300);
}; };
</script> </script>
<script>(function(){var s=document.createElement('style');s.textContent='.ic{display:inline-block;vertical-align:middle}';document.head.appendChild(s);document.querySelectorAll('[data-ic]').forEach(function(e){e.insertAdjacentHTML('afterbegin',window.ic(e.getAttribute('data-ic'),+e.getAttribute('data-sz')||16));});})();</script>
</body> </body>
</html> </html>
+45
View File
@@ -0,0 +1,45 @@
// Web Push (background / closed-tab / mobile notifications). Fully optional:
// - if the `web-push` package isn't installed, or VAPID env keys aren't set,
// isEnabled() is false and every call is a silent no-op (the app is unaffected).
// Configure in production by setting:
// VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY, VAPID_SUBJECT (e.g. mailto:admin@bizgaze.com)
// Generate a key pair once with: npx web-push generate-vapid-keys
const R = require('./repos');
let webpush = null;
try { webpush = require('web-push'); } catch (_) { /* package not installed -> push disabled */ }
const PUBLIC = process.env.VAPID_PUBLIC_KEY || '';
const PRIVATE = process.env.VAPID_PRIVATE_KEY || '';
const SUBJECT = process.env.VAPID_SUBJECT || 'mailto:support@bizgaze.com';
let ready = false;
if (webpush && PUBLIC && PRIVATE) {
try { webpush.setVapidDetails(SUBJECT, PUBLIC, PRIVATE); ready = true; }
catch (e) { console.warn('[push] invalid VAPID config:', e.message); }
}
if (!ready) console.log('[push] Web Push disabled (set web-push + VAPID_PUBLIC_KEY/VAPID_PRIVATE_KEY to enable).');
function isEnabled() { return ready; }
function publicKey() { return ready ? PUBLIC : ''; }
// Fire-and-forget push to every device the user has subscribed. Dead subscriptions
// (410 Gone / 404) are pruned. Never throws.
async function sendToUser(userId, payload) {
if (!ready) return;
let subs = [];
try { subs = R.pushSubs.byUser(userId); } catch (_) { return; }
const data = JSON.stringify(payload || {});
for (const s of subs) {
const sub = { endpoint: s.endpoint, keys: { p256dh: s.p256dh, auth: s.auth } };
try {
await webpush.sendNotification(sub, data, { TTL: 600 });
} catch (err) {
const code = err && err.statusCode;
if (code === 404 || code === 410) { try { R.pushSubs.removeByEndpoint(s.endpoint); } catch (_) {} }
// other errors (network, 4xx) are ignored — push is best-effort
}
}
}
module.exports = { isEnabled, publicKey, sendToUser };
+25
View File
@@ -0,0 +1,25 @@
// Fires a one-shot "starts in ~10 minutes" reminder to a scheduled meeting's host,
// group members, and invited participants. Runs on a 60s tick; marks each meeting reminded.
const R = require('./repos');
const CHAT = require('./chat');
function tick() {
try {
const now = Date.now();
const due = R.scheduledMeetings.dueForReminder(now, now + 10 * 60 * 1000); // starting within 10 min
for (const s of due) {
const recipients = new Set([s.created_by]);
let invited = []; try { invited = JSON.parse(s.participants || '[]'); } catch (_) {}
invited.forEach((id) => recipients.add(id));
if (s.group_id) { try { R.conversations.members(s.group_id).forEach((m) => recipients.add(m)); } catch (_) {} }
const evt = { type: 'meeting-reminder', meeting: { id: s.id, title: s.title, scheduledAt: s.scheduled_at, room: s.room_code } };
recipients.forEach((uid) => { try { CHAT.pushToUser(uid, evt); } catch (_) {} });
R.scheduledMeetings.markReminded(s.id);
}
} catch (_) { /* never let the timer die */ }
}
let timer = null;
function start() { if (!timer) timer = setInterval(tick, 60 * 1000); }
start();
module.exports = { start, tick };
+176 -2
View File
@@ -25,7 +25,7 @@ const users = {
byEmail: (email) => db.prepare('SELECT * FROM users WHERE email=? COLLATE NOCASE').get(email), byEmail: (email) => db.prepare('SELECT * FROM users WHERE email=? COLLATE NOCASE').get(email),
emailExists: (email) => !!db.prepare('SELECT 1 FROM users WHERE email=? COLLATE NOCASE').get(email), emailExists: (email) => !!db.prepare('SELECT 1 FROM users WHERE email=? COLLATE NOCASE').get(email),
listByTenant: (tenantId) => listByTenant: (tenantId) =>
db.prepare('SELECT id,email,name,role,active,created_at FROM users WHERE team_id=?').all(tenantId), db.prepare('SELECT id,email,name,role,active,avatar_url,created_at FROM users WHERE team_id=?').all(tenantId),
inTenant: (id, tenantId) => inTenant: (id, tenantId) =>
db.prepare('SELECT * FROM users WHERE id=? AND team_id=?').get(id, tenantId), db.prepare('SELECT * FROM users WHERE id=? AND team_id=?').get(id, tenantId),
create: ({ tenantId, email, hash, salt, role, name, mfaSecret }) => { create: ({ tenantId, email, hash, salt, role, name, mfaSecret }) => {
@@ -40,6 +40,7 @@ const users = {
setRole: (id, role) => db.prepare('UPDATE users SET role=? WHERE id=?').run(role, id), setRole: (id, role) => db.prepare('UPDATE users SET role=? WHERE id=?').run(role, id),
setPassword: (id, hash, salt) => db.prepare('UPDATE users SET pw_hash=?, pw_salt=? WHERE id=?').run(hash, salt, id), setPassword: (id, hash, salt) => db.prepare('UPDATE users SET pw_hash=?, pw_salt=? WHERE id=?').run(hash, salt, id),
setActive: (id, active) => db.prepare('UPDATE users SET active=? WHERE id=?').run(active ? 1 : 0, id), setActive: (id, active) => db.prepare('UPDATE users SET active=? WHERE id=?').run(active ? 1 : 0, id),
setAvatar: (id, url) => db.prepare('UPDATE users SET avatar_url=? WHERE id=?').run(url || null, id),
remove: (id) => db.prepare('DELETE FROM users WHERE id=?').run(id), remove: (id) => db.prepare('DELETE FROM users WHERE id=?').run(id),
}; };
@@ -101,4 +102,177 @@ const sessionsLog = {
}, },
}; };
module.exports = { teams, users, authSessions, machines, audit, sessionsLog }; const refreshTokens = {
create: ({ userId, tokenHash, ttl }) =>
db.prepare('INSERT INTO refresh_tokens (token_hash,user_id,created_at,expires_at,revoked) VALUES (?,?,?,?,0)')
.run(tokenHash, userId, now(), now() + ttl),
byHash: (h) => db.prepare('SELECT * FROM refresh_tokens WHERE token_hash=?').get(h),
revoke: (h) => db.prepare('UPDATE refresh_tokens SET revoked=1 WHERE token_hash=?').run(h),
revokeByUser: (userId) => db.prepare('UPDATE refresh_tokens SET revoked=1 WHERE user_id=?').run(userId),
};
const apiKeys = {
create: ({ id, tenantId, name, keyHash, scopes, createdBy }) =>
db.prepare('INSERT INTO api_keys (id,team_id,name,key_hash,scopes,created_by,created_at,revoked) VALUES (?,?,?,?,?,?,?,0)')
.run(id, tenantId, name || null, keyHash, scopes || '', createdBy || null, now()),
byHash: (h) => db.prepare('SELECT * FROM api_keys WHERE key_hash=?').get(h),
listByTenant: (tenantId) =>
db.prepare('SELECT id,name,scopes,created_by,created_at,last_used_at,revoked FROM api_keys WHERE team_id=? ORDER BY created_at DESC').all(tenantId),
revoke: (id, tenantId) => db.prepare('UPDATE api_keys SET revoked=1 WHERE id=? AND team_id=?').run(id, tenantId),
touch: (id) => db.prepare('UPDATE api_keys SET last_used_at=? WHERE id=?').run(now(), id),
};
const webhooks = {
create: ({ id, tenantId, url, secret, events, createdBy }) =>
db.prepare('INSERT INTO webhooks (id,team_id,url,secret,events,active,created_by,created_at) VALUES (?,?,?,?,?,1,?,?)')
.run(id, tenantId, url, secret, events || '', createdBy || null, now()),
activeForTenant: (tenantId) => db.prepare('SELECT * FROM webhooks WHERE team_id=? AND active=1').all(tenantId),
listByTenant: (tenantId) =>
db.prepare('SELECT id,url,events,active,created_by,created_at,last_status,last_error,last_at FROM webhooks WHERE team_id=? ORDER BY created_at DESC').all(tenantId),
remove: (id, tenantId) => db.prepare('DELETE FROM webhooks WHERE id=? AND team_id=?').run(id, tenantId),
setStatus: (id, status, err) => db.prepare('UPDATE webhooks SET last_status=?, last_error=?, last_at=? WHERE id=?').run(status, err || null, now(), id),
};
const messages = {
send: ({ id, teamId, senderId, recipientId, body, replyTo, attachmentId, conversationId, mentions, msgType }) =>
db.prepare('INSERT INTO messages (id,team_id,sender_id,recipient_id,body,created_at,reply_to,attachment_id,conversation_id,mentions,msg_type) VALUES (?,?,?,?,?,?,?,?,?,?,?)')
.run(id, teamId, senderId, recipientId || '', body, now(), replyTo || null, attachmentId || null, conversationId || null, (mentions && mentions.length) ? JSON.stringify(mentions) : null, msgType || null),
byId: (id) => db.prepare('SELECT * FROM messages WHERE id=?').get(id),
byAttachment: (attachmentId) => db.prepare('SELECT * FROM messages WHERE attachment_id=? LIMIT 1').get(attachmentId),
setPoll: (messageId, pollId) => db.prepare('UPDATE messages SET poll_id=? WHERE id=?').run(pollId, messageId),
markDelivered: (id) => db.prepare('UPDATE messages SET delivered_at=? WHERE id=? AND delivered_at IS NULL').run(now(), id),
// Full 1:1 (DM) thread between two users (both directions), oldest first.
thread: (teamId, a, b, limit = 300) =>
db.prepare(`SELECT * FROM messages WHERE team_id=? AND conversation_id IS NULL
AND ((sender_id=? AND recipient_id=?) OR (sender_id=? AND recipient_id=?))
ORDER BY created_at ASC LIMIT ?`).all(teamId, a, b, b, a, limit),
markRead: (teamId, recipientId, senderId) =>
db.prepare('UPDATE messages SET read_at=? WHERE team_id=? AND conversation_id IS NULL AND recipient_id=? AND sender_id=? AND read_at IS NULL')
.run(now(), teamId, recipientId, senderId),
// Recent DM messages involving a user (newest first) — reduced into per-contact conversations.
recentFor: (teamId, userId, limit = 1000) =>
db.prepare('SELECT * FROM messages WHERE team_id=? AND conversation_id IS NULL AND (sender_id=? OR recipient_id=?) ORDER BY created_at DESC LIMIT ?')
.all(teamId, userId, userId, limit),
// Group conversation helpers.
threadByConversation: (conversationId, limit = 300) =>
db.prepare('SELECT * FROM messages WHERE conversation_id=? ORDER BY created_at ASC LIMIT ?').all(conversationId, limit),
lastInConversation: (conversationId) =>
db.prepare('SELECT * FROM messages WHERE conversation_id=? ORDER BY created_at DESC LIMIT 1').get(conversationId),
unreadInConversation: (conversationId, userId, since) =>
db.prepare('SELECT COUNT(*) AS c FROM messages WHERE conversation_id=? AND sender_id<>? AND created_at>?').get(conversationId, userId, since).c,
};
const reactions = {
// Toggle with ONE reaction per user per message: picking an emoji replaces any prior
// reaction by that user; picking the same one again removes it. Returns true if added.
toggle: (messageId, userId, emoji) => {
const had = db.prepare('SELECT 1 FROM message_reactions WHERE message_id=? AND user_id=? AND emoji=?').get(messageId, userId, emoji);
db.prepare('DELETE FROM message_reactions WHERE message_id=? AND user_id=?').run(messageId, userId);
if (had) return false;
db.prepare('INSERT INTO message_reactions (message_id,user_id,emoji,created_at) VALUES (?,?,?,?)').run(messageId, userId, emoji, now());
return true;
},
forMessage: (messageId) => db.prepare('SELECT user_id, emoji FROM message_reactions WHERE message_id=? ORDER BY created_at ASC').all(messageId),
// All reactions on the messages in a 1:1 thread.
forPair: (teamId, a, b) =>
db.prepare(`SELECT r.message_id, r.user_id, r.emoji FROM message_reactions r
JOIN messages m ON r.message_id = m.id
WHERE m.team_id=? AND m.conversation_id IS NULL AND ((m.sender_id=? AND m.recipient_id=?) OR (m.sender_id=? AND m.recipient_id=?))`).all(teamId, a, b, b, a),
// All reactions on the messages in a group conversation.
forConversation: (conversationId) =>
db.prepare(`SELECT r.message_id, r.user_id, r.emoji FROM message_reactions r
JOIN messages m ON r.message_id = m.id WHERE m.conversation_id=?`).all(conversationId),
};
const conversations = {
create: ({ id, teamId, name, createdBy }) =>
db.prepare('INSERT INTO conversations (id,team_id,type,name,created_by,created_at) VALUES (?,?,?,?,?,?)').run(id, teamId, 'group', name || null, createdBy || null, now()),
byId: (id) => db.prepare('SELECT * FROM conversations WHERE id=?').get(id),
addMember: (conversationId, userId, admin) =>
db.prepare('INSERT OR IGNORE INTO conversation_members (conversation_id,user_id,last_read_at,joined_at,admin) VALUES (?,?,?,?,?)').run(conversationId, userId, 0, now(), admin ? 1 : 0),
members: (conversationId) => db.prepare('SELECT user_id FROM conversation_members WHERE conversation_id=? ORDER BY joined_at ASC').all(conversationId).map((r) => r.user_id),
isMember: (conversationId, userId) => !!db.prepare('SELECT 1 FROM conversation_members WHERE conversation_id=? AND user_id=?').get(conversationId, userId),
// ---- Group admins (multiple allowed) ----
isAdmin: (conversationId, userId) => !!db.prepare('SELECT 1 FROM conversation_members WHERE conversation_id=? AND user_id=? AND admin=1').get(conversationId, userId),
admins: (conversationId) => db.prepare('SELECT user_id FROM conversation_members WHERE conversation_id=? AND admin=1').all(conversationId).map((r) => r.user_id),
setMemberAdmin: (conversationId, userId, v) => db.prepare('UPDATE conversation_members SET admin=? WHERE conversation_id=? AND user_id=?').run(v ? 1 : 0, conversationId, userId),
oldestMember: (conversationId) => { const r = db.prepare('SELECT user_id FROM conversation_members WHERE conversation_id=? ORDER BY joined_at ASC LIMIT 1').get(conversationId); return r ? r.user_id : null; },
listForUser: (teamId, userId) =>
db.prepare('SELECT c.* FROM conversations c JOIN conversation_members m ON m.conversation_id=c.id WHERE c.team_id=? AND m.user_id=?').all(teamId, userId),
lastReadAt: (conversationId, userId) => { const r = db.prepare('SELECT last_read_at FROM conversation_members WHERE conversation_id=? AND user_id=?').get(conversationId, userId); return r ? r.last_read_at : 0; },
memberReads: (conversationId) => db.prepare('SELECT user_id, last_read_at FROM conversation_members WHERE conversation_id=?').all(conversationId),
setAdminOnly: (id, v) => db.prepare('UPDATE conversations SET admin_only=? WHERE id=?').run(v ? 1 : 0, id),
markRead: (conversationId, userId) => db.prepare('UPDATE conversation_members SET last_read_at=? WHERE conversation_id=? AND user_id=?').run(now(), conversationId, userId),
rename: (id, name) => db.prepare('UPDATE conversations SET name=? WHERE id=?').run(name, id),
setAvatar: (id, attachmentId) => db.prepare('UPDATE conversations SET avatar_id=? WHERE id=?').run(attachmentId || null, id),
byAvatar: (attachmentId) => db.prepare('SELECT * FROM conversations WHERE avatar_id=? LIMIT 1').get(attachmentId),
removeMember: (conversationId, userId) => db.prepare('DELETE FROM conversation_members WHERE conversation_id=? AND user_id=?').run(conversationId, userId),
remove: (id) => { db.prepare('DELETE FROM conversation_members WHERE conversation_id=?').run(id); db.prepare('DELETE FROM conversations WHERE id=?').run(id); },
};
const attachments = {
create: ({ id, teamId, uploaderId, name, mime, size }) =>
db.prepare('INSERT INTO attachments (id,team_id,uploader_id,name,mime,size,created_at) VALUES (?,?,?,?,?,?,?)')
.run(id, teamId, uploaderId, name, mime || null, size || 0, now()),
byId: (id) => db.prepare('SELECT * FROM attachments WHERE id=?').get(id),
};
const scheduledMeetings = {
create: ({ id, teamId, groupId, roomCode, title, description, scheduledAt, createdBy, participants, durationMins, recurrence }) =>
db.prepare('INSERT INTO scheduled_meetings (id,team_id,group_id,room_code,title,description,scheduled_at,created_by,created_at,participants,duration_mins,recurrence) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)')
.run(id, teamId, groupId || null, roomCode, title, description || null, scheduledAt, createdBy, now(), (participants && participants.length) ? JSON.stringify(participants) : null, durationMins || null, (recurrence && recurrence.length) ? JSON.stringify(recurrence) : null),
byId: (id) => db.prepare('SELECT * FROM scheduled_meetings WHERE id=?').get(id),
byCode: (code) => db.prepare('SELECT * FROM scheduled_meetings WHERE room_code=? ORDER BY created_at DESC LIMIT 1').get(code),
// Meetings a user can see: created by them, a member of the group, or an invited participant.
listForUser: (teamId, userId) =>
db.prepare(`SELECT s.* FROM scheduled_meetings s
WHERE s.team_id=? AND (
s.created_by=? OR
(s.group_id IS NOT NULL AND EXISTS (SELECT 1 FROM conversation_members cm WHERE cm.conversation_id=s.group_id AND cm.user_id=?)) OR
(s.participants IS NOT NULL AND s.participants LIKE '%'||?||'%'))
ORDER BY s.scheduled_at ASC`).all(teamId, userId, userId, '"' + userId + '"'),
dueForReminder: (fromTs, toTs) => db.prepare('SELECT * FROM scheduled_meetings WHERE reminded=0 AND ended_at IS NULL AND scheduled_at>=? AND scheduled_at<=?').all(fromTs, toTs),
markReminded: (id) => db.prepare('UPDATE scheduled_meetings SET reminded=1 WHERE id=?').run(id),
end: (id, teamId) => db.prepare('UPDATE scheduled_meetings SET ended_at=? WHERE id=? AND team_id=?').run(now(), id, teamId),
cancel: (id, teamId) => db.prepare('UPDATE scheduled_meetings SET cancelled=1, ended_at=? WHERE id=? AND team_id=?').run(now(), id, teamId),
reschedule: (id, teamId, ts) => db.prepare('UPDATE scheduled_meetings SET scheduled_at=?, reminded=0 WHERE id=? AND team_id=?').run(ts, id, teamId), // recurrence: roll to next occurrence
update: (id, teamId, { title, description, scheduledAt, durationMins, participants, recurrence }) =>
db.prepare('UPDATE scheduled_meetings SET title=?, description=?, scheduled_at=?, duration_mins=?, participants=?, recurrence=?, reminded=0 WHERE id=? AND team_id=?')
.run(title, description || null, scheduledAt, durationMins || null, (participants && participants.length) ? JSON.stringify(participants) : null, (recurrence && recurrence.length) ? JSON.stringify(recurrence) : null, id, teamId),
remove: (id, teamId) => db.prepare('DELETE FROM scheduled_meetings WHERE id=? AND team_id=?').run(id, teamId),
};
const recordings = {
create: ({ id, teamId, room, groupId, meetingId, title, kind, file, mime, size, durationMs, createdBy, createdByName }) =>
db.prepare('INSERT INTO recordings (id,team_id,room,group_id,meeting_id,title,kind,file,mime,size,duration_ms,created_by,created_by_name,created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)')
.run(id, teamId, room || null, groupId || null, meetingId || null, title || null, kind, file || null, mime || null, size || null, durationMs || null, createdBy || null, createdByName || null, now()),
byId: (id) => db.prepare('SELECT * FROM recordings WHERE id=?').get(id),
forTeam: (teamId) => db.prepare('SELECT * FROM recordings WHERE team_id=? ORDER BY created_at DESC').all(teamId),
};
const polls = {
create: ({ id, teamId, conversationId, messageId, question, options, multi, createdBy }) =>
db.prepare('INSERT INTO polls (id,team_id,conversation_id,message_id,question,options,multi,closed,created_by,created_at) VALUES (?,?,?,?,?,?,?,0,?,?)')
.run(id, teamId, conversationId, messageId || null, question, JSON.stringify(options), multi ? 1 : 0, createdBy, now()),
byId: (id) => db.prepare('SELECT * FROM polls WHERE id=?').get(id),
close: (id) => db.prepare('UPDATE polls SET closed=1 WHERE id=?').run(id),
};
const pollVotes = {
forPoll: (pollId) => db.prepare('SELECT user_id, option_idx FROM poll_votes WHERE poll_id=?').all(pollId),
hasVoted: (pollId, userId, idx) => !!db.prepare('SELECT 1 FROM poll_votes WHERE poll_id=? AND user_id=? AND option_idx=?').get(pollId, userId, idx),
add: (pollId, userId, idx) => db.prepare('INSERT OR IGNORE INTO poll_votes (poll_id,user_id,option_idx,created_at) VALUES (?,?,?,?)').run(pollId, userId, idx, now()),
remove: (pollId, userId, idx) => db.prepare('DELETE FROM poll_votes WHERE poll_id=? AND user_id=? AND option_idx=?').run(pollId, userId, idx),
clearUser: (pollId, userId) => db.prepare('DELETE FROM poll_votes WHERE poll_id=? AND user_id=?').run(pollId, userId),
};
const pushSubs = {
// Upsert by endpoint: re-subscribing the same browser updates its keys/owner.
add: ({ id, userId, endpoint, p256dh, auth }) =>
db.prepare('INSERT INTO push_subscriptions (id,user_id,endpoint,p256dh,auth,created_at) VALUES (?,?,?,?,?,?) ON CONFLICT(endpoint) DO UPDATE SET user_id=excluded.user_id, p256dh=excluded.p256dh, auth=excluded.auth')
.run(id, userId, endpoint, p256dh, auth, now()),
byUser: (userId) => db.prepare('SELECT * FROM push_subscriptions WHERE user_id=?').all(userId),
removeByEndpoint: (endpoint) => db.prepare('DELETE FROM push_subscriptions WHERE endpoint=?').run(endpoint),
};
module.exports = { teams, users, authSessions, machines, audit, sessionsLog, refreshTokens, apiKeys, webhooks, messages, reactions, attachments, conversations, scheduledMeetings, recordings, polls, pollVotes, pushSubs };
+930 -28
View File
File diff suppressed because it is too large Load Diff
+17 -1
View File
@@ -1,5 +1,6 @@
// Session/auth helpers: resolve the current user from the cookie, write audit rows. // Session/auth helpers: resolve the current user from the cookie, write audit rows.
const R = require('./repos'); const R = require('./repos');
const A = require('./auth');
const { parseCookies, now } = require('./lib'); const { parseCookies, now } = require('./lib');
function audit(entry) { function audit(entry) {
@@ -35,4 +36,19 @@ function currentUser(req, { requireMfa = true } = {}) {
return { ...u, _session: s }; return { ...u, _session: s };
} }
module.exports = { audit, currentUser, tokenFromReq }; // Resolve a third-party API key from `X-API-Key` or `Authorization: Bearer bzc_...`.
// Returns { id, teamId, scopes:[], name } or null. Keys are prefixed `bzc_` and stored hashed.
function apiKeyFromReq(req) {
let raw = req.headers && req.headers['x-api-key'];
if (!raw) {
const h = req.headers && (req.headers.authorization || req.headers.Authorization);
if (h && /^Bearer\s+bzc_/i.test(h)) raw = h.replace(/^Bearer\s+/i, '').trim();
}
if (!raw || !/^bzc_/.test(raw)) return null;
const row = R.apiKeys.byHash(A.hashToken(raw));
if (!row || row.revoked) return null;
return { id: row.id, teamId: row.team_id, scopes: String(row.scopes || '').split(',').map((s) => s.trim()).filter(Boolean), name: row.name };
}
function keyHasScope(key, scope) { return !!key && (key.scopes.includes(scope) || key.scopes.includes('*')); }
module.exports = { audit, currentUser, tokenFromReq, apiKeyFromReq, keyHasScope };
+165 -17
View File
@@ -5,7 +5,9 @@
const R = require('./repos'); const R = require('./repos');
const A = require('./auth'); const A = require('./auth');
const { currentUser, audit } = require('./session'); const { currentUser, audit } = require('./session');
const { onlineAgents, liveSessions, pendingShares } = require('./presence'); const { onlineAgents, liveSessions, pendingShares, meetingRooms, roomToDmCall, roomHost, transcriptBuffers, transcriptSubs } = require('./presence');
const W = require('./webhooks');
const CHAT = require('./chat');
function onConnection(ws, req) { function onConnection(ws, req) {
const hb = setInterval(() => { const hb = setInterval(() => {
@@ -20,6 +22,132 @@ function onConnection(ws, req) {
function handle(ws, m, req) { function handle(ws, m, req) {
switch (m.type) { switch (m.type) {
// --- Logged-in user registers this socket for live chat delivery ---
case 'chat-hello': {
const u = currentUser(req); // identity from the cookie/Bearer on the WS upgrade
if (!u) return ws.send(JSON.stringify({ type: 'error', message: 'unauthorized' }));
ws._chatUserId = u.id; ws._chatTeamId = u.team_id;
CHAT.register(u.id, ws);
ws.send(JSON.stringify({ type: 'chat-ready' }));
break;
}
// Recipient's client acknowledges a DM was delivered → mark it + tell the sender.
case 'chat-delivered': {
if (!ws._chatUserId || !m.id) break;
const msg = R.messages.byId(m.id);
if (!msg || msg.conversation_id || msg.team_id !== ws._chatTeamId) break; // DMs only
if (msg.recipient_id !== ws._chatUserId) break; // only the recipient can ack
if (!msg.delivered_at) { R.messages.markDelivered(m.id); try { CHAT.pushToUser(msg.sender_id, { type: 'chat-delivered', id: m.id }); } catch (_) {} }
break;
}
// --- Meetings (mesh): create a room, join by code, relay SDP/ICE peer-to-peer ---
case 'meeting-create': {
let code; do { code = A.numericCode(6); } while (meetingRooms.has(code));
meetingRooms.set(code, new Map());
const cu = currentUser(req); if (cu) roomHost.set(code, cu.id); // ad-hoc meeting: creator = host
ws.send(JSON.stringify({ type: 'meeting-created', room: code }));
break;
}
case 'meeting-join': {
const room = String(m.room || '').trim();
let peers = meetingRooms.get(room);
// A scheduled meeting's room is created lazily on first join (its code lives in the DB).
if (!peers) {
const sched = R.scheduledMeetings.byCode(room);
if (sched && !sched.ended_at) { peers = new Map(); meetingRooms.set(room, peers); }
}
if (!peers) return ws.send(JSON.stringify({ type: 'error', message: 'Meeting not found' }));
const peerId = A.token(6);
const name = String(m.name || 'Guest').slice(0, 60);
ws.kind = 'meeting'; ws._meetingRoom = room; ws._peerId = peerId; ws._peerName = name;
// Host = the meeting's creator. roomHost is set on call/meeting creation; scheduled meetings fall back to created_by.
let hostUserId = roomHost.get(room);
if (hostUserId === undefined) { try { const s = R.scheduledMeetings.byCode(room); if (s) { hostUserId = s.created_by; roomHost.set(room, hostUserId); } } catch (_) {} }
const ju = currentUser(req);
ws._meetingUserId = ju ? ju.id : null; // for per-user transcript ownership
const isHost = !!(ju && hostUserId && ju.id === hostUserId);
// Tell the newcomer who's already here (they initiate offers to existing peers)…
ws.send(JSON.stringify({ type: 'meeting-joined', room, peerId, isHost, peers: [...peers.entries()].map(([id, p]) => ({ peerId: id, name: p.name })) }));
// …and tell existing peers a newcomer arrived.
for (const [, p] of peers) { if (p.ws.readyState === 1) p.ws.send(JSON.stringify({ type: 'meeting-peer-joined', peerId, name })); }
peers.set(peerId, { ws, name });
const tsubs = transcriptSubs.get(room); if (tsubs && tsubs.size > 0) ws.send(JSON.stringify({ type: 'meeting-transcribe-state', active: true })); // catch up: already transcribing
break;
}
case 'meeting-signal': {
const peers = ws._meetingRoom && meetingRooms.get(ws._meetingRoom);
if (!peers) return;
const target = peers.get(m.to);
if (target && target.ws.readyState === 1) target.ws.send(JSON.stringify({ type: 'meeting-signal', from: ws._peerId, data: m.data }));
break;
}
// Relay a peer's mic/cam state to everyone else in the room (for the tile mute icon).
case 'meeting-state': {
const peers = ws._meetingRoom && meetingRooms.get(ws._meetingRoom);
if (!peers) return;
for (const [id, p] of peers) { if (id !== ws._peerId && p.ws.readyState === 1) p.ws.send(JSON.stringify({ type: 'meeting-peer-state', peerId: ws._peerId, muted: !!m.muted, camOff: !!m.camOff })); }
break;
}
// Relay a peer's screen-share on/off to everyone else (for the tile badge + single-share rule).
case 'meeting-screen': {
const peers = ws._meetingRoom && meetingRooms.get(ws._meetingRoom);
if (!peers) return;
for (const [id, p] of peers) { if (id !== ws._peerId && p.ws.readyState === 1) p.ws.send(JSON.stringify({ type: 'meeting-peer-screen', from: ws._peerId, on: !!m.on })); }
break;
}
// Host: set whether multiple people may share their screen at once.
case 'meeting-sharemode': {
const peers = ws._meetingRoom && meetingRooms.get(ws._meetingRoom);
if (!peers) return;
for (const [id, p] of peers) { if (id !== ws._peerId && p.ws.readyState === 1) p.ws.send(JSON.stringify({ type: 'meeting-sharemode', multi: !!m.multi })); }
break;
}
// Host starts/stops recording → tell everyone so they see (and hear) the "being recorded" notice.
case 'meeting-recording': {
const peers = ws._meetingRoom && meetingRooms.get(ws._meetingRoom);
if (!peers) return;
for (const [id, p] of peers) { if (id !== ws._peerId && p.ws.readyState === 1) p.ws.send(JSON.stringify({ type: 'meeting-recording', on: !!m.on, by: ws._peerName || 'The host' })); }
break;
}
// A participant subscribes/unsubscribes to a transcript copy. While ≥1 subscriber, EVERY client
// transcribes its own mic (full conversation); each subscriber gets their own private copy.
// Unsubscribing only drops YOUR copy — it never stops anyone else's.
case 'meeting-transcribe': {
const room = ws._meetingRoom; const peers = room && meetingRooms.get(room);
if (!peers) return; const uid = ws._meetingUserId; if (!uid) return;
let subs = transcriptSubs.get(room); if (!subs) { subs = new Set(); transcriptSubs.set(room, subs); }
if (m.on) subs.add(uid); else { try { require('./calls').finalizeTranscript(room, uid); } catch (_) {} } // finalize writes + removes the sub
const active = subs.size > 0;
for (const [, p] of peers) { if (p.ws.readyState === 1) p.ws.send(JSON.stringify({ type: 'meeting-transcribe-state', active })); }
break;
}
// A participant's recognized speech segment → appended to the room's shared transcript buffer.
case 'meeting-transcript': {
const room = ws._meetingRoom; if (!room || !meetingRooms.get(room)) return;
const text = String(m.text || '').slice(0, 1000).trim(); if (!text) return;
let buf = transcriptBuffers.get(room); if (!buf) { buf = []; transcriptBuffers.set(room, buf); }
buf.push({ t: Date.now(), speaker: ws._peerName || 'Guest', text });
if (buf.length > 8000) buf.shift();
break;
}
// Host: mute everyone else in the room.
case 'meeting-muteall': {
const peers = ws._meetingRoom && meetingRooms.get(ws._meetingRoom);
if (!peers) return;
for (const [id, p] of peers) { if (id !== ws._peerId && p.ws.readyState === 1) p.ws.send(JSON.stringify({ type: 'meeting-muteall', by: ws._peerId })); }
break;
}
// Host: transfer host to another peer (broadcast the new host to the room).
case 'meeting-host': {
const peers = ws._meetingRoom && meetingRooms.get(ws._meetingRoom);
if (!peers || !m.to) return;
for (const [, p] of peers) { if (p.ws.readyState === 1) p.ws.send(JSON.stringify({ type: 'meeting-host', hostPeerId: m.to })); }
break;
}
case 'meeting-leave': {
leaveMeeting(ws);
break;
}
// --- Agent comes online --- // --- Agent comes online ---
case 'agent-hello': { case 'agent-hello': {
const machine = R.machines.byEnrollToken(m.enrollToken); const machine = R.machines.byEnrollToken(m.enrollToken);
@@ -61,6 +189,7 @@ function handle(ws, m, req) {
try { try {
R.sessionsLog.create({ id: m.sessionId, tenantId: sess.machine.team_id, agentEmail: sess.user.email, agentName: sess.agentName || sess.user.email, ticket: sess.ticket || null }); R.sessionsLog.create({ id: m.sessionId, tenantId: sess.machine.team_id, agentEmail: sess.user.email, agentName: sess.agentName || sess.user.email, ticket: sess.ticket || null });
} catch (e) { /* duplicate consent */ } } catch (e) { /* duplicate consent */ }
try { W.emit('session.started', sess.machine.team_id, { sessionId: m.sessionId, agent_email: sess.user.email, agent_name: sess.agentName || sess.user.email, ticket: sess.ticket || null, started_at: Date.now() }); } catch (_) {}
sess.viewerWs.send(JSON.stringify({ type: 'session-ready', sessionId: m.sessionId })); sess.viewerWs.send(JSON.stringify({ type: 'session-ready', sessionId: m.sessionId }));
sess.agentWs.send(JSON.stringify({ type: 'start-stream', sessionId: m.sessionId })); sess.agentWs.send(JSON.stringify({ type: 'start-stream', sessionId: m.sessionId }));
} else { } else {
@@ -133,26 +262,16 @@ function handle(ws, m, req) {
} }
} }
function notifyBizGaze(sessionId) {
const url = process.env.BIZGAZE_WEBHOOK_URL;
if (!url) return;
try {
const row = R.sessionsLog.byId(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) { function endSession(sessionId, reason) {
const sess = liveSessions.get(sessionId); const sess = liveSessions.get(sessionId);
if (!sess) return; if (!sess) return;
try { R.sessionsLog.end(sessionId); } catch (e) {} try { R.sessionsLog.end(sessionId); } catch (e) {}
notifyBizGaze(sessionId); try {
const row = R.sessionsLog.byId(sessionId);
if (row) W.emit('session.ended', sess.machine.team_id, { 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 });
} catch (e) {}
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') }); 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) => { [sess.agentWs, sess.viewerWs].forEach((p) => {
if (p && p.readyState === 1) p.send(JSON.stringify({ type: 'session-ended', sessionId, reason: reason || null })); if (p && p.readyState === 1) p.send(JSON.stringify({ type: 'session-ended', sessionId, reason: reason || null }));
@@ -160,7 +279,36 @@ function endSession(sessionId, reason) {
liveSessions.delete(sessionId); liveSessions.delete(sessionId);
} }
function leaveMeeting(ws) {
const room = ws._meetingRoom;
if (!room) return;
const peers = meetingRooms.get(room);
ws._meetingRoom = null;
const pid = ws._peerId;
if (!peers) return;
try { require('./calls').finalizeTranscript(room, ws._meetingUserId); } catch (_) {} // save THIS user's transcript
peers.delete(pid);
// 1:1 call: when either party leaves, end it for everyone (a DM call has no "remaining" call).
if (roomToDmCall.has(room)) {
for (const [, p] of peers) { if (p.ws.readyState === 1) { try { p.ws.send(JSON.stringify({ type: 'meeting-ended' })); } catch (_) {} p._meetingRoom = null; } }
meetingRooms.delete(room);
try { require('./calls').finalizeTranscript(room); } catch (_) {} // any remaining buffers (safety)
roomHost.delete(room);
try { require('./calls').endCallByRoom(room); } catch (_) {}
return;
}
for (const [, p] of peers) { if (p.ws.readyState === 1) p.ws.send(JSON.stringify({ type: 'meeting-peer-left', peerId: pid })); }
if (peers.size === 0) {
meetingRooms.delete(room);
try { require('./calls').finalizeTranscript(room); } catch (_) {} // before endCallByRoom clears the maps
roomHost.delete(room);
try { require('./calls').endCallByRoom(room); } catch (_) {}
}
}
function cleanup(ws) { function cleanup(ws) {
CHAT.unregister(ws);
leaveMeeting(ws);
if (ws.kind === 'agent' && ws.machineId) onlineAgents.delete(ws.machineId); if (ws.kind === 'agent' && ws.machineId) onlineAgents.delete(ws.machineId);
if (ws.kind === 'sharer' && ws.shareCode) pendingShares.delete(ws.shareCode); if (ws.kind === 'sharer' && ws.shareCode) pendingShares.delete(ws.shareCode);
if (ws.sessionId) { if (ws.sessionId) {
+81 -7
View File
@@ -5,9 +5,9 @@ const path = require('path');
const R = require('./repos'); const R = require('./repos');
const { json } = require('./lib'); const { json } = require('./lib');
const { currentUser } = require('./session'); const { currentUser } = require('./session');
const { PUBLIC_DIR, REC_DIR, TRANS_DIR } = require('./config'); const { PUBLIC_DIR, REC_DIR, TRANS_DIR, UPLOADS_DIR } = require('./config');
const MIME = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.svg': 'image/svg+xml', '.ico': 'image/x-icon' }; const MIME = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.svg': 'image/svg+xml', '.ico': 'image/x-icon', '.json': 'application/json', '.webmanifest': 'application/manifest+json' };
function serveStatic(req, res) { function serveStatic(req, res) {
let p = req.url.split('?')[0]; let p = req.url.split('?')[0];
@@ -19,11 +19,28 @@ function serveStatic(req, res) {
if (p === '/connect') p = '/connect.html'; if (p === '/connect') p = '/connect.html';
const fp = path.join(PUBLIC_DIR, path.normalize(p)); const fp = path.join(PUBLIC_DIR, path.normalize(p));
if (!fp.startsWith(PUBLIC_DIR)) return json(res, 403, { error: 'forbidden' }); if (!fp.startsWith(PUBLIC_DIR)) return json(res, 403, { error: 'forbidden' });
fs.readFile(fp, (err, data) => { // ETag + revalidation: the browser keeps the file cached and we answer repeat loads with a
if (err) return json(res, 404, { error: 'not found' }); // tiny 304 (no re-download) when nothing changed — fast reloads, but always fresh on edits.
const ct = MIME[path.extname(fp)] || 'application/octet-stream'; fs.stat(fp, (serr, st) => {
res.writeHead(200, { 'Content-Type': ct, 'Cache-Control': 'no-cache' }); if (serr || !st.isFile()) return json(res, 404, { error: 'not found' });
res.end(data); const ext = path.extname(fp);
const ct = MIME[ext] || 'application/octet-stream';
// HTML entry pages are NEVER cached (no-store) so a deploy reaches every browser on the
// next load — no hard-refresh needed. Versioned assets (e.g. icons.js?v=) still revalidate
// cheaply via ETag/304.
const isHtml = ext === '.html';
const etag = '"' + st.size.toString(16) + '-' + Math.round(st.mtimeMs).toString(16) + '"';
if (!isHtml && req.headers['if-none-match'] === etag) {
res.writeHead(304, { ETag: etag, 'Cache-Control': 'no-cache' });
return res.end();
}
fs.readFile(fp, (err, data) => {
if (err) return json(res, 404, { error: 'not found' });
const headers = { 'Content-Type': ct, 'Content-Length': st.size, 'Cache-Control': isHtml ? 'no-store, must-revalidate' : 'no-cache' };
if (!isHtml) headers.ETag = etag;
res.writeHead(200, headers);
res.end(data);
});
}); });
} }
@@ -66,6 +83,63 @@ function handleGet(req, res) {
rs.pipe(res); rs.pipe(res);
}); });
} }
// Meeting recordings & transcripts (/mrec/<id>). Visible to the creator, group members, or those
// who can see the scheduled meeting it belongs to.
if (pathOnly.startsWith('/mrec/')) {
const u = currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' });
const id = path.basename(decodeURIComponent(pathOnly));
const r = R.recordings.byId(id);
if (!r || r.team_id !== u.team_id || !r.file) return json(res, 404, { error: 'not found' });
let allowed = r.created_by === u.id;
if (r.kind === 'transcript') allowed = r.created_by === u.id; // transcripts are private to their owner
else {
if (!allowed && r.group_id) allowed = R.conversations.isMember(r.group_id, u.id);
if (!allowed && r.meeting_id) { const s = R.scheduledMeetings.byId(r.meeting_id); if (s) allowed = s.created_by === u.id || (s.participants && s.participants.includes('"' + u.id + '"')); }
}
if (!allowed) return json(res, 403, { error: 'forbidden' });
const isVideo = r.kind === 'video';
const dir = isVideo ? REC_DIR : TRANS_DIR;
const fp = path.join(dir, r.file);
if (!fp.startsWith(dir)) return json(res, 403, { error: 'forbidden' });
const ext = isVideo ? 'webm' : 'txt';
const fname = String(r.title || 'meeting').replace(/[^a-z0-9 _-]/gi, '').trim().slice(0, 40) || 'meeting';
return fs.stat(fp, (err, st) => {
if (err) return json(res, 404, { error: 'not found' });
res.writeHead(200, { 'Content-Type': r.mime || (isVideo ? 'video/webm' : 'text/plain; charset=utf-8'), 'Content-Length': st.size, 'Content-Disposition': 'attachment; filename="' + fname + '-' + (isVideo ? 'recording' : 'transcript') + '.' + ext + '"', 'Cache-Control': 'no-store', 'Accept-Ranges': 'bytes' });
const rs = fs.createReadStream(fp);
rs.on('error', () => { try { res.destroy(); } catch (e) {} });
rs.pipe(res);
});
}
if (pathOnly.startsWith('/files/')) {
const u = currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' });
const id = path.basename(decodeURIComponent(pathOnly));
const a = R.attachments.byId(id);
if (!a || a.team_id !== u.team_id) return json(res, 404, { error: 'not found' });
// Authorize: the uploader, a participant of the message carrying this attachment,
// or a member of the group that uses this attachment as its image.
const msg = R.messages.byAttachment(id);
const avatarGroup = R.conversations.byAvatar(id);
const allowed = a.uploader_id === u.id
|| (avatarGroup && R.conversations.isMember(avatarGroup.id, u.id))
|| (msg && (
msg.conversation_id ? R.conversations.isMember(msg.conversation_id, u.id)
: (msg.sender_id === u.id || msg.recipient_id === u.id)));
if (!allowed) return json(res, 403, { error: 'forbidden' });
const fp = path.join(UPLOADS_DIR, id);
if (!fp.startsWith(UPLOADS_DIR)) return json(res, 403, { error: 'forbidden' });
const isImage = /^image\//.test(a.mime || '');
const safeName = String(a.name || 'file').replace(/[\r\n"]/g, '');
return fs.stat(fp, (err, st) => {
if (err) return json(res, 404, { error: 'not found' });
res.writeHead(200, { 'Content-Type': a.mime || 'application/octet-stream', 'Content-Length': st.size, 'Content-Disposition': (isImage ? 'inline' : 'attachment') + '; filename="' + safeName + '"', 'Cache-Control': 'private, max-age=86400' });
const rs = fs.createReadStream(fp);
rs.on('error', () => { try { res.destroy(); } catch (e) {} });
rs.pipe(res);
});
}
return serveStatic(req, res); return serveStatic(req, res);
} }
+270
View File
@@ -18,6 +18,8 @@ process.env.HTTPS_PORT = 8444; // avoid clashing with a running dev server on 84
const { server } = require('../server'); const { server } = require('../server');
const A = require('../auth'); const A = require('../auth');
const WebSocket = require('ws'); const WebSocket = require('ws');
const http = require('http');
const crypto = require('crypto');
const BASE = `http://localhost:${PORT}`; const BASE = `http://localhost:${PORT}`;
let passed = 0, failed = 0; let passed = 0, failed = 0;
@@ -64,6 +66,12 @@ function nextMsg(ws, type, timeout = 3000) {
await wait(300); // let server bind await wait(300); // let server bind
console.log('E2E backend tests:'); console.log('E2E backend tests:');
// Local receiver to capture outbound webhook deliveries.
const webhookHits = [];
const hookSrv = http.createServer((rq, rs) => { let b = ''; rq.on('data', (c) => (b += c)); rq.on('end', () => { webhookHits.push({ sig: rq.headers['x-bizgaze-signature'], body: b }); rs.writeHead(200); rs.end('ok'); }); });
await new Promise((r) => hookSrv.listen(8077, r));
const HOOK_URL = 'http://localhost:8077/hook';
// 1. Register (first user becomes admin) // 1. Register (first user becomes admin)
const email = 'tech@example.com'; const email = 'tech@example.com';
const reg = await call('/api/register', { email, password: 'supersecret', teamName: 'Acme IT' }); const reg = await call('/api/register', { email, password: 'supersecret', teamName: 'Acme IT' });
@@ -78,6 +86,258 @@ function nextMsg(ws, type, timeout = 3000) {
const me = await get('/api/me', cookie); const me = await get('/api/me', cookie);
check('me works after login, role=admin', me.status === 200 && me.data.role === 'admin'); check('me works after login, role=admin', me.status === 200 && me.data.role === 'admin');
// 3b. Native client path: access+refresh tokens, refresh exchange (rotated), Bearer auth
check('login returns access + refresh tokens', !!login.data.token && !!login.data.refreshToken);
const refreshed = await call('/api/v1/auth/refresh', { refreshToken: login.data.refreshToken });
check('refresh issues a new access token', refreshed.status === 200 && !!refreshed.data.token && !!refreshed.data.refreshToken);
const meBearer = await fetch(BASE + '/api/v1/me', { headers: { Authorization: 'Bearer ' + refreshed.data.token } });
check('new access token authorizes /api/v1/me (Bearer)', meBearer.status === 200);
const reuse = await call('/api/v1/auth/refresh', { refreshToken: login.data.refreshToken });
check('rotated (old) refresh token is rejected', reuse.status === 401);
// 3c. API keys (machine-to-machine integration), scoped + revocable
const mkKey = await call('/api/v1/keys', { name: 'ci', scopes: ['report:read'] }, cookie);
check('admin creates API key (bzc_ prefix)', mkKey.status === 200 && /^bzc_/.test(mkKey.data.key || ''));
const apiKey = mkKey.data.key;
const repKey = await fetch(BASE + '/api/v1/report', { headers: { 'X-API-Key': apiKey } });
check('API key with report:read reads /api/v1/report', repKey.status === 200);
const repNone = await fetch(BASE + '/api/v1/report');
check('no credential -> /api/v1/report 401', repNone.status === 401);
const audKey = await fetch(BASE + '/api/v1/audit', { headers: { 'X-API-Key': apiKey } });
check('report-only key cannot read audit (scope enforced)', audKey.status === 401);
const revKey = await call('/api/v1/keys/revoke', { id: mkKey.data.id }, cookie);
check('admin revokes API key', revKey.status === 200);
const repRevoked = await fetch(BASE + '/api/v1/report', { headers: { 'X-API-Key': apiKey } });
check('revoked API key -> 401', repRevoked.status === 401);
// 3d. Register a webhook (delivery is asserted after the session flow below)
const mkHook = await call('/api/v1/webhooks', { url: HOOK_URL, events: ['*'] }, cookie);
check('admin registers a webhook', mkHook.status === 200 && !!mkHook.data.secret);
const hookSecret = mkHook.data.secret;
// 3e. Chat (persistent 1:1 messaging) — two users in the same team
const adminId = me.data.id;
await call('/api/v1/users', { email: 'bob@example.com', password: 'supersecret', name: 'Bob' }, cookie);
const bobLogin = await call('/api/v1/login', { email: 'bob@example.com', password: 'supersecret' });
const bobCookie = bobLogin.cookie;
const contacts = await get('/api/v1/messages/contacts', cookie);
check('contacts list includes the other user', contacts.status === 200 && contacts.data.some((c) => c.email === 'bob@example.com'));
const bobId = (contacts.data.find((c) => c.email === 'bob@example.com') || {}).id;
const bobWs = new WebSocket(`ws://localhost:${PORT}/ws`, { headers: { Cookie: bobCookie } });
bobWs.q = []; bobWs.on('message', (d) => bobWs.q.push(JSON.parse(d)));
await new Promise((r) => bobWs.on('open', r));
bobWs.send(JSON.stringify({ type: 'chat-hello' }));
await nextMsg(bobWs, 'chat-ready');
check('chat-hello -> chat-ready', true);
const sent = await call('/api/v1/messages', { to: bobId, body: 'hello bob' }, cookie);
check('message sent', sent.status === 200 && !!sent.data.id);
const pushed = await nextMsg(bobWs, 'chat-message');
check('recipient receives the message live over WS', pushed.message && pushed.message.body === 'hello bob');
const bobConvos = await get('/api/v1/messages/conversations', bobCookie);
check('recipient conversation shows unread', bobConvos.data.some((c) => c.contactId === adminId && c.unread >= 1));
const bobThread = await get('/api/v1/messages/thread?with=' + adminId, bobCookie);
check('thread returns the message', bobThread.status === 200 && bobThread.data.some((m) => m.body === 'hello bob'));
const bobConvos2 = await get('/api/v1/messages/conversations', bobCookie);
check('reading the thread clears unread', !bobConvos2.data.some((c) => c.contactId === adminId && c.unread >= 1));
// read receipt: after bob read the thread, admin's sent message shows read_at
const adminTh = await get('/api/v1/messages/thread?with=' + bobId, cookie);
const seenMsg = adminTh.data.find((x) => x.body === 'hello bob');
check('read receipt: sent message marked read after recipient reads', !!seenMsg && !!seenMsg.read_at);
// reply / quote
const reply = await call('/api/v1/messages', { to: bobId, body: 'replying to you', replyTo: sent.data.id }, cookie);
check('reply accepts replyTo', reply.status === 200 && reply.data.reply_to === sent.data.id);
const adminThread = await get('/api/v1/messages/thread?with=' + bobId, cookie);
check('thread carries the quoted preview', adminThread.data.some((x) => x.reply && x.reply.body === 'hello bob'));
// reactions
const react1 = await call('/api/v1/messages/react', { messageId: sent.data.id, emoji: '👍' }, cookie);
check('reaction toggles on', react1.status === 200 && react1.data.added === true);
const rpush = await nextMsg(bobWs, 'chat-reaction');
check('reaction pushed live (full set, with who)', rpush.messageId === sent.data.id && Array.isArray(rpush.reactions) && rpush.reactions.some((r) => r.emoji === '👍' && r.count === 1 && Array.isArray(r.who) && r.who.length === 1));
const th2 = await get('/api/v1/messages/thread?with=' + bobId, cookie);
const rmsg = th2.data.find((x) => x.id === sent.data.id);
check('thread aggregates the reaction', !!rmsg && rmsg.reactions.some((r) => r.emoji === '👍' && r.count === 1 && r.mine === true && r.who.length === 1));
// one reaction per user: a different emoji REPLACES the previous one (no stacking)
const react1b = await call('/api/v1/messages/react', { messageId: sent.data.id, emoji: '❤️' }, cookie);
check('switching emoji replaces (one reaction per user)', react1b.data.added === true && react1b.data.reactions.length === 1 && react1b.data.reactions[0].emoji === '❤️');
const react2 = await call('/api/v1/messages/react', { messageId: sent.data.id, emoji: '❤️' }, cookie);
check('reaction toggles off', react2.data.added === false && react2.data.reactions.length === 0);
// file sharing
const up = await fetch(BASE + '/api/v1/messages/upload', { method: 'POST', headers: { Cookie: cookie, 'Content-Type': 'text/plain', 'X-Filename': encodeURIComponent('note.txt') }, body: 'hello file' });
const upd = await up.json();
check('file upload returns an id', up.status === 200 && !!upd.id);
const fmsg = await call('/api/v1/messages', { to: bobId, attachmentId: upd.id }, cookie);
check('message with attachment (no text) accepted', fmsg.status === 200 && fmsg.data.attachment && fmsg.data.attachment.id === upd.id);
const dl = await fetch(BASE + '/files/' + upd.id, { headers: { Cookie: cookie } });
const dlBody = await dl.text();
check('attachment downloads for a participant', dl.status === 200 && dlBody === 'hello file');
const dlNo = await fetch(BASE + '/files/' + upd.id);
check('attachment download denied without auth', dlNo.status === 401);
// 3g. Group chat
const mkGroup = await call('/api/v1/groups', { name: 'Team Huddle', memberIds: [bobId] }, cookie);
check('group created with members', mkGroup.status === 200 && !!mkGroup.data.id && mkGroup.data.members === 2);
const gid = mkGroup.data.id;
bobWs.q.length = 0; // drain prior pushes so we catch the group message
const gsend = await call('/api/v1/messages', { group: gid, body: 'hi team' }, cookie);
check('group message sent', gsend.status === 200 && gsend.data.conversation_id === gid);
const gpush = await nextMsg(bobWs, 'chat-message');
check('group message delivered to a member', gpush.message && gpush.message.conversation_id === gid && gpush.message.body === 'hi team');
const gconv = await get('/api/v1/messages/conversations', bobCookie);
const gItem = gconv.data.find((c) => c.kind === 'group' && c.id === gid);
check('group appears in member conversations with unread', !!gItem && gItem.unread >= 1);
const gthread = await get('/api/v1/messages/thread?group=' + gid, bobCookie);
check('group thread returns messages with sender name', gthread.status === 200 && gthread.data.some((m) => m.body === 'hi team' && m.fromName));
const gconv2 = await get('/api/v1/messages/conversations', bobCookie);
const gItem2 = gconv2.data.find((c) => c.kind === 'group' && c.id === gid);
check('reading group thread clears unread', !!gItem2 && gItem2.unread === 0);
// group "seen by": bob read the thread above, so admin's message lists him as a reader
const gSeen = await get('/api/v1/messages/thread?group=' + gid, cookie);
const hiTeam = gSeen.data.find((x) => x.body === 'hi team');
check('group message shows seen-by readers', !!hiTeam && Array.isArray(hiTeam.seenBy) && hiTeam.seenBy.length >= 1);
const gmembers = await get('/api/v1/groups/members?group=' + gid, cookie);
check('group members listed', gmembers.status === 200 && gmembers.data.length === 2);
// @mentions: members + @everyone are stored; non-members are filtered out
const ment = await call('/api/v1/messages', { group: gid, body: 'ping @Bob and @everyone', mentions: [bobId, 'everyone', 'not-a-member'] }, cookie);
check('group message accepts mentions', ment.status === 200);
check('mentions filtered to members + everyone', Array.isArray(ment.data.mentions) && ment.data.mentions.includes(bobId) && ment.data.mentions.includes('everyone') && !ment.data.mentions.includes('not-a-member'));
const mthread = await get('/api/v1/messages/thread?group=' + gid, bobCookie);
const mEntry = mthread.data.find((x) => x.id === ment.data.id);
check('thread carries mentions', !!mEntry && mEntry.mentions.includes(bobId) && mEntry.mentions.includes('everyone'));
// 3j. Polls (single + multi, vote/switch, close, inline in thread)
const mkPoll = await call('/api/v1/polls', { group: gid, question: 'Lunch?', options: ['Pizza', 'Sushi'], multi: false }, cookie);
check('poll created', mkPoll.status === 200 && mkPoll.data.options.length === 2 && mkPoll.data.totalVotes === 0);
const pid = mkPoll.data.id;
const needTwo = await call('/api/v1/polls', { group: gid, question: 'X?', options: ['only one'] }, cookie);
check('poll requires >=2 options', needTwo.status === 400);
const v1 = await call('/api/v1/polls/vote', { pollId: pid, optionIdx: 0 }, bobCookie);
check('vote recorded', v1.status === 200 && v1.data.options[0].votes === 1 && v1.data.options[0].mine === true);
const v2 = await call('/api/v1/polls/vote', { pollId: pid, optionIdx: 1 }, bobCookie); // single-choice -> switch
check('single-choice switches the vote', v2.data.options[0].votes === 0 && v2.data.options[1].votes === 1);
const av = await call('/api/v1/polls/vote', { pollId: pid, optionIdx: 0 }, cookie);
check('second voter counted', av.data.totalVotes === 2 && av.data.voters === 2);
const mkPoll2 = await call('/api/v1/polls', { group: gid, question: 'Toppings?', options: ['Cheese', 'Olives', 'Mushroom'], multi: true }, cookie);
const pid2 = mkPoll2.data.id;
await call('/api/v1/polls/vote', { pollId: pid2, optionIdx: 0 }, bobCookie);
const mv = await call('/api/v1/polls/vote', { pollId: pid2, optionIdx: 1 }, bobCookie);
check('multi-choice keeps multiple votes', mv.data.options[0].votes === 1 && mv.data.options[1].votes === 1 && mv.data.voters === 1);
const badClose = await call('/api/v1/polls/close', { pollId: pid }, bobCookie);
check('non-creator cannot close a poll', badClose.status === 403);
const closed = await call('/api/v1/polls/close', { pollId: pid }, cookie);
check('creator closes the poll', closed.status === 200 && closed.data.closed === true);
const voteClosed = await call('/api/v1/polls/vote', { pollId: pid, optionIdx: 0 }, bobCookie);
check('cannot vote on a closed poll', voteClosed.status === 400);
const pthread = await get('/api/v1/messages/thread?group=' + gid, bobCookie);
const pmsg = pthread.data.find((x) => x.poll && x.poll.id === pid);
check('poll renders inline in the thread', !!pmsg && pmsg.poll.options.length === 2 && pmsg.poll.closed === true);
// group management: rename, info, remove(leave)
const ren = await call('/api/v1/groups/rename', { group: gid, name: 'Renamed Huddle' }, cookie);
check('group renamed', ren.status === 200 && ren.data.name === 'Renamed Huddle');
const gthrRen = await get('/api/v1/messages/thread?group=' + gid, cookie);
check('rename posts an activity message', gthrRen.data.some((x) => x.system && /renamed/.test(x.body)));
// admin-only toggle: only the creator can change it / manage members when on
const ao1 = await call('/api/v1/groups/admin-only', { group: gid, value: true }, cookie);
check('admin enables admin-only', ao1.status === 200 && ao1.data.adminOnly === true);
const aoBobToggle = await call('/api/v1/groups/admin-only', { group: gid, value: false }, bobCookie);
check('non-admin cannot change admin-only', aoBobToggle.status === 403);
const aoBobRemove = await call('/api/v1/groups/remove', { group: gid, userId: adminId }, bobCookie);
check('admin-only blocks non-admin removing others', aoBobRemove.status === 403);
const ao0 = await call('/api/v1/groups/admin-only', { group: gid, value: false }, cookie);
check('admin disables admin-only', ao0.status === 200 && ao0.data.adminOnly === false);
// shared group call: start returns a room; a second start joins the same live call
const call1 = await call('/api/v1/groups/call/start', { group: gid }, cookie);
check('group call starts with a room', call1.status === 200 && /^\d{6}$/.test(call1.data.room || '') && call1.data.active === true);
const call2 = await call('/api/v1/groups/call/start', { group: gid }, bobCookie);
check('second start joins the same call (no code)', call2.status === 200 && call2.data.room === call1.data.room && call2.data.already === true);
const convCall = await get('/api/v1/messages/conversations', bobCookie);
const gcRow = convCall.data.find((c) => c.kind === 'group' && c.id === gid);
check('conversations expose the active call', !!gcRow && gcRow.callActive === true && gcRow.callRoom === call1.data.room);
// 1:1 call + add-participant invite
const dmCall1 = await call('/api/v1/calls/dm/start', { to: bobId }, cookie);
check('DM call starts with a room', dmCall1.status === 200 && /^\d{6}$/.test(dmCall1.data.room || '') && dmCall1.data.active === true);
const dmCall2 = await call('/api/v1/calls/dm/start', { to: adminId }, bobCookie);
check('DM call join returns the same room', dmCall2.status === 200 && dmCall2.data.room === dmCall1.data.room && dmCall2.data.already === true);
const inv = await call('/api/v1/calls/invite', { room: dmCall1.data.room, userIds: [bobId] }, cookie);
check('invite to a live call accepted', inv.status === 200 && inv.data.invited === 1);
const invBad = await call('/api/v1/calls/invite', { room: '000000', userIds: [bobId] }, cookie);
check('invite to a non-existent call rejected', invBad.status === 404);
const ginfo = await get('/api/v1/groups/info?group=' + gid, cookie);
check('group info returns name + members + isCreator', ginfo.status === 200 && ginfo.data.name === 'Renamed Huddle' && ginfo.data.isCreator === true && ginfo.data.members.length === 2);
// 3h. Scheduled meetings (schedule -> announce -> list buckets -> lazy join -> cancel)
const future = Date.now() + 24 * 60 * 60 * 1000;
bobWs.q.length = 0;
const sched = await call('/api/v1/meetings/schedule', { group: gid, title: 'Sprint Review', description: 'Demo + retro', scheduledAt: future, whenText: 'Tomorrow' }, cookie);
check('meeting scheduled, returns 6-digit room code', sched.status === 200 && /^\d{6}$/.test(sched.data.roomCode || ''));
const schP = await call('/api/v1/meetings/schedule', { title: 'Synced', scheduledAt: Date.now() + 3600000, participants: [bobId] }, cookie);
check('meeting scheduled with participants', schP.status === 200 && Array.isArray(schP.data.participants) && schP.data.participants.includes(bobId));
const bobMeetings = await get('/api/v1/meetings', bobCookie);
const bm = bobMeetings.data.find((m) => m.id === schP.data.id);
check('invited participant sees the meeting', !!bm && bm.isHost === false);
const schPush = await nextMsg(bobWs, 'chat-message');
check('schedule announced in the group chat', schPush.message && /Scheduled a call/.test(schPush.message.body));
const pastM = await call('/api/v1/meetings/schedule', { group: gid, title: 'Old Standup', scheduledAt: Date.now() - 2 * 60 * 60 * 1000 }, cookie);
check('past meeting scheduling is rejected', pastM.status === 400); // can't schedule in the past (#1)
const mlist = await get('/api/v1/meetings', cookie);
const schUp = mlist.data.find((m) => m.id === sched.data.id);
check('meetings list buckets upcoming', mlist.status === 200 && schUp && schUp.status === 'upcoming');
const sm = wsClient(); await new Promise((r) => sm.on('open', r));
sm.send(JSON.stringify({ type: 'meeting-join', room: sched.data.roomCode, name: 'Admin' }));
const smJoined = await nextMsg(sm, 'meeting-joined');
check('scheduled meeting joinable by code (room created lazily)', !!smJoined.peerId && smJoined.room === sched.data.roomCode);
const mlist2 = await get('/api/v1/meetings', cookie);
const upRun = mlist2.data.find((m) => m.id === sched.data.id);
check('scheduled meeting shows running while a peer is connected', !!upRun && upRun.status === 'running' && upRun.inCall >= 1);
sm.close();
const cancelBob = await call('/api/v1/meetings/cancel', { id: sched.data.id }, bobCookie);
check('non-organizer cannot cancel a meeting', cancelBob.status === 403);
const cancelOk = await call('/api/v1/meetings/cancel', { id: sched.data.id }, cookie);
check('organizer cancels the meeting', cancelOk.status === 200);
// 3i. Group image (upload -> set as group avatar -> visible to members)
const imgUp = await fetch(BASE + '/api/v1/messages/upload', { method: 'POST', headers: { Cookie: cookie, 'Content-Type': 'image/png', 'X-Filename': encodeURIComponent('logo.png') }, body: Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) });
const imgUpd = await imgUp.json();
check('group image uploaded', imgUp.status === 200 && !!imgUpd.id);
const badAv = await call('/api/v1/groups/avatar', { group: gid, attachmentId: upd.id }, cookie); // upd.id is text/plain
check('non-image rejected as group photo', badAv.status === 400);
const setAv = await call('/api/v1/groups/avatar', { group: gid, attachmentId: imgUpd.id }, cookie);
check('group photo set', setAv.status === 200 && setAv.data.avatar === '/files/' + imgUpd.id);
const ginfoAv = await get('/api/v1/groups/info?group=' + gid, cookie);
check('group info exposes the photo', ginfoAv.status === 200 && ginfoAv.data.avatar === '/files/' + imgUpd.id);
const memberFetch = await fetch(BASE + '/files/' + imgUpd.id, { headers: { Cookie: bobCookie } });
check('group member can fetch the group photo', memberFetch.status === 200);
const bobLeave = await call('/api/v1/groups/remove', { group: gid }, bobCookie); // bob leaves
check('member can leave the group', bobLeave.status === 200 && bobLeave.data.left === true);
const gthrLeft = await get('/api/v1/messages/thread?group=' + gid, cookie);
check('leaving posts an activity message', gthrLeft.data.some((x) => x.system && /left/.test(x.body)));
const ginfo2 = await get('/api/v1/groups/info?group=' + gid, cookie);
check('member count drops after leave', ginfo2.status === 200 && ginfo2.data.members.length === 1);
bobWs.close();
// 3f. Meetings (mesh) signaling: create room, two peers join, relay signal, leave
const alice = wsClient(); await new Promise((r) => alice.on('open', r));
alice.send(JSON.stringify({ type: 'meeting-create' }));
const created = await nextMsg(alice, 'meeting-created');
check('meeting-create returns a 6-digit room code', /^\d{6}$/.test(created.room || ''));
alice.send(JSON.stringify({ type: 'meeting-join', room: created.room, name: 'Alice' }));
const aJoined = await nextMsg(alice, 'meeting-joined');
check('first peer joins (room empty)', !!aJoined.peerId && aJoined.peers.length === 0);
const carol = wsClient(); await new Promise((r) => carol.on('open', r));
carol.send(JSON.stringify({ type: 'meeting-join', room: created.room, name: 'Carol' }));
const cJoined = await nextMsg(carol, 'meeting-joined');
check('second peer sees the first in the room', cJoined.peers.some((p) => p.peerId === aJoined.peerId));
const aPeerJoined = await nextMsg(alice, 'meeting-peer-joined');
check('existing peer notified of newcomer', aPeerJoined.peerId === cJoined.peerId);
alice.send(JSON.stringify({ type: 'meeting-signal', to: cJoined.peerId, data: { fake: 'offer' } }));
const relayed = await nextMsg(carol, 'meeting-signal');
check('meeting signal relayed peer->peer', relayed.from === aJoined.peerId && relayed.data.fake === 'offer');
carol.close();
const aPeerLeft = await nextMsg(alice, 'meeting-peer-left');
check('peer-left delivered on disconnect', aPeerLeft.peerId === cJoined.peerId);
alice.close();
// 4. Wrong password rejected // 4. Wrong password rejected
const badLogin = await call('/api/login', { email, password: 'wrong' }); const badLogin = await call('/api/login', { email, password: 'wrong' });
check('wrong password rejected', badLogin.status === 401); check('wrong password rejected', badLogin.status === 401);
@@ -130,6 +390,15 @@ function nextMsg(ws, type, timeout = 3000) {
await nextMsg(agent, 'session-ended'); await nextMsg(agent, 'session-ended');
check('session-ended delivered to agent', true); check('session-ended delivered to agent', true);
// 14b. Outbound webhook delivery (session.started + session.ended) with valid signatures
await wait(900);
const parse = (h) => { try { return JSON.parse(h.body); } catch { return {}; } };
const hookStarted = webhookHits.find((h) => parse(h).event === 'session.started');
const hookEnded = webhookHits.find((h) => parse(h).event === 'session.ended');
check('webhook received session.started', !!hookStarted);
check('webhook received session.ended', !!hookEnded);
check('webhook signature is valid (HMAC-SHA256)', !!hookEnded && hookEnded.sig === crypto.createHmac('sha256', hookSecret).update(hookEnded.body).digest('base64url'));
// 15. Audit log captured the full flow // 15. Audit log captured the full flow
const audit = await get('/api/audit', cookie); const audit = await get('/api/audit', cookie);
const actions = audit.data.map((a) => a.action); const actions = audit.data.map((a) => a.action);
@@ -146,6 +415,7 @@ function nextMsg(ws, type, timeout = 3000) {
check('consent denial -> viewer session-denied', !!denied); check('consent denial -> viewer session-denied', !!denied);
agent.close(); viewer.close(); agent.close(); viewer.close();
hookSrv.close();
console.log(`\n${passed} passed, ${failed} failed.`); console.log(`\n${passed} passed, ${failed} failed.`);
server.close(); server.close();
process.exit(failed ? 1 : 0); process.exit(failed ? 1 : 0);
+54
View File
@@ -0,0 +1,54 @@
// Outbound webhook delivery. emit(event, tenantId, payload) fans the event out to every
// active per-tenant subscription registered for that event, plus the legacy global
// BIZGAZE_WEBHOOK_URL (back-compat). Each delivery is HMAC-signed and retried on failure.
//
// NOTE (roadmap): retries are in-memory/best-effort. For guaranteed delivery this should
// move to a persistent queue when the app scales to multiple instances (see ARCHITECTURE.md).
const R = require('./repos');
const crypto = require('crypto');
const EVENTS = ['session.started', 'session.ended'];
function sign(secret, body) {
return crypto.createHmac('sha256', secret || '').update(body).digest('base64url');
}
const RETRY_DELAYS = [2000, 10000, 30000]; // after the first attempt
function deliver(url, secret, body, onDone) {
let attempt = 0;
const go = async () => {
attempt++;
let ok = false, status = 0, err = null;
try {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-BizGaze-Signature': sign(secret, body), 'X-BizGaze-Event': (() => { try { return JSON.parse(body).event; } catch { return ''; } })() },
body,
signal: AbortSignal.timeout(10000),
});
status = res.status; ok = res.ok;
} catch (e) { err = (e && e.message) || 'delivery failed'; }
if (ok || attempt > RETRY_DELAYS.length) { if (onDone) onDone({ ok, status, err }); return; }
setTimeout(go, RETRY_DELAYS[attempt - 1]);
};
go();
}
function emit(event, tenantId, payload) {
const body = JSON.stringify({ event, ...payload });
// Per-tenant subscriptions
try {
for (const h of R.webhooks.activeForTenant(tenantId)) {
const subs = String(h.events || '').split(',').map((s) => s.trim());
if (subs.includes('*') || subs.includes(event)) {
deliver(h.url, h.secret, body, (r) => { try { R.webhooks.setStatus(h.id, r.ok ? 1 : 0, r.err || ('HTTP ' + r.status)); } catch (_) {} });
}
}
} catch (_) {}
// Legacy global webhook (back-compat): session.ended → BIZGAZE_WEBHOOK_URL, signed with SSO_SECRET.
if (event === 'session.ended' && process.env.BIZGAZE_WEBHOOK_URL) {
deliver(process.env.BIZGAZE_WEBHOOK_URL, process.env.SSO_SECRET || '', body);
}
}
module.exports = { emit, sign, EVENTS };