Bladeren bron

feat: BizGaze Connect home, BizGaze login, modular backend, /api/v1

User-facing
- New post-login home (/home): chat rail + Share/Connect (embedded) + Meeting; login lives here when logged out
- Landing: "Log in with BizGaze" + no-login screen share
- Console replaced by a role-scoped Dashboard (/dashboard): admins see all team sessions, others see only their own; stats + CSV/PDF export
- Recordings saved as MP4 (H.264/AAC) with WebM fallback; old .webm still downloadable
- Fix: duplicate "Sign in" on the login card

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sravan 1 week geleden
bovenliggende
commit
ba8bfc3f46

+ 4
- 0
.env.example Bestand weergeven

@@ -12,6 +12,10 @@ TURN_CREDENTIAL=
12 12
 # Optional: open self-registration of the first/any team (1 to enable).
13 13
 # ALLOW_REGISTRATION=1
14 14
 
15
+# Optional: BizGaze as the identity provider. When set, /api/login validates
16
+# credentials against this endpoint (after a local check) and provisions the user.
17
+# BIZGAZE_LOGIN_URL=https://c02.bizgaze.app/Account/ValidateAndLogin
18
+
15 19
 # Optional: shared secret for BizGaze SSO + signed webhook delivery.
16 20
 # SSO_SECRET=
17 21
 

+ 4
- 0
.gitignore Bestand weergeven

@@ -29,6 +29,10 @@ dist/
29 29
 build/
30 30
 out/
31 31
 
32
+# Runtime media (created at startup by config.js)
33
+server/recordings/
34
+server/transcripts/
35
+
32 36
 # OS files
33 37
 .DS_Store
34 38
 Thumbs.db

+ 163
- 0
ARCHITECTURE.md Bestand weergeven

