Initial commit

This commit is contained in:
decolua committed 2026-01-05 09:58:59 +07:00
commit 3857598de4
159 files changed
+14537

No files matched your search

@@ -0,0 +1,764 @@
"use client";
import { useState, useEffect, useMemo } from "react";
import { useParams } from "next/navigation";
import Link from "next/link";
import Image from "next/image";
import { Card, Button, Badge, Input, Modal, CardSkeleton, OAuthModal } from "@/shared/components";
import { OAUTH_PROVIDERS, APIKEY_PROVIDERS, getProviderAlias } from "@/shared/constants/providers";
import { getModelsByProviderId } from "@/shared/constants/models";
import { PROVIDER_ENDPOINTS } from "@/shared/constants/config";
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
export default function ProviderDetailPage() {
const params = useParams();
const providerId = params.id;
const [connections, setConnections] = useState([]);
const [loading, setLoading] = useState(true);
const [showOAuthModal, setShowOAuthModal] = useState(false);
const [showAddApiKeyModal, setShowAddApiKeyModal] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
const [selectedConnection, setSelectedConnection] = useState(null);
const [modelAliases, setModelAliases] = useState({});
const { copied, copy } = useCopyToClipboard();
const providerInfo = OAUTH_PROVIDERS[providerId] || APIKEY_PROVIDERS[providerId];
const isOAuth = !!OAUTH_PROVIDERS[providerId];
const models = getModelsByProviderId(providerId);
const providerAlias = getProviderAlias(providerId);
useEffect(() => {
fetchConnections();
fetchAliases();
}, [providerId]);
const fetchAliases = async () => {
try {
const res = await fetch("/api/models/alias");
const data = await res.json();
if (res.ok) {
setModelAliases(data.aliases || {});
}
} catch (error) {
console.log("Error fetching aliases:", error);
}
};
const handleSetAlias = async (modelId, alias) => {
const fullModel = `${providerAlias}/${modelId}`;
try {
const res = await fetch("/api/models/alias", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model: fullModel, alias }),
});
if (res.ok) {
await fetchAliases();
} else {
const data = await res.json();
alert(data.error || "Failed to set alias");
}
} catch (error) {
console.log("Error setting alias:", error);
}
};
const handleDeleteAlias = async (alias) => {
try {
const res = await fetch(`/api/models/alias?alias=${encodeURIComponent(alias)}`, {
method: "DELETE",
});
if (res.ok) {
await fetchAliases();
}
} catch (error) {
console.log("Error deleting alias:", error);
}
};
const fetchConnections = async () => {
try {
const res = await fetch("/api/providers");
const data = await res.json();
if (res.ok) {
const filtered = (data.connections || []).filter(c => c.provider === providerId);
setConnections(filtered);
}
} catch (error) {
console.log("Error fetching connections:", error);
} finally {
setLoading(false);
}
};
const handleDelete = async (id) => {
if (!confirm("Delete this connection?")) return;
try {
const res = await fetch(`/api/providers/${id}`, { method: "DELETE" });
if (res.ok) {
setConnections(connections.filter(c => c.id !== id));
}
} catch (error) {
console.log("Error deleting connection:", error);
}
};
const handleOAuthSuccess = () => {
fetchConnections();
setShowOAuthModal(false);
};
const handleSaveApiKey = async (formData) => {
try {
const res = await fetch("/api/providers", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ provider: providerId, ...formData }),
});
if (res.ok) {
await fetchConnections();
setShowAddApiKeyModal(false);
}
} catch (error) {
console.log("Error saving connection:", error);
}
};
const handleUpdateConnection = async (formData) => {
try {
const res = await fetch(`/api/providers/${selectedConnection.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(formData),
});
if (res.ok) {
await fetchConnections();
setShowEditModal(false);
}
} catch (error) {
console.log("Error updating connection:", error);
}
};
const handleSwapPriority = async (conn1, conn2) => {
if (!conn1 || !conn2) return;
try {
// Swap priorities
await Promise.all([
fetch(`/api/providers/${conn1.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ priority: conn2.priority }),
}),
fetch(`/api/providers/${conn2.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ priority: conn1.priority }),
}),
]);
await fetchConnections();
} catch (error) {
console.log("Error swapping priority:", error);
}
};
if (!providerInfo) {
return (
<div className="text-center py-20">
<p className="text-text-muted">Provider not found</p>
<Link href="/dashboard/providers" className="text-primary mt-4 inline-block">
Back to Providers
</Link>
</div>
);
}
if (loading) {
return (
<div className="flex flex-col gap-8">
<CardSkeleton />
<CardSkeleton />
</div>
);
}
return (
<div className="flex flex-col gap-8">
{/* Header */}
<div>
<Link
href="/dashboard/providers"
className="inline-flex items-center gap-1 text-sm text-text-muted hover:text-primary transition-colors mb-4"
>
<span className="material-symbols-outlined text-lg">arrow_back</span>
Back to Providers
</Link>
<div className="flex items-center gap-4">
<div
className="rounded-lg flex items-center justify-center"
style={{ backgroundColor: `${providerInfo.color}15` }}
>
<img
src={`/providers/${providerInfo.id}.png`}
alt={providerInfo.name}
className="size-12 object-contain rounded-lg"
onError={(e) => { e.target.style.display = "none"; }}
/>
</div>
<div>
<h1 className="text-3xl font-semibold tracking-tight">{providerInfo.name}</h1>
<p className="text-text-muted">
{connections.length} connection{connections.length !== 1 ? "s" : ""}
</p>
</div>
</div>
</div>
{/* Connections */}
<Card>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">Connections</h2>
<Button
size="sm"
icon="add"
onClick={() => isOAuth ? setShowOAuthModal(true) : setShowAddApiKeyModal(true)}
>
Add
</Button>
</div>
{connections.length === 0 ? (
<div className="text-center py-8">
<span className="material-symbols-outlined text-4xl text-text-muted mb-2">
{isOAuth ? "lock" : "key"}
</span>
<p className="text-sm text-text-muted">No connections yet</p>
</div>
) : (
<div className="flex flex-col gap-2">
{connections
.sort((a, b) => (a.priority || 0) - (b.priority || 0))
.map((conn, index) => (
<ConnectionRow
key={conn.id}
connection={conn}
isOAuth={isOAuth}
isFirst={index === 0}
isLast={index === connections.length - 1}
onMoveUp={() => handleSwapPriority(conn, connections[index - 1])}
onMoveDown={() => handleSwapPriority(conn, connections[index + 1])}
onEdit={() => {
setSelectedConnection(conn);
setShowEditModal(true);
}}
onDelete={() => handleDelete(conn.id)}
/>
))}
</div>
)}
</Card>
{/* Models */}
<Card>
<h2 className="text-lg font-semibold mb-4">
{providerInfo.passthroughModels ? "Model Aliases" : "Available Models"}
</h2>
{providerInfo.passthroughModels ? (
<PassthroughModelsSection
providerAlias={providerAlias}
modelAliases={modelAliases}
copied={copied}
onCopy={copy}
onSetAlias={handleSetAlias}
onDeleteAlias={handleDeleteAlias}
/>
) : models.length === 0 ? (
<p className="text-sm text-text-muted">No models configured</p>
) : (
<div className="flex flex-wrap gap-3">
{models.map((model) => {
const fullModel = `${providerAlias}/${model.id}`;
// Also check for old format (providerId/model) for backward compatibility
const oldFormatModel = `${providerId}/${model.id}`;
const existingAlias = Object.entries(modelAliases).find(
([, m]) => m === fullModel || m === oldFormatModel
)?.[0];
return (
<ModelRow
key={model.id}
model={model}
fullModel={fullModel}
alias={existingAlias}
copied={copied}
onCopy={copy}
onSetAlias={(alias) => handleSetAlias(model.id, alias)}
onDeleteAlias={() => handleDeleteAlias(existingAlias)}
/>
);
})}
</div>
)}
</Card>
{/* Modals */}
<OAuthModal
isOpen={showOAuthModal}
provider={providerId}
providerInfo={providerInfo}
onSuccess={handleOAuthSuccess}
onClose={() => setShowOAuthModal(false)}
/>
<AddApiKeyModal
isOpen={showAddApiKeyModal}
provider={providerId}
onSave={handleSaveApiKey}
onClose={() => setShowAddApiKeyModal(false)}
/>
<EditConnectionModal
isOpen={showEditModal}
connection={selectedConnection}
onSave={handleUpdateConnection}
onClose={() => setShowEditModal(false)}
/>
</div>
);
}
function ModelRow({ model, fullModel, alias, copied, onCopy }) {
return (
<div className="flex items-center gap-2 px-3 py-2 rounded-lg border border-border hover:bg-sidebar/50">
<span className="material-symbols-outlined text-base text-text-muted">smart_toy</span>
<code className="text-xs text-text-muted font-mono bg-sidebar px-1.5 py-0.5 rounded">{fullModel}</code>
<button
onClick={() => onCopy(fullModel, `model-${model.id}`)}
className="p-0.5 hover:bg-sidebar rounded text-text-muted hover:text-primary"
title="Copy model"
>
<span className="material-symbols-outlined text-sm">
{copied === `model-${model.id}` ? "check" : "content_copy"}
</span>
</button>
</div>
);
}
function PassthroughModelsSection({ providerAlias, modelAliases, copied, onCopy, onSetAlias, onDeleteAlias }) {
const [newModel, setNewModel] = useState("");
const [adding, setAdding] = useState(false);
// Filter aliases for this provider - models are persisted via alias
const providerAliases = Object.entries(modelAliases).filter(
([, model]) => model.startsWith(`${providerAlias}/`)
);
const allModels = providerAliases.map(([alias, fullModel]) => ({
modelId: fullModel.replace(`${providerAlias}/`, ""),
fullModel,
alias,
}));
// Generate default alias from modelId (last part after /)
const generateDefaultAlias = (modelId) => {
const parts = modelId.split("/");
return parts[parts.length - 1];
};
const handleAdd = async () => {
if (!newModel.trim() || adding) return;
const modelId = newModel.trim();
const defaultAlias = generateDefaultAlias(modelId);
// Check if alias already exists
if (modelAliases[defaultAlias]) {
alert(`Alias "${defaultAlias}" already exists. Please use a different model or edit existing alias.`);
return;
}
setAdding(true);
try {
await onSetAlias(modelId, defaultAlias);
setNewModel("");
} catch (error) {
console.log("Error adding model:", error);
} finally {
setAdding(false);
}
};
return (
<div className="flex flex-col gap-4">
<p className="text-sm text-text-muted">
OpenRouter supports any model. Add models and create aliases for quick access.
</p>
{/* Add new model */}
<div className="flex items-end gap-2">
<div className="flex-1">
<label className="text-xs text-text-muted mb-1 block">Model ID (from OpenRouter)</label>
<input
type="text"
value={newModel}
onChange={(e) => setNewModel(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleAdd()}
placeholder="anthropic/claude-3-opus"
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
/>
</div>
<Button size="sm" icon="add" onClick={handleAdd} disabled={!newModel.trim() || adding}>
{adding ? "Adding..." : "Add"}
</Button>
</div>
{/* Models list */}
{allModels.length > 0 && (
<div className="flex flex-col gap-3">
{allModels.map(({ modelId, fullModel, alias }) => (
<PassthroughModelRow
key={fullModel}
modelId={modelId}
fullModel={fullModel}
copied={copied}
onCopy={onCopy}
onDeleteAlias={() => onDeleteAlias(alias)}
/>
))}
</div>
)}
</div>
);
}
function PassthroughModelRow({ modelId, fullModel, copied, onCopy, onDeleteAlias }) {
return (
<div className="flex items-center gap-3 p-3 rounded-lg border border-border hover:bg-sidebar/50">
<span className="material-symbols-outlined text-base text-text-muted">smart_toy</span>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{modelId}</p>
<div className="flex items-center gap-1 mt-1">
<code className="text-xs text-text-muted font-mono bg-sidebar px-1.5 py-0.5 rounded">{fullModel}</code>
<button
onClick={() => onCopy(fullModel, `model-${modelId}`)}
className="p-0.5 hover:bg-sidebar rounded text-text-muted hover:text-primary"
title="Copy model"
>
<span className="material-symbols-outlined text-sm">
{copied === `model-${modelId}` ? "check" : "content_copy"}
</span>
</button>
</div>
</div>
{/* Delete button */}
<button
onClick={onDeleteAlias}
className="p-1 hover:bg-red-50 rounded text-red-500"
title="Remove model"
>
<span className="material-symbols-outlined text-sm">delete</span>
</button>
</div>
);
}
function CooldownTimer({ until }) {
const [remaining, setRemaining] = useState("");
useEffect(() => {
const updateRemaining = () => {
const diff = new Date(until).getTime() - Date.now();
if (diff <= 0) {
setRemaining("");
return;
}
const secs = Math.floor(diff / 1000);
if (secs < 60) {
setRemaining(`${secs}s`);
} else if (secs < 3600) {
setRemaining(`${Math.floor(secs / 60)}m ${secs % 60}s`);
} else {
const hrs = Math.floor(secs / 3600);
const mins = Math.floor((secs % 3600) / 60);
setRemaining(`${hrs}h ${mins}m`);
}
};
updateRemaining();
const interval = setInterval(updateRemaining, 1000);
return () => clearInterval(interval);
}, [until]);
if (!remaining) return null;
return (
<span className="text-xs text-orange-500 font-mono">
⏱ {remaining}
</span>
);
}
function ConnectionRow({ connection, isOAuth, isFirst, isLast, onMoveUp, onMoveDown, onEdit, onDelete }) {
const displayName = isOAuth
? connection.name || connection.email || connection.displayName || "OAuth Account"
: connection.name;
// Use useState + useEffect for impure Date.now() to avoid calling during render
const [isCooldown, setIsCooldown] = useState(false);
useEffect(() => {
const checkCooldown = () => {
const cooldown = connection.rateLimitedUntil &&
new Date(connection.rateLimitedUntil).getTime() > Date.now();
setIsCooldown(cooldown);
};
checkCooldown();
// Update every second while in cooldown
const interval = connection.rateLimitedUntil ? setInterval(checkCooldown, 1000) : null;
return () => {
if (interval) clearInterval(interval);
};
}, [connection.rateLimitedUntil]);
// Determine effective status (override unavailable if cooldown expired)
const effectiveStatus = (connection.testStatus === "unavailable" && !isCooldown)
? "active" // Cooldown expired → treat as active
: connection.testStatus;
const getStatusVariant = () => {
if (effectiveStatus === "active" || effectiveStatus === "success") return "success";
if (effectiveStatus === "error" || effectiveStatus === "expired" || effectiveStatus === "unavailable") return "error";
return "default";
};
const hasError = effectiveStatus === "error" || effectiveStatus === "expired" || effectiveStatus === "unavailable";
return (
<div className="flex items-center justify-between p-3 rounded-lg border border-border hover:bg-sidebar/50 hover:cursor-pointer">
<div className="flex items-center gap-3 flex-1 min-w-0">
{/* Priority arrows */}
<div className="flex flex-col">
<button
onClick={onMoveUp}
disabled={isFirst}
className={`p-0.5 rounded ${isFirst ? "text-text-muted/30 cursor-not-allowed" : "hover:bg-sidebar text-text-muted hover:text-primary"}`}
>
<span className="material-symbols-outlined text-sm">keyboard_arrow_up</span>
</button>
<button
onClick={onMoveDown}
disabled={isLast}
className={`p-0.5 rounded ${isLast ? "text-text-muted/30 cursor-not-allowed" : "hover:bg-sidebar text-text-muted hover:text-primary"}`}
>
<span className="material-symbols-outlined text-sm">keyboard_arrow_down</span>
</button>
</div>
<span className="material-symbols-outlined text-base text-text-muted">
{isOAuth ? "lock" : "key"}
</span>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{displayName}</p>
<div className="flex items-center gap-2 mt-1">
<Badge variant={getStatusVariant()} size="sm" dot>
{effectiveStatus || "Unknown"}
</Badge>
{isCooldown && <CooldownTimer until={connection.rateLimitedUntil} />}
{connection.lastError && (
<span className="text-xs text-red-500 truncate max-w-[300px]" title={connection.lastError}>
{connection.lastError}
</span>
)}
<span className="text-xs text-text-muted">#{connection.priority}</span>
{connection.globalPriority && (
<span className="text-xs text-text-muted">Auto: {connection.globalPriority}</span>
)}
</div>
</div>
</div>
<div className="flex gap-1">
<button onClick={onEdit} className="p-2 hover:bg-sidebar rounded">
<span className="material-symbols-outlined text-base">edit</span>
</button>
<button onClick={onDelete} className="p-2 hover:bg-red-50 rounded text-red-500">
<span className="material-symbols-outlined text-base">delete</span>
</button>
</div>
</div>
);
}
function AddApiKeyModal({ isOpen, provider, onSave, onClose }) {
const [formData, setFormData] = useState({
name: "",
apiKey: "",
priority: 1,
});
const [validating, setValidating] = useState(false);
const [validationResult, setValidationResult] = useState(null);
const handleValidate = async () => {
setValidating(true);
try {
const res = await fetch("/api/providers/validate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ provider, apiKey: formData.apiKey }),
});
const data = await res.json();
setValidationResult(data.valid ? "success" : "failed");
} catch {
setValidationResult("failed");
} finally {
setValidating(false);
}
};
const handleSubmit = () => {
onSave({
name: formData.name,
apiKey: formData.apiKey,
priority: formData.priority,
testStatus: validationResult === "success" ? "active" : "unknown",
});
};
if (!provider) return null;
return (
<Modal isOpen={isOpen} title={`Add ${provider} API Key`} onClose={onClose}>
<div className="flex flex-col gap-4">
<Input
label="Name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="Production Key"
/>
<div className="flex gap-2">
<Input
label="API Key"
type="password"
value={formData.apiKey}
onChange={(e) => setFormData({ ...formData, apiKey: e.target.value })}
className="flex-1"
/>
<div className="pt-6">
<Button onClick={handleValidate} disabled={!formData.apiKey || validating} variant="secondary">
{validating ? "Checking..." : "Check"}
</Button>
</div>
</div>
{validationResult && (
<Badge variant={validationResult === "success" ? "success" : "error"}>
{validationResult === "success" ? "Valid" : "Invalid"}
</Badge>
)}
<Input
label="Priority"
type="number"
value={formData.priority}
onChange={(e) => setFormData({ ...formData, priority: parseInt(e.target.value) || 1 })}
/>
<div className="flex gap-2">
<Button onClick={handleSubmit} fullWidth disabled={!formData.name || !formData.apiKey}>
Save
</Button>
<Button onClick={onClose} variant="ghost" fullWidth>
Cancel
</Button>
</div>
</div>
</Modal>
);
}
function EditConnectionModal({ isOpen, connection, onSave, onClose }) {
const [formData, setFormData] = useState({
name: "",
priority: 1,
});
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState(null);
useEffect(() => {
if (connection) {
setFormData({
name: connection.name || "",
priority: connection.priority || 1,
});
setTestResult(null);
}
}, [connection]);
const handleTest = async () => {
if (!connection?.provider) return;
setTesting(true);
setTestResult(null);
try {
const res = await fetch(`/api/providers/${connection.id}/test`, { method: "POST" });
const data = await res.json();
setTestResult(data.valid ? "success" : "failed");
if (data.valid) {
onSave({ testStatus: "active", lastError: null, lastErrorAt: null });
} else {
onSave({ testStatus: "error", lastError: data.error, lastErrorAt: new Date().toISOString() });
}
} catch {
setTestResult("failed");
} finally {
setTesting(false);
}
};
const handleSubmit = () => {
const updates = { name: formData.name, priority: formData.priority };
onSave(updates);
};
if (!connection) return null;
const isOAuth = connection.authType === "oauth";
return (
<Modal isOpen={isOpen} title="Edit Connection" onClose={onClose}>
<div className="flex flex-col gap-4">
<Input
label="Name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder={isOAuth ? "Account name" : "Production Key"}
/>
{isOAuth && connection.email && (
<div className="bg-sidebar/50 p-3 rounded-lg">
<p className="text-sm text-text-muted mb-1">Email</p>
<p className="font-medium">{connection.email}</p>
</div>
)}
<Input
label="Priority"
type="number"
value={formData.priority}
onChange={(e) => setFormData({ ...formData, priority: parseInt(e.target.value) || 1 })}
/>
{/* Test Connection */}
<div className="flex items-center gap-3">
<Button onClick={handleTest} variant="secondary" disabled={testing}>
{testing ? "Testing..." : "Test Connection"}
</Button>
{testResult && (
<Badge variant={testResult === "success" ? "success" : "error"}>
{testResult === "success" ? "Valid" : "Failed"}
</Badge>
)}
</div>
<div className="flex gap-2">
<Button onClick={handleSubmit} fullWidth>Save</Button>
<Button onClick={onClose} variant="ghost" fullWidth>Cancel</Button>
</div>
</div>
</Modal>
);
}
@@ -0,0 +1,220 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { Card, Button, Input, Select, Toggle } from "@/shared/components";
import { AI_PROVIDERS, AUTH_METHODS } from "@/shared/constants/config";
const providerOptions = Object.values(AI_PROVIDERS).map((p) => ({
value: p.id,
label: p.name,
}));
const authMethodOptions = Object.values(AUTH_METHODS).map((m) => ({
value: m.id,
label: m.name,
}));
export default function NewProviderPage() {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [formData, setFormData] = useState({
provider: "",
authMethod: "api_key",
apiKey: "",
displayName: "",
isActive: true,
});
const [errors, setErrors] = useState({});
const handleChange = (field, value) => {
setFormData((prev) => ({ ...prev, [field]: value }));
if (errors[field]) {
setErrors((prev) => ({ ...prev, [field]: null }));
}
};
const validate = () => {
const newErrors = {};
if (!formData.provider) newErrors.provider = "Please select a provider";
if (formData.authMethod === "api_key" && !formData.apiKey) {
newErrors.apiKey = "API Key is required";
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async (e) => {
e.preventDefault();
if (!validate()) return;
setLoading(true);
try {
const response = await fetch("/api/providers", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(formData),
});
if (response.ok) {
router.push("/dashboard/providers");
} else {
const data = await response.json();
setErrors({ submit: data.error || "Failed to create provider" });
}
} catch (error) {
setErrors({ submit: "An error occurred. Please try again." });
} finally {
setLoading(false);
}
};
const selectedProvider = AI_PROVIDERS[formData.provider];
return (
<div className="max-w-2xl mx-auto">
{/* Header */}
<div className="mb-8">
<Link
href="/dashboard/providers"
className="inline-flex items-center gap-1 text-sm text-text-muted hover:text-primary transition-colors mb-4"
>
<span className="material-symbols-outlined text-lg">arrow_back</span>
Back to Providers
</Link>
<h1 className="text-3xl font-semibold tracking-tight">Add New Provider</h1>
<p className="text-text-muted mt-2">
Configure a new AI provider to use with your applications.
</p>
</div>
{/* Form */}
<Card>
<form onSubmit={handleSubmit} className="flex flex-col gap-6">
{/* Provider Selection */}
<Select
label="Provider"
options={providerOptions}
value={formData.provider}
onChange={(e) => handleChange("provider", e.target.value)}
placeholder="Select a provider"
error={errors.provider}
required
/>
{/* Provider Info */}
{selectedProvider && (
<Card.Section className="flex items-center gap-3">
<div
className="size-10 rounded-lg flex items-center justify-center bg-bg border border-border"
>
<span
className="material-symbols-outlined text-xl"
style={{ color: selectedProvider.color }}
>
{selectedProvider.icon}
</span>
</div>
<div>
<p className="font-medium">{selectedProvider.name}</p>
<p className="text-sm text-text-muted">
Selected provider
</p>
</div>
</Card.Section>
)}
{/* Auth Method */}
<div className="flex flex-col gap-3">
<label className="text-sm font-medium">
Authentication Method <span className="text-red-500">*</span>
</label>
<div className="flex gap-3">
{authMethodOptions.map((method) => (
<button
key={method.value}
type="button"
onClick={() => handleChange("authMethod", method.value)}
className={`flex-1 flex items-center justify-center gap-2 p-4 rounded-lg border transition-all ${
formData.authMethod === method.value
? "border-primary bg-primary/5 text-primary"
: "border-border hover:border-primary/50"
}`}
>
<span className="material-symbols-outlined">
{method.value === "api_key" ? "key" : "lock"}
</span>
<span className="font-medium">{method.label}</span>
</button>
))}
</div>
</div>
{/* API Key Input */}
{formData.authMethod === "api_key" && (
<Input
label="API Key"
type="password"
placeholder="Enter your API key"
value={formData.apiKey}
onChange={(e) => handleChange("apiKey", e.target.value)}
error={errors.apiKey}
hint="Your API key will be encrypted and stored securely."
required
/>
)}
{/* OAuth2 Button */}
{formData.authMethod === "oauth2" && (
<Card.Section>
<p className="text-sm text-text-muted mb-4">
Connect your account using OAuth2 authentication.
</p>
<Button type="button" variant="secondary" icon="link">
Connect with OAuth2
</Button>
</Card.Section>
)}
{/* Display Name */}
<Input
label="Display Name"
placeholder="e.g., Production API, Dev Environment"
value={formData.displayName}
onChange={(e) => handleChange("displayName", e.target.value)}
hint="Optional. A friendly name to identify this configuration."
/>
{/* Active Toggle */}
<Toggle
checked={formData.isActive}
onChange={(checked) => handleChange("isActive", checked)}
label="Active"
description="Enable this provider for use in your applications"
/>
{/* Error Message */}
{errors.submit && (
<div className="p-4 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-600 dark:text-red-400 text-sm">
{errors.submit}
</div>
)}
{/* Actions */}
<div className="flex gap-3 pt-4 border-t border-border">
<Link href="/dashboard/providers" className="flex-1">
<Button type="button" variant="ghost" fullWidth>
Cancel
</Button>
</Link>
<Button type="submit" loading={loading} fullWidth className="flex-1">
Create Provider
</Button>
</div>
</form>
</Card>
</div>
);
}
@@ -0,0 +1,235 @@
"use client";
import { useState, useEffect } from "react";
import { Card, CardSkeleton, Badge } from "@/shared/components";
import { OAUTH_PROVIDERS, APIKEY_PROVIDERS } from "@/shared/constants/config";
import Image from "next/image";
import Link from "next/link";
import { getErrorCode, getRelativeTime } from "@/shared/utils";
export default function ProvidersPage() {
const [connections, setConnections] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchData();
}, []);
const fetchData = async () => {
try {
const res = await fetch("/api/providers");
const data = await res.json();
if (res.ok) setConnections(data.connections || []);
} catch (error) {
console.log("Error fetching data:", error);
} finally {
setLoading(false);
}
};
const getProviderStats = (providerId, authType) => {
const providerConnections = connections.filter(
c => c.provider === providerId && c.authType === authType
);
// Helper: check if connection is effectively active (cooldown expired)
const getEffectiveStatus = (conn) => {
const isCooldown = conn.rateLimitedUntil && new Date(conn.rateLimitedUntil).getTime() > Date.now();
return (conn.testStatus === "unavailable" && !isCooldown) ? "active" : conn.testStatus;
};
const connected = providerConnections.filter(c => {
const status = getEffectiveStatus(c);
return status === "active" || status === "success";
}).length;
const errorConns = providerConnections.filter(c => {
const status = getEffectiveStatus(c);
return status === "error" || status === "expired" || status === "unavailable";
});
const error = errorConns.length;
const total = providerConnections.length;
// Get latest error info
const latestError = errorConns.sort((a, b) =>
new Date(b.lastErrorAt || 0) - new Date(a.lastErrorAt || 0)
)[0];
const errorCode = latestError ? getErrorCode(latestError.lastError) : null;
const errorTime = latestError?.lastErrorAt ? getRelativeTime(latestError.lastErrorAt) : null;
return { connected, error, total, errorCode, errorTime };
};
if (loading) {
return (
<div className="flex flex-col gap-8">
<CardSkeleton />
<CardSkeleton />
</div>
);
}
return (
<div className="flex flex-col gap-8">
{/* OAuth Providers */}
<div className="flex flex-col gap-4">
<h2 className="text-xl font-semibold">OAuth Providers</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{Object.entries(OAUTH_PROVIDERS).map(([key, info]) => (
<ProviderCard
key={key}
providerId={key}
provider={info}
stats={getProviderStats(key, "oauth")}
/>
))}
</div>
</div>
{/* API Key Providers */}
<div className="flex flex-col gap-4">
<h2 className="text-xl font-semibold">API Key Providers</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{Object.entries(APIKEY_PROVIDERS).map(([key, info]) => (
<ApiKeyProviderCard
key={key}
providerId={key}
provider={info}
stats={getProviderStats(key, "apikey")}
/>
))}
</div>
</div>
</div>
);
}
function ProviderCard({ providerId, provider, stats }) {
const { connected, error, errorCode, errorTime } = stats;
const [imgError, setImgError] = useState(false);
const getStatusDisplay = () => {
const parts = [];
if (connected > 0) {
parts.push(
<Badge key="connected" variant="success" size="sm" dot>
{connected} Connected
</Badge>
);
}
if (error > 0) {
const errText = errorCode ? `${error} Error (${errorCode})` : `${error} Error`;
parts.push(
<Badge key="error" variant="error" size="sm" dot>
{errText}
</Badge>
);
}
if (parts.length === 0) {
return <span className="text-text-muted">No connections</span>;
}
return parts;
};
return (
<Link href={`/dashboard/providers/${providerId}`}>
<Card className="h-full hover:border-primary/50 transition-colors cursor-pointer">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div
className="size-10 rounded-lg flex items-center justify-center"
style={{ backgroundColor: `${provider.color}15` }}
>
{!imgError ? (
<img
src={`/providers/${provider.id}.png`}
alt={provider.name}
className="size-10 object-contain rounded-lg"
onError={() => setImgError(true)}
/>
) : (
<span
className="text-sm font-bold"
style={{ color: provider.color }}
>
{provider.textIcon || provider.id.slice(0, 2).toUpperCase()}
</span>
)}
</div>
<div>
<h3 className="font-semibold">{provider.name}</h3>
<div className="flex items-center gap-2 text-xs flex-wrap">
{getStatusDisplay()}
{errorTime && <span className="text-text-muted">• {errorTime}</span>}
</div>
</div>
</div>
<span className="material-symbols-outlined text-text-muted">
chevron_right
</span>
</div>
</Card>
</Link>
);
}
// API Key providers - only use textIcon, no image
function ApiKeyProviderCard({ providerId, provider, stats }) {
const { connected, error, errorCode, errorTime } = stats;
const getStatusDisplay = () => {
const parts = [];
if (connected > 0) {
parts.push(
<Badge key="connected" variant="success" size="sm" dot>
{connected} Connected
</Badge>
);
}
if (error > 0) {
const errText = errorCode ? `${error} Error (${errorCode})` : `${error} Error`;
parts.push(
<Badge key="error" variant="error" size="sm" dot>
{errText}
</Badge>
);
}
if (parts.length === 0) {
return <span className="text-text-muted">No connections</span>;
}
return parts;
};
return (
<Link href={`/dashboard/providers/${providerId}`}>
<Card className="h-full hover:border-primary/50 transition-colors cursor-pointer">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div
className="size-10 rounded-lg flex items-center justify-center"
style={{ backgroundColor: `${provider.color}15` }}
>
<span
className="text-sm font-bold"
style={{ color: provider.color }}
>
{provider.textIcon || provider.id.slice(0, 2).toUpperCase()}
</span>
</div>
<div>
<h3 className="font-semibold">{provider.name}</h3>
<div className="flex items-center gap-2 text-xs flex-wrap">
{getStatusDisplay()}
{errorTime && <span className="text-text-muted">• {errorTime}</span>}
</div>
</div>
</div>
<span className="material-symbols-outlined text-text-muted">
chevron_right
</span>
</div>
</Card>
</Link>
);
}