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
This commit is contained in:
Thiên Toán
2026-02-20 15:07:12 +07:00
committed by GitHub
parent 806bd4ae14
commit 73388a02a1
4 changed files with 119 additions and 9 deletions

View File

@@ -362,6 +362,7 @@ erDiagram
string name string name
string key string key
string machineId string machineId
boolean isActive
} }
USAGE_ENTRY { USAGE_ENTRY {

View File

@@ -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 [baseUrl, setBaseUrl] = useState("/v1");
const cloudEndpointNew = cloudUrl ? `${cloudUrl}/v1` : ""; const cloudEndpointNew = cloudUrl ? `${cloudUrl}/v1` : "";
@@ -405,7 +420,7 @@ export default function APIPageClient({ machineId }) {
{keys.map((key) => ( {keys.map((key) => (
<div <div
key={key.id} key={key.id}
className="group flex items-center justify-between py-3 border-b border-black/[0.03] dark:border-white/[0.03] last:border-b-0" className={`group flex items-center justify-between py-3 border-b border-black/[0.03] dark:border-white/[0.03] last:border-b-0 ${key.isActive === false ? "opacity-60" : ""}`}
> >
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="text-sm font-medium">{key.name}</p> <p className="text-sm font-medium">{key.name}</p>
@@ -423,13 +438,32 @@ export default function APIPageClient({ machineId }) {
<p className="text-xs text-text-muted mt-1"> <p className="text-xs text-text-muted mt-1">
Created {new Date(key.createdAt).toLocaleDateString()} Created {new Date(key.createdAt).toLocaleDateString()}
</p> </p>
{key.isActive === false && (
<p className="text-xs text-orange-500 mt-1">Paused</p>
)}
</div>
<div className="flex items-center gap-2">
<Toggle
size="sm"
checked={key.isActive ?? true}
onChange={(checked) => {
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"}
/>
<button
onClick={() => handleDeleteKey(key.id)}
className="p-2 hover:bg-red-500/10 rounded text-red-500 opacity-0 group-hover:opacity-100 transition-all"
>
<span className="material-symbols-outlined text-[18px]">delete</span>
</button>
</div> </div>
<button
onClick={() => handleDeleteKey(key.id)}
className="p-2 hover:bg-red-500/10 rounded text-red-500 opacity-0 group-hover:opacity-100 transition-all"
>
<span className="material-symbols-outlined text-[18px]">delete</span>
</button>
</div> </div>
))} ))}
</div> </div>

View File

@@ -1,8 +1,48 @@
import { NextResponse } from "next/server"; 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 { getConsistentMachineId } from "@/shared/utils/machineId";
import { syncToCloud } from "@/app/api/sync/cloud/route"; 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 // DELETE /api/keys/[id] - Delete API key
export async function DELETE(request, { params }) { export async function DELETE(request, { params }) {
try { try {

View File

@@ -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 }; return { data: next, changed };
@@ -649,6 +659,7 @@ export async function createApiKey(name, machineId) {
name: name, name: name,
key: result.key, key: result.key,
machineId: machineId, machineId: machineId,
isActive: true,
createdAt: now, createdAt: now,
}; };
@@ -673,12 +684,36 @@ export async function deleteApiKey(id) {
return true; 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 * Validate API key
*/ */
export async function validateApiKey(key) { export async function validateApiKey(key) {
const db = await getDb(); 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 ============ // ============ Data Cleanup ============