Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
288 changes: 288 additions & 0 deletions src/app/(main)/management/AiProvidersSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,288 @@
"use client";

import React, { useEffect, useState } from "react";
import { Bot, ChevronRight, Plus, TestTube2, Trash2 } from "lucide-react";
import { Button } from "@components/ui/Button";
import { Modal } from "@components/ui/Modal";
import { toast } from "sonner";
import type { Dict } from "@providers/I18nProvider";

export type AIProviderType = "openai" | "anthropic" | "google" | "custom";

export interface AIProvider {
id: string;
name: string;
type: AIProviderType;
apiKey: string;
baseUrl?: string;
model?: string;
enabled: boolean;
isDefault: boolean;
}

const typeLabels: Record<AIProviderType, string> = {
openai: "OpenAI",
anthropic: "Anthropic",
google: "Google Gemini",
custom: "Custom",
};

export function AiProvidersSection({ dict }: { dict: Dict }) {
const [providers, setProviders] = useState<AIProvider[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [editing, setEditing] = useState<AIProvider | null>(null);
const [testingId, setTestingId] = useState<string | null>(null);

const load = async () => {
setLoading(true);
try {
const res = await fetch("/api/management/ai-providers");
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Failed to load providers");
setProviders(Array.isArray(data.providers) ? data.providers : []);
} catch (error) {
console.error(error);
toast.error(dict.common.error);
} finally {
setLoading(false);
}
};

useEffect(() => {
load();
}, []);

const createProvider = async (provider: Omit<AIProvider, "id">) => {
setSaving(true);
try {
const res = await fetch("/api/management/ai-providers", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(provider),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Failed to create provider");
await load();
setEditing(null);
toast.success(dict.common.success);
} catch (error) {
console.error(error);
toast.error(dict.common.error);
} finally {
setSaving(false);
}
};

const updateProvider = async (provider: AIProvider) => {
setSaving(true);
try {
const res = await fetch(`/api/management/ai-providers/${provider.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(provider),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Failed to update provider");
await load();
setEditing(null);
toast.success(dict.common.success);
} catch (error) {
console.error(error);
toast.error(dict.common.error);
} finally {
setSaving(false);
}
};

const deleteProvider = async (id: string) => {
setSaving(true);
try {
const res = await fetch(`/api/management/ai-providers/${id}`, {
method: "DELETE",
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Failed to delete provider");
await load();
toast.success(dict.common.success);
} catch (error) {
console.error(error);
toast.error(dict.common.error);
} finally {
setSaving(false);
}
};

const setDefaultProvider = async (id: string) => {
setSaving(true);
try {
const res = await fetch(`/api/management/ai-providers/${id}/default`, {
method: "POST",
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Failed to set default provider");
await load();
toast.success(dict.common.success);
} catch (error) {
console.error(error);
toast.error(dict.common.error);
} finally {
setSaving(false);
}
};

const toggleEnabled = async (provider: AIProvider) => {
await updateProvider({ ...provider, enabled: !provider.enabled });
};

const testConnection = async (providerId: string) => {
setTestingId(providerId);
try {
const res = await fetch(`/api/management/ai-providers/${providerId}/test`, {
method: "POST",
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Connection failed");
toast.success(data.message || "Connection successful");
} catch (error) {
console.error(error);
toast.error(error instanceof Error ? error.message : dict.common.error);
} finally {
setTestingId(null);
}
};

if (loading) {
return <p className="text-muted">{dict.common.loading}</p>;
}

return (
<div className="auth-settings">
<section className="settings-section">
<div className="flex justify-between items-center gap-4">
<div>
<h2 className="section-title">{dict.management.aiProviders || "AI Providers"}</h2>
<p className="section-subtitle">
{dict.management.aiProvidersDesc || "Configure model providers for workspace AI features."}
</p>
</div>
<Button className="btn-primary" onClick={() => setEditing({
id: "",
name: "",
type: "openai",
apiKey: "",
baseUrl: "",
model: "",
enabled: true,
isDefault: providers.length === 0,
})}>
<Plus size={16} /> {dict.management.addAiProvider || "Add provider"}
</Button>
</div>

<div className="provider-grid mt-5">
{providers.map((p) => (
<div key={p.id} className="provider-card">
<div className="provider-header">
<div className="provider-icon-wrapper" style={{ backgroundColor: "#6366f110", color: "#6366f1" }}>
<Bot size={24} />
</div>
<button className={`zen-switch-small ${p.enabled ? "active" : ""}`} onClick={() => toggleEnabled(p)} disabled={saving}>
<div className="switch-thumb" />
</button>
</div>
<h3 className="provider-name">{p.name}</h3>
<div className="provider-status">
<span className={`status-dot ${p.enabled ? "active" : ""}`} />
{p.enabled ? dict.common.enabled : dict.common.disabled} · {typeLabels[p.type]}
</div>
{p.isDefault && <p className="text-[10px] mt-2 uppercase tracking-wider text-indigo-500">{dict.management.defaultProvider || "Default provider"}</p>}
<div className="flex flex-col gap-2 mt-4">
<Button className="btn-minimal w-full justify-between" onClick={() => setEditing(p)} noRipple>
<span>{dict.management.configure}</span>
<ChevronRight size={16} />
</Button>
<div className="flex gap-2">
<Button className="btn-minimal flex-1" onClick={() => setDefaultProvider(p.id)} disabled={p.isDefault || saving}>
{dict.management.setDefaultProvider || "Set default"}
</Button>
<Button className="btn-minimal flex-1" onClick={() => testConnection(p.id)} disabled={testingId === p.id}>
<TestTube2 size={14} />
</Button>
<Button className="btn-minimal" onClick={() => deleteProvider(p.id)} disabled={saving}>
<Trash2 size={14} />
</Button>
</div>
</div>
</div>
))}
</div>
</section>

{editing && (
<AiProviderModal
provider={editing}
saving={saving}
onClose={() => setEditing(null)}
onSave={(next) => (editing.id ? updateProvider(next as AIProvider) : createProvider(next as Omit<AIProvider, "id">))}
dict={dict}
/>
)}
</div>
);
}

function AiProviderModal({
provider,
saving,
onClose,
onSave,
dict,
}: {
provider: AIProvider;
saving: boolean;
onClose: () => void;
onSave: (provider: Partial<AIProvider>) => void;
dict: Dict;
}) {
const [formData, setFormData] = useState<AIProvider>(provider);
return (
<Modal isOpen={true} onClose={onClose} title={`${dict.management.configure} ${formData.name || "Provider"}`}>
<form
className="flex flex-col gap-4 mt-5"
onSubmit={(e) => {
e.preventDefault();
onSave(formData);
}}
>
<div className="form-group">
<label className="modal-label">Name</label>
<input className="zen-input" value={formData.name} onChange={(e) => setFormData({ ...formData, name: e.target.value })} required />
</div>
<div className="form-group">
<label className="modal-label">Type</label>
<select className="zen-input" value={formData.type} onChange={(e) => setFormData({ ...formData, type: e.target.value as AIProviderType })}>
{Object.entries(typeLabels).map(([value, label]) => (
<option key={value} value={value}>{label}</option>
))}
</select>
</div>
<div className="form-group">
<label className="modal-label">API Key</label>
<input className="zen-input" value={formData.apiKey} onChange={(e) => setFormData({ ...formData, apiKey: e.target.value })} required type="password" />
</div>
<div className="form-group">
<label className="modal-label">Base URL</label>
<input className="zen-input" value={formData.baseUrl || ""} onChange={(e) => setFormData({ ...formData, baseUrl: e.target.value })} placeholder="Optional for standard endpoints" />
</div>
<div className="form-group">
<label className="modal-label">Model</label>
<input className="zen-input" value={formData.model || ""} onChange={(e) => setFormData({ ...formData, model: e.target.value })} />
</div>
<div className="modal-footer-full">
<Button className="btn-primary btn-full" type="submit" disabled={saving}>{dict.common.save}</Button>
</div>
</form>
</Modal>
);
}
20 changes: 19 additions & 1 deletion src/app/(main)/management/ManagementClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { Modal } from "@components/ui/Modal";
import { AuditTable, type AuditLog } from "@components/audit/AuditTable";
import { AuditExportModal } from "@components/audit/AuditExportModal";
import { VercelIcon } from "@components/icons/VercelIcon";
import { AiProvidersSection } from "./AiProvidersSection";

type ProviderKey =
| "google"
Expand Down Expand Up @@ -97,7 +98,12 @@ export function ManagementClient() {
useEffect(() => {
const handleHashChange = () => {
const hash = window.location.hash.replace("#", "");
if (hash === "audit" || hash === "authentication" || hash === "sso") {
if (
hash === "audit" ||
hash === "authentication" ||
hash === "sso" ||
hash === "ai-providers"
) {
setActiveTab(hash);
} else {
setActiveTab("authentication");
Expand All @@ -117,6 +123,7 @@ export function ManagementClient() {
const titles: Record<string, string> = {
authentication: dict.pages.management,
sso: dict.pages.ssoProviders,
"ai-providers": dict.management.aiProviders || "AI Providers",
audit: dict.pages.securityAudit,
};

Expand Down Expand Up @@ -323,6 +330,15 @@ export function ManagementClient() {
<Globe size={16} />
{dict.management.ssoProviders}
</button>
<button
className={`management-tab-btn ${
activeTab === "ai-providers" ? "active" : ""
}`}
onClick={() => (window.location.hash = "ai-providers")}
>
<Globe size={16} />
{dict.management.aiProviders || "AI Providers"}
</button>
<button
className={`management-tab-btn ${
activeTab === "audit" ? "active" : ""
Expand Down Expand Up @@ -575,6 +591,8 @@ export function ManagementClient() {
</section>
</div>
)}

{activeTab === "ai-providers" && <AiProvidersSection dict={dict} />}
</main>
</div>

Expand Down
21 changes: 21 additions & 0 deletions src/app/api/management/ai-providers/[id]/default/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { adminAction } from "@lib/server-utils";
import { getAiProviders, saveAiProviders } from "../../_lib";

export const dynamic = "force-dynamic";

export const POST = adminAction(async (_req, { params }) => {
const { id } = params as { id: string };
const providers = await getAiProviders();

if (!providers.some((provider) => provider.id === id)) {
throw { status: 404, message: "Provider not found" };
}

const updated = providers.map((provider) => ({
...provider,
isDefault: provider.id === id,
}));

await saveAiProviders(updated);
return { success: true };
}, { requireUser: true });
Loading
Loading