feat: Add compliance page to settings (#26777)

* Add compliance page to settings

* fix: sanitize error logging in B2 download route

Avoid logging raw error objects which may expose sensitive B2 credentials
or internal information. Use sanitized error message instead.

Co-Authored-By: unknown <>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-01-12 21:50:09 -03:00
1 parent 3a6ca9ef15
commit 5f21f54bae
11 files changed
+397 -1

No files matched your search

+7
View File
@@ -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=
@@ -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" },
},
],
},
{
@@ -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 (
<SettingsHeader
title={t("compliance")}
description={t("compliance_description")}
borderInShellHeader={true}>
<ComplianceView />
</SettingsHeader>
);
};
export default Page;
@@ -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 });
}
}
@@ -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 (
<SkeletonContainer>
<div className="border-subtle border border-t-0 px-4 py-8 sm:px-6">
<SkeletonText className="h-8 w-full" />
<SkeletonText className="mt-4 h-8 w-full" />
<SkeletonText className="mt-4 h-8 w-full" />
</div>
</SkeletonContainer>
);
};
const ComplianceView = () => {
const { t } = useLocale();
const session = useSession();
if (session.status === "loading") return <SkeletonLoader />;
// 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 (
<div className="border-subtle space-y-8 rounded-b-xl border border-t-0 px-4 py-6 sm:px-6">
{/* DPA Section */}
<section>
<h2 className="text-emphasis mb-4 text-base font-semibold">
{t("data_privacy")}
</h2>
<div className="space-y-3">
{dpaDocuments.map((doc) => (
<ComplianceDocumentCard
key={doc.id}
document={doc}
hasAccess={!doc.restricted || hasRestrictedAccess}
/>
))}
</div>
</section>
{/* Reports Section */}
<section>
<h2 className="text-emphasis mb-4 text-base font-semibold">
{t("compliance_reports")}
</h2>
<div className="space-y-3">
{reportDocuments.map((doc) => (
<ComplianceDocumentCard
key={doc.id}
document={doc}
hasAccess={!doc.restricted || hasRestrictedAccess}
/>
))}
</div>
</section>
{/* Other Documents Section */}
<section>
<h2 className="text-emphasis mb-4 text-base font-semibold">
{t("other_documents")}
</h2>
<div className="space-y-3">
{otherDocuments.map((doc) => (
<ComplianceDocumentCard
key={doc.id}
document={doc}
hasAccess={!doc.restricted || hasRestrictedAccess}
/>
))}
</div>
</section>
</div>
);
};
export default ComplianceView;
@@ -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 (
<div
className={classNames(
"border-subtle bg-default flex items-center justify-between rounded-lg border p-4",
!hasAccess && "opacity-60"
)}>
<div className="flex items-center gap-3">
<div className="bg-subtle flex h-10 w-10 items-center justify-center rounded-lg">
<Icon name="file-text" className="text-default h-5 w-5" />
</div>
<div>
<p className="text-emphasis text-sm font-medium">{t(document.name)}</p>
<p className="text-subtle text-sm">{t(document.description)}</p>
</div>
</div>
<div>
{hasAccess ? (
<Button color="secondary" onClick={handleDownload} StartIcon="download">
{t("download")}
</Button>
) : (
<Tooltip content={t("compliance_upgrade_tooltip")}>
<span>
<Button color="secondary" href="/settings/billing" StartIcon="lock">
{t("upgrade_to_access")}
</Button>
</span>
</Tooltip>
)}
</div>
</div>
);
}
@@ -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,
},
];
+2
View File
@@ -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",
@@ -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",
+5 -1
View File
@@ -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": {
+33
View File
@@ -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"