mirror of
https://github.com/decolua/9router.git
synced 2026-05-08 12:01:28 +00:00
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:
@@ -362,6 +362,7 @@ erDiagram
|
||||
string name
|
||||
string key
|
||||
string machineId
|
||||
boolean isActive
|
||||
}
|
||||
|
||||
USAGE_ENTRY {
|
||||
|
||||
@@ -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) => (
|
||||
<div
|
||||
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">
|
||||
<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">
|
||||
Created {new Date(key.createdAt).toLocaleDateString()}
|
||||
</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>
|
||||
<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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 ============
|
||||
|
||||
Reference in New Issue
Block a user