BizGaze Connect: chat, meetings, recordings, mobile, directory + UI fixes
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -32,6 +32,7 @@ out/
|
||||
# Runtime media (created at startup by config.js)
|
||||
server/recordings/
|
||||
server/transcripts/
|
||||
server/uploads/
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
|
||||
+13
-4
@@ -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
|
||||
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).
|
||||
- [ ] **Refresh tokens** (short access token + long refresh) so native apps stay signed in safely.
|
||||
- [ ] **API keys** table + middleware (scoped per *tenant*, hashed at rest).
|
||||
- [ ] **Push-notification hooks** (APNs/FCM) for incoming sessions/calls on mobile.
|
||||
- [ ] **OIDC/JWT** SSO; per-tenant **webhook subscriptions** with retries.
|
||||
- [x] **Refresh tokens** — `/api/v1/auth/refresh` exchanges a long-lived (90d) refresh token for a
|
||||
fresh access token, with **rotation** (old token revoked on use) + replay rejection. Stored as a
|
||||
SHA-256 hash (`refresh_tokens` table). Login returns one; logout/deactivate/reset/delete revoke them.
|
||||
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)
|
||||
- [ ] Elevate **tenant → `organization`** (additive migration: add `organizations`, keep `team_id`
|
||||
|
||||
@@ -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,
|
||||
signup URL, role mapping. (See BizGaze-Connect-SSO-SPEC.md.)
|
||||
2. **New post-login home (NEXT TASK)** — see below.
|
||||
3. **Persistent chat** (Slack-style 1:1 + group messaging between registered users) — large new system.
|
||||
4. **Meetings** (multi-party video) — large new system (needs SFU or mesh).
|
||||
3. **Persistent chat** — 1:1 messaging is BUILT (messages table, `/api/v1/messages/*`, live delivery
|
||||
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.
|
||||
|
||||
## NEXT TASK: new post-login home (start with a mockup)
|
||||
|
||||
+3
-1
@@ -15,6 +15,8 @@ function verifyPassword(password, salt, expectedHash) {
|
||||
// ---- Random tokens ----
|
||||
const token = (bytes = 24) => crypto.randomBytes(bytes).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) =>
|
||||
String(crypto.randomInt(0, 10 ** digits)).padStart(digits, '0');
|
||||
|
||||
@@ -70,6 +72,6 @@ function otpauthUrl(secret, email, issuer = 'RemoteAccess') {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
hashPassword, verifyPassword, token, id, numericCode,
|
||||
hashPassword, verifyPassword, token, id, hashToken, numericCode,
|
||||
newMfaSecret, totp, verifyTotp, otpauthUrl,
|
||||
};
|
||||
|
||||
@@ -9,6 +9,20 @@
|
||||
function loginUrl() { return process.env.BIZGAZE_LOGIN_URL || ''; }
|
||||
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) {
|
||||
const url = loginUrl();
|
||||
if (!url) return { ok: false, configured: false };
|
||||
@@ -30,6 +44,7 @@ async function validateLogin(username, password) {
|
||||
return {
|
||||
ok: true, configured: true,
|
||||
name: s.name || null,
|
||||
avatarUrl: photoUrlFrom(s),
|
||||
isAdmin: !!s.isAdmin,
|
||||
tenantRef: s.tenantId != null ? String(s.tenantId) : null, // BizGaze tenant (org) id
|
||||
bizgazeUserId: s.userId != null ? String(s.userId) : null,
|
||||
|
||||
+5
-1
@@ -5,8 +5,10 @@ const path = require('path');
|
||||
const PUBLIC_DIR = path.join(__dirname, 'public');
|
||||
const REC_DIR = path.join(__dirname, 'recordings');
|
||||
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(TRANS_DIR, { recursive: true }); } catch (e) {}
|
||||
try { fs.mkdirSync(UPLOADS_DIR, { recursive: true }); } catch (e) {}
|
||||
|
||||
module.exports = {
|
||||
PORT: process.env.PORT || 8090,
|
||||
@@ -14,5 +16,7 @@ module.exports = {
|
||||
PUBLIC_DIR,
|
||||
REC_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)
|
||||
};
|
||||
|
||||
+206
@@ -81,4 +81,210 @@ 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 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);
|
||||
`);
|
||||
|
||||
module.exports = db;
|
||||
|
||||
@@ -4,4 +4,13 @@ module.exports = {
|
||||
onlineAgents: new Map(), // machineId -> { ws, machine }
|
||||
liveSessions: new Map(), // sessionId -> { agentWs, viewerWs, machine, user }
|
||||
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)
|
||||
};
|
||||
|
||||
@@ -31,6 +31,10 @@
|
||||
.topbar2.show{display:flex;} #barStatus{font-weight:600;font-size:.9rem;color:var(--blue);}
|
||||
#endBtn{padding:.45rem 1rem;background:#fee2e2;color:#b91c1c;border:none;border-radius:8px;font-weight:600;cursor:pointer;}
|
||||
#video{width:100vw;height:calc(100vh - 46px);background:#0b1220;object-fit:contain;display:none;cursor:crosshair;outline:none;}
|
||||
/* 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 .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)}
|
||||
@@ -53,10 +57,11 @@
|
||||
html.embed #homeLink{display:none!important;}
|
||||
html.embed #video{height:100vh!important;}
|
||||
</style>
|
||||
<script src="/icons.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<script>if(new URLSearchParams(location.search).get('embed')==='1')document.documentElement.classList.add('embed');</script>
|
||||
<a href="/home" id="homeLink">← Home</a>
|
||||
<a href="/home" id="homeLink"><span data-ic="arrowLeft" data-sz="16"></span> Home</a>
|
||||
<div class="topbar" id="topbar">
|
||||
<div class="brandrow"><img src="/logo.png" alt="" style="height:46px;width:auto;max-width:190px;border-radius:8px;object-fit:contain;background:#fff;padding:5px 12px;image-rendering:-webkit-optimize-contrast" onerror="this.replaceWith(Object.assign(document.createElement('div'),{className:'logo',textContent:'B'}))"><div class="brand">BizGaze <span>Support</span></div></div>
|
||||
<div class="agentchip" id="agentChip"></div>
|
||||
@@ -68,7 +73,7 @@
|
||||
<script>
|
||||
let ICE={iceServers:[{urls:'stun:stun.l.google.com:19302'}]};
|
||||
const IS_MOBILE=/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile/i.test(navigator.userAgent||'');
|
||||
let __icePromise=Promise.resolve();try{__icePromise=fetch('/api/ice').then(r=>r.ok?r.json():null).then(c=>{if(c&&c.iceServers&&IS_MOBILE)ICE=c;}).catch(()=>{});}catch(_){}
|
||||
let __icePromise=Promise.resolve();try{__icePromise=fetch('/api/ice').then(r=>r.ok?r.json():null).then(c=>{if(c&&c.iceServers)ICE=c;}).catch(()=>{});}catch(_){}
|
||||
async function ensureIce(){try{await __icePromise;}catch(_){}return ICE;}
|
||||
function pEsc(s){return String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));}
|
||||
// When embedded in the home shell, tell the parent when a session is live so the
|
||||
@@ -163,7 +168,8 @@ function connectWS(){
|
||||
case 'code-pending': sessionId=m.sessionId; renderWaiting(); setupPeer(); break;
|
||||
case 'session-ready': if(statusEl)statusEl.textContent='Allowed — connecting…'; break;
|
||||
case 'offer': await pc.setRemoteDescription(new RTCSessionDescription(m.sdp));
|
||||
try{ const mic=await navigator.mediaDevices.getUserMedia({audio:true}); window.__mic=mic; mic.getAudioTracks().forEach(t=>pc.addTrack(t,mic)); }catch(e){}
|
||||
// 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);
|
||||
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;
|
||||
@@ -191,6 +197,8 @@ function renderEnded(msg){
|
||||
bzcSession(false);
|
||||
try{ stopRecording(); }catch(_){}
|
||||
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; }
|
||||
video.style.display='none'; bar.classList.remove('show');
|
||||
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_MICOFF='<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="3" y1="3" x2="21" y2="21"/><path d="M9 9v4a3 3 0 0 0 5 2.2"/><path d="M5 10a7 7 0 0 0 10.9 5.6"/><line x1="12" y1="19" x2="12" y2="22"/></svg>';
|
||||
const SVG_CHAT='<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>';
|
||||
const SVG_END='<svg viewBox="0 0 24 24" width="17" height="17" fill="#fff"><rect x="5" y="5" width="14" height="14" rx="2.5"/></svg>';
|
||||
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_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;
|
||||
@@ -296,20 +304,21 @@ function stopRecording(){
|
||||
showRecTimer(false);
|
||||
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(){
|
||||
if(document.getElementById('sessionBar'))return;
|
||||
{ const hl=document.getElementById('homeLink'); if(hl) hl.style.display='none'; }
|
||||
bzcSession(true);
|
||||
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 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 end=_btn('endBtn2',SVG_END,'End','#dc2626');
|
||||
bar.appendChild(mic);bar.appendChild(chat);bar.appendChild(rec);bar.appendChild(end);
|
||||
document.body.appendChild(bar);
|
||||
mic.onclick=()=>{const m=window.__mic;if(!m)return;const t=m.getAudioTracks()[0];if(!t)return;t.enabled=!t.enabled;mic.innerHTML='<span style="display:inline-flex">'+(t.enabled?SVG_MIC:SVG_MICOFF)+'</span><span>'+(t.enabled?'Mic':'Muted')+'</span>';mic.style.background=t.enabled?'#2563eb':'#6b7280';};
|
||||
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;
|
||||
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(_){} };
|
||||
@@ -321,7 +330,7 @@ function buildBar(){
|
||||
function buildChatPanel(){
|
||||
if(document.getElementById('chatPanel'))return;
|
||||
const p=document.createElement('div'); p.id='chatPanel';
|
||||
p.style.cssText='position:fixed;right:18px;bottom:84px;width:300px;max-width:92vw;height:360px;max-height:62vh;z-index:2147483001;background:#fff;border:1px solid #e6e9ef;border-radius:16px;box-shadow:0 14px 34px rgba(0,0,0,.28);display:none;flex-direction:column;overflow:hidden';
|
||||
p.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">✕</span></div><div id="chatMsgs" style="flex:1;overflow-y:auto;padding:.6rem;display:flex;flex-direction:column;gap:.4rem;font-size:.85rem"></div><div style="display:flex;gap:.4rem;padding:.5rem;border-top:1px solid #e6e9ef"><input id="chatInput" placeholder="Type a message..." style="flex:1;padding:.5rem;border:1px solid #e6e9ef;border-radius:8px;font-size:.85rem;outline:none"><button id="chatSend" style="background:#FFC708;border:none;border-radius:8px;padding:.5rem .85rem;font-weight:700;cursor:pointer">Send</button></div>';
|
||||
document.body.appendChild(p);
|
||||
document.getElementById('chatSend').onclick=sendChat;
|
||||
@@ -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'}));};
|
||||
function esc(s){return String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));}
|
||||
</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>
|
||||
</html>
|
||||
|
||||
@@ -28,6 +28,11 @@
|
||||
th,td{text-align:left;padding:.55rem .5rem;border-bottom:1px solid var(--line);}
|
||||
.pill{font-size:.74rem;font-weight:600;padding:.15rem .55rem;border-radius:99px;}
|
||||
.pill.on{background:#ecfdf3;color:#15803d;}
|
||||
.pill.off{background:#fee2e2;color:var(--red);}
|
||||
.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;}
|
||||
.tabs{display:flex;gap:.5rem;margin-bottom:1.2rem;}
|
||||
.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:hover{background:#f1f5f9}
|
||||
.profile .pmenu a.danger{color:#b91c1c;border-top:1px solid #eef1f6}
|
||||
.ic{display:inline-block;vertical-align:middle}
|
||||
</style>
|
||||
<script src="/icons.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<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">To</span><input id="fTo" type="date"></div>
|
||||
<button id="fApply">Apply</button>
|
||||
<button id="fExcel" class="mini" style="padding:.6rem .9rem">⬇ Excel</button>
|
||||
<button id="fPdf" class="mini" style="padding:.6rem .9rem">⬇ PDF</button>
|
||||
<button id="fExcel" class="mini" style="padding:.6rem .9rem">${ic('download',15)} Excel</button>
|
||||
<button id="fPdf" class="mini" style="padding:.6rem .9rem">${ic('download',15)} PDF</button>
|
||||
</div>
|
||||
${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>
|
||||
<div id="repPager" class="pager"></div>
|
||||
<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('fExcel').onclick = exportExcel;
|
||||
document.getElementById('fPdf').onclick = exportPdf;
|
||||
if (IS_ADMIN) await populateAgentFilter();
|
||||
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;
|
||||
function pagerHTML(page, pages, total, fn){
|
||||
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>${r.ended_at ? fmtDuration(dur) : '<span class="pill on">in progress</span>'}</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.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.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>${ic('download',14)} Text</a>` : ''
|
||||
].join('') || '<span class="muted">—</span>'}</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
+1920
-100
File diff suppressed because it is too large
Load Diff
@@ -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.show { display:block; }
|
||||
</style>
|
||||
<script src="/icons.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<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>
|
||||
<input id="token" placeholder="enroll token">
|
||||
<button id="goBtn">Go online</button>
|
||||
@@ -98,5 +99,6 @@ function teardown() {
|
||||
}
|
||||
function esc(s){return String(s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));}
|
||||
</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>
|
||||
</html>
|
||||
|
||||
@@ -17,9 +17,9 @@
|
||||
.inner{max-width:780px;width:100%;text-align:center;}
|
||||
h1{color:var(--blue);font-size:1.8rem;margin:0 0 .4rem;}
|
||||
.sub{color:var(--muted);margin-bottom:2.2rem;}
|
||||
.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:hover{transform:translateY(-2px);box-shadow:0 16px 34px rgba(31,59,115,.34);background:var(--blue-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{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(224,172,0,.4);background:var(--brand-d);}
|
||||
.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::before,.divider::after{content:"";flex:1;height:1px;background:var(--line);}
|
||||
.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.danger{color:#b91c1c;border-top:1px solid #eef1f6}
|
||||
</style>
|
||||
<script src="/icons.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<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>
|
||||
</a>
|
||||
</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>
|
||||
<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';
|
||||
}}catch(_){}})();
|
||||
</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>
|
||||
</html>
|
||||
|
||||
+32
-16
@@ -20,7 +20,7 @@
|
||||
.sub{color:var(--muted);font-size:.97rem;line-height:1.5;margin-bottom:1.6rem;}
|
||||
.codewrap{background:#fffdf2;border:2px dashed var(--brand);border-radius:14px;padding:1.2rem;}
|
||||
.codelabel{font-size:.78rem;letter-spacing:.08em;text-transform:uppercase;color:var(--muted);margin-bottom:.3rem;}
|
||||
.code{font-size: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.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);}
|
||||
@@ -46,11 +46,12 @@
|
||||
.profile .pmenu a:hover{background:#f1f5f9}
|
||||
.profile .pmenu a.danger{color:#b91c1c;border-top:1px solid #eef1f6}
|
||||
</style>
|
||||
<script src="/icons.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<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>
|
||||
<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)">← 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="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'}))">
|
||||
@@ -65,12 +66,12 @@
|
||||
<div class="codelabel">Your session code</div>
|
||||
<div style="display:flex;align-items:center;justify-content:center;gap:.7rem">
|
||||
<div class="code" id="code">······</div>
|
||||
<button id="copyBtn" title="Click to copy" aria-label="Click to copy" style="flex:0 0 auto;width:38px;height:38px;padding:0;background:#fff;border:1px solid var(--brand);color:var(--blue);border-radius:8px;cursor:pointer;display:inline-flex;align-items:center;justify-content:center"><svg id="copyIcon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button>
|
||||
<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 id="status" class="status">Preparing your code…</div>
|
||||
<div id="consentBox"></div>
|
||||
<div class="foot">🔒 You stay in control. The agent can only see your screen after you tap Allow, and you can stop anytime.</div>
|
||||
<div 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>
|
||||
@@ -79,7 +80,7 @@ let ICE={iceServers:[{urls:'stun:stun.l.google.com:19302'}]};
|
||||
let SHARER_NAME='Customer';
|
||||
try{fetch('/api/me').then(r=>r.ok?r.json():null).then(m=>{if(m&&(m.name||m.email))SHARER_NAME=m.name||m.email;}).catch(()=>{});}catch(_){}
|
||||
const IS_MOBILE=/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile/i.test(navigator.userAgent||'');
|
||||
let __icePromise=Promise.resolve();try{__icePromise=fetch('/api/ice').then(r=>r.ok?r.json():null).then(c=>{if(c&&c.iceServers&&IS_MOBILE)ICE=c;}).catch(()=>{});}catch(_){}
|
||||
let __icePromise=Promise.resolve();try{__icePromise=fetch('/api/ice').then(r=>r.ok?r.json():null).then(c=>{if(c&&c.iceServers)ICE=c;}).catch(()=>{});}catch(_){}
|
||||
async function ensureIce(){try{await __icePromise;}catch(_){}return ICE;}
|
||||
function pEsc(s){return String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));}
|
||||
// When embedded in the home shell, tell the parent when a session is live so the
|
||||
@@ -98,7 +99,7 @@ document.getElementById('copyBtn').onclick=async()=>{
|
||||
try{ await navigator.clipboard.writeText(code); }
|
||||
catch(e){ const ta=document.createElement('textarea');ta.value=code;document.body.appendChild(ta);ta.select();document.execCommand('copy');ta.remove(); }
|
||||
const b=document.getElementById('copyBtn'); const old=b.innerHTML;
|
||||
b.innerHTML='<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#16a34a" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>';
|
||||
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);
|
||||
};
|
||||
let ws,pc,localStream,chatChannel,sessionId;
|
||||
@@ -144,7 +145,9 @@ function showConsent(m){
|
||||
async function beginCapture(){
|
||||
try{ localStream=await navigator.mediaDevices.getDisplayMedia({video:{displaySurface:'monitor',frameRate:{ideal:30}},audio:false,monitorTypeSurfaces:'include'}); }
|
||||
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(_){}
|
||||
return true;
|
||||
}
|
||||
@@ -152,11 +155,10 @@ async function startStreaming(){
|
||||
// If the Allow tap already captured the screen (mobile path), reuse it.
|
||||
if(!localStream){
|
||||
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');
|
||||
// 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'}); }
|
||||
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; }
|
||||
if(mic){ window.__mic=mic; try{mic.getAudioTracks().forEach(t=>localStream.addTrack(t));}catch(_){} }
|
||||
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; }
|
||||
}
|
||||
await ensureIce();
|
||||
indicator.classList.add('show'); setStatus('You are now sharing your screen with your agent.','on'); bzcSession(true);
|
||||
@@ -168,11 +170,14 @@ async function startStreaming(){
|
||||
pc.ondatachannel=(ev)=>{ev.channel.onmessage=()=>{};};
|
||||
pc.ontrack=(ev)=>{ if(ev.track.kind==='audio'){ let a=document.getElementById('remoteAudio'); if(!a){a=document.createElement('audio');a.id='remoteAudio';a.autoplay=true;document.body.appendChild(a);} a.srcObject=ev.streams[0]; } };
|
||||
pc.onicecandidate=(ev)=>{if(ev.candidate)ws.send(JSON.stringify({type:'ice-candidate',sessionId,candidate:ev.candidate}));};
|
||||
pc.onconnectionstatechange=()=>{ if(pc&&pc.connectionState==='failed'){ try{ws.send(JSON.stringify({type:'end-session',sessionId,reason:'customer-ended'}));}catch(_){} endShareSession('The connection was lost. Tap below for a new code if you still need help.'); } };
|
||||
pc.onconnectionstatechange=()=>{ if(!pc) return; if(pc.connectionState==='connected'){ clearTimeout(window.__connWatch); } if(pc.connectionState==='failed'){ clearTimeout(window.__connWatch); try{ws.send(JSON.stringify({type:'end-session',sessionId,reason:'customer-ended'}));}catch(_){} endShareSession("Couldn't connect on this network — it may be blocking screen sharing. Try a different network (e.g. mobile data / hotspot), then tap below for a new code."); } };
|
||||
chatChannel=pc.createDataChannel('chat',{ordered:true});
|
||||
chatChannel.onmessage=(e)=>{try{addChat(JSON.parse(e.data));}catch(_){}};
|
||||
const offer=await pc.createOffer(); await pc.setLocalDescription(offer);
|
||||
ws.send(JSON.stringify({type:'offer',sessionId,sdp:pc.localDescription}));
|
||||
// Watchdog: clear failure message instead of a blank screen if no path establishes.
|
||||
clearTimeout(window.__connWatch);
|
||||
window.__connWatch=setTimeout(()=>{ if(pc && pc.connectionState!=='connected' && !sessionOver){ try{ws.send(JSON.stringify({type:'end-session',sessionId,reason:'customer-ended'}));}catch(_){} endShareSession("Couldn't connect on this network — it may be blocking screen sharing. Try a different network (e.g. mobile data / hotspot), then tap below for a new code."); } }, 20000);
|
||||
localStream.getVideoTracks()[0].onended=()=>{ws.send(JSON.stringify({type:'end-session',sessionId,reason:'customer-ended'}));teardown();};
|
||||
}
|
||||
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||
@@ -221,18 +226,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_MICOFF='<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="3" y1="3" x2="21" y2="21"/><path d="M9 9v4a3 3 0 0 0 5 2.2"/><path d="M5 10a7 7 0 0 0 10.9 5.6"/><line x1="12" y1="19" x2="12" y2="22"/></svg>';
|
||||
const SVG_CHAT='<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>';
|
||||
const SVG_END='<svg viewBox="0 0 24 24" width="17" height="17" fill="#fff"><rect x="5" y="5" width="14" height="14" rx="2.5"/></svg>';
|
||||
function _btn(id,svg,label,bg){const b=document.createElement('button');b.id=id;b.innerHTML='<span style="display:inline-flex">'+svg+'</span><span>'+label+'</span>';b.style.cssText='display:inline-flex;align-items:center;gap:7px;border:none;border-radius:12px;padding:.62rem 1rem;font-weight:600;font-size:.9rem;cursor:pointer;color:#fff;background:'+bg+';transition:background .15s,transform .08s';b.onmouseenter=()=>b.style.transform='translateY(-1px)';b.onmouseleave=()=>b.style.transform='none';return b;}
|
||||
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.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(){
|
||||
if(document.getElementById('sessionBar'))return;
|
||||
const bar=document.createElement('div'); bar.id='sessionBar';
|
||||
bar.style.cssText='position:fixed;right:18px;bottom:18px;z-index:2147483000;display:flex;gap:10px;align-items:center;background:rgba(15,23,42,.94);padding:8px 12px;border-radius:16px;box-shadow:0 10px 28px rgba(0,0,0,.35)';
|
||||
const mic=_btn('micBtn',SVG_MIC,'Mic','#2563eb');
|
||||
const mic=_btn('micBtn',SVG_MICOFF,'Muted','#6b7280');
|
||||
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);
|
||||
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;
|
||||
end.onclick=()=>{ try{ws.send(JSON.stringify({type:'end-session',sessionId,reason:'customer-ended'}));}catch(_){} teardown(); };
|
||||
buildChatPanel();
|
||||
@@ -263,5 +278,6 @@ function removeSessionUI(){['sessionBar','chatPanel','remoteAudio','muteBtn','ms
|
||||
|
||||
function esc(s){return String(s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));}
|
||||
</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>
|
||||
</html>
|
||||
|
||||
@@ -11,12 +11,13 @@
|
||||
button { padding: 0.5rem 1rem; background: #334155; color: #fff; border: none; border-radius: 6px; cursor: pointer; }
|
||||
a { color: #3b82f6; }
|
||||
</style>
|
||||
<script src="/icons.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div id="status">Connecting…</div>
|
||||
<div>
|
||||
<a href="/">← Console</a>
|
||||
<a href="/"><span data-ic="arrowLeft" data-sz="16"></span> Console</a>
|
||||
<button id="endBtn">End session</button>
|
||||
</div>
|
||||
</header>
|
||||
@@ -103,5 +104,6 @@ document.getElementById('endBtn').onclick = () => {
|
||||
setTimeout(() => (location.href = '/'), 300);
|
||||
};
|
||||
</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>
|
||||
</html>
|
||||
|
||||
+168
-2
@@ -25,7 +25,7 @@ const users = {
|
||||
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),
|
||||
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) =>
|
||||
db.prepare('SELECT * FROM users WHERE id=? AND team_id=?').get(id, tenantId),
|
||||
create: ({ tenantId, email, hash, salt, role, name, mfaSecret }) => {
|
||||
@@ -37,8 +37,10 @@ const users = {
|
||||
},
|
||||
enableMfa: (id) => db.prepare('UPDATE users SET mfa_enabled=1 WHERE id=?').run(id),
|
||||
setName: (id, name) => db.prepare('UPDATE users SET name=? WHERE id=?').run(name, 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),
|
||||
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),
|
||||
};
|
||||
|
||||
@@ -100,4 +102,168 @@ 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),
|
||||
};
|
||||
|
||||
module.exports = { teams, users, authSessions, machines, audit, sessionsLog, refreshTokens, apiKeys, webhooks, messages, reactions, attachments, conversations, scheduledMeetings, recordings, polls, pollVotes };
|
||||
|
||||
+920
-40
File diff suppressed because it is too large
Load Diff
+17
-1
@@ -1,5 +1,6 @@
|
||||
// Session/auth helpers: resolve the current user from the cookie, write audit rows.
|
||||
const R = require('./repos');
|
||||
const A = require('./auth');
|
||||
const { parseCookies, now } = require('./lib');
|
||||
|
||||
function audit(entry) {
|
||||
@@ -35,4 +36,19 @@ function currentUser(req, { requireMfa = true } = {}) {
|
||||
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
@@ -5,7 +5,9 @@
|
||||
const R = require('./repos');
|
||||
const A = require('./auth');
|
||||
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) {
|
||||
const hb = setInterval(() => {
|
||||
@@ -20,6 +22,132 @@ function onConnection(ws, req) {
|
||||
|
||||
function handle(ws, m, req) {
|
||||
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 ---
|
||||
case 'agent-hello': {
|
||||
const machine = R.machines.byEnrollToken(m.enrollToken);
|
||||
@@ -61,6 +189,7 @@ function handle(ws, m, req) {
|
||||
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 });
|
||||
} 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.agentWs.send(JSON.stringify({ type: 'start-stream', sessionId: m.sessionId }));
|
||||
} 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) {
|
||||
const sess = liveSessions.get(sessionId);
|
||||
if (!sess) return;
|
||||
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') });
|
||||
[sess.agentWs, sess.viewerWs].forEach((p) => {
|
||||
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);
|
||||
}
|
||||
|
||||
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) {
|
||||
CHAT.unregister(ws);
|
||||
leaveMeeting(ws);
|
||||
if (ws.kind === 'agent' && ws.machineId) onlineAgents.delete(ws.machineId);
|
||||
if (ws.kind === 'sharer' && ws.shareCode) pendingShares.delete(ws.shareCode);
|
||||
if (ws.sessionId) {
|
||||
|
||||
+72
-5
@@ -5,7 +5,7 @@ const path = require('path');
|
||||
const R = require('./repos');
|
||||
const { json } = require('./lib');
|
||||
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' };
|
||||
|
||||
@@ -19,11 +19,21 @@ function serveStatic(req, res) {
|
||||
if (p === '/connect') p = '/connect.html';
|
||||
const fp = path.join(PUBLIC_DIR, path.normalize(p));
|
||||
if (!fp.startsWith(PUBLIC_DIR)) return json(res, 403, { error: 'forbidden' });
|
||||
fs.readFile(fp, (err, data) => {
|
||||
if (err) return json(res, 404, { error: 'not found' });
|
||||
// ETag + revalidation: the browser keeps the file cached and we answer repeat loads with a
|
||||
// tiny 304 (no re-download) when nothing changed — fast reloads, but always fresh on edits.
|
||||
fs.stat(fp, (serr, st) => {
|
||||
if (serr || !st.isFile()) return json(res, 404, { error: 'not found' });
|
||||
const ct = MIME[path.extname(fp)] || 'application/octet-stream';
|
||||
res.writeHead(200, { 'Content-Type': ct, 'Cache-Control': 'no-cache' });
|
||||
res.end(data);
|
||||
const etag = '"' + st.size.toString(16) + '-' + Math.round(st.mtimeMs).toString(16) + '"';
|
||||
if (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' });
|
||||
res.writeHead(200, { 'Content-Type': ct, 'Cache-Control': 'no-cache', ETag: etag, 'Content-Length': st.size });
|
||||
res.end(data);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -66,6 +76,63 @@ function handleGet(req, 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);
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@ process.env.HTTPS_PORT = 8444; // avoid clashing with a running dev server on 84
|
||||
const { server } = require('../server');
|
||||
const A = require('../auth');
|
||||
const WebSocket = require('ws');
|
||||
const http = require('http');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const BASE = `http://localhost:${PORT}`;
|
||||
let passed = 0, failed = 0;
|
||||
@@ -64,6 +66,12 @@ function nextMsg(ws, type, timeout = 3000) {
|
||||
await wait(300); // let server bind
|
||||
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)
|
||||
const email = 'tech@example.com';
|
||||
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);
|
||||
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
|
||||
const badLogin = await call('/api/login', { email, password: 'wrong' });
|
||||
check('wrong password rejected', badLogin.status === 401);
|
||||
@@ -130,6 +390,15 @@ function nextMsg(ws, type, timeout = 3000) {
|
||||
await nextMsg(agent, 'session-ended');
|
||||
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
|
||||
const audit = await get('/api/audit', cookie);
|
||||
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);
|
||||
|
||||
agent.close(); viewer.close();
|
||||
hookSrv.close();
|
||||
console.log(`\n${passed} passed, ${failed} failed.`);
|
||||
server.close();
|
||||
process.exit(failed ? 1 : 0);
|
||||
|
||||
Reference in New Issue
Block a user