2
0

first commit

This commit is contained in:
2024-08-09 00:39:27 +02:00
commit 79688abe2e
5698 changed files with 497838 additions and 0 deletions

View File

@@ -0,0 +1,10 @@
import type { NextApiRequest } from "next";
import { HttpError } from "@calcom/lib/http-error";
export default function checkSession(req: NextApiRequest) {
if (!req.session?.user?.id) {
throw new HttpError({ statusCode: 401, message: "Unauthorized" });
}
return req.session;
}

View File

@@ -0,0 +1,18 @@
import type { DestinationCalendar } from "@prisma/client";
import { metadata as OutlookMetadata } from "../../office365calendar";
/**
* When inviting attendees to a calendar event, sometimes the external ID is only used for internal purposes
* Need to process the correct external ID for the calendar service
*/
const processExternalId = (destinationCalendar: DestinationCalendar) => {
if (destinationCalendar.integration === OutlookMetadata.type) {
// Primary email should always be present for Outlook
return destinationCalendar.primaryEmail || destinationCalendar.externalId;
}
return destinationCalendar.externalId;
};
export default processExternalId;

View File

@@ -0,0 +1,64 @@
import { WEBAPP_URL } from "@calcom/lib/constants";
import type { AppCategories } from "@calcom/prisma/enums";
import type { IconName } from "@calcom/ui";
function getHref(baseURL: string, category: string, useQueryParam: boolean) {
const baseUrlParsed = new URL(baseURL, WEBAPP_URL);
baseUrlParsed.searchParams.set("category", category);
return useQueryParam ? `${baseUrlParsed.toString()}` : `${baseURL}/${category}`;
}
type AppCategoryEntry = {
name: AppCategories;
href: string;
icon: IconName;
};
const getAppCategories = (baseURL: string, useQueryParam: boolean): AppCategoryEntry[] => {
// Manually sorted alphabetically, but leaving "Other" at the end
// TODO: Refactor and type with Record<AppCategories, AppCategoryEntry> to enforce consistency
return [
{
name: "analytics",
href: getHref(baseURL, "analytics", useQueryParam),
icon: "bar-chart",
},
{
name: "automation",
href: getHref(baseURL, "automation", useQueryParam),
icon: "share-2",
},
{
name: "calendar",
href: getHref(baseURL, "calendar", useQueryParam),
icon: "calendar",
},
{
name: "conferencing",
href: getHref(baseURL, "conferencing", useQueryParam),
icon: "video",
},
{
name: "crm",
href: getHref(baseURL, "crm", useQueryParam),
icon: "contact",
},
{
name: "messaging",
href: getHref(baseURL, "messaging", useQueryParam),
icon: "mail",
},
{
name: "payment",
href: getHref(baseURL, "payment", useQueryParam),
icon: "credit-card",
},
{
name: "other",
href: getHref(baseURL, "other", useQueryParam),
icon: "grid-3x3",
},
];
};
export default getAppCategories;

View File

@@ -0,0 +1,22 @@
import type { AppCategories } from "@calcom/prisma/enums";
/**
* Handles if the app category should be full capitalized ex. CRM
*
* @param {App["variant"]} variant - The variant of the app.
* @param {boolean} [returnLowerCase] - Optional flag to return the title in lowercase.
*/
const getAppCategoryTitle = (variant: AppCategories, returnLowerCase?: boolean) => {
let title: string;
if (variant === "crm") {
title = "CRM";
return title;
} else {
title = variant;
}
return returnLowerCase ? title.toLowerCase() : title;
};
export default getAppCategoryTitle;

View File

@@ -0,0 +1,10 @@
import type { Prisma } from "@prisma/client";
import prisma from "@calcom/prisma";
async function getAppKeysFromSlug(slug: string) {
const app = await prisma.app.findUnique({ where: { slug } });
return (app?.keys || {}) as Prisma.JsonObject;
}
export default getAppKeysFromSlug;

View File

