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"