diff --git a/apps/client/src/App.tsx b/apps/client/src/App.tsx index a496bb1fb..8d77f634a 100644 --- a/apps/client/src/App.tsx +++ b/apps/client/src/App.tsx @@ -38,6 +38,7 @@ import UserApiKeys from "@/ee/api-key/pages/user-api-keys"; import WorkspaceApiKeys from "@/ee/api-key/pages/workspace-api-keys"; import AiSettings from "@/ee/ai/pages/ai-settings.tsx"; import Integrations from "@/features/integration/pages/integrations.tsx"; +import Connections from "@/features/integration/pages/connections.tsx"; export default function App() { const { t } = useTranslation(); @@ -94,6 +95,7 @@ export default function App() { element={} /> } /> + } /> } /> } /> } /> diff --git a/apps/client/src/components/icons/index.ts b/apps/client/src/components/icons/index.ts index 609cd058a..29e20de46 100644 --- a/apps/client/src/components/icons/index.ts +++ b/apps/client/src/components/icons/index.ts @@ -10,6 +10,7 @@ export { LinearIcon } from "./linear-icon.tsx"; export { TypeformIcon } from "./typeform-icon.tsx"; export { VimeoIcon } from "./vimeo-icon.tsx"; export { MiroIcon } from "./miro-icon.tsx"; +export { SlackIcon } from "./slack-icon.tsx"; export { FramerIcon } from "./framer-icon.tsx"; export { LoomIcon } from "./loom-icon.tsx"; export { YoutubeIcon } from "./youtube-icon.tsx"; diff --git a/apps/client/src/components/icons/slack-icon.tsx b/apps/client/src/components/icons/slack-icon.tsx new file mode 100644 index 000000000..d26e9f2c5 --- /dev/null +++ b/apps/client/src/components/icons/slack-icon.tsx @@ -0,0 +1,32 @@ +import { rem } from '@mantine/core'; + +interface Props { + size?: number | string; +} + +export function SlackIcon({ size }: Props) { + return ( + + + + + + + ); +} diff --git a/apps/client/src/components/settings/settings-sidebar.tsx b/apps/client/src/components/settings/settings-sidebar.tsx index 936d79878..500fa62a9 100644 --- a/apps/client/src/components/settings/settings-sidebar.tsx +++ b/apps/client/src/components/settings/settings-sidebar.tsx @@ -72,6 +72,11 @@ const groupedData: DataGroup[] = [ isEnterprise: true, showDisabledInNonEE: true, }, + { + label: "Connections", + icon: IconPlug, + path: "/settings/account/connections", + }, ], }, { diff --git a/apps/client/src/features/editor/components/integration-link/integration-link-view.tsx b/apps/client/src/features/editor/components/integration-link/integration-link-view.tsx index adbe47743..30ad5082d 100644 --- a/apps/client/src/features/editor/components/integration-link/integration-link-view.tsx +++ b/apps/client/src/features/editor/components/integration-link/integration-link-view.tsx @@ -9,15 +9,8 @@ import { Anchor, Stack, } from "@mantine/core"; -import { useEffect, useCallback, memo, type ReactNode } from "react"; -import { - FigmaIcon, - GithubIcon, - GitlabIcon, - GoogleDocsIcon, - JiraIcon, - LinearIcon, -} from "@/components/icons"; +import { useEffect, useCallback, memo } from "react"; +import { getIntegrationIcon } from "@/features/integration/components/integration-icons"; import { unfurlUrl } from "@/features/integration/services/integration-service"; import classes from "./integration-link-view.module.css"; @@ -43,15 +36,6 @@ function toBadgeColor(raw?: string): string { return raw; } -const providerIcons: Record ReactNode> = { - github: (size) => , - gitlab: (size) => , - figma: (size) => , - linear: (size) => , - jira: (size) => , - google_docs: (size) => , -}; - function IntegrationLinkView(props: any) { const { node, updateAttributes, editor } = props; const { url, provider, unfurlData, status } = node.attrs; @@ -108,8 +92,6 @@ function IntegrationLinkView(props: any) { ); } - const renderIcon = providerIcons[provider]; - return ( {unfurlData.authorAvatarUrl ? ( - ) : renderIcon ? ( -
{renderIcon(28)}
- ) : null} + ) : ( +
{getIntegrationIcon(provider, 28)}
+ )} @@ -154,9 +136,9 @@ function IntegrationLinkView(props: any) { )} - {renderIcon && ( + {provider && (
- {renderIcon(18)} + {getIntegrationIcon(provider, 18)}
)} diff --git a/apps/client/src/features/integration/components/connection-row.tsx b/apps/client/src/features/integration/components/connection-row.tsx new file mode 100644 index 000000000..af192498d --- /dev/null +++ b/apps/client/src/features/integration/components/connection-row.tsx @@ -0,0 +1,83 @@ +import { Group, Text, Button, Box } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import { IntegrationDefinition, UserConnection } from "../types/integration.types"; +import { getIntegrationIcon } from "./integration-icons"; + +type ConnectionRowProps = { + definition: IntegrationDefinition; + connection?: UserConnection; + installed: boolean; + onConnect: (integrationId: string) => void; + onDisconnect: (integrationId: string) => void; + isDisconnecting?: boolean; +}; + +export default function ConnectionRow({ + definition, + connection, + installed, + onConnect, + onDisconnect, + isDisconnecting, +}: ConnectionRowProps) { + const { t } = useTranslation(); + const isConnected = !!connection; + const isAvailable = installed && (connection?.isEnabled ?? true); + + return ( + + + + {getIntegrationIcon(definition.type, 28)} +
+ + {definition.name} + + + {definition.description} + +
+
+ + + {isConnected ? ( + <> + + {t("Connected")} + {connection.providerUserId && ` (${connection.providerUserId})`} + + + + ) : isAvailable ? ( + + ) : ( + + {t("Not available")} + + )} + +
+
+ ); +} diff --git a/apps/client/src/features/integration/components/integration-icons.tsx b/apps/client/src/features/integration/components/integration-icons.tsx new file mode 100644 index 000000000..da18e45c7 --- /dev/null +++ b/apps/client/src/features/integration/components/integration-icons.tsx @@ -0,0 +1,30 @@ +import { ReactNode } from "react"; +import { + FigmaIcon, + GithubIcon, + GitlabIcon, + GoogleDocsIcon, + JiraIcon, + LinearIcon, + SlackIcon, +} from "@/components/icons"; +import { IconPuzzle } from "@tabler/icons-react"; + +const integrationIconMap: Record ReactNode> = { + github: (size) => , + gitlab: (size) => , + slack: (size) => , + linear: (size) => , + jira: (size) => , + figma: (size) => , + google_docs: (size) => , +}; + +export function getIntegrationIcon( + type: string, + size: number, +): ReactNode { + const renderIcon = integrationIconMap[type]; + if (renderIcon) return renderIcon(size); + return ; +} diff --git a/apps/client/src/features/integration/components/integration-row.tsx b/apps/client/src/features/integration/components/integration-row.tsx new file mode 100644 index 000000000..ef67a4852 --- /dev/null +++ b/apps/client/src/features/integration/components/integration-row.tsx @@ -0,0 +1,96 @@ +import { Group, Text, Badge, Button, Switch, Box, Stack } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import { + IntegrationDefinition, + Integration, +} from "../types/integration.types"; +import { getIntegrationIcon } from "./integration-icons"; + +type IntegrationRowProps = { + definition: IntegrationDefinition; + installation?: Integration; + onInstall: (type: string) => void; + onUninstall: (integrationId: string) => void; + onConfigure: (integration: Integration) => void; + onToggle: (integration: Integration, enabled: boolean) => void; +}; + +export default function IntegrationRow({ + definition, + installation, + onInstall, + onUninstall, + onConfigure, + onToggle, +}: IntegrationRowProps) { + const { t } = useTranslation(); + const isInstalled = !!installation; + + return ( + + + + {getIntegrationIcon(definition.type, 28)} + + + + {definition.name} + + {definition.capabilities.map((cap) => ( + + {cap} + + ))} + + + {definition.description} + + + + + + {isInstalled ? ( + <> + + onToggle(installation, e.currentTarget.checked) + } + size="sm" + /> + + + + ) : ( + + )} + + + + ); +} diff --git a/apps/client/src/features/integration/components/integration-settings-modal.tsx b/apps/client/src/features/integration/components/integration-settings-modal.tsx index b32908e0d..3c0ef15fb 100644 --- a/apps/client/src/features/integration/components/integration-settings-modal.tsx +++ b/apps/client/src/features/integration/components/integration-settings-modal.tsx @@ -1,12 +1,6 @@ -import { Modal, Button, Group, Stack, TextInput, Text } from "@mantine/core"; -import { notifications } from "@mantine/notifications"; +import { Modal, Stack, Text } from "@mantine/core"; import { useTranslation } from "react-i18next"; -import { Integration, ConnectionStatus } from "../types/integration.types"; -import { - useConnectionStatus, - useDisconnectIntegration, -} from "../queries/integration-query"; -import * as integrationService from "../services/integration-service"; +import { Integration } from "../types/integration.types"; type IntegrationSettingsModalProps = { integration: Integration | null; @@ -20,34 +14,9 @@ export default function IntegrationSettingsModal({ onClose, }: IntegrationSettingsModalProps) { const { t } = useTranslation(); - const { data: connectionStatus } = useConnectionStatus(integration?.id); - const disconnectMutation = useDisconnectIntegration(); if (!integration) return null; - const handleConnect = async () => { - try { - const result = await integrationService.getOAuthAuthorizeUrl({ - integrationId: integration.id, - }); - window.location.href = result.authorizationUrl; - } catch (error) { - const errorMessage = error["response"]?.data?.message; - notifications.show({ - message: errorMessage || t("Failed to start OAuth connection"), - color: "red", - }); - } - }; - - const handleDisconnect = async () => { - await disconnectMutation.mutateAsync({ - integrationId: integration.id, - }); - }; - - const hasOAuth = true; - return ( - {hasOAuth && ( -
- - {t("Connection")} - - {connectionStatus?.connected ? ( - - - {t("Connected")} - {connectionStatus.providerUserId && - ` (${connectionStatus.providerUserId})`} - - - - ) : ( - - )} -
- )} + + {t("Integration settings will appear here.")} +
); diff --git a/apps/client/src/features/integration/pages/connections.tsx b/apps/client/src/features/integration/pages/connections.tsx new file mode 100644 index 000000000..708179197 --- /dev/null +++ b/apps/client/src/features/integration/pages/connections.tsx @@ -0,0 +1,106 @@ +import { Text, Loader, Center, Alert, Stack } from "@mantine/core"; +import { Helmet } from "react-helmet-async"; +import { useTranslation } from "react-i18next"; +import { notifications } from "@mantine/notifications"; +import { getAppName } from "@/lib/config"; +import SettingsTitle from "@/components/settings/settings-title"; +import ConnectionRow from "../components/connection-row"; +import { + useAvailableIntegrations, + useInstalledIntegrations, + useMyConnections, + useDisconnectIntegration, +} from "../queries/integration-query"; +import * as integrationService from "../services/integration-service"; + +export default function Connections() { + const { t } = useTranslation(); + const { data: available, isLoading: loadingAvailable } = + useAvailableIntegrations(); + const { data: installed, isLoading: loadingInstalled } = + useInstalledIntegrations(); + const { data: myConnections, isLoading: loadingConnections } = + useMyConnections(); + const disconnectMutation = useDisconnectIntegration(); + + const isLoading = loadingAvailable || loadingInstalled || loadingConnections; + + const handleConnect = async (type: string) => { + const integration = installed?.find((i) => i.type === type); + if (!integration) return; + + try { + const result = await integrationService.getOAuthAuthorizeUrl({ + integrationId: integration.id, + }); + window.location.href = result.authorizationUrl; + } catch (error) { + const errorMessage = error["response"]?.data?.message; + notifications.show({ + message: errorMessage || t("Failed to start OAuth connection"), + color: "red", + }); + } + }; + + const handleDisconnect = (integrationId: string) => { + disconnectMutation.mutate({ integrationId }); + }; + + const error = new URLSearchParams(window.location.search).get("error"); + + return ( + <> + + + {t("Connections")} - {getAppName()} + + + + + + + {t("Manage the apps you have connected to your account.")} + + + {error === "oauth_failed" && ( + + {t("OAuth connection failed. Please try again.")} + + )} + + {isLoading ? ( +
+ +
+ ) : !available?.length ? ( + + {t("No integrations available.")} + + ) : ( + + {available + .filter((def) => def.capabilities.includes("oauth")) + .map((def) => { + const installation = installed?.find((i) => i.type === def.type); + const connection = myConnections?.find( + (c) => c.type === def.type, + ); + + return ( + + ); + })} + + )} + + ); +} diff --git a/apps/client/src/features/integration/pages/integrations.tsx b/apps/client/src/features/integration/pages/integrations.tsx index eb4f14b9a..85cbfe637 100644 --- a/apps/client/src/features/integration/pages/integrations.tsx +++ b/apps/client/src/features/integration/pages/integrations.tsx @@ -1,10 +1,10 @@ -import { SimpleGrid, Text, Loader, Center, Alert } from "@mantine/core"; +import { Text, Loader, Center, Alert, Stack } from "@mantine/core"; import { Helmet } from "react-helmet-async"; import { useTranslation } from "react-i18next"; import { useState, useCallback } from "react"; import { getAppName } from "@/lib/config"; import SettingsTitle from "@/components/settings/settings-title"; -import IntegrationCard from "../components/integration-card"; +import IntegrationRow from "../components/integration-row"; import IntegrationSettingsModal from "../components/integration-settings-modal"; import { useAvailableIntegrations, @@ -68,6 +68,10 @@ export default function Integrations() { + + {t("Manage workspace integrations.")} + + {error === "oauth_failed" && ( {t("OAuth connection failed. Please try again.")} @@ -83,11 +87,11 @@ export default function Integrations() { {t("No integrations available.")} ) : ( - + {available.map((def) => { const installation = installed?.find((i) => i.type === def.type); return ( - ); })} - + )} { + const req = await api.post("/integrations/connections/mine"); + return req.data; +} + export async function getConnectionStatus(data: { integrationId: string; }): Promise { diff --git a/apps/client/src/features/integration/types/integration.types.ts b/apps/client/src/features/integration/types/integration.types.ts index 7e982bf1e..aec8a8491 100644 --- a/apps/client/src/features/integration/types/integration.types.ts +++ b/apps/client/src/features/integration/types/integration.types.ts @@ -24,6 +24,14 @@ export type ConnectionStatus = { providerUserId?: string; }; +export type UserConnection = { + integrationId: string; + type: string; + isEnabled: boolean; + providerUserId: string | null; + connectedAt: string; +}; + export type UnfurlResult = { title: string; description?: string; diff --git a/apps/server/src/core/integration/integration-connection.service.ts b/apps/server/src/core/integration/integration-connection.service.ts index 819759341..24015962c 100644 --- a/apps/server/src/core/integration/integration-connection.service.ts +++ b/apps/server/src/core/integration/integration-connection.service.ts @@ -50,6 +50,21 @@ export class IntegrationConnectionService { ); } + async getUserConnections(userId: string, workspaceId: string) { + const rows = await this.connectionRepo.findByUserAndWorkspace( + userId, + workspaceId, + ); + + return rows.map((row) => ({ + integrationId: row.integrationId, + type: row.type, + isEnabled: row.isEnabled, + providerUserId: row.providerUserId ?? null, + connectedAt: row.createdAt, + })); + } + async disconnect( integrationId: string, userId: string, diff --git a/apps/server/src/core/integration/integration.controller.ts b/apps/server/src/core/integration/integration.controller.ts index 63b53ed9f..30c295c41 100644 --- a/apps/server/src/core/integration/integration.controller.ts +++ b/apps/server/src/core/integration/integration.controller.ts @@ -116,6 +116,16 @@ export class IntegrationController { }); } + @UseGuards(JwtAuthGuard) + @HttpCode(HttpStatus.OK) + @Post('connections/mine') + async getMyConnections( + @AuthUser() user: User, + @AuthWorkspace() workspace: Workspace, + ) { + return this.connectionService.getUserConnections(user.id, workspace.id); + } + @UseGuards(JwtAuthGuard) @HttpCode(HttpStatus.OK) @Post('connection/status') diff --git a/apps/server/src/core/integration/repos/integration-connection.repo.ts b/apps/server/src/core/integration/repos/integration-connection.repo.ts index 53f557bc4..93e164dac 100644 --- a/apps/server/src/core/integration/repos/integration-connection.repo.ts +++ b/apps/server/src/core/integration/repos/integration-connection.repo.ts @@ -122,6 +122,32 @@ export class IntegrationConnectionRepo { .execute(); } + async findByUserAndWorkspace( + userId: string, + workspaceId: string, + trx?: KyselyTransaction, + ) { + const db = dbOrTx(this.db, trx); + return db + .selectFrom('integrationConnections') + .innerJoin( + 'integrations', + 'integrations.id', + 'integrationConnections.integrationId', + ) + .select([ + 'integrationConnections.integrationId', + 'integrations.type', + 'integrations.isEnabled', + 'integrationConnections.providerUserId', + 'integrationConnections.createdAt', + ]) + .where('integrationConnections.userId', '=', userId) + .where('integrations.workspaceId', '=', workspaceId) + .where('integrations.deletedAt', 'is', null) + .execute(); + } + async deleteByIntegration( integrationId: string, trx?: KyselyTransaction,