feat: Add OAuth support for closecom app linking (#17936)

* feat: Add OAuth support for closecom app linking

* Fix types

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
This commit is contained in:
Anwar Sadath
2024-12-10 00:18:30 +00:00
committed by GitHub
co-authored by Anik Dhabal Babu
parent 9c9d7fe0c3
commit 0657a96795
17 changed files with 294 additions and 280 deletions
+2 -1
View File
@@ -239,7 +239,8 @@ CLOUDFLARE_TURNSTILE_SECRET=
NEXT_PUBLIC_TEAM_IMPERSONATION=false
# Close.com internal CRM
CLOSECOM_API_KEY=
CLOSECOM_CLIENT_ID=
CLOSECOM_CLIENT_SECRET=
# Sendgrid internal sync service
SENDGRID_SYNC_API_KEY=
@@ -232,7 +232,7 @@ export const ConfigureStepCard: FC<ConfigureStepCardProps> = (props) => {
size="md"
className="inline-flex justify-center"
/>
<p className="text-subtle block">{group.slug}</p>
<p className="text-subtle block pl-2">{group.slug}</p>
</div>
)}
<EventTypeGroup
@@ -12,7 +12,6 @@ export const AppSetupMap = {
"ics-feed": dynamic(() => import("../../ics-feedcalendar/pages/setup")),
zapier: dynamic(() => import("../../zapier/pages/setup")),
make: dynamic(() => import("../../make/pages/setup")),
closecom: dynamic(() => import("../../closecom/pages/setup")),
sendgrid: dynamic(() => import("../../sendgrid/pages/setup")),
stripe: dynamic(() => import("../../stripepayment/pages/setup")),
paypal: dynamic(() => import("../../paypal/pages/setup")),
+27 -7
View File
@@ -1,13 +1,33 @@
import type { NextApiRequest, NextApiResponse } from "next";
import checkSession from "../../_utils/auth";
import { checkInstalled } from "../../_utils/installation";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { defaultResponder } from "@calcom/lib/server";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const session = checkSession(req);
await checkInstalled("closecom", session.user?.id);
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
import { encodeOAuthState } from "../../_utils/oauth/encodeOAuthState";
const returnTo = req.query.returnTo;
async function handler(req: NextApiRequest, res: NextApiResponse) {
if (!req.session?.user?.id) {
return res.status(401).json({ message: "You must be logged in to do this" });
}
return res.status(200).json({ url: `/apps/closecom/setup${returnTo ? `?returnTo=${returnTo}` : ""}` });
const { client_id } = await getAppKeysFromSlug("closecom");
if (!client_id || typeof client_id !== "string")
return res.status(400).json({ message: "Close.com client_id missing." });
const state = encodeOAuthState(req) ?? "";
const params = new URLSearchParams({
client_id,
redirect_uri: `${WEBAPP_URL}/api/integrations/closecom/callback`,
response_type: "code",
state,
});
return res.status(200).json({
url: `https://app.close.com/oauth2/authorize/?${params.toString()}`,
});
}
export default defaultResponder(handler);
@@ -1,45 +0,0 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { symmetricEncrypt } from "@calcom/lib/crypto";
import { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
import { defaultResponder } from "@calcom/lib/server";
import prisma from "@calcom/prisma";
import checkSession from "../../_utils/auth";
import getInstalledAppPath from "../../_utils/getInstalledAppPath";
import appConfig from "../config.json";
export async function getHandler(req: NextApiRequest, res: NextApiResponse) {
const session = checkSession(req);
const { api_key } = req.body;
if (!api_key) throw new HttpError({ statusCode: 400, message: "No Api Key provoided to check" });
const encrypted = symmetricEncrypt(JSON.stringify({ api_key }), process.env.CALENDSO_ENCRYPTION_KEY || "");
const data = {
type: appConfig.type,
key: { encrypted },
userId: session.user?.id,
appId: appConfig.slug,
};
try {
await prisma.credential.create({
data,
select: {
id: true,
},
});
} catch (reason) {
logger.error("Could not add Close.com app", reason);
return res.status(500).json({ message: "Could not add Close.com app" });
}
return res.status(200).json({
url: req.query.returnTo ? req.query.returnTo : getInstalledAppPath({ variant: "crm", slug: "closecom" }),
});
}
export default defaultResponder(getHandler);
@@ -1,29 +0,0 @@
import type { NextApiRequest, NextApiResponse } from "next";
import CloseCom from "@calcom/lib/CloseCom";
import { HttpError } from "@calcom/lib/http-error";
import { defaultResponder } from "@calcom/lib/server";
import checkSession from "../../_utils/auth";
export async function getHandler(req: NextApiRequest, res: NextApiResponse) {
const { api_key } = req.body;
if (!api_key) throw new HttpError({ statusCode: 400, message: "No Api Key provoided to check" });
checkSession(req);
const closeCom: CloseCom = new CloseCom(api_key);
try {
const userInfo = await closeCom.me();
if (userInfo.first_name) {
return res.status(200).end();
} else {
return res.status(404).end();
}
} catch (e) {
return res.status(500).json({ message: e });
}
}
export default defaultResponder(getHandler);
-1
View File
@@ -2,5 +2,4 @@ import { defaultHandler } from "@calcom/lib/server";
export default defaultHandler({
GET: import("./_getAdd"),
POST: import("./_postAdd"),
});
@@ -0,0 +1,88 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
import { HttpError } from "@calcom/lib/http-error";
import { defaultHandler, defaultResponder } from "@calcom/lib/server";
import prisma from "@calcom/prisma";
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
import getInstalledAppPath from "../../_utils/getInstalledAppPath";
import { decodeOAuthState } from "../../_utils/oauth/decodeOAuthState";
import appConfig from "../config.json";
async function getHandler(req: NextApiRequest, res: NextApiResponse) {
const { code } = req.query;
const state = decodeOAuthState(req);
const redirectAfterSuccess =
getSafeRedirectUrl(state?.returnTo) ??
getInstalledAppPath({ variant: appConfig.variant, slug: appConfig.slug });
const redirectAfterSuccessOrError = getSafeRedirectUrl(state?.onErrorReturnTo) ?? redirectAfterSuccess;
if (!code || typeof code !== "string") {
if (state?.onErrorReturnTo || state?.returnTo) {
res.redirect(redirectAfterSuccessOrError);
return;
}
throw new HttpError({ statusCode: 400, message: "`code` must be a string" });
}
if (!req.session?.user?.id) {
throw new HttpError({ statusCode: 401, message: "You must be logged in to do this" });
}
const { client_id, client_secret } = await getAppKeysFromSlug("closecom");
if (!client_id || typeof client_id !== "string")
return res.status(400).json({ message: "Close.com client_id missing." });
if (!client_secret || typeof client_secret !== "string")
return res.status(400).json({ message: "Close.com client_secret missing." });
try {
const response = await fetch("https://api.close.com/oauth2/token/", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
client_id,
client_secret,
grant_type: "authorization_code",
code: code as string,
redirect_uri: `${WEBAPP_URL}/api/integrations/closecom/callback`,
}),
});
if (!response.ok) {
throw new Error("Failed to get access token");
}
const responseJson = await response.json();
const { access_token, refresh_token, expires_in } = responseJson;
const expires_at = Date.now() + expires_in * 1000;
await prisma.credential.create({
data: {
type: "closecom_crm",
key: {
access_token,
refresh_token,
expires_at,
},
userId: req.session.user.id,
appId: "closecom",
},
});
return res.redirect(redirectAfterSuccess);
} catch (error) {
console.error("Error getting close.com access token", error);
return res.redirect(`${redirectAfterSuccessOrError}?error=Error getting close.com access token`);
}
}
export default defaultHandler({
GET: Promise.resolve({ default: defaultResponder(getHandler) }),
});
-5
View File
@@ -1,5 +0,0 @@
import { defaultHandler } from "@calcom/lib/server";
export default defaultHandler({
POST: import("./_postCheck"),
});
+1 -1
View File
@@ -1,2 +1,2 @@
export { default as add } from "./add";
export { default as check } from "./check";
export { default as callback } from "./callback";
+2 -1
View File
@@ -13,5 +13,6 @@
"email": "help@cal.com",
"description": "Close is the inside sales CRM of choice for startups and SMBs. Make more calls, send more emails and close more deals starting today.",
"__createdUsingCli": true,
"isOAuth": false
"isOAuth": true,
"dirName": "closecom"
}
+38 -14
View File
@@ -9,9 +9,23 @@ import type { CalendarEvent } from "@calcom/types/Calendar";
import type { CredentialPayload } from "@calcom/types/Credential";
import type { CRM, ContactCreateInput, CrmEvent, Contact } from "@calcom/types/CrmService";
const apiKeySchema = z.object({
encrypted: z.string(),
});
// Schema that supports both OAuth and API key credentials
const credentialSchema = z
.object({
encrypted: z.string().optional(),
access_token: z.string().optional(),
refresh_token: z.string().optional(),
expires_at: z.number().optional(),
})
.refine(
(data) => {
// Either encrypted (API key) or access_token (OAuth) must be present
return !!data.encrypted || !!data.access_token;
},
{
message: "Either API key or OAuth credentials must be provided",
}
);
const CALENDSO_ENCRYPTION_KEY = process.env.CALENDSO_ENCRYPTION_KEY || "";
@@ -27,10 +41,7 @@ const calComCustomActivityFields: CloseComFieldOptions = [
/**
* Authentication
* Close.com requires Basic Auth for any request to their APIs, which is far from
* ideal considering that such a strategy requires generating an API Key by the
* user and input it in our system. A Setup page was created when trying to install
* Close.com App in order to instruct how to create such resource and to obtain it.
* Close.com provides OAuth for authentication.
*
* Meeting creation
* Close.com does not expose a "Meeting" API, it may be available in the future.
@@ -55,17 +66,30 @@ export default class CloseComCRMService implements CRM {
this.integrationName = "closecom_other_calendar";
this.log = logger.getSubLogger({ prefix: [`[[lib] ${this.integrationName}`] });
const parsedCredentialKey = apiKeySchema.safeParse(credential.key);
const parsedKey = credentialSchema.safeParse(credential.key);
let decrypted;
if (parsedCredentialKey.success) {
decrypted = symmetricDecrypt(parsedCredentialKey.data.encrypted, CALENDSO_ENCRYPTION_KEY);
if (!parsedKey.success) {
throw new Error(
`Invalid credentials for userId ${credential.userId} and appId ${credential.appId}: ${parsedKey.error}`
);
}
// Initialize CloseCom client based on credential type
if (parsedKey.data.encrypted) {
// API key authentication
const decrypted = symmetricDecrypt(parsedKey.data.encrypted, CALENDSO_ENCRYPTION_KEY);
const { api_key } = JSON.parse(decrypted);
this.closeCom = new CloseCom(api_key);
} else if (parsedKey.data.access_token) {
// OAuth authentication
this.closeCom = new CloseCom(parsedKey.data.access_token, {
refresh_token: parsedKey.data.refresh_token,
expires_at: parsedKey.data.expires_at,
isOAuth: true,
userId: credential.userId!,
});
} else {
throw Error(
`No API Key found for userId ${credential.userId} and appId ${credential.appId}: ${parsedCredentialKey.error}`
);
throw new Error("No valid authentication method found");
}
}
@@ -1,160 +0,0 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { Toaster } from "react-hot-toast";
import z from "zod";
import { WebAppURL } from "@calcom/lib/WebAppURL";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery";
import { Button, Form, showToast, TextField } from "@calcom/ui";
const formSchema = z.object({
api_key: z.string(),
});
export default function CloseComSetup() {
const { t } = useLocale();
const router = useRouter();
const query = useRouterQuery();
const [testPassed, setTestPassed] = useState<boolean | undefined>(undefined);
const [testLoading, setTestLoading] = useState<boolean>(false);
const form = useForm<{
api_key: string;
}>({
resolver: zodResolver(formSchema),
});
useEffect(() => {
const timer = setTimeout(() => {
if (testPassed === false) {
setTestPassed(undefined);
}
}, 3000);
return () => clearTimeout(timer);
}, [testPassed]);
return (
<div className="bg-emphasis flex h-screen">
<div className="bg-default m-auto rounded p-5 md:w-[520px] md:p-10">
<div className="flex flex-col space-y-5 md:flex-row md:space-x-5 md:space-y-0">
<div>
{/* eslint-disable @next/next/no-img-element */}
<img
src="/api/app-store/closecom/icon.svg"
alt="Apple Calendar"
className="h-12 w-12 max-w-2xl"
/>
</div>
<div>
<h1 className="text-default">{t("provide_api_key")}</h1>
<div className="mt-1 text-sm">
{t("generate_api_key_description", { appName: "Close.com" })}{" "}
<a
className="text-indigo-400"
href="https://app.close.com/settings/api/"
target="_blank"
rel="noopener noreferrer">
Close.com
</a>
. {t("it_stored_encrypted")}
</div>
<div className="my-2 mt-3">
<Form
form={form}
handleSubmit={async (values) => {
const { returnTo } = query;
const url = new WebAppURL("/api/integrations/closecom/add");
if (returnTo) url.searchParams.append("returnTo", `${returnTo}`);
const res = await fetch(url.href, {
method: "POST",
body: JSON.stringify(values),
headers: {
"Content-Type": "application/json",
},
});
const json = await res.json();
if (res.ok) {
router.push(json.url);
} else {
showToast(json.message, "error");
}
}}>
<fieldset className="space-y-2" disabled={form.formState.isSubmitting}>
<Controller
name="api_key"
control={form.control}
render={({ field: { onBlur, onChange } }) => (
<TextField
className="my-0"
onBlur={onBlur}
disabled={testPassed === true}
name="api_key"
placeholder="api_xyz..."
onChange={async (e) => {
onChange(e.target.value);
form.setValue("api_key", e.target.value);
await form.trigger("api_key");
}}
/>
)}
/>
</fieldset>
<div className="mt-5 justify-end space-x-2 rtl:space-x-reverse sm:mt-4 sm:flex">
<Button type="button" color="secondary" onClick={() => router.back()}>
{t("cancel")}
</Button>
<Button
type="submit"
loading={testLoading}
disabled={testPassed === true}
StartIcon={testPassed === undefined ? undefined : testPassed ? "check" : "x"}
className={
testPassed !== undefined
? testPassed
? " !bg-success hover:bg-success !text-green-700"
: "bg-error hover:bg-error !border-red-700 !text-red-700"
: "secondary"
}
color={testPassed === true ? "minimal" : "secondary"}
onClick={async () => {
const check = await form.trigger("api_key");
if (!check) return;
const api_key = form.getValues("api_key");
setTestLoading(true);
const res = await fetch("/api/integrations/closecom/check", {
method: "POST",
body: JSON.stringify({ api_key }),
headers: {
"Content-Type": "application/json",
},
});
if (res.status === 200) {
setTestPassed(true);
} else {
setTestPassed(false);
}
setTestLoading(false);
}}>
{t(
testPassed !== undefined ? (testPassed ? "test_passed" : "test_failed") : "test_api_key"
)}
</Button>
<Button type="submit" loading={form.formState.isSubmitting}>
{t("save")}
</Button>
</div>
</Form>
</div>
</div>
</div>
</div>
<Toaster position="bottom-right" />
</div>
);
}
+4 -1
View File
@@ -4,4 +4,7 @@ import { eventTypeAppCardZod } from "../eventTypeAppCardZod";
export const appDataSchema = eventTypeAppCardZod;
export const appKeysSchema = z.object({});
export const appKeysSchema = z.object({
client_id: z.string(),
client_secret: z.string(),
});
+122 -12
View File
@@ -1,4 +1,5 @@
import logger from "@calcom/lib/logger";
import prisma from "@calcom/prisma";
export type CloseComLead = {
companyName?: string | null | undefined;
@@ -145,8 +146,6 @@ export type typeCloseComCustomActivityGet = {
type CloseComCustomActivityCustomField<T extends string> = `custom.${T}`;
const environmentApiKey = process.env.CLOSECOM_API_KEY || "";
type CloseComQuery = {
negate?: boolean;
queries?: CloseComQuery[] | CloseComCondition[];
@@ -186,7 +185,7 @@ export type CloseComCustomFieldCreateResponse = {
};
/**
* This class to instance communicating to Close.com APIs requires an API Key.
* This class to instance communicating to Close.com APIs requires an API Key (legacy) or OAuth.
*
* You can either pass to the constructor an API Key or have one defined as an
* environment variable in case the communication to Close.com is just for
@@ -194,13 +193,128 @@ export type CloseComCustomFieldCreateResponse = {
*/
export default class CloseCom {
private apiUrl = "https://api.close.com/api/v1";
private apiKey: string | undefined = undefined;
private api_key: string;
private refresh_token?: string;
private expires_at?: number;
private isOAuth: boolean;
private log: typeof logger;
private userId?: number;
constructor(providedApiKey = "") {
constructor(
apiKey: string,
options?: {
refresh_token?: string;
expires_at?: number;
isOAuth?: boolean;
userId?: number;
}
) {
this.log = logger.getSubLogger({ prefix: [`[[lib] close.com`] });
if (!providedApiKey && !environmentApiKey) throw Error("Close.com Api Key not present");
this.apiKey = providedApiKey || environmentApiKey;
this.api_key = apiKey;
this.refresh_token = options?.refresh_token;
this.expires_at = options?.expires_at;
this.isOAuth = options?.isOAuth || false;
this.userId = options?.userId;
}
private async getHeaders() {
if (this.isOAuth) {
// Check if token needs refresh
if (await this.shouldRefreshToken()) {
await this.refreshAccessToken();
}
return {
Authorization: `Bearer ${this.api_key}`,
"Content-Type": "application/json",
};
}
// API key auth
return {
Authorization: `Basic ${Buffer.from(`${this.api_key}:`).toString("base64")}`,
"Content-Type": "application/json",
};
}
private async shouldRefreshToken(): Promise<boolean> {
if (!this.expires_at) return false;
// Refresh token if it expires in less than 5 minutes
const fiveMinutesInMs = 5 * 60 * 1000;
return Date.now() + fiveMinutesInMs >= this.expires_at;
}
private async refreshAccessToken() {
if (!this.refresh_token) throw new Error("No refresh token available");
if (!this.userId) throw new Error("No userId available for token refresh");
try {
const response = await fetch("https://api.close.com/oauth2/token/", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: this.refresh_token,
client_id: process.env.CLOSECOM_CLIENT_ID!,
client_secret: process.env.CLOSECOM_CLIENT_SECRET!,
}),
});
if (!response.ok) {
throw new Error("Failed to refresh token");
}
const data = await response.json();
this.api_key = data.access_token;
this.refresh_token = data.refresh_token;
this.expires_at = Date.now() + data.expires_in * 1000;
try {
const credential = await prisma.credential.findFirst({
where: {
userId: this.userId,
type: "closecom_crm",
},
select: {
id: true,
},
});
if (!credential?.id) {
throw new Error("Credential not found");
}
await prisma.credential.update({
where: {
id: credential.id,
},
data: {
key: {
access_token: data.access_token,
refresh_token: data.refresh_token,
expires_at: this.expires_at,
},
},
});
} catch (error) {
this.log.error("Failed to update credentials in database", error);
}
return {
access_token: data.access_token,
refresh_token: data.refresh_token,
expires_at: this.expires_at,
};
} catch (error) {
this.log.error("Token refresh process failed", {
error,
userId: this.userId,
});
throw error;
}
}
public me = async () => {
@@ -355,11 +469,7 @@ export default class CloseCom {
data?: Record<string, unknown>;
}) => {
this.log.debug(method, urlPath, query, data);
const credentials = Buffer.from(`${this.apiKey}:`).toString("base64");
const headers = {
Authorization: `Basic ${credentials}`,
"Content-Type": "application/json",
};
const headers = await this.getHeaders();
const queryString = query ? `?${new URLSearchParams(String(query)).toString()}` : "";
return await fetch(`${this.apiUrl}${urlPath}${queryString}`, {
headers,
+7
View File
@@ -385,6 +385,13 @@ export default async function main() {
});
}
if (process.env.CLOSECOM_CLIENT_ID && process.env.CLOSECOM_CLIENT_SECRET) {
await createApp("closecom", "closecom", ["crm"], "closecom_crm", {
client_id: process.env.CLOSECOM_CLIENT_ID,
client_secret: process.env.CLOSECOM_CLIENT_SECRET,
});
}
for (const [, app] of Object.entries(appStoreMetadata)) {
if (app.isTemplate && process.argv[2] !== "seed-templates") {
continue;
+2 -1
View File
@@ -280,7 +280,8 @@
"CALENDSO_ENCRYPTION_KEY",
"CALCOM_CREDENTIAL_SYNC_SECRET",
"CI",
"CLOSECOM_API_KEY",
"CLOSECOM_CLIENT_ID",
"CLOSECOM_CLIENT_SECRET",
"CRON_API_KEY",
"CRON_SECRET",
"CRON_ENABLE_APP_SYNC",