@@ -0,0 +1,53 @@
import logger from "@calcom/lib/logger";
import type { Calendar, CalendarClass } from "@calcom/types/Calendar";
import type { CredentialPayload } from "@calcom/types/Credential";
import appStore from "..";
interface CalendarApp {
lib: {
CalendarService: CalendarClass;
};
}
const log = logger.getSubLogger({ prefix: ["CalendarManager"] });
/**
* @see [Using type predicates](https://www.typescriptlang.org/docs/handbook/2/narrowing.html#using-type-predicates)
*/
const isCalendarService = (x: unknown): x is CalendarApp =>
!!x &&
typeof x === "object" &&
"lib" in x &&
typeof x.lib === "object" &&
!!x.lib &&
"CalendarService" in x.lib;
export const getCalendar = async (credential: CredentialPayload | null): Promise<Calendar | null> => {
if (!credential || !credential.key) return null;
let { type: calendarType } = credential;
if (calendarType?.endsWith("_other_calendar")) {
calendarType = calendarType.split("_other_calendar")[0];
}
// Backwards compatibility until CRM manager is created
if (calendarType?.endsWith("_crm")) {
calendarType = calendarType.split("_crm")[0];
}
const calendarAppImportFn = appStore[calendarType.split("_").join("") as keyof typeof appStore];
if (!calendarAppImportFn) {
log.warn(`calendar of type ${calendarType} is not implemented`);
return null;
}
const calendarApp = await calendarAppImportFn();
if (!isCalendarService(calendarApp)) {
log.warn(`calendar of type ${calendarType} is not implemented`);
return null;
}
log.info("Got calendarApp", calendarApp.lib.CalendarService);
const CalendarService = calendarApp.lib.CalendarService;
return new CalendarService(credential);
};

View File

@@ -0,0 +1,33 @@
import logger from "@calcom/lib/logger";
import type { CredentialPayload } from "@calcom/types/Credential";
import type { CRM } from "@calcom/types/CrmService";
import appStore from "..";
type Class<I, Args extends any[] = any[]> = new (...args: Args) => I;
type CrmClass = Class<CRM, [CredentialPayload]>;
const log = logger.getSubLogger({ prefix: ["CrmManager"] });
export const getCrm = async (credential: CredentialPayload) => {
if (!credential || !credential.key) return null;
const { type: crmType } = credential;
const crmName = crmType.split("_")[0];
const crmAppImportFn = appStore[crmName as keyof typeof appStore];
if (!crmAppImportFn) {
log.warn(`crm of type ${crmType} is not implemented`);
return null;
}
const crmApp = await crmAppImportFn();
if (crmApp && "lib" in crmApp && "CrmService" in crmApp.lib) {
const CrmService = crmApp.lib.CrmService as CrmClass;
return new CrmService(credential);
}
};
export default getCrm;

View File

@@ -0,0 +1,51 @@
import type { z } from "zod";
import type { BookerEvent } from "@calcom/features/bookings/types";
import type { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
export type EventTypeApps = NonNullable<NonNullable<z.infer<typeof EventTypeMetaDataSchema>>["apps"]>;
export type EventTypeAppsList = keyof EventTypeApps;
export const getEventTypeAppData = <T extends EventTypeAppsList>(
eventType: Pick<BookerEvent, "price" | "currency" | "metadata">,
appId: T,
forcedGet?: boolean
): EventTypeApps[T] => {
const metadata = eventType.metadata;
const appMetadata = metadata?.apps && metadata.apps[appId];
if (appMetadata) {
const allowDataGet = forcedGet ? true : appMetadata.enabled;
return allowDataGet
? {
...appMetadata,
// We should favor eventType's price and currency over appMetadata's price and currency
price: eventType.price || appMetadata.price || null,
currency: eventType.currency || appMetadata.currency || null,
// trackingId is legacy way to store value for TRACKING_ID. So, we need to support both.
TRACKING_ID: appMetadata.TRACKING_ID || appMetadata.trackingId || null,
}
: null;
}
// Backward compatibility for existing event types.
// TODO: After the new AppStore EventType App flow is stable, write a migration to migrate metadata to new format which will let us remove this compatibility code
// Migration isn't being done right now, to allow a revert if needed
const legacyAppsData = {
stripe: {
enabled: !!eventType.price,
// Price default is 0 in DB. So, it would always be non nullish.
price: eventType.price,
// Currency default is "usd" in DB.So, it would also be available always
currency: eventType.currency,
paymentOption: "ON_BOOKING",
},
giphy: {
enabled: !!eventType.metadata?.giphyThankYouPage,
thankYouPage: eventType.metadata?.giphyThankYouPage || "",
},
} as const;
// TODO: This assertion helps typescript hint that only one of the app's data can be returned
const legacyAppData = legacyAppsData[appId as Extract<T, keyof typeof legacyAppsData>];
const allowDataGet = forcedGet ? true : legacyAppData?.enabled;
return allowDataGet ? legacyAppData : null;
};

View File

@@ -0,0 +1,20 @@
import z from "zod";
import { AppCategories } from "@calcom/prisma/enums";
const variantSchema = z.nativeEnum(AppCategories);
export default function getInstalledAppPath(
{ variant, slug }: { variant?: string; slug?: string },
locationSearch = ""
): string {
if (!variant) return `/apps/installed${locationSearch}`;
const parsedVariant = variantSchema.safeParse(variant);
if (!parsedVariant.success) return `/apps/installed${locationSearch}`;
if (!slug) return `/apps/installed/${variant}${locationSearch}`;
return `/apps/installed/${variant}?hl=${slug}${locationSearch && locationSearch.slice(1)}`;
}

View File

@@ -0,0 +1,14 @@
import type Zod from "zod";
import type z from "zod";
import getAppKeysFromSlug from "./getAppKeysFromSlug";
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);
}
export default getParsedAppKeysFromSlug;

