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:
co-authored by
Anik Dhabal Babu
parent
9c9d7fe0c3
commit
0657a96795
+2
-1
@@ -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")),
|
||||
|
||||
@@ -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);
|
||||
@@ -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) }),
|
||||
});
|
||||
@@ -1,5 +0,0 @@
|
||||
import { defaultHandler } from "@calcom/lib/server";
|
||||
|
||||
export default defaultHandler({
|
||||
POST: import("./_postCheck"),
|
||||
});
|
||||
@@ -1,2 +1,2 @@
|
||||
export { default as add } from "./add";
|
||||
export { default as check } from "./check";
|
||||
export { default as callback } from "./callback";
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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,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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user