23 Revīzijas

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 16:27:59 +05:30
Sravan 27355cec76 BizGaze Connect: chat, meetings, recordings, mobile, directory + UI fixes
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 16:15:29 +05:30
Sravan 0a739ee2fd Merge branch 'hotfix/bizgaze-only-login' of Sravan/BizGaze_Remote into master 2026-06-16 09:21:35 +00:00
Sravan 54b74d5db1 feat(turn): self-hosted coturn support + time-limited creds + failure UX
- /api/ice: when TURN_SECRET is set, mint short-lived HMAC credentials
  (coturn use-auth-secret) so no permanent password is exposed and the relay
  can't be abused. Static TURN_USERNAME/CREDENTIAL still supported.
- share.html: connection watchdog + clear "couldn't connect on this network"
  message instead of a blank screen when no path can be established.
- deploy/coturn: ready-to-run turnserver.conf + docker-compose + README for
  hosting our own TURN on a VM we own (flat cost, no per-GB billing).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 14:36:05 +05:30
Sravan 6ac280f178 fix(webrtc): use TURN on desktop too (screen share blank/disconnect)
TURN relay candidates were applied only when IS_MOBILE, leaving desktop
clients STUN-only. Customers behind symmetric NAT / corporate firewalls /
VPNs then couldn't establish the peer connection -> connectionState 'failed'
-> "connection lost" -> blank screen right after granting permissions. This
hit only some users (those needing a relay).

Apply the /api/ice config (STUN + managed TURN) regardless of device, in both
the customer (share.html) and agent (connect.html) flows. Requires TURN_URLS /
TURN_USERNAME / TURN_CREDENTIAL to be set in the production environment.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 13:15:06 +05:30
Sravan caba3b3a21 Merge branch 'hotfix/bizgaze-only-login' of Sravan/BizGaze_Remote into master 2026-06-16 05:24:28 +00:00
Sravan 5448cf0614 fix(auth): BizGaze-only login + admin sees all sessions
When BIZGAZE_LOGIN_URL is configured, verify credentials ONLY against BizGaze
(no local-password fallback) so stale in-app accounts can't shadow a BizGaze
login. Everyone is then provisioned into the same tenant, restoring the admin's
team-scoped "see all sessions" report.

- login: BizGaze-only when the IdP is configured; local path kept for dev/tests
- provisionFromBizgaze: keep role in sync with BizGaze (isAdmin) on every login;
  optional ADMIN_EMAILS allowlist as a lockout safety net
- block POST /api/users (add local agent) when BizGaze is the IdP — this is what
  previously split tenants
- scripts/migrate-bizgaze-only.js: one-time, dry-run-by-default cleanup that
  deletes pre-BizGaze local accounts (no sso_user_created audit entry)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 19:02:08 +05:30
Sravan d045847a59 added Username or password do not match" + lockout warning 2026-06-12 01:13:31 +05:30
Sravan ba8bfc3f46 feat: BizGaze Connect home, BizGaze login, modular backend, /api/v1
User-facing
- New post-login home (/home): chat rail + Share/Connect (embedded) + Meeting; login lives here when logged out
- Landing: "Log in with BizGaze" + no-login screen share
- Console replaced by a role-scoped Dashboard (/dashboard): admins see all team sessions, others see only their own; stats + CSV/PDF export
- Recordings saved as MP4 (H.264/AAC) with WebM fallback; old .webm still downloadable
- Fix: duplicate "Sign in" on the login card

Auth / integration
- BizGaze as identity provider: /api/login validates against BIZGAZE_LOGIN_URL (env-gated) and provisions a local user
- Phase 2 start: /api/v1 alias for all /api routes; Authorization: Bearer accepted across HTTP + WS; login returns a token (for native desktop/mobile clients)

Backend refactor (Phase 1, behavior-preserving)
- Split server.js into config/lib/session/presence/routes/static/signaling + repos (data-access) + bizgaze (service)
- All SQL behind repos.js, tenant-scoped (tenantId == team_id for now)
- e2e updated to current flow (21/21 pass before and after)

Docs: ARCHITECTURE.md (target architecture + phased plan), CLAUDE.md repo layout, .env.example BIZGAZE_LOGIN_URL

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 00:40:07 +05:30
Sravan f6ebaa7bfb Merge branch 'master' of https://code.bizgaze.com/Sravan/BizGaze_Remote 2026-06-10 17:34:35 +05:30
Sravan 1d9ffcc3d4 Mobile screen share bug fix 2026-06-10 16:47:14 +05:30
Sravan 28f616d829 Added Record screen, transcribe, mobile screen share bug fix. 2026-06-10 16:46:03 +05:30
44 mainīti faili ar 7251 papildinājumiem un 944 dzēšanām
+4
Parādīt failu
@@ -12,6 +12,10 @@ TURN_CREDENTIAL=
# Optional: open self-registration of the first/any team (1 to enable).
# ALLOW_REGISTRATION=1
# Optional: BizGaze as the identity provider. When set, /api/login validates
# credentials against this endpoint (after a local check) and provisions the user.
# BIZGAZE_LOGIN_URL=https://c02.bizgaze.app/Account/ValidateAndLogin
# Optional: shared secret for BizGaze SSO + signed webhook delivery.
# SSO_SECRET=
+5
Parādīt failu
@@ -29,6 +29,11 @@ dist/
build/
out/
# Runtime media (created at startup by config.js)
server/recordings/
server/transcripts/
server/uploads/
# OS files
.DS_Store
Thumbs.db
+172
Parādīt failu
@@ -0,0 +1,172 @@
# BizGaze Connect — Architecture & Roadmap
This document records the **current** architecture, the **target** architecture, and a
**phased migration plan** so that the three strategic goals can be added *additively*
rather than as rewrites.
Strategic goals (see also `CLAUDE.md`):
1. **Native Android/iOS apps**
2. **Integration with any third-party application**
3. **Org-based licensing model** (Zoom-like: organizations buy seats/plans)
---
## 1. Current architecture (as of 2026-06)
```
Single Node process (server/server.js, ~640 lines)
├── HTTP JSON API (/api/*, cookie-session auth)
├── WebSocket signaling (/ws — SDP/ICE relay, consent, share codes)
├── Static file serving (public/*.html, single-file pages, no build)
└── In-process state liveSessions / onlineAgents / pendingShares (Maps)
Data: node:sqlite single file (server/data.db)
teams, users, sessions_auth, machines, audit_log, sessions_log
Media: WebRTC P2P (1:1). STUN + managed TURN. Media never traverses the server.
Auth: scrypt passwords, opaque session token in an HttpOnly `sid` cookie. TOTP code exists but
login currently marks sessions MFA-passed directly.
Integrations: outbound webhook (single env URL, `session.ended`, HMAC-signed);
inbound SSO (`/sso`, custom HMAC token).
Recordings/transcripts: written to local disk (server/recordings, server/transcripts).
```
### What is already future-proof (keep)
- **WebRTC + `/ws` signaling** — standards-based; reused as-is by native apps and an SFU.
- **P2P media** — no server media path for 1:1; cheap and private.
- **HMAC-signed webhooks** and **audit log** — right primitives, just need to scale out.
- **Team-scoped queries** — the seed of multi-tenancy is present.
### Structural constraints that block the roadmap
| # | Constraint | Blocks |
|---|-----------|--------|
| C1 | Auth is **cookie-only** (`parseCookies(req).sid`); no `Authorization: Bearer`, no API keys | Mobile, Integrations |
| C2 | **No API versioning** (`/api/...`) | Mobile (shipped clients pin a contract) |
| C3 | **`team` is a thin tenant** `(id,name,created_at)`; app assumes one team | Licensing |
| C4 | **Session state in process memory** (Maps) | Horizontal scale, Meetings |
| C5 | **SQLite single-writer**, queries inline at ~100 call-sites | Scale, multi-tenant isolation |
| C6 | **P2P mesh only** — no SFU | Multi-party meetings (Zoom-like) |
| C7 | **Recordings on local disk** | Multi-instance, per-org storage quotas |
| C8 | **Monolithic `server.js`** mixes HTTP/WS/static/logic/DB | All (maintainability) |
---
## 2. Target architecture (principles)
1. **Organization is the top-level tenant.** Every row and every request resolves to an
`org_id`. Billing, seats, plan, and feature flags hang off the Organization.
2. **Data access goes through a repository layer**, never raw SQL in route handlers.
This is what makes the SQLite→Postgres migration and strict tenant-scoping feasible.
3. **The API is versioned and token-addressable.** `/api/v1`; auth accepted via cookie
(web) *or* `Authorization: Bearer` (mobile) *or* scoped API key (integrations).
4. **Shared runtime state lives outside the process** (Redis) so the app can run N instances.
5. **Multi-party media uses an SFU** (LiveKit/mediasoup); 1:1 may stay P2P.
6. **Entitlements are enforced centrally** — one middleware checks plan limits before
privileged actions (add seat, start meeting, record, call the API).
```
┌──────────── clients ────────────┐
│ web (HTML) mobile (native) 3rd-party (API key) │
└───────┬───────────┬───────────────┬──────────────┘
│ cookie │ Bearer │ API key
┌───────▼───────────▼───────────────▼──────────────┐
│ API v1 (routes → services → repository) │
│ authN (cookie/Bearer/key) · authZ (RBAC) │
│ entitlements middleware (plan/seat/feature) │
└───────┬───────────────────────┬──────────────────┘
│ │
┌─────────▼────────┐ ┌─────────▼─────────┐
│ Repository layer │ │ Signaling (ws) │──── Redis (shared state,
│ (SQLite→Postgres)│ │ + SFU for meetings│ pub/sub across instances)
└─────────┬────────┘ └───────────────────┘
Postgres · Object storage (recordings) · usage-metering
```
---
## 3. Goal-by-goal requirements
### Goal 1 — Native Android/iOS
- **Bearer-token auth** (C1) + refresh tokens; device registration.
- **`/api/v1`** (C2) — stable, documented contract.
- **Push notifications** (APNs/FCM) for incoming sessions/calls (mobile can't hold a background WS).
- Reuse WebRTC/`/ws` via `react-native-webrtc`/native SDKs; native screen capture
(ReplayKit / MediaProjection) for the phone-can't-share gap.
### Goal 2 — Third-party integration
- **Scoped API keys / OAuth2 client-credentials** per org (C1).
- **Webhook subscriptions** per org: multiple endpoints, event types, signed payloads, retries.
- **OIDC/JWT SSO** to replace the custom HMAC `/sso`.
- Optional: embeddable JS widget / SDK.
### Goal 3 — Org licensing (Zoom-like)
- **Organization** entity (C3): `plan`, `seats`, `status`, `trial_ends_at`, feature flags.
- **Entitlements + metering**: tables for plan limits and usage (minutes, sessions, storage);
central enforcement middleware.
- **SFU** for multi-party meetings (C6); per-org concurrent-meeting / minute caps.
- **Redis shared state** (C4) for multi-instance; **Postgres** (C5); **object storage** (C7).
- Billing provider integration (e.g. Stripe) driving subscription state.
---
## 4. Phased plan
> **Priority (set by the user 2026-06-11): mobile + integration first; licensing last.**
> Principle unchanged: do the shared groundwork first, so later work is additive. Because
> licensing is last, the full **Organization** entity moves to Phase 3 with it — Phase 1 keeps
> only a *tenant-id abstraction* (mapping to today's `team_id`) so Phase-2 auth/keys don't need
> reworking when the tenant is later elevated to a full Organization.
### Phase 1 — Foundations (structural, no behavior change) ✅ DONE (2026-06-11)
- [x] Extracted a **data-access layer** (`repos.js`) — all SQL moved out of `server.js`.
- [x] **Modularized** `server.js``config / lib / session / presence / routes / static / signaling`
(plus `repos` data layer and `bizgaze` service). `server.js` is now a thin entry point.
- [x] Standardized a **tenant id** in the data layer (repo params named `tenantId`, == `team_id` today);
every query is tenant-scoped. *No Organization entity / plan-seats yet — that's Phase 3.*
- Verified behavior-preserving by `test/e2e.js` (21/21) before and after.
### Phase 2 — API + access ← **PRIORITY** (mobile + desktop + integrations)
Target clients: web (cookie), native **Android/iOS**, a native **Windows desktop app where the
viewer CONTROLS the remote screen** (reuses the WebRTC `inputChannel` + OS input injection like
`agent/`), and third-party systems (API keys). All authenticate through this one access layer.
- [x] **`/api/v1`** — every `/api/*` route aliased under `/api/v1/*` (routes.js); web keeps unversioned paths.
- [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).
- [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`
as alias/FK); add `plan`, `seats`, `status`, `features` columns.
- [ ] **Entitlements** module + central enforcement; **usage metering** (minutes/sessions/storage).
- [ ] **SFU** (LiveKit/mediasoup) for multi-party meetings; keep 1:1 P2P.
- [ ] **Redis** for `liveSessions/onlineAgents/pendingShares` + cross-instance pub/sub.
- [ ] **Postgres** migration (enabled by the Phase-1 data-access layer); **object storage** (S3) for recordings.
- [ ] Billing provider + subscription lifecycle webhooks.
### Explicitly NOT yet
- Don't add an SFU or Postgres before the data-access layer exists — you'd rework them.
- Don't build the full Organization/plan model before Phase 2 ships — but *do* keep the tenant-id
abstraction consistent from Phase 1 so the elevation is additive.
- Don't shard or add microservices; a well-modularized monolith + Redis + Postgres scales far enough.
---
## 5. Key decisions to confirm (when we reach them)
- **Auth tokens:** opaque (DB-backed, easy revoke) vs JWT (stateless, harder revoke). *Lean: opaque
access + refresh, since `sessions_auth` already works that way.*
- **SFU:** LiveKit (batteries-included, good mobile SDKs) vs mediasoup (lower-level, more control).
- **DB:** Postgres (recommended) — keep the repository layer DB-agnostic until the cutover.
- **Billing:** Stripe vs BizGaze's own billing (depends on how Connect sits inside the BizGaze suite).
+138
Parādīt failu
@@ -0,0 +1,138 @@
# BizGaze Connect — project brief
Place this file at the repo root (`remote-access-app/CLAUDE.md`). Claude Code reads
it automatically each session.
## What this is
**BizGaze Connect** — a no-install, browser-based remote support / screen-sharing
tool for the BizGaze ecosystem. A customer opens a page, gets a 6-digit code; a
signed-in BizGaze agent enters the code, the customer taps Allow, and the agent
sees the customer's screen with two-way voice + chat. Live at **remote.bizgaze.com**.
Roadmap: grow into a communication platform (meetings + persistent chat) for
registered BizGaze users.
## Tech stack (intentionally minimal — keep it this way)
- **Node.js >= 22.5**, single npm dependency: `ws` (WebSocket).
- **Built-in `node:sqlite`** (no native modules). DB file: `server/data.db`.
- **WebRTC** peer-to-peer for media (screen video + voice + data channels).
- **No build step, no framework.** Each page is a single self-contained HTML file
with inline `<style>` and `<script>`. Do not introduce React/bundlers.
- Auth: scrypt password hashing, HttpOnly session cookie. (SSO migration in progress.)
## Repo layout
```
server/
server.js # thin entry: HTTP dispatch + WS attach + listeners (HTTP/HTTPS)
config.js # env + filesystem paths (PORT, dirs, SESSION_TTL)
lib.js # HTTP helpers: json / readBody / parseCookies / now
session.js # currentUser (cookie -> user) + audit()
presence.js # shared in-memory live state (onlineAgents/liveSessions/pendingShares)
routes.js # HTTP JSON API (/api/*, /sso) -> { "METHOD /path": handler } map
static.js # static file serving + authenticated recording/transcript downloads
signaling.js # WebSocket signaling (consent + SDP/ICE relay)
repos.js # data-access layer — ALL SQL lives here (tenant-scoped)
bizgaze.js # BizGaze identity provider (validate login, env-gated)
db.js # node:sqlite schema + idempotent migrations
auth.js # scrypt hashing, token/id generation, TOTP helpers
package.json # { "dependencies": { "ws": "^8.18" }, engines node>=22.5 }
test/e2e.js # 21-check backend e2e (register->login->session->signaling->audit)
public/
index.html # public landing (Log in with BizGaze / share without login)
home.html # post-login shell: chat rail + Share/Connect (iframe) + Meeting (/home)
dashboard.html# login + role-scoped session report (/dashboard, replaces /console)
connect.html # agent: enter code, view screen, control bar (/connect)
share.html # customer: get code, share screen (/share)
home-mockup.html # locked design reference for home
logo.png
recordings/ # saved session recordings (.webm) [created at runtime]
transcripts/ # saved transcripts (.txt) [created at runtime]
```
Architecture/roadmap detail lives in `ARCHITECTURE.md`. Backend SQL must go through
`repos.js` (never inline in routes/signaling). Run `node test/e2e.js` after backend edits.
## Run locally
```
cd server && npm install && node server.js
# HTTP on :8090 (HTTPS on :8443 only if cert.pem + key.pem exist in server/)
# Env: ALLOW_REGISTRATION=1 opens the first-team registration
```
First registered user becomes admin; registration then closes (unless ALLOW_REGISTRATION=1).
## Key HTTP routes (server.js)
- `POST /api/register|login|logout`, `GET /api/me`, `GET/POST /api/users`,
`POST /api/users/manage`, `GET /api/setup-state`, `GET /api/report`
- `GET /api/ice` — returns STUN, plus managed TURN **only for mobile clients**
(TURN creds come from env: `TURN_URLS`, `TURN_USERNAME`, `TURN_CREDENTIAL`)
- `POST /api/recording?sessionId=` / `POST /api/transcript?sessionId=` — uploads
- `GET /recordings/<sid>.webm` / `GET /transcripts/<sid>.txt` — authed downloads (streamed w/ Content-Length)
- `GET /sso?token=` — SSO entry (HMAC today; JWT migration planned)
- Page routes: `/`, `/console`, `/connect`, `/share`
## WebSocket signaling (`/ws`)
`liveSessions` map (sessionId -> {agentWs, viewerWs, ...}). Message cases:
`agent-hello`, `viewer-connect`, `consent`, `share-create`, `code-connect`,
`offer`/`answer`/`ice-candidate` (relayed peer-to-peer), `recording`, `transcript`,
`end-session`. Keepalive ping every 25s. Media never traverses the server.
## Current features (all working on desktop)
- Code-based no-install screen share (customer shares, agent views).
- Two-way voice; in-session chat (logged-in sharer's name shown).
- **Session recording**: agent presses Record; mixes customer screen + both voices;
uploads `.webm`; downloadable from the report. Customer sees a "being recorded"
banner + live timer.
- **Auto-transcript**: each side runs Web Speech API on its own mic; lines stream to
the agent; combined `.txt` (voices + chat) uploaded; downloadable from the report.
- **Session report**: filter by agent/date, CSV + PDF export, pagination (5/page),
agent search, recording/transcript download links.
- Agent management (admin invites, roles admin/technician/viewer), remember-me,
case-insensitive email, password show/hide, session-end webhook to BizGaze.
## Hard constraints (do not try to "fix" these)
- **Mobile browsers CANNOT share their screen.** Android Chrome and iOS Safari do
not expose `getDisplayMedia` screen capture to web pages. Only a native app can
capture a phone screen. The share page detects mobile and shows a clear message.
(Desktop screen share works fully.)
- Screen capture requires a user gesture → `getDisplayMedia` is called directly from
the customer's "Allow" tap (see share.html `beginCapture`).
- Recording/transcript use browser MediaRecorder + Web Speech API → Chrome/Edge only.
## Production
- `remote.bizgaze.com`, Linux, Docker, behind a reverse proxy.
- Proxy MUST: upgrade `/ws` with long timeouts; allow large bodies on
`/api/recording`; not buffer `/recordings/` downloads. (See IT-HANDOFF-PROXY.md.)
- Env vars: `TURN_URLS`, `TURN_USERNAME`, `TURN_CREDENTIAL` (Metered TURN),
`SSO_SECRET`, `BIZGAZE_WEBHOOK_URL`, `BIZGAZE_LOGIN_URL` (identity provider for `/api/login`),
`ALLOW_REGISTRATION`, `DB_PATH`, `PORT`, `HTTPS_PORT`.
## In progress / roadmap
1. **SSO with BizGaze** (active): BizGaze becomes the identity provider. It issues a
signed token; `/sso` verifies it and creates a local session. Supports both
"from inside BizGaze" and a "Log in with BizGaze" button at our URL. Waiting on
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** — 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)
After login, replace the current dashboard with a BizGaze Connect "home":
- **Left sidebar (Slack-style):** list of recent chats/contacts with avatar,
name, last-message preview, unread badge. (Mock data first — no chat backend yet.)
- **Main area with tabs:** **Meeting** (placeholder "coming soon"), **Share Screen**
(links to the existing share flow), **Connect Screen** (existing agent connect flow).
- Top bar: BizGaze Connect wordmark (brand blue #1F3B73 / yellow #FFC708, logo.png),
profile dropdown (existing pattern in the HTML).
- Build a **standalone static mockup first** (e.g. `public/home-mockup.html`) to lock
the layout, then wire the real tabs/sidebar. Keep the single-file, no-framework style.
## Conventions
- Brand: blue `#1F3B73`, yellow `#FFC708`, logo at `/logo.png`.
- Single-file HTML pages; reuse the existing `profileHTML()`/`wireProfile()` and
brand patterns already in console.html/connect.html.
- Always `node --check` extracted inline scripts after edits; test against a local
`node server.js` before committing.
+44
Parādīt failu
@@ -0,0 +1,44 @@
# Self-hosted TURN (coturn) for BizGaze Connect
Why: customers behind symmetric NAT / corporate firewalls / VPNs can't form a direct
WebRTC path, so screen share blanks out and disconnects. A TURN relay fixes it. We host
our own coturn on a VM we already own — flat cost, no per-GB billing.
## 1. VM prerequisites
- A VM with a **public IP** (your data-center VM is fine).
- A DNS A record, e.g. `turn.yourdomain.com` -> that public IP.
- A TLS cert for that name (Let's Encrypt): `certbot certonly --standalone -d turn.yourdomain.com`
## 2. Open firewall ports (on the VM and any edge firewall)
- `3478/udp` and `3478/tcp` (STUN/TURN)
- `5349/tcp` (TURN over TLS) — and `443/tcp` if you enable alt-tls
- `49152-65535/udp` (relay range)
## 3. Configure
Edit `turnserver.conf`:
- `external-ip=` your VM's public IP
- `static-auth-secret=` a long random string (e.g. `openssl rand -hex 32`)
- `realm=` your domain
- `cert=` / `pkey=` paths to your Let's Encrypt cert
## 4. Run
```
docker compose up -d # uses docker-compose.yml here
# or native: apt install coturn; copy this file to /etc/turnserver.conf; enable in /etc/default/coturn; systemctl enable --now coturn
```
## 5. Point the app at it (production env)
```
TURN_URLS=turn:turn.yourdomain.com:3478,turn:turn.yourdomain.com:3478?transport=tcp,turns:turn.yourdomain.com:5349?transport=tcp
TURN_SECRET=<the same static-auth-secret from turnserver.conf>
TURN_TTL=86400
```
The app's `/api/ice` mints short-lived credentials from `TURN_SECRET` automatically — no
permanent password is exposed, and outsiders can't reuse your relay. Restart the app.
## 6. Verify
- `GET https://<app>/api/ice` should return a `turn:`/`turns:` entry with a username + credential.
- Test page: https://webrtc.github.io/samples/src/content/peerconnection/trickle-ice/
Add your `turns:turn.yourdomain.com:5349?transport=tcp` with the username/credential from
`/api/ice`; you should see a candidate of type **relay**. If you do, restrictive networks
are covered.
+12
Parādīt failu
@@ -0,0 +1,12 @@
# Run coturn on your VM: docker compose up -d
# host networking is required so the UDP relay port range works without per-port mapping.
services:
coturn:
image: coturn/coturn:latest
container_name: coturn
restart: unless-stopped
network_mode: host
volumes:
- ./turnserver.conf:/etc/coturn/turnserver.conf:ro
- /etc/letsencrypt:/etc/letsencrypt:ro # TLS cert for turns:
command: ["-c", "/etc/coturn/turnserver.conf"]
+45
Parādīt failu
@@ -0,0 +1,45 @@
# coturn config for BizGaze Connect self-hosted TURN.
# Put this on your VM (public IP) and run via Docker (see docker-compose.yml) or
# native coturn (apt install coturn). Replace every CHANGE_ME / placeholder.
# --- listening ---
listening-port=3478
tls-listening-port=5349
# If this VM has a spare 443, also exposing TURNS on 443 gives the best traversal
# through strict corporate firewalls (uncomment + ensure nothing else uses 443):
# alt-tls-listening-port=443
# Public address clients reach. If the VM has a 1:1 NAT, use external-ip=PUBLIC/PRIVATE.
external-ip=CHANGE_ME_PUBLIC_IP
# Relay port range (open these UDP ports in the firewall too).
min-port=49152
max-port=65535
# --- auth: time-limited shared-secret credentials (matches the app's TURN_SECRET) ---
use-auth-secret
static-auth-secret=CHANGE_ME_LONG_RANDOM_SECRET
realm=connect.yourdomain.com
# --- TLS (needed for turns: on 5349/443). Use a real cert for turn.yourdomain.com ---
cert=/etc/letsencrypt/live/turn.yourdomain.com/fullchain.pem
pkey=/etc/letsencrypt/live/turn.yourdomain.com/privkey.pem
# --- hardening ---
fingerprint
no-cli
no-multicast-peers
no-tcp-relay
# Block relaying to private/internal ranges (prevents your relay being used to reach
# your own LAN / cloud metadata — important SSRF protection):
denied-peer-ip=0.0.0.0-0.255.255.255
denied-peer-ip=10.0.0.0-10.255.255.255
denied-peer-ip=100.64.0.0-100.127.255.255
denied-peer-ip=169.254.0.0-169.254.255.255
denied-peer-ip=172.16.0.0-172.31.255.255
denied-peer-ip=192.168.0.0-192.168.255.255
denied-peer-ip=::1
denied-peer-ip=fc00::-fdff:ffff:ffff:ffff:ffff:ffff:ffff:ffff
# Optional: cap per-session bandwidth (bytes/sec) to protect the VM, e.g. 700000 = ~5.6 Mbps
# bps-capacity=0
# total-quota=100
+3 -1
Parādīt failu
@@ -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,
};
+58
Parādīt failu
@@ -0,0 +1,58 @@
// BizGaze as identity provider.
// Validates a username/password against BizGaze's ValidateAndLogin endpoint.
// Enabled only when BIZGAZE_LOGIN_URL is set (so tests/local runs stay self-contained).
//
// Success response shape (observed):
// { status: 1, currentSession: { name, userId, tenantId, unibaseId, isAdmin, ... }, message }
// Failure: status !== 1, with a `message`.
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 };
let res;
try {
res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ UserName: username, Password: password, UnibaseId: '', RememberMe: false }),
signal: AbortSignal.timeout(15000),
});
} catch (e) {
return { ok: false, configured: true, error: 'BizGaze sign-in is unavailable right now' };
}
let data;
try { data = await res.json(); } catch { return { ok: false, configured: true, error: 'Unexpected response from BizGaze' }; }
const s = data && data.currentSession;
if (data && data.status === 1 && s) {
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,
unibaseId: s.unibaseId || null,
message: data.message || 'Login Success',
};
}
return { ok: false, configured: true, message: (data && data.message) || 'Invalid BizGaze credentials' };
}
module.exports = { validateLogin, isEnabled };
+151
Parādīt failu
@@ -0,0 +1,151 @@
// Shared group calls: one live call per group. Members join without a code; the call
// ends (with a duration line in the chat) when the last participant's mesh room empties.
const fs = require('fs');
const path = require('path');
const R = require('./repos');
const A = require('./auth');
const CHAT = require('./chat');
const { TRANS_DIR } = require('./config');
const { meetingRooms, groupCalls, roomToGroupCall, dmCalls, roomToDmCall, roomHost, transcriptBuffers, transcriptSubs } = require('./presence');
const now = () => Date.now();
const pairKey = (a, b) => [a, b].sort().join('|');
// Resolve a room's meeting context (group / scheduled meeting / title) for labelling recordings.
function meetingContext(room) {
const ctx = { groupId: null, meetingId: null, title: 'Meeting' };
try {
const sched = R.scheduledMeetings.byCode(room);
if (sched) { ctx.meetingId = sched.id; ctx.groupId = sched.group_id || null; ctx.title = sched.title || 'Meeting'; }
} catch (_) {}
if (!ctx.groupId) { const gid = roomToGroupCall.get(room); if (gid) ctx.groupId = gid; }
if (ctx.groupId && ctx.title === 'Meeting') { try { const g = R.conversations.byId(ctx.groupId); if (g) ctx.title = g.name || 'Group'; } catch (_) {} }
if (!ctx.groupId && !ctx.meetingId && roomToDmCall.has(room)) ctx.title = 'Direct Call';
return ctx;
}
// Save the FULL shared conversation transcript as a PRIVATE copy for each subscriber. onlyUserId
// finalizes just that subscriber (on their leave / opt-out); omit to flush all remaining (room end).
// Must run BEFORE endCallByRoom (which clears the room→meeting maps meetingContext relies on).
function finalizeTranscript(room, onlyUserId) {
const subs = transcriptSubs.get(room); if (!subs || !subs.size) { if (!onlyUserId) { transcriptBuffers.delete(room); transcriptSubs.delete(room); } return; }
const buf = transcriptBuffers.get(room) || [];
const ids = onlyUserId ? (subs.has(onlyUserId) ? [onlyUserId] : []) : [...subs];
if (ids.length && buf.length) {
const ctx = meetingContext(room);
const lines = buf.map((s) => { const ts = new Date(s.t); const hh = String(ts.getHours()).padStart(2, '0'), mm = String(ts.getMinutes()).padStart(2, '0'); return '[' + hh + ':' + mm + '] ' + s.speaker + ': ' + s.text; });
const body = ctx.title + ' — transcript\n' + new Date(buf[0].t).toLocaleString() + '\n\n' + lines.join('\n') + '\n';
for (const uid of ids) {
let user = null; try { user = R.users.byId(uid); } catch (_) {}
if (!user) { subs.delete(uid); continue; }
const id = A.id(); const file = 'm_' + id + '.txt';
try { fs.writeFileSync(path.join(TRANS_DIR, file), body); } catch (e) { continue; }
// groupId null → private to its creator (see canSeeRec / /mrec auth).
R.recordings.create({ id, teamId: user.team_id, room, groupId: null, meetingId: ctx.meetingId, title: ctx.title, kind: 'transcript', file, mime: 'text/plain', size: null, durationMs: null, createdBy: uid, createdByName: user.name || user.email });
subs.delete(uid);
}
} else { ids.forEach((uid) => subs.delete(uid)); }
if (!subs.size) { transcriptBuffers.delete(room); transcriptSubs.delete(room); } // last subscriber done
}
function fmtDur(ms) { const s = Math.max(0, Math.round(ms / 1000)); const m = Math.floor(s / 60); return m ? (m + 'm ' + (s % 60) + 's') : (s + 's'); }
function broadcast(group, evt) { try { for (const mid of R.conversations.members(group)) CHAT.pushToUser(mid, evt); } catch (_) {} }
// Post a centered activity line into the group (system sender → no ping on clients).
function postSystem(group, teamId, text) {
const id = A.id();
R.messages.send({ id, teamId, senderId: '__system__', recipientId: '', body: text, conversationId: group });
const m = R.messages.byId(id);
broadcast(group, { type: 'chat-message', message: { id: m.id, from: '__system__', conversation_id: group, body: m.body, created_at: m.created_at, system: true } });
}
function startGroupCall(group, teamId, user) {
const existing = groupCalls.get(group);
if (existing) return { room: existing.room, active: true, already: true };
let room; do { room = A.numericCode(6); } while (meetingRooms.has(room));
meetingRooms.set(room, new Map());
const call = { room, startedAt: now(), startedBy: user.id, startedByName: user.name || user.email };
// Log the call as a meeting so it appears under Past meetings (history) with the group name.
try { const hid = A.id(); R.scheduledMeetings.create({ id: hid, teamId, groupId: group, roomCode: room, title: 'Group call', description: null, scheduledAt: now(), createdBy: user.id }); call.historyId = hid; call.teamId = teamId; } catch (_) {}
groupCalls.set(group, call); roomToGroupCall.set(room, group); roomHost.set(room, user.id); // creator = host
postSystem(group, teamId, '📞 ' + call.startedByName + ' started a group call');
let gName = 'Group'; try { const g = R.conversations.byId(group); if (g) gName = g.name || 'Group'; } catch (_) {}
broadcast(group, { type: 'group-call', group, active: true, room, by: user.id, startedByName: call.startedByName, groupName: gName });
return { room, active: true };
}
// Called from signaling when a mesh room empties — ends the group call if this room was one.
function endGroupCallByRoom(room) {
const group = roomToGroupCall.get(room);
if (!group) return;
const call = groupCalls.get(group);
roomToGroupCall.delete(room); groupCalls.delete(group); roomHost.delete(room);
if (call) {
let teamId = call.teamId; try { const g = R.conversations.byId(group); if (g) { teamId = g.team_id; postSystem(group, g.team_id, '📞 Group call ended · ' + fmtDur(now() - call.startedAt)); } } catch (_) {}
if (call.historyId && teamId) { try { R.scheduledMeetings.end(call.historyId, teamId); } catch (_) {} } // mark the history row past
broadcast(group, { type: 'group-call', group, active: false, room });
}
}
// 1:1 (DM) call. Notifies both parties (state + a chat line) so the callee sees "Join".
function startDmCall(me, otherId, teamId) {
const key = pairKey(me.id, otherId);
const existing = dmCalls.get(key);
if (existing) return { room: existing.room, active: true, already: true };
let room; do { room = A.numericCode(6); } while (meetingRooms.has(room));
meetingRooms.set(room, new Map());
const byName = me.name || me.email;
const call = { room, startedAt: now(), startedBy: me.id, startedByName: byName, users: [me.id, otherId], teamId };
// Log to history (both participants) so the call shows under Past meetings with its transcript.
try { const hid = A.id(); R.scheduledMeetings.create({ id: hid, teamId, groupId: null, roomCode: room, title: 'Direct Call', description: null, scheduledAt: now(), createdBy: me.id, participants: [me.id, otherId] }); call.historyId = hid; } catch (_) {}
dmCalls.set(key, call); roomToDmCall.set(room, key); roomHost.set(room, me.id); // caller = host
// A viewer-relative activity line: the caller sees "You started a call", the callee sees the name.
const mid = A.id();
R.messages.send({ id: mid, teamId, senderId: me.id, recipientId: otherId, body: '📞 Started a call', msgType: 'call-start' });
const m = R.messages.byId(mid); const dto = { id: m.id, from: me.id, to: otherId, conversation_id: null, body: m.body, created_at: m.created_at, system: true, evt: 'call-start', byName };
try { CHAT.pushToUser(otherId, { type: 'chat-message', message: dto }); } catch (_) {}
try { CHAT.pushToUser(me.id, { type: 'chat-message', message: dto }); } catch (_) {}
try { CHAT.pushToUser(otherId, { type: 'dm-call', active: true, room, with: me.id, by: me.id, byName }); } catch (_) {}
try { CHAT.pushToUser(me.id, { type: 'dm-call', active: true, room, with: otherId, by: me.id, byName }); } catch (_) {}
return { room, active: true };
}
function endDmCallByRoom(room, silent) {
const key = roomToDmCall.get(room); if (!key) return;
const call = dmCalls.get(key);
roomToDmCall.delete(room); dmCalls.delete(key); roomHost.delete(room);
if (!call) return;
if (call.historyId && call.teamId) { try { R.scheduledMeetings.end(call.historyId, call.teamId); } catch (_) {} } // mark history past
// "Call ended · duration" activity line in the DM (shown to both) — skipped on decline.
if (!silent) try {
const mid = A.id(); const body = '📞 Call ended · ' + fmtDur(now() - call.startedAt);
R.messages.send({ id: mid, teamId: call.teamId, senderId: call.startedBy, recipientId: call.users.find((u) => u !== call.startedBy) || '', body, msgType: 'call-end' });
const m = R.messages.byId(mid); const dto = { id: m.id, from: call.startedBy, to: m.recipient_id, conversation_id: null, body, created_at: m.created_at, system: true, evt: 'call-end' };
call.users.forEach((uid) => { try { CHAT.pushToUser(uid, { type: 'chat-message', message: dto }); } catch (_) {} });
} catch (_) {}
call.users.forEach((uid, i) => { try { CHAT.pushToUser(uid, { type: 'dm-call', active: false, with: call.users[1 - i], room }); } catch (_) {} });
}
// Called from signaling when any mesh room empties.
function endCallByRoom(room) { endGroupCallByRoom(room); endDmCallByRoom(room); }
// Callee declines a 1:1 call: post "Call declined" into the DM, drop the waiting caller, end it.
function declineDmCall(room, byUser) {
const key = roomToDmCall.get(room); if (!key) return { ok: false };
const call = dmCalls.get(key); if (!call) return { ok: false };
const callerId = call.users.find((id) => id !== byUser.id) || call.startedBy;
try {
const mid = A.id();
R.messages.send({ id: mid, teamId: byUser.team_id, senderId: byUser.id, recipientId: callerId, body: '📞 Call declined', msgType: 'call-end' });
const mm = R.messages.byId(mid); const dto = { id: mm.id, from: byUser.id, to: callerId, conversation_id: null, body: mm.body, created_at: mm.created_at, system: true, evt: 'call-end' };
CHAT.pushToUser(callerId, { type: 'chat-message', message: dto });
CHAT.pushToUser(byUser.id, { type: 'chat-message', message: dto });
} catch (_) {}
// Drop the caller who's still waiting in the (otherwise empty) mesh room.
const peers = meetingRooms.get(room);
if (peers) { for (const [, p] of peers) { if (p.ws.readyState === 1) { try { p.ws.send(JSON.stringify({ type: 'meeting-ended' })); } catch (_) {} p._meetingRoom = null; } } meetingRooms.delete(room); }
endDmCallByRoom(room, true); // silent: we already posted "Call declined"
return { ok: true };
}
module.exports = { startGroupCall, startDmCall, endGroupCallByRoom, endDmCallByRoom, endCallByRoom, declineDmCall, finalizeTranscript, meetingContext, fmtDur, pairKey };
+31
Parādīt failu
@@ -0,0 +1,31 @@
// Chat presence + real-time delivery. A logged-in user opens a WebSocket and sends
// `chat-hello`; signaling.js registers the socket here. Messages are persisted over HTTP
// (routes.js) and pushed live to the recipient's sockets via pushToUser().
const { chatClients } = require('./presence');
function register(userId, ws) {
if (!chatClients.has(userId)) chatClients.set(userId, new Set());
chatClients.get(userId).add(ws);
ws._chatUserId = userId;
}
function unregister(ws) {
const id = ws && ws._chatUserId;
if (!id) return;
const set = chatClients.get(id);
if (set) { set.delete(ws); if (!set.size) chatClients.delete(id); }
}
function isOnline(userId) {
const s = chatClients.get(userId);
return !!(s && s.size);
}
function pushToUser(userId, obj) {
const s = chatClients.get(userId);
if (!s) return;
const data = JSON.stringify(obj);
for (const ws of s) { if (ws.readyState === 1) { try { ws.send(data); } catch (_) {} } }
}
module.exports = { register, unregister, isOnline, pushToUser };
+22
Parādīt failu
@@ -0,0 +1,22 @@
// Runtime config + filesystem paths. Reads process.env once at startup.
const fs = require('fs');
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,
HTTPS_PORT: process.env.HTTPS_PORT || 8443,
PUBLIC_DIR,
REC_DIR,
TRANS_DIR,
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)
};
+224
Parādīt failu
@@ -77,4 +77,228 @@ CREATE TABLE IF NOT EXISTS sessions_log (
);
`);
// Migration: stored recording filename for a session (null if not recorded)
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);
`);
// Web Push subscriptions (one per browser/device per user) for background/closed-tab
// notifications. endpoint is unique; p256dh+auth are the encryption keys from the browser.
db.exec(`
CREATE TABLE IF NOT EXISTS push_subscriptions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
endpoint TEXT NOT NULL UNIQUE,
p256dh TEXT NOT NULL,
auth TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_push_user ON push_subscriptions(user_id);
`);
module.exports = db;
+57
Parādīt failu
@@ -0,0 +1,57 @@
// BizGaze user-directory search (cross-tenant). The auth token is kept SERVER-SIDE only — the
// browser calls /api/directory/search and never sees the token. Configure via env in production:
// BIZGAZE_DIRECTORY_URL (base, the search term is appended url-encoded)
// BIZGAZE_DIRECTORY_TOKEN (the "stat ..." Authorization header value)
const DEFAULT_URL = 'https://app.bizgaze.com/apis/v4/bizgaze/integrations/users_chatsearch/get_usersforchatsearch/searchterm/';
const DEFAULT_TOKEN = 'stat 3cd2e190b4db448496ae316b155d2441';
function baseUrl() { return process.env.BIZGAZE_DIRECTORY_URL || DEFAULT_URL; }
function token() { return process.env.BIZGAZE_DIRECTORY_TOKEN || DEFAULT_TOKEN; }
function enabled() { return !!(baseUrl() && token()); }
// Pull a field from an object by any of several case-insensitive key names.
function field(o, names) {
const keys = Object.keys(o || {});
for (const want of names) { for (const k of keys) { if (k.toLowerCase() === want) { const v = o[k]; if (v != null && v !== '') return String(v); } } }
return '';
}
// BizGaze responses vary (raw array, or wrapped in Result/data, sometimes a JSON string). Normalize.
function toArray(data) {
let d = data;
if (typeof d === 'string') { try { d = JSON.parse(d); } catch (_) { return []; } }
if (Array.isArray(d)) return d;
if (d && typeof d === 'object') {
for (const key of ['Result', 'result', 'data', 'Data', 'records', 'Records', 'items', 'Items']) {
if (d[key] != null) { let v = d[key]; if (typeof v === 'string') { try { v = JSON.parse(v); } catch (_) {} } if (Array.isArray(v)) return v; }
}
}
return [];
}
function pick(o) {
return {
id: field(o, ['userid', 'id', 'contactid', 'partyid', 'recordid']),
name: field(o, ['fullname', 'name', 'displayname', 'username', 'contactname', 'firstname']),
email: field(o, ['email', 'emailaddress', 'emailid', 'mail']),
phone: field(o, ['mobile', 'mobilenumber', 'phone', 'phonenumber', 'contactno', 'contactnumber']),
avatar: field(o, ['photourl', 'photo', 'avatar', 'imageurl', 'profilepic', 'profileimage']),
org: field(o, ['organization', 'organisation', 'company', 'tenantname', 'orgname']),
};
}
async function search(term) {
if (!enabled() || !term || term.trim().length < 2) return [];
const url = baseUrl() + encodeURIComponent(term.trim());
const ctrl = new AbortController();
const to = setTimeout(() => ctrl.abort(), 8000);
try {
const r = await fetch(url, { headers: { Authorization: token(), Accept: 'application/json' }, signal: ctrl.signal });
if (!r.ok) return [];
const data = await r.json().catch(() => null);
return toArray(data).map(pick).filter((x) => x.name || x.email || x.phone).slice(0, 25);
} catch (_) { return []; }
finally { clearTimeout(to); }
}
module.exports = { search, enabled };
+30
Parādīt failu
@@ -0,0 +1,30 @@
// Small HTTP helpers shared across the server.
const now = () => Date.now();
const json = (res, code, body) => {
// no-store: API/JSON responses (and 404s) must never be cached — a cached 404 for an asset
// like /manifest.json would otherwise persist on a device even after the file is deployed.
res.writeHead(code, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
res.end(JSON.stringify(body));
};
function readBody(req) {
return new Promise((resolve) => {
let data = '';
req.on('data', (c) => (data += c));
req.on('end', () => {
try { resolve(data ? JSON.parse(data) : {}); } catch { resolve({}); }
});
});
}
function parseCookies(req) {
const out = {};
(req.headers.cookie || '').split(';').forEach((c) => {
const [k, ...v] = c.trim().split('=');
if (k) out[k] = decodeURIComponent(v.join('='));
});
return out;
}
module.exports = { now, json, readBody, parseCookies };
+192 -4
Parādīt failu
@@ -1,17 +1,205 @@
{
"name": "remote-access-server",
"version": "0.2.0",
"name": "bizgaze-support-server",
"version": "2.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "remote-access-server",
"version": "0.2.0",
"name": "bizgaze-support-server",
"version": "2.0.0",
"dependencies": {
"web-push": "^3.6.7",
"ws": "^8.18.0"
},
"engines": {
"node": ">=22.5.0"
},
"optionalDependencies": {
"nodemailer": "^6.9.14"
}
},
"node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"node_modules/asn1.js": {
"version": "5.4.1",
"resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
"integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
"license": "MIT",
"dependencies": {
"bn.js": "^4.0.0",
"inherits": "^2.0.1",
"minimalistic-assert": "^1.0.0",
"safer-buffer": "^2.1.0"
}
},
"node_modules/bn.js": {
"version": "4.12.3",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz",
"integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==",
"license": "MIT"
},
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/http_ece": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz",
"integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==",
"license": "MIT",
"engines": {
"node": ">=16"
}
},
"node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
"license": "MIT",
"dependencies": {
"jwa": "^2.0.1",
"safe-buffer": "^5.0.1"
}
},
"node_modules/minimalistic-assert": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
"license": "ISC"
},
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/nodemailer": {
"version": "6.10.1",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz",
"integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==",
"license": "MIT-0",
"optional": true,
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/web-push": {
"version": "3.6.7",
"resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz",
"integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==",
"license": "MPL-2.0",
"dependencies": {
"asn1.js": "^5.3.0",
"http_ece": "1.2.0",
"https-proxy-agent": "^7.0.0",
"jws": "^4.0.0",
"minimist": "^1.2.5"
},
"bin": {
"web-push": "src/cli.js"
},
"engines": {
"node": ">= 16"
}
},
"node_modules/ws": {
+13 -4
Parādīt failu
@@ -3,8 +3,17 @@
"version": "2.0.0",
"description": "BizGaze Support — remote screen sharing: public landing, agent console, sessions, SSO + webhook for BizGaze integration",
"main": "server.js",
"scripts": { "start": "node server.js" },
"engines": { "node": ">=22.5.0" },
"dependencies": { "ws": "^8.18.0" },
"optionalDependencies": { "nodemailer": "^6.9.14" }
"scripts": {
"start": "node server.js"
},
"engines": {
"node": ">=22.5.0"
},
"dependencies": {
"web-push": "^3.6.7",
"ws": "^8.18.0"
},
"optionalDependencies": {
"nodemailer": "^6.9.14"
}
}
+16
Parādīt failu
@@ -0,0 +1,16 @@
// In-memory live state shared between the HTTP routes and the WebSocket signaling layer.
// NOTE (roadmap): this is the piece that must move to Redis to run multiple instances.
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)
};
Bināro failu nav iespējams attēlot.

