fix: disable plain for self hosters (#19853)

* fix: disable plain for self hosters

* fix type error

* test update

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
This commit is contained in:
nizzy
2025-03-13 08:50:16 +00:00
committed by GitHub
co-authored by Udit Takkar
parent 07716474d3
commit 082f8e1b04
4 changed files with 224 additions and 199 deletions
+6
View File
@@ -5,6 +5,7 @@ import { NextResponse } from "next/server";
import { z } from "zod";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { IS_PLAIN_CHAT_ENABLED } from "@calcom/lib/constants";
import prisma from "@calcom/prisma";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
@@ -20,6 +21,11 @@ const responseSchema = z.object({
});
async function handler() {
// Early return if Plain Chat is not enabled
if (!IS_PLAIN_CHAT_ENABLED) {
return NextResponse.json({ error: "Plain Chat is not enabled" }, { status: 404 });
}
const session = await getServerSession({ req: buildLegacyRequest(headers(), cookies()) });
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorized - No session email found" }, { status: 401 });
+202 -199
View File
@@ -5,6 +5,8 @@ import { usePathname, useSearchParams } from "next/navigation";
import Script from "next/script";
import { useEffect, useState, useCallback, useMemo } from "react";
import { IS_PLAIN_CHAT_ENABLED } from "@calcom/lib/constants";
declare global {
interface Window {
Plain?: {
@@ -75,228 +77,229 @@ interface PlainChatConfig {
};
}
const PlainChat = () => {
const [config, setConfig] = useState<PlainChatConfig | null>(null);
const [isSmallScreen, setIsSmallScreen] = useState(false);
const { data: session } = useSession();
const pathname = usePathname();
const searchParams = useSearchParams();
const PlainChat = IS_PLAIN_CHAT_ENABLED
? () => {
const [config, setConfig] = useState<PlainChatConfig | null>(null);
const [isSmallScreen, setIsSmallScreen] = useState(false);
const { data: session } = useSession();
const pathname = usePathname();
const searchParams = useSearchParams();
const shouldOpenPlain = pathname === "/event-types" && searchParams?.has("openPlain");
const userEmail = session?.user?.email;
const shouldOpenPlain = pathname === "/event-types" && searchParams?.has("openPlain");
const userEmail = session?.user?.email;
const isAppDomain = useMemo(() => {
const restrictedPathsSet = new Set(
(process.env.NEXT_PUBLIC_PLAIN_CHAT_EXCLUDED_PATHS?.split(",") || []).map((path) => path.trim())
);
const isAppDomain = useMemo(() => {
const restrictedPathsSet = new Set(
(process.env.NEXT_PUBLIC_PLAIN_CHAT_EXCLUDED_PATHS?.split(",") || []).map((path) => path.trim())
);
const pathSegments = pathname?.split("/").filter(Boolean) || [];
const pathSegments = pathname?.split("/").filter(Boolean) || [];
return (
typeof window !== "undefined" &&
window.location.origin === process.env.NEXT_PUBLIC_WEBAPP_URL &&
!pathSegments.some((segment) => restrictedPathsSet.has(segment))
);
}, [pathname]);
return (
typeof window !== "undefined" &&
window.location.origin === process.env.NEXT_PUBLIC_WEBAPP_URL &&
!pathSegments.some((segment) => restrictedPathsSet.has(segment))
);
}, [pathname]);
const checkScreenSize = useCallback(() => {
if (typeof window === "undefined") return;
const checkScreenSize = useCallback(() => {
if (typeof window === "undefined") return;
const isSmall = window.innerWidth < 768;
setIsSmallScreen(isSmall);
const isSmall = window.innerWidth < 768;
setIsSmallScreen(isSmall);
if (isSmall && window.Plain) {
const plainElement = document.querySelector("#plain-container");
plainElement?.remove();
window.Plain = undefined;
} else if (!isSmall && window.Plain === undefined) {
window.plainScriptLoaded?.();
}
}, []);
if (isSmall && window.Plain) {
const plainElement = document.querySelector("#plain-container");
plainElement?.remove();
window.Plain = undefined;
} else if (!isSmall && window.Plain === undefined) {
window.plainScriptLoaded?.();
}
}, []);
const initConfig = useCallback(async () => {
if (!userEmail) return;
const initConfig = useCallback(async () => {
if (!userEmail) return;
try {
const response = await fetch("/api/plain-hash", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
});
// Check if Plain Chat is enabled
if (!IS_PLAIN_CHAT_ENABLED) {
return;
}
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Failed to generate hash: ${errorText}`);
}
const data = await response.json();
if (!data.hash || !data.email || !data.appId) {
throw new Error("Missing required fields in API response");
}
const plainChatConfig: PlainChatConfig = {
appId: data.appId,
customerDetails: {
email: data.email,
shortName: data.shortName,
fullName: data.fullName,
emailHash: data.hash,
chatAvatarUrl: data.chatAvatarUrl,
},
links: [
{
icon: "book",
text: "Documentation",
url: "https://cal.com/docs",
},
{
icon: "chat",
text: "Ask the community",
url: "https://github.com/calcom/cal.com/discussions",
},
],
chatButtons: [
{
icon: "chat",
text: "Ask a question",
threadDetails: {
labelTypeIds: ["lt_01JFJWNWAC464N8DZ6YE71YJRF"],
tierIdentifier: { externalId: data.userTier },
try {
const response = await fetch("/api/plain-hash", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
},
{
icon: "bulb",
text: "Send feedback",
threadDetails: {
labelTypeIds: ["lt_01JFJWP3KECF1YQES6XF212RFW"],
tierIdentifier: { externalId: data.userTier },
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Failed to generate hash: ${errorText}`);
}
const data = await response.json();
if (!data.hash || !data.email || !data.appId) {
throw new Error("Missing required fields in API response");
}
const plainChatConfig: PlainChatConfig = {
appId: data.appId,
customerDetails: {
email: data.email,
shortName: data.shortName,
fullName: data.fullName,
emailHash: data.hash,
chatAvatarUrl: data.chatAvatarUrl,
},
},
{
icon: "error",
text: "Report an issue",
form: {
fields: [
{
type: "dropdown",
placeholder: "Select severity...",
options: [
links: [
{
icon: "book",
text: "Documentation",
url: "https://cal.com/docs",
},
{
icon: "chat",
text: "Ask the community",
url: "https://github.com/calcom/cal.com/discussions",
},
],
chatButtons: [
{
icon: "chat",
text: "Ask a question",
threadDetails: {
labelTypeIds: ["lt_01JFJWNWAC464N8DZ6YE71YJRF"],
tierIdentifier: { externalId: data.userTier },
},
},
{
icon: "bulb",
text: "Send feedback",
threadDetails: {
labelTypeIds: ["lt_01JFJWP3KECF1YQES6XF212RFW"],
tierIdentifier: { externalId: data.userTier },
},
},
{
icon: "error",
text: "Report an issue",
form: {
fields: [
{
icon: "support",
text: "I'm unable to use the app",
threadDetails: {
labelTypeIds: ["lt_01JFJWNWAC464N8DZ6YE71YJRF"],
tierIdentifier: { externalId: data.userTier },
},
onClick: () => {
console.log("User selected severity: Unable to use app");
},
},
{
icon: "error",
text: "Major functionality degraded",
threadDetails: {
labelTypeIds: ["lt_01JFJWP3KECF1YQES6XF212RFW"],
tierIdentifier: { externalId: data.userTier },
},
onClick: () => {
console.log("User selected severity: Major functionality degraded");
},
},
{
icon: "bug",
text: "Minor annoyance",
threadDetails: {
labelTypeIds: ["lt_01JFJWPC8ADW0PK28JHMJR6NSS"],
tierIdentifier: { externalId: data.userTier },
},
onClick: () => {
console.log("User selected severity: Minor annoyance");
},
type: "dropdown",
placeholder: "Select severity...",
options: [
{
icon: "support",
text: "I'm unable to use the app",
threadDetails: {
labelTypeIds: ["lt_01JFJWNWAC464N8DZ6YE71YJRF"],
tierIdentifier: { externalId: data.userTier },
},
},
{
icon: "error",
text: "Major functionality degraded",
threadDetails: {
labelTypeIds: ["lt_01JFJWP3KECF1YQES6XF212RFW"],
tierIdentifier: { externalId: data.userTier },
},
},
{
icon: "bug",
text: "Minor annoyance",
threadDetails: {
labelTypeIds: ["lt_01JFJWPC8ADW0PK28JHMJR6NSS"],
tierIdentifier: { externalId: data.userTier },
},
},
],
},
],
},
],
},
],
entryPoint: {
type: "chat",
},
},
],
entryPoint: {
type: "chat",
},
hideBranding: true,
theme: "auto",
style: {
brandColor: "#FFFFFF",
launcherBackgroundColor: "#262626",
launcherIconColor: "#FFFFFF",
},
position: {
zIndex: "1",
bottom: "20px",
right: "20px",
},
};
hideBranding: true,
theme: "auto",
style: {
brandColor: "#FFFFFF",
launcherBackgroundColor: "#262626",
launcherIconColor: "#FFFFFF",
},
position: {
zIndex: "1",
bottom: "20px",
right: "20px",
},
};
if (process.env.NODE_ENV === "development" || process.env.NODE_ENV === "test") {
window.__PLAIN_CONFIG__ = plainChatConfig;
}
setConfig(plainChatConfig);
if (shouldOpenPlain) {
const timer = setTimeout(() => {
if (window.Plain) {
window.Plain.open();
if (process.env.NODE_ENV === "development" || process.env.NODE_ENV === "test") {
window.__PLAIN_CONFIG__ = plainChatConfig;
}
}, 100);
return () => clearTimeout(timer);
}
} catch (error) {
console.error("Failed to initialize Plain Chat:", error);
}
}, [userEmail, shouldOpenPlain]);
useEffect(() => {
if (!isAppDomain) return;
setConfig(plainChatConfig);
checkScreenSize();
window.addEventListener("resize", checkScreenSize);
initConfig();
return () => window.removeEventListener("resize", checkScreenSize);
}, [isAppDomain, checkScreenSize, initConfig, userEmail]);
const plainChatScript = `
window.plainScriptLoaded = function() {
if (window.Plain && ${Boolean(config)}) {
try {
Plain.init(${config ? JSON.stringify(config) : null});
if (shouldOpenPlain) {
const timer = setTimeout(() => {
if (window.Plain) {
window.Plain.open();
}
}, 100);
return () => clearTimeout(timer);
}
} catch (error) {
console.error("Failed to initialize Plain:", error);
console.error("Failed to initialize Plain Chat:", error);
}
}
}, [userEmail, shouldOpenPlain]);
useEffect(() => {
if (!isAppDomain) return;
// Skip initialization if Plain Chat is not enabled
if (!IS_PLAIN_CHAT_ENABLED) return;
checkScreenSize();
window.addEventListener("resize", checkScreenSize);
initConfig();
return () => window.removeEventListener("resize", checkScreenSize);
}, [isAppDomain, checkScreenSize, initConfig, userEmail]);
const plainChatScript = `
window.plainScriptLoaded = function() {
if (window.Plain && ${Boolean(config)}) {
try {
Plain.init(${config ? JSON.stringify(config) : null});
} catch (error) {
console.error("Failed to initialize Plain:", error);
}
}
}
`;
if (!isAppDomain || isSmallScreen || !config || typeof window === "undefined") return null;
return (
<>
<Script
id="plain-chat"
src="https://chat.cdn-plain.com/index.js"
strategy="afterInteractive"
onLoad={() => window.plainScriptLoaded?.()}
/>
<Script
id="plain-chat-init"
strategy="afterInteractive"
dangerouslySetInnerHTML={{ __html: plainChatScript }}
/>
</>
);
}
`;
if (!isAppDomain || isSmallScreen || !config || typeof window === "undefined") return null;
return (
<>
<Script
id="plain-chat"
src="https://chat.cdn-plain.com/index.js"
strategy="afterInteractive"
onLoad={() => window.plainScriptLoaded?.()}
/>
<Script
id="plain-chat-init"
strategy="afterInteractive"
dangerouslySetInnerHTML={{ __html: plainChatScript }}
/>
</>
);
};
: () => null;
export default PlainChat;
+13
View File
@@ -5,6 +5,7 @@ import type { NextRequest } from "next/server";
import { describe, it, expect, vi, beforeAll, beforeEach } from "vitest";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { IS_PLAIN_CHAT_ENABLED } from "@calcom/lib/constants";
import { POST } from "../../app/api/plain-hash/route";
@@ -47,12 +48,24 @@ describe("Plain Integration API", () => {
beforeAll(() => {
process.env.PLAIN_CHAT_HMAC_SECRET_KEY = "test-secret";
process.env.NEXT_PUBLIC_PLAIN_CHAT_ID = "test-chat-id";
// Force the feature to be enabled for tests
vi.mock("@calcom/lib/constants", () => ({
IS_PLAIN_CHAT_ENABLED: true,
}));
});
beforeEach(() => {
vi.resetAllMocks();
});
// Skip all tests if the feature is disabled
if (!IS_PLAIN_CHAT_ENABLED) {
it("skips tests when Plain Chat is disabled", () => {
console.log("Plain Chat is disabled, skipping tests");
});
return;
}
const tiers = [
{ tier: "free", teams: [] },
{ tier: "teams", teams: [{ team: { id: 2 } }] },
+3
View File
@@ -213,3 +213,6 @@ export const GOOGLE_CALENDAR_SCOPES = [
"https://www.googleapis.com/auth/calendar.readonly",
];
export const DIRECTORY_IDS_TO_LOG = process.env.DIRECTORY_IDS_TO_LOG?.split(",") || [];
export const IS_PLAIN_CHAT_ENABLED =
!!process.env.NEXT_PUBLIC_PLAIN_CHAT_ID && process.env.NEXT_PUBLIC_PLAIN_CHAT_ID !== "";