{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,