View File

@@ -0,0 +1,58 @@
import type { Prisma } from "@prisma/client";
import { HttpError } from "@calcom/lib/http-error";
import prisma from "@calcom/prisma";
import type { UserProfile } from "@calcom/types/UserProfile";
export async function checkInstalled(slug: string, userId: number) {
const alreadyInstalled = await prisma.credential.findFirst({
where: {
appId: slug,
userId: userId,
},
});
if (alreadyInstalled) {
throw new HttpError({ statusCode: 422, message: "Already installed" });
}
}
type InstallationArgs = {
appType: string;
user: {
id: number;
profile?: UserProfile;
};
slug: string;
key?: Prisma.InputJsonValue;
teamId?: number;
subscriptionId?: string | null;
paymentStatus?: string | null;
billingCycleStart?: number | null;
};
export async function createDefaultInstallation({
appType,
user,
slug,
key = {},
teamId,
billingCycleStart,
paymentStatus,
subscriptionId,
}: InstallationArgs) {
const installation = await prisma.credential.create({
data: {
type: appType,
key,
...(teamId ? { teamId } : { userId: user.id }),
appId: slug,
subscriptionId,
paymentStatus,
billingCycleStart,
},
});
if (!installation) {
throw new Error(`Unable to create user credential for type ${appType}`);
}
return installation;
}

View File

@@ -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,
},
});
}
};

View File

@@ -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

View File

@@ -0,0 +1,565 @@
/**
* 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;
}) {
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
);
if (this.useCredentialSync) {
// Though it should be validated without credential sync as well but it seems like we have some credentials without userId in production
// So, we are not validating it for now
ensureValidResourceOwner(resourceOwner);
}
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 });
}

View File

@@ -0,0 +1,69 @@
import type { NextApiRequest } from "next";
import { HttpError } from "@calcom/lib/http-error";
import prisma from "@calcom/prisma";
import { decodeOAuthState } from "../oauth/decodeOAuthState";
import { throwIfNotHaveAdminAccessToTeam } from "../throwIfNotHaveAdminAccessToTeam";
/**
* This function is used to create app credentials for either a user or a team
*
* @param appData information about the app
* @param appData.type the app slug
* @param appData.appId the app slug
* @param key the keys for the app's credentials
* @param req the request object from the API call. Used to determine if the credential belongs to a user or a team
*/
const createOAuthAppCredential = async (
appData: { type: string; appId: string },
key: unknown,
req: NextApiRequest
) => {
const userId = req.session?.user.id;
if (!userId) {
throw new HttpError({ statusCode: 401, message: "You must be logged in to do this" });
}
// For OAuth flows, see if a teamId was passed through the state
const state = decodeOAuthState(req);
if (state?.teamId) {
// Check that the user belongs to the team
const team = await prisma.team.findFirst({
where: {
id: state.teamId,
members: {
some: {
userId: req.session?.user.id,
accepted: true,
},
},
},
select: { id: true, members: { select: { userId: true } } },
});
if (!team) throw new Error("User does not belong to the team");
return await prisma.credential.create({
data: {
type: appData.type,
key: key || {},
teamId: state.teamId,
appId: appData.appId,
},
});
}
await throwIfNotHaveAdminAccessToTeam({ teamId: state?.teamId ?? null, userId });
return await prisma.credential.create({
data: {
type: appData.type,
key: key || {},
userId,
appId: appData.appId,
},
});
};
export default createOAuthAppCredential;

