Compare commits
7 Commits
b6fbf7a921
...
1420c4ecfa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1420c4ecfa | ||
|
|
6504d3c7b9 | ||
|
|
147f9d4761 | ||
|
|
1a6d61ec36 | ||
|
|
a320f7ad97 | ||
|
|
f4144c96f1 | ||
|
|
e3a9d9f63c |
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
web/node_modules/
|
||||||
|
web/dist/
|
||||||
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
11
web/env.d.ts
vendored
Normal file
11
web/env.d.ts
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
declare module "*.vue" {
|
||||||
|
import type { DefineComponent } from "vue";
|
||||||
|
const component: DefineComponent<
|
||||||
|
Record<string, unknown>,
|
||||||
|
Record<string, unknown>,
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
export default component;
|
||||||
|
}
|
||||||
12
web/index.html
Normal file
12
web/index.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>CookieBridge Admin</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
2694
web/package-lock.json
generated
Normal file
2694
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
33
web/package.json
Normal file
33
web/package.json
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"name": "cookiebridge-web",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vue-tsc --noEmit && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"test:e2e": "playwright test tests/e2e/",
|
||||||
|
"test:e2e:ui": "playwright test tests/e2e/ --ui",
|
||||||
|
"test:e2e:headed": "playwright test tests/e2e/ --headed",
|
||||||
|
"test:api": "playwright test tests/api/ --project=chromium",
|
||||||
|
"test:all": "playwright test"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@headlessui/vue": "^1.7.0",
|
||||||
|
"@heroicons/vue": "^2.2.0",
|
||||||
|
"axios": "^1.8.0",
|
||||||
|
"pinia": "^3.0.0",
|
||||||
|
"vue": "^3.5.0",
|
||||||
|
"vue-router": "^4.5.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.58.2",
|
||||||
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
|
"@vitejs/plugin-vue": "^5.2.0",
|
||||||
|
"tailwindcss": "^4.0.0",
|
||||||
|
"typescript": "^5.9.0",
|
||||||
|
"vite": "^6.2.0",
|
||||||
|
"vue-tsc": "^2.2.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
60
web/playwright.config.ts
Normal file
60
web/playwright.config.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { defineConfig, devices } from "@playwright/test";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CookieBridge Admin Frontend - Playwright E2E Test Configuration
|
||||||
|
*
|
||||||
|
* Prerequisites: RCA-12 (scaffold), RCA-13 (API), RCA-14 (login),
|
||||||
|
* RCA-15 (dashboard), RCA-16 (cookies), RCA-17 (devices), RCA-18 (settings)
|
||||||
|
* must all be complete before running these tests.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* npm run test:e2e — run all tests headless
|
||||||
|
* npm run test:e2e:ui — interactive UI mode
|
||||||
|
* npm run test:e2e:headed — run with browser visible
|
||||||
|
*/
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: "./tests/e2e",
|
||||||
|
fullyParallel: true,
|
||||||
|
forbidOnly: !!process.env.CI,
|
||||||
|
retries: process.env.CI ? 2 : 0,
|
||||||
|
workers: process.env.CI ? 1 : undefined,
|
||||||
|
reporter: [["html", { open: "never" }], ["list"]],
|
||||||
|
use: {
|
||||||
|
baseURL: process.env.BASE_URL ?? "http://localhost:5173",
|
||||||
|
trace: "on-first-retry",
|
||||||
|
screenshot: "only-on-failure",
|
||||||
|
video: "retain-on-failure",
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: "chromium",
|
||||||
|
use: { ...devices["Desktop Chrome"] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "firefox",
|
||||||
|
use: { ...devices["Desktop Firefox"] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "webkit",
|
||||||
|
use: { ...devices["Desktop Safari"] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mobile-chrome",
|
||||||
|
use: { ...devices["Pixel 5"] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mobile-safari",
|
||||||
|
use: { ...devices["iPhone 12"] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "tablet",
|
||||||
|
use: { ...devices["iPad Pro 11"] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
webServer: {
|
||||||
|
command: "npm run dev",
|
||||||
|
url: "http://localhost:5173",
|
||||||
|
reuseExistingServer: !process.env.CI,
|
||||||
|
timeout: 30_000,
|
||||||
|
},
|
||||||
|
});
|
||||||
3
web/src/App.vue
Normal file
3
web/src/App.vue
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<template>
|
||||||
|
<router-view />
|
||||||
|
</template>
|
||||||
30
web/src/api/client.ts
Normal file
30
web/src/api/client.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
const api = axios.create({
|
||||||
|
baseURL: "/admin",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Attach auth token to requests
|
||||||
|
api.interceptors.request.use((config) => {
|
||||||
|
const token = localStorage.getItem("cb_admin_token");
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle 401 responses (skip login endpoint — 401 there means bad credentials, not expired session)
|
||||||
|
api.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
(error) => {
|
||||||
|
const url = error.config?.url ?? "";
|
||||||
|
if (error.response?.status === 401 && !url.includes("/auth/login")) {
|
||||||
|
localStorage.removeItem("cb_admin_token");
|
||||||
|
window.location.href = "/login";
|
||||||
|
}
|
||||||
|
return Promise.reject(error);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default api;
|
||||||
55
web/src/components/layout/AppLayout.vue
Normal file
55
web/src/components/layout/AppLayout.vue
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useRouter } from "vue-router";
|
||||||
|
import { useAuthStore } from "@/stores/auth";
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const auth = useAuthStore();
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ name: "Dashboard", path: "/", icon: "📊" },
|
||||||
|
{ name: "Cookies", path: "/cookies", icon: "🍪" },
|
||||||
|
{ name: "Devices", path: "/devices", icon: "📱" },
|
||||||
|
{ name: "Settings", path: "/settings", icon: "⚙️" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
auth.logout();
|
||||||
|
router.push("/login");
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex h-screen bg-gray-50">
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<aside class="w-64 border-r border-gray-200 bg-white">
|
||||||
|
<div class="flex h-16 items-center border-b border-gray-200 px-6">
|
||||||
|
<h1 class="text-lg font-semibold text-gray-900">CookieBridge</h1>
|
||||||
|
</div>
|
||||||
|
<nav class="mt-4 space-y-1 px-3">
|
||||||
|
<router-link
|
||||||
|
v-for="item in navItems"
|
||||||
|
:key="item.path"
|
||||||
|
:to="item.path"
|
||||||
|
class="flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100"
|
||||||
|
active-class="!bg-blue-50 !text-blue-700"
|
||||||
|
>
|
||||||
|
<span>{{ item.icon }}</span>
|
||||||
|
<span>{{ item.name }}</span>
|
||||||
|
</router-link>
|
||||||
|
</nav>
|
||||||
|
<div class="absolute bottom-0 w-64 border-t border-gray-200 p-4">
|
||||||
|
<button
|
||||||
|
class="w-full rounded-lg px-3 py-2 text-left text-sm font-medium text-gray-600 hover:bg-gray-100"
|
||||||
|
@click="logout"
|
||||||
|
>
|
||||||
|
Sign Out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Main content -->
|
||||||
|
<main class="flex-1 overflow-auto">
|
||||||
|
<router-view />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
10
web/src/main.ts
Normal file
10
web/src/main.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { createApp } from "vue";
|
||||||
|
import { createPinia } from "pinia";
|
||||||
|
import router from "./router";
|
||||||
|
import App from "./App.vue";
|
||||||
|
import "./style.css";
|
||||||
|
|
||||||
|
const app = createApp(App);
|
||||||
|
app.use(createPinia());
|
||||||
|
app.use(router);
|
||||||
|
app.mount("#app");
|
||||||
94
web/src/router/index.ts
Normal file
94
web/src/router/index.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { createRouter, createWebHistory, type RouteRecordRaw } from "vue-router";
|
||||||
|
import { useAuthStore } from "@/stores/auth";
|
||||||
|
import api from "@/api/client";
|
||||||
|
|
||||||
|
const routes: RouteRecordRaw[] = [
|
||||||
|
{
|
||||||
|
path: "/login",
|
||||||
|
name: "login",
|
||||||
|
component: () => import("@/views/LoginView.vue"),
|
||||||
|
meta: { requiresAuth: false },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/setup",
|
||||||
|
name: "setup",
|
||||||
|
component: () => import("@/views/SetupView.vue"),
|
||||||
|
meta: { requiresAuth: false },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/",
|
||||||
|
component: () => import("@/components/layout/AppLayout.vue"),
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
redirect: "/dashboard",
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: "dashboard",
|
||||||
|
name: "dashboard",
|
||||||
|
component: () => import("@/views/DashboardView.vue"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "cookies",
|
||||||
|
name: "cookies",
|
||||||
|
component: () => import("@/views/CookiesView.vue"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "devices",
|
||||||
|
name: "devices",
|
||||||
|
component: () => import("@/views/DevicesView.vue"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "settings",
|
||||||
|
name: "settings",
|
||||||
|
component: () => import("@/views/SettingsView.vue"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(),
|
||||||
|
routes,
|
||||||
|
});
|
||||||
|
|
||||||
|
let setupChecked = false;
|
||||||
|
let isSetUp = false;
|
||||||
|
|
||||||
|
router.beforeEach(async (to) => {
|
||||||
|
const auth = useAuthStore();
|
||||||
|
|
||||||
|
// Check setup status once on first navigation
|
||||||
|
if (!setupChecked) {
|
||||||
|
try {
|
||||||
|
const { data } = await api.get("/setup/status");
|
||||||
|
isSetUp = data.initialised;
|
||||||
|
} catch {
|
||||||
|
// If server unreachable, assume setup done
|
||||||
|
isSetUp = true;
|
||||||
|
}
|
||||||
|
setupChecked = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redirect to setup if not configured (unless already on setup page)
|
||||||
|
if (!isSetUp && to.name !== "setup") {
|
||||||
|
return { name: "setup" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// After setup is done, don't allow revisiting setup
|
||||||
|
if (isSetUp && to.name === "setup") {
|
||||||
|
return { name: "login" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (to.meta.requiresAuth !== false && !auth.isAuthenticated) {
|
||||||
|
return { name: "login" };
|
||||||
|
}
|
||||||
|
if (to.name === "login" && auth.isAuthenticated) {
|
||||||
|
return { name: "dashboard" };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Allow marking setup as complete from the setup view
|
||||||
|
export function markSetupComplete(): void {
|
||||||
|
isSetUp = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default router;
|
||||||
22
web/src/stores/auth.ts
Normal file
22
web/src/stores/auth.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { defineStore } from "pinia";
|
||||||
|
import { ref, computed } from "vue";
|
||||||
|
import api from "@/api/client";
|
||||||
|
|
||||||
|
export const useAuthStore = defineStore("auth", () => {
|
||||||
|
const token = ref<string | null>(localStorage.getItem("cb_admin_token"));
|
||||||
|
|
||||||
|
const isAuthenticated = computed(() => !!token.value);
|
||||||
|
|
||||||
|
async function login(username: string, password: string): Promise<void> {
|
||||||
|
const { data } = await api.post("/auth/login", { username, password }, { baseURL: "/admin" });
|
||||||
|
token.value = data.token;
|
||||||
|
localStorage.setItem("cb_admin_token", data.token);
|
||||||
|
}
|
||||||
|
|
||||||
|
function logout(): void {
|
||||||
|
token.value = null;
|
||||||
|
localStorage.removeItem("cb_admin_token");
|
||||||
|
}
|
||||||
|
|
||||||
|
return { token, isAuthenticated, login, logout };
|
||||||
|
});
|
||||||
60
web/src/stores/cookies.ts
Normal file
60
web/src/stores/cookies.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { defineStore } from "pinia";
|
||||||
|
import { ref, computed } from "vue";
|
||||||
|
import api from "@/api/client";
|
||||||
|
import type { EncryptedCookieBlob } from "@/types/api";
|
||||||
|
|
||||||
|
export const useCookiesStore = defineStore("cookies", () => {
|
||||||
|
const cookies = ref<EncryptedCookieBlob[]>([]);
|
||||||
|
const loading = ref(false);
|
||||||
|
const error = ref<string | null>(null);
|
||||||
|
|
||||||
|
const domains = computed(() => {
|
||||||
|
const set = new Set(cookies.value.map((c) => c.domain));
|
||||||
|
return Array.from(set).sort();
|
||||||
|
});
|
||||||
|
|
||||||
|
const byDomain = computed(() => {
|
||||||
|
const map = new Map<string, EncryptedCookieBlob[]>();
|
||||||
|
for (const cookie of cookies.value) {
|
||||||
|
const list = map.get(cookie.domain) ?? [];
|
||||||
|
list.push(cookie);
|
||||||
|
map.set(cookie.domain, list);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function fetchCookies(domain?: string): Promise<void> {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
try {
|
||||||
|
const params: Record<string, string> = { limit: "200" };
|
||||||
|
if (domain) params.domain = domain;
|
||||||
|
const { data } = await api.get("/cookies", { params });
|
||||||
|
cookies.value = data.items ?? data.cookies ?? [];
|
||||||
|
} catch (e: unknown) {
|
||||||
|
error.value = e instanceof Error ? e.message : "Failed to fetch cookies";
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteCookie(
|
||||||
|
domain: string,
|
||||||
|
cookieName: string,
|
||||||
|
path: string,
|
||||||
|
): Promise<void> {
|
||||||
|
// 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(
|
||||||
|
(c) =>
|
||||||
|
!(c.domain === domain && c.cookieName === cookieName && c.path === path),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { cookies, loading, error, domains, byDomain, fetchCookies, deleteCookie };
|
||||||
|
});
|
||||||
30
web/src/stores/devices.ts
Normal file
30
web/src/stores/devices.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { defineStore } from "pinia";
|
||||||
|
import { ref } from "vue";
|
||||||
|
import api from "@/api/client";
|
||||||
|
import type { DeviceInfo } from "@/types/api";
|
||||||
|
|
||||||
|
export const useDevicesStore = defineStore("devices", () => {
|
||||||
|
const devices = ref<DeviceInfo[]>([]);
|
||||||
|
const loading = ref(false);
|
||||||
|
const error = ref<string | null>(null);
|
||||||
|
|
||||||
|
async function fetchDevices(): Promise<void> {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
try {
|
||||||
|
const { data } = await api.get("/devices");
|
||||||
|
devices.value = data.devices;
|
||||||
|
} catch (e: unknown) {
|
||||||
|
error.value = e instanceof Error ? e.message : "Failed to fetch devices";
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function revokeDevice(deviceId: string): Promise<void> {
|
||||||
|
await api.post(`/devices/${deviceId}/revoke`);
|
||||||
|
devices.value = devices.value.filter((d) => d.deviceId !== deviceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { devices, loading, error, fetchDevices, revokeDevice };
|
||||||
|
});
|
||||||
43
web/src/stores/settings.ts
Normal file
43
web/src/stores/settings.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { defineStore } from "pinia";
|
||||||
|
import { ref } from "vue";
|
||||||
|
import api from "@/api/client";
|
||||||
|
|
||||||
|
export interface AppSettings {
|
||||||
|
syncIntervalMs: number;
|
||||||
|
maxDevices: number;
|
||||||
|
autoSync: boolean;
|
||||||
|
theme: "light" | "dark" | "system";
|
||||||
|
sessionTimeoutMinutes: number;
|
||||||
|
language: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_SETTINGS: AppSettings = {
|
||||||
|
syncIntervalMs: 30_000,
|
||||||
|
maxDevices: 10,
|
||||||
|
autoSync: true,
|
||||||
|
theme: "system",
|
||||||
|
sessionTimeoutMinutes: 60,
|
||||||
|
language: "en",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useSettingsStore = defineStore("settings", () => {
|
||||||
|
const settings = ref<AppSettings>({ ...DEFAULT_SETTINGS });
|
||||||
|
const loading = ref(false);
|
||||||
|
|
||||||
|
async function fetchSettings(): Promise<void> {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const { data } = await api.get("/settings");
|
||||||
|
settings.value = { ...DEFAULT_SETTINGS, ...data };
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateSettings(patch: Partial<AppSettings>): Promise<void> {
|
||||||
|
const { data } = await api.patch("/settings", patch);
|
||||||
|
settings.value = { ...settings.value, ...data };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { settings, loading, fetchSettings, updateSettings };
|
||||||
|
});
|
||||||
1
web/src/style.css
Normal file
1
web/src/style.css
Normal file
@@ -0,0 +1 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
62
web/src/types/api.ts
Normal file
62
web/src/types/api.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
/** Device registration response */
|
||||||
|
export interface DeviceInfo {
|
||||||
|
deviceId: string;
|
||||||
|
name: string;
|
||||||
|
platform: string;
|
||||||
|
encPub: string;
|
||||||
|
token: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Encrypted cookie blob stored on the relay server */
|
||||||
|
export interface EncryptedCookieBlob {
|
||||||
|
id: string;
|
||||||
|
deviceId: string;
|
||||||
|
domain: string;
|
||||||
|
cookieName: string;
|
||||||
|
path: string;
|
||||||
|
ciphertext: string;
|
||||||
|
nonce: string;
|
||||||
|
lamportTs: number;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Agent token */
|
||||||
|
export interface AgentToken {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
token: string;
|
||||||
|
encPub: string;
|
||||||
|
allowedDomains: string[];
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pairing session */
|
||||||
|
export interface PairingSession {
|
||||||
|
pairingCode: string;
|
||||||
|
expiresAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pairing accept response */
|
||||||
|
export interface PairingResult {
|
||||||
|
initiator: { deviceId: string; x25519PubKey: string };
|
||||||
|
acceptor: { deviceId: string; x25519PubKey: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Health check response */
|
||||||
|
export interface HealthStatus {
|
||||||
|
status: string;
|
||||||
|
connections: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Login credentials for admin auth */
|
||||||
|
export interface LoginCredentials {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Auth token response */
|
||||||
|
export interface AuthResponse {
|
||||||
|
token: string;
|
||||||
|
expiresAt: string;
|
||||||
|
}
|
||||||
363
web/src/views/CookiesView.vue
Normal file
363
web/src/views/CookiesView.vue
Normal file
@@ -0,0 +1,363 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref, computed, watch } from "vue";
|
||||||
|
import { useCookiesStore } from "@/stores/cookies";
|
||||||
|
import type { EncryptedCookieBlob } from "@/types/api";
|
||||||
|
|
||||||
|
const store = useCookiesStore();
|
||||||
|
const search = ref("");
|
||||||
|
const selectedDomain = ref<string | null>(null);
|
||||||
|
const selectedCookie = ref<EncryptedCookieBlob | null>(null);
|
||||||
|
const selectedIds = ref<Set<string>>(new Set());
|
||||||
|
const expandedDomains = ref<Set<string>>(new Set());
|
||||||
|
const confirmingDeleteCookie = ref<EncryptedCookieBlob | null>(null);
|
||||||
|
const confirmingBatchDelete = ref(false);
|
||||||
|
|
||||||
|
onMounted(() => store.fetchCookies());
|
||||||
|
|
||||||
|
// Auto-expand all domains when cookies load
|
||||||
|
watch(
|
||||||
|
() => store.cookies,
|
||||||
|
(cookies) => {
|
||||||
|
const domains = new Set(cookies.map((c) => c.domain));
|
||||||
|
expandedDomains.value = domains;
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
const filteredCookies = computed(() => {
|
||||||
|
let list = store.cookies;
|
||||||
|
if (search.value) {
|
||||||
|
const q = search.value.toLowerCase();
|
||||||
|
list = list.filter(
|
||||||
|
(c) => c.domain.toLowerCase().includes(q) || c.cookieName.toLowerCase().includes(q),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
});
|
||||||
|
|
||||||
|
const groupedByDomain = computed(() => {
|
||||||
|
const map = new Map<string, EncryptedCookieBlob[]>();
|
||||||
|
for (const cookie of filteredCookies.value) {
|
||||||
|
const list = map.get(cookie.domain) ?? [];
|
||||||
|
list.push(cookie);
|
||||||
|
map.set(cookie.domain, list);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
|
||||||
|
function selectDomain(domain: string | null) {
|
||||||
|
selectedDomain.value = domain;
|
||||||
|
store.fetchCookies(domain ?? undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleDomain(domain: string) {
|
||||||
|
if (expandedDomains.value.has(domain)) {
|
||||||
|
expandedDomains.value.delete(domain);
|
||||||
|
} else {
|
||||||
|
expandedDomains.value.add(domain);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectCookie(cookie: EncryptedCookieBlob) {
|
||||||
|
selectedCookie.value = cookie;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDetail() {
|
||||||
|
selectedCookie.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSelect(id: string) {
|
||||||
|
if (selectedIds.value.has(id)) {
|
||||||
|
selectedIds.value.delete(id);
|
||||||
|
} else {
|
||||||
|
selectedIds.value.add(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSelectAll() {
|
||||||
|
if (selectedIds.value.size === filteredCookies.value.length) {
|
||||||
|
selectedIds.value.clear();
|
||||||
|
} else {
|
||||||
|
selectedIds.value = new Set(filteredCookies.value.map((c) => c.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestDelete(cookie: EncryptedCookieBlob) {
|
||||||
|
confirmingDeleteCookie.value = cookie;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDelete() {
|
||||||
|
const cookie = confirmingDeleteCookie.value;
|
||||||
|
if (!cookie) return;
|
||||||
|
await store.deleteCookie(cookie.domain, cookie.cookieName, cookie.path);
|
||||||
|
if (selectedCookie.value?.id === cookie.id) {
|
||||||
|
selectedCookie.value = null;
|
||||||
|
}
|
||||||
|
confirmingDeleteCookie.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelDelete() {
|
||||||
|
confirmingDeleteCookie.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestBatchDelete() {
|
||||||
|
if (selectedIds.value.size === 0) return;
|
||||||
|
confirmingBatchDelete.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmBatchDelete() {
|
||||||
|
for (const id of selectedIds.value) {
|
||||||
|
const c = store.cookies.find((x) => x.id === id);
|
||||||
|
if (c) await store.deleteCookie(c.domain, c.cookieName, c.path);
|
||||||
|
}
|
||||||
|
selectedIds.value.clear();
|
||||||
|
confirmingBatchDelete.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelBatchDelete() {
|
||||||
|
confirmingBatchDelete.value = false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="h-full overflow-auto p-8">
|
||||||
|
<!-- Detail panel (replaces list when a cookie is selected) -->
|
||||||
|
<div v-if="selectedCookie">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h2 class="text-2xl font-semibold text-gray-900">Cookie Details</h2>
|
||||||
|
<button
|
||||||
|
class="rounded-lg border border-gray-200 px-3 py-2 text-sm font-medium text-gray-600 hover:bg-gray-50"
|
||||||
|
@click="closeDetail"
|
||||||
|
>
|
||||||
|
Back to list
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 max-w-lg rounded-xl bg-white p-6 ring-1 ring-gray-200">
|
||||||
|
<dl class="space-y-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<dt class="font-medium text-gray-500">Name</dt>
|
||||||
|
<dd class="mt-0.5 text-gray-900">{{ selectedCookie.cookieName }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="font-medium text-gray-500">Value</dt>
|
||||||
|
<dd class="mt-0.5 font-mono text-xs text-gray-400 break-all max-h-24 overflow-auto">
|
||||||
|
{{ selectedCookie.ciphertext?.slice(0, 80) }}...
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="font-medium text-gray-500">Domain</dt>
|
||||||
|
<dd class="mt-0.5 font-mono text-xs text-gray-900">{{ selectedCookie.domain }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="font-medium text-gray-500">Path</dt>
|
||||||
|
<dd class="mt-0.5 font-mono text-xs text-gray-900">{{ selectedCookie.path }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="font-medium text-gray-500">Expires</dt>
|
||||||
|
<dd class="mt-0.5 text-gray-900">
|
||||||
|
{{ (selectedCookie as any).expires ? new Date((selectedCookie as any).expires).toLocaleString() : "Session" }}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="font-medium text-gray-500">Secure</dt>
|
||||||
|
<dd class="mt-0.5 text-gray-900">{{ (selectedCookie as any).secure ? "Yes" : "No" }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="font-medium text-gray-500">HttpOnly</dt>
|
||||||
|
<dd class="mt-0.5 text-gray-900">{{ (selectedCookie as any).httpOnly ? "Yes" : "No" }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="font-medium text-gray-500">Device ID</dt>
|
||||||
|
<dd class="mt-0.5 font-mono text-xs text-gray-600 break-all">
|
||||||
|
{{ selectedCookie.deviceId }}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="font-medium text-gray-500">Lamport Timestamp</dt>
|
||||||
|
<dd class="mt-0.5 text-gray-900">{{ selectedCookie.lamportTs }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="font-medium text-gray-500">Updated At</dt>
|
||||||
|
<dd class="mt-0.5 text-gray-900">
|
||||||
|
{{ new Date(selectedCookie.updatedAt).toLocaleString() }}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="mt-6 w-full rounded-lg border border-red-200 px-3 py-2 text-xs font-medium text-red-600 hover:bg-red-50"
|
||||||
|
@click="requestDelete(selectedCookie)"
|
||||||
|
>
|
||||||
|
Delete Cookie
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Cookie list (shown when no cookie selected) -->
|
||||||
|
<div v-else>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-2xl font-semibold text-gray-900">Cookies</h2>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">
|
||||||
|
{{ store.cookies.length }} cookies across {{ store.domains.length }} domains
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
v-if="selectedIds.size > 0"
|
||||||
|
class="rounded-lg bg-red-600 px-3 py-2 text-xs font-medium text-white hover:bg-red-700"
|
||||||
|
@click="requestBatchDelete"
|
||||||
|
>
|
||||||
|
Delete Selected ({{ selectedIds.size }})
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
v-model="search"
|
||||||
|
type="text"
|
||||||
|
placeholder="Search domain or name..."
|
||||||
|
class="w-64 rounded-lg border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error state -->
|
||||||
|
<div v-if="store.error" role="alert" class="mt-4 rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-700">
|
||||||
|
{{ store.error }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Grouped cookie list -->
|
||||||
|
<div class="mt-6 space-y-3">
|
||||||
|
<div v-if="store.loading" class="rounded-xl bg-white p-8 text-center text-sm text-gray-500 ring-1 ring-gray-200">
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
<div v-else-if="filteredCookies.length === 0 && !store.error" class="rounded-xl bg-white p-8 text-center text-sm text-gray-500 ring-1 ring-gray-200">
|
||||||
|
No cookies found
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-for="[domain, cookies] in groupedByDomain"
|
||||||
|
:key="domain"
|
||||||
|
class="overflow-hidden rounded-xl bg-white ring-1 ring-gray-200"
|
||||||
|
>
|
||||||
|
<!-- Domain header -->
|
||||||
|
<button
|
||||||
|
class="flex w-full items-center justify-between px-4 py-3 text-left hover:bg-gray-50"
|
||||||
|
@click="toggleDomain(domain)"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
class="text-xs text-gray-400 transition-transform"
|
||||||
|
:class="expandedDomains.has(domain) ? 'rotate-90' : ''"
|
||||||
|
>▶</span>
|
||||||
|
<span class="font-mono text-sm font-medium text-gray-900">{{ domain }}</span>
|
||||||
|
<span class="rounded-full bg-gray-100 px-2 py-0.5 text-xs text-gray-500">
|
||||||
|
{{ cookies.length }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Cookies table (expanded) -->
|
||||||
|
<div v-show="expandedDomains.has(domain)">
|
||||||
|
<table class="w-full text-left text-sm">
|
||||||
|
<thead class="border-y border-gray-100 bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th class="w-8 px-4 py-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="rounded border-gray-300"
|
||||||
|
@change="toggleSelectAll()"
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
|
<th class="px-4 py-2 font-medium text-gray-600">Cookie</th>
|
||||||
|
<th class="px-4 py-2 font-medium text-gray-600">Location</th>
|
||||||
|
<th class="px-4 py-2 font-medium text-gray-600">Device</th>
|
||||||
|
<th class="px-4 py-2 font-medium text-gray-600">Updated</th>
|
||||||
|
<th class="px-4 py-2 font-medium text-gray-600"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-50">
|
||||||
|
<tr
|
||||||
|
v-for="cookie in cookies"
|
||||||
|
:key="cookie.id"
|
||||||
|
class="cursor-pointer hover:bg-blue-50"
|
||||||
|
@click="selectCookie(cookie)"
|
||||||
|
>
|
||||||
|
<td class="px-4 py-2" @click.stop>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="rounded border-gray-300"
|
||||||
|
:checked="selectedIds.has(cookie.id)"
|
||||||
|
@change="toggleSelect(cookie.id)"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-2 font-medium text-gray-900">{{ cookie.cookieName }}</td>
|
||||||
|
<td class="px-4 py-2 font-mono text-xs text-gray-600">{{ cookie.path }}</td>
|
||||||
|
<td class="px-4 py-2 font-mono text-xs text-gray-500 truncate max-w-[100px]">
|
||||||
|
{{ cookie.deviceId.slice(0, 12) }}...
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-2 text-xs text-gray-500">
|
||||||
|
{{ new Date(cookie.updatedAt).toLocaleString() }}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-2" @click.stop>
|
||||||
|
<button
|
||||||
|
class="text-red-600 hover:text-red-800 text-xs font-medium"
|
||||||
|
@click="requestDelete(cookie)"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Single delete confirmation dialog -->
|
||||||
|
<div v-if="confirmingDeleteCookie" class="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
|
||||||
|
<div role="dialog" class="w-80 rounded-xl bg-white p-6 shadow-lg">
|
||||||
|
<p class="text-sm text-gray-600">
|
||||||
|
Are you sure you want to delete this cookie?
|
||||||
|
</p>
|
||||||
|
<div class="mt-4 flex gap-3">
|
||||||
|
<button
|
||||||
|
class="flex-1 rounded-lg border border-gray-200 px-3 py-2 text-sm font-medium text-gray-600 hover:bg-gray-50"
|
||||||
|
@click="cancelDelete"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="flex-1 rounded-lg bg-red-600 px-3 py-2 text-sm font-medium text-white hover:bg-red-700"
|
||||||
|
@click="confirmDelete"
|
||||||
|
>
|
||||||
|
Confirm Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Batch delete confirmation dialog -->
|
||||||
|
<div v-if="confirmingBatchDelete" class="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
|
||||||
|
<div role="dialog" class="w-80 rounded-xl bg-white p-6 shadow-lg">
|
||||||
|
<p class="text-sm text-gray-600">
|
||||||
|
Are you sure you want to delete {{ selectedIds.size }} cookies?
|
||||||
|
</p>
|
||||||
|
<div class="mt-4 flex gap-3">
|
||||||
|
<button
|
||||||
|
class="flex-1 rounded-lg border border-gray-200 px-3 py-2 text-sm font-medium text-gray-600 hover:bg-gray-50"
|
||||||
|
@click="cancelBatchDelete"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="flex-1 rounded-lg bg-red-600 px-3 py-2 text-sm font-medium text-white hover:bg-red-700"
|
||||||
|
@click="confirmBatchDelete"
|
||||||
|
>
|
||||||
|
Confirm Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
238
web/src/views/DashboardView.vue
Normal file
238
web/src/views/DashboardView.vue
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref, computed } from "vue";
|
||||||
|
import api from "@/api/client";
|
||||||
|
|
||||||
|
interface DashboardData {
|
||||||
|
connections: number;
|
||||||
|
totalDevices: number;
|
||||||
|
onlineDevices: number;
|
||||||
|
totalCookies: number;
|
||||||
|
uniqueDomains: number;
|
||||||
|
syncCount: number;
|
||||||
|
uptimeSeconds: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DeviceSummary {
|
||||||
|
deviceId: string;
|
||||||
|
name: string;
|
||||||
|
platform: string;
|
||||||
|
online: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dashboard = ref<DashboardData | null>(null);
|
||||||
|
const devices = ref<DeviceSummary[]>([]);
|
||||||
|
const loading = ref(true);
|
||||||
|
const error = ref<string | null>(null);
|
||||||
|
|
||||||
|
const offlineDevices = computed(
|
||||||
|
() => (dashboard.value?.totalDevices ?? 0) - (dashboard.value?.onlineDevices ?? 0),
|
||||||
|
);
|
||||||
|
|
||||||
|
function platformIcon(platform: string): string {
|
||||||
|
const p = platform.toLowerCase();
|
||||||
|
if (p.includes("chrome")) return "chrome";
|
||||||
|
if (p.includes("firefox")) return "firefox";
|
||||||
|
if (p.includes("edge")) return "edge";
|
||||||
|
if (p.includes("safari")) return "safari";
|
||||||
|
return "device";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatUptime(seconds: number): string {
|
||||||
|
const d = Math.floor(seconds / 86400);
|
||||||
|
const h = Math.floor((seconds % 86400) / 3600);
|
||||||
|
if (d > 0) return `${d}d ${h}h`;
|
||||||
|
const m = Math.floor((seconds % 3600) / 60);
|
||||||
|
if (h > 0) return `${h}h ${m}m`;
|
||||||
|
return `${m}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchData() {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
try {
|
||||||
|
const dashRes = await api.get("/dashboard");
|
||||||
|
dashboard.value = dashRes.data;
|
||||||
|
} catch {
|
||||||
|
error.value = "Failed to load dashboard data";
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const devRes = await api.get("/devices");
|
||||||
|
devices.value = devRes.data.devices ?? [];
|
||||||
|
} catch {
|
||||||
|
// Devices list is optional — dashboard still shows stats
|
||||||
|
}
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(fetchData);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="p-8">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="rounded-lg border border-gray-200 px-3 py-2 text-sm font-medium text-gray-600 hover:bg-gray-50"
|
||||||
|
@click="fetchData"
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="mt-8 text-sm text-gray-500">Loading...</div>
|
||||||
|
|
||||||
|
<div v-else-if="error" role="alert" class="mt-8 rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-700">
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<!-- Stat cards -->
|
||||||
|
<div class="mt-6 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<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 ?? 0 }}
|
||||||
|
<span class="text-base font-normal text-gray-400">
|
||||||
|
/ {{ dashboard?.totalDevices ?? 0 }}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<div class="mt-2 flex gap-3 text-xs">
|
||||||
|
<span class="flex items-center gap-1 text-green-600">
|
||||||
|
<span class="h-1.5 w-1.5 rounded-full bg-green-500" />
|
||||||
|
{{ dashboard?.onlineDevices ?? 0 }} online
|
||||||
|
</span>
|
||||||
|
<span class="flex items-center gap-1 text-gray-400">
|
||||||
|
<span class="h-1.5 w-1.5 rounded-full bg-gray-300" />
|
||||||
|
{{ offlineDevices }} offline
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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 ?? 0 }}
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 text-xs text-gray-500">
|
||||||
|
across {{ dashboard?.uniqueDomains ?? 0 }} domains
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-xl bg-white p-6 ring-1 ring-gray-200">
|
||||||
|
<p class="text-sm font-medium text-gray-500">Sync Activity</p>
|
||||||
|
<p class="mt-2 text-3xl font-semibold text-gray-900">
|
||||||
|
{{ dashboard?.syncCount ?? 0 }}
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 text-xs text-gray-500">total sync operations</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-xl bg-white p-6 ring-1 ring-gray-200">
|
||||||
|
<p class="text-sm font-medium text-gray-500">Uptime</p>
|
||||||
|
<p class="mt-2 text-3xl font-semibold text-gray-900">
|
||||||
|
{{ dashboard?.uptimeSeconds ? formatUptime(dashboard.uptimeSeconds) : "—" }}
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 text-xs text-gray-500">server running time</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Device status list -->
|
||||||
|
<div class="mt-8">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900">Device Status</h3>
|
||||||
|
<router-link to="/devices" class="text-sm font-medium text-blue-600 hover:text-blue-800">
|
||||||
|
View all →
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="devices.length === 0"
|
||||||
|
class="mt-4 rounded-xl bg-white p-6 text-center text-sm text-gray-500 ring-1 ring-gray-200"
|
||||||
|
>
|
||||||
|
No devices registered yet
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="mt-4 overflow-hidden rounded-xl bg-white ring-1 ring-gray-200">
|
||||||
|
<table class="w-full text-left text-sm">
|
||||||
|
<thead class="border-b border-gray-200 bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-3 font-medium text-gray-600">Device</th>
|
||||||
|
<th class="px-4 py-3 font-medium text-gray-600">Platform</th>
|
||||||
|
<th class="px-4 py-3 font-medium text-gray-600">Status</th>
|
||||||
|
<th class="px-4 py-3 font-medium text-gray-600">Registered</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-100">
|
||||||
|
<tr v-for="device in devices.slice(0, 10)" :key="device.deviceId" class="hover:bg-gray-50">
|
||||||
|
<td class="px-4 py-3 font-medium text-gray-900">{{ device.name }}</td>
|
||||||
|
<td class="px-4 py-3 text-gray-600 capitalize">{{ platformIcon(device.platform) }}</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-xs font-medium"
|
||||||
|
:class="device.online
|
||||||
|
? 'bg-green-50 text-green-700'
|
||||||
|
: 'bg-gray-100 text-gray-500'"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="h-1.5 w-1.5 rounded-full"
|
||||||
|
:class="device.online ? 'bg-green-500' : 'bg-gray-400'"
|
||||||
|
/>
|
||||||
|
{{ device.online ? "Online" : "Offline" }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-gray-500">
|
||||||
|
{{ new Date(device.createdAt).toLocaleDateString() }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick actions -->
|
||||||
|
<div class="mt-8 grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||||
|
<router-link
|
||||||
|
to="/cookies"
|
||||||
|
class="flex items-center gap-3 rounded-xl bg-white p-5 ring-1 ring-gray-200 hover:ring-blue-300 transition-shadow"
|
||||||
|
>
|
||||||
|
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-blue-50 text-lg">
|
||||||
|
🍪
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-900">View Cookies</p>
|
||||||
|
<p class="text-xs text-gray-500">Manage synced cookies</p>
|
||||||
|
</div>
|
||||||
|
</router-link>
|
||||||
|
|
||||||
|
<router-link
|
||||||
|
to="/devices"
|
||||||
|
class="flex items-center gap-3 rounded-xl bg-white p-5 ring-1 ring-gray-200 hover:ring-blue-300 transition-shadow"
|
||||||
|
>
|
||||||
|
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-green-50 text-lg">
|
||||||
|
📱
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-900">Manage Devices</p>
|
||||||
|
<p class="text-xs text-gray-500">View and revoke devices</p>
|
||||||
|
</div>
|
||||||
|
</router-link>
|
||||||
|
|
||||||
|
<router-link
|
||||||
|
to="/settings"
|
||||||
|
class="flex items-center gap-3 rounded-xl bg-white p-5 ring-1 ring-gray-200 hover:ring-blue-300 transition-shadow"
|
||||||
|
>
|
||||||
|
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-gray-100 text-lg">
|
||||||
|
⚙️
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-900">Settings</p>
|
||||||
|
<p class="text-xs text-gray-500">Configure sync and security</p>
|
||||||
|
</div>
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
220
web/src/views/DevicesView.vue
Normal file
220
web/src/views/DevicesView.vue
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref, computed } from "vue";
|
||||||
|
import api from "@/api/client";
|
||||||
|
|
||||||
|
interface DeviceEntry {
|
||||||
|
deviceId: string;
|
||||||
|
name: string;
|
||||||
|
platform: string;
|
||||||
|
createdAt: string;
|
||||||
|
online: boolean;
|
||||||
|
lastSeen?: string;
|
||||||
|
ipAddress?: string | null;
|
||||||
|
extensionVersion?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const devices = ref<DeviceEntry[]>([]);
|
||||||
|
const loading = ref(true);
|
||||||
|
const error = ref<string | null>(null);
|
||||||
|
const filter = ref<"all" | "online" | "offline">("all");
|
||||||
|
const expandedId = ref<string | null>(null);
|
||||||
|
const revoking = ref<string | null>(null);
|
||||||
|
const confirmRevoke = ref<string | null>(null);
|
||||||
|
|
||||||
|
const filtered = computed(() => {
|
||||||
|
if (filter.value === "online") return devices.value.filter((d) => d.online);
|
||||||
|
if (filter.value === "offline") return devices.value.filter((d) => !d.online);
|
||||||
|
return devices.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
function platformLabel(platform: string): string {
|
||||||
|
const p = platform.toLowerCase();
|
||||||
|
if (p.includes("chrome")) return "Chrome";
|
||||||
|
if (p.includes("firefox")) return "Firefox";
|
||||||
|
if (p.includes("edge")) return "Edge";
|
||||||
|
if (p.includes("safari")) return "Safari";
|
||||||
|
return platform;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleExpand(id: string) {
|
||||||
|
expandedId.value = expandedId.value === id ? null : id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRevoke(deviceId: string) {
|
||||||
|
revoking.value = deviceId;
|
||||||
|
try {
|
||||||
|
await api.post(`/devices/${deviceId}/revoke`);
|
||||||
|
devices.value = devices.value.filter((d) => d.deviceId !== deviceId);
|
||||||
|
} catch {
|
||||||
|
error.value = "Failed to revoke device";
|
||||||
|
} finally {
|
||||||
|
revoking.value = null;
|
||||||
|
confirmRevoke.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const { data } = await api.get("/devices");
|
||||||
|
devices.value = data.devices ?? [];
|
||||||
|
} catch {
|
||||||
|
error.value = "Failed to load devices";
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="p-8">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-2xl font-semibold text-gray-900">Devices</h2>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">
|
||||||
|
{{ devices.length }} registered devices
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<!-- Status filter -->
|
||||||
|
<div class="flex rounded-lg border border-gray-200 bg-white p-0.5">
|
||||||
|
<button
|
||||||
|
v-for="f in (['all', 'online', 'offline'] as const)"
|
||||||
|
:key="f"
|
||||||
|
class="rounded-md px-3 py-1.5 text-xs font-medium capitalize"
|
||||||
|
:class="filter === f ? 'bg-gray-100 text-gray-900' : 'text-gray-500 hover:text-gray-700'"
|
||||||
|
@click="filter = f"
|
||||||
|
>
|
||||||
|
{{ f }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="mt-6 text-sm text-gray-500">Loading...</div>
|
||||||
|
|
||||||
|
<div v-else-if="error" role="alert" class="mt-6 rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-700">
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="filtered.length === 0" class="mt-6 text-sm text-gray-500">
|
||||||
|
No devices {{ filter !== "all" ? `(${filter})` : "" }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="mt-6 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<div
|
||||||
|
v-for="device in filtered"
|
||||||
|
:key="device.deviceId"
|
||||||
|
class="device-card rounded-xl bg-white ring-1 ring-gray-200 transition-shadow hover:shadow-sm"
|
||||||
|
>
|
||||||
|
<!-- Card header -->
|
||||||
|
<div class="p-5">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-gray-100 text-sm font-bold text-gray-600">
|
||||||
|
{{ platformLabel(device.platform).slice(0, 2).toUpperCase() }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="font-medium text-gray-900">{{ device.name }}</h3>
|
||||||
|
<p class="text-xs text-gray-500">{{ platformLabel(device.platform) }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium"
|
||||||
|
:class="device.online
|
||||||
|
? 'bg-green-50 text-green-700'
|
||||||
|
: 'bg-gray-100 text-gray-500'"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="h-1.5 w-1.5 rounded-full"
|
||||||
|
:class="device.online ? 'bg-green-500' : 'bg-gray-400'"
|
||||||
|
/>
|
||||||
|
{{ device.online ? "Online" : "Offline" }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl class="mt-4 space-y-1.5 text-sm">
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<dt class="text-gray-500">Registered</dt>
|
||||||
|
<dd class="text-gray-900">{{ new Date(device.createdAt).toLocaleDateString() }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<dt class="text-gray-500">Last Seen</dt>
|
||||||
|
<dd class="text-gray-900">
|
||||||
|
{{ device.lastSeen ? new Date(device.lastSeen).toLocaleString() : "—" }}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<dt class="text-gray-500">Device ID</dt>
|
||||||
|
<dd class="font-mono text-xs text-gray-600">{{ device.deviceId.slice(0, 16) }}...</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<!-- Expand toggle -->
|
||||||
|
<button
|
||||||
|
class="mt-3 text-xs font-medium text-blue-600 hover:text-blue-800"
|
||||||
|
@click="toggleExpand(device.deviceId)"
|
||||||
|
>
|
||||||
|
{{ expandedId === device.deviceId ? "Hide details" : "Show details" }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Expanded details -->
|
||||||
|
<div v-if="expandedId === device.deviceId" class="mt-3 rounded-lg bg-gray-50 p-3 text-xs">
|
||||||
|
<dl class="space-y-1">
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<dt class="text-gray-500">Full Device ID</dt>
|
||||||
|
<dd class="font-mono text-gray-700 break-all max-w-[200px] text-right">
|
||||||
|
{{ device.deviceId }}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<dt class="text-gray-500">Platform</dt>
|
||||||
|
<dd class="text-gray-700">{{ device.platform }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<dt class="text-gray-500">Registered</dt>
|
||||||
|
<dd class="text-gray-700">{{ new Date(device.createdAt).toLocaleString() }}</dd>
|
||||||
|
</div>
|
||||||
|
<div v-if="device.extensionVersion" class="flex justify-between">
|
||||||
|
<dt class="text-gray-500">Extension Version</dt>
|
||||||
|
<dd class="text-gray-700">{{ device.extensionVersion }}</dd>
|
||||||
|
</div>
|
||||||
|
<div v-if="device.ipAddress" class="flex justify-between">
|
||||||
|
<dt class="text-gray-500">IP Address</dt>
|
||||||
|
<dd class="font-mono text-gray-700">{{ device.ipAddress }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Revoke action -->
|
||||||
|
<div class="border-t border-gray-100 px-5 py-3">
|
||||||
|
<template v-if="confirmRevoke === device.deviceId">
|
||||||
|
<p class="mb-2 text-xs text-red-600">
|
||||||
|
This will disconnect the device and revoke its token. Continue?
|
||||||
|
</p>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
class="flex-1 rounded-lg bg-red-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-red-700 disabled:opacity-50"
|
||||||
|
:disabled="revoking === device.deviceId"
|
||||||
|
@click="handleRevoke(device.deviceId)"
|
||||||
|
>
|
||||||
|
{{ revoking === device.deviceId ? "Revoking..." : "Confirm" }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="flex-1 rounded-lg border border-gray-200 px-3 py-1.5 text-xs font-medium text-gray-600 hover:bg-gray-50"
|
||||||
|
@click="confirmRevoke = null"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<button
|
||||||
|
v-else
|
||||||
|
class="w-full rounded-lg border border-red-200 px-3 py-1.5 text-xs font-medium text-red-600 hover:bg-red-50"
|
||||||
|
@click="confirmRevoke = device.deviceId"
|
||||||
|
>
|
||||||
|
Sign Out Device
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
75
web/src/views/LoginView.vue
Normal file
75
web/src/views/LoginView.vue
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from "vue";
|
||||||
|
import { useRouter } from "vue-router";
|
||||||
|
import { useAuthStore } from "@/stores/auth";
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const auth = useAuthStore();
|
||||||
|
|
||||||
|
const username = ref("");
|
||||||
|
const password = ref("");
|
||||||
|
const error = ref("");
|
||||||
|
const loading = ref(false);
|
||||||
|
|
||||||
|
async function handleLogin() {
|
||||||
|
error.value = "";
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
await auth.login(username.value, password.value);
|
||||||
|
router.push({ name: "dashboard" });
|
||||||
|
} catch {
|
||||||
|
error.value = "Invalid credentials";
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex min-h-screen items-center justify-center bg-gray-50">
|
||||||
|
<div class="w-full max-w-sm rounded-xl bg-white p-8 shadow-sm ring-1 ring-gray-200">
|
||||||
|
<h1 class="mb-1 text-xl font-semibold text-gray-900">CookieBridge</h1>
|
||||||
|
<p class="mb-6 text-sm text-gray-500">Sign in to the admin panel</p>
|
||||||
|
|
||||||
|
<form @submit.prevent="handleLogin" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700" for="username">
|
||||||
|
Username
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="username"
|
||||||
|
v-model="username"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
autocomplete="username"
|
||||||
|
class="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700" for="password">
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
v-model="password"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
autocomplete="current-password"
|
||||||
|
class="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="error" class="text-sm text-red-600">{{ error }}</p>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
:disabled="loading || !username || !password"
|
||||||
|
class="w-full rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{{ loading ? "Signing in..." : "Sign In" }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
316
web/src/views/SettingsView.vue
Normal file
316
web/src/views/SettingsView.vue
Normal file
@@ -0,0 +1,316 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref } from "vue";
|
||||||
|
import { TabGroup, TabList, Tab, TabPanels, TabPanel } from "@headlessui/vue";
|
||||||
|
import { useSettingsStore } from "@/stores/settings";
|
||||||
|
import api from "@/api/client";
|
||||||
|
|
||||||
|
const store = useSettingsStore();
|
||||||
|
const saving = ref(false);
|
||||||
|
const saved = ref(false);
|
||||||
|
const saveError = ref("");
|
||||||
|
const loadError = ref("");
|
||||||
|
const passwordError = ref("");
|
||||||
|
|
||||||
|
// Password change
|
||||||
|
const currentPassword = ref("");
|
||||||
|
const newPassword = ref("");
|
||||||
|
const confirmNewPassword = ref("");
|
||||||
|
|
||||||
|
const syncFrequencyOptions = [
|
||||||
|
{ label: "Real-time", value: 0 },
|
||||||
|
{ label: "Every minute", value: 60_000 },
|
||||||
|
{ label: "Every 5 minutes", value: 300_000 },
|
||||||
|
{ label: "Manual only", value: -1 },
|
||||||
|
];
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
await store.fetchSettings();
|
||||||
|
} catch {
|
||||||
|
loadError.value = "Failed to load settings";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
saving.value = true;
|
||||||
|
saved.value = false;
|
||||||
|
saveError.value = "";
|
||||||
|
try {
|
||||||
|
await store.updateSettings(store.settings);
|
||||||
|
saved.value = true;
|
||||||
|
setTimeout(() => (saved.value = false), 2000);
|
||||||
|
} catch {
|
||||||
|
saveError.value = "Failed to save settings";
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function changePassword() {
|
||||||
|
passwordError.value = "";
|
||||||
|
if (newPassword.value !== confirmNewPassword.value) {
|
||||||
|
passwordError.value = "New passwords do not match";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (newPassword.value.length < 8) {
|
||||||
|
passwordError.value = "Password must be at least 8 characters";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await api.post("/auth/change-password", {
|
||||||
|
currentPassword: currentPassword.value,
|
||||||
|
newPassword: newPassword.value,
|
||||||
|
});
|
||||||
|
currentPassword.value = "";
|
||||||
|
newPassword.value = "";
|
||||||
|
confirmNewPassword.value = "";
|
||||||
|
passwordError.value = "";
|
||||||
|
saved.value = true;
|
||||||
|
setTimeout(() => (saved.value = false), 2000);
|
||||||
|
} catch {
|
||||||
|
passwordError.value = "Current password is incorrect";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="p-8">
|
||||||
|
<h2 class="text-2xl font-semibold text-gray-900">Settings</h2>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">Configure sync, security, and appearance</p>
|
||||||
|
|
||||||
|
<!-- Success toast -->
|
||||||
|
<div
|
||||||
|
v-if="saved"
|
||||||
|
class="fixed right-8 top-8 z-50 rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white shadow-lg"
|
||||||
|
>
|
||||||
|
Settings saved
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error toast -->
|
||||||
|
<div
|
||||||
|
v-if="saveError"
|
||||||
|
class="fixed right-8 top-8 z-50 rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white shadow-lg"
|
||||||
|
>
|
||||||
|
{{ saveError }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Load error -->
|
||||||
|
<div v-if="loadError" role="alert" class="mt-6 rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-700">
|
||||||
|
{{ loadError }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 max-w-2xl">
|
||||||
|
<TabGroup>
|
||||||
|
<TabList class="flex gap-1 rounded-xl bg-gray-100 p-1">
|
||||||
|
<Tab
|
||||||
|
v-for="tab in ['Sync', 'Security', 'Appearance']"
|
||||||
|
:key="tab"
|
||||||
|
v-slot="{ selected }"
|
||||||
|
as="template"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="w-full rounded-lg px-4 py-2 text-sm font-medium transition-colors"
|
||||||
|
:class="selected ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500 hover:text-gray-700'"
|
||||||
|
>
|
||||||
|
{{ tab }}
|
||||||
|
</button>
|
||||||
|
</Tab>
|
||||||
|
</TabList>
|
||||||
|
|
||||||
|
<TabPanels class="mt-4">
|
||||||
|
<!-- Sync Settings -->
|
||||||
|
<TabPanel class="space-y-6">
|
||||||
|
<section class="rounded-xl bg-white p-6 ring-1 ring-gray-200">
|
||||||
|
<h3 class="font-medium text-gray-900">Sync Configuration</h3>
|
||||||
|
<div class="mt-4 space-y-5">
|
||||||
|
<!-- Auto-sync toggle -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<label id="auto-sync-label" class="text-sm font-medium text-gray-700">Auto-sync</label>
|
||||||
|
<p class="text-xs text-gray-500">Automatically sync cookies between devices</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
role="switch"
|
||||||
|
:aria-checked="store.settings.autoSync"
|
||||||
|
aria-labelledby="auto-sync-label"
|
||||||
|
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||||
|
:class="store.settings.autoSync ? 'bg-blue-600' : 'bg-gray-200'"
|
||||||
|
@click="store.settings.autoSync = !store.settings.autoSync"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="inline-block h-4 w-4 rounded-full bg-white transition-transform"
|
||||||
|
:class="store.settings.autoSync ? 'translate-x-6' : 'translate-x-1'"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sync frequency -->
|
||||||
|
<div>
|
||||||
|
<label for="sync-frequency" class="block text-sm font-medium text-gray-700">Sync Frequency</label>
|
||||||
|
<select
|
||||||
|
id="sync-frequency"
|
||||||
|
v-model="store.settings.syncIntervalMs"
|
||||||
|
class="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
v-for="opt in syncFrequencyOptions"
|
||||||
|
:key="opt.value"
|
||||||
|
:value="opt.value"
|
||||||
|
>
|
||||||
|
{{ opt.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<button
|
||||||
|
:disabled="saving"
|
||||||
|
class="w-full rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
|
||||||
|
@click="save"
|
||||||
|
>
|
||||||
|
{{ saving ? "Saving..." : "Save Sync Settings" }}
|
||||||
|
</button>
|
||||||
|
</TabPanel>
|
||||||
|
|
||||||
|
<!-- Security Settings -->
|
||||||
|
<TabPanel class="space-y-6">
|
||||||
|
<!-- Change password -->
|
||||||
|
<section class="rounded-xl bg-white p-6 ring-1 ring-gray-200">
|
||||||
|
<h3 class="font-medium text-gray-900">Change Password</h3>
|
||||||
|
<form class="mt-4 space-y-4" @submit.prevent="changePassword">
|
||||||
|
<div>
|
||||||
|
<label for="current-password" class="block text-sm text-gray-700">Current Password</label>
|
||||||
|
<input
|
||||||
|
id="current-password"
|
||||||
|
v-model="currentPassword"
|
||||||
|
type="password"
|
||||||
|
autocomplete="current-password"
|
||||||
|
class="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="new-password" class="block text-sm text-gray-700">New Password</label>
|
||||||
|
<input
|
||||||
|
id="new-password"
|
||||||
|
v-model="newPassword"
|
||||||
|
type="password"
|
||||||
|
autocomplete="new-password"
|
||||||
|
minlength="8"
|
||||||
|
class="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="confirm-password" class="block text-sm text-gray-700">Confirm Password</label>
|
||||||
|
<input
|
||||||
|
id="confirm-password"
|
||||||
|
v-model="confirmNewPassword"
|
||||||
|
type="password"
|
||||||
|
autocomplete="new-password"
|
||||||
|
class="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p v-if="passwordError" class="text-sm text-red-600">{{ passwordError }}</p>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="w-full rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
Change Password
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Other security settings -->
|
||||||
|
<section class="rounded-xl bg-white p-6 ring-1 ring-gray-200">
|
||||||
|
<h3 class="font-medium text-gray-900">Device Security</h3>
|
||||||
|
<div class="mt-4 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label for="session-timeout" class="block text-sm text-gray-700">Session Timeout (minutes)</label>
|
||||||
|
<input
|
||||||
|
id="session-timeout"
|
||||||
|
v-model.number="store.settings.sessionTimeoutMinutes"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="1440"
|
||||||
|
class="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
<p class="mt-1 text-xs text-gray-500">Auto-logout after inactivity</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="max-devices" class="block text-sm text-gray-700">Max Devices</label>
|
||||||
|
<input
|
||||||
|
id="max-devices"
|
||||||
|
v-model.number="store.settings.maxDevices"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="50"
|
||||||
|
class="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
<p class="mt-1 text-xs text-gray-500">Maximum number of devices that can register</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<button
|
||||||
|
:disabled="saving"
|
||||||
|
class="w-full rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
|
||||||
|
@click="save"
|
||||||
|
>
|
||||||
|
{{ saving ? "Saving..." : "Save Security Settings" }}
|
||||||
|
</button>
|
||||||
|
</TabPanel>
|
||||||
|
|
||||||
|
<!-- Appearance Settings -->
|
||||||
|
<TabPanel class="space-y-6">
|
||||||
|
<section class="rounded-xl bg-white p-6 ring-1 ring-gray-200">
|
||||||
|
<h3 class="font-medium text-gray-900">Appearance</h3>
|
||||||
|
<div class="mt-4 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700">Theme</label>
|
||||||
|
<div class="mt-2 grid grid-cols-3 gap-3">
|
||||||
|
<button
|
||||||
|
v-for="t in (['light', 'dark', 'system'] as const)"
|
||||||
|
:key="t"
|
||||||
|
class="rounded-lg border-2 px-4 py-3 text-center text-sm font-medium capitalize transition-colors"
|
||||||
|
:class="store.settings.theme === t
|
||||||
|
? 'border-blue-600 bg-blue-50 text-blue-700'
|
||||||
|
: 'border-gray-200 text-gray-600 hover:border-gray-300'"
|
||||||
|
@click="store.settings.theme = t"
|
||||||
|
>
|
||||||
|
{{ t }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700">Language</label>
|
||||||
|
<div class="mt-2 grid grid-cols-2 gap-3">
|
||||||
|
<button
|
||||||
|
v-for="lang in [{ value: 'en', label: 'English' }, { value: 'zh', label: '中文' }]"
|
||||||
|
:key="lang.value"
|
||||||
|
class="rounded-lg border-2 px-4 py-3 text-center text-sm font-medium transition-colors"
|
||||||
|
:class="store.settings.language === lang.value
|
||||||
|
? 'border-blue-600 bg-blue-50 text-blue-700'
|
||||||
|
: 'border-gray-200 text-gray-600 hover:border-gray-300'"
|
||||||
|
@click="store.settings.language = lang.value"
|
||||||
|
>
|
||||||
|
{{ lang.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<button
|
||||||
|
:disabled="saving"
|
||||||
|
class="w-full rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
|
||||||
|
@click="save"
|
||||||
|
>
|
||||||
|
{{ saving ? "Saving..." : "Save Appearance" }}
|
||||||
|
</button>
|
||||||
|
</TabPanel>
|
||||||
|
</TabPanels>
|
||||||
|
</TabGroup>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
268
web/src/views/SetupView.vue
Normal file
268
web/src/views/SetupView.vue
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from "vue";
|
||||||
|
import { useRouter } from "vue-router";
|
||||||
|
import api from "@/api/client";
|
||||||
|
import { markSetupComplete } from "@/router";
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const step = ref(1);
|
||||||
|
const totalSteps = 4;
|
||||||
|
|
||||||
|
// Step 2: Admin account
|
||||||
|
const username = ref("");
|
||||||
|
const password = ref("");
|
||||||
|
const confirmPassword = ref("");
|
||||||
|
|
||||||
|
// Step 3: Basic config
|
||||||
|
const listenPort = ref(8100);
|
||||||
|
const enableHttps = ref(false);
|
||||||
|
|
||||||
|
const error = ref("");
|
||||||
|
const loading = ref(false);
|
||||||
|
|
||||||
|
const passwordMismatch = computed(
|
||||||
|
() => confirmPassword.value.length > 0 && password.value !== confirmPassword.value,
|
||||||
|
);
|
||||||
|
|
||||||
|
const canProceedStep2 = computed(
|
||||||
|
() =>
|
||||||
|
username.value.length >= 3 &&
|
||||||
|
password.value.length >= 8 &&
|
||||||
|
password.value === confirmPassword.value,
|
||||||
|
);
|
||||||
|
|
||||||
|
function nextStep() {
|
||||||
|
error.value = "";
|
||||||
|
if (step.value === 2 && passwordMismatch.value) {
|
||||||
|
error.value = "Passwords do not match";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
step.value = Math.min(step.value + 1, totalSteps);
|
||||||
|
}
|
||||||
|
|
||||||
|
function prevStep() {
|
||||||
|
error.value = "";
|
||||||
|
step.value = Math.max(step.value - 1, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function completeSetup() {
|
||||||
|
error.value = "";
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
await api.post("/setup/init", {
|
||||||
|
username: username.value,
|
||||||
|
password: password.value,
|
||||||
|
});
|
||||||
|
markSetupComplete();
|
||||||
|
step.value = totalSteps;
|
||||||
|
} catch {
|
||||||
|
error.value = "Setup failed. Please try again.";
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToLogin() {
|
||||||
|
router.push("/login");
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex min-h-screen items-center justify-center bg-gray-50">
|
||||||
|
<div class="w-full max-w-lg rounded-xl bg-white p-8 shadow-sm ring-1 ring-gray-200">
|
||||||
|
<!-- Progress bar -->
|
||||||
|
<div class="mb-6 flex gap-2">
|
||||||
|
<div
|
||||||
|
v-for="i in totalSteps"
|
||||||
|
:key="i"
|
||||||
|
class="h-1.5 flex-1 rounded-full"
|
||||||
|
:class="i <= step ? 'bg-blue-600' : 'bg-gray-200'"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 1: Welcome -->
|
||||||
|
<div v-if="step === 1">
|
||||||
|
<h1 class="text-2xl font-semibold text-gray-900">Welcome to CookieBridge</h1>
|
||||||
|
<p class="mt-3 text-sm leading-relaxed text-gray-600">
|
||||||
|
Synchronize your browser cookies across devices with end-to-end encryption.
|
||||||
|
Login once on any device, and stay logged in everywhere.
|
||||||
|
</p>
|
||||||
|
<ul class="mt-4 space-y-2 text-sm text-gray-600">
|
||||||
|
<li class="flex gap-2">
|
||||||
|
<span class="text-green-500">✓</span>
|
||||||
|
End-to-end encrypted — the server never sees your data
|
||||||
|
</li>
|
||||||
|
<li class="flex gap-2">
|
||||||
|
<span class="text-green-500">✓</span>
|
||||||
|
Multi-browser support (Chrome, Firefox, Edge, Safari)
|
||||||
|
</li>
|
||||||
|
<li class="flex gap-2">
|
||||||
|
<span class="text-green-500">✓</span>
|
||||||
|
AI agent integration via Agent Skill API
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<button
|
||||||
|
class="mt-6 w-full rounded-lg bg-blue-600 px-4 py-2.5 text-sm font-medium text-white hover:bg-blue-700"
|
||||||
|
@click="nextStep"
|
||||||
|
>
|
||||||
|
Continue
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 2: Admin account -->
|
||||||
|
<div v-if="step === 2">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900">Create Admin Account</h2>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">Set up your administrator credentials</p>
|
||||||
|
|
||||||
|
<form class="mt-5 space-y-4" @submit.prevent="nextStep">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700" for="setup-username">
|
||||||
|
Username
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="setup-username"
|
||||||
|
v-model="username"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
minlength="3"
|
||||||
|
autocomplete="username"
|
||||||
|
class="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||||
|
placeholder="admin"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700" for="setup-password">
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="setup-password"
|
||||||
|
v-model="password"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
minlength="8"
|
||||||
|
autocomplete="new-password"
|
||||||
|
aria-label="Password"
|
||||||
|
class="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||||
|
placeholder="At least 8 characters"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700" for="setup-confirm">
|
||||||
|
Confirm Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="setup-confirm"
|
||||||
|
v-model="confirmPassword"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
autocomplete="new-password"
|
||||||
|
aria-label="Confirm Password"
|
||||||
|
class="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||||
|
:class="passwordMismatch ? 'border-red-300 focus:border-red-500 focus:ring-red-500' : ''"
|
||||||
|
/>
|
||||||
|
<p v-if="passwordMismatch" class="mt-1 text-xs text-red-600">
|
||||||
|
Passwords do not match
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="error" class="text-sm text-red-600">{{ error }}</p>
|
||||||
|
|
||||||
|
<div class="flex gap-3 pt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex-1 rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
|
||||||
|
@click="prevStep"
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
:disabled="!canProceedStep2"
|
||||||
|
class="flex-1 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 3: Basic config -->
|
||||||
|
<div v-if="step === 3">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900">Basic Configuration</h2>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">Configure your relay server</p>
|
||||||
|
|
||||||
|
<div class="mt-5 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700" for="setup-port">
|
||||||
|
Listen Port
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="setup-port"
|
||||||
|
v-model.number="listenPort"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="65535"
|
||||||
|
class="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
<p class="mt-1 text-xs text-gray-500">Default: 8100</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between rounded-lg border border-gray-200 px-4 py-3">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-700">Enable HTTPS</p>
|
||||||
|
<p class="text-xs text-gray-500">Recommended for production use</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||||
|
:class="enableHttps ? 'bg-blue-600' : 'bg-gray-200'"
|
||||||
|
@click="enableHttps = !enableHttps"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="inline-block h-4 w-4 rounded-full bg-white transition-transform"
|
||||||
|
:class="enableHttps ? 'translate-x-6' : 'translate-x-1'"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="error" class="text-sm text-red-600">{{ error }}</p>
|
||||||
|
|
||||||
|
<div class="flex gap-3 pt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex-1 rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
|
||||||
|
@click="prevStep"
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
:disabled="loading"
|
||||||
|
class="flex-1 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
|
||||||
|
@click="completeSetup"
|
||||||
|
>
|
||||||
|
{{ loading ? "Setting up..." : "Next" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 4: Done -->
|
||||||
|
<div v-if="step === 4" class="text-center">
|
||||||
|
<div class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-green-100">
|
||||||
|
<span class="text-2xl text-green-600">✓</span>
|
||||||
|
</div>
|
||||||
|
<h2 class="mt-4 text-xl font-semibold text-gray-900">Setup Complete!</h2>
|
||||||
|
<p class="mt-2 text-sm text-gray-500">
|
||||||
|
Your CookieBridge server is ready. Sign in with your admin credentials.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
class="mt-6 w-full rounded-lg bg-blue-600 px-4 py-2.5 text-sm font-medium text-white hover:bg-blue-700"
|
||||||
|
@click="goToLogin"
|
||||||
|
>
|
||||||
|
Go to Login
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
273
web/tests/api/admin-api.spec.ts
Normal file
273
web/tests/api/admin-api.spec.ts
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin REST API integration tests (RCA-13)
|
||||||
|
*
|
||||||
|
* These tests call the relay server's /admin/* endpoints directly
|
||||||
|
* via Playwright's APIRequestContext, without a browser.
|
||||||
|
*
|
||||||
|
* Run with: npx playwright test tests/api/ --project=chromium
|
||||||
|
*
|
||||||
|
* Requires:
|
||||||
|
* - Relay server running at BASE_URL (default http://localhost:8100)
|
||||||
|
* - TEST_ADMIN_USER and TEST_ADMIN_PASS env vars (or defaults admin/testpassword123)
|
||||||
|
*
|
||||||
|
* NOTE: These tests assume a clean server state. Run against a dedicated
|
||||||
|
* test instance, not production.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const API_BASE = process.env.RELAY_BASE_URL ?? "http://localhost:8100";
|
||||||
|
const ADMIN_USER = process.env.TEST_ADMIN_USER ?? "admin";
|
||||||
|
const ADMIN_PASS = process.env.TEST_ADMIN_PASS ?? "testpassword123";
|
||||||
|
|
||||||
|
let adminToken = "";
|
||||||
|
|
||||||
|
test.describe("Admin Auth API", () => {
|
||||||
|
test("POST /admin/auth/login — valid credentials returns JWT", async ({ request }) => {
|
||||||
|
const res = await request.post(`${API_BASE}/admin/auth/login`, {
|
||||||
|
data: { username: ADMIN_USER, password: ADMIN_PASS },
|
||||||
|
});
|
||||||
|
expect(res.status()).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body).toHaveProperty("token");
|
||||||
|
expect(typeof body.token).toBe("string");
|
||||||
|
expect(body.token.length).toBeGreaterThan(10);
|
||||||
|
expect(body).toHaveProperty("expiresAt");
|
||||||
|
adminToken = body.token;
|
||||||
|
});
|
||||||
|
|
||||||
|
test("POST /admin/auth/login — wrong password returns 401", async ({ request }) => {
|
||||||
|
const res = await request.post(`${API_BASE}/admin/auth/login`, {
|
||||||
|
data: { username: ADMIN_USER, password: "wrongpassword" },
|
||||||
|
});
|
||||||
|
expect(res.status()).toBe(401);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body).toHaveProperty("error");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("POST /admin/auth/login — missing fields returns 400", async ({ request }) => {
|
||||||
|
const res = await request.post(`${API_BASE}/admin/auth/login`, {
|
||||||
|
data: { username: ADMIN_USER },
|
||||||
|
});
|
||||||
|
expect(res.status()).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("GET /admin/auth/me — valid token returns user info", async ({ request }) => {
|
||||||
|
// Ensure we have a token
|
||||||
|
if (!adminToken) {
|
||||||
|
const login = await request.post(`${API_BASE}/admin/auth/login`, {
|
||||||
|
data: { username: ADMIN_USER, password: ADMIN_PASS },
|
||||||
|
});
|
||||||
|
adminToken = (await login.json()).token;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await request.get(`${API_BASE}/admin/auth/me`, {
|
||||||
|
headers: { Authorization: `Bearer ${adminToken}` },
|
||||||
|
});
|
||||||
|
expect(res.status()).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body).toHaveProperty("username", ADMIN_USER);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("GET /admin/auth/me — no token returns 401", async ({ request }) => {
|
||||||
|
const res = await request.get(`${API_BASE}/admin/auth/me`);
|
||||||
|
expect(res.status()).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("POST /admin/auth/logout — clears session", async ({ request }) => {
|
||||||
|
const login = await request.post(`${API_BASE}/admin/auth/login`, {
|
||||||
|
data: { username: ADMIN_USER, password: ADMIN_PASS },
|
||||||
|
});
|
||||||
|
const token = (await login.json()).token;
|
||||||
|
|
||||||
|
const res = await request.post(`${API_BASE}/admin/auth/logout`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
expect([200, 204]).toContain(res.status());
|
||||||
|
|
||||||
|
// Token should now be invalid
|
||||||
|
const me = await request.get(`${API_BASE}/admin/auth/me`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
expect(me.status()).toBe(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("Setup API", () => {
|
||||||
|
test("GET /admin/setup/status returns initialised flag", async ({ request }) => {
|
||||||
|
const res = await request.get(`${API_BASE}/admin/setup/status`);
|
||||||
|
expect(res.status()).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body).toHaveProperty("initialised");
|
||||||
|
expect(typeof body.initialised).toBe("boolean");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("Dashboard API", () => {
|
||||||
|
test.beforeAll(async ({ request }) => {
|
||||||
|
const login = await request.post(`${API_BASE}/admin/auth/login`, {
|
||||||
|
data: { username: ADMIN_USER, password: ADMIN_PASS },
|
||||||
|
});
|
||||||
|
adminToken = (await login.json()).token;
|
||||||
|
});
|
||||||
|
|
||||||
|
test("GET /admin/dashboard — returns stats shape", async ({ request }) => {
|
||||||
|
const res = await request.get(`${API_BASE}/admin/dashboard`, {
|
||||||
|
headers: { Authorization: `Bearer ${adminToken}` },
|
||||||
|
});
|
||||||
|
expect(res.status()).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body).toHaveProperty("devices");
|
||||||
|
expect(body).toHaveProperty("cookies");
|
||||||
|
expect(body).toHaveProperty("syncCount");
|
||||||
|
expect(body).toHaveProperty("uptimeSeconds");
|
||||||
|
expect(typeof body.syncCount).toBe("number");
|
||||||
|
expect(typeof body.uptimeSeconds).toBe("number");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("GET /admin/dashboard — unauthenticated returns 401", async ({ request }) => {
|
||||||
|
const res = await request.get(`${API_BASE}/admin/dashboard`);
|
||||||
|
expect(res.status()).toBe(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("Cookies API", () => {
|
||||||
|
test.beforeAll(async ({ request }) => {
|
||||||
|
const login = await request.post(`${API_BASE}/admin/auth/login`, {
|
||||||
|
data: { username: ADMIN_USER, password: ADMIN_PASS },
|
||||||
|
});
|
||||||
|
adminToken = (await login.json()).token;
|
||||||
|
});
|
||||||
|
|
||||||
|
test("GET /admin/cookies — returns list with pagination fields", async ({ request }) => {
|
||||||
|
const res = await request.get(`${API_BASE}/admin/cookies`, {
|
||||||
|
headers: { Authorization: `Bearer ${adminToken}` },
|
||||||
|
});
|
||||||
|
expect(res.status()).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body).toHaveProperty("cookies");
|
||||||
|
expect(Array.isArray(body.cookies)).toBe(true);
|
||||||
|
expect(body).toHaveProperty("total");
|
||||||
|
expect(typeof body.total).toBe("number");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("GET /admin/cookies?domain=xxx — filters by domain", async ({ request }) => {
|
||||||
|
const res = await request.get(`${API_BASE}/admin/cookies?domain=nonexistent.example`, {
|
||||||
|
headers: { Authorization: `Bearer ${adminToken}` },
|
||||||
|
});
|
||||||
|
expect(res.status()).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
// All returned cookies should match the domain filter
|
||||||
|
for (const cookie of body.cookies) {
|
||||||
|
expect(cookie.domain).toBe("nonexistent.example");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("DELETE /admin/cookies/:id — removes specific cookie", async ({ request }) => {
|
||||||
|
// First: push a cookie via the device API so we have something to delete
|
||||||
|
// (Depends on RCA-13 admin API — if there's a test cookie fixture, use that)
|
||||||
|
// This test is a placeholder that verifies the endpoint contract:
|
||||||
|
const res = await request.delete(`${API_BASE}/admin/cookies/nonexistent-id`, {
|
||||||
|
headers: { Authorization: `Bearer ${adminToken}` },
|
||||||
|
});
|
||||||
|
// 404 for nonexistent, or 200 if the implementation ignores missing IDs
|
||||||
|
expect([200, 404]).toContain(res.status());
|
||||||
|
});
|
||||||
|
|
||||||
|
test("DELETE /admin/cookies — bulk delete requires body", async ({ request }) => {
|
||||||
|
const res = await request.delete(`${API_BASE}/admin/cookies`, {
|
||||||
|
headers: { Authorization: `Bearer ${adminToken}` },
|
||||||
|
data: { ids: [] },
|
||||||
|
});
|
||||||
|
expect([200, 400]).toContain(res.status());
|
||||||
|
});
|
||||||
|
|
||||||
|
test("GET /admin/cookies — unauthenticated returns 401", async ({ request }) => {
|
||||||
|
const res = await request.get(`${API_BASE}/admin/cookies`);
|
||||||
|
expect(res.status()).toBe(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("Devices API", () => {
|
||||||
|
test.beforeAll(async ({ request }) => {
|
||||||
|
const login = await request.post(`${API_BASE}/admin/auth/login`, {
|
||||||
|
data: { username: ADMIN_USER, password: ADMIN_PASS },
|
||||||
|
});
|
||||||
|
adminToken = (await login.json()).token;
|
||||||
|
});
|
||||||
|
|
||||||
|
test("GET /admin/devices — returns list of devices", async ({ request }) => {
|
||||||
|
const res = await request.get(`${API_BASE}/admin/devices`, {
|
||||||
|
headers: { Authorization: `Bearer ${adminToken}` },
|
||||||
|
});
|
||||||
|
expect(res.status()).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body).toHaveProperty("devices");
|
||||||
|
expect(Array.isArray(body.devices)).toBe(true);
|
||||||
|
// Each device should have the expected shape
|
||||||
|
for (const device of body.devices) {
|
||||||
|
expect(device).toHaveProperty("id");
|
||||||
|
expect(device).toHaveProperty("name");
|
||||||
|
expect(device).toHaveProperty("platform");
|
||||||
|
expect(device).toHaveProperty("online");
|
||||||
|
expect(typeof device.online).toBe("boolean");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("POST /admin/devices/:id/revoke — returns 404 for unknown device", async ({
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
const res = await request.post(`${API_BASE}/admin/devices/nonexistent/revoke`, {
|
||||||
|
headers: { Authorization: `Bearer ${adminToken}` },
|
||||||
|
});
|
||||||
|
expect([404, 400]).toContain(res.status());
|
||||||
|
});
|
||||||
|
|
||||||
|
test("GET /admin/devices — unauthenticated returns 401", async ({ request }) => {
|
||||||
|
const res = await request.get(`${API_BASE}/admin/devices`);
|
||||||
|
expect(res.status()).toBe(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("Settings API", () => {
|
||||||
|
test.beforeAll(async ({ request }) => {
|
||||||
|
const login = await request.post(`${API_BASE}/admin/auth/login`, {
|
||||||
|
data: { username: ADMIN_USER, password: ADMIN_PASS },
|
||||||
|
});
|
||||||
|
adminToken = (await login.json()).token;
|
||||||
|
});
|
||||||
|
|
||||||
|
test("GET /admin/settings — returns settings object", async ({ request }) => {
|
||||||
|
const res = await request.get(`${API_BASE}/admin/settings`, {
|
||||||
|
headers: { Authorization: `Bearer ${adminToken}` },
|
||||||
|
});
|
||||||
|
expect(res.status()).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body).toHaveProperty("sync");
|
||||||
|
expect(body).toHaveProperty("security");
|
||||||
|
expect(body).toHaveProperty("appearance");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("PATCH /admin/settings — partial update is accepted", async ({ request }) => {
|
||||||
|
const res = await request.patch(`${API_BASE}/admin/settings`, {
|
||||||
|
headers: { Authorization: `Bearer ${adminToken}` },
|
||||||
|
data: { sync: { autoSync: true } },
|
||||||
|
});
|
||||||
|
expect([200, 204]).toContain(res.status());
|
||||||
|
});
|
||||||
|
|
||||||
|
test("PATCH /admin/settings — unknown fields are ignored or rejected gracefully", async ({
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
const res = await request.patch(`${API_BASE}/admin/settings`, {
|
||||||
|
headers: { Authorization: `Bearer ${adminToken}` },
|
||||||
|
data: { unknownField: "value" },
|
||||||
|
});
|
||||||
|
expect([200, 204, 400]).toContain(res.status());
|
||||||
|
});
|
||||||
|
|
||||||
|
test("GET /admin/settings — unauthenticated returns 401", async ({ request }) => {
|
||||||
|
const res = await request.get(`${API_BASE}/admin/settings`);
|
||||||
|
expect(res.status()).toBe(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
239
web/tests/e2e/01-login.spec.ts
Normal file
239
web/tests/e2e/01-login.spec.ts
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RCA-14: Login page + first-run setup wizard
|
||||||
|
*
|
||||||
|
* Covers:
|
||||||
|
* - Login / logout flow
|
||||||
|
* - Form validation and error display
|
||||||
|
* - Route guard: unauthenticated redirect to /login
|
||||||
|
* - Route guard: authenticated redirect away from /login → dashboard
|
||||||
|
* - First-run setup wizard (GET /admin/setup/status → redirect to /setup)
|
||||||
|
*/
|
||||||
|
|
||||||
|
test.describe("Login page", () => {
|
||||||
|
test("shows username and password fields", async ({ page }) => {
|
||||||
|
await page.goto("/login");
|
||||||
|
await expect(page.getByLabel(/username/i)).toBeVisible();
|
||||||
|
await expect(page.getByLabel(/password/i)).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByRole("button", { name: /log in|sign in/i }),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("disables submit while fields are empty", async ({ page }) => {
|
||||||
|
await page.goto("/login");
|
||||||
|
const btn = page.getByRole("button", { name: /log in|sign in/i });
|
||||||
|
// Should either be disabled or clicking it shows a validation error
|
||||||
|
const isEmpty = (await btn.getAttribute("disabled")) !== null;
|
||||||
|
if (!isEmpty) {
|
||||||
|
await btn.click();
|
||||||
|
// At least one validation error should appear
|
||||||
|
const hasError = await page
|
||||||
|
.getByRole("alert")
|
||||||
|
.or(page.locator("[class*=error]"))
|
||||||
|
.or(page.locator("[class*=invalid]"))
|
||||||
|
.count();
|
||||||
|
expect(hasError).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shows error on invalid credentials", async ({ page }) => {
|
||||||
|
await page.route("**/admin/auth/login", (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
status: 401,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ error: "Invalid credentials" }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.goto("/login");
|
||||||
|
await page.getByLabel(/username/i).fill("wrong");
|
||||||
|
await page.getByLabel(/password/i).fill("wrong");
|
||||||
|
await page.getByRole("button", { name: /log in|sign in/i }).click();
|
||||||
|
|
||||||
|
await expect(page.getByText(/invalid credentials|wrong|incorrect/i)).toBeVisible();
|
||||||
|
// Should remain on /login
|
||||||
|
await expect(page).toHaveURL(/\/login/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("submits form on Enter key", async ({ page }) => {
|
||||||
|
await page.route("**/admin/auth/login", (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ token: "test-jwt", expiresAt: "2099-01-01" }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.goto("/login");
|
||||||
|
await page.getByLabel(/username/i).fill("admin");
|
||||||
|
await page.getByLabel(/password/i).fill("password");
|
||||||
|
await page.getByLabel(/password/i).press("Enter");
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/dashboard/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("redirects to dashboard on successful login", async ({ page }) => {
|
||||||
|
await page.route("**/admin/auth/login", (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ token: "test-jwt", expiresAt: "2099-01-01" }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.goto("/login");
|
||||||
|
await page.getByLabel(/username/i).fill("admin");
|
||||||
|
await page.getByLabel(/password/i).fill("password");
|
||||||
|
await page.getByRole("button", { name: /log in|sign in/i }).click();
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/dashboard/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("Route guards", () => {
|
||||||
|
test("unauthenticated user is redirected to /login from protected routes", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
for (const route of ["/dashboard", "/cookies", "/devices", "/settings"]) {
|
||||||
|
await page.goto(route);
|
||||||
|
await expect(page).toHaveURL(/\/login/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("authenticated user visiting /login is redirected to /dashboard", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
// Seed a token so the app thinks we're logged in
|
||||||
|
await page.goto("/login");
|
||||||
|
await page.evaluate(() => localStorage.setItem("cb_admin_token", "fake-jwt"));
|
||||||
|
|
||||||
|
// Mock /admin/auth/me to return a valid user
|
||||||
|
await page.route("**/admin/auth/me", (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ username: "admin" }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.goto("/login");
|
||||||
|
await expect(page).toHaveURL(/\/dashboard/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("First-run setup wizard", () => {
|
||||||
|
test("redirects to /setup when not yet initialised", async ({ page }) => {
|
||||||
|
await page.route("**/admin/setup/status", (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ initialised: false }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.goto("/");
|
||||||
|
await expect(page).toHaveURL(/\/setup/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("wizard has 4 steps and can be completed", async ({ page }) => {
|
||||||
|
await page.route("**/admin/setup/status", (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ initialised: false }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await page.route("**/admin/setup/init", (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ ok: true }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.goto("/setup");
|
||||||
|
|
||||||
|
// Step 1: Welcome
|
||||||
|
await expect(page.getByText(/welcome|cookiebridge/i)).toBeVisible();
|
||||||
|
await page.getByRole("button", { name: /next|continue/i }).click();
|
||||||
|
|
||||||
|
// Step 2: Create admin account
|
||||||
|
await page.getByLabel(/username/i).fill("admin");
|
||||||
|
await page.getByLabel(/^password$/i).fill("Secure123!");
|
||||||
|
await page.getByLabel(/confirm password/i).fill("Secure123!");
|
||||||
|
await page.getByRole("button", { name: /next|continue/i }).click();
|
||||||
|
|
||||||
|
// Step 3: Basic config (port, HTTPS)
|
||||||
|
await expect(
|
||||||
|
page.getByLabel(/port/i).or(page.getByText(/port|https/i)),
|
||||||
|
).toBeVisible();
|
||||||
|
await page.getByRole("button", { name: /next|continue/i }).click();
|
||||||
|
|
||||||
|
// Step 4: Completion
|
||||||
|
await expect(page.getByText(/done|complete|finish/i)).toBeVisible();
|
||||||
|
await page.getByRole("button", { name: /go to login|finish/i }).click();
|
||||||
|
await expect(page).toHaveURL(/\/login/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("password mismatch in setup shows error", async ({ page }) => {
|
||||||
|
await page.route("**/admin/setup/status", (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ initialised: false }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.goto("/setup");
|
||||||
|
await page.getByRole("button", { name: /next|continue/i }).click();
|
||||||
|
|
||||||
|
await page.getByLabel(/username/i).fill("admin");
|
||||||
|
await page.getByLabel(/^password$/i).fill("Secure123!");
|
||||||
|
await page.getByLabel(/confirm password/i).fill("Mismatch999!");
|
||||||
|
await page.getByRole("button", { name: /next|continue/i }).click();
|
||||||
|
|
||||||
|
await expect(page.getByText(/do not match|mismatch|must match/i)).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("Logout", () => {
|
||||||
|
test("logout clears session and redirects to /login", async ({ page }) => {
|
||||||
|
await page.route("**/admin/auth/login", (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ token: "test-jwt", expiresAt: "2099-01-01" }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await page.route("**/admin/auth/logout", (route) =>
|
||||||
|
route.fulfill({ status: 204 }),
|
||||||
|
);
|
||||||
|
await page.route("**/admin/dashboard", (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ devices: {}, cookies: {}, syncCount: 0, uptimeSeconds: 0 }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Log in first
|
||||||
|
await page.goto("/login");
|
||||||
|
await page.getByLabel(/username/i).fill("admin");
|
||||||
|
await page.getByLabel(/password/i).fill("password");
|
||||||
|
await page.getByRole("button", { name: /log in|sign in/i }).click();
|
||||||
|
await expect(page).toHaveURL(/\/dashboard/);
|
||||||
|
|
||||||
|
// Log out
|
||||||
|
const logoutBtn = page
|
||||||
|
.getByRole("button", { name: /log ?out|sign ?out/i })
|
||||||
|
.or(page.getByRole("link", { name: /log ?out|sign ?out/i }));
|
||||||
|
await logoutBtn.click();
|
||||||
|
await expect(page).toHaveURL(/\/login/);
|
||||||
|
|
||||||
|
// Token should be gone
|
||||||
|
const token = await page.evaluate(() => localStorage.getItem("cb_admin_token"));
|
||||||
|
expect(token).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
116
web/tests/e2e/02-dashboard.spec.ts
Normal file
116
web/tests/e2e/02-dashboard.spec.ts
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
import { loginViaAPI } from "./helpers/auth.js";
|
||||||
|
import { mockDashboard, mockAPIError } from "./helpers/mock-api.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RCA-15: Dashboard
|
||||||
|
*
|
||||||
|
* Covers:
|
||||||
|
* - Stats cards render with correct values
|
||||||
|
* - Device status list
|
||||||
|
* - Quick-action links navigate to correct routes
|
||||||
|
* - Data refresh works
|
||||||
|
* - Error state when API fails
|
||||||
|
*/
|
||||||
|
|
||||||
|
test.describe("Dashboard", () => {
|
||||||
|
test.beforeEach(async ({ page, request }) => {
|
||||||
|
await loginViaAPI(page, request);
|
||||||
|
await mockDashboard(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shows all four stats cards", async ({ page }) => {
|
||||||
|
await page.goto("/dashboard");
|
||||||
|
|
||||||
|
// Connected devices
|
||||||
|
await expect(page.getByText(/connected devices|devices/i).first()).toBeVisible();
|
||||||
|
// Cookie count
|
||||||
|
await expect(page.getByText(/cookie|cookies/i).first()).toBeVisible();
|
||||||
|
// Sync count
|
||||||
|
await expect(page.getByText(/sync/i).first()).toBeVisible();
|
||||||
|
// Uptime
|
||||||
|
await expect(page.getByText(/uptime|running/i).first()).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("stats cards display values from the API", async ({ page }) => {
|
||||||
|
await page.goto("/dashboard");
|
||||||
|
// Our mock returns: devices total=3, cookies total=142, syncCount=57
|
||||||
|
await expect(page.getByText("3")).toBeVisible();
|
||||||
|
await expect(page.getByText("142")).toBeVisible();
|
||||||
|
await expect(page.getByText("57")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("device status list shows online/offline badges", async ({ page }) => {
|
||||||
|
await page.route("**/admin/devices*", (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({
|
||||||
|
devices: [
|
||||||
|
{ id: "d1", name: "Chrome on macOS", platform: "chrome", online: true, lastSeen: new Date().toISOString() },
|
||||||
|
{ id: "d2", name: "Firefox on Windows", platform: "firefox", online: false, lastSeen: "2026-03-15T10:00:00Z" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.goto("/dashboard");
|
||||||
|
|
||||||
|
await expect(page.getByText("Chrome on macOS")).toBeVisible();
|
||||||
|
await expect(page.getByText("Firefox on Windows")).toBeVisible();
|
||||||
|
// At least one online/offline indicator
|
||||||
|
const badges = page.getByText(/online|offline/i);
|
||||||
|
await expect(badges.first()).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("quick action 'View all cookies' navigates to /cookies", async ({ page }) => {
|
||||||
|
await page.goto("/dashboard");
|
||||||
|
await page.getByRole("link", { name: /view all cookie|all cookie|cookie/i }).first().click();
|
||||||
|
await expect(page).toHaveURL(/\/cookies/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("quick action 'Manage devices' navigates to /devices", async ({ page }) => {
|
||||||
|
await page.goto("/dashboard");
|
||||||
|
await page.getByRole("link", { name: /manage device|devices/i }).first().click();
|
||||||
|
await expect(page).toHaveURL(/\/devices/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("quick action 'Settings' navigates to /settings", async ({ page }) => {
|
||||||
|
await page.goto("/dashboard");
|
||||||
|
await page.getByRole("link", { name: /setting|settings/i }).first().click();
|
||||||
|
await expect(page).toHaveURL(/\/settings/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("refresh button re-fetches dashboard data", async ({ page }) => {
|
||||||
|
let callCount = 0;
|
||||||
|
await page.route("**/admin/dashboard", (route) => {
|
||||||
|
callCount++;
|
||||||
|
return route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ devices: {}, cookies: {}, syncCount: callCount, uptimeSeconds: 0 }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto("/dashboard");
|
||||||
|
const refreshBtn = page.getByRole("button", { name: /refresh/i });
|
||||||
|
if (await refreshBtn.isVisible()) {
|
||||||
|
const before = callCount;
|
||||||
|
await refreshBtn.click();
|
||||||
|
expect(callCount).toBeGreaterThan(before);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shows error message when dashboard API fails", async ({ page }) => {
|
||||||
|
await page.unroute("**/admin/dashboard");
|
||||||
|
await mockAPIError(page, "**/admin/dashboard", 500, "Server error");
|
||||||
|
|
||||||
|
await page.goto("/dashboard");
|
||||||
|
await expect(
|
||||||
|
page
|
||||||
|
.getByRole("alert")
|
||||||
|
.or(page.getByText(/error|failed|unavailable/i))
|
||||||
|
.first(),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
186
web/tests/e2e/03-cookies.spec.ts
Normal file
186
web/tests/e2e/03-cookies.spec.ts
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
import { loginViaAPI } from "./helpers/auth.js";
|
||||||
|
import { mockCookies, mockAPIError } from "./helpers/mock-api.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RCA-16: Cookie management page
|
||||||
|
*
|
||||||
|
* Covers:
|
||||||
|
* - Cookies grouped by domain
|
||||||
|
* - Search by domain name
|
||||||
|
* - Search by cookie name
|
||||||
|
* - Detail panel shows all fields
|
||||||
|
* - Delete single cookie with confirmation
|
||||||
|
* - Bulk delete
|
||||||
|
* - Domain group collapse/expand
|
||||||
|
* - Pagination / scroll
|
||||||
|
* - API error state
|
||||||
|
*/
|
||||||
|
|
||||||
|
test.describe("Cookie management", () => {
|
||||||
|
test.beforeEach(async ({ page, request }) => {
|
||||||
|
await loginViaAPI(page, request);
|
||||||
|
await mockCookies(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("lists cookies grouped by domain", async ({ page }) => {
|
||||||
|
await page.goto("/cookies");
|
||||||
|
|
||||||
|
await expect(page.getByText("example.com")).toBeVisible();
|
||||||
|
await expect(page.getByText("other.io")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("search by domain filters results", async ({ page }) => {
|
||||||
|
await page.goto("/cookies");
|
||||||
|
|
||||||
|
const searchInput = page
|
||||||
|
.getByPlaceholder(/search/i)
|
||||||
|
.or(page.getByRole("searchbox"))
|
||||||
|
.or(page.getByLabel(/search/i));
|
||||||
|
|
||||||
|
await searchInput.fill("other.io");
|
||||||
|
|
||||||
|
await expect(page.getByText("other.io")).toBeVisible();
|
||||||
|
await expect(page.getByText("example.com")).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("search by cookie name filters results", async ({ page }) => {
|
||||||
|
await page.goto("/cookies");
|
||||||
|
|
||||||
|
const searchInput = page
|
||||||
|
.getByPlaceholder(/search/i)
|
||||||
|
.or(page.getByRole("searchbox"))
|
||||||
|
.or(page.getByLabel(/search/i));
|
||||||
|
|
||||||
|
await searchInput.fill("session");
|
||||||
|
|
||||||
|
// "session" cookie under example.com should be visible
|
||||||
|
await expect(page.getByText("session")).toBeVisible();
|
||||||
|
// "token" under other.io should not be visible
|
||||||
|
await expect(page.getByText("token")).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("clicking a cookie shows detail panel with all fields", async ({ page }) => {
|
||||||
|
await page.goto("/cookies");
|
||||||
|
|
||||||
|
// Click the "session" cookie row
|
||||||
|
await page.getByText("session").first().click();
|
||||||
|
|
||||||
|
// Detail panel should show all cookie fields
|
||||||
|
await expect(page.getByText(/name/i)).toBeVisible();
|
||||||
|
await expect(page.getByText(/value/i)).toBeVisible();
|
||||||
|
await expect(page.getByText(/domain/i)).toBeVisible();
|
||||||
|
await expect(page.getByText(/path/i)).toBeVisible();
|
||||||
|
await expect(page.getByText(/expires/i)).toBeVisible();
|
||||||
|
await expect(page.getByText(/secure/i)).toBeVisible();
|
||||||
|
await expect(page.getByText(/httponly/i)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("deletes a single cookie after confirmation", async ({ page }) => {
|
||||||
|
let deleteCalled = false;
|
||||||
|
await page.route("**/admin/cookies/c1", (route) => {
|
||||||
|
if (route.request().method() === "DELETE") {
|
||||||
|
deleteCalled = true;
|
||||||
|
return route.fulfill({ status: 200, contentType: "application/json", body: "{}" });
|
||||||
|
}
|
||||||
|
return route.continue();
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto("/cookies");
|
||||||
|
|
||||||
|
// Click the first cookie's delete button
|
||||||
|
const deleteBtn = page
|
||||||
|
.getByRole("button", { name: /delete/i })
|
||||||
|
.first();
|
||||||
|
await deleteBtn.click();
|
||||||
|
|
||||||
|
// Confirmation dialog should appear
|
||||||
|
await expect(
|
||||||
|
page.getByRole("dialog").or(page.getByText(/confirm|are you sure/i)),
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
// Confirm deletion
|
||||||
|
await page.getByRole("button", { name: /confirm|yes|delete/i }).last().click();
|
||||||
|
|
||||||
|
expect(deleteCalled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("cancel on delete dialog does not delete the cookie", async ({ page }) => {
|
||||||
|
let deleteCalled = false;
|
||||||
|
await page.route("**/admin/cookies/*", (route) => {
|
||||||
|
if (route.request().method() === "DELETE") {
|
||||||
|
deleteCalled = true;
|
||||||
|
}
|
||||||
|
return route.continue();
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto("/cookies");
|
||||||
|
const deleteBtn = page.getByRole("button", { name: /delete/i }).first();
|
||||||
|
await deleteBtn.click();
|
||||||
|
|
||||||
|
await page
|
||||||
|
.getByRole("button", { name: /cancel|no/i })
|
||||||
|
.last()
|
||||||
|
.click();
|
||||||
|
|
||||||
|
expect(deleteCalled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("can select multiple cookies and bulk delete", async ({ page }) => {
|
||||||
|
let bulkDeleteCalled = false;
|
||||||
|
await page.route("**/admin/cookies", (route) => {
|
||||||
|
if (route.request().method() === "DELETE") {
|
||||||
|
bulkDeleteCalled = true;
|
||||||
|
return route.fulfill({ status: 200, contentType: "application/json", body: "{}" });
|
||||||
|
}
|
||||||
|
return route.continue();
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto("/cookies");
|
||||||
|
|
||||||
|
// Select checkboxes
|
||||||
|
const checkboxes = page.getByRole("checkbox");
|
||||||
|
const count = await checkboxes.count();
|
||||||
|
if (count > 0) {
|
||||||
|
await checkboxes.first().check();
|
||||||
|
if (count > 1) await checkboxes.nth(1).check();
|
||||||
|
|
||||||
|
const bulkBtn = page.getByRole("button", { name: /delete selected|bulk delete/i });
|
||||||
|
if (await bulkBtn.isVisible()) {
|
||||||
|
await bulkBtn.click();
|
||||||
|
await page.getByRole("button", { name: /confirm|yes|delete/i }).last().click();
|
||||||
|
expect(bulkDeleteCalled).toBe(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("domain group collapses and expands", async ({ page }) => {
|
||||||
|
await page.goto("/cookies");
|
||||||
|
|
||||||
|
// Find a domain group header and click to collapse
|
||||||
|
const groupHeader = page.getByText("example.com").first();
|
||||||
|
await groupHeader.click();
|
||||||
|
|
||||||
|
// After collapse, cookies within that domain should be hidden
|
||||||
|
// (exact selector depends on implementation — check one of the children)
|
||||||
|
const sessionCookie = page.getByText("session");
|
||||||
|
// It may be hidden or removed; either is acceptable
|
||||||
|
const isVisible = await sessionCookie.isVisible().catch(() => false);
|
||||||
|
// Click again to expand
|
||||||
|
await groupHeader.click();
|
||||||
|
await expect(page.getByText("session")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shows error message when cookies API fails", async ({ page }) => {
|
||||||
|
await page.unroute("**/admin/cookies*");
|
||||||
|
await mockAPIError(page, "**/admin/cookies*", 500, "Failed to load cookies");
|
||||||
|
|
||||||
|
await page.goto("/cookies");
|
||||||
|
await expect(
|
||||||
|
page
|
||||||
|
.getByRole("alert")
|
||||||
|
.or(page.getByText(/error|failed|could not load/i))
|
||||||
|
.first(),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
171
web/tests/e2e/04-devices.spec.ts
Normal file
171
web/tests/e2e/04-devices.spec.ts
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
import { loginViaAPI } from "./helpers/auth.js";
|
||||||
|
import { mockDevices, mockAPIError } from "./helpers/mock-api.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RCA-17: Device management page
|
||||||
|
*
|
||||||
|
* Covers:
|
||||||
|
* - Device card grid layout
|
||||||
|
* - Online/offline status badge
|
||||||
|
* - Platform icons (chrome, firefox, edge, safari)
|
||||||
|
* - Last seen time displayed
|
||||||
|
* - Remote revoke with confirmation dialog
|
||||||
|
* - Device detail expansion
|
||||||
|
* - Filter by online status
|
||||||
|
* - API error state
|
||||||
|
*/
|
||||||
|
|
||||||
|
test.describe("Device management", () => {
|
||||||
|
test.beforeEach(async ({ page, request }) => {
|
||||||
|
await loginViaAPI(page, request);
|
||||||
|
await mockDevices(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("displays device cards in a grid", async ({ page }) => {
|
||||||
|
await page.goto("/devices");
|
||||||
|
|
||||||
|
await expect(page.getByText("Chrome on macOS")).toBeVisible();
|
||||||
|
await expect(page.getByText("Firefox on Windows")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shows online badge for online device", async ({ page }) => {
|
||||||
|
await page.goto("/devices");
|
||||||
|
|
||||||
|
// Find the Chrome on macOS card and verify it has an online indicator
|
||||||
|
const chromeCard = page.locator("[class*=card], [class*=device]").filter({
|
||||||
|
hasText: "Chrome on macOS",
|
||||||
|
});
|
||||||
|
await expect(chromeCard).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
chromeCard.getByText(/online/i).or(chromeCard.locator("[class*=online]")),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shows offline badge for offline device", async ({ page }) => {
|
||||||
|
await page.goto("/devices");
|
||||||
|
|
||||||
|
const ffCard = page.locator("[class*=card], [class*=device]").filter({
|
||||||
|
hasText: "Firefox on Windows",
|
||||||
|
});
|
||||||
|
await expect(ffCard).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
ffCard.getByText(/offline/i).or(ffCard.locator("[class*=offline]")),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shows last active time for each device", async ({ page }) => {
|
||||||
|
await page.goto("/devices");
|
||||||
|
|
||||||
|
await expect(page.getByText(/last seen|last active/i).first()).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("remote revoke opens confirmation dialog", async ({ page }) => {
|
||||||
|
await page.goto("/devices");
|
||||||
|
|
||||||
|
const revokeBtn = page
|
||||||
|
.getByRole("button", { name: /revoke|logout|sign out/i })
|
||||||
|
.first();
|
||||||
|
await revokeBtn.click();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page
|
||||||
|
.getByRole("dialog")
|
||||||
|
.or(page.getByText(/confirm|are you sure|revoke/i))
|
||||||
|
.first(),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("confirming revoke calls POST /admin/devices/:id/revoke", async ({ page }) => {
|
||||||
|
let revokeCalled = false;
|
||||||
|
await page.route("**/admin/devices/d1/revoke", (route) => {
|
||||||
|
if (route.request().method() === "POST") {
|
||||||
|
revokeCalled = true;
|
||||||
|
return route.fulfill({ status: 200, contentType: "application/json", body: "{}" });
|
||||||
|
}
|
||||||
|
return route.continue();
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto("/devices");
|
||||||
|
|
||||||
|
const revokeBtn = page
|
||||||
|
.getByRole("button", { name: /revoke|logout|sign out/i })
|
||||||
|
.first();
|
||||||
|
await revokeBtn.click();
|
||||||
|
|
||||||
|
await page
|
||||||
|
.getByRole("button", { name: /confirm|yes|revoke/i })
|
||||||
|
.last()
|
||||||
|
.click();
|
||||||
|
|
||||||
|
expect(revokeCalled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("cancelling revoke dialog does not call API", async ({ page }) => {
|
||||||
|
let revokeCalled = false;
|
||||||
|
await page.route("**/admin/devices/*/revoke", (route) => {
|
||||||
|
revokeCalled = true;
|
||||||
|
return route.continue();
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto("/devices");
|
||||||
|
|
||||||
|
const revokeBtn = page
|
||||||
|
.getByRole("button", { name: /revoke|logout|sign out/i })
|
||||||
|
.first();
|
||||||
|
await revokeBtn.click();
|
||||||
|
|
||||||
|
await page
|
||||||
|
.getByRole("button", { name: /cancel|no/i })
|
||||||
|
.last()
|
||||||
|
.click();
|
||||||
|
|
||||||
|
expect(revokeCalled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("device detail expansion shows extra fields", async ({ page }) => {
|
||||||
|
await page.goto("/devices");
|
||||||
|
|
||||||
|
// Click a device card or expand button to reveal detail
|
||||||
|
const card = page
|
||||||
|
.locator("[class*=card], [class*=device]")
|
||||||
|
.filter({ hasText: "Chrome on macOS" });
|
||||||
|
await card.click();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page
|
||||||
|
.getByText(/extension version|version/i)
|
||||||
|
.or(page.getByText(/registered|first seen/i))
|
||||||
|
.first(),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("filter by 'online' shows only online devices", async ({ page }) => {
|
||||||
|
await page.goto("/devices");
|
||||||
|
|
||||||
|
const filterSelect = page
|
||||||
|
.getByLabel(/filter|status/i)
|
||||||
|
.or(page.getByRole("combobox"))
|
||||||
|
.or(page.getByRole("listbox"));
|
||||||
|
|
||||||
|
if ((await filterSelect.count()) > 0) {
|
||||||
|
await filterSelect.first().selectOption({ label: /online/i });
|
||||||
|
|
||||||
|
await expect(page.getByText("Chrome on macOS")).toBeVisible();
|
||||||
|
await expect(page.getByText("Firefox on Windows")).not.toBeVisible();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shows error message when devices API fails", async ({ page }) => {
|
||||||
|
await page.unroute("**/admin/devices*");
|
||||||
|
await mockAPIError(page, "**/admin/devices*", 500, "Failed to load devices");
|
||||||
|
|
||||||
|
await page.goto("/devices");
|
||||||
|
await expect(
|
||||||
|
page
|
||||||
|
.getByRole("alert")
|
||||||
|
.or(page.getByText(/error|failed|could not load/i))
|
||||||
|
.first(),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
210
web/tests/e2e/05-settings.spec.ts
Normal file
210
web/tests/e2e/05-settings.spec.ts
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
import { loginViaAPI } from "./helpers/auth.js";
|
||||||
|
import { mockSettings, mockAPIError } from "./helpers/mock-api.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RCA-18: Settings page
|
||||||
|
*
|
||||||
|
* Covers:
|
||||||
|
* - Three tabs: Sync / Security / Appearance
|
||||||
|
* - Settings are pre-populated from GET /admin/settings
|
||||||
|
* - Changes saved via PATCH /admin/settings
|
||||||
|
* - Success toast on save
|
||||||
|
* - Password change (security tab)
|
||||||
|
* - Theme selection (appearance tab)
|
||||||
|
* - Language selection (appearance tab)
|
||||||
|
* - API error on save
|
||||||
|
*/
|
||||||
|
|
||||||
|
test.describe("Settings page", () => {
|
||||||
|
test.beforeEach(async ({ page, request }) => {
|
||||||
|
await loginViaAPI(page, request);
|
||||||
|
await mockSettings(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("displays three tabs: sync, security, appearance", async ({ page }) => {
|
||||||
|
await page.goto("/settings");
|
||||||
|
|
||||||
|
await expect(page.getByRole("tab", { name: /sync/i })).toBeVisible();
|
||||||
|
await expect(page.getByRole("tab", { name: /security/i })).toBeVisible();
|
||||||
|
await expect(page.getByRole("tab", { name: /appearance/i })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Sync tab ---
|
||||||
|
|
||||||
|
test("sync tab: auto-sync toggle reflects saved value", async ({ page }) => {
|
||||||
|
await page.goto("/settings");
|
||||||
|
|
||||||
|
await page.getByRole("tab", { name: /sync/i }).click();
|
||||||
|
|
||||||
|
const toggle = page
|
||||||
|
.getByRole("switch", { name: /auto.?sync/i })
|
||||||
|
.or(page.getByLabel(/auto.?sync/i));
|
||||||
|
// Mock returns autoSync: true
|
||||||
|
await expect(toggle).toBeChecked();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sync tab: frequency selector shows current value", async ({ page }) => {
|
||||||
|
await page.goto("/settings");
|
||||||
|
await page.getByRole("tab", { name: /sync/i }).click();
|
||||||
|
|
||||||
|
// Mock returns frequency: "realtime"
|
||||||
|
const select = page
|
||||||
|
.getByLabel(/frequency/i)
|
||||||
|
.or(page.getByRole("combobox").filter({ hasText: /realtime/i }));
|
||||||
|
await expect(select).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sync tab: saving calls PATCH /admin/settings", async ({ page }) => {
|
||||||
|
let patchCalled = false;
|
||||||
|
await page.route("**/admin/settings", (route) => {
|
||||||
|
if (route.request().method() === "PATCH") {
|
||||||
|
patchCalled = true;
|
||||||
|
return route.fulfill({ status: 200, contentType: "application/json", body: "{}" });
|
||||||
|
}
|
||||||
|
return route.continue();
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto("/settings");
|
||||||
|
await page.getByRole("tab", { name: /sync/i }).click();
|
||||||
|
|
||||||
|
// Toggle auto-sync off
|
||||||
|
const toggle = page
|
||||||
|
.getByRole("switch", { name: /auto.?sync/i })
|
||||||
|
.or(page.getByLabel(/auto.?sync/i));
|
||||||
|
await toggle.click();
|
||||||
|
|
||||||
|
// Some implementations save immediately; others have an explicit Save button
|
||||||
|
const saveBtn = page.getByRole("button", { name: /save/i });
|
||||||
|
if (await saveBtn.isVisible()) await saveBtn.click();
|
||||||
|
|
||||||
|
expect(patchCalled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sync tab: success toast appears after save", async ({ page }) => {
|
||||||
|
await page.route("**/admin/settings", (route) => {
|
||||||
|
if (route.request().method() === "PATCH") {
|
||||||
|
return route.fulfill({ status: 200, contentType: "application/json", body: "{}" });
|
||||||
|
}
|
||||||
|
return route.continue();
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto("/settings");
|
||||||
|
await page.getByRole("tab", { name: /sync/i }).click();
|
||||||
|
|
||||||
|
const toggle = page
|
||||||
|
.getByRole("switch", { name: /auto.?sync/i })
|
||||||
|
.or(page.getByLabel(/auto.?sync/i));
|
||||||
|
await toggle.click();
|
||||||
|
|
||||||
|
const saveBtn = page.getByRole("button", { name: /save/i });
|
||||||
|
if (await saveBtn.isVisible()) await saveBtn.click();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page.getByText(/saved|success|updated/i).first(),
|
||||||
|
).toBeVisible({ timeout: 5000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Security tab ---
|
||||||
|
|
||||||
|
test("security tab: change password requires current + new + confirm", async ({ page }) => {
|
||||||
|
await page.goto("/settings");
|
||||||
|
await page.getByRole("tab", { name: /security/i }).click();
|
||||||
|
|
||||||
|
await expect(page.getByLabel(/current password/i)).toBeVisible();
|
||||||
|
await expect(page.getByLabel(/new password/i)).toBeVisible();
|
||||||
|
await expect(page.getByLabel(/confirm.*(new )?password/i)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("security tab: password change with mismatch shows error", async ({ page }) => {
|
||||||
|
await page.goto("/settings");
|
||||||
|
await page.getByRole("tab", { name: /security/i }).click();
|
||||||
|
|
||||||
|
await page.getByLabel(/current password/i).fill("oldPass123");
|
||||||
|
await page.getByLabel(/new password/i).fill("NewPass456!");
|
||||||
|
await page.getByLabel(/confirm.*(new )?password/i).fill("Different789!");
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: /change|save password|update/i }).click();
|
||||||
|
|
||||||
|
await expect(page.getByText(/do not match|mismatch|must match/i)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("security tab: session timeout field accepts numeric input", async ({ page }) => {
|
||||||
|
await page.goto("/settings");
|
||||||
|
await page.getByRole("tab", { name: /security/i }).click();
|
||||||
|
|
||||||
|
const timeoutField = page
|
||||||
|
.getByLabel(/session timeout/i)
|
||||||
|
.or(page.getByRole("spinbutton").filter({ hasText: /timeout/i }));
|
||||||
|
if (await timeoutField.isVisible()) {
|
||||||
|
await timeoutField.fill("120");
|
||||||
|
await expect(timeoutField).toHaveValue("120");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Appearance tab ---
|
||||||
|
|
||||||
|
test("appearance tab: theme options present (light/dark/system)", async ({ page }) => {
|
||||||
|
await page.goto("/settings");
|
||||||
|
await page.getByRole("tab", { name: /appearance/i }).click();
|
||||||
|
|
||||||
|
await expect(page.getByText(/light/i)).toBeVisible();
|
||||||
|
await expect(page.getByText(/dark/i)).toBeVisible();
|
||||||
|
await expect(page.getByText(/system/i)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("appearance tab: language selector shows Chinese and English options", async ({ page }) => {
|
||||||
|
await page.goto("/settings");
|
||||||
|
await page.getByRole("tab", { name: /appearance/i }).click();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page.getByText(/chinese|中文/i).or(page.getByText("zh")),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByText(/english/i).or(page.getByText("en")),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Error states ---
|
||||||
|
|
||||||
|
test("shows error message when settings fail to load", async ({ page }) => {
|
||||||
|
await page.unroute("**/admin/settings*");
|
||||||
|
await mockAPIError(page, "**/admin/settings*", 500, "Failed to load settings");
|
||||||
|
|
||||||
|
await page.goto("/settings");
|
||||||
|
await expect(
|
||||||
|
page
|
||||||
|
.getByRole("alert")
|
||||||
|
.or(page.getByText(/error|failed|could not load/i))
|
||||||
|
.first(),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shows error toast when save fails", async ({ page }) => {
|
||||||
|
await page.route("**/admin/settings", (route) => {
|
||||||
|
if (route.request().method() === "PATCH") {
|
||||||
|
return route.fulfill({
|
||||||
|
status: 500,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ error: "Server error" }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return route.continue();
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto("/settings");
|
||||||
|
await page.getByRole("tab", { name: /sync/i }).click();
|
||||||
|
|
||||||
|
const toggle = page
|
||||||
|
.getByRole("switch", { name: /auto.?sync/i })
|
||||||
|
.or(page.getByLabel(/auto.?sync/i));
|
||||||
|
await toggle.click();
|
||||||
|
|
||||||
|
const saveBtn = page.getByRole("button", { name: /save/i });
|
||||||
|
if (await saveBtn.isVisible()) await saveBtn.click();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page.getByText(/error|failed|could not save/i).first(),
|
||||||
|
).toBeVisible({ timeout: 5000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
63
web/tests/e2e/06-responsive.spec.ts
Normal file
63
web/tests/e2e/06-responsive.spec.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { test, expect, devices } from "@playwright/test";
|
||||||
|
import { loginViaAPI } from "./helpers/auth.js";
|
||||||
|
import { mockDashboard, mockCookies, mockDevices, mockSettings } from "./helpers/mock-api.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Responsive layout tests
|
||||||
|
*
|
||||||
|
* These run on the default desktop viewport; the Playwright projects
|
||||||
|
* in playwright.config.ts also exercise mobile-chrome, mobile-safari,
|
||||||
|
* and tablet viewports automatically.
|
||||||
|
*
|
||||||
|
* This file adds explicit viewport-override tests for key layout expectations.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const PAGES = [
|
||||||
|
{ path: "/dashboard", name: "Dashboard" },
|
||||||
|
{ path: "/cookies", name: "Cookies" },
|
||||||
|
{ path: "/devices", name: "Devices" },
|
||||||
|
{ path: "/settings", name: "Settings" },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const { path, name } of PAGES) {
|
||||||
|
test.describe(`Responsive — ${name}`, () => {
|
||||||
|
test.beforeEach(async ({ page, request }) => {
|
||||||
|
await loginViaAPI(page, request);
|
||||||
|
await mockDashboard(page);
|
||||||
|
await mockCookies(page);
|
||||||
|
await mockDevices(page);
|
||||||
|
await mockSettings(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renders without horizontal scroll on mobile (375px)", async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 375, height: 812 });
|
||||||
|
await page.goto(path);
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
|
const scrollWidth = await page.evaluate(() => document.body.scrollWidth);
|
||||||
|
const clientWidth = await page.evaluate(() => document.body.clientWidth);
|
||||||
|
expect(scrollWidth).toBeLessThanOrEqual(clientWidth + 1); // 1px tolerance
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renders without horizontal scroll on tablet (768px)", async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 768, height: 1024 });
|
||||||
|
await page.goto(path);
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
|
const scrollWidth = await page.evaluate(() => document.body.scrollWidth);
|
||||||
|
const clientWidth = await page.evaluate(() => document.body.clientWidth);
|
||||||
|
expect(scrollWidth).toBeLessThanOrEqual(clientWidth + 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("navigation is reachable on mobile", async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 375, height: 812 });
|
||||||
|
await page.goto(path);
|
||||||
|
|
||||||
|
// On mobile there's typically a hamburger menu or bottom nav
|
||||||
|
const nav = page
|
||||||
|
.getByRole("navigation")
|
||||||
|
.or(page.getByRole("button", { name: /menu|nav/i }));
|
||||||
|
await expect(nav.first()).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
46
web/tests/e2e/helpers/auth.ts
Normal file
46
web/tests/e2e/helpers/auth.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { type Page, type APIRequestContext, expect } from "@playwright/test";
|
||||||
|
|
||||||
|
export const TEST_ADMIN = {
|
||||||
|
username: process.env.TEST_ADMIN_USER ?? "admin",
|
||||||
|
password: process.env.TEST_ADMIN_PASS ?? "testpassword123",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log in via the UI login form and wait for the dashboard to load.
|
||||||
|
*/
|
||||||
|
export async function loginViaUI(page: Page): Promise<void> {
|
||||||
|
await page.goto("/login");
|
||||||
|
await page.getByLabel(/username/i).fill(TEST_ADMIN.username);
|
||||||
|
await page.getByLabel(/password/i).fill(TEST_ADMIN.password);
|
||||||
|
await page.getByRole("button", { name: /log in|sign in/i }).click();
|
||||||
|
await expect(page).toHaveURL(/\/dashboard/);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log in via the admin API directly and store the token in localStorage.
|
||||||
|
* Faster than UI login for tests that only need an authenticated session.
|
||||||
|
*/
|
||||||
|
export async function loginViaAPI(
|
||||||
|
page: Page,
|
||||||
|
_request?: APIRequestContext,
|
||||||
|
): Promise<string> {
|
||||||
|
const token = "test-jwt-token";
|
||||||
|
await page.goto("/");
|
||||||
|
await page.evaluate(
|
||||||
|
({ t }) => localStorage.setItem("cb_admin_token", t),
|
||||||
|
{ t: token },
|
||||||
|
);
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log out via the UI and confirm redirect to /login.
|
||||||
|
*/
|
||||||
|
export async function logoutViaUI(page: Page): Promise<void> {
|
||||||
|
// Common patterns: a "Logout" button in the nav/header
|
||||||
|
const logoutBtn = page
|
||||||
|
.getByRole("button", { name: /log ?out|sign ?out/i })
|
||||||
|
.or(page.getByRole("link", { name: /log ?out|sign ?out/i }));
|
||||||
|
await logoutBtn.click();
|
||||||
|
await expect(page).toHaveURL(/\/login/);
|
||||||
|
}
|
||||||
164
web/tests/e2e/helpers/mock-api.ts
Normal file
164
web/tests/e2e/helpers/mock-api.ts
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import { type Page } from "@playwright/test";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Intercept /admin/dashboard and return a canned response so UI tests
|
||||||
|
* don't depend on a running relay server with real data.
|
||||||
|
*/
|
||||||
|
export async function mockDashboard(page: Page): Promise<void> {
|
||||||
|
await page.route("**/admin/dashboard", (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({
|
||||||
|
totalDevices: 3,
|
||||||
|
onlineDevices: 2,
|
||||||
|
totalCookies: 142,
|
||||||
|
uniqueDomains: 8,
|
||||||
|
connections: 2,
|
||||||
|
syncCount: 57,
|
||||||
|
uptimeSeconds: 86400,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Intercept /admin/cookies and return a paginated list.
|
||||||
|
*/
|
||||||
|
export async function mockCookies(page: Page): Promise<void> {
|
||||||
|
await page.route("**/admin/cookies*", (route) => {
|
||||||
|
if (route.request().method() === "DELETE") {
|
||||||
|
return route.fulfill({ status: 200, contentType: "application/json", body: "{}" });
|
||||||
|
}
|
||||||
|
return route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({
|
||||||
|
cookies: [
|
||||||
|
{
|
||||||
|
id: "c1",
|
||||||
|
deviceId: "dev-001",
|
||||||
|
domain: "example.com",
|
||||||
|
cookieName: "session",
|
||||||
|
path: "/",
|
||||||
|
ciphertext: "encrypted-abc123",
|
||||||
|
nonce: "nonce1",
|
||||||
|
lamportTs: 1,
|
||||||
|
updatedAt: "2026-03-01T00:00:00Z",
|
||||||
|
expires: "2027-01-01T00:00:00Z",
|
||||||
|
secure: true,
|
||||||
|
httpOnly: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "c2",
|
||||||
|
deviceId: "dev-001",
|
||||||
|
domain: "example.com",
|
||||||
|
cookieName: "pref",
|
||||||
|
path: "/",
|
||||||
|
ciphertext: "encrypted-dark",
|
||||||
|
nonce: "nonce2",
|
||||||
|
lamportTs: 2,
|
||||||
|
updatedAt: "2026-03-02T00:00:00Z",
|
||||||
|
expires: "2027-06-01T00:00:00Z",
|
||||||
|
secure: false,
|
||||||
|
httpOnly: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "c3",
|
||||||
|
deviceId: "dev-002",
|
||||||
|
domain: "other.io",
|
||||||
|
cookieName: "token",
|
||||||
|
path: "/",
|
||||||
|
ciphertext: "encrypted-xyz",
|
||||||
|
nonce: "nonce3",
|
||||||
|
lamportTs: 3,
|
||||||
|
updatedAt: "2026-03-03T00:00:00Z",
|
||||||
|
expires: null,
|
||||||
|
secure: true,
|
||||||
|
httpOnly: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 3,
|
||||||
|
page: 1,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Intercept /admin/devices and return device list.
|
||||||
|
*/
|
||||||
|
export async function mockDevices(page: Page): Promise<void> {
|
||||||
|
await page.route("**/admin/devices*", (route) => {
|
||||||
|
if (route.request().method() !== "GET") return route.continue();
|
||||||
|
return route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({
|
||||||
|
devices: [
|
||||||
|
{
|
||||||
|
deviceId: "d1",
|
||||||
|
name: "Chrome on macOS",
|
||||||
|
platform: "chrome",
|
||||||
|
online: true,
|
||||||
|
lastSeen: new Date().toISOString(),
|
||||||
|
createdAt: "2026-01-01T00:00:00Z",
|
||||||
|
ipAddress: "192.168.1.10",
|
||||||
|
extensionVersion: "2.0.0",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
deviceId: "d2",
|
||||||
|
name: "Firefox on Windows",
|
||||||
|
platform: "firefox",
|
||||||
|
online: false,
|
||||||
|
lastSeen: "2026-03-15T10:00:00Z",
|
||||||
|
createdAt: "2026-02-01T00:00:00Z",
|
||||||
|
ipAddress: null,
|
||||||
|
extensionVersion: "2.0.0",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Intercept /admin/settings and return settings object.
|
||||||
|
*/
|
||||||
|
export async function mockSettings(page: Page): Promise<void> {
|
||||||
|
await page.route("**/admin/settings*", (route) => {
|
||||||
|
if (route.request().method() === "GET") {
|
||||||
|
return route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({
|
||||||
|
autoSync: true,
|
||||||
|
syncIntervalMs: 0,
|
||||||
|
maxDevices: 10,
|
||||||
|
theme: "system",
|
||||||
|
sessionTimeoutMinutes: 60,
|
||||||
|
language: "zh",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return route.fulfill({ status: 200, contentType: "application/json", body: "{}" });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simulate a 500 error on the given path — used for error-handling tests.
|
||||||
|
*/
|
||||||
|
export async function mockAPIError(
|
||||||
|
page: Page,
|
||||||
|
urlPattern: string,
|
||||||
|
status = 500,
|
||||||
|
message = "Internal Server Error",
|
||||||
|
): Promise<void> {
|
||||||
|
await page.route(urlPattern, (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
status,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ error: message }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
24
web/tsconfig.json
Normal file
24
web/tsconfig.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"strict": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"importHelpers": true,
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
},
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"types": ["vite/client"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.vue", "env.d.ts"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
34
web/vite.config.ts
Normal file
34
web/vite.config.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import vue from "@vitejs/plugin-vue";
|
||||||
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue(), tailwindcss()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": resolve(__dirname, "src"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
"/api": {
|
||||||
|
target: "http://localhost:8100",
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
"/admin": {
|
||||||
|
target: "http://localhost:8100",
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
"/ws": {
|
||||||
|
target: "ws://localhost:8100",
|
||||||
|
ws: true,
|
||||||
|
},
|
||||||
|
"/health": {
|
||||||
|
target: "http://localhost:8100",
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user