mirror of
https://github.com/eliasstepanik/core.git
synced 2026-01-22 04:28:30 +00:00
Feat: added integration connect and mcp oAuth
This commit is contained in:
parent
30e5462e14
commit
f2b4a5f64a
@ -142,7 +142,7 @@ export const ConversationList = ({
|
|||||||
className={cn(
|
className={cn(
|
||||||
"border-border h-auto w-full justify-start rounded p-2 py-1 text-left",
|
"border-border h-auto w-full justify-start rounded p-2 py-1 text-left",
|
||||||
currentConversationId === conversation.id &&
|
currentConversationId === conversation.id &&
|
||||||
"bg-accent text-accent-foreground font-semibold",
|
"bg-accent font-semibold",
|
||||||
)}
|
)}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
navigate(`/home/conversation/${conversation.id}`);
|
navigate(`/home/conversation/${conversation.id}`);
|
||||||
@ -155,7 +155,7 @@ export const ConversationList = ({
|
|||||||
<div className="flex w-full items-start space-x-3">
|
<div className="flex w-full items-start space-x-3">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<p className={cn("truncate font-normal")}>
|
<p className={cn("text-foreground truncate font-normal")}>
|
||||||
{conversation.title || "Untitled Conversation"}
|
{conversation.title || "Untitled Conversation"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -5,14 +5,16 @@ import {
|
|||||||
RiSlackFill,
|
RiSlackFill,
|
||||||
} from "@remixicon/react";
|
} from "@remixicon/react";
|
||||||
import { LayoutGrid } from "lucide-react";
|
import { LayoutGrid } from "lucide-react";
|
||||||
|
import { LinearIcon, SlackIcon } from "./icons";
|
||||||
|
|
||||||
export const ICON_MAPPING = {
|
export const ICON_MAPPING = {
|
||||||
slack: RiSlackFill,
|
slack: SlackIcon,
|
||||||
email: RiMailFill,
|
email: RiMailFill,
|
||||||
discord: RiDiscordFill,
|
discord: RiDiscordFill,
|
||||||
github: RiGithubFill,
|
github: RiGithubFill,
|
||||||
|
|
||||||
gmail: RiMailFill,
|
gmail: RiMailFill,
|
||||||
|
linear: LinearIcon,
|
||||||
|
|
||||||
// Default icon
|
// Default icon
|
||||||
integration: LayoutGrid,
|
integration: LayoutGrid,
|
||||||
|
|||||||
2
apps/webapp/app/components/icons/index.ts
Normal file
2
apps/webapp/app/components/icons/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export * from "./slack-icon";
|
||||||
|
export * from "./linear-icon";
|
||||||
23
apps/webapp/app/components/icons/linear-icon.tsx
Normal file
23
apps/webapp/app/components/icons/linear-icon.tsx
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import type { IconProps } from "./types";
|
||||||
|
|
||||||
|
export function LinearIcon({ size = 18, className }: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
className={className}
|
||||||
|
viewBox="0 0 256 255.999966"
|
||||||
|
version="1.1"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
preserveAspectRatio="xMidYMid"
|
||||||
|
>
|
||||||
|
<title>Linear</title>
|
||||||
|
<g>
|
||||||
|
<path
|
||||||
|
d="M8.17351507,102.61269 L153.38731,247.826434 C155.506492,249.945871 154.484163,253.546047 151.537796,254.096394 C146.624112,255.014493 141.611832,255.651912 136.517857,255.991749 C135.430224,256.064224 134.367944,255.654216 133.5971,254.883372 L1.11657404,122.4029 C0.345783548,121.632056 -0.0643290124,120.569776 0.0082341699,119.482143 C0.348078154,114.388168 0.985591977,109.375888 1.9035369,104.462204 C2.45395044,101.515837 6.05412868,100.493508 8.17351507,102.61269 Z M4.08163806,161.409157 C3.11270474,157.795152 7.38226007,155.515399 10.0279718,158.161111 L97.8388892,245.972054 C100.484601,248.617766 98.2048481,252.887372 94.5908433,251.918311 C50.5602697,240.113381 15.8867212,205.43973 4.08163806,161.409157 Z M16.8085843,64.1644626 C18.0417047,62.028635 20.9559826,61.7013463 22.6999089,63.4453494 L192.554651,233.299989 C194.298654,235.043992 193.971621,237.958347 191.835537,239.191441 C188.24202,241.266062 184.538382,243.171405 180.73538,244.896457 C179.304068,245.545401 177.625881,245.218112 176.514687,244.106918 L11.8930308,79.4853127 C10.7818114,78.3741189 10.4544971,76.695932 11.1036452,75.2646202 C12.828595,71.4616177 14.7338864,67.7579799 16.8085843,64.1644626 Z M127.860072,0 C198.629979,0 256,57.3702771 256,128.139928 C256,165.709238 239.831733,199.502437 214.07401,222.940712 C212.587126,224.293659 210.305837,224.204026 208.884257,222.782702 L33.2172979,47.1157434 C31.7959738,45.6941631 31.7063407,43.4128738 33.0592877,41.9259895 C56.4975633,16.1681897 90.2907616,0 127.860072,0 Z"
|
||||||
|
fill="#222326"
|
||||||
|
></path>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
apps/webapp/app/components/icons/slack-icon.tsx
Normal file
30
apps/webapp/app/components/icons/slack-icon.tsx
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import type { IconProps } from "./types";
|
||||||
|
|
||||||
|
export function SlackIcon({ size = 18, className }: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 127 127"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M27.2 80c0 7.3-5.9 13.2-13.2 13.2C6.7 93.2.8 87.3.8 80c0-7.3 5.9-13.2 13.2-13.2h13.2V80zm6.6 0c0-7.3 5.9-13.2 13.2-13.2 7.3 0 13.2 5.9 13.2 13.2v33c0 7.3-5.9 13.2-13.2 13.2-7.3 0-13.2-5.9-13.2-13.2V80z"
|
||||||
|
fill="#E01E5A"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M47 27c-7.3 0-13.2-5.9-13.2-13.2C33.8 6.5 39.7.6 47 .6c7.3 0 13.2 5.9 13.2 13.2V27H47zm0 6.7c7.3 0 13.2 5.9 13.2 13.2 0 7.3-5.9 13.2-13.2 13.2H13.9C6.6 60.1.7 54.2.7 46.9c0-7.3 5.9-13.2 13.2-13.2H47z"
|
||||||
|
fill="#36C5F0"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M99.9 46.9c0-7.3 5.9-13.2 13.2-13.2 7.3 0 13.2 5.9 13.2 13.2 0 7.3-5.9 13.2-13.2 13.2H99.9V46.9zm-6.6 0c0 7.3-5.9 13.2-13.2 13.2-7.3 0-13.2-5.9-13.2-13.2V13.8C66.9 6.5 72.8.6 80.1.6c7.3 0 13.2 5.9 13.2 13.2v33.1z"
|
||||||
|
fill="#2EB67D"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M80.1 99.8c7.3 0 13.2 5.9 13.2 13.2 0 7.3-5.9 13.2-13.2 13.2-7.3 0-13.2-5.9-13.2-13.2V99.8h13.2zm0-6.6c-7.3 0-13.2-5.9-13.2-13.2 0-7.3 5.9-13.2 13.2-13.2h33.1c7.3 0 13.2 5.9 13.2 13.2 0 7.3-5.9 13.2-13.2 13.2H80.1z"
|
||||||
|
fill="#ECB22E"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
6
apps/webapp/app/components/icons/types.tsx
Normal file
6
apps/webapp/app/components/icons/types.tsx
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export interface IconProps {
|
||||||
|
size?: number;
|
||||||
|
className?: string;
|
||||||
|
color?: string;
|
||||||
|
onClick?: (event: MouseEvent) => void;
|
||||||
|
}
|
||||||
@ -1,248 +0,0 @@
|
|||||||
import React, { useState, useCallback } from "react";
|
|
||||||
import { useFetcher } from "@remix-run/react";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from "~/components/ui/dialog";
|
|
||||||
import { Button } from "~/components/ui/button";
|
|
||||||
import { Input } from "~/components/ui/input";
|
|
||||||
import { FormButtons } from "~/components/ui/FormButtons";
|
|
||||||
|
|
||||||
interface IntegrationAuthDialogProps {
|
|
||||||
integration: {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
description?: string;
|
|
||||||
spec: any;
|
|
||||||
};
|
|
||||||
children: React.ReactNode;
|
|
||||||
onOpenChange?: (open: boolean) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseSpec(spec: any) {
|
|
||||||
if (!spec) return {};
|
|
||||||
if (typeof spec === "string") {
|
|
||||||
try {
|
|
||||||
return JSON.parse(spec);
|
|
||||||
} catch {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return spec;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function IntegrationAuthDialog({
|
|
||||||
integration,
|
|
||||||
children,
|
|
||||||
onOpenChange,
|
|
||||||
}: IntegrationAuthDialogProps) {
|
|
||||||
const [apiKey, setApiKey] = useState("");
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [isConnecting, setIsConnecting] = useState(false);
|
|
||||||
const [isMCPConnecting, setIsMCPConnecting] = useState(false);
|
|
||||||
|
|
||||||
const apiKeyFetcher = useFetcher();
|
|
||||||
const oauthFetcher = useFetcher<{ redirectURL: string }>();
|
|
||||||
const mcpFetcher = useFetcher<{ redirectURL: string }>();
|
|
||||||
|
|
||||||
const specData = parseSpec(integration.spec);
|
|
||||||
const hasApiKey = !!specData?.auth?.api_key;
|
|
||||||
const hasOAuth2 = !!specData?.auth?.OAuth2;
|
|
||||||
const hasMCPAuth = !!specData?.mcpAuth;
|
|
||||||
|
|
||||||
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]);
|
|
||||||
|
|
||||||
const handleMCPConnect = useCallback(() => {
|
|
||||||
setIsMCPConnecting(true);
|
|
||||||
mcpFetcher.submit(
|
|
||||||
{
|
|
||||||
integrationDefinitionId: integration.id,
|
|
||||||
redirectURL: window.location.href,
|
|
||||||
mcp: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
method: "post",
|
|
||||||
action: "/api/v1/oauth",
|
|
||||||
encType: "application/json",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}, [integration.id, mcpFetcher]);
|
|
||||||
|
|
||||||
// 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]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (mcpFetcher.state === "idle" && isMCPConnecting) {
|
|
||||||
if (mcpFetcher.data?.redirectURL) {
|
|
||||||
window.location.href = mcpFetcher.data.redirectURL;
|
|
||||||
} else {
|
|
||||||
setIsMCPConnecting(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [mcpFetcher.state, mcpFetcher.data, isMCPConnecting]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (open) {
|
|
||||||
setApiKey("");
|
|
||||||
}
|
|
||||||
onOpenChange?.(open);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
|
||||||
|
|
||||||
<DialogContent className="p-4 sm:max-w-md">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Connect to {integration.name}</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
{integration.description ||
|
|
||||||
`Connect your ${integration.name} account to enable integration.`}
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
{/* API Key Authentication */}
|
|
||||||
{hasApiKey && (
|
|
||||||
<div className="space-y-4 py-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label htmlFor="apiKey" className="text-sm font-medium">
|
|
||||||
{specData?.auth?.api_key?.label || "API Key"}
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
id="apiKey"
|
|
||||||
type="password"
|
|
||||||
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>
|
|
||||||
<FormButtons
|
|
||||||
confirmButton={
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="default"
|
|
||||||
disabled={isLoading || !apiKey.trim()}
|
|
||||||
onClick={handleApiKeyConnect}
|
|
||||||
>
|
|
||||||
{isLoading || apiKeyFetcher.state === "submitting"
|
|
||||||
? "Connecting..."
|
|
||||||
: "Connect"}
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* OAuth Authentication */}
|
|
||||||
{hasOAuth2 && (
|
|
||||||
<div className="flex justify-center py-4">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="default"
|
|
||||||
size="lg"
|
|
||||||
disabled={isConnecting || oauthFetcher.state === "submitting"}
|
|
||||||
onClick={handleOAuthConnect}
|
|
||||||
>
|
|
||||||
{isConnecting || oauthFetcher.state === "submitting"
|
|
||||||
? "Connecting..."
|
|
||||||
: `Connect to ${integration.name}`}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* MCP Authentication */}
|
|
||||||
{hasMCPAuth && (
|
|
||||||
<div className="space-y-4 py-4">
|
|
||||||
<div className="border-t pt-4">
|
|
||||||
<h4 className="text-sm font-medium mb-2">MCP Authentication</h4>
|
|
||||||
<p className="text-muted-foreground text-xs mb-4">
|
|
||||||
This integration requires MCP (Model Context Protocol) authentication.
|
|
||||||
</p>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="lg"
|
|
||||||
className="w-full"
|
|
||||||
disabled={isMCPConnecting || mcpFetcher.state === "submitting"}
|
|
||||||
onClick={handleMCPConnect}
|
|
||||||
>
|
|
||||||
{isMCPConnecting || mcpFetcher.state === "submitting"
|
|
||||||
? "Connecting..."
|
|
||||||
: `Connect via MCP`}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* No authentication method found */}
|
|
||||||
{!hasApiKey && !hasOAuth2 && !hasMCPAuth && (
|
|
||||||
<div className="text-muted-foreground py-4 text-center">
|
|
||||||
This integration doesn't specify an authentication method.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DialogFooter className="sm:justify-start">
|
|
||||||
<div className="text-muted-foreground w-full text-xs">
|
|
||||||
By connecting, you agree to the {integration.name} terms of service.
|
|
||||||
</div>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -0,0 +1,68 @@
|
|||||||
|
import React, { useCallback } from "react";
|
||||||
|
import { useFetcher } from "@remix-run/react";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { Check } from "lucide-react";
|
||||||
|
|
||||||
|
interface ConnectedAccountSectionProps {
|
||||||
|
activeAccount?: {
|
||||||
|
id: string;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConnectedAccountSection({
|
||||||
|
activeAccount,
|
||||||
|
}: ConnectedAccountSectionProps) {
|
||||||
|
const disconnectFetcher = useFetcher();
|
||||||
|
|
||||||
|
const handleDisconnect = useCallback(() => {
|
||||||
|
if (!activeAccount?.id) return;
|
||||||
|
|
||||||
|
disconnectFetcher.submit(
|
||||||
|
{
|
||||||
|
integrationAccountId: activeAccount.id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "post",
|
||||||
|
action: "/api/v1/integration_account/disconnect",
|
||||||
|
encType: "application/json",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}, [activeAccount?.id, disconnectFetcher]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (disconnectFetcher.state === "idle" && disconnectFetcher.data) {
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
}, [disconnectFetcher.state, disconnectFetcher.data]);
|
||||||
|
|
||||||
|
if (!activeAccount) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-6 space-y-2">
|
||||||
|
<h3 className="text-lg font-medium">Connected Account</h3>
|
||||||
|
<div className="bg-background-3 rounded-lg p-4">
|
||||||
|
<div className="text-sm">
|
||||||
|
<p className="inline-flex items-center gap-2 font-medium">
|
||||||
|
<Check size={16} /> Account ID: {activeAccount.id}
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground mb-3">
|
||||||
|
Connected on{" "}
|
||||||
|
{new Date(activeAccount.createdAt).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
<div className="flex w-full justify-end">
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
disabled={disconnectFetcher.state === "submitting"}
|
||||||
|
onClick={handleDisconnect}
|
||||||
|
>
|
||||||
|
{disconnectFetcher.state === "submitting"
|
||||||
|
? "Disconnecting..."
|
||||||
|
: "Disconnect"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -9,6 +9,7 @@ import {
|
|||||||
CardTitle,
|
CardTitle,
|
||||||
} from "~/components/ui/card";
|
} from "~/components/ui/card";
|
||||||
import { getIcon, type IconType } from "~/components/icon-utils";
|
import { getIcon, type IconType } from "~/components/icon-utils";
|
||||||
|
import { Badge } from "../ui/badge";
|
||||||
|
|
||||||
interface IntegrationCardProps {
|
interface IntegrationCardProps {
|
||||||
integration: {
|
integration: {
|
||||||
@ -19,26 +20,20 @@ interface IntegrationCardProps {
|
|||||||
slug?: string;
|
slug?: string;
|
||||||
};
|
};
|
||||||
isConnected: boolean;
|
isConnected: boolean;
|
||||||
onClick?: () => void;
|
|
||||||
showDetail?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function IntegrationCard({
|
export function IntegrationCard({
|
||||||
integration,
|
integration,
|
||||||
isConnected,
|
isConnected,
|
||||||
onClick,
|
|
||||||
showDetail = false,
|
|
||||||
}: IntegrationCardProps) {
|
}: IntegrationCardProps) {
|
||||||
const Component = getIcon(integration.icon as IconType);
|
const Component = getIcon(integration.icon as IconType);
|
||||||
|
|
||||||
const CardWrapper = showDetail ? Link : "div";
|
|
||||||
const cardProps = showDetail
|
|
||||||
? { to: `/home/integration/${integration.slug || integration.id}` }
|
|
||||||
: { onClick, className: "cursor-pointer" };
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CardWrapper {...cardProps}>
|
<Link
|
||||||
<Card className="transition-all hover:shadow-md">
|
to={`/home/integration/${integration.slug || integration.id}`}
|
||||||
|
className="bg-background-3 h-full rounded-lg"
|
||||||
|
>
|
||||||
|
<Card className="transition-all">
|
||||||
<CardHeader className="p-4">
|
<CardHeader className="p-4">
|
||||||
<div className="bg-background-2 mb-2 flex h-6 w-6 items-center justify-center rounded">
|
<div className="bg-background-2 mb-2 flex h-6 w-6 items-center justify-center rounded">
|
||||||
<Component size={18} />
|
<Component size={18} />
|
||||||
@ -51,13 +46,13 @@ export function IntegrationCard({
|
|||||||
{isConnected && (
|
{isConnected && (
|
||||||
<CardFooter className="p-3">
|
<CardFooter className="p-3">
|
||||||
<div className="flex w-full items-center justify-end">
|
<div className="flex w-full items-center justify-end">
|
||||||
<span className="rounded-full bg-green-100 px-2 py-0.5 text-xs text-green-800">
|
<Badge className="h-6 rounded bg-green-100 p-2 text-xs text-green-800">
|
||||||
Connected
|
Connected
|
||||||
</span>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</CardFooter>
|
</CardFooter>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
</CardWrapper>
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -1,7 +1,6 @@
|
|||||||
import React, { useMemo } from "react";
|
import React, { useMemo } from "react";
|
||||||
import { Search } from "lucide-react";
|
import { Search } from "lucide-react";
|
||||||
import { IntegrationCard } from "./IntegrationCard";
|
import { IntegrationCard } from "./integration-card";
|
||||||
import { IntegrationAuthDialog } from "./IntegrationAuthDialog";
|
|
||||||
|
|
||||||
interface IntegrationGridProps {
|
interface IntegrationGridProps {
|
||||||
integrations: Array<{
|
integrations: Array<{
|
||||||
@ -19,7 +18,6 @@ interface IntegrationGridProps {
|
|||||||
export function IntegrationGrid({
|
export function IntegrationGrid({
|
||||||
integrations,
|
integrations,
|
||||||
activeAccountIds,
|
activeAccountIds,
|
||||||
showDetail = false,
|
|
||||||
}: IntegrationGridProps) {
|
}: IntegrationGridProps) {
|
||||||
const hasActiveAccount = (integrationDefinitionId: string) =>
|
const hasActiveAccount = (integrationDefinitionId: string) =>
|
||||||
activeAccountIds.has(integrationDefinitionId);
|
activeAccountIds.has(integrationDefinitionId);
|
||||||
@ -38,29 +36,13 @@ export function IntegrationGrid({
|
|||||||
{integrations.map((integration) => {
|
{integrations.map((integration) => {
|
||||||
const isConnected = hasActiveAccount(integration.id);
|
const isConnected = hasActiveAccount(integration.id);
|
||||||
|
|
||||||
if (showDetail) {
|
|
||||||
return (
|
|
||||||
<IntegrationCard
|
|
||||||
key={integration.id}
|
|
||||||
integration={integration}
|
|
||||||
isConnected={isConnected}
|
|
||||||
showDetail={true}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<IntegrationAuthDialog
|
<IntegrationCard
|
||||||
key={integration.id}
|
|
||||||
integration={integration}
|
integration={integration}
|
||||||
>
|
isConnected={isConnected}
|
||||||
<IntegrationCard
|
/>
|
||||||
integration={integration}
|
|
||||||
isConnected={isConnected}
|
|
||||||
/>
|
|
||||||
</IntegrationAuthDialog>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
133
apps/webapp/app/components/integrations/mcp-auth-section.tsx
Normal file
133
apps/webapp/app/components/integrations/mcp-auth-section.tsx
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
import React, { useCallback, useState } from "react";
|
||||||
|
import { useFetcher } from "@remix-run/react";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { Check } from "lucide-react";
|
||||||
|
|
||||||
|
interface MCPAuthSectionProps {
|
||||||
|
integration: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
activeAccount?: {
|
||||||
|
id: string;
|
||||||
|
integrationConfiguration?: {
|
||||||
|
mcp?: any;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
hasMCPAuth: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MCPAuthSection({
|
||||||
|
integration,
|
||||||
|
activeAccount,
|
||||||
|
hasMCPAuth,
|
||||||
|
}: MCPAuthSectionProps) {
|
||||||
|
const [isMCPConnecting, setIsMCPConnecting] = useState(false);
|
||||||
|
const mcpFetcher = useFetcher<{ redirectURL: string }>();
|
||||||
|
const disconnectMcpFetcher = useFetcher();
|
||||||
|
|
||||||
|
const isMCPConnected = activeAccount?.integrationConfiguration?.mcp;
|
||||||
|
|
||||||
|
const handleMCPConnect = useCallback(() => {
|
||||||
|
setIsMCPConnecting(true);
|
||||||
|
mcpFetcher.submit(
|
||||||
|
{
|
||||||
|
integrationDefinitionId: integration.id,
|
||||||
|
redirectURL: window.location.href,
|
||||||
|
integrationAccountId: activeAccount?.id as string,
|
||||||
|
mcp: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "post",
|
||||||
|
action: "/api/v1/oauth",
|
||||||
|
encType: "application/json",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}, [integration.id, mcpFetcher]);
|
||||||
|
|
||||||
|
const handleMCPDisconnect = useCallback(() => {
|
||||||
|
if (!activeAccount?.id) return;
|
||||||
|
|
||||||
|
disconnectMcpFetcher.submit(
|
||||||
|
{
|
||||||
|
integrationAccountId: activeAccount.id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "post",
|
||||||
|
action: "/api/v1/integration_account/disconnect_mcp",
|
||||||
|
encType: "application/json",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}, [activeAccount?.id, disconnectMcpFetcher]);
|
||||||
|
|
||||||
|
// Watch for fetcher completion
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (mcpFetcher.state === "idle" && isMCPConnecting) {
|
||||||
|
if (mcpFetcher.data?.redirectURL) {
|
||||||
|
window.location.href = mcpFetcher.data.redirectURL;
|
||||||
|
} else {
|
||||||
|
setIsMCPConnecting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [mcpFetcher.state, mcpFetcher.data, isMCPConnecting]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (disconnectMcpFetcher.state === "idle" && disconnectMcpFetcher.data) {
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
}, [disconnectMcpFetcher.state, disconnectMcpFetcher.data]);
|
||||||
|
|
||||||
|
if (!hasMCPAuth || !activeAccount) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-6 space-y-2">
|
||||||
|
<h3 className="text-lg font-medium">MCP Authentication</h3>
|
||||||
|
|
||||||
|
{isMCPConnected ? (
|
||||||
|
<div className="bg-background-3 rounded-lg p-4">
|
||||||
|
<div className="text-sm">
|
||||||
|
<p className="inline-flex items-center gap-2 font-medium">
|
||||||
|
<Check size={16} /> MCP Connected
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground mb-3">
|
||||||
|
MCP (Model Context Protocol) authentication is active
|
||||||
|
</p>
|
||||||
|
<div className="flex w-full justify-end">
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
disabled={disconnectMcpFetcher.state === "submitting"}
|
||||||
|
onClick={handleMCPDisconnect}
|
||||||
|
>
|
||||||
|
{disconnectMcpFetcher.state === "submitting"
|
||||||
|
? "Disconnecting..."
|
||||||
|
: "Disconnect"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : activeAccount ? (
|
||||||
|
<div className="bg-background-3 rounded-lg p-4">
|
||||||
|
<h4 className="text-md mb-1 font-medium">
|
||||||
|
MCP (Model Context Protocol) Authentication
|
||||||
|
</h4>
|
||||||
|
<p className="text-muted-foreground mb-3 text-sm">
|
||||||
|
This integration requires MCP (Model Context Protocol)
|
||||||
|
authentication. Please provide the required MCP credentials in
|
||||||
|
addition to any other authentication method.
|
||||||
|
</p>
|
||||||
|
<div className="flex w-full justify-end">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
disabled={isMCPConnecting || mcpFetcher.state === "submitting"}
|
||||||
|
onClick={handleMCPConnect}
|
||||||
|
>
|
||||||
|
{isMCPConnecting || mcpFetcher.state === "submitting"
|
||||||
|
? "Connecting..."
|
||||||
|
: `Connect for MCP`}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -48,37 +48,20 @@ function LogItemRenderer(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const getStatusIcon = (status: string) => {
|
|
||||||
switch (status) {
|
|
||||||
case "PROCESSING":
|
|
||||||
return <Clock className="h-4 w-4 text-blue-500" />;
|
|
||||||
case "PENDING":
|
|
||||||
return <Clock className="h-4 w-4 text-yellow-500" />;
|
|
||||||
case "COMPLETED":
|
|
||||||
return <CheckCircle className="h-4 w-4 text-green-500" />;
|
|
||||||
case "FAILED":
|
|
||||||
return <XCircle className="h-4 w-4 text-red-500" />;
|
|
||||||
case "CANCELLED":
|
|
||||||
return <XCircle className="h-4 w-4 text-gray-500" />;
|
|
||||||
default:
|
|
||||||
return <AlertCircle className="h-4 w-4 text-gray-500" />;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStatusColor = (status: string) => {
|
const getStatusColor = (status: string) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case "PROCESSING":
|
case "PROCESSING":
|
||||||
return "bg-blue-100 text-blue-800";
|
return "bg-blue-100 text-blue-800 hover:bg-blue-100 hover:text-blue-800";
|
||||||
case "PENDING":
|
case "PENDING":
|
||||||
return "bg-yellow-100 text-yellow-800";
|
return "bg-yellow-100 text-yellow-800 hover:bg-yellow-100 hover:text-yellow-800";
|
||||||
case "COMPLETED":
|
case "COMPLETED":
|
||||||
return "bg-green-100 text-green-800";
|
return "bg-green-100 text-green-800 hover:bg-green-100 hover:text-green-800";
|
||||||
case "FAILED":
|
case "FAILED":
|
||||||
return "bg-red-100 text-red-800";
|
return "bg-red-100 text-red-800 hover:bg-red-100 hover:text-red-800";
|
||||||
case "CANCELLED":
|
case "CANCELLED":
|
||||||
return "bg-gray-100 text-gray-800";
|
return "bg-gray-100 text-gray-800 hover:bg-gray-100 hover:text-gray-800";
|
||||||
default:
|
default:
|
||||||
return "bg-gray-100 text-gray-800";
|
return "bg-gray-100 text-gray-800 hover:bg-gray-100 hover:text-gray-800";
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -95,12 +78,16 @@ function LogItemRenderer(
|
|||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<div className="mb-2 flex items-start justify-between">
|
<div className="mb-2 flex items-start justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Badge variant="outline" className="text-xs">
|
<Badge variant="secondary" className="rounded text-xs">
|
||||||
{log.source}
|
{log.source}
|
||||||
</Badge>
|
</Badge>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
{getStatusIcon(log.status)}
|
<Badge
|
||||||
<Badge className={cn("text-xs", getStatusColor(log.status))}>
|
className={cn(
|
||||||
|
"rounded text-xs",
|
||||||
|
getStatusColor(log.status),
|
||||||
|
)}
|
||||||
|
>
|
||||||
{log.status.toLowerCase()}
|
{log.status.toLowerCase()}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
@ -111,21 +98,11 @@ function LogItemRenderer(
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mb-2">
|
<div className="mb-2">
|
||||||
<p className="text-sm text-gray-700">{log.ingestText}</p>
|
<p className="text-foreground text-sm">{log.ingestText}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-muted-foreground flex items-center justify-between text-xs">
|
<div className="text-muted-foreground flex items-center justify-between text-xs">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
{log.sourceURL && (
|
|
||||||
<a
|
|
||||||
href={log.sourceURL}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-blue-600 underline hover:text-blue-800"
|
|
||||||
>
|
|
||||||
Source URL
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
{log.processedAt && (
|
{log.processedAt && (
|
||||||
<span>
|
<span>
|
||||||
Processed: {new Date(log.processedAt).toLocaleString()}
|
Processed: {new Date(log.processedAt).toLocaleString()}
|
||||||
|
|||||||
@ -52,7 +52,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
|||||||
<SidebarHeader>
|
<SidebarHeader>
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
<div className="mt-1 flex w-full items-center justify-start gap-2">
|
<div className="mt-1 ml-1 flex w-full items-center justify-start gap-2">
|
||||||
<Logo width={20} height={20} />
|
<Logo width={20} height={20} />
|
||||||
C.O.R.E.
|
C.O.R.E.
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -30,7 +30,7 @@ export const NavMain = ({
|
|||||||
<Button
|
<Button
|
||||||
isActive={location.pathname.includes(item.url)}
|
isActive={location.pathname.includes(item.url)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-grayAlpha-100 w-fit gap-1 !rounded-md",
|
"bg-grayAlpha-100 text-foreground w-fit gap-1 !rounded-md",
|
||||||
location.pathname.includes(item.url) &&
|
location.pathname.includes(item.url) &&
|
||||||
"!bg-accent !text-accent-foreground",
|
"!bg-accent !text-accent-foreground",
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -8,10 +8,10 @@ const badgeVariants = cva(
|
|||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default:
|
default:
|
||||||
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
|
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
||||||
secondary: "border-none bg-grayAlpha-100",
|
secondary: "border-none bg-grayAlpha-100",
|
||||||
destructive:
|
destructive:
|
||||||
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
|
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||||
outline: "text-foreground bg-background",
|
outline: "text-foreground bg-background",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
|
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
|
||||||
import { CheckIcon } from '@radix-ui/react-icons';
|
import { CheckIcon } from "@radix-ui/react-icons";
|
||||||
import React from 'react';
|
import React from "react";
|
||||||
|
|
||||||
import { cn } from '../../lib/utils';
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
const Checkbox = React.forwardRef<
|
const Checkbox = React.forwardRef<
|
||||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||||
@ -11,13 +11,13 @@ const Checkbox = React.forwardRef<
|
|||||||
<CheckboxPrimitive.Root
|
<CheckboxPrimitive.Root
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
'checkbox peer h-4 w-4 shrink-0 rounded-sm border-1 border-border-dark focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:border-primary data-[state=checked]:text-primary-foreground',
|
"checkbox peer border-border-dark focus-visible:ring-ring data-[state=checked]:bg-primary data-[state=checked]:border-primary data-[state=checked]:text-primary-foreground h-4 w-4 shrink-0 rounded-[5px] border-1 focus-visible:ring-1 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<CheckboxPrimitive.Indicator
|
<CheckboxPrimitive.Indicator
|
||||||
className={cn('flex items-center text-white justify-center')}
|
className={cn("flex items-center justify-center text-white")}
|
||||||
>
|
>
|
||||||
<CheckIcon className="h-3 w-3" />
|
<CheckIcon className="h-3 w-3" />
|
||||||
</CheckboxPrimitive.Indicator>
|
</CheckboxPrimitive.Indicator>
|
||||||
|
|||||||
@ -1,8 +1,7 @@
|
|||||||
import { useLocation, useNavigate } from "@remix-run/react";
|
import { useLocation, useNavigate } from "@remix-run/react";
|
||||||
import { Button } from "./button";
|
import { Button } from "./button";
|
||||||
import { Plus } from "lucide-react";
|
import { ArrowLeft, ArrowRight, Plus } from "lucide-react";
|
||||||
import { SidebarTrigger } from "./sidebar";
|
import { SidebarTrigger } from "./sidebar";
|
||||||
import React from "react";
|
|
||||||
|
|
||||||
const PAGE_TITLES: Record<string, string> = {
|
const PAGE_TITLES: Record<string, string> = {
|
||||||
"/home/dashboard": "Memory graph",
|
"/home/dashboard": "Memory graph",
|
||||||
@ -50,6 +49,36 @@ function getLogsTab(pathname: string): "all" | "activity" {
|
|||||||
return "all";
|
return "all";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Back and Forward navigation component
|
||||||
|
function NavigationBackForward() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mr-1 flex items-center">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="xs"
|
||||||
|
aria-label="Back"
|
||||||
|
onClick={() => navigate(-1)}
|
||||||
|
className="rounded"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={16} />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="xs"
|
||||||
|
aria-label="Forward"
|
||||||
|
onClick={() => navigate(1)}
|
||||||
|
className="rounded"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<ArrowRight size={16} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function SiteHeader() {
|
export function SiteHeader() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@ -72,8 +101,10 @@ export function SiteHeader() {
|
|||||||
return (
|
return (
|
||||||
<header className="flex h-(--header-height) shrink-0 items-center gap-2 border-b border-gray-300 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-(--header-height)">
|
<header className="flex h-(--header-height) shrink-0 items-center gap-2 border-b border-gray-300 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-(--header-height)">
|
||||||
<div className="flex w-full items-center justify-between gap-1 px-4 pr-2 lg:gap-2">
|
<div className="flex w-full items-center justify-between gap-1 px-4 pr-2 lg:gap-2">
|
||||||
<div className="flex items-center gap-1">
|
<div className="-ml-1 flex items-center gap-1">
|
||||||
<SidebarTrigger className="-ml-1" />
|
{/* Back/Forward navigation before SidebarTrigger */}
|
||||||
|
<NavigationBackForward />
|
||||||
|
<SidebarTrigger className="mr-1" />
|
||||||
|
|
||||||
<h1 className="text-base">{title}</h1>
|
<h1 className="text-base">{title}</h1>
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,51 @@
|
|||||||
|
import { json, type ActionFunctionArgs } from "@remix-run/node";
|
||||||
|
import { requireUserId } from "~/services/session.server";
|
||||||
|
|
||||||
|
import { logger } from "~/services/logger.service";
|
||||||
|
import { prisma } from "~/db.server";
|
||||||
|
|
||||||
|
export async function action({ request }: ActionFunctionArgs) {
|
||||||
|
if (request.method !== "POST") {
|
||||||
|
return json({ error: "Method not allowed" }, { status: 405 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const userId = await requireUserId(request);
|
||||||
|
const body = await request.json();
|
||||||
|
const { integrationAccountId } = body;
|
||||||
|
|
||||||
|
if (!integrationAccountId) {
|
||||||
|
return json(
|
||||||
|
{ error: "Integration account ID is required" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Soft delete the integration account by setting deletedAt
|
||||||
|
const updatedAccount = await prisma.integrationAccount.delete({
|
||||||
|
where: {
|
||||||
|
id: integrationAccountId,
|
||||||
|
deleted: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info("Integration account disconnected (soft deleted)", {
|
||||||
|
integrationAccountId,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return json({
|
||||||
|
success: true,
|
||||||
|
message: "Integration account disconnected successfully",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Failed to disconnect integration account", {
|
||||||
|
error: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
});
|
||||||
|
|
||||||
|
return json(
|
||||||
|
{ error: "Failed to disconnect integration account" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,75 @@
|
|||||||
|
import { json, type ActionFunctionArgs } from "@remix-run/node";
|
||||||
|
import { requireUserId } from "~/services/session.server";
|
||||||
|
|
||||||
|
import { logger } from "~/services/logger.service";
|
||||||
|
import { prisma } from "~/db.server";
|
||||||
|
|
||||||
|
export async function action({ request }: ActionFunctionArgs) {
|
||||||
|
if (request.method !== "POST") {
|
||||||
|
return json({ error: "Method not allowed" }, { status: 405 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const userId = await requireUserId(request);
|
||||||
|
const body = await request.json();
|
||||||
|
const { integrationAccountId } = body;
|
||||||
|
|
||||||
|
if (!integrationAccountId) {
|
||||||
|
return json(
|
||||||
|
{ error: "Integration account ID is required" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the current integration account
|
||||||
|
const currentAccount = await prisma.integrationAccount.findUnique({
|
||||||
|
where: {
|
||||||
|
id: integrationAccountId,
|
||||||
|
deleted: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!currentAccount) {
|
||||||
|
return json({ error: "Integration account not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the current configuration
|
||||||
|
const currentConfig =
|
||||||
|
(currentAccount.integrationConfiguration as any) || {};
|
||||||
|
|
||||||
|
// Remove the mcp key from the configuration
|
||||||
|
const updatedConfig = { ...currentConfig };
|
||||||
|
delete updatedConfig.mcp;
|
||||||
|
|
||||||
|
// Update the integration account
|
||||||
|
const updatedAccount = await prisma.integrationAccount.update({
|
||||||
|
where: {
|
||||||
|
id: integrationAccountId,
|
||||||
|
deleted: null,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
integrationConfiguration: updatedConfig,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info("MCP configuration disconnected", {
|
||||||
|
integrationAccountId,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return json({
|
||||||
|
success: true,
|
||||||
|
message: "MCP configuration disconnected successfully",
|
||||||
|
account: updatedAccount,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Failed to disconnect MCP configuration", {
|
||||||
|
error: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
});
|
||||||
|
|
||||||
|
return json(
|
||||||
|
{ error: "Failed to disconnect MCP configuration" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,11 +1,11 @@
|
|||||||
import { json } from "@remix-run/node";
|
import { json } from "@remix-run/node";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
|
import { createHybridActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
|
||||||
import { createIntegrationAccount } from "~/services/integrationAccount.server";
|
|
||||||
import { IntegrationEventType } from "@core/types";
|
import { IntegrationEventType } from "@core/types";
|
||||||
import { runIntegrationTrigger } from "~/services/integration.server";
|
import { runIntegrationTrigger } from "~/services/integration.server";
|
||||||
import { getIntegrationDefinitionWithId } from "~/services/integrationDefinition.server";
|
import { getIntegrationDefinitionWithId } from "~/services/integrationDefinition.server";
|
||||||
import { logger } from "~/services/logger.service";
|
import { logger } from "~/services/logger.service";
|
||||||
|
import { getWorkspaceByUser } from "~/models/workspace.server";
|
||||||
|
|
||||||
// Schema for creating an integration account with API key
|
// Schema for creating an integration account with API key
|
||||||
const IntegrationAccountBodySchema = z.object({
|
const IntegrationAccountBodySchema = z.object({
|
||||||
@ -14,30 +14,30 @@ const IntegrationAccountBodySchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Route for creating an integration account directly with an API key
|
// Route for creating an integration account directly with an API key
|
||||||
const { action, loader } = createActionApiRoute(
|
const { action, loader } = createHybridActionApiRoute(
|
||||||
{
|
{
|
||||||
body: IntegrationAccountBodySchema,
|
body: IntegrationAccountBodySchema,
|
||||||
allowJWT: true,
|
allowJWT: true,
|
||||||
authorization: {
|
authorization: {
|
||||||
action: "create",
|
action: "integrationaccount:create",
|
||||||
subject: "IntegrationAccount",
|
|
||||||
},
|
},
|
||||||
corsStrategy: "all",
|
corsStrategy: "all",
|
||||||
},
|
},
|
||||||
async ({ body, authentication }) => {
|
async ({ body, authentication }) => {
|
||||||
const { integrationDefinitionId, apiKey } = body;
|
const { integrationDefinitionId, apiKey } = body;
|
||||||
const { userId } = authentication;
|
const { userId } = authentication;
|
||||||
|
const workspace = await getWorkspaceByUser(authentication.userId);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get the integration definition
|
// Get the integration definition
|
||||||
const integrationDefinition = await getIntegrationDefinitionWithId(
|
const integrationDefinition = await getIntegrationDefinitionWithId(
|
||||||
integrationDefinitionId
|
integrationDefinitionId,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!integrationDefinition) {
|
if (!integrationDefinition) {
|
||||||
return json(
|
return json(
|
||||||
{ error: "Integration definition not found" },
|
{ error: "Integration definition not found" },
|
||||||
{ status: 404 }
|
{ status: 404 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -50,26 +50,18 @@ const { action, loader } = createActionApiRoute(
|
|||||||
apiKey,
|
apiKey,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
userId
|
userId,
|
||||||
|
workspace?.id,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!setupResult || !setupResult.accountId) {
|
if (!setupResult || !setupResult.accountId) {
|
||||||
return json(
|
return json(
|
||||||
{ error: "Failed to setup integration with the provided API key" },
|
{ error: "Failed to setup integration with the provided API key" },
|
||||||
{ status: 400 }
|
{ status: 400 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create the integration account
|
return json({ success: true, setupResult });
|
||||||
const integrationAccount = await createIntegrationAccount({
|
|
||||||
accountId: setupResult.accountId,
|
|
||||||
integrationDefinitionId,
|
|
||||||
userId,
|
|
||||||
config: setupResult.config || {},
|
|
||||||
settings: setupResult.settings || {},
|
|
||||||
});
|
|
||||||
|
|
||||||
return json({ success: true, integrationAccount });
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Error creating integration account", {
|
logger.error("Error creating integration account", {
|
||||||
error,
|
error,
|
||||||
@ -78,10 +70,10 @@ const { action, loader } = createActionApiRoute(
|
|||||||
});
|
});
|
||||||
return json(
|
return json(
|
||||||
{ error: "Failed to create integration account" },
|
{ error: "Failed to create integration account" },
|
||||||
{ status: 500 }
|
{ status: 500 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
export { action, loader };
|
export { action, loader };
|
||||||
|
|||||||
@ -109,6 +109,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
|||||||
const integrationDef =
|
const integrationDef =
|
||||||
log.activity?.integrationAccount?.integrationDefinition;
|
log.activity?.integrationAccount?.integrationDefinition;
|
||||||
const logData = log.data as any;
|
const logData = log.data as any;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: log.id,
|
id: log.id,
|
||||||
source: integrationDef?.name || logData?.source || "Unknown",
|
source: integrationDef?.name || logData?.source || "Unknown",
|
||||||
|
|||||||
@ -81,21 +81,25 @@ const { action, loader } = createActionApiRoute(
|
|||||||
const integrationConfig =
|
const integrationConfig =
|
||||||
integrationAccount?.integrationConfiguration as any;
|
integrationAccount?.integrationConfiguration as any;
|
||||||
|
|
||||||
if (!integrationAccount || !integrationConfig) {
|
if (
|
||||||
|
!integrationAccount ||
|
||||||
|
!integrationConfig ||
|
||||||
|
!integrationConfig.mcp
|
||||||
|
) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
serverUrl,
|
serverUrl,
|
||||||
tokens: {
|
tokens: {
|
||||||
access_token: integrationConfig.access_token,
|
access_token: integrationConfig.mcp.tokens.access_token,
|
||||||
token_type: integrationConfig.token_type || "bearer",
|
token_type: integrationConfig.mcp.tokens.token_type || "bearer",
|
||||||
expires_in: integrationConfig.expires_in || 3600,
|
expires_in: integrationConfig.mcp.tokens.expires_in || 3600,
|
||||||
refresh_token: integrationConfig.refresh_token,
|
refresh_token: integrationConfig.mcp.tokens.refresh_token,
|
||||||
scope: integrationConfig.scope || "read write",
|
scope: integrationConfig.mcp.tokens.scope || "read write",
|
||||||
},
|
},
|
||||||
expiresAt: integrationConfig.expiresAt
|
expiresAt: integrationConfig.mcp.tokens.expiresAt
|
||||||
? new Date(integrationConfig.expiresAt)
|
? new Date(integrationConfig.mcp.tokens.expiresAt)
|
||||||
: new Date(Date.now() + 3600 * 1000),
|
: new Date(Date.now() + 3600 * 1000),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|||||||
@ -6,6 +6,10 @@ import { createMCPAuthClient } from "@core/mcp-proxy";
|
|||||||
import { logger } from "~/services/logger.service";
|
import { logger } from "~/services/logger.service";
|
||||||
import { env } from "~/env.server";
|
import { env } from "~/env.server";
|
||||||
import { getIntegrationDefinitionForState } from "~/services/oauth/oauth.server";
|
import { getIntegrationDefinitionForState } from "~/services/oauth/oauth.server";
|
||||||
|
import {
|
||||||
|
getIntegrationAccount,
|
||||||
|
getIntegrationAccountForId,
|
||||||
|
} from "~/services/integrationAccount.server";
|
||||||
|
|
||||||
const CALLBACK_URL = `${env.APP_ORIGIN}/api/v1/oauth/callback`;
|
const CALLBACK_URL = `${env.APP_ORIGIN}/api/v1/oauth/callback`;
|
||||||
const MCP_CALLBACK_URL = `${CALLBACK_URL}/mcp`;
|
const MCP_CALLBACK_URL = `${CALLBACK_URL}/mcp`;
|
||||||
@ -26,8 +30,13 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const { integrationDefinitionId, redirectURL } =
|
const {
|
||||||
await getIntegrationDefinitionForState(state);
|
integrationDefinitionId,
|
||||||
|
redirectURL,
|
||||||
|
userId,
|
||||||
|
workspaceId,
|
||||||
|
integrationAccountId,
|
||||||
|
} = await getIntegrationDefinitionForState(state);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// For now, we'll assume Linear integration - in the future this should be derived from state
|
// For now, we'll assume Linear integration - in the future this should be derived from state
|
||||||
@ -35,6 +44,9 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
|||||||
integrationDefinitionId,
|
integrationDefinitionId,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const integrationAccount =
|
||||||
|
await getIntegrationAccountForId(integrationAccountId);
|
||||||
|
|
||||||
if (!integrationDefinition) {
|
if (!integrationDefinition) {
|
||||||
throw new Error("Integration definition not found");
|
throw new Error("Integration definition not found");
|
||||||
}
|
}
|
||||||
@ -71,11 +83,13 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
|||||||
state,
|
state,
|
||||||
redirect_uri: MCP_CALLBACK_URL,
|
redirect_uri: MCP_CALLBACK_URL,
|
||||||
},
|
},
|
||||||
integrationDefinition,
|
mcp: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
// We need to get userId from somewhere - for now using undefined
|
// We need to get userId from somewhere - for now using undefined
|
||||||
undefined,
|
userId,
|
||||||
|
workspaceId,
|
||||||
|
integrationAccount ?? undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
return new Response(null, {
|
return new Response(null, {
|
||||||
|
|||||||
@ -14,7 +14,7 @@ const { loader } = createActionApiRoute(
|
|||||||
for (const [key, value] of url.searchParams.entries()) {
|
for (const [key, value] of url.searchParams.entries()) {
|
||||||
params[key] = value;
|
params[key] = value;
|
||||||
}
|
}
|
||||||
return await callbackHandler(params, request);
|
return await callbackHandler(params);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -1,14 +1,22 @@
|
|||||||
import React, { useMemo } from "react";
|
import React, { useMemo, useState, useCallback } from "react";
|
||||||
import { json, type LoaderFunctionArgs } from "@remix-run/node";
|
import { json, type LoaderFunctionArgs } from "@remix-run/node";
|
||||||
import { useLoaderData, Link } from "@remix-run/react";
|
import { useLoaderData, useFetcher } from "@remix-run/react";
|
||||||
import { requireUserId, requireWorkpace } from "~/services/session.server";
|
import { requireUserId, requireWorkpace } from "~/services/session.server";
|
||||||
import { getIntegrationDefinitions } from "~/services/integrationDefinition.server";
|
import { getIntegrationDefinitions } from "~/services/integrationDefinition.server";
|
||||||
import { getIntegrationAccounts } from "~/services/integrationAccount.server";
|
import { getIntegrationAccounts } from "~/services/integrationAccount.server";
|
||||||
import { IntegrationAuthDialog } from "~/components/integrations/IntegrationAuthDialog";
|
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
|
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 { getIcon, type IconType } from "~/components/icon-utils";
|
||||||
import { ArrowLeft, ExternalLink } from "lucide-react";
|
import { Checkbox } from "~/components/ui/checkbox";
|
||||||
|
import { MCPAuthSection } from "~/components/integrations/mcp-auth-section";
|
||||||
|
import { ConnectedAccountSection } from "~/components/integrations/connected-account-section";
|
||||||
|
|
||||||
export async function loader({ request, params }: LoaderFunctionArgs) {
|
export async function loader({ request, params }: LoaderFunctionArgs) {
|
||||||
const userId = await requireUserId(request);
|
const userId = await requireUserId(request);
|
||||||
@ -21,7 +29,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const integration = integrationDefinitions.find(
|
const integration = integrationDefinitions.find(
|
||||||
(def) => def.slug === slug || def.id === slug
|
(def) => def.slug === slug || def.id === slug,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!integration) {
|
if (!integration) {
|
||||||
@ -49,83 +57,119 @@ function parseSpec(spec: any) {
|
|||||||
|
|
||||||
export default function IntegrationDetail() {
|
export default function IntegrationDetail() {
|
||||||
const { integration, integrationAccounts } = useLoaderData<typeof loader>();
|
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 activeAccount = useMemo(
|
const activeAccount = useMemo(
|
||||||
() =>
|
() =>
|
||||||
integrationAccounts.find(
|
integrationAccounts.find(
|
||||||
(acc) => acc.integrationDefinitionId === integration.id && acc.isActive
|
(acc) => acc.integrationDefinitionId === integration.id && acc.isActive,
|
||||||
),
|
),
|
||||||
[integrationAccounts, integration.id]
|
[integrationAccounts, integration.id],
|
||||||
);
|
);
|
||||||
|
|
||||||
const specData = useMemo(() => parseSpec(integration.spec), [integration.spec]);
|
const specData = useMemo(
|
||||||
|
() => parseSpec(integration.spec),
|
||||||
|
[integration.spec],
|
||||||
|
);
|
||||||
const hasApiKey = !!specData?.auth?.api_key;
|
const hasApiKey = !!specData?.auth?.api_key;
|
||||||
const hasOAuth2 = !!specData?.auth?.OAuth2;
|
const hasOAuth2 = !!specData?.auth?.OAuth2;
|
||||||
const hasMCPAuth = !!specData?.mcpAuth;
|
const hasMCPAuth = !!specData?.mcpAuth;
|
||||||
const Component = getIcon(integration.icon as IconType);
|
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 (
|
return (
|
||||||
<div className="home flex h-full flex-col overflow-y-auto p-4 px-5">
|
<div className="home flex h-full flex-col overflow-y-auto p-4 px-5">
|
||||||
{/* Header */}
|
|
||||||
<div className="mb-6 flex items-center gap-4">
|
|
||||||
<Link
|
|
||||||
to="/home/integrations"
|
|
||||||
className="flex items-center gap-2 text-muted-foreground hover:text-foreground"
|
|
||||||
>
|
|
||||||
<ArrowLeft size={16} />
|
|
||||||
Back to Integrations
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Integration Details */}
|
{/* Integration Details */}
|
||||||
<div className="mx-auto max-w-2xl space-y-6">
|
<div className="mx-auto w-2xl space-y-6">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader className="bg-background-2">
|
||||||
<div className="flex items-start gap-4">
|
<div className="flex items-start gap-4">
|
||||||
<div className="bg-background-2 flex h-12 w-12 items-center justify-center rounded">
|
<div className="bg-grayAlpha-100 flex h-12 w-12 items-center justify-center rounded">
|
||||||
<Component size={24} />
|
<Component size={24} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="-mt-1 flex-1">
|
||||||
<CardTitle className="text-2xl">{integration.name}</CardTitle>
|
<CardTitle className="text-2xl">{integration.name}</CardTitle>
|
||||||
<CardDescription className="mt-2 text-base">
|
<CardDescription className="text-base">
|
||||||
{integration.description || `Connect to ${integration.name}`}
|
{integration.description || `Connect to ${integration.name}`}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent>
|
<CardContent className="bg-background-2 p-4">
|
||||||
{/* Connection Status */}
|
|
||||||
<div className="mb-6 flex items-center gap-3">
|
|
||||||
<span className="text-sm font-medium">Status:</span>
|
|
||||||
{activeAccount ? (
|
|
||||||
<span className="rounded-full bg-green-100 px-3 py-1 text-sm text-green-800">
|
|
||||||
Connected
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="rounded-full bg-gray-100 px-3 py-1 text-sm text-gray-700">
|
|
||||||
Not Connected
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Authentication Methods */}
|
{/* Authentication Methods */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h3 className="text-lg font-medium">Authentication Methods</h3>
|
<h3 className="text-lg font-medium">Authentication Methods</h3>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{hasApiKey && (
|
{hasApiKey && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm">✓ API Key authentication</span>
|
<span className="inline-flex items-center gap-2 text-sm">
|
||||||
|
<Checkbox checked /> API Key authentication
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{hasOAuth2 && (
|
{hasOAuth2 && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm">✓ OAuth 2.0 authentication</span>
|
<span className="inline-flex items-center gap-2 text-sm">
|
||||||
</div>
|
<Checkbox checked />
|
||||||
)}
|
OAuth 2.0 authentication
|
||||||
{hasMCPAuth && (
|
</span>
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-sm">✓ MCP (Model Context Protocol) authentication</span>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!hasApiKey && !hasOAuth2 && !hasMCPAuth && (
|
{!hasApiKey && !hasOAuth2 && !hasMCPAuth && (
|
||||||
@ -136,46 +180,110 @@ export default function IntegrationDetail() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Connect Button */}
|
{/* Connect Section */}
|
||||||
{!activeAccount && (hasApiKey || hasOAuth2 || hasMCPAuth) && (
|
{!activeAccount && (hasApiKey || hasOAuth2) && (
|
||||||
<div className="mt-6 flex justify-center">
|
<div className="mt-6 space-y-4">
|
||||||
<IntegrationAuthDialog integration={integration}>
|
<h3 className="text-lg font-medium">
|
||||||
<Button size="lg" className="px-8">
|
Connect to {integration.name}
|
||||||
Connect to {integration.name}
|
</h3>
|
||||||
</Button>
|
|
||||||
</IntegrationAuthDialog>
|
{/* 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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Connected Account Info */}
|
{/* Connected Account Info */}
|
||||||
{activeAccount && (
|
<ConnectedAccountSection activeAccount={activeAccount} />
|
||||||
<div className="mt-6 space-y-4">
|
|
||||||
<h3 className="text-lg font-medium">Connected Account</h3>
|
|
||||||
<div className="rounded-lg border bg-green-50 p-4">
|
|
||||||
<div className="text-sm text-green-800">
|
|
||||||
<p className="font-medium">Account ID: {activeAccount.id}</p>
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
Connected on {new Date(activeAccount.createdAt).toLocaleDateString()}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Integration Spec Details */}
|
{/* MCP Authentication Section */}
|
||||||
{specData && Object.keys(specData).length > 0 && (
|
<MCPAuthSection
|
||||||
<div className="mt-6 space-y-4">
|
integration={integration}
|
||||||
<h3 className="text-lg font-medium">Integration Details</h3>
|
activeAccount={activeAccount as any}
|
||||||
<div className="rounded-lg border bg-gray-50 p-4">
|
hasMCPAuth={hasMCPAuth}
|
||||||
<pre className="text-sm text-gray-700">
|
/>
|
||||||
{JSON.stringify(specData, null, 2)}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
|||||||
import { requireUserId, requireWorkpace } from "~/services/session.server";
|
import { requireUserId, requireWorkpace } from "~/services/session.server";
|
||||||
import { getIntegrationDefinitions } from "~/services/integrationDefinition.server";
|
import { getIntegrationDefinitions } from "~/services/integrationDefinition.server";
|
||||||
import { getIntegrationAccounts } from "~/services/integrationAccount.server";
|
import { getIntegrationAccounts } from "~/services/integrationAccount.server";
|
||||||
import { IntegrationGrid } from "~/components/integrations/IntegrationGrid";
|
import { IntegrationGrid } from "~/components/integrations/integration-grid";
|
||||||
|
|
||||||
export async function loader({ request }: LoaderFunctionArgs) {
|
export async function loader({ request }: LoaderFunctionArgs) {
|
||||||
const userId = await requireUserId(request);
|
const userId = await requireUserId(request);
|
||||||
|
|||||||
@ -2,34 +2,33 @@ import { useState } from "react";
|
|||||||
import { useLogs } from "~/hooks/use-logs";
|
import { useLogs } from "~/hooks/use-logs";
|
||||||
import { LogsFilters } from "~/components/logs/logs-filters";
|
import { LogsFilters } from "~/components/logs/logs-filters";
|
||||||
import { VirtualLogsList } from "~/components/logs/virtual-logs-list";
|
import { VirtualLogsList } from "~/components/logs/virtual-logs-list";
|
||||||
import { AppContainer, PageContainer, PageBody } from "~/components/layout/app-layout";
|
import { AppContainer, PageContainer } from "~/components/layout/app-layout";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
import { Card, CardContent } from "~/components/ui/card";
|
||||||
import { Badge } from "~/components/ui/badge";
|
|
||||||
import { Activity } from "lucide-react";
|
import { Activity } from "lucide-react";
|
||||||
|
|
||||||
export default function LogsActivity() {
|
export default function LogsActivity() {
|
||||||
const [selectedSource, setSelectedSource] = useState<string | undefined>();
|
const [selectedSource, setSelectedSource] = useState<string | undefined>();
|
||||||
const [selectedStatus, setSelectedStatus] = useState<string | undefined>();
|
const [selectedStatus, setSelectedStatus] = useState<string | undefined>();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
logs,
|
logs,
|
||||||
hasMore,
|
hasMore,
|
||||||
loadMore,
|
loadMore,
|
||||||
availableSources,
|
availableSources,
|
||||||
isLoading,
|
isLoading,
|
||||||
isInitialLoad
|
isInitialLoad,
|
||||||
} = useLogs({
|
} = useLogs({
|
||||||
endpoint: '/api/v1/logs/activity',
|
endpoint: "/api/v1/logs/activity",
|
||||||
source: selectedSource,
|
source: selectedSource,
|
||||||
status: selectedStatus
|
status: selectedStatus,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isInitialLoad) {
|
if (isInitialLoad) {
|
||||||
return (
|
return (
|
||||||
<AppContainer>
|
<AppContainer>
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
<div className="flex items-center justify-center h-64">
|
<div className="flex h-64 items-center justify-center">
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
<div className="border-primary h-8 w-8 animate-spin rounded-full border-b-2"></div>
|
||||||
</div>
|
</div>
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
</AppContainer>
|
</AppContainer>
|
||||||
@ -37,80 +36,43 @@ export default function LogsActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppContainer>
|
<div className="space-y-6 p-4 px-5">
|
||||||
<PageContainer>
|
<LogsFilters
|
||||||
<PageBody>
|
availableSources={availableSources}
|
||||||
<div className="space-y-6">
|
selectedSource={selectedSource}
|
||||||
{/* Header */}
|
selectedStatus={selectedStatus}
|
||||||
<div className="flex items-center justify-between">
|
onSourceChange={setSelectedSource}
|
||||||
<div className="flex items-center gap-3">
|
onStatusChange={setSelectedStatus}
|
||||||
<Activity className="h-6 w-6 text-primary" />
|
/>
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold">Activity Ingestion Logs</h1>
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
View ingestion logs for activities from connected integrations
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Badge variant="outline" className="text-sm">
|
|
||||||
{logs.length} activity logs loaded
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Logs List */}
|
||||||
<Card>
|
<div className="space-y-4">
|
||||||
<CardHeader>
|
{logs.length === 0 ? (
|
||||||
<CardTitle className="text-lg">Filters</CardTitle>
|
<Card>
|
||||||
</CardHeader>
|
<CardContent className="bg-background-2 flex items-center justify-center py-16">
|
||||||
<CardContent>
|
<div className="text-center">
|
||||||
<LogsFilters
|
<Activity className="text-muted-foreground mx-auto mb-4 h-12 w-12" />
|
||||||
availableSources={availableSources}
|
<h3 className="mb-2 text-lg font-semibold">
|
||||||
selectedSource={selectedSource}
|
No activity logs found
|
||||||
selectedStatus={selectedStatus}
|
</h3>
|
||||||
onSourceChange={setSelectedSource}
|
<p className="text-muted-foreground">
|
||||||
onStatusChange={setSelectedStatus}
|
{selectedSource || selectedStatus
|
||||||
/>
|
? "Try adjusting your filters to see more results."
|
||||||
</CardContent>
|
: "No activity ingestion logs are available yet."}
|
||||||
</Card>
|
</p>
|
||||||
|
|
||||||
{/* Logs List */}
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h2 className="text-lg font-semibold">Activity Ingestion Queue</h2>
|
|
||||||
{hasMore && (
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
Scroll to load more...
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
</CardContent>
|
||||||
{logs.length === 0 ? (
|
</Card>
|
||||||
<Card>
|
) : (
|
||||||
<CardContent className="flex items-center justify-center py-16">
|
<VirtualLogsList
|
||||||
<div className="text-center">
|
logs={logs}
|
||||||
<Activity className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
hasMore={hasMore}
|
||||||
<h3 className="text-lg font-semibold mb-2">No activity logs found</h3>
|
loadMore={loadMore}
|
||||||
<p className="text-muted-foreground">
|
isLoading={isLoading}
|
||||||
{selectedSource || selectedStatus
|
height={600}
|
||||||
? 'Try adjusting your filters to see more results.'
|
/>
|
||||||
: 'No activity ingestion logs are available yet.'}
|
)}
|
||||||
</p>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
) : (
|
|
||||||
<VirtualLogsList
|
|
||||||
logs={logs}
|
|
||||||
hasMore={hasMore}
|
|
||||||
loadMore={loadMore}
|
|
||||||
isLoading={isLoading}
|
|
||||||
height={600}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</PageBody>
|
|
||||||
</PageContainer>
|
|
||||||
</AppContainer>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,39 +1,12 @@
|
|||||||
import { tasks } from "@trigger.dev/sdk/v3";
|
import { tasks } from "@trigger.dev/sdk/v3";
|
||||||
|
|
||||||
import { getOrCreatePersonalAccessToken } from "./personalAccessToken.server";
|
|
||||||
import { logger } from "./logger.service";
|
import { logger } from "./logger.service";
|
||||||
import { type integrationRun } from "~/trigger/integrations/integration-run";
|
import { type integrationRun } from "~/trigger/integrations/integration-run";
|
||||||
|
|
||||||
import type { IntegrationDefinitionV2 } from "@core/database";
|
import type {
|
||||||
|
IntegrationAccount,
|
||||||
/**
|
IntegrationDefinitionV2,
|
||||||
* Prepares the parameters for triggering an integration.
|
} from "@core/database";
|
||||||
* If userId is provided, gets or creates a personal access token for the user.
|
|
||||||
*/
|
|
||||||
async function prepareIntegrationTrigger(
|
|
||||||
integrationDefinition: IntegrationDefinitionV2,
|
|
||||||
userId?: string,
|
|
||||||
) {
|
|
||||||
logger.info(`Loading integration ${integrationDefinition.slug}`);
|
|
||||||
|
|
||||||
let pat = "";
|
|
||||||
let patId = "";
|
|
||||||
if (userId) {
|
|
||||||
// Use the integration slug as the token name for uniqueness
|
|
||||||
const tokenResult = await getOrCreatePersonalAccessToken({
|
|
||||||
name: integrationDefinition.slug ?? "integration",
|
|
||||||
userId,
|
|
||||||
});
|
|
||||||
pat = tokenResult.token ?? "";
|
|
||||||
patId = tokenResult.id ?? "";
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
integrationDefinition,
|
|
||||||
pat,
|
|
||||||
patId,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Triggers an integration run asynchronously.
|
* Triggers an integration run asynchronously.
|
||||||
@ -43,11 +16,24 @@ export async function runIntegrationTriggerAsync(
|
|||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
event: any,
|
event: any,
|
||||||
userId?: string,
|
userId?: string,
|
||||||
|
workspaceId?: string,
|
||||||
) {
|
) {
|
||||||
const params = await prepareIntegrationTrigger(integrationDefinition, userId);
|
logger.info(
|
||||||
|
`Triggering async integration run for ${integrationDefinition.slug}`,
|
||||||
|
{
|
||||||
|
integrationId: integrationDefinition.id,
|
||||||
|
event: event.event,
|
||||||
|
userId,
|
||||||
|
workspaceId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
return await tasks.trigger<typeof integrationRun>("integration-run", {
|
return await tasks.trigger<typeof integrationRun>("integration-run", {
|
||||||
...params,
|
integrationDefinition,
|
||||||
event,
|
event: event.event,
|
||||||
|
eventBody: event.eventBody,
|
||||||
|
integrationAccount: event.integrationAccount,
|
||||||
|
workspaceId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -59,14 +45,26 @@ export async function runIntegrationTrigger(
|
|||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
event: any,
|
event: any,
|
||||||
userId?: string,
|
userId?: string,
|
||||||
|
workspaceId?: string,
|
||||||
|
integrationAccount?: IntegrationAccount,
|
||||||
) {
|
) {
|
||||||
const params = await prepareIntegrationTrigger(integrationDefinition, userId);
|
logger.info(
|
||||||
|
`Triggering sync integration run for ${integrationDefinition.slug}`,
|
||||||
|
{
|
||||||
|
integrationId: integrationDefinition.id,
|
||||||
|
event: event.event,
|
||||||
|
userId,
|
||||||
|
workspaceId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const response = await tasks.triggerAndPoll<typeof integrationRun>(
|
const response = await tasks.triggerAndPoll<typeof integrationRun>(
|
||||||
"integration-run",
|
"integration-run",
|
||||||
{
|
{
|
||||||
...params,
|
integrationDefinition,
|
||||||
integrationAccount: event.integrationAccount,
|
integrationAccount,
|
||||||
|
workspaceId,
|
||||||
|
userId,
|
||||||
event: event.event,
|
event: event.event,
|
||||||
eventBody: event.eventBody,
|
eventBody: event.eventBody,
|
||||||
},
|
},
|
||||||
|
|||||||
@ -13,27 +13,10 @@ export const getIntegrationAccount = async (
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createIntegrationAccount = async ({
|
export const getIntegrationAccountForId = async (id: string) => {
|
||||||
integrationDefinitionId,
|
return await prisma.integrationAccount.findUnique({
|
||||||
userId,
|
where: {
|
||||||
accountId,
|
id,
|
||||||
config,
|
|
||||||
settings,
|
|
||||||
}: {
|
|
||||||
integrationDefinitionId: string;
|
|
||||||
userId: string;
|
|
||||||
accountId: string;
|
|
||||||
config?: Record<string, any>;
|
|
||||||
settings?: Record<string, any>;
|
|
||||||
}) => {
|
|
||||||
return prisma.integrationAccount.create({
|
|
||||||
data: {
|
|
||||||
accountId,
|
|
||||||
integrationDefinitionId,
|
|
||||||
integratedById: userId,
|
|
||||||
config: config || {},
|
|
||||||
settings: settings || {},
|
|
||||||
isActive: true,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@ -49,3 +32,13 @@ export const getIntegrationAccounts = async (userId: string) => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getIntegrationAccountForSlug = async (slug: string) => {
|
||||||
|
return await prisma.integrationAccount.findFirst({
|
||||||
|
where: {
|
||||||
|
integrationDefinition: {
|
||||||
|
slug,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { type OAuth2Params } from "@core/types";
|
import { type OAuth2Params } from "@core/types";
|
||||||
import { IsBoolean, IsString } from "class-validator";
|
import { IsBoolean, IsOptional, IsString } from "class-validator";
|
||||||
import type { IntegrationDefinitionV2 } from "@core/database";
|
import type { IntegrationDefinitionV2 } from "@core/database";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
@ -25,12 +25,17 @@ export class OAuthBodyInterface {
|
|||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
integrationDefinitionId: string;
|
integrationDefinitionId: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
integrationAccountId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const OAuthBodySchema = z.object({
|
export const OAuthBodySchema = z.object({
|
||||||
redirectURL: z.string(),
|
redirectURL: z.string(),
|
||||||
integrationDefinitionId: z.string(),
|
integrationDefinitionId: z.string(),
|
||||||
mcp: z.boolean().optional().default(false),
|
mcp: z.boolean().optional().default(false),
|
||||||
|
integrationAccountId: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type CallbackParams = Record<string, string>;
|
export type CallbackParams = Record<string, string>;
|
||||||
|
|||||||
@ -24,17 +24,20 @@ const MCP_CALLBACK_URL = `${CALLBACK_URL}/mcp`;
|
|||||||
const session: Record<string, SessionRecord> = {};
|
const session: Record<string, SessionRecord> = {};
|
||||||
const mcpSession: Record<
|
const mcpSession: Record<
|
||||||
string,
|
string,
|
||||||
{ integrationDefinitionId: string; redirectURL: string }
|
{
|
||||||
|
integrationDefinitionId: string;
|
||||||
|
redirectURL: string;
|
||||||
|
workspaceId: string;
|
||||||
|
userId: string;
|
||||||
|
integrationAccountId: string;
|
||||||
|
}
|
||||||
> = {};
|
> = {};
|
||||||
|
|
||||||
export type CallbackParams = Record<string, string>;
|
export type CallbackParams = Record<string, string>;
|
||||||
|
|
||||||
// Remix-style callback handler
|
// Remix-style callback handler
|
||||||
// Accepts a Remix LoaderFunctionArgs-like object: { request }
|
// Accepts a Remix LoaderFunctionArgs-like object: { request }
|
||||||
export async function callbackHandler(
|
export async function callbackHandler(params: CallbackParams) {
|
||||||
params: CallbackParams,
|
|
||||||
request: Request,
|
|
||||||
) {
|
|
||||||
if (!params.state) {
|
if (!params.state) {
|
||||||
throw new Error("No state found");
|
throw new Error("No state found");
|
||||||
}
|
}
|
||||||
@ -134,14 +137,14 @@ export async function callbackHandler(
|
|||||||
...params,
|
...params,
|
||||||
redirect_uri: CALLBACK_URL,
|
redirect_uri: CALLBACK_URL,
|
||||||
},
|
},
|
||||||
integrationDefinition,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
sessionRecord.userId,
|
sessionRecord.userId,
|
||||||
|
sessionRecord.workspaceId,
|
||||||
);
|
);
|
||||||
|
|
||||||
await tasks.trigger<typeof scheduler>("scheduler", {
|
await tasks.trigger<typeof scheduler>("scheduler", {
|
||||||
integrationAccountId: integrationAccount.id,
|
integrationAccountId: integrationAccount?.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
return new Response(null, {
|
return new Response(null, {
|
||||||
@ -253,7 +256,7 @@ export async function getRedirectURLForMCP(
|
|||||||
userId: string,
|
userId: string,
|
||||||
workspaceId?: string,
|
workspaceId?: string,
|
||||||
) {
|
) {
|
||||||
const { integrationDefinitionId } = oAuthBody;
|
const { integrationDefinitionId, integrationAccountId } = oAuthBody;
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
`We got OAuth request for ${workspaceId}: ${userId}: ${integrationDefinitionId}`,
|
`We got OAuth request for ${workspaceId}: ${userId}: ${integrationDefinitionId}`,
|
||||||
@ -265,6 +268,10 @@ export async function getRedirectURLForMCP(
|
|||||||
integrationDefinitionId,
|
integrationDefinitionId,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (!integrationAccountId) {
|
||||||
|
throw new Error("No integration account found");
|
||||||
|
}
|
||||||
|
|
||||||
if (!integrationDefinition) {
|
if (!integrationDefinition) {
|
||||||
throw new Error("No integration definition found");
|
throw new Error("No integration definition found");
|
||||||
}
|
}
|
||||||
@ -290,6 +297,9 @@ export async function getRedirectURLForMCP(
|
|||||||
mcpSession[state] = {
|
mcpSession[state] = {
|
||||||
integrationDefinitionId: integrationDefinition.id,
|
integrationDefinitionId: integrationDefinition.id,
|
||||||
redirectURL,
|
redirectURL,
|
||||||
|
userId,
|
||||||
|
workspaceId: workspaceId as string,
|
||||||
|
integrationAccountId,
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@ -42,7 +42,7 @@ export class WebhookService {
|
|||||||
|
|
||||||
if (integrationDefinition) {
|
if (integrationDefinition) {
|
||||||
try {
|
try {
|
||||||
const accountIdResponse = await runIntegrationTrigger(
|
const identifyResponse = await runIntegrationTrigger(
|
||||||
integrationDefinition,
|
integrationDefinition,
|
||||||
{
|
{
|
||||||
event: IntegrationEventType.IDENTIFY,
|
event: IntegrationEventType.IDENTIFY,
|
||||||
@ -55,12 +55,39 @@ export class WebhookService {
|
|||||||
|
|
||||||
let accountId: string | undefined;
|
let accountId: string | undefined;
|
||||||
|
|
||||||
if (
|
// Handle new CLI message format response
|
||||||
accountIdResponse?.message?.startsWith("The event payload type is")
|
if (identifyResponse?.success && identifyResponse?.result) {
|
||||||
) {
|
// Check if there are identifiers in the response
|
||||||
accountId = undefined;
|
if (
|
||||||
|
identifyResponse.result.identifiers &&
|
||||||
|
identifyResponse.result.identifiers.length > 0
|
||||||
|
) {
|
||||||
|
accountId = identifyResponse.result.identifiers[0].id;
|
||||||
|
} else if (
|
||||||
|
identifyResponse.result.activities &&
|
||||||
|
identifyResponse.result.activities.length > 0
|
||||||
|
) {
|
||||||
|
// Sometimes the account ID might be in activities data
|
||||||
|
const firstActivity = identifyResponse.result.activities[0];
|
||||||
|
accountId = firstActivity.accountId || firstActivity.id;
|
||||||
|
} else {
|
||||||
|
// Check raw output for backward compatibility
|
||||||
|
accountId = identifyResponse.rawOutput?.trim();
|
||||||
|
}
|
||||||
|
} else if (identifyResponse?.error) {
|
||||||
|
logger.warn("Integration IDENTIFY command failed", {
|
||||||
|
error: identifyResponse.error,
|
||||||
|
sourceName,
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
accountId = accountIdResponse;
|
// Handle legacy response format for backward compatibility
|
||||||
|
if (
|
||||||
|
identifyResponse?.message?.startsWith("The event payload type is")
|
||||||
|
) {
|
||||||
|
accountId = undefined;
|
||||||
|
} else {
|
||||||
|
accountId = identifyResponse;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (accountId) {
|
if (accountId) {
|
||||||
@ -68,6 +95,17 @@ export class WebhookService {
|
|||||||
where: { accountId },
|
where: { accountId },
|
||||||
include: { integrationDefinition: true },
|
include: { integrationDefinition: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
logger.info("Found integration account for webhook", {
|
||||||
|
accountId,
|
||||||
|
integrationAccountId: integrationAccount?.id,
|
||||||
|
sourceName,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
logger.warn("No account ID found from IDENTIFY command", {
|
||||||
|
sourceName,
|
||||||
|
response: identifyResponse,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Failed to identify integration account", {
|
logger.error("Failed to identify integration account", {
|
||||||
@ -85,22 +123,38 @@ export class WebhookService {
|
|||||||
|
|
||||||
if (integrationAccount) {
|
if (integrationAccount) {
|
||||||
try {
|
try {
|
||||||
await runIntegrationTrigger(
|
logger.info(`Processing webhook for ${sourceName}`, {
|
||||||
|
integrationAccountId: integrationAccount.id,
|
||||||
|
integrationSlug: integrationAccount.integrationDefinition.slug,
|
||||||
|
});
|
||||||
|
|
||||||
|
const processResponse = await runIntegrationTrigger(
|
||||||
integrationAccount.integrationDefinition,
|
integrationAccount.integrationDefinition,
|
||||||
{
|
{
|
||||||
event: IntegrationEventType.PROCESS,
|
event: IntegrationEventType.PROCESS,
|
||||||
integrationAccount,
|
|
||||||
eventBody: {
|
eventBody: {
|
||||||
eventHeaders,
|
eventHeaders,
|
||||||
eventData: { ...eventBody },
|
eventData: { ...eventBody },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
integrationAccount.integratedById,
|
integrationAccount.integratedById,
|
||||||
|
integrationAccount.workspaceId,
|
||||||
|
integrationAccount,
|
||||||
);
|
);
|
||||||
|
|
||||||
logger.log(`Successfully processed webhook for ${sourceName}`, {
|
if (processResponse?.success) {
|
||||||
integrationAccountId: integrationAccount.id,
|
logger.log(`Successfully processed webhook for ${sourceName}`, {
|
||||||
});
|
integrationAccountId: integrationAccount.id,
|
||||||
|
activitiesCreated: processResponse.result?.activities?.length || 0,
|
||||||
|
messagesProcessed: processResponse.messages?.length || 0,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
logger.warn(`Webhook processing had issues for ${sourceName}`, {
|
||||||
|
integrationAccountId: integrationAccount.id,
|
||||||
|
error: processResponse?.error,
|
||||||
|
success: processResponse?.success,
|
||||||
|
});
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Failed to process webhook for ${sourceName}`, {
|
logger.error(`Failed to process webhook for ${sourceName}`, {
|
||||||
error,
|
error,
|
||||||
|
|||||||
@ -3,8 +3,6 @@ import { IntegrationEventType } from "@core/types";
|
|||||||
import { logger, schedules, tasks } from "@trigger.dev/sdk/v3";
|
import { logger, schedules, tasks } from "@trigger.dev/sdk/v3";
|
||||||
|
|
||||||
import { type integrationRun } from "./integration-run";
|
import { type integrationRun } from "./integration-run";
|
||||||
import { getOrCreatePersonalAccessToken } from "../utils/utils";
|
|
||||||
import { nanoid } from "nanoid";
|
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
@ -27,7 +25,7 @@ export const integrationRunSchedule = schedules.task({
|
|||||||
|
|
||||||
if (!integrationAccount) {
|
if (!integrationAccount) {
|
||||||
const deletedSchedule = await schedules.del(externalId);
|
const deletedSchedule = await schedules.del(externalId);
|
||||||
logger.info("No integration account found");
|
logger.info("No integration account found, deleting schedule");
|
||||||
return deletedSchedule;
|
return deletedSchedule;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -36,22 +34,20 @@ export const integrationRunSchedule = schedules.task({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const pat = await getOrCreatePersonalAccessToken({
|
logger.info("Triggering scheduled integration run", {
|
||||||
name: `integration_scheduled_${nanoid(10)}`,
|
integrationId: integrationAccount.integrationDefinition.id,
|
||||||
userId: integrationAccount.workspace.userId as string,
|
integrationSlug: integrationAccount.integrationDefinition.slug,
|
||||||
|
accountId: integrationAccount.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!pat || !pat.token) {
|
|
||||||
logger.info("No pat token found");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return await tasks.trigger<typeof integrationRun>("integration-run", {
|
return await tasks.trigger<typeof integrationRun>("integration-run", {
|
||||||
event: IntegrationEventType.SYNC,
|
event: IntegrationEventType.SYNC,
|
||||||
pat: pat.token,
|
|
||||||
patId: pat.id,
|
|
||||||
integrationAccount,
|
integrationAccount,
|
||||||
integrationDefinition: integrationAccount.integrationDefinition,
|
integrationDefinition: integrationAccount.integrationDefinition,
|
||||||
|
eventBody: {
|
||||||
|
scheduled: true,
|
||||||
|
scheduledAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,100 +1,435 @@
|
|||||||
import createLoadRemoteModule, {
|
|
||||||
createRequires,
|
|
||||||
} from "@paciolan/remote-module-loader";
|
|
||||||
|
|
||||||
import { logger, task } from "@trigger.dev/sdk/v3";
|
import { logger, task } from "@trigger.dev/sdk/v3";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
import { spawn } from "child_process";
|
||||||
|
import {
|
||||||
|
writeFileSync,
|
||||||
|
unlinkSync,
|
||||||
|
mkdtempSync,
|
||||||
|
existsSync,
|
||||||
|
readFileSync,
|
||||||
|
} from "fs";
|
||||||
|
import { join, isAbsolute, resolve } from "path";
|
||||||
|
import { tmpdir } from "os";
|
||||||
import {
|
import {
|
||||||
type IntegrationDefinitionV2,
|
type IntegrationDefinitionV2,
|
||||||
type IntegrationAccount,
|
type IntegrationAccount,
|
||||||
} from "@core/database";
|
} from "@core/database";
|
||||||
import { deletePersonalAccessToken } from "../utils/utils";
|
import { IntegrationEventType, type Message } from "@core/types";
|
||||||
import { type IntegrationEventType } from "@core/types";
|
import { extractMessagesFromOutput } from "../utils/cli-message-handler";
|
||||||
|
import {
|
||||||
|
createActivities,
|
||||||
|
createIntegrationAccount,
|
||||||
|
saveIntegrationAccountState,
|
||||||
|
saveMCPConfig,
|
||||||
|
} from "../utils/message-utils";
|
||||||
|
|
||||||
const fetcher = async (url: string) => {
|
/**
|
||||||
// Handle remote URLs with axios
|
* Determines if a string is a URL.
|
||||||
const response = await axios.get(url);
|
*/
|
||||||
|
function isUrl(str: string): boolean {
|
||||||
return response.data;
|
try {
|
||||||
};
|
// Accepts http, https, file, etc.
|
||||||
|
const url = new URL(str);
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
return url.protocol === "http:" || url.protocol === "https:";
|
||||||
const loadRemoteModule = async (requires: any) =>
|
} catch {
|
||||||
createLoadRemoteModule({ fetcher, requires });
|
return false;
|
||||||
|
}
|
||||||
function createAxiosInstance(token: string) {
|
|
||||||
const instance = axios.create();
|
|
||||||
|
|
||||||
instance.interceptors.request.use((config) => {
|
|
||||||
// Check if URL starts with /api and doesn't have a full host
|
|
||||||
if (config.url?.startsWith("/api")) {
|
|
||||||
config.url = `${process.env.BACKEND_HOST}${config.url.replace("/api/", "/")}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
config.url?.includes(process.env.FRONTEND_HOST || "") ||
|
|
||||||
config.url?.includes(process.env.BACKEND_HOST || "")
|
|
||||||
) {
|
|
||||||
config.headers.Authorization = `Bearer ${token}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return config;
|
|
||||||
});
|
|
||||||
|
|
||||||
return instance;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
/**
|
||||||
const getRequires = (axios: any) => createRequires({ axios });
|
* Loads integration file from a URL or a local path.
|
||||||
|
*/
|
||||||
|
const loadIntegrationSource = async (source: string): Promise<string> => {
|
||||||
|
if (!source) {
|
||||||
|
throw new Error("Integration source is not provided");
|
||||||
|
}
|
||||||
|
|
||||||
|
// If it's a URL, fetch it
|
||||||
|
if (isUrl(source)) {
|
||||||
|
try {
|
||||||
|
const response = await axios.get(source);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to fetch integration file from ${source}: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, treat as a local file path (absolute or relative)
|
||||||
|
let filePath = source;
|
||||||
|
if (!isAbsolute(filePath)) {
|
||||||
|
filePath = resolve(process.cwd(), filePath);
|
||||||
|
}
|
||||||
|
if (existsSync(filePath)) {
|
||||||
|
try {
|
||||||
|
return readFileSync(filePath, "utf8");
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to read integration file from path ${filePath}: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Integration source is not found: ${source}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Executes integration CLI command with integration file
|
||||||
|
*/
|
||||||
|
const executeCLICommand = async (
|
||||||
|
integrationFile: string,
|
||||||
|
eventType: IntegrationEventType,
|
||||||
|
eventBody?: any,
|
||||||
|
config?: any,
|
||||||
|
integrationDefinition?: IntegrationDefinitionV2,
|
||||||
|
state?: any,
|
||||||
|
): Promise<string> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
// Create temporary directory for the integration file
|
||||||
|
const tempDir = mkdtempSync(join(tmpdir(), "integration-"));
|
||||||
|
const integrationPath = join(tempDir, "integration.js");
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Write integration file to temporary location
|
||||||
|
writeFileSync(integrationPath, integrationFile);
|
||||||
|
|
||||||
|
// Build command arguments based on event type and integration-cli spec
|
||||||
|
const args = [integrationPath];
|
||||||
|
|
||||||
|
switch (eventType) {
|
||||||
|
case IntegrationEventType.SETUP:
|
||||||
|
args.push("setup");
|
||||||
|
args.push("--event-body", JSON.stringify(eventBody || {}));
|
||||||
|
args.push(
|
||||||
|
"--integration-definition",
|
||||||
|
JSON.stringify(integrationDefinition || {}),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case IntegrationEventType.IDENTIFY:
|
||||||
|
args.push("identify");
|
||||||
|
args.push("--webhook-data", JSON.stringify(eventBody || {}));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case IntegrationEventType.PROCESS:
|
||||||
|
args.push("process");
|
||||||
|
args.push(
|
||||||
|
"--event-data",
|
||||||
|
JSON.stringify(eventBody?.eventData || eventBody || {}),
|
||||||
|
);
|
||||||
|
args.push("--config", JSON.stringify(config || {}));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case IntegrationEventType.SYNC:
|
||||||
|
args.push("sync");
|
||||||
|
args.push("--config", JSON.stringify(config || {}));
|
||||||
|
args.push("--state", JSON.stringify(state || {}));
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new Error(`Unsupported event type: ${eventType}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use node to execute the integration file
|
||||||
|
const childProcess = spawn("node", args, {
|
||||||
|
env: process.env,
|
||||||
|
cwd: tempDir,
|
||||||
|
stdio: ["pipe", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
|
||||||
|
let stdout = "";
|
||||||
|
let stderr = "";
|
||||||
|
|
||||||
|
childProcess.stdout.on("data", (data) => {
|
||||||
|
stdout += data.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
childProcess.stderr.on("data", (data) => {
|
||||||
|
stderr += data.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
childProcess.on("close", (code) => {
|
||||||
|
try {
|
||||||
|
// Clean up temporary file
|
||||||
|
unlinkSync(integrationPath);
|
||||||
|
} catch (cleanupError) {
|
||||||
|
logger.warn("Failed to cleanup temporary file", {
|
||||||
|
error: cleanupError,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code === 0) {
|
||||||
|
resolve(stdout);
|
||||||
|
} else {
|
||||||
|
reject(
|
||||||
|
new Error(
|
||||||
|
`Integration CLI failed with exit code ${code}: ${stderr}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
childProcess.on("error", (error) => {
|
||||||
|
try {
|
||||||
|
unlinkSync(integrationPath);
|
||||||
|
} catch (cleanupError) {
|
||||||
|
logger.warn("Failed to cleanup temporary file", {
|
||||||
|
error: cleanupError,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
try {
|
||||||
|
unlinkSync(integrationPath);
|
||||||
|
} catch (cleanupError) {
|
||||||
|
logger.warn("Failed to cleanup temporary file", {
|
||||||
|
error: cleanupError,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
async function handleActivityMessage(
|
||||||
|
messages: Message[],
|
||||||
|
integrationAccountId: string,
|
||||||
|
userId: string,
|
||||||
|
): Promise<any> {
|
||||||
|
return createActivities({ integrationAccountId, messages, userId });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleStateMessage(
|
||||||
|
messages: Message[],
|
||||||
|
integrationAccountId: string,
|
||||||
|
): Promise<any> {
|
||||||
|
// TODO: Implement state message handling
|
||||||
|
return saveIntegrationAccountState({ messages, integrationAccountId });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleIdentifierMessage(message: Message): Promise<any> {
|
||||||
|
return message.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAccountMessage(
|
||||||
|
messages: Message[],
|
||||||
|
integrationDefinition: IntegrationDefinitionV2,
|
||||||
|
workspaceId: string,
|
||||||
|
userId: string,
|
||||||
|
integrationAccountId: string,
|
||||||
|
): Promise<any> {
|
||||||
|
const message = messages[0];
|
||||||
|
const mcp = message.data.mcp;
|
||||||
|
|
||||||
|
if (mcp) {
|
||||||
|
return await saveMCPConfig({
|
||||||
|
integrationAccountId,
|
||||||
|
config: message.data.config,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle only one messages since account gets created only for one
|
||||||
|
const {
|
||||||
|
data: { settings, config, accountId },
|
||||||
|
} = messages[0];
|
||||||
|
return await createIntegrationAccount({
|
||||||
|
integrationDefinitionId: integrationDefinition.id,
|
||||||
|
workspaceId,
|
||||||
|
settings,
|
||||||
|
config,
|
||||||
|
accountId,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles CLI messages array and performs necessary actions based on message types
|
||||||
|
*/
|
||||||
|
async function handleMessageResponse(
|
||||||
|
messages: Message[],
|
||||||
|
integrationDefinition: IntegrationDefinitionV2,
|
||||||
|
workspaceId: string,
|
||||||
|
userId: string,
|
||||||
|
integrationAccountId?: string,
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
logger.info("Handling CLI message response", {
|
||||||
|
integrationId: integrationDefinition.id,
|
||||||
|
messageCount: messages.length,
|
||||||
|
messageTypes: messages.map((m) => m.type),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Group messages by type
|
||||||
|
const grouped: Record<string, Message[]> = {};
|
||||||
|
for (const message of messages) {
|
||||||
|
if (!grouped[message.type]) {
|
||||||
|
grouped[message.type] = [];
|
||||||
|
}
|
||||||
|
grouped[message.type].push(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle "activity" messages
|
||||||
|
if (grouped["activity"]) {
|
||||||
|
return await handleActivityMessage(
|
||||||
|
grouped["activity"],
|
||||||
|
integrationAccountId as string,
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle "state" messages
|
||||||
|
if (grouped["state"]) {
|
||||||
|
return await handleStateMessage(
|
||||||
|
grouped["state"],
|
||||||
|
integrationAccountId as string,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle "identifier" messages
|
||||||
|
if (grouped["identifier"]) {
|
||||||
|
return await handleIdentifierMessage(grouped["identifier"][0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle "account" messages (these may involve Prisma writes)
|
||||||
|
if (grouped["account"]) {
|
||||||
|
return await handleAccountMessage(
|
||||||
|
grouped["account"],
|
||||||
|
integrationDefinition,
|
||||||
|
workspaceId,
|
||||||
|
userId,
|
||||||
|
integrationAccountId as string,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Warn for unknown message types
|
||||||
|
for (const type of Object.keys(grouped)) {
|
||||||
|
if (!["activity", "state", "identifier", "account"].includes(type)) {
|
||||||
|
for (const message of grouped[type]) {
|
||||||
|
logger.warn("Unknown message type", {
|
||||||
|
messageType: type,
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Failed to handle CLI message response", {
|
||||||
|
error: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
integrationId: integrationDefinition.id,
|
||||||
|
messages,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove old event-based handlers as they are replaced by message-type handlers above
|
||||||
|
|
||||||
export const integrationRun = task({
|
export const integrationRun = task({
|
||||||
id: "integration-run",
|
id: "integration-run",
|
||||||
run: async ({
|
run: async ({
|
||||||
pat,
|
|
||||||
patId,
|
|
||||||
eventBody,
|
eventBody,
|
||||||
integrationAccount,
|
integrationAccount,
|
||||||
integrationDefinition,
|
integrationDefinition,
|
||||||
event,
|
event,
|
||||||
|
workspaceId,
|
||||||
|
userId,
|
||||||
}: {
|
}: {
|
||||||
pat: string;
|
|
||||||
patId: string;
|
|
||||||
// This is the event you want to pass to the integration
|
// This is the event you want to pass to the integration
|
||||||
event: IntegrationEventType;
|
event: IntegrationEventType;
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
eventBody?: any;
|
eventBody?: any;
|
||||||
integrationDefinition: IntegrationDefinitionV2;
|
integrationDefinition: IntegrationDefinitionV2;
|
||||||
integrationAccount?: IntegrationAccount;
|
integrationAccount?: IntegrationAccount;
|
||||||
|
workspaceId?: string;
|
||||||
|
userId?: string;
|
||||||
}) => {
|
}) => {
|
||||||
const remoteModuleLoad = await loadRemoteModule(
|
try {
|
||||||
getRequires(createAxiosInstance(pat)),
|
logger.info(
|
||||||
);
|
`Starting integration run for ${integrationDefinition.slug}`,
|
||||||
|
{
|
||||||
|
event,
|
||||||
|
integrationId: integrationDefinition.id,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
logger.info(
|
// Load the integration file from a URL or a local path
|
||||||
`${integrationDefinition.url}/${integrationDefinition.version}/index.cjs`,
|
const integrationSource = integrationDefinition.url as string;
|
||||||
);
|
const integrationFile = await loadIntegrationSource(integrationSource);
|
||||||
|
logger.info(`Loaded integration file from ${integrationSource}`);
|
||||||
|
|
||||||
const integrationFunction = await remoteModuleLoad(
|
// Prepare enhanced event body based on event type
|
||||||
`${integrationDefinition.url}/${integrationDefinition.version}/index.cjs`,
|
let enhancedEventBody = eventBody;
|
||||||
);
|
|
||||||
|
|
||||||
// const integrationFunction = await remoteModuleLoad(
|
// For SETUP events, include OAuth response and parameters
|
||||||
// `${integrationDefinition.url}`,
|
if (event === IntegrationEventType.SETUP) {
|
||||||
// );
|
enhancedEventBody = {
|
||||||
|
...eventBody,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Construct the proper IntegrationEventPayload structure
|
// For PROCESS events, ensure eventData is properly structured
|
||||||
const integrationEventPayload = {
|
if (event === IntegrationEventType.PROCESS) {
|
||||||
event,
|
enhancedEventBody = {
|
||||||
eventBody: { ...eventBody, integrationDefinition },
|
eventData: eventBody,
|
||||||
config: integrationAccount?.integrationConfiguration || {},
|
};
|
||||||
};
|
}
|
||||||
|
|
||||||
const result = await integrationFunction.run(integrationEventPayload);
|
logger.info(`Executing integration CLI`, {
|
||||||
|
event,
|
||||||
|
integrationId: integrationDefinition.id,
|
||||||
|
hasConfig: !!integrationAccount?.integrationConfiguration,
|
||||||
|
});
|
||||||
|
|
||||||
await deletePersonalAccessToken(patId);
|
const settings = integrationAccount?.settings as any;
|
||||||
|
|
||||||
logger.info("Personal access token deleted");
|
// Execute the CLI command using node
|
||||||
|
const output = await executeCLICommand(
|
||||||
|
integrationFile,
|
||||||
|
event,
|
||||||
|
enhancedEventBody,
|
||||||
|
integrationAccount?.integrationConfiguration,
|
||||||
|
integrationDefinition,
|
||||||
|
settings?.state,
|
||||||
|
);
|
||||||
|
|
||||||
return result;
|
logger.info("Integration CLI executed successfully");
|
||||||
|
|
||||||
|
// Process the output messages
|
||||||
|
const messages = extractMessagesFromOutput(output);
|
||||||
|
|
||||||
|
logger.info("Integration run completed", {
|
||||||
|
messageCount: messages.length,
|
||||||
|
messageTypes: messages.map((m) => m.type),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle all CLI messages through the generic handler
|
||||||
|
return await handleMessageResponse(
|
||||||
|
messages,
|
||||||
|
integrationDefinition,
|
||||||
|
workspaceId as string,
|
||||||
|
userId as string,
|
||||||
|
integrationAccount?.id,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = `Integration run failed: ${error instanceof Error ? error.message : "Unknown error"}`;
|
||||||
|
logger.error(errorMessage, {
|
||||||
|
integrationId: integrationDefinition.id,
|
||||||
|
event,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
|
||||||
|
// For SETUP commands, we need to throw the error so OAuth callback can handle it
|
||||||
|
if (event === IntegrationEventType.SETUP) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For other commands, return error in appropriate format
|
||||||
|
return {
|
||||||
|
error: errorMessage,
|
||||||
|
errors: [errorMessage],
|
||||||
|
};
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
45
apps/webapp/app/trigger/utils/cli-message-handler.ts
Normal file
45
apps/webapp/app/trigger/utils/cli-message-handler.ts
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import { type Message } from "@core/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates if a message has the correct structure
|
||||||
|
* @param message - Message to validate
|
||||||
|
* @returns True if valid, false otherwise
|
||||||
|
*/
|
||||||
|
export function isValidMessage(message: any): message is Message {
|
||||||
|
return (
|
||||||
|
typeof message === "object" &&
|
||||||
|
message !== null &&
|
||||||
|
typeof message.type === "string" &&
|
||||||
|
message.data !== undefined &&
|
||||||
|
["spec", "activity", "state", "identifier", "account"].includes(
|
||||||
|
message.type,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts and validates messages from CLI output
|
||||||
|
* @param output - Raw CLI output string
|
||||||
|
* @returns Array of valid messages
|
||||||
|
*/
|
||||||
|
export function extractMessagesFromOutput(output: string): Message[] {
|
||||||
|
const messages: Message[] = [];
|
||||||
|
const lines = output.split("\n");
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(trimmed);
|
||||||
|
if (isValidMessage(parsed)) {
|
||||||
|
messages.push(parsed);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Line is not JSON, skip it
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
130
apps/webapp/app/trigger/utils/message-utils.ts
Normal file
130
apps/webapp/app/trigger/utils/message-utils.ts
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
import { type Message } from "@core/types";
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
export const createIntegrationAccount = async ({
|
||||||
|
integrationDefinitionId,
|
||||||
|
userId,
|
||||||
|
accountId,
|
||||||
|
config,
|
||||||
|
settings,
|
||||||
|
workspaceId,
|
||||||
|
}: {
|
||||||
|
integrationDefinitionId: string;
|
||||||
|
userId: string;
|
||||||
|
accountId: string;
|
||||||
|
workspaceId: string;
|
||||||
|
config?: Record<string, any>;
|
||||||
|
settings?: Record<string, any>;
|
||||||
|
}) => {
|
||||||
|
return prisma.integrationAccount.create({
|
||||||
|
data: {
|
||||||
|
accountId,
|
||||||
|
integrationDefinitionId,
|
||||||
|
integratedById: userId,
|
||||||
|
integrationConfiguration: config || {},
|
||||||
|
settings: settings || {},
|
||||||
|
isActive: true,
|
||||||
|
workspaceId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const saveMCPConfig = async ({
|
||||||
|
integrationAccountId,
|
||||||
|
config,
|
||||||
|
}: {
|
||||||
|
integrationAccountId: string;
|
||||||
|
config: any;
|
||||||
|
}) => {
|
||||||
|
const integrationAccount = await prisma.integrationAccount.findUnique({
|
||||||
|
where: {
|
||||||
|
id: integrationAccountId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!integrationAccount) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const integrationConfig = integrationAccount.integrationConfiguration as any;
|
||||||
|
|
||||||
|
return prisma.integrationAccount.update({
|
||||||
|
where: {
|
||||||
|
id: integrationAccountId,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
integrationConfiguration: {
|
||||||
|
...integrationConfig,
|
||||||
|
mcp: config,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const saveIntegrationAccountState = async ({
|
||||||
|
messages,
|
||||||
|
integrationAccountId,
|
||||||
|
}: {
|
||||||
|
messages: Message[];
|
||||||
|
integrationAccountId: string;
|
||||||
|
}) => {
|
||||||
|
const integrationAccount = await prisma.integrationAccount.findUnique({
|
||||||
|
where: {
|
||||||
|
id: integrationAccountId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const settings = integrationAccount?.settings as any;
|
||||||
|
const state = settings.state;
|
||||||
|
|
||||||
|
return Promise.all(
|
||||||
|
messages.map(async (message) => {
|
||||||
|
return await prisma.integrationAccount.update({
|
||||||
|
where: {
|
||||||
|
id: integrationAccountId,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
settings: {
|
||||||
|
...settings,
|
||||||
|
state: {
|
||||||
|
...state,
|
||||||
|
...message.data,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createActivities = async ({
|
||||||
|
integrationAccountId,
|
||||||
|
messages,
|
||||||
|
}: {
|
||||||
|
integrationAccountId: string;
|
||||||
|
messages: Message[];
|
||||||
|
userId: string;
|
||||||
|
}) => {
|
||||||
|
const integrationAccount = await prisma.integrationAccount.findUnique({
|
||||||
|
where: {
|
||||||
|
id: integrationAccountId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!integrationAccount) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return await prisma.activity.createMany({
|
||||||
|
data: messages.map((message) => {
|
||||||
|
return {
|
||||||
|
text: message.data.text,
|
||||||
|
sourceURL: message.data.sourceURL,
|
||||||
|
integrationAccountId,
|
||||||
|
workspaceId: integrationAccount?.workspaceId,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
};
|
||||||
3
integrations/linear/.gitignore
vendored
Normal file
3
integrations/linear/.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
bin
|
||||||
|
node_modules
|
||||||
|
|
||||||
@ -1,19 +0,0 @@
|
|||||||
import axios from 'axios';
|
|
||||||
|
|
||||||
export async function integrationCreate(data: any, integrationDefinition: any) {
|
|
||||||
const { api_key } = data;
|
|
||||||
|
|
||||||
const integrationConfiguration = {
|
|
||||||
api_key: api_key,
|
|
||||||
};
|
|
||||||
|
|
||||||
const payload = {
|
|
||||||
settings: {},
|
|
||||||
accountId: 'linear-account', // Linear doesn't have a specific account ID
|
|
||||||
config: integrationConfiguration,
|
|
||||||
integrationDefinitionId: integrationDefinition.id,
|
|
||||||
};
|
|
||||||
|
|
||||||
const integrationAccount = (await axios.post(`/api/v1/integration_account`, payload)).data;
|
|
||||||
return integrationAccount;
|
|
||||||
}
|
|
||||||
@ -23,10 +23,6 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/preset-typescript": "^7.26.0",
|
"@babel/preset-typescript": "^7.26.0",
|
||||||
"@rollup/plugin-commonjs": "^28.0.1",
|
|
||||||
"@rollup/plugin-json": "^6.1.0",
|
|
||||||
"@rollup/plugin-node-resolve": "^15.3.0",
|
|
||||||
"@rollup/plugin-replace": "^5.0.7",
|
|
||||||
"@types/node": "^18.0.20",
|
"@types/node": "^18.0.20",
|
||||||
"eslint": "^9.24.0",
|
"eslint": "^9.24.0",
|
||||||
"eslint-config-prettier": "^10.1.2",
|
"eslint-config-prettier": "^10.1.2",
|
||||||
@ -37,10 +33,6 @@
|
|||||||
"eslint-plugin-unused-imports": "^2.0.0",
|
"eslint-plugin-unused-imports": "^2.0.0",
|
||||||
"prettier": "^3.4.2",
|
"prettier": "^3.4.2",
|
||||||
"rimraf": "^3.0.2",
|
"rimraf": "^3.0.2",
|
||||||
"rollup": "^4.28.1",
|
|
||||||
"rollup-plugin-node-polyfills": "^0.2.1",
|
|
||||||
"rollup-plugin-terser": "^7.0.2",
|
|
||||||
"rollup-plugin-typescript2": "^0.34.1",
|
|
||||||
"tslib": "^2.8.1",
|
"tslib": "^2.8.1",
|
||||||
"typescript": "^4.7.2",
|
"typescript": "^4.7.2",
|
||||||
"tsup": "^8.0.1",
|
"tsup": "^8.0.1",
|
||||||
@ -66,6 +58,6 @@
|
|||||||
"commander": "^12.0.0",
|
"commander": "^12.0.0",
|
||||||
"openai": "^4.0.0",
|
"openai": "^4.0.0",
|
||||||
"react-query": "^3.39.3",
|
"react-query": "^3.39.3",
|
||||||
"@redplanethq/sdk": "0.1.0"
|
"@redplanethq/sdk": "0.1.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
9605
integrations/linear/pnpm-lock.yaml
generated
9605
integrations/linear/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -1,71 +0,0 @@
|
|||||||
import commonjs from '@rollup/plugin-commonjs';
|
|
||||||
import json from '@rollup/plugin-json';
|
|
||||||
import resolve from '@rollup/plugin-node-resolve';
|
|
||||||
import nodePolyfills from 'rollup-plugin-node-polyfills';
|
|
||||||
import postcss from 'rollup-plugin-postcss';
|
|
||||||
import { babel } from '@rollup/plugin-babel';
|
|
||||||
import { terser } from 'rollup-plugin-terser';
|
|
||||||
import typescript from 'rollup-plugin-typescript2';
|
|
||||||
|
|
||||||
const frontendPlugins = [
|
|
||||||
postcss({
|
|
||||||
inject: true, // Inject CSS as JS, making it part of the bundle
|
|
||||||
minimize: true, // Minify CSS
|
|
||||||
}),
|
|
||||||
json(),
|
|
||||||
resolve({ extensions: ['.js', '.jsx', '.ts', '.tsx'] }),
|
|
||||||
commonjs({
|
|
||||||
include: /\/node_modules\//,
|
|
||||||
}),
|
|
||||||
typescript({
|
|
||||||
tsconfig: 'tsconfig.frontend.json',
|
|
||||||
}),
|
|
||||||
babel({
|
|
||||||
extensions: ['.js', '.jsx', '.ts', '.tsx'],
|
|
||||||
presets: ['@babel/preset-env', '@babel/preset-react', '@babel/preset-typescript'],
|
|
||||||
}),
|
|
||||||
terser(),
|
|
||||||
];
|
|
||||||
|
|
||||||
export default [
|
|
||||||
{
|
|
||||||
input: 'frontend/index.tsx',
|
|
||||||
external: ['react', 'react-dom', '@tegonhq/ui', 'axios', 'react-query'],
|
|
||||||
output: [
|
|
||||||
{
|
|
||||||
file: 'dist/frontend/index.js',
|
|
||||||
sourcemap: true,
|
|
||||||
format: 'cjs',
|
|
||||||
exports: 'named',
|
|
||||||
preserveModules: false,
|
|
||||||
inlineDynamicImports: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
plugins: frontendPlugins,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
input: 'backend/index.ts',
|
|
||||||
external: ['axios'],
|
|
||||||
output: [
|
|
||||||
{
|
|
||||||
file: 'dist/backend/index.js',
|
|
||||||
sourcemap: true,
|
|
||||||
format: 'cjs',
|
|
||||||
exports: 'named',
|
|
||||||
preserveModules: false,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
plugins: [
|
|
||||||
nodePolyfills(),
|
|
||||||
json(),
|
|
||||||
resolve({ extensions: ['.js', '.ts'] }),
|
|
||||||
commonjs({
|
|
||||||
include: /\/node_modules\//,
|
|
||||||
}),
|
|
||||||
typescript({
|
|
||||||
tsconfig: 'tsconfig.json',
|
|
||||||
}),
|
|
||||||
terser(),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
66
integrations/linear/src/account-create.ts
Normal file
66
integrations/linear/src/account-create.ts
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
export async function integrationCreate({ apiKey }: { apiKey: string }) {
|
||||||
|
// Fetch the Linear user info using the GraphQL API
|
||||||
|
const response = await fetch('https://api.linear.app/graphql', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `${apiKey}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
query: 'query { viewer { id name email } }',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch Linear user: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
const viewer = result?.data?.viewer;
|
||||||
|
const userId = viewer?.id;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
throw new Error('Could not extract userId from Linear GraphQL API response');
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
type: 'account',
|
||||||
|
data: {
|
||||||
|
settings: {
|
||||||
|
user: {
|
||||||
|
id: viewer.id,
|
||||||
|
name: viewer.name,
|
||||||
|
email: viewer.email,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
accountId: userId,
|
||||||
|
config: { apiKey },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MCPIntegrationCreateData {
|
||||||
|
oauthResponse: {
|
||||||
|
access_token: string;
|
||||||
|
token_type?: string;
|
||||||
|
expires_in?: number;
|
||||||
|
refresh_token?: string;
|
||||||
|
scope?: string;
|
||||||
|
[key: string]: any;
|
||||||
|
};
|
||||||
|
mcp: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function integrationCreateForMCP(data: MCPIntegrationCreateData) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
type: 'account',
|
||||||
|
data: {
|
||||||
|
mcp: true,
|
||||||
|
config: data.oauthResponse,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import { handleSchedule } from 'schedule';
|
import { handleSchedule } from './schedule';
|
||||||
import { integrationCreate } from './account-create';
|
import { integrationCreate, integrationCreateForMCP } from './account-create';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
IntegrationCLI,
|
IntegrationCLI,
|
||||||
@ -11,7 +11,9 @@ import {
|
|||||||
export async function run(eventPayload: IntegrationEventPayload) {
|
export async function run(eventPayload: IntegrationEventPayload) {
|
||||||
switch (eventPayload.event) {
|
switch (eventPayload.event) {
|
||||||
case IntegrationEventType.SETUP:
|
case IntegrationEventType.SETUP:
|
||||||
return await integrationCreate(eventPayload.eventBody, eventPayload.integrationDefinition);
|
return eventPayload.eventBody.mcp
|
||||||
|
? await integrationCreateForMCP(eventPayload.eventBody)
|
||||||
|
: await integrationCreate(eventPayload.eventBody);
|
||||||
|
|
||||||
case IntegrationEventType.SYNC:
|
case IntegrationEventType.SYNC:
|
||||||
return await handleSchedule(eventPayload.config);
|
return await handleSchedule(eventPayload.config);
|
||||||
@ -1,6 +1,5 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { IntegrationAccount } from '@redplanethq/sol-sdk';
|
|
||||||
|
|
||||||
interface LinearActivityCreateParams {
|
interface LinearActivityCreateParams {
|
||||||
url: string;
|
url: string;
|
||||||
@ -257,7 +256,7 @@ async function fetchRecentComments(accessToken: string, lastSyncTime: string) {
|
|||||||
async function processIssueActivities(
|
async function processIssueActivities(
|
||||||
issues: any[],
|
issues: any[],
|
||||||
userId: string,
|
userId: string,
|
||||||
integrationAccount: IntegrationAccount,
|
integrationAccount: any,
|
||||||
isCreator: boolean = false,
|
isCreator: boolean = false,
|
||||||
) {
|
) {
|
||||||
const activities = [];
|
const activities = [];
|
||||||
@ -426,11 +425,7 @@ async function processIssueActivities(
|
|||||||
/**
|
/**
|
||||||
* Process comment activities and create appropriate activity records
|
* Process comment activities and create appropriate activity records
|
||||||
*/
|
*/
|
||||||
async function processCommentActivities(
|
async function processCommentActivities(comments: any[], userId: string, integrationAccount: any) {
|
||||||
comments: any[],
|
|
||||||
userId: string,
|
|
||||||
integrationAccount: IntegrationAccount,
|
|
||||||
) {
|
|
||||||
const activities = [];
|
const activities = [];
|
||||||
|
|
||||||
for (const comment of comments) {
|
for (const comment of comments) {
|
||||||
@ -513,7 +508,7 @@ function getDefaultSyncTime(): string {
|
|||||||
/**
|
/**
|
||||||
* Main function to handle scheduled sync for Linear integration
|
* Main function to handle scheduled sync for Linear integration
|
||||||
*/
|
*/
|
||||||
export async function handleSchedule(integrationAccount: IntegrationAccount) {
|
export async function handleSchedule(integrationAccount: any) {
|
||||||
try {
|
try {
|
||||||
const integrationConfiguration = integrationAccount.integrationConfiguration as any;
|
const integrationConfiguration = integrationAccount.integrationConfiguration as any;
|
||||||
|
|
||||||
@ -594,6 +589,6 @@ export async function handleSchedule(integrationAccount: IntegrationAccount) {
|
|||||||
/**
|
/**
|
||||||
* The main handler for the scheduled sync event
|
* The main handler for the scheduled sync event
|
||||||
*/
|
*/
|
||||||
export async function scheduleHandler(integrationAccount: IntegrationAccount) {
|
export async function scheduleHandler(integrationAccount: any) {
|
||||||
return handleSchedule(integrationAccount);
|
return handleSchedule(integrationAccount);
|
||||||
}
|
}
|
||||||
@ -1,32 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "es2022",
|
|
||||||
"lib": ["dom", "dom.iterable", "esnext"],
|
|
||||||
"baseUrl": "frontend",
|
|
||||||
"allowJs": false,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"allowSyntheticDefaultImports": true,
|
|
||||||
"strict": true,
|
|
||||||
"forceConsistentCasingInFileNames": true,
|
|
||||||
"module": "esnext",
|
|
||||||
"moduleResolution": "node",
|
|
||||||
"resolveJsonModule": true,
|
|
||||||
"isolatedModules": true,
|
|
||||||
"jsx": "react-jsx",
|
|
||||||
"strictNullChecks": true,
|
|
||||||
"removeComments": true,
|
|
||||||
"preserveConstEnums": true,
|
|
||||||
"sourceMap": true,
|
|
||||||
"noUnusedParameters": true,
|
|
||||||
"noUnusedLocals": true,
|
|
||||||
"noImplicitReturns": true,
|
|
||||||
"noImplicitThis": true,
|
|
||||||
"noImplicitAny": true,
|
|
||||||
"noFallthroughCasesInSwitch": true,
|
|
||||||
"useUnknownInCatchVariables": false
|
|
||||||
},
|
|
||||||
"include": ["frontend/**/*"],
|
|
||||||
"exclude": ["node_modules", "build", "dist", "scripts", "acceptance-tests", "webpack", "jest"],
|
|
||||||
"types": ["typePatches"]
|
|
||||||
}
|
|
||||||
@ -1,17 +1,19 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "es2022",
|
"target": "ES2022",
|
||||||
"lib": ["dom", "dom.iterable", "esnext"],
|
|
||||||
"baseUrl": "backend",
|
|
||||||
"allowJs": false,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"allowSyntheticDefaultImports": true,
|
|
||||||
"strict": true,
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"baseUrl": "frontend",
|
||||||
|
"allowJs": false,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
"module": "esnext",
|
"module": "esnext",
|
||||||
"moduleResolution": "node",
|
"moduleResolution": "node",
|
||||||
"resolveJsonModule": true,
|
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"strictNullChecks": true,
|
"strictNullChecks": true,
|
||||||
"removeComments": true,
|
"removeComments": true,
|
||||||
@ -25,7 +27,7 @@
|
|||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"useUnknownInCatchVariables": false
|
"useUnknownInCatchVariables": false
|
||||||
},
|
},
|
||||||
"include": ["backend/**/*"],
|
"include": ["src/**/*"],
|
||||||
"exclude": ["node_modules", "build", "dist", "scripts", "acceptance-tests", "webpack", "jest"],
|
"exclude": ["node_modules", "build", "dist", "scripts", "acceptance-tests", "webpack", "jest"],
|
||||||
"types": ["typePatches"]
|
"types": ["typePatches"]
|
||||||
}
|
}
|
||||||
|
|||||||
20
integrations/linear/tsup.config.ts
Normal file
20
integrations/linear/tsup.config.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { defineConfig } from 'tsup';
|
||||||
|
import { dependencies } from './package.json';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
entry: ['src/index.ts'],
|
||||||
|
format: ['cjs'], // or esm if you're using that
|
||||||
|
bundle: true,
|
||||||
|
target: 'node16',
|
||||||
|
outDir: 'bin',
|
||||||
|
splitting: false,
|
||||||
|
shims: true,
|
||||||
|
clean: true,
|
||||||
|
name: 'linear',
|
||||||
|
platform: 'node',
|
||||||
|
legacyOutput: false,
|
||||||
|
noExternal: Object.keys(dependencies || {}), // ⬅️ bundle all deps
|
||||||
|
treeshake: {
|
||||||
|
preset: 'recommended',
|
||||||
|
},
|
||||||
|
});
|
||||||
@ -23,10 +23,6 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/preset-typescript": "^7.26.0",
|
"@babel/preset-typescript": "^7.26.0",
|
||||||
"@rollup/plugin-commonjs": "^28.0.1",
|
|
||||||
"@rollup/plugin-json": "^6.1.0",
|
|
||||||
"@rollup/plugin-node-resolve": "^15.3.0",
|
|
||||||
"@rollup/plugin-replace": "^5.0.7",
|
|
||||||
"@types/node": "^18.0.20",
|
"@types/node": "^18.0.20",
|
||||||
"eslint": "^9.24.0",
|
"eslint": "^9.24.0",
|
||||||
"eslint-config-prettier": "^10.1.2",
|
"eslint-config-prettier": "^10.1.2",
|
||||||
@ -37,10 +33,6 @@
|
|||||||
"eslint-plugin-unused-imports": "^2.0.0",
|
"eslint-plugin-unused-imports": "^2.0.0",
|
||||||
"prettier": "^3.4.2",
|
"prettier": "^3.4.2",
|
||||||
"rimraf": "^3.0.2",
|
"rimraf": "^3.0.2",
|
||||||
"rollup": "^4.28.1",
|
|
||||||
"rollup-plugin-node-polyfills": "^0.2.1",
|
|
||||||
"rollup-plugin-terser": "^7.0.2",
|
|
||||||
"rollup-plugin-typescript2": "^0.34.1",
|
|
||||||
"tslib": "^2.8.1",
|
"tslib": "^2.8.1",
|
||||||
"typescript": "^4.7.2",
|
"typescript": "^4.7.2",
|
||||||
"tsup": "^8.0.1",
|
"tsup": "^8.0.1",
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2022",
|
"target": "ES2022",
|
||||||
|
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
@ -9,16 +8,12 @@
|
|||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"outDir": "./dist",
|
"outDir": "./dist",
|
||||||
"rootDir": "./src",
|
"rootDir": "./src",
|
||||||
|
|
||||||
"lib": ["dom", "dom.iterable", "esnext"],
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
"baseUrl": "frontend",
|
"baseUrl": "frontend",
|
||||||
"allowJs": false,
|
"allowJs": false,
|
||||||
|
|
||||||
"allowSyntheticDefaultImports": true,
|
"allowSyntheticDefaultImports": true,
|
||||||
|
|
||||||
"module": "esnext",
|
"module": "esnext",
|
||||||
"moduleResolution": "node",
|
"moduleResolution": "node",
|
||||||
|
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"strictNullChecks": true,
|
"strictNullChecks": true,
|
||||||
"removeComments": true,
|
"removeComments": true,
|
||||||
|
|||||||
@ -180,6 +180,7 @@ export class NodeOAuthClientProvider implements OAuthClientProvider {
|
|||||||
authorizationUrl.searchParams.set("resource", this.authorizeResource);
|
authorizationUrl.searchParams.set("resource", this.authorizeResource);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Keep it
|
||||||
console.log(this.authorizationUrl);
|
console.log(this.authorizationUrl);
|
||||||
|
|
||||||
// Store the URL instead of opening browser
|
// Store the URL instead of opening browser
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@redplanethq/sdk",
|
"name": "@redplanethq/sdk",
|
||||||
"version": "0.1.0",
|
"version": "0.1.1",
|
||||||
"description": "CORE Node.JS SDK",
|
"description": "CORE Node.JS SDK",
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
"types": "./dist/index.d.ts",
|
"types": "./dist/index.d.ts",
|
||||||
|
|||||||
@ -1 +1 @@
|
|||||||
export { IntegrationCLI } from './integration_cli';
|
export { IntegrationCLI } from './integration-cli';
|
||||||
|
|||||||
@ -56,7 +56,7 @@ export abstract class IntegrationCLI {
|
|||||||
});
|
});
|
||||||
|
|
||||||
for (const message of messages) {
|
for (const message of messages) {
|
||||||
console.log(JSON.stringify(message, null, 2));
|
console.log(JSON.stringify(message));
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error during setup:', error);
|
console.error('Error during setup:', error);
|
||||||
@ -83,7 +83,7 @@ export abstract class IntegrationCLI {
|
|||||||
});
|
});
|
||||||
|
|
||||||
for (const message of messages) {
|
for (const message of messages) {
|
||||||
console.log(JSON.stringify(message, null, 2));
|
console.log(JSON.stringify(message));
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error processing data:', error);
|
console.error('Error processing data:', error);
|
||||||
@ -105,7 +105,7 @@ export abstract class IntegrationCLI {
|
|||||||
});
|
});
|
||||||
|
|
||||||
for (const message of messages) {
|
for (const message of messages) {
|
||||||
console.log(JSON.stringify(message, null, 2));
|
console.log(JSON.stringify(message));
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error identifying account:', error);
|
console.error('Error identifying account:', error);
|
||||||
@ -126,7 +126,7 @@ export abstract class IntegrationCLI {
|
|||||||
data: spec,
|
data: spec,
|
||||||
};
|
};
|
||||||
// For spec, we keep the single message output for compatibility
|
// For spec, we keep the single message output for compatibility
|
||||||
console.log(JSON.stringify(message, null, 2));
|
console.log(JSON.stringify(message));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error getting spec:', error);
|
console.error('Error getting spec:', error);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
@ -153,7 +153,7 @@ export abstract class IntegrationCLI {
|
|||||||
});
|
});
|
||||||
|
|
||||||
for (const message of messages) {
|
for (const message of messages) {
|
||||||
console.log(JSON.stringify(message, null, 2));
|
console.log(JSON.stringify(message));
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error during sync:', error);
|
console.error('Error during sync:', error);
|
||||||
@ -56,7 +56,7 @@ export interface Identifier {
|
|||||||
type?: string;
|
type?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MessageType = "spec" | "activity" | "state" | "identifier";
|
export type MessageType = "spec" | "activity" | "state" | "identifier" | "account";
|
||||||
|
|
||||||
export interface Message {
|
export interface Message {
|
||||||
type: MessageType;
|
type: MessageType;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user