@@ -0,0 +1,163 @@
1
+# BizGaze Connect — Architecture & Roadmap
2
+
3
+This document records the **current** architecture, the **target** architecture, and a
4
+**phased migration plan** so that the three strategic goals can be added *additively*
5
+rather than as rewrites.
6
+
7
+Strategic goals (see also `CLAUDE.md`):
8
+1. **Native Android/iOS apps**
9
+2. **Integration with any third-party application**
10
+3. **Org-based licensing model** (Zoom-like: organizations buy seats/plans)
11
+
12
+---
13
+
14
+## 1. Current architecture (as of 2026-06)
15
+
16
+```
17
+Single Node process (server/server.js, ~640 lines)
18
+├── HTTP JSON API        (/api/*, cookie-session auth)
19
+├── WebSocket signaling  (/ws — SDP/ICE relay, consent, share codes)
20
+├── Static file serving  (public/*.html, single-file pages, no build)
21
+└── In-process state     liveSessions / onlineAgents / pendingShares  (Maps)
22
+
23
+Data:  node:sqlite single file (server/data.db)
24
+       teams, users, sessions_auth, machines, audit_log, sessions_log
25
+Media: WebRTC P2P (1:1). STUN + managed TURN. Media never traverses the server.
26
+Auth:  scrypt passwords, opaque session token in an HttpOnly `sid` cookie. TOTP code exists but
27
+       login currently marks sessions MFA-passed directly.
28
+Integrations: outbound webhook (single env URL, `session.ended`, HMAC-signed);
29
+              inbound SSO (`/sso`, custom HMAC token).
30
+Recordings/transcripts: written to local disk (server/recordings, server/transcripts).
31
+```
32
+
33
+### What is already future-proof (keep)
34
+- **WebRTC + `/ws` signaling** — standards-based; reused as-is by native apps and an SFU.
35
+- **P2P media** — no server media path for 1:1; cheap and private.
36
+- **HMAC-signed webhooks** and **audit log** — right primitives, just need to scale out.
37
+- **Team-scoped queries** — the seed of multi-tenancy is present.
38
+
39
+### Structural constraints that block the roadmap
40
+| # | Constraint | Blocks |
41
+|---|-----------|--------|
42
+| C1 | Auth is **cookie-only** (`parseCookies(req).sid`); no `Authorization: Bearer`, no API keys | Mobile, Integrations |
43
+| C2 | **No API versioning** (`/api/...`) | Mobile (shipped clients pin a contract) |
44
+| C3 | **`team` is a thin tenant** `(id,name,created_at)`; app assumes one team | Licensing |
45
+| C4 | **Session state in process memory** (Maps) | Horizontal scale, Meetings |
46
+| C5 | **SQLite single-writer**, queries inline at ~100 call-sites | Scale, multi-tenant isolation |
47
+| C6 | **P2P mesh only** — no SFU | Multi-party meetings (Zoom-like) |
48
+| C7 | **Recordings on local disk** | Multi-instance, per-org storage quotas |
49
+| C8 | **Monolithic `server.js`** mixes HTTP/WS/static/logic/DB | All (maintainability) |
50
+
51
+---
52
+
53
+## 2. Target architecture (principles)
54
+
55
+1. **Organization is the top-level tenant.** Every row and every request resolves to an
56
+   `org_id`. Billing, seats, plan, and feature flags hang off the Organization.
57
+2. **Data access goes through a repository layer**, never raw SQL in route handlers.
58
+   This is what makes the SQLite→Postgres migration and strict tenant-scoping feasible.
59
+3. **The API is versioned and token-addressable.** `/api/v1`; auth accepted via cookie
60
+   (web) *or* `Authorization: Bearer` (mobile) *or* scoped API key (integrations).
61
+4. **Shared runtime state lives outside the process** (Redis) so the app can run N instances.
62
+5. **Multi-party media uses an SFU** (LiveKit/mediasoup); 1:1 may stay P2P.
63
+6. **Entitlements are enforced centrally** — one middleware checks plan limits before
64
+   privileged actions (add seat, start meeting, record, call the API).
65
+
66
+```
67
+            ┌──────────── clients ────────────┐
68
+            │ web (HTML)   mobile (native)  3rd-party (API key) │
69
+            └───────┬───────────┬───────────────┬──────────────┘
70
+                    │ cookie     │ Bearer        │ API key
71
+            ┌───────▼───────────▼───────────────▼──────────────┐
72
+            │  API v1  (routes → services → repository)         │
73
+            │  authN (cookie/Bearer/key) · authZ (RBAC)         │
74
+            │  entitlements middleware (plan/seat/feature)      │
75
+            └───────┬───────────────────────┬──────────────────┘
76
+                    │                        │
77
+          ┌─────────▼────────┐     ┌─────────▼─────────┐
78
+          │ Repository layer │     │ Signaling (ws)    │──── Redis (shared state,
79
+          │ (SQLite→Postgres)│     │ + SFU for meetings│      pub/sub across instances)
80
+          └─────────┬────────┘     └───────────────────┘
81
+                    │
82
+        Postgres · Object storage (recordings) · usage-metering
83
+```
84
+
85
+---
86
+
87
+## 3. Goal-by-goal requirements
88
+
89
+### Goal 1 — Native Android/iOS
90
+- **Bearer-token auth** (C1) + refresh tokens; device registration.
91
+- **`/api/v1`** (C2) — stable, documented contract.
92
+- **Push notifications** (APNs/FCM) for incoming sessions/calls (mobile can't hold a background WS).
93
+- Reuse WebRTC/`/ws` via `react-native-webrtc`/native SDKs; native screen capture
94
+  (ReplayKit / MediaProjection) for the phone-can't-share gap.
95
+
96
+### Goal 2 — Third-party integration
97
+- **Scoped API keys / OAuth2 client-credentials** per org (C1).
98
+- **Webhook subscriptions** per org: multiple endpoints, event types, signed payloads, retries.
99
+- **OIDC/JWT SSO** to replace the custom HMAC `/sso`.
100
+- Optional: embeddable JS widget / SDK.
101
+
102
+### Goal 3 — Org licensing (Zoom-like)
103
+- **Organization** entity (C3): `plan`, `seats`, `status`, `trial_ends_at`, feature flags.
104
+- **Entitlements + metering**: tables for plan limits and usage (minutes, sessions, storage);
105
+  central enforcement middleware.
106
+- **SFU** for multi-party meetings (C6); per-org concurrent-meeting / minute caps.
107
+- **Redis shared state** (C4) for multi-instance; **Postgres** (C5); **object storage** (C7).
108
+- Billing provider integration (e.g. Stripe) driving subscription state.
109
+
110
+---
111
+
112
+## 4. Phased plan
113
+
114
+> **Priority (set by the user 2026-06-11): mobile + integration first; licensing last.**
115
+> Principle unchanged: do the shared groundwork first, so later work is additive. Because
116
+> licensing is last, the full **Organization** entity moves to Phase 3 with it — Phase 1 keeps
117
+> only a *tenant-id abstraction* (mapping to today's `team_id`) so Phase-2 auth/keys don't need
118
+> reworking when the tenant is later elevated to a full Organization.
119
+
120
+### Phase 1 — Foundations (structural, no behavior change)  ✅ DONE (2026-06-11)
121
+- [x] Extracted a **data-access layer** (`repos.js`) — all SQL moved out of `server.js`.
122
+- [x] **Modularized** `server.js` → `config / lib / session / presence / routes / static / signaling`
123
+      (plus `repos` data layer and `bizgaze` service). `server.js` is now a thin entry point.
124
+- [x] Standardized a **tenant id** in the data layer (repo params named `tenantId`, == `team_id` today);
125
+      every query is tenant-scoped. *No Organization entity / plan-seats yet — that's Phase 3.*
126
+- Verified behavior-preserving by `test/e2e.js` (21/21) before and after.
127
+
128
+### Phase 2 — API + access  ← **PRIORITY** (mobile + desktop + integrations)
129
+Target clients: web (cookie), native **Android/iOS**, a native **Windows desktop app where the
130
+viewer CONTROLS the remote screen** (reuses the WebRTC `inputChannel` + OS input injection like
131
+`agent/`), and third-party systems (API keys). All authenticate through this one access layer.
132
+- [x] **`/api/v1`** — every `/api/*` route aliased under `/api/v1/*` (routes.js); web keeps unversioned paths.
133
+- [x] **`Authorization: Bearer <token>`** accepted in `currentUser()` across HTTP + WS, alongside the
134
+      cookie (session.js `tokenFromReq`); `/api/login` now also returns the `token` for native clients.
135
+      WS upgrades carry the token in the Authorization header (native) or `?access_token=` (browser fallback).
136
+- [ ] **Refresh tokens** (short access token + long refresh) so native apps stay signed in safely.
137
+- [ ] **API keys** table + middleware (scoped per *tenant*, hashed at rest).
138
+- [ ] **Push-notification hooks** (APNs/FCM) for incoming sessions/calls on mobile.
139
+- [ ] **OIDC/JWT** SSO; per-tenant **webhook subscriptions** with retries.
140
+
141
+### Phase 3 — Licensing + scale (last, per priority)
142
+- [ ] Elevate **tenant → `organization`** (additive migration: add `organizations`, keep `team_id`
143
+      as alias/FK); add `plan`, `seats`, `status`, `features` columns.
144
+- [ ] **Entitlements** module + central enforcement; **usage metering** (minutes/sessions/storage).
145
+- [ ] **SFU** (LiveKit/mediasoup) for multi-party meetings; keep 1:1 P2P.
146
+- [ ] **Redis** for `liveSessions/onlineAgents/pendingShares` + cross-instance pub/sub.
147
+- [ ] **Postgres** migration (enabled by the Phase-1 data-access layer); **object storage** (S3) for recordings.
148
+- [ ] Billing provider + subscription lifecycle webhooks.
149
+
150
+### Explicitly NOT yet
151
+- Don't add an SFU or Postgres before the data-access layer exists — you'd rework them.
152
+- Don't build the full Organization/plan model before Phase 2 ships — but *do* keep the tenant-id
153
+  abstraction consistent from Phase 1 so the elevation is additive.
154
+- Don't shard or add microservices; a well-modularized monolith + Redis + Postgres scales far enough.
155
+
156
+---
157
+
158
+## 5. Key decisions to confirm (when we reach them)
159
+- **Auth tokens:** opaque (DB-backed, easy revoke) vs JWT (stateless, harder revoke). *Lean: opaque
160
+  access + refresh, since `sessions_auth` already works that way.*
161
+- **SFU:** LiveKit (batteries-included, good mobile SDKs) vs mediasoup (lower-level, more control).
162
+- **DB:** Postgres (recommended) — keep the repository layer DB-agnostic until the cutover.
163
+- **Billing:** Stripe vs BizGaze's own billing (depends on how Connect sits inside the BizGaze suite).

+ 134
- 0
CLAUDE.md Bestand weergeven

@@ -0,0 +1,134 @@
1
+# BizGaze Connect — project brief
2
+
3
+Place this file at the repo root (`remote-access-app/CLAUDE.md`). Claude Code reads
4
+it automatically each session.
5
+
6
+## What this is
7
+**BizGaze Connect** — a no-install, browser-based remote support / screen-sharing
8
+tool for the BizGaze ecosystem. A customer opens a page, gets a 6-digit code; a
9
+signed-in BizGaze agent enters the code, the customer taps Allow, and the agent
10
+sees the customer's screen with two-way voice + chat. Live at **remote.bizgaze.com**.
11
+Roadmap: grow into a communication platform (meetings + persistent chat) for
12
+registered BizGaze users.
13
+
14
+## Tech stack (intentionally minimal — keep it this way)
15
+- **Node.js >= 22.5**, single npm dependency: `ws` (WebSocket).
16
+- **Built-in `node:sqlite`** (no native modules). DB file: `server/data.db`.
17
+- **WebRTC** peer-to-peer for media (screen video + voice + data channels).
18
+- **No build step, no framework.** Each page is a single self-contained HTML file
19
+  with inline `<style>` and `<script>`. Do not introduce React/bundlers.
20
+- Auth: scrypt password hashing, HttpOnly session cookie. (SSO migration in progress.)
21
+
22
+## Repo layout
23
+```
24
+server/
25
+  server.js      # thin entry: HTTP dispatch + WS attach + listeners (HTTP/HTTPS)
26
+  config.js      # env + filesystem paths (PORT, dirs, SESSION_TTL)
27
+  lib.js         # HTTP helpers: json / readBody / parseCookies / now
28
+  session.js     # currentUser (cookie -> user) + audit()
29
+  presence.js    # shared in-memory live state (onlineAgents/liveSessions/pendingShares)
30
+  routes.js      # HTTP JSON API (/api/*, /sso) -> { "METHOD /path": handler } map
31
+  static.js      # static file serving + authenticated recording/transcript downloads
32
+  signaling.js   # WebSocket signaling (consent + SDP/ICE relay)
33
+  repos.js       # data-access layer — ALL SQL lives here (tenant-scoped)
34
+  bizgaze.js     # BizGaze identity provider (validate login, env-gated)
35
+  db.js          # node:sqlite schema + idempotent migrations
36
+  auth.js        # scrypt hashing, token/id generation, TOTP helpers
37
+  package.json   # { "dependencies": { "ws": "^8.18" }, engines node>=22.5 }
38
+  test/e2e.js    # 21-check backend e2e (register->login->session->signaling->audit)
39
+  public/
40
+    index.html    # public landing (Log in with BizGaze / share without login)
41
+    home.html     # post-login shell: chat rail + Share/Connect (iframe) + Meeting (/home)
42
+    dashboard.html# login + role-scoped session report (/dashboard, replaces /console)
43
+    connect.html  # agent: enter code, view screen, control bar (/connect)
44
+    share.html    # customer: get code, share screen (/share)
45
+    home-mockup.html # locked design reference for home
46
+    logo.png
47
+  recordings/    # saved session recordings (.webm)  [created at runtime]
48
+  transcripts/   # saved transcripts (.txt)           [created at runtime]
49
+```
50
+Architecture/roadmap detail lives in `ARCHITECTURE.md`. Backend SQL must go through
51
+`repos.js` (never inline in routes/signaling). Run `node test/e2e.js` after backend edits.
52
+
53
+## Run locally
54
+```
55
+cd server && npm install && node server.js
56
+# HTTP on :8090 (HTTPS on :8443 only if cert.pem + key.pem exist in server/)
57
+# Env: ALLOW_REGISTRATION=1 opens the first-team registration
58
+```
59
+First registered user becomes admin; registration then closes (unless ALLOW_REGISTRATION=1).
60
+
61
+## Key HTTP routes (server.js)
62
+- `POST /api/register|login|logout`, `GET /api/me`, `GET/POST /api/users`,
63
+  `POST /api/users/manage`, `GET /api/setup-state`, `GET /api/report`
64
+- `GET /api/ice` — returns STUN, plus managed TURN **only for mobile clients**
65
+  (TURN creds come from env: `TURN_URLS`, `TURN_USERNAME`, `TURN_CREDENTIAL`)
66
+- `POST /api/recording?sessionId=` / `POST /api/transcript?sessionId=` — uploads
67
+- `GET /recordings/<sid>.webm` / `GET /transcripts/<sid>.txt` — authed downloads (streamed w/ Content-Length)
68
+- `GET /sso?token=` — SSO entry (HMAC today; JWT migration planned)
69
+- Page routes: `/`, `/console`, `/connect`, `/share`
70
+
71
+## WebSocket signaling (`/ws`)
72
+`liveSessions` map (sessionId -> {agentWs, viewerWs, ...}). Message cases:
73
+`agent-hello`, `viewer-connect`, `consent`, `share-create`, `code-connect`,
74
+`offer`/`answer`/`ice-candidate` (relayed peer-to-peer), `recording`, `transcript`,
75
+`end-session`. Keepalive ping every 25s. Media never traverses the server.
76
+
77
+## Current features (all working on desktop)
78
+- Code-based no-install screen share (customer shares, agent views).
79
+- Two-way voice; in-session chat (logged-in sharer's name shown).
80
+- **Session recording**: agent presses Record; mixes customer screen + both voices;
81
+  uploads `.webm`; downloadable from the report. Customer sees a "being recorded"
82
+  banner + live timer.
83
+- **Auto-transcript**: each side runs Web Speech API on its own mic; lines stream to
84
+  the agent; combined `.txt` (voices + chat) uploaded; downloadable from the report.
85
+- **Session report**: filter by agent/date, CSV + PDF export, pagination (5/page),
86
+  agent search, recording/transcript download links.
87
+- Agent management (admin invites, roles admin/technician/viewer), remember-me,
88
+  case-insensitive email, password show/hide, session-end webhook to BizGaze.
89
+
90
+## Hard constraints (do not try to "fix" these)
91
+- **Mobile browsers CANNOT share their screen.** Android Chrome and iOS Safari do
92
+  not expose `getDisplayMedia` screen capture to web pages. Only a native app can
93
+  capture a phone screen. The share page detects mobile and shows a clear message.
94
+  (Desktop screen share works fully.)
95
+- Screen capture requires a user gesture → `getDisplayMedia` is called directly from
96
+  the customer's "Allow" tap (see share.html `beginCapture`).
97
+- Recording/transcript use browser MediaRecorder + Web Speech API → Chrome/Edge only.
98
+
99
+## Production
100
+- `remote.bizgaze.com`, Linux, Docker, behind a reverse proxy.
101
+- Proxy MUST: upgrade `/ws` with long timeouts; allow large bodies on
102
+  `/api/recording`; not buffer `/recordings/` downloads. (See IT-HANDOFF-PROXY.md.)
103
+- Env vars: `TURN_URLS`, `TURN_USERNAME`, `TURN_CREDENTIAL` (Metered TURN),
104
+  `SSO_SECRET`, `BIZGAZE_WEBHOOK_URL`, `BIZGAZE_LOGIN_URL` (identity provider for `/api/login`),
105
+  `ALLOW_REGISTRATION`, `DB_PATH`, `PORT`, `HTTPS_PORT`.
106
+
107
+## In progress / roadmap
108
+1. **SSO with BizGaze** (active): BizGaze becomes the identity provider. It issues a
109
+   signed token; `/sso` verifies it and creates a local session. Supports both
110
+   "from inside BizGaze" and a "Log in with BizGaze" button at our URL. Waiting on
111
+   the dev team for: shared secret, token format (JWT preferred), SSO start URL,
112
+   signup URL, role mapping. (See BizGaze-Connect-SSO-SPEC.md.)
113
+2. **New post-login home (NEXT TASK)** — see below.
114
+3. **Persistent chat** (Slack-style 1:1 + group messaging between registered users) — large new system.
115
+4. **Meetings** (multi-party video) — large new system (needs SFU or mesh).
116
+5. **Downloadable Android app** — the only way to support phone screen-sharing.
117
+
118
+## NEXT TASK: new post-login home (start with a mockup)
119
+After login, replace the current dashboard with a BizGaze Connect "home":
120
+- **Left sidebar (Slack-style):** list of recent chats/contacts with avatar,
121
+  name, last-message preview, unread badge. (Mock data first — no chat backend yet.)
122
+- **Main area with tabs:** **Meeting** (placeholder "coming soon"), **Share Screen**
123
+  (links to the existing share flow), **Connect Screen** (existing agent connect flow).
124
+- Top bar: BizGaze Connect wordmark (brand blue #1F3B73 / yellow #FFC708, logo.png),
125
+  profile dropdown (existing pattern in the HTML).
126
+- Build a **standalone static mockup first** (e.g. `public/home-mockup.html`) to lock
127
+  the layout, then wire the real tabs/sidebar. Keep the single-file, no-framework style.
128
+
129
+## Conventions
130
+- Brand: blue `#1F3B73`, yellow `#FFC708`, logo at `/logo.png`.
131
+- Single-file HTML pages; reuse the existing `profileHTML()`/`wireProfile()` and
132
+  brand patterns already in console.html/connect.html.
133
+- Always `node --check` extracted inline scripts after edits; test against a local
134
+  `node server.js` before committing.

+ 43
- 0
server/bizgaze.js Bestand weergeven

@@ -0,0 +1,43 @@
1
+// BizGaze as identity provider.
2
+// Validates a username/password against BizGaze's ValidateAndLogin endpoint.
3
+// Enabled only when BIZGAZE_LOGIN_URL is set (so tests/local runs stay self-contained).
4
+//
5
+// Success response shape (observed):
6
+//   { status: 1, currentSession: { name, userId, tenantId, unibaseId, isAdmin, ... }, message }
7
+// Failure: status !== 1, with a `message`.
8
+
9
+function loginUrl() { return process.env.BIZGAZE_LOGIN_URL || ''; }
10
+const isEnabled = () => !!loginUrl();
11
+
12
+async function validateLogin(username, password) {
13
+  const url = loginUrl();
14
+  if (!url) return { ok: false, configured: false };
15
+  let res;
16
+  try {
17
+    res = await fetch(url, {
18
+      method: 'POST',
19
+      headers: { 'Content-Type': 'application/json' },
20
+      body: JSON.stringify({ UserName: username, Password: password, UnibaseId: '', RememberMe: false }),
21
+      signal: AbortSignal.timeout(15000),
22
+    });
23
+  } catch (e) {
24
+    return { ok: false, configured: true, error: 'BizGaze sign-in is unavailable right now' };
25
+  }
26
+  let data;
27
+  try { data = await res.json(); } catch { return { ok: false, configured: true, error: 'Unexpected response from BizGaze' }; }
28
+  const s = data && data.currentSession;
29
+  if (data && data.status === 1 && s) {
30
+    return {
31
+      ok: true, configured: true,
32
+      name: s.name || null,
33
+      isAdmin: !!s.isAdmin,
34
+      tenantRef: s.tenantId != null ? String(s.tenantId) : null,  // BizGaze tenant (org) id
35
+      bizgazeUserId: s.userId != null ? String(s.userId) : null,
36
+      unibaseId: s.unibaseId || null,
37
+      message: data.message || 'Login Success',
38
+    };
39
+  }
40
+  return { ok: false, configured: true, message: (data && data.message) || 'Invalid BizGaze credentials' };
41
+}
42
+
43
+module.exports = { validateLogin, isEnabled };

+ 18
- 0
server/config.js Bestand weergeven

@@ -0,0 +1,18 @@
1
+// Runtime config + filesystem paths. Reads process.env once at startup.
2
+const fs = require('fs');
3
+const path = require('path');
4
+
5
+const PUBLIC_DIR = path.join(__dirname, 'public');
6
+const REC_DIR = path.join(__dirname, 'recordings');
7
+const TRANS_DIR = path.join(__dirname, 'transcripts');
8
+try { fs.mkdirSync(REC_DIR, { recursive: true }); } catch (e) {}
9
+try { fs.mkdirSync(TRANS_DIR, { recursive: true }); } catch (e) {}
10
+
11
+module.exports = {
12
+  PORT: process.env.PORT || 8090,
13
+  HTTPS_PORT: process.env.HTTPS_PORT || 8443,
14
+  PUBLIC_DIR,
15
+  REC_DIR,
16
+  TRANS_DIR,
17
+  SESSION_TTL: 1000 * 60 * 60 * 24, // 24h auto-logout
18
+};

+ 28
- 0
server/lib.js Bestand weergeven

@@ -0,0 +1,28 @@
1
+// Small HTTP helpers shared across the server.
2
+const now = () => Date.now();
3
+
4
+const json = (res, code, body) => {
5
+  res.writeHead(code, { 'Content-Type': 'application/json' });
6
+  res.end(JSON.stringify(body));
7
+};
8
+
9
+function readBody(req) {
10
+  return new Promise((resolve) => {
11
+    let data = '';
12
+    req.on('data', (c) => (data += c));
13
+    req.on('end', () => {
14
+      try { resolve(data ? JSON.parse(data) : {}); } catch { resolve({}); }
15
+    });
16
+  });
17
+}
18
+
19
+function parseCookies(req) {
20
+  const out = {};
21
+  (req.headers.cookie || '').split(';').forEach((c) => {
22
+    const [k, ...v] = c.trim().split('=');
23
+    if (k) out[k] = decodeURIComponent(v.join('='));
24
+  });
25
+  return out;
26
+}
27
+
28
+module.exports = { now, json, readBody, parseCookies };

+ 7
- 0
server/presence.js Bestand weergeven

@@ -0,0 +1,7 @@
1
+// In-memory live state shared between the HTTP routes and the WebSocket signaling layer.
2
+// NOTE (roadmap): this is the piece that must move to Redis to run multiple instances.
3
+module.exports = {
4
+  onlineAgents: new Map(),   // machineId -> { ws, machine }
5
+  liveSessions: new Map(),   // sessionId -> { agentWs, viewerWs, machine, user }
6
+  pendingShares: new Map(),  // code -> { sharerWs, sessionId } (no-install ad-hoc shares)
7
+};

+ 29
- 5
server/public/connect.html Bestand weergeven

@@ -43,9 +43,20 @@
43 43
   .pwwrap input{padding-right:2.7rem;}
44 44
   .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;}
45 45
   .eye:hover{color:var(--blue);}
46
+  #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);}
47
+  .formerr{color:#b91c1c;font-weight:600;font-size:.88rem;margin-top:.9rem;min-height:1.1em;text-align:left;}
48
+  .formerr.show{display:flex;align-items:center;gap:.5rem;background:#fee2e2;border:1px solid #fca5a5;border-radius:9px;padding:.6rem .75rem;animation:errShake .35s;}
49
+  .formerr.show::before{content:"⚠";font-size:1rem;}
50
+  @keyframes errShake{0%,100%{transform:translateX(0)}20%,60%{transform:translateX(-5px)}40%,80%{transform:translateX(5px)}}
51
+  /* Embedded inside the home shell: hide own chrome (the shell provides it). */
52
+  html.embed .topbar{display:none!important;}
53
+  html.embed #homeLink{display:none!important;}
54
+  html.embed #video{height:100vh!important;}
46 55
 </style>
47 56
 </head>
48 57
 <body>
58
+<script>if(new URLSearchParams(location.search).get('embed')==='1')document.documentElement.classList.add('embed');</script>
59
+<a href="/home" id="homeLink">&#8592; Home</a>
49 60
 <div class="topbar" id="topbar">
50 61
   <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>
51 62
   <div class="agentchip" id="agentChip"></div>
@@ -60,7 +71,10 @@ const IS_MOBILE=/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|M
60 71
 let __icePromise=Promise.resolve();try{__icePromise=fetch('/api/ice').then(r=>r.ok?r.json():null).then(c=>{if(c&&c.iceServers&&IS_MOBILE)ICE=c;}).catch(()=>{});}catch(_){}
61 72
 async function ensureIce(){try{await __icePromise;}catch(_){}return ICE;}
62 73
 function pEsc(s){return String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));}
63
-function profileHTML(name){return '<div class="profile"><button class="pbtn" id="pbtn">'+pEsc(name)+' <span style="font-size:.65rem">&#9662;</span></button><div class="pmenu" id="pmenu"><a href="/console">Console / Dashboard</a><a id="plogout" class="danger">Logout</a></div></div>';}
74
+// When embedded in the home shell, tell the parent when a session is live so the
75
+// rail can show a "return here" indicator.
76
+function bzcSession(active){try{if(window.parent&&window.parent!==window)window.parent.postMessage({type:'bzc-session',flow:'connect',active:!!active},location.origin);}catch(_){}}
77
+function profileHTML(name){return '<div class="profile"><button class="pbtn" id="pbtn">'+pEsc(name)+' <span style="font-size:.65rem">&#9662;</span></button><div class="pmenu" id="pmenu"><a href="/home">Home</a><a href="/dashboard">Dashboard</a><a id="plogout" class="danger">Logout</a></div></div>';}
64 78
 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='/';};}
65 79
 function makeBrandClickable(){document.querySelectorAll('.brandrow,.wordmark').forEach(el=>{el.style.cursor='pointer';el.addEventListener('click',()=>{location.href='/';});});}
66 80
 makeBrandClickable();
@@ -99,13 +113,14 @@ function renderLogin(){
99 113
     <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>
100 114
     <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>
101 115
     <button class="btn" id="loginBtn" style="width:100%">Sign in</button>
102
-    <div class="status err" id="err"></div>`;
116
+    <div class="formerr" id="err"></div>`;
103 117
   {
104 118
     const doSignIn=async()=>{
119
+      const errEl=document.getElementById('err'); errEl.textContent=''; errEl.classList.remove('show');
105 120
       try{
106 121
         await api('/api/login',{email:document.getElementById('email').value,password:document.getElementById('pw').value,remember:(document.getElementById('rememberMe')||{}).checked||false});
107 122
         me=await api('/api/me',null,'GET'); renderAgent();
108
-      }catch(e){ document.getElementById('err').textContent=e.message; }
123
+      }catch(e){ errEl.textContent=/invalid credentials/i.test(e.message)?'Incorrect email or password. Please try again.':e.message; errEl.classList.add('show'); }
109 124
     };
110 125
     document.getElementById('loginBtn').onclick=doSignIn;
111 126
     onEnter(['email','pw'], doSignIn);
@@ -173,11 +188,13 @@ function renderWaiting(){
173 188
 }
174 189
 
175 190
 function renderEnded(msg){
191
+  bzcSession(false);
176 192
   try{ stopRecording(); }catch(_){}
177 193
   removeSessionUI();
178 194
   if(pc){ try{pc.close();}catch(e){} pc=null; }
179 195
   video.style.display='none'; bar.classList.remove('show');
180 196
   topbar.style.display='flex'; wrap.style.display='grid';
197
+  { const hl=document.getElementById('homeLink'); if(hl && !document.documentElement.classList.contains('embed')) hl.style.display=''; }
181 198
   card.innerHTML=`
182 199
     <h1>Session ended</h1>
183 200
     <div class="sub">${esc(msg)}</div>
@@ -251,12 +268,17 @@ function startRecording(){
251 268
     const mixed=new MediaStream();
252 269
     mixed.addTrack(remote.getVideoTracks()[0]);
253 270
     dest.stream.getAudioTracks().forEach(t=>mixed.addTrack(t));
254
-    let mime='video/webm;codecs=vp8,opus'; if(!(window.MediaRecorder&&MediaRecorder.isTypeSupported(mime))) mime='video/webm';
271
+    // Prefer MP4 (H.264/AAC) — playable by most tools (Windows Media Player, QuickTime,
272
+    // WhatsApp, etc.). Fall back to WebM only if the browser can't record MP4.
273
+    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'];
274
+    let mime='video/webm'; for(const t of REC_TYPES){ if(window.MediaRecorder&&MediaRecorder.isTypeSupported(t)){ mime=t; break; } }
275
+    const recExt = mime.indexOf('mp4')!==-1 ? 'mp4' : 'webm';
276
+    const recBlobType = mime.indexOf('mp4')!==-1 ? 'video/mp4' : 'video/webm';
255 277
     recChunks=[];
256 278
     mediaRecorder=new MediaRecorder(mixed,{mimeType:mime});
257 279
     mediaRecorder.ondataavailable=(e)=>{ if(e.data&&e.data.size) recChunks.push(e.data); };
258 280
     mediaRecorder.onstop=async()=>{
259
-      try{ const blob=new Blob(recChunks,{type:'video/webm'}); if(blob.size) await fetch('/api/recording?sessionId='+encodeURIComponent(sessionId),{method:'POST',headers:{'Content-Type':'video/webm'},body:blob}); }catch(_){}
281
+      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(_){}
260 282
       try{recCtx&&recCtx.close();}catch(_){} recCtx=null;
261 283
     };
262 284
     mediaRecorder.start(1000);
@@ -277,6 +299,8 @@ function stopRecording(){
277 299
 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;}
278 300
 function buildBar(){
279 301
   if(document.getElementById('sessionBar'))return;
302
+  { const hl=document.getElementById('homeLink'); if(hl) hl.style.display='none'; }
303
+  bzcSession(true);
280 304
   const bar=document.createElement('div'); bar.id='sessionBar';
281 305
   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)';
282 306
   const mic=_btn('micBtn',SVG_MIC,'Mic','#2563eb');

server/public/console.html → server/public/dashboard.html Bestand weergeven

@@ -3,7 +3,7 @@
3 3
 <head>
4 4
 <meta charset="UTF-8">
5 5
 <meta name="viewport" content="width=device-width, initial-scale=1">
6
-<title>BizGaze Support — Staff Console</title>
6
+<title>BizGaze Connect — Dashboard</title>
7 7
 <style>
8 8
   :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; }
9 9
   *{box-sizing:border-box;}
@@ -11,8 +11,7 @@
11 11
   header{background:var(--blue);padding:.75rem 1.5rem;display:flex;justify-content:space-between;align-items:center;}
12 12
   .brandrow{display:flex;align-items:center;gap:.6rem;}
13 13
   .logo{width:30px;height:30px;border-radius:8px;background:var(--brand);display:grid;place-items:center;font-weight:800;color:var(--blue);}
14
-  .brand{font-weight:700;color:#fff;font-size:1.05rem;} .brand span{color:var(--brand);font-weight:600;}
15
-  .who{color:#dbe4f5;font-size:.85rem;margin-right:.7rem;}
14
+  .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;}
16 15
   main{max-width:1020px;margin:1.8rem auto;padding:0 1rem;}
17 16
   .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);}
18 17
   h2{font-size:1rem;margin:0 0 1rem;color:var(--blue);}
@@ -20,27 +19,19 @@
20 19
   input:focus,select:focus{outline:none;border-color:var(--brand);}
21 20
   button{padding:.6rem 1.1rem;background:var(--brand);color:var(--ink);border:none;border-radius:10px;font-weight:700;cursor:pointer;font-size:.92rem;}
22 21
   button:hover{background:var(--brand-d);}
23
-  button.ghost{background:transparent;color:#dbe4f5;border:1px solid #46598c;font-weight:600;}
24
-  button.ghost:hover{background:var(--blue-d);}
25 22
   button.mini{padding:.32rem .6rem;font-size:.76rem;font-weight:600;background:#eef1f6;color:var(--blue);border:1px solid var(--line);}
26 23
   button.mini:hover{background:var(--blue-soft);}
27
-  button.mini.danger{color:var(--red);}
28 24
   .row{display:flex;gap:.5rem;align-items:center;}
29 25
   .muted{color:var(--muted);font-size:.85rem;}
30 26
   table{width:100%;border-collapse:collapse;font-size:.88rem;}
31 27
   th{color:var(--muted);font-weight:600;font-size:.76rem;text-transform:uppercase;letter-spacing:.04em;}
32 28
   th,td{text-align:left;padding:.55rem .5rem;border-bottom:1px solid var(--line);}
33 29
   .pill{font-size:.74rem;font-weight:600;padding:.15rem .55rem;border-radius:99px;}
34
-  .pill.on{background:#ecfdf3;color:#15803d;} .pill.off{background:#fee2e2;color:var(--red);}
30
+  .pill.on{background:#ecfdf3;color:#15803d;}
35 31
   .hidden{display:none;}
36 32
   .tabs{display:flex;gap:.5rem;margin-bottom:1.2rem;}
37 33
   .tabs button{background:#eef1f6;color:var(--muted);font-weight:600;}
38 34
   .tabs button.active{background:var(--blue);color:#fff;}
39
-  .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;}
40
-  .quick h2{color:#fff;margin:0 0 .25rem;}
41
-  .quick p{margin:0;color:#cdd7ee;font-size:.88rem;}
42
-  .quick a{background:var(--brand);color:var(--ink);text-decoration:none;font-weight:700;padding:.7rem 1.3rem;border-radius:10px;white-space:nowrap;}
43
-  .quick a:hover{background:var(--brand-d);}
44 35
   .lbl{display:block;font-size:.74rem;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;margin:.7rem 0 .15rem;}
45 36
   .filters{display:flex;gap:.6rem;align-items:flex-end;flex-wrap:wrap;margin-bottom:1rem;}
46 37
   .filters .f{flex:1;min-width:140px;}
@@ -50,15 +41,26 @@
50 41
   .pager button{padding:.32rem .7rem;font-size:.8rem;font-weight:600;background:#eef1f6;color:var(--blue);border:1px solid var(--line);}
51 42
   .pager button:hover:not(:disabled){background:var(--blue-soft);}
52 43
   .pager button:disabled{opacity:.4;cursor:default;}
53
-  .pwwrap{position:relative;}
54
-  .pwwrap input{padding-right:2.6rem;}
44
+  .stats{display:flex;gap:1rem;flex-wrap:wrap;margin-bottom:1.25rem;}
45
+  .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);}
46
+  .stat .v{font-size:1.7rem;font-weight:800;color:var(--blue);line-height:1.1;}
47
+  .stat .k{font-size:.78rem;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin-top:.2rem;}
48
+  .formerr{color:var(--red);font-weight:600;font-size:.88rem;margin:.7rem 0 0;min-height:1em;}
49
+  .formerr.show{display:flex;align-items:center;gap:.5rem;background:#fee2e2;border:1px solid #fca5a5;border-radius:9px;padding:.6rem .75rem;animation:errShake .35s;}
50
+  .formerr.show::before{content:"⚠";font-size:1rem;}
51
+  @keyframes errShake{0%,100%{transform:translateX(0)}20%,60%{transform:translateX(-5px)}40%,80%{transform:translateX(5px)}}
52
+  .pwwrap{position:relative;} .pwwrap input{padding-right:2.6rem;}
55 53
   .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;}
56 54
   .eye:hover{background:none;color:var(--blue);}
57 55
   .profile{position:relative}
58
-  .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}
56
+  .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}
59 57
   .profile .pbtn:hover{background:rgba(255,255,255,.24)}
60
-  .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}
58
+  .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}
59
+  .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}
61 60
   .profile .pmenu.open{display:block}
61
+  .profile .pmenu .phead{padding:.7rem .9rem;border-bottom:1px solid #eef1f6}
62
+  .profile .pmenu .phead .n{font-weight:700;font-size:.9rem}
63
+  .profile .pmenu .phead .e{color:var(--muted);font-size:.78rem}
62 64
   .profile .pmenu a{display:block;padding:.6rem .9rem;color:#1f2430;text-decoration:none;font-size:.9rem;cursor:pointer}
63 65
   .profile .pmenu a:hover{background:#f1f5f9}
64 66
   .profile .pmenu a.danger{color:#b91c1c;border-top:1px solid #eef1f6}
@@ -66,7 +68,7 @@
66 68
 </head>
67 69
 <body>
68 70
 <header>
69
-  <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>
71
+  <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>
70 72
   <div class="row" id="hdrRight"></div>
71 73
 </header>
72 74
 <main id="app"></main>
@@ -77,10 +79,19 @@ const EYE_ON='<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke
77 79
 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>';}
78 80
 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;};});}
79 81
 function pEsc(s){return String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));}
80
-function profileHTML(name){return '<div class="profile"><button class="pbtn" id="pbtn">'+pEsc(name)+' <span style="font-size:.65rem">&#9662;</span></button><div class="pmenu" id="pmenu"><a href="/console">Console / Dashboard</a><a id="plogout" class="danger">Logout</a></div></div>';}
82
+function initials(name){const p=String(name||'?').trim().split(/\s+/);return ((p[0]||'?')[0]+(p[1]?p[1][0]:'')).toUpperCase();}
83
+function profileHTML(u){
84
+  const display=u.name||u.email;
85
+  return '<div class="profile"><button class="pbtn" id="pbtn">'
86
+    + '<span class="pav">'+pEsc(initials(display))+'</span>'
87
+    + pEsc(display)+' <span style="font-size:.65rem">&#9662;</span></button>'
88
+    + '<div class="pmenu" id="pmenu">'
89
+    + '<div class="phead"><div class="n">'+pEsc(display)+'</div><div class="e">'+pEsc(u.email)+(u.role?' · '+pEsc(u.role):'')+'</div></div>'
90
+    + '<a href="/home">Home</a>'
91
+    + '<a class="danger" id="plogout">Logout</a>'
92
+    + '</div></div>';
93
+}
81 94
 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='/';};}
82
-function makeBrandClickable(){document.querySelectorAll('.brandrow,.wordmark').forEach(el=>{el.style.cursor='pointer';el.addEventListener('click',()=>{location.href='/';});});}
83
-makeBrandClickable();
84 95
 const app = document.getElementById('app');
85 96
 const hdrRight = document.getElementById('hdrRight');
86 97
 
@@ -92,23 +103,20 @@ async function api(path, body, method = 'POST') {
92 103
   if (!r.ok) throw new Error(data.error || 'request failed');
93 104
   return data;
94 105
 }
95
-
96
-
97 106
 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(); } }); }); }
98
-
99 107
 function view(html) { app.innerHTML = html; }
100 108
 
101
-// ---------- Auth ----------
109
+// ---------- Auth (login lives here; on success → home) ----------
102 110
 async function authView() {
103 111
   hdrRight.innerHTML = '';
104 112
   let regOpen = false;
105 113
   try { regOpen = (await api('/api/setup-state', null, 'GET')).registrationOpen; } catch {}
106 114
   view(`
107 115
     <div class="card" style="max-width:420px;margin:3rem auto">
108
-      <div class="tabs">
116
+      ${regOpen ? `<div class="tabs">
109 117
         <button id="tabLogin" class="active">Sign in</button>
110
-        ${regOpen ? '<button id="tabReg">Register team</button>' : ''}
111
-      </div>
118
+        <button id="tabReg">Register team</button>
119
+      </div>` : ''}
112 120
       <div id="loginForm">
113 121
         <span class="lbl">Email</span>
114 122
         <input id="li_email" placeholder="you@bizgaze.com" type="email">
@@ -116,7 +124,7 @@ async function authView() {
116 124
         ${pwField("li_pw","password")}
117 125
         <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>
118 126
         <button id="li_btn" style="width:100%;margin-top:.5rem">Sign in</button>
119
-        <p id="li_err" class="muted"></p>
127
+        <p id="li_err" class="formerr"></p>
120 128
       </div>
121 129
       ${regOpen ? `<div id="regForm" class="hidden">
122 130
         <span class="lbl">Team name</span>
@@ -126,7 +134,7 @@ async function authView() {
126 134
         <span class="lbl">Password</span>
127 135
         ${pwField("rg_pw","min 8 characters")}
128 136
         <button id="rg_btn" style="width:100%;margin-top:1rem">Create team</button>
129
-        <p id="rg_err" class="muted"></p>
137
+        <p id="rg_err" class="formerr"></p>
130 138
       </div>` : ''}
131 139
     </div>`);
132 140
   document.getElementById('li_btn').onclick = doLogin;
@@ -145,86 +153,56 @@ async function authView() {
145 153
     const rt = document.getElementById('tabReg'); if (rt) rt.classList.toggle('active', !login);
146 154
   }
147 155
 }
148
-
156
+function showErr(id, msg) { const el = document.getElementById(id); el.textContent = msg; el.classList.add('show'); }
157
+function clearErr(id) { const el = document.getElementById(id); el.textContent = ''; el.classList.remove('show'); }
149 158
 async function doLogin() {
159
+  clearErr('li_err');
150 160
   try {
151 161
     const rem = document.getElementById('li_remember');
152 162
     await api('/api/login', { email: li_email.value, password: li_pw.value, remember: rem ? rem.checked : false });
153
-    location.reload();
154
-  } catch (e) { li_err.textContent = e.message; }
163
+    location.href = '/home';
164
+  } catch (e) {
165
+    showErr('li_err', /invalid credentials/i.test(e.message) ? 'Incorrect email or password. Please try again.' : e.message);
166
+  }
155 167
 }
156
-
157 168
 async function doRegister() {
169
+  clearErr('rg_err');
158 170
   try {
159 171
     await api('/api/register', { email: rg_email.value, password: rg_pw.value, teamName: rg_team.value });
160 172
     await api('/api/login', { email: rg_email.value, password: rg_pw.value });
161
-    location.reload();
162
-  } catch (e) { rg_err.textContent = e.message; }
173
+    location.href = '/home';
174
+  } catch (e) { showErr('rg_err', e.message); }
163 175
 }
164 176
 
165 177
 // ---------- Dashboard ----------
166
-let ME = null;
178
+let ME = null, IS_ADMIN = false;
167 179
 async function dashboard(me) {
168
-  ME = me;
169
-  hdrRight.innerHTML = profileHTML((me.name||me.email)+' · '+me.role); wireProfile();
180
+  ME = me; IS_ADMIN = (me.role === 'admin');
181
+  hdrRight.innerHTML = profileHTML(me); wireProfile();
170 182
   view(`
171
-    <div class="card quick">
172
-      <div><h2>Start a support session</h2><p>Customer gives you their 6-digit code from the share page.</p></div>
173
-      <a href="/connect">Open connect page →</a>
174
-    </div>
175
-    <div class="card" id="agentsCard">
176
-      <h2>Agents</h2>
177
-      <input id="agSearch" class="srch" placeholder="Search agents by name or email">
178
-      <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>
179
-      <div id="agPager" class="pager"></div>
180
-      <div class="row" style="margin-top:1rem;flex-wrap:wrap">
181
-        <input id="agEmail" placeholder="agent email" style="max-width:200px">
182
-        <input id="agName" placeholder="display name (from BizGaze)" style="max-width:210px">
183
-        <input id="agPw" placeholder="temporary password" style="max-width:170px">
184
-        <select id="agRole" style="max-width:140px">
185
-          <option value="technician">technician</option><option value="admin">admin</option><option value="viewer">view-only</option>
186
-        </select>
187
-        <button id="agAdd">Add agent</button>
188
-      </div>
189
-      <p id="agOut" class="muted"></p>
190
-    </div>
183
+    <div class="stats" id="stats"></div>
191 184
     <div class="card">
192
-      <h2>Session report</h2>
185
+      <h2>${IS_ADMIN ? 'Connection report — all agents' : 'My connection report'}</h2>
193 186
       <div class="filters">
194
-        <div class="f"><span class="lbl">Agent</span><select id="fAgent"><option value="">All agents</option></select></div>
187
+        ${IS_ADMIN ? '<div class="f"><span class="lbl">Agent</span><select id="fAgent"><option value="">All agents</option></select></div>' : ''}
195 188
         <div class="f"><span class="lbl">From</span><input id="fFrom" type="date"></div>
196 189
         <div class="f"><span class="lbl">To</span><input id="fTo" type="date"></div>
197 190
         <button id="fApply">Apply</button>
198 191
         <button id="fExcel" class="mini" style="padding:.6rem .9rem">⬇ Excel</button>
199 192
         <button id="fPdf" class="mini" style="padding:.6rem .9rem">⬇ PDF</button>
200 193
       </div>
201
-      <table id="report"><thead><tr><th>Date</th><th>Start time</th><th>Agent</th><th>Ticket</th><th>Time spent</th><th>Recording / Transcript</th></tr></thead><tbody></tbody></table>
194
+      ${IS_ADMIN ? '<input id="repSearch" class="srch" placeholder="Search by agent or ticket">' : ''}
195
+      <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>
202 196
       <div id="repPager" class="pager"></div>
203 197
       <p id="repSummary" class="muted" style="margin-top:.6rem"></p>
204 198
     </div>`);
205
-
206
-  if (me.role !== 'admin') document.getElementById('agentsCard').style.display = 'none';
207
-  else {
208
-    document.getElementById('agAdd').onclick = addAgent;
209
-    onEnter(['agEmail','agName','agPw'], addAgent);
210
-    await loadAgents();
211
-  }
212 199
   document.getElementById('fApply').onclick = loadReport;
213 200
   document.getElementById('fExcel').onclick = exportExcel;
214 201
   document.getElementById('fPdf').onclick = exportPdf;
215
-  await populateAgentFilter();
202
+  if (IS_ADMIN) await populateAgentFilter();
216 203
   await loadReport();
217 204
 }
218 205
 
219
-async function addAgent() {
220
-  try {
221
-    const r = await api('/api/users', { email: agEmail.value, name: agName.value, password: agPw.value, role: agRole.value });
222
-    agOut.textContent = `Agent ${r.email} added. Share the email + temporary password — they sign in at /connect.`;
223
-    agEmail.value = ''; agName.value = ''; agPw.value = '';
224
-    loadAgents(); populateAgentFilter();
225
-  } catch (e) { agOut.textContent = e.message; }
226
-}
227
-
228 206
 const PER_PAGE = 5;
229 207
 function pagerHTML(page, pages, total, fn){
230 208
   if (total <= PER_PAGE) return total ? `<span>${total} total</span>` : '';
@@ -232,69 +210,15 @@ function pagerHTML(page, pages, total, fn){
232 210
        + `<span>Page ${page} of ${pages} · ${total} total</span>`
233 211
        + `<button ${page>=pages?'disabled':''} onclick="${fn}(${page+1})">Next ›</button>`;
234 212
 }
235
-let AGENTS_ALL = [], agentPage = 1, agentSearch = '';
236
-function agentRowHTML(u){ return `
237
-    <tr>
238
-      <td>${esc(u.email)}</td><td>${esc(u.name || '—')}</td><td>${esc(u.role)}</td>
239
-      <td><span class="pill ${u.active === 0 ? 'off' : 'on'}">${u.active === 0 ? 'deactivated' : 'active'}</span></td>
240
-      <td>
241
-        <button class="mini" onclick="resetPw('${u.id}','${esc(u.email)}')">Reset password</button>
242
-        <button class="mini" onclick="renameAgent('${u.id}','${esc(u.email)}')">Edit name</button>
243
-        ${u.id === ME.id ? '' : (u.active === 0
244
-          ? `<button class="mini" onclick="manage('${u.id}','activate')">Activate</button>`
245
-          : `<button class="mini danger" onclick="manage('${u.id}','deactivate')">Deactivate</button>`)
246
-        }
247
-        ${u.id === ME.id ? '' : `<button class="mini danger" onclick="delAgent('${u.id}','${esc(u.email)}')">Delete</button>`}
248
-      </td>
249
-    </tr>`; }
250
-async function loadAgents() {
251
-  AGENTS_ALL = await api('/api/users', null, 'GET');
252
-  agentPage = 1;
253
-  const s = document.getElementById('agSearch');
254
-  if (s && !s._w){ s._w = 1; s.addEventListener('input', () => { agentSearch = s.value.trim().toLowerCase(); agentPage = 1; renderAgents(); }); }
255
-  renderAgents();
256
-}
257
-window.agentGo = (p) => { agentPage = p; renderAgents(); };
258
-function renderAgents(){
259
-  const all = agentSearch ? AGENTS_ALL.filter(u => ((u.name||'')+' '+(u.email||'')).toLowerCase().includes(agentSearch)) : AGENTS_ALL;
260
-  const pages = Math.max(1, Math.ceil(all.length / PER_PAGE));
261
-  if (agentPage > pages) agentPage = pages;
262
-  const slice = all.slice((agentPage-1)*PER_PAGE, (agentPage-1)*PER_PAGE + PER_PAGE);
263
-  document.querySelector('#agents tbody').innerHTML = slice.map(agentRowHTML).join('') || '<tr><td colspan=5 class="muted">No matching agents.</td></tr>';
264
-  document.getElementById('agPager').innerHTML = pagerHTML(agentPage, pages, all.length, 'agentGo');
265
-}
266 213
 
267
-window.resetPw = async (id, email) => {
268
-  const pw = prompt(`New password for ${email} (min 8 characters):`);
269
-  if (!pw) return;
270
-  try { await api('/api/users/manage', { id, action: 'reset-password', password: pw }); agOut.textContent = `Password reset for ${email}. They were signed out everywhere.`; }
271
-  catch (e) { agOut.textContent = e.message; }
272
-};
273
-window.renameAgent = async (id, email) => {
274
-  const name = prompt(`Display name for ${email} (as in the BizGaze app):`);
275
-  if (!name) return;
276
-  try { await api('/api/users/manage', { id, action: 'rename', name }); loadAgents(); }
277
-  catch (e) { agOut.textContent = e.message; }
278
-};
279
-window.manage = async (id, action) => {
280
-  try { await api('/api/users/manage', { id, action }); loadAgents(); }
281
-  catch (e) { agOut.textContent = e.message; }
282
-};
283
-window.delAgent = async (id, email) => {
284
-  if (!confirm(`Delete ${email}? This cannot be undone. (Tip: Deactivate keeps their history.)`)) return;
285
-  try { await api('/api/users/manage', { id, action: 'delete' }); loadAgents(); populateAgentFilter(); }
286
-  catch (e) { agOut.textContent = e.message; }
287
-};
288
-
289
-// ---------- Session report ----------
290 214
 async function populateAgentFilter() {
291 215
   try {
292 216
     const rows = await api('/api/users', null, 'GET');
293
-    const sel = document.getElementById('fAgent');
217
+    const sel = document.getElementById('fAgent'); if (!sel) return;
294 218
     const cur = sel.value;
295 219
     sel.innerHTML = '<option value="">All agents</option>' + rows.map(u => `<option value="${esc(u.email)}">${esc(u.name || u.email)}</option>`).join('');
296 220
     sel.value = cur;
297
-  } catch { /* non-admins cannot list agents; filter stays "All" */ }
221
+  } catch { /* non-admins cannot list agents */ }
298 222
 }
299 223
 
300 224
 function fmtDuration(ms) {
@@ -313,7 +237,7 @@ function reportRowHTML(r){
313 237
   return `<tr>
314 238
       <td>${d.toLocaleDateString()}</td>
315 239
       <td class="muted">${d.toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'})}</td>
316
-      <td>${esc(r.agent_name || r.agent_email || '—')}</td>
240
+      ${IS_ADMIN ? `<td>${esc(r.agent_name || r.agent_email || '—')}</td>` : ''}
317 241
       <td>${esc(r.ticket || 'Direct session')}</td>
318 242
       <td>${r.ended_at ? fmtDuration(dur) : '<span class="pill on">in progress</span>'}</td>
319 243
       <td>${[
@@ -324,27 +248,48 @@ function reportRowHTML(r){
324 248
 }
325 249
 async function loadReport() {
326 250
   const q = new URLSearchParams();
327
-  if (fAgent.value) q.set('agent', fAgent.value);
251
+  const fa = document.getElementById('fAgent');
252
+  if (fa && fa.value) q.set('agent', fa.value);
328 253
   if (fFrom.value) q.set('from', fFrom.value);
329 254
   if (fTo.value) q.set('to', fTo.value);
330 255
   REPORT_ROWS = await api('/api/report?' + q.toString(), null, 'GET');
331 256
   reportPage = 1;
257
+  const s = document.getElementById('repSearch');
258
+  if (s && !s._w){ s._w = 1; s.addEventListener('input', () => { reportSearch = s.value.trim().toLowerCase(); reportPage = 1; renderReport(); }); }
259
+  renderStats();
332 260
   renderReport();
333 261
 }
334 262
 window.reportGo = (p) => { reportPage = p; renderReport(); };
263
+function filteredRows(){
264
+  return reportSearch ? REPORT_ROWS.filter(r => ((r.agent_name||'')+' '+(r.agent_email||'')+' '+(r.ticket||'')).toLowerCase().includes(reportSearch)) : REPORT_ROWS;
265
+}
266
+function renderStats(){
267
+  const el = document.getElementById('stats'); if (!el) return;
268
+  const rows = REPORT_ROWS;
269
+  const total = rows.length;
270
+  const totalMs = rows.reduce((a, r) => a + (r.ended_at ? r.ended_at - r.started_at : 0), 0);
271
+  const recs = rows.filter(r => r.recording).length;
272
+  const cards = [
273
+    { v: total, k: IS_ADMIN ? 'Total sessions' : 'My sessions' },
274
+    { v: fmtDuration(totalMs), k: 'Time spent' },
275
+    { v: recs, k: 'Recorded' },
276
+  ];
277
+  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('');
278
+}
335 279
 function renderReport(){
336
-  const all = reportSearch ? REPORT_ROWS.filter(r => ((r.agent_name||'')+' '+(r.agent_email||'')+' '+(r.ticket||'')).toLowerCase().includes(reportSearch)) : REPORT_ROWS;
280
+  const all = filteredRows();
337 281
   const pages = Math.max(1, Math.ceil(all.length / PER_PAGE));
338 282
   if (reportPage > pages) reportPage = pages;
339 283
   const slice = all.slice((reportPage-1)*PER_PAGE, (reportPage-1)*PER_PAGE + PER_PAGE);
340
-  document.querySelector('#report tbody').innerHTML = slice.map(reportRowHTML).join('') || '<tr><td colspan=6 class="muted">No sessions match.</td></tr>';
284
+  const cols = IS_ADMIN ? 6 : 5;
285
+  document.querySelector('#report tbody').innerHTML = slice.map(reportRowHTML).join('') || `<tr><td colspan=${cols} class="muted">No sessions match.</td></tr>`;
341 286
   document.getElementById('repPager').innerHTML = pagerHTML(reportPage, pages, all.length, 'reportGo');
342 287
   const total = all.reduce((a, r) => a + (r.ended_at ? r.ended_at - r.started_at : 0), 0);
343 288
   repSummary.textContent = all.length ? `${all.length} session(s) · total time ${fmtDuration(total)}` : '';
344 289
 }
345 290
 
346 291
 function reportData() {
347
-  return REPORT_ROWS.map((r) => {
292
+  return filteredRows().map((r) => {
348 293
     const d = new Date(r.started_at);
349 294
     return {
350 295
       date: d.toLocaleDateString(), start: d.toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'}),
@@ -353,27 +298,27 @@ function reportData() {
353 298
     };
354 299
   });
355 300
 }
356
-
357 301
 function exportExcel() {
358 302
   const rows = reportData();
359 303
   if (!rows.length) { repSummary.textContent = 'Nothing to export for this period.'; return; }
360
-  const head = ['Date','Start time','Agent','Ticket','Time spent'];
304
+  const head = ['Date','Start time'].concat(IS_ADMIN ? ['Agent'] : []).concat(['Ticket','Time spent']);
361 305
   const csvCell = (v) => '"' + String(v).replace(/"/g, '""') + '"';
362
-  const csv = '\ufeff' + [head, ...rows.map(r => [r.date, r.start, r.agent, r.ticket, r.spent])]
306
+  const out = '' + [head, ...rows.map(r => [r.date, r.start].concat(IS_ADMIN ? [r.agent] : []).concat([r.ticket, r.spent]))]
363 307
     .map(line => line.map(csvCell).join(',')).join('\r\n');
364 308
   const a = document.createElement('a');
365
-  a.href = URL.createObjectURL(new Blob([csv], { type: 'text/csv;charset=utf-8' }));
366
-  a.download = 'session-report.csv';
309
+  a.href = URL.createObjectURL(new Blob([out], { type: 'text/csv;charset=utf-8' }));
310
+  a.download = 'connection-report.csv';
367 311
   a.click(); URL.revokeObjectURL(a.href);
368 312
 }
369
-
370 313
 function exportPdf() {
371 314
   const rows = reportData();
372 315
   if (!rows.length) { repSummary.textContent = 'Nothing to export for this period.'; return; }
373 316
   const period = (fFrom.value || 'start') + ' to ' + (fTo.value || 'today');
374
-  const agentSel = fAgent.value || 'All agents';
317
+  const fa = document.getElementById('fAgent');
318
+  const agentSel = IS_ADMIN ? (fa && fa.value || 'All agents') : (ME.name || ME.email);
375 319
   const w = window.open('', '_blank');
376
-  w.document.write('<html><head><title>Session report</title><style>' +
320
+  const headCells = ['Date','Start time'].concat(IS_ADMIN ? ['Agent'] : []).concat(['Ticket','Time spent']);
321
+  w.document.write('<html><head><title>Connection report</title><style>' +
377 322
     'body{font-family:Segoe UI,Arial,sans-serif;color:#1f2430;margin:32px}' +
378 323
     'h1{font-size:18px;color:#1F3B73;border-bottom:3px solid #FFC708;padding-bottom:6px}' +
379 324
     '.meta{color:#6b7280;font-size:12px;margin-bottom:14px}' +
@@ -381,10 +326,10 @@ function exportPdf() {
381 326
     'th{background:#1F3B73;color:#fff;text-align:left;padding:6px 8px}' +
382 327
     'td{padding:6px 8px;border-bottom:1px solid #e6e9ef}' +
383 328
     '</style></head><body>' +
384
-    '<h1>BizGaze Support — Session report</h1>' +
385
-    '<div class="meta">Agent: ' + esc(agentSel) + ' · Period: ' + esc(period) + ' · Generated ' + new Date().toLocaleString() + '</div>' +
386
-    '<table><tr><th>Date</th><th>Start time</th><th>Agent</th><th>Ticket</th><th>Time spent</th></tr>' +
387
-    rows.map(r => '<tr><td>' + [r.date, r.start, esc(r.agent), esc(r.ticket), r.spent].join('</td><td>') + '</td></tr>').join('') +
329
+    '<h1>BizGaze Connect — Connection report</h1>' +
330
+    '<div class="meta">' + esc(IS_ADMIN ? 'Agent: ' + agentSel : 'Agent: ' + agentSel) + ' · Period: ' + esc(period) + ' · Generated ' + new Date().toLocaleString() + '</div>' +
331
+    '<table><tr>' + headCells.map(h => '<th>' + esc(h) + '</th>').join('') + '</tr>' +
332
+    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('') +
388 333
     '</table><div class="meta" style="margin-top:12px">' + esc(repSummary.textContent) + '</div></body></html>');
389 334
   w.document.close();
390 335
   w.onload = () => { w.print(); };
@@ -393,9 +338,10 @@ function exportPdf() {
393 338
 function esc(s) { return String(s == null ? '' : s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c])); }
394 339
 
395 340
 // ---------- Boot ----------
341
+// Login lives on /home — send logged-out visitors there.
396 342
 (async function () {
397 343
   try { const me = await api('/api/me', null, 'GET'); dashboard(me); }
398
-  catch { authView(); }
344
+  catch { location.href = '/home'; }
399 345
 })();
400 346
 </script>
401 347
 </body>

+ 277
- 0
server/public/home-mockup.html Bestand weergeven

@@ -0,0 +1,277 @@
1
+<!DOCTYPE html>
2
+<html lang="en">
3
+<head>
4
+<meta charset="UTF-8">
5
+<meta name="viewport" content="width=device-width, initial-scale=1">
6
+<title>BizGaze Connect — Home</title>
7
+<style>
8
+  :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; }
9
+  *{box-sizing:border-box;}
10
+  html,body{height:100%;}
11
+  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;}
12
+
13
+  /* ---- Top bar (matches console.html) ---- */
14
+  header{background:var(--blue);padding:.75rem 1.5rem;display:flex;justify-content:space-between;align-items:center;flex:0 0 auto;}
15
+  .brandrow{display:flex;align-items:center;gap:.6rem;cursor:pointer;}
16
+  .logo{width:30px;height:30px;border-radius:8px;background:var(--brand);display:grid;place-items:center;font-weight:800;color:var(--blue);}
17
+  .brand{font-weight:700;color:#fff;font-size:1.05rem;} .brand span.y{color:var(--brand);font-weight:700;}
18
+  .brand span.tag{color:#8ea3cf;font-weight:500;font-size:.85rem;}
19
+
20
+  /* ---- Profile dropdown (from console.html) ---- */
21
+  .profile{position:relative}
22
+  .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}
23
+  .profile .pbtn:hover{background:rgba(255,255,255,.24)}
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}
25
+  .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}
26
+  .profile .pmenu.open{display:block}
27
+  .profile .pmenu .phead{padding:.7rem .9rem;border-bottom:1px solid #eef1f6}
28
+  .profile .pmenu .phead .n{font-weight:700;font-size:.9rem}
29
+  .profile .pmenu .phead .e{color:var(--muted);font-size:.78rem}
30
+  .profile .pmenu a{display:block;padding:.6rem .9rem;color:#1f2430;text-decoration:none;font-size:.9rem;cursor:pointer}
31
+  .profile .pmenu a:hover{background:#f1f5f9}
32
+  .profile .pmenu a.danger{color:#b91c1c;border-top:1px solid #eef1f6}
33
+
34
+  /* ---- Shell ---- */
35
+  .shell{flex:1 1 auto;display:flex;min-height:0;}
36
+
37
+  /* ---- Sidebar ---- */
38
+  .sidebar{width:320px;flex:0 0 320px;background:var(--card);border-right:1px solid var(--line);display:flex;flex-direction:column;min-height:0;}
39
+  .side-head{padding:1rem 1rem .75rem;border-bottom:1px solid var(--line);}
40
+  .side-title{display:flex;align-items:center;justify-content:space-between;margin-bottom:.7rem;}
41
+  .side-title h2{font-size:.95rem;margin:0;color:var(--blue);}
42
+  .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;}
43
+  .newchat:hover{background:#dbe6fb;}
44
+  .search{position:relative;}
45
+  .search svg{position:absolute;left:.65rem;top:50%;transform:translateY(-50%);color:var(--muted);}
46
+  .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;}
47
+  .search input:focus{outline:none;border-color:var(--brand);}
48
+
49
+  .chatlist{overflow-y:auto;flex:1 1 auto;padding:.4rem;}
50
+  .chat-row{display:flex;gap:.7rem;align-items:center;padding:.6rem .65rem;border-radius:12px;cursor:pointer;position:relative;}
51
+  .chat-row:hover{background:#f3f6fb;}
52
+  .chat-row.active{background:var(--blue-soft);}
53
+  .chat-row.active::before{content:"";position:absolute;left:0;top:.7rem;bottom:.7rem;width:3px;border-radius:3px;background:var(--blue);}
54
+  .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;}
55
+  .avatar .dot{position:absolute;right:-1px;bottom:-1px;width:11px;height:11px;border-radius:50%;border:2px solid #fff;background:#cbd2dd;}
56
+  .avatar .dot.on{background:var(--green);}
57
+  .chat-main{flex:1 1 auto;min-width:0;}
58
+  .chat-top{display:flex;justify-content:space-between;align-items:baseline;gap:.5rem;}
59
+  .chat-name{font-weight:600;font-size:.92rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
60
+  .chat-time{color:var(--muted);font-size:.72rem;flex:0 0 auto;}
61
+  .chat-bottom{display:flex;justify-content:space-between;align-items:center;gap:.5rem;margin-top:.15rem;}
62
+  .chat-prev{color:var(--muted);font-size:.82rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1 1 auto;}
63
+  .chat-row.unread .chat-prev{color:var(--ink);font-weight:500;}
64
+  .chat-row.unread .chat-name{font-weight:700;}
65
+  .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;}
66
+  .no-results{padding:2rem 1rem;text-align:center;color:var(--muted);font-size:.85rem;}
67
+
68
+  /* ---- Main content ---- */
69
+  .content{flex:1 1 auto;display:flex;flex-direction:column;min-width:0;min-height:0;}
70
+  .tabs{display:flex;gap:.4rem;padding:1rem 1.5rem 0;border-bottom:1px solid var(--line);background:var(--card);}
71
+  .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;}
72
+  .tabs button:hover{color:var(--blue);background:#f6f8fb;}
73
+  .tabs button.active{color:var(--blue);border-bottom-color:var(--brand);}
74
+  .panel-wrap{flex:1 1 auto;overflow-y:auto;padding:2rem 1.5rem;display:flex;}
75
+  .panel{display:none;margin:auto;width:100%;max-width:560px;}
76
+  .panel.active{display:block;}
77
+
78
+  .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;}
79
+  .feat-icon{width:72px;height:72px;border-radius:20px;display:grid;place-items:center;margin:0 auto 1.2rem;}
80
+  .feat-icon.blue{background:var(--blue-soft);color:var(--blue);}
81
+  .feat-icon.yellow{background:#fff6d8;color:var(--brand-d);}
82
+  .card h1{font-size:1.45rem;margin:0 0 .5rem;color:var(--blue);}
83
+  .card p{color:var(--muted);font-size:.95rem;line-height:1.55;margin:0 auto 1.6rem;max-width:400px;}
84
+  .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;}
85
+  .btn:hover{background:var(--brand-d);}
86
+  .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;}
87
+  .hint{margin-top:1.4rem;font-size:.8rem;color:var(--muted);}
88
+
89
+  @media (max-width:760px){
90
+    .sidebar{width:108px;flex:0 0 108px;}
91
+    .side-title h2,.search,.chat-main{display:none;}
92
+    .chat-row{justify-content:center;}
93
+    .side-head{padding:.8rem .5rem;}
94
+  }
95
+</style>
96
+</head>
97
+<body>
98
+<header>
99
+  <div class="brandrow" id="brandrow">
100
+    <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'}))">
101
+    <div class="brand">BizGaze <span class="y">Connect</span> <span class="tag">· Home</span></div>
102
+  </div>
103
+  <div id="hdrRight"></div>
104
+</header>
105
+
106
+<div class="shell">
107
+  <!-- ---------- Sidebar ---------- -->
108
+  <aside class="sidebar">
109
+    <div class="side-head">
110
+      <div class="side-title">
111
+        <h2>Chats</h2>
112
+        <button class="newchat" title="New chat" aria-label="New chat">+</button>
113
+      </div>
114
+      <div class="search">
115
+        <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>
116
+        <input id="chatSearch" placeholder="Search chats" autocomplete="off">
117
+      </div>
118
+    </div>
119
+    <div class="chatlist" id="chatlist"></div>
120
+  </aside>
121
+
122
+  <!-- ---------- Main ---------- -->
123
+  <section class="content">
124
+    <div class="tabs">
125
+      <button data-tab="meeting" class="active">
126
+        <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>
127
+        Meeting
128
+      </button>
129
+      <button data-tab="share">
130
+        <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>
131
+        Share Screen
132
+      </button>
133
+      <button data-tab="connect">
134
+        <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>
135
+        Connect Screen
136
+      </button>
137
+    </div>
138
+
139
+    <div class="panel-wrap">
140
+      <!-- Meeting -->
141
+      <div class="panel active" data-panel="meeting">
142
+        <div class="card">
143
+          <div class="feat-icon yellow">
144
+            <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>
145
+          </div>
146
+          <span class="pill-soon">COMING SOON</span>
147
+          <h1>Meetings are on the way</h1>
148
+          <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>
149
+          <button class="btn" id="notifyBtn">🔔 Notify me when it's ready</button>
150
+          <div class="hint">In the meantime, use <b>Share Screen</b> or <b>Connect Screen</b> to start a session.</div>
151
+        </div>
152
+      </div>
153
+
154
+      <!-- Share Screen -->
155
+      <div class="panel" data-panel="share">
156
+        <div class="card">
157
+          <div class="feat-icon blue">
158
+            <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>
159
+          </div>
160
+          <h1>Share your screen</h1>
161
+          <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>
162
+          <a class="btn" href="/share">Start sharing →</a>
163
+          <div class="hint">Desktop browsers only — phones can't share their screen yet.</div>
164
+        </div>
165
+      </div>
166
+
167
+      <!-- Connect Screen -->
168
+      <div class="panel" data-panel="connect">
169
+        <div class="card">
170
+          <div class="feat-icon blue">
171
+            <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>
172
+          </div>
173
+          <h1>Connect to a screen</h1>
174
+          <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>
175
+          <a class="btn" href="/connect">Open connect page →</a>
176
+          <div class="hint">The other person taps <b>Allow</b> before you can see anything.</div>
177
+        </div>
178
+      </div>
179
+    </div>
180
+  </section>
181
+</div>
182
+
183
+<script>
184
+// ---------- Helpers (reused patterns from console.html) ----------
185
+function pEsc(s){return String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));}
186
+function initials(name){return name.trim().split(/\s+/).slice(0,2).map(w=>w[0]).join('').toUpperCase();}
187
+
188
+// Stable avatar color from a name
189
+const AV_COLORS=['#1F3B73','#2563eb','#0e7490','#7c3aed','#be185d','#b45309','#15803d','#9d174d'];
190
+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];}
191
+
192
+// Profile dropdown (mirrors profileHTML()/wireProfile() from console.html)
193
+const SAMPLE_USER={name:'Sravan Mareddy',email:'sravanm@bizgaze.com',role:'admin'};
194
+function profileHTML(u){
195
+  return '<div class="profile"><button class="pbtn" id="pbtn">'
196
+    + '<span class="pav">'+pEsc(initials(u.name))+'</span>'
197
+    + pEsc(u.name)+' <span style="font-size:.65rem">&#9662;</span></button>'
198
+    + '<div class="pmenu" id="pmenu">'
199
+    + '<div class="phead"><div class="n">'+pEsc(u.name)+'</div><div class="e">'+pEsc(u.email)+' · '+pEsc(u.role)+'</div></div>'
200
+    + '<a href="/console">Console / Dashboard</a>'
201
+    + '<a href="#">Settings</a>'
202
+    + '<a class="danger" id="plogout">Logout</a>'
203
+    + '</div></div>';
204
+}
205
+function wireProfile(){
206
+  const btn=document.getElementById('pbtn'),menu=document.getElementById('pmenu');
207
+  if(!btn)return;
208
+  btn.onclick=(e)=>{e.stopPropagation();menu.classList.toggle('open');};
209
+  document.addEventListener('click',()=>menu.classList.remove('open'));
210
+  const lo=document.getElementById('plogout');
211
+  if(lo)lo.onclick=(e)=>{e.preventDefault();alert('Mockup — logout would sign you out and return to /.');};
212
+}
213
+document.getElementById('hdrRight').innerHTML=profileHTML(SAMPLE_USER);
214
+wireProfile();
215
+document.getElementById('brandrow').onclick=()=>{location.href='/';};
216
+
217
+// ---------- Mock chat data ----------
218
+const CHATS=[
219
+  {name:'Anwi Systems',     msg:"Perfect, the screen share worked great. Thanks!", time:'9:42 AM', unread:0, online:true,  active:true},
220
+  {name:'Priya Sharma',     msg:"Can you connect to my screen at 3pm?",           time:'9:15 AM', unread:2, online:true},
221
+  {name:'GAPL Group',       msg:"You: I've shared the 6-digit code with you",     time:'Yesterday', unread:0, online:false},
222
+  {name:'Battery Doctors',  msg:"The invoice module is throwing an error again",   time:'Yesterday', unread:5, online:true},
223
+  {name:'Ramesh Marketing', msg:"You: Let me know once you're at your desk",       time:'Mon',     unread:0, online:false},
224
+  {name:'STC Support',      msg:"Typing…",                                         time:'Mon',     unread:1, online:true},
225
+  {name:'Samruddhi Traders',msg:"Thanks for the help earlier 👍",                  time:'Sun',     unread:0, online:false},
226
+  {name:'DMS 3.0 Team',     msg:"You: Closing the ticket, all resolved",           time:'Fri',     unread:0, online:false},
227
+];
228
+
229
+const listEl=document.getElementById('chatlist');
230
+function chatRowHTML(c,i){
231
+  const cls=['chat-row'];
232
+  if(c.active)cls.push('active');
233
+  if(c.unread>0)cls.push('unread');
234
+  return '<div class="'+cls.join(' ')+'" data-i="'+i+'">'
235
+    + '<div class="avatar" style="background:'+avColor(c.name)+'">'+pEsc(initials(c.name))
236
+    +   '<span class="dot'+(c.online?' on':'')+'"></span></div>'
237
+    + '<div class="chat-main">'
238
+    +   '<div class="chat-top"><span class="chat-name">'+pEsc(c.name)+'</span><span class="chat-time">'+pEsc(c.time)+'</span></div>'
239
+    +   '<div class="chat-bottom"><span class="chat-prev">'+pEsc(c.msg)+'</span>'
240
+    +     (c.unread>0?'<span class="badge">'+c.unread+'</span>':'')+'</div>'
241
+    + '</div></div>';
242
+}
243
+function renderChats(filter){
244
+  const q=(filter||'').trim().toLowerCase();
245
+  const rows=CHATS.map((c,i)=>({c,i})).filter(({c})=>!q||c.name.toLowerCase().includes(q)||c.msg.toLowerCase().includes(q));
246
+  listEl.innerHTML = rows.length
247
+    ? rows.map(({c,i})=>chatRowHTML(c,i)).join('')
248
+    : '<div class="no-results">No chats match “'+pEsc(filter)+'”.</div>';
249
+  listEl.querySelectorAll('.chat-row').forEach(row=>{
250
+    row.onclick=()=>{
251
+      CHATS.forEach(c=>c.active=false);
252
+      CHATS[+row.dataset.i].active=true;
253
+      CHATS[+row.dataset.i].unread=0;
254
+      renderChats(document.getElementById('chatSearch').value);
255
+    };
256
+  });
257
+}
258
+renderChats('');
259
+document.getElementById('chatSearch').addEventListener('input',e=>renderChats(e.target.value));
260
+
261
+// ---------- Tab switching ----------
262
+const tabBtns=document.querySelectorAll('.tabs button');
263
+const panels=document.querySelectorAll('.panel');
264
+tabBtns.forEach(btn=>{
265
+  btn.onclick=()=>{
266
+    const tab=btn.dataset.tab;
267
+    tabBtns.forEach(b=>b.classList.toggle('active',b===btn));
268
+    panels.forEach(p=>p.classList.toggle('active',p.dataset.panel===tab));
269
+  };
270
+});
271
+
272
+// Mockup-only stubs
273
+document.querySelector('.newchat').onclick=()=>alert('Mockup — “New chat” would open the contact picker.');
274
+document.getElementById('notifyBtn').onclick=()=>alert("Thanks! We'll let you know when Meetings launches.");
275
+</script>
276
+</body>
277
+</html>

+ 478
- 0
server/public/home.html Bestand weergeven

@@ -0,0 +1,478 @@
1
+<!DOCTYPE html>
2
+<html lang="en">
3
+<head>
4
+<meta charset="UTF-8">
5
+<meta name="viewport" content="width=device-width, initial-scale=1">
6
+<title>BizGaze Connect</title>
7
+<style>
8
+  :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; }
9
+  *{box-sizing:border-box;}
10
+  html,body{height:100%;}
11
+  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;}
12
+
13
+  /* ---- Top bar ---- */
14
+  header{background:var(--blue);padding:.7rem 1.4rem;display:flex;justify-content:space-between;align-items:center;flex:0 0 auto;}
15
+  .brandrow{display:flex;align-items:center;gap:.6rem;}
16
+  .logo{width:30px;height:30px;border-radius:8px;background:var(--brand);display:grid;place-items:center;font-weight:800;color:var(--blue);}
17
+  .brand{font-weight:700;color:#fff;font-size:1.05rem;} .brand span.y{color:var(--brand);font-weight:700;}
18
+
19
+  /* ---- Profile dropdown (from console.html) ---- */
20
+  .profile{position:relative}
21
+  .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}
22
+  .profile .pbtn:hover{background:rgba(255,255,255,.24)}
23
+  .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}
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:210px;overflow:hidden;z-index:5000;display:none}
25
+  .profile .pmenu.open{display:block}
26
+  .profile .pmenu .phead{padding:.7rem .9rem;border-bottom:1px solid #eef1f6}
27
+  .profile .pmenu .phead .n{font-weight:700;font-size:.9rem}
28
+  .profile .pmenu .phead .e{color:var(--muted);font-size:.78rem}
29
+  .profile .pmenu a{display:block;padding:.6rem .9rem;color:#1f2430;text-decoration:none;font-size:.9rem;cursor:pointer}
30
+  .profile .pmenu a:hover{background:#f1f5f9}
31
+  .profile .pmenu a.danger{color:#b91c1c;border-top:1px solid #eef1f6}
32
+
33
+  /* ---- Shell ---- */
34
+  .shell{flex:1 1 auto;display:flex;min-height:0;}
35
+
36
+  /* ---- Icon rail ---- */
37
+  .rail{width:74px;flex:0 0 74px;background:var(--card);border-right:1px solid var(--line);display:flex;flex-direction:column;align-items:center;padding:.8rem 0;gap:.4rem;}
38
+  .railbtn{position:relative;width:50px;height:50px;border:none;background:transparent;border-radius:14px;color:var(--muted);cursor:pointer;display:grid;place-items:center;transition:background .12s,color .12s;}
39
+  .railbtn:hover{background:var(--blue-soft);color:var(--blue);}
40
+  .railbtn.active{background:var(--blue);color:#fff;}
41
+  .railbtn .rdot{position:absolute;top:8px;right:8px;min-width:16px;height:16px;border-radius:99px;background:var(--brand);color:var(--blue);font-size:.62rem;font-weight:800;display:grid;place-items:center;padding:0 .2rem;border:2px solid var(--card);}
42
+  .railbtn.active .rdot{border-color:var(--blue);}
43
+  .railbtn .livedot{position:absolute;top:8px;right:8px;width:11px;height:11px;border-radius:50%;background:var(--green);border:2px solid var(--card);display:none;}
44
+  .railbtn.active .livedot{border-color:var(--blue);}
45
+  .railbtn.live .livedot{display:block;animation:livePulse 1.4s infinite;}
46
+  @keyframes livePulse{0%,100%{opacity:1}50%{opacity:.3}}
47
+  .railbtn .rlabel{font-size:.6rem;margin-top:0;}
48
+  /* tooltip */
49
+  .railbtn::after{content:attr(data-tip);position:absolute;left:calc(100% + 12px);top:50%;transform:translateY(-50%);background:var(--blue-d);color:#fff;padding:.35rem .6rem;border-radius:8px;font-size:.78rem;font-weight:600;white-space:nowrap;opacity:0;pointer-events:none;transition:opacity .14s;z-index:200;box-shadow:0 6px 16px rgba(0,0,0,.25);}
50
+  .railbtn::before{content:"";position:absolute;left:calc(100% + 6px);top:50%;transform:translateY(-50%);border:6px solid transparent;border-right-color:var(--blue-d);opacity:0;pointer-events:none;transition:opacity .14s;z-index:200;}
51
+  .railbtn:hover::after,.railbtn:hover::before{opacity:1;}
52
+  .rail-spacer{flex:1 1 auto;}
53
+  .caption{font-size:.58rem;color:var(--muted);text-align:center;line-height:1.2;}
54
+
55
+  /* ---- Chat list column ---- */
56
+  .chatcol{width:312px;flex:0 0 312px;background:var(--card);border-right:1px solid var(--line);display:flex;flex-direction:column;min-height:0;}
57
+  .chatcol.hidden{display:none;}
58
+  .side-head{padding:1rem 1rem .75rem;border-bottom:1px solid var(--line);}
59
+  .side-title{display:flex;align-items:center;justify-content:space-between;margin-bottom:.7rem;}
60
+  .side-title h2{font-size:.95rem;margin:0;color:var(--blue);}
61
+  .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;}
62
+  .newchat:hover{background:#dbe6fb;}
63
+  .search{position:relative;}
64
+  .search svg{position:absolute;left:.65rem;top:50%;transform:translateY(-50%);color:var(--muted);}
65
+  .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;}
66
+  .search input:focus{outline:none;border-color:var(--brand);}
67
+
68
+  .chatlist{overflow-y:auto;flex:1 1 auto;padding:.4rem;}
69
+  .chat-row{display:flex;gap:.7rem;align-items:center;padding:.6rem .65rem;border-radius:12px;cursor:pointer;position:relative;}
70
+  .chat-row:hover{background:#f3f6fb;}
71
+  .chat-row.active{background:var(--blue-soft);}
72
+  .chat-row.active::before{content:"";position:absolute;left:0;top:.7rem;bottom:.7rem;width:3px;border-radius:3px;background:var(--blue);}
73
+  .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;}
74
+  .avatar .dot{position:absolute;right:-1px;bottom:-1px;width:11px;height:11px;border-radius:50%;border:2px solid #fff;background:#cbd2dd;}
75
+  .avatar .dot.on{background:var(--green);}
76
+  .chat-main{flex:1 1 auto;min-width:0;}
77
+  .chat-top{display:flex;justify-content:space-between;align-items:baseline;gap:.5rem;}
78
+  .chat-name{font-weight:600;font-size:.92rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
79
+  .chat-time{color:var(--muted);font-size:.72rem;flex:0 0 auto;}
80
+  .chat-bottom{display:flex;justify-content:space-between;align-items:center;gap:.5rem;margin-top:.15rem;}
81
+  .chat-prev{color:var(--muted);font-size:.82rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1 1 auto;}
82
+  .chat-row.unread .chat-prev{color:var(--ink);font-weight:500;}
83
+  .chat-row.unread .chat-name{font-weight:700;}
84
+  .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;}
85
+  .no-results{padding:2rem 1rem;text-align:center;color:var(--muted);font-size:.85rem;}
86
+  .demo-note{padding:.5rem 1rem;border-top:1px solid var(--line);color:var(--muted);font-size:.72rem;text-align:center;background:#fbfcfe;}
87
+
88
+  /* ---- Main content ---- */
89
+  .content{flex:1 1 auto;position:relative;min-width:0;min-height:0;background:var(--bg);}
90
+  .panel{position:absolute;inset:0;display:none;}
91
+  .panel.active{display:flex;}
92
+  .panel.center{align-items:center;justify-content:center;padding:2rem;overflow-y:auto;}
93
+  .panel iframe{width:100%;height:100%;border:0;display:block;background:var(--bg);}
94
+
95
+  /* welcome + feature cards */
96
+  .welcome{text-align:center;max-width:560px;}
97
+  .welcome .wave{font-size:3rem;line-height:1;margin-bottom:.4rem;}
98
+  .welcome h1{font-size:1.8rem;color:var(--blue);margin:.2rem 0 .5rem;}
99
+  .welcome p{color:var(--muted);font-size:1rem;line-height:1.6;margin:0 auto 1.8rem;max-width:440px;}
100
+  .wcards{display:flex;gap:1rem;flex-wrap:wrap;justify-content:center;}
101
+  .wcard{flex:1;min-width:150px;max-width:180px;background:var(--card);border:1px solid var(--line);border-radius:14px;padding:1.2rem 1rem;cursor:pointer;transition:transform .12s,box-shadow .12s,border-color .12s;text-align:center;}
102
+  .wcard:hover{transform:translateY(-3px);box-shadow:0 12px 28px rgba(20,30,60,.1);border-color:var(--brand);}
103
+  .wcard .wi{width:46px;height:46px;border-radius:12px;display:grid;place-items:center;margin:0 auto .6rem;background:var(--blue-soft);color:var(--blue);}
104
+  .wcard h3{margin:0 0 .2rem;font-size:.95rem;color:var(--blue);}
105
+  .wcard p{margin:0;font-size:.78rem;color:var(--muted);line-height:1.4;}
106
+
107
+  .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;max-width:520px;}
108
+  .feat-icon{width:72px;height:72px;border-radius:20px;display:grid;place-items:center;margin:0 auto 1.2rem;}
109
+  .feat-icon.yellow{background:#fff6d8;color:var(--brand-d);}
110
+  .card h1{font-size:1.45rem;margin:0 0 .5rem;color:var(--blue);}
111
+  .card p{color:var(--muted);font-size:.95rem;line-height:1.55;margin:0 auto 1.6rem;max-width:400px;}
112
+  .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;}
113
+  .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;}
114
+  .btn:hover{background:var(--brand-d);}
115
+  .hint{margin-top:1.4rem;font-size:.8rem;color:var(--muted);}
116
+
117
+  /* conversation placeholder (selected chat, no backend yet) */
118
+  .convo{flex-direction:column;display:flex;width:100%;height:100%;}
119
+  .convo-head{display:flex;align-items:center;gap:.7rem;padding:.9rem 1.2rem;border-bottom:1px solid var(--line);background:var(--card);}
120
+  .convo-back{border:none;background:var(--blue-soft);color:var(--blue);width:34px;height:34px;border-radius:9px;font-size:1.1rem;cursor:pointer;display:grid;place-items:center;flex:0 0 auto;}
121
+  .convo-back:hover{background:#dbe6fb;}
122
+  .convo-head .nm{font-weight:700;color:var(--ink);}
123
+  .convo-head .st{font-size:.78rem;color:var(--muted);}
124
+  .convo-body{flex:1;display:grid;place-items:center;text-align:center;color:var(--muted);padding:2rem;}
125
+  .convo-body .big{font-size:2.4rem;margin-bottom:.4rem;}
126
+
127
+  /* ---- Login (shown on /home when logged out) ---- */
128
+  .authwrap{flex:1 1 auto;display:none;align-items:center;justify-content:center;padding:1.5rem;min-height:0;}
129
+  .authcard{background:var(--card);border:1px solid var(--line);border-radius:16px;padding:2rem;max-width:400px;width:100%;box-shadow:0 10px 30px rgba(20,30,60,.08);}
130
+  .authcard h1{font-size:1.3rem;color:var(--blue);margin:0 0 .3rem;text-align:center;}
131
+  .authcard .sub{color:var(--muted);font-size:.9rem;text-align:center;margin-bottom:1.2rem;}
132
+  .authtabs{display:flex;gap:.5rem;margin-bottom:1.1rem;}
133
+  .authtabs button{flex:1;background:#eef1f6;color:var(--muted);font-weight:600;border:none;border-radius:9px;padding:.5rem;cursor:pointer;font-size:.9rem;}
134
+  .authtabs button.active{background:var(--blue);color:#fff;}
135
+  .authcard .lbl{display:block;font-size:.74rem;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;margin:.7rem 0 .15rem;}
136
+  .authcard input{width:100%;padding:.6rem .7rem;border-radius:10px;border:2px solid var(--line);background:#fbfcfe;color:var(--ink);font-size:.92rem;}
137
+  .authcard input:focus{outline:none;border-color:var(--brand);}
138
+  .authcard .gobtn{width:100%;margin-top:1rem;padding:.7rem;background:var(--brand);color:var(--ink);border:none;border-radius:10px;font-weight:700;cursor:pointer;font-size:.95rem;}
139
+  .authcard .gobtn:hover{background:var(--brand-d);}
140
+  .authcard .pwwrap{position:relative;} .authcard .pwwrap input{padding-right:2.6rem;}
141
+  .authcard .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;}
142
+  .authcard .eye:hover{color:var(--blue);}
143
+  .formerr{color:#b91c1c;font-weight:600;font-size:.88rem;margin:.7rem 0 0;min-height:1em;}
144
+  .formerr.show{display:flex;align-items:center;gap:.5rem;background:#fee2e2;border:1px solid #fca5a5;border-radius:9px;padding:.6rem .75rem;animation:errShake .35s;}
145
+  .formerr.show::before{content:"⚠";font-size:1rem;}
146
+  @keyframes errShake{0%,100%{transform:translateX(0)}20%,60%{transform:translateX(-5px)}40%,80%{transform:translateX(5px)}}
147
+  .hidden{display:none;}
148
+
149
+  /* ---- Loading / toast ---- */
150
+  .loading{position:fixed;inset:0;display:grid;place-items:center;background:var(--bg);z-index:9000;color:var(--muted);font-size:.9rem;}
151
+  .toast{position:fixed;left:50%;bottom:1.6rem;transform:translateX(-50%) translateY(1rem);background:var(--blue);color:#fff;padding:.7rem 1.2rem;border-radius:10px;font-size:.88rem;box-shadow:0 10px 28px rgba(0,0,0,.22);opacity:0;pointer-events:none;transition:opacity .2s,transform .2s;z-index:9500;}
152
+  .toast.show{opacity:1;transform:translateX(-50%) translateY(0);}
153
+
154
+  @media (max-width:760px){
155
+    .chatcol{width:260px;flex:0 0 260px;}
156
+  }
157
+</style>
158
+</head>
159
+<body>
160
+<div class="loading" id="loading">Loading…</div>
161
+
162
+<header>
163
+  <div class="brandrow">
164
+    <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'}))">
165
+    <div class="brand">BizGaze <span class="y">Connect</span></div>
166
+  </div>
167
+  <div id="hdrRight"></div>
168
+</header>
169
+
170
+<div class="shell">
171
+  <!-- ---------- Icon rail ---------- -->
172
+  <nav class="rail" id="rail">
173
+    <button class="railbtn active" data-tab="chat" data-tip="Chat" aria-label="Chat">
174
+      <svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" 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>
175
+      <span class="rdot" id="railUnread" style="display:none">0</span>
176
+    </button>
177
+    <button class="railbtn" data-tab="share" data-tip="Share Screen" aria-label="Share Screen">
178
+      <svg viewBox="0 0 24 24" width="22" height="22" 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>
179
+      <span class="livedot"></span>
180
+    </button>
181
+    <button class="railbtn" data-tab="connect" data-tip="Connect Screen" aria-label="Connect Screen">
182
+      <svg viewBox="0 0 24 24" width="22" height="22" 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>
183
+      <span class="livedot"></span>
184
+    </button>
185
+    <button class="railbtn" data-tab="meeting" data-tip="Meeting" aria-label="Meeting">
186
+      <svg viewBox="0 0 24 24" width="22" height="22" 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>
187
+    </button>
188
+    <div class="rail-spacer"></div>
189
+  </nav>
190
+
191
+  <!-- ---------- Chat list (Chat tab only) ---------- -->
192
+  <aside class="chatcol" id="chatcol">
193
+    <div class="side-head">
194
+      <div class="side-title">
195
+        <h2>Chats</h2>
196
+        <button class="newchat" id="newChat" title="New chat" aria-label="New chat">+</button>
197
+      </div>
198
+      <div class="search">
199
+        <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>
200
+        <input id="chatSearch" placeholder="Search chats" autocomplete="off">
201
+      </div>
202
+    </div>
203
+    <div class="chatlist" id="chatlist"></div>
204
+    <div class="demo-note">💬 Chat is coming soon — showing sample conversations</div>
205
+  </aside>
206
+
207
+  <!-- ---------- Main content ---------- -->
208
+  <main class="content">
209
+    <!-- Chat panel: welcome (no selection) OR conversation placeholder -->
210
+    <div class="panel center active" data-panel="chat" id="chatPanel"></div>
211
+
212
+    <!-- Share -->
213
+    <div class="panel" data-panel="share" id="sharePanel"></div>
214
+
215
+    <!-- Connect -->
216
+    <div class="panel" data-panel="connect" id="connectPanel"></div>
217
+
218
+    <!-- Meeting -->
219
+    <div class="panel center" data-panel="meeting">
220
+      <div class="card">
221
+        <div class="feat-icon yellow">
222
+          <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>
223
+        </div>
224
+        <span class="pill-soon">COMING SOON</span>
225
+        <h1>Meetings are on the way</h1>
226
+        <p>Soon you'll be able to host multi-party video meetings with your BizGaze team and customers — right here, no install needed.</p>
227
+        <button class="btn" id="notifyBtn">🔔 Notify me when it's ready</button>
228
+        <div class="hint">In the meantime, use <b>Share Screen</b> or <b>Connect Screen</b> from the left.</div>
229
+      </div>
230
+    </div>
231
+  </main>
232
+</div>
233
+
234
+<div class="authwrap" id="authwrap"></div>
235
+
236
+<div class="toast" id="toast"></div>
237
+
238
+<script>
239
+// ---------- Helpers ----------
240
+function pEsc(s){return String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));}
241
+function initials(name){const p=String(name||'?').trim().split(/\s+/);return ((p[0]||'?')[0]+(p[1]?p[1][0]:'')).toUpperCase();}
242
+function firstName(name){return String(name||'').trim().split(/\s+/)[0]||'there';}
243
+const AV_COLORS=['#1F3B73','#2563eb','#0e7490','#7c3aed','#be185d','#b45309','#15803d','#9d174d'];
244
+function avColor(name){let h=0;for(const c of String(name))h=(h*31+c.charCodeAt(0))>>>0;return AV_COLORS[h%AV_COLORS.length];}
245
+
246
+let toastTimer=null;
247
+function toast(msg){const t=document.getElementById('toast');t.textContent=msg;t.classList.add('show');clearTimeout(toastTimer);toastTimer=setTimeout(()=>t.classList.remove('show'),2600);}
248
+
249
+// ---------- Profile dropdown (mirrors profileHTML()/wireProfile() from console.html) ----------
250
+function profileHTML(u){
251
+  const display=u.name||u.email;
252
+  return '<div class="profile"><button class="pbtn" id="pbtn">'
253
+    + '<span class="pav">'+pEsc(initials(display))+'</span>'
254
+    + pEsc(display)+' <span style="font-size:.65rem">&#9662;</span></button>'
255
+    + '<div class="pmenu" id="pmenu">'
256
+    + '<div class="phead"><div class="n">'+pEsc(display)+'</div><div class="e">'+pEsc(u.email)+(u.role?' · '+pEsc(u.role):'')+'</div></div>'
257
+    + '<a href="/dashboard">Dashboard</a>'
258
+    + '<a class="danger" id="plogout">Logout</a>'
259
+    + '</div></div>';
260
+}
261
+function wireProfile(){
262
+  const btn=document.getElementById('pbtn'),menu=document.getElementById('pmenu');
263
+  if(!btn)return;
264
+  btn.onclick=(e)=>{e.stopPropagation();menu.classList.toggle('open');};
265
+  document.addEventListener('click',()=>menu.classList.remove('open'));
266
+  const lo=document.getElementById('plogout');
267
+  if(lo)lo.onclick=async()=>{try{await fetch('/api/logout',{method:'POST'});}catch(_){}location.href='/';};
268
+}
269
+
270
+// ---------- Mock chat data (placeholder — no chat backend yet, see CLAUDE.md) ----------
271
+const CHATS=[
272
+  {name:'Anwi Systems',     msg:"Perfect, the screen share worked great. Thanks!", time:'9:42 AM', unread:0, online:true},
273
+  {name:'Priya Sharma',     msg:"Can you connect to my screen at 3pm?",           time:'9:15 AM', unread:2, online:true},
274
+  {name:'GAPL Group',       msg:"You: I've shared the 6-digit code with you",     time:'Yesterday', unread:0, online:false},
275
+  {name:'Battery Doctors',  msg:"The invoice module is throwing an error again",   time:'Yesterday', unread:5, online:true},
276
+  {name:'Ramesh Marketing', msg:"You: Let me know once you're at your desk",       time:'Mon',     unread:0, online:false},
277
+  {name:'STC Support',      msg:"Typing…",                                         time:'Mon',     unread:1, online:true},
278
+  {name:'Samruddhi Traders',msg:"Thanks for the help earlier 👍",                  time:'Sun',     unread:0, online:false},
279
+  {name:'DMS 3.0 Team',     msg:"You: Closing the ticket, all resolved",           time:'Fri',     unread:0, online:false},
280
+];
281
+let selectedChat=null;   // index into CHATS, or null = welcome
282
+
283
+const listEl=document.getElementById('chatlist');
284
+function chatRowHTML(c,i){
285
+  const cls=['chat-row'];
286
+  if(selectedChat===i)cls.push('active');
287
+  if(c.unread>0)cls.push('unread');
288
+  return '<div class="'+cls.join(' ')+'" data-i="'+i+'">'
289
+    + '<div class="avatar" style="background:'+avColor(c.name)+'">'+pEsc(initials(c.name))
290
+    +   '<span class="dot'+(c.online?' on':'')+'"></span></div>'
291
+    + '<div class="chat-main">'
292
+    +   '<div class="chat-top"><span class="chat-name">'+pEsc(c.name)+'</span><span class="chat-time">'+pEsc(c.time)+'</span></div>'
293
+    +   '<div class="chat-bottom"><span class="chat-prev">'+pEsc(c.msg)+'</span>'
294
+    +     (c.unread>0?'<span class="badge">'+c.unread+'</span>':'')+'</div>'
295
+    + '</div></div>';
296
+}
297
+function renderChats(filter){
298
+  const q=(filter||'').trim().toLowerCase();
299
+  const rows=CHATS.map((c,i)=>({c,i})).filter(({c})=>!q||c.name.toLowerCase().includes(q)||c.msg.toLowerCase().includes(q));
300
+  listEl.innerHTML = rows.length
301
+    ? rows.map(({c,i})=>chatRowHTML(c,i)).join('')
302
+    : '<div class="no-results">No chats match “'+pEsc(filter)+'”.</div>';
303
+  listEl.querySelectorAll('.chat-row').forEach(row=>{
304
+    row.onclick=()=>{
305
+      selectedChat=+row.dataset.i;
306
+      CHATS[selectedChat].unread=0;
307
+      renderChats(document.getElementById('chatSearch').value);
308
+      renderChatPanel();
309
+      updateRailUnread();
310
+    };
311
+  });
312
+}
313
+function updateRailUnread(){
314
+  const total=CHATS.reduce((a,c)=>a+(c.unread||0),0);
315
+  const d=document.getElementById('railUnread');
316
+  if(total>0){ d.textContent=total>99?'99+':total; d.style.display='grid'; }
317
+  else d.style.display='none';
318
+}
319
+
320
+// ---------- Chat main panel: welcome OR conversation placeholder ----------
321
+let ME={};
322
+function welcomeHTML(){
323
+  return '<div class="welcome">'
324
+    + '<div class="wave">👋</div>'
325
+    + '<h1>Hi, '+pEsc(firstName(ME.name||ME.email))+'! Welcome to BizGaze Connect</h1>'
326
+    + '<p>Pick a conversation on the left to start chatting, or jump straight into a session from the sidebar.</p>'
327
+    + '<div class="wcards">'
328
+    +   '<div class="wcard" data-go="share"><div class="wi"><svg viewBox="0 0 24 24" width="22" height="22" 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><h3>Share Screen</h3><p>Show your screen with a 6-digit code</p></div>'
329
+    +   '<div class="wcard" data-go="connect"><div class="wi"><svg viewBox="0 0 24 24" width="22" height="22" 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><h3>Connect Screen</h3><p>Enter a customer\'s code to help</p></div>'
330
+    +   '<div class="wcard" data-go="meeting"><div class="wi"><svg viewBox="0 0 24 24" width="22" height="22" 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><h3>Meeting</h3><p>Multi-party video — coming soon</p></div>'
331
+    + '</div></div>';
332
+}
333
+function convoHTML(c){
334
+  return '<div class="convo">'
335
+    + '<div class="convo-head">'
336
+    +   '<button class="convo-back" id="convoBack" title="Back to home (Esc)" aria-label="Back to home">&#8592;</button>'
337
+    +   '<div class="avatar" style="width:38px;height:38px;flex:0 0 38px;background:'+avColor(c.name)+'">'+pEsc(initials(c.name))+'<span class="dot'+(c.online?' on':'')+'"></span></div>'
338
+    +   '<div><div class="nm">'+pEsc(c.name)+'</div><div class="st">'+(c.online?'Online':'Offline')+'</div></div>'
339
+    + '</div>'
340
+    + '<div class="convo-body"><div><div class="big">💬</div><div style="font-weight:600;color:var(--ink);margin-bottom:.3rem">Messaging is coming soon</div><div>Persistent 1:1 chat with '+pEsc(c.name)+' will live here.<br>For now, start a screen session from the left.</div></div></div>'
341
+    + '</div>';
342
+}
343
+function renderChatPanel(){
344
+  const el=document.getElementById('chatPanel');
345
+  if(selectedChat==null){ el.classList.add('center'); el.innerHTML=welcomeHTML(); wireWelcome(); }
346
+  else { el.classList.remove('center'); el.innerHTML=convoHTML(CHATS[selectedChat]); const b=document.getElementById('convoBack'); if(b) b.onclick=showWelcome; }
347
+}
348
+function wireWelcome(){
349
+  document.querySelectorAll('#chatPanel .wcard').forEach(card=>{
350
+    card.onclick=()=>switchTab(card.dataset.go);
351
+  });
352
+}
353
+
354
+// ---------- Tabs (icon rail) ----------
355
+// Chat and Meeting are in-shell panels; Share and Connect load in the center panel via
356
+// a single, same-origin, lazily-loaded iframe (cheap isolation, no page navigation).
357
+const railBtns=document.querySelectorAll('.railbtn');
358
+const panels=document.querySelectorAll('.panel');
359
+const chatcol=document.getElementById('chatcol');
360
+let loaded={share:false,connect:false};
361
+function currentTab(){ const b=document.querySelector('.railbtn.active'); return b?b.dataset.tab:'chat'; }
362
+function switchTab(tab){
363
+  railBtns.forEach(b=>b.classList.toggle('active',b.dataset.tab===tab));
364
+  panels.forEach(p=>p.classList.toggle('active',p.dataset.panel===tab));
365
+  chatcol.classList.toggle('hidden', tab!=='chat');
366
+  // Lazy-load the embedded flows on first open; keep them mounted afterwards so a
367
+  // live session survives tab switches.
368
+  if(tab==='share' && !loaded.share){ document.getElementById('sharePanel').innerHTML='<iframe src="/share?embed=1" allow="camera; microphone; display-capture; clipboard-read; clipboard-write"></iframe>'; loaded.share=true; }
369
+  if(tab==='connect' && !loaded.connect){ document.getElementById('connectPanel').innerHTML='<iframe src="/connect?embed=1" allow="camera; microphone; display-capture; clipboard-read; clipboard-write"></iframe>'; loaded.connect=true; }
370
+}
371
+function showWelcome(){ selectedChat=null; renderChats(document.getElementById('chatSearch').value); renderChatPanel(); updateRailUnread(); }
372
+railBtns.forEach(btn=>{ btn.onclick=()=>{
373
+  const tab=btn.dataset.tab;
374
+  // Re-clicking Chat (while already on it) returns to the welcome screen.
375
+  if(tab==='chat' && currentTab()==='chat' && selectedChat!=null){ showWelcome(); }
376
+  switchTab(tab);
377
+}; });
378
+// Esc clears the open conversation and brings back the welcome screen.
379
+document.addEventListener('keydown',(e)=>{ if(e.key==='Escape' && currentTab()==='chat' && selectedChat!=null){ showWelcome(); } });
380
+
381
+// Embedded Share/Connect flows report session start/stop so the rail can show a "live"
382
+// dot — that's how you know a session is still running after switching to Chat.
383
+window.addEventListener('message',(e)=>{
384
+  if(e.origin!==location.origin) return;
385
+  const d=e.data;
386
+  if(!d||d.type!=='bzc-session'||(d.flow!=='share'&&d.flow!=='connect')) return;
387
+  const btn=document.querySelector('.railbtn[data-tab="'+d.flow+'"]');
388
+  if(!btn) return;
389
+  btn.classList.toggle('live', !!d.active);
390
+  if(d.active && currentTab()!==d.flow){
391
+    toast((d.flow==='share'?'Screen share':'Connection')+' is live — tap the highlighted icon to return');
392
+  }
393
+});
394
+
395
+// Sidebar + misc wiring
396
+document.getElementById('chatSearch').addEventListener('input',e=>renderChats(e.target.value));
397
+document.getElementById('newChat').onclick=()=>toast('New chat is coming soon');
398
+document.getElementById('notifyBtn').onclick=()=>toast("Thanks! We'll let you know when Meetings launches.");
399
+
400
+// ---------- Login (shown here on /home when logged out) ----------
401
+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>';
402
+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>';
403
+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>';}
404
+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;};});}
405
+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();}});});}
406
+function showErr(id,msg){const el=document.getElementById(id);el.textContent=msg;el.classList.add('show');}
407
+function clearErr(id){const el=document.getElementById(id);el.textContent='';el.classList.remove('show');}
408
+async function postJSON(path,body){const r=await fetch(path,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});const d=await r.json().catch(()=>({}));if(!r.ok)throw new Error(d.error||'request failed');return d;}
409
+async function renderLogin(){
410
+  document.querySelector('.shell').style.display='none';
411
+  const aw=document.getElementById('authwrap'); aw.style.display='flex';
412
+  let regOpen=false; try{ regOpen=(await (await fetch('/api/setup-state')).json()).registrationOpen; }catch(_){}
413
+  aw.innerHTML=`<div class="authcard">
414
+    <h1>Welcome to BizGaze Connect</h1>
415
+    <div class="sub">Sign in to access chats, screen share and connect.</div>
416
+    ${regOpen?`<div class="authtabs">
417
+      <button id="tabLogin" class="active">Sign in</button>
418
+      <button id="tabReg">Register team</button>
419
+    </div>`:''}
420
+    <div id="loginForm">
421
+      <span class="lbl">Email</span><input id="li_email" type="email" placeholder="you@bizgaze.com">
422
+      <span class="lbl">Password</span>${pwField('li_pw','password')}
423
+      <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>
424
+      <button class="gobtn" id="li_btn">Sign in</button>
425
+      <p id="li_err" class="formerr"></p>
426
+    </div>
427
+    ${regOpen?`<div id="regForm" class="hidden">
428
+      <span class="lbl">Team name</span><input id="rg_team" placeholder="e.g. BizGaze Support">
429
+      <span class="lbl">Email</span><input id="rg_email" type="email" placeholder="you@bizgaze.com">
430
+      <span class="lbl">Password</span>${pwField('rg_pw','min 8 characters')}
431
+      <button class="gobtn" id="rg_btn">Create team</button>
432
+      <p id="rg_err" class="formerr"></p>
433
+    </div>`:''}
434
+  </div>`;
435
+  document.getElementById('li_btn').onclick=doLogin;
436
+  wireEyes();
437
+  onEnter(['li_email','li_pw'],doLogin);
438
+  if(regOpen){
439
+    const lf=document.getElementById('loginForm'), rf=document.getElementById('regForm');
440
+    const tl=document.getElementById('tabLogin'), tr=document.getElementById('tabReg');
441
+    tl.onclick=()=>{lf.classList.remove('hidden');rf.classList.add('hidden');tl.classList.add('active');tr.classList.remove('active');};
442
+    tr.onclick=()=>{rf.classList.remove('hidden');lf.classList.add('hidden');tr.classList.add('active');tl.classList.remove('active');};
443
+    document.getElementById('rg_btn').onclick=doRegister;
444
+    onEnter(['rg_team','rg_email','rg_pw'],doRegister);
445
+  }
446
+}
447
+async function doLogin(){
448
+  clearErr('li_err');
449
+  try{
450
+    await postJSON('/api/login',{email:document.getElementById('li_email').value,password:document.getElementById('li_pw').value,remember:document.getElementById('li_remember').checked});
451
+    location.reload();
452
+  }catch(e){ showErr('li_err', /invalid credentials/i.test(e.message)?'Incorrect email or password. Please try again.':e.message); }
453
+}
454
+async function doRegister(){
455
+  clearErr('rg_err');
456
+  try{
457
+    await postJSON('/api/register',{email:document.getElementById('rg_email').value,password:document.getElementById('rg_pw').value,teamName:document.getElementById('rg_team').value});
458
+    await postJSON('/api/login',{email:document.getElementById('rg_email').value,password:document.getElementById('rg_pw').value});
459
+    location.reload();
460
+  }catch(e){ showErr('rg_err', e.message); }
461
+}
462
+
463
+// ---------- Boot: show the app if signed in, otherwise the login ----------
464
+(async function(){
465
+  let me=null;
466
+  try{ const r=await fetch('/api/me'); if(r.ok) me=await r.json(); }catch(_){}
467
+  if(!me){ await renderLogin(); document.getElementById('loading').style.display='none'; return; }
468
+  ME=me;
469
+  document.getElementById('hdrRight').innerHTML=profileHTML(me);
470
+  wireProfile();
471
+  renderChats('');
472
+  renderChatPanel();
473
+  updateRailUnread();
474
+  document.getElementById('loading').style.display='none';
475
+})();
476
+</script>
477
+</body>
478
+</html>

+ 27
- 19
server/public/index.html Bestand weergeven

@@ -17,14 +17,19 @@
17 17
   .inner{max-width:780px;width:100%;text-align:center;}
18 18
   h1{color:var(--blue);font-size:1.8rem;margin:0 0 .4rem;}
19 19
   .sub{color:var(--muted);margin-bottom:2.2rem;}
20
+  .ssobtn{display:inline-flex;align-items:center;justify-content:center;gap:.6rem;background:var(--blue);color:#fff;text-decoration:none;font-weight:700;font-size:1.02rem;padding:.95rem 2rem;border-radius:12px;box-shadow:0 10px 26px rgba(31,59,115,.28);transition:transform .12s,box-shadow .12s,background .12s;}
21
+  .ssobtn:hover{transform:translateY(-2px);box-shadow:0 16px 34px rgba(31,59,115,.34);background:var(--blue-d);}
22
+  .ssobtn .bmark{width:26px;height:26px;border-radius:7px;background:var(--brand);color:var(--blue);display:grid;place-items:center;font-weight:800;font-size:.9rem;}
23
+  .divider{display:flex;align-items:center;gap:1rem;color:var(--muted);font-size:.85rem;max-width:360px;margin:1.8rem auto;}
24
+  .divider::before,.divider::after{content:"";flex:1;height:1px;background:var(--line);}
20 25
   .choices{display:flex;gap:1.4rem;flex-wrap:wrap;justify-content:center;}
21
-  .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;}
26
+  .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;}
22 27
   .choice:hover{transform:translateY(-3px);box-shadow:0 16px 38px rgba(20,30,60,.12);border-color:var(--brand);}
23
-  .icon{width:66px;height:66px;border-radius:18px;display:grid;place-items:center;margin:0 auto 1.1rem;}
28
+  .icon{width:56px;height:56px;flex:0 0 56px;border-radius:16px;display:grid;place-items:center;}
24 29
   .icon.share{background:#FFF7DA;} .icon.connect{background:var(--blue-soft);}
25
-  .icon svg{width:34px;height:34px;}
26
-  .choice h3{margin:.2rem 0 .4rem;color:var(--blue);font-size:1.15rem;}
27
-  .choice p{margin:0;color:var(--muted);font-size:.9rem;line-height:1.5;}
30
+  .icon svg{width:30px;height:30px;}
31
+  .choice h3{margin:0 0 .25rem;color:var(--blue);font-size:1.1rem;}
32
+  .choice p{margin:0;color:var(--muted);font-size:.88rem;line-height:1.45;}
28 33
   .foot{color:var(--muted);font-size:.82rem;margin-top:2.4rem;}
29 34
   footer{text-align:center;color:#9aa3b2;font-size:.8rem;padding:1rem;}
30 35
   .profile{position:relative}
@@ -43,35 +48,38 @@
43 48
     <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'">
44 49
     <div class="brand">BizGaze <span>Support</span></div>
45 50
   </div>
46
-  <div id="authArea"><a class="signin" href="/console">Staff sign in</a></div>
51
+  <div id="authArea"></div>
47 52
 </header>
48 53
 <div class="wrap">
49 54
   <div class="inner">
50
-    <h1>How can we help you today?</h1>
51
-    <div class="sub">Secure remote support — no downloads, you stay in control.</div>
52
-    <div class="choices">
55
+    <h1>Welcome to BizGaze Connect</h1>
56
+    <div class="sub">Chat, meetings and secure remote support — for the BizGaze ecosystem.</div>
57
+    <!-- Stub SSO: routes to staff login for now; swap href to /sso once BizGaze SSO is wired. -->
58
+    <a class="ssobtn" id="ssoBtn" href="/home"><span class="bmark">B</span> Log in with BizGaze</a>
59
+    <div class="divider">need support? no account required</div>
60
+    <div class="choices" style="max-width:400px;margin:0 auto">
53 61
       <a class="choice" href="/share">
54 62
         <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>
55
-        <h3>Share my screen</h3>
56
-        <p>You need help. Get a one-time code and show your screen to a BizGaze support agent.</p>
57
-      </a>
58
-      <a class="choice" href="/connect">
59
-        <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>
60
-        <h3>Connect to a screen</h3>
61
-        <p>You're a support agent. Sign in, then enter the customer's code to view their screen.</p>
63
+        <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>
62 64
       </a>
63 65
     </div>
64
-    <div class="foot">🔒 Screen sharing only starts after the customer approves it, and can be stopped anytime.</div>
66
+    <div class="foot">🔒 Screen sharing only starts after you approve it, and can be stopped anytime.</div>
65 67
   </div>
66 68
 </div>
67 69
 <footer>© BizGaze · Remote Support</footer>
68 70
 <script>
69 71
 function pEsc(s){return String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));}
70
-function profileHTML(name){return '<div class="profile"><button class="pbtn" id="pbtn">'+pEsc(name)+' <span style="font-size:.65rem">&#9662;</span></button><div class="pmenu" id="pmenu"><a href="/console">Console / Dashboard</a><a id="plogout" class="danger">Logout</a></div></div>';}
72
+function profileHTML(name){return '<div class="profile"><button class="pbtn" id="pbtn">'+pEsc(name)+' <span style="font-size:.65rem">&#9662;</span></button><div class="pmenu" id="pmenu"><a href="/home">Home</a><a href="/dashboard">Dashboard</a><a id="plogout" class="danger">Logout</a></div></div>';}
71 73
 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='/';};}
