Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions app/api/integrations/slack/oauth/authorize/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { NextResponse } from "next/server";

import { authOptions } from "@/pages/api/auth/[...nextauth]";
import { getServerSession } from "next-auth/next";
import { z } from "zod";

import { getSlackInstallationUrl } from "@/lib/integrations/slack/install";
import prisma from "@/lib/prisma";
import { CustomUser } from "@/lib/types";
import { getSearchParams } from "@/lib/utils/get-search-params";

const oAuthAuthorizeSchema = z.object({
teamId: z.string().cuid(),
});

export async function GET(req: Request) {
try {
const session = await getServerSession(authOptions);
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

const { teamId } = oAuthAuthorizeSchema.parse(getSearchParams(req.url));
console.log("teamId", teamId);
const userId = (session.user as CustomUser).id;

const userTeam = await prisma.userTeam.findUnique({
where: {
userId_teamId: {
userId,
teamId,
},
},
});
Comment on lines +27 to +34
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

Confirm composite unique (userId, teamId) exists for userTeam.

findUnique on userId_teamId requires a matching @@unique/@@id. If absent, use findFirst.


🏁 Script executed:

#!/bin/bash
set -euo pipefail
echo "Model UserTeam definition:"
rg -nP -C3 'model\s+UserTeam\b' prisma

echo
echo "Looking for composite unique on [userId, teamId]:"
rg -nP '@@unique\(\s*\[userId\s*,\s*teamId\]\s*\)' prisma || echo "Composite unique NOT found"

Length of output: 420


Fix findUnique usage — add composite unique or use findFirst

Verified: prisma/schema/team.prisma model UserTeam does NOT define @@unique([userId, teamId]) (composite unique NOT found). findUnique({ where: { userId_teamId: { userId, teamId } } }) is invalid.

  • Option A (preferred): add @@unique([userId, teamId]) to model UserTeam in prisma/schema/team.prisma so the existing findUnique call is correct.
  • Option B: change app/api/integrations/slack/oauth/authorize/route.ts (lines ~27–34) to use prisma.userTeam.findFirst({ where: { userId, teamId } }) or query by a true unique field.
🤖 Prompt for AI Agents
In app/api/integrations/slack/oauth/authorize/route.ts around lines 27 to 34,
the code uses prisma.userTeam.findUnique with a composite key userId_teamId but
the UserTeam model in prisma/schema/team.prisma does not define
@@unique([userId, teamId]), so update either the schema or the query: preferred
fix — add @@unique([userId, teamId]) to the UserTeam model in
prisma/schema/team.prisma and run prisma migrate to make the composite unique
valid; alternative quick fix — change the call in route.ts to
prisma.userTeam.findFirst({ where: { userId, teamId } }) (or query by an actual
unique field) so the query matches the schema.


if (!userTeam) {
return NextResponse.json({ error: "Access denied" }, { status: 403 });
}

const oauthUrl = await getSlackInstallationUrl(teamId);
console.log("oauthUrl", oauthUrl);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Do not log the OAuth URL (leaks state).

oauthUrl includes the state param. Avoid logging it.

-    console.log("oauthUrl", oauthUrl);
+    // Avoid logging oauthUrl; it contains sensitive `state`.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
console.log("oauthUrl", oauthUrl);
// Avoid logging oauthUrl; it contains sensitive `state`.
🤖 Prompt for AI Agents
In app/api/integrations/slack/oauth/authorize/route.ts around line 41, remove
the console.log("oauthUrl", oauthUrl) which prints the OAuth URL containing the
state param; instead either remove the logging entirely or log a non-sensitive
placeholder (e.g., "oauthUrl generated" or only log safe parts like the provider
name) and if you must inspect the URL in development use a secure debug flag
that strips or redacts the state and query params before logging.


return NextResponse.json({
oauthUrl,
});
} catch (error) {
console.error("Slack OAuth authorization error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
Comment on lines +46 to +52
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Return 400 on validation errors instead of 500.

Handle Zod parsing failures explicitly.

-  } catch (error) {
+  } catch (error) {
+    if (error instanceof z.ZodError) {
+      return NextResponse.json({ error: "Invalid request" }, { status: 400 });
+    }
     console.error("Slack OAuth authorization error:", error);
     return NextResponse.json(
       { error: "Internal server error" },
       { status: 500 },
     );
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
} catch (error) {
console.error("Slack OAuth authorization error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: "Invalid request" }, { status: 400 });
}
console.error("Slack OAuth authorization error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
🤖 Prompt for AI Agents
In app/api/integrations/slack/oauth/authorize/route.ts around lines 46 to 52,
the catch block currently returns a 500 for all errors; update it to detect Zod
validation failures and return a 400 instead. Import ZodError from "zod" (or use
error.name === "ZodError"), and in the catch: if the error is a ZodError return
NextResponse.json({ error: "Invalid request", details: /* error issues or
message */ }, { status: 400 }); otherwise log the error and return the existing
500 response; ensure logs distinguish validation vs internal errors.

}
95 changes: 95 additions & 0 deletions app/api/integrations/slack/oauth/callback/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { redirect } from "next/navigation";
import { NextResponse } from "next/server";

import { Team } from "@prisma/client";
import { getSession } from "next-auth/react";
import z from "zod";
Comment on lines +5 to +6
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Use getServerSession(authOptions) in App Router route.

getSession from next-auth/react is for client; it will not work reliably in a server route.

-import { getSession } from "next-auth/react";
+import { getServerSession } from "next-auth/next";
+import { authOptions } from "@/pages/api/auth/[...nextauth]";
-    const session = await getSession();
+    const session = await getServerSession(authOptions);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
import { getSession } from "next-auth/react";
import z from "zod";
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/pages/api/auth/[...nextauth]";
import z from "zod";
🤖 Prompt for AI Agents
In app/api/integrations/slack/oauth/callback/route.ts around lines 5-6, the code
imports getSession from next-auth/react which is client-side; replace it with
getServerSession from next-auth and call getServerSession(authOptions) in this
server route. Import or reference your authOptions (e.g., from your
[...nextauth] config) and use await getServerSession(req, res, authOptions) or
the appropriate signature for your Next.js version, remove the getSession
import/usages, and ensure the route handler runs on the server so the
server-side session is retrieved reliably.


import { installIntegration } from "@/lib/integrations/install";
import { getSlackEnv } from "@/lib/integrations/slack/env";
import { SlackCredential } from "@/lib/integrations/slack/types";
import { encryptSlackToken } from "@/lib/integrations/slack/utils";
import prisma from "@/lib/prisma";
import { redis } from "@/lib/redis";
import { CustomUser } from "@/lib/types";
import { getSearchParams } from "@/lib/utils/get-search-params";

export const dynamic = "force-dynamic";

const oAuthCallbackSchema = z.object({
code: z.string(),
state: z.string(),
});

export const GET = async (req: Request) => {
const env = getSlackEnv();

let workspace: Pick<Team, "id" | "plan"> | null = null;

try {
const session = await getSession();
if (!session) {
throw new Error("Unauthorized");
}

const userId = (session.user as CustomUser).id;

const { code, state } = oAuthCallbackSchema.parse(getSearchParams(req.url));

// Find workspace that initiated the Stripe app install
const teamId = await redis.get<string>(`slack:install:state:${state}`);

if (!teamId) {
throw new Error("Unknown state");
}

workspace = await prisma.team.findUniqueOrThrow({
where: {
id: teamId,
},
select: {
id: true,
plan: true,
},
});

const formData = new FormData();
formData.append("code", code);
formData.append("client_id", env.SLACK_CLIENT_ID);
formData.append("client_secret", env.SLACK_CLIENT_SECRET);
formData.append(
"redirect_uri",
`${process.env.NEXT_PUBLIC_BASE_URL}/api/integrations/slack/oauth/callback`,
);

const response = await fetch("https://slack.com/api/oauth.v2.access", {
method: "POST",
body: formData,
});

const data = await response.json();

console.log("data", data);

const credentials: SlackCredential = {
appId: data.app_id,
botUserId: data.bot_user_id,
scope: data.scope,
accessToken: encryptSlackToken(data.access_token),
tokenType: data.token_type,
authUser: data.authed_user,
team: data.team,
};
Comment on lines +70 to +82
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

⚠️ Potential issue

Validate Slack response and avoid logging secrets.

Check data.ok, surface Slack errors, and stop logging the full payload which contains tokens.

-    const data = await response.json();
-
-    console.log("data", data);
-
-    const credentials: SlackCredential = {
-      appId: data.app_id,
-      botUserId: data.bot_user_id,
-      scope: data.scope,
-      accessToken: encryptSlackToken(data.access_token),
-      tokenType: data.token_type,
-      authUser: data.authed_user,
-      team: data.team,
-    };
+    const data = await response.json();
+    if (!data?.ok) {
+      throw new Error(`Slack OAuth failed: ${data?.error ?? "unknown_error"}`);
+    }
+    const {
+      app_id,
+      bot_user_id,
+      scope,
+      access_token,
+      token_type,
+      authed_user,
+      team,
+    } = data;
+    const credentials: SlackCredential = {
+      appId: app_id,
+      botUserId: bot_user_id,
+      scope,
+      accessToken: encryptSlackToken(access_token),
+      tokenType: token_type,
+      authUser: authed_user,
+      team,
+    };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const data = await response.json();
console.log("data", data);
const credentials: SlackCredential = {
appId: data.app_id,
botUserId: data.bot_user_id,
scope: data.scope,
accessToken: encryptSlackToken(data.access_token),
tokenType: data.token_type,
authUser: data.authed_user,
team: data.team,
};
const data = await response.json();
if (!data?.ok) {
throw new Error(`Slack OAuth failed: ${data?.error ?? "unknown_error"}`);
}
const {
app_id,
bot_user_id,
scope,
access_token,
token_type,
authed_user,
team,
} = data;
const credentials: SlackCredential = {
appId: app_id,
botUserId: bot_user_id,
scope,
accessToken: encryptSlackToken(access_token),
tokenType: token_type,
authUser: authed_user,
team,
};
🤖 Prompt for AI Agents
In app/api/integrations/slack/oauth/callback/route.ts around lines 70 to 82, the
code currently parses and logs the full Slack response and assumes success;
instead check if data.ok is true and if not throw or return an error that
surfaces data.error (and any useful error_description) to the caller; remove the
console.log("data", data) call so tokens and secrets are not printed; validate
required fields (access_token, app_id, team, etc.) before building
SlackCredential and return a clear error if any are missing; keep
encryptSlackToken for the token but never log the raw or encrypted token.


await installIntegration({
integrationId: env.SLACK_INTEGRATION_ID,
userId,
teamId,
credentials,
});
} catch (e: any) {
Comment on lines +84 to +90
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Delete the Redis state after a successful install.

Prevents replay and keeps Redis tidy.

     await installIntegration({
       integrationId: env.SLACK_INTEGRATION_ID,
       userId,
       teamId,
       credentials,
     });
+    await redis.del(`slack:install:state:${state}`);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
await installIntegration({
integrationId: env.SLACK_INTEGRATION_ID,
userId,
teamId,
credentials,
});
} catch (e: any) {
await installIntegration({
integrationId: env.SLACK_INTEGRATION_ID,
userId,
teamId,
credentials,
});
await redis.del(`slack:install:state:${state}`);
} catch (e: any) {
🤖 Prompt for AI Agents
In app/api/integrations/slack/oauth/callback/route.ts around lines 84 to 90,
after a successful call to installIntegration(...) you must delete the Redis
state used for the OAuth handshake to prevent replay and keep Redis tidy; add
code to compute the same Redis key you used to store the state (e.g., using the
state value or session id) and call await redis.del(key) (or the project's Redis
client delete method) immediately after installIntegration resolves and before
returning a response, and ensure errors from the deletion are caught/logged but
do not block the successful install flow.

return NextResponse.json({ error: e.message }, { status: 500 });
}

redirect(`/settings/slack`);
};
41 changes: 41 additions & 0 deletions app/api/views-dataroom/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ import { PreviewSession, verifyPreviewSession } from "@/lib/auth/preview-auth";
import { sendOtpVerificationEmail } from "@/lib/emails/send-email-otp-verification";
import { getFile } from "@/lib/files/get-file";
import { newId } from "@/lib/id-helper";
import {
notifyDataroomAccess,
notifyDocumentView,
} from "@/lib/integrations/slack/events";
import prisma from "@/lib/prisma";
import { ratelimit } from "@/lib/redis";
import { parseSheet } from "@/lib/sheet";
Expand Down Expand Up @@ -657,6 +661,24 @@ export async function POST(request: NextRequest) {
enableNotification: link.enableNotification,
}),
);

if (link.teamId && !isPreview) {
waitUntil(
(async () => {
try {
await notifyDataroomAccess({
teamId: link.teamId!,
dataroomId,
linkId,
viewerEmail: verifiedEmail ?? email,
viewerId: viewer?.id,
});
} catch (error) {
console.error("Error sending Slack notification:", error);
}
})(),
);
}
}

