sriram před 2 týdny
revize
b984b55bc0

+ 34
- 0
.gitignore Zobrazit soubor

@@ -0,0 +1,34 @@
1
+# Dependencies
2
+node_modules/
3
+
4
+# Logs
5
+*.log
6
+npm-debug.log*
7
+
8
+# Database files
9
+*.db
10
+*.db-shm
11
+*.db-wal
12
+*.sqlite
13
+
14
+# Certificates & keys
15
+*.pem
16
+*.key
17
+*.crt
18
+
19
+# Environment files
20
+.env
21
+.env.*
22
+
23
+# Build output
24
+dist/
25
+build/
26
+out/
27
+
28
+# OS files
29
+.DS_Store
30
+Thumbs.db
31
+
32
+# Editor
33
+.vscode/
34
+.idea/

+ 0
- 0
.npmignore.note Zobrazit soubor


+ 42
- 0
INSTALL.md Zobrazit soubor

@@ -0,0 +1,42 @@
1
+# Install & Test — Remote Access Platform
2
+
3
+## Prerequisite
4
+Install **Node.js 22.5 or newer** (LTS is fine): https://nodejs.org
5
+(The installer adds `node` and `npm` to your PATH.)
6
+
7
+## Steps
8
+
9
+1. **Unzip** this folder anywhere (e.g. `C:\remote-access-app`).
10
+2. Double-click **`SETUP.bat`** — installs all dependencies (the agent pulls
11
+   Electron, so the first run takes a few minutes).
12
+3. Double-click **`Start-Server.bat`** — leave this window open.
13
+4. Open **http://localhost:8090** in your browser:
14
+   - Click **Register team**, enter an email + password.
15
+   - Add the shown **2FA secret** to an authenticator app (Google Authenticator,
16
+     Authy, 1Password), enter a code to finish, then log in.
17
+   - In **Machines**, type a name and click **Enroll machine** — copy the
18
+     `AGENT_ENROLL_TOKEN` it shows.
19
+5. Double-click **`Start-Agent.bat`**, accept the default server URL, and paste
20
+   the token. The agent window appears and the machine turns **green** in the
21
+   console.
22
+6. In the console, click **Connect**. The agent shows a **consent prompt** —
23
+   click **Allow**. You'll see the live screen and can control it; a red banner
24
+   stays on the host while the session is active.
25
+
26
+> To test on **two PCs**, run the server on one and use that PC's IP as the
27
+> server URL on the other (e.g. `http://192.168.1.50:8090`). Both must be on the
28
+> same network for direct connection (a TURN relay is the next step for
29
+> internet-wide use).
30
+
31
+## Notes
32
+- **Same-machine test:** you can run the server, browser, and agent all on one PC
33
+  to see the full flow (you'll be controlling your own screen).
34
+- **Input control** uses the `nut-js` library. If it failed to install, the agent
35
+  still streams the screen but logs input instead of injecting it — re-run
36
+  `SETUP.bat` on a machine with build tools to enable full control.
37
+- To stop: close the agent window and press `Ctrl+C` in the server window.
38
+
39
+## Troubleshooting
40
+- *"Node.js is not installed"* → install from nodejs.org, reopen `SETUP.bat`.
41
+- *Browser can't reach localhost:8090* → make sure `Start-Server.bat` is still running.
42
+- *Machine stays offline* → check the token was pasted fully and the server URL is correct.

+ 94
- 0
README.md Zobrazit soubor

@@ -0,0 +1,94 @@
1
+# Remote Access Platform — Alpha
2
+
3
+A self-hostable remote support platform for IT teams: technicians log in to a web
4
+console, see their team's machines, and start a screen-share + remote-control
5
+session to any online machine after the end user grants consent. Built to the
6
+spec in `PRD-remote-access-platform.md`.
7
+
8
+This alpha implements the PRD's P0 requirements: authenticated console, **MFA**,
9
+**RBAC**, **machine enrollment**, **per-session consent**, **WebRTC screen
10
+streaming + remote input**, and an **immutable audit log**.
11
+
12
+```
13
+remote-access-app/
14
+├── server/        Backend: HTTP API + WebSocket signaling + SQLite
15
+│   ├── server.js  Auth, MFA, machines, audit API + signaling broker
16
+│   ├── auth.js    scrypt passwords, TOTP MFA, tokens (no external auth deps)
17
+│   ├── db.js      Schema via Node's built-in node:sqlite
18
+│   ├── public/    Web console (index.html) + remote viewer (viewer.html)
19
+│   └── test/e2e.js  26-check end-to-end test of the whole backend flow
20
+└── agent/         Native host agent (Electron)
21
+    ├── main.js          Consent window, screen source, OS input injection
22
+    ├── input/inject.js  Mouse/keyboard injection via nut-js (Win32 SendInput)
23
+    └── renderer/        Agent UI + WebRTC screen capture
24
+```
25
+
26
+## Quick start
27
+
28
+### 1. Server (any OS, Node 22.5+)
29
+
30
+```bash
31
+cd server
32
+npm install        # only dependency is `ws`
33
+npm start          # serves http://localhost:8090
34
+```
35
+
36
+Open **http://localhost:8090**, click **Register team**, then set up MFA
37
+(add the shown secret to Google Authenticator / Authy / 1Password and enter a code).
38
+Log in, and enroll a machine — you'll get an `AGENT_ENROLL_TOKEN`.
39
+
40
+### 2. Agent (on the Windows/macOS PC to be controlled)
41
+
42
+```bash
43
+cd agent
44
+npm install                     # installs Electron + nut-js (input injection)
45
+set SERVER_URL=http://<server-ip>:8090     # Windows
46
+set AGENT_ENROLL_TOKEN=<token from console>
47
+npm start
48
+```
49
+
50
+The agent window comes online; the machine shows green in the console.
51
+Click **Connect** in the console — the agent shows a consent prompt. On **Allow**,
52
+the technician sees the live screen and can control it. A red banner stays on the
53
+host screen for the whole session, and every step is written to the audit log.
54
+
55
+> Set a machine to **unattended** at enrollment to skip the consent prompt
56
+> (for servers / headless machines), per the PRD's unattended-access policy.
57
+
58
+## What's tested (in this sandbox)
59
+
60
+```
61
+cd server && npm test            # 26/26 checks pass
62
+cd agent  && npm run test:input  # 5/5 input-mapping checks pass
63
+```
64
+
65
+The e2e test drives the real backend: register → enable MFA → login (password +
66
+TOTP) → enroll machine → agent connects → technician requests session → consent →
67
+SDP/ICE relay → session end → audit verification → consent-denial path.
68
+
69
+## What requires real hardware (not testable in a headless sandbox)
70
+
71
+- **OS input injection** runs through `nut-js` (Win32 `SendInput` on Windows,
72
+  `CGEvent` on macOS). Without it installed, `inject.js` degrades to a safe
73
+  logging no-op so the agent still runs. Verify on a real desktop.
74
+- **Screen capture** uses Electron's `desktopCapturer` / `getDisplayMedia`.
75
+
76
+## Architecture notes
77
+
78
+- **Media is peer-to-peer.** The server only brokers signaling (SDP/ICE) and
79
+  consent; screen frames and input never pass through it. Channels are
80
+  DTLS-encrypted by WebRTC.
81
+- **NAT traversal** uses a public STUN server. ~10–15% of connections behind
82
+  symmetric NATs will need a **TURN relay** (coturn) — the next infra item.
83
+- **Auth** uses scrypt password hashing and RFC-6238 TOTP, implemented on Node's
84
+  built-in `crypto` — no `bcrypt`/`jsonwebtoken`/`speakeasy` dependencies.
85
+- **Storage** is `node:sqlite` (built into Node 22.5+), so the backend has a
86
+  single runtime dependency (`ws`).
87
+
88
+## Gaps before production (from PRD §5)
89
+
90
+1. **TURN relay** for non-P2P connections (coturn).
91
+2. **macOS/Linux agents** + code-signed installers; packaged Windows binary.
92
+3. **File transfer, clipboard sync, multi-monitor** (PRD P1).
93
+4. **SSO, session recording, SOC 2** (PRD P1/T8).
94
+5. Harden signaling: rate limiting, per-session authz checks, CSRF on cookie API.

+ 51
- 0
SETUP.bat Zobrazit soubor

@@ -0,0 +1,51 @@
1
+@echo off
2
+setlocal
3
+title Remote Access Platform - Setup
4
+echo ============================================
5
+echo   Remote Access Platform - Setup
6
+echo ============================================
7
+echo.
8
+
9
+REM --- Check Node.js ---
10
+where node >nul 2>nul
11
+if errorlevel 1 (
12
+  echo [X] Node.js is not installed.
13
+  echo     Please install Node.js 22.5 or newer from https://nodejs.org
14
+  echo     Then run this SETUP.bat again.
15
+  echo.
16
+  pause
17
+  exit /b 1
18
+)
19
+for /f "tokens=*" %%v in ('node -v') do set NODEV=%%v
20
+echo [OK] Node.js found: %NODEV%
21
+echo.
22
+
23
+REM --- Install server dependencies ---
24
+echo Installing SERVER dependencies...
25
+pushd "%~dp0server"
26
+call npm install --no-audit --no-fund
27
+if errorlevel 1 ( echo [X] Server install failed. & popd & pause & exit /b 1 )
28
+popd
29
+echo [OK] Server ready.
30
+echo.
31
+
32
+REM --- Install agent dependencies (Electron + input injection) ---
33
+echo Installing AGENT dependencies (Electron download may take a few minutes)...
34
+pushd "%~dp0agent"
35
+call npm install --no-audit --no-fund
36
+if errorlevel 1 ( echo [!] Agent install had issues. Input injection may be limited, but the agent will still run. )
37
+popd
38
+echo [OK] Agent ready.
39
+echo.
40
+
41
+echo ============================================
42
+echo   Setup complete!
43
+echo ============================================
44
+echo.
45
+echo Next steps:
46
+echo   1. Double-click  Start-Server.bat
47
+echo   2. Open http://localhost:8090 in your browser
48
+echo   3. Register a team, set up 2FA, enroll a machine (copy the token)
49
+echo   4. Double-click  Start-Agent.bat  and paste the token
50
+echo.
51
+pause

+ 18
- 0
Start-Agent.bat Zobrazit soubor

@@ -0,0 +1,18 @@
1
+@echo off
2
+setlocal
3
+title Remote Access - Agent
4
+echo ============================================
5
+echo   Remote Access Agent
6
+echo ============================================
7
+echo.
8
+set "DEF_URL=http://localhost:8090"
9
+set /p SERVER_URL=Server URL [%DEF_URL%]: 
10
+if "%SERVER_URL%"=="" set SERVER_URL=%DEF_URL%
11
+echo.
12
+set /p AGENT_ENROLL_TOKEN=Paste the enroll token from the console: 
13
+if "%AGENT_ENROLL_TOKEN%"=="" ( echo [X] No token entered. & pause & exit /b 1 )
14
+echo.
15
+echo Connecting to %SERVER_URL% ...
16
+cd /d "%~dp0agent"
17
+call npm start
18
+pause

+ 8
- 0
Start-Server.bat Zobrazit soubor

@@ -0,0 +1,8 @@
1
+@echo off
2
+title Remote Access - Server
3
+echo Starting server on http://localhost:8090 ...
4
+echo (Keep this window open. Press Ctrl+C to stop.)
5
+echo.
6
+cd /d "%~dp0server"
7
+node server.js
8
+pause

+ 34
- 0
agent/.gitignore Zobrazit soubor

@@ -0,0 +1,34 @@
1
+# Dependencies
2
+node_modules/
3
+
4
+# Logs
5
+*.log
6
+npm-debug.log*
7
+
8
+# Database files
9
+*.db
10
+*.db-shm
11
+*.db-wal
12
+*.sqlite
13
+
14
+# Certificates & keys
15
+*.pem
16
+*.key
17
+*.crt
18
+
19
+# Environment files
20
+.env
21
+.env.*
22
+
23
+# Build output
24
+dist/
25
+build/
26
+out/
27
+
28
+# OS files
29
+.DS_Store
30
+Thumbs.db
31
+
32
+# Editor
33
+.vscode/
34
+.idea/

+ 103
- 0
agent/input/inject.js Zobrazit soubor

@@ -0,0 +1,103 @@
1
+// OS input injection layer.
2
+//
3
+// Cross-platform mouse/keyboard control via @nut-tree-fork/nut-js (optional
4
+// native dependency). If nut-js isn't installed (e.g. CI, or a sandbox without
5
+// a display), this module degrades to a logging no-op so the rest of the agent
6
+// still runs and can be tested. On Windows, nut-js drives the Win32 SendInput
7
+// API under the hood — the same mechanism TeamViewer/AnyDesk use.
8
+
9
+let nut = null;
10
+try {
11
+  // eslint-disable-next-line import/no-extraneous-dependencies
12
+  nut = require('@nut-tree-fork/nut-js');
13
+  nut.mouse.config.autoDelayMs = 0;
14
+  nut.keyboard.config.autoDelayMs = 0;
15
+} catch {
16
+  nut = null;
17
+}
18
+
19
+const available = !!nut;
20
+
21
+// Map browser KeyboardEvent.key values to nut-js Key enum names.
22
+function mapKey(key, code) {
23
+  if (!nut) return null;
24
+  const K = nut.Key;
25
+  const direct = {
26
+    'Enter': K.Enter, 'Backspace': K.Backspace, 'Tab': K.Tab, 'Escape': K.Escape,
27
+    ' ': K.Space, 'ArrowLeft': K.Left, 'ArrowRight': K.Right, 'ArrowUp': K.Up, 'ArrowDown': K.Down,
28
+    'Home': K.Home, 'End': K.End, 'PageUp': K.PageUp, 'PageDown': K.PageDown, 'Delete': K.Delete,
29
+    'Control': K.LeftControl, 'Shift': K.LeftShift, 'Alt': K.LeftAlt, 'Meta': K.LeftSuper,
30
+    'CapsLock': K.CapsLock,
31
+  };
32
+  if (direct[key] !== undefined) return [direct[key]];
33
+  if (/^F\d{1,2}$/.test(key) && K[key] !== undefined) return [K[key]];
34
+  if (key && key.length === 1) {
35
+    const upper = key.toUpperCase();
36
+    if (/[A-Z]/.test(upper) && K[upper] !== undefined) return [K[upper]];
37
+    if (/[0-9]/.test(key) && K['Num' + key] !== undefined) return [K['Num' + key]];
38
+    // Fall back to typing the literal character (handles symbols/shifted chars)
39
+    return { type: key };
40
+  }
41
+  return null;
42
+}
43
+
44
+async function moveTo(xNorm, yNorm) {
45
+  if (!nut) return;
46
+  const { width, height } = await nut.screen.getResolution();
47
+  await nut.mouse.setPosition(new nut.Point(Math.round(xNorm * width), Math.round(yNorm * height)));
48
+}
49
+
50
+function buttonEnum(b) {
51
+  if (!nut) return null;
52
+  return b === 2 ? nut.Button.RIGHT : b === 1 ? nut.Button.MIDDLE : nut.Button.LEFT;
53
+}
54
+
55
+const pressed = new Set();
56
+
57
+// Inject a single normalized input event coming from the viewer.
58
+async function inject(evt) {
59
+  if (!nut) {
60
+    if (evt.kind !== 'mousemove') console.log('[input:noop]', JSON.stringify(evt));
61
+    return;
62
+  }
63
+  try {
64
+    switch (evt.kind) {
65
+      case 'mousemove':
66
+        await moveTo(evt.x, evt.y); break;
67
+      case 'mousedown':
68
+        await moveTo(evt.x, evt.y); await nut.mouse.pressButton(buttonEnum(evt.button)); break;
69
+      case 'mouseup':
70
+        await nut.mouse.releaseButton(buttonEnum(evt.button)); break;
71
+      case 'dblclick':
72
+        await moveTo(evt.x, evt.y); await nut.mouse.doubleClick(nut.Button.LEFT); break;
73
+      case 'scroll':
74
+        if (evt.dy) await (evt.dy > 0 ? nut.mouse.scrollDown(Math.abs(evt.dy)) : nut.mouse.scrollUp(Math.abs(evt.dy)));
75
+        if (evt.dx) await (evt.dx > 0 ? nut.mouse.scrollRight(Math.abs(evt.dx)) : nut.mouse.scrollLeft(Math.abs(evt.dx)));
76
+        break;
77
+      case 'keydown': {
78
+        const m = mapKey(evt.key, evt.code);
79
+        if (!m) break;
80
+        if (m.type) { await nut.keyboard.type(m.type); break; }
81
+        await nut.keyboard.pressKey(...m); m.forEach((k) => pressed.add(k));
82
+        break;
83
+      }
84
+      case 'keyup': {
85
+        const m = mapKey(evt.key, evt.code);
86
+        if (!m || m.type) break;
87
+        await nut.keyboard.releaseKey(...m); m.forEach((k) => pressed.delete(k));
88
+        break;
89
+      }
90
+    }
91
+  } catch (e) {
92
+    console.error('[input] inject error:', e.message);
93
+  }
94
+}
95
+
96
+// Safety: release any stuck modifier keys when a session ends.
97
+async function releaseAll() {
98
+  if (!nut) { pressed.clear(); return; }
99
+  for (const k of pressed) { try { await nut.keyboard.releaseKey(k); } catch {} }
100
+  pressed.clear();
101
+}
102
+
103
+module.exports = { inject, releaseAll, available, mapKey };

+ 44
- 0
agent/input/inject.test.js Zobrazit soubor

@@ -0,0 +1,44 @@
1
+// Unit test for the input mapping logic — runs without a display or nut-js.
2
+// Verifies keymap returns sane shapes and inject() no-ops gracefully.
3
+const inject = require('./inject');
4
+const assert = require('assert');
5
+
6
+let pass = 0;
7
+function check(name, cond) {
8
+  assert.ok(cond, name);
9
+  console.log('  ok -', name);
10
+  pass++;
11
+}
12
+
13
+(async () => {
14
+  console.log('input layer tests:');
15
+  check('module exposes inject/releaseAll/available', typeof inject.inject === 'function' && typeof inject.releaseAll === 'function');
16
+  check('available is boolean', typeof inject.available === 'boolean');
17
+
18
+  // mapKey returns null when nut-js absent (sandbox) — that is expected & safe.
19
+  if (!inject.available) {
20
+    check('mapKey is null-safe without nut-js', inject.mapKey('a', 'KeyA') === null);
21
+  } else {
22
+    check('mapKey letter -> array', Array.isArray(inject.mapKey('a', 'KeyA')));
23
+    check('mapKey Enter -> array', Array.isArray(inject.mapKey('Enter', 'Enter')));
24
+    check('mapKey symbol -> type fallback', inject.mapKey('@', 'Digit2').type === '@');
25
+  }
26
+
27
+  // inject() must not throw on any event kind, even with no backend.
28
+  for (const evt of [
29
+    { kind: 'mousemove', x: 0.5, y: 0.5 },
30
+    { kind: 'mousedown', button: 0, x: 0.1, y: 0.2 },
31
+    { kind: 'mouseup', button: 0 },
32
+    { kind: 'dblclick', x: 0.3, y: 0.3 },
33
+    { kind: 'scroll', dx: 0, dy: 120 },
34
+    { kind: 'keydown', key: 'a', code: 'KeyA' },
35
+    { kind: 'keyup', key: 'a', code: 'KeyA' },
36
+  ]) {
37
+    await inject.inject(evt);
38
+  }
39
+  check('inject handled all event kinds without throwing', true);
40
+  await inject.releaseAll();
41
+  check('releaseAll clears state', true);
42
+
43
+  console.log(`\n${pass} checks passed.`);
44
+})();

+ 47
- 0
agent/input/injector.js Zobrazit soubor

@@ -0,0 +1,47 @@
1
+// OS input injection for Windows via user32 SendInput (koffi FFI).
2
+// On non-Windows platforms, exports a stub that logs instead of injecting,
3
+// so the agent can run in dev/test environments.
4
+
5
+const { toVirtualKey } = require('./keymap');
6
+
7
+const MOUSEEVENTF_MOVE = 0x0001;
8
+const MOUSEEVENTF_ABSOLUTE = 0x8000;
9
+const MOUSEEVENTF_LEFTDOWN = 0x0002;
10
+const MOUSEEVENTF_LEFTUP = 0x0004;
11
+const MOUSEEVENTF_RIGHTDOWN = 0x0008;
12
+const MOUSEEVENTF_RIGHTUP = 0x0010;
13
+const MOUSEEVENTF_MIDDLEDOWN = 0x0020;
14
+const MOUSEEVENTF_MIDDLEUP = 0x0040;
15
+const MOUSEEVENTF_WHEEL = 0x0800;
16
+const KEYEVENTF_KEYUP = 0x0002;
17
+const INPUT_MOUSE = 0;
18
+const INPUT_KEYBOARD = 1;
19
+
20
+const BUTTON_DOWN = { 0: MOUSEEVENTF_LEFTDOWN, 1: MOUSEEVENTF_MIDDLEDOWN, 2: MOUSEEVENTF_RIGHTDOWN };
21
+const BUTTON_UP = { 0: MOUSEEVENTF_LEFTUP, 1: MOUSEEVENTF_MIDDLEUP, 2: MOUSEEVENTF_RIGHTUP };
22
+
23
+function createWindowsInjector() {
24
+  const koffi = require('koffi');
25
+  const user32 = koffi.load('user32.dll');
26
+
27
+  const MOUSEINPUT = koffi.struct('MOUSEINPUT', {
28
+    dx: 'long', dy: 'long', mouseData: 'int32',
29
+    dwFlags: 'uint32', time: 'uint32', dwExtraInfo: 'uintptr_t',
30
+  });
31
+  const KEYBDINPUT = koffi.struct('KEYBDINPUT', {
32
+    wVk: 'uint16', wScan: 'uint16',
33
+    dwFlags: 'uint32', time: 'uint32', dwExtraInfo: 'uintptr_t',
34
+  });
35
+  const HARDWAREINPUT = koffi.struct('HARDWAREINPUT', {
36
+    uMsg: 'uint32', wParamL: 'uint16', wParamH: 'uint16',
37
+  });
38
+  const INPUT_UNION = koffi.union('INPUT_UNION', {
39
+    mi: MOUSEINPUT, ki: KEYBDINPUT, hi: HARDWAREINPUT,
40
+  });
41
+  const INPUT = koffi.struct('INPUT', { type: 'uint32', u: INPUT_UNION });
42
+
43
+  const SendInput = user32.func('uint32 SendInput(uint32 cInputs, INPUT *pInputs, int cbSize)');
44
+  const INPUT_SIZE = koffi.sizeof(INPUT);
45
+
46
+  function sendMouse(mi) {
47
+    SendInput(1, [{ type: INPUT_MOUSE, u: { mi:

+ 71
- 0
agent/input/keymap.js Zobrazit soubor

@@ -0,0 +1,71 @@
1
+// Maps browser KeyboardEvent.code values to Windows virtual-key codes.
2
+// Reference: https://learn.microsoft.com/en-us/windows/win32/inputdev/virtual-key-codes
3
+
4
+const VK = {};
5
+
6
+// Letters (KeyA..KeyZ -> 0x41..0x5A)
7
+for (let i = 0; i < 26; i++) VK['Key' + String.fromCharCode(65 + i)] = 0x41 + i;
8
+// Top-row digits (Digit0..Digit9 -> 0x30..0x39)
9
+for (let i = 0; i <= 9; i++) VK['Digit' + i] = 0x30 + i;
10
+// Numpad digits
11
+for (let i = 0; i <= 9; i++) VK['Numpad' + i] = 0x60 + i;
12
+// Function keys
13
+for (let i = 1; i <= 24; i++) VK['F' + i] = 0x70 + (i - 1);
14
+
15
+Object.assign(VK, {
16
+  Escape: 0x1b,
17
+  Tab: 0x09,
18
+  CapsLock: 0x14,
19
+  ShiftLeft: 0xa0,
20
+  ShiftRight: 0xa1,
21
+  ControlLeft: 0xa2,
22
+  ControlRight: 0xa3,
23
+  AltLeft: 0xa4,
24
+  AltRight: 0xa5,
25
+  MetaLeft: 0x5b,
26
+  MetaRight: 0x5c,
27
+  ContextMenu: 0x5d,
28
+  Space: 0x20,
29
+  Enter: 0x0d,
30
+  NumpadEnter: 0x0d,
31
+  Backspace: 0x08,
32
+  Delete: 0x2e,
33
+  Insert: 0x2d,
34
+  Home: 0x24,
35
+  End: 0x23,
36
+  PageUp: 0x21,
37
+  PageDown: 0x22,
38
+  ArrowUp: 0x26,
39
+  ArrowDown: 0x28,
40
+  ArrowLeft: 0x25,
41
+  ArrowRight: 0x27,
42
+  PrintScreen: 0x2c,
43
+  ScrollLock: 0x91,
44
+  Pause: 0x13,
45
+  NumLock: 0x90,
46
+  // OEM punctuation (US layout)
47
+  Semicolon: 0xba,
48
+  Equal: 0xbb,
49
+  Comma: 0xbc,
50
+  Minus: 0xbd,
51
+  Period: 0xbe,
52
+  Slash: 0xbf,
53
+  Backquote: 0xc0,
54
+  BracketLeft: 0xdb,
55
+  Backslash: 0xdc,
56
+  BracketRight: 0xdd,
57
+  Quote: 0xde,
58
+  // Numpad operators
59
+  NumpadMultiply: 0x6a,
60
+  NumpadAdd: 0x6b,
61
+  NumpadSubtract: 0x6d,
62
+  NumpadDecimal: 0x6e,
63
+  NumpadDivide: 0x6f,
64
+});
65
+
66
+/** @param {string} code KeyboardEvent.code @returns {number|undefined} Windows VK code */
67
+function toVirtualKey(code) {
68
+  return VK[code];
69
+}
70
+
71
+module.exports = { toVirtualKey, VK };

+ 66
- 0
agent/main.js Zobrazit soubor

@@ -0,0 +1,66 @@
1
+// Electron main process for the host agent.
2
+// Owns: the consent window, screen-source selection, and OS input injection.
3
+// WebRTC lives in the renderer (it needs a DOM/navigator); input events arrive
4
+// from the renderer over IPC and are injected here.
5
+const { app, BrowserWindow, ipcMain, desktopCapturer, screen } = require('electron');
6
+const path = require('path');
7
+const injector = require('./input/inject');
8
+
9
+const SERVER_URL = process.env.SERVER_URL || 'http://localhost:8090';
10
+const ENROLL_TOKEN = process.env.AGENT_ENROLL_TOKEN || '';
11
+
12
+let win;
13
+
14
+function createWindow() {
15
+  win = new BrowserWindow({
16
+    width: 460,
17
+    height: 560,
18
+    resizable: false,
19
+    title: 'Remote Access Agent',
20
+    webPreferences: {
21
+      preload: path.join(__dirname, 'preload.js'),
22
+      contextIsolation: true,
23
+      nodeIntegration: false,
24
+    },
25
+  });
26
+  win.loadFile(path.join(__dirname, 'renderer', 'agent.html'));
27
+  // Pass config to the renderer once loaded
28
+  win.webContents.on('did-finish-load', () => {
29
+    win.webContents.send('config', { serverUrl: SERVER_URL, enrollToken: ENROLL_TOKEN });
30
+  });
31
+}
32
+
33
+// ---- Screen source for getDisplayMedia (Electron requires a handler) ----
34
+function registerDisplayMediaHandler() {
35
+  const { session } = require('electron');
36
+  session.defaultSession.setDisplayMediaRequestHandler((request, callback) => {
37
+    desktopCapturer.getSources({ types: ['screen'] }).then((sources) => {
38
+      // Default to the primary display; production would let the user pick.
39
+      callback({ video: sources[0], audio: 'loopback' });
40
+    });
41
+  }, { useSystemPicker: false });
42
+}
43
+
44
+// ---- IPC: renderer forwards remote input events here for OS injection ----
45
+ipcMain.on('inject-input', (_e, evt) => {
46
+  injector.inject(evt);
47
+});
48
+ipcMain.on('session-ended', () => {
49
+  injector.releaseAll();
50
+});
51
+ipcMain.handle('get-primary-size', () => {
52
+  const { size } = screen.getPrimaryDisplay();
53
+  return size;
54
+});
55
+
56
+app.whenReady().then(() => {
57
+  registerDisplayMediaHandler();
58
+  createWindow();
59
+  app.on('activate', () => {
60
+    if (BrowserWindow.getAllWindows().length === 0) createWindow();
61
+  });
62
+});
63
+
64
+app.on('window-all-closed', () => {
65
+  if (process.platform !== 'darwin') app.quit();
66
+});

+ 2491
- 0
agent/package-lock.json
Diff nebyl zobrazen, protože je příliš veliký
Zobrazit soubor


+ 19
- 0
agent/package.json Zobrazit soubor

@@ -0,0 +1,19 @@
1
+{
2
+  "name": "remote-access-agent",
3
+  "version": "0.2.0",
4
+  "description": "Native host agent — screen capture, WebRTC, consent, OS input injection",
5
+  "main": "main.js",
6
+  "scripts": {
7
+    "start": "electron .",
8
+    "test:input": "node input/inject.test.js"
9
+  },
10
+  "dependencies": {
11
+    "ws": "^8.18.0"
12
+  },
13
+  "optionalDependencies": {
14
+    "@nut-tree-fork/nut-js": "^4.2.0"
15
+  },
16
+  "devDependencies": {
17
+    "electron": "^31.0.0"
18
+  }
19
+}

+ 9
- 0
agent/preload.js Zobrazit soubor

@@ -0,0 +1,9 @@
1
+// Secure bridge between the sandboxed renderer and the main process.
2
+const { contextBridge, ipcRenderer } = require('electron');
3
+
4
+contextBridge.exposeInMainWorld('agent', {
5
+  onConfig: (cb) => ipcRenderer.on('config', (_e, cfg) => cb(cfg)),
6
+  injectInput: (evt) => ipcRenderer.send('inject-input', evt),
7
+  sessionEnded: () => ipcRenderer.send('session-ended'),
8
+  getPrimarySize: () => ipcRenderer.invoke('get-primary-size'),
9
+});

+ 33
- 0
agent/renderer/agent.html Zobrazit soubor

@@ -0,0 +1,33 @@
1
+<!DOCTYPE html>
2
+<html lang="en">
3
+<head>
4
+<meta charset="UTF-8">
5
+<title>Remote Access Agent</title>
6
+<style>
7
+  body { font-family: system-ui, sans-serif; background: #0f172a; color: #e2e8f0; margin: 0; padding: 1.5rem; }
8
+  h1 { font-size: 1.1rem; }
9
+  .status { background: #1e293b; padding: 0.7rem 1rem; border-radius: 8px; margin: 0.8rem 0; font-size: 0.9rem; }
10
+  .status.on { background: #14532d; }
11
+  .status.warn { background: #7c2d12; }
12
+  .consent { background: #1e293b; border: 1px solid #3b82f6; border-radius: 10px; padding: 1.2rem; margin-top: 1rem; }
13
+  .consent h2 { font-size: 1rem; margin: 0 0 0.5rem; }
14
+  button { padding: 0.6rem 1.2rem; border: none; border-radius: 8px; font-weight: 600; cursor: pointer; }
15
+  .grant { background: #22c55e; color: #052e16; }
16
+  .deny { background: #ef4444; color: #fff; margin-left: 0.5rem; }
17
+  .muted { color: #94a3b8; font-size: 0.82rem; }
18
+  .indicator { position: fixed; bottom: 0; left: 0; right: 0; background: #b91c1c; color: #fff; text-align: center; padding: 0.4rem; font-size: 0.85rem; display: none; }
19
+  .indicator.show { display: block; }
20
+  #log { font-family: monospace; font-size: 0.72rem; color: #64748b; height: 120px; overflow-y: auto; margin-top: 1rem; }
21
+</style>
22
+</head>
23
+<body>
24
+<h1>🛡️ Remote Access Agent</h1>
25
+<div id="status" class="status">Starting…</div>
26
+<div id="consentBox"></div>
27
+<p class="muted">This machine accepts remote support sessions. You'll be asked to approve each connection unless an unattended-access policy is set.</p>
28
+<div id="log"></div>
29
+<div id="indicator" class="indicator">● A technician is currently viewing/controlling this screen</div>
30
+
31
+<script src="agent.js"></script>
32
+</body>
33
+</html>

+ 135
- 0
agent/renderer/agent.js Zobrazit soubor

@@ -0,0 +1,135 @@
1
+// Agent renderer: connects to the signaling server, handles consent,
2
+// captures the screen, and streams it over WebRTC to the technician's viewer.
3
+// Remote input events arrive on the data channel and are handed to the main
4
+// process (via the preload bridge) for OS-level injection.
5
+
6
+const statusEl = document.getElementById('status');
7
+const consentBox = document.getElementById('consentBox');
8
+const indicator = document.getElementById('indicator');
9
+const logEl = document.getElementById('log');
10
+
11
+let cfg = null;
12
+let ws = null;
13
+let pc = null;
14
+let localStream = null;
15
+let currentSessionId = null;
16
+
17
+const log = (t) => { const d = document.createElement('div'); d.textContent = t; logEl.appendChild(d); logEl.scrollTop = logEl.scrollHeight; };
18
+const setStatus = (t, cls = '') => { statusEl.textContent = t; statusEl.className = 'status ' + cls; };
19
+
20
+window.agent.onConfig((c) => {
21
+  cfg = c;
22
+  if (!cfg.enrollToken) {
23
+    setStatus('No enroll token. Set AGENT_ENROLL_TOKEN and restart.', 'warn');
24
+    return;
25
+  }
26
+  connect();
27
+});
28
+
29
+function wsUrl() {
30
+  const u = new URL(cfg.serverUrl);
31
+  const proto = u.protocol === 'https:' ? 'wss:' : 'ws:';
32
+  return `${proto}//${u.host}/ws`;
33
+}
34
+
35
+function connect() {
36
+  setStatus('Connecting to server…');
37
+  ws = new WebSocket(wsUrl());
38
+  ws.onopen = () => {
39
+    ws.send(JSON.stringify({ type: 'agent-hello', enrollToken: cfg.enrollToken }));
40
+  };
41
+  ws.onmessage = (e) => handle(JSON.parse(e.data));
42
+  ws.onclose = () => { setStatus('Disconnected. Reconnecting in 3s…', 'warn'); setTimeout(connect, 3000); };
43
+}
44
+
45
+async function handle(m) {
46
+  switch (m.type) {
47
+    case 'agent-registered':
48
+      setStatus(`Online as "${m.name}". Waiting for sessions.`, 'on');
49
+      log(`registered: ${m.name}`);
50
+      break;
51
+
52
+    case 'session-request':
53
+      if (m.unattended) { log(`unattended session ${m.sessionId} — auto-granting`); grant(m.sessionId); }
54
+      else showConsent(m);
55
+      break;
56
+
57
+    case 'start-stream':
58
+      currentSessionId = m.sessionId;
59
+      await startStreaming();
60
+      break;
61
+
62
+    case 'answer':
63
+      if (pc) await pc.setRemoteDescription(new RTCSessionDescription(m.sdp));
64
+      break;
65
+    case 'ice-candidate':
66
+      if (m.candidate && pc) await pc.addIceCandidate(new RTCIceCandidate(m.candidate));
67
+      break;
68
+
69
+    case 'session-ended':
70
+      teardown();
71
+      break;
72
+    case 'error':
73
+      setStatus('Server: ' + m.message, 'warn');
74
+      break;
75
+  }
76
+}
77
+
78
+function showConsent(m) {
79
+  consentBox.innerHTML = `
80
+    <div class="consent">
81
+      <h2>Allow remote support?</h2>
82
+      <p class="muted"><b>${escapeHtml(m.technician)}</b> is requesting to view and control this PC.</p>
83
+      <button class="grant" id="grantBtn">Allow</button>
84
+      <button class="deny" id="denyBtn">Deny</button>
85
+    </div>`;
86
+  document.getElementById('grantBtn').onclick = () => { consentBox.innerHTML = ''; grant(m.sessionId); };
87
+  document.getElementById('denyBtn').onclick = () => { consentBox.innerHTML = ''; deny(m.sessionId); };
88
+}
89
+
90
+const grant = (sessionId) => ws.send(JSON.stringify({ type: 'consent', sessionId, granted: true }));
91
+const deny = (sessionId) => ws.send(JSON.stringify({ type: 'consent', sessionId, granted: false }));
92
+
93
+async function startStreaming() {
94
+  setStatus('Sharing screen with technician…', 'on');
95
+  indicator.classList.add('show');
96
+  try {
97
+    localStream = await navigator.mediaDevices.getDisplayMedia({ video: { frameRate: { ideal: 30 } }, audio: false });
98
+  } catch (err) {
99
+    log('getDisplayMedia failed: ' + err.message);
100
+    setStatus('Screen capture failed.', 'warn');
101
+    return;
102
+  }
103
+
104
+  pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] });
105
+  localStream.getTracks().forEach((t) => pc.addTrack(t, localStream));
106
+
107
+  // Viewer creates the input data channel; we receive it here.
108
+  pc.ondatachannel = (ev) => {
109
+    const ch = ev.channel;
110
+    ch.onmessage = (msg) => {
111
+      let evt; try { evt = JSON.parse(msg.data); } catch { return; }
112
+      window.agent.injectInput(evt); // -> main process -> OS injection
113
+    };
114
+  };
115
+
116
+  pc.onicecandidate = (ev) => {
117
+    if (ev.candidate) ws.send(JSON.stringify({ type: 'ice-candidate', sessionId: currentSessionId, candidate: ev.candidate }));
118
+  };
119
+
120
+  const offer = await pc.createOffer();
121
+  await pc.setLocalDescription(offer);
122
+  ws.send(JSON.stringify({ type: 'offer', sessionId: currentSessionId, sdp: pc.localDescription }));
123
+  log('sent offer for session ' + currentSessionId);
124
+}
125
+
126
+function teardown() {
127
+  indicator.classList.remove('show');
128
+  window.agent.sessionEnded();
129
+  if (localStream) { localStream.getTracks().forEach((t) => t.stop()); localStream = null; }
130
+  if (pc) { pc.close(); pc = null; }
131
+  currentSessionId = null;
132
+  setStatus('Session ended. Waiting for sessions.', 'on');
133
+}
134
+
135
+function escapeHtml(s) { return String(s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c])); }

+ 34
- 0
server/.gitignore Zobrazit soubor

@@ -0,0 +1,34 @@
1
+# Dependencies
2
+node_modules/
3
+
4
+# Logs
5
+*.log
6
+npm-debug.log*
7
+
8
+# Database files
9
+*.db
10
+*.db-shm
11
+*.db-wal
12
+*.sqlite
13
+
14
+# Certificates & keys
15
+*.pem
16
+*.key
17
+*.crt
18
+
19
+# Environment files
20
+.env
21
+.env.*
22
+
23
+# Build output
24
+dist/
25
+build/
26
+out/
27
+
28
+# OS files
29
+.DS_Store
30
+Thumbs.db
31
+
32
+# Editor
33
+.vscode/
34
+.idea/

+ 75
- 0
server/auth.js Zobrazit soubor

@@ -0,0 +1,75 @@
1
+// Auth utilities — password hashing (scrypt), tokens, and TOTP MFA.
2
+// Uses only Node's built-in crypto, no external auth deps.
3
+const crypto = require('crypto');
4
+
5
+// ---- Passwords (scrypt) ----
6
+function hashPassword(password, salt = crypto.randomBytes(16).toString('hex')) {
7
+  const hash = crypto.scryptSync(password, salt, 64).toString('hex');
8
+  return { hash, salt };
9
+}
10
+function verifyPassword(password, salt, expectedHash) {
11
+  const hash = crypto.scryptSync(password, salt, 64).toString('hex');
12
+  return crypto.timingSafeEqual(Buffer.from(hash), Buffer.from(expectedHash));
13
+}
14
+
15
+// ---- Random tokens ----
16
+const token = (bytes = 24) => crypto.randomBytes(bytes).toString('hex');
17
+const id = () => crypto.randomBytes(8).toString('hex');
18
+const numericCode = (digits = 6) =>
19
+  String(crypto.randomInt(0, 10 ** digits)).padStart(digits, '0');
20
+
21
+// ---- TOTP (RFC 6238), SHA-1, 30s, 6 digits ----
22
+const B32 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
23
+function base32Encode(buf) {
24
+  let bits = 0, value = 0, out = '';
25
+  for (const byte of buf) {
26
+    value = (value << 8) | byte; bits += 8;
27
+    while (bits >= 5) { out += B32[(value >>> (bits - 5)) & 31]; bits -= 5; }
28
+  }
29
+  if (bits > 0) out += B32[(value << (5 - bits)) & 31];
30
+  return out;
31
+}
32
+function base32Decode(str) {
33
+  const clean = str.replace(/=+$/, '').toUpperCase();
34
+  let bits = 0, value = 0; const out = [];
35
+  for (const ch of clean) {
36
+    const idx = B32.indexOf(ch);
37
+    if (idx === -1) continue;
38
+    value = (value << 5) | idx; bits += 5;
39
+    if (bits >= 8) { out.push((value >>> (bits - 8)) & 0xff); bits -= 8; }
40
+  }
41
+  return Buffer.from(out);
42
+}
43
+function newMfaSecret() {
44
+  return base32Encode(crypto.randomBytes(20));
45
+}
46
+function totp(secret, timeStep = Math.floor(Date.now() / 30000)) {
47
+  const key = base32Decode(secret);
48
+  const buf = Buffer.alloc(8);
49
+  buf.writeBigUInt64BE(BigInt(timeStep));
50
+  const hmac = crypto.createHmac('sha1', key).update(buf).digest();
51
+  const offset = hmac[hmac.length - 1] & 0xf;
52
+  const code =
53
+    ((hmac[offset] & 0x7f) << 24) |
54
+    ((hmac[offset + 1] & 0xff) << 16) |
55
+    ((hmac[offset + 2] & 0xff) << 8) |
56
+    (hmac[offset + 3] & 0xff);
57
+  return String(code % 1_000_000).padStart(6, '0');
58
+}
59
+function verifyTotp(secret, code, window = 1) {
60
+  if (!/^\d{6}$/.test(String(code || ''))) return false;
61
+  const step = Math.floor(Date.now() / 30000);
62
+  for (let i = -window; i <= window; i++) {
63
+    if (totp(secret, step + i) === String(code)) return true;
64
+  }
65
+  return false;
66
+}
67
+function otpauthUrl(secret, email, issuer = 'RemoteAccess') {
68
+  return `otpauth://totp/${encodeURIComponent(issuer)}:${encodeURIComponent(email)}` +
69
+         `?secret=${secret}&issuer=${encodeURIComponent(issuer)}&algorithm=SHA1&digits=6&period=30`;
70
+}
71
+
72
+module.exports = {
73
+  hashPassword, verifyPassword, token, id, numericCode,
74
+  newMfaSecret, totp, verifyTotp, otpauthUrl,
75
+};

+ 80
- 0
server/db.js Zobrazit soubor

@@ -0,0 +1,80 @@
1
+// SQLite data layer + schema.
2
+// Uses Node's built-in node:sqlite (no native compilation needed).
3
+const { DatabaseSync } = require('node:sqlite');
4
+const path = require('path');
5
+
6
+const db = new DatabaseSync(process.env.DB_PATH || path.join(__dirname, 'data.db'));
7
+// WAL is preferred but unsupported on some mounted/network filesystems; fall back quietly.
8
+try { db.exec('PRAGMA journal_mode = WAL'); } catch { /* default rollback journal is fine */ }
9
+db.exec('PRAGMA foreign_keys = ON');
10
+
11
+db.exec(`
12
+CREATE TABLE IF NOT EXISTS teams (
13
+  id TEXT PRIMARY KEY,
14
+  name TEXT NOT NULL,
15
+  created_at INTEGER NOT NULL
16
+);
17
+
18
+CREATE TABLE IF NOT EXISTS users (
19
+  id TEXT PRIMARY KEY,
20
+  team_id TEXT NOT NULL REFERENCES teams(id),
21
+  email TEXT NOT NULL UNIQUE,
22
+  pw_hash TEXT NOT NULL,
23
+  pw_salt TEXT NOT NULL,
24
+  role TEXT NOT NULL DEFAULT 'technician',
25
+  mfa_secret TEXT,
26
+  mfa_enabled INTEGER NOT NULL DEFAULT 0,
27
+  created_at INTEGER NOT NULL
28
+);
29
+
30
+CREATE TABLE IF NOT EXISTS sessions_auth (
31
+  token TEXT PRIMARY KEY,
32
+  user_id TEXT NOT NULL REFERENCES users(id),
33
+  mfa_passed INTEGER NOT NULL DEFAULT 0,
34
+  created_at INTEGER NOT NULL,
35
+  expires_at INTEGER NOT NULL
36
+);
37
+
38
+CREATE TABLE IF NOT EXISTS machines (
39
+  id TEXT PRIMARY KEY,
40
+  team_id TEXT NOT NULL REFERENCES teams(id),
41
+  name TEXT NOT NULL,
42
+  enroll_token TEXT NOT NULL UNIQUE,
43
+  unattended INTEGER NOT NULL DEFAULT 0,
44
+  last_seen INTEGER,
45
+  created_at INTEGER NOT NULL
46
+);
47
+
48
+CREATE TABLE IF NOT EXISTS audit_log (
49
+  id INTEGER PRIMARY KEY AUTOINCREMENT,
50
+  team_id TEXT NOT NULL,
51
+  user_id TEXT,
52
+  user_email TEXT,
53
+  machine_id TEXT,
54
+  machine_name TEXT,
55
+  action TEXT NOT NULL,
56
+  detail TEXT,
57
+  at INTEGER NOT NULL
58
+);
59
+`);
60
+
61
+// Migration: optional display name for agents (shown to customers on consent)
62
+try { db.exec('ALTER TABLE users ADD COLUMN name TEXT'); } catch (e) { /* already exists */ }
63
+
64
+// Migration: agent active flag (deactivate without deleting)
65
+try { db.exec('ALTER TABLE users ADD COLUMN active INTEGER NOT NULL DEFAULT 1'); } catch (e) { /* exists */ }
66
+
67
+// Session report: one row per support session with duration
68
+db.exec(`
69
+CREATE TABLE IF NOT EXISTS sessions_log (
70
+  id TEXT PRIMARY KEY,
71
+  team_id TEXT NOT NULL,
72
+  agent_email TEXT,
73
+  agent_name TEXT,
74
+  ticket TEXT,
75
+  started_at INTEGER NOT NULL,
76
+  ended_at INTEGER
77
+);
78
+`);
79
+
80
+module.exports = db;

+ 37
- 0
server/package-lock.json Zobrazit soubor

@@ -0,0 +1,37 @@
1
+{
2
+  "name": "remote-access-server",
3
+  "version": "0.2.0",
4
+  "lockfileVersion": 3,
5
+  "requires": true,
6
+  "packages": {
7
+    "": {
8
+      "name": "remote-access-server",
9
+      "version": "0.2.0",
10
+      "dependencies": {
11
+        "ws": "^8.18.0"
12
+      },
13
+      "engines": {
14
+        "node": ">=22.5.0"
15
+      }
16
+    },
17
+    "node_modules/ws": {
18
+      "version": "8.21.0",
19
+      "license": "MIT",
20
+      "engines": {
21
+        "node": ">=10.0.0"
22
+      },
23
+      "peerDependencies": {
24
+        "bufferutil": "^4.0.1",
25
+        "utf-8-validate": ">=5.0.2"
26
+      },
27
+      "peerDependenciesMeta": {
28
+        "bufferutil": {
29
+          "optional": true
30
+        },
31
+        "utf-8-validate": {
32
+          "optional": true
33
+        }
34
+      }
35
+    }
36
+  }
37
+}

+ 13
- 0
server/package.json Zobrazit soubor

@@ -0,0 +1,13 @@
1
+{
2
+  "name": "remote-access-server",
3
+  "version": "0.2.0",
4
+  "description": "Backend platform: auth, MFA, RBAC, machine enrollment, signaling, audit logs",
5
+  "main": "server.js",
6
+  "type": "commonjs",
7
+  "scripts": {
8
+    "start": "node server.js",
9
+    "test": "node test/e2e.js"
10
+  },
11
+  "engines": { "node": ">=22.5.0" },
12
+  "dependencies": { "ws": "^8.18.0" }
13
+}

+ 183
- 0
server/public/connect.html Zobrazit soubor

@@ -0,0 +1,183 @@
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 Support — Agent Console</title>
7
+<style>
8
+  :root{ --brand:#FFC708; --brand-d:#E0AC00; --blue:#1F3B73; --blue-d:#16294f; --ink:#1f2430; --muted:#6b7280; --bg:#f6f8fb; --card:#fff; --line:#e6e9ef; }
9
+  *{box-sizing:border-box;}
10
+  body{font-family:'Segoe UI',system-ui,sans-serif;background:var(--bg);color:var(--ink);margin:0;}
11
+  .topbar{background:var(--blue);padding:.7rem 1.2rem;display:flex;align-items:center;justify-content:space-between;gap:.6rem;}
12
+  .brandrow{display:flex;align-items:center;gap:.6rem;}
13
+  .logo{width:28px;height:28px;border-radius:7px;background:var(--brand);display:grid;place-items:center;font-weight:800;color:var(--blue);}
14
+  .brand{font-weight:700;color:#fff;} .brand span{color:var(--brand);font-weight:600;}
15
+  .agentchip{color:#dbe4f5;font-size:.85rem;}
16
+  .agentchip b{color:#fff;} .agentchip a{color:var(--brand);text-decoration:none;margin-left:.5rem;cursor:pointer;}
17
+  .wrap{min-height:calc(100vh - 50px);display:grid;place-items:center;padding:1.5rem;}
18
+  .card{background:var(--card);border:1px solid var(--line);border-radius:18px;padding:2.2rem;max-width:430px;width:100%;box-shadow:0 10px 30px rgba(20,30,60,.06);}
19
+  h1{font-size:1.35rem;margin:.1rem 0 .3rem;color:var(--blue);text-align:center;}
20
+  .sub{color:var(--muted);font-size:.92rem;margin-bottom:1.3rem;text-align:center;}
21
+  .lbl{display:block;font-size:.74rem;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;margin:.85rem 0 .25rem;}
22
+  input{width:100%;padding:.7rem;border-radius:10px;border:2px solid var(--line);background:#fbfcfe;color:var(--ink);font-size:1rem;}
23
+  input:focus{outline:none;border-color:var(--brand);}
24
+  input.code{font-size:1.7rem;letter-spacing:.35rem;text-align:center;}
25
+  .btn{margin-top:1.1rem;width:100%;padding:.85rem;background:var(--brand);color:var(--ink);border:none;border-radius:10px;font-weight:700;font-size:1.02rem;cursor:pointer;}
26
+  .btn:hover{background:var(--brand-d);}
27
+  .status{color:var(--muted);font-size:.88rem;margin-top:.9rem;text-align:center;min-height:1.1em;}
28
+  .err{color:#b91c1c;}
29
+  .prefill{background:#EAF0FB;border:1px solid #c7d6f0;border-radius:8px;padding:.5rem .7rem;font-size:.85rem;color:var(--blue-d);margin-top:.25rem;}
30
+  .topbar2{background:var(--card);border-bottom:1px solid var(--line);padding:.5rem 1rem;display:none;justify-content:space-between;align-items:center;}
31
+  .topbar2.show{display:flex;} #barStatus{font-weight:600;font-size:.9rem;color:var(--blue);}
32
+  #endBtn{padding:.45rem 1rem;background:#fee2e2;color:#b91c1c;border:none;border-radius:8px;font-weight:600;cursor:pointer;}
33
+  #video{width:100vw;height:calc(100vh - 46px);background:#0b1220;object-fit:contain;display:none;cursor:crosshair;outline:none;}
34
+</style>
35
+</head>
36
+<body>
37
+<div class="topbar" id="topbar">
38
+  <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>
39
+  <div class="agentchip" id="agentChip"></div>
40
+</div>
41
+<div class="topbar2" id="bar"><span id="barStatus"></span><button id="endBtn">End session</button></div>
42
+<div class="wrap" id="wrap"><div class="card" id="card"></div></div>
43
+<video id="video" autoplay playsinline muted tabindex="0"></video>
44
+
45
+<script>
46
+const params=new URLSearchParams(location.search);
47
+const presetTicket=params.get('ticket')||'';
48
+const presetCode=params.get('code')||'';
49
+const card=document.getElementById('card'), wrap=document.getElementById('wrap'),
50
+      agentChip=document.getElementById('agentChip'), bar=document.getElementById('bar'),
51
+      topbar=document.getElementById('topbar'), video=document.getElementById('video'), barStatus=document.getElementById('barStatus');
52
+let ws,pc,inputChannel,sessionId,me=null;
53
+
54
+async function api(path,body,method='POST'){
55
+  const opt={method,headers:{'Content-Type':'application/json'}};
56
+  if(body)opt.body=JSON.stringify(body);
57
+  const r=await fetch(path,opt); const data=await r.json().catch(()=>({}));
58
+  if(!r.ok) throw new Error(data.error||'request failed'); return data;
59
+}
60
+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(); } }); }); }
61
+
62
+// ---- boot: are we a logged-in agent? ----
63
+(async function boot(){
64
+  try{ me=await api('/api/me',null,'GET'); renderAgent(); }
65
+  catch{ renderLogin(); }
66
+})();
67
+
68
+// ---- LOGIN ----
69
+function renderLogin(){
70
+  agentChip.textContent='';
71
+  card.innerHTML = `
72
+    <h1>Agent sign in</h1>
73
+    <div class="sub">Sign in to start a support session. Your name is shown to the customer.</div>
74
+    <span class="lbl">Email</span><input id="email" type="email" placeholder="you@bizgaze.com">
75
+    <span class="lbl">Password</span><input id="pw" type="password" placeholder="password">
76
+    <button class="btn" id="loginBtn">Sign in</button>
77
+    <div class="status err" id="err"></div>`;
78
+  {
79
+    const doSignIn=async()=>{
80
+      try{
81
+        await api('/api/login',{email:document.getElementById('email').value,password:document.getElementById('pw').value});
82
+        me=await api('/api/me',null,'GET'); renderAgent();
83
+      }catch(e){ document.getElementById('err').textContent=e.message; }
84
+    };
85
+    document.getElementById('loginBtn').onclick=doSignIn;
86
+    onEnter(['email','pw'], doSignIn);
87
+  }
88
+}
89
+
90
+// ---- AGENT CONNECT ----
91
+function renderAgent(){
92
+  const displayName = me.name || me.email;
93
+  agentChip.innerHTML = `Signed in as <b>${esc(displayName)}</b><a id="logout">Log out</a>`;
94
+  document.getElementById('logout').onclick=async()=>{ await api('/api/logout'); location.reload(); };
95
+
96
+  card.innerHTML = `
97
+    <h1>Start a support session</h1>
98
+    <div class="sub">Ask the customer to open the share page and read you their code.</div>
99
+    <span class="lbl">Ticket number (optional)</span>
100
+    <input id="ticketInput" maxlength="40" placeholder="e.g. TKT-1042 — leave blank for a direct session" value="${esc(presetTicket)}" ${presetTicket?'readonly':''}>
101
+    ${presetTicket?'<div class="prefill">Linked from service request '+esc(presetTicket)+'</div>':''}
102
+    <span class="lbl">Session code from customer</span>
103
+    <input id="codeInput" class="code" maxlength="6" inputmode="numeric" placeholder="000000" value="${esc(presetCode)}">
104
+    <button class="btn" id="connectBtn">Connect</button>
105
+    <div class="status" id="status">Enter the customer's code to begin.</div>`;
106
+  connectWS();
107
+  document.getElementById('connectBtn').onclick=startConnect;
108
+  onEnter(['ticketInput','codeInput'], startConnect);
109
+}
110
+
111
+async function startConnect(){
112
+  const statusEl=document.getElementById('status'); statusEl.className='status';
113
+  const ticket=document.getElementById('ticketInput').value.trim();
114
+  const code=document.getElementById('codeInput').value.trim();
115
+  if(!/^\d{6}$/.test(code)){ statusEl.textContent='Please enter the 6-digit code.'; return; }
116
+  statusEl.textContent='Connecting…';
117
+  ws.send(JSON.stringify({type:'code-connect',code,ticket}));
118
+}
119
+
120
+function connectWS(){
121
+  ws=new WebSocket((location.protocol==='https:'?'wss://':'ws://')+location.host+'/ws');
122
+  ws.onmessage=async(e)=>{const m=JSON.parse(e.data);const statusEl=document.getElementById('status');switch(m.type){
123
+    case 'code-pending': sessionId=m.sessionId; renderWaiting(); setupPeer(); break;
124
+    case 'session-ready': if(statusEl)statusEl.textContent='Allowed — connecting…'; break;
125
+    case 'offer': await pc.setRemoteDescription(new RTCSessionDescription(m.sdp));
126
+      const ans=await pc.createAnswer(); await pc.setLocalDescription(ans);
127
+      ws.send(JSON.stringify({type:'answer',sessionId,sdp:pc.localDescription})); break;
128
+    case 'ice-candidate': if(m.candidate&&pc) await pc.addIceCandidate(new RTCIceCandidate(m.candidate)); break;
129
+    case 'session-denied': renderEnded('The customer declined the request.'); break;
130
+    case 'session-ended': {
131
+      const msgs={'share-cancelled':'The customer cancelled the screen selection. Ask them to refresh their page for a new code.',
132
+                  'customer-ended':'The customer stopped sharing their screen. Ask them to refresh their page for a new code.',
133
+                  'agent-ended':'You ended the session.'};
134
+      renderEnded(msgs[m.reason]||'The session has ended.'); break;
135
+    }
136
+    case 'error': if(statusEl){statusEl.className='status err';statusEl.textContent=m.message;} break;
137
+  }};
138
+}
139
+function renderWaiting(){
140
+  card.innerHTML=`
141
+    <h1>Waiting for the customer…</h1>
142
+    <div class="sub">Code accepted. The customer has been asked to tap <b>Allow</b> on their screen.</div>
143
+    <div class="status" id="status">Waiting for the customer to tap Allow…</div>
144
+    <button class="btn" id="cancelBtn" style="background:#eef1f6;color:#1F3B73">Cancel</button>`;
145
+  document.getElementById('cancelBtn').onclick=()=>{ try{ws.send(JSON.stringify({type:'end-session',sessionId}));}catch(e){} location.reload(); };
146
+}
147
+
148
+function renderEnded(msg){
149
+  if(pc){ try{pc.close();}catch(e){} pc=null; }
150
+  video.style.display='none'; bar.classList.remove('show');
151
+  topbar.style.display='flex'; wrap.style.display='grid';
152
+  card.innerHTML=`
153
+    <h1>Session ended</h1>
154
+    <div class="sub">${esc(msg)}</div>
155
+    <button class="btn" id="againBtn">Start a new session</button>`;
156
+  document.getElementById('againBtn').onclick=()=>location.reload();
157
+}
158
+
159
+function setupPeer(){
160
+  pc=new RTCPeerConnection({iceServers:[{urls:'stun:stun.l.google.com:19302'}]});
161
+  inputChannel=pc.createDataChannel('input',{ordered:true});
162
+  pc.ontrack=(ev)=>{
163
+    video.srcObject=ev.streams[0]; wrap.style.display='none'; topbar.style.display='none'; bar.classList.add('show'); video.style.display='block';
164
+    barStatus.textContent='Connected — viewing the customer’s screen'; video.focus();
165
+  };
166
+  pc.onicecandidate=(ev)=>{if(ev.candidate)ws.send(JSON.stringify({type:'ice-candidate',sessionId,candidate:ev.candidate}));};
167
+}
168
+const send=(o)=>{if(inputChannel&&inputChannel.readyState==='open')inputChannel.send(JSON.stringify(o));};
169
+const rel=(e)=>{const r=video.getBoundingClientRect();return{x:(e.clientX-r.left)/r.width,y:(e.clientY-r.top)/r.height};};
170
+let lm=0;
171
+video.addEventListener('mousemove',e=>{const t=performance.now();if(t-lm<30)return;lm=t;send({kind:'mousemove',...rel(e)});});
172
+video.addEventListener('mousedown',e=>{video.focus();send({kind:'mousedown',button:e.button,...rel(e)});});
173
+video.addEventListener('mouseup',e=>send({kind:'mouseup',button:e.button,...rel(e)}));
174
+video.addEventListener('dblclick',e=>send({kind:'dblclick',...rel(e)}));
175
+video.addEventListener('wheel',e=>{e.preventDefault();send({kind:'scroll',dx:e.deltaX,dy:e.deltaY});},{passive:false});
176
+video.addEventListener('contextmenu',e=>e.preventDefault());
177
+video.addEventListener('keydown',e=>{e.preventDefault();send({kind:'keydown',key:e.key,code:e.code});});
178
+video.addEventListener('keyup',e=>{e.preventDefault();send({kind:'keyup',key:e.key,code:e.code});});
179
+document.getElementById('endBtn').onclick=()=>{ws.send(JSON.stringify({type:'end-session',sessionId,reason:'agent-ended'}));};
180
+function esc(s){return String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));}
181
+</script>
182
+</body>
183
+</html>

+ 102
- 0
server/public/host.html Zobrazit soubor

@@ -0,0 +1,102 @@
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>Browser Host — Remote Access</title>
7
+<style>
8
+  body { font-family: system-ui, sans-serif; background:#0f172a; color:#e2e8f0; margin:0; padding:1.5rem; }
9
+  .card { max-width:560px; margin:0 auto; background:#1e293b; border-radius:12px; padding:1.5rem; }
10
+  h1 { font-size:1.15rem; margin:0 0 1rem; }
11
+  input { width:100%; padding:.7rem; border-radius:8px; border:1px solid #334155; background:#0f172a; color:#e2e8f0; margin:.3rem 0; }
12
+  button { padding:.7rem 1.3rem; background:#3b82f6; color:#fff; border:none; border-radius:8px; font-weight:600; cursor:pointer; }
13
+  .status { background:#0f172a; padding:.7rem 1rem; border-radius:8px; margin:1rem 0; font-size:.9rem; }
14
+  .status.on { background:#14532d; } .status.warn { background:#7c2d12; }
15
+  .consent { border:1px solid #3b82f6; border-radius:10px; padding:1rem; margin-top:1rem; }
16
+  .grant { background:#22c55e; color:#052e16; } .deny { background:#ef4444; margin-left:.5rem; }
17
+  .muted { color:#94a3b8; font-size:.82rem; }
18
+  #log { font-family:monospace; font-size:.72rem; color:#64748b; height:120px; overflow-y:auto; margin-top:1rem; background:#020617; padding:.6rem; border-radius:8px; }
19
+  .indicator { position:fixed; bottom:0; left:0; right:0; background:#b91c1c; color:#fff; text-align:center; padding:.4rem; font-size:.85rem; display:none; }
20
+  .indicator.show { display:block; }
21
+</style>
22
+</head>
23
+<body>
24
+<div class="card">
25
+  <h1>🖥️ Browser Host (no install)</h1>
26
+  <p class="muted">Shares this screen with a technician. Paste the enroll token from the console and click Go online.</p>
27
+  <input id="token" placeholder="enroll token">
28
+  <button id="goBtn">Go online</button>
29
+  <div id="status" class="status">Idle.</div>
30
+  <div id="consentBox"></div>
31
+  <div id="log"></div>
32
+</div>
33
+<div id="indicator" class="indicator">● A technician is viewing this screen</div>
34
+
35
+<script>
36
+const statusEl = document.getElementById('status');
37
+const consentBox = document.getElementById('consentBox');
38
+const indicator = document.getElementById('indicator');
39
+const logEl = document.getElementById('log');
40
+const log = (t) => { const d=document.createElement('div'); d.textContent=t; logEl.appendChild(d); logEl.scrollTop=logEl.scrollHeight; };
41
+const setStatus = (t,c='') => { statusEl.textContent=t; statusEl.className='status '+c; };
42
+
43
+let ws, pc, localStream, sessionId;
44
+
45
+document.getElementById('goBtn').onclick = () => {
46
+  const token = document.getElementById('token').value.trim();
47
+  if (!token) return setStatus('Enter the enroll token first.', 'warn');
48
+  connect(token);
49
+};
50
+
51
+function connect(token) {
52
+  setStatus('Connecting to server…');
53
+  ws = new WebSocket((location.protocol==='https:'?'wss://':'ws://') + location.host + '/ws');
54
+  ws.onopen = () => ws.send(JSON.stringify({ type:'agent-hello', enrollToken: token }));
55
+  ws.onmessage = (e) => handle(JSON.parse(e.data));
56
+  ws.onclose = () => setStatus('Disconnected.', 'warn');
57
+}
58
+
59
+async function handle(m) {
60
+  switch (m.type) {
61
+    case 'agent-registered': setStatus(`Online as "${m.name}". Waiting for a session.`, 'on'); log('registered: '+m.name); break;
62
+    case 'session-request': m.unattended ? grant(m.sessionId) : showConsent(m); break;
63
+    case 'start-stream': sessionId = m.sessionId; await startStreaming(); break;
64
+    case 'answer': if (pc) await pc.setRemoteDescription(new RTCSessionDescription(m.sdp)); break;
65
+    case 'ice-candidate': if (m.candidate && pc) await pc.addIceCandidate(new RTCIceCandidate(m.candidate)); break;
66
+    case 'session-ended': teardown(); break;
67
+    case 'error': setStatus('Server: '+m.message, 'warn'); break;
68
+  }
69
+}
70
+
71
+function showConsent(m) {
72
+  consentBox.innerHTML = `<div class="consent"><b>${esc(m.technician)}</b> wants to view this screen.
73
+    <div style="margin-top:.6rem"><button class="grant" id="g">Allow</button><button class="deny" id="d">Deny</button></div></div>`;
74
+  document.getElementById('g').onclick = () => { consentBox.innerHTML=''; grant(m.sessionId); };
75
+  document.getElementById('d').onclick = () => { consentBox.innerHTML=''; ws.send(JSON.stringify({type:'consent',sessionId:m.sessionId,granted:false})); };
76
+}
77
+const grant = (sid) => ws.send(JSON.stringify({ type:'consent', sessionId:sid, granted:true }));
78
+
79
+async function startStreaming() {
80
+  setStatus('Sharing screen…', 'on'); indicator.classList.add('show');
81
+  try { localStream = await navigator.mediaDevices.getDisplayMedia({ video:{frameRate:{ideal:30}}, audio:false }); }
82
+  catch (err) { log('getDisplayMedia failed: '+err.message); setStatus('Screen capture cancelled/failed.', 'warn'); return; }
83
+  pc = new RTCPeerConnection({ iceServers:[{urls:'stun:stun.l.google.com:19302'}] });
84
+  localStream.getTracks().forEach(t => pc.addTrack(t, localStream));
85
+  pc.ondatachannel = (ev) => { ev.channel.onmessage = (msg) => { let e; try{e=JSON.parse(msg.data)}catch{return} if(e.kind!=='mousemove') log('remote input: '+e.kind+' '+(e.key||'')); }; };
86
+  pc.onicecandidate = (ev) => { if (ev.candidate) ws.send(JSON.stringify({type:'ice-candidate',sessionId,candidate:ev.candidate})); };
87
+  const offer = await pc.createOffer(); await pc.setLocalDescription(offer);
88
+  ws.send(JSON.stringify({ type:'offer', sessionId, sdp: pc.localDescription }));
89
+  log('sent offer');
90
+  localStream.getVideoTracks()[0].onended = () => teardown();
91
+}
92
+
93
+function teardown() {
94
+  indicator.classList.remove('show');
95
+  if (localStream) { localStream.getTracks().forEach(t=>t.stop()); localStream=null; }
96
+  if (pc) { pc.close(); pc=null; }
97
+  setStatus('Session ended. Still online, waiting.', 'on');
98
+}
99
+function esc(s){return String(s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));}
100
+</script>
101
+</body>
102
+</html>

+ 335
- 0
server/public/index.html Zobrazit soubor

@@ -0,0 +1,335 @@
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 Support — Console</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
+  body{font-family:'Segoe UI',system-ui,sans-serif;background:var(--bg);color:var(--ink);margin:0;}
11
+  header{background:var(--blue);padding:.75rem 1.5rem;display:flex;justify-content:space-between;align-items:center;}
12
+  .brandrow{display:flex;align-items:center;gap:.6rem;}
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;}
16
+  main{max-width:1020px;margin:1.8rem auto;padding:0 1rem;}
17
+  .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
+  h2{font-size:1rem;margin:0 0 1rem;color:var(--blue);}
19
+  input,select{width:100%;padding:.6rem .7rem;border-radius:10px;border:2px solid var(--line);background:#fbfcfe;color:var(--ink);margin:.25rem 0;font-size:.92rem;}
20
+  input:focus,select:focus{outline:none;border-color:var(--brand);}
21
+  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
+  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
+  button.mini{padding:.32rem .6rem;font-size:.76rem;font-weight:600;background:#eef1f6;color:var(--blue);border:1px solid var(--line);}
26
+  button.mini:hover{background:var(--blue-soft);}
27
+  button.mini.danger{color:var(--red);}
28
+  .row{display:flex;gap:.5rem;align-items:center;}
29
+  .muted{color:var(--muted);font-size:.85rem;}
30
+  table{width:100%;border-collapse:collapse;font-size:.88rem;}
31
+  th{color:var(--muted);font-weight:600;font-size:.76rem;text-transform:uppercase;letter-spacing:.04em;}
32
+  th,td{text-align:left;padding:.55rem .5rem;border-bottom:1px solid var(--line);}
33
+  .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);}
35
+  .hidden{display:none;}
36
+  .tabs{display:flex;gap:.5rem;margin-bottom:1.2rem;}
37
+  .tabs button{background:#eef1f6;color:var(--muted);font-weight:600;}
38
+  .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
+  .lbl{display:block;font-size:.74rem;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;margin:.7rem 0 .15rem;}
45
+  .filters{display:flex;gap:.6rem;align-items:flex-end;flex-wrap:wrap;margin-bottom:1rem;}
46
+  .filters .f{flex:1;min-width:140px;}
47
+  .filters .lbl{margin:.1rem 0 .15rem;}
48
+</style>
49
+</head>
50
+<body>
51
+<header>
52
+  <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>
53
+  <div class="row"><span id="who" class="who"></span><button id="logoutBtn" class="ghost hidden">Log out</button></div>
54
+</header>
55
+<main id="app"></main>
56
+
57
+<script>
58
+const app = document.getElementById('app');
59
+const who = document.getElementById('who');
60
+const logoutBtn = document.getElementById('logoutBtn');
61
+
62
+async function api(path, body, method = 'POST') {
63
+  const opt = { method, headers: { 'Content-Type': 'application/json' } };
64
+  if (body) opt.body = JSON.stringify(body);
65
+  const r = await fetch(path, opt);
66
+  const data = await r.json().catch(() => ({}));
67
+  if (!r.ok) throw new Error(data.error || 'request failed');
68
+  return data;
69
+}
70
+
71
+logoutBtn.onclick = async () => { await api('/api/logout'); location.reload(); };
72
+
73
+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(); } }); }); }
74
+
75
+function view(html) { app.innerHTML = html; }
76
+
77
+// ---------- Auth ----------
78
+function authView() {
79
+  who.textContent = '';
80
+  logoutBtn.classList.add('hidden');
81
+  view(`
82
+    <div class="card" style="max-width:420px;margin:3rem auto">
83
+      <div class="tabs">
84
+        <button id="tabLogin" class="active">Sign in</button>
85
+        <button id="tabReg">Register team</button>
86
+      </div>
87
+      <div id="loginForm">
88
+        <span class="lbl">Email</span>
89
+        <input id="li_email" placeholder="you@bizgaze.com" type="email">
90
+        <span class="lbl">Password</span>
91
+        <input id="li_pw" placeholder="password" type="password">
92
+        <button id="li_btn" style="width:100%;margin-top:1rem">Sign in</button>
93
+        <p id="li_err" class="muted"></p>
94
+      </div>
95
+      <div id="regForm" class="hidden">
96
+        <span class="lbl">Team name</span>
97
+        <input id="rg_team" placeholder="e.g. BizGaze Support">
98
+        <span class="lbl">Email</span>
99
+        <input id="rg_email" placeholder="you@bizgaze.com" type="email">
100
+        <span class="lbl">Password</span>
101
+        <input id="rg_pw" placeholder="min 8 characters" type="password">
102
+        <button id="rg_btn" style="width:100%;margin-top:1rem">Create team</button>
103
+        <p id="rg_err" class="muted"></p>
104
+      </div>
105
+    </div>`);
106
+  document.getElementById('tabLogin').onclick = () => { toggle(true); };
107
+  document.getElementById('tabReg').onclick = () => { toggle(false); };
108
+  function toggle(login) {
109
+    document.getElementById('loginForm').classList.toggle('hidden', !login);
110
+    document.getElementById('regForm').classList.toggle('hidden', login);
111
+    document.getElementById('tabLogin').classList.toggle('active', login);
112
+    document.getElementById('tabReg').classList.toggle('active', !login);
113
+  }
114
+  document.getElementById('li_btn').onclick = doLogin;
115
+  document.getElementById('rg_btn').onclick = doRegister;
116
+  onEnter(['li_email','li_pw'], doLogin);
117
+  onEnter(['rg_team','rg_email','rg_pw'], doRegister);
118
+}
119
+
120
+async function doLogin() {
121
+  try {
122
+    await api('/api/login', { email: li_email.value, password: li_pw.value });
123
+    location.reload();
124
+  } catch (e) { li_err.textContent = e.message; }
125
+}
126
+
127
+async function doRegister() {
128
+  try {
129
+    await api('/api/register', { email: rg_email.value, password: rg_pw.value, teamName: rg_team.value });
130
+    await api('/api/login', { email: rg_email.value, password: rg_pw.value });
131
+    location.reload();
132
+  } catch (e) { rg_err.textContent = e.message; }
133
+}
134
+
135
+// ---------- Dashboard ----------
136
+let ME = null;
137
+async function dashboard(me) {
138
+  ME = me;
139
+  who.textContent = `${me.name || me.email} · ${me.role}`;
140
+  logoutBtn.classList.remove('hidden');
141
+  view(`
142
+    <div class="card quick">
143
+      <div><h2>Start a support session</h2><p>Customer gives you their 6-digit code from the share page.</p></div>
144
+      <a href="/connect">Open connect page →</a>
145
+    </div>
146
+    <div class="card" id="agentsCard">
147
+      <h2>Agents</h2>
148
+      <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>
149
+      <div class="row" style="margin-top:1rem;flex-wrap:wrap">
150
+        <input id="agEmail" placeholder="agent email" style="max-width:200px">
151
+        <input id="agName" placeholder="display name (from BizGaze)" style="max-width:210px">
152
+        <input id="agPw" placeholder="temporary password" style="max-width:170px">
153
+        <select id="agRole" style="max-width:140px">
154
+          <option value="technician">technician</option><option value="admin">admin</option><option value="viewer">view-only</option>
155
+        </select>
156
+        <button id="agAdd">Add agent</button>
157
+      </div>
158
+      <p id="agOut" class="muted"></p>
159
+    </div>
160
+    <div class="card">
161
+      <h2>Session report</h2>
162
+      <div class="filters">
163
+        <div class="f"><span class="lbl">Agent</span><select id="fAgent"><option value="">All agents</option></select></div>
164
+        <div class="f"><span class="lbl">From</span><input id="fFrom" type="date"></div>
165
+        <div class="f"><span class="lbl">To</span><input id="fTo" type="date"></div>
166
+        <button id="fApply">Apply</button>
167
+        <button id="fExcel" class="mini" style="padding:.6rem .9rem">⬇ Excel</button>
168
+        <button id="fPdf" class="mini" style="padding:.6rem .9rem">⬇ PDF</button>
169
+      </div>
170
+      <table id="report"><thead><tr><th>Date</th><th>Start time</th><th>Agent</th><th>Ticket</th><th>Time spent</th></tr></thead><tbody></tbody></table>
171
+      <p id="repSummary" class="muted" style="margin-top:.6rem"></p>
172
+    </div>`);
173
+
174
+  if (me.role !== 'admin') document.getElementById('agentsCard').style.display = 'none';
175
+  else {
176
+    document.getElementById('agAdd').onclick = addAgent;
177
+    onEnter(['agEmail','agName','agPw'], addAgent);
178
+    await loadAgents();
179
+  }
180
+  document.getElementById('fApply').onclick = loadReport;
181
+  document.getElementById('fExcel').onclick = exportExcel;
182
+  document.getElementById('fPdf').onclick = exportPdf;
183
+  await populateAgentFilter();
184
+  await loadReport();
185
+}
186
+
187
+async function addAgent() {
188
+  try {
189
+    const r = await api('/api/users', { email: agEmail.value, name: agName.value, password: agPw.value, role: agRole.value });
190
+    agOut.textContent = `Agent ${r.email} added. Share the email + temporary password — they sign in at /connect.`;
191
+    agEmail.value = ''; agName.value = ''; agPw.value = '';
192
+    loadAgents(); populateAgentFilter();
193
+  } catch (e) { agOut.textContent = e.message; }
194
+}
195
+
196
+async function loadAgents() {
197
+  const rows = await api('/api/users', null, 'GET');
198
+  document.querySelector('#agents tbody').innerHTML = rows.map((u) => `
199
+    <tr>
200
+      <td>${esc(u.email)}</td><td>${esc(u.name || '—')}</td><td>${esc(u.role)}</td>
201
+      <td><span class="pill ${u.active === 0 ? 'off' : 'on'}">${u.active === 0 ? 'deactivated' : 'active'}</span></td>
202
+      <td>
203
+        <button class="mini" onclick="resetPw('${u.id}','${esc(u.email)}')">Reset password</button>
204
+        <button class="mini" onclick="renameAgent('${u.id}','${esc(u.email)}')">Edit name</button>
205
+        ${u.id === ME.id ? '' : (u.active === 0
206
+          ? `<button class="mini" onclick="manage('${u.id}','activate')">Activate</button>`
207
+          : `<button class="mini danger" onclick="manage('${u.id}','deactivate')">Deactivate</button>`)
208
+        }
209
+        ${u.id === ME.id ? '' : `<button class="mini danger" onclick="delAgent('${u.id}','${esc(u.email)}')">Delete</button>`}
210
+      </td>
211
+    </tr>`).join('');
212
+}
213
+
214
+window.resetPw = async (id, email) => {
215
+  const pw = prompt(`New password for ${email} (min 8 characters):`);
216
+  if (!pw) return;
217
+  try { await api('/api/users/manage', { id, action: 'reset-password', password: pw }); agOut.textContent = `Password reset for ${email}. They were signed out everywhere.`; }
218
+  catch (e) { agOut.textContent = e.message; }
219
+};
220
+window.renameAgent = async (id, email) => {
221
+  const name = prompt(`Display name for ${email} (as in the BizGaze app):`);
222
+  if (!name) return;
223
+  try { await api('/api/users/manage', { id, action: 'rename', name }); loadAgents(); }
224
+  catch (e) { agOut.textContent = e.message; }
225
+};
226
+window.manage = async (id, action) => {
227
+  try { await api('/api/users/manage', { id, action }); loadAgents(); }
228
+  catch (e) { agOut.textContent = e.message; }
229
+};
230
+window.delAgent = async (id, email) => {
231
+  if (!confirm(`Delete ${email}? This cannot be undone. (Tip: Deactivate keeps their history.)`)) return;
232
+  try { await api('/api/users/manage', { id, action: 'delete' }); loadAgents(); populateAgentFilter(); }
233
+  catch (e) { agOut.textContent = e.message; }
234
+};
235
+
236
+// ---------- Session report ----------
237
+async function populateAgentFilter() {
238
+  try {
239
+    const rows = await api('/api/users', null, 'GET');
240
+    const sel = document.getElementById('fAgent');
241
+    const cur = sel.value;
242
+    sel.innerHTML = '<option value="">All agents</option>' + rows.map(u => `<option value="${esc(u.email)}">${esc(u.name || u.email)}</option>`).join('');
243
+    sel.value = cur;
244
+  } catch { /* non-admins cannot list agents; filter stays "All" */ }
245
+}
246
+
247
+function fmtDuration(ms) {
248
+  if (ms == null) return '—';
249
+  const s = Math.round(ms / 1000);
250
+  if (s < 60) return s + 's';
251
+  const m = Math.floor(s / 60), r = s % 60;
252
+  if (m < 60) return m + 'm ' + r + 's';
253
+  return Math.floor(m / 60) + 'h ' + (m % 60) + 'm';
254
+}
255
+
256
+let REPORT_ROWS = [];
257
+async function loadReport() {
258
+  const q = new URLSearchParams();
259
+  if (fAgent.value) q.set('agent', fAgent.value);
260
+  if (fFrom.value) q.set('from', fFrom.value);
261
+  if (fTo.value) q.set('to', fTo.value);
262
+  const rows = await api('/api/report?' + q.toString(), null, 'GET');
263
+  REPORT_ROWS = rows;
264
+  document.querySelector('#report tbody').innerHTML = rows.map((r) => {
265
+    const d = new Date(r.started_at);
266
+    const dur = r.ended_at ? (r.ended_at - r.started_at) : null;
267
+    return `<tr>
268
+      <td>${d.toLocaleDateString()}</td>
269
+      <td class="muted">${d.toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'})}</td>
270
+      <td>${esc(r.agent_name || r.agent_email || '—')}</td>
271
+      <td>${esc(r.ticket || 'Direct session')}</td>
272
+      <td>${r.ended_at ? fmtDuration(dur) : '<span class="pill on">in progress</span>'}</td>
273
+    </tr>`;
274
+  }).join('') || '<tr><td colspan=5 class="muted">No sessions in this period.</td></tr>';
275
+  const total = rows.reduce((a, r) => a + (r.ended_at ? r.ended_at - r.started_at : 0), 0);
276
+  repSummary.textContent = rows.length ? `${rows.length} session(s) · total time ${fmtDuration(total)}` : '';
277
+}
278
+
279
+function reportData() {
280
+  return REPORT_ROWS.map((r) => {
281
+    const d = new Date(r.started_at);
282
+    return {
283
+      date: d.toLocaleDateString(), start: d.toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'}),
284
+      agent: r.agent_name || r.agent_email || '', ticket: r.ticket || 'Direct session',
285
+      spent: r.ended_at ? fmtDuration(r.ended_at - r.started_at) : 'in progress',
286
+    };
287
+  });
288
+}
289
+
290
+function exportExcel() {
291
+  const rows = reportData();
292
+  if (!rows.length) { repSummary.textContent = 'Nothing to export for this period.'; return; }
293
+  const head = ['Date','Start time','Agent','Ticket','Time spent'];
294
+  const csvCell = (v) => '"' + String(v).replace(/"/g, '""') + '"';
295
+  const csv = '\ufeff' + [head, ...rows.map(r => [r.date, r.start, r.agent, r.ticket, r.spent])]
296
+    .map(line => line.map(csvCell).join(',')).join('\r\n');
297
+  const a = document.createElement('a');
298
+  a.href = URL.createObjectURL(new Blob([csv], { type: 'text/csv;charset=utf-8' }));
299
+  a.download = 'session-report.csv';
300
+  a.click(); URL.revokeObjectURL(a.href);
301
+}
302
+
303
+function exportPdf() {
304
+  const rows = reportData();
305
+  if (!rows.length) { repSummary.textContent = 'Nothing to export for this period.'; return; }
306
+  const period = (fFrom.value || 'start') + ' to ' + (fTo.value || 'today');
307
+  const agentSel = fAgent.value || 'All agents';
308
+  const w = window.open('', '_blank');
309
+  w.document.write('<html><head><title>Session report</title><style>' +
310
+    'body{font-family:Segoe UI,Arial,sans-serif;color:#1f2430;margin:32px}' +
311
+    'h1{font-size:18px;color:#1F3B73;border-bottom:3px solid #FFC708;padding-bottom:6px}' +
312
+    '.meta{color:#6b7280;font-size:12px;margin-bottom:14px}' +
313
+    'table{width:100%;border-collapse:collapse;font-size:12px}' +
314
+    'th{background:#1F3B73;color:#fff;text-align:left;padding:6px 8px}' +
315
+    'td{padding:6px 8px;border-bottom:1px solid #e6e9ef}' +
316
+    '</style></head><body>' +
317
+    '<h1>BizGaze Support — Session report</h1>' +
318
+    '<div class="meta">Agent: ' + esc(agentSel) + ' · Period: ' + esc(period) + ' · Generated ' + new Date().toLocaleString() + '</div>' +
319
+    '<table><tr><th>Date</th><th>Start time</th><th>Agent</th><th>Ticket</th><th>Time spent</th></tr>' +
320
+    rows.map(r => '<tr><td>' + [r.date, r.start, esc(r.agent), esc(r.ticket), r.spent].join('</td><td>') + '</td></tr>').join('') +
321
+    '</table><div class="meta" style="margin-top:12px">' + esc(repSummary.textContent) + '</div></body></html>');
322
+  w.document.close();
323
+  w.onload = () => { w.print(); };
324
+}
325
+
326
+function esc(s) { return String(s == null ? '' : s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c])); }
327
+
328
+// ---------- Boot ----------
329
+(async function () {
330
+  try { const me = await api('/api/me', null, 'GET'); dashboard(me); }
331
+  catch { authView(); }
332
+})();
333
+</script>
334
+</body>
335
+</html>

binární
server/public/logo.png Zobrazit soubor


+ 122
- 0
server/public/share.html Zobrazit soubor

@@ -0,0 +1,122 @@
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 Support — Share your screen</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:#ffffff; --line:#e6e9ef; }
9
+  *{box-sizing:border-box;}
10
+  body{font-family:'Segoe UI',system-ui,sans-serif;background:var(--bg);color:var(--ink);margin:0;}
11
+  .stage{display:flex;min-height:100vh;}
12
+  .brandpanel{flex:1;background:linear-gradient(160deg,var(--blue),var(--blue-d));color:#fff;display:flex;flex-direction:column;justify-content:center;align-items:center;text-align:center;padding:2.5rem;}
13
+  .mark{width:88px;height:88px;border-radius:22px;background:var(--brand);display:grid;place-items:center;font-weight:800;font-size:2.6rem;color:var(--blue);margin-bottom:1.2rem;box-shadow:0 12px 30px rgba(0,0,0,.25);}
14
+  .wordmark{font-size:2.2rem;font-weight:800;letter-spacing:.01em;}
15
+  .wordmark span{color:var(--brand);}
16
+  .tagline{color:#cdd7ee;margin-top:.6rem;font-size:1rem;max-width:300px;line-height:1.5;}
17
+  .panelside{flex:1;display:flex;align-items:center;justify-content:center;padding:2rem;}
18
+  .card{background:var(--card);border:1px solid var(--line);border-radius:18px;padding:2.4rem;max-width:440px;width:100%;text-align:center;box-shadow:0 10px 30px rgba(20,30,60,.06);}
19
+  h1{font-size:1.45rem;margin:.2rem 0 .4rem;color:var(--blue);}
20
+  .sub{color:var(--muted);font-size:.97rem;line-height:1.5;margin-bottom:1.6rem;}
21
+  .codewrap{background:#fffdf2;border:2px dashed var(--brand);border-radius:14px;padding:1.2rem;}
22
+  .codelabel{font-size:.78rem;letter-spacing:.08em;text-transform:uppercase;color:var(--muted);margin-bottom:.3rem;}
23
+  .code{font-size:3rem;letter-spacing:.5rem;font-weight:800;color:var(--ink);}
24
+  .status{margin-top:1.3rem;padding:.7rem 1rem;border-radius:10px;background:#f1f5f9;color:#475569;font-size:.92rem;}
25
+  .status.on{background:#ecfdf3;color:#15803d;}
26
+  .consent{margin-top:1.3rem;border:1px solid #c7d6f0;background:var(--blue-soft);border-left:5px solid var(--blue);border-radius:12px;padding:1.3rem;text-align:left;color:var(--blue-d);}
27
+  .consent .who{font-weight:700;color:var(--blue);}
28
+  .btns{margin-top:1rem;display:flex;gap:.6rem;}
29
+  button{flex:1;padding:.8rem 1rem;border:none;border-radius:10px;font-weight:700;font-size:.98rem;cursor:pointer;}
30
+  .grant{background:var(--brand);color:var(--ink);} .grant:hover{background:var(--brand-d);}
31
+  .deny{background:#fff;color:var(--blue);border:1px solid #c7d6f0;} .deny:hover{background:#f3f6fc;}
32
+  .foot{color:var(--muted);font-size:.8rem;margin-top:1.4rem;}
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
+  .indicator.show{display:block;}
35
+  @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
+</style>
37
+</head>
38
+<body>
39
+<div class="indicator" id="indicator">● Your screen is being shared — close this tab anytime to stop</div>
40
+<div class="stage">
41
+  <div class="brandpanel">
42
+    <img src="/logo.png" alt="" style="width:88px;height:88px;border-radius:22px;object-fit:contain;margin-bottom:1.2rem;background:#fff;padding:8px;box-shadow:0 12px 30px rgba(0,0,0,.25)" onerror="this.replaceWith(Object.assign(document.createElement('div'),{className:'mark',textContent:'B'}))">
43
+    <div class="wordmark">BizGaze <span>Support</span></div>
44
+    <div class="tagline">Secure, instant remote support — no downloads, you stay in control.</div>
45
+  </div>
46
+  <div class="panelside">
47
+    <div class="card">
48
+      <h1>Let's get you connected</h1>
49
+      <div class="sub">Share the code below with your BizGaze support agent.</div>
50
+      <div class="codewrap">
51
+        <div class="codelabel">Your session code</div>
52
+        <div style="display:flex;align-items:center;justify-content:center;gap:.7rem">
53
+          <div class="code" id="code">······</div>
54
+          <button id="copyBtn" title="Click to copy" aria-label="Click to copy" style="flex:0 0 auto;width:38px;height:38px;padding:0;background:#fff;border:1px solid var(--brand);color:var(--blue);border-radius:8px;cursor:pointer;display:inline-flex;align-items:center;justify-content:center"><svg id="copyIcon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button>
55
+        </div>
56
+      </div>
57
+      <div id="status" class="status">Preparing your code…</div>
58
+      <div id="consentBox"></div>
59
+      <div class="foot">🔒 You stay in control. The agent can only see your screen after you tap Allow, and you can stop anytime.</div>
60
+    </div>
61
+  </div>
62
+</div>
63
+<script>
64
+const codeEl=document.getElementById('code'), statusEl=document.getElementById('status'),
65
+      consentBox=document.getElementById('consentBox'), indicator=document.getElementById('indicator');
66
+const setStatus=(t,c='')=>{statusEl.textContent=t;statusEl.className='status '+c;};
67
+document.getElementById('copyBtn').onclick=async()=>{
68
+  const code=codeEl.textContent.trim();
69
+  if(!/^\d{6}$/.test(code)) return;
70
+  try{ await navigator.clipboard.writeText(code); }
71
+  catch(e){ const ta=document.createElement('textarea');ta.value=code;document.body.appendChild(ta);ta.select();document.execCommand('copy');ta.remove(); }
72
+  const b=document.getElementById('copyBtn'); const old=b.innerHTML;
73
+  b.innerHTML='<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#16a34a" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>';
74
+  setTimeout(()=>{b.innerHTML=old;},1500);
75
+};
76
+let ws,pc,localStream,sessionId;
77
+ws=new WebSocket((location.protocol==='https:'?'wss://':'ws://')+location.host+'/ws');
78
+ws.onopen=()=>ws.send(JSON.stringify({type:'share-create'}));
79
+ws.onmessage=async(e)=>{const m=JSON.parse(e.data);switch(m.type){
80
+  case 'share-code': codeEl.textContent=m.code; setStatus('Waiting for your agent to enter the code…'); break;
81
+  case 'share-request': onAgentConnected(m); break;
82
+  case 'start-stream': sessionId=m.sessionId; await startStreaming(); break;
83
+  case 'answer': if(pc) await pc.setRemoteDescription(new RTCSessionDescription(m.sdp)); break;
84
+  case 'ice-candidate': if(m.candidate&&pc) await pc.addIceCandidate(new RTCIceCandidate(m.candidate)); break;
85
+  case 'session-ended': teardown(); break;
86
+  case 'error': setStatus(m.message,''); break;
87
+}};
88
+ws.onclose=()=>setStatus('Connection closed. Refresh the page to start again.');
89
+function onAgentConnected(m){
90
+  const cw=document.querySelector('.codewrap');
91
+  if(cw) cw.style.display='none';
92
+  setStatus('Your agent has connected. Please respond below.','on');
93
+  showConsent(m);
94
+}
95
+function showConsent(m){
96
+  const name=(m.technician&&m.technician.trim())?m.technician:'Your support agent';
97
+  consentBox.innerHTML='<div class="consent">Your support agent <span class="who">'+esc(name)+'</span> would like to view your screen to help you.'+
98
+    '<div class="btns"><button class="grant" id="g">Allow</button><button class="deny" id="d">Not now</button></div></div>';
99
+  const allow=()=>{consentBox.innerHTML='';document.removeEventListener('keydown',onKey);ws.send(JSON.stringify({type:'consent',sessionId:m.sessionId,granted:true}));};
100
+  const onKey=(e)=>{if(e.key==='Enter'){e.preventDefault();allow();}};
101
+  document.addEventListener('keydown',onKey);
102
+  document.getElementById('g').onclick=allow;
103
+  document.getElementById('d').onclick=()=>{consentBox.innerHTML='';document.removeEventListener('keydown',onKey);ws.send(JSON.stringify({type:'consent',sessionId:m.sessionId,granted:false}));setStatus('Connection declined. Refresh this page if you need a new code.');};
104
+}
105
+async function startStreaming(){
106
+  setStatus('In the popup: click the screen preview so it is selected, then press Share.','on');
107
+  try{ localStream=await navigator.mediaDevices.getDisplayMedia({video:{displaySurface:'monitor',frameRate:{ideal:30}},audio:false,monitorTypeSurfaces:'include'}); }
108
+  catch(err){ try{ws.send(JSON.stringify({type:'end-session',sessionId,reason:'share-cancelled'}));}catch(e){} setStatus('Screen share was cancelled. Refresh the page to try again.'); return; }
109
+  indicator.classList.add('show'); setStatus('You are now sharing your screen with your agent.','on');
110
+  pc=new RTCPeerConnection({iceServers:[{urls:'stun:stun.l.google.com:19302'}]});
111
+  localStream.getTracks().forEach(t=>pc.addTrack(t,localStream));
112
+  pc.ondatachannel=(ev)=>{ev.channel.onmessage=()=>{};};
113
+  pc.onicecandidate=(ev)=>{if(ev.candidate)ws.send(JSON.stringify({type:'ice-candidate',sessionId,candidate:ev.candidate}));};
114
+  const offer=await pc.createOffer(); await pc.setLocalDescription(offer);
115
+  ws.send(JSON.stringify({type:'offer',sessionId,sdp:pc.localDescription}));
116
+  localStream.getVideoTracks()[0].onended=()=>{ws.send(JSON.stringify({type:'end-session',sessionId,reason:'customer-ended'}));teardown();};
117
+}
118
+function teardown(){indicator.classList.remove('show');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.');}
119
+function esc(s){return String(s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));}
120
+</script>
121
+</body>
122
+</html>

+ 107
- 0
server/public/viewer.html Zobrazit soubor

@@ -0,0 +1,107 @@
1
+<!DOCTYPE html>
2
+<html lang="en">
3
+<head>
4
+<meta charset="UTF-8">
5
+<title>Remote Session</title>
6
+<style>
7
+  body { font-family: system-ui, sans-serif; background: #0f172a; color: #e2e8f0; margin: 0; }
8
+  header { background: #1e293b; padding: 0.6rem 1rem; display: flex; justify-content: space-between; align-items: center; }
9
+  #status { font-size: 0.9rem; color: #94a3b8; }
10
+  #video { width: 100vw; height: calc(100vh - 48px); background: #020617; object-fit: contain; cursor: crosshair; display: block; outline: none; }
11
+  button { padding: 0.5rem 1rem; background: #334155; color: #fff; border: none; border-radius: 6px; cursor: pointer; }
12
+  a { color: #3b82f6; }
13
+</style>
14
+</head>
15
+<body>
16
+<header>
17
+  <div id="status">Connecting…</div>
18
+  <div>
19
+    <a href="/">← Console</a>
20
+    <button id="endBtn">End session</button>
21
+  </div>
22
+</header>
23
+<video id="video" autoplay playsinline muted tabindex="0"></video>
24
+
25
+<script>
26
+const params = new URLSearchParams(location.search);
27
+const machineId = params.get('machine');
28
+const machineName = params.get('name') || 'remote PC';
29
+const statusEl = document.getElementById('status');
30
+const video = document.getElementById('video');
31
+
32
+let pc, inputChannel, sessionId;
33
+const ws = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/ws');
34
+
35
+const setStatus = (t) => (statusEl.textContent = t);
36
+
37
+ws.onopen = () => {
38
+  setStatus(`Requesting access to ${machineName}…`);
39
+  ws.send(JSON.stringify({ type: 'viewer-connect', machineId }));
40
+};
41
+
42
+ws.onmessage = async (e) => {
43
+  const m = JSON.parse(e.data);
44
+  switch (m.type) {
45
+    case 'session-pending':
46
+      sessionId = m.sessionId;
47
+      setStatus(`Waiting for ${machineName} to grant consent…`);
48
+      break;
49
+    case 'session-denied':
50
+      setStatus('Consent denied by the remote user.');
51
+      break;
52
+    case 'session-ready':
53
+      setStatus('Consent granted. Establishing connection…');
54
+      setupPeer();
55
+      break;
56
+    case 'offer':
57
+      await pc.setRemoteDescription(new RTCSessionDescription(m.sdp));
58
+      const ans = await pc.createAnswer();
59
+      await pc.setLocalDescription(ans);
60
+      ws.send(JSON.stringify({ type: 'answer', sessionId, sdp: pc.localDescription }));
61
+      break;
62
+    case 'ice-candidate':
63
+      if (m.candidate && pc) await pc.addIceCandidate(new RTCIceCandidate(m.candidate));
64
+      break;
65
+    case 'session-ended':
66
+      setStatus('Session ended.');
67
+      video.srcObject = null;
68
+      break;
69
+    case 'error':
70
+      setStatus('Error: ' + m.message);
71
+      break;
72
+  }
73
+};
74
+
75
+function setupPeer() {
76
+  pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] });
77
+  inputChannel = pc.createDataChannel('input', { ordered: true });
78
+  pc.ontrack = (ev) => {
79
+    video.srcObject = ev.streams[0];
80
+    setStatus(`Connected to ${machineName} — controlling. Click the screen to send input.`);
81
+    video.focus();
82
+  };
83
+  pc.onicecandidate = (ev) => {
84
+    if (ev.candidate) ws.send(JSON.stringify({ type: 'ice-candidate', sessionId, candidate: ev.candidate }));
85
+  };
86
+}
87
+
88
+// ---- input capture (normalized coords) ----
89
+const send = (o) => { if (inputChannel && inputChannel.readyState === 'open') inputChannel.send(JSON.stringify(o)); };
90
+const rel = (e) => { const r = video.getBoundingClientRect(); return { x: (e.clientX - r.left) / r.width, y: (e.clientY - r.top) / r.height }; };
91
+let lastMove = 0;
92
+video.addEventListener('mousemove', (e) => { const t = performance.now(); if (t - lastMove < 30) return; lastMove = t; send({ kind: 'mousemove', ...rel(e) }); });
93
+video.addEventListener('mousedown', (e) => { video.focus(); send({ kind: 'mousedown', button: e.button, ...rel(e) }); });
94
+video.addEventListener('mouseup', (e) => send({ kind: 'mouseup', button: e.button, ...rel(e) }));
95
+video.addEventListener('dblclick', (e) => send({ kind: 'dblclick', ...rel(e) }));
96
+video.addEventListener('wheel', (e) => { e.preventDefault(); send({ kind: 'scroll', dx: e.deltaX, dy: e.deltaY }); }, { passive: false });
97
+video.addEventListener('contextmenu', (e) => e.preventDefault());
98
+video.addEventListener('keydown', (e) => { e.preventDefault(); send({ kind: 'keydown', key: e.key, code: e.code, ctrl: e.ctrlKey, alt: e.altKey, shift: e.shiftKey, meta: e.metaKey }); });
99
+video.addEventListener('keyup', (e) => { e.preventDefault(); send({ kind: 'keyup', key: e.key, code: e.code }); });
100
+
101
+document.getElementById('endBtn').onclick = () => {
102
+  ws.send(JSON.stringify({ type: 'end-session', sessionId }));
103
+  setTimeout(() => (location.href = '/'), 300);
104
+};
105
+</script>
106
+</body>
107
+</html>

+ 457
- 0
server/server.js Zobrazit soubor

@@ -0,0 +1,457 @@
1
+// Remote Access Platform — backend server
2
+// HTTP JSON API (auth, MFA, machines, audit) + authenticated WebSocket signaling.
3
+const http = require('http');
4
+const https = require('https');
5
+const fs = require('fs');
6
+const path = require('path');
7
+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 SESSION_TTL = 1000 * 60 * 60 * 12; // 12h
15
+
16
+// ---------- helpers ----------
17
+const now = () => Date.now();
18
+const json = (res, code, body) => {
19
+  res.writeHead(code, { 'Content-Type': 'application/json' });
20
+  res.end(JSON.stringify(body));
21
+};
22
+function readBody(req) {
23
+  return new Promise((resolve) => {
24
+    let data = '';
25
+    req.on('data', (c) => (data += c));
26
+    req.on('end', () => {
27
+      try { resolve(data ? JSON.parse(data) : {}); } catch { resolve({}); }
28
+    });
29
+  });
30
+}
31
+function parseCookies(req) {
32
+  const out = {};
33
+  (req.headers.cookie || '').split(';').forEach((c) => {
34
+    const [k, ...v] = c.trim().split('=');
35
+    if (k) out[k] = decodeURIComponent(v.join('='));
36
+  });
37
+  return out;
38
+}
39
+function audit(entry) {
40
+  db.prepare(
41
+    `INSERT INTO audit_log (team_id,user_id,user_email,machine_id,machine_name,action,detail,at)
42
+     VALUES (@team_id,@user_id,@user_email,@machine_id,@machine_name,@action,@detail,@at)`
43
+  ).run({
44
+    team_id: entry.team_id, user_id: entry.user_id || null, user_email: entry.user_email || null,
45
+    machine_id: entry.machine_id || null, machine_name: entry.machine_name || null,
46
+    action: entry.action, detail: entry.detail || null, at: now(),
47
+  });
48
+}
49
+
50
+// Resolve the logged-in user from the session cookie. Returns user row (with mfa state) or null.
51
+function currentUser(req, { requireMfa = true } = {}) {
52
+  const tok = parseCookies(req).sid;
53
+  if (!tok) return null;
54
+  const s = db.prepare('SELECT * FROM sessions_auth WHERE token=?').get(tok);
55
+  if (!s || s.expires_at < now()) return null;
56
+  if (requireMfa && !s.mfa_passed) return null;
57
+  const u = db.prepare('SELECT * FROM users WHERE id=?').get(s.user_id);
58
+  if (!u || u.active === 0) return null;
59
+  return { ...u, _session: s };
60
+}
61
+
62
+// ---------- HTTP API ----------
63
+const routes = {};
64
+const route = (method, p, fn) => (routes[`${method} ${p}`] = fn);
65
+
66
+// Register: creates a team + admin user. MFA must be set up before full access.
67
+route('POST', '/api/register', async (req, res) => {
68
+  const { email, password, teamName } = await readBody(req);
69
+  if (!email || !password) return json(res, 400, { error: 'email and password required' });
70
+  if (db.prepare('SELECT 1 FROM users WHERE email=?').get(email))
71
+    return json(res, 409, { error: 'email already registered' });
72
+  const teamId = A.id(), userId = A.id();
73
+  const { hash, salt } = A.hashPassword(password);
74
+  const mfaSecret = A.newMfaSecret();
75
+  db.prepare('INSERT INTO teams (id,name,created_at) VALUES (?,?,?)')
76
+    .run(teamId, teamName || `${email}'s team`, now());
77
+  db.prepare(`INSERT INTO users (id,team_id,email,pw_hash,pw_salt,role,mfa_secret,mfa_enabled,created_at)
78
+              VALUES (?,?,?,?,?,?,?,0,?)`)
79
+    .run(userId, teamId, email, hash, salt, 'admin', mfaSecret, now());
80
+  audit({ team_id: teamId, user_id: userId, user_email: email, action: 'user_registered' });
81
+  json(res, 200, { ok: true });
82
+});
83
+
84
+// Verify MFA enrollment (confirm the user scanned the QR / entered code)
85
+route('POST', '/api/mfa/enable', async (req, res) => {
86
+  const { email, code } = await readBody(req);
87
+  const u = db.prepare('SELECT * FROM users WHERE email=?').get(email);
88
+  if (!u) return json(res, 404, { error: 'no such user' });
89
+  if (!A.verifyTotp(u.mfa_secret, code)) return json(res, 401, { error: 'invalid code' });
90
+  db.prepare('UPDATE users SET mfa_enabled=1 WHERE id=?').run(u.id);
91
+  json(res, 200, { ok: true });
92
+});
93
+
94
+// Login step 1: email + password -> sets a session cookie (mfa not yet passed)
95
+route('POST', '/api/login', async (req, res) => {
96
+  const { email, password } = await readBody(req);
97
+  const u = db.prepare('SELECT * FROM users WHERE email=?').get(email);
98
+  if (!u || !A.verifyPassword(password, u.pw_salt, u.pw_hash))
99
+    return json(res, 401, { error: 'invalid credentials' });
100
+  if (u.active === 0) return json(res, 403, { error: 'This account has been deactivated' });
101
+  const tok = A.token();
102
+  db.prepare('INSERT INTO sessions_auth (token,user_id,mfa_passed,created_at,expires_at) VALUES (?,?,1,?,?)')
103
+    .run(tok, u.id, now(), now() + SESSION_TTL);
104
+  res.setHeader('Set-Cookie', `sid=${tok}; HttpOnly; Path=/; Max-Age=${SESSION_TTL / 1000}`);
105
+  audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'login' });
106
+  json(res, 200, { ok: true, mfaRequired: false });
107
+});
108
+
109
+// Login step 2: TOTP code -> marks session mfa_passed
110
+route('POST', '/api/login/mfa', async (req, res) => {
111
+  const { code } = await readBody(req);
112
+  const tok = parseCookies(req).sid;
113
+  const s = tok && db.prepare('SELECT * FROM sessions_auth WHERE token=?').get(tok);
114
+  if (!s) return json(res, 401, { error: 'no session' });
115
+  const u = db.prepare('SELECT * FROM users WHERE id=?').get(s.user_id);
116
+  if (!A.verifyTotp(u.mfa_secret, code)) return json(res, 401, { error: 'invalid code' });
117
+  db.prepare('UPDATE sessions_auth SET mfa_passed=1 WHERE token=?').run(tok);
118
+  audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'login' });
119
+  json(res, 200, { ok: true });
120
+});
121
+
122
+route('POST', '/api/logout', async (req, res) => {
123
+  const tok = parseCookies(req).sid;
124
+  if (tok) db.prepare('DELETE FROM sessions_auth WHERE token=?').run(tok);
125
+  res.setHeader('Set-Cookie', 'sid=; HttpOnly; Path=/; Max-Age=0');
126
+  json(res, 200, { ok: true });
127
+});
128
+
129
+route('GET', '/api/me', async (req, res) => {
130
+  const u = currentUser(req);
131
+  if (!u) return json(res, 401, { error: 'unauthorized' });
132
+  json(res, 200, { id: u.id, email: u.email, role: u.role, teamId: u.team_id, name: u.name || null });
133
+});
134
+
135
+// Admin adds an agent login to their team
136
+route('POST', '/api/users', async (req, res) => {
137
+  const u = currentUser(req);
138
+  if (!u) return json(res, 401, { error: 'unauthorized' });
139
+  if (u.role !== 'admin') return json(res, 403, { error: 'only admins can add agents' });
140
+  const { email, password, name, role } = await readBody(req);
141
+  if (!email || !password) return json(res, 400, { error: 'email and temporary password required' });
142
+  if (db.prepare('SELECT 1 FROM users WHERE email=?').get(email))
143
+    return json(res, 409, { error: 'email already registered' });
144
+  const userId = A.id();
145
+  const { hash, salt } = A.hashPassword(password);
146
+  const mfaSecret = A.newMfaSecret();
147
+  const r = (role === 'admin' || role === 'viewer') ? role : 'technician';
148
+  db.prepare(`INSERT INTO users (id,team_id,email,pw_hash,pw_salt,role,name,mfa_secret,mfa_enabled,created_at)
149
+              VALUES (?,?,?,?,?,?,?,?,0,?)`)
150
+    .run(userId, u.team_id, email, hash, salt, r, (name || null), mfaSecret, now());
151
+  audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'agent_added', detail: email + ' (' + r + ')' });
152
+  json(res, 200, { ok: true, id: userId, email, role: r });
153
+});
154
+
155
+// List the team's agents
156
+route('GET', '/api/users', async (req, res) => {
157
+  const u = currentUser(req);
158
+  if (!u) return json(res, 401, { error: 'unauthorized' });
159
+  const rows = db.prepare('SELECT id,email,name,role,active,created_at FROM users WHERE team_id=?').all(u.team_id);
160
+  json(res, 200, rows);
161
+});
162
+
163
+// First-login MFA self-setup: a logged-in (password ok) user who hasn't enabled MFA yet
164
+route('GET', '/api/mfa/setup', async (req, res) => {
165
+  const u = currentUser(req, { requireMfa: false });
166
+  if (!u) return json(res, 401, { error: 'unauthorized' });
167
+  if (u.mfa_enabled) return json(res, 400, { error: 'MFA already enabled' });
168
+  json(res, 200, { secret: u.mfa_secret, otpauthUrl: A.otpauthUrl(u.mfa_secret, u.email) });
169
+});
170
+
171
+// Admin manages an agent: reset password, rename, deactivate/activate, delete.
172
+// (Display names are owned by the admin/BizGaze app — agents cannot edit them.)
173
+route('POST', '/api/users/manage', async (req, res) => {
174
+  const u = currentUser(req);
175
+  if (!u) return json(res, 401, { error: 'unauthorized' });
176
+  if (u.role !== 'admin') return json(res, 403, { error: 'only admins can manage agents' });
177
+  const { id, action, password, name } = await readBody(req);
178
+  const target = db.prepare('SELECT * FROM users WHERE id=? AND team_id=?').get(id, u.team_id);
179
+  if (!target) return json(res, 404, { error: 'no such agent' });
180
+  switch (action) {
181
+    case 'reset-password': {
182
+      if (!password || String(password).length < 8) return json(res, 400, { error: 'new password must be at least 8 characters' });
183
+      const { hash, salt } = A.hashPassword(password);
184
+      db.prepare('UPDATE users SET pw_hash=?, pw_salt=? WHERE id=?').run(hash, salt, target.id);
185
+      db.prepare('DELETE FROM sessions_auth WHERE user_id=?').run(target.id); // force re-login
186
+      audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'agent_password_reset', detail: target.email });
187
+      return json(res, 200, { ok: true });
188
+    }
189
+    case 'rename': {
190
+      const clean = String(name || '').trim().slice(0, 60);
191
+      if (!clean) return json(res, 400, { error: 'name required' });
192
+      db.prepare('UPDATE users SET name=? WHERE id=?').run(clean, target.id);
193
+      audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'agent_renamed', detail: target.email + ' -> ' + clean });
194
+      return json(res, 200, { ok: true, name: clean });
195
+    }
196
+    case 'deactivate': {
197
+      if (target.id === u.id) return json(res, 400, { error: 'you cannot deactivate your own account' });
198
+      db.prepare('UPDATE users SET active=0 WHERE id=?').run(target.id);
199
+      db.prepare('DELETE FROM sessions_auth WHERE user_id=?').run(target.id);
200
+      audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'agent_deactivated', detail: target.email });
201
+      return json(res, 200, { ok: true });
202
+    }
203
+    case 'activate': {
204
+      db.prepare('UPDATE users SET active=1 WHERE id=?').run(target.id);
205
+      audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'agent_activated', detail: target.email });
206
+      return json(res, 200, { ok: true });
207
+    }
208
+    case 'delete': {
209
+      if (target.id === u.id) return json(res, 400, { error: 'you cannot delete your own account' });
210
+      db.prepare('DELETE FROM sessions_auth WHERE user_id=?').run(target.id);
211
+      db.prepare('DELETE FROM users WHERE id=?').run(target.id);
212
+      audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'agent_deleted', detail: target.email });
213
+      return json(res, 200, { ok: true });
214
+    }
215
+    default: return json(res, 400, { error: 'unknown action' });
216
+  }
217
+});
218
+
219
+// Session report: one row per session, filterable by agent and date period
220
+route('GET', '/api/report', async (req, res) => {
221
+  const u = currentUser(req);
222
+  if (!u) return json(res, 401, { error: 'unauthorized' });
223
+  const q = new URLSearchParams(req.url.split('?')[1] || '');
224
+  let sql = 'SELECT * FROM sessions_log WHERE team_id=?';
225
+  const args = [u.team_id];
226
+  if (q.get('agent')) { sql += ' AND agent_email=?'; args.push(q.get('agent')); }
227
+  if (q.get('from')) { sql += ' AND started_at>=?'; args.push(new Date(q.get('from') + 'T00:00:00').getTime()); }
228
+  if (q.get('to')) { sql += ' AND started_at<=?'; args.push(new Date(q.get('to') + 'T23:59:59').getTime()); }
229
+  sql += ' ORDER BY started_at DESC LIMIT 500';
230
+  json(res, 200, db.prepare(sql).all(...args));
231
+});
232
+
233
+// List machines for the team (with live online status from signaling layer)
234
+route('GET', '/api/machines', async (req, res) => {
235
+  const u = currentUser(req);
236
+  if (!u) return json(res, 401, { error: 'unauthorized' });
237
+  const rows = db.prepare('SELECT id,name,unattended,last_seen FROM machines WHERE team_id=?').all(u.team_id);
238
+  json(res, 200, rows.map((m) => ({ ...m, online: onlineAgents.has(m.id) })));
239
+});
240
+
241
+// Create a machine enrollment token (admin/technician). Agent uses it to come online.
242
+route('POST', '/api/machines', async (req, res) => {
243
+  const u = currentUser(req);
244
+  if (!u) return json(res, 401, { error: 'unauthorized' });
245
+  if (u.role === 'viewer') return json(res, 403, { error: 'forbidden' });
246
+  const { name, unattended } = await readBody(req);
247
+  const mId = A.id(), enroll = A.token();
248
+  db.prepare('INSERT INTO machines (id,team_id,name,enroll_token,unattended,created_at) VALUES (?,?,?,?,?,?)')
249
+    .run(mId, u.team_id, name || 'Unnamed PC', enroll, unattended ? 1 : 0, now());
250
+  audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, machine_id: mId, machine_name: name, action: 'machine_enrolled' });
251
+  json(res, 200, { id: mId, enrollToken: enroll });
252
+});
253
+
254
+route('GET', '/api/audit', async (req, res) => {
255
+  const u = currentUser(req);
256
+  if (!u) return json(res, 401, { error: 'unauthorized' });
257
+  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);
258
+  json(res, 200, rows);
259
+});
260
+
261
+// ---------- static + router ----------
262
+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' };
263
+function serveStatic(req, res) {
264
+  let p = req.url.split('?')[0];
265
+  if (p === '/') p = '/index.html';
266
+  if (p === '/share') p = '/share.html';
267
+  if (p === '/connect') p = '/connect.html';
268
+  const fp = path.join(PUBLIC_DIR, path.normalize(p));
269
+  if (!fp.startsWith(PUBLIC_DIR)) return json(res, 403, { error: 'forbidden' });
270
+  fs.readFile(fp, (err, data) => {
271
+    if (err) return json(res, 404, { error: 'not found' });
272
+    const ct = MIME[path.extname(fp)] || 'application/octet-stream';
273
+    res.writeHead(200, { 'Content-Type': ct, 'Cache-Control': 'no-cache' });
274
+    res.end(data);
275
+  });
276
+}
277
+
278
+const server = http.createServer(async (req, res) => {
279
+  const key = `${req.method} ${req.url.split('?')[0]}`;
280
+  if (routes[key]) return routes[key](req, res);
281
+  if (req.method === 'GET') return serveStatic(req, res);
282
+  json(res, 404, { error: 'not found' });
283
+});
284
+
285
+// ---------- WebSocket signaling ----------
286
+// Two kinds of WS clients:
287
+//   agent  -> authenticates with machine enroll_token, waits for session requests
288
+//   viewer -> authenticated technician, requests a session to a machine
289
+// The server brokers consent and relays SDP/ICE. Media never traverses the server.
290
+const onlineAgents = new Map();   // machineId -> { ws, machine }
291
+const liveSessions = new Map();   // sessionId -> { agentWs, viewerWs, machine, user }
292
+const pendingShares = new Map();  // code -> { sharerWs, sessionId } (no-install ad-hoc shares)
293
+
294
+function onConnection(ws, req) {
295
+  ws.on('message', (raw) => {
296
+    let m; try { m = JSON.parse(raw); } catch { return; }
297
+    handle(ws, m, req);
298
+  });
299
+  ws.on('close', () => cleanup(ws));
300
+}
301
+
302
+const wss = new WebSocketServer({ server, path: '/ws' });
303
+wss.on('connection', onConnection);
304
+
305
+function handle(ws, m, req) {
306
+  switch (m.type) {
307
+    // --- Agent comes online ---
308
+    case 'agent-hello': {
309
+      const machine = db.prepare('SELECT * FROM machines WHERE enroll_token=?').get(m.enrollToken);
310
+      if (!machine) return ws.send(JSON.stringify({ type: 'error', message: 'invalid enroll token' }));
311
+      ws.kind = 'agent'; ws.machineId = machine.id;
312
+      onlineAgents.set(machine.id, { ws, machine });
313
+      db.prepare('UPDATE machines SET last_seen=? WHERE id=?').run(now(), machine.id);
314
+      ws.send(JSON.stringify({ type: 'agent-registered', machineId: machine.id, name: machine.name }));
315
+      break;
316
+    }
317
+    // --- Technician requests control of a machine ---
318
+    case 'viewer-connect': {
319
+      const u = currentUser(req); // cookie sent on WS upgrade
320
+      if (!u) return ws.send(JSON.stringify({ type: 'error', message: 'unauthorized' }));
321
+      const agent = onlineAgents.get(m.machineId);
322
+      const machine = db.prepare('SELECT * FROM machines WHERE id=? AND team_id=?').get(m.machineId, u.team_id);
323
+      if (!machine) return ws.send(JSON.stringify({ type: 'error', message: 'no such machine' }));
324
+      if (!agent) return ws.send(JSON.stringify({ type: 'error', message: 'machine offline' }));
325
+      if (u.role === 'viewer' && false) {} // view-only still allowed to watch; control gated agent-side
326
+      const sessionId = A.token(8);
327
+      ws.kind = 'viewer'; ws.sessionId = sessionId;
328
+      liveSessions.set(sessionId, { agentWs: agent.ws, viewerWs: ws, machine, user: u });
329
+      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' });
330
+      // Ask the agent for consent (or auto-grant if unattended policy is on)
331
+      agent.ws.sessionId = sessionId;
332
+      agent.ws.send(JSON.stringify({
333
+        type: 'session-request', sessionId,
334
+        technician: u.email, unattended: !!machine.unattended,
335
+      }));
336
+      ws.send(JSON.stringify({ type: 'session-pending', sessionId, machineName: machine.name }));
337
+      break;
338
+    }
339
+    // --- Agent grants/denies consent ---
340
+    case 'consent': {
341
+      const sess = liveSessions.get(m.sessionId);
342
+      if (!sess) return;
343
+      if (m.granted) {
344
+        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') });
345
+        try {
346
+          db.prepare('INSERT INTO sessions_log (id,team_id,agent_email,agent_name,ticket,started_at) VALUES (?,?,?,?,?,?)')
347
+            .run(m.sessionId, sess.machine.team_id, sess.user.email, (sess.agentName || sess.user.email), sess.ticket || null, now());
348
+        } catch (e) { /* duplicate consent */ }
349
+        sess.viewerWs.send(JSON.stringify({ type: 'session-ready', sessionId: m.sessionId }));
350
+        sess.agentWs.send(JSON.stringify({ type: 'start-stream', sessionId: m.sessionId }));
351
+      } else {
352
+        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') });
353
+        sess.viewerWs.send(JSON.stringify({ type: 'session-denied', sessionId: m.sessionId }));
354
+        liveSessions.delete(m.sessionId);
355
+      }
356
+      break;
357
+    }
358
+    // --- No-install: end user opens /share, gets a one-time code ---
359
+    case 'share-create': {
360
+      let code;
361
+      do { code = A.numericCode(6); } while (pendingShares.has(code));
362
+      const sessionId = A.token(8);
363
+      ws.kind = 'sharer'; ws.shareCode = code; ws.sessionId = sessionId;
364
+      pendingShares.set(code, { sharerWs: ws, sessionId });
365
+      ws.send(JSON.stringify({ type: 'share-code', code }));
366
+      break;
367
+    }
368
+    // --- Logged-in agent enters the code (+ ticket) to connect ---
369
+    case 'code-connect': {
370
+      const agent = currentUser(req); // identity from the agent's authenticated session
371
+      if (!agent) {
372
+        return ws.send(JSON.stringify({ type: 'error', message: 'Please sign in as an agent first' }));
373
+      }
374
+      const ticket = String(m.ticket || '').trim() || null; // optional: direct sessions have no ticket
375
+      const pend = pendingShares.get(String(m.code || '').trim());
376
+      if (!pend || pend.sharerWs.readyState !== 1) {
377
+        return ws.send(JSON.stringify({ type: 'error', message: 'Invalid or expired code' }));
378
+      }
379
+      pendingShares.delete(pend.sharerWs.shareCode);
380
+      const sessionId = pend.sessionId;
381
+      ws.kind = 'viewer'; ws.sessionId = sessionId;
382
+      const agentName = agent.name || agent.email;
383
+      const machine = { id: null, name: 'Support session ' + pend.sharerWs.shareCode, team_id: agent.team_id };
384
+      const user = { id: agent.id, email: agent.email, team_id: agent.team_id };
385
+      liveSessions.set(sessionId, { agentWs: pend.sharerWs, viewerWs: ws, machine, user, ticket, agentName });
386
+      pend.sharerWs.sessionId = sessionId;
387
+      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 });
388
+      pend.sharerWs.send(JSON.stringify({ type: 'share-request', sessionId, technician: agentName, ticket }));
389
+      ws.send(JSON.stringify({ type: 'code-pending', sessionId }));
390
+      break;
391
+    }
392
+    // --- Relay WebRTC signaling between the two peers ---
393
+    case 'offer': case 'answer': case 'ice-candidate': {
394
+      const sess = liveSessions.get(m.sessionId || ws.sessionId);
395
+      if (!sess) return;
396
+      const peer = ws === sess.agentWs ? sess.viewerWs : sess.agentWs;
397
+      if (peer && peer.readyState === 1) peer.send(JSON.stringify(m));
398
+      break;
399
+    }
400
+    case 'end-session': {
401
+      endSession(ws.sessionId, m.reason || null);
402
+      break;
403
+    }
404
+  }
405
+}
406
+
407
+function endSession(sessionId, reason) {
408
+  const sess = liveSessions.get(sessionId);
409
+  if (!sess) return;
410
+  try { db.prepare('UPDATE sessions_log SET ended_at=? WHERE id=? AND ended_at IS NULL').run(now(), sessionId); } catch (e) {}
411
+  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') });
412
+  [sess.agentWs, sess.viewerWs].forEach((p) => {
413
+    if (p && p.readyState === 1) p.send(JSON.stringify({ type: 'session-ended', sessionId, reason: reason || null }));
414
+  });
415
+  liveSessions.delete(sessionId);
416
+}
417
+
418
+function cleanup(ws) {
419
+  if (ws.kind === 'agent' && ws.machineId) onlineAgents.delete(ws.machineId);
420
+  if (ws.kind === 'sharer' && ws.shareCode) pendingShares.delete(ws.shareCode);
421
+  if (ws.sessionId) {
422
+    for (const [sid, sess] of liveSessions) {
423
+      if (sess.agentWs === ws || sess.viewerWs === ws) endSession(sid);
424
+    }
425
+  }
426
+}
427
+
428
+server.listen(PORT, () => {
429
+  console.log(`HTTP  on http://localhost:${PORT}`);
430
+});
431
+
432
+// HTTPS — required so other devices can share their screen (browsers block
433
+// screen capture on non-secure origins). Uses cert.pem/key.pem if present.
434
+let httpsServer = null;
435
+try {
436
+  const certPath = path.join(__dirname, 'cert.pem');
437
+  const keyPath = path.join(__dirname, 'key.pem');
438
+  if (fs.existsSync(certPath) && fs.existsSync(keyPath)) {
439
+    httpsServer = https.createServer(
440
+      { cert: fs.readFileSync(certPath), key: fs.readFileSync(keyPath) },
441
+      (req, res) => server.emit('request', req, res)
442
+    );
443
+    const wssSecure = new WebSocketServer({ server: httpsServer, path: '/ws' });
444
+    wssSecure.on('connection', onConnection);
445
+    httpsServer.listen(HTTPS_PORT, () => {
446
+      console.log(`HTTPS on https://localhost:${HTTPS_PORT}  (use this address from other devices)`);
447
+      console.log(`  End user shares screen:   https://<this-pc-ip>:${HTTPS_PORT}/share`);
448
+      console.log(`  Technician connects:      https://<this-pc-ip>:${HTTPS_PORT}/connect`);
449
+    });
450
+  } else {
451
+    console.log('(No cert.pem/key.pem found — HTTPS disabled. Other devices can view but not share their screen.)');
452
+  }
453
+} catch (e) {
454
+  console.log('HTTPS failed to start:', e.message);
455
+}
456
+
457
+module.exports = { server };

