Salīdzināt revīzijas
23 Revīzijas
7620300ef7
...
master
| Autors | SHA1 | Datums | |
|---|---|---|---|
| e9e5c7f406 | |||
| a427be9b6f | |||
| b576ed372a | |||
| f4a23ae805 | |||
| f7ddb2e7ae | |||
| 5edb3fa241 | |||
| 88d7657364 | |||
| 1272b81cee | |||
| d50d4bde47 | |||
| 1f4516d69b | |||
| fcd6a60baa | |||
| bda63b6f0a | |||
| 27355cec76 | |||
| 0a739ee2fd | |||
| 54b74d5db1 | |||
| 6ac280f178 | |||
| caba3b3a21 | |||
| 5448cf0614 | |||
| d045847a59 | |||
| ba8bfc3f46 | |||
| f6ebaa7bfb | |||
| 1d9ffcc3d4 | |||
| 28f616d829 |
@@ -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=
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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"]
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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)
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
ģenerēts
+192
-4
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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=>({'&':'&','<':'<','>':'>','"':'"'}[c]));}
|
||||
function profileHTML(name){return '<div class="profile"><button class="pbtn" id="pbtn">'+pEsc(name)+' <span style="font-size:.65rem">▾</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">▾</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">✕</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=>({'&':'&','<':'<','>':'>','"':'"'}[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>
|
||||
|
||||
@@ -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=>({'&':'&','<':'<','>':'>','"':'"'}[c]));}
|
||||
function profileHTML(name){return '<div class="profile"><button class="pbtn" id="pbtn">'+pEsc(name)+' <span style="font-size:.65rem">▾</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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); }
|
||||
|
||||
// ---------- Boot ----------
|
||||
(async function () {
|
||||
try { const me = await api('/api/me', null, 'GET'); dashboard(me); }
|
||||
catch { authView(); }
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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=>({'&':'&','<':'<','>':'>','"':'"'}[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">▾</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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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>
|
||||
@@ -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=>({'&':'&','<':'<','>':'>','"':'"'}[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">▾</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
@@ -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=>({'&':'&','<':'<','>':'>','"':'"'}[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 |
@@ -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>';
|
||||
};
|
||||
})();
|
||||
@@ -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=>({'&':'&','<':'<','>':'>','"':'"'}[c]));}
|
||||
function profileHTML(name){return '<div class="profile"><button class="pbtn" id="pbtn">'+pEsc(name)+' <span style="font-size:.65rem">▾</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">▾</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 →'; 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>
|
||||
|
||||
@@ -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
@@ -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)">← 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=>({'&':'&','<':'<','>':'>','"':'"'}[c]));}
|
||||
function profileHTML(name){return '<div class="profile"><button class="pbtn" id="pbtn">'+pEsc(name)+' <span style="font-size:.65rem">▾</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">▾</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=>({'&':'&','<':'<','>':'>','"':'"'}[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>
|
||||
|
||||
@@ -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 (_) {}
|
||||
})());
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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
@@ -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}`);
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
Atsaukties uz šo jaunā problēmā
Block a user