feat: add admin REST API layer for frontend management panel
Implement JWT-protected /admin/* routes on the relay server: - Auth: login, logout, me, setup/status, setup/init (first-time config) - Dashboard: stats overview (connections, devices, cookies, domains) - Cookies: paginated list with domain/search filter, detail, delete, batch delete - Devices: list with online status, revoke - Settings: get/update (sync interval, max devices, theme) Uses scrypt for password hashing and jsonwebtoken for JWT. Adds listAll/revoke to DeviceRegistry, getAll/getById/deleteById to CookieBlobStore, disconnect to ConnectionManager. Updates frontend to use /admin/* endpoints. All 38 existing tests pass. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
164
package-lock.json
generated
164
package-lock.json
generated
@@ -1,20 +1,22 @@
|
|||||||
{
|
{
|
||||||
"name": "cookiebridge",
|
"name": "cookiebridge",
|
||||||
"version": "1.0.0",
|
"version": "0.1.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "cookiebridge",
|
"name": "cookiebridge",
|
||||||
"version": "1.0.0",
|
"version": "0.1.0",
|
||||||
"license": "ISC",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"jsonwebtoken": "^9.0.3",
|
||||||
"sodium-native": "^5.1.0",
|
"sodium-native": "^5.1.0",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"uuid": "^13.0.0",
|
"uuid": "^13.0.0",
|
||||||
"ws": "^8.19.0"
|
"ws": "^8.19.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
"@types/node": "^25.5.0",
|
"@types/node": "^25.5.0",
|
||||||
"@types/sodium-native": "^2.3.9",
|
"@types/sodium-native": "^2.3.9",
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
@@ -848,6 +850,24 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/jsonwebtoken": {
|
||||||
|
"version": "9.0.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz",
|
||||||
|
"integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/ms": "*",
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/ms": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "25.5.0",
|
"version": "25.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz",
|
||||||
@@ -1147,6 +1167,12 @@
|
|||||||
"bare": ">=1.2.0"
|
"bare": ">=1.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/buffer-equal-constant-time": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
"node_modules/chai": {
|
"node_modules/chai": {
|
||||||
"version": "6.2.2",
|
"version": "6.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
|
||||||
@@ -1174,6 +1200,15 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ecdsa-sig-formatter": {
|
||||||
|
"version": "1.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
|
||||||
|
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/es-module-lexer": {
|
"node_modules/es-module-lexer": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz",
|
||||||
@@ -1304,6 +1339,49 @@
|
|||||||
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
|
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jsonwebtoken": {
|
||||||
|
"version": "9.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
|
||||||
|
"integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"jws": "^4.0.1",
|
||||||
|
"lodash.includes": "^4.3.0",
|
||||||
|
"lodash.isboolean": "^3.0.3",
|
||||||
|
"lodash.isinteger": "^4.0.4",
|
||||||
|
"lodash.isnumber": "^3.0.3",
|
||||||
|
"lodash.isplainobject": "^4.0.6",
|
||||||
|
"lodash.isstring": "^4.0.1",
|
||||||
|
"lodash.once": "^4.0.0",
|
||||||
|
"ms": "^2.1.1",
|
||||||
|
"semver": "^7.5.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12",
|
||||||
|
"npm": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/jwa": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"buffer-equal-constant-time": "^1.0.1",
|
||||||
|
"ecdsa-sig-formatter": "1.0.11",
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/jws": {
|
||||||
|
"version": "4.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
|
||||||
|
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"jwa": "^2.0.1",
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/lightningcss": {
|
"node_modules/lightningcss": {
|
||||||
"version": "1.32.0",
|
"version": "1.32.0",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
||||||
@@ -1565,6 +1643,48 @@
|
|||||||
"url": "https://opencollective.com/parcel"
|
"url": "https://opencollective.com/parcel"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lodash.includes": {
|
||||||
|
"version": "4.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
|
||||||
|
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isboolean": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isinteger": {
|
||||||
|
"version": "4.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
|
||||||
|
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isnumber": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isplainobject": {
|
||||||
|
"version": "4.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
|
||||||
|
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isstring": {
|
||||||
|
"version": "4.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
|
||||||
|
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.once": {
|
||||||
|
"version": "4.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
|
||||||
|
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/magic-string": {
|
"node_modules/magic-string": {
|
||||||
"version": "0.30.21",
|
"version": "0.30.21",
|
||||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||||
@@ -1575,6 +1695,12 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ms": {
|
||||||
|
"version": "2.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/nanoid": {
|
"node_modules/nanoid": {
|
||||||
"version": "3.3.11",
|
"version": "3.3.11",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||||
@@ -1717,6 +1843,38 @@
|
|||||||
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.9"
|
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.9"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/safe-buffer": {
|
||||||
|
"version": "5.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||||
|
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/semver": {
|
||||||
|
"version": "7.7.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||||
|
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"bin": {
|
||||||
|
"semver": "bin/semver.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/siginfo": {
|
"node_modules/siginfo": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
|
||||||
|
|||||||
@@ -12,17 +12,24 @@
|
|||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"keywords": ["cookies", "sync", "encryption", "browser-extension"],
|
"keywords": [
|
||||||
|
"cookies",
|
||||||
|
"sync",
|
||||||
|
"encryption",
|
||||||
|
"browser-extension"
|
||||||
|
],
|
||||||
"author": "Rc707Agency",
|
"author": "Rc707Agency",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "commonjs",
|
"type": "commonjs",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"jsonwebtoken": "^9.0.3",
|
||||||
"sodium-native": "^5.1.0",
|
"sodium-native": "^5.1.0",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"uuid": "^13.0.0",
|
"uuid": "^13.0.0",
|
||||||
"ws": "^8.19.0"
|
"ws": "^8.19.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
"@types/node": "^25.5.0",
|
"@types/node": "^25.5.0",
|
||||||
"@types/sodium-native": "^2.3.9",
|
"@types/sodium-native": "^2.3.9",
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
|
|||||||
102
src/relay/admin/auth.ts
Normal file
102
src/relay/admin/auth.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import crypto from "node:crypto";
|
||||||
|
import jwt from "jsonwebtoken";
|
||||||
|
|
||||||
|
const SCRYPT_KEYLEN = 64;
|
||||||
|
const SCRYPT_COST = 16384;
|
||||||
|
const SCRYPT_BLOCK_SIZE = 8;
|
||||||
|
const SCRYPT_PARALLELISM = 1;
|
||||||
|
|
||||||
|
export interface AdminUser {
|
||||||
|
username: string;
|
||||||
|
passwordHash: string; // scrypt hash, hex
|
||||||
|
salt: string; // hex
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminSettings {
|
||||||
|
syncIntervalMs: number;
|
||||||
|
maxDevices: number;
|
||||||
|
autoSync: boolean;
|
||||||
|
theme: "light" | "dark" | "system";
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_SETTINGS: AdminSettings = {
|
||||||
|
syncIntervalMs: 30_000,
|
||||||
|
maxDevices: 10,
|
||||||
|
autoSync: true,
|
||||||
|
theme: "system",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-memory admin state. Stores admin user, JWT secret, and settings.
|
||||||
|
* In production this would be persisted to disk/database.
|
||||||
|
*/
|
||||||
|
export class AdminStore {
|
||||||
|
private adminUser: AdminUser | null = null;
|
||||||
|
private jwtSecret: string;
|
||||||
|
private settings: AdminSettings = { ...DEFAULT_SETTINGS };
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.jwtSecret = crypto.randomBytes(32).toString("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
get isSetUp(): boolean {
|
||||||
|
return this.adminUser !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** First-time setup: create the admin account. */
|
||||||
|
async setup(username: string, password: string): Promise<void> {
|
||||||
|
if (this.adminUser) throw new Error("Already configured");
|
||||||
|
const salt = crypto.randomBytes(16).toString("hex");
|
||||||
|
const hash = await this.hashPassword(password, salt);
|
||||||
|
this.adminUser = {
|
||||||
|
username,
|
||||||
|
passwordHash: hash,
|
||||||
|
salt,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Authenticate and return a JWT. */
|
||||||
|
async login(username: string, password: string): Promise<string> {
|
||||||
|
if (!this.adminUser) throw new Error("Not configured");
|
||||||
|
if (this.adminUser.username !== username) throw new Error("Invalid credentials");
|
||||||
|
const hash = await this.hashPassword(password, this.adminUser.salt);
|
||||||
|
if (hash !== this.adminUser.passwordHash) throw new Error("Invalid credentials");
|
||||||
|
return jwt.sign({ sub: username, role: "admin" }, this.jwtSecret, { expiresIn: "24h" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Verify a JWT and return the payload. */
|
||||||
|
verifyToken(token: string): { sub: string; role: string } {
|
||||||
|
return jwt.verify(token, this.jwtSecret) as { sub: string; role: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
getUser(): { username: string; createdAt: string } | null {
|
||||||
|
if (!this.adminUser) return null;
|
||||||
|
return { username: this.adminUser.username, createdAt: this.adminUser.createdAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
getSettings(): AdminSettings {
|
||||||
|
return { ...this.settings };
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSettings(patch: Partial<AdminSettings>): AdminSettings {
|
||||||
|
Object.assign(this.settings, patch);
|
||||||
|
return { ...this.settings };
|
||||||
|
}
|
||||||
|
|
||||||
|
private hashPassword(password: string, salt: string): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
crypto.scrypt(
|
||||||
|
password,
|
||||||
|
Buffer.from(salt, "hex"),
|
||||||
|
SCRYPT_KEYLEN,
|
||||||
|
{ N: SCRYPT_COST, r: SCRYPT_BLOCK_SIZE, p: SCRYPT_PARALLELISM },
|
||||||
|
(err, derived) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(derived.toString("hex"));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
328
src/relay/admin/routes.ts
Normal file
328
src/relay/admin/routes.ts
Normal file
@@ -0,0 +1,328 @@
|
|||||||
|
import http from "node:http";
|
||||||
|
import type { AdminStore } from "./auth.js";
|
||||||
|
import type { ConnectionManager } from "../connections.js";
|
||||||
|
import type { CookieBlobStore } from "../store.js";
|
||||||
|
import type { DeviceRegistry } from "../tokens.js";
|
||||||
|
|
||||||
|
export interface AdminDeps {
|
||||||
|
adminStore: AdminStore;
|
||||||
|
connections: ConnectionManager;
|
||||||
|
cookieStore: CookieBlobStore;
|
||||||
|
deviceRegistry: DeviceRegistry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle /admin/* routes. Returns true if the route was handled.
|
||||||
|
*/
|
||||||
|
export function handleAdminRoute(
|
||||||
|
req: http.IncomingMessage,
|
||||||
|
res: http.ServerResponse,
|
||||||
|
deps: AdminDeps,
|
||||||
|
): boolean {
|
||||||
|
const url = req.url ?? "";
|
||||||
|
const method = req.method ?? "";
|
||||||
|
|
||||||
|
if (!url.startsWith("/admin/")) return false;
|
||||||
|
|
||||||
|
// --- Public routes (no auth) ---
|
||||||
|
|
||||||
|
if (method === "GET" && url === "/admin/setup/status") {
|
||||||
|
json(res, 200, { isSetUp: deps.adminStore.isSetUp });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === "POST" && url === "/admin/setup/init") {
|
||||||
|
handleSetupInit(req, res, deps);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === "POST" && url === "/admin/auth/login") {
|
||||||
|
handleLogin(req, res, deps);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Protected routes ---
|
||||||
|
|
||||||
|
const user = authenticate(req, deps.adminStore);
|
||||||
|
if (!user) {
|
||||||
|
json(res, 401, { error: "Unauthorized" });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === "POST" && url === "/admin/auth/logout") {
|
||||||
|
json(res, 200, { ok: true });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === "GET" && url === "/admin/auth/me") {
|
||||||
|
const info = deps.adminStore.getUser();
|
||||||
|
json(res, 200, info);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === "GET" && url === "/admin/dashboard") {
|
||||||
|
handleDashboard(res, deps);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cookie management
|
||||||
|
if (method === "GET" && url.startsWith("/admin/cookies")) {
|
||||||
|
handleCookieList(req, res, deps);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (method === "DELETE" && url.startsWith("/admin/cookies/")) {
|
||||||
|
handleCookieDeleteById(req, res, deps);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (method === "DELETE" && url === "/admin/cookies") {
|
||||||
|
handleCookieBatchDelete(req, res, deps);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Device management
|
||||||
|
if (method === "GET" && url === "/admin/devices") {
|
||||||
|
handleDeviceList(res, deps);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (method === "POST" && url.match(/^\/admin\/devices\/[^/]+\/revoke$/)) {
|
||||||
|
handleDeviceRevoke(req, res, deps);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settings
|
||||||
|
if (method === "GET" && url === "/admin/settings") {
|
||||||
|
json(res, 200, deps.adminStore.getSettings());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (method === "PATCH" && url === "/admin/settings") {
|
||||||
|
handleSettingsUpdate(req, res, deps);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
json(res, 404, { error: "Admin route not found" });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Auth helpers ---
|
||||||
|
|
||||||
|
function authenticate(
|
||||||
|
req: http.IncomingMessage,
|
||||||
|
store: AdminStore,
|
||||||
|
): { sub: string; role: string } | null {
|
||||||
|
const auth = req.headers.authorization;
|
||||||
|
if (!auth?.startsWith("Bearer ")) return null;
|
||||||
|
try {
|
||||||
|
return store.verifyToken(auth.slice(7));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Route handlers ---
|
||||||
|
|
||||||
|
function handleSetupInit(
|
||||||
|
req: http.IncomingMessage,
|
||||||
|
res: http.ServerResponse,
|
||||||
|
deps: AdminDeps,
|
||||||
|
): void {
|
||||||
|
readBody(req, async (body) => {
|
||||||
|
try {
|
||||||
|
const { username, password } = JSON.parse(body);
|
||||||
|
if (!username || !password) {
|
||||||
|
json(res, 400, { error: "Missing username or password" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (deps.adminStore.isSetUp) {
|
||||||
|
json(res, 409, { error: "Already configured" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await deps.adminStore.setup(username, password);
|
||||||
|
const token = await deps.adminStore.login(username, password);
|
||||||
|
json(res, 201, { token, username });
|
||||||
|
} catch {
|
||||||
|
json(res, 400, { error: "Invalid request" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLogin(
|
||||||
|
req: http.IncomingMessage,
|
||||||
|
res: http.ServerResponse,
|
||||||
|
deps: AdminDeps,
|
||||||
|
): void {
|
||||||
|
readBody(req, async (body) => {
|
||||||
|
try {
|
||||||
|
const { username, password } = JSON.parse(body);
|
||||||
|
if (!username || !password) {
|
||||||
|
json(res, 400, { error: "Missing username or password" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const token = await deps.adminStore.login(username, password);
|
||||||
|
json(res, 200, { token });
|
||||||
|
} catch {
|
||||||
|
json(res, 401, { error: "Invalid credentials" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDashboard(res: http.ServerResponse, deps: AdminDeps): void {
|
||||||
|
const devices = deps.deviceRegistry.listAll();
|
||||||
|
const onlineDeviceIds = devices
|
||||||
|
.filter((d) => deps.connections.isOnline(d.deviceId))
|
||||||
|
.map((d) => d.deviceId);
|
||||||
|
|
||||||
|
const allCookies = deps.cookieStore.getAll();
|
||||||
|
|
||||||
|
const domains = new Set(allCookies.map((c) => c.domain));
|
||||||
|
|
||||||
|
json(res, 200, {
|
||||||
|
connections: deps.connections.connectedCount,
|
||||||
|
totalDevices: devices.length,
|
||||||
|
onlineDevices: onlineDeviceIds.length,
|
||||||
|
totalCookies: allCookies.length,
|
||||||
|
uniqueDomains: domains.size,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCookieList(
|
||||||
|
req: http.IncomingMessage,
|
||||||
|
res: http.ServerResponse,
|
||||||
|
deps: AdminDeps,
|
||||||
|
): void {
|
||||||
|
const parsed = new URL(req.url ?? "", `http://${req.headers.host}`);
|
||||||
|
|
||||||
|
// Check if this is a single cookie detail request: /admin/cookies/:id
|
||||||
|
const idMatch = parsed.pathname.match(/^\/admin\/cookies\/([a-f0-9]+)$/);
|
||||||
|
if (idMatch) {
|
||||||
|
const cookie = deps.cookieStore.getById(idMatch[1]);
|
||||||
|
if (!cookie) {
|
||||||
|
json(res, 404, { error: "Cookie not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
json(res, 200, cookie);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const domain = parsed.searchParams.get("domain") ?? undefined;
|
||||||
|
const search = parsed.searchParams.get("q") ?? undefined;
|
||||||
|
const page = parseInt(parsed.searchParams.get("page") ?? "1", 10);
|
||||||
|
const limit = Math.min(parseInt(parsed.searchParams.get("limit") ?? "50", 10), 200);
|
||||||
|
|
||||||
|
let cookies = deps.cookieStore.getAll();
|
||||||
|
|
||||||
|
if (domain) {
|
||||||
|
cookies = cookies.filter((c) => c.domain === domain);
|
||||||
|
}
|
||||||
|
if (search) {
|
||||||
|
const q = search.toLowerCase();
|
||||||
|
cookies = cookies.filter(
|
||||||
|
(c) => c.domain.toLowerCase().includes(q) || c.cookieName.toLowerCase().includes(q),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = cookies.length;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
const items = cookies.slice(offset, offset + limit);
|
||||||
|
|
||||||
|
json(res, 200, { items, total, page, limit });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCookieDeleteById(
|
||||||
|
_req: http.IncomingMessage,
|
||||||
|
res: http.ServerResponse,
|
||||||
|
deps: AdminDeps,
|
||||||
|
): void {
|
||||||
|
const parsed = new URL(_req.url ?? "", `http://${_req.headers.host}`);
|
||||||
|
const idMatch = parsed.pathname.match(/^\/admin\/cookies\/([a-f0-9]+)$/);
|
||||||
|
if (!idMatch) {
|
||||||
|
json(res, 400, { error: "Invalid cookie ID" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const deleted = deps.cookieStore.deleteById(idMatch[1]);
|
||||||
|
json(res, 200, { deleted });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCookieBatchDelete(
|
||||||
|
req: http.IncomingMessage,
|
||||||
|
res: http.ServerResponse,
|
||||||
|
deps: AdminDeps,
|
||||||
|
): void {
|
||||||
|
readBody(req, (body) => {
|
||||||
|
try {
|
||||||
|
const { ids } = JSON.parse(body) as { ids: string[] };
|
||||||
|
if (!ids || !Array.isArray(ids)) {
|
||||||
|
json(res, 400, { error: "Missing ids array" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let count = 0;
|
||||||
|
for (const id of ids) {
|
||||||
|
if (deps.cookieStore.deleteById(id)) count++;
|
||||||
|
}
|
||||||
|
json(res, 200, { deleted: count });
|
||||||
|
} catch {
|
||||||
|
json(res, 400, { error: "Invalid JSON" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDeviceList(res: http.ServerResponse, deps: AdminDeps): void {
|
||||||
|
const devices = deps.deviceRegistry.listAll().map((d) => ({
|
||||||
|
deviceId: d.deviceId,
|
||||||
|
name: d.name,
|
||||||
|
platform: d.platform,
|
||||||
|
createdAt: d.createdAt,
|
||||||
|
online: deps.connections.isOnline(d.deviceId),
|
||||||
|
}));
|
||||||
|
json(res, 200, { devices });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDeviceRevoke(
|
||||||
|
req: http.IncomingMessage,
|
||||||
|
res: http.ServerResponse,
|
||||||
|
deps: AdminDeps,
|
||||||
|
): void {
|
||||||
|
const parsed = new URL(req.url ?? "", `http://${req.headers.host}`);
|
||||||
|
const match = parsed.pathname.match(/^\/admin\/devices\/([^/]+)\/revoke$/);
|
||||||
|
if (!match) {
|
||||||
|
json(res, 400, { error: "Invalid device ID" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const deviceId = match[1];
|
||||||
|
const revoked = deps.deviceRegistry.revoke(deviceId);
|
||||||
|
if (revoked) {
|
||||||
|
deps.connections.disconnect(deviceId);
|
||||||
|
}
|
||||||
|
json(res, 200, { revoked });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSettingsUpdate(
|
||||||
|
req: http.IncomingMessage,
|
||||||
|
res: http.ServerResponse,
|
||||||
|
deps: AdminDeps,
|
||||||
|
): void {
|
||||||
|
readBody(req, (body) => {
|
||||||
|
try {
|
||||||
|
const patch = JSON.parse(body);
|
||||||
|
const updated = deps.adminStore.updateSettings(patch);
|
||||||
|
json(res, 200, updated);
|
||||||
|
} catch {
|
||||||
|
json(res, 400, { error: "Invalid JSON" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Helpers ---
|
||||||
|
|
||||||
|
function json(res: http.ServerResponse, status: number, data: unknown): void {
|
||||||
|
res.writeHead(status, { "Content-Type": "application/json" });
|
||||||
|
res.end(JSON.stringify(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
function readBody(req: http.IncomingMessage, cb: (body: string) => void): void {
|
||||||
|
let data = "";
|
||||||
|
req.on("data", (chunk: Buffer) => {
|
||||||
|
data += chunk.toString();
|
||||||
|
if (data.length > 64 * 1024) req.destroy();
|
||||||
|
});
|
||||||
|
req.on("end", () => cb(data));
|
||||||
|
}
|
||||||
@@ -61,6 +61,15 @@ export class ConnectionManager {
|
|||||||
return conn !== undefined && conn.ws.readyState === 1;
|
return conn !== undefined && conn.ws.readyState === 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Forcibly disconnect a device. */
|
||||||
|
disconnect(deviceId: string): void {
|
||||||
|
const conn = this.connections.get(deviceId);
|
||||||
|
if (conn) {
|
||||||
|
conn.ws.close(4004, "Revoked");
|
||||||
|
this.connections.delete(deviceId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Get count of connected devices. */
|
/** Get count of connected devices. */
|
||||||
get connectedCount(): number {
|
get connectedCount(): number {
|
||||||
return this.connections.size;
|
return this.connections.size;
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import {
|
|||||||
MESSAGE_TYPES,
|
MESSAGE_TYPES,
|
||||||
PING_INTERVAL_MS,
|
PING_INTERVAL_MS,
|
||||||
} from "../protocol/spec.js";
|
} from "../protocol/spec.js";
|
||||||
|
import { AdminStore } from "./admin/auth.js";
|
||||||
|
import { handleAdminRoute } from "./admin/routes.js";
|
||||||
|
|
||||||
export interface RelayServerConfig {
|
export interface RelayServerConfig {
|
||||||
port: number;
|
port: number;
|
||||||
@@ -50,6 +52,7 @@ export class RelayServer {
|
|||||||
readonly cookieStore: CookieBlobStore;
|
readonly cookieStore: CookieBlobStore;
|
||||||
readonly deviceRegistry: DeviceRegistry;
|
readonly deviceRegistry: DeviceRegistry;
|
||||||
readonly agentRegistry: AgentRegistry;
|
readonly agentRegistry: AgentRegistry;
|
||||||
|
readonly adminStore: AdminStore;
|
||||||
private pendingAuths = new Map<WebSocket, PendingAuth>();
|
private pendingAuths = new Map<WebSocket, PendingAuth>();
|
||||||
private authenticatedDevices = new Map<WebSocket, string>();
|
private authenticatedDevices = new Map<WebSocket, string>();
|
||||||
private pingIntervals = new Map<WebSocket, ReturnType<typeof setInterval>>();
|
private pingIntervals = new Map<WebSocket, ReturnType<typeof setInterval>>();
|
||||||
@@ -60,6 +63,7 @@ export class RelayServer {
|
|||||||
this.cookieStore = new CookieBlobStore();
|
this.cookieStore = new CookieBlobStore();
|
||||||
this.deviceRegistry = new DeviceRegistry();
|
this.deviceRegistry = new DeviceRegistry();
|
||||||
this.agentRegistry = new AgentRegistry();
|
this.agentRegistry = new AgentRegistry();
|
||||||
|
this.adminStore = new AdminStore();
|
||||||
|
|
||||||
this.httpServer = http.createServer(this.handleHttp.bind(this));
|
this.httpServer = http.createServer(this.handleHttp.bind(this));
|
||||||
this.wss = new WebSocketServer({ server: this.httpServer });
|
this.wss = new WebSocketServer({ server: this.httpServer });
|
||||||
@@ -99,6 +103,17 @@ export class RelayServer {
|
|||||||
const url = req.url ?? "";
|
const url = req.url ?? "";
|
||||||
const method = req.method ?? "";
|
const method = req.method ?? "";
|
||||||
|
|
||||||
|
// Admin routes
|
||||||
|
if (url.startsWith("/admin/")) {
|
||||||
|
handleAdminRoute(req, res, {
|
||||||
|
adminStore: this.adminStore,
|
||||||
|
connections: this.connections,
|
||||||
|
cookieStore: this.cookieStore,
|
||||||
|
deviceRegistry: this.deviceRegistry,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Health
|
// Health
|
||||||
if (method === "GET" && url === "/health") {
|
if (method === "GET" && url === "/health") {
|
||||||
this.json(res, 200, { status: "ok", connections: this.connections.connectedCount });
|
this.json(res, 200, { status: "ok", connections: this.connections.connectedCount });
|
||||||
|
|||||||
@@ -80,6 +80,38 @@ export class CookieBlobStore {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Get all stored blobs across all devices. */
|
||||||
|
getAll(): EncryptedCookieBlob[] {
|
||||||
|
const result: EncryptedCookieBlob[] = [];
|
||||||
|
for (const deviceMap of this.store.values()) {
|
||||||
|
result.push(...deviceMap.values());
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get a single blob by its ID. */
|
||||||
|
getById(id: string): EncryptedCookieBlob | null {
|
||||||
|
for (const deviceMap of this.store.values()) {
|
||||||
|
for (const blob of deviceMap.values()) {
|
||||||
|
if (blob.id === id) return blob;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Delete a blob by its ID. Returns true if found and deleted. */
|
||||||
|
deleteById(id: string): boolean {
|
||||||
|
for (const deviceMap of this.store.values()) {
|
||||||
|
for (const [key, blob] of deviceMap) {
|
||||||
|
if (blob.id === id) {
|
||||||
|
deviceMap.delete(key);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/** Get all blobs updated after a given timestamp (for polling). */
|
/** Get all blobs updated after a given timestamp (for polling). */
|
||||||
getUpdatedSince(deviceIds: string[], since: string): EncryptedCookieBlob[] {
|
getUpdatedSince(deviceIds: string[], since: string): EncryptedCookieBlob[] {
|
||||||
const result: EncryptedCookieBlob[] = [];
|
const result: EncryptedCookieBlob[] = [];
|
||||||
|
|||||||
@@ -72,6 +72,28 @@ export class DeviceRegistry {
|
|||||||
const paired = this.getPairedDevices(deviceId);
|
const paired = this.getPairedDevices(deviceId);
|
||||||
return [deviceId, ...paired];
|
return [deviceId, ...paired];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** List all registered devices. */
|
||||||
|
listAll(): DeviceInfo[] {
|
||||||
|
return Array.from(this.devices.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Revoke a device: remove its token and registration. Returns true if it existed. */
|
||||||
|
revoke(deviceId: string): boolean {
|
||||||
|
const device = this.devices.get(deviceId);
|
||||||
|
if (!device) return false;
|
||||||
|
this.tokenToDevice.delete(device.token);
|
||||||
|
this.devices.delete(deviceId);
|
||||||
|
// Clean up pairings
|
||||||
|
const paired = this.pairings.get(deviceId);
|
||||||
|
if (paired) {
|
||||||
|
for (const peerId of paired) {
|
||||||
|
this.pairings.get(peerId)?.delete(deviceId);
|
||||||
|
}
|
||||||
|
this.pairings.delete(deviceId);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
|
||||||
const api = axios.create({
|
const api = axios.create({
|
||||||
baseURL: "/api",
|
baseURL: "/admin",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export const useAuthStore = defineStore("auth", () => {
|
|||||||
const isAuthenticated = computed(() => !!token.value);
|
const isAuthenticated = computed(() => !!token.value);
|
||||||
|
|
||||||
async function login(username: string, password: string): Promise<void> {
|
async function login(username: string, password: string): Promise<void> {
|
||||||
const { data } = await api.post("/auth/login", { username, password });
|
const { data } = await api.post("/auth/login", { username, password }, { baseURL: "/admin" });
|
||||||
token.value = data.token;
|
token.value = data.token;
|
||||||
localStorage.setItem("cb_admin_token", data.token);
|
localStorage.setItem("cb_admin_token", data.token);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,9 +27,10 @@ export const useCookiesStore = defineStore("cookies", () => {
|
|||||||
loading.value = true;
|
loading.value = true;
|
||||||
error.value = null;
|
error.value = null;
|
||||||
try {
|
try {
|
||||||
const params = domain ? { domain } : {};
|
const params: Record<string, string> = { limit: "200" };
|
||||||
|
if (domain) params.domain = domain;
|
||||||
const { data } = await api.get("/cookies", { params });
|
const { data } = await api.get("/cookies", { params });
|
||||||
cookies.value = data.cookies;
|
cookies.value = data.items ?? data.cookies ?? [];
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
error.value = e instanceof Error ? e.message : "Failed to fetch cookies";
|
error.value = e instanceof Error ? e.message : "Failed to fetch cookies";
|
||||||
} finally {
|
} finally {
|
||||||
@@ -42,7 +43,13 @@ export const useCookiesStore = defineStore("cookies", () => {
|
|||||||
cookieName: string,
|
cookieName: string,
|
||||||
path: string,
|
path: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await api.delete("/cookies", { data: { domain, cookieName, path } });
|
// Find the cookie ID first, then delete by ID
|
||||||
|
const cookie = cookies.value.find(
|
||||||
|
(c) => c.domain === domain && c.cookieName === cookieName && c.path === path,
|
||||||
|
);
|
||||||
|
if (cookie) {
|
||||||
|
await api.delete(`/cookies/${cookie.id}`);
|
||||||
|
}
|
||||||
cookies.value = cookies.value.filter(
|
cookies.value = cookies.value.filter(
|
||||||
(c) =>
|
(c) =>
|
||||||
!(c.domain === domain && c.cookieName === cookieName && c.path === path),
|
!(c.domain === domain && c.cookieName === cookieName && c.path === path),
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export const useDevicesStore = defineStore("devices", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function revokeDevice(deviceId: string): Promise<void> {
|
async function revokeDevice(deviceId: string): Promise<void> {
|
||||||
await api.delete(`/devices/${deviceId}`);
|
await api.post(`/devices/${deviceId}/revoke`);
|
||||||
devices.value = devices.value.filter((d) => d.deviceId !== deviceId);
|
devices.value = devices.value.filter((d) => d.deviceId !== deviceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,22 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref } from "vue";
|
import { onMounted, ref } from "vue";
|
||||||
import api from "@/api/client";
|
import api from "@/api/client";
|
||||||
import type { HealthStatus } from "@/types/api";
|
|
||||||
|
|
||||||
const health = ref<HealthStatus | null>(null);
|
interface DashboardData {
|
||||||
|
connections: number;
|
||||||
|
totalDevices: number;
|
||||||
|
onlineDevices: number;
|
||||||
|
totalCookies: number;
|
||||||
|
uniqueDomains: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dashboard = ref<DashboardData | null>(null);
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
const { data } = await api.get("/health", { baseURL: "" });
|
const { data } = await api.get("/dashboard");
|
||||||
health.value = data;
|
dashboard.value = data;
|
||||||
} catch {
|
} catch {
|
||||||
// Server might be down
|
// Server might be down
|
||||||
} finally {
|
} finally {
|
||||||
@@ -23,26 +30,49 @@ onMounted(async () => {
|
|||||||
<h2 class="text-2xl font-semibold text-gray-900">Dashboard</h2>
|
<h2 class="text-2xl font-semibold text-gray-900">Dashboard</h2>
|
||||||
<p class="mt-1 text-sm text-gray-500">CookieBridge relay server overview</p>
|
<p class="mt-1 text-sm text-gray-500">CookieBridge relay server overview</p>
|
||||||
|
|
||||||
<div class="mt-6 grid grid-cols-1 gap-4 sm:grid-cols-3">
|
<div class="mt-6 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
<!-- Server Status -->
|
<!-- Server Status -->
|
||||||
<div class="rounded-xl bg-white p-6 ring-1 ring-gray-200">
|
<div class="rounded-xl bg-white p-6 ring-1 ring-gray-200">
|
||||||
<p class="text-sm font-medium text-gray-500">Server Status</p>
|
<p class="text-sm font-medium text-gray-500">Server Status</p>
|
||||||
<div class="mt-2 flex items-center gap-2">
|
<div class="mt-2 flex items-center gap-2">
|
||||||
<span
|
<span
|
||||||
class="inline-block h-2.5 w-2.5 rounded-full"
|
class="inline-block h-2.5 w-2.5 rounded-full"
|
||||||
:class="health?.status === 'ok' ? 'bg-green-500' : 'bg-red-500'"
|
:class="dashboard ? 'bg-green-500' : 'bg-red-500'"
|
||||||
/>
|
/>
|
||||||
<span class="text-lg font-semibold text-gray-900">
|
<span class="text-lg font-semibold text-gray-900">
|
||||||
{{ loading ? "Checking..." : health?.status === "ok" ? "Online" : "Offline" }}
|
{{ loading ? "Checking..." : dashboard ? "Online" : "Offline" }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Devices -->
|
||||||
|
<div class="rounded-xl bg-white p-6 ring-1 ring-gray-200">
|
||||||
|
<p class="text-sm font-medium text-gray-500">Devices</p>
|
||||||
|
<p class="mt-2 text-3xl font-semibold text-gray-900">
|
||||||
|
{{ dashboard?.onlineDevices ?? "—" }}
|
||||||
|
<span class="text-base font-normal text-gray-400">
|
||||||
|
/ {{ dashboard?.totalDevices ?? "—" }}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 text-xs text-gray-500">online / total</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Cookies -->
|
||||||
|
<div class="rounded-xl bg-white p-6 ring-1 ring-gray-200">
|
||||||
|
<p class="text-sm font-medium text-gray-500">Cookies</p>
|
||||||
|
<p class="mt-2 text-3xl font-semibold text-gray-900">
|
||||||
|
{{ dashboard?.totalCookies ?? "—" }}
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 text-xs text-gray-500">
|
||||||
|
across {{ dashboard?.uniqueDomains ?? "—" }} domains
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Active Connections -->
|
<!-- Active Connections -->
|
||||||
<div class="rounded-xl bg-white p-6 ring-1 ring-gray-200">
|
<div class="rounded-xl bg-white p-6 ring-1 ring-gray-200">
|
||||||
<p class="text-sm font-medium text-gray-500">Active Connections</p>
|
<p class="text-sm font-medium text-gray-500">WebSocket Connections</p>
|
||||||
<p class="mt-2 text-3xl font-semibold text-gray-900">
|
<p class="mt-2 text-3xl font-semibold text-gray-900">
|
||||||
{{ health?.connections ?? "—" }}
|
{{ dashboard?.connections ?? "—" }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ export default defineConfig({
|
|||||||
target: "http://localhost:8100",
|
target: "http://localhost:8100",
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
|
"/admin": {
|
||||||
|
target: "http://localhost:8100",
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
"/ws": {
|
"/ws": {
|
||||||
target: "ws://localhost:8100",
|
target: "ws://localhost:8100",
|
||||||
ws: true,
|
ws: true,
|
||||||
|
|||||||
Reference in New Issue
Block a user