View File

@@ -0,0 +1,12 @@
import type { NextApiRequest } from "next";
import type { IntegrationOAuthCallbackState } from "../../types";
export function decodeOAuthState(req: NextApiRequest) {
if (typeof req.query.state !== "string") {
return undefined;
}
const state: IntegrationOAuthCallbackState = JSON.parse(req.query.state);
return state;
}

View File

@@ -0,0 +1,12 @@
import type { NextApiRequest } from "next";
import type { IntegrationOAuthCallbackState } from "../../types";
export function encodeOAuthState(req: NextApiRequest) {
if (typeof req.query.state !== "string") {
return undefined;
}
const state: IntegrationOAuthCallbackState = JSON.parse(req.query.state);
return JSON.stringify(state);
}

View File

@@ -0,0 +1,26 @@
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.error(
"GoogleCalendarService-getTokenObjectFromCredential",
safeStringify(parsedTokenResponse.error.issues)
);
throw new Error(
`Could not parse credential.key ${credential.id} with error: ${parsedTokenResponse?.error}`
);
}
const tokenResponse = parsedTokenResponse.data;
if (!tokenResponse) {
throw new Error(`credential.key is not set for credential ${credential.id}`);
}
return tokenResponse;
}

View File

@@ -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,
},
},
});
}
};

View File

@@ -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,
};

View File

@@ -0,0 +1,46 @@
import { z } from "zod";
import { APP_CREDENTIAL_SHARING_ENABLED } from "@calcom/lib/constants";
export const minimumTokenResponseSchema = z
.object({
access_token: z.string(),
})
.passthrough()
.superRefine((tokenObject, ctx) => {
if (!Object.values(tokenObject).some((value) => typeof value === "number")) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message:
"Missing a field that defines a token expiry date. Check the specific app package to see how token expiry is defined.",
});
}
});
export type ParseRefreshTokenResponse<S extends z.ZodTypeAny> =
| z.infer<S>
| z.infer<typeof minimumTokenResponseSchema>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const parseRefreshTokenResponse = (response: any, schema: z.ZodTypeAny) => {
let refreshTokenResponse;
const credentialSyncingEnabled =
APP_CREDENTIAL_SHARING_ENABLED && process.env.CALCOM_CREDENTIAL_SYNC_ENDPOINT;
if (APP_CREDENTIAL_SHARING_ENABLED && process.env.CALCOM_CREDENTIAL_SYNC_ENDPOINT) {
refreshTokenResponse = minimumTokenResponseSchema.safeParse(response);
} else {
refreshTokenResponse = schema.safeParse(response);
}
if (!refreshTokenResponse.success) {
throw new Error("Invalid refreshed tokens were returned");
}
if (!refreshTokenResponse.data.refresh_token && credentialSyncingEnabled) {
refreshTokenResponse.data.refresh_token = "refresh_token";
}
return refreshTokenResponse.data;
};
export default parseRefreshTokenResponse;

View File

@@ -0,0 +1,35 @@
import {
APP_CREDENTIAL_SHARING_ENABLED,
CREDENTIAL_SYNC_SECRET,
CREDENTIAL_SYNC_SECRET_HEADER_NAME,
} from "@calcom/lib/constants";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const refreshOAuthTokens = async (refreshFunction: () => any, appSlug: string, userId: number | null) => {
// Check that app syncing is enabled and that the credential belongs to a user
if (
APP_CREDENTIAL_SHARING_ENABLED &&
process.env.CALCOM_CREDENTIAL_SYNC_ENDPOINT &&
CREDENTIAL_SYNC_SECRET &&
userId
) {
// Customize the payload based on what your endpoint requires
// The response should only contain the access token and expiry date
const response = await fetch(process.env.CALCOM_CREDENTIAL_SYNC_ENDPOINT, {
method: "POST",
headers: {
[CREDENTIAL_SYNC_SECRET_HEADER_NAME]: CREDENTIAL_SYNC_SECRET,
},
body: new URLSearchParams({
calcomUserId: userId.toString(),
appSlug,
}),
});
return response;
} else {
const response = await refreshFunction();
return response;
}
};
export default refreshOAuthTokens;