72 74
 function makeBrandClickable(){document.querySelectorAll('.brandrow,.wordmark').forEach(el=>{el.style.cursor='pointer';el.addEventListener('click',()=>{location.href='/';});});}
73 75
 makeBrandClickable();
74
-(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(_){}})();
76
+(async function(){try{const r=await fetch('/api/me');if(r.ok){const me=await r.json();
77
+  document.getElementById('authArea').innerHTML=profileHTML(me.name||me.email);wireProfile();
78
+  // Already signed in: swap the login CTA for an "enter app" CTA.
79
+  const b=document.getElementById('ssoBtn'); if(b){ b.innerHTML='Open BizGaze Connect &rarr;'; b.href='/home'; }
80
+  const h=document.querySelector('.inner h1'); if(h){ const fn=String(me.name||'').trim().split(/\s+/)[0]; h.textContent='Welcome back'+(fn?', '+fn:'')+'!'; }
81
+  const dv=document.querySelector('.divider'); if(dv) dv.textContent='need to help someone? share your screen';
82
+}}catch(_){}})();
75 83
 </script>
76 84
 </body>
77 85
 </html>

+ 12
- 4
server/public/share.html Bestand weergeven

@@ -32,6 +32,10 @@
32 32
   .foot{color:var(--muted);font-size:.8rem;margin-top:1.4rem;}
