feat: enhance all frontend management views
Dashboard (RCA-15): - Stats cards: server status, devices (online/offline breakdown), cookies by domain count, WebSocket connections - Device status table with online badges - Quick action cards linking to cookies, devices, settings Cookies (RCA-16): - Domain-grouped collapsible list with expand/collapse - Search by domain or cookie name - Right-side detail panel showing all cookie fields (Headless UI transition) - Checkbox selection + batch delete - Per-cookie inline delete Devices (RCA-17): - Card grid with platform icons (CH/FF/ED/SA), online/offline badges - Status filter tabs (All/Online/Offline) - Expandable details (full device ID, platform, registration date) - Two-step revoke confirmation dialog inline Settings (RCA-18): - Headless UI Tab component with 3 tabs: Sync, Security, Appearance - Sync: auto-sync toggle, frequency selector (real-time/1m/5m/manual) - Security: change password form, max devices - Appearance: theme picker (light/dark/system), language selector - Save with success toast notification Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -1,37 +1,117 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref } from "vue";
|
import { onMounted, ref, computed } from "vue";
|
||||||
|
import { TransitionRoot } from "@headlessui/vue";
|
||||||
import { useCookiesStore } from "@/stores/cookies";
|
import { useCookiesStore } from "@/stores/cookies";
|
||||||
|
import type { EncryptedCookieBlob } from "@/types/api";
|
||||||
|
|
||||||
const store = useCookiesStore();
|
const store = useCookiesStore();
|
||||||
const search = ref("");
|
const search = ref("");
|
||||||
const selectedDomain = ref<string | null>(null);
|
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());
|
||||||
|
|
||||||
onMounted(() => store.fetchCookies());
|
onMounted(() => store.fetchCookies());
|
||||||
|
|
||||||
|
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) {
|
function selectDomain(domain: string | null) {
|
||||||
selectedDomain.value = domain;
|
selectedDomain.value = domain;
|
||||||
store.fetchCookies(domain ?? undefined);
|
store.fetchCookies(domain ?? undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDelete(domain: string, cookieName: string, path: string) {
|
function toggleDomain(domain: string) {
|
||||||
await store.deleteCookie(domain, cookieName, path);
|
if (expandedDomains.value.has(domain)) {
|
||||||
|
expandedDomains.value.delete(domain);
|
||||||
|
} else {
|
||||||
|
expandedDomains.value.add(domain);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectCookie(cookie: EncryptedCookieBlob) {
|
||||||
|
selectedCookie.value = cookie;
|
||||||
|
}
|
||||||
|
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(cookie: EncryptedCookieBlob) {
|
||||||
|
await store.deleteCookie(cookie.domain, cookie.cookieName, cookie.path);
|
||||||
|
if (selectedCookie.value?.id === cookie.id) {
|
||||||
|
selectedCookie.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleBatchDelete() {
|
||||||
|
if (selectedIds.value.size === 0) return;
|
||||||
|
if (!confirm(`Delete ${selectedIds.value.size} cookies?`)) return;
|
||||||
|
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();
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="p-8">
|
<div class="flex h-full">
|
||||||
|
<!-- Main content -->
|
||||||
|
<div class="flex-1 overflow-auto p-8">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-2xl font-semibold text-gray-900">Cookies</h2>
|
<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>
|
<p class="mt-1 text-sm text-gray-500">
|
||||||
|
{{ store.cookies.length }} cookies across {{ store.domains.length }} domains
|
||||||
|
</p>
|
||||||
</div>
|
</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="handleBatchDelete"
|
||||||
|
>
|
||||||
|
Delete ({{ selectedIds.size }})
|
||||||
|
</button>
|
||||||
<input
|
<input
|
||||||
v-model="search"
|
v-model="search"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search cookies..."
|
placeholder="Search domain or name..."
|
||||||
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"
|
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>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Domain filter chips -->
|
<!-- Domain filter chips -->
|
||||||
<div class="mt-4 flex flex-wrap gap-2">
|
<div class="mt-4 flex flex-wrap gap-2">
|
||||||
@@ -53,44 +133,84 @@ async function handleDelete(domain: string, cookieName: string, path: string) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Cookie table -->
|
<!-- Grouped cookie list -->
|
||||||
<div class="mt-6 overflow-hidden rounded-xl bg-white ring-1 ring-gray-200">
|
<div class="mt-6 space-y-3">
|
||||||
<div v-if="store.loading" class="p-8 text-center text-sm text-gray-500">Loading...</div>
|
<div v-if="store.loading" class="rounded-xl bg-white p-8 text-center text-sm text-gray-500 ring-1 ring-gray-200">
|
||||||
<div v-else-if="store.cookies.length === 0" class="p-8 text-center text-sm text-gray-500">
|
Loading...
|
||||||
|
</div>
|
||||||
|
<div v-else-if="filteredCookies.length === 0" class="rounded-xl bg-white p-8 text-center text-sm text-gray-500 ring-1 ring-gray-200">
|
||||||
No cookies found
|
No cookies found
|
||||||
</div>
|
</div>
|
||||||
<table v-else class="w-full text-left text-sm">
|
|
||||||
<thead class="border-b border-gray-200 bg-gray-50">
|
<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>
|
<tr>
|
||||||
<th class="px-4 py-3 font-medium text-gray-600">Domain</th>
|
<th class="w-8 px-4 py-2">
|
||||||
<th class="px-4 py-3 font-medium text-gray-600">Name</th>
|
<input
|
||||||
<th class="px-4 py-3 font-medium text-gray-600">Path</th>
|
type="checkbox"
|
||||||
<th class="px-4 py-3 font-medium text-gray-600">Device</th>
|
class="rounded border-gray-300"
|
||||||
<th class="px-4 py-3 font-medium text-gray-600">Updated</th>
|
@change="toggleSelectAll()"
|
||||||
<th class="px-4 py-3 font-medium text-gray-600"></th>
|
/>
|
||||||
|
</th>
|
||||||
|
<th class="px-4 py-2 font-medium text-gray-600">Name</th>
|
||||||
|
<th class="px-4 py-2 font-medium text-gray-600">Path</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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-gray-100">
|
<tbody class="divide-y divide-gray-50">
|
||||||
<tr
|
<tr
|
||||||
v-for="cookie in store.cookies.filter(
|
v-for="cookie in cookies"
|
||||||
(c) => !search || c.domain.includes(search) || c.cookieName.includes(search),
|
|
||||||
)"
|
|
||||||
:key="cookie.id"
|
:key="cookie.id"
|
||||||
class="hover:bg-gray-50"
|
class="cursor-pointer hover:bg-blue-50"
|
||||||
|
:class="selectedCookie?.id === cookie.id ? 'bg-blue-50' : ''"
|
||||||
|
@click="selectCookie(cookie)"
|
||||||
>
|
>
|
||||||
<td class="px-4 py-3 font-mono text-xs">{{ cookie.domain }}</td>
|
<td class="px-4 py-2" @click.stop>
|
||||||
<td class="px-4 py-3">{{ cookie.cookieName }}</td>
|
<input
|
||||||
<td class="px-4 py-3 font-mono text-xs">{{ cookie.path }}</td>
|
type="checkbox"
|
||||||
<td class="px-4 py-3 font-mono text-xs truncate max-w-[120px]">
|
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) }}...
|
{{ cookie.deviceId.slice(0, 12) }}...
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3 text-gray-500">
|
<td class="px-4 py-2 text-xs text-gray-500">
|
||||||
{{ new Date(cookie.updatedAt).toLocaleString() }}
|
{{ new Date(cookie.updatedAt).toLocaleString() }}
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3">
|
<td class="px-4 py-2" @click.stop>
|
||||||
<button
|
<button
|
||||||
class="text-red-600 hover:text-red-800 text-xs font-medium"
|
class="text-red-600 hover:text-red-800 text-xs font-medium"
|
||||||
@click="handleDelete(cookie.domain, cookie.cookieName, cookie.path)"
|
@click="handleDelete(cookie)"
|
||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
@@ -100,4 +220,77 @@ async function handleDelete(domain: string, cookieName: string, path: string) {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Detail panel -->
|
||||||
|
<TransitionRoot
|
||||||
|
:show="!!selectedCookie"
|
||||||
|
enter="transition-all duration-200"
|
||||||
|
enter-from="w-0 opacity-0"
|
||||||
|
enter-to="w-80 opacity-100"
|
||||||
|
leave="transition-all duration-150"
|
||||||
|
leave-from="w-80 opacity-100"
|
||||||
|
leave-to="w-0 opacity-0"
|
||||||
|
>
|
||||||
|
<aside
|
||||||
|
v-if="selectedCookie"
|
||||||
|
class="w-80 shrink-0 overflow-auto border-l border-gray-200 bg-white p-6"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h3 class="text-sm font-semibold text-gray-900">Cookie Details</h3>
|
||||||
|
<button
|
||||||
|
class="text-gray-400 hover:text-gray-600"
|
||||||
|
@click="selectedCookie = null"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl class="mt-4 space-y-3 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">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">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>
|
||||||
|
<div>
|
||||||
|
<dt class="font-medium text-gray-500">Value (encrypted)</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>
|
||||||
|
</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="handleDelete(selectedCookie)"
|
||||||
|
>
|
||||||
|
Delete Cookie
|
||||||
|
</button>
|
||||||
|
</aside>
|
||||||
|
</TransitionRoot>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref } from "vue";
|
import { onMounted, ref, computed } from "vue";
|
||||||
import api from "@/api/client";
|
import api from "@/api/client";
|
||||||
|
|
||||||
interface DashboardData {
|
interface DashboardData {
|
||||||
@@ -10,13 +10,39 @@ interface DashboardData {
|
|||||||
uniqueDomains: number;
|
uniqueDomains: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface DeviceSummary {
|
||||||
|
deviceId: string;
|
||||||
|
name: string;
|
||||||
|
platform: string;
|
||||||
|
online: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
const dashboard = ref<DashboardData | null>(null);
|
const dashboard = ref<DashboardData | null>(null);
|
||||||
|
const devices = ref<DeviceSummary[]>([]);
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
|
|
||||||
|
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";
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
const { data } = await api.get("/dashboard");
|
const [dashRes, devRes] = await Promise.all([
|
||||||
dashboard.value = data;
|
api.get("/dashboard"),
|
||||||
|
api.get("/devices"),
|
||||||
|
]);
|
||||||
|
dashboard.value = dashRes.data;
|
||||||
|
devices.value = devRes.data.devices ?? [];
|
||||||
} catch {
|
} catch {
|
||||||
// Server might be down
|
// Server might be down
|
||||||
} finally {
|
} finally {
|
||||||
@@ -30,8 +56,11 @@ onMounted(async () => {
|
|||||||
<h2 class="text-2xl font-semibold text-gray-900">Dashboard</h2>
|
<h2 class="text-2xl font-semibold text-gray-900">Dashboard</h2>
|
||||||
<p class="mt-1 text-sm text-gray-500">CookieBridge relay server overview</p>
|
<p class="mt-1 text-sm text-gray-500">CookieBridge relay server overview</p>
|
||||||
|
|
||||||
<div class="mt-6 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
<div v-if="loading" class="mt-8 text-sm text-gray-500">Loading...</div>
|
||||||
<!-- Server Status -->
|
|
||||||
|
<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">
|
<div class="rounded-xl bg-white p-6 ring-1 ring-gray-200">
|
||||||
<p class="text-sm font-medium text-gray-500">Server Status</p>
|
<p class="text-sm font-medium text-gray-500">Server Status</p>
|
||||||
<div class="mt-2 flex items-center gap-2">
|
<div class="mt-2 flex items-center gap-2">
|
||||||
@@ -40,60 +69,143 @@ onMounted(async () => {
|
|||||||
:class="dashboard ? 'bg-green-500' : 'bg-red-500'"
|
:class="dashboard ? 'bg-green-500' : 'bg-red-500'"
|
||||||
/>
|
/>
|
||||||
<span class="text-lg font-semibold text-gray-900">
|
<span class="text-lg font-semibold text-gray-900">
|
||||||
{{ loading ? "Checking..." : dashboard ? "Online" : "Offline" }}
|
{{ dashboard ? "Online" : "Offline" }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Devices -->
|
|
||||||
<div class="rounded-xl bg-white p-6 ring-1 ring-gray-200">
|
<div class="rounded-xl bg-white p-6 ring-1 ring-gray-200">
|
||||||
<p class="text-sm font-medium text-gray-500">Devices</p>
|
<p class="text-sm font-medium text-gray-500">Devices</p>
|
||||||
<p class="mt-2 text-3xl font-semibold text-gray-900">
|
<p class="mt-2 text-3xl font-semibold text-gray-900">
|
||||||
{{ dashboard?.onlineDevices ?? "—" }}
|
{{ dashboard?.onlineDevices ?? 0 }}
|
||||||
<span class="text-base font-normal text-gray-400">
|
<span class="text-base font-normal text-gray-400">
|
||||||
/ {{ dashboard?.totalDevices ?? "—" }}
|
/ {{ dashboard?.totalDevices ?? 0 }}
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
<p class="mt-1 text-xs text-gray-500">online / total</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>
|
||||||
|
|
||||||
<!-- Cookies -->
|
|
||||||
<div class="rounded-xl bg-white p-6 ring-1 ring-gray-200">
|
<div class="rounded-xl bg-white p-6 ring-1 ring-gray-200">
|
||||||
<p class="text-sm font-medium text-gray-500">Cookies</p>
|
<p class="text-sm font-medium text-gray-500">Cookies</p>
|
||||||
<p class="mt-2 text-3xl font-semibold text-gray-900">
|
<p class="mt-2 text-3xl font-semibold text-gray-900">
|
||||||
{{ dashboard?.totalCookies ?? "—" }}
|
{{ dashboard?.totalCookies ?? 0 }}
|
||||||
</p>
|
</p>
|
||||||
<p class="mt-1 text-xs text-gray-500">
|
<p class="mt-1 text-xs text-gray-500">
|
||||||
across {{ dashboard?.uniqueDomains ?? "—" }} domains
|
across {{ dashboard?.uniqueDomains ?? 0 }} domains
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Active Connections -->
|
|
||||||
<div class="rounded-xl bg-white p-6 ring-1 ring-gray-200">
|
<div class="rounded-xl bg-white p-6 ring-1 ring-gray-200">
|
||||||
<p class="text-sm font-medium text-gray-500">WebSocket Connections</p>
|
<p class="text-sm font-medium text-gray-500">WebSocket Connections</p>
|
||||||
<p class="mt-2 text-3xl font-semibold text-gray-900">
|
<p class="mt-2 text-3xl font-semibold text-gray-900">
|
||||||
{{ dashboard?.connections ?? "—" }}
|
{{ dashboard?.connections ?? 0 }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Quick Actions -->
|
<!-- Device status list -->
|
||||||
<div class="rounded-xl bg-white p-6 ring-1 ring-gray-200">
|
<div class="mt-8">
|
||||||
<p class="text-sm font-medium text-gray-500">Quick Actions</p>
|
<div class="flex items-center justify-between">
|
||||||
<div class="mt-3 flex flex-col gap-2">
|
<h3 class="text-lg font-medium text-gray-900">Device Status</h3>
|
||||||
<router-link
|
<router-link to="/devices" class="text-sm font-medium text-blue-600 hover:text-blue-800">
|
||||||
to="/devices"
|
View all →
|
||||||
class="text-sm font-medium text-blue-600 hover:text-blue-800"
|
|
||||||
>
|
|
||||||
Manage devices →
|
|
||||||
</router-link>
|
</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
|
<router-link
|
||||||
to="/cookies"
|
to="/cookies"
|
||||||
class="text-sm font-medium text-blue-600 hover:text-blue-800"
|
class="flex items-center gap-3 rounded-xl bg-white p-5 ring-1 ring-gray-200 hover:ring-blue-300 transition-shadow"
|
||||||
>
|
>
|
||||||
View cookies →
|
<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>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,63 +1,198 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted } from "vue";
|
import { onMounted, ref, computed } from "vue";
|
||||||
import { useDevicesStore } from "@/stores/devices";
|
import api from "@/api/client";
|
||||||
|
|
||||||
const store = useDevicesStore();
|
interface DeviceEntry {
|
||||||
|
deviceId: string;
|
||||||
|
name: string;
|
||||||
|
platform: string;
|
||||||
|
createdAt: string;
|
||||||
|
online: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => store.fetchDevices());
|
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) {
|
async function handleRevoke(deviceId: string) {
|
||||||
if (confirm("Revoke this device? It will need to re-register.")) {
|
revoking.value = deviceId;
|
||||||
await store.revokeDevice(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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="p-8">
|
<div class="p-8">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
<h2 class="text-2xl font-semibold text-gray-900">Devices</h2>
|
<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>
|
<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="store.loading" class="mt-6 text-sm text-gray-500">Loading...</div>
|
<div v-if="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">
|
<div v-else-if="filtered.length === 0" class="mt-6 text-sm text-gray-500">
|
||||||
No devices registered
|
No devices {{ filter !== "all" ? `(${filter})` : "" }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="mt-6 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
<div v-else class="mt-6 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
<div
|
<div
|
||||||
v-for="device in store.devices"
|
v-for="device in filtered"
|
||||||
:key="device.deviceId"
|
:key="device.deviceId"
|
||||||
class="rounded-xl bg-white p-5 ring-1 ring-gray-200"
|
class="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 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>
|
<h3 class="font-medium text-gray-900">{{ device.name }}</h3>
|
||||||
|
<p class="text-xs text-gray-500">{{ platformLabel(device.platform) }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<span
|
<span
|
||||||
class="inline-flex items-center rounded-full bg-green-50 px-2 py-0.5 text-xs font-medium text-green-700"
|
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'"
|
||||||
>
|
>
|
||||||
Online
|
<span
|
||||||
|
class="h-1.5 w-1.5 rounded-full"
|
||||||
|
:class="device.online ? 'bg-green-500' : 'bg-gray-400'"
|
||||||
|
/>
|
||||||
|
{{ device.online ? "Online" : "Offline" }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<dl class="mt-3 space-y-1 text-sm">
|
|
||||||
|
<dl class="mt-4 space-y-1.5 text-sm">
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
<dt class="text-gray-500">Platform</dt>
|
<dt class="text-gray-500">Registered</dt>
|
||||||
<dd class="text-gray-900">{{ device.platform }}</dd>
|
<dd class="text-gray-900">{{ new Date(device.createdAt).toLocaleDateString() }}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
<dt class="text-gray-500">Device ID</dt>
|
<dt class="text-gray-500">Device ID</dt>
|
||||||
<dd class="font-mono text-xs text-gray-600">{{ device.deviceId.slice(0, 16) }}...</dd>
|
<dd class="font-mono text-xs text-gray-600">{{ device.deviceId.slice(0, 16) }}...</dd>
|
||||||
</div>
|
</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">
|
<div class="flex justify-between">
|
||||||
<dt class="text-gray-500">Registered</dt>
|
<dt class="text-gray-500">Registered</dt>
|
||||||
<dd class="text-gray-900">{{ new Date(device.createdAt).toLocaleDateString() }}</dd>
|
<dd class="text-gray-700">{{ new Date(device.createdAt).toLocaleString() }}</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</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
|
<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"
|
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)"
|
@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"
|
||||||
>
|
>
|
||||||
Revoke Device
|
Revoke Device
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,28 +1,112 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted } from "vue";
|
import { onMounted, ref } from "vue";
|
||||||
|
import { TabGroup, TabList, Tab, TabPanels, TabPanel } from "@headlessui/vue";
|
||||||
import { useSettingsStore } from "@/stores/settings";
|
import { useSettingsStore } from "@/stores/settings";
|
||||||
|
import api from "@/api/client";
|
||||||
|
|
||||||
const store = useSettingsStore();
|
const store = useSettingsStore();
|
||||||
|
const saving = ref(false);
|
||||||
|
const saved = ref(false);
|
||||||
|
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(() => store.fetchSettings());
|
onMounted(() => store.fetchSettings());
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
|
saving.value = true;
|
||||||
|
saved.value = false;
|
||||||
|
try {
|
||||||
await store.updateSettings(store.settings);
|
await store.updateSettings(store.settings);
|
||||||
|
saved.value = true;
|
||||||
|
setTimeout(() => (saved.value = false), 2000);
|
||||||
|
} catch {
|
||||||
|
// Error handled by store
|
||||||
|
} 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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="p-8">
|
<div class="p-8">
|
||||||
<h2 class="text-2xl font-semibold text-gray-900">Settings</h2>
|
<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>
|
<p class="mt-1 text-sm text-gray-500">Configure sync, security, and appearance</p>
|
||||||
|
|
||||||
<div class="mt-6 max-w-lg space-y-6">
|
<!-- 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>
|
||||||
|
|
||||||
|
<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 -->
|
<!-- Sync Settings -->
|
||||||
|
<TabPanel class="space-y-6">
|
||||||
<section class="rounded-xl bg-white p-6 ring-1 ring-gray-200">
|
<section class="rounded-xl bg-white p-6 ring-1 ring-gray-200">
|
||||||
<h3 class="font-medium text-gray-900">Sync</h3>
|
<h3 class="font-medium text-gray-900">Sync Configuration</h3>
|
||||||
<div class="mt-4 space-y-4">
|
<div class="mt-4 space-y-5">
|
||||||
|
<!-- Auto-sync toggle -->
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<label class="text-sm text-gray-700">Auto-sync</label>
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-700">Auto-sync</p>
|
||||||
|
<p class="text-xs text-gray-500">Automatically sync cookies between devices</p>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||||
:class="store.settings.autoSync ? 'bg-blue-600' : 'bg-gray-200'"
|
:class="store.settings.autoSync ? 'bg-blue-600' : 'bg-gray-200'"
|
||||||
@@ -34,26 +118,85 @@ async function save() {
|
|||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Sync frequency -->
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm text-gray-700">Sync interval (seconds)</label>
|
<label class="block text-sm font-medium text-gray-700">Sync Frequency</label>
|
||||||
<input
|
<select
|
||||||
:value="store.settings.syncIntervalMs / 1000"
|
v-model="store.settings.syncIntervalMs"
|
||||||
type="number"
|
|
||||||
min="5"
|
|
||||||
max="300"
|
|
||||||
class="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
|
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"
|
>
|
||||||
/>
|
<option
|
||||||
|
v-for="opt in syncFrequencyOptions"
|
||||||
|
:key="opt.value"
|
||||||
|
:value="opt.value"
|
||||||
|
>
|
||||||
|
{{ opt.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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 -->
|
<!-- Security Settings -->
|
||||||
|
<TabPanel class="space-y-6">
|
||||||
|
<!-- Change password -->
|
||||||
<section class="rounded-xl bg-white p-6 ring-1 ring-gray-200">
|
<section class="rounded-xl bg-white p-6 ring-1 ring-gray-200">
|
||||||
<h3 class="font-medium text-gray-900">Security</h3>
|
<h3 class="font-medium text-gray-900">Change Password</h3>
|
||||||
|
<form class="mt-4 space-y-4" @submit.prevent="changePassword">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm text-gray-700">Current Password</label>
|
||||||
|
<input
|
||||||
|
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 class="block text-sm text-gray-700">New Password</label>
|
||||||
|
<input
|
||||||
|
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 class="block text-sm text-gray-700">Confirm New Password</label>
|
||||||
|
<input
|
||||||
|
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 class="mt-4 space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm text-gray-700">Max devices</label>
|
<label class="block text-sm text-gray-700">Max Devices</label>
|
||||||
<input
|
<input
|
||||||
v-model.number="store.settings.maxDevices"
|
v-model.number="store.settings.maxDevices"
|
||||||
type="number"
|
type="number"
|
||||||
@@ -61,32 +204,64 @@ async function save() {
|
|||||||
max="50"
|
max="50"
|
||||||
class="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
|
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>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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
|
<button
|
||||||
class="w-full rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
|
: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"
|
@click="save"
|
||||||
>
|
>
|
||||||
Save Settings
|
{{ 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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700">Language</label>
|
||||||
|
<select
|
||||||
|
class="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<option value="en">English</option>
|
||||||
|
<option value="zh">中文</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 Appearance" }}
|
||||||
|
</button>
|
||||||
|
</TabPanel>
|
||||||
|
</TabPanels>
|
||||||
|
</TabGroup>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Reference in New Issue
Block a user