Skip to content

Commit 25b54a3

Browse files
committed
feat(slack): add Slack integration settings page and uninstall
Introduce a new route for organization Slack integration settings, including loader and action handlers. The loader fetches the organization's Slack integration, its team name, and related alert channels for display. The action supports an "uninstall" intent that disables related Slack alert channels and marks the integration as deleted within a single transaction. Also add UI imports and helper utilities used by the page (formatDate, components, and validation). Include error handling and logging for missing integrations and transaction failures. Reasons: - Provide a dedicated settings UI for viewing Slack integration details and managing/uninstalling the integration. - Ensure uninstall cleans up related alert channels atomically to avoid inconsistent state.
1 parent 73e0229 commit 25b54a3

File tree

3 files changed

+339
-0
lines changed

3 files changed

+339
-0
lines changed

apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,14 @@ import {
66
UserGroupIcon,
77
} from "@heroicons/react/20/solid";
88
import { ArrowLeftIcon } from "@heroicons/react/24/solid";
9+
import { SlackIcon } from "@trigger.dev/companyicons";
910
import { VercelLogo } from "~/components/integrations/VercelLogo";
1011
import { useFeatures } from "~/hooks/useFeatures";
1112
import { type MatchedOrganization } from "~/hooks/useOrganizations";
1213
import { cn } from "~/utils/cn";
1314
import {
1415
organizationSettingsPath,
16+
organizationSlackIntegrationPath,
1517
organizationTeamPath,
1618
organizationVercelIntegrationPath,
1719
rootPath,
@@ -127,6 +129,13 @@ export function OrganizationSettingsSideMenu({
127129
to={organizationVercelIntegrationPath(organization)}
128130
data-action="integrations"
129131
/>
132+
<SideMenuItem
133+
name="Slack"
134+
icon={SlackIcon}
135+
activeIconColor="text-white"
136+
to={organizationSlackIntegrationPath(organization)}
137+
data-action="integrations"
138+
/>
130139
</div>
131140
<div className="flex flex-col gap-1">
132141
<SideMenuHeader title="App version" />
Lines changed: 326 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,326 @@
1+
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node";
2+
import { json, redirect } from "@remix-run/node";
3+
import { fromPromise } from "neverthrow";
4+
import { Form, useActionData, useNavigation } from "@remix-run/react";
5+
import { typedjson, useTypedLoaderData } from "remix-typedjson";
6+
import { z } from "zod";
7+
import { DialogClose } from "@radix-ui/react-dialog";
8+
import { SlackIcon } from "@trigger.dev/companyicons";
9+
import { TrashIcon } from "@heroicons/react/20/solid";
10+
import { Button } from "~/components/primitives/Buttons";
11+
import {
12+
Dialog,
13+
DialogContent,
14+
DialogDescription,
15+
DialogHeader,
16+
DialogTitle,
17+
DialogTrigger,
18+
} from "~/components/primitives/Dialog";
19+
import { FormButtons } from "~/components/primitives/FormButtons";
20+
import { Header1 } from "~/components/primitives/Headers";
21+
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
22+
import { Paragraph } from "~/components/primitives/Paragraph";
23+
import {
24+
Table,
25+
TableBody,
26+
TableCell,
27+
TableHeader,
28+
TableHeaderCell,
29+
TableRow,
30+
} from "~/components/primitives/Table";
31+
import { EnabledStatus } from "~/components/runs/v3/EnabledStatus";
32+
import { $transaction, prisma } from "~/db.server";
33+
import { requireOrganization } from "~/services/org.server";
34+
import { OrganizationParamsSchema } from "~/utils/pathBuilder";
35+
import { logger } from "~/services/logger.server";
36+
37+
function formatDate(date: Date): string {
38+
return new Intl.DateTimeFormat("en-US", {
39+
month: "short",
40+
day: "numeric",
41+
year: "numeric",
42+
hour: "numeric",
43+
minute: "2-digit",
44+
second: "2-digit",
45+
hour12: true,
46+
}).format(date);
47+
}
48+
49+
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
50+
const { organizationSlug } = OrganizationParamsSchema.parse(params);
51+
const { organization } = await requireOrganization(request, organizationSlug);
52+
53+
const slackIntegration = await prisma.organizationIntegration.findFirst({
54+
where: {
55+
organizationId: organization.id,
56+
service: "SLACK",
57+
deletedAt: null,
58+
},
59+
});
60+
61+
if (!slackIntegration) {
62+
return typedjson({
63+
organization,
64+
slackIntegration: null,
65+
alertChannels: [],
66+
teamName: null,
67+
});
68+
}
69+
70+
const integrationData = slackIntegration.integrationData as any;
71+
const teamName = integrationData?.team?.name ?? null;
72+
73+
const alertChannels = await prisma.projectAlertChannel.findMany({
74+
where: {
75+
type: "SLACK",
76+
project: { organizationId: organization.id },
77+
OR: [
78+
{ integrationId: slackIntegration.id },
79+
{
80+
properties: {
81+
path: ["integrationId"],
82+
equals: slackIntegration.id,
83+
},
84+
},
85+
],
86+
},
87+
include: {
88+
project: {
89+
select: {
90+
id: true,
91+
slug: true,
92+
name: true,
93+
},
94+
},
95+
},
96+
orderBy: {
97+
createdAt: "desc",
98+
},
99+
});
100+
101+
return typedjson({
102+
organization,
103+
slackIntegration,
104+
alertChannels,
105+
teamName,
106+
});
107+
};
108+
109+
const ActionSchema = z.object({
110+
intent: z.literal("uninstall"),
111+
});
112+
113+
export const action = async ({ request, params }: ActionFunctionArgs) => {
114+
const { organizationSlug } = OrganizationParamsSchema.parse(params);
115+
const { organization, userId } = await requireOrganization(request, organizationSlug);
116+
117+
const formData = await request.formData();
118+
const result = ActionSchema.safeParse({ intent: formData.get("intent") });
119+
if (!result.success) {
120+
return json({ error: "Invalid action" }, { status: 400 });
121+
}
122+
123+
const slackIntegration = await prisma.organizationIntegration.findFirst({
124+
where: {
125+
organizationId: organization.id,
126+
service: "SLACK",
127+
deletedAt: null,
128+
},
129+
});
130+
131+
if (!slackIntegration) {
132+
return json({ error: "Slack integration not found" }, { status: 404 });
133+
}
134+
135+
const txResult = await fromPromise(
136+
$transaction(prisma, async (tx) => {
137+
await tx.projectAlertChannel.updateMany({
138+
where: {
139+
type: "SLACK",
140+
OR: [
141+
{ integrationId: slackIntegration.id },
142+
{
143+
properties: {
144+
path: ["integrationId"],
145+
equals: slackIntegration.id,
146+
},
147+
},
148+
],
149+
},
150+
data: {
151+
enabled: false,
152+
integrationId: null,
153+
},
154+
});
155+
156+
await tx.organizationIntegration.update({
157+
where: { id: slackIntegration.id },
158+
data: { deletedAt: new Date() },
159+
});
160+
}),
161+
(error) => error
162+
);
163+
164+
if (txResult.isErr()) {
165+
logger.error("Failed to remove Slack integration", {
166+
organizationId: organization.id,
167+
organizationSlug,
168+
userId,
169+
integrationId: slackIntegration.id,
170+
error: txResult.error instanceof Error ? txResult.error.message : String(txResult.error),
171+
});
172+
173+
return json(
174+
{ error: "Failed to remove Slack integration. Please try again." },
175+
{ status: 500 }
176+
);
177+
}
178+
179+
logger.info("Slack integration removed successfully", {
180+
organizationId: organization.id,
181+
organizationSlug,
182+
userId,
183+
integrationId: slackIntegration.id,
184+
});
185+
186+
return redirect(`/orgs/${organizationSlug}/settings`);
187+
};
188+
189+
export default function SlackIntegrationPage() {
190+
const { slackIntegration, alertChannels, teamName } =
191+
useTypedLoaderData<typeof loader>();
192+
const actionData = useActionData<typeof action>();
193+
const navigation = useNavigation();
194+
const isUninstalling =
195+
navigation.state === "submitting" && navigation.formData?.get("intent") === "uninstall";
196+
197+
if (!slackIntegration) {
198+
return (
199+
<PageContainer>
200+
<PageBody>
201+
<div className="flex flex-col items-center justify-center py-8">
202+
<Header1>No Slack Integration Found</Header1>
203+
<Paragraph className="mt-2 text-center text-text-dimmed">
204+
This organization doesn't have a Slack integration configured. You can connect Slack
205+
when setting up alert channels in your project settings.
206+
</Paragraph>
207+
</div>
208+
</PageBody>
209+
</PageContainer>
210+
);
211+
}
212+
213+
return (
214+
<PageContainer>
215+
<PageBody>
216+
<div className="mb-8">
217+
<Header1>Slack Integration</Header1>
218+
<Paragraph className="mt-2 text-text-dimmed">
219+
Manage your organization's Slack integration and connected alert channels.
220+
</Paragraph>
221+
</div>
222+
223+
{/* Integration Info Section */}
224+
<div className="mb-8 rounded-lg border border-grid-bright bg-background-bright p-6">
225+
<div className="flex items-center justify-between">
226+
<div>
227+
<h2 className="text-lg font-medium text-text-bright">Integration Details</h2>
228+
<div className="mt-2 space-y-1 text-sm text-text-dimmed">
229+
{teamName && (
230+
<div>
231+
<span className="font-medium">Slack Workspace:</span> {teamName}
232+
</div>
233+
)}
234+
<div>
235+
<span className="font-medium">Installed:</span>{" "}
236+
{formatDate(new Date(slackIntegration.createdAt))}
237+
</div>
238+
</div>
239+
</div>
240+
<div className="flex flex-col items-end gap-2">
241+
<Dialog>
242+
<DialogTrigger asChild>
243+
<Button variant="danger/medium" LeadingIcon={TrashIcon} disabled={isUninstalling}>
244+
Remove Integration
245+
</Button>
246+
</DialogTrigger>
247+
<DialogContent>
248+
<DialogHeader>
249+
<DialogTitle>Remove Slack Integration</DialogTitle>
250+
</DialogHeader>
251+
<DialogDescription>
252+
This will remove the Slack integration and disable all connected alert channels.
253+
This action cannot be undone.
254+
</DialogDescription>
255+
<FormButtons
256+
confirmButton={
257+
<Form method="post">
258+
<input type="hidden" name="intent" value="uninstall" />
259+
<Button
260+
variant="danger/medium"
261+
LeadingIcon={TrashIcon}
262+
type="submit"
263+
disabled={isUninstalling}
264+
>
265+
{isUninstalling ? "Removing..." : "Remove Integration"}
266+
</Button>
267+
</Form>
268+
}
269+
cancelButton={
270+
<DialogClose asChild>
271+
<Button variant="tertiary/medium">Cancel</Button>
272+
</DialogClose>
273+
}
274+
/>
275+
</DialogContent>
276+
</Dialog>
277+
{actionData?.error && (
278+
<Paragraph variant="small" className="text-error">
279+
{actionData.error}
280+
</Paragraph>
281+
)}
282+
</div>
283+
</div>
284+
</div>
285+
286+
{/* Connected Alert Channels Section */}
287+
<div>
288+
<h2 className="mb-4 text-lg font-medium text-text-bright">
289+
Connected Alert Channels ({alertChannels.length})
290+
</h2>
291+
292+
{alertChannels.length === 0 ? (
293+
<div className="rounded-lg border border-grid-bright bg-background-bright p-6 text-center">
294+
<Paragraph className="text-text-dimmed">
295+
No alert channels are currently connected to this Slack integration.
296+
</Paragraph>
297+
</div>
298+
) : (
299+
<Table>
300+
<TableHeader>
301+
<TableRow>
302+
<TableHeaderCell>Channel Name</TableHeaderCell>
303+
<TableHeaderCell>Project</TableHeaderCell>
304+
<TableHeaderCell>Status</TableHeaderCell>
305+
<TableHeaderCell>Created</TableHeaderCell>
306+
</TableRow>
307+
</TableHeader>
308+
<TableBody>
309+
{alertChannels.map((channel) => (
310+
<TableRow key={channel.id}>
311+
<TableCell>{channel.name}</TableCell>
312+
<TableCell>{channel.project.name}</TableCell>
313+
<TableCell>
314+
<EnabledStatus enabled={channel.enabled} />
315+
</TableCell>
316+
<TableCell>{formatDate(new Date(channel.createdAt))}</TableCell>
317+
</TableRow>
318+
))}
319+
</TableBody>
320+
</Table>
321+
)}
322+
</div>
323+
</PageBody>
324+
</PageContainer>
325+
);
326+
}

apps/webapp/app/utils/pathBuilder.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,10 @@ export function organizationVercelIntegrationPath(organization: OrgForPath) {
129129
return `${organizationIntegrationsPath(organization)}/vercel`;
130130
}
131131

132+
export function organizationSlackIntegrationPath(organization: OrgForPath) {
133+
return `${organizationIntegrationsPath(organization)}/slack`;
134+
}
135+
132136
function organizationParam(organization: OrgForPath) {
133137
return organization.slug;
134138
}

0 commit comments

Comments
 (0)