33 33
   .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;}
34 34
   .indicator.show{display:block;}
35
+  /* Embedded inside the home shell: hide own chrome (the shell provides it). */
36
+  html.embed .brandpanel{display:none!important;}
37
+  html.embed #homeLink{display:none!important;}
38
+  html.embed .panelside{flex:1;}
35 39
   @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;} }
36 40
   .profile{position:relative}
37 41
   .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}
@@ -44,6 +48,7 @@
44 48
 </style>
45 49
 </head>
46 50
 <body>
51
+<script>if(new URLSearchParams(location.search).get('embed')==='1')document.documentElement.classList.add('embed');</script>
47 52
 <div class="indicator" id="indicator">● Your screen is being shared — close this tab anytime to stop</div>
48 53
 <a href="/" id="homeLink" style="position:fixed;top:14px;left:16px;z-index:50;display:inline-flex;align-items:center;gap:6px;background:rgba(255,255,255,.92);color:#1F3B73;text-decoration:none;font-weight:600;font-size:.86rem;padding:.45rem .8rem;border-radius:10px;box-shadow:0 4px 12px rgba(0,0,0,.15)">&#8592; Home</a>
49 54
 <div class="stage">
@@ -77,7 +82,10 @@ const IS_MOBILE=/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|M
77 82
 let __icePromise=Promise.resolve();try{__icePromise=fetch('/api/ice').then(r=>r.ok?r.json():null).then(c=>{if(c&&c.iceServers&&IS_MOBILE)ICE=c;}).catch(()=>{});}catch(_){}