+ 164
- 0
server/test/e2e.js Zobrazit soubor

@@ -0,0 +1,164 @@
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.
6
+
7
+process.env.DB_PATH = '/tmp/ra-e2e.db';
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 {} }
10
+
11
+const PORT = 8099;
12
+process.env.PORT = PORT;
13
+const { server } = require('../server');
14
+const A = require('../auth');
15
+const WebSocket = require('ws');
16
+
17
+const BASE = `http://localhost:${PORT}`;
18
+let passed = 0, failed = 0;
19
+function check(name, cond) {
20
+  if (cond) { console.log('  ok  -', name); passed++; }
21
+  else { console.log('  FAIL-', name); failed++; }
22
+}
23
+
24
+// minimal cookie-aware fetch
25
+async function call(path, body, cookie) {
26
+  const r = await fetch(BASE + path, {
27
+    method: 'POST',
28
+    headers: { 'Content-Type': 'application/json', ...(cookie ? { Cookie: cookie } : {}) },
29
+    body: body ? JSON.stringify(body) : undefined,
30
+  });
31
+  const setCookie = r.headers.get('set-cookie');
32
+  const data = await r.json().catch(() => ({}));
33
+  return { status: r.status, data, cookie: setCookie ? setCookie.split(';')[0] : cookie };
34
+}
35
+async function get(path, cookie) {
36
+  const r = await fetch(BASE + path, { headers: cookie ? { Cookie: cookie } : {} });
37
+  return { status: r.status, data: await r.json().catch(() => ({})) };
38
+}
39
+const wait = (ms) => new Promise((r) => setTimeout(r, ms));
40
+function wsClient() {
41
+  const ws = new WebSocket(`ws://localhost:${PORT}/ws`);
42
+  ws.q = [];
43
+  ws.on('message', (d) => ws.q.push(JSON.parse(d)));
44
+  return ws;
45
+}
46
+function nextMsg(ws, type, timeout = 3000) {
47
+  return new Promise((resolve, reject) => {
48
+    const start = Date.now();
49
+    (function poll() {
50
+      const i = ws.q.findIndex((m) => m.type === type);
51
+      if (i >= 0) return resolve(ws.q.splice(i, 1)[0]);
52
+      if (Date.now() - start > timeout) return reject(new Error('timeout waiting for ' + type));
53
+      setTimeout(poll, 20);
54
+    })();
55
+  });
56
+}
57
+
58
+(async () => {
59
+  await wait(300); // let server bind
60
+  console.log('E2E backend tests:');
61
+
62
+  // 1. Register
63
+  const email = 'tech@example.com';
64
+  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;
67
+
68
+  // 2. Login before MFA enabled — allowed, mfaRequired=false
69
+  let login = await call('/api/login', { email, password: 'supersecret' });
70
+  check('login sets session cookie', !!login.cookie);
71
+
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);
90
+  const me = await get('/api/me', cookie);
91
+  check('me works after mfa, role=admin', me.status === 200 && me.data.role === 'admin');
92
+
93
+  // 7. Wrong password rejected
94
+  const badLogin = await call('/api/login', { email, password: 'wrong' });
95
+  check('wrong password rejected', badLogin.status === 401);
96
+
97
+  // 8. Enroll a machine (consent-required)
98
+  const mach = await call('/api/machines', { name: 'Dana-Laptop', unattended: false }, cookie);
99
+  check('machine enrolled, returns token', mach.status === 200 && mach.data.enrollToken);
100
+  const enrollToken = mach.data.enrollToken;
101
+
102
+  // 9. Agent comes online
103
+  const agent = wsClient();
104
+  await new Promise((r) => agent.on('open', r));
105
+  agent.send(JSON.stringify({ type: 'agent-hello', enrollToken }));
106
+  const reg2 = await nextMsg(agent, 'agent-registered');
107
+  check('agent registers via enroll token', reg2.name === 'Dana-Laptop');
108
+
109
+  // machine shows online in API
110
+  const machines = await get('/api/machines', cookie);
111
+  check('machine reports online', machines.data[0].online === true);
112
+
113
+  // 10. Technician (viewer) requests a session — needs cookie on the WS upgrade
114
+  const viewer = new WebSocket(`ws://localhost:${PORT}/ws`, { headers: { Cookie: cookie } });
115
+  viewer.q = []; viewer.on('message', (d) => viewer.q.push(JSON.parse(d)));
116
+  await new Promise((r) => viewer.on('open', r));
117
+  viewer.send(JSON.stringify({ type: 'viewer-connect', machineId: machines.data[0].id }));
118
+  const pending = await nextMsg(viewer, 'session-pending');
119
+  check('viewer gets session-pending', !!pending.sessionId);
120
+
121
+  // 11. Agent receives the consent request
122
+  const reqMsg = await nextMsg(agent, 'session-request');
123
+  check('agent receives session-request with technician email', reqMsg.technician === email);
124
+
125
+  // 12. Agent grants consent -> both sides proceed
126
+  agent.send(JSON.stringify({ type: 'consent', sessionId: reqMsg.sessionId, granted: true }));
127
+  const ready = await nextMsg(viewer, 'session-ready');
128
+  const startStream = await nextMsg(agent, 'start-stream');
129
+  check('consent grant -> viewer session-ready', !!ready);
130
+  check('consent grant -> agent start-stream', !!startStream);
131
+
132
+  // 13. Signaling relay: agent offer reaches viewer; viewer answer reaches agent
133
+  agent.send(JSON.stringify({ type: 'offer', sessionId: reqMsg.sessionId, sdp: { fake: 'offer' } }));
134
+  const relayedOffer = await nextMsg(viewer, 'offer');
135
+  check('offer relayed agent->viewer', relayedOffer.sdp.fake === 'offer');
136
+  viewer.send(JSON.stringify({ type: 'answer', sessionId: reqMsg.sessionId, sdp: { fake: 'answer' } }));
137
+  const relayedAnswer = await nextMsg(agent, 'answer');
138
+  check('answer relayed viewer->agent', relayedAnswer.sdp.fake === 'answer');
139
+
140
+  // 14. End session
141
+  viewer.send(JSON.stringify({ type: 'end-session', sessionId: reqMsg.sessionId }));
142
+  await nextMsg(agent, 'session-ended');
143
+  check('session-ended delivered to agent', true);
144
+
145
+  // 15. Audit log captured the full flow
146
+  const audit = await get('/api/audit', cookie);
147
+  const actions = audit.data.map((a) => a.action);
148
+  for (const a of ['user_registered', 'login', 'machine_enrolled', 'session_requested', 'consent_granted', 'session_ended']) {
149
+    check(`audit contains "${a}"`, actions.includes(a));
150
+  }
151
+
152
+  // 16. Denial path
153
+  viewer.send(JSON.stringify({ type: 'viewer-connect', machineId: machines.data[0].id }));
154
+  const pending2 = await nextMsg(viewer, 'session-pending');
155
+  const req2 = await nextMsg(agent, 'session-request');
156
+  agent.send(JSON.stringify({ type: 'consent', sessionId: req2.sessionId, granted: false }));
157
+  const denied = await nextMsg(viewer, 'session-denied');
158
+  check('consent denial -> viewer session-denied', !!denied);
159
+
160
+  agent.close(); viewer.close();
161
+  console.log(`\n${passed} passed, ${failed} failed.`);
162
+  server.close();
163
+  process.exit(failed ? 1 : 0);
164
+})().catch((e) => { console.error('E2E ERROR:', e); process.exit(1); });

Loading…
Zrušit
Uložit