mirror of
https://github.com/eliasstepanik/core.git
synced 2026-01-12 15:58:27 +00:00
Fix: UI for integrations and logs
This commit is contained in:
parent
32379a0b0e
commit
78d401f618
109
apps/webapp/app/components/integrations/api-key-auth-section.tsx
Normal file
109
apps/webapp/app/components/integrations/api-key-auth-section.tsx
Normal file
@ -0,0 +1,109 @@
|
||||
import React, { useState, useCallback } from "react";
|
||||
import { useFetcher } from "@remix-run/react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Input } from "~/components/ui/input";
|
||||
|
||||
interface ApiKeyAuthSectionProps {
|
||||
integration: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
specData: any;
|
||||
activeAccount: any;
|
||||
}
|
||||
|
||||
export function ApiKeyAuthSection({
|
||||
integration,
|
||||
specData,
|
||||
activeAccount,
|
||||
}: ApiKeyAuthSectionProps) {
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [showApiKeyForm, setShowApiKeyForm] = useState(false);
|
||||
const apiKeyFetcher = useFetcher();
|
||||
|
||||
const handleApiKeyConnect = useCallback(() => {
|
||||
if (!apiKey.trim()) return;
|
||||
|
||||
setIsLoading(true);
|
||||
apiKeyFetcher.submit(
|
||||
{
|
||||
integrationDefinitionId: integration.id,
|
||||
apiKey,
|
||||
},
|
||||
{
|
||||
method: "post",
|
||||
action: "/api/v1/integration_account",
|
||||
encType: "application/json",
|
||||
},
|
||||
);
|
||||
}, [integration.id, apiKey, apiKeyFetcher]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (apiKeyFetcher.state === "idle" && isLoading) {
|
||||
if (apiKeyFetcher.data !== undefined) {
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
}, [apiKeyFetcher.state, apiKeyFetcher.data, isLoading]);
|
||||
|
||||
if (activeAccount || !specData?.auth?.api_key) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-background-3 space-y-4 rounded-lg p-4">
|
||||
<h4 className="font-medium">API Key Authentication</h4>
|
||||
{!showApiKeyForm ? (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setShowApiKeyForm(true)}
|
||||
className="w-full"
|
||||
>
|
||||
Connect with API Key
|
||||
</Button>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="apiKey" className="text-sm font-medium">
|
||||
{specData?.auth?.api_key?.label || "API Key"}
|
||||
</label>
|
||||
<Input
|
||||
id="apiKey"
|
||||
placeholder="Enter your API key"
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
/>
|
||||
{specData?.auth?.api_key?.description && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{specData.auth.api_key.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setShowApiKeyForm(false);
|
||||
setApiKey("");
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
disabled={isLoading || !apiKey.trim()}
|
||||
onClick={handleApiKeyConnect}
|
||||
>
|
||||
{isLoading || apiKeyFetcher.state === "submitting"
|
||||
? "Connecting..."
|
||||
: "Connect"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,81 @@
|
||||
import React, { useState, useCallback } from "react";
|
||||
import { useFetcher } from "@remix-run/react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Textarea } from "~/components/ui/textarea";
|
||||
|
||||
interface IngestionRuleSectionProps {
|
||||
ingestionRule: {
|
||||
id: string;
|
||||
text: string;
|
||||
} | null;
|
||||
activeAccount: any;
|
||||
}
|
||||
|
||||
export function IngestionRuleSection({
|
||||
ingestionRule,
|
||||
activeAccount,
|
||||
}: IngestionRuleSectionProps) {
|
||||
const [ingestionRuleText, setIngestionRuleText] = useState(
|
||||
ingestionRule?.text || "",
|
||||
);
|
||||
const ingestionRuleFetcher = useFetcher();
|
||||
|
||||
const handleIngestionRuleUpdate = useCallback(() => {
|
||||
ingestionRuleFetcher.submit(
|
||||
{
|
||||
ingestionRule: ingestionRuleText,
|
||||
},
|
||||
{
|
||||
method: "post",
|
||||
},
|
||||
);
|
||||
}, [ingestionRuleText, ingestionRuleFetcher]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (ingestionRuleFetcher.state === "idle") {
|
||||
// Optionally show success message or refresh
|
||||
}
|
||||
}, [ingestionRuleFetcher.state, ingestionRuleFetcher.data]);
|
||||
|
||||
if (!activeAccount) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-6 space-y-4">
|
||||
<h3 className="text-lg font-medium">Ingestion Rule</h3>
|
||||
<div className="bg-background-3 space-y-4 rounded-lg p-4">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="ingestionRule" className="text-sm font-medium">
|
||||
Rule Description
|
||||
</label>
|
||||
<Textarea
|
||||
id="ingestionRule"
|
||||
placeholder={`Example for Gmail: "Only ingest emails from the last 24 hours that contain the word 'urgent' or 'important' in the subject line or body. Skip promotional emails and newsletters. Focus on emails from known contacts or business domains."`}
|
||||
value={ingestionRuleText}
|
||||
onChange={(e) => setIngestionRuleText(e.target.value)}
|
||||
className="min-h-[100px]"
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Describe what data should be ingested from this integration
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
disabled={
|
||||
!ingestionRuleText.trim() ||
|
||||
ingestionRuleFetcher.state === "submitting"
|
||||
}
|
||||
onClick={handleIngestionRuleUpdate}
|
||||
>
|
||||
{ingestionRuleFetcher.state === "submitting"
|
||||
? "Updating..."
|
||||
: "Update Rule"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -32,7 +32,7 @@ export function IntegrationGrid({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-6 grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
||||
{integrations.map((integration) => {
|
||||
const isConnected = hasActiveAccount(integration.id);
|
||||
|
||||
|
||||
@ -0,0 +1,68 @@
|
||||
import React, { useState, useCallback } from "react";
|
||||
import { useFetcher } from "@remix-run/react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
|
||||
interface OAuthAuthSectionProps {
|
||||
integration: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
specData: any;
|
||||
activeAccount: any;
|
||||
}
|
||||
|
||||
export function OAuthAuthSection({
|
||||
integration,
|
||||
specData,
|
||||
activeAccount,
|
||||
}: OAuthAuthSectionProps) {
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
const oauthFetcher = useFetcher<{ redirectURL: string }>();
|
||||
|
||||
const handleOAuthConnect = useCallback(() => {
|
||||
setIsConnecting(true);
|
||||
oauthFetcher.submit(
|
||||
{
|
||||
integrationDefinitionId: integration.id,
|
||||
redirectURL: window.location.href,
|
||||
},
|
||||
{
|
||||
method: "post",
|
||||
action: "/api/v1/oauth",
|
||||
encType: "application/json",
|
||||
},
|
||||
);
|
||||
}, [integration.id, oauthFetcher]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (oauthFetcher.state === "idle" && isConnecting) {
|
||||
if (oauthFetcher.data?.redirectURL) {
|
||||
window.location.href = oauthFetcher.data.redirectURL;
|
||||
} else {
|
||||
setIsConnecting(false);
|
||||
}
|
||||
}
|
||||
}, [oauthFetcher.state, oauthFetcher.data, isConnecting]);
|
||||
|
||||
if (activeAccount || !specData?.auth?.OAuth2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-background-3 rounded-lg p-4">
|
||||
<h4 className="mb-3 font-medium">OAuth 2.0 Authentication</h4>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
disabled={isConnecting || oauthFetcher.state === "submitting"}
|
||||
onClick={handleOAuthConnect}
|
||||
className="w-full"
|
||||
>
|
||||
{isConnecting || oauthFetcher.state === "submitting"
|
||||
? "Connecting..."
|
||||
: `Connect to ${integration.name}`}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
33
apps/webapp/app/components/integrations/section.tsx
Normal file
33
apps/webapp/app/components/integrations/section.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
interface SectionProps {
|
||||
icon?: React.ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
metadata?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Section({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
metadata,
|
||||
children,
|
||||
}: SectionProps) {
|
||||
return (
|
||||
<div className="flex gap-6">
|
||||
<div className="flex w-[400px] shrink-0 flex-col">
|
||||
{icon && <>{icon}</>}
|
||||
<h3 className="text-lg"> {title} </h3>
|
||||
<p className="text-muted-foreground">{description}</p>
|
||||
{metadata ? metadata : null}
|
||||
</div>
|
||||
<div className="grow">
|
||||
<div className="flex h-full w-full justify-center">
|
||||
<div className="flex h-full max-w-[76ch] grow flex-col gap-2">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -12,10 +12,81 @@ import {
|
||||
import { type LogItem } from "~/hooks/use-logs";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Card, CardContent } from "~/components/ui/card";
|
||||
import { AlertCircle, CheckCircle, Clock, XCircle } from "lucide-react";
|
||||
import { AlertCircle, ChevronDown, XCircle } from "lucide-react";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { ScrollManagedList } from "../virtualized-list";
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogClose,
|
||||
} from "../ui/dialog";
|
||||
import { Button } from "../ui";
|
||||
|
||||
// --- LogTextCollapse component ---
|
||||
function LogTextCollapse({ text }: { text?: string }) {
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
|
||||
// Show collapse if text is long (by word count)
|
||||
const COLLAPSE_WORD_LIMIT = 30;
|
||||
|
||||
if (!text) {
|
||||
return (
|
||||
<div className="text-muted-foreground mb-2 text-xs italic">
|
||||
No log details.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Split by words for word count
|
||||
const words = text.split(/\s+/);
|
||||
const isLong = words.length > COLLAPSE_WORD_LIMIT;
|
||||
|
||||
let displayText: string;
|
||||
if (isLong) {
|
||||
displayText = words.slice(0, COLLAPSE_WORD_LIMIT).join(" ") + " ...";
|
||||
} else {
|
||||
displayText = text;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-2">
|
||||
<p
|
||||
className={cn(
|
||||
"whitespace-p-wrap pt-2 text-sm break-words",
|
||||
isLong ? "max-h-16 overflow-hidden" : "",
|
||||
)}
|
||||
style={{ lineHeight: "1.5" }}
|
||||
>
|
||||
{displayText}
|
||||
</p>
|
||||
{isLong && (
|
||||
<>
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="max-w-2xl p-4">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex w-full items-center justify-between">
|
||||
<span>Log Details</span>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="max-h-[70vh] overflow-auto p-0">
|
||||
<p
|
||||
className="px-3 py-2 text-sm break-words whitespace-pre-wrap"
|
||||
style={{ lineHeight: "1.5" }}
|
||||
>
|
||||
{text}
|
||||
</p>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface VirtualLogsListProps {
|
||||
logs: LogItem[];
|
||||
hasMore: boolean;
|
||||
@ -88,7 +159,8 @@ function LogItemRenderer(
|
||||
getStatusColor(log.status),
|
||||
)}
|
||||
>
|
||||
{log.status.toLowerCase()}
|
||||
{log.status.charAt(0).toUpperCase() +
|
||||
log.status.slice(1).toLowerCase()}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
@ -97,9 +169,7 @@ function LogItemRenderer(
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-2">
|
||||
<p className="text-foreground text-sm">{log.ingestText}</p>
|
||||
</div>
|
||||
<LogTextCollapse text={log.ingestText} />
|
||||
|
||||
<div className="text-muted-foreground flex items-center justify-between text-xs">
|
||||
<div className="flex items-center gap-4">
|
||||
|
||||
@ -7,6 +7,7 @@ const PAGE_TITLES: Record<string, string> = {
|
||||
"/home/dashboard": "Memory graph",
|
||||
"/home/conversation": "Conversation",
|
||||
"/home/integrations": "Integrations",
|
||||
"/home/integration": "Integrations",
|
||||
"/home/logs": "Logs",
|
||||
};
|
||||
|
||||
@ -54,7 +55,7 @@ function NavigationBackForward() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div className="mr-1 flex items-center">
|
||||
<div className="mr-1 flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
|
||||
@ -6,6 +6,8 @@ import { runIntegrationTrigger } from "~/services/integration.server";
|
||||
import { getIntegrationDefinitionWithId } from "~/services/integrationDefinition.server";
|
||||
import { logger } from "~/services/logger.service";
|
||||
import { getWorkspaceByUser } from "~/models/workspace.server";
|
||||
import { tasks } from "@trigger.dev/sdk";
|
||||
import { scheduler } from "~/trigger/integrations/scheduler";
|
||||
|
||||
// Schema for creating an integration account with API key
|
||||
const IntegrationAccountBodySchema = z.object({
|
||||
@ -61,6 +63,10 @@ const { action, loader } = createHybridActionApiRoute(
|
||||
);
|
||||
}
|
||||
|
||||
await tasks.trigger<typeof scheduler>("scheduler", {
|
||||
integrationAccountId: setupResult?.id,
|
||||
});
|
||||
|
||||
return json({ success: true, setupResult });
|
||||
} catch (error) {
|
||||
logger.error("Error creating integration account", {
|
||||
|
||||
@ -1,22 +1,25 @@
|
||||
import React, { useMemo, useState, useCallback } from "react";
|
||||
import { json, type LoaderFunctionArgs } from "@remix-run/node";
|
||||
import { useLoaderData, useFetcher } from "@remix-run/react";
|
||||
import React, { useMemo } from "react";
|
||||
import {
|
||||
json,
|
||||
type LoaderFunctionArgs,
|
||||
type ActionFunctionArgs,
|
||||
} from "@remix-run/node";
|
||||
import { useLoaderData } from "@remix-run/react";
|
||||
import { requireUserId, requireWorkpace } from "~/services/session.server";
|
||||
import { getIntegrationDefinitions } from "~/services/integrationDefinition.server";
|
||||
import { getIntegrationAccounts } from "~/services/integrationAccount.server";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card";
|
||||
import { getIcon, type IconType } from "~/components/icon-utils";
|
||||
import { Checkbox } from "~/components/ui/checkbox";
|
||||
import { MCPAuthSection } from "~/components/integrations/mcp-auth-section";
|
||||
import { ConnectedAccountSection } from "~/components/integrations/connected-account-section";
|
||||
import { IngestionRuleSection } from "~/components/integrations/ingestion-rule-section";
|
||||
import { ApiKeyAuthSection } from "~/components/integrations/api-key-auth-section";
|
||||
import { OAuthAuthSection } from "~/components/integrations/oauth-auth-section";
|
||||
import {
|
||||
getIngestionRuleBySource,
|
||||
upsertIngestionRule,
|
||||
} from "~/services/ingestionRule.server";
|
||||
import { Section } from "~/components/integrations/section";
|
||||
|
||||
export async function loader({ request, params }: LoaderFunctionArgs) {
|
||||
const userId = await requireUserId(request);
|
||||
@ -36,13 +39,72 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
|
||||
throw new Response("Integration not found", { status: 404 });
|
||||
}
|
||||
|
||||
const activeAccount = integrationAccounts.find(
|
||||
(acc) => acc.integrationDefinitionId === integration.id && acc.isActive,
|
||||
);
|
||||
|
||||
let ingestionRule = null;
|
||||
if (activeAccount) {
|
||||
ingestionRule = await getIngestionRuleBySource(
|
||||
activeAccount.id,
|
||||
workspace.id,
|
||||
);
|
||||
}
|
||||
|
||||
return json({
|
||||
integration,
|
||||
integrationAccounts,
|
||||
userId,
|
||||
ingestionRule,
|
||||
});
|
||||
}
|
||||
|
||||
export async function action({ request, params }: ActionFunctionArgs) {
|
||||
const userId = await requireUserId(request);
|
||||
const workspace = await requireWorkpace(request);
|
||||
const { slug } = params;
|
||||
|
||||
const formData = await request.formData();
|
||||
const ingestionRuleText = formData.get("ingestionRule") as string;
|
||||
|
||||
if (!ingestionRuleText) {
|
||||
return json({ error: "Ingestion rule is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const [integrationDefinitions, integrationAccounts] = await Promise.all([
|
||||
getIntegrationDefinitions(workspace.id),
|
||||
getIntegrationAccounts(userId),
|
||||
]);
|
||||
|
||||
const integration = integrationDefinitions.find(
|
||||
(def) => def.slug === slug || def.id === slug,
|
||||
);
|
||||
|
||||
if (!integration) {
|
||||
throw new Response("Integration not found", { status: 404 });
|
||||
}
|
||||
|
||||
const activeAccount = integrationAccounts.find(
|
||||
(acc) => acc.integrationDefinitionId === integration.id && acc.isActive,
|
||||
);
|
||||
|
||||
if (!activeAccount) {
|
||||
return json(
|
||||
{ error: "No active integration account found" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
await upsertIngestionRule({
|
||||
text: ingestionRuleText,
|
||||
source: activeAccount.id,
|
||||
workspaceId: workspace.id,
|
||||
userId,
|
||||
});
|
||||
|
||||
return json({ success: true });
|
||||
}
|
||||
|
||||
function parseSpec(spec: any) {
|
||||
if (!spec) return {};
|
||||
if (typeof spec === "string") {
|
||||
@ -56,14 +118,8 @@ function parseSpec(spec: any) {
|
||||
}
|
||||
|
||||
export default function IntegrationDetail() {
|
||||
const { integration, integrationAccounts } = useLoaderData<typeof loader>();
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
const [showApiKeyForm, setShowApiKeyForm] = useState(false);
|
||||
|
||||
const apiKeyFetcher = useFetcher();
|
||||
const oauthFetcher = useFetcher<{ redirectURL: string }>();
|
||||
const { integration, integrationAccounts, ingestionRule } =
|
||||
useLoaderData<typeof loader>();
|
||||
|
||||
const activeAccount = useMemo(
|
||||
() =>
|
||||
@ -82,208 +138,86 @@ export default function IntegrationDetail() {
|
||||
const hasMCPAuth = !!specData?.mcpAuth;
|
||||
const Component = getIcon(integration.icon as IconType);
|
||||
|
||||
const handleApiKeyConnect = useCallback(() => {
|
||||
if (!apiKey.trim()) return;
|
||||
|
||||
setIsLoading(true);
|
||||
apiKeyFetcher.submit(
|
||||
{
|
||||
integrationDefinitionId: integration.id,
|
||||
apiKey,
|
||||
},
|
||||
{
|
||||
method: "post",
|
||||
action: "/api/v1/integration_account",
|
||||
encType: "application/json",
|
||||
},
|
||||
);
|
||||
}, [integration.id, apiKey, apiKeyFetcher]);
|
||||
|
||||
const handleOAuthConnect = useCallback(() => {
|
||||
setIsConnecting(true);
|
||||
oauthFetcher.submit(
|
||||
{
|
||||
integrationDefinitionId: integration.id,
|
||||
redirectURL: window.location.href,
|
||||
},
|
||||
{
|
||||
method: "post",
|
||||
action: "/api/v1/oauth",
|
||||
encType: "application/json",
|
||||
},
|
||||
);
|
||||
}, [integration.id, oauthFetcher]);
|
||||
|
||||
// Watch for fetcher completion
|
||||
React.useEffect(() => {
|
||||
if (apiKeyFetcher.state === "idle" && isLoading) {
|
||||
if (apiKeyFetcher.data !== undefined) {
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
}, [apiKeyFetcher.state, apiKeyFetcher.data, isLoading]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (oauthFetcher.state === "idle" && isConnecting) {
|
||||
if (oauthFetcher.data?.redirectURL) {
|
||||
window.location.href = oauthFetcher.data.redirectURL;
|
||||
} else {
|
||||
setIsConnecting(false);
|
||||
}
|
||||
}
|
||||
}, [oauthFetcher.state, oauthFetcher.data, isConnecting]);
|
||||
|
||||
return (
|
||||
<div className="home flex h-full flex-col overflow-y-auto p-4 px-5">
|
||||
{/* Integration Details */}
|
||||
<div className="mx-auto w-2xl space-y-6">
|
||||
<Card>
|
||||
<CardHeader className="bg-background-2">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="bg-grayAlpha-100 flex h-12 w-12 items-center justify-center rounded">
|
||||
<Component size={24} />
|
||||
</div>
|
||||
<div className="-mt-1 flex-1">
|
||||
<CardTitle className="text-2xl">{integration.name}</CardTitle>
|
||||
<CardDescription className="text-base">
|
||||
{integration.description || `Connect to ${integration.name}`}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="p-4 px-5">
|
||||
<Section
|
||||
title={integration.name}
|
||||
description={integration.description}
|
||||
icon={
|
||||
<div className="bg-grayAlpha-100 flex h-12 w-12 items-center justify-center rounded">
|
||||
<Component size={24} />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div>
|
||||
{/* Authentication Methods */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium">Authentication Methods</h3>
|
||||
<div className="space-y-2">
|
||||
{hasApiKey && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-flex items-center gap-2 text-sm">
|
||||
<Checkbox checked /> API Key authentication
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{hasOAuth2 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-flex items-center gap-2 text-sm">
|
||||
<Checkbox checked />
|
||||
OAuth 2.0 authentication
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{!hasApiKey && !hasOAuth2 && !hasMCPAuth && (
|
||||
<div className="text-muted-foreground text-sm">
|
||||
No authentication method specified
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
</div>
|
||||
|
||||
<CardContent className="bg-background-2 p-4">
|
||||
{/* Authentication Methods */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium">Authentication Methods</h3>
|
||||
<div className="space-y-2">
|
||||
{hasApiKey && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-flex items-center gap-2 text-sm">
|
||||
<Checkbox checked /> API Key authentication
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{hasOAuth2 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-flex items-center gap-2 text-sm">
|
||||
<Checkbox checked />
|
||||
OAuth 2.0 authentication
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{!hasApiKey && !hasOAuth2 && !hasMCPAuth && (
|
||||
<div className="text-muted-foreground text-sm">
|
||||
No authentication method specified
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Connect Section */}
|
||||
{!activeAccount && (hasApiKey || hasOAuth2) && (
|
||||
<div className="mt-6 space-y-4">
|
||||
<h3 className="text-lg font-medium">
|
||||
Connect to {integration.name}
|
||||
</h3>
|
||||
|
||||
{/* API Key Authentication */}
|
||||
<ApiKeyAuthSection
|
||||
integration={integration}
|
||||
specData={specData}
|
||||
activeAccount={activeAccount}
|
||||
/>
|
||||
|
||||
{/* OAuth Authentication */}
|
||||
<OAuthAuthSection
|
||||
integration={integration}
|
||||
specData={specData}
|
||||
activeAccount={activeAccount}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Connect Section */}
|
||||
{!activeAccount && (hasApiKey || hasOAuth2) && (
|
||||
<div className="mt-6 space-y-4">
|
||||
<h3 className="text-lg font-medium">
|
||||
Connect to {integration.name}
|
||||
</h3>
|
||||
{/* Connected Account Info */}
|
||||
<ConnectedAccountSection activeAccount={activeAccount} />
|
||||
|
||||
{/* API Key Authentication */}
|
||||
{hasApiKey && (
|
||||
<div className="bg-background-3 space-y-4 rounded-lg p-4">
|
||||
<h4 className="font-medium">API Key Authentication</h4>
|
||||
{!showApiKeyForm ? (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setShowApiKeyForm(true)}
|
||||
className="w-full"
|
||||
>
|
||||
Connect with API Key
|
||||
</Button>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="apiKey"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
{specData?.auth?.api_key?.label || "API Key"}
|
||||
</label>
|
||||
<Input
|
||||
id="apiKey"
|
||||
placeholder="Enter your API key"
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
/>
|
||||
{specData?.auth?.api_key?.description && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{specData.auth.api_key.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setShowApiKeyForm(false);
|
||||
setApiKey("");
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
disabled={isLoading || !apiKey.trim()}
|
||||
onClick={handleApiKeyConnect}
|
||||
>
|
||||
{isLoading || apiKeyFetcher.state === "submitting"
|
||||
? "Connecting..."
|
||||
: "Connect"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* MCP Authentication Section */}
|
||||
<MCPAuthSection
|
||||
integration={integration}
|
||||
activeAccount={activeAccount as any}
|
||||
hasMCPAuth={hasMCPAuth}
|
||||
/>
|
||||
|
||||
{/* OAuth Authentication */}
|
||||
{hasOAuth2 && (
|
||||
<div className="bg-background-3 rounded-lg p-4">
|
||||
<h4 className="mb-3 font-medium">
|
||||
OAuth 2.0 Authentication
|
||||
</h4>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
disabled={
|
||||
isConnecting || oauthFetcher.state === "submitting"
|
||||
}
|
||||
onClick={handleOAuthConnect}
|
||||
className="w-full"
|
||||
>
|
||||
{isConnecting || oauthFetcher.state === "submitting"
|
||||
? "Connecting..."
|
||||
: `Connect to ${integration.name}`}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Connected Account Info */}
|
||||
<ConnectedAccountSection activeAccount={activeAccount} />
|
||||
|
||||
{/* MCP Authentication Section */}
|
||||
<MCPAuthSection
|
||||
integration={integration}
|
||||
activeAccount={activeAccount as any}
|
||||
hasMCPAuth={hasMCPAuth}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
{/* Ingestion Rule Section */}
|
||||
<IngestionRuleSection
|
||||
ingestionRule={ingestionRule}
|
||||
activeAccount={activeAccount}
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -39,10 +39,6 @@ export default function Integrations() {
|
||||
|
||||
return (
|
||||
<div className="home flex h-full flex-col overflow-y-auto p-4 px-5">
|
||||
<div className="space-y-1 text-base">
|
||||
<p className="text-muted-foreground">Connect your tools and services</p>
|
||||
</div>
|
||||
|
||||
<IntegrationGrid
|
||||
integrations={integrationDefinitions}
|
||||
activeAccountIds={activeAccountIds}
|
||||
|
||||
56
apps/webapp/app/services/ingestionRule.server.ts
Normal file
56
apps/webapp/app/services/ingestionRule.server.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import { prisma } from "~/db.server";
|
||||
|
||||
export async function getIngestionRuleBySource(
|
||||
source: string,
|
||||
workspaceId: string,
|
||||
) {
|
||||
return await prisma.ingestionRule.findFirst({
|
||||
where: {
|
||||
source,
|
||||
workspaceId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Need to fix this later
|
||||
export async function upsertIngestionRule({
|
||||
text,
|
||||
source,
|
||||
workspaceId,
|
||||
userId,
|
||||
}: {
|
||||
text: string;
|
||||
source: string;
|
||||
workspaceId: string;
|
||||
userId: string;
|
||||
}) {
|
||||
// Find existing rule first
|
||||
const existingRule = await prisma.ingestionRule.findFirst({
|
||||
where: {
|
||||
source,
|
||||
workspaceId,
|
||||
},
|
||||
});
|
||||
|
||||
if (existingRule) {
|
||||
// Update existing rule
|
||||
return await prisma.ingestionRule.update({
|
||||
where: {
|
||||
id: existingRule.id,
|
||||
},
|
||||
data: {
|
||||
text,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// Create new rule
|
||||
return await prisma.ingestionRule.create({
|
||||
data: {
|
||||
text,
|
||||
source,
|
||||
workspaceId,
|
||||
userId,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user