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 name
|
||||||
string key
|
string key
|
||||||
string machineId
|
string machineId
|
||||||
|
boolean isActive
|
||||||
}
|
}
|
||||||
|
|
||||||
USAGE_ENTRY {
|
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 [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>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 ============
|
||||||
|
|||||||
Reference in New Issue
Block a user