From 73388a02a167eee599fd983a9ca8a76446b90955 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thi=C3=AAn=20To=C3=A1n?= Date: Fri, 20 Feb 2026 15:07:12 +0700 Subject: [PATCH] feat: add pause/resume functionality for API keys (#158) - Add isActive field to API key schema with migration - Implement PUT /api/keys/[id] endpoint for toggle - Update validation to reject paused keys (403) - Add UI toggle controls with confirmation - Ensure cloud sync preserves pause state --- docs/ARCHITECTURE.md | 1 + .../dashboard/endpoint/EndpointPageClient.js | 48 ++++++++++++++++--- src/app/api/keys/[id]/route.js | 42 +++++++++++++++- src/lib/localDb.js | 37 +++++++++++++- 4 files changed, 119 insertions(+), 9 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1a31ddaf..548c3190 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -362,6 +362,7 @@ erDiagram string name string key string machineId + boolean isActive } USAGE_ENTRY { diff --git a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js index 5ab015b2..9f725c43 100644 --- a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js +++ b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js @@ -272,6 +272,21 @@ export default function APIPageClient({ machineId }) { } }; + const handleToggleKey = async (id, isActive) => { + try { + const res = await fetch(`/api/keys/${id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ isActive }), + }); + if (res.ok) { + setKeys(prev => prev.map(k => k.id === id ? { ...k, isActive } : k)); + } + } catch (error) { + console.log("Error toggling key:", error); + } + }; + const [baseUrl, setBaseUrl] = useState("/v1"); const cloudEndpointNew = cloudUrl ? `${cloudUrl}/v1` : ""; @@ -405,7 +420,7 @@ export default function APIPageClient({ machineId }) { {keys.map((key) => (

{key.name}

@@ -423,13 +438,32 @@ export default function APIPageClient({ machineId }) {

Created {new Date(key.createdAt).toLocaleDateString()}

+ {key.isActive === false && ( +

Paused

+ )} +
+
+ { + if (key.isActive && !checked) { + if (confirm(`Pause API key "${key.name}"?\n\nThis key will stop working immediately but can be resumed later.`)) { + handleToggleKey(key.id, checked); + } + } else { + handleToggleKey(key.id, checked); + } + }} + title={key.isActive ? "Pause key" : "Resume key"} + /> +
-
))} diff --git a/src/app/api/keys/[id]/route.js b/src/app/api/keys/[id]/route.js index 825e2e78..1cfc0ff5 100644 --- a/src/app/api/keys/[id]/route.js +++ b/src/app/api/keys/[id]/route.js @@ -1,8 +1,48 @@ import { NextResponse } from "next/server"; -import { deleteApiKey, isCloudEnabled } from "@/lib/localDb"; +import { deleteApiKey, getApiKeyById, updateApiKey, isCloudEnabled } from "@/lib/localDb"; import { getConsistentMachineId } from "@/shared/utils/machineId"; import { syncToCloud } from "@/app/api/sync/cloud/route"; +// GET /api/keys/[id] - Get single key +export async function GET(request, { params }) { + try { + const { id } = await params; + const key = await getApiKeyById(id); + if (!key) { + return NextResponse.json({ error: "Key not found" }, { status: 404 }); + } + return NextResponse.json({ key }); + } catch (error) { + console.log("Error fetching key:", error); + return NextResponse.json({ error: "Failed to fetch key" }, { status: 500 }); + } +} + +// PUT /api/keys/[id] - Update key +export async function PUT(request, { params }) { + try { + const { id } = await params; + const body = await request.json(); + const { isActive } = body; + + const existing = await getApiKeyById(id); + if (!existing) { + return NextResponse.json({ error: "Key not found" }, { status: 404 }); + } + + const updateData = {}; + if (isActive !== undefined) updateData.isActive = isActive; + + const updated = await updateApiKey(id, updateData); + await syncKeysToCloudIfEnabled(); + + return NextResponse.json({ key: updated }); + } catch (error) { + console.log("Error updating key:", error); + return NextResponse.json({ error: "Failed to update key" }, { status: 500 }); + } +} + // DELETE /api/keys/[id] - Delete API key export async function DELETE(request, { params }) { try { diff --git a/src/lib/localDb.js b/src/lib/localDb.js index e7f675f7..34227a8c 100644 --- a/src/lib/localDb.js +++ b/src/lib/localDb.js @@ -115,6 +115,16 @@ function ensureDbShape(data) { } } } + + // Migrate existing API keys to have isActive + if (key === "apiKeys" && Array.isArray(next.apiKeys)) { + for (const apiKey of next.apiKeys) { + if (apiKey.isActive === undefined || apiKey.isActive === null) { + apiKey.isActive = true; + changed = true; + } + } + } } return { data: next, changed }; @@ -649,6 +659,7 @@ export async function createApiKey(name, machineId) { name: name, key: result.key, machineId: machineId, + isActive: true, createdAt: now, }; @@ -673,12 +684,36 @@ export async function deleteApiKey(id) { return true; } +/** + * Get API key by ID + */ +export async function getApiKeyById(id) { + const db = await getDb(); + return db.data.apiKeys.find(k => k.id === id) || null; +} + +/** + * Update API key + */ +export async function updateApiKey(id, data) { + const db = await getDb(); + const index = db.data.apiKeys.findIndex(k => k.id === id); + if (index === -1) return null; + db.data.apiKeys[index] = { + ...db.data.apiKeys[index], + ...data, + }; + await db.write(); + return db.data.apiKeys[index]; +} + /** * Validate API key */ export async function validateApiKey(key) { const db = await getDb(); - return db.data.apiKeys.some(k => k.key === key); + const found = db.data.apiKeys.find(k => k.key === key); + return found && found.isActive !== false; } // ============ Data Cleanup ============