247 lines
7.9 KiB
TypeScript
247 lines
7.9 KiB
TypeScript
|
|
"use client";
|
||
|
|
|
||
|
|
import * as React from "react";
|
||
|
|
import { useRouter, useSearchParams } from "next/navigation";
|
||
|
|
import { toast } from "sonner";
|
||
|
|
import { Loader2, ArrowLeft, Plug, Plus } from "lucide-react";
|
||
|
|
import { Button } from "@/components/ui/button";
|
||
|
|
import { Input } from "@/components/ui/input";
|
||
|
|
import { Label } from "@/components/ui/label";
|
||
|
|
import {
|
||
|
|
Card,
|
||
|
|
CardContent,
|
||
|
|
CardHeader,
|
||
|
|
CardTitle,
|
||
|
|
CardDescription,
|
||
|
|
} from "@/components/ui/card";
|
||
|
|
|
||
|
|
type ProviderInfo = {
|
||
|
|
id: string;
|
||
|
|
name: string;
|
||
|
|
description: string;
|
||
|
|
configFields: ConfigField[];
|
||
|
|
supportsOAuth: boolean;
|
||
|
|
};
|
||
|
|
|
||
|
|
type ConfigField = {
|
||
|
|
key: string;
|
||
|
|
label: string;
|
||
|
|
type: string;
|
||
|
|
placeholder?: string;
|
||
|
|
required?: boolean;
|
||
|
|
helpText?: string;
|
||
|
|
options?: { value: string; label: string }[];
|
||
|
|
};
|
||
|
|
|
||
|
|
export default function NewIntegrationPage() {
|
||
|
|
const router = useRouter();
|
||
|
|
const searchParams = useSearchParams();
|
||
|
|
const preselectedProvider = searchParams.get("provider") || "";
|
||
|
|
|
||
|
|
const [providers, setProviders] = React.useState<ProviderInfo[]>([]);
|
||
|
|
const [selectedProvider, setSelectedProvider] = React.useState(preselectedProvider);
|
||
|
|
const [name, setName] = React.useState("");
|
||
|
|
const [config, setConfig] = React.useState<Record<string, string>>({});
|
||
|
|
const [loading, setLoading] = React.useState(true);
|
||
|
|
const [creating, setCreating] = React.useState(false);
|
||
|
|
|
||
|
|
React.useEffect(() => {
|
||
|
|
fetch("/api/integrations")
|
||
|
|
.then((r) => r.json())
|
||
|
|
.then((data) => {
|
||
|
|
setProviders(data.providers || []);
|
||
|
|
setLoading(false);
|
||
|
|
})
|
||
|
|
.catch(() => setLoading(false));
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
React.useEffect(() => {
|
||
|
|
if (selectedProvider) {
|
||
|
|
const p = providers.find((pr) => pr.id === selectedProvider);
|
||
|
|
if (p && !name) {
|
||
|
|
setName(p.name);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}, [selectedProvider, providers, name]);
|
||
|
|
|
||
|
|
const provider = providers.find((p) => p.id === selectedProvider);
|
||
|
|
|
||
|
|
const handleCreate = async (e: React.FormEvent) => {
|
||
|
|
e.preventDefault();
|
||
|
|
if (!selectedProvider) {
|
||
|
|
toast.error("Select a provider");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
setCreating(true);
|
||
|
|
try {
|
||
|
|
const res = await fetch("/api/integrations", {
|
||
|
|
method: "POST",
|
||
|
|
headers: { "Content-Type": "application/json" },
|
||
|
|
body: JSON.stringify({
|
||
|
|
provider: selectedProvider,
|
||
|
|
name: name || provider?.name || selectedProvider,
|
||
|
|
config,
|
||
|
|
}),
|
||
|
|
});
|
||
|
|
|
||
|
|
if (res.ok) {
|
||
|
|
const data = await res.json();
|
||
|
|
toast.success("Integration created");
|
||
|
|
|
||
|
|
if (provider?.supportsOAuth) {
|
||
|
|
window.location.href = `/api/integrations/oauth/${selectedProvider}/authorize?integrationId=${data.integration.id}`;
|
||
|
|
} else {
|
||
|
|
router.push(`/settings/integrations/${data.integration.id}`);
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
const data = await res.json();
|
||
|
|
toast.error(data.error || "Failed to create integration");
|
||
|
|
}
|
||
|
|
} catch {
|
||
|
|
toast.error("Failed to create integration");
|
||
|
|
} finally {
|
||
|
|
setCreating(false);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
if (loading) {
|
||
|
|
return (
|
||
|
|
<div className="flex items-center justify-center py-20">
|
||
|
|
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="space-y-6">
|
||
|
|
<Button
|
||
|
|
variant="ghost"
|
||
|
|
size="sm"
|
||
|
|
onClick={() => router.push("/settings/integrations")}
|
||
|
|
>
|
||
|
|
<ArrowLeft className="mr-1.5 size-3.5" />
|
||
|
|
Back to Integrations
|
||
|
|
</Button>
|
||
|
|
|
||
|
|
{!selectedProvider ? (
|
||
|
|
<div className="space-y-4">
|
||
|
|
<h2 className="text-lg font-semibold">Choose a provider</h2>
|
||
|
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||
|
|
{providers.map((p) => (
|
||
|
|
<button
|
||
|
|
key={p.id}
|
||
|
|
onClick={() => setSelectedProvider(p.id)}
|
||
|
|
className="flex flex-col items-center gap-2 rounded-xl border border-border/50 p-5 text-center transition-colors hover:border-primary/50 hover:bg-primary/5"
|
||
|
|
>
|
||
|
|
<Plug className="size-6 text-muted-foreground" />
|
||
|
|
<span className="text-sm font-medium">{p.name}</span>
|
||
|
|
<span className="text-xs text-muted-foreground">
|
||
|
|
{p.description}
|
||
|
|
</span>
|
||
|
|
</button>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
) : (
|
||
|
|
<Card className="glass-card">
|
||
|
|
<CardHeader>
|
||
|
|
<CardTitle className="flex items-center gap-2 text-base">
|
||
|
|
<Plug className="size-4" />
|
||
|
|
Configure {provider?.name}
|
||
|
|
</CardTitle>
|
||
|
|
<CardDescription>{provider?.description}</CardDescription>
|
||
|
|
</CardHeader>
|
||
|
|
<CardContent>
|
||
|
|
<form onSubmit={handleCreate} className="space-y-4">
|
||
|
|
<div className="space-y-2">
|
||
|
|
<Label>Integration Name</Label>
|
||
|
|
<Input
|
||
|
|
value={name}
|
||
|
|
onChange={(e) => setName(e.target.value)}
|
||
|
|
placeholder={provider?.name || "My Integration"}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{provider?.configFields.map((field) => (
|
||
|
|
<div key={field.key} className="space-y-2">
|
||
|
|
<Label>
|
||
|
|
{field.label}
|
||
|
|
{field.required && (
|
||
|
|
<span className="text-destructive"> *</span>
|
||
|
|
)}
|
||
|
|
</Label>
|
||
|
|
{field.type === "select" && field.options ? (
|
||
|
|
<select
|
||
|
|
value={config[field.key] || ""}
|
||
|
|
onChange={(e) =>
|
||
|
|
setConfig((c) => ({
|
||
|
|
...c,
|
||
|
|
[field.key]: e.target.value,
|
||
|
|
}))
|
||
|
|
}
|
||
|
|
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||
|
|
>
|
||
|
|
<option value="">Select...</option>
|
||
|
|
{field.options.map((opt) => (
|
||
|
|
<option key={opt.value} value={opt.value}>
|
||
|
|
{opt.label}
|
||
|
|
</option>
|
||
|
|
))}
|
||
|
|
</select>
|
||
|
|
) : (
|
||
|
|
<Input
|
||
|
|
type={field.type === "password" ? "password" : "text"}
|
||
|
|
value={config[field.key] || ""}
|
||
|
|
onChange={(e) =>
|
||
|
|
setConfig((c) => ({
|
||
|
|
...c,
|
||
|
|
[field.key]: e.target.value,
|
||
|
|
}))
|
||
|
|
}
|
||
|
|
placeholder={field.placeholder}
|
||
|
|
required={field.required}
|
||
|
|
/>
|
||
|
|
)}
|
||
|
|
{field.helpText && (
|
||
|
|
<p className="text-xs text-muted-foreground">
|
||
|
|
{field.helpText}
|
||
|
|
</p>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
))}
|
||
|
|
|
||
|
|
{provider?.supportsOAuth && (
|
||
|
|
<p className="text-sm text-muted-foreground">
|
||
|
|
After creating, you'll be redirected to authorize with{" "}
|
||
|
|
{provider.name}.
|
||
|
|
</p>
|
||
|
|
)}
|
||
|
|
|
||
|
|
<div className="flex gap-3">
|
||
|
|
<Button
|
||
|
|
type="button"
|
||
|
|
variant="outline"
|
||
|
|
onClick={() => setSelectedProvider("")}
|
||
|
|
>
|
||
|
|
Change Provider
|
||
|
|
</Button>
|
||
|
|
<Button type="submit" disabled={creating} className="flex-1">
|
||
|
|
{creating ? (
|
||
|
|
<Loader2 className="mr-2 size-4 animate-spin" />
|
||
|
|
) : (
|
||
|
|
<Plus className="mr-2 size-4" />
|
||
|
|
)}
|
||
|
|
{provider?.supportsOAuth
|
||
|
|
? "Create & Connect"
|
||
|
|
: "Create Integration"}
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</form>
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|