View File

@@ -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();

View File

@@ -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,
},
});
};

View File

@@ -0,0 +1,94 @@
import type Stripe from "stripe";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { getStripeCustomerIdFromUserId, stripe } from "./stripe";
interface RedirectArgs {
userId: number;
appSlug: string;
appPaidMode: string;
priceId: string;
trialDays?: number;
}
export const withPaidAppRedirect = async ({
appSlug,
appPaidMode,
priceId,
userId,
trialDays,
}: RedirectArgs) => {
const redirect_uri = `${WEBAPP_URL}/api/integrations/${appSlug}/callback?checkoutId={CHECKOUT_SESSION_ID}`;
const stripeCustomerId = await getStripeCustomerIdFromUserId(userId);
const checkoutSession = await stripe.checkout.sessions.create({
success_url: redirect_uri,
cancel_url: redirect_uri,
mode: appPaidMode === "subscription" ? "subscription" : "payment",
payment_method_types: ["card"],
allow_promotion_codes: true,
customer: stripeCustomerId,
line_items: [
{
quantity: 1,
price: priceId,
},
],
client_reference_id: userId.toString(),
...(trialDays
? {
subscription_data: {
trial_period_days: trialDays,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore - trial_settings isn't available cc @erik
trial_settings: { end_behavior: { missing_payment_method: "cancel" } },
},
}
: undefined),
});
return checkoutSession.url;
};
export const withStripeCallback = async (
checkoutId: string,
appSlug: string,
callback: (args: { checkoutSession: Stripe.Checkout.Session }) => Promise<{ url: string }>
): Promise<{ url: string }> => {
if (!checkoutId) {
return {
url: `/apps/installed?error=${encodeURIComponent(
JSON.stringify({ message: "No Stripe Checkout Session ID" })
)}`,
};
}
const checkoutSession = await stripe.checkout.sessions.retrieve(checkoutId);
if (!checkoutSession) {
return {
url: `/apps/installed?error=${encodeURIComponent(
JSON.stringify({ message: "Unknown Stripe Checkout Session ID" })
)}`,
};
}
if (checkoutSession.payment_status !== "paid") {
return {
url: `/apps/installed?error=${encodeURIComponent(
JSON.stringify({ message: "Stripe Payment not processed" })
)}`,
};
}
if (checkoutSession.mode === "subscription" && checkoutSession.subscription) {
await stripe.subscriptions.update(checkoutSession.subscription.toString(), {
metadata: {
appSlug,
},
});
}
// Execute the callback if all checks pass
return callback({ checkoutSession });
};

View File

