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:
11 files changed
+397
-1
No files matched your search
@@ -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=
|
||||
+5
@@ -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" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
+30
@@ -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,
|
||||
},
|
||||
];
|
||||
@@ -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
@@ -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": {
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user