feat: add admin REST API layer for frontend management panel

Implement JWT-protected /admin/* routes on the relay server:
- Auth: login, logout, me, setup/status, setup/init (first-time config)
- Dashboard: stats overview (connections, devices, cookies, domains)
- Cookies: paginated list with domain/search filter, detail, delete, batch delete
- Devices: list with online status, revoke
- Settings: get/update (sync interval, max devices, theme)

Uses scrypt for password hashing and jsonwebtoken for JWT.
Adds listAll/revoke to DeviceRegistry, getAll/getById/deleteById to CookieBlobStore,
disconnect to ConnectionManager. Updates frontend to use /admin/* endpoints.

All 38 existing tests pass.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
徐枫
2026-03-17 20:28:56 +08:00
parent f4144c96f1
commit a320f7ad97
14 changed files with 733 additions and 19 deletions

View File

@@ -1,7 +1,7 @@
import axios from "axios";
const api = axios.create({
baseURL: "/api",
baseURL: "/admin",
headers: { "Content-Type": "application/json" },
});

View File

@@ -8,7 +8,7 @@ export const useAuthStore = defineStore("auth", () => {
const isAuthenticated = computed(() => !!token.value);
async function login(username: string, password: string): Promise<void> {
const { data } = await api.post("/auth/login", { username, password });
const { data } = await api.post("/auth/login", { username, password }, { baseURL: "/admin" });
token.value = data.token;
localStorage.setItem("cb_admin_token", data.token);
}

View File

@@ -27,9 +27,10 @@ export const useCookiesStore = defineStore("cookies", () => {
loading.value = true;
error.value = null;
try {
const params = domain ? { domain } : {};
const params: Record<string, string> = { limit: "200" };
if (domain) params.domain = domain;
const { data } = await api.get("/cookies", { params });
cookies.value = data.cookies;
cookies.value = data.items ?? data.cookies ?? [];
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : "Failed to fetch cookies";
} finally {
@@ -42,7 +43,13 @@ export const useCookiesStore = defineStore("cookies", () => {
cookieName: string,
path: string,
): Promise<void> {
await api.delete("/cookies", { data: { domain, cookieName, path } });
// Find the cookie ID first, then delete by ID
const cookie = cookies.value.find(
(c) => c.domain === domain && c.cookieName === cookieName && c.path === path,
);
if (cookie) {
await api.delete(`/cookies/${cookie.id}`);
}
cookies.value = cookies.value.filter(
(c) =>
!(c.domain === domain && c.cookieName === cookieName && c.path === path),

View File

@@ -22,7 +22,7 @@ export const useDevicesStore = defineStore("devices", () => {
}
async function revokeDevice(deviceId: string): Promise<void> {
await api.delete(`/devices/${deviceId}`);
await api.post(`/devices/${deviceId}/revoke`);
devices.value = devices.value.filter((d) => d.deviceId !== deviceId);
}

View File

@@ -1,15 +1,22 @@
<script setup lang="ts">
import { onMounted, ref } from "vue";
import api from "@/api/client";
import type { HealthStatus } from "@/types/api";
const health = ref<HealthStatus | null>(null);
interface DashboardData {
connections: number;
totalDevices: number;
onlineDevices: number;
totalCookies: number;
uniqueDomains: number;
}
const dashboard = ref<DashboardData | null>(null);
const loading = ref(true);
onMounted(async () => {
try {
const { data } = await api.get("/health", { baseURL: "" });
health.value = data;
const { data } = await api.get("/dashboard");
dashboard.value = data;
} catch {
// Server might be down
} finally {
@@ -23,26 +30,49 @@ onMounted(async () => {
<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 class="mt-6 grid grid-cols-1 gap-4 sm:grid-cols-3">
<div class="mt-6 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<!-- Server Status -->
<div class="rounded-xl bg-white p-6 ring-1 ring-gray-200">
<p class="text-sm font-medium text-gray-500">Server Status</p>
<div class="mt-2 flex items-center gap-2">
<span
class="inline-block h-2.5 w-2.5 rounded-full"
:class="health?.status === 'ok' ? 'bg-green-500' : 'bg-red-500'"
:class="dashboard ? 'bg-green-500' : 'bg-red-500'"
/>
<span class="text-lg font-semibold text-gray-900">
{{ loading ? "Checking..." : health?.status === "ok" ? "Online" : "Offline" }}
{{ loading ? "Checking..." : dashboard ? "Online" : "Offline" }}
</span>
</div>
</div>
<!-- Devices -->
<div class="rounded-xl bg-white p-6 ring-1 ring-gray-200">
<p class="text-sm font-medium text-gray-500">Devices</p>
<p class="mt-2 text-3xl font-semibold text-gray-900">
{{ dashboard?.onlineDevices ?? "—" }}
<span class="text-base font-normal text-gray-400">
/ {{ dashboard?.totalDevices ?? "—" }}
</span>
</p>
<p class="mt-1 text-xs text-gray-500">online / total</p>
</div>
<!-- Cookies -->
<div class="rounded-xl bg-white p-6 ring-1 ring-gray-200">
<p class="text-sm font-medium text-gray-500">Cookies</p>
<p class="mt-2 text-3xl font-semibold text-gray-900">
{{ dashboard?.totalCookies ?? "—" }}
</p>
<p class="mt-1 text-xs text-gray-500">
across {{ dashboard?.uniqueDomains ?? "—" }} domains
</p>
</div>
<!-- Active Connections -->
<div class="rounded-xl bg-white p-6 ring-1 ring-gray-200">
<p class="text-sm font-medium text-gray-500">Active Connections</p>
<p class="text-sm font-medium text-gray-500">WebSocket Connections</p>
<p class="mt-2 text-3xl font-semibold text-gray-900">
{{ health?.connections ?? "—" }}
{{ dashboard?.connections ?? "—" }}
</p>
</div>

View File

@@ -17,6 +17,10 @@ export default defineConfig({
target: "http://localhost:8100",
changeOrigin: true,
},
"/admin": {
target: "http://localhost:8100",
changeOrigin: true,
},
"/ws": {
target: "ws://localhost:8100",
ws: true,