fix: Credential Syncing Improvements (#14588)
* Add example app to test credential sync * Fixes * Changes to normalize flow of GoogleCalendar and Zoom * Add unit tests * PR Feedback * credential-sync-more-tests-and-more-apps * Fix yarn.lock * Clear cache * Add test * Fix yarn.lock * Fix 204 handling * Fix yarn.lock --------- Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
This commit is contained in:
@@ -299,6 +299,7 @@ E2E_TEST_CALCOM_GCAL_KEYS=
|
||||
CALCOM_CREDENTIAL_SYNC_SECRET=""
|
||||
# This is the header name that will be used to verify the webhook secret. Should be in lowercase
|
||||
CALCOM_CREDENTIAL_SYNC_HEADER_NAME="calcom-credential-sync-secret"
|
||||
# This the endpoint from which the token is fetched
|
||||
CALCOM_CREDENTIAL_SYNC_ENDPOINT=""
|
||||
# Key should match on Cal.com and your application
|
||||
# must be 24 bytes for AES256 encryption algorithm
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { NextApiRequest } from "next";
|
||||
|
||||
import { minimumTokenResponseSchema } from "@calcom/app-store/_utils/oauth/parseRefreshTokenResponse";
|
||||
import { OAuth2UniversalSchema } from "@calcom/app-store/_utils/oauth/universalSchema";
|
||||
import { symmetricDecrypt } from "@calcom/lib/crypto";
|
||||
import { defaultResponder } from "@calcom/lib/server";
|
||||
import prisma from "@calcom/prisma";
|
||||
@@ -63,7 +63,7 @@ async function handler(req: NextApiRequest) {
|
||||
symmetricDecrypt(encryptedKey, process.env.CALCOM_APP_CREDENTIAL_ENCRYPTION_KEY || "")
|
||||
);
|
||||
|
||||
const key = minimumTokenResponseSchema.parse(decryptedKey);
|
||||
const key = OAuth2UniversalSchema.parse(decryptedKey);
|
||||
|
||||
const credential = await prisma.credential.update({
|
||||
where: {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { NextApiRequest } from "next";
|
||||
|
||||
import { getCalendar } from "@calcom/app-store/_utils/getCalendar";
|
||||
import { minimumTokenResponseSchema } from "@calcom/app-store/_utils/oauth/parseRefreshTokenResponse";
|
||||
import { OAuth2UniversalSchema } from "@calcom/app-store/_utils/oauth/universalSchema";
|
||||
import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData";
|
||||
import { symmetricDecrypt } from "@calcom/lib/crypto";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
@@ -70,7 +70,7 @@ async function handler(req: NextApiRequest) {
|
||||
symmetricDecrypt(encryptedKey, process.env.CALCOM_APP_CREDENTIAL_ENCRYPTION_KEY || "")
|
||||
);
|
||||
|
||||
const key = minimumTokenResponseSchema.parse(decryptedKey);
|
||||
const key = OAuth2UniversalSchema.parse(decryptedKey);
|
||||
|
||||
// Need to get app type
|
||||
const app = await prisma.app.findUnique({
|
||||
|
||||
@@ -24,7 +24,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
return res.status(403).json({ message: "Invalid credential sync secret" });
|
||||
}
|
||||
|
||||
const reqBody = appCredentialWebhookRequestBodySchema.parse(req.body);
|
||||
const reqBodyParsed = appCredentialWebhookRequestBodySchema.safeParse(req.body);
|
||||
if (!reqBodyParsed.success) {
|
||||
return res.status(400).json({ error: reqBodyParsed.error.issues });
|
||||
}
|
||||
|
||||
const reqBody = reqBodyParsed.data;
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { id: reqBody.userId } });
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
CALCOM_TEST_USER_ID=1
|
||||
|
||||
GOOGLE_REFRESH_TOKEN=
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
|
||||
ZOOM_REFRESH_TOKEN=
|
||||
ZOOM_CLIENT_ID=
|
||||
ZOOM_CLIENT_SECRET=
|
||||
CALCOM_ADMIN_API_KEY=
|
||||
|
||||
# Refer to Cal.com env variables as these are set in their env
|
||||
CALCOM_CREDENTIAL_SYNC_SECRET="";
|
||||
CALCOM_CREDENTIAL_SYNC_HEADER_NAME="calcom-credential-sync-secret";
|
||||
CALCOM_APP_CREDENTIAL_ENCRYPTION_KEY="";
|
||||
@@ -0,0 +1,9 @@
|
||||
# README
|
||||
|
||||
This is an example app that acts as the source of truth for Cal.com Apps credentials. This app is capable of generating the access_token itself and then sync those to Cal.com app.
|
||||
|
||||
## How to start
|
||||
`yarn dev` starts the server on port 5100. After this open http://localhost:5100 and from there you would be able to manage the tokens for various Apps.
|
||||
|
||||
## Endpoints
|
||||
http://localhost:5100/api/getToken should be set as the value of env variable CALCOM_CREDENTIAL_SYNC_ENDPOINT in Cal.com
|
||||
@@ -0,0 +1,13 @@
|
||||
// How to get it? -> Establish a connection with Google(e.g. through cal.com app) and then copy the refresh_token from there.
|
||||
export const GOOGLE_REFRESH_TOKEN = process.env.GOOGLE_REFRESH_TOKEN;
|
||||
export const GOOGLE_CLIENT_ID = process.env.GOOGLE_CLIENT_ID;
|
||||
export const GOOGLE_CLIENT_SECRET = process.env.GOOGLE_CLIENT_SECRET;
|
||||
|
||||
export const ZOOM_REFRESH_TOKEN = process.env.ZOOM_REFRESH_TOKEN;
|
||||
export const ZOOM_CLIENT_ID = process.env.ZOOM_CLIENT_ID;
|
||||
export const ZOOM_CLIENT_SECRET = process.env.ZOOM_CLIENT_SECRET;
|
||||
export const CALCOM_ADMIN_API_KEY = process.env.CALCOM_ADMIN_API_KEY;
|
||||
|
||||
export const CALCOM_CREDENTIAL_SYNC_SECRET = process.env.CALCOM_CREDENTIAL_SYNC_SECRET;
|
||||
export const CALCOM_CREDENTIAL_SYNC_HEADER_NAME = process.env.CALCOM_CREDENTIAL_SYNC_HEADER_NAME;
|
||||
export const CALCOM_APP_CREDENTIAL_ENCRYPTION_KEY = process.env.CALCOM_APP_CREDENTIAL_ENCRYPTION_KEY;
|
||||
@@ -0,0 +1,89 @@
|
||||
import {
|
||||
GOOGLE_CLIENT_ID,
|
||||
GOOGLE_CLIENT_SECRET,
|
||||
GOOGLE_REFRESH_TOKEN,
|
||||
ZOOM_CLIENT_ID,
|
||||
ZOOM_CLIENT_SECRET,
|
||||
ZOOM_REFRESH_TOKEN,
|
||||
} from "../constants";
|
||||
|
||||
export async function generateGoogleCalendarAccessToken() {
|
||||
const keys = {
|
||||
client_id: GOOGLE_CLIENT_ID,
|
||||
client_secret: GOOGLE_CLIENT_SECRET,
|
||||
redirect_uris: [
|
||||
"http://localhost:3000/api/integrations/googlecalendar/callback",
|
||||
"http://localhost:3000/api/auth/callback/google",
|
||||
],
|
||||
};
|
||||
const clientId = keys.client_id;
|
||||
const clientSecret = keys.client_secret;
|
||||
const refresh_token = GOOGLE_REFRESH_TOKEN;
|
||||
|
||||
const url = "https://oauth2.googleapis.com/token";
|
||||
const data = {
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
refresh_token: refresh_token,
|
||||
grant_type: "refresh_token",
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: new URLSearchParams(data),
|
||||
});
|
||||
|
||||
const json = await response.json();
|
||||
if (json.access_token) {
|
||||
console.log("Access Token:", json.access_token);
|
||||
return json.access_token;
|
||||
} else {
|
||||
console.error("Failed to retrieve access token:", json);
|
||||
return null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching access token:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateZoomAccessToken() {
|
||||
const client_id = ZOOM_CLIENT_ID; // Replace with your client ID
|
||||
const client_secret = ZOOM_CLIENT_SECRET; // Replace with your client secret
|
||||
const refresh_token = ZOOM_REFRESH_TOKEN; // Replace with your refresh token
|
||||
|
||||
const url = "https://zoom.us/oauth/token";
|
||||
const auth = Buffer.from(`${client_id}:${client_secret}`).toString("base64");
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.append("grant_type", "refresh_token");
|
||||
params.append("refresh_token", refresh_token);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Basic ${auth}`,
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: params,
|
||||
});
|
||||
|
||||
const json = await response.json();
|
||||
if (json.access_token) {
|
||||
console.log("New Access Token:", json.access_token);
|
||||
console.log("New Refresh Token:", json.refresh_token); // Save this refresh token securely
|
||||
return json.access_token; // You might also want to return the new refresh token if applicable
|
||||
} else {
|
||||
console.error("Failed to refresh access token:", json);
|
||||
return null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error refreshing access token:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||
@@ -0,0 +1,9 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
require("dotenv").config({ path: "../../.env" });
|
||||
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
transpilePackages: ["@calcom/lib"],
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "@calcom/example-app-credential-sync",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "PORT=5100 next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@calcom/atoms": "*",
|
||||
"@prisma/client": "5.4.2",
|
||||
"next": "14.0.4",
|
||||
"prisma": "^5.7.1",
|
||||
"react": "^18",
|
||||
"react-dom": "^18"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.3.1",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"autoprefixer": "^10.0.1",
|
||||
"dotenv": "^16.3.1",
|
||||
"eslint": "^8",
|
||||
"eslint-config-next": "14.0.4",
|
||||
"postcss": "^8",
|
||||
"tailwindcss": "^3.3.0",
|
||||
"typescript": "^4.9.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { CALCOM_CREDENTIAL_SYNC_HEADER_NAME, CALCOM_CREDENTIAL_SYNC_SECRET } from "../../constants";
|
||||
import { generateGoogleCalendarAccessToken, generateZoomAccessToken } from "../../lib/integrations";
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const secret = req.headers[CALCOM_CREDENTIAL_SYNC_HEADER_NAME];
|
||||
console.log("getToken hit");
|
||||
try {
|
||||
if (!secret) {
|
||||
return res.status(403).json({ message: "secret header not set" });
|
||||
}
|
||||
if (secret !== CALCOM_CREDENTIAL_SYNC_SECRET) {
|
||||
return res.status(403).json({ message: "Invalid secret" });
|
||||
}
|
||||
|
||||
const calcomUserId = req.body.calcomUserId;
|
||||
const appSlug = req.body.appSlug;
|
||||
console.log("getToken Params", {
|
||||
calcomUserId,
|
||||
appSlug,
|
||||
});
|
||||
let accessToken;
|
||||
if (appSlug === "google-calendar") {
|
||||
accessToken = await generateGoogleCalendarAccessToken();
|
||||
} else if (appSlug === "zoom") {
|
||||
accessToken = await generateZoomAccessToken();
|
||||
} else {
|
||||
throw new Error("Unhandled values");
|
||||
}
|
||||
if (!accessToken) {
|
||||
throw new Error("Unable to generate token");
|
||||
}
|
||||
res.status(200).json({
|
||||
_1: true,
|
||||
access_token: accessToken,
|
||||
});
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { NextApiRequest } from "next";
|
||||
|
||||
import { symmetricEncrypt } from "@calcom/lib/crypto";
|
||||
|
||||
import {
|
||||
CALCOM_APP_CREDENTIAL_ENCRYPTION_KEY,
|
||||
CALCOM_CREDENTIAL_SYNC_SECRET,
|
||||
CALCOM_CREDENTIAL_SYNC_HEADER_NAME,
|
||||
CALCOM_ADMIN_API_KEY,
|
||||
} from "../../constants";
|
||||
import { generateGoogleCalendarAccessToken, generateZoomAccessToken } from "../../lib/integrations";
|
||||
|
||||
export default async function handler(req: NextApiRequest, res) {
|
||||
const isInvalid = req.query["invalid"] === "1";
|
||||
const userId = parseInt(req.query["userId"] as string);
|
||||
const appSlug = req.query["appSlug"];
|
||||
|
||||
try {
|
||||
let accessToken;
|
||||
if (appSlug === "google-calendar") {
|
||||
accessToken = await generateGoogleCalendarAccessToken();
|
||||
} else if (appSlug === "zoom") {
|
||||
accessToken = await generateZoomAccessToken();
|
||||
} else {
|
||||
throw new Error(`Unhandled appSlug: ${appSlug}`);
|
||||
}
|
||||
|
||||
if (!accessToken) {
|
||||
return res.status(500).json({ error: "Could not get access token" });
|
||||
}
|
||||
|
||||
const result = await fetch(
|
||||
`http://localhost:3002/api/v1/credential-sync?apiKey=${CALCOM_ADMIN_API_KEY}&userId=${userId}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
[CALCOM_CREDENTIAL_SYNC_HEADER_NAME]: CALCOM_CREDENTIAL_SYNC_SECRET,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
appSlug,
|
||||
encryptedKey: symmetricEncrypt(
|
||||
JSON.stringify({
|
||||
access_token: isInvalid ? "1233231231231" : accessToken,
|
||||
}),
|
||||
CALCOM_APP_CREDENTIAL_ENCRYPTION_KEY
|
||||
),
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
const clonedResult = result.clone();
|
||||
try {
|
||||
if (result.ok) {
|
||||
const json = await result.json();
|
||||
return res.status(200).json(json);
|
||||
} else {
|
||||
return res.status(400).json({ error: await clonedResult.text() });
|
||||
}
|
||||
} catch (e) {
|
||||
return res.status(400).json({ error: await clonedResult.text() });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return res.status(400).json({ message: "Internal Server Error", error: error.message });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useRouter, useSearchParams, usePathname } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export default function Index() {
|
||||
const [data, setData] = useState("");
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const pathname = usePathname();
|
||||
const appSlug = searchParams.get("appSlug");
|
||||
const userId = searchParams.get("userId");
|
||||
|
||||
useEffect(() => {
|
||||
let isRedirectNeeded = false;
|
||||
const newSearchParams = new URLSearchParams(new URL(document.URL).searchParams);
|
||||
if (!userId) {
|
||||
newSearchParams.set("userId", "1");
|
||||
isRedirectNeeded = true;
|
||||
}
|
||||
|
||||
if (!appSlug) {
|
||||
newSearchParams.set("appSlug", "google-calendar");
|
||||
isRedirectNeeded = true;
|
||||
}
|
||||
|
||||
if (isRedirectNeeded) {
|
||||
router.push(`${pathname}?${newSearchParams.toString()}`);
|
||||
}
|
||||
}, [router, pathname, userId, appSlug]);
|
||||
|
||||
async function updateToken({ invalid } = { invalid: false }) {
|
||||
const res = await fetch(
|
||||
`/api/setTokenInCalCom?invalid=${invalid ? 1 : 0}&userId=${userId}&appSlug=${appSlug}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const data = await res.json();
|
||||
setData(JSON.stringify(data));
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Welcome to Credential Sync Playground</h1>
|
||||
<p>
|
||||
You are managing credentials for cal.com <strong>userId={userId}</strong> for{" "}
|
||||
<strong>appSlug={appSlug}</strong>. Update query params to manage a different user or app{" "}
|
||||
</p>
|
||||
<button onClick={() => updateToken({ invalid: true })}>Give an invalid token to Cal.com</button>
|
||||
<button onClick={() => updateToken()}>Give a valid token to Cal.com</button>
|
||||
<div>{data}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": false,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noEmit": true,
|
||||
"incremental": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve"
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
+2
-1
@@ -11,7 +11,8 @@
|
||||
"packages/app-store/*",
|
||||
"packages/app-store/ee/*",
|
||||
"packages/platform/*",
|
||||
"packages/platform/examples/base"
|
||||
"packages/platform/examples/base",
|
||||
"example-apps/*"
|
||||
],
|
||||
"scripts": {
|
||||
"app-store-cli": "yarn workspace @calcom/app-store-cli",
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import type Zod from "zod";
|
||||
import type z from "zod";
|
||||
|
||||
import getAppKeysFromSlug from "./getAppKeysFromSlug";
|
||||
|
||||
export async function getParsedAppKeysFromSlug(slug: string, schema: Zod.Schema) {
|
||||
export async function getParsedAppKeysFromSlug<T extends Zod.Schema>(
|
||||
slug: string,
|
||||
schema: T
|
||||
): Promise<z.infer<T>> {
|
||||
const appKeys = await getAppKeysFromSlug(slug);
|
||||
return schema.parse(appKeys);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import prisma from "@calcom/prisma";
|
||||
import type { CredentialPayload } from "@calcom/types/Credential";
|
||||
|
||||
export const invalidateCredential = async (credentialId: CredentialPayload["id"]) => {
|
||||
const credential = await prisma.credential.findUnique({
|
||||
where: {
|
||||
id: credentialId,
|
||||
},
|
||||
});
|
||||
|
||||
if (credential) {
|
||||
await prisma.credential.update({
|
||||
where: {
|
||||
id: credentialId,
|
||||
},
|
||||
data: {
|
||||
invalid: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* This class is used to convert axios like response to fetch response
|
||||
*/
|
||||
export class AxiosLikeResponseToFetchResponse<
|
||||
T extends {
|
||||
status: number;
|
||||
statusText: string;
|
||||
data: unknown;
|
||||
}
|
||||
> extends Response {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
body: any;
|
||||
constructor(axiomResponse: T) {
|
||||
super(JSON.stringify(axiomResponse.data), {
|
||||
status: axiomResponse.status,
|
||||
statusText: axiomResponse.statusText,
|
||||
});
|
||||
}
|
||||
async json() {
|
||||
return super.json() as unknown as T["data"];
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,559 @@
|
||||
/**
|
||||
* Manages OAuth2.0 tokens for an app and resourceOwner. It automatically refreshes the token when needed.
|
||||
* It is aware of the credential sync endpoint and can sync the token from the third party source.
|
||||
* It is unaware of Prisma and App logic. It is just a utility to manage OAuth2.0 tokens with life cycle methods
|
||||
*
|
||||
* For a recommended usage example, see Zoom VideoApiAdapter.ts
|
||||
*/
|
||||
import type { z } from "zod";
|
||||
|
||||
import { CREDENTIAL_SYNC_ENDPOINT } from "@calcom/lib/constants";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
|
||||
import type { AxiosLikeResponseToFetchResponse } from "./AxiosLikeResponseToFetchResponse";
|
||||
import type { OAuth2TokenResponseInDbWhenExistsSchema, OAuth2UniversalSchema } from "./universalSchema";
|
||||
import { OAuth2UniversalSchemaWithCalcomBackwardCompatibility } from "./universalSchema";
|
||||
|
||||
const log = logger.getSubLogger({ prefix: ["app-store/_utils/oauth/OAuthManager"] });
|
||||
export const enum TokenStatus {
|
||||
UNUSABLE_TOKEN_OBJECT,
|
||||
UNUSABLE_ACCESS_TOKEN,
|
||||
INCONCLUSIVE,
|
||||
VALID,
|
||||
}
|
||||
|
||||
type ResourceOwner =
|
||||
| {
|
||||
id: number | null;
|
||||
type: "team";
|
||||
}
|
||||
| {
|
||||
id: number | null;
|
||||
type: "user";
|
||||
};
|
||||
|
||||
type FetchNewTokenObject = ({ refreshToken }: { refreshToken: string | null }) => Promise<Response | null>;
|
||||
type UpdateTokenObject = (
|
||||
token: z.infer<typeof OAuth2UniversalSchemaWithCalcomBackwardCompatibility>
|
||||
) => Promise<void>;
|
||||
type isTokenObjectUnusable = (response: Response) => Promise<{ reason: string } | null>;
|
||||
type isAccessTokenUnusable = (response: Response) => Promise<{ reason: string } | null>;
|
||||
type IsTokenExpired = (token: z.infer<typeof OAuth2UniversalSchema>) => Promise<boolean> | boolean;
|
||||
type InvalidateTokenObject = () => Promise<void>;
|
||||
type ExpireAccessToken = () => Promise<void>;
|
||||
type CredentialSyncVariables = {
|
||||
/**
|
||||
* The secret required to access the credential sync endpoint
|
||||
*/
|
||||
CREDENTIAL_SYNC_SECRET: string | undefined;
|
||||
/**
|
||||
* The header name that the secret should be passed in
|
||||
*/
|
||||
CREDENTIAL_SYNC_SECRET_HEADER_NAME: string;
|
||||
/**
|
||||
* The endpoint where the credential sync should happen
|
||||
*/
|
||||
CREDENTIAL_SYNC_ENDPOINT: string | undefined;
|
||||
|
||||
APP_CREDENTIAL_SHARING_ENABLED: boolean;
|
||||
};
|
||||
/**
|
||||
* Manages OAuth2.0 tokens for an app and resourceOwner
|
||||
* If expiry_date or expires_in isn't provided in token then it is considered expired immediately(if credential sync is not enabled)
|
||||
* If credential sync is enabled, the token is considered expired after a year. It is expected to be refreshed by the API request from the credential source(as it knows when the token is expired)
|
||||
*/
|
||||
export class OAuthManager {
|
||||
private currentTokenObject: z.infer<typeof OAuth2UniversalSchema>;
|
||||
private resourceOwner: ResourceOwner;
|
||||
private appSlug: string;
|
||||
private fetchNewTokenObject: FetchNewTokenObject;
|
||||
private updateTokenObject: UpdateTokenObject;
|
||||
private isTokenObjectUnusable: isTokenObjectUnusable;
|
||||
private isAccessTokenUnusable: isAccessTokenUnusable;
|
||||
private isTokenExpired: IsTokenExpired;
|
||||
private invalidateTokenObject: InvalidateTokenObject;
|
||||
private expireAccessToken: ExpireAccessToken;
|
||||
private credentialSyncVariables: CredentialSyncVariables;
|
||||
private useCredentialSync: boolean;
|
||||
private autoCheckTokenExpiryOnRequest: boolean;
|
||||
|
||||
constructor({
|
||||
resourceOwner,
|
||||
appSlug,
|
||||
currentTokenObject,
|
||||
fetchNewTokenObject,
|
||||
updateTokenObject,
|
||||
isTokenObjectUnusable,
|
||||
isAccessTokenUnusable,
|
||||
invalidateTokenObject,
|
||||
expireAccessToken,
|
||||
credentialSyncVariables,
|
||||
autoCheckTokenExpiryOnRequest = true,
|
||||
isTokenExpired = (token: z.infer<typeof OAuth2TokenResponseInDbWhenExistsSchema>) => {
|
||||
log.debug(
|
||||
"isTokenExpired called",
|
||||
safeStringify({ expiry_date: token.expiry_date, currentTime: Date.now() })
|
||||
);
|
||||
|
||||
return getExpiryDate() <= Date.now();
|
||||
|
||||
function isRelativeToEpoch(relativeTimeInSeconds: number) {
|
||||
return relativeTimeInSeconds > 1000000000; // If it is more than 2001-09-09 it can be considered relative to epoch. Also, that is more than 30 years in future which couldn't possibly be relative to current time
|
||||
}
|
||||
|
||||
function getExpiryDate() {
|
||||
if (token.expiry_date) {
|
||||
return token.expiry_date;
|
||||
}
|
||||
// It is usually in "seconds since now" but due to some integrations logic converting it to "seconds since epoch"(e.g. Office365Calendar has done that) we need to confirm what is the case here.
|
||||
// But we for now know that it is in seconds for sure
|
||||
// If it is not relative to epoch then it would be wrong to use it as it would make the token as non-expired when it could be expired
|
||||
if (token.expires_in && isRelativeToEpoch(token.expires_in)) {
|
||||
return token.expires_in * 1000;
|
||||
}
|
||||
// 0 means it would be expired as Date.now() is greater than that
|
||||
return 0;
|
||||
}
|
||||
},
|
||||
}: {
|
||||
/**
|
||||
* The resource owner for which the token is being managed
|
||||
*/
|
||||
resourceOwner: ResourceOwner;
|
||||
/**
|
||||
* Does response for any request contain information that refresh_token became invalid and thus the entire token object become unusable
|
||||
* Note: Right now, the implementations of this function makes it so that the response is considered invalid(sometimes) even if just access_token is revoked or invalid. In that case, regenerating access token should work. So, we shouldn't mark the token as invalid in that case.
|
||||
* We should instead mark the token as expired. We could do that by introducing isAccessTokenInvalid function
|
||||
*
|
||||
* @param response
|
||||
* @returns
|
||||
*/
|
||||
isTokenObjectUnusable: isTokenObjectUnusable;
|
||||
/**
|
||||
*
|
||||
*/
|
||||
isAccessTokenUnusable: isAccessTokenUnusable;
|
||||
/**
|
||||
* The current token object.
|
||||
*/
|
||||
currentTokenObject: z.infer<typeof OAuth2UniversalSchema>;
|
||||
/**
|
||||
* The unique identifier of the app that the token is for. It is required to do credential syncing in self-hosting
|
||||
*/
|
||||
appSlug: string;
|
||||
/**
|
||||
*
|
||||
* It could be null in case refresh_token isn't available. This is possible when credential sync happens from a third party who doesn't want to share refresh_token and credential syncing has been disabled after the sync has happened.
|
||||
* If credential syncing is still enabled `fetchNewTokenObject` wouldn't be called
|
||||
*/
|
||||
fetchNewTokenObject: FetchNewTokenObject;
|
||||
|
||||
/**
|
||||
* update token object
|
||||
*/
|
||||
updateTokenObject: UpdateTokenObject;
|
||||
/**
|
||||
* Handler to invalidate the token object. It is called when the token object is invalid and credential syncing is disabled
|
||||
*/
|
||||
invalidateTokenObject: InvalidateTokenObject;
|
||||
/*
|
||||
* Handler to expire the access token. It is called when credential syncing is enabled and when the token object expires
|
||||
*/
|
||||
expireAccessToken: ExpireAccessToken;
|
||||
/**
|
||||
* The variables required for credential syncing
|
||||
*/
|
||||
credentialSyncVariables: CredentialSyncVariables;
|
||||
/**
|
||||
* If the token should be checked for expiry before sending a request
|
||||
*/
|
||||
autoCheckTokenExpiryOnRequest?: boolean;
|
||||
/**
|
||||
* If there is a different way to check if the token is expired(and not the standard way of checking expiry_date)
|
||||
*/
|
||||
isTokenExpired?: IsTokenExpired;
|
||||
}) {
|
||||
ensureValidResourceOwner(resourceOwner);
|
||||
this.resourceOwner = resourceOwner;
|
||||
this.currentTokenObject = currentTokenObject;
|
||||
this.appSlug = appSlug;
|
||||
this.fetchNewTokenObject = fetchNewTokenObject;
|
||||
this.isTokenObjectUnusable = isTokenObjectUnusable;
|
||||
this.isAccessTokenUnusable = isAccessTokenUnusable;
|
||||
this.isTokenExpired = isTokenExpired;
|
||||
this.invalidateTokenObject = invalidateTokenObject;
|
||||
this.expireAccessToken = expireAccessToken;
|
||||
this.credentialSyncVariables = credentialSyncVariables;
|
||||
this.useCredentialSync = !!(
|
||||
credentialSyncVariables.APP_CREDENTIAL_SHARING_ENABLED &&
|
||||
credentialSyncVariables.CREDENTIAL_SYNC_ENDPOINT &&
|
||||
credentialSyncVariables.CREDENTIAL_SYNC_SECRET_HEADER_NAME &&
|
||||
credentialSyncVariables.CREDENTIAL_SYNC_SECRET
|
||||
);
|
||||
this.autoCheckTokenExpiryOnRequest = autoCheckTokenExpiryOnRequest;
|
||||
this.updateTokenObject = updateTokenObject;
|
||||
}
|
||||
|
||||
private isResponseNotOkay(response: Response) {
|
||||
return !response.ok || response.status < 200 || response.status >= 300;
|
||||
}
|
||||
|
||||
public async getTokenObjectOrFetch() {
|
||||
const myLog = log.getSubLogger({
|
||||
prefix: [`getTokenObjectOrFetch:appSlug=${this.appSlug}`],
|
||||
});
|
||||
const isExpired = await this.isTokenExpired(this.currentTokenObject);
|
||||
myLog.debug(
|
||||
"getTokenObjectOrFetch called",
|
||||
safeStringify({
|
||||
isExpired,
|
||||
resourceOwner: this.resourceOwner,
|
||||
})
|
||||
);
|
||||
|
||||
if (!isExpired) {
|
||||
myLog.debug("Token is not expired. Returning the current token object");
|
||||
return { token: this.normalizeNewlyReceivedToken(this.currentTokenObject), isUpdated: false };
|
||||
} else {
|
||||
const token = {
|
||||
// Keep the old token object as it is, as some integrations don't send back all the props e.g. refresh_token isn't sent again by Google Calendar
|
||||
// It also allows any other properties set to be retained.
|
||||
// Let's not use normalizedCurrentTokenObject here as `normalizeToken` could possible be not idempotent
|
||||
...this.currentTokenObject,
|
||||
...this.normalizeNewlyReceivedToken(await this.refreshOAuthToken()),
|
||||
};
|
||||
myLog.debug("Token is expired. So, returning new token object");
|
||||
this.currentTokenObject = token;
|
||||
await this.updateTokenObject(token);
|
||||
return { token, isUpdated: true };
|
||||
}
|
||||
}
|
||||
|
||||
public async request(arg: { url: string; options: RequestInit }): Promise<{
|
||||
tokenStatus: TokenStatus;
|
||||
json: unknown;
|
||||
}>;
|
||||
public async request<T>(
|
||||
customFetch: () => Promise<
|
||||
AxiosLikeResponseToFetchResponse<{
|
||||
status: number;
|
||||
statusText: string;
|
||||
data: T;
|
||||
}>
|
||||
>
|
||||
): Promise<{
|
||||
tokenStatus: TokenStatus;
|
||||
json: T;
|
||||
}>;
|
||||
/**
|
||||
* Send request automatically adding the Authorization header with the access token. More importantly, handles token invalidation
|
||||
*/
|
||||
public async request<T>(
|
||||
customFetchOrUrlAndOptions:
|
||||
| { url: string; options: RequestInit }
|
||||
| (() => Promise<
|
||||
AxiosLikeResponseToFetchResponse<{
|
||||
status: number;
|
||||
statusText: string;
|
||||
data: T;
|
||||
}>
|
||||
>)
|
||||
) {
|
||||
let response;
|
||||
const myLog = log.getSubLogger({ prefix: ["request"] });
|
||||
|
||||
if (this.autoCheckTokenExpiryOnRequest) {
|
||||
await this.getTokenObjectOrFetch();
|
||||
}
|
||||
|
||||
if (typeof customFetchOrUrlAndOptions === "function") {
|
||||
myLog.debug("Sending request using customFetch");
|
||||
const customFetch = customFetchOrUrlAndOptions;
|
||||
try {
|
||||
response = await customFetch();
|
||||
} catch (e) {
|
||||
// Get response from error so that code further down can categorize it into tokenUnusable or access token unusable
|
||||
// Those methods accept response only
|
||||
response = handleFetchError(e);
|
||||
}
|
||||
} else {
|
||||
const { url, options } = customFetchOrUrlAndOptions;
|
||||
const headers = {
|
||||
Authorization: `Bearer ${this.currentTokenObject.access_token}`,
|
||||
"Content-Type": "application/json",
|
||||
...options?.headers,
|
||||
};
|
||||
myLog.debug("Sending request using fetch", safeStringify({ customFetchOrUrlAndOptions, headers }));
|
||||
// We don't catch fetch error here because such an error would be temporary and we shouldn't take any action on it.
|
||||
response = await fetch(url, {
|
||||
method: "GET",
|
||||
...options,
|
||||
headers: headers,
|
||||
});
|
||||
}
|
||||
|
||||
myLog.debug(
|
||||
"Response from request",
|
||||
safeStringify({
|
||||
text: await response.clone().text(),
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
})
|
||||
);
|
||||
|
||||
const { tokenStatus, json } = await this.getAndValidateOAuth2Response({
|
||||
response,
|
||||
});
|
||||
|
||||
if (tokenStatus === TokenStatus.UNUSABLE_TOKEN_OBJECT) {
|
||||
// In case of Credential Sync, we expire the token so that through the sync we can refresh the token
|
||||
// TODO: We should consider sending a special 'reason' query param to toke sync endpoint to convey the reason for getting token
|
||||
await this.invalidate();
|
||||
} else if (tokenStatus === TokenStatus.UNUSABLE_ACCESS_TOKEN) {
|
||||
await this.expireAccessToken();
|
||||
} else if (tokenStatus === TokenStatus.INCONCLUSIVE) {
|
||||
await this.onInconclusiveResponse();
|
||||
}
|
||||
|
||||
// We are done categorizing the token status. Now, we can throw back
|
||||
if ("myFetchError" in (json || {})) {
|
||||
throw new Error(json.myFetchError);
|
||||
}
|
||||
|
||||
return { tokenStatus: tokenStatus, json };
|
||||
}
|
||||
|
||||
/**
|
||||
* It doesn't automatically detect the response for tokenObject and accessToken becoming invalid
|
||||
* Could be used when you expect a possible non JSON response as well.
|
||||
*/
|
||||
public async requestRaw({ url, options }: { url: string; options: RequestInit }) {
|
||||
const myLog = log.getSubLogger({ prefix: ["requestRaw"] });
|
||||
myLog.debug("Sending request using fetch", safeStringify({ url, options }));
|
||||
if (this.autoCheckTokenExpiryOnRequest) {
|
||||
await this.getTokenObjectOrFetch();
|
||||
}
|
||||
const headers = {
|
||||
Authorization: `Bearer ${this.currentTokenObject.access_token}`,
|
||||
"Content-Type": "application/json",
|
||||
...options?.headers,
|
||||
};
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
...options,
|
||||
headers: headers,
|
||||
});
|
||||
myLog.debug(
|
||||
"Response from request",
|
||||
safeStringify({
|
||||
text: await response.clone().text(),
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
})
|
||||
);
|
||||
if (this.isResponseNotOkay(response)) {
|
||||
await this.onInconclusiveResponse();
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
private async onInconclusiveResponse() {
|
||||
const myLog = log.getSubLogger({ prefix: ["onInconclusiveResponse"] });
|
||||
myLog.debug("Expiring the access token");
|
||||
// We can't really take any action on inconclusive response
|
||||
// But in case of credential sync we should expire the token so that through the sync we can possibly fix the issue by refreshing the token
|
||||
// It is important because in that cases tokens have an infinite expiry and it is possible that the token is revoked and isAccessUnusable and isTokenObjectUnusable couldn't detect the issue
|
||||
if (this.useCredentialSync) {
|
||||
await this.expireAccessToken();
|
||||
}
|
||||
}
|
||||
|
||||
private async invalidate() {
|
||||
const myLog = log.getSubLogger({ prefix: ["invalidate"] });
|
||||
if (this.useCredentialSync) {
|
||||
myLog.debug("Expiring the access token");
|
||||
// We are not calling it through refreshOAuthToken flow because the token is refreshed already there
|
||||
// There is no point expiring the token as we will probably get the same result in that case.
|
||||
await this.expireAccessToken();
|
||||
} else {
|
||||
myLog.debug("Invalidating the token object");
|
||||
// In case credential sync is enabled there is no point of marking the token as invalid as user doesn't take action on that.
|
||||
// The third party needs to sync the correct credential back which we get done by marking the token as expired.
|
||||
await this.invalidateTokenObject();
|
||||
}
|
||||
}
|
||||
|
||||
private normalizeNewlyReceivedToken(
|
||||
token: z.infer<typeof OAuth2UniversalSchemaWithCalcomBackwardCompatibility>
|
||||
) {
|
||||
if (!token.expiry_date && !token.expires_in) {
|
||||
// Use a practically infinite expiry(a year) for when Credential Sync is enabled. Token is expected to be refreshed by the API request from the credential source.
|
||||
// If credential sync is not enabled, we should consider the token as expired otherwise the token could be considered valid forever
|
||||
token.expiry_date = this.useCredentialSync ? Date.now() + 365 * 24 * 3600 * 1000 : 0;
|
||||
} else if (token.expires_in !== undefined && token.expiry_date === undefined) {
|
||||
token.expiry_date = Math.round(Date.now() + token.expires_in * 1000);
|
||||
|
||||
// As expires_in could be relative to current time, we can't keep it in the token object as it could endup giving wrong absolute expiry_time if outdated value is used
|
||||
// That could happen if we merge token objects which we do
|
||||
delete token.expires_in;
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
// TODO: On regenerating access_token successfully, we should call makeTokenObjectValid(to counter invalidateTokenObject). This should fix stale banner in UI to reconnect when the connection is working
|
||||
private async refreshOAuthToken() {
|
||||
const myLog = log.getSubLogger({ prefix: ["refreshOAuthToken"] });
|
||||
let response;
|
||||
const refreshToken = this.currentTokenObject.refresh_token ?? null;
|
||||
if (this.resourceOwner.id && this.useCredentialSync) {
|
||||
if (
|
||||
!this.credentialSyncVariables.CREDENTIAL_SYNC_SECRET ||
|
||||
!this.credentialSyncVariables.CREDENTIAL_SYNC_SECRET_HEADER_NAME ||
|
||||
!this.credentialSyncVariables.CREDENTIAL_SYNC_ENDPOINT
|
||||
) {
|
||||
throw new Error("Credential syncing is enabled but the required env variables are not set");
|
||||
}
|
||||
myLog.debug(
|
||||
"Refreshing OAuth token from credential sync endpoint",
|
||||
safeStringify({
|
||||
appSlug: this.appSlug,
|
||||
resourceOwner: this.resourceOwner,
|
||||
endpoint: CREDENTIAL_SYNC_ENDPOINT,
|
||||
})
|
||||
);
|
||||
|
||||
try {
|
||||
response = await fetch(`${this.credentialSyncVariables.CREDENTIAL_SYNC_ENDPOINT}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
[this.credentialSyncVariables.CREDENTIAL_SYNC_SECRET_HEADER_NAME]:
|
||||
this.credentialSyncVariables.CREDENTIAL_SYNC_SECRET,
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
calcomUserId: this.resourceOwner.id.toString(),
|
||||
appSlug: this.appSlug,
|
||||
}),
|
||||
});
|
||||
} catch (e) {
|
||||
myLog.error("Could not refresh the token.", safeStringify(e));
|
||||
throw new Error(
|
||||
`Could not refresh the token due to connection issue with the endpoint: ${CREDENTIAL_SYNC_ENDPOINT}`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
myLog.debug(
|
||||
"Refreshing OAuth token",
|
||||
safeStringify({
|
||||
appSlug: this.appSlug,
|
||||
resourceOwner: this.resourceOwner,
|
||||
})
|
||||
);
|
||||
try {
|
||||
response = await this.fetchNewTokenObject({ refreshToken });
|
||||
} catch (e) {
|
||||
response = handleFetchError(e);
|
||||
}
|
||||
if (!response) {
|
||||
throw new Error("`fetchNewTokenObject` could not refresh the token");
|
||||
}
|
||||
}
|
||||
|
||||
const clonedResponse = response.clone();
|
||||
myLog.debug(
|
||||
"Response from refreshOAuthToken",
|
||||
safeStringify({
|
||||
text: await clonedResponse.text(),
|
||||
ok: clonedResponse.ok,
|
||||
status: clonedResponse.status,
|
||||
statusText: clonedResponse.statusText,
|
||||
})
|
||||
);
|
||||
|
||||
const { json, tokenStatus } = await this.getAndValidateOAuth2Response({
|
||||
response,
|
||||
});
|
||||
if (tokenStatus === TokenStatus.UNUSABLE_TOKEN_OBJECT) {
|
||||
await this.invalidateTokenObject();
|
||||
} else if (tokenStatus === TokenStatus.UNUSABLE_ACCESS_TOKEN) {
|
||||
await this.expireAccessToken();
|
||||
}
|
||||
const parsedToken = OAuth2UniversalSchemaWithCalcomBackwardCompatibility.safeParse(json);
|
||||
if (!parsedToken.success) {
|
||||
myLog.error("Token parsing error:", safeStringify(parsedToken.error.issues));
|
||||
throw new Error("Invalid token response");
|
||||
}
|
||||
return parsedToken.data;
|
||||
}
|
||||
|
||||
private async getAndValidateOAuth2Response({ response }: { response: Response }) {
|
||||
const myLog = log.getSubLogger({ prefix: ["getAndValidateOAuth2Response"] });
|
||||
const clonedResponse = response.clone();
|
||||
|
||||
// handle empty response (causes crash otherwise on doing json() as "" is invalid JSON) which is valid in some cases like PATCH calls(with 204 response)
|
||||
if ((await clonedResponse.text()).trim() === "") {
|
||||
return { tokenStatus: TokenStatus.VALID, json: null, invalidReason: null } as const;
|
||||
}
|
||||
|
||||
const tokenObjectUsabilityRes = await this.isTokenObjectUnusable(response.clone());
|
||||
const accessTokenUsabilityRes = await this.isAccessTokenUnusable(response.clone());
|
||||
const isNotOkay = this.isResponseNotOkay(response);
|
||||
|
||||
const json = await response.json();
|
||||
|
||||
if (tokenObjectUsabilityRes?.reason) {
|
||||
myLog.error("Token Object has become unusable");
|
||||
return {
|
||||
tokenStatus: TokenStatus.UNUSABLE_TOKEN_OBJECT,
|
||||
invalidReason: tokenObjectUsabilityRes.reason,
|
||||
json,
|
||||
} as const;
|
||||
}
|
||||
|
||||
if (accessTokenUsabilityRes?.reason) {
|
||||
myLog.error("Access Token has become unusable");
|
||||
return {
|
||||
tokenStatus: TokenStatus.UNUSABLE_ACCESS_TOKEN,
|
||||
invalidReason: accessTokenUsabilityRes?.reason,
|
||||
json,
|
||||
};
|
||||
}
|
||||
|
||||
// Any handlable not ok response should be handled through isTokenObjectUnusable or isAccessTokenUnusable but if still not handled, we should throw an error
|
||||
// So, that the caller can handle it. It could be a network error or some other temporary error from the third party App itself.
|
||||
if (isNotOkay) {
|
||||
return {
|
||||
tokenStatus: TokenStatus.INCONCLUSIVE,
|
||||
invalidReason: response.statusText,
|
||||
json,
|
||||
};
|
||||
}
|
||||
|
||||
return { tokenStatus: TokenStatus.VALID, json, invalidReason: null } as const;
|
||||
}
|
||||
}
|
||||
|
||||
function ensureValidResourceOwner(
|
||||
resourceOwner: { id: number | null; type: "team" } | { id: number | null; type: "user" }
|
||||
) {
|
||||
if (resourceOwner.type === "team") {
|
||||
throw new Error("Teams are not supported");
|
||||
} else {
|
||||
if (!resourceOwner.id) {
|
||||
throw new Error("resourceOwner should have id set");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* It converts error into a Response
|
||||
*/
|
||||
function handleFetchError(e: unknown) {
|
||||
const myLog = log.getSubLogger({ prefix: ["handleFetchError"] });
|
||||
myLog.debug("Error", safeStringify(e));
|
||||
if (e instanceof Error) {
|
||||
return new Response(JSON.stringify({ myFetchError: e.message }), { status: 500 });
|
||||
}
|
||||
return new Response(JSON.stringify({ myFetchError: "UNKNOWN_ERROR" }), { status: 500 });
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import type { CredentialPayload } from "@calcom/types/Credential";
|
||||
|
||||
import { OAuth2TokenResponseInDbSchema } from "./universalSchema";
|
||||
|
||||
export function getTokenObjectFromCredential(credential: CredentialPayload) {
|
||||
const parsedTokenResponse = OAuth2TokenResponseInDbSchema.safeParse(credential.key);
|
||||
|
||||
if (!parsedTokenResponse.success) {
|
||||
logger.debug(
|
||||
"GoogleCalendarService-getTokenObjectFromCredential",
|
||||
safeStringify(parsedTokenResponse.error.issues)
|
||||
);
|
||||
throw new Error("Could not parse credential.key");
|
||||
}
|
||||
|
||||
const tokenResponse = parsedTokenResponse.data;
|
||||
if (!tokenResponse) {
|
||||
throw new Error("credential.key is not set");
|
||||
}
|
||||
|
||||
return tokenResponse;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import prisma from "@calcom/prisma";
|
||||
import type { CredentialPayload } from "@calcom/types/Credential";
|
||||
|
||||
import { getTokenObjectFromCredential } from "./getTokenObjectFromCredential";
|
||||
|
||||
export const markTokenAsExpired = async (credential: CredentialPayload) => {
|
||||
const tokenResponse = getTokenObjectFromCredential(credential);
|
||||
if (credential && credential.key) {
|
||||
await prisma.credential.update({
|
||||
where: {
|
||||
id: credential.id,
|
||||
},
|
||||
data: {
|
||||
key: {
|
||||
...tokenResponse,
|
||||
expiry_date: Date.now() - 3600 * 1000,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
import {
|
||||
APP_CREDENTIAL_SHARING_ENABLED,
|
||||
CREDENTIAL_SYNC_ENDPOINT,
|
||||
CREDENTIAL_SYNC_SECRET,
|
||||
CREDENTIAL_SYNC_SECRET_HEADER_NAME,
|
||||
} from "@calcom/lib/constants";
|
||||
|
||||
import { invalidateCredential } from "../invalidateCredential";
|
||||
import { getTokenObjectFromCredential } from "./getTokenObjectFromCredential";
|
||||
import { markTokenAsExpired } from "./markTokenAsExpired";
|
||||
import { updateTokenObject } from "./updateTokenObject";
|
||||
|
||||
export const credentialSyncVariables = {
|
||||
APP_CREDENTIAL_SHARING_ENABLED: APP_CREDENTIAL_SHARING_ENABLED,
|
||||
CREDENTIAL_SYNC_ENDPOINT: CREDENTIAL_SYNC_ENDPOINT,
|
||||
CREDENTIAL_SYNC_SECRET: CREDENTIAL_SYNC_SECRET,
|
||||
CREDENTIAL_SYNC_SECRET_HEADER_NAME: CREDENTIAL_SYNC_SECRET_HEADER_NAME,
|
||||
};
|
||||
|
||||
export const oAuthManagerHelper = {
|
||||
updateTokenObject,
|
||||
markTokenAsExpired,
|
||||
invalidateCredential: invalidateCredential,
|
||||
getTokenObjectFromCredential,
|
||||
credentialSyncVariables,
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* We should be able to work with just the access token.
|
||||
* access_token allows us to access the resources
|
||||
*/
|
||||
export const OAuth2BareMinimumUniversalSchema = z
|
||||
.object({
|
||||
access_token: z.string(),
|
||||
/**
|
||||
* It is usually 'Bearer'
|
||||
*/
|
||||
token_type: z.string().optional(),
|
||||
})
|
||||
// We want any other property to be passed through and stay there.
|
||||
.passthrough();
|
||||
|
||||
export const OAuth2UniversalSchema = OAuth2BareMinimumUniversalSchema.extend({
|
||||
/**
|
||||
* If we aren't sent refresh_token, it means that the party syncing us the credentials don't want us to ever refresh the token.
|
||||
* They would be responsible to send us the access_token before it expires.
|
||||
*/
|
||||
refresh_token: z.string().optional(),
|
||||
|
||||
/**
|
||||
* It is only needed when connecting to the API for the first time. So, it is okay if the party syncing us the credentials don't send it as then it is responsible to provide us the access_token already
|
||||
*/
|
||||
scope: z.string().optional(),
|
||||
|
||||
/**
|
||||
* Absolute expiration time in milliseconds
|
||||
*/
|
||||
expiry_date: z.number().optional(),
|
||||
});
|
||||
|
||||
export const OAuth2UniversalSchemaWithCalcomBackwardCompatibility = OAuth2UniversalSchema.extend({
|
||||
/**
|
||||
* Time in seconds until the token expires
|
||||
* Either this or expiry_date should be provided
|
||||
*/
|
||||
expires_in: z.number().optional(),
|
||||
});
|
||||
|
||||
export const OAuth2TokenResponseInDbWhenExistsSchema = OAuth2UniversalSchemaWithCalcomBackwardCompatibility;
|
||||
export const OAuth2TokenResponseInDbSchema = OAuth2UniversalSchemaWithCalcomBackwardCompatibility.nullable();
|
||||
@@ -0,0 +1,22 @@
|
||||
import type z from "zod";
|
||||
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import type { OAuth2UniversalSchemaWithCalcomBackwardCompatibility } from "./universalSchema";
|
||||
|
||||
export const updateTokenObject = async ({
|
||||
tokenObject,
|
||||
credentialId,
|
||||
}: {
|
||||
tokenObject: z.infer<typeof OAuth2UniversalSchemaWithCalcomBackwardCompatibility>;
|
||||
credentialId: number;
|
||||
}) => {
|
||||
await prisma.credential.update({
|
||||
where: {
|
||||
id: credentialId,
|
||||
},
|
||||
data: {
|
||||
key: tokenObject,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
export function generateJsonResponse({
|
||||
json,
|
||||
status = 200,
|
||||
statusText = "OK",
|
||||
}: {
|
||||
json: unknown;
|
||||
status?: number;
|
||||
statusText?: string;
|
||||
}) {
|
||||
return new Response(JSON.stringify(json), {
|
||||
status,
|
||||
statusText,
|
||||
});
|
||||
}
|
||||
|
||||
export function internalServerErrorResponse({
|
||||
json,
|
||||
}: {
|
||||
json: unknown;
|
||||
status?: number;
|
||||
statusText?: string;
|
||||
}) {
|
||||
return generateJsonResponse({ json, status: 500, statusText: "Internal Server Error" });
|
||||
}
|
||||
|
||||
export function generateTextResponse({
|
||||
text,
|
||||
status = 200,
|
||||
statusText = "OK",
|
||||
}: {
|
||||
text: string;
|
||||
status?: number;
|
||||
statusText?: string;
|
||||
}) {
|
||||
return new Response(text, {
|
||||
status: status,
|
||||
statusText: statusText,
|
||||
});
|
||||
}
|
||||
|
||||
export function successResponse({ json }: { json: unknown }) {
|
||||
return generateJsonResponse({ json });
|
||||
}
|
||||
@@ -9,6 +9,12 @@ import dayjs from "@calcom/dayjs";
|
||||
import { getFeatureFlag } from "@calcom/features/flags/server/utils";
|
||||
import { getLocation, getRichDescription } from "@calcom/lib/CalEventParser";
|
||||
import type CalendarService from "@calcom/lib/CalendarService";
|
||||
import {
|
||||
APP_CREDENTIAL_SHARING_ENABLED,
|
||||
CREDENTIAL_SYNC_ENDPOINT,
|
||||
CREDENTIAL_SYNC_SECRET,
|
||||
CREDENTIAL_SYNC_SECRET_HEADER_NAME,
|
||||
} from "@calcom/lib/constants";
|
||||
import { formatCalEvent } from "@calcom/lib/formatCalendarEvent";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
@@ -22,11 +28,16 @@ import type {
|
||||
} from "@calcom/types/Calendar";
|
||||
import type { CredentialPayload } from "@calcom/types/Credential";
|
||||
|
||||
import type { ParseRefreshTokenResponse } from "../../_utils/oauth/parseRefreshTokenResponse";
|
||||
import parseRefreshTokenResponse from "../../_utils/oauth/parseRefreshTokenResponse";
|
||||
import refreshOAuthTokens from "../../_utils/oauth/refreshOAuthTokens";
|
||||
import { invalidateCredential } from "../../_utils/invalidateCredential";
|
||||
import { AxiosLikeResponseToFetchResponse } from "../../_utils/oauth/AxiosLikeResponseToFetchResponse";
|
||||
import { OAuthManager } from "../../_utils/oauth/OAuthManager";
|
||||
import { getTokenObjectFromCredential } from "../../_utils/oauth/getTokenObjectFromCredential";
|
||||
import { markTokenAsExpired } from "../../_utils/oauth/markTokenAsExpired";
|
||||
import { OAuth2UniversalSchema } from "../../_utils/oauth/universalSchema";
|
||||
import { metadata } from "../_metadata";
|
||||
import { getGoogleAppKeys } from "./getGoogleAppKeys";
|
||||
import { googleCredentialSchema } from "./googleCredentialSchema";
|
||||
|
||||
const log = logger.getSubLogger({ prefix: ["app-store/googlecalendar/lib/CalendarService"] });
|
||||
|
||||
interface GoogleCalError extends Error {
|
||||
code?: number;
|
||||
@@ -64,80 +75,109 @@ function handleMinMax(min: string, max: string) {
|
||||
|
||||
export default class GoogleCalendarService implements Calendar {
|
||||
private integrationName = "";
|
||||
private auth: { getToken: () => Promise<MyGoogleAuth> };
|
||||
private auth: ReturnType<typeof this.initGoogleAuth>;
|
||||
private log: typeof logger;
|
||||
private credential: CredentialPayload;
|
||||
|
||||
private myGoogleAuth!: MyGoogleAuth;
|
||||
private oAuthManagerInstance!: OAuthManager;
|
||||
constructor(credential: CredentialPayload) {
|
||||
this.integrationName = "google_calendar";
|
||||
this.credential = credential;
|
||||
this.auth = this.googleAuth(credential);
|
||||
this.log = logger.getSubLogger({ prefix: [`[[lib] ${this.integrationName}`] });
|
||||
this.auth = this.initGoogleAuth(credential);
|
||||
this.log = log.getSubLogger({ prefix: [`[[lib] ${this.integrationName}`] });
|
||||
this.credential = credential;
|
||||
}
|
||||
|
||||
private googleAuth = (credential: CredentialPayload) => {
|
||||
const googleCredentials = googleCredentialSchema.parse(credential.key);
|
||||
private async getMyGoogleAuthSingleton() {
|
||||
const googleCredentials = OAuth2UniversalSchema.parse(this.credential.key);
|
||||
const { client_id, client_secret, redirect_uris } = await getGoogleAppKeys();
|
||||
this.myGoogleAuth = this.myGoogleAuth || new MyGoogleAuth(client_id, client_secret, redirect_uris[0]);
|
||||
this.myGoogleAuth.setCredentials(googleCredentials);
|
||||
return this.myGoogleAuth;
|
||||
}
|
||||
|
||||
async function getGoogleAuth() {
|
||||
const { client_id, client_secret, redirect_uris } = await getGoogleAppKeys();
|
||||
const myGoogleAuth = new MyGoogleAuth(client_id, client_secret, redirect_uris[0]);
|
||||
myGoogleAuth.setCredentials(googleCredentials);
|
||||
return myGoogleAuth;
|
||||
}
|
||||
|
||||
const refreshAccessToken = async (myGoogleAuth: Awaited<ReturnType<typeof getGoogleAuth>>) => {
|
||||
try {
|
||||
const res = await refreshOAuthTokens(
|
||||
async () => {
|
||||
const fetchTokens = await myGoogleAuth.refreshToken(googleCredentials.refresh_token);
|
||||
return fetchTokens.res;
|
||||
},
|
||||
"google-calendar",
|
||||
credential.userId
|
||||
);
|
||||
const token = res?.data;
|
||||
googleCredentials.access_token = token.access_token;
|
||||
googleCredentials.expiry_date = token.expiry_date;
|
||||
const parsedKey: ParseRefreshTokenResponse<typeof googleCredentialSchema> = parseRefreshTokenResponse(
|
||||
googleCredentials,
|
||||
googleCredentialSchema
|
||||
);
|
||||
await prisma.credential.update({
|
||||
where: { id: credential.id },
|
||||
data: { key: { ...parsedKey } as Prisma.InputJsonValue },
|
||||
private initGoogleAuth = (credential: CredentialPayload) => {
|
||||
const currentTokenObject = getTokenObjectFromCredential(credential);
|
||||
const auth = new OAuthManager({
|
||||
// Keep it false because we are not using auth.request everywhere. That would be done later as it involves many google calendar sdk functionc calls and needs to be tested well.
|
||||
autoCheckTokenExpiryOnRequest: false,
|
||||
credentialSyncVariables: {
|
||||
APP_CREDENTIAL_SHARING_ENABLED: APP_CREDENTIAL_SHARING_ENABLED,
|
||||
CREDENTIAL_SYNC_ENDPOINT: CREDENTIAL_SYNC_ENDPOINT,
|
||||
CREDENTIAL_SYNC_SECRET: CREDENTIAL_SYNC_SECRET,
|
||||
CREDENTIAL_SYNC_SECRET_HEADER_NAME: CREDENTIAL_SYNC_SECRET_HEADER_NAME,
|
||||
},
|
||||
resourceOwner: {
|
||||
type: "user",
|
||||
id: credential.userId,
|
||||
},
|
||||
appSlug: metadata.slug,
|
||||
currentTokenObject,
|
||||
fetchNewTokenObject: async ({ refreshToken }: { refreshToken: string | null }) => {
|
||||
const myGoogleAuth = await this.getMyGoogleAuthSingleton();
|
||||
const fetchTokens = await myGoogleAuth.refreshToken(refreshToken);
|
||||
// Create Response from fetchToken.res
|
||||
const response = new Response(JSON.stringify(fetchTokens.res?.data ?? null), {
|
||||
status: fetchTokens.res?.status,
|
||||
statusText: fetchTokens.res?.statusText,
|
||||
});
|
||||
myGoogleAuth.setCredentials(googleCredentials);
|
||||
} catch (err) {
|
||||
this.log.error("Error Refreshing Google Token", safeStringify(err));
|
||||
let message;
|
||||
if (err instanceof Error) message = err.message;
|
||||
else message = String(err);
|
||||
// if not invalid_grant, default behaviour (which admittedly isn't great)
|
||||
if (message !== "invalid_grant") return myGoogleAuth;
|
||||
// when the error is invalid grant, it's unrecoverable and the credential marked invalid.
|
||||
// TODO: Evaluate bubbling up and handling this in the CalendarManager. IMO this should be done
|
||||
// but this is a bigger refactor.
|
||||
return response;
|
||||
},
|
||||
isTokenExpired: async () => {
|
||||
const myGoogleAuth = await this.getMyGoogleAuthSingleton();
|
||||
return myGoogleAuth.isTokenExpiring();
|
||||
},
|
||||
isTokenObjectUnusable: async function (response) {
|
||||
// TODO: Confirm that if this logic should go to isAccessTokenUnusable
|
||||
if (!response.ok || (response.status < 200 && response.status >= 300)) {
|
||||
const responseBody = await response.json();
|
||||
|
||||
if (responseBody.error === "invalid_grant") {
|
||||
return {
|
||||
reason: "invalid_grant",
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
isAccessTokenUnusable: async () => {
|
||||
// As long as refresh_token is valid, access_token is regenerated and fixed automatically by Google Calendar when a problem with it is detected
|
||||
// So, a situation where access_token is invalid but refresh_token is valid should not happen
|
||||
return null;
|
||||
},
|
||||
invalidateTokenObject: () => invalidateCredential(this.credential.id),
|
||||
expireAccessToken: async () => {
|
||||
await markTokenAsExpired(this.credential);
|
||||
},
|
||||
updateTokenObject: async (token) => {
|
||||
this.myGoogleAuth.setCredentials(token);
|
||||
await prisma.credential.update({
|
||||
where: { id: credential.id },
|
||||
where: {
|
||||
id: credential.id,
|
||||
},
|
||||
data: {
|
||||
invalid: true,
|
||||
key: token,
|
||||
},
|
||||
});
|
||||
}
|
||||
return myGoogleAuth;
|
||||
};
|
||||
},
|
||||
});
|
||||
this.oAuthManagerInstance = auth;
|
||||
return {
|
||||
getToken: async () => {
|
||||
const myGoogleAuth = await getGoogleAuth();
|
||||
const isExpired = () => myGoogleAuth.isTokenExpiring();
|
||||
return !isExpired() ? Promise.resolve(myGoogleAuth) : refreshAccessToken(myGoogleAuth);
|
||||
getMyGoogleAuthWithRefreshedToken: async () => {
|
||||
// It would automatically update myGoogleAuth with correct token
|
||||
const { token } = await auth.getTokenObjectOrFetch();
|
||||
if (!token) {
|
||||
throw new Error("Invalid grant for Google Calendar app");
|
||||
}
|
||||
|
||||
const myGoogleAuth = await this.getMyGoogleAuthSingleton();
|
||||
return myGoogleAuth;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
public authedCalendar = async () => {
|
||||
const myGoogleAuth = await this.auth.getToken();
|
||||
const myGoogleAuth = await this.auth.getMyGoogleAuthWithRefreshedToken();
|
||||
const calendar = google.calendar({
|
||||
version: "v3",
|
||||
auth: myGoogleAuth,
|
||||
@@ -188,6 +228,7 @@ export default class GoogleCalendarService implements Calendar {
|
||||
};
|
||||
|
||||
async createEvent(calEventRaw: CalendarEvent, credentialId: number): Promise<NewCalendarEventType> {
|
||||
this.log.debug("Creating event");
|
||||
const formattedCalEvent = formatCalEvent(calEventRaw);
|
||||
|
||||
const payload: calendar_v3.Schema$Event = {
|
||||
@@ -480,11 +521,14 @@ export default class GoogleCalendarService implements Calendar {
|
||||
if (!calendarCacheEnabled) {
|
||||
this.log.warn("Calendar Cache is disabled - Skipping");
|
||||
const { timeMin, timeMax, items } = args;
|
||||
const apires = await calendar.freebusy.query({
|
||||
requestBody: { timeMin, timeMax, items },
|
||||
});
|
||||
|
||||
freeBusyResult = apires.data;
|
||||
({ json: freeBusyResult } = await this.oAuthManagerInstance.request(
|
||||
async () =>
|
||||
new AxiosLikeResponseToFetchResponse(
|
||||
await calendar.freebusy.query({
|
||||
requestBody: { timeMin, timeMax, items },
|
||||
})
|
||||
)
|
||||
));
|
||||
} else {
|
||||
const { timeMin: _timeMin, timeMax: _timeMax, items } = args;
|
||||
const { timeMin, timeMax } = handleMinMax(_timeMin, _timeMax);
|
||||
@@ -502,9 +546,14 @@ export default class GoogleCalendarService implements Calendar {
|
||||
if (cached) {
|
||||
freeBusyResult = cached.value as unknown as calendar_v3.Schema$FreeBusyResponse;
|
||||
} else {
|
||||
const apires = await calendar.freebusy.query({
|
||||
requestBody: { timeMin, timeMax, items },
|
||||
});
|
||||
({ json: freeBusyResult } = await this.oAuthManagerInstance.request(
|
||||
async () =>
|
||||
new AxiosLikeResponseToFetchResponse(
|
||||
await calendar.freebusy.query({
|
||||
requestBody: { timeMin, timeMax, items },
|
||||
})
|
||||
)
|
||||
));
|
||||
|
||||
// Skipping await to respond faster
|
||||
await prisma.calendarCache.upsert({
|
||||
@@ -515,18 +564,16 @@ export default class GoogleCalendarService implements Calendar {
|
||||
},
|
||||
},
|
||||
update: {
|
||||
value: JSON.parse(JSON.stringify(apires.data)),
|
||||
value: JSON.parse(JSON.stringify(freeBusyResult)),
|
||||
expiresAt: new Date(Date.now() + CACHING_TIME),
|
||||
},
|
||||
create: {
|
||||
value: JSON.parse(JSON.stringify(apires.data)),
|
||||
value: JSON.parse(JSON.stringify(freeBusyResult)),
|
||||
credentialId: this.credential.id,
|
||||
key,
|
||||
expiresAt: new Date(Date.now() + CACHING_TIME),
|
||||
},
|
||||
});
|
||||
|
||||
freeBusyResult = apires.data;
|
||||
}
|
||||
}
|
||||
if (!freeBusyResult.calendars) return null;
|
||||
@@ -548,6 +595,7 @@ export default class GoogleCalendarService implements Calendar {
|
||||
dateTo: string,
|
||||
selectedCalendars: IntegrationCalendar[]
|
||||
): Promise<EventBusyDate[]> {
|
||||
this.log.debug("Getting availability");
|
||||
const calendar = await this.authedCalendar();
|
||||
const selectedCalendarIds = selectedCalendars
|
||||
.filter((e) => e.integration === this.integrationName)
|
||||
@@ -613,11 +661,18 @@ export default class GoogleCalendarService implements Calendar {
|
||||
}
|
||||
|
||||
async listCalendars(): Promise<IntegrationCalendar[]> {
|
||||
this.log.debug("Listing calendars");
|
||||
const calendar = await this.authedCalendar();
|
||||
try {
|
||||
const cals = await calendar.calendarList.list({ fields: "items(id,summary,primary,accessRole)" });
|
||||
if (!cals.data.items) return [];
|
||||
return cals.data.items.map(
|
||||
const { json: cals } = await this.oAuthManagerInstance.request(
|
||||
async () =>
|
||||
new AxiosLikeResponseToFetchResponse(
|
||||
await calendar.calendarList.list({ fields: "items(id,summary,primary,accessRole)" })
|
||||
)
|
||||
);
|
||||
|
||||
if (!cals.items) return [];
|
||||
return cals.items.map(
|
||||
(cal) =>
|
||||
({
|
||||
externalId: cal.id ?? "No id",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { stringify } from "querystring";
|
||||
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { WEBAPP_URL_FOR_OAUTH } from "@calcom/lib/constants";
|
||||
|
||||
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
|
||||
import { encodeOAuthState } from "../../_utils/oauth/encodeOAuthState";
|
||||
@@ -21,7 +21,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
scope: scopes.join(" "),
|
||||
client_id,
|
||||
prompt: "select_account",
|
||||
redirect_uri: `${WEBAPP_URL}/api/integrations/office365calendar/callback`,
|
||||
redirect_uri: `${WEBAPP_URL_FOR_OAUTH}/api/integrations/office365calendar/callback`,
|
||||
state,
|
||||
};
|
||||
const query = stringify(params);
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Calendar as OfficeCalendar } from "@microsoft/microsoft-graph-type
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { renewSelectedCalendarCredentialId } from "@calcom/lib/connectedCalendar";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { WEBAPP_URL, WEBAPP_URL_FOR_OAUTH } from "@calcom/lib/constants";
|
||||
import { handleErrorsJson } from "@calcom/lib/errors";
|
||||
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
|
||||
import prisma from "@calcom/prisma";
|
||||
@@ -50,7 +50,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
scope: scopes.join(" "),
|
||||
redirect_uri: `${WEBAPP_URL}/api/integrations/office365calendar/callback`,
|
||||
redirect_uri: `${WEBAPP_URL_FOR_OAUTH}/api/integrations/office365calendar/callback`,
|
||||
client_secret,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import type { Calendar as OfficeCalendar, User } from "@microsoft/microsoft-graph-types-beta";
|
||||
import type { DefaultBodyType } from "msw";
|
||||
import { z } from "zod";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { getLocation, getRichDescription } from "@calcom/lib/CalEventParser";
|
||||
import { handleErrorsJson, handleErrorsRaw } from "@calcom/lib/errors";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import prisma from "@calcom/prisma";
|
||||
import type { BufferedBusyTime } from "@calcom/types/BufferedBusyTime";
|
||||
import type {
|
||||
Calendar,
|
||||
@@ -17,10 +15,10 @@ import type {
|
||||
} from "@calcom/types/Calendar";
|
||||
import type { CredentialPayload } from "@calcom/types/Credential";
|
||||
|
||||
import type { ParseRefreshTokenResponse } from "../../_utils/oauth/parseRefreshTokenResponse";
|
||||
import parseRefreshTokenResponse from "../../_utils/oauth/parseRefreshTokenResponse";
|
||||
import refreshOAuthTokens from "../../_utils/oauth/refreshOAuthTokens";
|
||||
import type { O365AuthCredentials } from "../types/Office365Calendar";
|
||||
import { OAuthManager } from "../../_utils/oauth/OAuthManager";
|
||||
import { getTokenObjectFromCredential } from "../../_utils/oauth/getTokenObjectFromCredential";
|
||||
import { oAuthManagerHelper } from "../../_utils/oauth/oAuthManagerHelper";
|
||||
import metadata from "../_metadata";
|
||||
import { getOfficeAppKeys } from "./getOfficeAppKeys";
|
||||
|
||||
interface IRequest {
|
||||
@@ -49,26 +47,58 @@ interface BodyValue {
|
||||
start: { dateTime: string };
|
||||
}
|
||||
|
||||
const refreshTokenResponseSchema = z.object({
|
||||
access_token: z.string(),
|
||||
expires_in: z
|
||||
.number()
|
||||
.transform((currentTimeOffsetInSeconds) => Math.round(+new Date() / 1000 + currentTimeOffsetInSeconds)),
|
||||
refresh_token: z.string().optional(),
|
||||
});
|
||||
|
||||
export default class Office365CalendarService implements Calendar {
|
||||
private url = "";
|
||||
private integrationName = "";
|
||||
private log: typeof logger;
|
||||
private accessToken: string | null = null;
|
||||
auth: { getToken: () => Promise<string> };
|
||||
private auth: OAuthManager;
|
||||
private apiGraphUrl = "https://graph.microsoft.com/v1.0";
|
||||
private credential: CredentialPayload;
|
||||
|
||||
constructor(credential: CredentialPayload) {
|
||||
this.integrationName = "office365_calendar";
|
||||
this.auth = this.o365Auth(credential);
|
||||
const tokenResponse = getTokenObjectFromCredential(credential);
|
||||
|
||||
this.auth = new OAuthManager({
|
||||
credentialSyncVariables: oAuthManagerHelper.credentialSyncVariables,
|
||||
resourceOwner: {
|
||||
type: "user",
|
||||
id: credential.userId,
|
||||
},
|
||||
appSlug: metadata.slug,
|
||||
currentTokenObject: tokenResponse,
|
||||
fetchNewTokenObject: async ({ refreshToken }: { refreshToken: string | null }) => {
|
||||
if (!refreshToken) {
|
||||
return null;
|
||||
}
|
||||
const { client_id, client_secret } = await getOfficeAppKeys();
|
||||
return await fetch("https://login.microsoftonline.com/common/oauth2/v2.0/token", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
scope: "User.Read Calendars.Read Calendars.ReadWrite",
|
||||
client_id,
|
||||
refresh_token: refreshToken,
|
||||
grant_type: "refresh_token",
|
||||
client_secret,
|
||||
}),
|
||||
});
|
||||
},
|
||||
isTokenObjectUnusable: async function () {
|
||||
// TODO: Implement this. As current implementation of CalendarService doesn't handle it. It hasn't been handled in the OAuthManager implementation as well.
|
||||
// This is a placeholder for future implementation.
|
||||
return null;
|
||||
},
|
||||
isAccessTokenUnusable: async function () {
|
||||
// TODO: Implement this
|
||||
return null;
|
||||
},
|
||||
invalidateTokenObject: () => oAuthManagerHelper.invalidateCredential(credential.id),
|
||||
expireAccessToken: () => oAuthManagerHelper.markTokenAsExpired(credential),
|
||||
updateTokenObject: (tokenObject) =>
|
||||
oAuthManagerHelper.updateTokenObject({ tokenObject, credentialId: credential.id }),
|
||||
});
|
||||
|
||||
this.credential = credential;
|
||||
this.log = logger.getSubLogger({ prefix: [`[[lib] ${this.integrationName}`] });
|
||||
}
|
||||
@@ -232,58 +262,6 @@ export default class Office365CalendarService implements Calendar {
|
||||
});
|
||||
}
|
||||
|
||||
private o365Auth = (credential: CredentialPayload) => {
|
||||
const isExpired = (expiryDate: number) => {
|
||||
if (!expiryDate) {
|
||||
return true;
|
||||
} else {
|
||||
return expiryDate < Math.round(+new Date() / 1000);
|
||||
}
|
||||
};
|
||||
const o365AuthCredentials = credential.key as O365AuthCredentials;
|
||||
|
||||
const refreshAccessToken = async (o365AuthCredentials: O365AuthCredentials) => {
|
||||
const { client_id, client_secret } = await getOfficeAppKeys();
|
||||
const response = await refreshOAuthTokens(
|
||||
async () =>
|
||||
await fetch("https://login.microsoftonline.com/common/oauth2/v2.0/token", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
scope: "User.Read Calendars.Read Calendars.ReadWrite",
|
||||
client_id,
|
||||
refresh_token: o365AuthCredentials.refresh_token,
|
||||
grant_type: "refresh_token",
|
||||
client_secret,
|
||||
}),
|
||||
}),
|
||||
"office365-calendar",
|
||||
credential.userId
|
||||
);
|
||||
const responseJson = await handleErrorsJson(response);
|
||||
const tokenResponse: ParseRefreshTokenResponse<typeof refreshTokenResponseSchema> =
|
||||
parseRefreshTokenResponse(responseJson, refreshTokenResponseSchema);
|
||||
o365AuthCredentials = { ...o365AuthCredentials, ...tokenResponse };
|
||||
await prisma.credential.update({
|
||||
where: {
|
||||
id: credential.id,
|
||||
},
|
||||
data: {
|
||||
key: o365AuthCredentials,
|
||||
},
|
||||
});
|
||||
return o365AuthCredentials.access_token;
|
||||
};
|
||||
|
||||
return {
|
||||
getToken: () =>
|
||||
refreshTokenResponseSchema.safeParse(o365AuthCredentials).success &&
|
||||
!isExpired(o365AuthCredentials.expires_in)
|
||||
? Promise.resolve(o365AuthCredentials.access_token)
|
||||
: refreshAccessToken(o365AuthCredentials),
|
||||
};
|
||||
};
|
||||
|
||||
private translateEvent = (event: CalendarEvent) => {
|
||||
return {
|
||||
subject: event.title,
|
||||
@@ -339,14 +317,12 @@ export default class Office365CalendarService implements Calendar {
|
||||
};
|
||||
|
||||
private fetcher = async (endpoint: string, init?: RequestInit | undefined) => {
|
||||
this.accessToken = await this.auth.getToken();
|
||||
return fetch(`${this.apiGraphUrl}${endpoint}`, {
|
||||
method: "get",
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
return this.auth.requestRaw({
|
||||
url: `${this.apiGraphUrl}${endpoint}`,
|
||||
options: {
|
||||
method: "get",
|
||||
...init,
|
||||
},
|
||||
...init,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { stringify } from "querystring";
|
||||
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { WEBAPP_URL_FOR_OAUTH } from "@calcom/lib/constants";
|
||||
|
||||
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
|
||||
import { encodeOAuthState } from "../../_utils/oauth/encodeOAuthState";
|
||||
@@ -20,7 +20,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
response_type: "code",
|
||||
scope: scopes.join(" "),
|
||||
client_id,
|
||||
redirect_uri: `${WEBAPP_URL}/api/integrations/office365video/callback`,
|
||||
redirect_uri: `${WEBAPP_URL_FOR_OAUTH}/api/integrations/office365video/callback`,
|
||||
state,
|
||||
};
|
||||
const query = stringify(params);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { WEBAPP_URL, WEBAPP_URL_FOR_OAUTH } from "@calcom/lib/constants";
|
||||
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
@@ -47,7 +47,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
scope: scopes.join(" "),
|
||||
redirect_uri: `${WEBAPP_URL}/api/integrations/office365video/callback`,
|
||||
redirect_uri: `${WEBAPP_URL_FOR_OAUTH}/api/integrations/office365video/callback`,
|
||||
client_secret,
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
import prismaMock from "../../../../tests/libs/__mocks__/prismaMock";
|
||||
|
||||
import { expect, test, vi, describe } from "vitest";
|
||||
|
||||
import { OAuthManager } from "../../_utils/oauth/OAuthManager";
|
||||
import { internalServerErrorResponse, successResponse } from "../../_utils/testUtils";
|
||||
import config from "../config.json";
|
||||
import VideoApiAdapter from "./VideoApiAdapter";
|
||||
|
||||
const URLS = {
|
||||
CREATE_MEETING: {
|
||||
url: "https://graph.microsoft.com/v1.0/me/onlineMeetings",
|
||||
method: "POST",
|
||||
},
|
||||
UPDATE_MEETING: {
|
||||
url: "https://graph.microsoft.com/v1.0/me/onlineMeetings",
|
||||
method: "POST",
|
||||
},
|
||||
};
|
||||
|
||||
vi.mock("../../_utils/getParsedAppKeysFromSlug", () => ({
|
||||
default: vi.fn().mockImplementation((slug) => {
|
||||
if (slug !== config.slug) {
|
||||
throw new Error(
|
||||
`expected to be called with the correct slug. Expected ${config.slug} - Received ${slug}`
|
||||
);
|
||||
}
|
||||
return {
|
||||
client_id: "FAKE_CLIENT_ID",
|
||||
client_secret: "FAKE_CLIENT_SECRET",
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockRequestRaw = vi.fn();
|
||||
vi.mock("../../_utils/oauth/OAuthManager", () => ({
|
||||
OAuthManager: vi.fn().mockImplementation(() => {
|
||||
return { requestRaw: mockRequestRaw };
|
||||
}),
|
||||
}));
|
||||
|
||||
const testCredential = {
|
||||
appId: config.slug,
|
||||
id: 1,
|
||||
invalid: false,
|
||||
key: {
|
||||
scope: "https://www.googleapis.com/auth/calendar.events",
|
||||
token_type: "Bearer",
|
||||
expiry_date: 1625097600000,
|
||||
access_token: "",
|
||||
refresh_token: "",
|
||||
},
|
||||
type: config.type,
|
||||
userId: 1,
|
||||
user: { email: "example@cal.com" },
|
||||
teamId: 1,
|
||||
};
|
||||
|
||||
describe("createMeeting", () => {
|
||||
test("Successful `createMeeting` call", async () => {
|
||||
prismaMock.calendarCache.findUnique;
|
||||
|
||||
const videoApi = VideoApiAdapter(testCredential);
|
||||
|
||||
mockRequestRaw.mockImplementation(({ url }) => {
|
||||
if (url === URLS.CREATE_MEETING.url) {
|
||||
return Promise.resolve(
|
||||
successResponse({
|
||||
json: {
|
||||
id: 1,
|
||||
joinWebUrl: "https://join_web_url.example.com",
|
||||
joinUrl: "https://join_url.example.com",
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
throw new Error("Unexpected URL");
|
||||
});
|
||||
|
||||
const event = {
|
||||
title: "Test Meeting",
|
||||
description: "Test Description",
|
||||
startTime: new Date(),
|
||||
endTime: new Date(),
|
||||
};
|
||||
|
||||
const createdMeeting = await videoApi?.createMeeting(event);
|
||||
expect(OAuthManager).toHaveBeenCalled();
|
||||
expect(mockRequestRaw).toHaveBeenCalledWith({
|
||||
url: URLS.CREATE_MEETING.url,
|
||||
options: {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
startDateTime: event.startTime,
|
||||
endDateTime: event.endTime,
|
||||
subject: event.title,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
expect(createdMeeting).toEqual({
|
||||
id: 1,
|
||||
password: "",
|
||||
type: "office365_video",
|
||||
url: "https://join_web_url.example.com",
|
||||
});
|
||||
});
|
||||
|
||||
test(" `createMeeting` when there is no joinWebUrl and only joinUrl", async () => {
|
||||
prismaMock.calendarCache.findUnique;
|
||||
|
||||
const videoApi = VideoApiAdapter(testCredential);
|
||||
|
||||
mockRequestRaw.mockImplementation(({ url }) => {
|
||||
if (url === URLS.CREATE_MEETING.url) {
|
||||
return Promise.resolve(
|
||||
successResponse({
|
||||
json: {
|
||||
id: 1,
|
||||
joinUrl: "https://join_url.example.com",
|
||||
error: {
|
||||
message: "ERROR",
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
throw new Error("Unexpected URL");
|
||||
});
|
||||
|
||||
const event = {
|
||||
title: "Test Meeting",
|
||||
description: "Test Description",
|
||||
startTime: new Date(),
|
||||
endTime: new Date(),
|
||||
};
|
||||
|
||||
await expect(() => videoApi?.createMeeting(event)).rejects.toThrowError(
|
||||
"Error creating MS Teams meeting"
|
||||
);
|
||||
expect(OAuthManager).toHaveBeenCalled();
|
||||
expect(mockRequestRaw).toHaveBeenCalledWith({
|
||||
url: URLS.CREATE_MEETING.url,
|
||||
options: {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
startDateTime: event.startTime,
|
||||
endDateTime: event.endTime,
|
||||
subject: event.title,
|
||||
}),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("Failing `createMeeting` call", async () => {
|
||||
const videoApi = VideoApiAdapter(testCredential);
|
||||
|
||||
mockRequestRaw.mockImplementation(({ url }) => {
|
||||
if (url === URLS.CREATE_MEETING.url) {
|
||||
return Promise.resolve(
|
||||
internalServerErrorResponse({
|
||||
json: {
|
||||
id: 1,
|
||||
joinWebUrl: "https://example.com",
|
||||
joinUrl: "https://example.com",
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
throw new Error("Unexpected URL");
|
||||
});
|
||||
|
||||
const event = {
|
||||
title: "Test Meeting",
|
||||
description: "Test Description",
|
||||
startTime: new Date(),
|
||||
endTime: new Date(),
|
||||
};
|
||||
|
||||
await expect(() => videoApi?.createMeeting(event)).rejects.toThrowError("Internal Server Error");
|
||||
expect(OAuthManager).toHaveBeenCalled();
|
||||
expect(mockRequestRaw).toHaveBeenCalledWith({
|
||||
url: URLS.CREATE_MEETING.url,
|
||||
options: {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
startDateTime: event.startTime,
|
||||
endDateTime: event.endTime,
|
||||
subject: event.title,
|
||||
}),
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateMeeting", () => {
|
||||
test("Successful `updateMeeting` call", async () => {
|
||||
const videoApi = VideoApiAdapter(testCredential);
|
||||
|
||||
mockRequestRaw.mockImplementation(({ url }) => {
|
||||
if (url === URLS.CREATE_MEETING.url) {
|
||||
return Promise.resolve(
|
||||
successResponse({
|
||||
json: {
|
||||
id: 1,
|
||||
joinWebUrl: "https://join_web_url.example.com",
|
||||
joinUrl: "https://join_url.example.com",
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
throw new Error("Unexpected URL");
|
||||
});
|
||||
|
||||
const event = {
|
||||
title: "Test Meeting",
|
||||
description: "Test Description",
|
||||
startTime: new Date(),
|
||||
endTime: new Date(),
|
||||
};
|
||||
|
||||
const updatedMeeting = await videoApi?.updateMeeting(null, event);
|
||||
expect(OAuthManager).toHaveBeenCalled();
|
||||
expect(mockRequestRaw).toHaveBeenCalledWith({
|
||||
url: URLS.CREATE_MEETING.url,
|
||||
options: {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
startDateTime: event.startTime,
|
||||
endDateTime: event.endTime,
|
||||
subject: event.title,
|
||||
}),
|
||||
},
|
||||
});
|
||||
expect(updatedMeeting).toEqual({
|
||||
id: 1,
|
||||
password: "",
|
||||
type: config.type,
|
||||
url: "https://join_web_url.example.com",
|
||||
});
|
||||
});
|
||||
|
||||
test("Failing `updateMeeting` call", async () => {
|
||||
const videoApi = VideoApiAdapter(testCredential);
|
||||
|
||||
mockRequestRaw.mockImplementation(({ url }) => {
|
||||
if (url === URLS.CREATE_MEETING.url) {
|
||||
return Promise.resolve(
|
||||
internalServerErrorResponse({
|
||||
json: {
|
||||
id: 1,
|
||||
joinWebUrl: "https://join_web_url.example.com",
|
||||
joinUrl: "https://join_url.example.com",
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
throw new Error("Unexpected URL");
|
||||
});
|
||||
|
||||
const event = {
|
||||
title: "Test Meeting",
|
||||
description: "Test Description",
|
||||
startTime: new Date(),
|
||||
endTime: new Date(),
|
||||
};
|
||||
|
||||
await expect(() => videoApi?.updateMeeting(null, event)).rejects.toThrowError("Internal Server Error");
|
||||
expect(OAuthManager).toHaveBeenCalled();
|
||||
expect(mockRequestRaw).toHaveBeenCalledWith({
|
||||
url: URLS.CREATE_MEETING.url,
|
||||
options: {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
startDateTime: event.startTime,
|
||||
endDateTime: event.endTime,
|
||||
subject: event.title,
|
||||
}),
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,18 +1,16 @@
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
|
||||
import { handleErrorsJson, handleErrorsRaw } from "@calcom/lib/errors";
|
||||
import { handleErrorsRaw } from "@calcom/lib/errors";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import prisma from "@calcom/prisma";
|
||||
import type { CalendarEvent } from "@calcom/types/Calendar";
|
||||
import type { CredentialPayload } from "@calcom/types/Credential";
|
||||
import type { PartialReference } from "@calcom/types/EventManager";
|
||||
import type { VideoApiAdapter, VideoCallData } from "@calcom/types/VideoApiAdapter";
|
||||
|
||||
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
|
||||
import refreshOAuthTokens from "../../_utils/oauth/refreshOAuthTokens";
|
||||
|
||||
let client_id = "";
|
||||
let client_secret = "";
|
||||
import getParsedAppKeysFromSlug from "../../_utils/getParsedAppKeysFromSlug";
|
||||
import { OAuthManager } from "../../_utils/oauth/OAuthManager";
|
||||
import { oAuthManagerHelper } from "../../_utils/oauth/oAuthManagerHelper";
|
||||
import config from "../config.json";
|
||||
|
||||
/** @link https://docs.microsoft.com/en-us/graph/api/application-post-onlinemeetings?view=graph-rest-1.0&tabs=http#response */
|
||||
export interface TeamsEventResult {
|
||||
@@ -24,90 +22,56 @@ export interface TeamsEventResult {
|
||||
subject: string;
|
||||
}
|
||||
|
||||
interface O365AuthCredentials {
|
||||
email: string;
|
||||
scope: string;
|
||||
token_type: string;
|
||||
expiry_date: number;
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
ext_expires_in: number;
|
||||
}
|
||||
const o365VideoAppKeysSchema = z.object({
|
||||
client_id: z.string(),
|
||||
client_secret: z.string(),
|
||||
});
|
||||
|
||||
interface ITokenResponse {
|
||||
expiry_date: number;
|
||||
expires_in?: number;
|
||||
token_type: string;
|
||||
scope: string;
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
error?: string;
|
||||
error_description?: string;
|
||||
}
|
||||
|
||||
// Checks to see if our O365 user token is valid or if we need to refresh
|
||||
const o365Auth = async (credential: CredentialPayload) => {
|
||||
const appKeys = await getAppKeysFromSlug("msteams");
|
||||
if (typeof appKeys.client_id === "string") client_id = appKeys.client_id;
|
||||
if (typeof appKeys.client_secret === "string") client_secret = appKeys.client_secret;
|
||||
if (!client_id) throw new HttpError({ statusCode: 400, message: "MS teams client_id missing." });
|
||||
if (!client_secret) throw new HttpError({ statusCode: 400, message: "MS teams client_secret missing." });
|
||||
|
||||
const isExpired = (expiryDate: number) => expiryDate < Math.round(+new Date());
|
||||
|
||||
const o365AuthCredentials = credential.key as unknown as O365AuthCredentials;
|
||||
|
||||
const refreshAccessToken = async (refreshToken: string) => {
|
||||
const response = await refreshOAuthTokens(
|
||||
async () =>
|
||||
await fetch("https://login.microsoftonline.com/common/oauth2/v2.0/token", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
client_id,
|
||||
refresh_token: refreshToken,
|
||||
grant_type: "refresh_token",
|
||||
client_secret,
|
||||
}),
|
||||
}),
|
||||
"msteams",
|
||||
credential.userId
|
||||
);
|
||||
|
||||
const responseBody = await handleErrorsJson<ITokenResponse>(response);
|
||||
|
||||
if (responseBody?.error) {
|
||||
console.error(responseBody);
|
||||
throw new HttpError({ statusCode: 500, message: `Error contacting MS Teams: ${responseBody.error}` });
|
||||
}
|
||||
// set expiry date as offset from current time.
|
||||
responseBody.expiry_date = Math.round(Date.now() + (responseBody?.expires_in || 0) * 1000);
|
||||
delete responseBody.expires_in;
|
||||
// Store new tokens in database.
|
||||
await prisma.credential.update({
|
||||
where: {
|
||||
id: credential.id,
|
||||
},
|
||||
data: {
|
||||
// @NOTE: prisma doesn't know key its a JSON so do as responseBody
|
||||
key: responseBody as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
o365AuthCredentials.expiry_date = responseBody.expiry_date;
|
||||
o365AuthCredentials.access_token = responseBody.access_token;
|
||||
return o365AuthCredentials.access_token;
|
||||
};
|
||||
|
||||
return {
|
||||
getToken: () =>
|
||||
isExpired(o365AuthCredentials.expiry_date)
|
||||
? refreshAccessToken(o365AuthCredentials.refresh_token)
|
||||
: Promise.resolve(o365AuthCredentials.access_token),
|
||||
};
|
||||
const getO365VideoAppKeys = async () => {
|
||||
return getParsedAppKeysFromSlug(config.slug, o365VideoAppKeysSchema);
|
||||
};
|
||||
|
||||
const TeamsVideoApiAdapter = (credential: CredentialPayload): VideoApiAdapter => {
|
||||
const auth = o365Auth(credential);
|
||||
const tokenResponse = oAuthManagerHelper.getTokenObjectFromCredential(credential);
|
||||
|
||||
const auth = new OAuthManager({
|
||||
credentialSyncVariables: oAuthManagerHelper.credentialSyncVariables,
|
||||
resourceOwner: {
|
||||
type: "user",
|
||||
id: credential.userId,
|
||||
},
|
||||
appSlug: config.slug,
|
||||
currentTokenObject: tokenResponse,
|
||||
fetchNewTokenObject: async ({ refreshToken }: { refreshToken: string | null }) => {
|
||||
if (!refreshToken) {
|
||||
return null;
|
||||
}
|
||||
const { client_id, client_secret } = await getO365VideoAppKeys();
|
||||
return await fetch("https://login.microsoftonline.com/common/oauth2/v2.0/token", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
client_id,
|
||||
refresh_token: refreshToken,
|
||||
grant_type: "refresh_token",
|
||||
client_secret,
|
||||
}),
|
||||
});
|
||||
},
|
||||
isTokenObjectUnusable: async function () {
|
||||
// TODO: Implement this. As current implementation of CalendarService doesn't handle it. It hasn't been handled in the OAuthManager implementation as well.
|
||||
// This is a placeholder for future implementation.
|
||||
return null;
|
||||
},
|
||||
isAccessTokenUnusable: async function () {
|
||||
// TODO: Implement this
|
||||
return null;
|
||||
},
|
||||
invalidateTokenObject: () => oAuthManagerHelper.invalidateCredential(credential.id),
|
||||
expireAccessToken: () => oAuthManagerHelper.markTokenAsExpired(credential),
|
||||
updateTokenObject: (tokenObject) =>
|
||||
oAuthManagerHelper.updateTokenObject({ tokenObject, credentialId: credential.id }),
|
||||
});
|
||||
|
||||
const translateEvent = (event: CalendarEvent) => {
|
||||
return {
|
||||
@@ -123,16 +87,15 @@ const TeamsVideoApiAdapter = (credential: CredentialPayload): VideoApiAdapter =>
|
||||
return Promise.resolve([]);
|
||||
},
|
||||
updateMeeting: async (bookingRef: PartialReference, event: CalendarEvent) => {
|
||||
const accessToken = await (await auth).getToken();
|
||||
|
||||
const resultString = await fetch("https://graph.microsoft.com/v1.0/me/onlineMeetings", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(translateEvent(event)),
|
||||
}).then(handleErrorsRaw);
|
||||
const resultString = await auth
|
||||
.requestRaw({
|
||||
url: "https://graph.microsoft.com/v1.0/me/onlineMeetings",
|
||||
options: {
|
||||
method: "POST",
|
||||
body: JSON.stringify(translateEvent(event)),
|
||||
},
|
||||
})
|
||||
.then(handleErrorsRaw);
|
||||
|
||||
const resultObject = JSON.parse(resultString);
|
||||
|
||||
@@ -140,23 +103,22 @@ const TeamsVideoApiAdapter = (credential: CredentialPayload): VideoApiAdapter =>
|
||||
type: "office365_video",
|
||||
id: resultObject.id,
|
||||
password: "",
|
||||
url: resultObject.joinUrl,
|
||||
url: resultObject.joinWebUrl || resultObject.joinUrl,
|
||||
});
|
||||
},
|
||||
deleteMeeting: () => {
|
||||
return Promise.resolve([]);
|
||||
},
|
||||
createMeeting: async (event: CalendarEvent): Promise<VideoCallData> => {
|
||||
const accessToken = await (await auth).getToken();
|
||||
|
||||
const resultString = await fetch("https://graph.microsoft.com/v1.0/me/onlineMeetings", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(translateEvent(event)),
|
||||
}).then(handleErrorsRaw);
|
||||
const resultString = await auth
|
||||
.requestRaw({
|
||||
url: "https://graph.microsoft.com/v1.0/me/onlineMeetings",
|
||||
options: {
|
||||
method: "POST",
|
||||
body: JSON.stringify(translateEvent(event)),
|
||||
},
|
||||
})
|
||||
.then(handleErrorsRaw);
|
||||
|
||||
const resultObject = JSON.parse(resultString);
|
||||
|
||||
|
||||
@@ -28,7 +28,9 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
try {
|
||||
const responseBody = await result.json();
|
||||
errorMessage = responseBody.error;
|
||||
} catch (e) {}
|
||||
} catch (e) {
|
||||
errorMessage = await result.clone().text();
|
||||
}
|
||||
|
||||
res.status(400).json({ message: errorMessage });
|
||||
return;
|
||||
|
||||
@@ -1,20 +1,30 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import {
|
||||
APP_CREDENTIAL_SHARING_ENABLED,
|
||||
CREDENTIAL_SYNC_ENDPOINT,
|
||||
CREDENTIAL_SYNC_SECRET,
|
||||
CREDENTIAL_SYNC_SECRET_HEADER_NAME,
|
||||
} from "@calcom/lib/constants";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import prisma from "@calcom/prisma";
|
||||
import type { Credential } from "@calcom/prisma/client";
|
||||
import { Frequency } from "@calcom/prisma/zod-utils";
|
||||
import type { CalendarEvent } from "@calcom/types/Calendar";
|
||||
import type { CredentialPayload } from "@calcom/types/Credential";
|
||||
import type { PartialReference } from "@calcom/types/EventManager";
|
||||
import type { VideoApiAdapter, VideoCallData } from "@calcom/types/VideoApiAdapter";
|
||||
|
||||
import type { ParseRefreshTokenResponse } from "../../_utils/oauth/parseRefreshTokenResponse";
|
||||
import parseRefreshTokenResponse from "../../_utils/oauth/parseRefreshTokenResponse";
|
||||
import refreshOAuthTokens from "../../_utils/oauth/refreshOAuthTokens";
|
||||
import metadata from "../_metadata";
|
||||
import { invalidateCredential } from "../../_utils/invalidateCredential";
|
||||
import { OAuthManager } from "../../_utils/oauth/OAuthManager";
|
||||
import { getTokenObjectFromCredential } from "../../_utils/oauth/getTokenObjectFromCredential";
|
||||
import { markTokenAsExpired } from "../../_utils/oauth/markTokenAsExpired";
|
||||
import { metadata } from "../_metadata";
|
||||
import { getZoomAppKeys } from "./getZoomAppKeys";
|
||||
|
||||
const log = logger.getSubLogger({ prefix: ["app-store/zoomvideo/lib/VideoApiAdapter"] });
|
||||
|
||||
/** @link https://marketplace.zoom.us/docs/api-reference/zoom-api/meetings/meetingcreate */
|
||||
const zoomEventResultSchema = z.object({
|
||||
id: z.number(),
|
||||
@@ -49,93 +59,6 @@ export const zoomMeetingsSchema = z.object({
|
||||
),
|
||||
});
|
||||
|
||||
// Successful API response
|
||||
// @TODO: add link to the docs
|
||||
const zoomTokenSchema = z.object({
|
||||
scope: z.string().regex(new RegExp("meeting:write")),
|
||||
expiry_date: z.number(),
|
||||
expires_in: z.number().optional(), // deprecated, purely for backwards compatibility; superseeded by expiry_date.
|
||||
token_type: z.literal("bearer"),
|
||||
access_token: z.string(),
|
||||
refresh_token: z.string(),
|
||||
});
|
||||
|
||||
type ZoomToken = z.infer<typeof zoomTokenSchema>;
|
||||
|
||||
const isTokenValid = (token: Partial<ZoomToken>) =>
|
||||
zoomTokenSchema.safeParse(token).success && (token.expires_in || token.expiry_date || 0) > Date.now();
|
||||
|
||||
/** @link https://marketplace.zoom.us/docs/guides/auth/oauth/#request */
|
||||
const zoomRefreshedTokenSchema = z.object({
|
||||
access_token: z.string(),
|
||||
token_type: z.literal("bearer"),
|
||||
refresh_token: z.string(),
|
||||
expires_in: z.number(),
|
||||
scope: z.string(),
|
||||
});
|
||||
|
||||
const zoomAuth = (credential: CredentialPayload) => {
|
||||
const refreshAccessToken = async (refreshToken: string) => {
|
||||
const { client_id, client_secret } = await getZoomAppKeys();
|
||||
const authHeader = `Basic ${Buffer.from(`${client_id}:${client_secret}`).toString("base64")}`;
|
||||
|
||||
const response = await refreshOAuthTokens(
|
||||
async () =>
|
||||
await fetch("https://zoom.us/oauth/token", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: authHeader,
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
refresh_token: refreshToken,
|
||||
grant_type: "refresh_token",
|
||||
}),
|
||||
}),
|
||||
metadata.slug,
|
||||
credential.userId
|
||||
);
|
||||
|
||||
const responseBody = await handleZoomResponse(response, credential.id);
|
||||
|
||||
if (responseBody.error) {
|
||||
if (responseBody.error === "invalid_grant") {
|
||||
return Promise.reject(new Error("Invalid grant for Cal.com zoom app"));
|
||||
}
|
||||
}
|
||||
// We check the if the new credentials matches the expected response structure
|
||||
const newTokens: ParseRefreshTokenResponse<typeof zoomRefreshedTokenSchema> = parseRefreshTokenResponse(
|
||||
responseBody,
|
||||
zoomRefreshedTokenSchema
|
||||
);
|
||||
|
||||
const key = credential.key as ZoomToken;
|
||||
key.access_token = newTokens.access_token ?? key.access_token;
|
||||
key.refresh_token = (newTokens.refresh_token as string) ?? key.refresh_token;
|
||||
// set expiry date as offset from current time.
|
||||
key.expiry_date =
|
||||
typeof newTokens.expires_in === "number"
|
||||
? Math.round(Date.now() + newTokens.expires_in * 1000)
|
||||
: key.expiry_date;
|
||||
// Store new tokens in database.
|
||||
await prisma.credential.update({
|
||||
where: { id: credential.id },
|
||||
data: { key: { ...key, ...newTokens } },
|
||||
});
|
||||
return newTokens.access_token;
|
||||
};
|
||||
|
||||
return {
|
||||
getToken: async () => {
|
||||
const credentialKey = credential.key as ZoomToken;
|
||||
|
||||
return isTokenValid(credentialKey)
|
||||
? Promise.resolve(credentialKey.access_token)
|
||||
: refreshAccessToken(credentialKey.refresh_token);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
type ZoomRecurrence = {
|
||||
end_date_time?: string;
|
||||
type: 1 | 2 | 3;
|
||||
@@ -146,6 +69,8 @@ type ZoomRecurrence = {
|
||||
};
|
||||
|
||||
const ZoomVideoApiAdapter = (credential: CredentialPayload): VideoApiAdapter => {
|
||||
const tokenResponse = getTokenObjectFromCredential(credential);
|
||||
|
||||
const translateEvent = (event: CalendarEvent) => {
|
||||
const getRecurrence = ({
|
||||
recurringEvent,
|
||||
@@ -228,18 +153,90 @@ const ZoomVideoApiAdapter = (credential: CredentialPayload): VideoApiAdapter =>
|
||||
};
|
||||
|
||||
const fetchZoomApi = async (endpoint: string, options?: RequestInit) => {
|
||||
const auth = zoomAuth(credential);
|
||||
const accessToken = await auth.getToken();
|
||||
const response = await fetch(`https://api.zoom.us/v2/${endpoint}`, {
|
||||
method: "GET",
|
||||
...options,
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
...options?.headers,
|
||||
const auth = new OAuthManager({
|
||||
credentialSyncVariables: {
|
||||
APP_CREDENTIAL_SHARING_ENABLED: APP_CREDENTIAL_SHARING_ENABLED,
|
||||
CREDENTIAL_SYNC_ENDPOINT: CREDENTIAL_SYNC_ENDPOINT,
|
||||
CREDENTIAL_SYNC_SECRET: CREDENTIAL_SYNC_SECRET,
|
||||
CREDENTIAL_SYNC_SECRET_HEADER_NAME: CREDENTIAL_SYNC_SECRET_HEADER_NAME,
|
||||
},
|
||||
resourceOwner: {
|
||||
type: "user",
|
||||
id: credential.userId,
|
||||
},
|
||||
appSlug: metadata.slug,
|
||||
currentTokenObject: tokenResponse,
|
||||
fetchNewTokenObject: async ({ refreshToken }: { refreshToken: string | null }) => {
|
||||
if (!refreshToken) {
|
||||
return null;
|
||||
}
|
||||
const clientCredentials = await getZoomAppKeys();
|
||||
const { client_id, client_secret } = clientCredentials;
|
||||
const authHeader = `Basic ${Buffer.from(`${client_id}:${client_secret}`).toString("base64")}`;
|
||||
return fetch("https://zoom.us/oauth/token", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: authHeader,
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
refresh_token: refreshToken,
|
||||
grant_type: "refresh_token",
|
||||
}),
|
||||
});
|
||||
},
|
||||
isTokenObjectUnusable: async function (response) {
|
||||
const myLog = logger.getSubLogger({ prefix: ["zoomvideo:isTokenObjectUnusable"] });
|
||||
myLog.debug(safeStringify({ status: response.status, ok: response.ok }));
|
||||
if (!response.ok || (response.status < 200 && response.status >= 300)) {
|
||||
const responseBody = await response.json();
|
||||
myLog.debug(safeStringify({ responseBody }));
|
||||
|
||||
if (responseBody.error === "invalid_grant") {
|
||||
return { reason: responseBody.error };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
isAccessTokenUnusable: async function (response) {
|
||||
const myLog = logger.getSubLogger({ prefix: ["zoomvideo:isAccessTokenUnusable"] });
|
||||
myLog.debug(safeStringify({ status: response.status, ok: response.ok }));
|
||||
if (!response.ok || (response.status < 200 && response.status >= 300)) {
|
||||
const responseBody = await response.json();
|
||||
myLog.debug(safeStringify({ responseBody }));
|
||||
|
||||
if (responseBody.code === 124) {
|
||||
return { reason: responseBody.message ?? "" };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
invalidateTokenObject: () => invalidateCredential(credential.id),
|
||||
expireAccessToken: () => markTokenAsExpired(credential),
|
||||
updateTokenObject: async (newTokenObject) => {
|
||||
await prisma.credential.update({
|
||||
where: {
|
||||
id: credential.id,
|
||||
},
|
||||
data: {
|
||||
key: newTokenObject,
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
const responseBody = await handleZoomResponse(response, credential.id);
|
||||
return responseBody;
|
||||
|
||||
const { json } = await auth.request({
|
||||
url: `https://api.zoom.us/v2/${endpoint}`,
|
||||
options: {
|
||||
method: "GET",
|
||||
...options,
|
||||
headers: {
|
||||
...options?.headers,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return json;
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -268,12 +265,6 @@ const ZoomVideoApiAdapter = (credential: CredentialPayload): VideoApiAdapter =>
|
||||
},
|
||||
body: JSON.stringify(translateEvent(event)),
|
||||
});
|
||||
if (response.error) {
|
||||
if (response.error === "invalid_grant") {
|
||||
await invalidateCredential(credential.id);
|
||||
return Promise.reject(new Error("Invalid grant for Cal.com zoom app"));
|
||||
}
|
||||
}
|
||||
|
||||
const result = zoomEventResultSchema.parse(response);
|
||||
|
||||
@@ -319,51 +310,11 @@ const ZoomVideoApiAdapter = (credential: CredentialPayload): VideoApiAdapter =>
|
||||
url: bookingRef.meetingUrl as string,
|
||||
});
|
||||
} catch (err) {
|
||||
log.error("Failed to update meeting", safeStringify(err));
|
||||
return Promise.reject(new Error("Failed to update meeting"));
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const handleZoomResponse = async (response: Response, credentialId: Credential["id"]) => {
|
||||
let _response = response.clone();
|
||||
const responseClone = response.clone();
|
||||
if (_response.headers.get("content-encoding") === "gzip") {
|
||||
const responseString = await response.text();
|
||||
_response = JSON.parse(responseString);
|
||||
}
|
||||
if (!response.ok || (response.status < 200 && response.status >= 300)) {
|
||||
const responseBody = await _response.json();
|
||||
|
||||
if ((response && response.status === 124) || responseBody.error === "invalid_grant") {
|
||||
await invalidateCredential(credentialId);
|
||||
}
|
||||
throw Error(response.statusText);
|
||||
}
|
||||
// handle 204 response code with empty response (causes crash otherwise as "" is invalid JSON)
|
||||
if (response.status === 204) {
|
||||
return;
|
||||
}
|
||||
return responseClone.json();
|
||||
};
|
||||
|
||||
const invalidateCredential = async (credentialId: Credential["id"]) => {
|
||||
const credential = await prisma.credential.findUnique({
|
||||
where: {
|
||||
id: credentialId,
|
||||
},
|
||||
});
|
||||
|
||||
if (credential) {
|
||||
await prisma.credential.update({
|
||||
where: {
|
||||
id: credentialId,
|
||||
},
|
||||
data: {
|
||||
invalid: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export default ZoomVideoApiAdapter;
|
||||
|
||||
@@ -104,7 +104,7 @@ export const getConnectedCalendars = async (
|
||||
}
|
||||
}
|
||||
|
||||
log.error("getConnectedCalendars failed", safeStringify({ error, item }));
|
||||
log.error("getConnectedCalendars failed", safeStringify(error), safeStringify({ item }));
|
||||
|
||||
return {
|
||||
integration: cleanIntegrationKeys(item.integration),
|
||||
|
||||
@@ -109,7 +109,11 @@ const createMeeting = async (credential: CredentialPayload, calEvent: CalendarEv
|
||||
log.debug("created Meeting", safeStringify(returnObject));
|
||||
} catch (err) {
|
||||
await sendBrokenIntegrationEmail(calEvent, "video");
|
||||
log.error("createMeeting failed", safeStringify({ err, calEvent: getPiiFreeCalendarEvent(calEvent) }));
|
||||
log.error(
|
||||
"createMeeting failed",
|
||||
safeStringify(err),
|
||||
safeStringify({ calEvent: getPiiFreeCalendarEvent(calEvent) })
|
||||
);
|
||||
|
||||
// Default to calVideo
|
||||
const defaultMeeting = await createMeetingWithCalVideo(calEvent);
|
||||
|
||||
@@ -115,6 +115,8 @@ export const CREDENTIAL_SYNC_SECRET = process.env.CALCOM_CREDENTIAL_SYNC_SECRET;
|
||||
export const CREDENTIAL_SYNC_SECRET_HEADER_NAME =
|
||||
process.env.CALCOM_CREDENTIAL_SYNC_HEADER_NAME || "calcom-credential-sync-secret";
|
||||
|
||||
export const CREDENTIAL_SYNC_ENDPOINT = process.env.CALCOM_CREDENTIAL_SYNC_ENDPOINT;
|
||||
|
||||
export const DEFAULT_LIGHT_BRAND_COLOR = "#292929";
|
||||
export const DEFAULT_DARK_BRAND_COLOR = "#fafafa";
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ export function getErrorFromUnknown(cause: unknown): Error & { statusCode?: numb
|
||||
}
|
||||
|
||||
export async function handleErrorsJson<Type>(response: Response): Promise<Type> {
|
||||
// FIXME: I don't know why we are handling gzipped case separately. This should be handled by fetch itself.
|
||||
if (response.headers.get("content-encoding") === "gzip") {
|
||||
const responseText = await response.text();
|
||||
return new Promise((resolve) => resolve(JSON.parse(responseText)));
|
||||
@@ -34,7 +35,7 @@ export function handleErrorsRaw(response: Response) {
|
||||
console.error({ response });
|
||||
return "{}";
|
||||
}
|
||||
if (!response.ok && response.status < 200 && response.status >= 300) {
|
||||
if (!response.ok || response.status < 200 || response.status >= 300) {
|
||||
response.text().then(console.log);
|
||||
throw Error(response.statusText);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
/**
|
||||
* It stringifies the object which is necessary to ensure that in a logging system(like Axiom) we see the object in context in a single log event
|
||||
*/
|
||||
export function safeStringify(obj: unknown) {
|
||||
try {
|
||||
if (obj instanceof Error) {
|
||||
// Errors don't serialize well, so we extract what we want
|
||||
return obj.stack ?? obj.message;
|
||||
}
|
||||
// Avoid crashing on circular references
|
||||
return JSON.stringify(obj);
|
||||
} catch (e) {
|
||||
|
||||
@@ -225,7 +225,7 @@ model TravelSchedule {
|
||||
@@index([endDate])
|
||||
}
|
||||
|
||||
// It holds Personal Profiles of a User plus it has email, password and other core things
|
||||
// It holds Personal Profiles of a User plus it has email, password and other core things..
|
||||
model User {
|
||||
id Int @id @default(autoincrement())
|
||||
username String?
|
||||
|
||||
@@ -290,6 +290,25 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@auth/core@npm:^0.1.4":
|
||||
version: 0.1.4
|
||||
resolution: "@auth/core@npm:0.1.4"
|
||||
dependencies:
|
||||
"@panva/hkdf": 1.0.2
|
||||
cookie: 0.5.0
|
||||
jose: 4.11.1
|
||||
oauth4webapi: 2.0.5
|
||||
preact: 10.11.3
|
||||
preact-render-to-string: 5.2.3
|
||||
peerDependencies:
|
||||
nodemailer: 6.8.0
|
||||
peerDependenciesMeta:
|
||||
nodemailer:
|
||||
optional: true
|
||||
checksum: 64854404ea1883e0deb5535b34bed95cd43fc85094aeaf4f15a79e14045020eb944f844defe857edfc8528a0a024be89cbb2a3069dedef0e9217a74ca6c3eb79
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@aw-web-design/x-default-browser@npm:1.4.126":
|
||||
version: 1.4.126
|
||||
resolution: "@aw-web-design/x-default-browser@npm:1.4.126"
|
||||
@@ -4004,7 +4023,7 @@ __metadata:
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@calcom/atoms@*, @calcom/atoms@workspace:packages/platform/atoms":
|
||||
"@calcom/atoms@*, @calcom/atoms@workspace:^, @calcom/atoms@workspace:packages/platform/atoms":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@calcom/atoms@workspace:packages/platform/atoms"
|
||||
dependencies:
|
||||
@@ -4017,9 +4036,14 @@ __metadata:
|
||||
"@types/react": 18.0.26
|
||||
"@types/react-dom": ^18.0.9
|
||||
"@vitejs/plugin-react": ^2.2.0
|
||||
autoprefixer: ^10.4.19
|
||||
class-variance-authority: ^0.4.0
|
||||
clsx: ^2.0.0
|
||||
lucide-react: ^0.364.0
|
||||
postcss: ^8.4.38
|
||||
postcss-import: ^16.1.0
|
||||
postcss-prefixer: ^3.0.0
|
||||
postcss-prefixwrap: 1.46.0
|
||||
react-use: ^17.4.2
|
||||
rollup-plugin-node-builtins: ^2.1.2
|
||||
tailwind-merge: ^1.13.2
|
||||
@@ -4031,6 +4055,41 @@ __metadata:
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@calcom/auth@workspace:apps/auth":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@calcom/auth@workspace:apps/auth"
|
||||
dependencies:
|
||||
"@auth/core": ^0.1.4
|
||||
"@calcom/app-store": "*"
|
||||
"@calcom/app-store-cli": "*"
|
||||
"@calcom/config": "*"
|
||||
"@calcom/core": "*"
|
||||
"@calcom/dayjs": "*"
|
||||
"@calcom/embed-core": "workspace:*"
|
||||
"@calcom/embed-react": "workspace:*"
|
||||
"@calcom/embed-snippet": "workspace:*"
|
||||
"@calcom/features": "*"
|
||||
"@calcom/lib": "*"
|
||||
"@calcom/prisma": "*"
|
||||
"@calcom/trpc": "*"
|
||||
"@calcom/tsconfig": "*"
|
||||
"@calcom/types": "*"
|
||||
"@calcom/ui": "*"
|
||||
"@types/node": 16.9.1
|
||||
"@types/react": 18.0.26
|
||||
"@types/react-dom": ^18.0.9
|
||||
eslint: ^8.34.0
|
||||
eslint-config-next: ^13.2.1
|
||||
next: ^13.5.4
|
||||
next-auth: ^4.22.1
|
||||
postcss: ^8.4.18
|
||||
react: ^18.2.0
|
||||
react-dom: ^18.2.0
|
||||
tailwindcss: ^3.3.3
|
||||
typescript: ^4.9.4
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@calcom/base@workspace:packages/platform/examples/base":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@calcom/base@workspace:packages/platform/examples/base"
|
||||
@@ -4151,7 +4210,7 @@ __metadata:
|
||||
chart.js: ^3.7.1
|
||||
client-only: ^0.0.1
|
||||
eslint: ^8.34.0
|
||||
next: ^13.4.6
|
||||
next: ^13.5.4
|
||||
next-auth: ^4.22.1
|
||||
next-i18next: ^13.2.2
|
||||
postcss: ^8.4.18
|
||||
@@ -4351,6 +4410,29 @@ __metadata:
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@calcom/example-app-credential-sync@workspace:example-apps/credential-sync":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@calcom/example-app-credential-sync@workspace:example-apps/credential-sync"
|
||||
dependencies:
|
||||
"@calcom/atoms": "*"
|
||||
"@prisma/client": 5.4.2
|
||||
"@types/node": ^20.3.1
|
||||
"@types/react": ^18
|
||||
"@types/react-dom": ^18
|
||||
autoprefixer: ^10.0.1
|
||||
dotenv: ^16.3.1
|
||||
eslint: ^8
|
||||
eslint-config-next: 14.0.4
|
||||
next: 14.0.4
|
||||
postcss: ^8
|
||||
prisma: ^5.7.1
|
||||
react: ^18
|
||||
react-dom: ^18
|
||||
tailwindcss: ^3.3.0
|
||||
typescript: ^4.9.4
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@calcom/exchange2013calendar@workspace:packages/app-store/exchange2013calendar":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@calcom/exchange2013calendar@workspace:packages/app-store/exchange2013calendar"
|
||||
@@ -5328,6 +5410,7 @@ __metadata:
|
||||
dependencies:
|
||||
"@algora/sdk": ^0.1.2
|
||||
"@calcom/app-store": "*"
|
||||
"@calcom/atoms": "workspace:^"
|
||||
"@calcom/config": "*"
|
||||
"@calcom/dayjs": "*"
|
||||
"@calcom/embed-react": "workspace:^"
|
||||
@@ -5373,6 +5456,7 @@ __metadata:
|
||||
"@vercel/og": ^0.5.0
|
||||
autoprefixer: ^10.4.12
|
||||
bcryptjs: ^2.4.3
|
||||
class-variance-authority: ^0.7.0
|
||||
clsx: ^1.2.1
|
||||
cobe: ^0.4.1
|
||||
concurrently: ^7.6.0
|
||||
@@ -5385,6 +5469,7 @@ __metadata:
|
||||
env-cmd: ^10.1.0
|
||||
eslint: ^8.34.0
|
||||
fathom-client: ^3.5.0
|
||||
framer-motion: ^11.0.25
|
||||
globby: ^13.1.3
|
||||
graphql: ^16.8.0
|
||||
graphql-codegen: ^0.4.0
|
||||
@@ -5406,7 +5491,7 @@ __metadata:
|
||||
prism-react-renderer: ^1.3.5
|
||||
react: ^18.2.0
|
||||
react-confetti: ^6.0.1
|
||||
react-datocms: ^3.1.0
|
||||
react-datocms: ^5.0.3
|
||||
react-device-detect: ^2.2.2
|
||||
react-dom: ^18.2.0
|
||||
react-fast-marquee: ^1.6.4
|
||||
@@ -5418,6 +5503,7 @@ __metadata:
|
||||
react-merge-refs: 1.1.0
|
||||
react-resize-detector: ^9.1.0
|
||||
react-twemoji: ^0.3.0
|
||||
react-twitter-embed: ^4.0.4
|
||||
react-use-measure: ^2.1.1
|
||||
react-wrap-balancer: ^1.0.0
|
||||
remark: ^14.0.2
|
||||
@@ -9117,6 +9203,59 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@mux/mux-player-react@npm:*":
|
||||
version: 2.5.0
|
||||
resolution: "@mux/mux-player-react@npm:2.5.0"
|
||||
dependencies:
|
||||
"@mux/mux-player": 2.5.0
|
||||
"@mux/playback-core": 0.23.0
|
||||
prop-types: ^15.7.2
|
||||
peerDependencies:
|
||||
"@types/react": ^17.0.0 || ^18
|
||||
react: ^17.0.2 || ^18
|
||||
react-dom: ^17.0.2 || ^18
|
||||
peerDependenciesMeta:
|
||||
"@types/react":
|
||||
optional: true
|
||||
"@types/react-dom":
|
||||
optional: true
|
||||
checksum: 9b2854793a47928d964fbbd46d4c2e659d1dea29993bf74febcdbd3dfd2729abdb9d9d926e67113376ea7a9d99da9c275705d702996503deade6d35128e7f6ac
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@mux/mux-player@npm:2.5.0":
|
||||
version: 2.5.0
|
||||
resolution: "@mux/mux-player@npm:2.5.0"
|
||||
dependencies:
|
||||
"@mux/mux-video": 0.18.0
|
||||
"@mux/playback-core": 0.23.0
|
||||
media-chrome: ~3.2.1
|
||||
checksum: 566bcb3372fb0f6ac4e37ce82707d565693681d010a7ae6d9890ae6b74320d8464af7bb1f8582709a7263c2e09e9e58137c2caf5f2c2eb3db91a137fb625d064
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@mux/mux-video@npm:0.18.0":
|
||||
version: 0.18.0
|
||||
resolution: "@mux/mux-video@npm:0.18.0"
|
||||
dependencies:
|
||||
"@mux/playback-core": 0.23.0
|
||||
castable-video: ~1.0.6
|
||||
custom-media-element: ~1.2.3
|
||||
media-tracks: ~0.3.0
|
||||
checksum: a6a8137cfbfd04304f11434cc8e2eff3c1f3e72fd758cd4b2bf4eea4ce29a29525116a491e4be3ce4c995eba483704a5020ab4db5960017b22c5a913bfb04279
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@mux/playback-core@npm:0.23.0":
|
||||
version: 0.23.0
|
||||
resolution: "@mux/playback-core@npm:0.23.0"
|
||||
dependencies:
|
||||
hls.js: ~1.5.8
|
||||
mux-embed: ~5.2.0
|
||||
checksum: c5bee62359ee79094ceca839972a7ecbcaf7339cb68ab5f404bbb6b3b7e25d1066c69bc4d92aa9912af5fa6d6a0e4eabe745e3589fffcc92f8b78171a6af58eb
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@ndelangen/get-tarball@npm:^3.0.7":
|
||||
version: 3.0.9
|
||||
resolution: "@ndelangen/get-tarball@npm:3.0.9"
|
||||
@@ -9386,13 +9525,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@next/env@npm:13.5.6":
|
||||
version: 13.5.6
|
||||
resolution: "@next/env@npm:13.5.6"
|
||||
checksum: 5e8f3f6f987a15dad3cd7b2bcac64a6382c2ec372d95d0ce6ab295eb59c9731222017eebf71ff3005932de2571f7543bce7e5c6a8c90030207fb819404138dc2
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@next/env@npm:14.0.4":
|
||||
version: 14.0.4
|
||||
resolution: "@next/env@npm:14.0.4"
|
||||
@@ -9432,13 +9564,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@next/swc-darwin-arm64@npm:13.5.6":
|
||||
version: 13.5.6
|
||||
resolution: "@next/swc-darwin-arm64@npm:13.5.6"
|
||||
conditions: os=darwin & cpu=arm64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@next/swc-darwin-arm64@npm:14.0.4":
|
||||
version: 14.0.4
|
||||
resolution: "@next/swc-darwin-arm64@npm:14.0.4"
|
||||
@@ -9460,13 +9585,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@next/swc-darwin-x64@npm:13.5.6":
|
||||
version: 13.5.6
|
||||
resolution: "@next/swc-darwin-x64@npm:13.5.6"
|
||||
conditions: os=darwin & cpu=x64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@next/swc-darwin-x64@npm:14.0.4":
|
||||
version: 14.0.4
|
||||
resolution: "@next/swc-darwin-x64@npm:14.0.4"
|
||||
@@ -9488,13 +9606,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@next/swc-linux-arm64-gnu@npm:13.5.6":
|
||||
version: 13.5.6
|
||||
resolution: "@next/swc-linux-arm64-gnu@npm:13.5.6"
|
||||
conditions: os=linux & cpu=arm64 & libc=glibc
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@next/swc-linux-arm64-gnu@npm:14.0.4":
|
||||
version: 14.0.4
|
||||
resolution: "@next/swc-linux-arm64-gnu@npm:14.0.4"
|
||||
@@ -9516,13 +9627,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@next/swc-linux-arm64-musl@npm:13.5.6":
|
||||
version: 13.5.6
|
||||
resolution: "@next/swc-linux-arm64-musl@npm:13.5.6"
|
||||
conditions: os=linux & cpu=arm64 & libc=musl
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@next/swc-linux-arm64-musl@npm:14.0.4":
|
||||
version: 14.0.4
|
||||
resolution: "@next/swc-linux-arm64-musl@npm:14.0.4"
|
||||
@@ -9544,13 +9648,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@next/swc-linux-x64-gnu@npm:13.5.6":
|
||||
version: 13.5.6
|
||||
resolution: "@next/swc-linux-x64-gnu@npm:13.5.6"
|
||||
conditions: os=linux & cpu=x64 & libc=glibc
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@next/swc-linux-x64-gnu@npm:14.0.4":
|
||||
version: 14.0.4
|
||||
resolution: "@next/swc-linux-x64-gnu@npm:14.0.4"
|
||||
@@ -9572,13 +9669,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@next/swc-linux-x64-musl@npm:13.5.6":
|
||||
version: 13.5.6
|
||||
resolution: "@next/swc-linux-x64-musl@npm:13.5.6"
|
||||
conditions: os=linux & cpu=x64 & libc=musl
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@next/swc-linux-x64-musl@npm:14.0.4":
|
||||
version: 14.0.4
|
||||
resolution: "@next/swc-linux-x64-musl@npm:14.0.4"
|
||||
@@ -9600,13 +9690,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@next/swc-win32-arm64-msvc@npm:13.5.6":
|
||||
version: 13.5.6
|
||||
resolution: "@next/swc-win32-arm64-msvc@npm:13.5.6"
|
||||
conditions: os=win32 & cpu=arm64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@next/swc-win32-arm64-msvc@npm:14.0.4":
|
||||
version: 14.0.4
|
||||
resolution: "@next/swc-win32-arm64-msvc@npm:14.0.4"
|
||||
@@ -9628,13 +9711,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@next/swc-win32-ia32-msvc@npm:13.5.6":
|
||||
version: 13.5.6
|
||||
resolution: "@next/swc-win32-ia32-msvc@npm:13.5.6"
|
||||
conditions: os=win32 & cpu=ia32
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@next/swc-win32-ia32-msvc@npm:14.0.4":
|
||||
version: 14.0.4
|
||||
resolution: "@next/swc-win32-ia32-msvc@npm:14.0.4"
|
||||
@@ -9656,13 +9732,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@next/swc-win32-x64-msvc@npm:13.5.6":
|
||||
version: 13.5.6
|
||||
resolution: "@next/swc-win32-x64-msvc@npm:13.5.6"
|
||||
conditions: os=win32 & cpu=x64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@next/swc-win32-x64-msvc@npm:14.0.4":
|
||||
version: 14.0.4
|
||||
resolution: "@next/swc-win32-x64-msvc@npm:14.0.4"
|
||||
@@ -10305,6 +10374,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@panva/hkdf@npm:1.0.2":
|
||||
version: 1.0.2
|
||||
resolution: "@panva/hkdf@npm:1.0.2"
|
||||
checksum: 75183b4d5ea816ef516dcea70985c610683579a9e2ac540c2d59b9a3ed27eedaff830a43a1c43c1683556a457c92ac66e09109ee995ab173090e4042c4c4bb03
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@panva/hkdf@npm:^1.0.2":
|
||||
version: 1.0.4
|
||||
resolution: "@panva/hkdf@npm:1.0.4"
|
||||
@@ -17259,15 +17335,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@vercel/analytics@npm:^0.1.6":
|
||||
version: 0.1.11
|
||||
resolution: "@vercel/analytics@npm:0.1.11"
|
||||
peerDependencies:
|
||||
react: ^16.8||^17||^18
|
||||
checksum: 05b8180ac6e23ebe7c09d74c43f8ee78c408cd0b6546e676389cbf4fba44dfeeae3648c9b52e2421be64fe3aeee8b026e6ea4bdfc0589fb5780670f2b090a167
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@vercel/edge-config@npm:^0.1.1":
|
||||
version: 0.1.1
|
||||
resolution: "@vercel/edge-config@npm:0.1.1"
|
||||
@@ -19004,6 +19071,24 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"autoprefixer@npm:^10.4.19":
|
||||
version: 10.4.19
|
||||
resolution: "autoprefixer@npm:10.4.19"
|
||||
dependencies:
|
||||
browserslist: ^4.23.0
|
||||
caniuse-lite: ^1.0.30001599
|
||||
fraction.js: ^4.3.7
|
||||
normalize-range: ^0.1.2
|
||||
picocolors: ^1.0.0
|
||||
postcss-value-parser: ^4.2.0
|
||||
peerDependencies:
|
||||
postcss: ^8.1.0
|
||||
bin:
|
||||
autoprefixer: bin/autoprefixer
|
||||
checksum: 3a4bc5bace05e057396dca2b306503efc175e90e8f2abf5472d3130b72da1d54d97c0ee05df21bf04fe66a7df93fd8c8ec0f1aca72a165f4701a02531abcbf11
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"available-typed-arrays@npm:^1.0.5":
|
||||
version: 1.0.5
|
||||
resolution: "available-typed-arrays@npm:1.0.5"
|
||||
@@ -20374,6 +20459,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"caniuse-lite@npm:^1.0.30001599":
|
||||
version: 1.0.30001612
|
||||
resolution: "caniuse-lite@npm:1.0.30001612"
|
||||
checksum: 2b6ab6a19c72bdf8dccac824944e828a2a1fae52c6dfeb2d64ccecfd60d0466d2e5a392e996da2150d92850188a5034666dceed34a38d978177f6934e0bf106d
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"capital-case@npm:^1.0.4":
|
||||
version: 1.0.4
|
||||
resolution: "capital-case@npm:1.0.4"
|
||||
@@ -20411,6 +20503,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"castable-video@npm:~1.0.6":
|
||||
version: 1.0.6
|
||||
resolution: "castable-video@npm:1.0.6"
|
||||
dependencies:
|
||||
custom-media-element: ~1.2.2
|
||||
checksum: 873ea75b35c594ed3755bacedd33628c070c751e34e0a64e42f98a4e9e0fda7f988ecac0ec723a4f7a25f332ba4650d4686e826614d81663554b9b31298cc648
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"ccount@npm:^2.0.0":
|
||||
version: 2.0.1
|
||||
resolution: "ccount@npm:2.0.1"
|
||||
@@ -20875,6 +20976,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"class-variance-authority@npm:^0.7.0":
|
||||
version: 0.7.0
|
||||
resolution: "class-variance-authority@npm:0.7.0"
|
||||
dependencies:
|
||||
clsx: 2.0.0
|
||||
checksum: e7fd1fab433ef06f52a1b7b241b70b4a185864deef199d3b0a2c3412f1cc179517288264c383f3b971a00d76811625fc8f7ffe709e6170219e88cd7368f08a20
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"classnames@npm:^2.2.5, classnames@npm:^2.2.6":
|
||||
version: 2.3.2
|
||||
resolution: "classnames@npm:2.3.2"
|
||||
@@ -21147,6 +21257,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"clsx@npm:2.0.0":
|
||||
version: 2.0.0
|
||||
resolution: "clsx@npm:2.0.0"
|
||||
checksum: a2cfb2351b254611acf92faa0daf15220f4cd648bdf96ce369d729813b85336993871a4bf6978ddea2b81b5a130478339c20d9d0b5c6fc287e5147f0c059276e
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"clsx@npm:^1.1.1":
|
||||
version: 1.1.1
|
||||
resolution: "clsx@npm:1.1.1"
|
||||
@@ -22326,6 +22443,16 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"css-selector-tokenizer@npm:^0.7.2":
|
||||
version: 0.7.3
|
||||
resolution: "css-selector-tokenizer@npm:0.7.3"
|
||||
dependencies:
|
||||
cssesc: ^3.0.0
|
||||
fastparse: ^1.1.2
|
||||
checksum: 92560a9616a8bc073b88c678aa04f22c599ac23c5f8587e60f4861069e2d5aeb37b722af581ae3c5fbce453bed7a893d9c3e06830912e6d28badc3b8b99acd24
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"css-to-react-native@npm:^3.0.0":
|
||||
version: 3.0.0
|
||||
resolution: "css-to-react-native@npm:3.0.0"
|
||||
@@ -22442,6 +22569,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"custom-media-element@npm:~1.2.2, custom-media-element@npm:~1.2.3":
|
||||
version: 1.2.3
|
||||
resolution: "custom-media-element@npm:1.2.3"
|
||||
checksum: da43680c35c870cd80ea02b9fce3efe25e9502c7088de5ab0e47ef2734aa9ce7e147c95f78a10abd4c13c47a81eae4712a7651ad18f87e023bc371082ba09842
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"d3-array@npm:2 - 3, d3-array@npm:2.10.0 - 3, d3-array@npm:^3.1.6":
|
||||
version: 3.2.3
|
||||
resolution: "d3-array@npm:3.2.3"
|
||||
@@ -25755,6 +25889,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"fastparse@npm:^1.1.2":
|
||||
version: 1.1.2
|
||||
resolution: "fastparse@npm:1.1.2"
|
||||
checksum: c4d199809dc4e8acafeb786be49481cc9144de296e2d54df4540ccfd868d0df73afc649aba70a748925eb32bbc4208b723d6288adf92382275031a8c7e10c0aa
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"fastq@npm:^1.6.0":
|
||||
version: 1.13.0
|
||||
resolution: "fastq@npm:1.13.0"
|
||||
@@ -26483,6 +26624,26 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"framer-motion@npm:^11.0.25":
|
||||
version: 11.1.7
|
||||
resolution: "framer-motion@npm:11.1.7"
|
||||
dependencies:
|
||||
tslib: ^2.4.0
|
||||
peerDependencies:
|
||||
"@emotion/is-prop-valid": "*"
|
||||
react: ^18.0.0
|
||||
react-dom: ^18.0.0
|
||||
peerDependenciesMeta:
|
||||
"@emotion/is-prop-valid":
|
||||
optional: true
|
||||
react:
|
||||
optional: true
|
||||
react-dom:
|
||||
optional: true
|
||||
checksum: ba4f2f0823eec7b78fa87180545cf8c832929ce52de8bab34312a67bfd6dee4ab6ad17143b9df2efcd4ede7cb87fc326843a1101a6cad3031c89a6ad2160e037
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"fresh@npm:0.5.2, fresh@npm:^0.5.2":
|
||||
version: 0.5.2
|
||||
resolution: "fresh@npm:0.5.2"
|
||||
@@ -28164,6 +28325,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"hls.js@npm:~1.5.8":
|
||||
version: 1.5.8
|
||||
resolution: "hls.js@npm:1.5.8"
|
||||
checksum: 93781b38859ce852952244e9671d536a73acb93a20cfbc3a68e372056d024789e48c7d67354b6b9ac35acab4a161d0d15adcf64279a0832f46465233cdbc8be0
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"hmac-drbg@npm:^1.0.1":
|
||||
version: 1.0.1
|
||||
resolution: "hmac-drbg@npm:1.0.1"
|
||||
@@ -30220,15 +30388,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"isomorphic-ws@npm:^5.0.0":
|
||||
version: 5.0.0
|
||||
resolution: "isomorphic-ws@npm:5.0.0"
|
||||
peerDependencies:
|
||||
ws: "*"
|
||||
checksum: e20eb2aee09ba96247465fda40c6d22c1153394c0144fa34fe6609f341af4c8c564f60ea3ba762335a7a9c306809349f9b863c8beedf2beea09b299834ad5398
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"isstream@npm:~0.1.2":
|
||||
version: 0.1.2
|
||||
resolution: "isstream@npm:0.1.2"
|
||||
@@ -30987,6 +31146,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"jose@npm:4.11.1":
|
||||
version: 4.11.1
|
||||
resolution: "jose@npm:4.11.1"
|
||||
checksum: cd15cba258d0fd20f6168631ce2e94fda8442df80e43c1033c523915cecdf390a1cc8efe0eab0c2d65935ca973d791c668aea80724d2aa9c2879d4e70f3081d7
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"jose@npm:5.2.1":
|
||||
version: 5.2.1
|
||||
resolution: "jose@npm:5.2.1"
|
||||
@@ -32976,15 +33142,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"lucide-react@npm:^0.363.0":
|
||||
version: 0.363.0
|
||||
resolution: "lucide-react@npm:0.363.0"
|
||||
peerDependencies:
|
||||
react: ^16.5.1 || ^17.0.0 || ^18.0.0
|
||||
checksum: abe8fad469a2f14181eca6f3682403c5d53a4a354f333b0f7d3b575471d451c6f3fd36f59d96745a5a823e4cc55eeb2dcda70774a6fe438834ea3fb6fa5b75af
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"luxon@npm:3.3.0":
|
||||
version: 3.3.0
|
||||
resolution: "luxon@npm:3.3.0"
|
||||
@@ -33544,6 +33701,20 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"media-chrome@npm:~3.2.1":
|
||||
version: 3.2.1
|
||||
resolution: "media-chrome@npm:3.2.1"
|
||||
checksum: 0e7d8a6850d0c4844be7b7975bcf80886aad5f2ae3cff1081aa5a7b7920ddf9f15b2da4df875f83ffb5f083d3fc1d337a8ac204317e40391f8311eb7f2f58c5e
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"media-tracks@npm:~0.3.0":
|
||||
version: 0.3.0
|
||||
resolution: "media-tracks@npm:0.3.0"
|
||||
checksum: 217880bdf566cd070f22fce2cba132cd602ae2838a20f1c75aeb5f928edda283133d474b1f427deb2758cd9b64911f88eb6380921662566bbeb7051adbb81629
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"media-typer@npm:0.3.0":
|
||||
version: 0.3.0
|
||||
resolution: "media-typer@npm:0.3.0"
|
||||
@@ -34898,6 +35069,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"mux-embed@npm:~5.2.0":
|
||||
version: 5.2.0
|
||||
resolution: "mux-embed@npm:5.2.0"
|
||||
checksum: c90f6e216a8239ef15534b26feb2299cbac51dff82766e23c89be6e30bf4348fe711fb092968b607fd45d1b509e75882efc2e12e0b34039483c1bf1b7c6690bb
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"mysql2@npm:3.9.1":
|
||||
version: 3.9.1
|
||||
resolution: "mysql2@npm:3.9.1"
|
||||
@@ -35320,61 +35498,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"next@npm:^13.4.6":
|
||||
version: 13.5.6
|
||||
resolution: "next@npm:13.5.6"
|
||||
dependencies:
|
||||
"@next/env": 13.5.6
|
||||
"@next/swc-darwin-arm64": 13.5.6
|
||||
"@next/swc-darwin-x64": 13.5.6
|
||||
"@next/swc-linux-arm64-gnu": 13.5.6
|
||||
"@next/swc-linux-arm64-musl": 13.5.6
|
||||
"@next/swc-linux-x64-gnu": 13.5.6
|
||||
"@next/swc-linux-x64-musl": 13.5.6
|
||||
"@next/swc-win32-arm64-msvc": 13.5.6
|
||||
"@next/swc-win32-ia32-msvc": 13.5.6
|
||||
"@next/swc-win32-x64-msvc": 13.5.6
|
||||
"@swc/helpers": 0.5.2
|
||||
busboy: 1.6.0
|
||||
caniuse-lite: ^1.0.30001406
|
||||
postcss: 8.4.31
|
||||
styled-jsx: 5.1.1
|
||||
watchpack: 2.4.0
|
||||
peerDependencies:
|
||||
"@opentelemetry/api": ^1.1.0
|
||||
react: ^18.2.0
|
||||
react-dom: ^18.2.0
|
||||
sass: ^1.3.0
|
||||
dependenciesMeta:
|
||||
"@next/swc-darwin-arm64":
|
||||
optional: true
|
||||
"@next/swc-darwin-x64":
|
||||
optional: true
|
||||
"@next/swc-linux-arm64-gnu":
|
||||
optional: true
|
||||
"@next/swc-linux-arm64-musl":
|
||||
optional: true
|
||||
"@next/swc-linux-x64-gnu":
|
||||
optional: true
|
||||
"@next/swc-linux-x64-musl":
|
||||
optional: true
|
||||
"@next/swc-win32-arm64-msvc":
|
||||
optional: true
|
||||
"@next/swc-win32-ia32-msvc":
|
||||
optional: true
|
||||
"@next/swc-win32-x64-msvc":
|
||||
optional: true
|
||||
peerDependenciesMeta:
|
||||
"@opentelemetry/api":
|
||||
optional: true
|
||||
sass:
|
||||
optional: true
|
||||
bin:
|
||||
next: dist/bin/next
|
||||
checksum: c869b0014ae921ada3bf22301985027ec320aebcd6aa9c16e8afbded68bb8def5874cca034c680e8c351a79578f1e514971d02777f6f0a5a1d7290f25970ac0d
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"next@npm:^13.5.4":
|
||||
version: 13.5.5
|
||||
resolution: "next@npm:13.5.5"
|
||||
@@ -36029,6 +36152,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"oauth4webapi@npm:2.0.5":
|
||||
version: 2.0.5
|
||||
resolution: "oauth4webapi@npm:2.0.5"
|
||||
checksum: 32d0cb7b1cca42d51dfb88075ca2d69fe33172a807e8ea50e317d17cab3bc80588ab8ebcb7eb4600c371a70af4674595b4b341daf6f3a655f1efa1ab715bb6c9
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"oauth@npm:^0.9.15":
|
||||
version: 0.9.15
|
||||
resolution: "oauth@npm:0.9.15"
|
||||
@@ -37874,6 +38004,19 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"postcss-import@npm:^16.1.0":
|
||||
version: 16.1.0
|
||||
resolution: "postcss-import@npm:16.1.0"
|
||||
dependencies:
|
||||
postcss-value-parser: ^4.0.0
|
||||
read-cache: ^1.0.0
|
||||
resolve: ^1.1.7
|
||||
peerDependencies:
|
||||
postcss: ^8.0.0
|
||||
checksum: 6d7f2fd649b7c7c3ff58d9d08003a0502466a007176655922ec535c98ab1a6bb42f09f017bb05bd18dd5fb57419df0ed9a06ec7f53b1a286fcb2daf964eec19c
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"postcss-js@npm:^4.0.1":
|
||||
version: 4.0.1
|
||||
resolution: "postcss-js@npm:4.0.1"
|
||||
@@ -37972,6 +38115,26 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"postcss-prefixer@npm:^3.0.0":
|
||||
version: 3.0.0
|
||||
resolution: "postcss-prefixer@npm:3.0.0"
|
||||
dependencies:
|
||||
css-selector-tokenizer: ^0.7.2
|
||||
peerDependencies:
|
||||
postcss: ^8.0.0
|
||||
checksum: 5083289b671e2b7d1507d8574c9bcf1efe4485117adc98cee34aae2d9edb5633d203947f1d202b0a8dc631e5920247995e414a08fba1bdf822f50a043333c203
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"postcss-prefixwrap@npm:1.46.0":
|
||||
version: 1.46.0
|
||||
resolution: "postcss-prefixwrap@npm:1.46.0"
|
||||
peerDependencies:
|
||||
postcss: "*"
|
||||
checksum: a76a391c54b95cca9ec024205ca9526fa5e2cb0e033da8c78aee557b5b604a18d667ac7f5209d2af9d5bb13c7562389af0edab3e9d31ad06109c44ba979fb671
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"postcss-pseudo-companion-classes@npm:^0.1.1":
|
||||
version: 0.1.1
|
||||
resolution: "postcss-pseudo-companion-classes@npm:0.1.1"
|
||||
@@ -38092,6 +38255,17 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"postcss@npm:^8.4.38":
|
||||
version: 8.4.38
|
||||
resolution: "postcss@npm:8.4.38"
|
||||
dependencies:
|
||||
nanoid: ^3.3.7
|
||||
picocolors: ^1.0.0
|
||||
source-map-js: ^1.2.0
|
||||
checksum: 649f9e60a763ca4b5a7bbec446a069edf07f057f6d780a5a0070576b841538d1ecf7dd888f2fbfd1f76200e26c969e405aeeae66332e6927dbdc8bdcb90b9451
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"postgres-array@npm:~2.0.0":
|
||||
version: 2.0.0
|
||||
resolution: "postgres-array@npm:2.0.0"
|
||||
@@ -38159,6 +38333,17 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"preact-render-to-string@npm:5.2.3":
|
||||
version: 5.2.3
|
||||
resolution: "preact-render-to-string@npm:5.2.3"
|
||||
dependencies:
|
||||
pretty-format: ^3.8.0
|
||||
peerDependencies:
|
||||
preact: ">=10"
|
||||
checksum: 6e46288d8956adde35b9fe3a21aecd9dea29751b40f0f155dea62f3896f27cb8614d457b32f48d33909d2da81135afcca6c55077520feacd7d15164d1371fb44
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"preact-render-to-string@npm:^5.1.19":
|
||||
version: 5.2.6
|
||||
resolution: "preact-render-to-string@npm:5.2.6"
|
||||
@@ -38170,7 +38355,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"preact@npm:^10.6.3":
|
||||
"preact@npm:10.11.3, preact@npm:^10.6.3":
|
||||
version: 10.11.3
|
||||
resolution: "preact@npm:10.11.3"
|
||||
checksum: 9387115aa0581e8226309e6456e9856f17dfc0e3d3e63f774de80f3d462a882ba7c60914c05942cb51d51e23e120dcfe904b8d392d46f29ad15802941fe7a367
|
||||
@@ -39190,20 +39375,21 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-datocms@npm:^3.1.0":
|
||||
version: 3.1.4
|
||||
resolution: "react-datocms@npm:3.1.4"
|
||||
"react-datocms@npm:^5.0.3":
|
||||
version: 5.0.3
|
||||
resolution: "react-datocms@npm:5.0.3"
|
||||
dependencies:
|
||||
"@mux/mux-player-react": "*"
|
||||
datocms-listen: ^0.1.9
|
||||
datocms-structured-text-generic-html-renderer: ^2.0.1
|
||||
datocms-structured-text-utils: ^2.0.1
|
||||
react-intersection-observer: ^8.33.1
|
||||
react-intersection-observer: ^9.4.3
|
||||
react-string-replace: ^1.1.0
|
||||
universal-base64: ^2.1.0
|
||||
use-deep-compare-effect: ^1.6.1
|
||||
peerDependencies:
|
||||
react: ">= 16.12.0"
|
||||
checksum: 54aba12aef4937175c2011548a8a576c96c8d8a596e84d191826910624c1d596e76a49782689dc236388a10803b02e700ac820cb7500cca7fd147a81f6c544c3
|
||||
checksum: 22c20152afb54424acfe967a2c8c525cd9f132a33374f2aba0231f16ea64dade389b096e2dac8de9ffded612bc32e9891d725609ee947639fe1cef907cb143f5
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -39445,12 +39631,16 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-intersection-observer@npm:^8.33.1":
|
||||
version: 8.34.0
|
||||
resolution: "react-intersection-observer@npm:8.34.0"
|
||||
"react-intersection-observer@npm:^9.4.3":
|
||||
version: 9.8.2
|
||||
resolution: "react-intersection-observer@npm:9.8.2"
|
||||
peerDependencies:
|
||||
react: ^15.0.0 || ^16.0.0 || ^17.0.0|| ^18.0.0
|
||||
checksum: 7713fecfd1512c7f5a60f9f0bf15403b8f8bbd4110bcafaeaea6de36a0e0eb60368c3638f99e9c97b75ad8fc787ea48c241dcb5c694f821d7f2976f709082cc5
|
||||
react: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0
|
||||
react-dom: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0
|
||||
peerDependenciesMeta:
|
||||
react-dom:
|
||||
optional: true
|
||||
checksum: 0e77226cf458499959773da9db9e00ae33a6eb95c2fa1ec3f506bbcba00955c1c27d2320fba8cdd740cadb1c7d6cc396386cbd9c359298cff196cac660dbee49
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -39974,6 +40164,18 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-twitter-embed@npm:^4.0.4":
|
||||
version: 4.0.4
|
||||
resolution: "react-twitter-embed@npm:4.0.4"
|
||||
dependencies:
|
||||
scriptjs: ^2.5.9
|
||||
peerDependencies:
|
||||
react: ^16.0.0 || ^17.0.0 || ^18.0.0
|
||||
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0
|
||||
checksum: cdb3c5bd04c4da0efa767476be47c0a3865fb6335f2a1b9e242170167b51615c38164223278cef60c77143c4bac27ba582cbea054d0af3f138104fa5ec537c4c
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-universal-interface@npm:^0.6.2":
|
||||
version: 0.6.2
|
||||
resolution: "react-universal-interface@npm:0.6.2"
|
||||
@@ -41801,6 +42003,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"scriptjs@npm:^2.5.9":
|
||||
version: 2.5.9
|
||||
resolution: "scriptjs@npm:2.5.9"
|
||||
checksum: fc84cb6b60b6fb9aa6f1b3bc59fc94b233bd5241ed3a04233579014382b5eb60640269c87d8657902acc09f9b785ee33230c218627cea00e653564bda8f5acb6
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"scuid@npm:^1.1.0":
|
||||
version: 1.1.0
|
||||
resolution: "scuid@npm:1.1.0"
|
||||
@@ -42478,6 +42687,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"source-map-js@npm:^1.2.0":
|
||||
version: 1.2.0
|
||||
resolution: "source-map-js@npm:1.2.0"
|
||||
checksum: 791a43306d9223792e84293b00458bf102a8946e7188f3db0e4e22d8d530b5f80a4ce468eb5ec0bf585443ad55ebbd630bf379c98db0b1f317fd902500217f97
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"source-map-support@npm:0.5.13":
|
||||
version: 0.5.13
|
||||
resolution: "source-map-support@npm:0.5.13"
|
||||
|
||||
Reference in New Issue
Block a user