@@ -0,0 +1,34 @@
import type z from "zod";
import type { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import type { appDataSchemas } from "../../apps.schemas.generated";
/**
*
* @param metadata The event type metadata
* @param inclusive Determines if multiple includes the case of 1
* @returns boolean
*/
const checkForMultiplePaymentApps = (
metadata: z.infer<typeof EventTypeMetaDataSchema>,
inclusive = false
) => {
let enabledPaymentApps = 0;
for (const appKey in metadata?.apps) {
const app = metadata?.apps[appKey as keyof typeof appDataSchemas];
if ("appCategories" in app) {
const isPaymentApp = app.appCategories.includes("payment");
if (isPaymentApp && app.enabled) {
enabledPaymentApps++;
}
} else if ("price" in app && app.enabled) {
enabledPaymentApps++;
}
}
return inclusive ? enabledPaymentApps >= 1 : enabledPaymentApps > 1;
};
export default checkForMultiplePaymentApps;

View File

@@ -0,0 +1,57 @@
import type { LocationObject } from "@calcom/app-store/locations";
import { getAppFromSlug } from "@calcom/app-store/utils";
import getBulkEventTypes from "@calcom/lib/event-types/getBulkEventTypes";
import prisma from "@calcom/prisma";
import { userMetadata } from "@calcom/prisma/zod-utils";
const setDefaultConferencingApp = async (userId: number, appSlug: string) => {
const eventTypes = await getBulkEventTypes(userId);
const eventTypeIds = eventTypes.eventTypes.map((item) => item.id);
const foundApp = getAppFromSlug(appSlug);
const appType = foundApp?.appData?.location?.type;
if (!appType) {
return;
}
const user = await prisma.user.findUnique({
where: {
id: userId,
},
select: {
metadata: true,
credentials: true,
},
});
const currentMetadata = userMetadata.parse(user?.metadata);
const credentialId = user?.credentials.find((item) => item.appId == appSlug)?.id;
//Update the default conferencing app for the user.
await prisma.user.update({
where: {
id: userId,
},
data: {
metadata: {
...currentMetadata,
defaultConferencingApp: {
appSlug,
},
},
},
});
await prisma.eventType.updateMany({
where: {
id: {
in: eventTypeIds,
},
userId,
},
data: {
locations: [{ type: appType, credentialId }] as LocationObject[],
},
});
};
export default setDefaultConferencingApp;

View File

@@ -0,0 +1,74 @@
import { Prisma } from "@prisma/client";
import Stripe from "stripe";
import { HttpError } from "@calcom/lib/http-error";
import prisma from "@calcom/prisma";
export async function getStripeCustomerIdFromUserId(userId: number) {
// Get user
const user = await prisma.user.findUnique({
where: {
id: userId,
},
select: {
email: true,
name: true,
metadata: true,
},
});
if (!user?.email) throw new HttpError({ statusCode: 404, message: "User email not found" });
const customerId = await getStripeCustomerId(user);
return customerId;
}
const userType = Prisma.validator<Prisma.UserArgs>()({
select: {
email: true,
metadata: true,
},
});
export type UserType = Prisma.UserGetPayload<typeof userType>;
/** This will retrieve the customer ID from Stripe or create it if it doesn't exists yet. */
export async function getStripeCustomerId(user: UserType): Promise<string> {
let customerId: string | null = null;
if (user?.metadata && typeof user.metadata === "object" && "stripeCustomerId" in user.metadata) {
customerId = (user?.metadata as Prisma.JsonObject).stripeCustomerId as string;
} else {
/* We fallback to finding the customer by email (which is not optimal) */
const customersResponse = await stripe.customers.list({
email: user.email,
limit: 1,
});
if (customersResponse.data[0]?.id) {
customerId = customersResponse.data[0].id;
} else {
/* Creating customer on Stripe and saving it on prisma */
const customer = await stripe.customers.create({ email: user.email });
customerId = customer.id;
}
await prisma.user.update({
where: {
email: user.email,
},
data: {
metadata: {
...(user.metadata as Prisma.JsonObject),
stripeCustomerId: customerId,
},
},
});
}
return customerId;
}
const stripePrivateKey = process.env.STRIPE_PRIVATE_KEY || "";
export const stripe = new Stripe(stripePrivateKey, {
apiVersion: "2020-08-27",
});

View File

@@ -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 });
}

View File

@@ -0,0 +1,20 @@
import { HttpError } from "@calcom/lib/http-error";
import { UserRepository } from "@calcom/lib/server/repository/user";
export const throwIfNotHaveAdminAccessToTeam = async ({
teamId,
userId,
}: {
teamId: number | null;
userId: number;
}) => {
if (!teamId) {
return;
}
const teamsUserHasAdminAccessFor = await UserRepository.getUserAdminTeams(userId);
const hasAdminAccessToTeam = teamsUserHasAdminAccessFor.some((id) => id === teamId);
if (!hasAdminAccessToTeam) {
throw new HttpError({ statusCode: 401, message: "You must be an admin of the team to do this" });
}
};

View File

