feat: scaffold Vue 3 + TypeScript + Vite frontend admin panel
Set up web/ directory with complete frontend scaffolding: - Vue 3 + TypeScript + Vite with Tailwind CSS v4 - Vue Router with auth guard (redirects to /login when unauthenticated) - Pinia stores: auth, cookies, devices, settings - Axios HTTP client with token interceptor - Views: Login, Dashboard, Cookies, Devices, Settings - Vite dev server proxy to relay API on port 8100 - Headless UI and Heroicons dependencies Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
web/node_modules/
|
||||
web/dist/
|
||||
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
28
web/package.json
Normal file
28
web/package.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "cookiebridge-web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
3
web/src/App.vue
Normal file
3
web/src/App.vue
Normal file
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
29
web/src/api/client.ts
Normal file
29
web/src/api/client.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import axios from "axios";
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: "/api",
|
||||
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
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
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");
|
||||
55
web/src/router/index.ts
Normal file
55
web/src/router/index.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { createRouter, createWebHistory, type RouteRecordRaw } from "vue-router";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: "/login",
|
||||
name: "login",
|
||||
component: () => import("@/views/LoginView.vue"),
|
||||
meta: { requiresAuth: false },
|
||||
},
|
||||
{
|
||||
path: "/",
|
||||
component: () => import("@/components/layout/AppLayout.vue"),
|
||||
meta: { requiresAuth: true },
|
||||
children: [
|
||||
{
|
||||
path: "",
|
||||
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,
|
||||
});
|
||||
|
||||
router.beforeEach((to) => {
|
||||
const auth = useAuthStore();
|
||||
if (to.meta.requiresAuth !== false && !auth.isAuthenticated) {
|
||||
return { name: "login" };
|
||||
}
|
||||
if (to.name === "login" && auth.isAuthenticated) {
|
||||
return { name: "dashboard" };
|
||||
}
|
||||
});
|
||||
|
||||
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 });
|
||||
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 };
|
||||
});
|
||||
53
web/src/stores/cookies.ts
Normal file
53
web/src/stores/cookies.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
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 = domain ? { domain } : {};
|
||||
const { data } = await api.get("/cookies", { params });
|
||||
cookies.value = 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> {
|
||||
await api.delete("/cookies", { data: { domain, cookieName, path } });
|
||||
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.delete(`/devices/${deviceId}`);
|
||||
devices.value = devices.value.filter((d) => d.deviceId !== deviceId);
|
||||
}
|
||||
|
||||
return { devices, loading, error, fetchDevices, revokeDevice };
|
||||
});
|
||||
41
web/src/stores/settings.ts
Normal file
41
web/src/stores/settings.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
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";
|
||||
}
|
||||
|
||||
const DEFAULT_SETTINGS: AppSettings = {
|
||||
syncIntervalMs: 30_000,
|
||||
maxDevices: 10,
|
||||
autoSync: true,
|
||||
theme: "system",
|
||||
};
|
||||
|
||||
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 };
|
||||
} catch {
|
||||
// Use defaults if settings endpoint doesn't exist yet
|
||||
} 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;
|
||||
}
|
||||
103
web/src/views/CookiesView.vue
Normal file
103
web/src/views/CookiesView.vue
Normal file
@@ -0,0 +1,103 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from "vue";
|
||||
import { useCookiesStore } from "@/stores/cookies";
|
||||
|
||||
const store = useCookiesStore();
|
||||
const search = ref("");
|
||||
const selectedDomain = ref<string | null>(null);
|
||||
|
||||
onMounted(() => store.fetchCookies());
|
||||
|
||||
function selectDomain(domain: string | null) {
|
||||
selectedDomain.value = domain;
|
||||
store.fetchCookies(domain ?? undefined);
|
||||
}
|
||||
|
||||
async function handleDelete(domain: string, cookieName: string, path: string) {
|
||||
await store.deleteCookie(domain, cookieName, path);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-8">
|
||||
<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">Manage synced encrypted cookies</p>
|
||||
</div>
|
||||
<input
|
||||
v-model="search"
|
||||
type="text"
|
||||
placeholder="Search cookies..."
|
||||
class="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>
|
||||
|
||||
<!-- Domain filter chips -->
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
<button
|
||||
class="rounded-full px-3 py-1 text-xs font-medium"
|
||||
:class="selectedDomain === null ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'"
|
||||
@click="selectDomain(null)"
|
||||
>
|
||||
All
|
||||
</button>
|
||||
<button
|
||||
v-for="domain in store.domains"
|
||||
:key="domain"
|
||||
class="rounded-full px-3 py-1 text-xs font-medium"
|
||||
:class="selectedDomain === domain ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'"
|
||||
@click="selectDomain(domain)"
|
||||
>
|
||||
{{ domain }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Cookie table -->
|
||||
<div class="mt-6 overflow-hidden rounded-xl bg-white ring-1 ring-gray-200">
|
||||
<div v-if="store.loading" class="p-8 text-center text-sm text-gray-500">Loading...</div>
|
||||
<div v-else-if="store.cookies.length === 0" class="p-8 text-center text-sm text-gray-500">
|
||||
No cookies found
|
||||
</div>
|
||||
<table v-else 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">Domain</th>
|
||||
<th class="px-4 py-3 font-medium text-gray-600">Name</th>
|
||||
<th class="px-4 py-3 font-medium text-gray-600">Path</th>
|
||||
<th class="px-4 py-3 font-medium text-gray-600">Device</th>
|
||||
<th class="px-4 py-3 font-medium text-gray-600">Updated</th>
|
||||
<th class="px-4 py-3 font-medium text-gray-600"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
<tr
|
||||
v-for="cookie in store.cookies.filter(
|
||||
(c) => !search || c.domain.includes(search) || c.cookieName.includes(search),
|
||||
)"
|
||||
:key="cookie.id"
|
||||
class="hover:bg-gray-50"
|
||||
>
|
||||
<td class="px-4 py-3 font-mono text-xs">{{ cookie.domain }}</td>
|
||||
<td class="px-4 py-3">{{ cookie.cookieName }}</td>
|
||||
<td class="px-4 py-3 font-mono text-xs">{{ cookie.path }}</td>
|
||||
<td class="px-4 py-3 font-mono text-xs truncate max-w-[120px]">
|
||||
{{ cookie.deviceId.slice(0, 12) }}...
|
||||
</td>
|
||||
<td class="px-4 py-3 text-gray-500">
|
||||
{{ new Date(cookie.updatedAt).toLocaleString() }}
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<button
|
||||
class="text-red-600 hover:text-red-800 text-xs font-medium"
|
||||
@click="handleDelete(cookie.domain, cookie.cookieName, cookie.path)"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
69
web/src/views/DashboardView.vue
Normal file
69
web/src/views/DashboardView.vue
Normal file
@@ -0,0 +1,69 @@
|
||||
<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);
|
||||
const loading = ref(true);
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const { data } = await api.get("/health", { baseURL: "" });
|
||||
health.value = data;
|
||||
} catch {
|
||||
// Server might be down
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-8">
|
||||
<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">
|
||||
<!-- 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'"
|
||||
/>
|
||||
<span class="text-lg font-semibold text-gray-900">
|
||||
{{ loading ? "Checking..." : health?.status === "ok" ? "Online" : "Offline" }}
|
||||
</span>
|
||||
</div>
|
||||
</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="mt-2 text-3xl font-semibold text-gray-900">
|
||||
{{ health?.connections ?? "—" }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="rounded-xl bg-white p-6 ring-1 ring-gray-200">
|
||||
<p class="text-sm font-medium text-gray-500">Quick Actions</p>
|
||||
<div class="mt-3 flex flex-col gap-2">
|
||||
<router-link
|
||||
to="/devices"
|
||||
class="text-sm font-medium text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
Manage devices →
|
||||
</router-link>
|
||||
<router-link
|
||||
to="/cookies"
|
||||
class="text-sm font-medium text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
View cookies →
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
63
web/src/views/DevicesView.vue
Normal file
63
web/src/views/DevicesView.vue
Normal file
@@ -0,0 +1,63 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from "vue";
|
||||
import { useDevicesStore } from "@/stores/devices";
|
||||
|
||||
const store = useDevicesStore();
|
||||
|
||||
onMounted(() => store.fetchDevices());
|
||||
|
||||
async function handleRevoke(deviceId: string) {
|
||||
if (confirm("Revoke this device? It will need to re-register.")) {
|
||||
await store.revokeDevice(deviceId);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-8">
|
||||
<h2 class="text-2xl font-semibold text-gray-900">Devices</h2>
|
||||
<p class="mt-1 text-sm text-gray-500">Registered devices and their status</p>
|
||||
|
||||
<div v-if="store.loading" class="mt-6 text-sm text-gray-500">Loading...</div>
|
||||
<div v-else-if="store.devices.length === 0" class="mt-6 text-sm text-gray-500">
|
||||
No devices registered
|
||||
</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 store.devices"
|
||||
:key="device.deviceId"
|
||||
class="rounded-xl bg-white p-5 ring-1 ring-gray-200"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="font-medium text-gray-900">{{ device.name }}</h3>
|
||||
<span
|
||||
class="inline-flex items-center rounded-full bg-green-50 px-2 py-0.5 text-xs font-medium text-green-700"
|
||||
>
|
||||
Online
|
||||
</span>
|
||||
</div>
|
||||
<dl class="mt-3 space-y-1 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-gray-500">Platform</dt>
|
||||
<dd class="text-gray-900">{{ device.platform }}</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>
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-gray-500">Registered</dt>
|
||||
<dd class="text-gray-900">{{ new Date(device.createdAt).toLocaleDateString() }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<button
|
||||
class="mt-4 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="handleRevoke(device.deviceId)"
|
||||
>
|
||||
Revoke Device
|
||||
</button>
|
||||
</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("/");
|
||||
} 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"
|
||||
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>
|
||||
92
web/src/views/SettingsView.vue
Normal file
92
web/src/views/SettingsView.vue
Normal file
@@ -0,0 +1,92 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from "vue";
|
||||
import { useSettingsStore } from "@/stores/settings";
|
||||
|
||||
const store = useSettingsStore();
|
||||
|
||||
onMounted(() => store.fetchSettings());
|
||||
|
||||
async function save() {
|
||||
await store.updateSettings(store.settings);
|
||||
}
|
||||
</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 and security settings</p>
|
||||
|
||||
<div class="mt-6 max-w-lg space-y-6">
|
||||
<!-- Sync Settings -->
|
||||
<section class="rounded-xl bg-white p-6 ring-1 ring-gray-200">
|
||||
<h3 class="font-medium text-gray-900">Sync</h3>
|
||||
<div class="mt-4 space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="text-sm text-gray-700">Auto-sync</label>
|
||||
<button
|
||||
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>
|
||||
<div>
|
||||
<label class="block text-sm text-gray-700">Sync interval (seconds)</label>
|
||||
<input
|
||||
:value="store.settings.syncIntervalMs / 1000"
|
||||
type="number"
|
||||
min="5"
|
||||
max="300"
|
||||
class="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
|
||||
@input="store.settings.syncIntervalMs = Number(($event.target as HTMLInputElement).value) * 1000"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Security Settings -->
|
||||
<section class="rounded-xl bg-white p-6 ring-1 ring-gray-200">
|
||||
<h3 class="font-medium text-gray-900">Security</h3>
|
||||
<div class="mt-4 space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm text-gray-700">Max devices</label>
|
||||
<input
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Appearance -->
|
||||
<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">
|
||||
<label class="block text-sm text-gray-700">Theme</label>
|
||||
<select
|
||||
v-model="store.settings.theme"
|
||||
class="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="system">System</option>
|
||||
<option value="light">Light</option>
|
||||
<option value="dark">Dark</option>
|
||||
</select>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<button
|
||||
class="w-full rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
|
||||
@click="save"
|
||||
>
|
||||
Save Settings
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
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"]
|
||||
}
|
||||
30
web/vite.config.ts
Normal file
30
web/vite.config.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
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,
|
||||
},
|
||||
"/ws": {
|
||||
target: "ws://localhost:8100",
|
||||
ws: true,
|
||||
},
|
||||
"/health": {
|
||||
target: "http://localhost:8100",
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user