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",
|
||||
"version": "1.0.0",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "cookiebridge",
|
||||
"version": "1.0.0",
|
||||
"license": "ISC",
|
||||
"version": "0.1.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"sodium-native": "^5.1.0",
|
||||
"typescript": "^5.9.3",
|
||||
"uuid": "^13.0.0",
|
||||
"ws": "^8.19.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/node": "^25.5.0",
|
||||
"@types/sodium-native": "^2.3.9",
|
||||
"@types/uuid": "^10.0.0",
|
||||
@@ -848,6 +850,24 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "25.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz",
|
||||
@@ -1147,6 +1167,12 @@
|
||||
"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": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
|
||||
@@ -1174,6 +1200,15 @@
|
||||
"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": {
|
||||
"version": "2.0.0",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
||||
@@ -1565,6 +1643,48 @@
|
||||
"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": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
@@ -1575,6 +1695,12 @@
|
||||
"@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": {
|
||||
"version": "3.3.11",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
|
||||
|
||||
@@ -12,17 +12,24 @@
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"keywords": ["cookies", "sync", "encryption", "browser-extension"],
|
||||
"keywords": [
|
||||
"cookies",
|
||||
"sync",
|
||||
"encryption",
|
||||
"browser-extension"
|
||||
],
|
||||
"author": "Rc707Agency",
|
||||
"license": "MIT",
|
||||
"type": "commonjs",
|
||||
"dependencies": {
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"sodium-native": "^5.1.0",
|
||||
"typescript": "^5.9.3",
|
||||
"uuid": "^13.0.0",
|
||||
"ws": "^8.19.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/node": "^25.5.0",
|
||||
"@types/sodium-native": "^2.3.9",
|
||||
"@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;
|
||||
}
|
||||
|
||||
/** 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 connectedCount(): number {
|
||||
return this.connections.size;
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
MESSAGE_TYPES,
|
||||
PING_INTERVAL_MS,
|
||||
} from "../protocol/spec.js";
|
||||
import { AdminStore } from "./admin/auth.js";
|
||||
import { handleAdminRoute } from "./admin/routes.js";
|
||||
|
||||
export interface RelayServerConfig {
|
||||
port: number;
|
||||
@@ -50,6 +52,7 @@ export class RelayServer {
|
||||
readonly cookieStore: CookieBlobStore;
|
||||
readonly deviceRegistry: DeviceRegistry;
|
||||
readonly agentRegistry: AgentRegistry;
|
||||
readonly adminStore: AdminStore;
|
||||
private pendingAuths = new Map<WebSocket, PendingAuth>();
|
||||
private authenticatedDevices = new Map<WebSocket, string>();
|
||||
private pingIntervals = new Map<WebSocket, ReturnType<typeof setInterval>>();
|
||||
@@ -60,6 +63,7 @@ export class RelayServer {
|
||||
this.cookieStore = new CookieBlobStore();
|
||||
this.deviceRegistry = new DeviceRegistry();
|
||||
this.agentRegistry = new AgentRegistry();
|
||||
this.adminStore = new AdminStore();
|
||||
|
||||
this.httpServer = http.createServer(this.handleHttp.bind(this));
|
||||
this.wss = new WebSocketServer({ server: this.httpServer });
|
||||
@@ -99,6 +103,17 @@ export class RelayServer {
|
||||
const url = req.url ?? "";
|
||||
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
|
||||
if (method === "GET" && url === "/health") {
|
||||
this.json(res, 200, { status: "ok", connections: this.connections.connectedCount });
|
||||
|
||||
@@ -80,6 +80,38 @@ export class CookieBlobStore {
|
||||
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). */
|
||||
getUpdatedSince(deviceIds: string[], since: string): EncryptedCookieBlob[] {
|
||||
const result: EncryptedCookieBlob[] = [];
|
||||
|
||||
@@ -72,6 +72,28 @@ export class DeviceRegistry {
|
||||
const paired = this.getPairedDevices(deviceId);
|
||||
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