mirror of
https://github.com/eliasstepanik/core.git
synced 2026-01-12 05:08:27 +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(
|
||||
"border-border h-auto w-full justify-start rounded p-2 py-1 text-left",
|
||||
currentConversationId === conversation.id &&
|
||||
"bg-accent text-accent-foreground font-semibold",
|
||||
"bg-accent font-semibold",
|
||||
)}
|
||||
onClick={() => {
|
||||
navigate(`/home/conversation/${conversation.id}`);
|
||||
@ -155,7 +155,7 @@ export const ConversationList = ({
|
||||
<div className="flex w-full items-start space-x-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<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"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@ -5,14 +5,16 @@ import {
|
||||
RiSlackFill,
|
||||
} from "@remixicon/react";
|
||||
import { LayoutGrid } from "lucide-react";
|
||||
import { LinearIcon, SlackIcon } from "./icons";
|
||||
|
||||
export const ICON_MAPPING = {
|
||||
slack: RiSlackFill,
|
||||
slack: SlackIcon,
|
||||
email: RiMailFill,
|
||||
discord: RiDiscordFill,
|
||||
github: RiGithubFill,
|
||||
|
||||
gmail: RiMailFill,
|
||||
linear: LinearIcon,
|
||||
|
||||
// Default icon
|
||||
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,
|
||||
} from "~/components/ui/card";
|
||||
import { getIcon, type IconType } from "~/components/icon-utils";
|
||||
import { Badge } from "../ui/badge";
|
||||
|
||||
interface IntegrationCardProps {
|
||||
integration: {
|
||||
@ -19,26 +20,20 @@ interface IntegrationCardProps {
|
||||
slug?: string;
|
||||
};
|
||||
isConnected: boolean;
|
||||
onClick?: () => void;
|
||||
showDetail?: boolean;
|
||||
}
|
||||
|
||||
export function IntegrationCard({
|
||||
integration,
|
||||
isConnected,
|
||||
onClick,
|
||||
showDetail = false,
|
||||
}: IntegrationCardProps) {
|
||||
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 (
|
||||
<CardWrapper {...cardProps}>
|
||||
<Card className="transition-all hover:shadow-md">
|
||||
<Link
|
||||
to={`/home/integration/${integration.slug || integration.id}`}
|
||||
className="bg-background-3 h-full rounded-lg"
|
||||
>
|
||||
<Card className="transition-all">
|
||||
<CardHeader className="p-4">
|
||||
<div className="bg-background-2 mb-2 flex h-6 w-6 items-center justify-center rounded">
|
||||
<Component size={18} />
|
||||
@ -51,13 +46,13 @@ export function IntegrationCard({
|
||||
{isConnected && (
|
||||
<CardFooter className="p-3">
|
||||
<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
|
||||
</span>
|
||||
</Badge>
|
||||
</div>
|
||||
</CardFooter>
|
||||
)}
|
||||
</Card>
|
||||
</CardWrapper>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,7 +1,6 @@
|
||||
import React, { useMemo } from "react";
|
||||
import { Search } from "lucide-react";
|
||||
import { IntegrationCard } from "./IntegrationCard";
|
||||
import { IntegrationAuthDialog } from "./IntegrationAuthDialog";
|
||||
import { IntegrationCard } from "./integration-card";
|
||||
|
||||
interface IntegrationGridProps {
|
||||
integrations: Array<{
|
||||
@ -19,7 +18,6 @@ interface IntegrationGridProps {
|
||||
export function IntegrationGrid({
|
||||
integrations,
|
||||
activeAccountIds,
|
||||
showDetail = false,
|
||||
}: IntegrationGridProps) {
|
||||
const hasActiveAccount = (integrationDefinitionId: string) =>
|
||||
activeAccountIds.has(integrationDefinitionId);
|
||||
@ -38,29 +36,13 @@ export function IntegrationGrid({
|
||||
{integrations.map((integration) => {
|
||||
const isConnected = hasActiveAccount(integration.id);
|
||||
|
||||
if (showDetail) {
|
||||
return (
|
||||
<IntegrationCard
|
||||
key={integration.id}
|
||||
integration={integration}
|
||||
isConnected={isConnected}
|
||||
showDetail={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<IntegrationAuthDialog
|
||||
key={integration.id}
|
||||
<IntegrationCard
|
||||
integration={integration}
|
||||
>
|
||||
<IntegrationCard
|
||||
integration={integration}
|
||||
isConnected={isConnected}
|
||||
/>
|
||||
</IntegrationAuthDialog>
|
||||
isConnected={isConnected}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</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) => {
|
||||
switch (status) {
|
||||
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":
|
||||
return "bg-yellow-100 text-yellow-800";
|
||||
return "bg-yellow-100 text-yellow-800 hover:bg-yellow-100 hover:text-yellow-800";
|
||||
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":
|
||||
return "bg-red-100 text-red-800";
|
||||
return "bg-red-100 text-red-800 hover:bg-red-100 hover:text-red-800";
|
||||
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:
|
||||
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">
|
||||
<div className="mb-2 flex items-start justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
<Badge variant="secondary" className="rounded text-xs">
|
||||
{log.source}
|
||||
</Badge>
|
||||
<div className="flex items-center gap-1">
|
||||
{getStatusIcon(log.status)}
|
||||
<Badge className={cn("text-xs", getStatusColor(log.status))}>
|
||||
<Badge
|
||||
className={cn(
|
||||
"rounded text-xs",
|
||||
getStatusColor(log.status),
|
||||
)}
|
||||
>
|
||||
{log.status.toLowerCase()}
|
||||
</Badge>
|
||||
</div>
|
||||
@ -111,21 +98,11 @@ function LogItemRenderer(
|
||||
</div>
|
||||
|
||||
<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 className="text-muted-foreground flex items-center justify-between text-xs">
|
||||
<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 && (
|
||||
<span>
|
||||
Processed: {new Date(log.processedAt).toLocaleString()}
|
||||
|
||||
@ -52,7 +52,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
<SidebarHeader>
|
||||
<SidebarMenu>
|
||||
<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} />
|
||||
C.O.R.E.
|
||||
</div>
|
||||
|
||||
@ -30,7 +30,7 @@ export const NavMain = ({
|
||||
<Button
|
||||
isActive={location.pathname.includes(item.url)}
|
||||
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) &&
|
||||
"!bg-accent !text-accent-foreground",
|
||||
)}
|
||||
|
||||
@ -8,10 +8,10 @@ const badgeVariants = cva(
|
||||
variants: {
|
||||
variant: {
|
||||
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",
|
||||
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",
|
||||
},
|
||||
},
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
|
||||
import { CheckIcon } from '@radix-ui/react-icons';
|
||||
import React from 'react';
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
|
||||
import { CheckIcon } from "@radix-ui/react-icons";
|
||||
import React from "react";
|
||||
|
||||
import { cn } from '../../lib/utils';
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const Checkbox = React.forwardRef<
|
||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||
@ -11,13 +11,13 @@ const Checkbox = React.forwardRef<
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
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,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<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" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
|
||||
@ -1,8 +1,7 @@
|
||||
import { useLocation, useNavigate } from "@remix-run/react";
|
||||
import { Button } from "./button";
|
||||
import { Plus } from "lucide-react";
|
||||
import { ArrowLeft, ArrowRight, Plus } from "lucide-react";
|
||||
import { SidebarTrigger } from "./sidebar";
|
||||
import React from "react";
|
||||
|
||||
const PAGE_TITLES: Record<string, string> = {
|
||||
"/home/dashboard": "Memory graph",
|
||||
@ -50,6 +49,36 @@ function getLogsTab(pathname: string): "all" | "activity" {
|
||||
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() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
@ -72,8 +101,10 @@ export function SiteHeader() {
|
||||
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)">
|
||||
<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">
|
||||
<SidebarTrigger className="-ml-1" />
|
||||
<div className="-ml-1 flex items-center gap-1">
|
||||
{/* Back/Forward navigation before SidebarTrigger */}
|
||||
<NavigationBackForward />
|
||||
<SidebarTrigger className="mr-1" />
|
||||
|
||||
<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 { z } from "zod";
|
||||
import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
|
||||
import { createIntegrationAccount } from "~/services/integrationAccount.server";
|
||||
import { createHybridActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
|
||||
import { IntegrationEventType } from "@core/types";
|
||||
import { runIntegrationTrigger } from "~/services/integration.server";
|
||||
import { getIntegrationDefinitionWithId } from "~/services/integrationDefinition.server";
|
||||
import { logger } from "~/services/logger.service";
|
||||
import { getWorkspaceByUser } from "~/models/workspace.server";
|
||||
|
||||
// Schema for creating an integration account with API key
|
||||
const IntegrationAccountBodySchema = z.object({
|
||||
@ -14,30 +14,30 @@ const IntegrationAccountBodySchema = z.object({
|
||||
});
|
||||
|
||||
// Route for creating an integration account directly with an API key
|
||||
const { action, loader } = createActionApiRoute(
|
||||
const { action, loader } = createHybridActionApiRoute(
|
||||
{
|
||||
body: IntegrationAccountBodySchema,
|
||||
allowJWT: true,
|
||||
authorization: {
|
||||
action: "create",
|
||||
subject: "IntegrationAccount",
|
||||
action: "integrationaccount:create",
|
||||
},
|
||||
corsStrategy: "all",
|
||||
},
|
||||
async ({ body, authentication }) => {
|
||||
const { integrationDefinitionId, apiKey } = body;
|
||||
const { userId } = authentication;
|
||||
const workspace = await getWorkspaceByUser(authentication.userId);
|
||||
|
||||
try {
|
||||
// Get the integration definition
|
||||
const integrationDefinition = await getIntegrationDefinitionWithId(
|
||||
integrationDefinitionId
|
||||
integrationDefinitionId,
|
||||
);
|
||||
|
||||
if (!integrationDefinition) {
|
||||
return json(
|
||||
{ error: "Integration definition not found" },
|
||||
{ status: 404 }
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
@ -50,26 +50,18 @@ const { action, loader } = createActionApiRoute(
|
||||
apiKey,
|
||||
},
|
||||
},
|
||||
userId
|
||||
userId,
|
||||
workspace?.id,
|
||||
);
|
||||
|
||||
if (!setupResult || !setupResult.accountId) {
|
||||
return json(
|
||||
{ error: "Failed to setup integration with the provided API key" },
|
||||
{ status: 400 }
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Create the integration account
|
||||
const integrationAccount = await createIntegrationAccount({
|
||||
accountId: setupResult.accountId,
|
||||
integrationDefinitionId,
|
||||
userId,
|
||||
config: setupResult.config || {},
|
||||
settings: setupResult.settings || {},
|
||||
});
|
||||
|
||||
return json({ success: true, integrationAccount });
|
||||
return json({ success: true, setupResult });
|
||||
} catch (error) {
|
||||
logger.error("Error creating integration account", {
|
||||
error,
|
||||
@ -78,10 +70,10 @@ const { action, loader } = createActionApiRoute(
|
||||
});
|
||||
return json(
|
||||
{ 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 =
|
||||
log.activity?.integrationAccount?.integrationDefinition;
|
||||
const logData = log.data as any;
|
||||
|
||||
return {
|
||||
id: log.id,
|
||||
source: integrationDef?.name || logData?.source || "Unknown",
|
||||
|
||||
@ -81,21 +81,25 @@ const { action, loader } = createActionApiRoute(
|
||||
const integrationConfig =
|
||||
integrationAccount?.integrationConfiguration as any;
|
||||
|
||||
if (!integrationAccount || !integrationConfig) {
|
||||
if (
|
||||
!integrationAccount ||
|
||||
!integrationConfig ||
|
||||
!integrationConfig.mcp
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
serverUrl,
|
||||
tokens: {
|
||||
access_token: integrationConfig.access_token,
|
||||
token_type: integrationConfig.token_type || "bearer",
|
||||
expires_in: integrationConfig.expires_in || 3600,
|
||||
refresh_token: integrationConfig.refresh_token,
|
||||
scope: integrationConfig.scope || "read write",
|
||||
access_token: integrationConfig.mcp.tokens.access_token,
|
||||
token_type: integrationConfig.mcp.tokens.token_type || "bearer",
|
||||
expires_in: integrationConfig.mcp.tokens.expires_in || 3600,
|
||||
refresh_token: integrationConfig.mcp.tokens.refresh_token,
|
||||
scope: integrationConfig.mcp.tokens.scope || "read write",
|
||||
},
|
||||
expiresAt: integrationConfig.expiresAt
|
||||
? new Date(integrationConfig.expiresAt)
|
||||
expiresAt: integrationConfig.mcp.tokens.expiresAt
|
||||
? new Date(integrationConfig.mcp.tokens.expiresAt)
|
||||
: new Date(Date.now() + 3600 * 1000),
|
||||
};
|
||||
},
|
||||
|
||||
@ -6,6 +6,10 @@ import { createMCPAuthClient } from "@core/mcp-proxy";
|
||||
import { logger } from "~/services/logger.service";
|
||||
import { env } from "~/env.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 MCP_CALLBACK_URL = `${CALLBACK_URL}/mcp`;
|
||||
@ -26,8 +30,13 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
||||
});
|
||||
}
|
||||
|
||||
const { integrationDefinitionId, redirectURL } =
|
||||
await getIntegrationDefinitionForState(state);
|
||||
const {
|
||||
integrationDefinitionId,
|
||||
redirectURL,
|
||||
userId,
|
||||
workspaceId,
|
||||
integrationAccountId,
|
||||
} = await getIntegrationDefinitionForState(state);
|
||||
|
||||
try {
|
||||
// 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,
|
||||
);
|
||||
|
||||
const integrationAccount =
|
||||
await getIntegrationAccountForId(integrationAccountId);
|
||||
|
||||
if (!integrationDefinition) {
|
||||
throw new Error("Integration definition not found");
|
||||
}
|
||||
@ -71,11 +83,13 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
||||
state,
|
||||
redirect_uri: MCP_CALLBACK_URL,
|
||||
},
|
||||
integrationDefinition,
|
||||
mcp: true,
|
||||
},
|
||||
},
|
||||
// We need to get userId from somewhere - for now using undefined
|
||||
undefined,
|
||||
userId,
|
||||
workspaceId,
|
||||
integrationAccount ?? undefined,
|
||||
);
|
||||
|
||||
return new Response(null, {
|
||||
|
||||
@ -14,7 +14,7 @@ const { loader } = createActionApiRoute(
|
||||
for (const [key, value] of url.searchParams.entries()) {
|
||||
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 { useLoaderData, Link } from "@remix-run/react";
|
||||
import { useLoaderData, useFetcher } from "@remix-run/react";
|
||||
import { requireUserId, requireWorkpace } from "~/services/session.server";
|
||||
import { getIntegrationDefinitions } from "~/services/integrationDefinition.server";
|
||||
import { getIntegrationAccounts } from "~/services/integrationAccount.server";
|
||||
import { IntegrationAuthDialog } from "~/components/integrations/IntegrationAuthDialog";
|
||||
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 { 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) {
|
||||
const userId = await requireUserId(request);
|
||||
@ -21,7 +29,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
|
||||
]);
|
||||
|
||||
const integration = integrationDefinitions.find(
|
||||
(def) => def.slug === slug || def.id === slug
|
||||
(def) => def.slug === slug || def.id === slug,
|
||||
);
|
||||
|
||||
if (!integration) {
|
||||
@ -49,83 +57,119 @@ function parseSpec(spec: any) {
|
||||
|
||||
export default function IntegrationDetail() {
|
||||
const { integration, integrationAccounts } = useLoaderData<typeof loader>();
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
const [showApiKeyForm, setShowApiKeyForm] = useState(false);
|
||||
|
||||
const apiKeyFetcher = useFetcher();
|
||||
const oauthFetcher = useFetcher<{ redirectURL: string }>();
|
||||
|
||||
const activeAccount = useMemo(
|
||||
() =>
|
||||
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 hasOAuth2 = !!specData?.auth?.OAuth2;
|
||||
const hasMCPAuth = !!specData?.mcpAuth;
|
||||
const Component = getIcon(integration.icon as IconType);
|
||||
|
||||
const handleApiKeyConnect = useCallback(() => {
|
||||
if (!apiKey.trim()) return;
|
||||
|
||||
setIsLoading(true);
|
||||
apiKeyFetcher.submit(
|
||||
{
|
||||
integrationDefinitionId: integration.id,
|
||||
apiKey,
|
||||
},
|
||||
{
|
||||
method: "post",
|
||||
action: "/api/v1/integration_account",
|
||||
encType: "application/json",
|
||||
},
|
||||
);
|
||||
}, [integration.id, apiKey, apiKeyFetcher]);
|
||||
|
||||
const handleOAuthConnect = useCallback(() => {
|
||||
setIsConnecting(true);
|
||||
oauthFetcher.submit(
|
||||
{
|
||||
integrationDefinitionId: integration.id,
|
||||
redirectURL: window.location.href,
|
||||
},
|
||||
{
|
||||
method: "post",
|
||||
action: "/api/v1/oauth",
|
||||
encType: "application/json",
|
||||
},
|
||||
);
|
||||
}, [integration.id, oauthFetcher]);
|
||||
|
||||
// Watch for fetcher completion
|
||||
React.useEffect(() => {
|
||||
if (apiKeyFetcher.state === "idle" && isLoading) {
|
||||
if (apiKeyFetcher.data !== undefined) {
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
}, [apiKeyFetcher.state, apiKeyFetcher.data, isLoading]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (oauthFetcher.state === "idle" && isConnecting) {
|
||||
if (oauthFetcher.data?.redirectURL) {
|
||||
window.location.href = oauthFetcher.data.redirectURL;
|
||||
} else {
|
||||
setIsConnecting(false);
|
||||
}
|
||||
}
|
||||
}, [oauthFetcher.state, oauthFetcher.data, isConnecting]);
|
||||
|
||||
return (
|
||||
<div className="home flex h-full flex-col overflow-y-auto p-4 px-5">
|
||||
{/* 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 */}
|
||||
<div className="mx-auto max-w-2xl space-y-6">
|
||||
<div className="mx-auto w-2xl space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardHeader className="bg-background-2">
|
||||
<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} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="-mt-1 flex-1">
|
||||
<CardTitle className="text-2xl">{integration.name}</CardTitle>
|
||||
<CardDescription className="mt-2 text-base">
|
||||
<CardDescription className="text-base">
|
||||
{integration.description || `Connect to ${integration.name}`}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
{/* 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>
|
||||
|
||||
<CardContent className="bg-background-2 p-4">
|
||||
{/* Authentication Methods */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium">Authentication Methods</h3>
|
||||
<div className="space-y-2">
|
||||
{hasApiKey && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm">✓ API Key authentication</span>
|
||||
<span className="inline-flex items-center gap-2 text-sm">
|
||||
<Checkbox checked /> API Key authentication
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{hasOAuth2 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm">✓ OAuth 2.0 authentication</span>
|
||||
</div>
|
||||
)}
|
||||
{hasMCPAuth && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm">✓ MCP (Model Context Protocol) authentication</span>
|
||||
<span className="inline-flex items-center gap-2 text-sm">
|
||||
<Checkbox checked />
|
||||
OAuth 2.0 authentication
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{!hasApiKey && !hasOAuth2 && !hasMCPAuth && (
|
||||
@ -136,46 +180,110 @@ export default function IntegrationDetail() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Connect Button */}
|
||||
{!activeAccount && (hasApiKey || hasOAuth2 || hasMCPAuth) && (
|
||||
<div className="mt-6 flex justify-center">
|
||||
<IntegrationAuthDialog integration={integration}>
|
||||
<Button size="lg" className="px-8">
|
||||
Connect to {integration.name}
|
||||
</Button>
|
||||
</IntegrationAuthDialog>
|
||||
{/* Connect Section */}
|
||||
{!activeAccount && (hasApiKey || hasOAuth2) && (
|
||||
<div className="mt-6 space-y-4">
|
||||
<h3 className="text-lg font-medium">
|
||||
Connect to {integration.name}
|
||||
</h3>
|
||||
|
||||
{/* API Key Authentication */}
|
||||
{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>
|
||||
)}
|
||||
|
||||
{/* Connected Account Info */}
|
||||
{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>
|
||||
)}
|
||||
<ConnectedAccountSection activeAccount={activeAccount} />
|
||||
|
||||
{/* Integration Spec Details */}
|
||||
{specData && Object.keys(specData).length > 0 && (
|
||||
<div className="mt-6 space-y-4">
|
||||
<h3 className="text-lg font-medium">Integration Details</h3>
|
||||
<div className="rounded-lg border bg-gray-50 p-4">
|
||||
<pre className="text-sm text-gray-700">
|
||||
{JSON.stringify(specData, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* MCP Authentication Section */}
|
||||
<MCPAuthSection
|
||||
integration={integration}
|
||||
activeAccount={activeAccount as any}
|
||||
hasMCPAuth={hasMCPAuth}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,7 +5,7 @@ import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { requireUserId, requireWorkpace } from "~/services/session.server";
|
||||
import { getIntegrationDefinitions } from "~/services/integrationDefinition.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) {
|
||||
const userId = await requireUserId(request);
|
||||
|
||||
@ -2,34 +2,33 @@ import { useState } from "react";
|
||||
import { useLogs } from "~/hooks/use-logs";
|
||||
import { LogsFilters } from "~/components/logs/logs-filters";
|
||||
import { VirtualLogsList } from "~/components/logs/virtual-logs-list";
|
||||
import { AppContainer, PageContainer, PageBody } from "~/components/layout/app-layout";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { AppContainer, PageContainer } from "~/components/layout/app-layout";
|
||||
import { Card, CardContent } from "~/components/ui/card";
|
||||
import { Activity } from "lucide-react";
|
||||
|
||||
export default function LogsActivity() {
|
||||
const [selectedSource, setSelectedSource] = useState<string | undefined>();
|
||||
const [selectedStatus, setSelectedStatus] = useState<string | undefined>();
|
||||
|
||||
const {
|
||||
logs,
|
||||
hasMore,
|
||||
loadMore,
|
||||
availableSources,
|
||||
isLoading,
|
||||
isInitialLoad
|
||||
} = useLogs({
|
||||
endpoint: '/api/v1/logs/activity',
|
||||
source: selectedSource,
|
||||
status: selectedStatus
|
||||
|
||||
const {
|
||||
logs,
|
||||
hasMore,
|
||||
loadMore,
|
||||
availableSources,
|
||||
isLoading,
|
||||
isInitialLoad,
|
||||
} = useLogs({
|
||||
endpoint: "/api/v1/logs/activity",
|
||||
source: selectedSource,
|
||||
status: selectedStatus,
|
||||
});
|
||||
|
||||
if (isInitialLoad) {
|
||||
return (
|
||||
<AppContainer>
|
||||
<PageContainer>
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<div className="border-primary h-8 w-8 animate-spin rounded-full border-b-2"></div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</AppContainer>
|
||||
@ -37,80 +36,43 @@ export default function LogsActivity() {
|
||||
}
|
||||
|
||||
return (
|
||||
<AppContainer>
|
||||
<PageContainer>
|
||||
<PageBody>
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<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>
|
||||
<div className="space-y-6 p-4 px-5">
|
||||
<LogsFilters
|
||||
availableSources={availableSources}
|
||||
selectedSource={selectedSource}
|
||||
selectedStatus={selectedStatus}
|
||||
onSourceChange={setSelectedSource}
|
||||
onStatusChange={setSelectedStatus}
|
||||
/>
|
||||
|
||||
{/* Filters */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Filters</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<LogsFilters
|
||||
availableSources={availableSources}
|
||||
selectedSource={selectedSource}
|
||||
selectedStatus={selectedStatus}
|
||||
onSourceChange={setSelectedSource}
|
||||
onStatusChange={setSelectedStatus}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
{/* Logs List */}
|
||||
<div className="space-y-4">
|
||||
{logs.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="bg-background-2 flex items-center justify-center py-16">
|
||||
<div className="text-center">
|
||||
<Activity className="text-muted-foreground mx-auto mb-4 h-12 w-12" />
|
||||
<h3 className="mb-2 text-lg font-semibold">
|
||||
No activity logs found
|
||||
</h3>
|
||||
<p className="text-muted-foreground">
|
||||
{selectedSource || selectedStatus
|
||||
? "Try adjusting your filters to see more results."
|
||||
: "No activity ingestion logs are available yet."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{logs.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="flex items-center justify-center py-16">
|
||||
<div className="text-center">
|
||||
<Activity className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold mb-2">No activity logs found</h3>
|
||||
<p className="text-muted-foreground">
|
||||
{selectedSource || selectedStatus
|
||||
? 'Try adjusting your filters to see more results.'
|
||||
: 'No activity ingestion logs are available yet.'}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<VirtualLogsList
|
||||
logs={logs}
|
||||
hasMore={hasMore}
|
||||
loadMore={loadMore}
|
||||
isLoading={isLoading}
|
||||
height={600}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PageBody>
|
||||
</PageContainer>
|
||||
</AppContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<VirtualLogsList
|
||||
logs={logs}
|
||||
hasMore={hasMore}
|
||||
loadMore={loadMore}
|
||||
isLoading={isLoading}
|
||||
height={600}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,39 +1,12 @@
|
||||
import { tasks } from "@trigger.dev/sdk/v3";
|
||||
|
||||
import { getOrCreatePersonalAccessToken } from "./personalAccessToken.server";
|
||||
import { logger } from "./logger.service";
|
||||
import { type integrationRun } from "~/trigger/integrations/integration-run";
|
||||
|
||||
import type { IntegrationDefinitionV2 } from "@core/database";
|
||||
|
||||
/**
|
||||
* Prepares the parameters for triggering an integration.
|
||||
* 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,
|
||||
};
|
||||
}
|
||||
import type {
|
||||
IntegrationAccount,
|
||||
IntegrationDefinitionV2,
|
||||
} from "@core/database";
|
||||
|
||||
/**
|
||||
* Triggers an integration run asynchronously.
|
||||
@ -43,11 +16,24 @@ export async function runIntegrationTriggerAsync(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
event: any,
|
||||
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", {
|
||||
...params,
|
||||
event,
|
||||
integrationDefinition,
|
||||
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
|
||||
event: any,
|
||||
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>(
|
||||
"integration-run",
|
||||
{
|
||||
...params,
|
||||
integrationAccount: event.integrationAccount,
|
||||
integrationDefinition,
|
||||
integrationAccount,
|
||||
workspaceId,
|
||||
userId,
|
||||
event: event.event,
|
||||
eventBody: event.eventBody,
|
||||
},
|
||||
|
||||
@ -13,27 +13,10 @@ export const getIntegrationAccount = async (
|
||||
});
|
||||
};
|
||||
|
||||
export const createIntegrationAccount = async ({
|
||||
integrationDefinitionId,
|
||||
userId,
|
||||
accountId,
|
||||
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,
|
||||
export const getIntegrationAccountForId = async (id: string) => {
|
||||
return await prisma.integrationAccount.findUnique({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -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 { IsBoolean, IsString } from "class-validator";
|
||||
import { IsBoolean, IsOptional, IsString } from "class-validator";
|
||||
import type { IntegrationDefinitionV2 } from "@core/database";
|
||||
import { z } from "zod";
|
||||
|
||||
@ -25,12 +25,17 @@ export class OAuthBodyInterface {
|
||||
|
||||
@IsString()
|
||||
integrationDefinitionId: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
integrationAccountId?: string;
|
||||
}
|
||||
|
||||
export const OAuthBodySchema = z.object({
|
||||
redirectURL: z.string(),
|
||||
integrationDefinitionId: z.string(),
|
||||
mcp: z.boolean().optional().default(false),
|
||||
integrationAccountId: z.string().optional(),
|
||||
});
|
||||
|
||||
export type CallbackParams = Record<string, string>;
|
||||
|
||||
@ -24,17 +24,20 @@ const MCP_CALLBACK_URL = `${CALLBACK_URL}/mcp`;
|
||||
const session: Record<string, SessionRecord> = {};
|
||||
const mcpSession: Record<
|
||||
string,
|
||||
{ integrationDefinitionId: string; redirectURL: string }
|
||||
{
|
||||
integrationDefinitionId: string;
|
||||
redirectURL: string;
|
||||
workspaceId: string;
|
||||
userId: string;
|
||||
integrationAccountId: string;
|
||||
}
|
||||
> = {};
|
||||
|
||||
export type CallbackParams = Record<string, string>;
|
||||
|
||||
// Remix-style callback handler
|
||||
// Accepts a Remix LoaderFunctionArgs-like object: { request }
|
||||
export async function callbackHandler(
|
||||
params: CallbackParams,
|
||||
request: Request,
|
||||
) {
|
||||
export async function callbackHandler(params: CallbackParams) {
|
||||
if (!params.state) {
|
||||
throw new Error("No state found");
|
||||
}
|
||||
@ -134,14 +137,14 @@ export async function callbackHandler(
|
||||
...params,
|
||||
redirect_uri: CALLBACK_URL,
|
||||
},
|
||||
integrationDefinition,
|
||||
},
|
||||
},
|
||||
sessionRecord.userId,
|
||||
sessionRecord.workspaceId,
|
||||
);
|
||||
|
||||
await tasks.trigger<typeof scheduler>("scheduler", {
|
||||
integrationAccountId: integrationAccount.id,
|
||||
integrationAccountId: integrationAccount?.id,
|
||||
});
|
||||
|
||||
return new Response(null, {
|
||||
@ -253,7 +256,7 @@ export async function getRedirectURLForMCP(
|
||||
userId: string,
|
||||
workspaceId?: string,
|
||||
) {
|
||||
const { integrationDefinitionId } = oAuthBody;
|
||||
const { integrationDefinitionId, integrationAccountId } = oAuthBody;
|
||||
|
||||
logger.info(
|
||||
`We got OAuth request for ${workspaceId}: ${userId}: ${integrationDefinitionId}`,
|
||||
@ -265,6 +268,10 @@ export async function getRedirectURLForMCP(
|
||||
integrationDefinitionId,
|
||||
);
|
||||
|
||||
if (!integrationAccountId) {
|
||||
throw new Error("No integration account found");
|
||||
}
|
||||
|
||||
if (!integrationDefinition) {
|
||||
throw new Error("No integration definition found");
|
||||
}
|
||||
@ -290,6 +297,9 @@ export async function getRedirectURLForMCP(
|
||||
mcpSession[state] = {
|
||||
integrationDefinitionId: integrationDefinition.id,
|
||||
redirectURL,
|
||||
userId,
|
||||
workspaceId: workspaceId as string,
|
||||
integrationAccountId,
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@ -42,7 +42,7 @@ export class WebhookService {
|
||||
|
||||
if (integrationDefinition) {
|
||||
try {
|
||||
const accountIdResponse = await runIntegrationTrigger(
|
||||
const identifyResponse = await runIntegrationTrigger(
|
||||
integrationDefinition,
|
||||
{
|
||||
event: IntegrationEventType.IDENTIFY,
|
||||
@ -55,12 +55,39 @@ export class WebhookService {
|
||||
|
||||
let accountId: string | undefined;
|
||||
|
||||
if (
|
||||
accountIdResponse?.message?.startsWith("The event payload type is")
|
||||
) {
|
||||
accountId = undefined;
|
||||
// Handle new CLI message format response
|
||||
if (identifyResponse?.success && identifyResponse?.result) {
|
||||
// Check if there are identifiers in the response
|
||||
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 {
|
||||
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) {
|
||||
@ -68,6 +95,17 @@ export class WebhookService {
|
||||
where: { accountId },
|
||||
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) {
|
||||
logger.error("Failed to identify integration account", {
|
||||
@ -85,22 +123,38 @@ export class WebhookService {
|
||||
|
||||
if (integrationAccount) {
|
||||
try {
|
||||
await runIntegrationTrigger(
|
||||
logger.info(`Processing webhook for ${sourceName}`, {
|
||||
integrationAccountId: integrationAccount.id,
|
||||
integrationSlug: integrationAccount.integrationDefinition.slug,
|
||||
});
|
||||
|
||||
const processResponse = await runIntegrationTrigger(
|
||||
integrationAccount.integrationDefinition,
|
||||
{
|
||||
event: IntegrationEventType.PROCESS,
|
||||
integrationAccount,
|
||||
eventBody: {
|
||||
eventHeaders,
|
||||
eventData: { ...eventBody },
|
||||
},
|
||||
},
|
||||
integrationAccount.integratedById,
|
||||
integrationAccount.workspaceId,
|
||||
integrationAccount,
|
||||
);
|
||||
|
||||
logger.log(`Successfully processed webhook for ${sourceName}`, {
|
||||
integrationAccountId: integrationAccount.id,
|
||||
});
|
||||
if (processResponse?.success) {
|
||||
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) {
|
||||
logger.error(`Failed to process webhook for ${sourceName}`, {
|
||||
error,
|
||||
|
||||
@ -3,8 +3,6 @@ import { IntegrationEventType } from "@core/types";
|
||||
import { logger, schedules, tasks } from "@trigger.dev/sdk/v3";
|
||||
|
||||
import { type integrationRun } from "./integration-run";
|
||||
import { getOrCreatePersonalAccessToken } from "../utils/utils";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
@ -27,7 +25,7 @@ export const integrationRunSchedule = schedules.task({
|
||||
|
||||
if (!integrationAccount) {
|
||||
const deletedSchedule = await schedules.del(externalId);
|
||||
logger.info("No integration account found");
|
||||
logger.info("No integration account found, deleting schedule");
|
||||
return deletedSchedule;
|
||||
}
|
||||
|
||||
@ -36,22 +34,20 @@ export const integrationRunSchedule = schedules.task({
|
||||
return null;
|
||||
}
|
||||
|
||||
const pat = await getOrCreatePersonalAccessToken({
|
||||
name: `integration_scheduled_${nanoid(10)}`,
|
||||
userId: integrationAccount.workspace.userId as string,
|
||||
logger.info("Triggering scheduled integration run", {
|
||||
integrationId: integrationAccount.integrationDefinition.id,
|
||||
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", {
|
||||
event: IntegrationEventType.SYNC,
|
||||
pat: pat.token,
|
||||
patId: pat.id,
|
||||
integrationAccount,
|
||||
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 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 {
|
||||
type IntegrationDefinitionV2,
|
||||
type IntegrationAccount,
|
||||
} from "@core/database";
|
||||
import { deletePersonalAccessToken } from "../utils/utils";
|
||||
import { type IntegrationEventType } from "@core/types";
|
||||
import { IntegrationEventType, type Message } 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
|
||||
const response = await axios.get(url);
|
||||
|
||||
return response.data;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const loadRemoteModule = async (requires: any) =>
|
||||
createLoadRemoteModule({ fetcher, requires });
|
||||
|
||||
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;
|
||||
/**
|
||||
* Determines if a string is a URL.
|
||||
*/
|
||||
function isUrl(str: string): boolean {
|
||||
try {
|
||||
// Accepts http, https, file, etc.
|
||||
const url = new URL(str);
|
||||
return url.protocol === "http:" || url.protocol === "https:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 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({
|
||||
id: "integration-run",
|
||||
run: async ({
|
||||
pat,
|
||||
patId,
|
||||
eventBody,
|
||||
integrationAccount,
|
||||
integrationDefinition,
|
||||
event,
|
||||
workspaceId,
|
||||
userId,
|
||||
}: {
|
||||
pat: string;
|
||||
patId: string;
|
||||
// This is the event you want to pass to the integration
|
||||
event: IntegrationEventType;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
eventBody?: any;
|
||||
integrationDefinition: IntegrationDefinitionV2;
|
||||
integrationAccount?: IntegrationAccount;
|
||||
workspaceId?: string;
|
||||
userId?: string;
|
||||
}) => {
|
||||
const remoteModuleLoad = await loadRemoteModule(
|
||||
getRequires(createAxiosInstance(pat)),
|
||||
);
|
||||
try {
|
||||
logger.info(
|
||||
`Starting integration run for ${integrationDefinition.slug}`,
|
||||
{
|
||||
event,
|
||||
integrationId: integrationDefinition.id,
|
||||
},
|
||||
);
|
||||
|
||||
logger.info(
|
||||
`${integrationDefinition.url}/${integrationDefinition.version}/index.cjs`,
|
||||
);
|
||||
// Load the integration file from a URL or a local path
|
||||
const integrationSource = integrationDefinition.url as string;
|
||||
const integrationFile = await loadIntegrationSource(integrationSource);
|
||||
logger.info(`Loaded integration file from ${integrationSource}`);
|
||||
|
||||
const integrationFunction = await remoteModuleLoad(
|
||||
`${integrationDefinition.url}/${integrationDefinition.version}/index.cjs`,
|
||||
);
|
||||
// Prepare enhanced event body based on event type
|
||||
let enhancedEventBody = eventBody;
|
||||
|
||||
// const integrationFunction = await remoteModuleLoad(
|
||||
// `${integrationDefinition.url}`,
|
||||
// );
|
||||
// For SETUP events, include OAuth response and parameters
|
||||
if (event === IntegrationEventType.SETUP) {
|
||||
enhancedEventBody = {
|
||||
...eventBody,
|
||||
};
|
||||
}
|
||||
|
||||
// Construct the proper IntegrationEventPayload structure
|
||||
const integrationEventPayload = {
|
||||
event,
|
||||
eventBody: { ...eventBody, integrationDefinition },
|
||||
config: integrationAccount?.integrationConfiguration || {},
|
||||
};
|
||||
// For PROCESS events, ensure eventData is properly structured
|
||||
if (event === IntegrationEventType.PROCESS) {
|
||||
enhancedEventBody = {
|
||||
eventData: eventBody,
|
||||
};
|
||||
}
|
||||
|
||||
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": {
|
||||
"@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",
|
||||
"eslint": "^9.24.0",
|
||||
"eslint-config-prettier": "^10.1.2",
|
||||
@ -37,10 +33,6 @@
|
||||
"eslint-plugin-unused-imports": "^2.0.0",
|
||||
"prettier": "^3.4.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",
|
||||
"typescript": "^4.7.2",
|
||||
"tsup": "^8.0.1",
|
||||
@ -66,6 +58,6 @@
|
||||
"commander": "^12.0.0",
|
||||
"openai": "^4.0.0",
|
||||
"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 { integrationCreate } from './account-create';
|
||||
import { handleSchedule } from './schedule';
|
||||
import { integrationCreate, integrationCreateForMCP } from './account-create';
|
||||
|
||||
import {
|
||||
IntegrationCLI,
|
||||
@ -11,7 +11,9 @@ import {
|
||||
export async function run(eventPayload: IntegrationEventPayload) {
|
||||
switch (eventPayload.event) {
|
||||
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:
|
||||
return await handleSchedule(eventPayload.config);
|
||||
@ -1,6 +1,5 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import axios from 'axios';
|
||||
import { IntegrationAccount } from '@redplanethq/sol-sdk';
|
||||
|
||||
interface LinearActivityCreateParams {
|
||||
url: string;
|
||||
@ -257,7 +256,7 @@ async function fetchRecentComments(accessToken: string, lastSyncTime: string) {
|
||||
async function processIssueActivities(
|
||||
issues: any[],
|
||||
userId: string,
|
||||
integrationAccount: IntegrationAccount,
|
||||
integrationAccount: any,
|
||||
isCreator: boolean = false,
|
||||
) {
|
||||
const activities = [];
|
||||
@ -426,11 +425,7 @@ async function processIssueActivities(
|
||||
/**
|
||||
* Process comment activities and create appropriate activity records
|
||||
*/
|
||||
async function processCommentActivities(
|
||||
comments: any[],
|
||||
userId: string,
|
||||
integrationAccount: IntegrationAccount,
|
||||
) {
|
||||
async function processCommentActivities(comments: any[], userId: string, integrationAccount: any) {
|
||||
const activities = [];
|
||||
|
||||
for (const comment of comments) {
|
||||
@ -513,7 +508,7 @@ function getDefaultSyncTime(): string {
|
||||
/**
|
||||
* Main function to handle scheduled sync for Linear integration
|
||||
*/
|
||||
export async function handleSchedule(integrationAccount: IntegrationAccount) {
|
||||
export async function handleSchedule(integrationAccount: any) {
|
||||
try {
|
||||
const integrationConfiguration = integrationAccount.integrationConfiguration as any;
|
||||
|
||||
@ -594,6 +589,6 @@ export async function handleSchedule(integrationAccount: IntegrationAccount) {
|
||||
/**
|
||||
* The main handler for the scheduled sync event
|
||||
*/
|
||||
export async function scheduleHandler(integrationAccount: IntegrationAccount) {
|
||||
export async function scheduleHandler(integrationAccount: any) {
|
||||
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": {
|
||||
"target": "es2022",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"baseUrl": "backend",
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "ES2022",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"baseUrl": "frontend",
|
||||
"allowJs": false,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"strictNullChecks": true,
|
||||
"removeComments": true,
|
||||
@ -25,7 +27,7 @@
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"useUnknownInCatchVariables": false
|
||||
},
|
||||
"include": ["backend/**/*"],
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "build", "dist", "scripts", "acceptance-tests", "webpack", "jest"],
|
||||
"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": {
|
||||
"@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",
|
||||
"eslint": "^9.24.0",
|
||||
"eslint-config-prettier": "^10.1.2",
|
||||
@ -37,10 +33,6 @@
|
||||
"eslint-plugin-unused-imports": "^2.0.0",
|
||||
"prettier": "^3.4.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",
|
||||
"typescript": "^4.7.2",
|
||||
"tsup": "^8.0.1",
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
@ -9,16 +8,12 @@
|
||||
"resolveJsonModule": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"baseUrl": "frontend",
|
||||
"allowJs": false,
|
||||
|
||||
"allowSyntheticDefaultImports": true,
|
||||
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
|
||||
"isolatedModules": true,
|
||||
"strictNullChecks": true,
|
||||
"removeComments": true,
|
||||
|
||||
@ -180,6 +180,7 @@ export class NodeOAuthClientProvider implements OAuthClientProvider {
|
||||
authorizationUrl.searchParams.set("resource", this.authorizeResource);
|
||||
}
|
||||
|
||||
// Keep it
|
||||
console.log(this.authorizationUrl);
|
||||
|
||||
// Store the URL instead of opening browser
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@redplanethq/sdk",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"description": "CORE Node.JS SDK",
|
||||
"main": "./dist/index.js",
|
||||
"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) {
|
||||
console.log(JSON.stringify(message, null, 2));
|
||||
console.log(JSON.stringify(message));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error during setup:', error);
|
||||
@ -83,7 +83,7 @@ export abstract class IntegrationCLI {
|
||||
});
|
||||
|
||||
for (const message of messages) {
|
||||
console.log(JSON.stringify(message, null, 2));
|
||||
console.log(JSON.stringify(message));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error processing data:', error);
|
||||
@ -105,7 +105,7 @@ export abstract class IntegrationCLI {
|
||||
});
|
||||
|
||||
for (const message of messages) {
|
||||
console.log(JSON.stringify(message, null, 2));
|
||||
console.log(JSON.stringify(message));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error identifying account:', error);
|
||||
@ -126,7 +126,7 @@ export abstract class IntegrationCLI {
|
||||
data: spec,
|
||||
};
|
||||
// For spec, we keep the single message output for compatibility
|
||||
console.log(JSON.stringify(message, null, 2));
|
||||
console.log(JSON.stringify(message));
|
||||
} catch (error) {
|
||||
console.error('Error getting spec:', error);
|
||||
process.exit(1);
|
||||
@ -153,7 +153,7 @@ export abstract class IntegrationCLI {
|
||||
});
|
||||
|
||||
for (const message of messages) {
|
||||
console.log(JSON.stringify(message, null, 2));
|
||||
console.log(JSON.stringify(message));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error during sync:', error);
|
||||
@ -56,7 +56,7 @@ export interface Identifier {
|
||||
type?: string;
|
||||
}
|
||||
|
||||
export type MessageType = "spec" | "activity" | "state" | "identifier";
|
||||
export type MessageType = "spec" | "activity" | "state" | "identifier" | "account";
|
||||
|
||||
export interface Message {
|
||||
type: MessageType;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user