diff --git a/.env.example b/.env.example index c14b676e35..ab656ae549 100644 --- a/.env.example +++ b/.env.example @@ -509,3 +509,10 @@ TRIGGER_DEV_PROJECT_REF= GOOGLE_ADS_ENABLED=1 # To enable Google Ads tracking (gclid) LINKEDIN_ADS_ENABLED=1 # To enable LinkedIn Ads tracking (li_fat_id) + +# - BACKBLAZE B2 (Compliance Documents) ************************************************************************* +# Used for storing and serving compliance documents (SOC 2, ISO 27001, pentest reports) +B2_APPLICATION_KEY_ID= +B2_APPLICATION_KEY= +B2_BUCKET_ID= +B2_BUCKET_NAME= diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/SettingsLayoutAppDirClient.tsx b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/SettingsLayoutAppDirClient.tsx index a861c2d51c..1dded19804 100644 --- a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/SettingsLayoutAppDirClient.tsx +++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/SettingsLayoutAppDirClient.tsx @@ -97,6 +97,11 @@ const getTabs = (orgBranding: OrganizationBranding | null) => { href: "/settings/security/two-factor-auth", trackingMetadata: { section: "security", page: "2fa_auth" }, }, + { + name: "compliance", + href: "/settings/security/compliance", + trackingMetadata: { section: "security", page: "compliance" }, + }, ], }, { diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/security/compliance/page.tsx b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/security/compliance/page.tsx new file mode 100644 index 0000000000..f93ed0a43d --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/security/compliance/page.tsx @@ -0,0 +1,30 @@ +import { _generateMetadata } from "app/_utils"; +import { getTranslate } from "app/_utils"; + +import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader"; + +import ComplianceView from "~/settings/security/compliance-view"; + +export const generateMetadata = async () => + await _generateMetadata( + (t) => t("compliance"), + (t) => t("compliance_description"), + undefined, + undefined, + "/settings/security/compliance" + ); + +const Page = async () => { + const t = await getTranslate(); + + return ( + + + + ); +}; + +export default Page; diff --git a/apps/web/app/api/compliance/download/route.ts b/apps/web/app/api/compliance/download/route.ts new file mode 100644 index 0000000000..b3ea38f151 --- /dev/null +++ b/apps/web/app/api/compliance/download/route.ts @@ -0,0 +1,74 @@ +import B2 from "backblaze-b2"; +import { cookies, headers } from "next/headers"; +import { NextResponse } from "next/server"; + +import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; + +import { buildLegacyRequest } from "@lib/buildLegacyCtx"; + +import { COMPLIANCE_DOCUMENTS } from "~/settings/security/compliance/compliance-documents"; + +const b2 = new B2({ + applicationKeyId: process.env.B2_APPLICATION_KEY_ID || "", + applicationKey: process.env.B2_APPLICATION_KEY || "", +}); + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const documentId = searchParams.get("id"); + + if (!documentId) { + return NextResponse.json({ error: "Missing document ID" }, { status: 400 }); + } + + // Find the document in our config + const document = COMPLIANCE_DOCUMENTS.find((doc) => doc.id === documentId); + if (!document) { + return NextResponse.json({ error: "Document not found" }, { status: 404 }); + } + + // Only handle B2 documents through this route + if (document.source.type !== "b2") { + return NextResponse.json({ error: "Invalid document type" }, { status: 400 }); + } + + // Check authentication + const legacyReq = buildLegacyRequest(await headers(), await cookies()); + const session = await getServerSession({ req: legacyReq }); + + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + // Check if document is restricted and user has access + if (document.restricted) { + const hasOrgAccess = !!session.user?.org?.id; + if (!hasOrgAccess) { + return NextResponse.json({ error: "Upgrade required to access this document" }, { status: 403 }); + } + } + + try { + // Authorize with B2 - this returns the downloadUrl + const { data: authResponse } = await b2.authorize(); + + const bucketName = process.env.B2_BUCKET_NAME || ""; + const fileName = document.source.fileName; + + // Get download authorization token for this specific file + const { data: downloadAuth } = await b2.getDownloadAuthorization({ + bucketId: process.env.B2_BUCKET_ID || "", + fileNamePrefix: fileName, + validDurationInSeconds: 3600, // 1 hour + }); + + // Construct the download URL using downloadUrl from authorize() and token from getDownloadAuthorization() + const downloadUrl = `${authResponse.downloadUrl}/file/${bucketName}/${fileName}?Authorization=${downloadAuth.authorizationToken}`; + + // Redirect to the authorized download URL + return NextResponse.redirect(downloadUrl, { status: 302 }); + } catch (error) { + console.error("Failed to get B2 download URL:", error instanceof Error ? error.message : "Unknown error"); + return NextResponse.json({ error: "Failed to generate download link" }, { status: 500 }); + } +} diff --git a/apps/web/modules/settings/security/compliance-view.tsx b/apps/web/modules/settings/security/compliance-view.tsx new file mode 100644 index 0000000000..f6ad8a15b4 --- /dev/null +++ b/apps/web/modules/settings/security/compliance-view.tsx @@ -0,0 +1,101 @@ +"use client"; + +import { useSession } from "next-auth/react"; + +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { + SkeletonContainer, + SkeletonText, +} from "@calcom/ui/components/skeleton"; + +import { ComplianceDocumentCard } from "./compliance/ComplianceDocumentCard"; +import { + COMPLIANCE_DOCUMENTS, + DOCUMENT_CATEGORIES, +} from "./compliance/compliance-documents"; + +const SkeletonLoader = () => { + return ( + +
+ + + +
+
+ ); +}; + +const ComplianceView = () => { + const { t } = useLocale(); + const session = useSession(); + + if (session.status === "loading") return ; + + // User has access to restricted documents if they're part of an organization + const hasRestrictedAccess = !!session.data?.user?.org?.id; + + const dpaDocuments = COMPLIANCE_DOCUMENTS.filter( + (doc) => doc.category === DOCUMENT_CATEGORIES.DPA + ); + const reportDocuments = COMPLIANCE_DOCUMENTS.filter( + (doc) => doc.category === DOCUMENT_CATEGORIES.REPORTS + ); + const otherDocuments = COMPLIANCE_DOCUMENTS.filter( + (doc) => doc.category === DOCUMENT_CATEGORIES.OTHER + ); + + return ( +
+ {/* DPA Section */} +
+

+ {t("data_privacy")} +

+
+ {dpaDocuments.map((doc) => ( + + ))} +
+
+ + {/* Reports Section */} +
+

+ {t("compliance_reports")} +

+
+ {reportDocuments.map((doc) => ( + + ))} +
+
+ + {/* Other Documents Section */} +
+

+ {t("other_documents")} +

+
+ {otherDocuments.map((doc) => ( + + ))} +
+
+
+ ); +}; + +export default ComplianceView; diff --git a/apps/web/modules/settings/security/compliance/ComplianceDocumentCard.tsx b/apps/web/modules/settings/security/compliance/ComplianceDocumentCard.tsx new file mode 100644 index 0000000000..cbb948e8e8 --- /dev/null +++ b/apps/web/modules/settings/security/compliance/ComplianceDocumentCard.tsx @@ -0,0 +1,66 @@ +"use client"; + +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import classNames from "@calcom/ui/classNames"; +import { Button } from "@calcom/ui/components/button"; +import { Icon } from "@calcom/ui/components/icon"; +import { Tooltip } from "@calcom/ui/components/tooltip"; + +import type { ComplianceDocument } from "./compliance-documents"; + +interface ComplianceDocumentCardProps { + document: ComplianceDocument; + hasAccess: boolean; +} + +export function ComplianceDocumentCard({ document, hasAccess }: ComplianceDocumentCardProps) { + const { t } = useLocale(); + + const getDownloadUrl = () => { + if (document.source.type === "url") { + return document.source.url; + } + // For B2 documents, use our API route + return `/api/compliance/download?id=${document.id}`; + }; + + const handleDownload = () => { + if (hasAccess) { + window.open(getDownloadUrl(), "_blank", "noopener,noreferrer"); + } + }; + + return ( +
+
+
+ +
+
+

{t(document.name)}

+

{t(document.description)}

+
+
+ +
+ {hasAccess ? ( + + ) : ( + + + + + + )} +
+
+ ); +} diff --git a/apps/web/modules/settings/security/compliance/compliance-documents.ts b/apps/web/modules/settings/security/compliance/compliance-documents.ts new file mode 100644 index 0000000000..d1e052d576 --- /dev/null +++ b/apps/web/modules/settings/security/compliance/compliance-documents.ts @@ -0,0 +1,58 @@ +export const DOCUMENT_CATEGORIES = { + DPA: "dpa", + REPORTS: "reports", + OTHER: "other", +} as const; + +export type DocumentCategory = (typeof DOCUMENT_CATEGORIES)[keyof typeof DOCUMENT_CATEGORIES]; + +export type DocumentSource = + | { type: "url"; url: string } + | { type: "b2"; fileName: string }; // fileName is the path in B2 bucket + +export interface ComplianceDocument { + id: string; + name: string; // Translation key + description: string; // Translation key + source: DocumentSource; + category: DocumentCategory; + restricted: boolean; // true = Organizations/Enterprise only +} + +export const COMPLIANCE_DOCUMENTS: ComplianceDocument[] = [ + // DPA Section - External URL + { + id: "dpa", + name: "data_protection_agreement", + description: "dpa_description", + source: { type: "url", url: "https://go.cal.com/dpa" }, + category: DOCUMENT_CATEGORIES.DPA, + restricted: false, + }, + // Reports Section - B2 Storage + { + id: "soc2-report", + name: "soc2_report", + description: "soc2_report_description", + source: { type: "b2", fileName: "SOC2.pdf" }, + category: DOCUMENT_CATEGORIES.REPORTS, + restricted: true, + }, + { + id: "iso27001-cert", + name: "iso27001_certification", + description: "iso27001_description", + source: { type: "b2", fileName: "ISO27001-2022.pdf" }, + category: DOCUMENT_CATEGORIES.REPORTS, + restricted: true, + }, + // Other Documents - B2 Storage + { + id: "pentest-report", + name: "pentest_report", + description: "pentest_report_description", + source: { type: "b2", fileName: "pentest.pdf" }, + category: DOCUMENT_CATEGORIES.OTHER, + restricted: true, + }, +]; diff --git a/apps/web/package.json b/apps/web/package.json index 9a8fb7d20b..8e30e12ac3 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -85,6 +85,7 @@ "@vercel/og": "0.6.3", "accept-language-parser": "1.5.0", "async": "3.2.4", + "backblaze-b2": "^1.7.1", "bcp-47-match": "2.0.3", "bcryptjs": "2.4.3", "botid": "1.5.7", @@ -160,6 +161,7 @@ "@testing-library/react": "13.3.0", "@types/accept-language-parser": "1.5.2", "@types/async": "3.2.15", + "@types/backblaze-b2": "^1", "@types/bcryptjs": "2.4.2", "@types/detect-port": "1.3.2", "@types/dompurify": "3.0.5", diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 1a7aa92746..da9eb99904 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -665,6 +665,22 @@ "invalid_password_hint": "The password must be a minimum of {{passwordLength}} characters long containing at least one number and have a mixture of uppercase and lowercase letters", "incorrect_password": "Password is incorrect.", "incorrect_email_password": "Email or password is incorrect.", + "compliance": "Compliance", + "compliance_description": "Access compliance documents, certifications, and data protection agreements", + "data_privacy": "Data privacy", + "data_protection_agreement": "Data Protection Agreement", + "dpa_description": "Sign our Data Processing Agreement for GDPR compliance", + "compliance_reports": "Compliance reports", + "soc2_report": "SOC 2 Type II report", + "soc2_report_description": "System and Organization Controls report for security and availability", + "iso27001_certification": "ISO 27001 Certification", + "iso27001_description": "Information security management system certification", + "other_documents": "Other documents", + "pentest_report": "Penetration test report", + "pentest_report_description": "Latest security penetration test results and findings", + "organizations_plan": "Organizations", + "upgrade_to_access": "Upgrade to access", + "compliance_upgrade_tooltip": "You need to be on the Organizations plan or higher to access this document.", "use_setting": "Use setting", "am_pm": "am/pm", "time_options": "Time options", diff --git a/turbo.json b/turbo.json index 31fae255e3..e8cdb94061 100644 --- a/turbo.json +++ b/turbo.json @@ -291,7 +291,11 @@ "GOOGLE_ADS_ENABLED", "LINKEDIN_ADS_ENABLED", "SEED_PLATFORM_OAUTH_CLIENT_ID", - "SEED_PLATFORM_OAUTH_CLIENT_SECRET" + "SEED_PLATFORM_OAUTH_CLIENT_SECRET", + "B2_APPLICATION_KEY_ID", + "B2_APPLICATION_KEY", + "B2_BUCKET_ID", + "B2_BUCKET_NAME" ], "tasks": { "@calcom/web#copy-app-store-static": { diff --git a/yarn.lock b/yarn.lock index 79a27a1159..5cbec33c58 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3527,6 +3527,7 @@ __metadata: "@todesktop/client-core": "npm:1.20.0" "@types/accept-language-parser": "npm:1.5.2" "@types/async": "npm:3.2.15" + "@types/backblaze-b2": "npm:^1" "@types/bcryptjs": "npm:2.4.2" "@types/detect-port": "npm:1.3.2" "@types/dompurify": "npm:3.0.5" @@ -3552,6 +3553,7 @@ __metadata: accept-language-parser: "npm:1.5.0" async: "npm:3.2.4" autoprefixer: "npm:10.4.19" + backblaze-b2: "npm:^1.7.1" bcp-47-match: "npm:2.0.3" bcryptjs: "npm:2.4.3" botid: "npm:1.5.7" @@ -15249,6 +15251,15 @@ __metadata: languageName: node linkType: hard +"@types/backblaze-b2@npm:^1": + version: 1.5.6 + resolution: "@types/backblaze-b2@npm:1.5.6" + dependencies: + "@types/node": "npm:*" + checksum: 10/c1f2b445d213a300953091cd009ab9e3a09350f1d133cd664935e9f8ae38339403236c09cb1cfd7e8d04d93e6ef85f8a8d66e2290cf18c9ccecf08ef41efbea0 + languageName: node + linkType: hard + "@types/bcryptjs@npm:2.4.2": version: 2.4.2 resolution: "@types/bcryptjs@npm:2.4.2" @@ -17634,6 +17645,17 @@ __metadata: languageName: node linkType: hard +"axios-retry@npm:^4.5.0": + version: 4.5.0 + resolution: "axios-retry@npm:4.5.0" + dependencies: + is-retry-allowed: "npm:^2.2.0" + peerDependencies: + axios: 0.x || 1.x + checksum: 10/39ed05248757387a44dde94255df8ad54088aece50574c6ce9a1cd02b9e40252f7390285cea54ded04e33a3a549e462d5bdacc8d3178221b7cd40e8aff09ba46 + languageName: node + linkType: hard + "axios@npm:1.13.2": version: 1.13.2 resolution: "axios@npm:1.13.2" @@ -17732,6 +17754,17 @@ __metadata: languageName: node linkType: hard +"backblaze-b2@npm:^1.7.1": + version: 1.7.1 + resolution: "backblaze-b2@npm:1.7.1" + dependencies: + axios: "npm:^1.9.0" + axios-retry: "npm:^4.5.0" + lodash: "npm:^4.17.21" + checksum: 10/b400b371cf1d852e963d079a88a7e92a80f3320cebd5f1046498f53e042396722fe1b7ab7f35d690a42aa5fd5c0a347a82cb380b295b61f96b41889feacfaeae + languageName: node + linkType: hard + "bail@npm:^2.0.0": version: 2.0.2 resolution: "bail@npm:2.0.2"