mirror of
https://github.com/mfts/papermark.git
synced 2025-12-20 01:03:24 +08:00
feat(ee): send blocked access email notifications
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import { reportDeniedAccessAttempt } from "@/ee/features/access-notifications";
|
||||
import { getTeamStorageConfigById } from "@/ee/features/storage/config";
|
||||
import { authOptions } from "@/pages/api/auth/[...nextauth]";
|
||||
import { ItemType, Link, LinkAudienceType } from "@prisma/client";
|
||||
@@ -27,7 +28,6 @@ import { generateOTP } from "@/lib/utils/generate-otp";
|
||||
import { LOCALHOST_IP } from "@/lib/utils/geo";
|
||||
import { checkGlobalBlockList } from "@/lib/utils/global-block-list";
|
||||
import { validateEmail } from "@/lib/utils/validate-email";
|
||||
import { sendBlockedEmailAttemptNotification } from "@/lib/emails/send-blocked-email-attempt";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
@@ -289,11 +289,8 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
if (globalBlockCheck.isBlocked) {
|
||||
try {
|
||||
await notifyBlockedAttempt(link, email);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
waitUntil(reportDeniedAccessAttempt(link, email, "global"));
|
||||
|
||||
return NextResponse.json({ message: "Access denied" }, { status: 403 });
|
||||
}
|
||||
|
||||
@@ -306,6 +303,8 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
// Deny access if the email is not allowed
|
||||
if (!isAllowed) {
|
||||
waitUntil(reportDeniedAccessAttempt(link, email, "allow"));
|
||||
|
||||
return NextResponse.json(
|
||||
{ message: "Unauthorized access" },
|
||||
{ status: 403 },
|
||||
@@ -322,11 +321,8 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
// Deny access if the email is denied
|
||||
if (isDenied) {
|
||||
try {
|
||||
await notifyBlockedAttempt(link, email);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
waitUntil(reportDeniedAccessAttempt(link, email, "deny"));
|
||||
|
||||
return NextResponse.json(
|
||||
{ message: "Unauthorized access" },
|
||||
{ status: 403 },
|
||||
@@ -369,6 +365,7 @@ export async function POST(request: NextRequest) {
|
||||
: false;
|
||||
|
||||
if (!isMember && !hasDomainAccess) {
|
||||
waitUntil(reportDeniedAccessAttempt(link, email, "allow"));
|
||||
return NextResponse.json(
|
||||
{ message: "Unauthorized access" },
|
||||
{ status: 403 },
|
||||
@@ -1025,59 +1022,3 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
type PartialLink = Partial<Link>
|
||||
export async function notifyBlockedAttempt(link: PartialLink, email: string) {
|
||||
if (!link) return;
|
||||
const users = await prisma.userTeam.findMany({
|
||||
where: {
|
||||
role: { in: ["ADMIN", "MANAGER"] },
|
||||
status: "ACTIVE",
|
||||
teamId: link.teamId!,
|
||||
},
|
||||
select: {
|
||||
user: { select: { email: true } },
|
||||
},
|
||||
});
|
||||
const adminEmails = users.map((u) => u.user?.email).filter((e): e is string => !!e);
|
||||
let ownerEmail: string | undefined;
|
||||
let resourceType: "dataroom" | "document" = "dataroom";
|
||||
let resourceName = "Dataroom";
|
||||
if (link.documentId) {
|
||||
resourceType = "document";
|
||||
const doc = await prisma.document.findUnique({ where: { id: link.documentId }, select: { name: true, ownerId: true } });
|
||||
resourceName = doc?.name || "Document";
|
||||
if (doc?.ownerId) {
|
||||
const owner = await prisma.userTeam.findUnique({
|
||||
where: {
|
||||
userId_teamId: {
|
||||
userId: doc.ownerId,
|
||||
teamId: link.teamId!,
|
||||
},
|
||||
status: "ACTIVE",
|
||||
},
|
||||
select: { user: { select: { email: true } } },
|
||||
});
|
||||
ownerEmail = owner?.user?.email || undefined;
|
||||
}
|
||||
} else if (link.dataroomId) {
|
||||
resourceType = "dataroom";
|
||||
resourceName = (await prisma.dataroom.findUnique({ where: { id: link.dataroomId }, select: { name: true } }))?.name || "Dataroom";
|
||||
}
|
||||
|
||||
let cc = adminEmails.slice(1);
|
||||
const to = ownerEmail || adminEmails[0] || "";
|
||||
if (ownerEmail && !cc.includes(ownerEmail) && ownerEmail !== to) {
|
||||
cc = [ownerEmail, ...cc];
|
||||
}
|
||||
if (to) {
|
||||
await sendBlockedEmailAttemptNotification({
|
||||
to,
|
||||
cc,
|
||||
blockedEmail: email!,
|
||||
linkName: link?.name || `Link #${link.id?.slice(-5)}`,
|
||||
resourceName,
|
||||
resourceType,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import { reportDeniedAccessAttempt } from "@/ee/features/access-notifications";
|
||||
import { getTeamStorageConfigById } from "@/ee/features/storage/config";
|
||||
// Import authOptions directly from the source
|
||||
import { authOptions } from "@/pages/api/auth/[...nextauth]";
|
||||
@@ -24,7 +25,6 @@ import { generateOTP } from "@/lib/utils/generate-otp";
|
||||
import { LOCALHOST_IP } from "@/lib/utils/geo";
|
||||
import { checkGlobalBlockList } from "@/lib/utils/global-block-list";
|
||||
import { validateEmail } from "@/lib/utils/validate-email";
|
||||
import { notifyBlockedAttempt } from "../views-dataroom/route";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
@@ -225,11 +225,8 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
if (globalBlockCheck.isBlocked) {
|
||||
try {
|
||||
await notifyBlockedAttempt(link, email);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
waitUntil(reportDeniedAccessAttempt(link, email, "global"));
|
||||
|
||||
return NextResponse.json({ message: "Access denied" }, { status: 403 });
|
||||
}
|
||||
|
||||
@@ -242,6 +239,8 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
// Deny access if the email is not allowed
|
||||
if (!isAllowed) {
|
||||
waitUntil(reportDeniedAccessAttempt(link, email, "allow"));
|
||||
|
||||
return NextResponse.json(
|
||||
{ message: "Unauthorized access" },
|
||||
{ status: 403 },
|
||||
@@ -258,11 +257,8 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
// Deny access if the email is denied
|
||||
if (isDenied) {
|
||||
try {
|
||||
await notifyBlockedAttempt(link, email);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
waitUntil(reportDeniedAccessAttempt(link, email, "deny"));
|
||||
|
||||
return NextResponse.json(
|
||||
{ message: "Unauthorized access" },
|
||||
{ status: 403 },
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
import {
|
||||
Body,
|
||||
Container,
|
||||
Head,
|
||||
Hr,
|
||||
Html,
|
||||
Preview,
|
||||
Section,
|
||||
Tailwind,
|
||||
Text,
|
||||
} from "@react-email/components";
|
||||
|
||||
export default function BlockedEmailAttempt({
|
||||
blockedEmail,
|
||||
linkName,
|
||||
resourceName,
|
||||
resourceType = "document",
|
||||
locationString,
|
||||
}: {
|
||||
blockedEmail: string;
|
||||
linkName: string;
|
||||
resourceName: string;
|
||||
resourceType?: "document" | "dataroom";
|
||||
locationString?: string;
|
||||
}) {
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>Blocked email attempted to access your {resourceType}</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">
|
||||
Blocked Email Attempted Access
|
||||
</Text>
|
||||
<Text className="text-sm leading-6 text-black">
|
||||
The blocked email <span className="font-semibold">{blockedEmail}</span> just attempted to access your {resourceType} <span className="font-semibold">{resourceName}</span> from the link <span className="font-semibold">{linkName}</span>.
|
||||
{locationString ? (
|
||||
<span>
|
||||
{" "}
|
||||
in <span className="font-semibold">{locationString}</span>
|
||||
</span>
|
||||
) : null}
|
||||
</Text>
|
||||
<Text className="text-sm leading-6 text-black">
|
||||
This email is on your block list and was denied access. No further action is required.
|
||||
</Text>
|
||||
<Hr />
|
||||
<Section className="mt-8 text-gray-400">
|
||||
<Text className="text-xs">
|
||||
© {new Date().getFullYear()} {" "}
|
||||
<a
|
||||
href="https://www.papermark.com"
|
||||
className="text-gray-400 no-underline hover:text-gray-400"
|
||||
target="_blank"
|
||||
>
|
||||
papermark.com
|
||||
</a>
|
||||
</Text>
|
||||
<Text className="text-xs">
|
||||
If you have any feedback or questions about this email, simply reply to it. I'd love to hear from you!
|
||||
</Text>
|
||||
</Section>
|
||||
</Container>
|
||||
</Body>
|
||||
</Tailwind>
|
||||
</Html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import React from "react";
|
||||
|
||||
import {
|
||||
Body,
|
||||
Container,
|
||||
Head,
|
||||
Hr,
|
||||
Html,
|
||||
Preview,
|
||||
Section,
|
||||
Tailwind,
|
||||
Text,
|
||||
} from "@react-email/components";
|
||||
|
||||
export default function BlockedEmailAttempt({
|
||||
blockedEmail,
|
||||
linkName,
|
||||
resourceName,
|
||||
resourceType = "document",
|
||||
timestamp,
|
||||
locationString,
|
||||
accessType,
|
||||
}: {
|
||||
blockedEmail: string;
|
||||
linkName: string;
|
||||
resourceName: string;
|
||||
resourceType?: "document" | "dataroom";
|
||||
timestamp?: string;
|
||||
locationString?: string;
|
||||
accessType?: "global" | "allow" | "deny";
|
||||
}) {
|
||||
const accessTypeTexts = {
|
||||
global:
|
||||
"This email is on your global block list and was denied access. No further action is required.",
|
||||
allow:
|
||||
"This email is not on your link's allow list and was denied access. No further action is required.",
|
||||
deny: "This email is on your link's block list and was denied access. No further action is required.",
|
||||
default: "This email was denied access. No further action is required.",
|
||||
};
|
||||
|
||||
const accessTypeText = accessType
|
||||
? accessTypeTexts[accessType]
|
||||
: accessTypeTexts.default;
|
||||
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>Blocked email attempted to access your {resourceType}</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">
|
||||
Blocked Email Attempted Access
|
||||
</Text>
|
||||
<Text className="text-sm leading-6 text-black">
|
||||
A blocked email attempted to access your {resourceType}:
|
||||
</Text>
|
||||
<Text className="break-all text-sm leading-6 text-black">
|
||||
<ul>
|
||||
<li className="text-sm leading-6 text-black">
|
||||
<span className="font-semibold">Email address:</span>{" "}
|
||||
{blockedEmail}
|
||||
</li>
|
||||
<li className="text-sm leading-6 text-black">
|
||||
<span className="font-semibold">Time:</span>{" "}
|
||||
{timestamp || new Date().toLocaleString()}
|
||||
</li>
|
||||
<li className="text-sm leading-6 text-black">
|
||||
<span className="font-semibold">
|
||||
{resourceType === "dataroom" ? "Dataroom" : "Document"}{" "}
|
||||
name:
|
||||
</span>{" "}
|
||||
{resourceName}
|
||||
</li>
|
||||
<li className="text-sm leading-6 text-black">
|
||||
<span className="font-semibold">Link name:</span> {linkName}
|
||||
</li>
|
||||
</ul>
|
||||
</Text>
|
||||
<Text className="mt-4 text-sm leading-6 text-black">
|
||||
{accessTypeText}
|
||||
</Text>
|
||||
<Hr />
|
||||
<Section className="mt-8 text-gray-400">
|
||||
<Text className="text-xs">
|
||||
© {new Date().getFullYear()}{" "}
|
||||
<a
|
||||
href="https://www.papermark.com"
|
||||
className="text-gray-400 no-underline hover:text-gray-400"
|
||||
target="_blank"
|
||||
>
|
||||
Papermark, Inc.
|
||||
</a>
|
||||
</Text>
|
||||
<Text className="text-xs">
|
||||
If you have any feedback or questions about this email, simply
|
||||
reply to it. I'd love to hear from you!
|
||||
</Text>
|
||||
</Section>
|
||||
</Container>
|
||||
</Body>
|
||||
</Tailwind>
|
||||
</Html>
|
||||
);
|
||||
}
|
||||
1
ee/features/access-notifications/index.ts
Normal file
1
ee/features/access-notifications/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { reportDeniedAccessAttempt } from "./lib/report-denied-access-attempt";
|
||||
@@ -0,0 +1,84 @@
|
||||
import { Link } from "@prisma/client";
|
||||
|
||||
import prisma from "@/lib/prisma";
|
||||
|
||||
import { sendBlockedEmailAttemptNotification } from "./send-blocked-email-attempt";
|
||||
|
||||
export async function reportDeniedAccessAttempt(
|
||||
link: Partial<Link>,
|
||||
email: string,
|
||||
accessType: "global" | "allow" | "deny" = "global",
|
||||
) {
|
||||
if (!link) return;
|
||||
|
||||
// Get all admin and manager emails
|
||||
const users = await prisma.userTeam.findMany({
|
||||
where: {
|
||||
role: { in: ["ADMIN", "MANAGER"] },
|
||||
status: "ACTIVE",
|
||||
teamId: link.teamId!,
|
||||
},
|
||||
select: {
|
||||
user: { select: { email: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const adminManagerEmails = users
|
||||
.map((u) => u.user?.email)
|
||||
.filter((e): e is string => !!e);
|
||||
|
||||
// Get resource info and owner email
|
||||
let resourceType: "dataroom" | "document" = "dataroom";
|
||||
let resourceName = "Dataroom";
|
||||
let ownerEmail: string | undefined;
|
||||
|
||||
if (link.documentId) {
|
||||
resourceType = "document";
|
||||
const document = await prisma.document.findUnique({
|
||||
where: { id: link.documentId },
|
||||
select: { name: true, ownerId: true },
|
||||
});
|
||||
resourceName = document?.name || "Document";
|
||||
|
||||
if (document?.ownerId) {
|
||||
const owner = await prisma.userTeam.findUnique({
|
||||
where: {
|
||||
userId_teamId: {
|
||||
userId: document.ownerId,
|
||||
teamId: link.teamId!,
|
||||
},
|
||||
status: "ACTIVE",
|
||||
},
|
||||
select: { user: { select: { email: true } } },
|
||||
});
|
||||
ownerEmail = owner?.user?.email || undefined;
|
||||
}
|
||||
} else if (link.dataroomId) {
|
||||
const dataroom = await prisma.dataroom.findUnique({
|
||||
where: { id: link.dataroomId },
|
||||
select: { name: true },
|
||||
});
|
||||
resourceName = dataroom?.name || "Dataroom";
|
||||
}
|
||||
|
||||
// Combine all recipients and remove duplicates
|
||||
const allRecipients = [...adminManagerEmails];
|
||||
if (ownerEmail && !allRecipients.includes(ownerEmail)) {
|
||||
allRecipients.push(ownerEmail);
|
||||
}
|
||||
|
||||
// Send email to all recipients
|
||||
if (allRecipients.length > 0) {
|
||||
const [to, ...cc] = allRecipients;
|
||||
await sendBlockedEmailAttemptNotification({
|
||||
to,
|
||||
cc: cc.length > 0 ? cc : undefined,
|
||||
blockedEmail: email,
|
||||
linkName: link.name || `Link #${link.id?.slice(-5)}`,
|
||||
resourceName,
|
||||
resourceType,
|
||||
timestamp: new Date().toLocaleString(),
|
||||
accessType,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { sendEmail } from "@/lib/resend";
|
||||
|
||||
import BlockedEmailAttempt from "../components/blocked-email-attempt";
|
||||
|
||||
export const sendBlockedEmailAttemptNotification = async ({
|
||||
to,
|
||||
cc,
|
||||
blockedEmail,
|
||||
linkName,
|
||||
resourceName,
|
||||
resourceType = "document",
|
||||
timestamp,
|
||||
locationString,
|
||||
accessType,
|
||||
}: {
|
||||
to: string;
|
||||
cc?: string[];
|
||||
blockedEmail: string;
|
||||
linkName: string;
|
||||
resourceName: string;
|
||||
resourceType?: "document" | "dataroom";
|
||||
timestamp?: string;
|
||||
locationString?: string;
|
||||
accessType: "global" | "allow" | "deny";
|
||||
}) => {
|
||||
const emailTemplate = BlockedEmailAttempt({
|
||||
blockedEmail,
|
||||
linkName,
|
||||
resourceName,
|
||||
resourceType,
|
||||
timestamp,
|
||||
locationString,
|
||||
accessType,
|
||||
});
|
||||
try {
|
||||
await sendEmail({
|
||||
to,
|
||||
cc,
|
||||
subject: `Blocked access attempt to ${resourceType}: ${resourceName}`,
|
||||
react: emailTemplate,
|
||||
system: true,
|
||||
test: process.env.NODE_ENV === "development",
|
||||
});
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error };
|
||||
}
|
||||
};
|
||||
@@ -1,41 +0,0 @@
|
||||
import BlockedEmailAttempt from "@/components/emails/blocked-email-attempt";
|
||||
import { sendEmail } from "@/lib/resend";
|
||||
|
||||
export const sendBlockedEmailAttemptNotification = async ({
|
||||
to,
|
||||
cc,
|
||||
blockedEmail,
|
||||
linkName,
|
||||
resourceName,
|
||||
resourceType = "document",
|
||||
locationString,
|
||||
}: {
|
||||
to: string;
|
||||
cc?: string[];
|
||||
blockedEmail: string;
|
||||
linkName: string;
|
||||
resourceName: string;
|
||||
resourceType?: "document" | "dataroom";
|
||||
locationString?: string;
|
||||
}) => {
|
||||
const emailTemplate = BlockedEmailAttempt({
|
||||
blockedEmail,
|
||||
linkName,
|
||||
resourceName,
|
||||
resourceType,
|
||||
locationString,
|
||||
});
|
||||
try {
|
||||
await sendEmail({
|
||||
to,
|
||||
cc,
|
||||
subject: `Blocked email attempted to access your ${resourceType}: ${resourceName}`,
|
||||
react: emailTemplate,
|
||||
system: true,
|
||||
test: process.env.NODE_ENV === "development",
|
||||
});
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error };
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user