const dataroomViewId =
Expand Down Expand Up @@ -768,6 +790,25 @@ export async function POST(request: NextRequest) {
select: { id: true },
});
console.timeEnd("create-view");
// Only send Slack notifications for non-preview views
if (link.teamId && !isPreview) {
waitUntil(
(async () => {
try {
await notifyDocumentView({
teamId: link.teamId!,
documentId,
dataroomId,
linkId,
viewerEmail: verifiedEmail ?? email,
viewerId: viewer?.id,
});
} catch (error) {
console.error("Error sending Slack notification:", error);
}
})(),
);
}
}

// if document version has pages, then return pages
Expand Down
14 changes: 14 additions & 0 deletions app/api/views/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { sendOtpVerificationEmail } from "@/lib/emails/send-email-otp-verificati
import { getFeatureFlags } from "@/lib/featureFlags";
import { getFile } from "@/lib/files/get-file";
import { newId } from "@/lib/id-helper";
import { notifyDocumentView } from "@/lib/integrations/slack/events";
import prisma from "@/lib/prisma";
import { ratelimit } from "@/lib/redis";
import { parseSheet } from "@/lib/sheet";
Expand Down Expand Up @@ -636,6 +637,19 @@ export async function POST(request: NextRequest) {
enableNotification: link.enableNotification,
}),
);
if (!isPreview) {
waitUntil(
notifyDocumentView({
teamId: link.teamId!,
documentId,
linkId,
viewerEmail: email ?? undefined,
viewerId: viewer?.id ?? undefined,
}).catch((error) => {
console.error("Error sending Slack notification:", error);
}),
);
}
}