78 83
 async function ensureIce(){try{await __icePromise;}catch(_){}return ICE;}
79 84
 function pEsc(s){return String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));}
80
-function profileHTML(name){return '<div class="profile"><button class="pbtn" id="pbtn">'+pEsc(name)+' <span style="font-size:.65rem">&#9662;</span></button><div class="pmenu" id="pmenu"><a href="/console">Console / Dashboard</a><a id="plogout" class="danger">Logout</a></div></div>';}
85
+// When embedded in the home shell, tell the parent when a session is live so the
86
+// rail can show a "return here" indicator.
87
+function bzcSession(active){try{if(window.parent&&window.parent!==window)window.parent.postMessage({type:'bzc-session',flow:'share',active:!!active},location.origin);}catch(_){}}
88
+function profileHTML(name){return '<div class="profile"><button class="pbtn" id="pbtn">'+pEsc(name)+' <span style="font-size:.65rem">&#9662;</span></button><div class="pmenu" id="pmenu"><a href="/home">Home</a><a href="/dashboard">Dashboard</a><a id="plogout" class="danger">Logout</a></div></div>';}
81 89
 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='/';};}
82 90
 function makeBrandClickable(){document.querySelectorAll('.brandrow,.wordmark').forEach(el=>{el.style.cursor='pointer';el.addEventListener('click',()=>{location.href='/';});});}
83 91
 makeBrandClickable();
@@ -151,7 +159,7 @@ async function startStreaming(){
151 159
     if(mic){ window.__mic=mic; try{mic.getAudioTracks().forEach(t=>localStream.addTrack(t));}catch(_){} }
152 160
   }
153 161
   await ensureIce();
154
-  indicator.classList.add('show'); setStatus('You are now sharing your screen with your agent.','on');
162
+  indicator.classList.add('show'); setStatus('You are now sharing your screen with your agent.','on'); bzcSession(true);
155 163
   { const hl=document.getElementById('homeLink'); if(hl) hl.style.display='none'; }
156 164
   window.onbeforeunload=function(){ if((localStream||document.getElementById('sessionBar'))&&!sessionOver){ return 'Leaving or refreshing this page will end your screen sharing session.'; } };
157 165
   pc=new RTCPeerConnection(ICE);
@@ -198,7 +206,7 @@ function recNotice(on){
198 206
   } else { clearInterval(recTimerInt); recTimerInt=null; if(n) n.remove(); }
199 207
 }