@@ -0,0 +1,138 @@
import type { UseMutationOptions } from "@tanstack/react-query";
import { useMutation } from "@tanstack/react-query";
import { usePathname } from "next/navigation";
import type { IntegrationOAuthCallbackState } from "@calcom/app-store/types";
import { WEBAPP_URL } from "@calcom/lib/constants";
import type { App } from "@calcom/types/App";
function gotoUrl(url: string, newTab?: boolean) {
if (newTab) {
window.open(url, "_blank");
return;
}
window.location.href = url;
}
type CustomUseMutationOptions =
| Omit<UseMutationOptions<unknown, unknown, unknown, unknown>, "mutationKey" | "mutationFn" | "onSuccess">
| undefined;
type AddAppMutationData = { setupPending: boolean } | void;
export type UseAddAppMutationOptions = CustomUseMutationOptions & {
onSuccess?: (data: AddAppMutationData) => void;
installGoogleVideo?: boolean;
returnTo?: string;
};
function useAddAppMutation(_type: App["type"] | null, options?: UseAddAppMutationOptions) {
const pathname = usePathname();
const onErrorReturnTo = `${WEBAPP_URL}${pathname}`;
const mutation = useMutation<
AddAppMutationData,
Error,
| {
type?: App["type"];
variant?: string;
slug?: string;
teamId?: number;
returnTo?: string;
defaultInstall?: boolean;
}
| ""
>({
...options,
mutationFn: async (variables) => {
let type: string | null | undefined;
const teamId = variables && variables.teamId ? variables.teamId : undefined;
const defaultInstall = variables && variables.defaultInstall ? variables.defaultInstall : undefined;
const returnTo = options?.returnTo
? options.returnTo
: variables && variables.returnTo
? variables.returnTo
: undefined;
if (variables === "") {
type = _type;
} else {
type = variables.type;
}
if (type?.endsWith("_other_calendar")) {
type = type.split("_other_calendar")[0];
}
if (options?.installGoogleVideo && type !== "google_calendar")
throw new Error("Could not install Google Meet");
const state: IntegrationOAuthCallbackState = {
onErrorReturnTo,
fromApp: true,
...(type === "google_calendar" && { installGoogleVideo: options?.installGoogleVideo }),
...(teamId && { teamId }),
...(returnTo && { returnTo }),
...(defaultInstall && { defaultInstall }),
};
const stateStr = JSON.stringify(state);
const searchParams = generateSearchParamString({
stateStr,
teamId,
returnTo,
});
const res = await fetch(`/api/integrations/${type}/add${searchParams}`);
if (!res.ok) {
const errorBody = await res.json();
throw new Error(errorBody.message || "Something went wrong");
}
const json = await res.json();
const externalUrl = /https?:\/\//.test(json?.url) && !json?.url?.startsWith(window.location.origin);
// Check first that the URL is absolute, then check that it is of different origin from the current.
if (externalUrl) {
// TODO: For Omni installation to authenticate and come back to the page where installation was initiated, some changes need to be done in all apps' add callbacks
gotoUrl(json.url, json.newTab);
return { setupPending: !json.newTab };
} else if (json.url) {
gotoUrl(json.url, json.newTab);
return {
setupPending:
json?.url?.endsWith("/setup") || json?.url?.includes("/apps/installation/event-types"),
};
} else if (returnTo) {
gotoUrl(returnTo, false);
return { setupPending: true };
} else {
return { setupPending: false };
}
},
});
return mutation;
}
export default useAddAppMutation;
const generateSearchParamString = ({
stateStr,
teamId,
returnTo,
}: {
stateStr: string;
teamId?: number;
returnTo?: string;
}) => {
const url = new URL("https://example.com"); // Base URL can be anything since we only care about the search params
url.searchParams.append("state", stateStr);
if (teamId !== undefined) {
url.searchParams.append("teamId", teamId.toString());
}
if (returnTo) {
url.searchParams.append("returnTo", returnTo);
}
// Return the search string part of the URL
return url.search;
};

View File

@@ -0,0 +1,38 @@
import { useState } from "react";
import { useAppContextWithSchema } from "@calcom/app-store/EventTypeAppContext";
import type { EventTypeAppCardApp } from "../types";
function useIsAppEnabled(app: EventTypeAppCardApp) {
const { getAppData, setAppData } = useAppContextWithSchema();
const [enabled, setEnabled] = useState(() => {
const isAppEnabled = getAppData("enabled");
if (!app.credentialOwner) {
return isAppEnabled ?? false; // Default to false if undefined
}
const credentialId = getAppData("credentialId");
const isAppEnabledForCredential =
isAppEnabled &&
(app.userCredentialIds.some((id) => id === credentialId) ||
app.credentialOwner.credentialId === credentialId);
return isAppEnabledForCredential ?? false; // Default to false if undefined
});
const updateEnabled = (newValue: boolean) => {
if (!newValue) {
setAppData("credentialId", undefined);
}
if (newValue && (app.userCredentialIds?.length || app.credentialOwner?.credentialId)) {
setAppData("credentialId", app.credentialOwner?.credentialId || app.userCredentialIds[0]);
}
setEnabled(newValue);
};
return { enabled, updateEnabled };
}
export default useIsAppEnabled;