const returnObject = {
Expand Down
84 changes: 84 additions & 0 deletions components/emails/installed-integration-notification.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import React from "react";

import {
Body,
Button,
Container,
Head,
Hr,
Html,
Preview,
Section,
Tailwind,
Text,
} from "@react-email/components";

import { Footer } from "./shared/footer";

export default function SlackIntegrationNotification({
email = "[email protected]",
team = {
name: "Acme, Inc",
},
integration = {
name: "Slack",
slug: "slack",
},
}: {
email: string;
team: {
name: string;
};
integration: {
name: string;
slug: string;
};
}) {
return (
<Html>
<Head />
<Preview>An integration has been added to your team</Preview>
<Tailwind>
<Body className="mx-auto my-auto bg-white font-sans">
<Container className="mx-auto my-10 w-[465px] p-5">
<Text className="mx-0 mb-8 mt-4 p-0 text-center text-2xl font-normal">
<span className="font-bold tracking-tighter">Papermark</span>
</Text>
<Text className="mx-0 my-7 p-0 text-center text-xl font-semibold text-black">
An integration has been added to your team
</Text>
<Text className="text-sm leading-6 text-black">
The <strong>{integration.name}</strong> integration has been added
to your team {team.name} on Papermark.
</Text>
<Text className="text-sm leading-6 text-black">
You can now receive notifications about document views, dataroom
access and downloads directly in your Slack channels.
</Text>
<Section className="my-8 text-center">
<Button
className="rounded bg-black text-center text-xs font-semibold text-white no-underline"
href={`${process.env.NEXT_PUBLIC_BASE_URL}/settings/integrations/${integration.slug}`}
style={{ padding: "12px 20px" }}
>
View installed integration
</Button>
</Section>

<Footer
footerText={
<>
This email was intended for{" "}
<span className="text-black">{email}</span>. If you were not
expecting this email, you can ignore this email. If you have
any feedback or questions about this email, simply reply to
it.
</>
}
/>
</Container>
</Body>
</Tailwind>
</Html>
);
}
2 changes: 2 additions & 0 deletions components/layouts/breadcrumb.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,8 @@ const SettingsBreadcrumb = () => {
return "API Tokens";
case "/settings/webhooks":
return "Webhooks";
case "/settings/slack":
return "Slack";
case "/settings/incoming-webhooks":
return "Incoming Webhooks";
case "/settings/branding":
Expand Down
7 changes: 7 additions & 0 deletions components/settings/settings-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export function SettingsHeader() {
const { data: features } = useSWR<{
tokens: boolean;
incomingWebhooks: boolean;
slack: boolean;
}>(
teamInfo?.currentTeam?.id
? `/api/feature-flags?teamId=${teamInfo.currentTeam.id}`
Expand Down Expand Up @@ -67,6 +68,12 @@ export function SettingsHeader() {
href: `/settings/webhooks`,
segment: "webhooks",
},
{
label: "Slack",
href: `/settings/slack`,
segment: "slack",
disabled: !features?.slack,
},
{
label: "Tokens",
href: `/settings/tokens`,
Expand Down
Loading