200 208
 function endShareSession(msgText){
201
-  sessionOver=true; window.onbeforeunload=null; { const hl=document.getElementById('homeLink'); if(hl) hl.style.display=''; } try{recNotice(false);stopCustTranscription();}catch(_){}
209
+  sessionOver=true; window.onbeforeunload=null; bzcSession(false); { const hl=document.getElementById('homeLink'); if(hl) hl.style.display=''; } try{recNotice(false);stopCustTranscription();}catch(_){}
202 210
   removeSessionUI();
203 211
   indicator.classList.remove('show');
204 212
   if(window.__mic){try{window.__mic.getTracks().forEach(t=>t.stop());}catch(_){}window.__mic=null;}
@@ -207,7 +215,7 @@ function endShareSession(msgText){
207 215
   var card=document.querySelector('.panelside .card');
208 216
   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>'; }
209 217
 }
210
-function teardown(){sessionOver=true;window.onbeforeunload=null;{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.');}
218
+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.');}
211 219
 
212 220
 let chatOpen=false;
213 221
 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>';

+ 103
- 0
server/repos.js Bestand weergeven

@@ -0,0 +1,103 @@
1
+// Data-access layer (Phase 1).
2
+// All SQL lives here, never in route/signaling handlers. This decouples the rest of
3
+// the app from SQLite so the store can later move to Postgres without touching callers.
4
+//
5
+// TENANT ABSTRACTION: a "tenant" currently maps 1:1 to a team (column `team_id`).
6
+// Repo signatures take `tenantId` so that when the tenant is later elevated to a
7
+// first-class Organization (Phase 3), callers and the API/auth built on top stay unchanged.
8
+const db = require('./db');
9
+const A = require('./auth');
10
+const now = () => Date.now();
11
+
12
+const teams = {
13
+  first: () => db.prepare('SELECT * FROM teams LIMIT 1').get(),
14
+  byId: (id) => db.prepare('SELECT * FROM teams WHERE id=?').get(id),
15
+  create: (name) => {
16
+    const id = A.id();
17
+    db.prepare('INSERT INTO teams (id,name,created_at) VALUES (?,?,?)').run(id, name, now());
18
+    return db.prepare('SELECT * FROM teams WHERE id=?').get(id);
19
+  },
20
+};
21
+
22
+const users = {
23
+  anyExists: () => !!db.prepare('SELECT 1 FROM users LIMIT 1').get(),
24
+  byId: (id) => db.prepare('SELECT * FROM users WHERE id=?').get(id),
25
+  byEmail: (email) => db.prepare('SELECT * FROM users WHERE email=? COLLATE NOCASE').get(email),
26
+  emailExists: (email) => !!db.prepare('SELECT 1 FROM users WHERE email=? COLLATE NOCASE').get(email),
27
+  listByTenant: (tenantId) =>
28
+    db.prepare('SELECT id,email,name,role,active,created_at FROM users WHERE team_id=?').all(tenantId),
29
+  inTenant: (id, tenantId) =>
30
+    db.prepare('SELECT * FROM users WHERE id=? AND team_id=?').get(id, tenantId),
31
+  create: ({ tenantId, email, hash, salt, role, name, mfaSecret }) => {
32
+    const id = A.id();
33
+    db.prepare(`INSERT INTO users (id,team_id,email,pw_hash,pw_salt,role,name,mfa_secret,mfa_enabled,created_at)
34
+                VALUES (?,?,?,?,?,?,?,?,0,?)`)
35
+      .run(id, tenantId, email, hash, salt, role, name || null, mfaSecret, now());
36
+    return id;
37
+  },
38
+  enableMfa: (id) => db.prepare('UPDATE users SET mfa_enabled=1 WHERE id=?').run(id),
39
+  setName: (id, name) => db.prepare('UPDATE users SET name=? WHERE id=?').run(name, id),
40
+  setPassword: (id, hash, salt) => db.prepare('UPDATE users SET pw_hash=?, pw_salt=? WHERE id=?').run(hash, salt, id),
41
+  setActive: (id, active) => db.prepare('UPDATE users SET active=? WHERE id=?').run(active ? 1 : 0, id),
42
+  remove: (id) => db.prepare('DELETE FROM users WHERE id=?').run(id),
43
+};
44
+
45
+const authSessions = {
46
+  byToken: (token) => db.prepare('SELECT * FROM sessions_auth WHERE token=?').get(token),
47
+  create: ({ token, userId, mfaPassed, ttl }) =>
48
+    db.prepare('INSERT INTO sessions_auth (token,user_id,mfa_passed,created_at,expires_at) VALUES (?,?,?,?,?)')
49
+      .run(token, userId, mfaPassed ? 1 : 0, now(), now() + ttl),
50
+  markMfaPassed: (token) => db.prepare('UPDATE sessions_auth SET mfa_passed=1 WHERE token=?').run(token),
51
+  deleteByToken: (token) => db.prepare('DELETE FROM sessions_auth WHERE token=?').run(token),
52
+  deleteByUser: (userId) => db.prepare('DELETE FROM sessions_auth WHERE user_id=?').run(userId),
53
+};
54
+
55
+const machines = {
56
+  byEnrollToken: (t) => db.prepare('SELECT * FROM machines WHERE enroll_token=?').get(t),
57
+  inTenant: (id, tenantId) => db.prepare('SELECT * FROM machines WHERE id=? AND team_id=?').get(id, tenantId),
58
+  listByTenant: (tenantId) =>
59
+    db.prepare('SELECT id,name,unattended,last_seen FROM machines WHERE team_id=?').all(tenantId),
60
+  create: ({ tenantId, name, enrollToken, unattended }) => {
61
+    const id = A.id();
62
+    db.prepare('INSERT INTO machines (id,team_id,name,enroll_token,unattended,created_at) VALUES (?,?,?,?,?,?)')
63
+      .run(id, tenantId, name, enrollToken, unattended ? 1 : 0, now());
64
+    return id;
65
+  },
66
+  touch: (id) => db.prepare('UPDATE machines SET last_seen=? WHERE id=?').run(now(), id),
67
+};
68
+
69
+const audit = {
70
+  add: (e) =>
71
+    db.prepare(`INSERT INTO audit_log (team_id,user_id,user_email,machine_id,machine_name,action,detail,at)
72
+                VALUES (@team_id,@user_id,@user_email,@machine_id,@machine_name,@action,@detail,@at)`)
73
+      .run({
74
+        team_id: e.team_id, user_id: e.user_id || null, user_email: e.user_email || null,
75
+        machine_id: e.machine_id || null, machine_name: e.machine_name || null,
76
+        action: e.action, detail: e.detail || null, at: now(),
77
+      }),
78
+  listByTenant: (tenantId) =>
79
+    db.prepare("SELECT * FROM audit_log WHERE team_id=? OR team_id='adhoc' ORDER BY at DESC LIMIT 200").all(tenantId),
80
+};
81
+
82
+const sessionsLog = {
83
+  byId: (id) => db.prepare('SELECT * FROM sessions_log WHERE id=?').get(id),
84
+  byIdInTenant: (id, tenantId) => db.prepare('SELECT * FROM sessions_log WHERE id=? AND team_id=?').get(id, tenantId),
85
+  create: ({ id, tenantId, agentEmail, agentName, ticket }) =>
86
+    db.prepare('INSERT INTO sessions_log (id,team_id,agent_email,agent_name,ticket,started_at) VALUES (?,?,?,?,?,?)')
87
+      .run(id, tenantId, agentEmail, agentName, ticket || null, now()),
88
+  end: (id) => db.prepare('UPDATE sessions_log SET ended_at=? WHERE id=? AND ended_at IS NULL').run(now(), id),
89
+  setRecording: (id, fname) => db.prepare('UPDATE sessions_log SET recording=? WHERE id=?').run(fname, id),
90
+  setTranscript: (id, fname) => db.prepare('UPDATE sessions_log SET transcript=? WHERE id=?').run(fname, id),
91
+  // Role-scoping is the caller's job: pass agentEmail to restrict to one agent (non-admins).
92
+  report: ({ tenantId, agentEmail, from, to }) => {
93
+    let sql = 'SELECT * FROM sessions_log WHERE team_id=?';
94
+    const args = [tenantId];
95
+    if (agentEmail) { sql += ' AND agent_email=?'; args.push(agentEmail); }
96
+    if (from) { sql += ' AND started_at>=?'; args.push(from); }
97
+    if (to) { sql += ' AND started_at<=?'; args.push(to); }
98
+    sql += ' ORDER BY started_at DESC LIMIT 500';
99
+    return db.prepare(sql).all(...args);
100
+  },
101
+};
102
+
103
+module.exports = { teams, users, authSessions, machines, audit, sessionsLog };

+ 339
- 0
server/routes.js Bestand weergeven

@@ -0,0 +1,339 @@
1
+// HTTP JSON API routes (auth, MFA, users, machines, report, audit, media uploads, SSO).
2
+// Returns a { "METHOD /path": handler } map consumed by server.js.
3
+const fs = require('fs');
4
+const path = require('path');
5
+const R = require('./repos');
6
+const A = require('./auth');
7
+const BZ = require('./bizgaze');
8
+const { now, json, readBody, parseCookies } = require('./lib');
9
+const { audit, currentUser } = require('./session');
10
+const { onlineAgents } = require('./presence');
11
+const { REC_DIR, TRANS_DIR, SESSION_TTL } = require('./config');
12
+
13
+const routes = {};
14
+const route = (method, p, fn) => (routes[`${method} ${p}`] = fn);
15
+
16
+// Register: creates a team + admin user. MFA must be set up before full access.
17
+route('POST', '/api/register', async (req, res) => {
18
+  const anyUser = R.users.anyExists();
19
+  if (anyUser && process.env.ALLOW_REGISTRATION !== '1')
20
+    return json(res, 403, { error: 'Registration is closed. Contact your administrator.' });
21
+  const { email, password, teamName } = await readBody(req);
22
+  if (!email || !password) return json(res, 400, { error: 'email and password required' });
23
+  if (R.users.emailExists(email))
24
+    return json(res, 409, { error: 'email already registered' });
25
+  const { hash, salt } = A.hashPassword(password);
26
+  const team = R.teams.create(teamName || `${email}'s team`);
27
+  const userId = R.users.create({ tenantId: team.id, email, hash, salt, role: 'admin', name: null, mfaSecret: A.newMfaSecret() });
28
+  audit({ team_id: team.id, user_id: userId, user_email: email, action: 'user_registered' });
29
+  json(res, 200, { ok: true });
30
+});
31
+
32
+// Verify MFA enrollment (confirm the user scanned the QR / entered code)
33
+route('POST', '/api/mfa/enable', async (req, res) => {
34
+  const { email, code } = await readBody(req);
35
+  const u = R.users.byEmail(email);
36
+  if (!u) return json(res, 404, { error: 'no such user' });
37
+  if (!A.verifyTotp(u.mfa_secret, code)) return json(res, 401, { error: 'invalid code' });
38
+  R.users.enableMfa(u.id);
39
+  json(res, 200, { ok: true });
40
+});
41
+
42
+// Provision (or refresh) a local user from a successful BizGaze identity check.
43
+// The local row exists so sessions, audit, and team-scoped data work; BizGaze stays
44
+// the source of truth for credentials (the local password is random + unused).
45
+function provisionFromBizgaze(email, bz) {
46
+  const existing = R.users.byEmail(email);
47
+  if (!existing) {
48
+    const team = R.teams.first() || R.teams.create('BizGaze');
49
+    const { hash, salt } = A.hashPassword(A.token());
50
+    const role = bz.isAdmin ? 'admin' : 'technician';
51
+    const id = R.users.create({ tenantId: team.id, email, hash, salt, role, name: bz.name || null, mfaSecret: A.newMfaSecret() });
52
+    audit({ team_id: team.id, user_id: id, user_email: email, action: 'sso_user_created', detail: 'via BizGaze' });
53
+    return R.users.byId(id);
54
+  }
55
+  if (bz.name && bz.name !== existing.name) R.users.setName(existing.id, bz.name);
56
+  return R.users.byId(existing.id);
57
+}
58
+
59
+// Login: validates locally first (seeded/bootstrap accounts), then against BizGaze
60
+// (the identity provider) when BIZGAZE_LOGIN_URL is configured. Sets a session cookie.
61
+route('POST', '/api/login', async (req, res) => {
62
+  const { email, password, remember } = await readBody(req);
63
+  if (!email || !password) return json(res, 400, { error: 'email and password required' });
64
+  const existing = R.users.byEmail(email);
65
+  if (existing && existing.active === 0) return json(res, 403, { error: 'This account has been deactivated' });
66
+
67
+  let u = (existing && A.verifyPassword(password, existing.pw_salt, existing.pw_hash)) ? existing : null;
68
+  if (!u) {
69
+    const bz = await BZ.validateLogin(email, password);
70
+    if (bz.ok) u = provisionFromBizgaze(email, bz);
71
+    else if (bz.error) return json(res, 503, { error: bz.error });
72
+  }
73
+  if (!u) return json(res, 401, { error: 'invalid credentials' });
74
+
75
+  const tok = A.token();
76
+  const ttl = remember ? 1000 * 60 * 60 * 24 * 30 : SESSION_TTL; // 30 days if remembered, else 24h
77
+  R.authSessions.create({ token: tok, userId: u.id, mfaPassed: true, ttl });
78
+  res.setHeader('Set-Cookie', `sid=${tok}; HttpOnly; Path=/; Max-Age=${ttl / 1000}`);
79
+  audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'login' });
80
+  // Cookie for the web app; token in the body for native desktop/mobile clients
81
+  // (they send it back as `Authorization: Bearer <token>`).
82
+  json(res, 200, { ok: true, mfaRequired: false, token: tok, expiresAt: now() + ttl });
83
+});
84
+
85
+// Login step 2: TOTP code -> marks session mfa_passed
86
+route('POST', '/api/login/mfa', async (req, res) => {
87
+  const { code } = await readBody(req);
88
+  const tok = parseCookies(req).sid;
89
+  const s = tok && R.authSessions.byToken(tok);
90
+  if (!s) return json(res, 401, { error: 'no session' });
91
+  const u = R.users.byId(s.user_id);
92
+  if (!A.verifyTotp(u.mfa_secret, code)) return json(res, 401, { error: 'invalid code' });
93
+  R.authSessions.markMfaPassed(tok);
94
+  audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'login' });
95
+  json(res, 200, { ok: true });
96
+});
97
+
98
+route('POST', '/api/logout', async (req, res) => {
99
+  const tok = parseCookies(req).sid;
100
+  if (tok) R.authSessions.deleteByToken(tok);
101
+  res.setHeader('Set-Cookie', 'sid=; HttpOnly; Path=/; Max-Age=0');
102
+  json(res, 200, { ok: true });
103
+});
104
+
105
+route('GET', '/api/setup-state', async (req, res) => {
106
+  const anyUser = R.users.anyExists();
107
+  json(res, 200, { registrationOpen: !anyUser || process.env.ALLOW_REGISTRATION === '1' });
108
+});
109
+
110
+// ICE servers for WebRTC. Always includes a public STUN; adds managed TURN if
111
+// configured via env (TURN_URLS comma-separated, TURN_USERNAME, TURN_CREDENTIAL).
112
+route('GET', '/api/ice', async (req, res) => {
113
+  const iceServers = [{ urls: 'stun:stun.l.google.com:19302' }];
114
+  if (process.env.TURN_URLS) {
115
+    iceServers.push({
116
+      urls: process.env.TURN_URLS.split(',').map((u) => u.trim()).filter(Boolean),
117
+      username: process.env.TURN_USERNAME || '',
118
+      credential: process.env.TURN_CREDENTIAL || '',
119
+    });
120
+  }
121
+  json(res, 200, { iceServers });
122
+});
123
+
124
+route('GET', '/api/me', async (req, res) => {
125
+  const u = currentUser(req);
126
+  if (!u) return json(res, 401, { error: 'unauthorized' });
127
+  json(res, 200, { id: u.id, email: u.email, role: u.role, teamId: u.team_id, name: u.name || null });
128
+});
129
+
130
+// ---------- BizGaze SSO: agent arrives already logged in ----------
131
+route('GET', '/sso', async (req, res) => {
132
+  if (!process.env.SSO_SECRET) { res.writeHead(503); return res.end('SSO not configured'); }
133
+  const q = new URLSearchParams(req.url.split('?')[1] || '');
134
+  const token = q.get('token') || '';
135
+  const [payloadB64, sig] = token.split('.');
136
+  const fail = (msg) => { res.writeHead(403, { 'Content-Type': 'text/plain' }); res.end(msg); };
137
+  if (!payloadB64 || !sig) return fail('Invalid SSO token');
138
+  const crypto = require('crypto');
139
+  const expect = crypto.createHmac('sha256', process.env.SSO_SECRET).update(payloadB64).digest('base64url');
140
+  const sigBuf = Buffer.from(sig), expBuf = Buffer.from(expect);
141
+  if (sigBuf.length !== expBuf.length || !crypto.timingSafeEqual(sigBuf, expBuf)) return fail('Invalid SSO signature');
142
+  let p; try { p = JSON.parse(Buffer.from(payloadB64, 'base64url').toString()); } catch { return fail('Invalid SSO payload'); }
143
+  if (!p.email || !p.exp || p.exp < Math.floor(now() / 1000)) return fail('SSO token expired');
144
+  let u = R.users.byEmail(p.email);
145
+  if (!u) {
146
+    const team = R.teams.first();
147
+    if (!team) return fail('No team configured');
148
+    const { hash, salt } = A.hashPassword(A.token());
149
+    const role = (p.role === 'admin' || p.role === 'viewer') ? p.role : 'technician';
150
+    const userId = R.users.create({ tenantId: team.id, email: p.email, hash, salt, role, name: p.name || null, mfaSecret: A.newMfaSecret() });
151
+    u = R.users.byId(userId);
152
+    audit({ team_id: team.id, user_id: userId, user_email: p.email, action: 'sso_user_created', detail: p.name || '' });
153
+  } else if (p.name && p.name !== u.name) {
154
+    R.users.setName(u.id, p.name);
155
+  }
156
+  if (u.active === 0) return fail('Account deactivated');
157
+  const tok = A.token();
158
+  R.authSessions.create({ token: tok, userId: u.id, mfaPassed: true, ttl: SESSION_TTL });
159
+  audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'login', detail: 'via BizGaze SSO' });
160
+  const dest = '/connect' + (p.ticket ? ('?ticket=' + encodeURIComponent(p.ticket)) : '');
161
+  res.writeHead(302, { 'Set-Cookie': `sid=${tok}; HttpOnly; Path=/; Max-Age=${SESSION_TTL / 1000}`, Location: dest });
162
+  res.end();
163
+});
164
+
165
+// Admin adds an agent login to their team
166
+route('POST', '/api/users', async (req, res) => {
167
+  const u = currentUser(req);
168
+  if (!u) return json(res, 401, { error: 'unauthorized' });
169
+  if (u.role !== 'admin') return json(res, 403, { error: 'only admins can add agents' });
170
+  const { email, password, name, role } = await readBody(req);
171
+  if (!email || !password) return json(res, 400, { error: 'email and temporary password required' });
172
+  if (R.users.emailExists(email))
173
+    return json(res, 409, { error: 'email already registered' });
174
+  const { hash, salt } = A.hashPassword(password);
175
+  const r = (role === 'admin' || role === 'viewer') ? role : 'technician';
176
+  const userId = R.users.create({ tenantId: u.team_id, email, hash, salt, role: r, name: name || null, mfaSecret: A.newMfaSecret() });
177
+  audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'agent_added', detail: email + ' (' + r + ')' });
178
+  json(res, 200, { ok: true, id: userId, email, role: r });
179
+});
180
+
181
+// List the team's agents
182
+route('GET', '/api/users', async (req, res) => {
183
+  const u = currentUser(req);
184
+  if (!u) return json(res, 401, { error: 'unauthorized' });
185
+  const rows = R.users.listByTenant(u.team_id);
186
+  json(res, 200, rows);
187
+});
188
+
189
+// First-login MFA self-setup: a logged-in (password ok) user who hasn't enabled MFA yet
190
+route('GET', '/api/mfa/setup', async (req, res) => {
191
+  const u = currentUser(req, { requireMfa: false });
192
+  if (!u) return json(res, 401, { error: 'unauthorized' });
193
+  if (u.mfa_enabled) return json(res, 400, { error: 'MFA already enabled' });
194
+  json(res, 200, { secret: u.mfa_secret, otpauthUrl: A.otpauthUrl(u.mfa_secret, u.email) });
195
+});
196
+
197
+// Admin manages an agent: reset password, rename, deactivate/activate, delete.
198
+route('POST', '/api/users/manage', async (req, res) => {
199
+  const u = currentUser(req);
200
+  if (!u) return json(res, 401, { error: 'unauthorized' });
201
+  if (u.role !== 'admin') return json(res, 403, { error: 'only admins can manage agents' });
202
+  const { id, action, password, name } = await readBody(req);
203
+  const target = R.users.inTenant(id, u.team_id);
204
+  if (!target) return json(res, 404, { error: 'no such agent' });
205
+  switch (action) {
206
+    case 'reset-password': {
207
+      if (!password || String(password).length < 8) return json(res, 400, { error: 'new password must be at least 8 characters' });
208
+      const { hash, salt } = A.hashPassword(password);
209
+      R.users.setPassword(target.id, hash, salt);
210
+      R.authSessions.deleteByUser(target.id); // force re-login
211
+      audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'agent_password_reset', detail: target.email });
212
+      return json(res, 200, { ok: true });
213
+    }
214
+    case 'rename': {
215
+      const clean = String(name || '').trim().slice(0, 60);
216
+      if (!clean) return json(res, 400, { error: 'name required' });
217
+      R.users.setName(target.id, clean);
218
+      audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'agent_renamed', detail: target.email + ' -> ' + clean });
219
+      return json(res, 200, { ok: true, name: clean });
220
+    }
221
+    case 'deactivate': {
222
+      if (target.id === u.id) return json(res, 400, { error: 'you cannot deactivate your own account' });
223
+      R.users.setActive(target.id, false);
224
+      R.authSessions.deleteByUser(target.id);
225
+      audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'agent_deactivated', detail: target.email });
226
+      return json(res, 200, { ok: true });
227
+    }
228
+    case 'activate': {
229
+      R.users.setActive(target.id, true);
230
+      audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'agent_activated', detail: target.email });
231
+      return json(res, 200, { ok: true });
232
+    }
233
+    case 'delete': {
234
+      if (target.id === u.id) return json(res, 400, { error: 'you cannot delete your own account' });
235
+      R.authSessions.deleteByUser(target.id);
236
+      R.users.remove(target.id);
237
+      audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'agent_deleted', detail: target.email });
238
+      return json(res, 200, { ok: true });
239
+    }
240
+    default: return json(res, 400, { error: 'unknown action' });
241
+  }
242
+});
243
+
244
+// Session report: one row per session, filterable by agent and date period
245
+route('GET', '/api/report', async (req, res) => {
246
+  const u = currentUser(req);
247
+  if (!u) return json(res, 401, { error: 'unauthorized' });
248
+  const q = new URLSearchParams(req.url.split('?')[1] || '');
249
+  // Admins see the whole team (and may filter by agent); everyone else sees only
250
+  // their own sessions, regardless of any agent filter passed.
251
+  const agentEmail = u.role !== 'admin' ? u.email : (q.get('agent') || null);
252
+  const from = q.get('from') ? new Date(q.get('from') + 'T00:00:00').getTime() : null;
253
+  const to = q.get('to') ? new Date(q.get('to') + 'T23:59:59').getTime() : null;
254
+  json(res, 200, R.sessionsLog.report({ tenantId: u.team_id, agentEmail, from, to }));
255
+});
256
+
257
+// List machines for the team (with live online status from signaling layer)
258
+route('GET', '/api/machines', async (req, res) => {
259
+  const u = currentUser(req);
260
+  if (!u) return json(res, 401, { error: 'unauthorized' });
261
+  const rows = R.machines.listByTenant(u.team_id);
262
+  json(res, 200, rows.map((m) => ({ ...m, online: onlineAgents.has(m.id) })));
263
+});
264
+
265
+// Create a machine enrollment token (admin/technician). Agent uses it to come online.
266
+route('POST', '/api/machines', async (req, res) => {
267
+  const u = currentUser(req);
268
+  if (!u) return json(res, 401, { error: 'unauthorized' });
269
+  if (u.role === 'viewer') return json(res, 403, { error: 'forbidden' });
270
+  const { name, unattended } = await readBody(req);
271
+  const enroll = A.token();
272
+  const mId = R.machines.create({ tenantId: u.team_id, name: name || 'Unnamed PC', enrollToken: enroll, unattended: !!unattended });
273
+  audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, machine_id: mId, machine_name: name, action: 'machine_enrolled' });
274
+  json(res, 200, { id: mId, enrollToken: enroll });
275
+});
276
+
277
+route('GET', '/api/audit', async (req, res) => {
278
+  const u = currentUser(req);
279
+  if (!u) return json(res, 401, { error: 'unauthorized' });
280
+  const rows = R.audit.listByTenant(u.team_id);
281
+  json(res, 200, rows);
282
+});
283
+
284
+// ---------- session recording: upload (agent) ----------
285
+const MAX_REC_BYTES = 500 * 1024 * 1024; // 500 MB safety cap
286
+route('POST', '/api/recording', async (req, res) => {
287
+  const u = currentUser(req);
288
+  if (!u) return json(res, 401, { error: 'unauthorized' });
289
+  const params = new URLSearchParams(req.url.split('?')[1] || '');
290
+  const sid = params.get('sessionId');
291
+  const ext = params.get('ext') === 'mp4' ? 'mp4' : 'webm'; // container chosen by the recorder
292
+  if (!sid) return json(res, 400, { error: 'sessionId required' });
293
+  const row = R.sessionsLog.byIdInTenant(sid, u.team_id);
294
+  if (!row) return json(res, 404, { error: 'no such session' });
295
+  const chunks = []; let total = 0, aborted = false;
296
+  req.on('data', (c) => { total += c.length; if (total > MAX_REC_BYTES) { aborted = true; req.destroy(); return; } chunks.push(c); });
297
+  req.on('end', () => {
298
+    if (aborted) return json(res, 413, { error: 'recording too large' });
299
+    const fname = sid + '.' + ext;
300
+    try {
301
+      fs.writeFileSync(path.join(REC_DIR, fname), Buffer.concat(chunks));
302
+      R.sessionsLog.setRecording(sid, fname);
303
+      audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'recording_saved', detail: 'session ' + sid });
304
+      json(res, 200, { ok: true });
305
+    } catch (e) { json(res, 500, { error: 'could not save recording' }); }
306
+  });
307
+  req.on('error', () => { try { res.end(); } catch (e) {} });
308
+});
309
+
310
+route('POST', '/api/transcript', async (req, res) => {
311
+  const u = currentUser(req);
312
+  if (!u) return json(res, 401, { error: 'unauthorized' });
313
+  const sid = new URLSearchParams(req.url.split('?')[1] || '').get('sessionId');
314
+  if (!sid) return json(res, 400, { error: 'sessionId required' });
315
+  const row = R.sessionsLog.byIdInTenant(sid, u.team_id);
316
+  if (!row) return json(res, 404, { error: 'no such session' });
317
+  const chunks = []; let total = 0, aborted = false;
318
+  req.on('data', (c) => { total += c.length; if (total > 5 * 1024 * 1024) { aborted = true; req.destroy(); return; } chunks.push(c); });
319
+  req.on('end', () => {
320
+    if (aborted) return json(res, 413, { error: 'transcript too large' });
321
+    const fname = sid + '.txt';
322
+    try {
323
+      fs.writeFileSync(path.join(TRANS_DIR, fname), Buffer.concat(chunks));
324
+      R.sessionsLog.setTranscript(sid, fname);
325
+      json(res, 200, { ok: true });
326
+    } catch (e) { json(res, 500, { error: 'could not save transcript' }); }
327
+  });
328
+  req.on('error', () => { try { res.end(); } catch (e) {} });
329
+});
330
+
331
+// API versioning: alias every /api/* route under /api/v1/* — a frozen contract for
332
+// native desktop/mobile clients. The web app keeps using the unversioned paths, and
333
+// both share the same handlers. (/sso is a browser redirect, intentionally unversioned.)
334
+for (const key of Object.keys(routes)) {
335
+  const m = key.match(/^(\S+) \/api\/(.+)$/);
336
+  if (m) routes[`${m[1]} /api/v1/${m[2]}`] = routes[key];
337
+}
338
+
339
+module.exports = routes;

+ 20
- 593
server/server.js Bestand weergeven

@@ -1,610 +1,37 @@
1
-// Remote Access Platform — backend server
2
-// HTTP JSON API (auth, MFA, machines, audit) + authenticated WebSocket signaling.
1
+// BizGaze Connect — backend entry point.
2
+// Thin wiring layer: HTTP request dispatch + WebSocket attach + listeners.
3
+// All logic lives in focused modules:
4
+//   repos.js      data-access (all SQL)
5
+//   bizgaze.js    BizGaze identity provider
6
+//   lib.js        HTTP helpers (json/readBody/parseCookies/now)
7
+//   session.js    currentUser / audit
8
+//   presence.js   shared in-memory live state (agents/sessions/shares)
9
+//   routes.js     HTTP JSON API (/api/*, /sso)
10
+//   static.js     static files + authenticated downloads (GET fallback)
11
+//   signaling.js  WebSocket signaling (consent + SDP/ICE relay)
3 12
 const http = require('http');
4 13
 const https = require('https');
5 14
 const fs = require('fs');
6 15
 const path = require('path');
7 16
 const { WebSocketServer } = require('ws');
