Skip to content

Commit

Permalink
feat: Add Slack unintall flow
Browse files Browse the repository at this point in the history
  • Loading branch information
johnjcsmith committed Dec 28, 2024
1 parent 558bf4c commit 52a88da
Show file tree
Hide file tree
Showing 15 changed files with 235 additions and 90 deletions.
54 changes: 35 additions & 19 deletions app/app/clusters/[clusterId]/integrations/slack/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import { Loading } from "@/components/loading";
import Nango from '@nangohq/frontend';
import toast from "react-hot-toast";
import { useRouter } from "next/navigation";
import { ClientInferResponses } from "@ts-rest/core";
import { contract } from "@/client/contract";

const nango = new Nango();

Expand All @@ -28,8 +30,10 @@ export default function SlackIntegration({
const { getToken } = useAuth();
const router = useRouter();
const [loading, setLoading] = useState(false);
const [sessionToken, setSessionToken] = useState<string | null>(null);
const [connectionId, setConnectionId] = useState<string | null>(null);
const [connection, setConnection] = useState<ClientInferResponses<
typeof contract.getIntegrations,
200
>["body"]["slack"] | null>(null);

const fetchConfig = useCallback(async () => {
setLoading(true);
Expand All @@ -44,19 +48,31 @@ export default function SlackIntegration({
setLoading(false);

if (response.status === 200) {
setSessionToken(response.body?.slack?.nangoSessionToken ?? null);
setConnectionId(response.body?.slack?.nangoConnectionId ?? null);
setConnection(response.body?.slack);
}

}, [clusterId, getToken]);

const onSlackConnect = async () => {
if (!sessionToken) {
const response = await client.createNangoSession({
headers: {
authorization: `Bearer ${await getToken()}`,
},
params: {
clusterId: clusterId,
},
body: {
integration: "slack",
}
});

if (response.status !== 200 || !response.body || !response.body.token) {
toast.error("Failed to connect to Slack");
return;
}

nango.openConnectUI({
sessionToken: sessionToken,
sessionToken: response.body.token,
onEvent: async (event) => {
if (event.type === "connect") {
toast.success("Connected to Slack");
Expand Down Expand Up @@ -106,21 +122,21 @@ export default function SlackIntegration({
</CardDescription>
</CardHeader>
<CardContent>
{sessionToken ? (
<div className="flex items-center gap-2">
<Button
variant="outline"
onClick={onSlackConnect}
>
Connect Slack
</Button>
</div>
) : null}
{connectionId ? (
{connection ? (
<div className="flex items-center gap-2">
<h3 className="text-gray-500">Connected ({connectionId})</h3>
<h3 className="text-gray-500">Slack Connected (Team: {connection.teamId})</h3>
</div>
) : null}
) : (
<div className="flex items-center gap-2">
<Button
variant="outline"
onClick={onSlackConnect}
>
Connect Slack
</Button>
</div>
)
}
</CardContent>
</Card>
</div>
Expand Down
30 changes: 28 additions & 2 deletions app/client/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,9 @@ export const integrationSchema = z.object({
.nullable(),
slack: z
.object({
nangoSessionToken: z.string().optional().nullable(),
nangoConnectionId: z.string().optional().nullable(),
nangoConnectionId: z.string(),
botUserId: z.string(),
teamId: z.string(),
})
.optional()
.nullable(),
Expand Down Expand Up @@ -1489,6 +1490,31 @@ export const definition = {
}),
},
},
createNangoSession: {
method: "POST",
path: "/clusters/:clusterId/nango/sessions",
pathParams: z.object({
clusterId: z.string(),
}),
headers: z.object({ authorization: z.string() }),
body: z.object({
integration: z.string(),
}),
responses: {
200: z.object({
token: z.string(),
}),
},
},
createNangoEvent: {
method: "POST",
path: "/nango/events",
headers: z.object({ "x-nango-signature": z.string() }),
body: z.object({}).passthrough(),
responses: {
200: z.undefined(),
},
},
} as const;

export const contract = c.router(definition);
8 changes: 8 additions & 0 deletions control-plane/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions control-plane/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@
"devDependencies": {
"@babel/preset-env": "^7.22.10",
"@babel/preset-typescript": "^7.22.11",
"@nangohq/types": "^0.48.1",
"@types/async-retry": "^1.4.9",
"@types/jest": "^29.5.14",
"@types/jsonpath": "^0.2.4",
Expand Down
25 changes: 21 additions & 4 deletions control-plane/src/modules/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,9 @@ export const integrationSchema = z.object({
.nullable(),
slack: z
.object({
nangoSessionToken: z.string().optional().nullable(),
nangoConnectionId: z.string().optional().nullable(),
nangoConnectionId: z.string(),
botUserId: z.string(),
teamId: z.string(),
})
.optional()
.nullable(),
Expand Down Expand Up @@ -1489,9 +1490,25 @@ export const definition = {
}),
},
},
nangoWebhook: {
createNangoSession: {
method: "POST",
path: "/integrations/nango",
path: "/clusters/:clusterId/nango/sessions",
pathParams: z.object({
clusterId: z.string(),
}),
headers: z.object({ authorization: z.string() }),
body: z.object({
integration: z.string(),
}),
responses: {
200: z.object({
token: z.string(),
}),
},
},
createNangoEvent: {
method: "POST",
path: "/nango/events",
headers: z.object({ "x-nango-signature": z.string() }),
body: z.object({}).passthrough(),
responses: {
Expand Down
22 changes: 13 additions & 9 deletions control-plane/src/modules/integrations/integrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,26 @@ import { eq, sql } from "drizzle-orm";
import { z } from "zod";
import { db, integrations } from "../data";
import { integrationSchema } from "./schema";
import { tavilyIntegration, valtownIntegration, toolhouseIntegration } from "./constants";
import { tavilyIntegration, valtownIntegration, toolhouseIntegration, slackIntegration } from "./constants";
import { tavily } from "./tavily";
import { toolhouse } from "./toolhouse";
import { valtown } from "./valtown";
import { ToolProvider } from "./types";
import { slack } from "./slack";
import { InstallableIntegration } from "./types";

const toolProviders: Record<string, ToolProvider> = {
const installableIntegrations: Record<string, InstallableIntegration> = {
[toolhouseIntegration]: toolhouse,
[tavilyIntegration]: tavily,
[valtownIntegration]: valtown,
[slackIntegration]: slack,
};

export function getToolProvider(tool: string) {
if (!toolProviders[tool as keyof typeof toolProviders]) {
export function getInstallables(tool: string) {
if (!installableIntegrations[tool as keyof typeof installableIntegrations]) {
throw new Error(`Unknown tool provider integration requested: ${tool}`);
}

return toolProviders[tool as keyof typeof toolProviders];
return installableIntegrations[tool as keyof typeof installableIntegrations];
}

export const getIntegrations = async ({
Expand Down Expand Up @@ -56,6 +58,8 @@ export const upsertIntegrations = async ({
clusterId: string;
config: z.infer<typeof integrationSchema>;
}) => {
const existing = await getIntegrations({ clusterId });

await db
.insert(integrations)
.values({
Expand All @@ -74,12 +78,12 @@ export const upsertIntegrations = async ({

await Promise.all(
Object.entries(config)
.filter(([key]) => toolProviders[key as keyof typeof toolProviders])
.filter(([key]) => installableIntegrations[key as keyof typeof installableIntegrations])
.map(([key, value]) => {
if (value) {
return getToolProvider(key)?.onActivate?.(clusterId, config);
return getInstallables(key)?.onActivate?.(clusterId, config);
} else if (value === null) {
return getToolProvider(key)?.onDeactivate?.(clusterId, config);
return getInstallables(key)?.onDeactivate?.(clusterId, config, existing);
}
})
);
Expand Down
26 changes: 18 additions & 8 deletions control-plane/src/modules/integrations/nango/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { Nango } from "@nangohq/node";
import { env } from "../../../utilities/env";
import { z } from "zod";
import { BadRequestError } from "../../../utilities/errors";
import { logger } from "../../observability/logger";

export const nango = env.NANGO_SECRET_KEY && new Nango({ secretKey: env.NANGO_SECRET_KEY });

Expand All @@ -15,14 +17,6 @@ export const webhookSchema = z.object({
})
})

export const slackConnectionSchema = z.object({
connection_config: z.object({
"team.id": z.string(),
"bot_user_id": z.string(),
}),
})


export const getSession = async ({
clusterId,
integrationId,
Expand All @@ -34,6 +28,22 @@ export const getSession = async ({
throw new Error("Nango is not configured");
}

const existing = await nango?.listConnections(
undefined,
undefined,
{
endUserId: clusterId,
}
)

if (existing?.connections.find((c) => c.provider_config_key === integrationId)) {
logger.warn("Attempted to create duplicate nango connection", {
integrationId,
existing: existing?.connections,
});
throw new BadRequestError(`Nango ${integrationId} connection already exists for cluster`);
}

const res = await nango?.createConnectSession({
end_user: {
id: clusterId,
Expand Down
Loading

0 comments on commit 52a88da

Please sign in to comment.