first commit
This commit is contained in:
9
calcom/packages/app-store/webex/DESCRIPTION.md
Normal file
9
calcom/packages/app-store/webex/DESCRIPTION.md
Normal file
@ -0,0 +1,9 @@
|
||||
---
|
||||
items:
|
||||
- 1.jpeg
|
||||
- 2.jpeg
|
||||
- 3.jpeg
|
||||
- 4.jpeg
|
||||
---
|
||||
|
||||
{DESCRIPTION}
|
14
calcom/packages/app-store/webex/README.md
Normal file
14
calcom/packages/app-store/webex/README.md
Normal file
@ -0,0 +1,14 @@
|
||||
### Obtaining Webex Client ID and Secret
|
||||
|
||||
1. Create a [Webex](https://www.webex.com/) acount, if you don't already have one.
|
||||
2. Go to [Webex for Developers](https://developer.webex.com/) and sign into to your Webex account. (Note: If you're creating a new account, create it on [Webex](https://www.webex.com/), not on [Webex for Developers](https://developer.webex.com/))
|
||||
3. On the upper right, click the profile icon and go to ["My Webex Apps"](https://developer.webex.com/my-apps)
|
||||
4. Click on "Create a New App" and select ["Integration"](https://developer.webex.com/my-apps/new/integration)
|
||||
5. Choose "No" for "Will this use a mobile SDK?"
|
||||
6. Give your app a name.
|
||||
7. Upload an icon or choose one of the default icons.
|
||||
8. Give your app a short description.
|
||||
9. Set the Redirect URI as `<Cal.com URL>/api/integrations/webex/callback` replacing Cal.com URL with the URI at which your application runs.
|
||||
10. Select the following scopes: "meeting:schedules_read", "meeting:schedules_write".
|
||||
11. Click "Add Integration".
|
||||
12. Copy the Client ID and Client Secret and add these while enabling the app through Settings -> Admin -> Apps interface
|
39
calcom/packages/app-store/webex/api/add.ts
Normal file
39
calcom/packages/app-store/webex/api/add.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import type { NextApiRequest } from "next";
|
||||
import { stringify } from "querystring";
|
||||
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { defaultHandler, defaultResponder } from "@calcom/lib/server";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import config from "../config.json";
|
||||
import { getWebexAppKeys } from "../lib/getWebexAppKeys";
|
||||
|
||||
async function handler(req: NextApiRequest) {
|
||||
// Get user
|
||||
await prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
id: req.session?.user?.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { client_id } = await getWebexAppKeys();
|
||||
|
||||
/** @link https://developer.webex.com/docs/integrations#requesting-permission */
|
||||
const params = {
|
||||
response_type: "code",
|
||||
client_id,
|
||||
redirect_uri: `${WEBAPP_URL}/api/integrations/${config.slug}/callback`,
|
||||
scope: "spark:kms meeting:schedules_read meeting:schedules_write", //should be "A space-separated list of scopes being requested by your integration"
|
||||
state: "",
|
||||
};
|
||||
const query = stringify(params).replaceAll("+", "%20");
|
||||
const url = `https://webexapis.com/v1/authorize?${query}`;
|
||||
return { url };
|
||||
}
|
||||
|
||||
export default defaultHandler({
|
||||
GET: Promise.resolve({ default: defaultResponder(handler) }),
|
||||
});
|
91
calcom/packages/app-store/webex/api/callback.ts
Normal file
91
calcom/packages/app-store/webex/api/callback.ts
Normal file
@ -0,0 +1,91 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import getInstalledAppPath from "../../_utils/getInstalledAppPath";
|
||||
import createOAuthAppCredential from "../../_utils/oauth/createOAuthAppCredential";
|
||||
import { decodeOAuthState } from "../../_utils/oauth/decodeOAuthState";
|
||||
import config from "../config.json";
|
||||
import { getWebexAppKeys } from "../lib/getWebexAppKeys";
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const { code } = req.query;
|
||||
const { client_id, client_secret } = await getWebexAppKeys();
|
||||
const state = decodeOAuthState(req);
|
||||
|
||||
/** @link https://developer.webex.com/docs/integrations#getting-an-access-token **/
|
||||
|
||||
const redirectUri = encodeURI(`${WEBAPP_URL}/api/integrations/${config.slug}/callback`);
|
||||
const params = new URLSearchParams([
|
||||
["grant_type", "authorization_code"],
|
||||
["client_id", client_id],
|
||||
["client_secret", client_secret],
|
||||
["code", code as string],
|
||||
["redirect_uri", redirectUri],
|
||||
]);
|
||||
|
||||
const options = {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: params,
|
||||
};
|
||||
|
||||
const result = await fetch("https://webexapis.com/v1/access_token", options);
|
||||
|
||||
if (result.status !== 200) {
|
||||
let errorMessage = "Something is wrong with Webex API";
|
||||
try {
|
||||
const responseBody = await result.json();
|
||||
errorMessage = responseBody.message;
|
||||
} catch (e) {}
|
||||
|
||||
res.status(400).json({ message: errorMessage });
|
||||
return;
|
||||
}
|
||||
|
||||
const responseBody = await result.json();
|
||||
|
||||
if (responseBody.message) {
|
||||
res.status(400).json({ message: responseBody.message });
|
||||
return;
|
||||
}
|
||||
|
||||
responseBody.expiry_date = Math.round(Date.now() + responseBody.expires_in * 1000);
|
||||
delete responseBody.expires_in;
|
||||
|
||||
const userId = req.session?.user.id;
|
||||
if (!userId) {
|
||||
return res.status(404).json({ message: "No user found" });
|
||||
}
|
||||
/**
|
||||
* With this we take care of no duplicate webex key for a single user
|
||||
* when creating a video room we only do findFirst so the if they have more than 1
|
||||
* others get ignored
|
||||
* */
|
||||
const existingCredentialWebexVideo = await prisma.credential.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
where: {
|
||||
type: config.type,
|
||||
userId: req.session?.user.id,
|
||||
appId: config.slug,
|
||||
},
|
||||
});
|
||||
|
||||
// Making sure we only delete webex_video
|
||||
const credentialIdsToDelete = existingCredentialWebexVideo.map((item) => item.id);
|
||||
if (credentialIdsToDelete.length > 0) {
|
||||
await prisma.credential.deleteMany({ where: { id: { in: credentialIdsToDelete }, userId } });
|
||||
}
|
||||
|
||||
await createOAuthAppCredential({ appId: config.slug, type: config.type }, responseBody, req);
|
||||
|
||||
res.redirect(
|
||||
getSafeRedirectUrl(state?.returnTo) ?? getInstalledAppPath({ variant: config.variant, slug: config.slug })
|
||||
);
|
||||
}
|
2
calcom/packages/app-store/webex/api/index.ts
Normal file
2
calcom/packages/app-store/webex/api/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { default as add } from "./add";
|
||||
export { default as callback } from "./callback";
|
27
calcom/packages/app-store/webex/config.json
Normal file
27
calcom/packages/app-store/webex/config.json
Normal file
@ -0,0 +1,27 @@
|
||||
{
|
||||
"/*": "Don't modify slug - If required, do it using cli edit command",
|
||||
"name": "Webex",
|
||||
"title": "Webex",
|
||||
"slug": "webex",
|
||||
"type": "webex_video",
|
||||
"imageSrc": "/icon.ico",
|
||||
"logo": "/icon.ico",
|
||||
"url": "https://github.com/aar2dee2",
|
||||
"variant": "conferencing",
|
||||
"categories": ["conferencing"],
|
||||
"publisher": "aar2dee2",
|
||||
"email": "support@cal.com",
|
||||
"description": "Create meetings with Cisco Webex",
|
||||
"appData": {
|
||||
"location": {
|
||||
"linkType": "dynamic",
|
||||
"type": "integrations:webex_video",
|
||||
"label": "Webex"
|
||||
}
|
||||
},
|
||||
"isTemplate": false,
|
||||
"__createdUsingCli": true,
|
||||
"__template": "basic",
|
||||
"concurrentMeetings": true,
|
||||
"isAuth": true
|
||||
}
|
2
calcom/packages/app-store/webex/index.ts
Normal file
2
calcom/packages/app-store/webex/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * as api from "./api";
|
||||
export * as lib from "./lib";
|
310
calcom/packages/app-store/webex/lib/VideoApiAdapter.ts
Normal file
310
calcom/packages/app-store/webex/lib/VideoApiAdapter.ts
Normal file
@ -0,0 +1,310 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import prisma from "@calcom/prisma";
|
||||
import type { Credential } from "@calcom/prisma/client";
|
||||
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 refreshOAuthTokens from "../../_utils/oauth/refreshOAuthTokens";
|
||||
import { getWebexAppKeys } from "./getWebexAppKeys";
|
||||
|
||||
/** @link https://developer.webex.com/docs/meetings **/
|
||||
const webexEventResultSchema = z.object({
|
||||
id: z.string(),
|
||||
webLink: z.string(),
|
||||
siteUrl: z.string(),
|
||||
password: z.string().optional().default(""),
|
||||
});
|
||||
|
||||
export type WebexEventResult = z.infer<typeof webexEventResultSchema>;
|
||||
|
||||
/** @link https://developer.webex.com/docs/api/v1/meetings/create-a-meeting */
|
||||
export const webexMeetingSchema = z.object({
|
||||
start: z.date(),
|
||||
end: z.date(),
|
||||
});
|
||||
|
||||
/** @link https://developer.webex.com/docs/api/v1/meetings/list-meetings */
|
||||
export const webexMeetingsSchema = z.object({
|
||||
items: z.array(webexMeetingSchema),
|
||||
});
|
||||
|
||||
/** @link https://developer.webex.com/docs/integrations#getting-an-access-token */
|
||||
const webexTokenSchema = z.object({
|
||||
scope: z.literal("spark:kms meeting:schedules_read meeting:schedules_write"),
|
||||
token_type: z.literal("Bearer"),
|
||||
access_token: z.string(),
|
||||
expires_in: z.number().optional(),
|
||||
refresh_token: z.string(),
|
||||
refresh_token_expires_in: z.number(),
|
||||
expiry_date: z.number(),
|
||||
});
|
||||
type WebexToken = z.infer<typeof webexTokenSchema>;
|
||||
const isTokenValid = (token: WebexToken) => token.expiry_date < Date.now();
|
||||
|
||||
/** @link https://developer.webex.com/docs/integrations#using-the-refresh-token */
|
||||
const webexRefreshedTokenSchema = z.object({
|
||||
scope: z.literal("spark:kms meeting:schedules_read meeting:schedules_write"),
|
||||
token_type: z.literal("Bearer"),
|
||||
access_token: z.string(),
|
||||
expires_in: z.number().optional(),
|
||||
refresh_token: z.string(),
|
||||
refresh_token_expires_in: z.number(),
|
||||
});
|
||||
|
||||
const webexAuth = (credential: CredentialPayload) => {
|
||||
const refreshAccessToken = async (refreshToken: string) => {
|
||||
const { client_id, client_secret } = await getWebexAppKeys();
|
||||
|
||||
const response = await refreshOAuthTokens(
|
||||
async () =>
|
||||
await fetch("https://webexapis.com/v1/access_token", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: "refresh_token",
|
||||
client_id: client_id,
|
||||
client_secret: client_secret,
|
||||
refresh_token: refreshToken,
|
||||
}),
|
||||
}),
|
||||
"webex",
|
||||
credential.userId
|
||||
);
|
||||
|
||||
const responseBody = await handleWebexResponse(response, credential.id);
|
||||
|
||||
if (responseBody.error) {
|
||||
if (responseBody.error === "invalid_grant") {
|
||||
return Promise.reject(new Error("Invalid grant for Cal.com webex app"));
|
||||
}
|
||||
}
|
||||
// We check the if the new credentials matches the expected response structure
|
||||
const parsedToken = webexRefreshedTokenSchema.safeParse(responseBody);
|
||||
if (!parsedToken.success) {
|
||||
return Promise.reject(new Error("Invalid refreshed tokens were returned"));
|
||||
}
|
||||
const newTokens = parsedToken.data;
|
||||
const oldCredential = await prisma.credential.findUniqueOrThrow({ where: { id: credential.id } });
|
||||
const parsedKey = webexTokenSchema.safeParse(oldCredential.key);
|
||||
if (!parsedKey.success) {
|
||||
return Promise.reject(new Error("Invalid credentials were saved in the DB"));
|
||||
}
|
||||
|
||||
const key = parsedKey.data;
|
||||
key.access_token = newTokens.access_token;
|
||||
key.refresh_token = newTokens.refresh_token;
|
||||
// set expiry date as offset from current time.
|
||||
if (newTokens.expires_in) {
|
||||
key.expiry_date = Math.round(Date.now() + newTokens.expires_in * 1000);
|
||||
}
|
||||
// Store new tokens in database.
|
||||
await prisma.credential.update({ where: { id: credential.id }, data: { key } });
|
||||
return newTokens.access_token;
|
||||
};
|
||||
return {
|
||||
getToken: async () => {
|
||||
let credentialKey: WebexToken | null = null;
|
||||
try {
|
||||
credentialKey = webexTokenSchema.parse(credential.key);
|
||||
} catch (error) {
|
||||
return Promise.reject("Webex credential keys parsing error");
|
||||
}
|
||||
|
||||
return !isTokenValid(credentialKey)
|
||||
? Promise.resolve(credentialKey.access_token)
|
||||
: refreshAccessToken(credentialKey.refresh_token);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const WebexVideoApiAdapter = (credential: CredentialPayload): VideoApiAdapter => {
|
||||
//TODO implement translateEvent for recurring events
|
||||
const translateEvent = (event: CalendarEvent) => {
|
||||
//To convert the Cal's CalendarEvent type to a webex meeting type
|
||||
/** @link https://developer.webex.com/docs/api/v1/meetings/create-a-meeting */
|
||||
//Required params - title, start, end
|
||||
|
||||
return {
|
||||
title: event.title,
|
||||
start: dayjs(event.startTime).utc().format(),
|
||||
end: dayjs(event.endTime).utc().format(),
|
||||
recurrence: event.recurrence, //Follows RFC 2445 https://www.ietf.org/rfc/rfc2445.txt, TODO check if needs conversion
|
||||
// timezone: event.organizer.timeZone, // Comment this out for now
|
||||
agenda: event.description,
|
||||
enableJoinBeforeHost: true, //this is true in zoom's api, do we need it here?
|
||||
invitees: event.attendees.map((attendee) => ({
|
||||
email: attendee.email,
|
||||
})),
|
||||
sendEmail: true,
|
||||
};
|
||||
};
|
||||
|
||||
const fetchWebexApi = async (endpoint: string, options?: RequestInit) => {
|
||||
const auth = webexAuth(credential);
|
||||
const accessToken = await auth.getToken();
|
||||
console.log("result of accessToken in fetchWebexApi", accessToken);
|
||||
console.log("createMeeting options in fetchWebexApi", options);
|
||||
const response = await fetch(`https://webexapis.com/v1/${endpoint}`, {
|
||||
method: "GET",
|
||||
...options,
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
...options?.headers,
|
||||
},
|
||||
});
|
||||
const responseBody = await handleWebexResponse(response, credential.id);
|
||||
return responseBody;
|
||||
};
|
||||
|
||||
return {
|
||||
getAvailability: async () => {
|
||||
try {
|
||||
const responseBody = await fetchWebexApi("meetings");
|
||||
|
||||
const data = webexMeetingsSchema.passthrough().parse(responseBody);
|
||||
return data.items.map((meeting) => ({
|
||||
start: meeting.start,
|
||||
end: meeting.end,
|
||||
}));
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
return [];
|
||||
}
|
||||
},
|
||||
createMeeting: async (event: CalendarEvent): Promise<VideoCallData> => {
|
||||
/** @link https://developer.webex.com/docs/api/v1/meetings/create-a-meeting */
|
||||
try {
|
||||
console.log("Creating meeting", event);
|
||||
console.log("meting body", translateEvent(event));
|
||||
console.log("request body in createMeeting", JSON.stringify(translateEvent(event)));
|
||||
const response = await fetchWebexApi("meetings", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(translateEvent(event)),
|
||||
});
|
||||
console.log("Webex create meeting response", response);
|
||||
if (response.error) {
|
||||
if (response.error === "invalid_grant") {
|
||||
await invalidateCredential(credential.id);
|
||||
return Promise.reject(new Error("Invalid grant for Cal.com webex app"));
|
||||
}
|
||||
}
|
||||
|
||||
const result = webexEventResultSchema.parse(response);
|
||||
if (result.id && result.webLink) {
|
||||
return {
|
||||
type: "webex_video",
|
||||
id: result.id.toString(),
|
||||
password: result.password || "",
|
||||
url: result.webLink,
|
||||
};
|
||||
}
|
||||
throw new Error(`Failed to create meeting. Response is ${JSON.stringify(result)}`);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
throw new Error("Unexpected error");
|
||||
}
|
||||
},
|
||||
deleteMeeting: async (uid: string): Promise<void> => {
|
||||
/** @link https://developer.webex.com/docs/api/v1/meetings/delete-a-meeting */
|
||||
try {
|
||||
const response = await fetchWebexApi(`meetings/${uid}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
console.log("Webex delete meeting response", response);
|
||||
if (response.error) {
|
||||
if (response.error === "invalid_grant") {
|
||||
await invalidateCredential(credential.id);
|
||||
return Promise.reject(new Error("Invalid grant for Cal.com webex app"));
|
||||
}
|
||||
}
|
||||
return Promise.resolve();
|
||||
} catch (err) {
|
||||
return Promise.reject(new Error("Failed to delete meeting"));
|
||||
}
|
||||
},
|
||||
updateMeeting: async (bookingRef: PartialReference, event: CalendarEvent): Promise<VideoCallData> => {
|
||||
/** @link https://developer.webex.com/docs/api/v1/meetings/update-a-meeting */
|
||||
try {
|
||||
const response = await fetchWebexApi(`meetings/${bookingRef.uid}`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
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 webex app"));
|
||||
}
|
||||
}
|
||||
|
||||
const result = webexEventResultSchema.parse(response);
|
||||
if (result.id && result.webLink) {
|
||||
return {
|
||||
type: "webex_video",
|
||||
id: bookingRef.meetingId as string,
|
||||
password: result.password || "",
|
||||
url: result.webLink,
|
||||
};
|
||||
}
|
||||
throw new Error(`Failed to create meeting. Response is ${JSON.stringify(result)}`);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
throw new Error("Unexpected error");
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const handleWebexResponse = 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 WebexVideoApiAdapter;
|
13
calcom/packages/app-store/webex/lib/getWebexAppKeys.ts
Normal file
13
calcom/packages/app-store/webex/lib/getWebexAppKeys.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
|
||||
|
||||
const webexAppKeysSchema = z.object({
|
||||
client_id: z.string(),
|
||||
client_secret: z.string(),
|
||||
});
|
||||
|
||||
export const getWebexAppKeys = async () => {
|
||||
const appKeys = await getAppKeysFromSlug("webex");
|
||||
return webexAppKeysSchema.parse(appKeys);
|
||||
};
|
2
calcom/packages/app-store/webex/lib/index.ts
Normal file
2
calcom/packages/app-store/webex/lib/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { getWebexAppKeys } from "./getWebexAppKeys";
|
||||
export { default as VideoApiAdapter } from "./VideoApiAdapter";
|
14
calcom/packages/app-store/webex/package.json
Normal file
14
calcom/packages/app-store/webex/package.json
Normal file
@ -0,0 +1,14 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"name": "@calcom/webex",
|
||||
"version": "0.0.0",
|
||||
"main": "./index.ts",
|
||||
"dependencies": {
|
||||
"@calcom/lib": "*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@calcom/types": "*"
|
||||
},
|
||||
"description": "Create meetings with Cisco Webex"
|
||||
}
|
BIN
calcom/packages/app-store/webex/static/1.jpeg
Normal file
BIN
calcom/packages/app-store/webex/static/1.jpeg
Normal file
Binary file not shown.
After Width: | Height: | Size: 332 KiB |
BIN
calcom/packages/app-store/webex/static/2.jpeg
Normal file
BIN
calcom/packages/app-store/webex/static/2.jpeg
Normal file
Binary file not shown.
After Width: | Height: | Size: 391 KiB |
BIN
calcom/packages/app-store/webex/static/3.jpeg
Normal file
BIN
calcom/packages/app-store/webex/static/3.jpeg
Normal file
Binary file not shown.
After Width: | Height: | Size: 259 KiB |
BIN
calcom/packages/app-store/webex/static/4.jpeg
Normal file
BIN
calcom/packages/app-store/webex/static/4.jpeg
Normal file
Binary file not shown.
After Width: | Height: | Size: 247 KiB |
BIN
calcom/packages/app-store/webex/static/icon.ico
Normal file
BIN
calcom/packages/app-store/webex/static/icon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 17 KiB |
8
calcom/packages/app-store/webex/zod.ts
Normal file
8
calcom/packages/app-store/webex/zod.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const appDataSchema = z.object({});
|
||||
|
||||
export const appKeysSchema = z.object({
|
||||
client_id: z.string().min(64),
|
||||
client_secret: z.string().min(64),
|
||||
});
|
Reference in New Issue
Block a user