8
-const db = require('./db');
9
-const A = require('./auth');
10
-
11
-const PORT = process.env.PORT || 8090;
12
-const HTTPS_PORT = process.env.HTTPS_PORT || 8443;
13
-const PUBLIC_DIR = path.join(__dirname, 'public');
14
-const REC_DIR = path.join(__dirname, 'recordings');
15
-try { fs.mkdirSync(REC_DIR, { recursive: true }); } catch (e) {}
16
-const TRANS_DIR = path.join(__dirname, 'transcripts');
17
-try { fs.mkdirSync(TRANS_DIR, { recursive: true }); } catch (e) {}
18
-const SESSION_TTL = 1000 * 60 * 60 * 24; // 24h auto-logout
19
-
20
-// ---------- helpers ----------
21
-const now = () => Date.now();
22
-const json = (res, code, body) => {
23
-  res.writeHead(code, { 'Content-Type': 'application/json' });
24
-  res.end(JSON.stringify(body));
25
-};
26
-function readBody(req) {
27
-  return new Promise((resolve) => {
28
-    let data = '';
29
-    req.on('data', (c) => (data += c));
30
-    req.on('end', () => {
31
-      try { resolve(data ? JSON.parse(data) : {}); } catch { resolve({}); }
32
-    });
33
-  });
34
-}
35
-function parseCookies(req) {
36
-  const out = {};
37
-  (req.headers.cookie || '').split(';').forEach((c) => {
38
-    const [k, ...v] = c.trim().split('=');
39
-    if (k) out[k] = decodeURIComponent(v.join('='));
40
-  });
41
-  return out;
42
-}
43
-function audit(entry) {
44
-  db.prepare(
45
-    `INSERT INTO audit_log (team_id,user_id,user_email,machine_id,machine_name,action,detail,at)
46
-     VALUES (@team_id,@user_id,@user_email,@machine_id,@machine_name,@action,@detail,@at)`
47
-  ).run({
48
-    team_id: entry.team_id, user_id: entry.user_id || null, user_email: entry.user_email || null,
49
-    machine_id: entry.machine_id || null, machine_name: entry.machine_name || null,
50
-    action: entry.action, detail: entry.detail || null, at: now(),
51
-  });
52
-}
53
-
54
-// Resolve the logged-in user from the session cookie. Returns user row (with mfa state) or null.
55
-function currentUser(req, { requireMfa = true } = {}) {
56
-  const tok = parseCookies(req).sid;
57
-  if (!tok) return null;
58
-  const s = db.prepare('SELECT * FROM sessions_auth WHERE token=?').get(tok);
59
-  if (!s || s.expires_at < now()) return null;
60
-  if (requireMfa && !s.mfa_passed) return null;
61
-  const u = db.prepare('SELECT * FROM users WHERE id=?').get(s.user_id);
62
-  if (!u || u.active === 0) return null;
63
-  return { ...u, _session: s };
64
-}
65
-
66
-// ---------- HTTP API ----------
67
-const routes = {};
68
-const route = (method, p, fn) => (routes[`${method} ${p}`] = fn);
69
-
70
-// Register: creates a team + admin user. MFA must be set up before full access.
71
-route('POST', '/api/register', async (req, res) => {
72
-  const anyUser = db.prepare('SELECT 1 FROM users LIMIT 1').get();
73
-  if (anyUser && process.env.ALLOW_REGISTRATION !== '1')
74
-    return json(res, 403, { error: 'Registration is closed. Contact your administrator.' });
75
-  const { email, password, teamName } = await readBody(req);
76
-  if (!email || !password) return json(res, 400, { error: 'email and password required' });
77
-  if (db.prepare('SELECT 1 FROM users WHERE email=? COLLATE NOCASE').get(email))
78
-    return json(res, 409, { error: 'email already registered' });
79
-  const teamId = A.id(), userId = A.id();
80
-  const { hash, salt } = A.hashPassword(password);
81
-  const mfaSecret = A.newMfaSecret();
82
-  db.prepare('INSERT INTO teams (id,name,created_at) VALUES (?,?,?)')
83
-    .run(teamId, teamName || `${email}'s team`, now());
84
-  db.prepare(`INSERT INTO users (id,team_id,email,pw_hash,pw_salt,role,mfa_secret,mfa_enabled,created_at)
85
-              VALUES (?,?,?,?,?,?,?,0,?)`)
86
-    .run(userId, teamId, email, hash, salt, 'admin', mfaSecret, now());
87
-  audit({ team_id: teamId, user_id: userId, user_email: email, action: 'user_registered' });
88
-  json(res, 200, { ok: true });
89
-});
90
-
91
-// Verify MFA enrollment (confirm the user scanned the QR / entered code)
92
-route('POST', '/api/mfa/enable', async (req, res) => {
93
-  const { email, code } = await readBody(req);
94
-  const u = db.prepare('SELECT * FROM users WHERE email=? COLLATE NOCASE').get(email);
95
-  if (!u) return json(res, 404, { error: 'no such user' });
96
-  if (!A.verifyTotp(u.mfa_secret, code)) return json(res, 401, { error: 'invalid code' });
97
-  db.prepare('UPDATE users SET mfa_enabled=1 WHERE id=?').run(u.id);
98
-  json(res, 200, { ok: true });
99
-});
100
-
101
-// Login step 1: email + password -> sets a session cookie (mfa not yet passed)
102
-route('POST', '/api/login', async (req, res) => {
103
-  const { email, password, remember } = await readBody(req);
104
-  const u = db.prepare('SELECT * FROM users WHERE email=? COLLATE NOCASE').get(email);
105
-  if (!u || !A.verifyPassword(password, u.pw_salt, u.pw_hash))
106
-    return json(res, 401, { error: 'invalid credentials' });
107
-  if (u.active === 0) return json(res, 403, { error: 'This account has been deactivated' });
108
-  const tok = A.token();
109
-  const ttl = remember ? 1000 * 60 * 60 * 24 * 30 : SESSION_TTL; // 30 days if remembered, else 24h
110
-  db.prepare('INSERT INTO sessions_auth (token,user_id,mfa_passed,created_at,expires_at) VALUES (?,?,1,?,?)')
111
-    .run(tok, u.id, now(), now() + ttl);
112
-  res.setHeader('Set-Cookie', `sid=${tok}; HttpOnly; Path=/; Max-Age=${ttl / 1000}`);
113
-  audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'login' });
114
-  json(res, 200, { ok: true, mfaRequired: false });
115
-});
116
-
117
-// Login step 2: TOTP code -> marks session mfa_passed
118
-route('POST', '/api/login/mfa', async (req, res) => {
119
-  const { code } = await readBody(req);
120
-  const tok = parseCookies(req).sid;
121
-  const s = tok && db.prepare('SELECT * FROM sessions_auth WHERE token=?').get(tok);
122
-  if (!s) return json(res, 401, { error: 'no session' });
123
-  const u = db.prepare('SELECT * FROM users WHERE id=?').get(s.user_id);
124
-  if (!A.verifyTotp(u.mfa_secret, code)) return json(res, 401, { error: 'invalid code' });
125
-  db.prepare('UPDATE sessions_auth SET mfa_passed=1 WHERE token=?').run(tok);
126
-  audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'login' });
127
-  json(res, 200, { ok: true });
128
-});
129
-
130
-route('POST', '/api/logout', async (req, res) => {
131
-  const tok = parseCookies(req).sid;
132
-  if (tok) db.prepare('DELETE FROM sessions_auth WHERE token=?').run(tok);
133
-  res.setHeader('Set-Cookie', 'sid=; HttpOnly; Path=/; Max-Age=0');
134
-  json(res, 200, { ok: true });
135
-});
136
-
137
-route('GET', '/api/setup-state', async (req, res) => {
138
-  const anyUser = db.prepare('SELECT 1 FROM users LIMIT 1').get();
139
-  json(res, 200, { registrationOpen: !anyUser || process.env.ALLOW_REGISTRATION === '1' });
140
-});
141
-
142
-// ICE servers for WebRTC. Always includes a public STUN; adds managed TURN if
143
-// configured via env (TURN_URLS comma-separated, TURN_USERNAME, TURN_CREDENTIAL).
144
-// Sign up for a managed TURN service (Cloudflare / Metered / Twilio) and set these
145
-// three env vars — nothing to install or run on your side.
146
-route('GET', '/api/ice', async (req, res) => {
147
-  const iceServers = [{ urls: 'stun:stun.l.google.com:19302' }];
148
-  if (process.env.TURN_URLS) {
149
-    iceServers.push({
150
-      urls: process.env.TURN_URLS.split(',').map((u) => u.trim()).filter(Boolean),
151
-      username: process.env.TURN_USERNAME || '',
152
-      credential: process.env.TURN_CREDENTIAL || '',
153
-    });
154
-  }
155
-  json(res, 200, { iceServers });
156
-});
157
-
158
-route('GET', '/api/me', async (req, res) => {
159
-  const u = currentUser(req);
160
-  if (!u) return json(res, 401, { error: 'unauthorized' });
161
-  json(res, 200, { id: u.id, email: u.email, role: u.role, teamId: u.team_id, name: u.name || null });
162
-});
163
-
164
-// ---------- BizGaze SSO: agent arrives already logged in ----------
165
-route('GET', '/sso', async (req, res) => {
166
-  if (!process.env.SSO_SECRET) { res.writeHead(503); return res.end('SSO not configured'); }
167
-  const q = new URLSearchParams(req.url.split('?')[1] || '');
168
-  const token = q.get('token') || '';
169
-  const [payloadB64, sig] = token.split('.');
170
-  const fail = (msg) => { res.writeHead(403, { 'Content-Type': 'text/plain' }); res.end(msg); };
171
-  if (!payloadB64 || !sig) return fail('Invalid SSO token');
172
-  const crypto = require('crypto');
173
-  const expect = crypto.createHmac('sha256', process.env.SSO_SECRET).update(payloadB64).digest('base64url');
174
-  const sigBuf = Buffer.from(sig), expBuf = Buffer.from(expect);
175
-  if (sigBuf.length !== expBuf.length || !crypto.timingSafeEqual(sigBuf, expBuf)) return fail('Invalid SSO signature');
176
-  let p; try { p = JSON.parse(Buffer.from(payloadB64, 'base64url').toString()); } catch { return fail('Invalid SSO payload'); }
177
-  if (!p.email || !p.exp || p.exp < Math.floor(now() / 1000)) return fail('SSO token expired');
178
-  let u = db.prepare('SELECT * FROM users WHERE email=? COLLATE NOCASE').get(p.email);
179
-  if (!u) {
180
-    const team = db.prepare('SELECT * FROM teams LIMIT 1').get();
181
-    if (!team) return fail('No team configured');
182
-    const userId = A.id();
183
-    const { hash, salt } = A.hashPassword(A.token());
184
-    const role = (p.role === 'admin' || p.role === 'viewer') ? p.role : 'technician';
185
-    db.prepare(`INSERT INTO users (id,team_id,email,pw_hash,pw_salt,role,name,mfa_secret,mfa_enabled,created_at)
186
-                VALUES (?,?,?,?,?,?,?,?,0,?)`)
187
-      .run(userId, team.id, p.email, hash, salt, role, (p.name || null), A.newMfaSecret(), now());
188
-    u = db.prepare('SELECT * FROM users WHERE id=?').get(userId);
189
-    audit({ team_id: team.id, user_id: userId, user_email: p.email, action: 'sso_user_created', detail: p.name || '' });
190
-  } else if (p.name && p.name !== u.name) {
191
-    db.prepare('UPDATE users SET name=? WHERE id=?').run(p.name, u.id);
192
-  }
193
-  if (u.active === 0) return fail('Account deactivated');
194
-  const tok = A.token();
195
-  db.prepare('INSERT INTO sessions_auth (token,user_id,mfa_passed,created_at,expires_at) VALUES (?,?,1,?,?)')
196
-    .run(tok, u.id, now(), now() + SESSION_TTL);
197
-  audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'login', detail: 'via BizGaze SSO' });
198
-  const dest = '/connect' + (p.ticket ? ('?ticket=' + encodeURIComponent(p.ticket)) : '');
199
-  res.writeHead(302, { 'Set-Cookie': `sid=${tok}; HttpOnly; Path=/; Max-Age=${SESSION_TTL / 1000}`, Location: dest });
200
-  res.end();
201
-});
202
-
203
-// Admin adds an agent login to their team
204
-route('POST', '/api/users', async (req, res) => {
205
-  const u = currentUser(req);
206
-  if (!u) return json(res, 401, { error: 'unauthorized' });
207
-  if (u.role !== 'admin') return json(res, 403, { error: 'only admins can add agents' });
208
-  const { email, password, name, role } = await readBody(req);
209
-  if (!email || !password) return json(res, 400, { error: 'email and temporary password required' });
210
-  if (db.prepare('SELECT 1 FROM users WHERE email=? COLLATE NOCASE').get(email))
211
-    return json(res, 409, { error: 'email already registered' });
212
-  const userId = A.id();
213
-  const { hash, salt } = A.hashPassword(password);
214
-  const mfaSecret = A.newMfaSecret();
215
-  const r = (role === 'admin' || role === 'viewer') ? role : 'technician';
216
-  db.prepare(`INSERT INTO users (id,team_id,email,pw_hash,pw_salt,role,name,mfa_secret,mfa_enabled,created_at)
217
-              VALUES (?,?,?,?,?,?,?,?,0,?)`)
218
-    .run(userId, u.team_id, email, hash, salt, r, (name || null), mfaSecret, now());
219
-  audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'agent_added', detail: email + ' (' + r + ')' });
220
-  json(res, 200, { ok: true, id: userId, email, role: r });
221
-});
222
-
223
-// List the team's agents
224
-route('GET', '/api/users', async (req, res) => {
225
-  const u = currentUser(req);
226
-  if (!u) return json(res, 401, { error: 'unauthorized' });
227
-  const rows = db.prepare('SELECT id,email,name,role,active,created_at FROM users WHERE team_id=?').all(u.team_id);
228
-  json(res, 200, rows);
229
-});
230
-
231
-// First-login MFA self-setup: a logged-in (password ok) user who hasn't enabled MFA yet
232
-route('GET', '/api/mfa/setup', async (req, res) => {
233
-  const u = currentUser(req, { requireMfa: false });
234
-  if (!u) return json(res, 401, { error: 'unauthorized' });
235
-  if (u.mfa_enabled) return json(res, 400, { error: 'MFA already enabled' });
236
-  json(res, 200, { secret: u.mfa_secret, otpauthUrl: A.otpauthUrl(u.mfa_secret, u.email) });
237
-});
238
-
239
-// Admin manages an agent: reset password, rename, deactivate/activate, delete.
240
-// (Display names are owned by the admin/BizGaze app — agents cannot edit them.)
241
-route('POST', '/api/users/manage', async (req, res) => {
242
-  const u = currentUser(req);
243
-  if (!u) return json(res, 401, { error: 'unauthorized' });
244
-  if (u.role !== 'admin') return json(res, 403, { error: 'only admins can manage agents' });
245
-  const { id, action, password, name } = await readBody(req);
246
-  const target = db.prepare('SELECT * FROM users WHERE id=? AND team_id=?').get(id, u.team_id);
247
-  if (!target) return json(res, 404, { error: 'no such agent' });
248
-  switch (action) {
249
-    case 'reset-password': {
250
-      if (!password || String(password).length < 8) return json(res, 400, { error: 'new password must be at least 8 characters' });
251
-      const { hash, salt } = A.hashPassword(password);
252
-      db.prepare('UPDATE users SET pw_hash=?, pw_salt=? WHERE id=?').run(hash, salt, target.id);
253
-      db.prepare('DELETE FROM sessions_auth WHERE user_id=?').run(target.id); // force re-login
254
-      audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'agent_password_reset', detail: target.email });
255
-      return json(res, 200, { ok: true });
256
-    }
257
-    case 'rename': {
258
-      const clean = String(name || '').trim().slice(0, 60);
259
-      if (!clean) return json(res, 400, { error: 'name required' });
260
-      db.prepare('UPDATE users SET name=? WHERE id=?').run(clean, target.id);
261
-      audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'agent_renamed', detail: target.email + ' -> ' + clean });
262
-      return json(res, 200, { ok: true, name: clean });
263
-    }
264
-    case 'deactivate': {
265
-      if (target.id === u.id) return json(res, 400, { error: 'you cannot deactivate your own account' });
266
-      db.prepare('UPDATE users SET active=0 WHERE id=?').run(target.id);
267
-      db.prepare('DELETE FROM sessions_auth WHERE user_id=?').run(target.id);
268
-      audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'agent_deactivated', detail: target.email });
269
-      return json(res, 200, { ok: true });
270
-    }
271
-    case 'activate': {
272
-      db.prepare('UPDATE users SET active=1 WHERE id=?').run(target.id);
273
-      audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'agent_activated', detail: target.email });
274
-      return json(res, 200, { ok: true });
275
-    }
276
-    case 'delete': {
277
-      if (target.id === u.id) return json(res, 400, { error: 'you cannot delete your own account' });
278
-      db.prepare('DELETE FROM sessions_auth WHERE user_id=?').run(target.id);
279
-      db.prepare('DELETE FROM users WHERE id=?').run(target.id);
280
-      audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'agent_deleted', detail: target.email });
281
-      return json(res, 200, { ok: true });
282
-    }
283
-    default: return json(res, 400, { error: 'unknown action' });
284
-  }
285
-});
286
-
287
-// Session report: one row per session, filterable by agent and date period
288
-route('GET', '/api/report', async (req, res) => {
289
-  const u = currentUser(req);
290
-  if (!u) return json(res, 401, { error: 'unauthorized' });
291
-  const q = new URLSearchParams(req.url.split('?')[1] || '');
292
-  let sql = 'SELECT * FROM sessions_log WHERE team_id=?';
293
-  const args = [u.team_id];
294
-  if (q.get('agent')) { sql += ' AND agent_email=?'; args.push(q.get('agent')); }
295
-  if (q.get('from')) { sql += ' AND started_at>=?'; args.push(new Date(q.get('from') + 'T00:00:00').getTime()); }
296
-  if (q.get('to')) { sql += ' AND started_at<=?'; args.push(new Date(q.get('to') + 'T23:59:59').getTime()); }
297
-  sql += ' ORDER BY started_at DESC LIMIT 500';
298
-  json(res, 200, db.prepare(sql).all(...args));
299
-});
300
-
301
-// List machines for the team (with live online status from signaling layer)
302
-route('GET', '/api/machines', async (req, res) => {
303
-  const u = currentUser(req);
304
-  if (!u) return json(res, 401, { error: 'unauthorized' });
305
-  const rows = db.prepare('SELECT id,name,unattended,last_seen FROM machines WHERE team_id=?').all(u.team_id);
306
-  json(res, 200, rows.map((m) => ({ ...m, online: onlineAgents.has(m.id) })));
307
-});
308
-
309
-// Create a machine enrollment token (admin/technician). Agent uses it to come online.
310
-route('POST', '/api/machines', async (req, res) => {
311
-  const u = currentUser(req);
312
-  if (!u) return json(res, 401, { error: 'unauthorized' });
313
-  if (u.role === 'viewer') return json(res, 403, { error: 'forbidden' });
314
-  const { name, unattended } = await readBody(req);
315
-  const mId = A.id(), enroll = A.token();
316
-  db.prepare('INSERT INTO machines (id,team_id,name,enroll_token,unattended,created_at) VALUES (?,?,?,?,?,?)')
317
-    .run(mId, u.team_id, name || 'Unnamed PC', enroll, unattended ? 1 : 0, now());
318
-  audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, machine_id: mId, machine_name: name, action: 'machine_enrolled' });
319
-  json(res, 200, { id: mId, enrollToken: enroll });
320
-});
321
-
322
-route('GET', '/api/audit', async (req, res) => {
323
-  const u = currentUser(req);
324
-  if (!u) return json(res, 401, { error: 'unauthorized' });
325
-  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);
326
-  json(res, 200, rows);
327
-});
328
-
329
-// ---------- session recording: upload (agent) + download (team) ----------
330
-const MAX_REC_BYTES = 500 * 1024 * 1024; // 500 MB safety cap
331
-route('POST', '/api/recording', async (req, res) => {
332
-  const u = currentUser(req);
333
-  if (!u) return json(res, 401, { error: 'unauthorized' });
334
-  const sid = new URLSearchParams(req.url.split('?')[1] || '').get('sessionId');
335
-  if (!sid) return json(res, 400, { error: 'sessionId required' });
336
-  const row = db.prepare('SELECT * FROM sessions_log WHERE id=? AND team_id=?').get(sid, u.team_id);
337
-  if (!row) return json(res, 404, { error: 'no such session' });
338
-  const chunks = []; let total = 0, aborted = false;
339
-  req.on('data', (c) => { total += c.length; if (total > MAX_REC_BYTES) { aborted = true; req.destroy(); return; } chunks.push(c); });
340
-  req.on('end', () => {
341
-    if (aborted) return json(res, 413, { error: 'recording too large' });
342
-    const fname = sid + '.webm';
343
-    try {
344
-      fs.writeFileSync(path.join(REC_DIR, fname), Buffer.concat(chunks));
345
-      db.prepare('UPDATE sessions_log SET recording=? WHERE id=?').run(fname, sid);
346
-      audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'recording_saved', detail: 'session ' + sid });
347
-      json(res, 200, { ok: true });
348
-    } catch (e) { json(res, 500, { error: 'could not save recording' }); }
349
-  });
350
-  req.on('error', () => { try { res.end(); } catch (e) {} });
351
-});
352
-
353
-route('POST', '/api/transcript', async (req, res) => {
354
-  const u = currentUser(req);
355
-  if (!u) return json(res, 401, { error: 'unauthorized' });
356
-  const sid = new URLSearchParams(req.url.split('?')[1] || '').get('sessionId');
357
-  if (!sid) return json(res, 400, { error: 'sessionId required' });
358
-  const row = db.prepare('SELECT * FROM sessions_log WHERE id=? AND team_id=?').get(sid, u.team_id);
359
-  if (!row) return json(res, 404, { error: 'no such session' });
360
-  const chunks = []; let total = 0, aborted = false;
361
-  req.on('data', (c) => { total += c.length; if (total > 5 * 1024 * 1024) { aborted = true; req.destroy(); return; } chunks.push(c); });
362
-  req.on('end', () => {
363
-    if (aborted) return json(res, 413, { error: 'transcript too large' });
364
-    const fname = sid + '.txt';
365
-    try {
366
-      fs.writeFileSync(path.join(TRANS_DIR, fname), Buffer.concat(chunks));
367
-      db.prepare('UPDATE sessions_log SET transcript=? WHERE id=?').run(fname, sid);
368
-      json(res, 200, { ok: true });
369
-    } catch (e) { json(res, 500, { error: 'could not save transcript' }); }
370
-  });
371
-  req.on('error', () => { try { res.end(); } catch (e) {} });
372
-});
373
-
374
-// ---------- static + router ----------
375
-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' };
376
-function serveStatic(req, res) {
377
-  let p = req.url.split('?')[0];
378
-  if (p === '/') p = '/index.html';
379
-  if (p === '/console') p = '/console.html';
380
-  if (p === '/share') p = '/share.html';
381
-  if (p === '/connect') p = '/connect.html';
382
-  const fp = path.join(PUBLIC_DIR, path.normalize(p));
383
-  if (!fp.startsWith(PUBLIC_DIR)) return json(res, 403, { error: 'forbidden' });
384
-  fs.readFile(fp, (err, data) => {
385
-    if (err) return json(res, 404, { error: 'not found' });
386
-    const ct = MIME[path.extname(fp)] || 'application/octet-stream';
387
-    res.writeHead(200, { 'Content-Type': ct, 'Cache-Control': 'no-cache' });
388
-    res.end(data);
389
-  });
390
-}
391
-
392
-const server = http.createServer(async (req, res) => {
17
+const { PORT, HTTPS_PORT } = require('./config');
18
+const { json } = require('./lib');
19
+const routes = require('./routes');
20
+const { handleGet } = require('./static');
21
+const { onConnection } = require('./signaling');
22
+
23
+// ---------- HTTP request dispatch ----------
24
+const server = http.createServer((req, res) => {
393 25
   const key = `${req.method} ${req.url.split('?')[0]}`;
394 26
   if (routes[key]) return routes[key](req, res);
395
-  if (req.method === 'GET' && req.url.split('?')[0].startsWith('/transcripts/')) {
396
-    const u = currentUser(req);
397
-    if (!u) return json(res, 401, { error: 'unauthorized' });
398
-    const name = path.basename(decodeURIComponent(req.url.split('?')[0]));
399
-    const sid = name.replace(/\.txt$/i, '');
400
-    const row = db.prepare('SELECT * FROM sessions_log WHERE id=? AND team_id=?').get(sid, u.team_id);
401
-    if (!row || !row.transcript) return json(res, 404, { error: 'not found' });
402
-    const fp = path.join(TRANS_DIR, row.transcript);
403
-    if (!fp.startsWith(TRANS_DIR)) return json(res, 403, { error: 'forbidden' });
404
-    return fs.stat(fp, (err, st) => {
405
-      if (err) return json(res, 404, { error: 'not found' });
406
-      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' });
407
-      const rs = fs.createReadStream(fp);
408
-      rs.on('error', () => { try { res.destroy(); } catch (e) {} });
409
-      rs.pipe(res);
410
-    });
411
-  }
412
-  if (req.method === 'GET' && req.url.split('?')[0].startsWith('/recordings/')) {
413
-    const u = currentUser(req);
414
-    if (!u) return json(res, 401, { error: 'unauthorized' });
415
-    const name = path.basename(decodeURIComponent(req.url.split('?')[0]));
416
-    const sid = name.replace(/\.webm$/i, '');
417
-    const row = db.prepare('SELECT * FROM sessions_log WHERE id=? AND team_id=?').get(sid, u.team_id);
418
-    if (!row || !row.recording) return json(res, 404, { error: 'not found' });
419
-    const fp = path.join(REC_DIR, row.recording);
420
-    if (!fp.startsWith(REC_DIR)) return json(res, 403, { error: 'forbidden' });
421
-    return fs.stat(fp, (err, st) => {
422
-      if (err) return json(res, 404, { error: 'not found' });
423
-      res.writeHead(200, { 'Content-Type': 'video/webm', 'Content-Length': st.size, 'Content-Disposition': 'attachment; filename="session-' + sid + '.webm"', 'Cache-Control': 'no-store', 'Accept-Ranges': 'bytes' });
424
-      const rs = fs.createReadStream(fp);
425
-      rs.on('error', () => { try { res.destroy(); } catch (e) {} });
426
-      rs.pipe(res);
427
-    });
428
-  }
429
-  if (req.method === 'GET') return serveStatic(req, res);
27
+  if (req.method === 'GET') return handleGet(req, res); // downloads + static
430 28
   json(res, 404, { error: 'not found' });
431 29
 });
432 30
 
433 31
 // ---------- WebSocket signaling ----------
434
-// Two kinds of WS clients:
435
-//   agent  -> authenticates with machine enroll_token, waits for session requests
436
-//   viewer -> authenticated technician, requests a session to a machine
437
-// The server brokers consent and relays SDP/ICE. Media never traverses the server.
438
-const onlineAgents = new Map();   // machineId -> { ws, machine }
439
-const liveSessions = new Map();   // sessionId -> { agentWs, viewerWs, machine, user }
440
-const pendingShares = new Map();  // code -> { sharerWs, sessionId } (no-install ad-hoc shares)
441
-
442
-function onConnection(ws, req) {
443
-  const hb = setInterval(() => {
444
-    if (ws.readyState === 1) { try { ws.ping(); } catch {} } else { clearInterval(hb); }
445
-  }, 25000);
446
-  ws.on('message', (raw) => {
447
-    let m; try { m = JSON.parse(raw); } catch { return; }
448
-    handle(ws, m, req);
449
-  });
450
-  ws.on('close', () => { clearInterval(hb); cleanup(ws); });
451
-}
452
-
453 32
 const wss = new WebSocketServer({ server, path: '/ws' });
454 33
 wss.on('connection', onConnection);
455 34
 
456
-function handle(ws, m, req) {
457
-  switch (m.type) {
458
-    // --- Agent comes online ---
459
-    case 'agent-hello': {
460
-      const machine = db.prepare('SELECT * FROM machines WHERE enroll_token=?').get(m.enrollToken);
461
-      if (!machine) return ws.send(JSON.stringify({ type: 'error', message: 'invalid enroll token' }));
462
-      ws.kind = 'agent'; ws.machineId = machine.id;
463
-      onlineAgents.set(machine.id, { ws, machine });
464
-      db.prepare('UPDATE machines SET last_seen=? WHERE id=?').run(now(), machine.id);
465
-      ws.send(JSON.stringify({ type: 'agent-registered', machineId: machine.id, name: machine.name }));
466
-      break;
467
-    }
468
-    // --- Technician requests control of a machine ---
469
-    case 'viewer-connect': {
470
-      const u = currentUser(req); // cookie sent on WS upgrade
471
-      if (!u) return ws.send(JSON.stringify({ type: 'error', message: 'unauthorized' }));
472
-      const agent = onlineAgents.get(m.machineId);
473
-      const machine = db.prepare('SELECT * FROM machines WHERE id=? AND team_id=?').get(m.machineId, u.team_id);
474
-      if (!machine) return ws.send(JSON.stringify({ type: 'error', message: 'no such machine' }));
475
-      if (!agent) return ws.send(JSON.stringify({ type: 'error', message: 'machine offline' }));
476
-      if (u.role === 'viewer' && false) {} // view-only still allowed to watch; control gated agent-side
477
-      const sessionId = A.token(8);
478
-      ws.kind = 'viewer'; ws.sessionId = sessionId;
479
-      liveSessions.set(sessionId, { agentWs: agent.ws, viewerWs: ws, machine, user: u });
480
-      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' });
481
-      // Ask the agent for consent (or auto-grant if unattended policy is on)
482
-      agent.ws.sessionId = sessionId;
483
-      agent.ws.send(JSON.stringify({
484
-        type: 'session-request', sessionId,
485
-        technician: u.email, unattended: !!machine.unattended,
486
-      }));
487
-      ws.send(JSON.stringify({ type: 'session-pending', sessionId, machineName: machine.name }));
488
-      break;
489
-    }
490
-    // --- Agent grants/denies consent ---
491
-    case 'consent': {
492
-      const sess = liveSessions.get(m.sessionId);
493
-      if (!sess) return;
494
-      if (m.granted) {
495
-        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') });
496
-        try {
497
-          db.prepare('INSERT INTO sessions_log (id,team_id,agent_email,agent_name,ticket,started_at) VALUES (?,?,?,?,?,?)')
498
-            .run(m.sessionId, sess.machine.team_id, sess.user.email, (sess.agentName || sess.user.email), sess.ticket || null, now());
499
-        } catch (e) { /* duplicate consent */ }
500
-        sess.viewerWs.send(JSON.stringify({ type: 'session-ready', sessionId: m.sessionId }));
501
-        sess.agentWs.send(JSON.stringify({ type: 'start-stream', sessionId: m.sessionId }));
502
-      } else {
503
-        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') });
504
-        sess.viewerWs.send(JSON.stringify({ type: 'session-denied', sessionId: m.sessionId }));
505
-        liveSessions.delete(m.sessionId);
506
-      }
507
-      break;
508
-    }
509
-    // --- No-install: end user opens /share, gets a one-time code ---
510
-    case 'share-create': {
511
-      let code;
512
-      do { code = A.numericCode(6); } while (pendingShares.has(code));
513
-      const sessionId = A.token(8);
514
-      ws.kind = 'sharer'; ws.shareCode = code; ws.sessionId = sessionId;
515
-      pendingShares.set(code, { sharerWs: ws, sessionId });
516
-      ws.send(JSON.stringify({ type: 'share-code', code }));
517
-      break;
518
-    }
519
-    // --- Logged-in agent enters the code (+ ticket) to connect ---
520
-    case 'code-connect': {
521
-      const agent = currentUser(req); // identity from the agent's authenticated session
522
-      if (!agent) {
523
-        return ws.send(JSON.stringify({ type: 'error', message: 'Please sign in as an agent first' }));
524
-      }
525
-      const ticket = String(m.ticket || '').trim() || null; // optional: direct sessions have no ticket
526
-      const pend = pendingShares.get(String(m.code || '').trim());
527
-      if (!pend || pend.sharerWs.readyState !== 1) {
528
-        return ws.send(JSON.stringify({ type: 'error', message: 'Invalid or expired code' }));
529
-      }
530
-      pendingShares.delete(pend.sharerWs.shareCode);
531
-      const sessionId = pend.sessionId;
532
-      ws.kind = 'viewer'; ws.sessionId = sessionId;
533
-      const agentName = agent.name || agent.email;
534
-      const machine = { id: null, name: 'Support session ' + pend.sharerWs.shareCode, team_id: agent.team_id };
535
-      const user = { id: agent.id, email: agent.email, team_id: agent.team_id };
536
-      liveSessions.set(sessionId, { agentWs: pend.sharerWs, viewerWs: ws, machine, user, ticket, agentName });
537
-      pend.sharerWs.sessionId = sessionId;
538
-      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 });
539
-      pend.sharerWs.send(JSON.stringify({ type: 'share-request', sessionId, technician: agentName, ticket }));
540
-      ws.send(JSON.stringify({ type: 'code-pending', sessionId }));
541
-      break;
542
-    }
543
-    // --- Relay WebRTC signaling between the two peers ---
544
-    case 'offer': case 'answer': case 'ice-candidate': {
545
-      const sess = liveSessions.get(m.sessionId || ws.sessionId);
546
-      if (!sess) return;
547
-      const peer = ws === sess.agentWs ? sess.viewerWs : sess.agentWs;
548
-      if (peer && peer.readyState === 1) peer.send(JSON.stringify(m));
549
-      break;
550
-    }
551
-    case 'transcript': {
552
-      const sess = liveSessions.get(m.sessionId || ws.sessionId);
553
-      if (!sess) return;
554
-      const peer = ws === sess.agentWs ? sess.viewerWs : sess.agentWs;
555
-      if (peer && peer.readyState === 1) peer.send(JSON.stringify(m));
556
-      break;
557
-    }
558
-    case 'recording': {
559
-      const sess = liveSessions.get(m.sessionId || ws.sessionId);
560
-      if (!sess) return;
561
-      const peer = ws === sess.agentWs ? sess.viewerWs : sess.agentWs;
562
-      if (peer && peer.readyState === 1) peer.send(JSON.stringify(m));
563
-      break;
564
-    }
565
-    case 'end-session': {
566
-      endSession(ws.sessionId, m.reason || null);
567
-      break;
568
-    }
569
-  }
570
-}
571
-
572
-function notifyBizGaze(sessionId) {
573
-  const url = process.env.BIZGAZE_WEBHOOK_URL;
574
-  if (!url) return;
575
-  try {
576
-    const row = db.prepare('SELECT * FROM sessions_log WHERE id=?').get(sessionId);
577
-    if (!row) return;
578
-    const body = JSON.stringify({ event: 'session.ended', sessionId: row.id, agent_email: row.agent_email,
579
-      agent_name: row.agent_name, ticket: row.ticket, started_at: row.started_at, ended_at: row.ended_at,
580
-      duration_ms: row.ended_at ? row.ended_at - row.started_at : null });
581
-    const crypto = require('crypto');
582
-    const sig = process.env.SSO_SECRET ? crypto.createHmac('sha256', process.env.SSO_SECRET).update(body).digest('base64url') : '';
583
-    fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-BizGaze-Signature': sig }, body }).catch(()=>{});
584
-  } catch (e) {}
585
-}
586
-function endSession(sessionId, reason) {
587
-  const sess = liveSessions.get(sessionId);
588
-  if (!sess) return;
589
-  try { db.prepare('UPDATE sessions_log SET ended_at=? WHERE id=? AND ended_at IS NULL').run(now(), sessionId); } catch (e) {}
590
-  notifyBizGaze(sessionId);
591
-  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') });
592
-  [sess.agentWs, sess.viewerWs].forEach((p) => {
593
-    if (p && p.readyState === 1) p.send(JSON.stringify({ type: 'session-ended', sessionId, reason: reason || null }));
594
-  });
595
-  liveSessions.delete(sessionId);
596
-}
597
-
598
-function cleanup(ws) {
599
-  if (ws.kind === 'agent' && ws.machineId) onlineAgents.delete(ws.machineId);
600
-  if (ws.kind === 'sharer' && ws.shareCode) pendingShares.delete(ws.shareCode);
601
-  if (ws.sessionId) {
602
-    for (const [sid, sess] of liveSessions) {
603
-      if (sess.agentWs === ws || sess.viewerWs === ws) endSession(sid);
604
-    }
605
-  }
606
-}
607
-
608 35
 server.listen(PORT, () => {
609 36
   console.log(`HTTP  on http://localhost:${PORT}`);
610 37
 });

