feat: Campsite conferencing app (#15713)
* feat: Campsite conferencing app * Improve redirect after OAuth flow --------- Co-authored-by: Somay Chauhan <somaychauhan98@gmail.com> Co-authored-by: Peer Richelsen <peeroke@gmail.com>
This commit is contained in:
co-authored by
Somay Chauhan
Peer Richelsen
parent
0dd3b25624
commit
c7c5cdf133
@@ -4,6 +4,7 @@
|
||||
**/
|
||||
import { appKeysSchema as alby_zod_ts } from "./alby/zod";
|
||||
import { appKeysSchema as basecamp3_zod_ts } from "./basecamp3/zod";
|
||||
import { appKeysSchema as campsite_zod_ts } from "./campsite/zod";
|
||||
import { appKeysSchema as closecom_zod_ts } from "./closecom/zod";
|
||||
import { appKeysSchema as dailyvideo_zod_ts } from "./dailyvideo/zod";
|
||||
import { appKeysSchema as fathom_zod_ts } from "./fathom/zod";
|
||||
@@ -50,6 +51,7 @@ import { appKeysSchema as zoomvideo_zod_ts } from "./zoomvideo/zod";
|
||||
export const appKeysSchemas = {
|
||||
alby: alby_zod_ts,
|
||||
basecamp3: basecamp3_zod_ts,
|
||||
campsite: campsite_zod_ts,
|
||||
closecom: closecom_zod_ts,
|
||||
dailyvideo: dailyvideo_zod_ts,
|
||||
fathom: fathom_zod_ts,
|
||||
|
||||
@@ -12,6 +12,7 @@ import basecamp3_config_json from "./basecamp3/config.json";
|
||||
import cal_ai_config_json from "./cal-ai/config.json";
|
||||
import { metadata as caldavcalendar__metadata_ts } from "./caldavcalendar/_metadata";
|
||||
import campfire_config_json from "./campfire/config.json";
|
||||
import campsite_config_json from "./campsite/config.json";
|
||||
import clic_config_json from "./clic/config.json";
|
||||
import closecom_config_json from "./closecom/config.json";
|
||||
import cron_config_json from "./cron/config.json";
|
||||
@@ -103,6 +104,7 @@ export const appStoreMetadata = {
|
||||
"cal-ai": cal_ai_config_json,
|
||||
caldavcalendar: caldavcalendar__metadata_ts,
|
||||
campfire: campfire_config_json,
|
||||
campsite: campsite_config_json,
|
||||
clic: clic_config_json,
|
||||
closecom: closecom_config_json,
|
||||
cron: cron_config_json,
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
**/
|
||||
import { appDataSchema as alby_zod_ts } from "./alby/zod";
|
||||
import { appDataSchema as basecamp3_zod_ts } from "./basecamp3/zod";
|
||||
import { appDataSchema as campsite_zod_ts } from "./campsite/zod";
|
||||
import { appDataSchema as closecom_zod_ts } from "./closecom/zod";
|
||||
import { appDataSchema as dailyvideo_zod_ts } from "./dailyvideo/zod";
|
||||
import { appDataSchema as fathom_zod_ts } from "./fathom/zod";
|
||||
@@ -50,6 +51,7 @@ import { appDataSchema as zoomvideo_zod_ts } from "./zoomvideo/zod";
|
||||
export const appDataSchemas = {
|
||||
alby: alby_zod_ts,
|
||||
basecamp3: basecamp3_zod_ts,
|
||||
campsite: campsite_zod_ts,
|
||||
closecom: closecom_zod_ts,
|
||||
dailyvideo: dailyvideo_zod_ts,
|
||||
fathom: fathom_zod_ts,
|
||||
|
||||
@@ -13,6 +13,7 @@ export const apiHandlers = {
|
||||
"cal-ai": import("./cal-ai/api"),
|
||||
caldavcalendar: import("./caldavcalendar/api"),
|
||||
campfire: import("./campfire/api"),
|
||||
campsite: import("./campsite/api"),
|
||||
clic: import("./clic/api"),
|
||||
closecom: import("./closecom/api"),
|
||||
cron: import("./cron/api"),
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
**/
|
||||
import around_config_json from "./around/config.json";
|
||||
import campfire_config_json from "./campfire/config.json";
|
||||
import campsite_config_json from "./campsite/config.json";
|
||||
import { metadata as dailyvideo__metadata_ts } from "./dailyvideo/_metadata";
|
||||
import demodesk_config_json from "./demodesk/config.json";
|
||||
import discord_config_json from "./discord/config.json";
|
||||
@@ -45,6 +46,7 @@ import { metadata as zoomvideo__metadata_ts } from "./zoomvideo/_metadata";
|
||||
export const appStoreMetadata = {
|
||||
around: around_config_json,
|
||||
campfire: campfire_config_json,
|
||||
campsite: campsite_config_json,
|
||||
dailyvideo: dailyvideo__metadata_ts,
|
||||
demodesk: demodesk_config_json,
|
||||
discord: discord_config_json,
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
items:
|
||||
- 1.png
|
||||
- 2.png
|
||||
- 3.png
|
||||
---
|
||||
|
||||
{DESCRIPTION}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { NextApiRequest } from "next";
|
||||
import { stringify } from "querystring";
|
||||
import { z } from "zod";
|
||||
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { defaultHandler, defaultResponder } from "@calcom/lib/server";
|
||||
|
||||
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
|
||||
import { encodeOAuthState } from "../../_utils/oauth/encodeOAuthState";
|
||||
|
||||
const campsiteAppKeysSchema = z.object({
|
||||
client_id: z.string(),
|
||||
client_secret: z.string(),
|
||||
});
|
||||
|
||||
export const getCampsiteAppKeys = async () => {
|
||||
const appKeys = await getAppKeysFromSlug("campsite");
|
||||
return campsiteAppKeysSchema.parse(appKeys);
|
||||
};
|
||||
|
||||
async function handler(req: NextApiRequest) {
|
||||
// Get user
|
||||
const user = req?.session?.user;
|
||||
if (!user) {
|
||||
return { status: 401, body: { error: "Unauthorized" } };
|
||||
}
|
||||
|
||||
const { client_id } = await getCampsiteAppKeys();
|
||||
const state = encodeOAuthState(req);
|
||||
|
||||
const params = {
|
||||
response_type: "code",
|
||||
client_id,
|
||||
redirect_uri: `${WEBAPP_URL}/api/integrations/campsite/callback`,
|
||||
state,
|
||||
scope: "read_user write_call_room",
|
||||
};
|
||||
const query = stringify(params);
|
||||
const url = `https://auth.campsite.co/oauth/authorize?${query}`;
|
||||
return { url };
|
||||
}
|
||||
|
||||
export default defaultHandler({
|
||||
GET: Promise.resolve({ default: defaultResponder(handler) }),
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
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 getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
|
||||
import getInstalledAppPath from "../../_utils/getInstalledAppPath";
|
||||
import createOAuthAppCredential from "../../_utils/oauth/createOAuthAppCredential";
|
||||
import { decodeOAuthState } from "../../_utils/oauth/decodeOAuthState";
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const state = decodeOAuthState(req);
|
||||
const userId = req.session?.user.id;
|
||||
if (!userId) {
|
||||
return res.status(404).json({ message: "No user found" });
|
||||
}
|
||||
const { code } = req.query;
|
||||
const { client_id, client_secret } = await getAppKeysFromSlug("campsite");
|
||||
|
||||
const result = await fetch(`https://auth.campsite.co/oauth/token`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
client_id,
|
||||
client_secret,
|
||||
redirect_uri: `${WEBAPP_URL}/api/integrations/campsite/callback`,
|
||||
}),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (result.status !== 200) {
|
||||
let errorMessage = "Something is wrong with the Campsite API";
|
||||
try {
|
||||
const responseBody = await result.json();
|
||||
errorMessage = responseBody.error;
|
||||
} catch (e) {}
|
||||
|
||||
res.status(400).json({ message: errorMessage });
|
||||
return;
|
||||
}
|
||||
|
||||
const responseBody = await result.json();
|
||||
if (responseBody.error) {
|
||||
res.status(400).json({ message: responseBody.error });
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* With this we take care of no duplicate Campsite keys for a single user
|
||||
* when creating a room using deleteMany if there is already a Campsite key
|
||||
* */
|
||||
await prisma.credential.deleteMany({
|
||||
where: {
|
||||
type: "campsite_conferencing",
|
||||
userId,
|
||||
appId: "campsite",
|
||||
},
|
||||
});
|
||||
|
||||
await createOAuthAppCredential(
|
||||
{ appId: "campsite", type: "campsite_conferencing" },
|
||||
{ access_token: responseBody.access_token },
|
||||
req
|
||||
);
|
||||
|
||||
res.redirect(
|
||||
getSafeRedirectUrl(state?.returnTo) ?? getInstalledAppPath({ variant: "conferencing", slug: "campsite" })
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as add } from "./add";
|
||||
export { default as callback } from "./callback";
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"/*": "Don't modify slug - If required, do it using cli edit command",
|
||||
"name": "Campsite",
|
||||
"slug": "campsite",
|
||||
"type": "campsite_conferencing",
|
||||
"logo": "icon.svg",
|
||||
"url": "https://campsite.co",
|
||||
"variant": "conferencing",
|
||||
"categories": ["conferencing"],
|
||||
"publisher": "Campsite",
|
||||
"email": "support@campsite.co",
|
||||
"appData": {
|
||||
"location": {
|
||||
"type": "integrations:{SLUG}_conferencing",
|
||||
"label": "{TITLE}",
|
||||
"linkType": "dynamic"
|
||||
}
|
||||
},
|
||||
"description": "Campsite is an all-in-one communication app that helps async teams move faster. Posts, docs, calls, and chat in one place — finally. Everything is searchable and easy to find.",
|
||||
"isTemplate": false,
|
||||
"__createdUsingCli": true,
|
||||
"__template": "event-type-location-video-static"
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * as api from "./api";
|
||||
export * as lib from "./lib";
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { CalendarEvent, EventBusyDate } 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";
|
||||
|
||||
type CampsiteToken = {
|
||||
access_token: string;
|
||||
};
|
||||
const CampsiteVideoApiAdapter = (credential: CredentialPayload): VideoApiAdapter => {
|
||||
return {
|
||||
createMeeting: async (_event: CalendarEvent): Promise<VideoCallData> => {
|
||||
const keys = credential.key as CampsiteToken;
|
||||
const { access_token } = keys;
|
||||
const response = await fetch("https://api.campsite.co/v1/integrations/cal_dot_com/call_rooms", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${access_token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
const callRoom = await response.json();
|
||||
|
||||
return {
|
||||
type: "campsite_conferencing",
|
||||
id: callRoom.id,
|
||||
password: "",
|
||||
url: callRoom.url,
|
||||
};
|
||||
},
|
||||
updateMeeting: async (bookingRef: PartialReference, _event: CalendarEvent): Promise<VideoCallData> => {
|
||||
return {
|
||||
type: "campsite_conferencing",
|
||||
id: bookingRef.externalCalendarId ? bookingRef.externalCalendarId : "",
|
||||
password: "",
|
||||
url: bookingRef.meetingUrl ? bookingRef.meetingUrl : "",
|
||||
};
|
||||
},
|
||||
deleteMeeting: async (_uid: string): Promise<unknown> => {
|
||||
return {};
|
||||
},
|
||||
getAvailability: async (_dateFrom?: string, _dateTo?: string): Promise<EventBusyDate[]> => {
|
||||
return [];
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export default CampsiteVideoApiAdapter;
|
||||
@@ -0,0 +1 @@
|
||||
export { default as VideoApiAdapter } from "./VideoApiAdapter";
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"name": "@calcom/campsite",
|
||||
"version": "0.0.0",
|
||||
"main": "./index.ts",
|
||||
"dependencies": {
|
||||
"@calcom/lib": "*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@calcom/types": "*"
|
||||
},
|
||||
"description": "Campsite is an all-in-one communication app that helps async teams move faster. Posts, docs, calls, and chat in one place — finally. Everything is searchable and easy to find."
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.9 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.0 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.0 MiB |
@@ -0,0 +1,157 @@
|
||||
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_2575_4550)">
|
||||
<g filter="url(#filter0_di_2575_4550)">
|
||||
<path
|
||||
d="M48 0H16C7.16344 0 0 7.16344 0 16V48C0 56.8366 7.16344 64 16 64H48C56.8366 64 64 56.8366 64 48V16C64 7.16344 56.8366 0 48 0Z"
|
||||
fill="url(#paint0_radial_2575_4550)"
|
||||
/>
|
||||
</g>
|
||||
<g filter="url(#filter1_dddii_2575_4550)">
|
||||
<path
|
||||
d="M9.52012 41.9532L22.0953 20.6545C22.3348 20.2489 22.7709 19.9999 23.2421 19.9999H43.7279C44.7587 19.9999 45.3987 21.1203 44.8747 22.0077L32.2996 43.3064C32.0601 43.7121 31.624 43.961 31.1528 43.961H10.667C9.63615 43.961 8.99619 42.8406 9.52012 41.9532Z"
|
||||
fill="url(#paint1_linear_2575_4550)"
|
||||
fill-opacity="0.96"
|
||||
/>
|
||||
<path
|
||||
d="M38.9673 43.9609H53.2769C54.2811 43.9609 54.924 42.8921 54.4531 42.0055L47.6958 29.2815C47.2086 28.3639 45.9046 28.333 45.3743 29.2264L37.8221 41.9505C37.2954 42.8378 37.9352 43.9609 38.9673 43.9609Z"
|
||||
fill="url(#paint2_linear_2575_4550)"
|
||||
fill-opacity="0.96"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<filter
|
||||
id="filter0_di_2575_4550"
|
||||
x="-40"
|
||||
y="0"
|
||||
width="144"
|
||||
height="144"
|
||||
filterUnits="userSpaceOnUse"
|
||||
color-interpolation-filters="sRGB"
|
||||
>
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix" />
|
||||
<feColorMatrix
|
||||
in="SourceAlpha"
|
||||
type="matrix"
|
||||
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
|
||||
result="hardAlpha"
|
||||
/>
|
||||
<feOffset dy="40" />
|
||||
<feGaussianBlur stdDeviation="20" />
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0" />
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_2575_4550" />
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_2575_4550" result="shape" />
|
||||
<feColorMatrix
|
||||
in="SourceAlpha"
|
||||
type="matrix"
|
||||
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
|
||||
result="hardAlpha"
|
||||
/>
|
||||
<feOffset dy="16" />
|
||||
<feGaussianBlur stdDeviation="8" />
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.25 0" />
|
||||
<feBlend mode="normal" in2="shape" result="effect2_innerShadow_2575_4550" />
|
||||
</filter>
|
||||
<filter
|
||||
id="filter1_dddii_2575_4550"
|
||||
x="1.33325"
|
||||
y="15.9999"
|
||||
width="61.2772"
|
||||
height="39.9611"
|
||||
filterUnits="userSpaceOnUse"
|
||||
color-interpolation-filters="sRGB"
|
||||
>
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix" />
|
||||
<feColorMatrix
|
||||
in="SourceAlpha"
|
||||
type="matrix"
|
||||
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
|
||||
result="hardAlpha"
|
||||
/>
|
||||
<feOffset dy="1" />
|
||||
<feGaussianBlur stdDeviation="1" />
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0.733333 0 0 0 0 0.0745098 0 0 0 0 0.0745098 0 0 0 0.316416 0" />
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_2575_4550" />
|
||||
<feColorMatrix
|
||||
in="SourceAlpha"
|
||||
type="matrix"
|
||||
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
|
||||
result="hardAlpha"
|
||||
/>
|
||||
<feOffset dy="2" />
|
||||
<feGaussianBlur stdDeviation="2" />
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0.733333 0 0 0 0 0.0745098 0 0 0 0 0.0745098 0 0 0 0.358691 0" />
|
||||
<feBlend mode="normal" in2="effect1_dropShadow_2575_4550" result="effect2_dropShadow_2575_4550" />
|
||||
<feColorMatrix
|
||||
in="SourceAlpha"
|
||||
type="matrix"
|
||||
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
|
||||
result="hardAlpha"
|
||||
/>
|
||||
<feOffset dy="4" />
|
||||
<feGaussianBlur stdDeviation="4" />
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0.733333 0 0 0 0 0.0745098 0 0 0 0 0.0745098 0 0 0 0.31 0" />
|
||||
<feBlend mode="normal" in2="effect2_dropShadow_2575_4550" result="effect3_dropShadow_2575_4550" />
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect3_dropShadow_2575_4550" result="shape" />
|
||||
<feColorMatrix
|
||||
in="SourceAlpha"
|
||||
type="matrix"
|
||||
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
|
||||
result="hardAlpha"
|
||||
/>
|
||||
<feOffset dy="-2" />
|
||||
<feGaussianBlur stdDeviation="12" />
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.4 0" />
|
||||
<feBlend mode="normal" in2="shape" result="effect4_innerShadow_2575_4550" />
|
||||
<feColorMatrix
|
||||
in="SourceAlpha"
|
||||
type="matrix"
|
||||
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
|
||||
result="hardAlpha"
|
||||
/>
|
||||
<feOffset />
|
||||
<feGaussianBlur stdDeviation="2" />
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0" />
|
||||
<feBlend mode="normal" in2="effect4_innerShadow_2575_4550" result="effect5_innerShadow_2575_4550" />
|
||||
</filter>
|
||||
<radialGradient
|
||||
id="paint0_radial_2575_4550"
|
||||
cx="0"
|
||||
cy="0"
|
||||
r="1"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="translate(13.5534 65.4757) rotate(-74.2658) scale(68.0246 308.191)"
|
||||
>
|
||||
<stop stop-color="#FA4438" />
|
||||
<stop offset="1" stop-color="#FF6D23" />
|
||||
</radialGradient>
|
||||
<linearGradient
|
||||
id="paint1_linear_2575_4550"
|
||||
x1="46.122"
|
||||
y1="28.5745"
|
||||
x2="46.122"
|
||||
y2="43.9609"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stop-color="white" />
|
||||
<stop offset="1" stop-color="white" stop-opacity="0.86" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint2_linear_2575_4550"
|
||||
x1="46.122"
|
||||
y1="28.5745"
|
||||
x2="46.122"
|
||||
y2="43.9609"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stop-color="white" />
|
||||
<stop offset="1" stop-color="white" stop-opacity="0.86" />
|
||||
</linearGradient>
|
||||
<clipPath id="clip0_2575_4550">
|
||||
<rect width="64" height="64" rx="16" fill="white" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 6.0 KiB |
@@ -0,0 +1,8 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const appDataSchema = z.object({});
|
||||
|
||||
export const appKeysSchema = z.object({
|
||||
client_id: z.string().min(1),
|
||||
client_secret: z.string().min(1),
|
||||
});
|
||||
@@ -4,6 +4,7 @@ const appStore = {
|
||||
applecalendar: () => import("./applecalendar"),
|
||||
aroundvideo: () => import("./around"),
|
||||
caldavcalendar: () => import("./caldavcalendar"),
|
||||
campsiteconferencing: () => import("./campsite"),
|
||||
closecom: () => import("./closecom"),
|
||||
dailyvideo: () => import("./dailyvideo"),
|
||||
googlecalendar: () => import("./googlecalendar"),
|
||||
|
||||
Reference in New Issue
Block a user