mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-03 05:45:04 +03:00
feat(api-manager): implement API key management with new endpoints and UI
- Add GET/PATCH endpoints for retrieving and updating API key permissions - Move API key management from Endpoint page to dedicated API Manager page - Add allowed_models column to database schema for model-specific access - Implement caching layer for improved API key validation performance
This commit is contained in:
@@ -0,0 +1,874 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useMemo, useCallback, memo } from "react";
|
||||
import { Card, Button, Input, Modal, CardSkeleton } from "@/shared/components";
|
||||
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
|
||||
|
||||
// Constants for validation
|
||||
const MAX_KEY_NAME_LENGTH = 100;
|
||||
const MAX_SELECTED_MODELS = 500;
|
||||
|
||||
// Debounce hook for search optimization
|
||||
function useDebouncedValue<T>(value: T, delay: number): T {
|
||||
const [debouncedValue, setDebouncedValue] = useState(value);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setDebouncedValue(value), delay);
|
||||
return () => clearTimeout(timer);
|
||||
}, [value, delay]);
|
||||
|
||||
return debouncedValue;
|
||||
}
|
||||
|
||||
// Sanitize user input to prevent XSS
|
||||
function sanitizeInput(input: string): string {
|
||||
return input
|
||||
.replace(/[<>]/g, "")
|
||||
.replace(/"/g, "")
|
||||
.replace(/'/g, "")
|
||||
.trim()
|
||||
.slice(0, MAX_KEY_NAME_LENGTH);
|
||||
}
|
||||
|
||||
// Validate key name
|
||||
function validateKeyName(name: string): { valid: boolean; error?: string } {
|
||||
if (!name || !name.trim()) {
|
||||
return { valid: false, error: "Key name is required" };
|
||||
}
|
||||
if (name.length > MAX_KEY_NAME_LENGTH) {
|
||||
return { valid: false, error: `Key name must be ${MAX_KEY_NAME_LENGTH} characters or less` };
|
||||
}
|
||||
// Only allow alphanumeric, spaces, hyphens, underscores
|
||||
if (!/^[a-zA-Z0-9_\-\s]+$/.test(name)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: "Key name can only contain letters, numbers, spaces, hyphens, and underscores",
|
||||
};
|
||||
}
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
interface ApiKey {
|
||||
id: string;
|
||||
name: string;
|
||||
key: string;
|
||||
allowedModels: string[] | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface Model {
|
||||
id: string;
|
||||
owned_by: string;
|
||||
}
|
||||
|
||||
export default function ApiManagerPageClient() {
|
||||
const [keys, setKeys] = useState<ApiKey[]>([]);
|
||||
const [allModels, setAllModels] = useState<Model[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
const [newKeyName, setNewKeyName] = useState("");
|
||||
const [createdKey, setCreatedKey] = useState<string | null>(null);
|
||||
const [editingKey, setEditingKey] = useState<ApiKey | null>(null);
|
||||
const [showPermissionsModal, setShowPermissionsModal] = useState(false);
|
||||
const [searchModel, setSearchModel] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const { copied, copy } = useCopyToClipboard();
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
fetchModels();
|
||||
}, []);
|
||||
|
||||
const fetchModels = async () => {
|
||||
try {
|
||||
const res = await fetch("/v1/models");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setAllModels(data.data || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Error fetching models:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/keys");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setKeys(data.keys || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Error fetching keys:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const clearError = useCallback(() => setError(null), []);
|
||||
|
||||
const handleCreateKey = async () => {
|
||||
// Validate and sanitize input
|
||||
const sanitizedName = sanitizeInput(newKeyName);
|
||||
const validation = validateKeyName(sanitizedName);
|
||||
|
||||
if (!validation.valid) {
|
||||
setError(validation.error || "Invalid key name");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
clearError();
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/keys", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: sanitizedName }),
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (res.ok) {
|
||||
setCreatedKey(data.key);
|
||||
await fetchData();
|
||||
setNewKeyName("");
|
||||
setShowAddModal(false);
|
||||
} else {
|
||||
setError(data.error || "Failed to create key");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error creating key:", error);
|
||||
setError("Failed to create key. Please try again.");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteKey = async (id: string) => {
|
||||
// Validate ID format to prevent injection
|
||||
if (!id || typeof id !== "string" || !/^[a-zA-Z0-9_-]+$/.test(id)) {
|
||||
setError("Invalid key ID");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm("Delete this API key?")) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
clearError();
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/keys/${encodeURIComponent(id)}`, { method: "DELETE" });
|
||||
if (res.ok) {
|
||||
setKeys((prev) => prev.filter((k) => k.id !== id));
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setError(data.error || "Failed to delete key");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error deleting key:", error);
|
||||
setError("Failed to delete key. Please try again.");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenPermissions = (key: ApiKey) => {
|
||||
if (!key || !key.id) return;
|
||||
setEditingKey(key);
|
||||
setShowPermissionsModal(true);
|
||||
};
|
||||
|
||||
const handleUpdatePermissions = async (allowedModels: string[]) => {
|
||||
if (!editingKey || !editingKey.id) return;
|
||||
|
||||
// Validate models array
|
||||
if (!Array.isArray(allowedModels)) {
|
||||
setError("Invalid models selection");
|
||||
return;
|
||||
}
|
||||
|
||||
// Limit number of selected models to prevent abuse
|
||||
if (allowedModels.length > MAX_SELECTED_MODELS) {
|
||||
setError(`Cannot select more than ${MAX_SELECTED_MODELS} models`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate each model ID
|
||||
const validModels = allowedModels.filter(
|
||||
(id) => typeof id === "string" && id.length > 0 && id.length < 200
|
||||
);
|
||||
|
||||
setIsSubmitting(true);
|
||||
clearError();
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/keys/${encodeURIComponent(editingKey.id)}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ allowedModels: validModels }),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
await fetchData();
|
||||
setShowPermissionsModal(false);
|
||||
setEditingKey(null);
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setError(data.error || "Failed to update permissions");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error updating permissions:", error);
|
||||
setError("Failed to update permissions. Please try again.");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Debounced search for performance
|
||||
const debouncedSearchModel = useDebouncedValue(searchModel, 150);
|
||||
|
||||
// Group models by provider
|
||||
const modelsByProvider = useMemo((): [string, any[]][] => {
|
||||
const grouped: Record<string, any[]> = {};
|
||||
for (const model of allModels) {
|
||||
const provider = (model.owned_by as string) || "unknown";
|
||||
if (!grouped[provider]) grouped[provider] = [];
|
||||
grouped[provider].push(model);
|
||||
}
|
||||
const entries = Object.entries(grouped) as [string, any[]][];
|
||||
return entries.sort((a, b) => a[0].localeCompare(b[0]));
|
||||
}, [allModels]);
|
||||
|
||||
// Filter models based on debounced search
|
||||
const filteredModelsByProvider = useMemo((): [string, any[]][] => {
|
||||
if (!debouncedSearchModel.trim()) return modelsByProvider;
|
||||
|
||||
const search = debouncedSearchModel.toLowerCase();
|
||||
return modelsByProvider
|
||||
.map(([provider, models]): [string, any[]] => [
|
||||
provider,
|
||||
models.filter(
|
||||
(m: any) => m.id.toLowerCase().includes(search) || provider.toLowerCase().includes(search)
|
||||
),
|
||||
])
|
||||
.filter(([, models]) => models.length > 0);
|
||||
}, [modelsByProvider, debouncedSearchModel]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
<CardSkeleton />
|
||||
<CardSkeleton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
{/* Error Banner */}
|
||||
{error && (
|
||||
<div className="flex items-center gap-3 p-4 bg-red-500/10 border border-red-500/30 rounded-lg">
|
||||
<span className="material-symbols-outlined text-red-500">error</span>
|
||||
<p className="text-sm text-red-700 dark:text-red-300 flex-1">{error}</p>
|
||||
<button
|
||||
onClick={clearError}
|
||||
className="text-red-500 hover:text-red-700 transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined">close</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header Card */}
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">API Key Management</h2>
|
||||
<p className="text-sm text-text-muted">
|
||||
Create and manage API keys for authenticating requests to your endpoint
|
||||
</p>
|
||||
</div>
|
||||
<Button icon="add" onClick={() => setShowAddModal(true)}>
|
||||
Create Key
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Keys List Card */}
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center justify-center size-10 rounded-lg bg-amber-500/10 shrink-0">
|
||||
<span className="material-symbols-outlined text-xl text-amber-500">vpn_key</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold">Registered Keys</h3>
|
||||
<p className="text-xs text-text-muted">
|
||||
{keys.length} {keys.length === 1 ? "key" : "keys"} registered
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-text-muted mb-4">
|
||||
Each key isolates usage tracking and can be revoked independently. Keys are masked after
|
||||
creation for security.
|
||||
</p>
|
||||
|
||||
{keys.length === 0 ? (
|
||||
<div className="text-center py-12 border border-dashed border-border rounded-lg">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-primary/10 text-primary mb-4">
|
||||
<span className="material-symbols-outlined text-[32px]">vpn_key</span>
|
||||
</div>
|
||||
<p className="text-text-main font-medium mb-2">No API keys yet</p>
|
||||
<p className="text-sm text-text-muted mb-4">
|
||||
Create your first API key to authenticate requests to your endpoint
|
||||
</p>
|
||||
<Button icon="add" onClick={() => setShowAddModal(true)}>
|
||||
Create Your First Key
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col border border-border rounded-lg overflow-hidden">
|
||||
{/* Table Header */}
|
||||
<div className="grid grid-cols-12 gap-4 px-4 py-3 bg-surface/50 border-b border-border text-xs font-semibold text-text-muted uppercase tracking-wider">
|
||||
<div className="col-span-3">Name</div>
|
||||
<div className="col-span-4">Key</div>
|
||||
<div className="col-span-2">Permissions</div>
|
||||
<div className="col-span-1">Created</div>
|
||||
<div className="col-span-2 text-right">Actions</div>
|
||||
</div>
|
||||
|
||||
{/* Table Rows */}
|
||||
{keys.map((key) => (
|
||||
<div
|
||||
key={key.id}
|
||||
className="grid grid-cols-12 gap-4 px-4 py-3 border-b border-black/[0.03] dark:border-white/[0.03] last:border-b-0 hover:bg-surface/30 transition-colors group"
|
||||
>
|
||||
<div className="col-span-3 flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-text-muted text-sm">label</span>
|
||||
<span className="text-sm font-medium truncate">{key.name}</span>
|
||||
</div>
|
||||
<div className="col-span-4 flex items-center">
|
||||
<code className="text-sm text-text-muted font-mono truncate">{key.key}</code>
|
||||
</div>
|
||||
<div className="col-span-2 flex items-center">
|
||||
{Array.isArray(key.allowedModels) && key.allowedModels.length > 0 ? (
|
||||
<button
|
||||
onClick={() => handleOpenPermissions(key)}
|
||||
className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-amber-500/10 text-amber-600 dark:text-amber-400 text-xs font-medium hover:bg-amber-500/20 transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">lock</span>
|
||||
{key.allowedModels.length}{" "}
|
||||
{key.allowedModels.length === 1 ? "model" : "models"}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => handleOpenPermissions(key)}
|
||||
className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-green-500/10 text-green-600 dark:text-green-400 text-xs font-medium hover:bg-green-500/20 transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">lock_open</span>
|
||||
All models
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-span-1 flex items-center text-sm text-text-muted">
|
||||
{new Date(key.createdAt).toLocaleDateString()}
|
||||
</div>
|
||||
<div className="col-span-2 flex items-center justify-end gap-1">
|
||||
<button
|
||||
onClick={() => handleOpenPermissions(key)}
|
||||
className="p-2 hover:bg-primary/10 rounded text-text-muted hover:text-primary opacity-0 group-hover:opacity-100 transition-all"
|
||||
title="Edit permissions"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">tune</span>
|
||||
</button>
|
||||
<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"
|
||||
title="Delete key"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">delete</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Usage Tips Card */}
|
||||
<Card>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex items-center justify-center size-10 rounded-lg bg-blue-500/10 shrink-0">
|
||||
<span className="material-symbols-outlined text-xl text-blue-500">lightbulb</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold mb-2">Usage Tips</h3>
|
||||
<ul className="text-sm text-text-muted space-y-1.5">
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="material-symbols-outlined text-xs text-primary mt-1">check</span>
|
||||
<span>
|
||||
Use API keys in the{" "}
|
||||
<code className="text-xs bg-surface px-1.5 py-0.5 rounded">Authorization</code>{" "}
|
||||
header as{" "}
|
||||
<code className="text-xs bg-surface px-1.5 py-0.5 rounded">Bearer YOUR_KEY</code>
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="material-symbols-outlined text-xs text-primary mt-1">check</span>
|
||||
<span>Keys are only shown once during creation — store them securely</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="material-symbols-outlined text-xs text-primary mt-1">check</span>
|
||||
<span>Create separate keys for different clients or environments</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="material-symbols-outlined text-xs text-primary mt-1">check</span>
|
||||
<span>Restrict keys to specific models for better security and cost control</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Add Key Modal */}
|
||||
<Modal
|
||||
isOpen={showAddModal}
|
||||
title="Create API Key"
|
||||
onClose={() => {
|
||||
setShowAddModal(false);
|
||||
setNewKeyName("");
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-text-main mb-1.5 block">Key Name</label>
|
||||
<Input
|
||||
value={newKeyName}
|
||||
onChange={(e) => setNewKeyName(e.target.value)}
|
||||
placeholder="e.g., Production Key, Development Key"
|
||||
autoFocus
|
||||
/>
|
||||
<p className="text-xs text-text-muted mt-1.5">
|
||||
Choose a descriptive name to identify this key's purpose
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={() => {
|
||||
setShowAddModal(false);
|
||||
setNewKeyName("");
|
||||
}}
|
||||
variant="ghost"
|
||||
fullWidth
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleCreateKey} fullWidth disabled={!newKeyName.trim()}>
|
||||
Create Key
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Created Key Modal */}
|
||||
<Modal isOpen={!!createdKey} title="API Key Created" onClose={() => setCreatedKey(null)}>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="material-symbols-outlined text-green-600 dark:text-green-400">
|
||||
check_circle
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-sm text-green-800 dark:text-green-200 font-medium mb-1">
|
||||
Key created successfully!
|
||||
</p>
|
||||
<p className="text-sm text-green-700 dark:text-green-300">
|
||||
Copy and store this key now — it won't be shown again.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Input value={createdKey || ""} readOnly className="flex-1 font-mono text-sm" />
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon={copied === "created_key" ? "check" : "content_copy"}
|
||||
onClick={() => copy(createdKey, "created_key")}
|
||||
>
|
||||
{copied === "created_key" ? "Copied!" : "Copy"}
|
||||
</Button>
|
||||
</div>
|
||||
<Button onClick={() => setCreatedKey(null)} fullWidth>
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Permissions Modal */}
|
||||
{editingKey && (
|
||||
<PermissionsModal
|
||||
key={editingKey.id}
|
||||
isOpen={showPermissionsModal}
|
||||
onClose={() => {
|
||||
setShowPermissionsModal(false);
|
||||
setEditingKey(null);
|
||||
}}
|
||||
apiKey={editingKey}
|
||||
modelsByProvider={filteredModelsByProvider}
|
||||
allModels={allModels}
|
||||
searchModel={searchModel}
|
||||
onSearchChange={setSearchModel}
|
||||
onSave={handleUpdatePermissions}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// -- Permissions Modal Component (Memoized for Performance) ------------------------------------------
|
||||
|
||||
const PermissionsModal = memo(function PermissionsModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
apiKey,
|
||||
modelsByProvider,
|
||||
allModels,
|
||||
searchModel,
|
||||
onSearchChange,
|
||||
onSave,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
apiKey: any;
|
||||
modelsByProvider: [string, any[]][];
|
||||
allModels: any[];
|
||||
searchModel: string;
|
||||
onSearchChange: (v: string) => void;
|
||||
onSave: (models: string[]) => void;
|
||||
}) {
|
||||
// Initialize state from props - component remounts when key prop changes
|
||||
const initialModels = Array.isArray(apiKey?.allowedModels) ? apiKey.allowedModels : [];
|
||||
const [selectedModels, setSelectedModels] = useState<string[]>(initialModels);
|
||||
const [allowAll, setAllowAll] = useState(initialModels.length === 0);
|
||||
const [expandedProviders, setExpandedProviders] = useState<Set<string>>(() => {
|
||||
// Expand all providers by default when in restrict mode with existing selections
|
||||
if (initialModels.length > 0) {
|
||||
return new Set(modelsByProvider.map(([p]) => p));
|
||||
}
|
||||
return new Set();
|
||||
});
|
||||
|
||||
// Memoize callbacks to prevent child re-renders
|
||||
const handleToggleModel = useCallback(
|
||||
(modelId: string) => {
|
||||
if (allowAll) return;
|
||||
|
||||
setSelectedModels((prev) => {
|
||||
if (prev.includes(modelId)) {
|
||||
return prev.filter((m) => m !== modelId);
|
||||
}
|
||||
return [...prev, modelId];
|
||||
});
|
||||
},
|
||||
[allowAll]
|
||||
);
|
||||
|
||||
const handleToggleProvider = useCallback(
|
||||
(provider: string, models: any[]) => {
|
||||
if (allowAll) return;
|
||||
|
||||
const modelIds = models.map((m) => m.id);
|
||||
setSelectedModels((prev) => {
|
||||
const allSelected = modelIds.every((id) => prev.includes(id));
|
||||
if (allSelected) {
|
||||
return prev.filter((m) => !modelIds.includes(m));
|
||||
}
|
||||
return [...new Set([...prev, ...modelIds])];
|
||||
});
|
||||
},
|
||||
[allowAll]
|
||||
);
|
||||
|
||||
const handleSelectAll = useCallback(() => {
|
||||
setAllowAll(true);
|
||||
setSelectedModels([]);
|
||||
}, []);
|
||||
|
||||
const handleRestrictMode = useCallback(() => {
|
||||
setAllowAll(false);
|
||||
// Expand all providers when entering restrict mode
|
||||
const allProviders = new Set(modelsByProvider.map(([p]) => p));
|
||||
setExpandedProviders(allProviders);
|
||||
}, [modelsByProvider]);
|
||||
|
||||
const handleToggleExpand = useCallback((provider: string) => {
|
||||
setExpandedProviders((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(provider)) {
|
||||
next.delete(provider);
|
||||
} else {
|
||||
next.add(provider);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleSelectAllModels = useCallback(() => {
|
||||
const allModelIds = allModels.map((m) => m.id);
|
||||
setSelectedModels(allModelIds);
|
||||
}, [allModels]);
|
||||
|
||||
const handleDeselectAllModels = useCallback(() => {
|
||||
setSelectedModels([]);
|
||||
}, []);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
onSave(allowAll ? [] : selectedModels);
|
||||
}, [onSave, allowAll, selectedModels]);
|
||||
|
||||
const handleClearSearch = useCallback(() => {
|
||||
onSearchChange("");
|
||||
}, [onSearchChange]);
|
||||
|
||||
const selectedCount = selectedModels.length;
|
||||
const totalModels = allModels.length;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={onClose ? isOpen : false}
|
||||
title={`Permissions: ${apiKey?.name || ""}`}
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Access Mode Toggle */}
|
||||
<div className="flex gap-2 p-1 bg-surface rounded-lg">
|
||||
<button
|
||||
onClick={handleSelectAll}
|
||||
className={`flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all ${
|
||||
allowAll
|
||||
? "bg-primary text-white"
|
||||
: "text-text-muted hover:bg-black/5 dark:hover:bg-white/5"
|
||||
}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">lock_open</span>
|
||||
Allow All
|
||||
</button>
|
||||
<button
|
||||
onClick={handleRestrictMode}
|
||||
className={`flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all ${
|
||||
!allowAll
|
||||
? "bg-primary text-white"
|
||||
: "text-text-muted hover:bg-black/5 dark:hover:bg-white/5"
|
||||
}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">lock</span>
|
||||
Restrict
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Info Banner */}
|
||||
<div
|
||||
className={`flex items-start gap-2 p-3 rounded-lg ${
|
||||
allowAll
|
||||
? "bg-green-500/10 border border-green-500/30"
|
||||
: "bg-amber-500/10 border border-amber-500/30"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`material-symbols-outlined text-[18px] ${
|
||||
allowAll ? "text-green-500" : "text-amber-500"
|
||||
}`}
|
||||
>
|
||||
{allowAll ? "info" : "warning"}
|
||||
</span>
|
||||
<p
|
||||
className={`text-xs ${
|
||||
allowAll ? "text-green-700 dark:text-green-300" : "text-amber-700 dark:text-amber-300"
|
||||
}`}
|
||||
>
|
||||
{allowAll
|
||||
? "This key can access all available models."
|
||||
: `This key can access ${selectedCount} of ${totalModels} models.`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Selected Models Summary (only in restrict mode) */}
|
||||
{!allowAll && selectedCount > 0 && (
|
||||
<div className="flex flex-col gap-1.5 p-2 bg-primary/5 rounded-lg border border-primary/20">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-primary">{selectedCount} selected</span>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={handleSelectAllModels}
|
||||
className="text-[10px] text-primary hover:bg-primary/10 px-1.5 py-0.5 rounded transition-colors"
|
||||
>
|
||||
All
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDeselectAllModels}
|
||||
className="text-[10px] text-red-500 hover:bg-red-500/10 px-1.5 py-0.5 rounded transition-colors"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1 max-h-16 overflow-y-auto content-start">
|
||||
{selectedModels.map((modelId) => (
|
||||
<span
|
||||
key={modelId}
|
||||
className="inline-flex items-center gap-0.5 px-1.5 py-0.5 bg-white dark:bg-surface text-text-main text-[10px] rounded border border-border"
|
||||
>
|
||||
<span className="font-mono truncate max-w-[120px]" title={modelId}>
|
||||
{modelId}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleToggleModel(modelId)}
|
||||
className="text-text-muted hover:text-red-500 transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[12px]">close</span>
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search and Model Selection (only in restrict mode) */}
|
||||
{!allowAll && (
|
||||
<>
|
||||
<div className="relative">
|
||||
<Input
|
||||
value={searchModel}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
placeholder="Search models by name or provider..."
|
||||
icon="search"
|
||||
/>
|
||||
{searchModel && (
|
||||
<button
|
||||
onClick={() => onSearchChange("")}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-text-muted hover:text-text-main"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">close</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="max-h-[280px] overflow-y-auto border border-border rounded-lg divide-y divide-border">
|
||||
{modelsByProvider.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-6 text-text-muted">
|
||||
<span className="material-symbols-outlined text-2xl mb-1">search_off</span>
|
||||
<p className="text-xs">No models found</p>
|
||||
</div>
|
||||
) : (
|
||||
modelsByProvider.map(([provider, models]) => {
|
||||
const selectedInProvider = selectedModels.filter((m) =>
|
||||
models.some((model) => model.id === m)
|
||||
).length;
|
||||
const allSelected = models.every((m) => selectedModels.includes(m.id));
|
||||
const someSelected = selectedInProvider > 0 && !allSelected;
|
||||
|
||||
return (
|
||||
<div key={provider} className="group">
|
||||
<button
|
||||
onClick={() => handleToggleExpand(provider)}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-surface/50 transition-colors text-left"
|
||||
>
|
||||
<span
|
||||
className={`material-symbols-outlined text-base transition-transform duration-200 ${
|
||||
expandedProviders.has(provider) ? "rotate-90" : ""
|
||||
}`}
|
||||
>
|
||||
chevron_right
|
||||
</span>
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<div
|
||||
className="relative flex items-center cursor-pointer shrink-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleToggleProvider(provider, models);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`w-4 h-4 rounded border-2 transition-colors flex items-center justify-center ${
|
||||
allSelected
|
||||
? "bg-primary border-primary"
|
||||
: someSelected
|
||||
? "bg-primary/20 border-primary"
|
||||
: "border-border hover:border-primary/50"
|
||||
}`}
|
||||
>
|
||||
{allSelected && (
|
||||
<span className="material-symbols-outlined text-white text-[12px]">
|
||||
check
|
||||
</span>
|
||||
)}
|
||||
{someSelected && !allSelected && (
|
||||
<span className="material-symbols-outlined text-primary text-[12px]">
|
||||
remove
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs font-semibold text-text-main truncate">
|
||||
{provider}
|
||||
</span>
|
||||
<span className="text-[10px] text-text-muted bg-surface px-1 py-0.5 rounded shrink-0">
|
||||
{models.length}
|
||||
</span>
|
||||
</div>
|
||||
{selectedInProvider > 0 && (
|
||||
<span className="text-[10px] font-medium text-primary bg-primary/10 px-1.5 py-0.5 rounded-full shrink-0">
|
||||
{selectedInProvider}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Expandable model list */}
|
||||
{expandedProviders.has(provider) && (
|
||||
<div className="px-3 pb-2 pl-9">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{models.map((model) => {
|
||||
const isSelected = selectedModels.includes(model.id);
|
||||
return (
|
||||
<button
|
||||
key={model.id}
|
||||
onClick={() => handleToggleModel(model.id)}
|
||||
className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-[10px] font-mono transition-all ${
|
||||
isSelected
|
||||
? "bg-primary text-white"
|
||||
: "bg-surface border border-border text-text-muted hover:border-primary/50 hover:text-text-main"
|
||||
}`}
|
||||
title={model.id}
|
||||
>
|
||||
{model.id}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleSave} fullWidth>
|
||||
Save Permissions
|
||||
</Button>
|
||||
<Button onClick={onClose} variant="ghost" fullWidth>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
});
|
||||
5
src/app/(dashboard)/dashboard/api-manager/page.tsx
Normal file
5
src/app/(dashboard)/dashboard/api-manager/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import ApiManagerPageClient from "./ApiManagerPageClient";
|
||||
|
||||
export default function ApiManagerPage() {
|
||||
return <ApiManagerPageClient />;
|
||||
}
|
||||
@@ -1,22 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useMemo, useCallback } from "react";
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import Image from "next/image";
|
||||
import { Card, Button, Input, Modal, CardSkeleton } from "@/shared/components";
|
||||
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
|
||||
import { AI_PROVIDERS, getProviderByAlias } from "@/shared/constants/providers";
|
||||
import Link from "next/link";
|
||||
|
||||
const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL;
|
||||
const CLOUD_ACTION_TIMEOUT_MS = 15000;
|
||||
|
||||
export default function APIPageClient({ machineId }) {
|
||||
const [keys, setKeys] = useState([]);
|
||||
const [providerConnections, setProviderConnections] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
const [newKeyName, setNewKeyName] = useState("");
|
||||
const [createdKey, setCreatedKey] = useState(null);
|
||||
|
||||
// Endpoints / models state
|
||||
const [allModels, setAllModels] = useState([]);
|
||||
@@ -133,16 +129,9 @@ export default function APIPageClient({ machineId }) {
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const [keysRes, providersRes] = await Promise.all([
|
||||
fetch("/api/keys"),
|
||||
fetch("/api/providers"),
|
||||
]);
|
||||
const providersRes = await fetch("/api/providers");
|
||||
|
||||
const [keysData, providersData] = await Promise.all([keysRes.json(), providersRes.json()]);
|
||||
|
||||
if (keysRes.ok) {
|
||||
setKeys(keysData.keys || []);
|
||||
}
|
||||
const providersData = await providersRes.json();
|
||||
|
||||
if (providersRes.ok) {
|
||||
setProviderConnections(providersData.connections || []);
|
||||
@@ -283,41 +272,6 @@ export default function APIPageClient({ machineId }) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateKey = async () => {
|
||||
if (!newKeyName.trim()) return;
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/keys", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: newKeyName }),
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (res.ok) {
|
||||
setCreatedKey(data.key);
|
||||
await fetchData();
|
||||
setNewKeyName("");
|
||||
setShowAddModal(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Error creating key:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteKey = async (id) => {
|
||||
if (!confirm("Delete this API key?")) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/keys/${id}`, { method: "DELETE" });
|
||||
if (res.ok) {
|
||||
setKeys(keys.filter((k) => k.id !== id));
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Error deleting key:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const [baseUrl, setBaseUrl] = useState("/v1");
|
||||
const cloudEndpointNew = `${CLOUD_URL}/v1`;
|
||||
|
||||
@@ -442,93 +396,22 @@ export default function APIPageClient({ machineId }) {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Registered Keys — collapsible section inside API Endpoint card */}
|
||||
<div className="border border-border rounded-lg overflow-hidden mt-4">
|
||||
<button
|
||||
onClick={() => setExpandedEndpoint(expandedEndpoint === "keys" ? null : "keys")}
|
||||
className="w-full flex items-center gap-3 p-4 hover:bg-surface/50 transition-colors text-left"
|
||||
>
|
||||
<div className="flex items-center justify-center size-10 rounded-lg bg-amber-500/10 shrink-0">
|
||||
<span className="material-symbols-outlined text-xl text-amber-500">vpn_key</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-sm">Registered Keys</span>
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-surface text-text-muted font-medium">
|
||||
{keys.length} {keys.length === 1 ? "key" : "keys"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-text-muted mt-0.5">
|
||||
Manage API keys used to authenticate requests to this endpoint
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
className={`material-symbols-outlined text-text-muted text-lg transition-transform ${expandedEndpoint === "keys" ? "rotate-180" : ""}`}
|
||||
>
|
||||
expand_more
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{expandedEndpoint === "keys" && (
|
||||
<div className="border-t border-border px-4 pb-4">
|
||||
<div className="flex items-center justify-between mt-3 mb-3">
|
||||
<p className="text-xs text-text-muted">
|
||||
Each key isolates usage tracking and can be revoked independently.
|
||||
</p>
|
||||
<Button size="sm" icon="add" onClick={() => setShowAddModal(true)}>
|
||||
Create Key
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{keys.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="inline-flex items-center justify-center w-12 h-12 rounded-full bg-primary/10 text-primary mb-3">
|
||||
<span className="material-symbols-outlined text-[24px]">vpn_key</span>
|
||||
</div>
|
||||
<p className="text-text-main font-medium mb-1 text-sm">No API keys yet</p>
|
||||
<p className="text-xs text-text-muted mb-3">
|
||||
Create your first API key to get started
|
||||
</p>
|
||||
<Button size="sm" icon="add" onClick={() => setShowAddModal(true)}>
|
||||
Create Key
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col">
|
||||
{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"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium">{key.name}</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<code className="text-xs text-text-muted font-mono">{key.key}</code>
|
||||
<button
|
||||
onClick={() => copy(key.key, key.id)}
|
||||
className="p-1 hover:bg-black/5 dark:hover:bg-white/5 rounded text-text-muted hover:text-primary opacity-0 group-hover:opacity-100 transition-all"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">
|
||||
{copied === key.id ? "check" : "content_copy"}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-text-muted mt-1">
|
||||
Created {new Date(key.createdAt).toLocaleDateString()}
|
||||
</p>
|
||||
</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>
|
||||
)}
|
||||
{/* Link to API Manager */}
|
||||
<div className="flex items-center gap-3 p-4 border border-border rounded-lg mt-4 bg-surface/30">
|
||||
<div className="flex items-center justify-center size-10 rounded-lg bg-amber-500/10 shrink-0">
|
||||
<span className="material-symbols-outlined text-xl text-amber-500">vpn_key</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-semibold text-sm">API Key Management</p>
|
||||
<p className="text-xs text-text-muted">
|
||||
Create and manage API keys for authenticating requests
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/dashboard/api-manager">
|
||||
<Button size="sm" variant="secondary" icon="arrow_forward">
|
||||
Manage Keys
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -538,8 +421,8 @@ export default function APIPageClient({ machineId }) {
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">Available Endpoints</h2>
|
||||
<p className="text-sm text-text-muted">
|
||||
{Object.values(endpointData).reduce((acc, models) => acc + models.length, 0)}{" "}
|
||||
models across{" "}
|
||||
{Object.values(endpointData).reduce((acc, models) => acc + models.length, 0)} models
|
||||
across{" "}
|
||||
{
|
||||
[
|
||||
endpointData.chat,
|
||||
@@ -837,67 +720,6 @@ export default function APIPageClient({ machineId }) {
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Add Key Modal */}
|
||||
<Modal
|
||||
isOpen={showAddModal}
|
||||
title="Create API Key"
|
||||
onClose={() => {
|
||||
setShowAddModal(false);
|
||||
setNewKeyName("");
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<Input
|
||||
label="Key Name"
|
||||
value={newKeyName}
|
||||
onChange={(e) => setNewKeyName(e.target.value)}
|
||||
placeholder="Production Key"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleCreateKey} fullWidth disabled={!newKeyName.trim()}>
|
||||
Create
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setShowAddModal(false);
|
||||
setNewKeyName("");
|
||||
}}
|
||||
variant="ghost"
|
||||
fullWidth
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Created Key Modal */}
|
||||
<Modal isOpen={!!createdKey} title="API Key Created" onClose={() => setCreatedKey(null)}>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4">
|
||||
<p className="text-sm text-yellow-800 dark:text-yellow-200 mb-2 font-medium">
|
||||
Save this key now!
|
||||
</p>
|
||||
<p className="text-sm text-yellow-700 dark:text-yellow-300">
|
||||
This is the only time you will see this key. Store it securely.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Input value={createdKey || ""} readOnly className="flex-1 font-mono text-sm" />
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon={copied === "created_key" ? "check" : "content_copy"}
|
||||
onClick={() => copy(createdKey, "created_key")}
|
||||
>
|
||||
{copied === "created_key" ? "Copied!" : "Copy"}
|
||||
</Button>
|
||||
</div>
|
||||
<Button onClick={() => setCreatedKey(null)} fullWidth>
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Disable Cloud Modal */}
|
||||
<Modal
|
||||
isOpen={showDisableModal}
|
||||
@@ -983,76 +805,6 @@ APIPageClient.propTypes = {
|
||||
machineId: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
function ProviderOverviewCard({ item, onClick }) {
|
||||
const [imgError, setImgError] = useState(false);
|
||||
|
||||
const statusVariant =
|
||||
item.errors > 0 ? "text-red-500" : item.connected > 0 ? "text-green-500" : "text-text-muted";
|
||||
|
||||
return (
|
||||
<div
|
||||
className="border border-border rounded-lg p-3 hover:bg-surface/40 transition-colors cursor-pointer"
|
||||
onClick={onClick}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => e.key === "Enter" && onClick?.()}
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div
|
||||
className="size-8 rounded-lg flex items-center justify-center"
|
||||
style={{ backgroundColor: `${item.provider.color || "#888"}15` }}
|
||||
>
|
||||
{imgError ? (
|
||||
<span
|
||||
className="text-[10px] font-bold"
|
||||
style={{ color: item.provider.color || "#888" }}
|
||||
>
|
||||
{item.provider.textIcon || item.provider.id.slice(0, 2).toUpperCase()}
|
||||
</span>
|
||||
) : (
|
||||
<Image
|
||||
src={`/providers/${item.provider.id}.png`}
|
||||
alt={item.provider.name}
|
||||
width={26}
|
||||
height={26}
|
||||
className="object-contain rounded-lg"
|
||||
sizes="26px"
|
||||
onError={() => setImgError(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-semibold truncate">{item.provider.name}</p>
|
||||
<p className={`text-xs ${statusVariant}`}>
|
||||
{item.total === 0
|
||||
? "Not configured"
|
||||
: `${item.connected} active · ${item.errors} error`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<span className="text-xs text-text-muted">#{item.total}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
ProviderOverviewCard.propTypes = {
|
||||
item: PropTypes.shape({
|
||||
id: PropTypes.string.isRequired,
|
||||
provider: PropTypes.shape({
|
||||
id: PropTypes.string.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
color: PropTypes.string,
|
||||
textIcon: PropTypes.string,
|
||||
}).isRequired,
|
||||
total: PropTypes.number.isRequired,
|
||||
connected: PropTypes.number.isRequired,
|
||||
errors: PropTypes.number.isRequired,
|
||||
}).isRequired,
|
||||
onClick: PropTypes.func,
|
||||
};
|
||||
|
||||
// -- Sub-component: Provider Models Modal ------------------------------------------
|
||||
|
||||
function ProviderModelsModal({ provider, models, copy, copied, onClose }) {
|
||||
|
||||
@@ -1,8 +1,71 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { deleteApiKey, isCloudEnabled } from "@/lib/localDb";
|
||||
import {
|
||||
deleteApiKey,
|
||||
getApiKeyById,
|
||||
updateApiKeyPermissions,
|
||||
isCloudEnabled,
|
||||
} from "@/lib/localDb";
|
||||
import { getConsistentMachineId } from "@/shared/utils/machineId";
|
||||
import { syncToCloud } from "@/lib/cloudSync";
|
||||
|
||||
// GET /api/keys/[id] - Get single API 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 });
|
||||
}
|
||||
|
||||
// Mask the key value
|
||||
return NextResponse.json({
|
||||
...key,
|
||||
key: key.key ? key.key.slice(0, 8) + "****" + key.key.slice(-4) : null,
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("Error fetching key:", error);
|
||||
return NextResponse.json({ error: "Failed to fetch key" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// PATCH /api/keys/[id] - Update API key permissions
|
||||
export async function PATCH(request, { params }) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
const { allowedModels } = body;
|
||||
|
||||
// Validate allowedModels is an array
|
||||
if (!Array.isArray(allowedModels)) {
|
||||
return NextResponse.json({ error: "allowedModels must be an array" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Validate each model ID is a string
|
||||
for (const model of allowedModels) {
|
||||
if (typeof model !== "string") {
|
||||
return NextResponse.json({ error: "Each model ID must be a string" }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await updateApiKeyPermissions(id, allowedModels);
|
||||
if (!updated) {
|
||||
return NextResponse.json({ error: "Key not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Auto sync to Cloud if enabled
|
||||
await syncKeysToCloudIfEnabled();
|
||||
|
||||
return NextResponse.json({
|
||||
message: "Permissions updated successfully",
|
||||
allowedModels,
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("Error updating key permissions:", error);
|
||||
return NextResponse.json({ error: "Failed to update permissions" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/keys/[id] - Delete API key
|
||||
export async function DELETE(request, { params }) {
|
||||
try {
|
||||
|
||||
@@ -6,9 +6,156 @@ import { v4 as uuidv4 } from "uuid";
|
||||
import { getDbInstance, rowToCamel } from "./core";
|
||||
import { backupDbFile } from "./backup";
|
||||
|
||||
// ──────────────── Performance Optimizations ────────────────
|
||||
|
||||
// Schema check memoization - only run once
|
||||
let _schemaChecked = false;
|
||||
|
||||
// LRU cache for API key validation (valid keys only)
|
||||
const _keyValidationCache = new Map<string, { valid: boolean; timestamp: number }>();
|
||||
const _keyMetadataCache = new Map<string, { metadata: any; timestamp: number }>();
|
||||
const CACHE_TTL = 60 * 1000; // 1 minute TTL
|
||||
const MAX_CACHE_SIZE = 1000;
|
||||
|
||||
// Compiled regex cache for wildcard patterns
|
||||
const _regexCache = new Map<string, RegExp>();
|
||||
|
||||
// Cache for model permission checks
|
||||
const _modelPermissionCache = new Map<string, { allowed: boolean; timestamp: number }>();
|
||||
|
||||
// Prepared statements cache
|
||||
let _stmtGetAllKeys: any = null;
|
||||
let _stmtGetKeyById: any = null;
|
||||
let _stmtValidateKey: any = null;
|
||||
let _stmtGetKeyMetadata: any = null;
|
||||
let _stmtInsertKey: any = null;
|
||||
let _stmtUpdatePermissions: any = null;
|
||||
let _stmtDeleteKey: any = null;
|
||||
|
||||
/**
|
||||
* Clear all caches (called on key create/update/delete)
|
||||
*/
|
||||
function invalidateCaches() {
|
||||
_keyValidationCache.clear();
|
||||
_keyMetadataCache.clear();
|
||||
_modelPermissionCache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* LRU eviction for cache
|
||||
*/
|
||||
function evictIfNeeded(cache: Map<any, any>) {
|
||||
if (cache.size > MAX_CACHE_SIZE) {
|
||||
// Remove oldest 20% of entries
|
||||
const entriesToRemove = Math.floor(MAX_CACHE_SIZE * 0.2);
|
||||
let i = 0;
|
||||
for (const key of cache.keys()) {
|
||||
if (i++ >= entriesToRemove) break;
|
||||
cache.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or compile regex for wildcard pattern
|
||||
*/
|
||||
function getWildcardRegex(pattern: string): RegExp {
|
||||
let regex = _regexCache.get(pattern);
|
||||
if (!regex) {
|
||||
const regexStr = pattern.replace(/\*/g, ".*");
|
||||
regex = new RegExp(`^${regexStr}$`);
|
||||
_regexCache.set(pattern, regex);
|
||||
// Prevent unbounded growth
|
||||
if (_regexCache.size > 100) {
|
||||
const firstKey = _regexCache.keys().next().value;
|
||||
if (firstKey) _regexCache.delete(firstKey);
|
||||
}
|
||||
}
|
||||
return regex;
|
||||
}
|
||||
|
||||
// Ensure the allowed_models column exists (memoized)
|
||||
function ensureAllowedModelsColumn(db) {
|
||||
if (_schemaChecked) return;
|
||||
|
||||
try {
|
||||
const columns = db.prepare("PRAGMA table_info(api_keys)").all();
|
||||
const columnNames = new Set(columns.map((column) => column.name));
|
||||
if (!columnNames.has("allowed_models")) {
|
||||
db.exec("ALTER TABLE api_keys ADD COLUMN allowed_models TEXT");
|
||||
console.log("[DB] Added api_keys.allowed_models column");
|
||||
}
|
||||
_schemaChecked = true;
|
||||
} catch (error) {
|
||||
console.warn("[DB] Failed to verify api_keys schema:", error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize prepared statements (lazy initialization)
|
||||
*/
|
||||
function getPreparedStatements(db: any) {
|
||||
if (!_stmtGetAllKeys) {
|
||||
_stmtGetAllKeys = db.prepare("SELECT * FROM api_keys ORDER BY created_at");
|
||||
_stmtGetKeyById = db.prepare("SELECT * FROM api_keys WHERE id = ?");
|
||||
_stmtValidateKey = db.prepare("SELECT 1 FROM api_keys WHERE key = ?");
|
||||
_stmtGetKeyMetadata = db.prepare(
|
||||
"SELECT id, name, machine_id, allowed_models FROM api_keys WHERE key = ?"
|
||||
);
|
||||
_stmtInsertKey = db.prepare(
|
||||
"INSERT INTO api_keys (id, name, key, machine_id, allowed_models, created_at) VALUES (?, ?, ?, ?, ?, ?)"
|
||||
);
|
||||
_stmtUpdatePermissions = db.prepare("UPDATE api_keys SET allowed_models = ? WHERE id = ?");
|
||||
_stmtDeleteKey = db.prepare("DELETE FROM api_keys WHERE id = ?");
|
||||
}
|
||||
return {
|
||||
getAllKeys: _stmtGetAllKeys,
|
||||
getKeyById: _stmtGetKeyById,
|
||||
validateKey: _stmtValidateKey,
|
||||
getKeyMetadata: _stmtGetKeyMetadata,
|
||||
insertKey: _stmtInsertKey,
|
||||
updatePermissions: _stmtUpdatePermissions,
|
||||
deleteKey: _stmtDeleteKey,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getApiKeys() {
|
||||
const db = getDbInstance();
|
||||
return db.prepare("SELECT * FROM api_keys ORDER BY created_at").all().map(rowToCamel);
|
||||
ensureAllowedModelsColumn(db);
|
||||
const stmt = getPreparedStatements(db);
|
||||
const rows = stmt.getAllKeys.all() as Record<string, any>[];
|
||||
return rows.map((row) => {
|
||||
const camelRow = rowToCamel(row) as Record<string, any>;
|
||||
// Parse allowed_models from JSON string to array
|
||||
camelRow.allowedModels = parseAllowedModels(camelRow.allowedModels);
|
||||
return camelRow;
|
||||
});
|
||||
}
|
||||
|
||||
export async function getApiKeyById(id: string) {
|
||||
const db = getDbInstance();
|
||||
ensureAllowedModelsColumn(db);
|
||||
const stmt = getPreparedStatements(db);
|
||||
const row = stmt.getKeyById.get(id) as Record<string, any> | undefined;
|
||||
if (!row) return null;
|
||||
const camelRow = rowToCamel(row) as Record<string, any>;
|
||||
camelRow.allowedModels = parseAllowedModels(camelRow.allowedModels);
|
||||
return camelRow;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to safely parse allowed_models JSON
|
||||
*/
|
||||
function parseAllowedModels(value: any): string[] {
|
||||
if (!value || typeof value !== "string" || value.trim() === "") {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function createApiKey(name, machineId) {
|
||||
@@ -17,6 +164,7 @@ export async function createApiKey(name, machineId) {
|
||||
}
|
||||
|
||||
const db = getDbInstance();
|
||||
ensureAllowedModelsColumn(db);
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const { generateApiKeyWithMachine } = await import("@/shared/utils/apiKey");
|
||||
@@ -27,35 +175,182 @@ export async function createApiKey(name, machineId) {
|
||||
name: name,
|
||||
key: result.key,
|
||||
machineId: machineId,
|
||||
allowedModels: [], // Empty array means all models allowed
|
||||
createdAt: now,
|
||||
};
|
||||
|
||||
db.prepare(
|
||||
"INSERT INTO api_keys (id, name, key, machine_id, created_at) VALUES (?, ?, ?, ?, ?)"
|
||||
).run(apiKey.id, apiKey.name, apiKey.key, apiKey.machineId, apiKey.createdAt);
|
||||
const stmt = getPreparedStatements(db);
|
||||
stmt.insertKey.run(apiKey.id, apiKey.name, apiKey.key, apiKey.machineId, "[]", apiKey.createdAt);
|
||||
|
||||
backupDbFile("pre-write");
|
||||
return apiKey;
|
||||
}
|
||||
|
||||
export async function deleteApiKey(id) {
|
||||
export async function updateApiKeyPermissions(id, allowedModels) {
|
||||
const db = getDbInstance();
|
||||
const result = db.prepare("DELETE FROM api_keys WHERE id = ?").run(id);
|
||||
ensureAllowedModelsColumn(db);
|
||||
|
||||
// allowedModels should be an array of model IDs (strings)
|
||||
// Empty array means all models are allowed
|
||||
const modelsJson = JSON.stringify(allowedModels || []);
|
||||
|
||||
const stmt = getPreparedStatements(db);
|
||||
const result = stmt.updatePermissions.run(modelsJson, id);
|
||||
|
||||
if (result.changes === 0) return false;
|
||||
|
||||
// Invalidate caches since permissions changed
|
||||
invalidateCaches();
|
||||
|
||||
backupDbFile("pre-write");
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function validateApiKey(key) {
|
||||
export async function deleteApiKey(id) {
|
||||
const db = getDbInstance();
|
||||
const row = db.prepare("SELECT 1 FROM api_keys WHERE key = ?").get(key);
|
||||
return !!row;
|
||||
const stmt = getPreparedStatements(db);
|
||||
const result = stmt.deleteKey.run(id);
|
||||
|
||||
if (result.changes === 0) return false;
|
||||
|
||||
// Invalidate caches since a key was removed
|
||||
invalidateCaches();
|
||||
|
||||
backupDbFile("pre-write");
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function getApiKeyMetadata(key) {
|
||||
if (!key) return null;
|
||||
/**
|
||||
* Validate API key with caching for performance
|
||||
* Cached valid keys reduce DB hits on every request
|
||||
*/
|
||||
export async function validateApiKey(key) {
|
||||
if (!key || typeof key !== "string") return false;
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
// Check cache first
|
||||
const cached = _keyValidationCache.get(key);
|
||||
if (cached && now - cached.timestamp < CACHE_TTL) {
|
||||
return cached.valid;
|
||||
}
|
||||
|
||||
const db = getDbInstance();
|
||||
const row = db.prepare("SELECT id, name, machine_id FROM api_keys WHERE key = ?").get(key);
|
||||
const stmt = getPreparedStatements(db);
|
||||
const row = stmt.validateKey.get(key);
|
||||
const valid = !!row;
|
||||
|
||||
// Only cache valid keys to prevent cache pollution
|
||||
if (valid) {
|
||||
evictIfNeeded(_keyValidationCache);
|
||||
_keyValidationCache.set(key, { valid: true, timestamp: now });
|
||||
}
|
||||
|
||||
return valid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get API key metadata with caching for performance
|
||||
*/
|
||||
export async function getApiKeyMetadata(key) {
|
||||
if (!key || typeof key !== "string") return null;
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
// Check cache first
|
||||
const cached = _keyMetadataCache.get(key);
|
||||
if (cached && now - cached.timestamp < CACHE_TTL) {
|
||||
return cached.metadata;
|
||||
}
|
||||
|
||||
const db = getDbInstance();
|
||||
ensureAllowedModelsColumn(db);
|
||||
const stmt = getPreparedStatements(db);
|
||||
const row = stmt.getKeyMetadata.get(key) as Record<string, any> | undefined;
|
||||
|
||||
if (!row) return null;
|
||||
return { id: row.id, name: row.name, machineId: row.machine_id };
|
||||
|
||||
const metadata = {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
machineId: row.machine_id,
|
||||
allowedModels: parseAllowedModels(row.allowed_models),
|
||||
};
|
||||
|
||||
// Cache the result
|
||||
evictIfNeeded(_keyMetadataCache);
|
||||
_keyMetadataCache.set(key, { metadata, timestamp: now });
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a model is allowed for a given API key
|
||||
* @param {string} key - The API key
|
||||
* @param {string} modelId - The model ID to check
|
||||
* @returns {boolean} - true if allowed, false if not
|
||||
*/
|
||||
export async function isModelAllowedForKey(key, modelId) {
|
||||
if (!key || !modelId) return true; // No key or model = allow (backward compatibility)
|
||||
|
||||
// Create cache key
|
||||
const cacheKey = `${key}:${modelId}`;
|
||||
const now = Date.now();
|
||||
|
||||
// Check permission cache
|
||||
const cached = _modelPermissionCache.get(cacheKey);
|
||||
if (cached && now - cached.timestamp < CACHE_TTL) {
|
||||
return cached.allowed;
|
||||
}
|
||||
|
||||
const metadata = await getApiKeyMetadata(key);
|
||||
if (!metadata) return true; // Key not found = allow (backward compatibility)
|
||||
|
||||
const { allowedModels } = metadata;
|
||||
|
||||
// Empty array means all models allowed
|
||||
if (!allowedModels || allowedModels.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
let allowed = false;
|
||||
|
||||
// Check if model matches any allowed pattern
|
||||
// Support exact match and prefix match (e.g., "openai/*" allows all OpenAI models)
|
||||
for (const pattern of allowedModels) {
|
||||
if (pattern === modelId) {
|
||||
allowed = true;
|
||||
break;
|
||||
}
|
||||
if (pattern.endsWith("/*")) {
|
||||
const prefix = pattern.slice(0, -2); // Remove "/*"
|
||||
if (modelId.startsWith(prefix + "/") || modelId.startsWith(prefix)) {
|
||||
allowed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Support wildcard patterns using cached regex
|
||||
if (pattern.includes("*")) {
|
||||
const regex = getWildcardRegex(pattern);
|
||||
if (regex.test(modelId)) {
|
||||
allowed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cache the result
|
||||
evictIfNeeded(_modelPermissionCache);
|
||||
_modelPermissionCache.set(cacheKey, { allowed, timestamp: now });
|
||||
|
||||
return allowed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all caches (exported for testing/debugging)
|
||||
*/
|
||||
export function clearApiKeyCaches() {
|
||||
invalidateCaches();
|
||||
_modelPermissionCache.clear();
|
||||
_regexCache.clear();
|
||||
}
|
||||
|
||||
@@ -55,10 +55,13 @@ export {
|
||||
export {
|
||||
// API Keys
|
||||
getApiKeys,
|
||||
getApiKeyById,
|
||||
createApiKey,
|
||||
deleteApiKey,
|
||||
validateApiKey,
|
||||
getApiKeyMetadata,
|
||||
updateApiKeyPermissions,
|
||||
isModelAllowedForKey,
|
||||
} from "./db/apiKeys";
|
||||
|
||||
export {
|
||||
|
||||
@@ -14,6 +14,7 @@ import CloudSyncStatus from "./CloudSyncStatus";
|
||||
const navItems = [
|
||||
{ href: "/dashboard", label: "Home", icon: "home", exact: true },
|
||||
{ href: "/dashboard/endpoint", label: "Endpoint", icon: "api" },
|
||||
{ href: "/dashboard/api-manager", label: "API Manager", icon: "vpn_key" },
|
||||
{ href: "/dashboard/providers", label: "Providers", icon: "dns" },
|
||||
{ href: "/dashboard/combos", label: "Combos", icon: "layers" },
|
||||
{ href: "/dashboard/logs", label: "Logs", icon: "description" },
|
||||
|
||||
Reference in New Issue
Block a user