+ 38
- 0
server/session.js Bestand weergeven

@@ -0,0 +1,38 @@
1
+// Session/auth helpers: resolve the current user from the cookie, write audit rows.
2
+const R = require('./repos');
3
+const { parseCookies, now } = require('./lib');
4
+
5
+function audit(entry) {
6
+  R.audit.add(entry);
7
+}
8
+
9
+// Resolve the session token from a request, supporting every client transport:
10
+//   - `Authorization: Bearer <token>`  → native desktop/mobile apps (HTTP + WS upgrade)
11
+//   - `sid` cookie                     → the web app (HTTP + same-origin WS)
12
+//   - `?access_token=`/`?token=` query → browser WS fallback when a cookie isn't usable
13
+// All three resolve to the same opaque token in `sessions_auth`.
14
+function tokenFromReq(req) {
15
+  const h = req.headers && (req.headers.authorization || req.headers.Authorization);
16
+  if (h && /^Bearer\s+/i.test(h)) return h.replace(/^Bearer\s+/i, '').trim();
17
+  const cookieTok = parseCookies(req).sid;
18
+  if (cookieTok) return cookieTok;
19
+  try {
20
+    const qs = (req.url || '').split('?')[1];
21
+    if (qs) { const t = new URLSearchParams(qs).get('access_token') || new URLSearchParams(qs).get('token'); if (t) return t; }
22
+  } catch (_) {}
23
+  return null;
24
+}
25
+
26
+// Resolve the logged-in user from the request. Returns user row (with mfa state) or null.
27
+function currentUser(req, { requireMfa = true } = {}) {
28
+  const tok = tokenFromReq(req);
29
+  if (!tok) return null;
30
+  const s = R.authSessions.byToken(tok);
31
+  if (!s || s.expires_at < now()) return null;
32
+  if (requireMfa && !s.mfa_passed) return null;
33
+  const u = R.users.byId(s.user_id);
34
+  if (!u || u.active === 0) return null;
35
+  return { ...u, _session: s };
36
+}
37
+
38
+module.exports = { audit, currentUser, tokenFromReq };

+ 173
- 0
server/signaling.js Bestand weergeven

@@ -0,0 +1,173 @@
1
+// WebSocket signaling. Two kinds of WS clients:
2
+//   agent  -> authenticates with machine enroll_token, waits for session requests
3
+//   viewer -> authenticated technician, requests a session to a machine
4
+// The server brokers consent and relays SDP/ICE. Media never traverses the server.
5
+const R = require('./repos');
6
+const A = require('./auth');
7
+const { currentUser, audit } = require('./session');
8
+const { onlineAgents, liveSessions, pendingShares } = require('./presence');
9
+
10
+function onConnection(ws, req) {
11
+  const hb = setInterval(() => {
12
+    if (ws.readyState === 1) { try { ws.ping(); } catch {} } else { clearInterval(hb); }
13
+  }, 25000);
14
+  ws.on('message', (raw) => {
15
+    let m; try { m = JSON.parse(raw); } catch { return; }
16
+    handle(ws, m, req);
17
+  });
18
+  ws.on('close', () => { clearInterval(hb); cleanup(ws); });
19
+}
20
+
21
+function handle(ws, m, req) {
22
+  switch (m.type) {
23
+    // --- Agent comes online ---
24
+    case 'agent-hello': {
25
+      const machine = R.machines.byEnrollToken(m.enrollToken);
26
+      if (!machine) return ws.send(JSON.stringify({ type: 'error', message: 'invalid enroll token' }));
27
+      ws.kind = 'agent'; ws.machineId = machine.id;
28
+      onlineAgents.set(machine.id, { ws, machine });
29
+      R.machines.touch(machine.id);
30
+      ws.send(JSON.stringify({ type: 'agent-registered', machineId: machine.id, name: machine.name }));
31
+      break;
32
+    }
33
+    // --- Technician requests control of a machine ---
34
+    case 'viewer-connect': {
35
+      const u = currentUser(req); // cookie sent on WS upgrade
36
+      if (!u) return ws.send(JSON.stringify({ type: 'error', message: 'unauthorized' }));
37
+      const agent = onlineAgents.get(m.machineId);
38
+      const machine = R.machines.inTenant(m.machineId, u.team_id);
39
+      if (!machine) return ws.send(JSON.stringify({ type: 'error', message: 'no such machine' }));
40
+      if (!agent) return ws.send(JSON.stringify({ type: 'error', message: 'machine offline' }));
41
+      if (u.role === 'viewer' && false) {} // view-only still allowed to watch; control gated agent-side
42
+      const sessionId = A.token(8);
43
+      ws.kind = 'viewer'; ws.sessionId = sessionId;
44
+      liveSessions.set(sessionId, { agentWs: agent.ws, viewerWs: ws, machine, user: u });
45
+      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' });
46
+      // Ask the agent for consent (or auto-grant if unattended policy is on)
47
+      agent.ws.sessionId = sessionId;
48
+      agent.ws.send(JSON.stringify({
49
+        type: 'session-request', sessionId,
50
+        technician: u.email, unattended: !!machine.unattended,
51
+      }));
52
+      ws.send(JSON.stringify({ type: 'session-pending', sessionId, machineName: machine.name }));
53
+      break;
54
+    }
55
+    // --- Agent grants/denies consent ---
56
+    case 'consent': {
57
+      const sess = liveSessions.get(m.sessionId);
58
+      if (!sess) return;
59
+      if (m.granted) {
60
+        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') });
61
+        try {
62
+          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 });
63
+        } catch (e) { /* duplicate consent */ }
64
+        sess.viewerWs.send(JSON.stringify({ type: 'session-ready', sessionId: m.sessionId }));
65
+        sess.agentWs.send(JSON.stringify({ type: 'start-stream', sessionId: m.sessionId }));
66
+      } else {
67
+        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') });
68
+        sess.viewerWs.send(JSON.stringify({ type: 'session-denied', sessionId: m.sessionId }));
69
+        liveSessions.delete(m.sessionId);
70
+      }
71
+      break;
72
+    }
73
+    // --- No-install: end user opens /share, gets a one-time code ---
74
+    case 'share-create': {
75
+      let code;
76
+      do { code = A.numericCode(6); } while (pendingShares.has(code));
77
+      const sessionId = A.token(8);
78
+      ws.kind = 'sharer'; ws.shareCode = code; ws.sessionId = sessionId;
79
+      pendingShares.set(code, { sharerWs: ws, sessionId });
80
+      ws.send(JSON.stringify({ type: 'share-code', code }));
81
+      break;
82
+    }
83
+    // --- Logged-in agent enters the code (+ ticket) to connect ---
84
+    case 'code-connect': {
85
+      const agent = currentUser(req); // identity from the agent's authenticated session
86
+      if (!agent) {
87
+        return ws.send(JSON.stringify({ type: 'error', message: 'Please sign in as an agent first' }));
88
+      }
89
+      const ticket = String(m.ticket || '').trim() || null; // optional: direct sessions have no ticket
90
+      const pend = pendingShares.get(String(m.code || '').trim());
91
+      if (!pend || pend.sharerWs.readyState !== 1) {
92
+        return ws.send(JSON.stringify({ type: 'error', message: 'Invalid or expired code' }));
93
+      }
94
+      pendingShares.delete(pend.sharerWs.shareCode);
95
+      const sessionId = pend.sessionId;
96
+      ws.kind = 'viewer'; ws.sessionId = sessionId;
97
+      const agentName = agent.name || agent.email;
98
+      const machine = { id: null, name: 'Support session ' + pend.sharerWs.shareCode, team_id: agent.team_id };
99
+      const user = { id: agent.id, email: agent.email, team_id: agent.team_id };
100
+      liveSessions.set(sessionId, { agentWs: pend.sharerWs, viewerWs: ws, machine, user, ticket, agentName });
101
+      pend.sharerWs.sessionId = sessionId;
102
+      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 });
103
+      pend.sharerWs.send(JSON.stringify({ type: 'share-request', sessionId, technician: agentName, ticket }));
104
+      ws.send(JSON.stringify({ type: 'code-pending', sessionId }));
105
+      break;
106
+    }
107
+    // --- Relay WebRTC signaling between the two peers ---
108
+    case 'offer': case 'answer': case 'ice-candidate': {
109
+      const sess = liveSessions.get(m.sessionId || ws.sessionId);
110
+      if (!sess) return;
111
+      const peer = ws === sess.agentWs ? sess.viewerWs : sess.agentWs;
112
+      if (peer && peer.readyState === 1) peer.send(JSON.stringify(m));
113
+      break;
114
+    }
115
+    case 'transcript': {
116
+      const sess = liveSessions.get(m.sessionId || ws.sessionId);
117
+      if (!sess) return;
118
+      const peer = ws === sess.agentWs ? sess.viewerWs : sess.agentWs;
119
+      if (peer && peer.readyState === 1) peer.send(JSON.stringify(m));
120
+      break;
121
+    }
122
+    case 'recording': {
123
+      const sess = liveSessions.get(m.sessionId || ws.sessionId);
124
+      if (!sess) return;
125
+      const peer = ws === sess.agentWs ? sess.viewerWs : sess.agentWs;
126
+      if (peer && peer.readyState === 1) peer.send(JSON.stringify(m));
127
+      break;
128
+    }
129
+    case 'end-session': {
130
+      endSession(ws.sessionId, m.reason || null);
131
+      break;
132
+    }
133
+  }
134
+}
135
+
136
+function notifyBizGaze(sessionId) {
137
+  const url = process.env.BIZGAZE_WEBHOOK_URL;
138
+  if (!url) return;
139
+  try {
140
+    const row = R.sessionsLog.byId(sessionId);
141
+    if (!row) return;
142
+    const body = JSON.stringify({ event: 'session.ended', sessionId: row.id, agent_email: row.agent_email,
143
+      agent_name: row.agent_name, ticket: row.ticket, started_at: row.started_at, ended_at: row.ended_at,
144
+      duration_ms: row.ended_at ? row.ended_at - row.started_at : null });
145
+    const crypto = require('crypto');
146
+    const sig = process.env.SSO_SECRET ? crypto.createHmac('sha256', process.env.SSO_SECRET).update(body).digest('base64url') : '';
147
+    fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-BizGaze-Signature': sig }, body }).catch(()=>{});
148
+  } catch (e) {}
149
+}
150
+
151
+function endSession(sessionId, reason) {
152
+  const sess = liveSessions.get(sessionId);
153
+  if (!sess) return;
154
+  try { R.sessionsLog.end(sessionId); } catch (e) {}
155
+  notifyBizGaze(sessionId);
156
+  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') });
157
+  [sess.agentWs, sess.viewerWs].forEach((p) => {
158
+    if (p && p.readyState === 1) p.send(JSON.stringify({ type: 'session-ended', sessionId, reason: reason || null }));
159
+  });
160
+  liveSessions.delete(sessionId);
161
+}
162
+
163
+function cleanup(ws) {
164
+  if (ws.kind === 'agent' && ws.machineId) onlineAgents.delete(ws.machineId);
165
+  if (ws.kind === 'sharer' && ws.shareCode) pendingShares.delete(ws.shareCode);
166
+  if (ws.sessionId) {
167
+    for (const [sid, sess] of liveSessions) {
168
+      if (sess.agentWs === ws || sess.viewerWs === ws) endSession(sid);
169
+    }
170
+  }
171
+}
172
+
173
+module.exports = { onConnection };

+ 72
- 0
server/static.js Bestand weergeven

@@ -0,0 +1,72 @@
1
+// Static file serving + authenticated recording/transcript downloads.
2
+// handleGet() is the fallback for any GET that didn't match an API route.
3
+const fs = require('fs');
4
+const path = require('path');
5
+const R = require('./repos');
6
+const { json } = require('./lib');
7
+const { currentUser } = require('./session');
8
+const { PUBLIC_DIR, REC_DIR, TRANS_DIR } = require('./config');
9
+
10
+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' };
11
+
12
+function serveStatic(req, res) {
13
+  let p = req.url.split('?')[0];
14
+  if (p === '/') p = '/index.html';
15
+  if (p === '/home') p = '/home.html';
16
+  // Console was replaced by Dashboard; keep the old path working.
17
+  if (p === '/console' || p === '/dashboard') p = '/dashboard.html';
18
+  if (p === '/share') p = '/share.html';
19
+  if (p === '/connect') p = '/connect.html';
20
+  const fp = path.join(PUBLIC_DIR, path.normalize(p));
21
+  if (!fp.startsWith(PUBLIC_DIR)) return json(res, 403, { error: 'forbidden' });
22
+  fs.readFile(fp, (err, data) => {
23
+    if (err) return json(res, 404, { error: 'not found' });
24
+    const ct = MIME[path.extname(fp)] || 'application/octet-stream';
25
+    res.writeHead(200, { 'Content-Type': ct, 'Cache-Control': 'no-cache' });
26
+    res.end(data);
27
+  });
28
+}
29
+
30
+// GET fallback: authenticated transcript/recording downloads, else static files.
31
+function handleGet(req, res) {
32
+  const pathOnly = req.url.split('?')[0];
33
+  if (pathOnly.startsWith('/transcripts/')) {
34
+    const u = currentUser(req);
35
+    if (!u) return json(res, 401, { error: 'unauthorized' });
36
+    const name = path.basename(decodeURIComponent(pathOnly));
37
+    const sid = name.replace(/\.txt$/i, '');
38
+    const row = R.sessionsLog.byIdInTenant(sid, u.team_id);
39
+    if (!row || !row.transcript) return json(res, 404, { error: 'not found' });
40
+    const fp = path.join(TRANS_DIR, row.transcript);
41
+    if (!fp.startsWith(TRANS_DIR)) return json(res, 403, { error: 'forbidden' });
42
+    return fs.stat(fp, (err, st) => {
43
+      if (err) return json(res, 404, { error: 'not found' });
44
+      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' });
45
+      const rs = fs.createReadStream(fp);
46
+      rs.on('error', () => { try { res.destroy(); } catch (e) {} });
47
+      rs.pipe(res);
48
+    });
49
+  }
50
+  if (pathOnly.startsWith('/recordings/')) {
51
+    const u = currentUser(req);
52
+    if (!u) return json(res, 401, { error: 'unauthorized' });
53
+    const name = path.basename(decodeURIComponent(pathOnly));
54
+    const sid = name.replace(/\.(webm|mp4)$/i, '');
55
+    const row = R.sessionsLog.byIdInTenant(sid, u.team_id);
56
+    if (!row || !row.recording) return json(res, 404, { error: 'not found' });
57
+    const fp = path.join(REC_DIR, row.recording);
58
+    if (!fp.startsWith(REC_DIR)) return json(res, 403, { error: 'forbidden' });
59
+    const ext = path.extname(row.recording).toLowerCase() === '.mp4' ? 'mp4' : 'webm';
60
+    const ctype = ext === 'mp4' ? 'video/mp4' : 'video/webm';
61
+    return fs.stat(fp, (err, st) => {
62
+      if (err) return json(res, 404, { error: 'not found' });
63
+      res.writeHead(200, { 'Content-Type': ctype, 'Content-Length': st.size, 'Content-Disposition': 'attachment; filename="session-' + sid + '.' + ext + '"', 'Cache-Control': 'no-store', 'Accept-Ranges': 'bytes' });
64
+      const rs = fs.createReadStream(fp);
65
+      rs.on('error', () => { try { res.destroy(); } catch (e) {} });
66
+      rs.pipe(res);
67
+    });
68
+  }
69
+  return serveStatic(req, res);
70
+}
71
+
72
+module.exports = { handleGet, serveStatic };

+ 19
- 31
server/test/e2e.js Bestand weergeven

@@ -1,15 +1,20 @@
1 1
 // End-to-end test of the backend platform.
2
-// Exercises the full flow: register -> enable MFA -> login (2 steps) ->
3
-// enroll machine -> agent comes online -> technician requests session ->
4
-// consent -> signaling relay -> audit trail. No browser/Electron needed:
5
-// the "agent" and "viewer" are raw WebSocket clients.
2
+// Exercises the full flow: register -> login -> enroll machine -> agent online ->
3
+// technician requests session -> consent -> signaling relay -> audit trail.
4
+// No browser/Electron needed: the "agent" and "viewer" are raw WebSocket clients.
5
+// (Login currently marks the session MFA-passed directly, so there is no separate
6
+// TOTP step in the product flow; the MFA endpoints still exist but aren't exercised here.)
6 7
 
7
-process.env.DB_PATH = '/tmp/ra-e2e.db';
8 8
 const fs = require('fs');
9
-for (const f of ['/tmp/ra-e2e.db', '/tmp/ra-e2e.db-wal', '/tmp/ra-e2e.db-shm']) { try { fs.unlinkSync(f); } catch {} }
9
+const os = require('os');
10
+const path = require('path');
11
+const DB = path.join(os.tmpdir(), 'ra-e2e.db');
12
+process.env.DB_PATH = DB;
13
+for (const f of [DB, DB + '-wal', DB + '-shm']) { try { fs.unlinkSync(f); } catch {} }
10 14
 
11 15
 const PORT = 8099;
12 16
 process.env.PORT = PORT;
17
+process.env.HTTPS_PORT = 8444; // avoid clashing with a running dev server on 8443
13 18
 const { server } = require('../server');
14 19
 const A = require('../auth');
15 20
 const WebSocket = require('ws');
@@ -59,38 +64,21 @@ function nextMsg(ws, type, timeout = 3000) {
59 64
   await wait(300); // let server bind
60 65
   console.log('E2E backend tests:');
61 66
 
62
-  // 1. Register
67
+  // 1. Register (first user becomes admin)
63 68
   const email = 'tech@example.com';
64 69
   const reg = await call('/api/register', { email, password: 'supersecret', teamName: 'Acme IT' });
65
-  check('register returns mfa setup', reg.status === 200 && reg.data.mfaSetup && reg.data.mfaSetup.secret);
66
-  const secret = reg.data.mfaSetup.secret;
70
+  check('register succeeds', reg.status === 200 && reg.data.ok === true);
67 71
 
68
-  // 2. Login before MFA enabled — allowed, mfaRequired=false
69
-  let login = await call('/api/login', { email, password: 'supersecret' });
72
+  // 2. Login -> session cookie (login marks the session MFA-passed)
73
+  const login = await call('/api/login', { email, password: 'supersecret' });
70 74
   check('login sets session cookie', !!login.cookie);
75
+  const cookie = login.cookie;
71 76
 
72
-  // 3. Enable MFA with a valid TOTP
73
-  const enable = await call('/api/mfa/enable', { email, code: A.totp(secret) });
74
-  check('mfa enable succeeds with valid code', enable.status === 200);
75
-  const badEnable = await call('/api/mfa/enable', { email, code: '000000' });
76
-  check('mfa enable rejects bad code', badEnable.status === 401);
77
-
78
-  // 4. Fresh login now requires MFA
79
-  login = await call('/api/login', { email, password: 'supersecret' });
80
-  check('login now flags mfaRequired', login.data.mfaRequired === true);
81
-  let cookie = login.cookie;
82
-
83
-  // 5. Protected route blocked until MFA passed
84
-  const meBlocked = await get('/api/me', cookie);
85
-  check('me blocked before mfa', meBlocked.status === 401);
86
-
87
-  // 6. Pass MFA
88
-  const mfa = await call('/api/login/mfa', { code: A.totp(secret) }, cookie);
89
-  check('login mfa step succeeds', mfa.status === 200);
77
+  // 3. Protected route works right after login, role=admin
90 78
   const me = await get('/api/me', cookie);
91
-  check('me works after mfa, role=admin', me.status === 200 && me.data.role === 'admin');
79
+  check('me works after login, role=admin', me.status === 200 && me.data.role === 'admin');
92 80
 
93
-  // 7. Wrong password rejected
81
+  // 4. Wrong password rejected
94 82
   const badLogin = await call('/api/login', { email, password: 'wrong' });
95 83
   check('wrong password rejected', badLogin.status === 401);
96 84
 

Laden…
Annuleren
Opslaan