Pēc

Platums:  |  Augstums:  |  Izmērs: 10 KiB

+148 -14
Parādīt failu
@@ -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)}
@@ -39,9 +43,25 @@
.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}
.pwwrap{position:relative;}
.pwwrap input{padding-right:2.7rem;}
.eye{position:absolute;right:.5rem;top:50%;transform:translateY(-50%);background:none;border:none;padding:.3rem;width:auto;color:var(--muted);display:inline-flex;align-items:center;cursor:pointer;margin:0;}
.eye:hover{color:var(--blue);}
#homeLink{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);}
.formerr{color:#b91c1c;font-weight:600;font-size:.88rem;margin-top:.9rem;min-height:1.1em;text-align:left;}
.formerr.show{display:flex;align-items:center;gap:.5rem;background:#fee2e2;border:1px solid #fca5a5;border-radius:9px;padding:.6rem .75rem;animation:errShake .35s;}
.formerr.show::before{content:"⚠";font-size:1rem;}
@keyframes errShake{0%,100%{transform:translateX(0)}20%,60%{transform:translateX(-5px)}40%,80%{transform:translateX(5px)}}
/* Embedded inside the home shell: hide own chrome (the shell provides it). */
html.embed .topbar{display:none!important;}
html.embed #homeLink{display:none!important;}
html.embed #video{height:100vh!important;}
</style>
<script src="/icons.js?v=3"></script>
</head>
<body>
<script>if(new URLSearchParams(location.search).get('embed')==='1')document.documentElement.classList.add('embed');</script>
<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>
@@ -52,10 +72,14 @@
<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)ICE=c;}).catch(()=>{});}catch(_){}
async function ensureIce(){try{await __icePromise;}catch(_){}return ICE;}
function pEsc(s){return String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));}
function profileHTML(name){return '<div class="profile"><button class="pbtn" id="pbtn">'+pEsc(name)+' <span style="font-size:.65rem">&#9662;</span></button><div class="pmenu" id="pmenu"><a href="/console">Console / Dashboard</a><a id="plogout" class="danger">Logout</a></div></div>';}
// When embedded in the home shell, tell the parent when a session is live so the
// rail can show a "return here" indicator.
function bzcSession(active){try{if(window.parent&&window.parent!==window)window.parent.postMessage({type:'bzc-session',flow:'connect',active:!!active},location.origin);}catch(_){}}
function profileHTML(name){return '<div class="profile"><button class="pbtn" id="pbtn">'+pEsc(name)+' <span style="font-size:.65rem">&#9662;</span></button><div class="pmenu" id="pmenu"><a href="/home">Home</a><a href="/dashboard">Dashboard</a><a id="plogout" class="danger">Logout</a></div></div>';}
function wireProfile(){const btn=document.getElementById('pbtn'),menu=document.getElementById('pmenu');if(!btn)return;btn.onclick=(e)=>{e.stopPropagation();menu.classList.toggle('open');};document.addEventListener('click',()=>menu.classList.remove('open'));const lo=document.getElementById('plogout');if(lo)lo.onclick=async()=>{try{await fetch('/api/logout',{method:'POST'});}catch(_){}location.href='/';};}
function makeBrandClickable(){document.querySelectorAll('.brandrow,.wordmark').forEach(el=>{el.style.cursor='pointer';el.addEventListener('click',()=>{location.href='/';});});}
makeBrandClickable();
@@ -82,25 +106,30 @@ function onEnter(ids, fn){ ids.forEach(id => { const el=document.getElementById(
})();
// ---- LOGIN ----
const EYE_OFF='<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg>';
const EYE_ON='<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>';
function wireEyes(){document.querySelectorAll('.eye').forEach(b=>{if(b._w)return;b._w=1;b.innerHTML=EYE_OFF;b.onclick=()=>{const inp=document.getElementById(b.getAttribute('data-for'));if(!inp)return;const show=inp.type==='password';inp.type=show?'text':'password';b.innerHTML=show?EYE_ON:EYE_OFF;};});}
function renderLogin(){
agentChip.textContent='';
card.innerHTML = `
<h1>Agent sign in</h1>
<h1>Sign in</h1>
<div class="sub">Sign in to start a support session. Your name is shown to the customer.</div>
<span class="lbl">Email</span><input id="email" type="email" placeholder="you@bizgaze.com">
<span class="lbl">Password</span><input id="pw" type="password" placeholder="password">
<span class="lbl">Password</span><div class="pwwrap"><input id="pw" type="password" placeholder="password"><button type="button" class="eye" data-for="pw" aria-label="Show password"></button></div>
<label style="display:flex;align-items:center;gap:.5rem;margin:.7rem 0;color:var(--ink);font-size:.9rem;cursor:pointer"><input type="checkbox" id="rememberMe" style="width:18px;height:18px;accent-color:var(--blue);margin:0"> Remember me on this device</label>
<button class="btn" id="loginBtn" style="width:100%">Sign in</button>
<div class="status err" id="err"></div>`;
<div class="formerr" id="err"></div>`;
{
const doSignIn=async()=>{
const errEl=document.getElementById('err'); errEl.textContent=''; errEl.classList.remove('show');
try{
await api('/api/login',{email:document.getElementById('email').value,password:document.getElementById('pw').value,remember:(document.getElementById('rememberMe')||{}).checked||false});
me=await api('/api/me',null,'GET'); renderAgent();
}catch(e){ document.getElementById('err').textContent=e.message; }
}catch(e){ errEl.textContent=/invalid credentials/i.test(e.message)?'Incorrect email or password. Please try again.':e.message; errEl.classList.add('show'); }
};
document.getElementById('loginBtn').onclick=doSignIn;
onEnter(['email','pw'], doSignIn);
wireEyes();
}
}
@@ -139,10 +168,12 @@ 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;
case 'transcript': if(recogActive&&m.text) addLine('customer', m.name||'Customer', m.text, !!m.chat); break;
case 'session-denied': renderEnded('The customer declined the request.'); break;
case 'session-ended': {
const msgs={'share-cancelled':'The customer cancelled the screen selection. Ask them to refresh their page for a new code.',
@@ -163,10 +194,15 @@ function renderWaiting(){
}
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';
{ const hl=document.getElementById('homeLink'); if(hl && !document.documentElement.classList.contains('embed')) hl.style.display=''; }
card.innerHTML=`
<h1>Session ended</h1>
<div class="sub">${esc(msg)}</div>
@@ -179,19 +215,112 @@ 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>';
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;
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
let recog=null, recogActive=false, transcriptLines=[];
let recTimerInt=null, recStartTs=0;
function fmtElapsedA(ms){const s=Math.max(0,Math.floor(ms/1000));return String(Math.floor(s/60)).padStart(2,'0')+':'+String(s%60).padStart(2,'0');}
function showRecTimer(on){
let c=document.getElementById('recTimer');
if(on){
if(!c){ c=document.createElement('div'); c.id='recTimer';
c.style.cssText='display:inline-flex;align-items:center;gap:6px;color:#fff;font-weight:700;font-size:.85rem;background:#dc2626;padding:.5rem .7rem;border-radius:12px';
c.innerHTML='<span style="width:9px;height:9px;border-radius:50%;background:#fff;display:inline-block;animation:recpulseA 1.2s infinite"></span><span id="recTimerVal">00:00</span>';
const bar=document.getElementById('sessionBar'), rb=document.getElementById('recBtn');
if(bar&&rb){ bar.insertBefore(c, rb); } else if(bar){ bar.appendChild(c); }
if(!document.getElementById('recPulseStyleA')){const st=document.createElement('style');st.id='recPulseStyleA';st.textContent='@keyframes recpulseA{0%,100%{opacity:1}50%{opacity:.2}}';document.head.appendChild(st);}
}
recStartTs=Date.now(); clearInterval(recTimerInt);
const upd=()=>{ const v=document.getElementById('recTimerVal'); if(v) v.textContent=fmtElapsedA(Date.now()-recStartTs); };
upd(); recTimerInt=setInterval(upd,1000);
} else { clearInterval(recTimerInt); recTimerInt=null; if(c) c.remove(); }
}
function addLine(role, name, text, isChat){ transcriptLines.push({ t: Date.now(), role: role, name: name||'', text: text, chat: !!isChat }); }
function startTranscription(){
transcriptLines=[];
if(!SR) return;
try{
recog=new SR(); recog.continuous=true; recog.interimResults=false; recog.lang='en-US';
recog.onresult=(e)=>{ for(let i=e.resultIndex;i<e.results.length;i++){ if(e.results[i].isFinal){ const txt=(e.results[i][0].transcript||'').trim(); if(txt) addLine('agent',(me&&(me.name||me.email))||'Agent',txt,false); } } };
recog.onerror=()=>{};
recog.onend=()=>{ if(recogActive){ try{recog.start();}catch(_){} } };
recogActive=true; recog.start();
}catch(e){}
}
function stopTranscription(){ recogActive=false; if(recog){ try{recog.stop();}catch(_){} recog=null; } }
function buildTranscriptText(){
const lines=transcriptLines.slice().sort((a,b)=>a.t-b.t);
const pad=(n)=>String(n).padStart(2,'0');
const head='BizGaze Support — Session transcript\nSession: '+sessionId+'\nGenerated: '+new Date().toLocaleString()+'\n'+('-'.repeat(48))+'\n';
const body=lines.map(l=>{ const d=new Date(l.t); const ts='['+pad(d.getHours())+':'+pad(d.getMinutes())+':'+pad(d.getSeconds())+']'; const who=(l.role==='agent'?'Agent':'Customer')+(l.name?' ('+l.name+')':'')+(l.chat?' [chat]':''); return ts+' '+who+': '+l.text; }).join('\n');
return head+(body||'(no speech captured)')+'\n';
}
async function uploadTranscript(){
if(!transcriptLines.length) return;
try{ await fetch('/api/transcript?sessionId='+encodeURIComponent(sessionId),{method:'POST',headers:{'Content-Type':'text/plain'},body:buildTranscriptText()}); }catch(_){}
}
function recBtnUpdate(on){const b=document.getElementById('recBtn');if(!b)return;b.innerHTML='<span style="display:inline-flex">'+(on?SVG_RECSTOP:SVG_REC)+'</span>';b.title=on?'Stop recording':'Record';b.style.background=on?'#dc2626':'#0ea5e9';}
function startRecording(){
const remote=video.srcObject;
const _vt=remote&&remote.getVideoTracks&&remote.getVideoTracks()[0];
if(!_vt||_vt.readyState!=='live'){ alert('No live screen to record. The customer may have disconnected.'); return; }
if(pc&&pc.connectionState&&pc.connectionState!=='connected'){ alert('Not connected to the customer right now.'); return; }
try{
recCtx=new (window.AudioContext||window.webkitAudioContext)();
const dest=recCtx.createMediaStreamDestination();
if(remote.getAudioTracks().length){ try{recCtx.createMediaStreamSource(new MediaStream(remote.getAudioTracks())).connect(dest);}catch(_){} }
if(window.__mic&&window.__mic.getAudioTracks().length){ try{recCtx.createMediaStreamSource(new MediaStream(window.__mic.getAudioTracks())).connect(dest);}catch(_){} }
const mixed=new MediaStream();
mixed.addTrack(remote.getVideoTracks()[0]);
dest.stream.getAudioTracks().forEach(t=>mixed.addTrack(t));
// Prefer MP4 (H.264/AAC) — playable by most tools (Windows Media Player, QuickTime,
// WhatsApp, etc.). Fall back to WebM only if the browser can't record MP4.
const REC_TYPES=['video/mp4;codecs=avc1.42E01E,mp4a.40.2','video/mp4;codecs=h264,aac','video/mp4','video/webm;codecs=vp8,opus','video/webm'];
let mime='video/webm'; for(const t of REC_TYPES){ if(window.MediaRecorder&&MediaRecorder.isTypeSupported(t)){ mime=t; break; } }
const recExt = mime.indexOf('mp4')!==-1 ? 'mp4' : 'webm';
const recBlobType = mime.indexOf('mp4')!==-1 ? 'video/mp4' : 'video/webm';
recChunks=[];
mediaRecorder=new MediaRecorder(mixed,{mimeType:mime});
mediaRecorder.ondataavailable=(e)=>{ if(e.data&&e.data.size) recChunks.push(e.data); };
mediaRecorder.onstop=async()=>{
try{ const blob=new Blob(recChunks,{type:recBlobType}); if(blob.size) await fetch('/api/recording?sessionId='+encodeURIComponent(sessionId)+'&ext='+recExt,{method:'POST',headers:{'Content-Type':recBlobType},body:blob}); }catch(_){}
try{recCtx&&recCtx.close();}catch(_){} recCtx=null;
};
mediaRecorder.start(1000);
startTranscription();
recBtnUpdate(true);
showRecTimer(true);
try{ws.send(JSON.stringify({type:'recording',sessionId,on:true}));}catch(_){}
}catch(e){ alert('Recording could not start on this browser.'); }
}
function stopRecording(){
if(mediaRecorder&&mediaRecorder.state!=='inactive'){ try{mediaRecorder.stop();}catch(_){} }
stopTranscription();
uploadTranscript();
recBtnUpdate(false);
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.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(end);
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(_){} };
buildChatPanel();
document.addEventListener('pointerdown',ensureAudio,{once:true});
@@ -201,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">&#10005;</span></div><div id="chatMsgs" style="flex:1;overflow-y:auto;padding:.6rem;display:flex;flex-direction:column;gap:.4rem;font-size:.85rem"></div><div style="display:flex;gap:.4rem;padding:.5rem;border-top:1px solid #e6e9ef"><input id="chatInput" placeholder="Type a message..." style="flex:1;padding:.5rem;border:1px solid #e6e9ef;border-radius:8px;font-size:.85rem;outline:none"><button id="chatSend" style="background:#FFC708;border:none;border-radius:8px;padding:.5rem .85rem;font-weight:700;cursor:pointer">Send</button></div>';
document.body.appendChild(p);
document.getElementById('chatSend').onclick=sendChat;
@@ -216,19 +345,23 @@ let __ac=null;
function ensureAudio(){try{__ac=__ac||new (window.AudioContext||window.webkitAudioContext)();if(__ac.state==='suspended')__ac.resume();}catch(_){}}
function beep(){ensureAudio();if(!__ac)return;try{const o=__ac.createOscillator(),g=__ac.createGain();o.type='sine';o.connect(g);g.connect(__ac.destination);const t0=__ac.currentTime;o.frequency.setValueAtTime(880,t0);o.frequency.setValueAtTime(660,t0+0.09);g.gain.setValueAtTime(0.0001,t0);g.gain.exponentialRampToValueAtTime(0.12,t0+0.02);g.gain.exponentialRampToValueAtTime(0.0001,t0+0.22);o.start(t0);o.stop(t0+0.24);}catch(_){}}
try{['pointerdown','keydown','touchstart','click'].forEach(ev=>document.addEventListener(ev,ensureAudio,{passive:true}));}catch(_){}
function sendChat(){const i=document.getElementById('chatInput');if(!i)return;const t=i.value.trim();if(!t)return;if(chatChannel&&chatChannel.readyState==='open'){chatChannel.send(JSON.stringify({name:(me&&(me.name||me.email))||'Support agent',text:t}));}addChat({from:'__self',name:'You',text:t});i.value='';}
function sendChat(){const i=document.getElementById('chatInput');if(!i)return;const t=i.value.trim();if(!t)return;if(chatChannel&&chatChannel.readyState==='open'){chatChannel.send(JSON.stringify({name:(me&&(me.name||me.email))||'Support agent',text:t}));}addChat({from:'__self',name:'You',text:t});if(recogActive)addLine('agent',(me&&(me.name||me.email))||'Agent',t,true);i.value='';}
function removeSessionUI(){['sessionBar','chatPanel','remoteAudio','muteBtn','msgToast'].forEach(id=>{const e=document.getElementById(id);if(e)e.remove();});}
async function setupPeer(){
await ensureIce();
pc=new RTCPeerConnection(ICE);
inputChannel=pc.createDataChannel('input',{ordered:true});
pc.ondatachannel=(ev)=>{ if(ev.channel.label==='chat'){ chatChannel=ev.channel; chatChannel.onmessage=(e)=>{try{addChat(JSON.parse(e.data));}catch(_){}}; } };
pc.ondatachannel=(ev)=>{ if(ev.channel.label==='chat'){ chatChannel=ev.channel; chatChannel.onmessage=(e)=>{try{const mm=JSON.parse(e.data);addChat(mm);if(recogActive)addLine('customer',mm.name||'Customer',mm.text,true);}catch(_){}}; } };
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]; return; }
video.srcObject=ev.streams[0]; wrap.style.display='none'; topbar.style.display='none'; video.style.display='block'; video.focus(); buildBar();
};
pc.onicecandidate=(ev)=>{if(ev.candidate)ws.send(JSON.stringify({type:'ice-candidate',sessionId,candidate:ev.candidate}));};
pc.onconnectionstatechange=()=>{ if(!pc)return; const s=pc.connectionState;
if(s==='failed'){ renderEnded('The connection was lost. Ask the customer to refresh their page for a new code.'); }
else if(s==='disconnected'){ clearTimeout(pc._dt); pc._dt=setTimeout(()=>{ if(pc&&(pc.connectionState==='disconnected'||pc.connectionState==='failed')) renderEnded('The connection was lost. Ask the customer to refresh their page for a new code.'); },8000); }
else if(s==='connected'){ clearTimeout(pc._dt); } };
}
const send=(o)=>{if(inputChannel&&inputChannel.readyState==='open')inputChannel.send(JSON.stringify(o));};
const rel=(e)=>{const r=video.getBoundingClientRect();return{x:(e.clientX-r.left)/r.width,y:(e.clientY-r.top)/r.height};};
@@ -244,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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[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>
-350
Parādīt failu
@@ -1,350 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>BizGaze Support — Staff Console</title>
<style>
:root{ --brand:#FFC708; --brand-d:#E0AC00; --blue:#1F3B73; --blue-d:#16294f; --blue-soft:#EAF0FB; --ink:#1f2430; --muted:#6b7280; --bg:#f6f8fb; --card:#fff; --line:#e6e9ef; --green:#16a34a; --red:#b91c1c; }
*{box-sizing:border-box;}
body{font-family:'Segoe UI',system-ui,sans-serif;background:var(--bg);color:var(--ink);margin:0;}
header{background:var(--blue);padding:.75rem 1.5rem;display:flex;justify-content:space-between;align-items:center;}
.brandrow{display:flex;align-items:center;gap:.6rem;}
.logo{width:30px;height:30px;border-radius:8px;background:var(--brand);display:grid;place-items:center;font-weight:800;color:var(--blue);}
.brand{font-weight:700;color:#fff;font-size:1.05rem;} .brand span{color:var(--brand);font-weight:600;}
.who{color:#dbe4f5;font-size:.85rem;margin-right:.7rem;}
main{max-width:1020px;margin:1.8rem auto;padding:0 1rem;}
.card{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:1.5rem;margin-bottom:1.25rem;box-shadow:0 6px 18px rgba(20,30,60,.05);}
h2{font-size:1rem;margin:0 0 1rem;color:var(--blue);}
input,select{width:100%;padding:.6rem .7rem;border-radius:10px;border:2px solid var(--line);background:#fbfcfe;color:var(--ink);margin:.25rem 0;font-size:.92rem;}
input:focus,select:focus{outline:none;border-color:var(--brand);}
button{padding:.6rem 1.1rem;background:var(--brand);color:var(--ink);border:none;border-radius:10px;font-weight:700;cursor:pointer;font-size:.92rem;}
button:hover{background:var(--brand-d);}
button.ghost{background:transparent;color:#dbe4f5;border:1px solid #46598c;font-weight:600;}
button.ghost:hover{background:var(--blue-d);}
button.mini{padding:.32rem .6rem;font-size:.76rem;font-weight:600;background:#eef1f6;color:var(--blue);border:1px solid var(--line);}
button.mini:hover{background:var(--blue-soft);}
button.mini.danger{color:var(--red);}
.row{display:flex;gap:.5rem;align-items:center;}
.muted{color:var(--muted);font-size:.85rem;}
table{width:100%;border-collapse:collapse;font-size:.88rem;}
th{color:var(--muted);font-weight:600;font-size:.76rem;text-transform:uppercase;letter-spacing:.04em;}
th,td{text-align:left;padding:.55rem .5rem;border-bottom:1px solid var(--line);}
.pill{font-size:.74rem;font-weight:600;padding:.15rem .55rem;border-radius:99px;}
.pill.on{background:#ecfdf3;color:#15803d;} .pill.off{background:#fee2e2;color:var(--red);}
.hidden{display:none;}
.tabs{display:flex;gap:.5rem;margin-bottom:1.2rem;}
.tabs button{background:#eef1f6;color:var(--muted);font-weight:600;}
.tabs button.active{background:var(--blue);color:#fff;}
.quick{display:flex;align-items:center;justify-content:space-between;gap:1rem;background:linear-gradient(120deg,var(--blue),var(--blue-d));color:#fff;border:none;}
.quick h2{color:#fff;margin:0 0 .25rem;}
.quick p{margin:0;color:#cdd7ee;font-size:.88rem;}
.quick a{background:var(--brand);color:var(--ink);text-decoration:none;font-weight:700;padding:.7rem 1.3rem;border-radius:10px;white-space:nowrap;}
.quick a:hover{background:var(--brand-d);}
.lbl{display:block;font-size:.74rem;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;margin:.7rem 0 .15rem;}
.filters{display:flex;gap:.6rem;align-items:flex-end;flex-wrap:wrap;margin-bottom:1rem;}
.filters .f{flex:1;min-width:140px;}
.filters .lbl{margin:.1rem 0 .15rem;}
.profile{position:relative}
.profile .pbtn{display:flex;align-items:center;gap:.4rem;background:rgba(255,255,255,.14);color:#fff;border:1px solid #46598c;border-radius:10px;padding:.45rem .85rem;font-weight:600;font-size:.88rem;cursor:pointer}
.profile .pbtn:hover{background:rgba(255,255,255,.24)}
.profile .pmenu{position:absolute;right:0;top:calc(100% + 6px);background:#fff;border:1px solid #e6e9ef;border-radius:10px;box-shadow:0 10px 28px rgba(0,0,0,.18);min-width:190px;overflow:hidden;z-index:5000;display:none}
.profile .pmenu.open{display:block}
.profile .pmenu a{display:block;padding:.6rem .9rem;color:#1f2430;text-decoration:none;font-size:.9rem;cursor:pointer}
.profile .pmenu a:hover{background:#f1f5f9}
.profile .pmenu a.danger{color:#b91c1c;border-top:1px solid #eef1f6}
</style>
</head>
<body>
<header>
<div class="brandrow"><img src="/logo.png" alt="" style="height:46px;width:auto;max-width:190px;border-radius:8px;object-fit:contain;background:#fff;padding:5px 12px;image-rendering:-webkit-optimize-contrast" onerror="this.replaceWith(Object.assign(document.createElement('div'),{className:'logo',textContent:'B'}))"><div class="brand">BizGaze <span>Support</span> <span style="color:#8ea3cf;font-weight:500;font-size:.85rem">· Console</span></div></div>
<div class="row" id="hdrRight"></div>
</header>
<main id="app"></main>
<script>
function pEsc(s){return String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));}
function profileHTML(name){return '<div class="profile"><button class="pbtn" id="pbtn">'+pEsc(name)+' <span style="font-size:.65rem">&#9662;</span></button><div class="pmenu" id="pmenu"><a href="/console">Console / Dashboard</a><a id="plogout" class="danger">Logout</a></div></div>';}
function wireProfile(){const btn=document.getElementById('pbtn'),menu=document.getElementById('pmenu');if(!btn)return;btn.onclick=(e)=>{e.stopPropagation();menu.classList.toggle('open');};document.addEventListener('click',()=>menu.classList.remove('open'));const lo=document.getElementById('plogout');if(lo)lo.onclick=async()=>{try{await fetch('/api/logout',{method:'POST'});}catch(_){}location.href='/';};}
function makeBrandClickable(){document.querySelectorAll('.brandrow,.wordmark').forEach(el=>{el.style.cursor='pointer';el.addEventListener('click',()=>{location.href='/';});});}
makeBrandClickable();
const app = document.getElementById('app');
const hdrRight = document.getElementById('hdrRight');
async function api(path, body, method = 'POST') {
const opt = { method, headers: { 'Content-Type': 'application/json' } };
if (body) opt.body = JSON.stringify(body);
const r = await fetch(path, opt);
const data = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(data.error || 'request failed');
return data;
}
function onEnter(ids, fn){ ids.forEach(id => { const el = document.getElementById(id); if (el) el.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); fn(); } }); }); }
function view(html) { app.innerHTML = html; }
// ---------- Auth ----------
async function authView() {
hdrRight.innerHTML = '';
let regOpen = false;
try { regOpen = (await api('/api/setup-state', null, 'GET')).registrationOpen; } catch {}
view(`
<div class="card" style="max-width:420px;margin:3rem auto">
<div class="tabs">
<button id="tabLogin" class="active">Sign in</button>
${regOpen ? '<button id="tabReg">Register team</button>' : ''}
</div>
<div id="loginForm">
<span class="lbl">Email</span>
<input id="li_email" placeholder="you@bizgaze.com" type="email">
<span class="lbl">Password</span>
<input id="li_pw" placeholder="password" type="password">
<label style="display:flex;align-items:center;gap:.5rem;margin:.7rem 0;color:var(--ink);font-size:.9rem;cursor:pointer"><input type="checkbox" id="li_remember" style="width:18px;height:18px;accent-color:var(--blue);margin:0"> Remember me on this device</label>
<button id="li_btn" style="width:100%;margin-top:.5rem">Sign in</button>
<p id="li_err" class="muted"></p>
</div>
${regOpen ? `<div id="regForm" class="hidden">
<span class="lbl">Team name</span>
<input id="rg_team" placeholder="e.g. BizGaze Support">
<span class="lbl">Email</span>
<input id="rg_email" placeholder="you@bizgaze.com" type="email">
<span class="lbl">Password</span>
<input id="rg_pw" placeholder="min 8 characters" type="password">
<button id="rg_btn" style="width:100%;margin-top:1rem">Create team</button>
<p id="rg_err" class="muted"></p>
</div>` : ''}
</div>`);
document.getElementById('li_btn').onclick = doLogin;
onEnter(['li_email','li_pw'], doLogin);
if (regOpen) {
document.getElementById('tabLogin').onclick = () => toggle(true);
document.getElementById('tabReg').onclick = () => toggle(false);
document.getElementById('rg_btn').onclick = doRegister;
onEnter(['rg_team','rg_email','rg_pw'], doRegister);
}
function toggle(login) {
document.getElementById('loginForm').classList.toggle('hidden', !login);
document.getElementById('regForm').classList.toggle('hidden', login);
document.getElementById('tabLogin').classList.toggle('active', login);
const rt = document.getElementById('tabReg'); if (rt) rt.classList.toggle('active', !login);
}
}
async function doLogin() {
try {
const rem = document.getElementById('li_remember');
await api('/api/login', { email: li_email.value, password: li_pw.value, remember: rem ? rem.checked : false });
location.reload();
} catch (e) { li_err.textContent = e.message; }
}
async function doRegister() {
try {
await api('/api/register', { email: rg_email.value, password: rg_pw.value, teamName: rg_team.value });
await api('/api/login', { email: rg_email.value, password: rg_pw.value });
location.reload();
} catch (e) { rg_err.textContent = e.message; }
}
// ---------- Dashboard ----------
let ME = null;
async function dashboard(me) {
ME = me;
hdrRight.innerHTML = profileHTML((me.name||me.email)+' · '+me.role); wireProfile();
view(`
<div class="card quick">
<div><h2>Start a support session</h2><p>Customer gives you their 6-digit code from the share page.</p></div>
<a href="/connect">Open connect page →</a>
</div>
<div class="card" id="agentsCard">
<h2>Agents</h2>
<table id="agents"><thead><tr><th>Email</th><th>Display name</th><th>Role</th><th>Status</th><th style="width:280px"></th></tr></thead><tbody></tbody></table>
<div class="row" style="margin-top:1rem;flex-wrap:wrap">
<input id="agEmail" placeholder="agent email" style="max-width:200px">
<input id="agName" placeholder="display name (from BizGaze)" style="max-width:210px">
<input id="agPw" placeholder="temporary password" style="max-width:170px">
<select id="agRole" style="max-width:140px">
<option value="technician">technician</option><option value="admin">admin</option><option value="viewer">view-only</option>
</select>
<button id="agAdd">Add agent</button>
</div>
<p id="agOut" class="muted"></p>
</div>
<div class="card">
<h2>Session report</h2>
<div class="filters">
<div class="f"><span class="lbl">Agent</span><select id="fAgent"><option value="">All agents</option></select></div>
<div class="f"><span class="lbl">From</span><input id="fFrom" type="date"></div>
<div class="f"><span class="lbl">To</span><input id="fTo" type="date"></div>
<button id="fApply">Apply</button>
<button id="fExcel" class="mini" style="padding:.6rem .9rem">⬇ Excel</button>
<button id="fPdf" class="mini" style="padding:.6rem .9rem">⬇ PDF</button>
</div>
<table id="report"><thead><tr><th>Date</th><th>Start time</th><th>Agent</th><th>Ticket</th><th>Time spent</th></tr></thead><tbody></tbody></table>
<p id="repSummary" class="muted" style="margin-top:.6rem"></p>
</div>`);
if (me.role !== 'admin') document.getElementById('agentsCard').style.display = 'none';
else {
document.getElementById('agAdd').onclick = addAgent;
onEnter(['agEmail','agName','agPw'], addAgent);
await loadAgents();
}
document.getElementById('fApply').onclick = loadReport;
document.getElementById('fExcel').onclick = exportExcel;
document.getElementById('fPdf').onclick = exportPdf;
await populateAgentFilter();
await loadReport();
}
async function addAgent() {
try {
const r = await api('/api/users', { email: agEmail.value, name: agName.value, password: agPw.value, role: agRole.value });
agOut.textContent = `Agent ${r.email} added. Share the email + temporary password — they sign in at /connect.`;
agEmail.value = ''; agName.value = ''; agPw.value = '';
loadAgents(); populateAgentFilter();
} catch (e) { agOut.textContent = e.message; }
}
async function loadAgents() {
const rows = await api('/api/users', null, 'GET');
document.querySelector('#agents tbody').innerHTML = rows.map((u) => `
<tr>
<td>${esc(u.email)}</td><td>${esc(u.name || '—')}</td><td>${esc(u.role)}</td>
<td><span class="pill ${u.active === 0 ? 'off' : 'on'}">${u.active === 0 ? 'deactivated' : 'active'}</span></td>
<td>
<button class="mini" onclick="resetPw('${u.id}','${esc(u.email)}')">Reset password</button>
<button class="mini" onclick="renameAgent('${u.id}','${esc(u.email)}')">Edit name</button>
${u.id === ME.id ? '' : (u.active === 0
? `<button class="mini" onclick="manage('${u.id}','activate')">Activate</button>`
: `<button class="mini danger" onclick="manage('${u.id}','deactivate')">Deactivate</button>`)
}
${u.id === ME.id ? '' : `<button class="mini danger" onclick="delAgent('${u.id}','${esc(u.email)}')">Delete</button>`}
</td>
</tr>`).join('');
}
window.resetPw = async (id, email) => {
const pw = prompt(`New password for ${email} (min 8 characters):`);
if (!pw) return;
try { await api('/api/users/manage', { id, action: 'reset-password', password: pw }); agOut.textContent = `Password reset for ${email}. They were signed out everywhere.`; }
catch (e) { agOut.textContent = e.message; }
};
window.renameAgent = async (id, email) => {
const name = prompt(`Display name for ${email} (as in the BizGaze app):`);
if (!name) return;
try { await api('/api/users/manage', { id, action: 'rename', name }); loadAgents(); }
catch (e) { agOut.textContent = e.message; }
};
window.manage = async (id, action) => {
try { await api('/api/users/manage', { id, action }); loadAgents(); }
catch (e) { agOut.textContent = e.message; }
};
window.delAgent = async (id, email) => {
if (!confirm(`Delete ${email}? This cannot be undone. (Tip: Deactivate keeps their history.)`)) return;
try { await api('/api/users/manage', { id, action: 'delete' }); loadAgents(); populateAgentFilter(); }
catch (e) { agOut.textContent = e.message; }
};
// ---------- Session report ----------
async function populateAgentFilter() {
try {
const rows = await api('/api/users', null, 'GET');
const sel = document.getElementById('fAgent');
const cur = sel.value;
sel.innerHTML = '<option value="">All agents</option>' + rows.map(u => `<option value="${esc(u.email)}">${esc(u.name || u.email)}</option>`).join('');
sel.value = cur;
} catch { /* non-admins cannot list agents; filter stays "All" */ }
}
function fmtDuration(ms) {
if (ms == null) return '—';
const s = Math.round(ms / 1000);
if (s < 60) return s + 's';
const m = Math.floor(s / 60), r = s % 60;
if (m < 60) return m + 'm ' + r + 's';
return Math.floor(m / 60) + 'h ' + (m % 60) + 'm';
}
let REPORT_ROWS = [];
async function loadReport() {
const q = new URLSearchParams();
if (fAgent.value) q.set('agent', fAgent.value);
if (fFrom.value) q.set('from', fFrom.value);
if (fTo.value) q.set('to', fTo.value);
const rows = await api('/api/report?' + q.toString(), null, 'GET');
REPORT_ROWS = rows;
document.querySelector('#report tbody').innerHTML = rows.map((r) => {
const d = new Date(r.started_at);
const dur = r.ended_at ? (r.ended_at - r.started_at) : null;
return `<tr>
<td>${d.toLocaleDateString()}</td>
<td class="muted">${d.toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'})}</td>
<td>${esc(r.agent_name || r.agent_email || '—')}</td>
<td>${esc(r.ticket || 'Direct session')}</td>
<td>${r.ended_at ? fmtDuration(dur) : '<span class="pill on">in progress</span>'}</td>
</tr>`;
}).join('') || '<tr><td colspan=5 class="muted">No sessions in this period.</td></tr>';
const total = rows.reduce((a, r) => a + (r.ended_at ? r.ended_at - r.started_at : 0), 0);
repSummary.textContent = rows.length ? `${rows.length} session(s) · total time ${fmtDuration(total)}` : '';
}
function reportData() {
return REPORT_ROWS.map((r) => {
const d = new Date(r.started_at);
return {
date: d.toLocaleDateString(), start: d.toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'}),
agent: r.agent_name || r.agent_email || '', ticket: r.ticket || 'Direct session',
spent: r.ended_at ? fmtDuration(r.ended_at - r.started_at) : 'in progress',
};
});
}
function exportExcel() {
const rows = reportData();
if (!rows.length) { repSummary.textContent = 'Nothing to export for this period.'; return; }
const head = ['Date','Start time','Agent','Ticket','Time spent'];
const csvCell = (v) => '"' + String(v).replace(/"/g, '""') + '"';
const csv = '\ufeff' + [head, ...rows.map(r => [r.date, r.start, r.agent, r.ticket, r.spent])]
.map(line => line.map(csvCell).join(',')).join('\r\n');
const a = document.createElement('a');
a.href = URL.createObjectURL(new Blob([csv], { type: 'text/csv;charset=utf-8' }));
a.download = 'session-report.csv';
a.click(); URL.revokeObjectURL(a.href);
}
function exportPdf() {
const rows = reportData();
if (!rows.length) { repSummary.textContent = 'Nothing to export for this period.'; return; }
const period = (fFrom.value || 'start') + ' to ' + (fTo.value || 'today');
const agentSel = fAgent.value || 'All agents';
const w = window.open('', '_blank');
w.document.write('<html><head><title>Session report</title><style>' +
'body{font-family:Segoe UI,Arial,sans-serif;color:#1f2430;margin:32px}' +
'h1{font-size:18px;color:#1F3B73;border-bottom:3px solid #FFC708;padding-bottom:6px}' +
'.meta{color:#6b7280;font-size:12px;margin-bottom:14px}' +
'table{width:100%;border-collapse:collapse;font-size:12px}' +
'th{background:#1F3B73;color:#fff;text-align:left;padding:6px 8px}' +
'td{padding:6px 8px;border-bottom:1px solid #e6e9ef}' +
'</style></head><body>' +
'<h1>BizGaze Support — Session report</h1>' +
'<div class="meta">Agent: ' + esc(agentSel) + ' · Period: ' + esc(period) + ' · Generated ' + new Date().toLocaleString() + '</div>' +
'<table><tr><th>Date</th><th>Start time</th><th>Agent</th><th>Ticket</th><th>Time spent</th></tr>' +
rows.map(r => '<tr><td>' + [r.date, r.start, esc(r.agent), esc(r.ticket), r.spent].join('</td><td>') + '</td></tr>').join('') +
'</table><div class="meta" style="margin-top:12px">' + esc(repSummary.textContent) + '</div></body></html>');
w.document.close();
w.onload = () => { w.print(); };
}
function esc(s) { return String(s == null ? '' : s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c])); }
// ---------- Boot ----------
(async function () {
try { const me = await api('/api/me', null, 'GET'); dashboard(me); }
catch { authView(); }
})();
</script>
</body>
</html>
+443
Parādīt failu
@@ -0,0 +1,443 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>BizGaze Connect — Dashboard</title>
<style>
:root{ --brand:#FFC708; --brand-d:#E0AC00; --blue:#1F3B73; --blue-d:#16294f; --blue-soft:#EAF0FB; --ink:#1f2430; --muted:#6b7280; --bg:#f6f8fb; --card:#fff; --line:#e6e9ef; --green:#16a34a; --red:#b91c1c; }
*{box-sizing:border-box;}
body{font-family:'Segoe UI',system-ui,sans-serif;background:var(--bg);color:var(--ink);margin:0;}
header{background:var(--blue);padding:.75rem 1.5rem;display:flex;justify-content:space-between;align-items:center;}
.brandrow{display:flex;align-items:center;gap:.6rem;}
.logo{width:30px;height:30px;border-radius:8px;background:var(--brand);display:grid;place-items:center;font-weight:800;color:var(--blue);}
.brand{font-weight:700;color:#fff;font-size:1.05rem;} .brand span.y{color:var(--brand);font-weight:700;} .brand span.tag{color:#8ea3cf;font-weight:500;font-size:.85rem;}
main{max-width:1020px;margin:1.8rem auto;padding:0 1rem;}
.card{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:1.5rem;margin-bottom:1.25rem;box-shadow:0 6px 18px rgba(20,30,60,.05);}
h2{font-size:1rem;margin:0 0 1rem;color:var(--blue);}
input,select{width:100%;padding:.6rem .7rem;border-radius:10px;border:2px solid var(--line);background:#fbfcfe;color:var(--ink);margin:.25rem 0;font-size:.92rem;}
input:focus,select:focus{outline:none;border-color:var(--brand);}
button{padding:.6rem 1.1rem;background:var(--brand);color:var(--ink);border:none;border-radius:10px;font-weight:700;cursor:pointer;font-size:.92rem;}
button:hover{background:var(--brand-d);}
button.mini{padding:.32rem .6rem;font-size:.76rem;font-weight:600;background:#eef1f6;color:var(--blue);border:1px solid var(--line);}
button.mini:hover{background:var(--blue-soft);}
.row{display:flex;gap:.5rem;align-items:center;}
.muted{color:var(--muted);font-size:.85rem;}
table{width:100%;border-collapse:collapse;font-size:.88rem;}
th{color:var(--muted);font-weight:600;font-size:.76rem;text-transform:uppercase;letter-spacing:.04em;}
th,td{text-align:left;padding:.55rem .5rem;border-bottom:1px solid var(--line);}
.pill{font-size:.74rem;font-weight:600;padding:.15rem .55rem;border-radius:99px;}
.pill.on{background:#ecfdf3;color:#15803d;}
.pill.off{background:#fee2e2;color:var(--red);}
.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;}
.tabs button.active{background:var(--blue);color:#fff;}
.lbl{display:block;font-size:.74rem;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;margin:.7rem 0 .15rem;}
.filters{display:flex;gap:.6rem;align-items:flex-end;flex-wrap:wrap;margin-bottom:1rem;}
.filters .f{flex:1;min-width:140px;}
.filters .lbl{margin:.1rem 0 .15rem;}
.srch{max-width:320px;margin:.2rem 0 .9rem;}
.pager{display:flex;gap:.5rem;align-items:center;justify-content:flex-end;margin-top:.8rem;font-size:.82rem;color:var(--muted);}
.pager button{padding:.32rem .7rem;font-size:.8rem;font-weight:600;background:#eef1f6;color:var(--blue);border:1px solid var(--line);}
.pager button:hover:not(:disabled){background:var(--blue-soft);}
.pager button:disabled{opacity:.4;cursor:default;}
.stats{display:flex;gap:1rem;flex-wrap:wrap;margin-bottom:1.25rem;}
.stat{flex:1;min-width:150px;background:var(--card);border:1px solid var(--line);border-radius:14px;padding:1.1rem 1.3rem;box-shadow:0 6px 18px rgba(20,30,60,.05);}
.stat .v{font-size:1.7rem;font-weight:800;color:var(--blue);line-height:1.1;}
.stat .k{font-size:.78rem;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin-top:.2rem;}
.formerr{color:var(--red);font-weight:600;font-size:.88rem;margin:.7rem 0 0;min-height:1em;}
.formerr.show{display:flex;align-items:center;gap:.5rem;background:#fee2e2;border:1px solid #fca5a5;border-radius:9px;padding:.6rem .75rem;animation:errShake .35s;}
.formerr.show::before{content:"⚠";font-size:1rem;}
@keyframes errShake{0%,100%{transform:translateX(0)}20%,60%{transform:translateX(-5px)}40%,80%{transform:translateX(5px)}}
.pwwrap{position:relative;} .pwwrap input{padding-right:2.6rem;}
.eye{position:absolute;right:.35rem;top:50%;transform:translateY(-50%);background:none;border:none;padding:.3rem;width:auto;color:var(--muted);display:inline-flex;align-items:center;cursor:pointer;}
.eye:hover{background:none;color:var(--blue);}
.profile{position:relative}
.profile .pbtn{display:flex;align-items:center;gap:.5rem;background:rgba(255,255,255,.14);color:#fff;border:1px solid #46598c;border-radius:10px;padding:.4rem .85rem .4rem .5rem;font-weight:600;font-size:.88rem;cursor:pointer}
.profile .pbtn:hover{background:rgba(255,255,255,.24)}
.profile .pbtn .pav{width:28px;height:28px;border-radius:50%;background:var(--brand);color:var(--blue);display:grid;place-items:center;font-weight:800;font-size:.78rem}
.profile .pmenu{position:absolute;right:0;top:calc(100% + 6px);background:#fff;border:1px solid #e6e9ef;border-radius:10px;box-shadow:0 10px 28px rgba(0,0,0,.18);min-width:210px;overflow:hidden;z-index:5000;display:none}
.profile .pmenu.open{display:block}
.profile .pmenu .phead{padding:.7rem .9rem;border-bottom:1px solid #eef1f6}
.profile .pmenu .phead .n{font-weight:700;font-size:.9rem}
.profile .pmenu .phead .e{color:var(--muted);font-size:.78rem}
.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?v=3"></script>
</head>
<body>
<header>
<div class="brandrow"><img src="/logo.png" alt="" style="height:46px;width:auto;max-width:190px;border-radius:8px;object-fit:contain;background:#fff;padding:5px 12px;image-rendering:-webkit-optimize-contrast" onerror="this.replaceWith(Object.assign(document.createElement('div'),{className:'logo',textContent:'B'}))"><div class="brand">BizGaze <span class="y">Connect</span> <span class="tag">· Dashboard</span></div></div>
<div class="row" id="hdrRight"></div>
</header>
<main id="app"></main>
<script>
const EYE_OFF='<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg>';
const EYE_ON='<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>';
function pwField(id,ph){return '<div class="pwwrap"><input id="'+id+'" type="password" placeholder="'+ph+'"><button type="button" class="eye" data-for="'+id+'" aria-label="Show password"></button></div>';}
function wireEyes(){document.querySelectorAll('.eye').forEach(b=>{if(b._w)return;b._w=1;b.innerHTML=EYE_OFF;b.onclick=()=>{const inp=document.getElementById(b.getAttribute('data-for'));if(!inp)return;const show=inp.type==='password';inp.type=show?'text':'password';b.innerHTML=show?EYE_ON:EYE_OFF;};});}
function pEsc(s){return String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));}
function initials(name){const p=String(name||'?').trim().split(/\s+/);return ((p[0]||'?')[0]+(p[1]?p[1][0]:'')).toUpperCase();}
function profileHTML(u){
const display=u.name||u.email;
return '<div class="profile"><button class="pbtn" id="pbtn">'
+ '<span class="pav">'+pEsc(initials(display))+'</span>'
+ pEsc(display)+' <span style="font-size:.65rem">&#9662;</span></button>'
+ '<div class="pmenu" id="pmenu">'
+ '<div class="phead"><div class="n">'+pEsc(display)+'</div><div class="e">'+pEsc(u.email)+(u.role?' · '+pEsc(u.role):'')+'</div></div>'
+ '<a href="/home">Home</a>'
+ '<a class="danger" id="plogout">Logout</a>'
+ '</div></div>';
}
function wireProfile(){const btn=document.getElementById('pbtn'),menu=document.getElementById('pmenu');if(!btn)return;btn.onclick=(e)=>{e.stopPropagation();menu.classList.toggle('open');};document.addEventListener('click',()=>menu.classList.remove('open'));const lo=document.getElementById('plogout');if(lo)lo.onclick=async()=>{try{await fetch('/api/logout',{method:'POST'});}catch(_){}location.href='/';};}
const app = document.getElementById('app');
const hdrRight = document.getElementById('hdrRight');
async function api(path, body, method = 'POST') {
const opt = { method, headers: { 'Content-Type': 'application/json' } };
if (body) opt.body = JSON.stringify(body);
const r = await fetch(path, opt);
const data = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(data.error || 'request failed');
return data;
}
function onEnter(ids, fn){ ids.forEach(id => { const el = document.getElementById(id); if (el) el.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); fn(); } }); }); }
function view(html) { app.innerHTML = html; }
// ---------- Auth (login lives here; on success → home) ----------
async function authView() {
hdrRight.innerHTML = '';
let regOpen = false;
try { regOpen = (await api('/api/setup-state', null, 'GET')).registrationOpen; } catch {}
view(`
<div class="card" style="max-width:420px;margin:3rem auto">
${regOpen ? `<div class="tabs">
<button id="tabLogin" class="active">Sign in</button>
<button id="tabReg">Register team</button>
</div>` : ''}
<div id="loginForm">
<span class="lbl">Email</span>
<input id="li_email" placeholder="you@bizgaze.com" type="email">
<span class="lbl">Password</span>
${pwField("li_pw","password")}
<label style="display:flex;align-items:center;gap:.5rem;margin:.7rem 0;color:var(--ink);font-size:.9rem;cursor:pointer"><input type="checkbox" id="li_remember" style="width:18px;height:18px;accent-color:var(--blue);margin:0"> Remember me on this device</label>
<button id="li_btn" style="width:100%;margin-top:.5rem">Sign in</button>
<p id="li_err" class="formerr"></p>
</div>
${regOpen ? `<div id="regForm" class="hidden">
<span class="lbl">Team name</span>
<input id="rg_team" placeholder="e.g. BizGaze Support">
<span class="lbl">Email</span>
<input id="rg_email" placeholder="you@bizgaze.com" type="email">
<span class="lbl">Password</span>
${pwField("rg_pw","min 8 characters")}
<button id="rg_btn" style="width:100%;margin-top:1rem">Create team</button>
<p id="rg_err" class="formerr"></p>
</div>` : ''}
</div>`);
document.getElementById('li_btn').onclick = doLogin;
wireEyes();
onEnter(['li_email','li_pw'], doLogin);
if (regOpen) {
document.getElementById('tabLogin').onclick = () => toggle(true);
document.getElementById('tabReg').onclick = () => toggle(false);
document.getElementById('rg_btn').onclick = doRegister;
onEnter(['rg_team','rg_email','rg_pw'], doRegister);
}
function toggle(login) {
document.getElementById('loginForm').classList.toggle('hidden', !login);
document.getElementById('regForm').classList.toggle('hidden', login);
document.getElementById('tabLogin').classList.toggle('active', login);
const rt = document.getElementById('tabReg'); if (rt) rt.classList.toggle('active', !login);
}
}
function showErr(id, msg) { const el = document.getElementById(id); el.textContent = msg; el.classList.add('show'); }
function clearErr(id) { const el = document.getElementById(id); el.textContent = ''; el.classList.remove('show'); }
async function doLogin() {
clearErr('li_err');
try {
const rem = document.getElementById('li_remember');
await api('/api/login', { email: li_email.value, password: li_pw.value, remember: rem ? rem.checked : false });
location.href = '/home';
} catch (e) {
showErr('li_err', /invalid credentials/i.test(e.message) ? 'Incorrect email or password. Please try again.' : e.message);
}
}
async function doRegister() {
clearErr('rg_err');
try {
await api('/api/register', { email: rg_email.value, password: rg_pw.value, teamName: rg_team.value });
await api('/api/login', { email: rg_email.value, password: rg_pw.value });
location.href = '/home';
} catch (e) { showErr('rg_err', e.message); }
}
// ---------- Dashboard ----------
let ME = null, IS_ADMIN = false;
async function dashboard(me) {
ME = me; IS_ADMIN = (me.role === 'admin');
hdrRight.innerHTML = profileHTML(me); wireProfile();
view(`
<div class="stats" id="stats"></div>
<div class="card">
<h2>${IS_ADMIN ? 'Connection report — all agents' : 'My connection report'}</h2>
<div class="filters">
${IS_ADMIN ? '<div class="f"><span class="lbl">Agent</span><select id="fAgent"><option value="">All agents</option></select></div>' : ''}
<div class="f"><span class="lbl">From</span><input id="fFrom" type="date"></div>
<div class="f"><span class="lbl">To</span><input id="fTo" type="date"></div>
<button id="fApply">Apply</button>
<button id="fExcel" class="mini" style="padding:.6rem .9rem">${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>
${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>` : '';
return `<button ${page<=1?'disabled':''} onclick="${fn}(${page-1})"> Prev</button>`
+ `<span>Page ${page} of ${pages} · ${total} total</span>`
+ `<button ${page>=pages?'disabled':''} onclick="${fn}(${page+1})">Next </button>`;
}
async function populateAgentFilter() {
try {
const rows = await api('/api/users', null, 'GET');
const sel = document.getElementById('fAgent'); if (!sel) return;
const cur = sel.value;
sel.innerHTML = '<option value="">All agents</option>' + rows.map(u => `<option value="${esc(u.email)}">${esc(u.name || u.email)}</option>`).join('');
sel.value = cur;
} catch { /* non-admins cannot list agents */ }
}
function fmtDuration(ms) {
if (ms == null) return '—';
const s = Math.round(ms / 1000);
if (s < 60) return s + 's';
const m = Math.floor(s / 60), r = s % 60;
if (m < 60) return m + 'm ' + r + 's';
return Math.floor(m / 60) + 'h ' + (m % 60) + 'm';
}
let REPORT_ROWS = [], reportPage = 1, reportSearch = '';
function reportRowHTML(r){
const d = new Date(r.started_at);
const dur = r.ended_at ? (r.ended_at - r.started_at) : null;
return `<tr>
<td>${d.toLocaleDateString()}</td>
<td class="muted">${d.toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'})}</td>
${IS_ADMIN ? `<td>${esc(r.agent_name || r.agent_email || '—')}</td>` : ''}
<td>${esc(r.ticket || 'Direct session')}</td>
<td>${r.ended_at ? fmtDuration(dur) : '<span class="pill on">in progress</span>'}</td>
<td>${[
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>`;
}
async function loadReport() {
const q = new URLSearchParams();
const fa = document.getElementById('fAgent');
if (fa && fa.value) q.set('agent', fa.value);
if (fFrom.value) q.set('from', fFrom.value);
if (fTo.value) q.set('to', fTo.value);
REPORT_ROWS = await api('/api/report?' + q.toString(), null, 'GET');
reportPage = 1;
const s = document.getElementById('repSearch');
if (s && !s._w){ s._w = 1; s.addEventListener('input', () => { reportSearch = s.value.trim().toLowerCase(); reportPage = 1; renderReport(); }); }
renderStats();
renderReport();
}
window.reportGo = (p) => { reportPage = p; renderReport(); };
function filteredRows(){
return reportSearch ? REPORT_ROWS.filter(r => ((r.agent_name||'')+' '+(r.agent_email||'')+' '+(r.ticket||'')).toLowerCase().includes(reportSearch)) : REPORT_ROWS;
}
function renderStats(){
const el = document.getElementById('stats'); if (!el) return;
const rows = REPORT_ROWS;
const total = rows.length;
const totalMs = rows.reduce((a, r) => a + (r.ended_at ? r.ended_at - r.started_at : 0), 0);
const recs = rows.filter(r => r.recording).length;
const cards = [
{ v: total, k: IS_ADMIN ? 'Total sessions' : 'My sessions' },
{ v: fmtDuration(totalMs), k: 'Time spent' },
{ v: recs, k: 'Recorded' },
];
el.innerHTML = cards.map(c => `<div class="stat"><div class="v">${esc(String(c.v))}</div><div class="k">${esc(c.k)}</div></div>`).join('');
}
function renderReport(){
const all = filteredRows();
const pages = Math.max(1, Math.ceil(all.length / PER_PAGE));
if (reportPage > pages) reportPage = pages;
const slice = all.slice((reportPage-1)*PER_PAGE, (reportPage-1)*PER_PAGE + PER_PAGE);
const cols = IS_ADMIN ? 6 : 5;
document.querySelector('#report tbody').innerHTML = slice.map(reportRowHTML).join('') || `<tr><td colspan=${cols} class="muted">No sessions match.</td></tr>`;
document.getElementById('repPager').innerHTML = pagerHTML(reportPage, pages, all.length, 'reportGo');
const total = all.reduce((a, r) => a + (r.ended_at ? r.ended_at - r.started_at : 0), 0);
repSummary.textContent = all.length ? `${all.length} session(s) · total time ${fmtDuration(total)}` : '';
}
function reportData() {
return filteredRows().map((r) => {
const d = new Date(r.started_at);
return {
date: d.toLocaleDateString(), start: d.toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'}),
agent: r.agent_name || r.agent_email || '', ticket: r.ticket || 'Direct session',
spent: r.ended_at ? fmtDuration(r.ended_at - r.started_at) : 'in progress',
};
});
}
function exportExcel() {
const rows = reportData();
if (!rows.length) { repSummary.textContent = 'Nothing to export for this period.'; return; }
const head = ['Date','Start time'].concat(IS_ADMIN ? ['Agent'] : []).concat(['Ticket','Time spent']);
const csvCell = (v) => '"' + String(v).replace(/"/g, '""') + '"';
const out = '' + [head, ...rows.map(r => [r.date, r.start].concat(IS_ADMIN ? [r.agent] : []).concat([r.ticket, r.spent]))]
.map(line => line.map(csvCell).join(',')).join('\r\n');
const a = document.createElement('a');
a.href = URL.createObjectURL(new Blob([out], { type: 'text/csv;charset=utf-8' }));
a.download = 'connection-report.csv';
a.click(); URL.revokeObjectURL(a.href);
}
function exportPdf() {
const rows = reportData();
if (!rows.length) { repSummary.textContent = 'Nothing to export for this period.'; return; }
const period = (fFrom.value || 'start') + ' to ' + (fTo.value || 'today');
const fa = document.getElementById('fAgent');
const agentSel = IS_ADMIN ? (fa && fa.value || 'All agents') : (ME.name || ME.email);
const w = window.open('', '_blank');
const headCells = ['Date','Start time'].concat(IS_ADMIN ? ['Agent'] : []).concat(['Ticket','Time spent']);
w.document.write('<html><head><title>Connection report</title><style>' +
'body{font-family:Segoe UI,Arial,sans-serif;color:#1f2430;margin:32px}' +
'h1{font-size:18px;color:#1F3B73;border-bottom:3px solid #FFC708;padding-bottom:6px}' +
'.meta{color:#6b7280;font-size:12px;margin-bottom:14px}' +
'table{width:100%;border-collapse:collapse;font-size:12px}' +
'th{background:#1F3B73;color:#fff;text-align:left;padding:6px 8px}' +
'td{padding:6px 8px;border-bottom:1px solid #e6e9ef}' +
'</style></head><body>' +
'<h1>BizGaze Connect — Connection report</h1>' +
'<div class="meta">' + esc(IS_ADMIN ? 'Agent: ' + agentSel : 'Agent: ' + agentSel) + ' · Period: ' + esc(period) + ' · Generated ' + new Date().toLocaleString() + '</div>' +
'<table><tr>' + headCells.map(h => '<th>' + esc(h) + '</th>').join('') + '</tr>' +
rows.map(r => '<tr><td>' + [r.date, r.start].concat(IS_ADMIN ? [esc(r.agent)] : []).concat([esc(r.ticket), r.spent]).join('</td><td>') + '</td></tr>').join('') +
'</table><div class="meta" style="margin-top:12px">' + esc(repSummary.textContent) + '</div></body></html>');
w.document.close();
w.onload = () => { w.print(); };
}
function esc(s) { return String(s == null ? '' : s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c])); }
// ---------- Boot ----------
// Login lives on /home — send logged-out visitors there.
(async function () {
try { const me = await api('/api/me', null, 'GET'); dashboard(me); }
catch { location.href = '/home'; }
})();
</script>
</body>
</html>
+277
Parādīt failu
@@ -0,0 +1,277 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>BizGaze Connect — Home</title>
<style>
:root{ --brand:#FFC708; --brand-d:#E0AC00; --blue:#1F3B73; --blue-d:#16294f; --blue-soft:#EAF0FB; --ink:#1f2430; --muted:#6b7280; --bg:#f6f8fb; --card:#fff; --line:#e6e9ef; --green:#16a34a; --red:#b91c1c; }
*{box-sizing:border-box;}
html,body{height:100%;}
body{font-family:'Segoe UI',system-ui,sans-serif;background:var(--bg);color:var(--ink);margin:0;display:flex;flex-direction:column;height:100vh;overflow:hidden;}
/* ---- Top bar (matches console.html) ---- */
header{background:var(--blue);padding:.75rem 1.5rem;display:flex;justify-content:space-between;align-items:center;flex:0 0 auto;}
.brandrow{display:flex;align-items:center;gap:.6rem;cursor:pointer;}
.logo{width:30px;height:30px;border-radius:8px;background:var(--brand);display:grid;place-items:center;font-weight:800;color:var(--blue);}
.brand{font-weight:700;color:#fff;font-size:1.05rem;} .brand span.y{color:var(--brand);font-weight:700;}
.brand span.tag{color:#8ea3cf;font-weight:500;font-size:.85rem;}
/* ---- Profile dropdown (from console.html) ---- */
.profile{position:relative}
.profile .pbtn{display:flex;align-items:center;gap:.5rem;background:rgba(255,255,255,.14);color:#fff;border:1px solid #46598c;border-radius:10px;padding:.4rem .85rem .4rem .5rem;font-weight:600;font-size:.88rem;cursor:pointer}
.profile .pbtn:hover{background:rgba(255,255,255,.24)}
.profile .pbtn .pav{width:28px;height:28px;border-radius:50%;background:var(--brand);color:var(--blue);display:grid;place-items:center;font-weight:800;font-size:.78rem}
.profile .pmenu{position:absolute;right:0;top:calc(100% + 6px);background:#fff;border:1px solid #e6e9ef;border-radius:10px;box-shadow:0 10px 28px rgba(0,0,0,.18);min-width:210px;overflow:hidden;z-index:5000;display:none}
.profile .pmenu.open{display:block}
.profile .pmenu .phead{padding:.7rem .9rem;border-bottom:1px solid #eef1f6}
.profile .pmenu .phead .n{font-weight:700;font-size:.9rem}
.profile .pmenu .phead .e{color:var(--muted);font-size:.78rem}
.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}
/* ---- Shell ---- */
.shell{flex:1 1 auto;display:flex;min-height:0;}
/* ---- Sidebar ---- */
.sidebar{width:320px;flex:0 0 320px;background:var(--card);border-right:1px solid var(--line);display:flex;flex-direction:column;min-height:0;}
.side-head{padding:1rem 1rem .75rem;border-bottom:1px solid var(--line);}
.side-title{display:flex;align-items:center;justify-content:space-between;margin-bottom:.7rem;}
.side-title h2{font-size:.95rem;margin:0;color:var(--blue);}
.newchat{width:30px;height:30px;border-radius:9px;border:none;background:var(--blue-soft);color:var(--blue);font-size:1.2rem;line-height:1;cursor:pointer;font-weight:700;display:grid;place-items:center;padding:0;}
.newchat:hover{background:#dbe6fb;}
.search{position:relative;}
.search svg{position:absolute;left:.65rem;top:50%;transform:translateY(-50%);color:var(--muted);}
.search input{width:100%;padding:.55rem .7rem .55rem 2.1rem;border-radius:10px;border:2px solid var(--line);background:#fbfcfe;color:var(--ink);font-size:.9rem;}
.search input:focus{outline:none;border-color:var(--brand);}
.chatlist{overflow-y:auto;flex:1 1 auto;padding:.4rem;}
.chat-row{display:flex;gap:.7rem;align-items:center;padding:.6rem .65rem;border-radius:12px;cursor:pointer;position:relative;}
.chat-row:hover{background:#f3f6fb;}
.chat-row.active{background:var(--blue-soft);}
.chat-row.active::before{content:"";position:absolute;left:0;top:.7rem;bottom:.7rem;width:3px;border-radius:3px;background:var(--blue);}
.avatar{width:42px;height:42px;flex:0 0 42px;border-radius:50%;display:grid;place-items:center;color:#fff;font-weight:700;font-size:.92rem;position:relative;}
.avatar .dot{position:absolute;right:-1px;bottom:-1px;width:11px;height:11px;border-radius:50%;border:2px solid #fff;background:#cbd2dd;}
.avatar .dot.on{background:var(--green);}
.chat-main{flex:1 1 auto;min-width:0;}
.chat-top{display:flex;justify-content:space-between;align-items:baseline;gap:.5rem;}
.chat-name{font-weight:600;font-size:.92rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.chat-time{color:var(--muted);font-size:.72rem;flex:0 0 auto;}
.chat-bottom{display:flex;justify-content:space-between;align-items:center;gap:.5rem;margin-top:.15rem;}
.chat-prev{color:var(--muted);font-size:.82rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1 1 auto;}
.chat-row.unread .chat-prev{color:var(--ink);font-weight:500;}
.chat-row.unread .chat-name{font-weight:700;}
.badge{flex:0 0 auto;background:var(--blue);color:#fff;font-size:.7rem;font-weight:700;min-width:19px;height:19px;border-radius:99px;padding:0 .35rem;display:grid;place-items:center;}
.no-results{padding:2rem 1rem;text-align:center;color:var(--muted);font-size:.85rem;}
/* ---- Main content ---- */
.content{flex:1 1 auto;display:flex;flex-direction:column;min-width:0;min-height:0;}
.tabs{display:flex;gap:.4rem;padding:1rem 1.5rem 0;border-bottom:1px solid var(--line);background:var(--card);}
.tabs button{background:transparent;color:var(--muted);font-weight:600;font-size:.92rem;border:none;border-bottom:3px solid transparent;padding:.6rem .9rem .8rem;cursor:pointer;display:flex;align-items:center;gap:.45rem;border-radius:8px 8px 0 0;}
.tabs button:hover{color:var(--blue);background:#f6f8fb;}
.tabs button.active{color:var(--blue);border-bottom-color:var(--brand);}
.panel-wrap{flex:1 1 auto;overflow-y:auto;padding:2rem 1.5rem;display:flex;}
.panel{display:none;margin:auto;width:100%;max-width:560px;}
.panel.active{display:block;}
.card{background:var(--card);border:1px solid var(--line);border-radius:16px;padding:2.2rem;box-shadow:0 6px 18px rgba(20,30,60,.05);text-align:center;}
.feat-icon{width:72px;height:72px;border-radius:20px;display:grid;place-items:center;margin:0 auto 1.2rem;}
.feat-icon.blue{background:var(--blue-soft);color:var(--blue);}
.feat-icon.yellow{background:#fff6d8;color:var(--brand-d);}
.card h1{font-size:1.45rem;margin:0 0 .5rem;color:var(--blue);}
.card p{color:var(--muted);font-size:.95rem;line-height:1.55;margin:0 auto 1.6rem;max-width:400px;}
.btn{display:inline-flex;align-items:center;gap:.5rem;text-decoration:none;padding:.8rem 1.6rem;background:var(--brand);color:var(--ink);border:none;border-radius:11px;font-weight:700;font-size:.95rem;cursor:pointer;}
.btn:hover{background:var(--brand-d);}
.pill-soon{display:inline-block;background:#fff6d8;color:var(--brand-d);font-size:.74rem;font-weight:700;padding:.25rem .7rem;border-radius:99px;letter-spacing:.03em;margin-bottom:1.2rem;}
.hint{margin-top:1.4rem;font-size:.8rem;color:var(--muted);}
@media (max-width:760px){
.sidebar{width:108px;flex:0 0 108px;}
.side-title h2,.search,.chat-main{display:none;}
.chat-row{justify-content:center;}
.side-head{padding:.8rem .5rem;}
}
</style>
</head>
<body>
<header>
<div class="brandrow" id="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 class="y">Connect</span> <span class="tag">· Home</span></div>
</div>
<div id="hdrRight"></div>
</header>
<div class="shell">
<!-- ---------- Sidebar ---------- -->
<aside class="sidebar">
<div class="side-head">
<div class="side-title">
<h2>Chats</h2>
<button class="newchat" title="New chat" aria-label="New chat">+</button>
</div>
<div class="search">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input id="chatSearch" placeholder="Search chats" autocomplete="off">
</div>
</div>
<div class="chatlist" id="chatlist"></div>
</aside>
<!-- ---------- Main ---------- -->
<section class="content">
<div class="tabs">
<button data-tab="meeting" class="active">
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="23 7 16 12 23 17 23 7"/><rect x="1" y="5" width="15" height="14" rx="2" ry="2"/></svg>
Meeting
</button>
<button data-tab="share">
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
Share Screen
</button>
<button data-tab="connect">
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><line x1="12" y1="20" x2="12.01" y2="20"/></svg>
Connect Screen
</button>
</div>
<div class="panel-wrap">
<!-- Meeting -->
<div class="panel active" data-panel="meeting">
<div class="card">
<div class="feat-icon yellow">
<svg viewBox="0 0 24 24" width="34" height="34" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="23 7 16 12 23 17 23 7"/><rect x="1" y="5" width="15" height="14" rx="2" ry="2"/></svg>
</div>
<span class="pill-soon">COMING SOON</span>
<h1>Meetings are on the way</h1>
<p>Soon you'll be able to host multi-party video meetings with your BizGaze team and customers — right here, no install needed. We're putting on the finishing touches.</p>
<button class="btn" id="notifyBtn">🔔 Notify me when it's ready</button>
<div class="hint">In the meantime, use <b>Share Screen</b> or <b>Connect Screen</b> to start a session.</div>
</div>
</div>
<!-- Share Screen -->
<div class="panel" data-panel="share">
<div class="card">
<div class="feat-icon blue">
<svg viewBox="0 0 24 24" width="34" height="34" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
</div>
<h1>Share your screen</h1>
<p>Let a teammate or customer see your screen instantly. You'll get a 6-digit code to share — they enter it to connect. No download, works right in the browser.</p>
<a class="btn" href="/share">Start sharing →</a>
<div class="hint">Desktop browsers only — phones can't share their screen yet.</div>
</div>
</div>
<!-- Connect Screen -->
<div class="panel" data-panel="connect">
<div class="card">
<div class="feat-icon blue">
<svg viewBox="0 0 24 24" width="34" height="34" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><line x1="12" y1="20" x2="12.01" y2="20"/></svg>
</div>
<h1>Connect to a screen</h1>
<p>Helping someone out? Enter the 6-digit code they give you to view their screen and provide live support — with two-way voice and chat built in.</p>
<a class="btn" href="/connect">Open connect page →</a>
<div class="hint">The other person taps <b>Allow</b> before you can see anything.</div>
</div>
</div>
</div>
</section>
</div>
<script>
// ---------- Helpers (reused patterns from console.html) ----------
function pEsc(s){return String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));}
function initials(name){return name.trim().split(/\s+/).slice(0,2).map(w=>w[0]).join('').toUpperCase();}
// Stable avatar color from a name
const AV_COLORS=['#1F3B73','#2563eb','#0e7490','#7c3aed','#be185d','#b45309','#15803d','#9d174d'];
function avColor(name){let h=0;for(const c of name)h=(h*31+c.charCodeAt(0))>>>0;return AV_COLORS[h%AV_COLORS.length];}
// Profile dropdown (mirrors profileHTML()/wireProfile() from console.html)
const SAMPLE_USER={name:'Sravan Mareddy',email:'sravanm@bizgaze.com',role:'admin'};
function profileHTML(u){
return '<div class="profile"><button class="pbtn" id="pbtn">'
+ '<span class="pav">'+pEsc(initials(u.name))+'</span>'
+ pEsc(u.name)+' <span style="font-size:.65rem">&#9662;</span></button>'
+ '<div class="pmenu" id="pmenu">'
+ '<div class="phead"><div class="n">'+pEsc(u.name)+'</div><div class="e">'+pEsc(u.email)+' · '+pEsc(u.role)+'</div></div>'
+ '<a href="/console">Console / Dashboard</a>'
+ '<a href="#">Settings</a>'
+ '<a class="danger" id="plogout">Logout</a>'
+ '</div></div>';
}
function wireProfile(){
const btn=document.getElementById('pbtn'),menu=document.getElementById('pmenu');
if(!btn)return;
btn.onclick=(e)=>{e.stopPropagation();menu.classList.toggle('open');};
document.addEventListener('click',()=>menu.classList.remove('open'));
const lo=document.getElementById('plogout');
if(lo)lo.onclick=(e)=>{e.preventDefault();alert('Mockup — logout would sign you out and return to /.');};
}
document.getElementById('hdrRight').innerHTML=profileHTML(SAMPLE_USER);
wireProfile();
document.getElementById('brandrow').onclick=()=>{location.href='/';};
// ---------- Mock chat data ----------
const CHATS=[
{name:'Anwi Systems', msg:"Perfect, the screen share worked great. Thanks!", time:'9:42 AM', unread:0, online:true, active:true},
{name:'Priya Sharma', msg:"Can you connect to my screen at 3pm?", time:'9:15 AM', unread:2, online:true},
{name:'GAPL Group', msg:"You: I've shared the 6-digit code with you", time:'Yesterday', unread:0, online:false},
{name:'Battery Doctors', msg:"The invoice module is throwing an error again", time:'Yesterday', unread:5, online:true},
{name:'Ramesh Marketing', msg:"You: Let me know once you're at your desk", time:'Mon', unread:0, online:false},
{name:'STC Support', msg:"Typing…", time:'Mon', unread:1, online:true},
{name:'Samruddhi Traders',msg:"Thanks for the help earlier 👍", time:'Sun', unread:0, online:false},
{name:'DMS 3.0 Team', msg:"You: Closing the ticket, all resolved", time:'Fri', unread:0, online:false},
];
const listEl=document.getElementById('chatlist');
function chatRowHTML(c,i){
const cls=['chat-row'];
if(c.active)cls.push('active');
if(c.unread>0)cls.push('unread');
return '<div class="'+cls.join(' ')+'" data-i="'+i+'">'
+ '<div class="avatar" style="background:'+avColor(c.name)+'">'+pEsc(initials(c.name))
+ '<span class="dot'+(c.online?' on':'')+'"></span></div>'
+ '<div class="chat-main">'
+ '<div class="chat-top"><span class="chat-name">'+pEsc(c.name)+'</span><span class="chat-time">'+pEsc(c.time)+'</span></div>'
+ '<div class="chat-bottom"><span class="chat-prev">'+pEsc(c.msg)+'</span>'
+ (c.unread>0?'<span class="badge">'+c.unread+'</span>':'')+'</div>'
+ '</div></div>';
}
function renderChats(filter){
const q=(filter||'').trim().toLowerCase();
const rows=CHATS.map((c,i)=>({c,i})).filter(({c})=>!q||c.name.toLowerCase().includes(q)||c.msg.toLowerCase().includes(q));
listEl.innerHTML = rows.length
? rows.map(({c,i})=>chatRowHTML(c,i)).join('')
: '<div class="no-results">No chats match “'+pEsc(filter)+'”.</div>';
listEl.querySelectorAll('.chat-row').forEach(row=>{
row.onclick=()=>{
CHATS.forEach(c=>c.active=false);
CHATS[+row.dataset.i].active=true;
CHATS[+row.dataset.i].unread=0;
renderChats(document.getElementById('chatSearch').value);
};
});
}
renderChats('');
document.getElementById('chatSearch').addEventListener('input',e=>renderChats(e.target.value));
// ---------- Tab switching ----------
const tabBtns=document.querySelectorAll('.tabs button');
const panels=document.querySelectorAll('.panel');
tabBtns.forEach(btn=>{
btn.onclick=()=>{
const tab=btn.dataset.tab;
tabBtns.forEach(b=>b.classList.toggle('active',b===btn));
panels.forEach(p=>p.classList.toggle('active',p.dataset.panel===tab));
};
});
// Mockup-only stubs
document.querySelector('.newchat').onclick=()=>alert('Mockup — “New chat” would open the contact picker.');
document.getElementById('notifyBtn').onclick=()=>alert("Thanks! We'll let you know when Meetings launches.");
</script>
</body>
</html>
Failā izmaiņas netiks attēlotas, jo tās ir par lielu Ielādēt izmaiņas
+3 -1
Parādīt failu
@@ -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?v=3"></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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[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>
Bināro failu nav iespējams attēlot.

Pēc

Platums:  |  Augstums:  |  Izmērs: 11 KiB

Bināro failu nav iespējams attēlot.

Pēc

Platums:  |  Augstums:  |  Izmērs: 44 KiB

+65
Parādīt failu
@@ -0,0 +1,65 @@
// Shared icon set (Lucide — modern line icons). Use ic('name', size) for any UI icon.
// Add new icons here so the whole app stays visually consistent.
(function () {
const P = {
chat: '<path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z"/>',
screenShare: '<path d="M13 3H4a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-3"/><path d="M8 21h8"/><path d="M12 17v4"/><path d="m17 8 5-5"/><path d="M17 3h5v5"/>',
wifi: '<path d="M12 20h.01"/><path d="M8.5 16.4a5 5 0 0 1 7 0"/><path d="M5 12.9a10 10 0 0 1 14 0"/><path d="M2 8.8a15 15 0 0 1 20 0"/>',
video: '<path d="m22 8-6 4 6 4V8Z"/><rect width="14" height="12" x="2" y="6" rx="2" ry="2"/>',
paperclip: '<path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48"/>',
smile: '<circle cx="12" cy="12" r="10"/><path d="M8 14s1.5 2 4 2 4-2 4-2"/><line x1="9" x2="9.01" y1="9" y2="9"/><line x1="15" x2="15.01" y1="9" y2="9"/>',
smilePlus: '<path d="M22 11v1a10 10 0 1 1-9-10"/><path d="M8 14s1.5 2 4 2 4-2 4-2"/><line x1="9" x2="9.01" y1="9" y2="9"/><line x1="15" x2="15.01" y1="9" y2="9"/><path d="M16 5h6"/><path d="M19 2v6"/>',
reply: '<polyline points="9 14 4 9 9 4"/><path d="M20 20v-7a4 4 0 0 0-4-4H4"/>',
info: '<circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/>',
x: '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
arrowLeft: '<path d="m12 19-7-7 7-7"/><path d="M19 12H5"/>',
users: '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
userPlus: '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><line x1="19" x2="19" y1="8" y2="14"/><line x1="22" x2="16" y1="11" y2="11"/>',
send: '<path d="M14.54 21.69a.5.5 0 0 0 .94-.03l6.5-19a.5.5 0 0 0-.64-.63l-19 6.5a.5.5 0 0 0-.02.93l7.93 3.18a2 2 0 0 1 1.1 1.11z"/><path d="m21.85 2.15-10.94 10.94"/>',
search: '<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>',
edit: '<path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/>',
trash: '<path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>',
logOut: '<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" x2="9" y1="12" y2="12"/>',
plus: '<path d="M5 12h14"/><path d="M12 5v14"/>',
check: '<path d="M20 6 9 17l-5-5"/>',
download: '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" x2="12" y1="15" y2="3"/>',
copy: '<rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>',
file: '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/>',
mic: '<path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" x2="12" y1="19" y2="22"/>',
micOff: '<line x1="2" x2="22" y1="2" y2="22"/><path d="M18.89 13.23A7.12 7.12 0 0 0 19 12v-2"/><path d="M5 10v2a7 7 0 0 0 12 5"/><path d="M15 9.34V5a3 3 0 0 0-5.68-1.33"/><path d="M9 9v3a3 3 0 0 0 5.12 2.12"/><line x1="12" x2="12" y1="19" y2="22"/>',
camera: '<path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z"/><circle cx="12" cy="13" r="3"/>',
cameraOff: '<line x1="2" x2="22" y1="2" y2="22"/><path d="M7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h12"/><path d="M9.5 4h5L17 7h3a2 2 0 0 1 2 2v7.5"/>',
phoneOff: '<path d="M10.68 13.31a16 16 0 0 0 3.41 2.6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7 2 2 0 0 1 1.72 2v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.42 19.42 0 0 1-3.33-2.67m-2.67-3.34a19.79 19.79 0 0 1-3.07-8.63A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91"/><line x1="2" x2="22" y1="2" y2="22"/>',
phone: '<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/>',
calendar: '<path d="M8 2v4"/><path d="M16 2v4"/><rect width="18" height="18" x="3" y="4" rx="2"/><path d="M3 10h18"/>',
pencil: '<path d="M21.17 6.83a2.83 2.83 0 0 0-4-4L3.84 16.17a2 2 0 0 0-.5.83l-1.32 4.35a.5.5 0 0 0 .62.62l4.35-1.32a2 2 0 0 0 .83-.5z"/><path d="m15 5 4 4"/>',
chevronDown: '<path d="m6 9 6 6 6-6"/>',
layoutDashboard:'<rect width="7" height="9" x="3" y="3" rx="1"/><rect width="7" height="5" x="14" y="3" rx="1"/><rect width="7" height="9" x="14" y="12" rx="1"/><rect width="7" height="5" x="3" y="16" rx="1"/>',
arrowRight: '<path d="M5 12h14"/><path d="m12 5 7 7-7 7"/>',
alertTriangle:'<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3z"/><path d="M12 9v4"/><path d="M12 17h.01"/>',
lock: '<rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
monitor: '<rect width="20" height="14" x="2" y="3" rx="2"/><line x1="8" x2="16" y1="21" y2="21"/><line x1="12" x2="12" y1="17" y2="21"/>',
barChart: '<path d="M3 3v16a2 2 0 0 0 2 2h16"/><rect x="7" y="11" width="3" height="6" rx="1"/><rect x="12" y="7" width="3" height="10" rx="1"/><rect x="17" y="13" width="3" height="4" rx="1"/>',
bell: '<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/>',
bold: '<path d="M14 12a4 4 0 0 0 0-8H6v8"/><path d="M15 20a4 4 0 0 0 0-8H6v8Z"/>',
italic: '<line x1="19" x2="10" y1="4" y2="4"/><line x1="14" x2="5" y1="20" y2="20"/><line x1="15" x2="9" y1="4" y2="20"/>',
strikethrough:'<path d="M16 4H9a3 3 0 0 0-2.83 4"/><path d="M14 12a4 4 0 0 1 0 8H6"/><line x1="4" x2="20" y1="12" y2="12"/>',
code: '<polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/>',
list: '<line x1="8" x2="21" y1="6" y2="6"/><line x1="8" x2="21" y1="12" y2="12"/><line x1="8" x2="21" y1="18" y2="18"/><line x1="3" x2="3.01" y1="6" y2="6"/><line x1="3" x2="3.01" y1="12" y2="12"/><line x1="3" x2="3.01" y1="18" y2="18"/>',
listOrdered: '<line x1="10" x2="21" y1="6" y2="6"/><line x1="10" x2="21" y1="12" y2="12"/><line x1="10" x2="21" y1="18" y2="18"/><path d="M4 6h1v4"/><path d="M4 10h2"/><path d="M6 18H4c0-1 2-2 2-3s-1-1.5-2-1"/>',
type: '<polyline points="4 7 4 4 20 4 20 7"/><line x1="9" x2="15" y1="20" y2="20"/><line x1="12" x2="12" y1="4" y2="20"/>',
crown: '<path d="m2 4 3 12h14l3-12-6 7-4-7-4 7-6-7z"/><path d="M5 20h14"/>',
checkCheck: '<path d="M18 6 7 17l-5-5"/><path d="m22 10-7.5 7.5L13 16"/>',
calendarX: '<path d="M8 2v4"/><path d="M16 2v4"/><rect width="18" height="18" x="3" y="4" rx="2"/><path d="M3 10h18"/><path d="m14 14-4 4"/><path d="m10 14 4 4"/>',
calendarClock:'<path d="M21 7.5V6a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h3.5"/><path d="M16 2v4"/><path d="M8 2v4"/><path d="M3 10h5"/><circle cx="16" cy="16" r="6"/><path d="M16 14v2l1.5 1"/>',
fileText: '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M16 13H8"/><path d="M16 17H8"/><path d="M10 9H8"/>',
record: '<circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="3.5" fill="currentColor"/>',
callEnd: '<g transform="rotate(135 12 12)"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/></g>',
settings: '<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>',
};
window.ICON = P;
window.ic = function (name, size) {
const s = size || 18;
return '<svg class="ic" width="' + s + '" height="' + s + '" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' + (P[name] || '') + '</svg>';
};
})();
+29 -19
Parādīt failu
@@ -17,14 +17,19 @@
.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(--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;}
.choice{flex:1;min-width:260px;max-width:340px;background:#fff;border:1px solid var(--line);border-radius:18px;padding:2.2rem 1.6rem;text-decoration:none;color:var(--ink);box-shadow:0 10px 30px rgba(20,30,60,.06);transition:transform .12s,box-shadow .12s;}
.choice{flex:1;min-width:260px;max-width:360px;background:#fff;border:1px solid var(--line);border-radius:18px;padding:1.8rem 1.6rem;text-decoration:none;color:var(--ink);box-shadow:0 10px 30px rgba(20,30,60,.06);transition:transform .12s,box-shadow .12s,border-color .12s;display:flex;align-items:center;gap:1.1rem;text-align:left;}
.choice:hover{transform:translateY(-3px);box-shadow:0 16px 38px rgba(20,30,60,.12);border-color:var(--brand);}
.icon{width:66px;height:66px;border-radius:18px;display:grid;place-items:center;margin:0 auto 1.1rem;}
.icon{width:56px;height:56px;flex:0 0 56px;border-radius:16px;display:grid;place-items:center;}
.icon.share{background:#FFF7DA;} .icon.connect{background:var(--blue-soft);}
.icon svg{width:34px;height:34px;}
.choice h3{margin:.2rem 0 .4rem;color:var(--blue);font-size:1.15rem;}
.choice p{margin:0;color:var(--muted);font-size:.9rem;line-height:1.5;}
.icon svg{width:30px;height:30px;}
.choice h3{margin:0 0 .25rem;color:var(--blue);font-size:1.1rem;}
.choice p{margin:0;color:var(--muted);font-size:.88rem;line-height:1.45;}
.foot{color:var(--muted);font-size:.82rem;margin-top:2.4rem;}
footer{text-align:center;color:#9aa3b2;font-size:.8rem;padding:1rem;}
.profile{position:relative}
@@ -36,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?v=3"></script>
</head>
<body>
<header>
@@ -43,35 +49,39 @@
<img src="/logo.png" alt="" style="height:40px;width:auto;max-width:170px;border-radius:8px;object-fit:contain;background:#fff;padding:4px 10px" onerror="this.style.display='none'">
<div class="brand">BizGaze <span>Support</span></div>
</div>
<div id="authArea"><a class="signin" href="/console">Staff sign in</a></div>
<div id="authArea"></div>
</header>
<div class="wrap">
<div class="inner">
<h1>How can we help you today?</h1>
<div class="sub">Secure remote support — no downloads, you stay in control.</div>
<div class="choices">
<h1>Welcome to BizGaze Connect</h1>
<div class="sub">Chat, meetings and secure remote support — for the BizGaze ecosystem.</div>
<!-- Stub SSO: routes to staff login for now; swap href to /sso once BizGaze SSO is wired. -->
<a class="ssobtn" id="ssoBtn" href="/home"><span class="bmark">B</span> Log in with BizGaze</a>
<div class="divider">need support? no account required</div>
<div class="choices" style="max-width:400px;margin:0 auto">
<a class="choice" href="/share">
<div class="icon share"><svg viewBox="0 0 24 24" fill="none" stroke="#BA7515" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg></div>
<h3>Share my screen</h3>
<p>You need help. Get a one-time code and show your screen to a BizGaze support agent.</p>
</a>
<a class="choice" href="/connect">
<div class="icon connect"><svg viewBox="0 0 24 24" fill="none" stroke="#1F3B73" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 17V7a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v10"/><path d="M2 21h20"/><path d="m9 9 3 3-3 3"/></svg></div>
<h3>Connect to a screen</h3>
<p>You're a support agent. Sign in, then enter the customer's code to view their screen.</p>
<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 the customer approves 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>
<script>
function pEsc(s){return String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));}
function profileHTML(name){return '<div class="profile"><button class="pbtn" id="pbtn">'+pEsc(name)+' <span style="font-size:.65rem">&#9662;</span></button><div class="pmenu" id="pmenu"><a href="/console">Console / Dashboard</a><a id="plogout" class="danger">Logout</a></div></div>';}
function profileHTML(name){return '<div class="profile"><button class="pbtn" id="pbtn">'+pEsc(name)+' <span style="font-size:.65rem">&#9662;</span></button><div class="pmenu" id="pmenu"><a href="/home">Home</a><a href="/dashboard">Dashboard</a><a id="plogout" class="danger">Logout</a></div></div>';}
function wireProfile(){const btn=document.getElementById('pbtn'),menu=document.getElementById('pmenu');if(!btn)return;btn.onclick=(e)=>{e.stopPropagation();menu.classList.toggle('open');};document.addEventListener('click',()=>menu.classList.remove('open'));const lo=document.getElementById('plogout');if(lo)lo.onclick=async()=>{try{await fetch('/api/logout',{method:'POST'});}catch(_){}location.href='/';};}
function makeBrandClickable(){document.querySelectorAll('.brandrow,.wordmark').forEach(el=>{el.style.cursor='pointer';el.addEventListener('click',()=>{location.href='/';});});}
makeBrandClickable();
(async function(){try{const r=await fetch('/api/me');if(r.ok){const me=await r.json();document.getElementById('authArea').innerHTML=profileHTML(me.name||me.email);wireProfile();}}catch(_){}})();
(async function(){try{const r=await fetch('/api/me');if(r.ok){const me=await r.json();
document.getElementById('authArea').innerHTML=profileHTML(me.name||me.email);wireProfile();
// Already signed in: swap the login CTA for an "enter app" CTA.
const b=document.getElementById('ssoBtn'); if(b){ b.innerHTML='Open BizGaze Connect &rarr;'; b.href='/home'; }
const h=document.querySelector('.inner h1'); if(h){ const fn=String(me.name||'').trim().split(/\s+/)[0]; h.textContent='Welcome back'+(fn?', '+fn:'')+'!'; }
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>
+16
Parādīt failu
@@ -0,0 +1,16 @@
{
"name": "BizGaze Connect",
"short_name": "Connect",
"description": "Chat, screen share, and video meetings for the BizGaze ecosystem.",
"start_url": "/home",
"scope": "/",
"display": "standalone",
"orientation": "portrait-primary",
"background_color": "#1F3B73",
"theme_color": "#1F3B73",
"icons": [
{ "src": "/icon-192.png?v=2", "sizes": "192x192", "type": "image/png", "purpose": "any" },
{ "src": "/icon-512.png?v=2", "sizes": "512x512", "type": "image/png", "purpose": "any" },
{ "src": "/icon-512.png?v=2", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
]
}
+101 -24
Parādīt failu
@@ -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:3rem;letter-spacing:.5rem;font-weight:800;color:var(--ink);}
.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);}
@@ -32,6 +32,10 @@
.foot{color:var(--muted);font-size:.8rem;margin-top:1.4rem;}
.indicator{position:fixed;top:0;left:0;right:0;background:#dc2626;color:#fff;text-align:center;padding:.5rem;font-size:.9rem;display:none;font-weight:600;z-index:9;}
.indicator.show{display:block;}
/* Embedded inside the home shell: hide own chrome (the shell provides it). */
html.embed .brandpanel{display:none!important;}
html.embed #homeLink{display:none!important;}
html.embed .panelside{flex:1;}
@media(max-width:860px){ .stage{flex-direction:column;} .brandpanel{padding:2rem;min-height:auto;} .mark{width:60px;height:60px;border-radius:16px;font-size:1.8rem;margin-bottom:.7rem;} .wordmark{font-size:1.5rem;} .tagline{display:none;} }
.profile{position:relative}
.profile .pbtn{display:flex;align-items:center;gap:.4rem;background:rgba(255,255,255,.14);color:#fff;border:1px solid #46598c;border-radius:10px;padding:.45rem .85rem;font-weight:600;font-size:.88rem;cursor:pointer}
@@ -42,10 +46,12 @@
.profile .pmenu a:hover{background:#f1f5f9}
.profile .pmenu a.danger{color:#b91c1c;border-top:1px solid #eef1f6}
</style>
<script src="/icons.js?v=3"></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="/" style="position:fixed;top:14px;left:16px;z-index:50;display:inline-flex;align-items:center;gap:6px;background:rgba(255,255,255,.92);color:#1F3B73;text-decoration:none;font-weight:600;font-size:.86rem;padding:.45rem .8rem;border-radius:10px;box-shadow:0 4px 12px rgba(0,0,0,.15)">&#8592; Home</a>
<a href="/" id="homeLink" style="position:fixed;top:14px;left:16px;z-index:50;display:inline-flex;align-items:center;gap:6px;background:rgba(255,255,255,.92);color:#1F3B73;text-decoration:none;font-weight:600;font-size:.86rem;padding:.45rem .8rem;border-radius:10px;box-shadow:0 4px 12px rgba(0,0,0,.15)"><span data-ic="arrowLeft" data-sz="16"></span> Home</a>
<div class="stage">
<div class="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'}))">
@@ -60,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>
@@ -73,10 +79,14 @@
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)ICE=c;}).catch(()=>{});}catch(_){}
async function ensureIce(){try{await __icePromise;}catch(_){}return ICE;}
function pEsc(s){return String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));}
function profileHTML(name){return '<div class="profile"><button class="pbtn" id="pbtn">'+pEsc(name)+' <span style="font-size:.65rem">&#9662;</span></button><div class="pmenu" id="pmenu"><a href="/console">Console / Dashboard</a><a id="plogout" class="danger">Logout</a></div></div>';}
// When embedded in the home shell, tell the parent when a session is live so the
// rail can show a "return here" indicator.
function bzcSession(active){try{if(window.parent&&window.parent!==window)window.parent.postMessage({type:'bzc-session',flow:'share',active:!!active},location.origin);}catch(_){}}
function profileHTML(name){return '<div class="profile"><button class="pbtn" id="pbtn">'+pEsc(name)+' <span style="font-size:.65rem">&#9662;</span></button><div class="pmenu" id="pmenu"><a href="/home">Home</a><a href="/dashboard">Dashboard</a><a id="plogout" class="danger">Logout</a></div></div>';}
function wireProfile(){const btn=document.getElementById('pbtn'),menu=document.getElementById('pmenu');if(!btn)return;btn.onclick=(e)=>{e.stopPropagation();menu.classList.toggle('open');};document.addEventListener('click',()=>menu.classList.remove('open'));const lo=document.getElementById('plogout');if(lo)lo.onclick=async()=>{try{await fetch('/api/logout',{method:'POST'});}catch(_){}location.href='/';};}
function makeBrandClickable(){document.querySelectorAll('.brandrow,.wordmark').forEach(el=>{el.style.cursor='pointer';el.addEventListener('click',()=>{location.href='/';});});}
makeBrandClickable();
@@ -89,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;
@@ -101,6 +111,7 @@ ws.onmessage=async(e)=>{const m=JSON.parse(e.data);switch(m.type){
case 'start-stream': sessionId=m.sessionId; await startStreaming(); break;
case 'answer': if(pc) await pc.setRemoteDescription(new RTCSessionDescription(m.sdp)); break;
case 'ice-candidate': if(m.candidate&&pc) await pc.addIceCandidate(new RTCIceCandidate(m.candidate)); break;
case 'recording': recNotice(m.on); if(m.on) startCustTranscription(); else stopCustTranscription(); break;
case 'session-ended': endShareSession('Your support agent ended the session. Tap below for a new code if you still need help.'); break;
case 'error': setStatus(m.message,''); break;
}};
@@ -115,38 +126,93 @@ function showConsent(m){
const name=(m.technician&&m.technician.trim())?m.technician:'Your support agent';
consentBox.innerHTML='<div class="consent">Your support agent <span class="who">'+esc(name)+'</span> would like to view your screen to help you.'+
'<div class="btns"><button class="grant" id="g">Allow</button><button class="deny" id="d">Not now</button></div></div>';
const allow=()=>{consentBox.innerHTML='';document.removeEventListener('keydown',onKey);ws.send(JSON.stringify({type:'consent',sessionId:m.sessionId,granted:true}));};
const allow=async()=>{
document.removeEventListener('keydown',onKey);
setStatus('Opening the screen picker — choose your screen and tap Share / Start.','on');
const ok=await beginCapture();
if(!ok){ consentBox.innerHTML=''; try{ws.send(JSON.stringify({type:'consent',sessionId:m.sessionId,granted:false}));}catch(_){} setStatus('Screen share was cancelled. Refresh this page if you need a new code.'); return; }
consentBox.innerHTML='';
try{ws.send(JSON.stringify({type:'consent',sessionId:m.sessionId,granted:true}));}catch(_){}
};
const onKey=(e)=>{if(e.key==='Enter'){e.preventDefault();allow();}};
document.addEventListener('keydown',onKey);
document.getElementById('g').onclick=allow;
document.getElementById('d').onclick=()=>{consentBox.innerHTML='';document.removeEventListener('keydown',onKey);ws.send(JSON.stringify({type:'consent',sessionId:m.sessionId,granted:false}));setStatus('Connection declined. Refresh this page if you need a new code.');};
}
// Capture the screen (+mic) DIRECTLY from the Allow tap. Mobile browsers reject
// getDisplayMedia unless it is called from a user gesture, so this must not run
// after a server round-trip. getDisplayMedia is called first to keep the gesture.
async function beginCapture(){
try{ localStream=await navigator.mediaDevices.getDisplayMedia({video:{displaySurface:'monitor',frameRate:{ideal:30}},audio:false,monitorTypeSurfaces:'include'}); }
catch(err){ return false; }
// 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;
}
async function startStreaming(){
// Make sure TURN/ICE is loaded before building the connection (needed for mobile/cellular relay).
// If the Allow tap already captured the screen (mobile path), reuse it.
if(!localStream){
await ensureIce();
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){ 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();
// Ask for the microphone FIRST. On Android the screen capture must be the LAST
// capture started, otherwise the mic permission prompt interrupts it and the share ends.
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');
try{ localStream=await navigator.mediaDevices.getDisplayMedia({video:{frameRate:{ideal:30}},audio:false}); }
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(_){} }
indicator.classList.add('show'); setStatus('You are now sharing your screen with your agent.','on');
indicator.classList.add('show'); setStatus('You are now sharing your screen with your agent.','on'); bzcSession(true);
{ const hl=document.getElementById('homeLink'); if(hl) hl.style.display='none'; }
window.onbeforeunload=function(){ if((localStream||document.getElementById('sessionBar'))&&!sessionOver){ return 'Leaving or refreshing this page will end your screen sharing session.'; } };
pc=new RTCPeerConnection(ICE);
buildBar();
localStream.getTracks().forEach(t=>pc.addTrack(t,localStream));
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'){ 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: if we can't establish the peer connection in time, show a clear reason
// instead of a blank screen (covers networks with no usable path even via TURN).
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;
let crecog=null, crecogActive=false, sessionOver=false;
function startCustTranscription(){
if(!SR){ return; }
try{
crecog=new SR(); crecog.continuous=true; crecog.interimResults=false; crecog.lang='en-US';
crecog.onresult=(e)=>{ for(let i=e.resultIndex;i<e.results.length;i++){ if(e.results[i].isFinal){ const txt=(e.results[i][0].transcript||'').trim(); if(txt){ try{ws.send(JSON.stringify({type:'transcript',sessionId,role:'customer',name:SHARER_NAME,text:txt,chat:false}));}catch(_){} } } } };
crecog.onerror=()=>{};
crecog.onend=()=>{ if(crecogActive){ try{crecog.start();}catch(_){} } };
crecogActive=true; crecog.start();
}catch(e){}
}
function stopCustTranscription(){ crecogActive=false; if(crecog){ try{crecog.stop();}catch(_){} crecog=null; } }
let recTimerInt=null, recStartTs=0;
function fmtElapsed(ms){const s=Math.max(0,Math.floor(ms/1000));return String(Math.floor(s/60)).padStart(2,'0')+':'+String(s%60).padStart(2,'0');}
function recNotice(on){
if(on&&sessionOver) return;
let n=document.getElementById('recNotice');
if(on){
if(!n){ n=document.createElement('div'); n.id='recNotice';
n.style.cssText='position:fixed;top:14px;left:50%;transform:translateX(-50%);z-index:2147483600;background:#b91c1c;color:#fff;font-weight:600;font-size:.9rem;padding:.5rem 1rem;border-radius:999px;box-shadow:0 6px 18px rgba(0,0,0,.3);display:flex;align-items:center;gap:.5rem';
n.innerHTML='<span style="width:10px;height:10px;border-radius:50%;background:#fff;display:inline-block;animation:recPulse 1.2s infinite"></span> This session is being recorded \u00b7 <span id="recTimeVal">00:00</span>';
document.body.appendChild(n);
if(!document.getElementById('recPulseStyle')){const st=document.createElement('style');st.id='recPulseStyle';st.textContent='@keyframes recPulse{0%,100%{opacity:1}50%{opacity:.25}}';document.head.appendChild(st);}
}
recStartTs=Date.now(); clearInterval(recTimerInt);
const upd=()=>{ const t=document.getElementById('recTimeVal'); if(t) t.textContent=fmtElapsed(Date.now()-recStartTs); };
upd(); recTimerInt=setInterval(upd,1000);
} else { clearInterval(recTimerInt); recTimerInt=null; if(n) n.remove(); }
}
function endShareSession(msgText){
sessionOver=true; window.onbeforeunload=null; bzcSession(false); { const hl=document.getElementById('homeLink'); if(hl) hl.style.display=''; } try{recNotice(false);stopCustTranscription();}catch(_){}
removeSessionUI();
indicator.classList.remove('show');
if(window.__mic){try{window.__mic.getTracks().forEach(t=>t.stop());}catch(_){}window.__mic=null;}
@@ -155,24 +221,34 @@ function endShareSession(msgText){
var card=document.querySelector('.panelside .card');
if(card){ card.innerHTML='<h1 style="color:var(--blue)">Session ended</h1><div class="sub">'+esc(msgText||'The session has ended.')+'</div><button onclick="location.reload()" style="width:100%;margin-top:.4rem">Get a new code</button>'; }
}
function teardown(){indicator.classList.remove('show');removeSessionUI();if(window.__mic){window.__mic.getTracks().forEach(t=>t.stop());window.__mic=null;}if(localStream){localStream.getTracks().forEach(t=>t.stop());localStream=null;}if(pc){pc.close();pc=null;}consentBox.innerHTML='';setStatus('Session ended. Refresh this page to get a new code.');}
function teardown(){sessionOver=true;window.onbeforeunload=null;bzcSession(false);{const hl=document.getElementById('homeLink');if(hl)hl.style.display='';}try{recNotice(false);stopCustTranscription();}catch(_){}indicator.classList.remove('show');removeSessionUI();if(window.__mic){window.__mic.getTracks().forEach(t=>t.stop());window.__mic=null;}if(localStream){localStream.getTracks().forEach(t=>t.stop());localStream=null;}if(pc){pc.close();pc=null;}consentBox.innerHTML='';setStatus('Session ended. Refresh this page to get a new code.');}
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();
@@ -203,5 +279,6 @@ function removeSessionUI(){['sessionBar','chatPanel','remoteAudio','muteBtn','ms
function esc(s){return String(s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[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>
+46
Parādīt failu
@@ -0,0 +1,46 @@
// BizGaze Connect service worker — NOTIFICATIONS ONLY.
// Intentionally has NO 'fetch' handler and NO caching, so it can never serve a stale
// version of the app. Its only job is to show push notifications when the page is in
// the background / frozen / closed, and to open the right chat when one is clicked.
self.addEventListener('install', (e) => { self.skipWaiting(); });
self.addEventListener('activate', (e) => { e.waitUntil(self.clients.claim()); });
// No-op fetch handler: present only so the app meets PWA installability criteria. It never
// calls respondWith(), so the browser performs its normal network fetch — NO caching, so this
// can never serve a stale app.
self.addEventListener('fetch', () => {});
self.addEventListener('push', (event) => {
let d = {};
try { d = event.data ? event.data.json() : {}; } catch (_) {}
const title = d.title || 'BizGaze Connect';
const options = {
body: d.body || '',
icon: '/logo.png',
badge: '/logo.png',
tag: d.tag || undefined, // collapse repeats from the same chat
renotify: !!d.tag,
data: { kind: d.kind || '', id: d.id || '' },
};
event.waitUntil((async () => {
// If a BizGaze tab is currently VISIBLE, the page itself alerts the user (ping / in-page
// popup) — skip the OS popup to avoid a double. Only show when no tab is visible
// (another tab/app, minimized, or closed) — exactly when the page can't alert.
const clientsArr = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
const visible = clientsArr.some((c) => c.visibilityState === 'visible');
if (visible) return;
await self.registration.showNotification(title, options);
})());
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
const { kind, id } = event.notification.data || {};
const url = '/home' + (id ? ('?openKind=' + encodeURIComponent(kind || 'dm') + '&openId=' + encodeURIComponent(id)) : '');
event.waitUntil((async () => {
const all = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
for (const c of all) {
if (c.url.includes('/home')) { try { await c.focus(); c.postMessage({ type: 'open-chat', kind, id }); return; } catch (_) {} }
}
try { await self.clients.openWindow(url); } catch (_) {}
})());
});
+3 -1
Parādīt failu
@@ -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?v=3"></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>
+45
Parādīt failu
@@ -0,0 +1,45 @@
// Web Push (background / closed-tab / mobile notifications). Fully optional:
// - if the `web-push` package isn't installed, or VAPID env keys aren't set,
// isEnabled() is false and every call is a silent no-op (the app is unaffected).
// Configure in production by setting:
// VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY, VAPID_SUBJECT (e.g. mailto:admin@bizgaze.com)
// Generate a key pair once with: npx web-push generate-vapid-keys
const R = require('./repos');
let webpush = null;
try { webpush = require('web-push'); } catch (_) { /* package not installed -> push disabled */ }
const PUBLIC = process.env.VAPID_PUBLIC_KEY || '';
const PRIVATE = process.env.VAPID_PRIVATE_KEY || '';
const SUBJECT = process.env.VAPID_SUBJECT || 'mailto:support@bizgaze.com';
let ready = false;
if (webpush && PUBLIC && PRIVATE) {
try { webpush.setVapidDetails(SUBJECT, PUBLIC, PRIVATE); ready = true; }
catch (e) { console.warn('[push] invalid VAPID config:', e.message); }
}
if (!ready) console.log('[push] Web Push disabled (set web-push + VAPID_PUBLIC_KEY/VAPID_PRIVATE_KEY to enable).');
function isEnabled() { return ready; }
function publicKey() { return ready ? PUBLIC : ''; }
// Fire-and-forget push to every device the user has subscribed. Dead subscriptions
// (410 Gone / 404) are pruned. Never throws.
async function sendToUser(userId, payload) {
if (!ready) return;
let subs = [];
try { subs = R.pushSubs.byUser(userId); } catch (_) { return; }
const data = JSON.stringify(payload || {});
for (const s of subs) {
const sub = { endpoint: s.endpoint, keys: { p256dh: s.p256dh, auth: s.auth } };
try {
await webpush.sendNotification(sub, data, { TTL: 600 });
} catch (err) {
const code = err && err.statusCode;
if (code === 404 || code === 410) { try { R.pushSubs.removeByEndpoint(s.endpoint); } catch (_) {} }
// other errors (network, 4xx) are ignored — push is best-effort
}
}
}
module.exports = { isEnabled, publicKey, sendToUser };
+25
Parādīt failu
@@ -0,0 +1,25 @@
// Fires a one-shot "starts in ~10 minutes" reminder to a scheduled meeting's host,
// group members, and invited participants. Runs on a 60s tick; marks each meeting reminded.
const R = require('./repos');
const CHAT = require('./chat');
function tick() {
try {
const now = Date.now();
const due = R.scheduledMeetings.dueForReminder(now, now + 10 * 60 * 1000); // starting within 10 min
for (const s of due) {
const recipients = new Set([s.created_by]);
let invited = []; try { invited = JSON.parse(s.participants || '[]'); } catch (_) {}
invited.forEach((id) => recipients.add(id));
if (s.group_id) { try { R.conversations.members(s.group_id).forEach((m) => recipients.add(m)); } catch (_) {} }
const evt = { type: 'meeting-reminder', meeting: { id: s.id, title: s.title, scheduledAt: s.scheduled_at, room: s.room_code } };
recipients.forEach((uid) => { try { CHAT.pushToUser(uid, evt); } catch (_) {} });
R.scheduledMeetings.markReminded(s.id);
}
} catch (_) { /* never let the timer die */ }
}
let timer = null;
function start() { if (!timer) timer = setInterval(tick, 60 * 1000); }
start();
module.exports = { start, tick };
+278
Parādīt failu
@@ -0,0 +1,278 @@
// Data-access layer (Phase 1).
// All SQL lives here, never in route/signaling handlers. This decouples the rest of
// the app from SQLite so the store can later move to Postgres without touching callers.
//
// TENANT ABSTRACTION: a "tenant" currently maps 1:1 to a team (column `team_id`).
// Repo signatures take `tenantId` so that when the tenant is later elevated to a
// first-class Organization (Phase 3), callers and the API/auth built on top stay unchanged.
const db = require('./db');
const A = require('./auth');
const now = () => Date.now();
const teams = {
first: () => db.prepare('SELECT * FROM teams LIMIT 1').get(),
byId: (id) => db.prepare('SELECT * FROM teams WHERE id=?').get(id),
create: (name) => {
const id = A.id();
db.prepare('INSERT INTO teams (id,name,created_at) VALUES (?,?,?)').run(id, name, now());
return db.prepare('SELECT * FROM teams WHERE id=?').get(id);
},
};
const users = {
anyExists: () => !!db.prepare('SELECT 1 FROM users LIMIT 1').get(),
byId: (id) => db.prepare('SELECT * FROM users WHERE id=?').get(id),
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,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 }) => {
const id = A.id();
db.prepare(`INSERT INTO users (id,team_id,email,pw_hash,pw_salt,role,name,mfa_secret,mfa_enabled,created_at)
VALUES (?,?,?,?,?,?,?,?,0,?)`)
.run(id, tenantId, email, hash, salt, role, name || null, mfaSecret, now());
return id;
},
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),
};
const authSessions = {
byToken: (token) => db.prepare('SELECT * FROM sessions_auth WHERE token=?').get(token),
create: ({ token, userId, mfaPassed, ttl }) =>
db.prepare('INSERT INTO sessions_auth (token,user_id,mfa_passed,created_at,expires_at) VALUES (?,?,?,?,?)')
.run(token, userId, mfaPassed ? 1 : 0, now(), now() + ttl),
markMfaPassed: (token) => db.prepare('UPDATE sessions_auth SET mfa_passed=1 WHERE token=?').run(token),
deleteByToken: (token) => db.prepare('DELETE FROM sessions_auth WHERE token=?').run(token),
deleteByUser: (userId) => db.prepare('DELETE FROM sessions_auth WHERE user_id=?').run(userId),
};
const machines = {
byEnrollToken: (t) => db.prepare('SELECT * FROM machines WHERE enroll_token=?').get(t),
inTenant: (id, tenantId) => db.prepare('SELECT * FROM machines WHERE id=? AND team_id=?').get(id, tenantId),
listByTenant: (tenantId) =>
db.prepare('SELECT id,name,unattended,last_seen FROM machines WHERE team_id=?').all(tenantId),
create: ({ tenantId, name, enrollToken, unattended }) => {
const id = A.id();
db.prepare('INSERT INTO machines (id,team_id,name,enroll_token,unattended,created_at) VALUES (?,?,?,?,?,?)')
.run(id, tenantId, name, enrollToken, unattended ? 1 : 0, now());
return id;
},
touch: (id) => db.prepare('UPDATE machines SET last_seen=? WHERE id=?').run(now(), id),
};
const audit = {
add: (e) =>
db.prepare(`INSERT INTO audit_log (team_id,user_id,user_email,machine_id,machine_name,action,detail,at)
VALUES (@team_id,@user_id,@user_email,@machine_id,@machine_name,@action,@detail,@at)`)
.run({
team_id: e.team_id, user_id: e.user_id || null, user_email: e.user_email || null,
machine_id: e.machine_id || null, machine_name: e.machine_name || null,
action: e.action, detail: e.detail || null, at: now(),
}),
listByTenant: (tenantId) =>
db.prepare("SELECT * FROM audit_log WHERE team_id=? OR team_id='adhoc' ORDER BY at DESC LIMIT 200").all(tenantId),
};
const sessionsLog = {
byId: (id) => db.prepare('SELECT * FROM sessions_log WHERE id=?').get(id),
byIdInTenant: (id, tenantId) => db.prepare('SELECT * FROM sessions_log WHERE id=? AND team_id=?').get(id, tenantId),
create: ({ id, tenantId, agentEmail, agentName, ticket }) =>
db.prepare('INSERT INTO sessions_log (id,team_id,agent_email,agent_name,ticket,started_at) VALUES (?,?,?,?,?,?)')
.run(id, tenantId, agentEmail, agentName, ticket || null, now()),
end: (id) => db.prepare('UPDATE sessions_log SET ended_at=? WHERE id=? AND ended_at IS NULL').run(now(), id),
setRecording: (id, fname) => db.prepare('UPDATE sessions_log SET recording=? WHERE id=?').run(fname, id),
setTranscript: (id, fname) => db.prepare('UPDATE sessions_log SET transcript=? WHERE id=?').run(fname, id),
// Role-scoping is the caller's job: pass agentEmail to restrict to one agent (non-admins).
report: ({ tenantId, agentEmail, from, to }) => {
let sql = 'SELECT * FROM sessions_log WHERE team_id=?';
const args = [tenantId];
if (agentEmail) { sql += ' AND agent_email=?'; args.push(agentEmail); }
if (from) { sql += ' AND started_at>=?'; args.push(from); }
if (to) { sql += ' AND started_at<=?'; args.push(to); }
sql += ' ORDER BY started_at DESC LIMIT 500';
return db.prepare(sql).all(...args);
},
};
const refreshTokens = {
create: ({ userId, tokenHash, ttl }) =>
db.prepare('INSERT INTO refresh_tokens (token_hash,user_id,created_at,expires_at,revoked) VALUES (?,?,?,?,0)')
.run(tokenHash, userId, now(), now() + ttl),
byHash: (h) => db.prepare('SELECT * FROM refresh_tokens WHERE token_hash=?').get(h),
revoke: (h) => db.prepare('UPDATE refresh_tokens SET revoked=1 WHERE token_hash=?').run(h),
revokeByUser: (userId) => db.prepare('UPDATE refresh_tokens SET revoked=1 WHERE user_id=?').run(userId),
};
const apiKeys = {
create: ({ id, tenantId, name, keyHash, scopes, createdBy }) =>
db.prepare('INSERT INTO api_keys (id,team_id,name,key_hash,scopes,created_by,created_at,revoked) VALUES (?,?,?,?,?,?,?,0)')
.run(id, tenantId, name || null, keyHash, scopes || '', createdBy || null, now()),
byHash: (h) => db.prepare('SELECT * FROM api_keys WHERE key_hash=?').get(h),
listByTenant: (tenantId) =>
db.prepare('SELECT id,name,scopes,created_by,created_at,last_used_at,revoked FROM api_keys WHERE team_id=? ORDER BY created_at DESC').all(tenantId),
revoke: (id, tenantId) => db.prepare('UPDATE api_keys SET revoked=1 WHERE id=? AND team_id=?').run(id, tenantId),
touch: (id) => db.prepare('UPDATE api_keys SET last_used_at=? WHERE id=?').run(now(), id),
};
const webhooks = {
create: ({ id, tenantId, url, secret, events, createdBy }) =>
db.prepare('INSERT INTO webhooks (id,team_id,url,secret,events,active,created_by,created_at) VALUES (?,?,?,?,?,1,?,?)')
.run(id, tenantId, url, secret, events || '', createdBy || null, now()),
activeForTenant: (tenantId) => db.prepare('SELECT * FROM webhooks WHERE team_id=? AND active=1').all(tenantId),
listByTenant: (tenantId) =>
db.prepare('SELECT id,url,events,active,created_by,created_at,last_status,last_error,last_at FROM webhooks WHERE team_id=? ORDER BY created_at DESC').all(tenantId),
remove: (id, tenantId) => db.prepare('DELETE FROM webhooks WHERE id=? AND team_id=?').run(id, tenantId),
setStatus: (id, status, err) => db.prepare('UPDATE webhooks SET last_status=?, last_error=?, last_at=? WHERE id=?').run(status, err || null, now(), id),
};
const messages = {
send: ({ id, teamId, senderId, recipientId, body, replyTo, attachmentId, conversationId, mentions, msgType }) =>
db.prepare('INSERT INTO messages (id,team_id,sender_id,recipient_id,body,created_at,reply_to,attachment_id,conversation_id,mentions,msg_type) VALUES (?,?,?,?,?,?,?,?,?,?,?)')
.run(id, teamId, senderId, recipientId || '', body, now(), replyTo || null, attachmentId || null, conversationId || null, (mentions && mentions.length) ? JSON.stringify(mentions) : null, msgType || null),
byId: (id) => db.prepare('SELECT * FROM messages WHERE id=?').get(id),
byAttachment: (attachmentId) => db.prepare('SELECT * FROM messages WHERE attachment_id=? LIMIT 1').get(attachmentId),
setPoll: (messageId, pollId) => db.prepare('UPDATE messages SET poll_id=? WHERE id=?').run(pollId, messageId),
markDelivered: (id) => db.prepare('UPDATE messages SET delivered_at=? WHERE id=? AND delivered_at IS NULL').run(now(), id),
// Full 1:1 (DM) thread between two users (both directions), oldest first.
thread: (teamId, a, b, limit = 300) =>
db.prepare(`SELECT * FROM messages WHERE team_id=? AND conversation_id IS NULL
AND ((sender_id=? AND recipient_id=?) OR (sender_id=? AND recipient_id=?))
ORDER BY created_at ASC LIMIT ?`).all(teamId, a, b, b, a, limit),
markRead: (teamId, recipientId, senderId) =>
db.prepare('UPDATE messages SET read_at=? WHERE team_id=? AND conversation_id IS NULL AND recipient_id=? AND sender_id=? AND read_at IS NULL')
.run(now(), teamId, recipientId, senderId),
// Recent DM messages involving a user (newest first) — reduced into per-contact conversations.
recentFor: (teamId, userId, limit = 1000) =>
db.prepare('SELECT * FROM messages WHERE team_id=? AND conversation_id IS NULL AND (sender_id=? OR recipient_id=?) ORDER BY created_at DESC LIMIT ?')
.all(teamId, userId, userId, limit),
// Group conversation helpers.
threadByConversation: (conversationId, limit = 300) =>
db.prepare('SELECT * FROM messages WHERE conversation_id=? ORDER BY created_at ASC LIMIT ?').all(conversationId, limit),
lastInConversation: (conversationId) =>
db.prepare('SELECT * FROM messages WHERE conversation_id=? ORDER BY created_at DESC LIMIT 1').get(conversationId),
unreadInConversation: (conversationId, userId, since) =>
db.prepare('SELECT COUNT(*) AS c FROM messages WHERE conversation_id=? AND sender_id<>? AND created_at>?').get(conversationId, userId, since).c,
};
const reactions = {
// Toggle with ONE reaction per user per message: picking an emoji replaces any prior
// reaction by that user; picking the same one again removes it. Returns true if added.
toggle: (messageId, userId, emoji) => {
const had = db.prepare('SELECT 1 FROM message_reactions WHERE message_id=? AND user_id=? AND emoji=?').get(messageId, userId, emoji);
db.prepare('DELETE FROM message_reactions WHERE message_id=? AND user_id=?').run(messageId, userId);
if (had) return false;
db.prepare('INSERT INTO message_reactions (message_id,user_id,emoji,created_at) VALUES (?,?,?,?)').run(messageId, userId, emoji, now());
return true;
},
forMessage: (messageId) => db.prepare('SELECT user_id, emoji FROM message_reactions WHERE message_id=? ORDER BY created_at ASC').all(messageId),
// All reactions on the messages in a 1:1 thread.
forPair: (teamId, a, b) =>
db.prepare(`SELECT r.message_id, r.user_id, r.emoji FROM message_reactions r
JOIN messages m ON r.message_id = m.id
WHERE m.team_id=? AND m.conversation_id IS NULL AND ((m.sender_id=? AND m.recipient_id=?) OR (m.sender_id=? AND m.recipient_id=?))`).all(teamId, a, b, b, a),
// All reactions on the messages in a group conversation.
forConversation: (conversationId) =>
db.prepare(`SELECT r.message_id, r.user_id, r.emoji FROM message_reactions r
JOIN messages m ON r.message_id = m.id WHERE m.conversation_id=?`).all(conversationId),
};
const conversations = {
create: ({ id, teamId, name, createdBy }) =>
db.prepare('INSERT INTO conversations (id,team_id,type,name,created_by,created_at) VALUES (?,?,?,?,?,?)').run(id, teamId, 'group', name || null, createdBy || null, now()),
byId: (id) => db.prepare('SELECT * FROM conversations WHERE id=?').get(id),
addMember: (conversationId, userId, admin) =>
db.prepare('INSERT OR IGNORE INTO conversation_members (conversation_id,user_id,last_read_at,joined_at,admin) VALUES (?,?,?,?,?)').run(conversationId, userId, 0, now(), admin ? 1 : 0),
members: (conversationId) => db.prepare('SELECT user_id FROM conversation_members WHERE conversation_id=? ORDER BY joined_at ASC').all(conversationId).map((r) => r.user_id),
isMember: (conversationId, userId) => !!db.prepare('SELECT 1 FROM conversation_members WHERE conversation_id=? AND user_id=?').get(conversationId, userId),
// ---- Group admins (multiple allowed) ----
isAdmin: (conversationId, userId) => !!db.prepare('SELECT 1 FROM conversation_members WHERE conversation_id=? AND user_id=? AND admin=1').get(conversationId, userId),
admins: (conversationId) => db.prepare('SELECT user_id FROM conversation_members WHERE conversation_id=? AND admin=1').all(conversationId).map((r) => r.user_id),
setMemberAdmin: (conversationId, userId, v) => db.prepare('UPDATE conversation_members SET admin=? WHERE conversation_id=? AND user_id=?').run(v ? 1 : 0, conversationId, userId),
oldestMember: (conversationId) => { const r = db.prepare('SELECT user_id FROM conversation_members WHERE conversation_id=? ORDER BY joined_at ASC LIMIT 1').get(conversationId); return r ? r.user_id : null; },
listForUser: (teamId, userId) =>
db.prepare('SELECT c.* FROM conversations c JOIN conversation_members m ON m.conversation_id=c.id WHERE c.team_id=? AND m.user_id=?').all(teamId, userId),
lastReadAt: (conversationId, userId) => { const r = db.prepare('SELECT last_read_at FROM conversation_members WHERE conversation_id=? AND user_id=?').get(conversationId, userId); return r ? r.last_read_at : 0; },
memberReads: (conversationId) => db.prepare('SELECT user_id, last_read_at FROM conversation_members WHERE conversation_id=?').all(conversationId),
setAdminOnly: (id, v) => db.prepare('UPDATE conversations SET admin_only=? WHERE id=?').run(v ? 1 : 0, id),
markRead: (conversationId, userId) => db.prepare('UPDATE conversation_members SET last_read_at=? WHERE conversation_id=? AND user_id=?').run(now(), conversationId, userId),
rename: (id, name) => db.prepare('UPDATE conversations SET name=? WHERE id=?').run(name, id),
setAvatar: (id, attachmentId) => db.prepare('UPDATE conversations SET avatar_id=? WHERE id=?').run(attachmentId || null, id),
byAvatar: (attachmentId) => db.prepare('SELECT * FROM conversations WHERE avatar_id=? LIMIT 1').get(attachmentId),
removeMember: (conversationId, userId) => db.prepare('DELETE FROM conversation_members WHERE conversation_id=? AND user_id=?').run(conversationId, userId),
remove: (id) => { db.prepare('DELETE FROM conversation_members WHERE conversation_id=?').run(id); db.prepare('DELETE FROM conversations WHERE id=?').run(id); },
};
const attachments = {
create: ({ id, teamId, uploaderId, name, mime, size }) =>
db.prepare('INSERT INTO attachments (id,team_id,uploader_id,name,mime,size,created_at) VALUES (?,?,?,?,?,?,?)')
.run(id, teamId, uploaderId, name, mime || null, size || 0, now()),
byId: (id) => db.prepare('SELECT * FROM attachments WHERE id=?').get(id),
};
const scheduledMeetings = {
create: ({ id, teamId, groupId, roomCode, title, description, scheduledAt, createdBy, participants, durationMins, recurrence }) =>
db.prepare('INSERT INTO scheduled_meetings (id,team_id,group_id,room_code,title,description,scheduled_at,created_by,created_at,participants,duration_mins,recurrence) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)')
.run(id, teamId, groupId || null, roomCode, title, description || null, scheduledAt, createdBy, now(), (participants && participants.length) ? JSON.stringify(participants) : null, durationMins || null, (recurrence && recurrence.length) ? JSON.stringify(recurrence) : null),
byId: (id) => db.prepare('SELECT * FROM scheduled_meetings WHERE id=?').get(id),
byCode: (code) => db.prepare('SELECT * FROM scheduled_meetings WHERE room_code=? ORDER BY created_at DESC LIMIT 1').get(code),
// Meetings a user can see: created by them, a member of the group, or an invited participant.
listForUser: (teamId, userId) =>
db.prepare(`SELECT s.* FROM scheduled_meetings s
WHERE s.team_id=? AND (
s.created_by=? OR
(s.group_id IS NOT NULL AND EXISTS (SELECT 1 FROM conversation_members cm WHERE cm.conversation_id=s.group_id AND cm.user_id=?)) OR
(s.participants IS NOT NULL AND s.participants LIKE '%'||?||'%'))
ORDER BY s.scheduled_at ASC`).all(teamId, userId, userId, '"' + userId + '"'),
dueForReminder: (fromTs, toTs) => db.prepare('SELECT * FROM scheduled_meetings WHERE reminded=0 AND ended_at IS NULL AND scheduled_at>=? AND scheduled_at<=?').all(fromTs, toTs),
markReminded: (id) => db.prepare('UPDATE scheduled_meetings SET reminded=1 WHERE id=?').run(id),
end: (id, teamId) => db.prepare('UPDATE scheduled_meetings SET ended_at=? WHERE id=? AND team_id=?').run(now(), id, teamId),
cancel: (id, teamId) => db.prepare('UPDATE scheduled_meetings SET cancelled=1, ended_at=? WHERE id=? AND team_id=?').run(now(), id, teamId),
reschedule: (id, teamId, ts) => db.prepare('UPDATE scheduled_meetings SET scheduled_at=?, reminded=0 WHERE id=? AND team_id=?').run(ts, id, teamId), // recurrence: roll to next occurrence
update: (id, teamId, { title, description, scheduledAt, durationMins, participants, recurrence }) =>
db.prepare('UPDATE scheduled_meetings SET title=?, description=?, scheduled_at=?, duration_mins=?, participants=?, recurrence=?, reminded=0 WHERE id=? AND team_id=?')
.run(title, description || null, scheduledAt, durationMins || null, (participants && participants.length) ? JSON.stringify(participants) : null, (recurrence && recurrence.length) ? JSON.stringify(recurrence) : null, id, teamId),
remove: (id, teamId) => db.prepare('DELETE FROM scheduled_meetings WHERE id=? AND team_id=?').run(id, teamId),
};
const recordings = {
create: ({ id, teamId, room, groupId, meetingId, title, kind, file, mime, size, durationMs, createdBy, createdByName }) =>
db.prepare('INSERT INTO recordings (id,team_id,room,group_id,meeting_id,title,kind,file,mime,size,duration_ms,created_by,created_by_name,created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)')
.run(id, teamId, room || null, groupId || null, meetingId || null, title || null, kind, file || null, mime || null, size || null, durationMs || null, createdBy || null, createdByName || null, now()),
byId: (id) => db.prepare('SELECT * FROM recordings WHERE id=?').get(id),
forTeam: (teamId) => db.prepare('SELECT * FROM recordings WHERE team_id=? ORDER BY created_at DESC').all(teamId),
};
const polls = {
create: ({ id, teamId, conversationId, messageId, question, options, multi, createdBy }) =>
db.prepare('INSERT INTO polls (id,team_id,conversation_id,message_id,question,options,multi,closed,created_by,created_at) VALUES (?,?,?,?,?,?,?,0,?,?)')
.run(id, teamId, conversationId, messageId || null, question, JSON.stringify(options), multi ? 1 : 0, createdBy, now()),
byId: (id) => db.prepare('SELECT * FROM polls WHERE id=?').get(id),
close: (id) => db.prepare('UPDATE polls SET closed=1 WHERE id=?').run(id),
};
const pollVotes = {
forPoll: (pollId) => db.prepare('SELECT user_id, option_idx FROM poll_votes WHERE poll_id=?').all(pollId),
hasVoted: (pollId, userId, idx) => !!db.prepare('SELECT 1 FROM poll_votes WHERE poll_id=? AND user_id=? AND option_idx=?').get(pollId, userId, idx),
add: (pollId, userId, idx) => db.prepare('INSERT OR IGNORE INTO poll_votes (poll_id,user_id,option_idx,created_at) VALUES (?,?,?,?)').run(pollId, userId, idx, now()),
remove: (pollId, userId, idx) => db.prepare('DELETE FROM poll_votes WHERE poll_id=? AND user_id=? AND option_idx=?').run(pollId, userId, idx),
clearUser: (pollId, userId) => db.prepare('DELETE FROM poll_votes WHERE poll_id=? AND user_id=?').run(pollId, userId),
};
const pushSubs = {
// Upsert by endpoint: re-subscribing the same browser updates its keys/owner.
add: ({ id, userId, endpoint, p256dh, auth }) =>
db.prepare('INSERT INTO push_subscriptions (id,user_id,endpoint,p256dh,auth,created_at) VALUES (?,?,?,?,?,?) ON CONFLICT(endpoint) DO UPDATE SET user_id=excluded.user_id, p256dh=excluded.p256dh, auth=excluded.auth')
.run(id, userId, endpoint, p256dh, auth, now()),
byUser: (userId) => db.prepare('SELECT * FROM push_subscriptions WHERE user_id=?').all(userId),
removeByEndpoint: (endpoint) => db.prepare('DELETE FROM push_subscriptions WHERE endpoint=?').run(endpoint),
};
module.exports = { teams, users, authSessions, machines, audit, sessionsLog, refreshTokens, apiKeys, webhooks, messages, reactions, attachments, conversations, scheduledMeetings, recordings, polls, pollVotes, pushSubs };
Failā izmaiņas netiks attēlotas, jo tās ir par lielu Ielādēt izmaiņas
@@ -0,0 +1,67 @@
#!/usr/bin/env node
// One-time PRODUCTION migration for "BizGaze-only logins".
//
// Deletes the in-app (pre-BizGaze) local accounts. Combined with the BizGaze-only login
// change, every user then signs in through BizGaze and is provisioned into the same
// tenant — which restores the admin's "see all sessions" report.
//
// A "pre-BizGaze" account = a user with NO 'sso_user_created' audit entry for its email
// (i.e. created locally via register/console, not provisioned by a BizGaze login).
//
// SAFE BY DEFAULT: dry-run unless you pass --apply. BACK UP THE DB FIRST.
// Dry run : node scripts/migrate-bizgaze-only.js
// Apply : node scripts/migrate-bizgaze-only.js --apply
// Honors DB_PATH (same env var the server uses).
const db = require('../db');
const APPLY = process.argv.includes('--apply');
const tableExists = (name) => !!db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?").get(name);
const ssoEmails = new Set(
db.prepare("SELECT DISTINCT lower(user_email) AS e FROM audit_log WHERE action='sso_user_created' AND user_email IS NOT NULL")
.all().map((r) => r.e),
);
const users = db.prepare('SELECT id,email,name,role,team_id,active FROM users').all();
const keep = users.filter((u) => ssoEmails.has(String(u.email).toLowerCase()));
const remove = users.filter((u) => !ssoEmails.has(String(u.email).toLowerCase()));
console.log('=== Teams ===');
for (const t of db.prepare('SELECT id,name FROM teams').all()) {
const uc = db.prepare('SELECT COUNT(*) AS c FROM users WHERE team_id=?').get(t.id).c;
console.log(` ${t.id} ${t.name} (${uc} users)`);
}
console.log('\n=== Users ===');
console.log(` total: ${users.length} | BizGaze-provisioned (keep): ${keep.length} | local pre-BizGaze (delete): ${remove.length}`);
console.log('\n KEEP (already BizGaze-provisioned):');
keep.forEach((u) => console.log(` ${u.email} [${u.role}] team ${u.team_id}`));
console.log('\n DELETE (local / pre-BizGaze):');
remove.forEach((u) => console.log(` ${u.email} [${u.role}] team ${u.team_id}`));
if (!remove.length) { console.log('\nNothing to delete. Done.'); process.exit(0); }
if (!APPLY) {
console.log('\nDRY RUN — no changes made. Re-run with --apply to delete the local accounts above.');
console.log('After deletion, those users sign in via BizGaze and are recreated automatically.');
process.exit(0);
}
const delAuth = db.prepare('DELETE FROM sessions_auth WHERE user_id=?');
const delRefresh = tableExists('refresh_tokens') ? db.prepare('DELETE FROM refresh_tokens WHERE user_id=?') : null;
const delUser = db.prepare('DELETE FROM users WHERE id=?');
let deleted = 0;
db.exec('BEGIN');
try {
for (const u of remove) {
delAuth.run(u.id); // clear active sessions (FK) — also logs them out
if (delRefresh) delRefresh.run(u.id);
delUser.run(u.id);
deleted++;
}
db.exec('COMMIT');
} catch (e) {
db.exec('ROLLBACK');
console.error('FAILED — rolled back, no changes applied:', e.message);
process.exit(1);
}
console.log(`\nDONE. Deleted ${deleted} local account(s). They are recreated via BizGaze on next sign-in.`);
+19 -495
Parādīt failu
@@ -1,513 +1,37 @@
// Remote Access Platform — backend server
// HTTP JSON API (auth, MFA, machines, audit) + authenticated WebSocket signaling.
// BizGaze Connect — backend entry point.
// Thin wiring layer: HTTP request dispatch + WebSocket attach + listeners.
// All logic lives in focused modules:
// repos.js data-access (all SQL)
// bizgaze.js BizGaze identity provider
// lib.js HTTP helpers (json/readBody/parseCookies/now)
// session.js currentUser / audit
// presence.js shared in-memory live state (agents/sessions/shares)
// routes.js HTTP JSON API (/api/*, /sso)
// static.js static files + authenticated downloads (GET fallback)
// signaling.js WebSocket signaling (consent + SDP/ICE relay)
const http = require('http');
const https = require('https');
const fs = require('fs');
const path = require('path');
const { WebSocketServer } = require('ws');
const db = require('./db');
const A = require('./auth');
const { PORT, HTTPS_PORT } = require('./config');
const { json } = require('./lib');
const routes = require('./routes');
const { handleGet } = require('./static');
const { onConnection } = require('./signaling');
const PORT = process.env.PORT || 8090;
const HTTPS_PORT = process.env.HTTPS_PORT || 8443;
const PUBLIC_DIR = path.join(__dirname, 'public');
const SESSION_TTL = 1000 * 60 * 60 * 24; // 24h auto-logout
// ---------- helpers ----------
const now = () => Date.now();
const json = (res, code, body) => {
res.writeHead(code, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(body));
};
function readBody(req) {
return new Promise((resolve) => {
let data = '';
req.on('data', (c) => (data += c));
req.on('end', () => {
try { resolve(data ? JSON.parse(data) : {}); } catch { resolve({}); }
});
});
}
function parseCookies(req) {
const out = {};
(req.headers.cookie || '').split(';').forEach((c) => {
const [k, ...v] = c.trim().split('=');
if (k) out[k] = decodeURIComponent(v.join('='));
});
return out;
}
function audit(entry) {
db.prepare(
`INSERT INTO audit_log (team_id,user_id,user_email,machine_id,machine_name,action,detail,at)
VALUES (@team_id,@user_id,@user_email,@machine_id,@machine_name,@action,@detail,@at)`
).run({
team_id: entry.team_id, user_id: entry.user_id || null, user_email: entry.user_email || null,
machine_id: entry.machine_id || null, machine_name: entry.machine_name || null,
action: entry.action, detail: entry.detail || null, at: now(),
});
}
// Resolve the logged-in user from the session cookie. Returns user row (with mfa state) or null.
function currentUser(req, { requireMfa = true } = {}) {
const tok = parseCookies(req).sid;
if (!tok) return null;
const s = db.prepare('SELECT * FROM sessions_auth WHERE token=?').get(tok);
if (!s || s.expires_at < now()) return null;
if (requireMfa && !s.mfa_passed) return null;
const u = db.prepare('SELECT * FROM users WHERE id=?').get(s.user_id);
if (!u || u.active === 0) return null;
return { ...u, _session: s };
}
// ---------- HTTP API ----------
const routes = {};
const route = (method, p, fn) => (routes[`${method} ${p}`] = fn);
// Register: creates a team + admin user. MFA must be set up before full access.
route('POST', '/api/register', async (req, res) => {
const anyUser = db.prepare('SELECT 1 FROM users LIMIT 1').get();
if (anyUser && process.env.ALLOW_REGISTRATION !== '1')
return json(res, 403, { error: 'Registration is closed. Contact your administrator.' });
const { email, password, teamName } = await readBody(req);
if (!email || !password) return json(res, 400, { error: 'email and password required' });
if (db.prepare('SELECT 1 FROM users WHERE email=? COLLATE NOCASE').get(email))
return json(res, 409, { error: 'email already registered' });
const teamId = A.id(), userId = A.id();
const { hash, salt } = A.hashPassword(password);
const mfaSecret = A.newMfaSecret();
db.prepare('INSERT INTO teams (id,name,created_at) VALUES (?,?,?)')
.run(teamId, teamName || `${email}'s team`, now());
db.prepare(`INSERT INTO users (id,team_id,email,pw_hash,pw_salt,role,mfa_secret,mfa_enabled,created_at)
VALUES (?,?,?,?,?,?,?,0,?)`)
.run(userId, teamId, email, hash, salt, 'admin', mfaSecret, now());
audit({ team_id: teamId, user_id: userId, user_email: email, action: 'user_registered' });
json(res, 200, { ok: true });
});
// Verify MFA enrollment (confirm the user scanned the QR / entered code)
route('POST', '/api/mfa/enable', async (req, res) => {
const { email, code } = await readBody(req);
const u = db.prepare('SELECT * FROM users WHERE email=? COLLATE NOCASE').get(email);
if (!u) return json(res, 404, { error: 'no such user' });
if (!A.verifyTotp(u.mfa_secret, code)) return json(res, 401, { error: 'invalid code' });
db.prepare('UPDATE users SET mfa_enabled=1 WHERE id=?').run(u.id);
json(res, 200, { ok: true });
});
// Login step 1: email + password -> sets a session cookie (mfa not yet passed)
route('POST', '/api/login', async (req, res) => {
const { email, password, remember } = await readBody(req);
const u = db.prepare('SELECT * FROM users WHERE email=? COLLATE NOCASE').get(email);
if (!u || !A.verifyPassword(password, u.pw_salt, u.pw_hash))
return json(res, 401, { error: 'invalid credentials' });
if (u.active === 0) return json(res, 403, { error: 'This account has been deactivated' });
const tok = A.token();
const ttl = remember ? 1000 * 60 * 60 * 24 * 30 : SESSION_TTL; // 30 days if remembered, else 24h
db.prepare('INSERT INTO sessions_auth (token,user_id,mfa_passed,created_at,expires_at) VALUES (?,?,1,?,?)')
.run(tok, u.id, now(), now() + ttl);
res.setHeader('Set-Cookie', `sid=${tok}; HttpOnly; Path=/; Max-Age=${ttl / 1000}`);
audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'login' });
json(res, 200, { ok: true, mfaRequired: false });
});
// Login step 2: TOTP code -> marks session mfa_passed
route('POST', '/api/login/mfa', async (req, res) => {
const { code } = await readBody(req);
const tok = parseCookies(req).sid;
const s = tok && db.prepare('SELECT * FROM sessions_auth WHERE token=?').get(tok);
if (!s) return json(res, 401, { error: 'no session' });
const u = db.prepare('SELECT * FROM users WHERE id=?').get(s.user_id);
if (!A.verifyTotp(u.mfa_secret, code)) return json(res, 401, { error: 'invalid code' });
db.prepare('UPDATE sessions_auth SET mfa_passed=1 WHERE token=?').run(tok);
audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'login' });
json(res, 200, { ok: true });
});
route('POST', '/api/logout', async (req, res) => {
const tok = parseCookies(req).sid;
if (tok) db.prepare('DELETE FROM sessions_auth WHERE token=?').run(tok);
res.setHeader('Set-Cookie', 'sid=; HttpOnly; Path=/; Max-Age=0');
json(res, 200, { ok: true });
});
route('GET', '/api/setup-state', async (req, res) => {
const anyUser = db.prepare('SELECT 1 FROM users LIMIT 1').get();
json(res, 200, { registrationOpen: !anyUser || process.env.ALLOW_REGISTRATION === '1' });
});
// ICE servers for WebRTC. Always includes a public STUN; adds managed TURN if
// configured via env (TURN_URLS comma-separated, TURN_USERNAME, TURN_CREDENTIAL).
// Sign up for a managed TURN service (Cloudflare / Metered / Twilio) and set these
// three env vars — nothing to install or run on your side.
route('GET', '/api/ice', async (req, res) => {
const iceServers = [{ urls: 'stun:stun.l.google.com:19302' }];
if (process.env.TURN_URLS) {
iceServers.push({
urls: process.env.TURN_URLS.split(',').map((u) => u.trim()).filter(Boolean),
username: process.env.TURN_USERNAME || '',
credential: process.env.TURN_CREDENTIAL || '',
});
}
json(res, 200, { iceServers });
});
route('GET', '/api/me', async (req, res) => {
const u = currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' });
json(res, 200, { id: u.id, email: u.email, role: u.role, teamId: u.team_id, name: u.name || null });
});
// ---------- BizGaze SSO: agent arrives already logged in ----------
route('GET', '/sso', async (req, res) => {
if (!process.env.SSO_SECRET) { res.writeHead(503); return res.end('SSO not configured'); }
const q = new URLSearchParams(req.url.split('?')[1] || '');
const token = q.get('token') || '';
const [payloadB64, sig] = token.split('.');
const fail = (msg) => { res.writeHead(403, { 'Content-Type': 'text/plain' }); res.end(msg); };
if (!payloadB64 || !sig) return fail('Invalid SSO token');
const crypto = require('crypto');
const expect = crypto.createHmac('sha256', process.env.SSO_SECRET).update(payloadB64).digest('base64url');
const sigBuf = Buffer.from(sig), expBuf = Buffer.from(expect);
if (sigBuf.length !== expBuf.length || !crypto.timingSafeEqual(sigBuf, expBuf)) return fail('Invalid SSO signature');
let p; try { p = JSON.parse(Buffer.from(payloadB64, 'base64url').toString()); } catch { return fail('Invalid SSO payload'); }
if (!p.email || !p.exp || p.exp < Math.floor(now() / 1000)) return fail('SSO token expired');
let u = db.prepare('SELECT * FROM users WHERE email=? COLLATE NOCASE').get(p.email);
if (!u) {
const team = db.prepare('SELECT * FROM teams LIMIT 1').get();
if (!team) return fail('No team configured');
const userId = A.id();
const { hash, salt } = A.hashPassword(A.token());
const role = (p.role === 'admin' || p.role === 'viewer') ? p.role : 'technician';
db.prepare(`INSERT INTO users (id,team_id,email,pw_hash,pw_salt,role,name,mfa_secret,mfa_enabled,created_at)
VALUES (?,?,?,?,?,?,?,?,0,?)`)
.run(userId, team.id, p.email, hash, salt, role, (p.name || null), A.newMfaSecret(), now());
u = db.prepare('SELECT * FROM users WHERE id=?').get(userId);
audit({ team_id: team.id, user_id: userId, user_email: p.email, action: 'sso_user_created', detail: p.name || '' });
} else if (p.name && p.name !== u.name) {
db.prepare('UPDATE users SET name=? WHERE id=?').run(p.name, u.id);
}
if (u.active === 0) return fail('Account deactivated');
const tok = A.token();
db.prepare('INSERT INTO sessions_auth (token,user_id,mfa_passed,created_at,expires_at) VALUES (?,?,1,?,?)')
.run(tok, u.id, now(), now() + SESSION_TTL);
audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'login', detail: 'via BizGaze SSO' });
const dest = '/connect' + (p.ticket ? ('?ticket=' + encodeURIComponent(p.ticket)) : '');
res.writeHead(302, { 'Set-Cookie': `sid=${tok}; HttpOnly; Path=/; Max-Age=${SESSION_TTL / 1000}`, Location: dest });
res.end();
});
// Admin adds an agent login to their team
route('POST', '/api/users', async (req, res) => {
const u = currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' });
if (u.role !== 'admin') return json(res, 403, { error: 'only admins can add agents' });
const { email, password, name, role } = await readBody(req);
if (!email || !password) return json(res, 400, { error: 'email and temporary password required' });
if (db.prepare('SELECT 1 FROM users WHERE email=? COLLATE NOCASE').get(email))
return json(res, 409, { error: 'email already registered' });
const userId = A.id();
const { hash, salt } = A.hashPassword(password);
const mfaSecret = A.newMfaSecret();
const r = (role === 'admin' || role === 'viewer') ? role : 'technician';
db.prepare(`INSERT INTO users (id,team_id,email,pw_hash,pw_salt,role,name,mfa_secret,mfa_enabled,created_at)
VALUES (?,?,?,?,?,?,?,?,0,?)`)
.run(userId, u.team_id, email, hash, salt, r, (name || null), mfaSecret, now());
audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'agent_added', detail: email + ' (' + r + ')' });
json(res, 200, { ok: true, id: userId, email, role: r });
});
// List the team's agents
route('GET', '/api/users', async (req, res) => {
const u = currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' });
const rows = db.prepare('SELECT id,email,name,role,active,created_at FROM users WHERE team_id=?').all(u.team_id);
json(res, 200, rows);
});
// First-login MFA self-setup: a logged-in (password ok) user who hasn't enabled MFA yet
route('GET', '/api/mfa/setup', async (req, res) => {
const u = currentUser(req, { requireMfa: false });
if (!u) return json(res, 401, { error: 'unauthorized' });
if (u.mfa_enabled) return json(res, 400, { error: 'MFA already enabled' });
json(res, 200, { secret: u.mfa_secret, otpauthUrl: A.otpauthUrl(u.mfa_secret, u.email) });
});
// Admin manages an agent: reset password, rename, deactivate/activate, delete.
// (Display names are owned by the admin/BizGaze app — agents cannot edit them.)
route('POST', '/api/users/manage', async (req, res) => {
const u = currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' });
if (u.role !== 'admin') return json(res, 403, { error: 'only admins can manage agents' });
const { id, action, password, name } = await readBody(req);
const target = db.prepare('SELECT * FROM users WHERE id=? AND team_id=?').get(id, u.team_id);
if (!target) return json(res, 404, { error: 'no such agent' });
switch (action) {
case 'reset-password': {
if (!password || String(password).length < 8) return json(res, 400, { error: 'new password must be at least 8 characters' });
const { hash, salt } = A.hashPassword(password);
db.prepare('UPDATE users SET pw_hash=?, pw_salt=? WHERE id=?').run(hash, salt, target.id);
db.prepare('DELETE FROM sessions_auth WHERE user_id=?').run(target.id); // force re-login
audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'agent_password_reset', detail: target.email });
return json(res, 200, { ok: true });
}
case 'rename': {
const clean = String(name || '').trim().slice(0, 60);
if (!clean) return json(res, 400, { error: 'name required' });
db.prepare('UPDATE users SET name=? WHERE id=?').run(clean, target.id);
audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'agent_renamed', detail: target.email + ' -> ' + clean });
return json(res, 200, { ok: true, name: clean });
}
case 'deactivate': {
if (target.id === u.id) return json(res, 400, { error: 'you cannot deactivate your own account' });
db.prepare('UPDATE users SET active=0 WHERE id=?').run(target.id);
db.prepare('DELETE FROM sessions_auth WHERE user_id=?').run(target.id);
audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'agent_deactivated', detail: target.email });
return json(res, 200, { ok: true });
}
case 'activate': {
db.prepare('UPDATE users SET active=1 WHERE id=?').run(target.id);
audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'agent_activated', detail: target.email });
return json(res, 200, { ok: true });
}
case 'delete': {
if (target.id === u.id) return json(res, 400, { error: 'you cannot delete your own account' });
db.prepare('DELETE FROM sessions_auth WHERE user_id=?').run(target.id);
db.prepare('DELETE FROM users WHERE id=?').run(target.id);
audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'agent_deleted', detail: target.email });
return json(res, 200, { ok: true });
}
default: return json(res, 400, { error: 'unknown action' });
}
});
// Session report: one row per session, filterable by agent and date period
route('GET', '/api/report', async (req, res) => {
const u = currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' });
const q = new URLSearchParams(req.url.split('?')[1] || '');
let sql = 'SELECT * FROM sessions_log WHERE team_id=?';
const args = [u.team_id];
if (q.get('agent')) { sql += ' AND agent_email=?'; args.push(q.get('agent')); }
if (q.get('from')) { sql += ' AND started_at>=?'; args.push(new Date(q.get('from') + 'T00:00:00').getTime()); }
if (q.get('to')) { sql += ' AND started_at<=?'; args.push(new Date(q.get('to') + 'T23:59:59').getTime()); }
sql += ' ORDER BY started_at DESC LIMIT 500';
json(res, 200, db.prepare(sql).all(...args));
});
// List machines for the team (with live online status from signaling layer)
route('GET', '/api/machines', async (req, res) => {
const u = currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' });
const rows = db.prepare('SELECT id,name,unattended,last_seen FROM machines WHERE team_id=?').all(u.team_id);
json(res, 200, rows.map((m) => ({ ...m, online: onlineAgents.has(m.id) })));
});
// Create a machine enrollment token (admin/technician). Agent uses it to come online.
route('POST', '/api/machines', async (req, res) => {
const u = currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' });
if (u.role === 'viewer') return json(res, 403, { error: 'forbidden' });
const { name, unattended } = await readBody(req);
const mId = A.id(), enroll = A.token();
db.prepare('INSERT INTO machines (id,team_id,name,enroll_token,unattended,created_at) VALUES (?,?,?,?,?,?)')
.run(mId, u.team_id, name || 'Unnamed PC', enroll, unattended ? 1 : 0, now());
audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, machine_id: mId, machine_name: name, action: 'machine_enrolled' });
json(res, 200, { id: mId, enrollToken: enroll });
});
route('GET', '/api/audit', async (req, res) => {
const u = currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' });
const rows = db.prepare("SELECT * FROM audit_log WHERE team_id=? OR team_id='adhoc' ORDER BY at DESC LIMIT 200").all(u.team_id);
json(res, 200, rows);
});
// ---------- static + router ----------
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' };
function serveStatic(req, res) {
let p = req.url.split('?')[0];
if (p === '/') p = '/index.html';
if (p === '/console') p = '/console.html';
if (p === '/share') p = '/share.html';
if (p === '/connect') p = '/connect.html';
const fp = path.join(PUBLIC_DIR, path.normalize(p));
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' });
const ct = MIME[path.extname(fp)] || 'application/octet-stream';
res.writeHead(200, { 'Content-Type': ct, 'Cache-Control': 'no-cache' });
res.end(data);
});
}
const server = http.createServer(async (req, res) => {
// ---------- HTTP request dispatch ----------
const server = http.createServer((req, res) => {
const key = `${req.method} ${req.url.split('?')[0]}`;
if (routes[key]) return routes[key](req, res);
if (req.method === 'GET') return serveStatic(req, res);
if (req.method === 'GET') return handleGet(req, res); // downloads + static
json(res, 404, { error: 'not found' });
});
// ---------- WebSocket signaling ----------
// Two kinds of WS clients:
// agent -> authenticates with machine enroll_token, waits for session requests
// viewer -> authenticated technician, requests a session to a machine
// The server brokers consent and relays SDP/ICE. Media never traverses the server.
const onlineAgents = new Map(); // machineId -> { ws, machine }
const liveSessions = new Map(); // sessionId -> { agentWs, viewerWs, machine, user }
const pendingShares = new Map(); // code -> { sharerWs, sessionId } (no-install ad-hoc shares)
function onConnection(ws, req) {
const hb = setInterval(() => {
if (ws.readyState === 1) { try { ws.ping(); } catch {} } else { clearInterval(hb); }
}, 25000);
ws.on('message', (raw) => {
let m; try { m = JSON.parse(raw); } catch { return; }
handle(ws, m, req);
});
ws.on('close', () => { clearInterval(hb); cleanup(ws); });
}
const wss = new WebSocketServer({ server, path: '/ws' });
wss.on('connection', onConnection);
function handle(ws, m, req) {
switch (m.type) {
// --- Agent comes online ---
case 'agent-hello': {
const machine = db.prepare('SELECT * FROM machines WHERE enroll_token=?').get(m.enrollToken);
if (!machine) return ws.send(JSON.stringify({ type: 'error', message: 'invalid enroll token' }));
ws.kind = 'agent'; ws.machineId = machine.id;
onlineAgents.set(machine.id, { ws, machine });
db.prepare('UPDATE machines SET last_seen=? WHERE id=?').run(now(), machine.id);
ws.send(JSON.stringify({ type: 'agent-registered', machineId: machine.id, name: machine.name }));
break;
}
// --- Technician requests control of a machine ---
case 'viewer-connect': {
const u = currentUser(req); // cookie sent on WS upgrade
if (!u) return ws.send(JSON.stringify({ type: 'error', message: 'unauthorized' }));
const agent = onlineAgents.get(m.machineId);
const machine = db.prepare('SELECT * FROM machines WHERE id=? AND team_id=?').get(m.machineId, u.team_id);
if (!machine) return ws.send(JSON.stringify({ type: 'error', message: 'no such machine' }));
if (!agent) return ws.send(JSON.stringify({ type: 'error', message: 'machine offline' }));
if (u.role === 'viewer' && false) {} // view-only still allowed to watch; control gated agent-side
const sessionId = A.token(8);
ws.kind = 'viewer'; ws.sessionId = sessionId;
liveSessions.set(sessionId, { agentWs: agent.ws, viewerWs: ws, machine, user: u });
audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, machine_id: machine.id, machine_name: machine.name, action: 'session_requested' });
// Ask the agent for consent (or auto-grant if unattended policy is on)
agent.ws.sessionId = sessionId;
agent.ws.send(JSON.stringify({
type: 'session-request', sessionId,
technician: u.email, unattended: !!machine.unattended,
}));
ws.send(JSON.stringify({ type: 'session-pending', sessionId, machineName: machine.name }));
break;
}
// --- Agent grants/denies consent ---
case 'consent': {
const sess = liveSessions.get(m.sessionId);
if (!sess) return;
if (m.granted) {
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: 'consent_granted', detail: sess.ticket ? 'Ticket ' + sess.ticket : (sess.machine.id ? null : 'Direct session') });
try {
db.prepare('INSERT INTO sessions_log (id,team_id,agent_email,agent_name,ticket,started_at) VALUES (?,?,?,?,?,?)')
.run(m.sessionId, sess.machine.team_id, sess.user.email, (sess.agentName || sess.user.email), sess.ticket || null, now());
} catch (e) { /* duplicate consent */ }
sess.viewerWs.send(JSON.stringify({ type: 'session-ready', sessionId: m.sessionId }));
sess.agentWs.send(JSON.stringify({ type: 'start-stream', sessionId: m.sessionId }));
} else {
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: 'consent_denied', detail: sess.ticket ? 'Ticket ' + sess.ticket : (sess.machine.id ? null : 'Direct session') });
sess.viewerWs.send(JSON.stringify({ type: 'session-denied', sessionId: m.sessionId }));
liveSessions.delete(m.sessionId);
}
break;
}
// --- No-install: end user opens /share, gets a one-time code ---
case 'share-create': {
let code;
do { code = A.numericCode(6); } while (pendingShares.has(code));
const sessionId = A.token(8);
ws.kind = 'sharer'; ws.shareCode = code; ws.sessionId = sessionId;
pendingShares.set(code, { sharerWs: ws, sessionId });
ws.send(JSON.stringify({ type: 'share-code', code }));
break;
}
// --- Logged-in agent enters the code (+ ticket) to connect ---
case 'code-connect': {
const agent = currentUser(req); // identity from the agent's authenticated session
if (!agent) {
return ws.send(JSON.stringify({ type: 'error', message: 'Please sign in as an agent first' }));
}
const ticket = String(m.ticket || '').trim() || null; // optional: direct sessions have no ticket
const pend = pendingShares.get(String(m.code || '').trim());
if (!pend || pend.sharerWs.readyState !== 1) {
return ws.send(JSON.stringify({ type: 'error', message: 'Invalid or expired code' }));
}
pendingShares.delete(pend.sharerWs.shareCode);
const sessionId = pend.sessionId;
ws.kind = 'viewer'; ws.sessionId = sessionId;
const agentName = agent.name || agent.email;
const machine = { id: null, name: 'Support session ' + pend.sharerWs.shareCode, team_id: agent.team_id };
const user = { id: agent.id, email: agent.email, team_id: agent.team_id };
liveSessions.set(sessionId, { agentWs: pend.sharerWs, viewerWs: ws, machine, user, ticket, agentName });
pend.sharerWs.sessionId = sessionId;
audit({ team_id: agent.team_id, user_id: agent.id, user_email: agent.email, machine_name: machine.name, action: 'code_session_requested', detail: (ticket ? 'Ticket ' + ticket : 'Direct session (no ticket)') + ' · agent ' + agentName });
pend.sharerWs.send(JSON.stringify({ type: 'share-request', sessionId, technician: agentName, ticket }));
ws.send(JSON.stringify({ type: 'code-pending', sessionId }));
break;
}
// --- Relay WebRTC signaling between the two peers ---
case 'offer': case 'answer': case 'ice-candidate': {
const sess = liveSessions.get(m.sessionId || ws.sessionId);
if (!sess) return;
const peer = ws === sess.agentWs ? sess.viewerWs : sess.agentWs;
if (peer && peer.readyState === 1) peer.send(JSON.stringify(m));
break;
}
case 'end-session': {
endSession(ws.sessionId, m.reason || null);
break;
}
}
}
function notifyBizGaze(sessionId) {
const url = process.env.BIZGAZE_WEBHOOK_URL;
if (!url) return;
try {
const row = db.prepare('SELECT * FROM sessions_log WHERE id=?').get(sessionId);
if (!row) return;
const body = JSON.stringify({ event: 'session.ended', sessionId: row.id, agent_email: row.agent_email,
agent_name: row.agent_name, ticket: row.ticket, started_at: row.started_at, ended_at: row.ended_at,
duration_ms: row.ended_at ? row.ended_at - row.started_at : null });
const crypto = require('crypto');
const sig = process.env.SSO_SECRET ? crypto.createHmac('sha256', process.env.SSO_SECRET).update(body).digest('base64url') : '';
fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-BizGaze-Signature': sig }, body }).catch(()=>{});
} catch (e) {}
}
function endSession(sessionId, reason) {
const sess = liveSessions.get(sessionId);
if (!sess) return;
try { db.prepare('UPDATE sessions_log SET ended_at=? WHERE id=? AND ended_at IS NULL').run(now(), sessionId); } catch (e) {}
notifyBizGaze(sessionId);
audit({ team_id: sess.machine.team_id, user_id: sess.user.id, user_email: sess.user.email, machine_id: sess.machine.id, machine_name: sess.machine.name, action: 'session_ended', detail: sess.ticket ? 'Ticket ' + sess.ticket : (sess.machine.id ? null : 'Direct session') });
[sess.agentWs, sess.viewerWs].forEach((p) => {
if (p && p.readyState === 1) p.send(JSON.stringify({ type: 'session-ended', sessionId, reason: reason || null }));
});
liveSessions.delete(sessionId);
}
function cleanup(ws) {
if (ws.kind === 'agent' && ws.machineId) onlineAgents.delete(ws.machineId);
if (ws.kind === 'sharer' && ws.shareCode) pendingShares.delete(ws.shareCode);
if (ws.sessionId) {
for (const [sid, sess] of liveSessions) {
if (sess.agentWs === ws || sess.viewerWs === ws) endSession(sid);
}
}
}
server.listen(PORT, () => {
console.log(`HTTP on http://localhost:${PORT}`);
});
+54
Parādīt failu
@@ -0,0 +1,54 @@
// 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) {
R.audit.add(entry);
}
// Resolve the session token from a request, supporting every client transport:
// - `Authorization: Bearer <token>` → native desktop/mobile apps (HTTP + WS upgrade)
// - `sid` cookie → the web app (HTTP + same-origin WS)
// - `?access_token=`/`?token=` query → browser WS fallback when a cookie isn't usable
// All three resolve to the same opaque token in `sessions_auth`.
function tokenFromReq(req) {
const h = req.headers && (req.headers.authorization || req.headers.Authorization);
if (h && /^Bearer\s+/i.test(h)) return h.replace(/^Bearer\s+/i, '').trim();
const cookieTok = parseCookies(req).sid;
if (cookieTok) return cookieTok;
try {
const qs = (req.url || '').split('?')[1];
if (qs) { const t = new URLSearchParams(qs).get('access_token') || new URLSearchParams(qs).get('token'); if (t) return t; }
} catch (_) {}
return null;
}
// Resolve the logged-in user from the request. Returns user row (with mfa state) or null.
function currentUser(req, { requireMfa = true } = {}) {
const tok = tokenFromReq(req);
if (!tok) return null;
const s = R.authSessions.byToken(tok);
if (!s || s.expires_at < now()) return null;
if (requireMfa && !s.mfa_passed) return null;
const u = R.users.byId(s.user_id);
if (!u || u.active === 0) return null;
return { ...u, _session: s };
}
// 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 };
+321
Parādīt failu
@@ -0,0 +1,321 @@
// WebSocket signaling. Two kinds of WS clients:
// agent -> authenticates with machine enroll_token, waits for session requests
// viewer -> authenticated technician, requests a session to a machine
// The server brokers consent and relays SDP/ICE. Media never traverses the server.
const R = require('./repos');
const A = require('./auth');
const { currentUser, audit } = require('./session');
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(() => {
if (ws.readyState === 1) { try { ws.ping(); } catch {} } else { clearInterval(hb); }
}, 25000);
ws.on('message', (raw) => {
let m; try { m = JSON.parse(raw); } catch { return; }
handle(ws, m, req);
});
ws.on('close', () => { clearInterval(hb); cleanup(ws); });
}
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);
if (!machine) return ws.send(JSON.stringify({ type: 'error', message: 'invalid enroll token' }));
ws.kind = 'agent'; ws.machineId = machine.id;
onlineAgents.set(machine.id, { ws, machine });
R.machines.touch(machine.id);
ws.send(JSON.stringify({ type: 'agent-registered', machineId: machine.id, name: machine.name }));
break;
}
// --- Technician requests control of a machine ---
case 'viewer-connect': {
const u = currentUser(req); // cookie sent on WS upgrade
if (!u) return ws.send(JSON.stringify({ type: 'error', message: 'unauthorized' }));
const agent = onlineAgents.get(m.machineId);
const machine = R.machines.inTenant(m.machineId, u.team_id);
if (!machine) return ws.send(JSON.stringify({ type: 'error', message: 'no such machine' }));
if (!agent) return ws.send(JSON.stringify({ type: 'error', message: 'machine offline' }));
if (u.role === 'viewer' && false) {} // view-only still allowed to watch; control gated agent-side
const sessionId = A.token(8);
ws.kind = 'viewer'; ws.sessionId = sessionId;
liveSessions.set(sessionId, { agentWs: agent.ws, viewerWs: ws, machine, user: u });
audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, machine_id: machine.id, machine_name: machine.name, action: 'session_requested' });
// Ask the agent for consent (or auto-grant if unattended policy is on)
agent.ws.sessionId = sessionId;
agent.ws.send(JSON.stringify({
type: 'session-request', sessionId,
technician: u.email, unattended: !!machine.unattended,
}));
ws.send(JSON.stringify({ type: 'session-pending', sessionId, machineName: machine.name }));
break;
}
// --- Agent grants/denies consent ---
case 'consent': {
const sess = liveSessions.get(m.sessionId);
if (!sess) return;
if (m.granted) {
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: 'consent_granted', detail: sess.ticket ? 'Ticket ' + sess.ticket : (sess.machine.id ? null : 'Direct session') });
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 {
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: 'consent_denied', detail: sess.ticket ? 'Ticket ' + sess.ticket : (sess.machine.id ? null : 'Direct session') });
sess.viewerWs.send(JSON.stringify({ type: 'session-denied', sessionId: m.sessionId }));
liveSessions.delete(m.sessionId);
}
break;
}
// --- No-install: end user opens /share, gets a one-time code ---
case 'share-create': {
let code;
do { code = A.numericCode(6); } while (pendingShares.has(code));
const sessionId = A.token(8);
ws.kind = 'sharer'; ws.shareCode = code; ws.sessionId = sessionId;
pendingShares.set(code, { sharerWs: ws, sessionId });
ws.send(JSON.stringify({ type: 'share-code', code }));
break;
}
// --- Logged-in agent enters the code (+ ticket) to connect ---
case 'code-connect': {
const agent = currentUser(req); // identity from the agent's authenticated session
if (!agent) {
return ws.send(JSON.stringify({ type: 'error', message: 'Please sign in as an agent first' }));
}
const ticket = String(m.ticket || '').trim() || null; // optional: direct sessions have no ticket
const pend = pendingShares.get(String(m.code || '').trim());
if (!pend || pend.sharerWs.readyState !== 1) {
return ws.send(JSON.stringify({ type: 'error', message: 'Invalid or expired code' }));
}
pendingShares.delete(pend.sharerWs.shareCode);
const sessionId = pend.sessionId;
ws.kind = 'viewer'; ws.sessionId = sessionId;
const agentName = agent.name || agent.email;
const machine = { id: null, name: 'Support session ' + pend.sharerWs.shareCode, team_id: agent.team_id };
const user = { id: agent.id, email: agent.email, team_id: agent.team_id };
liveSessions.set(sessionId, { agentWs: pend.sharerWs, viewerWs: ws, machine, user, ticket, agentName });
pend.sharerWs.sessionId = sessionId;
audit({ team_id: agent.team_id, user_id: agent.id, user_email: agent.email, machine_name: machine.name, action: 'code_session_requested', detail: (ticket ? 'Ticket ' + ticket : 'Direct session (no ticket)') + ' · agent ' + agentName });
pend.sharerWs.send(JSON.stringify({ type: 'share-request', sessionId, technician: agentName, ticket }));
ws.send(JSON.stringify({ type: 'code-pending', sessionId }));
break;
}
// --- Relay WebRTC signaling between the two peers ---
case 'offer': case 'answer': case 'ice-candidate': {
const sess = liveSessions.get(m.sessionId || ws.sessionId);
if (!sess) return;
const peer = ws === sess.agentWs ? sess.viewerWs : sess.agentWs;
if (peer && peer.readyState === 1) peer.send(JSON.stringify(m));
break;
}
case 'transcript': {
const sess = liveSessions.get(m.sessionId || ws.sessionId);
if (!sess) return;
const peer = ws === sess.agentWs ? sess.viewerWs : sess.agentWs;
if (peer && peer.readyState === 1) peer.send(JSON.stringify(m));
break;
}
case 'recording': {
const sess = liveSessions.get(m.sessionId || ws.sessionId);
if (!sess) return;
const peer = ws === sess.agentWs ? sess.viewerWs : sess.agentWs;
if (peer && peer.readyState === 1) peer.send(JSON.stringify(m));
break;
}
case 'end-session': {
endSession(ws.sessionId, m.reason || null);
break;
}
}
}
function endSession(sessionId, reason) {
const sess = liveSessions.get(sessionId);
if (!sess) return;
try { R.sessionsLog.end(sessionId); } catch (e) {}
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 }));
});
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) {
for (const [sid, sess] of liveSessions) {
if (sess.agentWs === ws || sess.viewerWs === ws) endSession(sid);
}
}
}
module.exports = { onConnection };
+146
Parādīt failu
@@ -0,0 +1,146 @@
// Static file serving + authenticated recording/transcript downloads.
// handleGet() is the fallback for any GET that didn't match an API route.
const fs = require('fs');
const path = require('path');
const R = require('./repos');
const { json } = require('./lib');
const { currentUser } = require('./session');
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', '.json': 'application/json', '.webmanifest': 'application/manifest+json' };
function serveStatic(req, res) {
let p = req.url.split('?')[0];
if (p === '/') p = '/index.html';
if (p === '/home') p = '/home.html';
// Console was replaced by Dashboard; keep the old path working.
if (p === '/console' || p === '/dashboard') p = '/dashboard.html';
if (p === '/share') p = '/share.html';
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' });
// 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 ext = path.extname(fp);
const ct = MIME[ext] || 'application/octet-stream';
// HTML entry pages are NEVER cached (no-store) so a deploy reaches every browser on the
// next load — no hard-refresh needed. Versioned assets (e.g. icons.js?v=) still revalidate
// cheaply via ETag/304.
const isHtml = ext === '.html';
const etag = '"' + st.size.toString(16) + '-' + Math.round(st.mtimeMs).toString(16) + '"';
if (!isHtml && req.headers['if-none-match'] === etag) {
res.writeHead(304, { ETag: etag, 'Cache-Control': 'no-cache' });
return res.end();
}
fs.readFile(fp, (err, data) => {
if (err) return json(res, 404, { error: 'not found' });
const headers = { 'Content-Type': ct, 'Content-Length': st.size, 'Cache-Control': isHtml ? 'no-store, must-revalidate' : 'no-cache' };
if (!isHtml) headers.ETag = etag;
res.writeHead(200, headers);
res.end(data);
});
});
}
// GET fallback: authenticated transcript/recording downloads, else static files.
function handleGet(req, res) {
const pathOnly = req.url.split('?')[0];
if (pathOnly.startsWith('/transcripts/')) {
const u = currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' });
const name = path.basename(decodeURIComponent(pathOnly));
const sid = name.replace(/\.txt$/i, '');
const row = R.sessionsLog.byIdInTenant(sid, u.team_id);
if (!row || !row.transcript) return json(res, 404, { error: 'not found' });
const fp = path.join(TRANS_DIR, row.transcript);
if (!fp.startsWith(TRANS_DIR)) return json(res, 403, { error: 'forbidden' });
return fs.stat(fp, (err, st) => {
if (err) return json(res, 404, { error: 'not found' });
res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8', 'Content-Length': st.size, 'Content-Disposition': 'attachment; filename="transcript-' + sid + '.txt"', 'Cache-Control': 'no-store' });
const rs = fs.createReadStream(fp);
rs.on('error', () => { try { res.destroy(); } catch (e) {} });
rs.pipe(res);
});
}
if (pathOnly.startsWith('/recordings/')) {
const u = currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' });
const name = path.basename(decodeURIComponent(pathOnly));
const sid = name.replace(/\.(webm|mp4)$/i, '');
const row = R.sessionsLog.byIdInTenant(sid, u.team_id);
if (!row || !row.recording) return json(res, 404, { error: 'not found' });
const fp = path.join(REC_DIR, row.recording);
if (!fp.startsWith(REC_DIR)) return json(res, 403, { error: 'forbidden' });
const ext = path.extname(row.recording).toLowerCase() === '.mp4' ? 'mp4' : 'webm';
const ctype = ext === 'mp4' ? 'video/mp4' : 'video/webm';
return fs.stat(fp, (err, st) => {
if (err) return json(res, 404, { error: 'not found' });
res.writeHead(200, { 'Content-Type': ctype, 'Content-Length': st.size, 'Content-Disposition': 'attachment; filename="session-' + sid + '.' + ext + '"', 'Cache-Control': 'no-store', 'Accept-Ranges': 'bytes' });
const rs = fs.createReadStream(fp);
rs.on('error', () => { try { res.destroy(); } catch (e) {} });
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);
}
module.exports = { handleGet, serveStatic };
+289 -31
Parādīt failu
@@ -1,18 +1,25 @@
// End-to-end test of the backend platform.
// Exercises the full flow: register -> enable MFA -> login (2 steps) ->
// enroll machine -> agent comes online -> technician requests session ->
// consent -> signaling relay -> audit trail. No browser/Electron needed:
// the "agent" and "viewer" are raw WebSocket clients.
// Exercises the full flow: register -> login -> enroll machine -> agent online ->
// technician requests session -> consent -> signaling relay -> audit trail.
// No browser/Electron needed: the "agent" and "viewer" are raw WebSocket clients.
// (Login currently marks the session MFA-passed directly, so there is no separate
// TOTP step in the product flow; the MFA endpoints still exist but aren't exercised here.)
process.env.DB_PATH = '/tmp/ra-e2e.db';
const fs = require('fs');
for (const f of ['/tmp/ra-e2e.db', '/tmp/ra-e2e.db-wal', '/tmp/ra-e2e.db-shm']) { try { fs.unlinkSync(f); } catch {} }
const os = require('os');
const path = require('path');
const DB = path.join(os.tmpdir(), 'ra-e2e.db');
process.env.DB_PATH = DB;
for (const f of [DB, DB + '-wal', DB + '-shm']) { try { fs.unlinkSync(f); } catch {} }
const PORT = 8099;
process.env.PORT = PORT;
process.env.HTTPS_PORT = 8444; // avoid clashing with a running dev server on 8443
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;
@@ -59,38 +66,279 @@ function nextMsg(ws, type, timeout = 3000) {
await wait(300); // let server bind
console.log('E2E backend tests:');
// 1. Register
// 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' });
check('register returns mfa setup', reg.status === 200 && reg.data.mfaSetup && reg.data.mfaSetup.secret);
const secret = reg.data.mfaSetup.secret;
check('register succeeds', reg.status === 200 && reg.data.ok === true);
// 2. Login before MFA enabled — allowed, mfaRequired=false
let login = await call('/api/login', { email, password: 'supersecret' });
// 2. Login -> session cookie (login marks the session MFA-passed)
const login = await call('/api/login', { email, password: 'supersecret' });
check('login sets session cookie', !!login.cookie);
const cookie = login.cookie;
// 3. Enable MFA with a valid TOTP
const enable = await call('/api/mfa/enable', { email, code: A.totp(secret) });
check('mfa enable succeeds with valid code', enable.status === 200);
const badEnable = await call('/api/mfa/enable', { email, code: '000000' });
check('mfa enable rejects bad code', badEnable.status === 401);
// 4. Fresh login now requires MFA
login = await call('/api/login', { email, password: 'supersecret' });
check('login now flags mfaRequired', login.data.mfaRequired === true);
let cookie = login.cookie;
// 5. Protected route blocked until MFA passed
const meBlocked = await get('/api/me', cookie);
check('me blocked before mfa', meBlocked.status === 401);
// 6. Pass MFA
const mfa = await call('/api/login/mfa', { code: A.totp(secret) }, cookie);
check('login mfa step succeeds', mfa.status === 200);
// 3. Protected route works right after login, role=admin
const me = await get('/api/me', cookie);
check('me works after mfa, role=admin', me.status === 200 && me.data.role === 'admin');
check('me works after login, role=admin', me.status === 200 && me.data.role === 'admin');
// 7. Wrong password rejected
// 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);
@@ -142,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);
@@ -158,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);
+54
Parādīt failu
@@ -0,0 +1,54 @@
// Outbound webhook delivery. emit(event, tenantId, payload) fans the event out to every
// active per-tenant subscription registered for that event, plus the legacy global
// BIZGAZE_WEBHOOK_URL (back-compat). Each delivery is HMAC-signed and retried on failure.
//
// NOTE (roadmap): retries are in-memory/best-effort. For guaranteed delivery this should
// move to a persistent queue when the app scales to multiple instances (see ARCHITECTURE.md).
const R = require('./repos');
const crypto = require('crypto');
const EVENTS = ['session.started', 'session.ended'];
function sign(secret, body) {
return crypto.createHmac('sha256', secret || '').update(body).digest('base64url');
}
const RETRY_DELAYS = [2000, 10000, 30000]; // after the first attempt
function deliver(url, secret, body, onDone) {
let attempt = 0;
const go = async () => {
attempt++;
let ok = false, status = 0, err = null;
try {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-BizGaze-Signature': sign(secret, body), 'X-BizGaze-Event': (() => { try { return JSON.parse(body).event; } catch { return ''; } })() },
body,
signal: AbortSignal.timeout(10000),
});
status = res.status; ok = res.ok;
} catch (e) { err = (e && e.message) || 'delivery failed'; }
if (ok || attempt > RETRY_DELAYS.length) { if (onDone) onDone({ ok, status, err }); return; }
setTimeout(go, RETRY_DELAYS[attempt - 1]);
};
go();
}
function emit(event, tenantId, payload) {
const body = JSON.stringify({ event, ...payload });
// Per-tenant subscriptions
try {
for (const h of R.webhooks.activeForTenant(tenantId)) {
const subs = String(h.events || '').split(',').map((s) => s.trim());
if (subs.includes('*') || subs.includes(event)) {
deliver(h.url, h.secret, body, (r) => { try { R.webhooks.setStatus(h.id, r.ok ? 1 : 0, r.err || ('HTTP ' + r.status)); } catch (_) {} });
}
}
} catch (_) {}
// Legacy global webhook (back-compat): session.ended → BIZGAZE_WEBHOOK_URL, signed with SSO_SECRET.
if (event === 'session.ended' && process.env.BIZGAZE_WEBHOOK_URL) {
deliver(process.env.BIZGAZE_WEBHOOK_URL, process.env.SSO_SECRET || '', body);
}
}
module.exports = { emit, sign, EVENTS };