Admin apps UI (#5494)

* Abstract app category navigation

* Send key schema to frontend

Co-authored-by: Omar López <zomars@users.noreply.github.com>

* Render keys for apps on admin

* Add enabled col for apps

* Save app keys to DB

* Add checks for admin role

* Abstract setup components

* Add AdminAppsList to setup wizard

* Migrate to v10 tRPC

* Default hide keys

* Display enabled apps

* Merge branch 'main' into admin-apps-ui

* Toggle calendars

* WIP

* Add params and include AppCategoryNavigation

* Refactor getEnabledApps

* Add warning for disabling apps

* Fallback to cal video when a video app is disabled

* WIP send disabled email

* Send email to all users of  event types with payment app

* Disable Stripe when app is disabled

* Disable apps in event types

* Send email to users on disabled apps

* Send email based on what app was disabled

* WIP type fix

* Disable navigation to apps list if already setup

* UI import fixes

* Waits for session data before redirecting

* Updates admin seeded password

To comply with admin password requirements

* Update yarn.lock

* Flex fixes

* Adds admin middleware

* Clean up

* WIP

* WIP

* NTS

* Add dirName to app metadata

* Upsert app if not in db

* Upsert app if not in db

* Add dirName to app metadata

* Add keys to app packages w/ keys

* Merge with main

* Toggle show keys & on enable

* Fix empty keys

* Fix lark calendar metadata

* Fix some type errors

* Fix Lark metadata & check for category when upserting

* More type fixes

* Fix types & add keys to google cal

* WIP

* WIP

* WIP

* More type fixes

* Fix type errors

* Fix type errors

* More type fixes

* More type fixes

* More type fixes

* Feedback

* Fixes default value

* Feedback

* Migrate credential invalid col default value "false"

* Upsert app on saving keys

* Clean up

* Validate app keys on frontend

* Add nonempty to app keys schemas

* Add web3

* Listlocale filter on categories / category

* Grab app metadata via category or categories

* Show empty screen if no apps are enabled

* Fix type checks

* Fix type checks

* Fix type checks

* Fix type checks

* Fix type checks

* Fix type checks

* Replace .nonempty() w/ .min(1)

* Fix type error

* Address feedback

* Added migration to keep current apps enabled

* Update apps.tsx

* Fix bug

* Add keys schema to Plausible app

* Add appKeysSchema to zod.ts template

* Update AdminAppsList.tsx

Co-authored-by: Omar López <zomars@users.noreply.github.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: zomars <zomars@me.com>
Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
This commit is contained in:
Joe Au-Yeung 2022-12-07 16:47:02 -05:00 committed by GitHub
parent 21dd1f4e95
commit a9a295dc54
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
114 changed files with 1396 additions and 193 deletions

@ -1 +1 @@
Subproject commit 41d22c8ccb64f30a8f2a4e5ed106828e0c075027
Subproject commit 3d84ce68c9baa5d4ce7c85a37a9b8678f399b7a7

View File

@ -60,9 +60,10 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
res.setHeader("Content-Type", "text/html");
res.setHeader("Cache-Control", "no-cache, no-store, private, must-revalidate");
res.write(
renderEmail("AttendeeScheduledEmail", {
attendee: evt.attendees[0],
calEvent: evt,
renderEmail("DisabledAppEmail", {
appName: "Stripe",
appType: ["payment"],
t,
})
);
res.end();

View File

@ -1,5 +1,4 @@
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { InferGetStaticPropsType, NextPageContext } from "next";
import { GetServerSideProps, InferGetServerSidePropsType } from "next";
import { ChangeEventHandler, useState } from "react";
import { getAppRegistry, getAppRegistryWithCredentials } from "@calcom/app-store/_appRegistry";
@ -31,7 +30,10 @@ function AppsSearch({
);
}
export default function Apps({ appStore, categories }: InferGetStaticPropsType<typeof getServerSideProps>) {
export default function Apps({
categories,
appStore,
}: InferGetServerSidePropsType<typeof getServerSideProps>) {
const { t } = useLocale();
const [searchText, setSearchText] = useState<string | undefined>(undefined);
@ -42,7 +44,8 @@ export default function Apps({ appStore, categories }: InferGetStaticPropsType<t
subtitle={t("app_store_description")}
actions={(className) => (
<AppsSearch className={className} onChange={(e) => setSearchText(e.target.value)} />
)}>
)}
emptyStore={!appStore.length}>
{!searchText && (
<>
<AppStoreCategories categories={categories} />
@ -54,7 +57,7 @@ export default function Apps({ appStore, categories }: InferGetStaticPropsType<t
);
}
export const getServerSideProps = async (context: NextPageContext) => {
export const getServerSideProps: GetServerSideProps = async (context) => {
const ssg = await ssgInit(context);
const session = await getSession(context);
@ -77,7 +80,6 @@ export const getServerSideProps = async (context: NextPageContext) => {
}, {} as Record<string, number>);
return {
props: {
trpcState: ssg.dehydrate(),
categories: Object.entries(categories)
.map(([name, count]): { name: AppCategories; count: number } => ({
name: name as AppCategories,
@ -87,6 +89,7 @@ export const getServerSideProps = async (context: NextPageContext) => {
return b.count - a.count;
}),
appStore,
trpcState: ssg.dehydrate(),
},
};
};

View File

@ -0,0 +1,48 @@
import { useState } from "react";
import AdminAppsList from "@calcom/features/apps/AdminAppsList";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import prisma from "@calcom/prisma";
import { inferSSRProps } from "@calcom/types/inferSSRProps";
import { WizardForm } from "@calcom/ui";
import SetupFormStep1 from "./steps/SetupFormStep1";
import StepDone from "./steps/StepDone";
export default function Setup(props: inferSSRProps<typeof getServerSideProps>) {
const { t } = useLocale();
const [isLoadingStep1, setIsLoadingStep1] = useState(false);
const shouldDisable = props.userCount !== 0;
const steps = [
{
title: t("administrator_user"),
description: t("lets_create_first_administrator_user"),
content: shouldDisable ? <StepDone /> : <SetupFormStep1 setIsLoading={setIsLoadingStep1} />,
isLoading: isLoadingStep1,
},
{
title: t("enable_apps"),
description: t("enable_apps_description"),
content: <AdminAppsList baseURL="/auth/setup" />,
isLoading: false,
},
];
return (
<>
<main className="flex items-center bg-gray-100 print:h-full">
<WizardForm href="/auth/setup" steps={steps} disableNavigation={shouldDisable} />
</main>
</>
);
}
export const getServerSideProps = async () => {
const userCount = await prisma.user.count();
return {
props: {
userCount,
},
};
};

View File

@ -1,34 +1,14 @@
import { CheckIcon } from "@heroicons/react/solid";
import { zodResolver } from "@hookform/resolvers/zod";
import classNames from "classnames";
import { GetServerSidePropsContext } from "next";
import { signIn } from "next-auth/react";
import { useRouter } from "next/router";
import { useState } from "react";
import { Controller, FormProvider, useForm } from "react-hook-form";
import * as z from "zod";
import { isPasswordValid } from "@calcom/lib/auth";
import { WEBSITE_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import prisma from "@calcom/prisma";
import { inferSSRProps } from "@calcom/types/inferSSRProps";
import { EmailField, Label, PasswordField, TextField, WizardForm } from "@calcom/ui";
import { ssrInit } from "@server/lib/ssr";
const StepDone = () => {
const { t } = useLocale();
return (
<div className="min-h-36 my-6 flex flex-col items-center justify-center">
<div className="flex h-[72px] w-[72px] items-center justify-center rounded-full bg-gray-600 dark:bg-white">
<CheckIcon className="inline-block h-10 w-10 text-white dark:bg-white dark:text-gray-600" />
</div>
<div className="max-w-[420px] text-center">
<h2 className="mt-6 mb-1 text-lg font-medium dark:text-gray-300">{t("all_done")}</h2>
</div>
</div>
);
};
import { EmailField, Label, PasswordField, TextField } from "@calcom/ui";
const SetupFormStep1 = (props: { setIsLoading: (val: boolean) => void }) => {
const router = useRouter();
@ -83,13 +63,19 @@ const SetupFormStep1 = (props: { setIsLoading: (val: boolean) => void }) => {
},
});
if (response.status === 200) {
router.replace(`/auth/login?email=${data.email_address.toLowerCase()}`);
await signIn("credentials", {
redirect: false,
callbackUrl: "/",
email: data.email_address.toLowerCase(),
password: data.password,
});
router.replace(`/auth/setup?step=2&category=calendar`);
} else {
router.replace("/auth/setup");
}
}, onError);
const longWebsiteUrl = process.env.NEXT_PUBLIC_WEBSITE_URL.length > 30;
const longWebsiteUrl = WEBSITE_URL.length > 30;
return (
<FormProvider {...formMethods}>
@ -201,37 +187,4 @@ const SetupFormStep1 = (props: { setIsLoading: (val: boolean) => void }) => {
);
};
export default function Setup(props: inferSSRProps<typeof getServerSideProps>) {
const { t } = useLocale();
const [isLoadingStep1, setIsLoadingStep1] = useState(false);
const steps = [
{
title: t("administrator_user"),
description: t("lets_create_first_administrator_user"),
content: props.userCount !== 0 ? <StepDone /> : <SetupFormStep1 setIsLoading={setIsLoadingStep1} />,
enabled: props.userCount === 0, // to check if the wizard should show buttons to navigate through more steps
isLoading: isLoadingStep1,
},
];
return (
<>
<main className="flex h-screen items-center bg-gray-100 print:h-full">
<WizardForm href="/auth/setup" steps={steps} containerClassname="max-w-sm" />
</main>
</>
);
}
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
const ssr = await ssrInit(context);
const userCount = await prisma.user.count();
return {
props: {
trpcState: ssr.dehydrate(),
userCount,
},
};
};
export default SetupFormStep1;

View File

@ -0,0 +1,19 @@
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Icon } from "@calcom/ui";
const StepDone = () => {
const { t } = useLocale();
return (
<div className="min-h-36 my-6 flex flex-col items-center justify-center">
<div className="flex h-[72px] w-[72px] items-center justify-center rounded-full bg-gray-600 dark:bg-white">
<Icon.FiCheck className="inline-block h-10 w-10 text-white dark:bg-white dark:text-gray-600" />
</div>
<div className="max-w-[420px] text-center">
<h2 className="mt-6 mb-1 text-lg font-medium dark:text-gray-300">{t("all_done")}</h2>
</div>
</div>
);
};
export default StepDone;

View File

@ -8,9 +8,10 @@ import { useForm } from "react-hook-form";
import { z } from "zod";
import { StripeData } from "@calcom/app-store/stripepayment/lib/server";
import getApps, { getEventTypeAppData, getLocationOptions } from "@calcom/app-store/utils";
import { getEventTypeAppData, getLocationOptions } from "@calcom/app-store/utils";
import { EventLocationType, LocationObject } from "@calcom/core/location";
import { parseBookingLimit, parseRecurringEvent, validateBookingLimitOrder } from "@calcom/lib";
import getEnabledApps from "@calcom/lib/apps/getEnabledApps";
import { CAL_URL } from "@calcom/lib/constants";
import convertToNewDurationType from "@calcom/lib/convertToNewDurationType";
import findDurationType from "@calcom/lib/findDurationType";
@ -467,6 +468,9 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
const credentials = await prisma.credential.findMany({
where: {
userId: session.user.id,
app: {
enabled: true,
},
},
select: {
id: true,
@ -474,6 +478,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
key: true,
userId: true,
appId: true,
invalid: true,
},
});
@ -526,7 +531,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
}
const currentUser = eventType.users.find((u) => u.id === session.user.id);
const t = await getTranslation(currentUser?.locale ?? "en", "common");
const integrations = getApps(credentials);
const integrations = await getEnabledApps(credentials);
const locationOptions = getLocationOptions(integrations, t);
const eventTypeObject = Object.assign({}, eventType, {

View File

@ -1,5 +1,6 @@
import { GetServerSidePropsContext } from "next";
import AdminAppsList from "@calcom/features/apps/AdminAppsList";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { getAdminLayout as getLayout, Meta } from "@calcom/ui";
@ -10,7 +11,7 @@ function AdminAppsView() {
return (
<>
<Meta title={t("apps")} description={t("apps_description")} />
<h1>{t("apps_listing")}</h1>
<AdminAppsList baseURL="/settings/admin/apps" className="w-0" />
</>
);
}

View File

@ -1403,8 +1403,8 @@
"route_to": "Route to",
"test_preview_description": "Test your routing form without submitting any data",
"test_routing": "Test Routing",
"booking_limit_reached": "Booking Limit for this event type has been reached",
"fill_this_field": "Please fill in this field",
"payment_app_disabled": "An admin has disabled a payment app",
"edit_event_type": "Edit event type",
"collective_scheduling": "Collective Scheduling",
"make_it_easy_to_book": "Make it easy to book your team when everyone is available.",
"find_the_best_person": "Find the best person available and cycle through your team.",
@ -1413,6 +1413,28 @@
"calcom_is_better_with_team": "Cal.com is better with teams",
"add_your_team_members": "Add your team members to your event types. Use collective scheduling to include everyone or find the most suitable person with round robin scheduling.",
"booking_limit_reached":"Booking Limit for this event type has been reached",
"admin_has_disabled": "An admin has disabled {{appName}}",
"disabled_app_affects_event_type": "An admin has disabled {{appName}} which affects your event type {{eventType}}",
"disable_payment_app": "The admin has disabled {{appName}} which affects your event type {{title}}. Attendees are still able to book this type of event but will not be prompted to pay. You may hide hide the event type to prevent this until your admin renables your payment method.",
"payment_disabled_still_able_to_book": "Attendees are still able to book this type of event but will not be prompted to pay. You may hide hide the event type to prevent this until your admin reenables your payment method.",
"app_disabled_with_event_type": "The admin has disabled {{appName}} which affects your event type {{title}}.",
"app_disabled_video": "The admin has disabled {{appName}} which may affect your event types. If you have event types with {{appName}} as the location then it will default to Cal Video.",
"app_disabled_subject": "{{appName}} has been disabled",
"navigate_installed_apps": "Go to installed apps",
"disabled_calendar": "If you have another calendar installed new bookings will be added to it. If not then connect a new calendar so you do not miss any new bookings.",
"enable_apps": "Enable Apps",
"enable_apps_description": "Enable apps that users can integrate with Cal.com",
"app_is_enabled": "{{appName}} is enabled",
"app_is_disabled": "{{appName}} is disabled",
"keys_have_been_saved": "Keys have been saved",
"disable_app": "Disable App",
"disable_app_description": "Disabling this app could cause problems with how your users interact with Cal",
"edit_keys": "Edit Keys",
"apps_description": "Enable apps for your instance of Cal",
"no_available_apps": "There are no available apps",
"no_available_apps_description": "Please ensure there are apps in your deployment under 'packages/app-store'",
"no_apps": "There are no apps enabled in this instance of Cal",
"apps_settings": "Apps settings",
"fill_this_field": "Please fill in this field",
"options": "Options",
"enter_option": "Enter Option {{index}}",

@ -1 +1 @@
Subproject commit 925f81ed92ee54cb1512c2836077e06d68c95123
Subproject commit c358039b1c1d0c5c0b7901c6489261c4e8e8c6e0

View File

@ -62,6 +62,7 @@ function generateFiles() {
const browserOutput = [`import dynamic from "next/dynamic"`];
const metadataOutput = [];
const schemasOutput = [];
const appKeysSchemasOutput = [];
const serverOutput = [];
const appDirs: { name: string; path: string }[] = [];
@ -190,6 +191,20 @@ function generateFiles() {
})
);
appKeysSchemasOutput.push(
...getObjectExporter("appKeysSchemas", {
fileToBeImported: "zod.ts",
// Import path must have / even for windows and not \
importBuilder: (app) =>
`import { appKeysSchema as ${getVariableName(app.name)}_keys_schema } from "./${app.path.replace(
/\\/g,
"/"
)}/zod";`,
// Key must be appId as this is used by eventType metadata and lookup is by appId
entryBuilder: (app) => ` "${getAppId(app)}":${getVariableName(app.name)}_keys_schema ,`,
})
);
browserOutput.push(
...getObjectExporter("InstallAppButtonMap", {
fileToBeImported: "components/InstallAppButton.tsx",
@ -225,6 +240,7 @@ function generateFiles() {
["apps.server.generated.ts", serverOutput],
["apps.browser.generated.tsx", browserOutput],
["apps.schemas.generated.ts", schemasOutput],
["apps.keys-schemas.generated.ts", appKeysSchemasOutput],
];
filesToGenerate.forEach(([fileName, output]) => {
fs.writeFileSync(`${APP_STORE_PATH}/${fileName}`, formatOutput(`${banner}${output.join("\n")}`));

View File

@ -26,7 +26,10 @@ export async function getAppWithMetadata(app: { dirName: string }) {
/** Mainly to use in listings for the frontend, use in getStaticProps or getServerSideProps */
export async function getAppRegistry() {
const dbApps = await prisma.app.findMany({ select: { dirName: true, slug: true, categories: true } });
const dbApps = await prisma.app.findMany({
where: { enabled: true },
select: { dirName: true, slug: true, categories: true, enabled: true },
});
const apps = [] as Omit<App, "key">[];
for await (const dbapp of dbApps) {
const app = await getAppWithMetadata(dbapp);
@ -51,7 +54,10 @@ export async function getAppRegistry() {
}
export async function getAppRegistryWithCredentials(userId: number) {
const dbApps = await prisma.app.findMany({ include: { credentials: { where: { userId } } } });
const dbApps = await prisma.app.findMany({
where: { enabled: true },
include: { credentials: { where: { userId } } },
});
const apps = [] as (Omit<App, "key"> & {
credentials: Credential[];
})[];

View File

@ -26,7 +26,7 @@ export default function AppCard({
const [animationRef] = useAutoAnimate<HTMLDivElement>();
return (
<div className="mb-4 mt-2 rounded-md border border-gray-200">
<div className={`mb-4 mt-2 rounded-md border border-gray-200 ${!app.enabled && "grayscale"}`}>
<div className="p-4 text-sm sm:p-8">
<div className="flex w-full flex-col gap-2 sm:flex-row sm:gap-0">
{/* Don't know why but w-[42px] isn't working, started happening when I started using next/dynamic */}
@ -44,6 +44,7 @@ export default function AppCard({
{app?.isInstalled ? (
<div className="ml-auto flex items-center">
<Switch
disabled={!app.enabled}
onCheckedChange={(enabled) => {
if (switchOnClick) {
switchOnClick(enabled);
@ -54,7 +55,7 @@ export default function AppCard({
/>
</div>
) : (
<OmniInstallAppButton className="ml-auto flex items-center" appId={app?.slug} />
<OmniInstallAppButton className="ml-auto flex items-center" appId={app.slug} />
)}
</div>
</div>

View File

@ -0,0 +1,34 @@
import { useMemo } from "react";
import { classNames } from "@calcom/lib";
import { HorizontalTabs, VerticalTabs } from "@calcom/ui";
import getAppCategories from "../_utils/getAppCategories";
const AppCategoryNavigation = ({
baseURL,
children,
containerClassname,
className,
}: {
baseURL: string;
children: React.ReactNode;
containerClassname: string;
className?: string;
}) => {
const appCategories = useMemo(() => getAppCategories(baseURL), [baseURL]);
return (
<div className={classNames("flex flex-col p-2 md:p-0 xl:flex-row", className)}>
<div className="hidden xl:block">
<VerticalTabs tabs={appCategories} sticky linkProps={{ shallow: true }} />
</div>
<div className="block overflow-x-scroll xl:hidden">
<HorizontalTabs tabs={appCategories} linkProps={{ shallow: true }} />
</div>
<main className={containerClassname}>{children}</main>
</div>
);
};
export default AppCategoryNavigation;

View File

@ -7,3 +7,5 @@ export const appDataSchema = eventTypeAppCardZod.merge(
isSunrise: z.boolean(),
})
);
export const appKeysSchema = z.object({});

View File

@ -0,0 +1,43 @@
import { Icon } from "@calcom/ui";
const getAppCategories = (baseURL: string) => {
return [
{
name: "calendar",
href: `${baseURL}/calendar`,
icon: Icon.FiCalendar,
},
{
name: "conferencing",
href: `${baseURL}/conferencing`,
icon: Icon.FiVideo,
},
{
name: "payment",
href: `${baseURL}/payment`,
icon: Icon.FiCreditCard,
},
{
name: "automation",
href: `${baseURL}/automation`,
icon: Icon.FiShare2,
},
{
name: "analytics",
href: `${baseURL}/analytics`,
icon: Icon.FiBarChart,
},
{
name: "web3",
href: `${baseURL}/web3`,
icon: Icon.FiBarChart,
},
{
name: "other",
href: `${baseURL}/other`,
icon: Icon.FiGrid,
},
];
};
export default getAppCategories;

View File

@ -20,6 +20,7 @@ export const metadata = {
url: "https://cal.com/",
verified: true,
email: "help@cal.com",
dirName: "applecalendar",
} as AppMeta;
export default metadata;

View File

@ -25,6 +25,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
key: symmetricEncrypt(JSON.stringify({ username, password }), process.env.CALENDSO_ENCRYPTION_KEY!),
userId: user.id,
appId: "apple-calendar",
invalid: false,
};
try {

View File

@ -0,0 +1,47 @@
/**
This file is autogenerated using the command `yarn app-store:build --watch`.
Don't modify this file manually.
**/
import { appKeysSchema as dailyvideo_keys_schema } from "./dailyvideo/zod";
import { appKeysSchema as routing_forms_keys_schema } from "./ee/routing-forms/zod";
import { appKeysSchema as fathom_keys_schema } from "./fathom/zod";
import { appKeysSchema as ga4_keys_schema } from "./ga4/zod";
import { appKeysSchema as giphy_keys_schema } from "./giphy/zod";
import { appKeysSchema as googlecalendar_keys_schema } from "./googlecalendar/zod";
import { appKeysSchema as hubspot_keys_schema } from "./hubspot/zod";
import { appKeysSchema as larkcalendar_keys_schema } from "./larkcalendar/zod";
import { appKeysSchema as office365calendar_keys_schema } from "./office365calendar/zod";
import { appKeysSchema as office365video_keys_schema } from "./office365video/zod";
import { appKeysSchema as plausible_keys_schema } from "./plausible/zod";
import { appKeysSchema as qr_code_keys_schema } from "./qr_code/zod";
import { appKeysSchema as rainbow_keys_schema } from "./rainbow/zod";
import { appKeysSchema as salesforce_keys_schema } from "./salesforce/zod";
import { appKeysSchema as stripepayment_keys_schema } from "./stripepayment/zod";
import { appKeysSchema as tandemvideo_keys_schema } from "./tandemvideo/zod";
import { appKeysSchema as vital_keys_schema } from "./vital/zod";
import { appKeysSchema as wordpress_keys_schema } from "./wordpress/zod";
import { appKeysSchema as zapier_keys_schema } from "./zapier/zod";
import { appKeysSchema as zoomvideo_keys_schema } from "./zoomvideo/zod";
export const appKeysSchemas = {
dailyvideo: dailyvideo_keys_schema,
"routing-forms": routing_forms_keys_schema,
fathom: fathom_keys_schema,
ga4: ga4_keys_schema,
giphy: giphy_keys_schema,
googlecalendar: googlecalendar_keys_schema,
hubspot: hubspot_keys_schema,
larkcalendar: larkcalendar_keys_schema,
office365calendar: office365calendar_keys_schema,
office365video: office365video_keys_schema,
plausible: plausible_keys_schema,
qr_code: qr_code_keys_schema,
rainbow: rainbow_keys_schema,
salesforce: salesforce_keys_schema,
stripe: stripepayment_keys_schema,
tandemvideo: tandemvideo_keys_schema,
vital: vital_keys_schema,
wordpress: wordpress_keys_schema,
zapier: zapier_keys_schema,
zoomvideo: zoomvideo_keys_schema,
};

View File

@ -2,24 +2,46 @@
This file is autogenerated using the command `yarn app-store:build --watch`.
Don't modify this file manually.
**/
import { appDataSchema as dailyvideo_schema } from "./dailyvideo/zod";
import { appDataSchema as routing_forms_schema } from "./ee/routing-forms/zod";
import { appDataSchema as fathom_schema } from "./fathom/zod";
import { appDataSchema as ga4_schema } from "./ga4/zod";
import { appDataSchema as giphy_schema } from "./giphy/zod";
import { appDataSchema as googlecalendar_schema } from "./googlecalendar/zod";
import { appDataSchema as hubspot_schema } from "./hubspot/zod";
import { appDataSchema as larkcalendar_schema } from "./larkcalendar/zod";
import { appDataSchema as office365calendar_schema } from "./office365calendar/zod";
import { appDataSchema as office365video_schema } from "./office365video/zod";
import { appDataSchema as plausible_schema } from "./plausible/zod";
import { appDataSchema as qr_code_schema } from "./qr_code/zod";
import { appDataSchema as rainbow_schema } from "./rainbow/zod";
import { appDataSchema as salesforce_schema } from "./salesforce/zod";
import { appDataSchema as stripepayment_schema } from "./stripepayment/zod";
import { appDataSchema as tandemvideo_schema } from "./tandemvideo/zod";
import { appDataSchema as vital_schema } from "./vital/zod";
import { appDataSchema as wordpress_schema } from "./wordpress/zod";
import { appDataSchema as zapier_schema } from "./zapier/zod";
import { appDataSchema as zoomvideo_schema } from "./zoomvideo/zod";
export const appDataSchemas = {
dailyvideo: dailyvideo_schema,
"routing-forms": routing_forms_schema,
fathom: fathom_schema,
ga4: ga4_schema,
giphy: giphy_schema,
googlecalendar: googlecalendar_schema,
hubspot: hubspot_schema,
larkcalendar: larkcalendar_schema,
office365calendar: office365calendar_schema,
office365video: office365video_schema,
plausible: plausible_schema,
qr_code: qr_code_schema,
rainbow: rainbow_schema,
salesforce: salesforce_schema,
stripe: stripepayment_schema,
tandemvideo: tandemvideo_schema,
vital: vital_schema,
wordpress: wordpress_schema,
zapier: zapier_schema,
zoomvideo: zoomvideo_schema,
};

View File

@ -4,6 +4,7 @@ import config from "./config.json";
export const metadata = {
category: "other",
dirName: "around",
appData: {
location: {
linkType: "static",

View File

@ -20,6 +20,7 @@ export const metadata = {
url: "https://cal.com/",
verified: true,
email: "ali@cal.com",
dirName: "caldavcalendar",
} as AppMeta;
export default metadata;

View File

@ -28,6 +28,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
),
userId: user.id,
appId: "caldav-calendar",
invalid: false,
};
try {

View File

@ -4,6 +4,7 @@ import config from "./config.json";
export const metadata = {
category: "other",
dirName: "caldavcalendar",
...config,
} as AppMeta;

View File

@ -12,6 +12,7 @@ export const metadata = {
verified: true,
licenseRequired: true,
isProOnly: true,
dirName: "closecom",
...config,
} as App;

View File

@ -29,6 +29,7 @@ export const metadata = {
},
},
key: { apikey: process.env.DAILY_API_KEY },
dirName: "dailyvideo",
} as AppMeta;
export default metadata;

View File

@ -0,0 +1,8 @@
import { z } from "zod";
export const appKeysSchema = z.object({
api_key: z.string().min(1),
scale_plan: z.string(),
});
export const appDataSchema = z.object({});

View File

@ -12,6 +12,7 @@ export const metadata = {
verified: true,
licenseRequired: true,
isProOnly: true,
dirName: "routing-forms",
...config,
} as AppMeta;

View File

@ -44,3 +44,5 @@ export const zodRoutes = z
// TODO: This is a requirement right now that zod.ts file (if it exists) must have appDataSchema export(which is only required by apps having EventTypeAppCard interface)
// This is a temporary solution and will be removed in future
export const appDataSchema = z.any();
export const appKeysSchema = z.object({});

View File

@ -21,6 +21,7 @@ export const metadata = {
url: "https://cal.com/",
verified: true,
email: "help@cal.com",
dirName: "exchange2013calendar",
} as AppMeta;
export default metadata;

View File

@ -34,6 +34,7 @@ async function postHandler(req: NextApiRequest, res: NextApiResponse) {
key: symmetricEncrypt(JSON.stringify(body), process.env.CALENDSO_ENCRYPTION_KEY!),
userId: user.id,
appId: "exchange2013-calendar",
invalid: false,
};
try {

View File

@ -21,6 +21,7 @@ export const metadata = {
url: "https://cal.com/",
verified: true,
email: "help@cal.com",
dirName: "exchange2016calendar",
} as AppMeta;
export default metadata;

View File

@ -34,6 +34,7 @@ async function postHandler(req: NextApiRequest, res: NextApiResponse) {
key: symmetricEncrypt(JSON.stringify(body), process.env.CALENDSO_ENCRYPTION_KEY!),
userId: user.id,
appId: "exchange2016-calendar",
invalid: false,
};
try {

View File

@ -9,6 +9,7 @@ export const metadata = {
reviews: 0,
trending: true,
verified: true,
dirName: "exchangecalendar",
...config,
} as App;

View File

@ -25,7 +25,13 @@ export async function getHandler(req: NextApiRequest, res: NextApiResponse) {
const session = checkSession(req);
const body = formSchema.parse(req.body);
const encrypted = symmetricEncrypt(JSON.stringify(body), process.env.CALENDSO_ENCRYPTION_KEY || "");
const data = { type: "exchange_calendar", key: encrypted, userId: session.user?.id, appId: "exchange" };
const data = {
type: "exchange_calendar",
key: encrypted,
userId: session.user?.id,
appId: "exchange",
invalid: false,
};
try {
const service = new CalendarService({ id: 0, ...data });

View File

@ -4,6 +4,7 @@ import config from "./config.json";
export const metadata = {
category: "analytics",
dirName: "fathom",
...config,
} as AppMeta;

View File

@ -7,3 +7,5 @@ export const appDataSchema = eventTypeAppCardZod.merge(
trackingId: z.string(),
})
);
export const appKeysSchema = z.object({});

View File

@ -4,6 +4,7 @@ import config from "./config.json";
export const metadata = {
category: "other",
dirName: "ga4",
...config,
} as AppMeta;

View File

@ -7,3 +7,5 @@ export const appDataSchema = eventTypeAppCardZod.merge(
trackingId: z.string(),
})
);
export const appKeysSchema = z.object({});

View File

@ -22,6 +22,7 @@ export const metadata = {
verified: true,
extendsFeature: "EventType",
email: "help@cal.com",
dirName: "giphy",
} as AppMeta;
export default metadata;

View File

@ -7,3 +7,7 @@ export const appDataSchema = eventTypeAppCardZod.merge(
thankYouPage: z.string().optional(),
})
);
export const appKeysSchema = z.object({
app_key: z.string().min(1),
});

View File

@ -21,6 +21,7 @@ export const metadata = {
url: "https://cal.com/",
verified: true,
email: "help@cal.com",
dirName: "googlecalendar",
} as AppMeta;
export default metadata;

View File

@ -0,0 +1,9 @@
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),
redirect_uris: z.string().min(1),
});

View File

@ -20,7 +20,7 @@ export const metadata = {
trending: false,
url: "https://cal.com/",
verified: true,
isGlobal: true,
isGlobal: false,
email: "help@cal.com",
appData: {
location: {
@ -29,6 +29,7 @@ export const metadata = {
label: "Google Meet",
},
},
dirName: "googlevideo",
} as AppMeta;
export default metadata;

View File

@ -21,6 +21,7 @@ export const metadata = {
title: "HubSpot CRM",
trending: true,
email: "help@cal.com",
dirName: "hubspot",
} as AppMeta;
export default metadata;

View File

@ -0,0 +1,8 @@
import { z } from "zod";
export const appKeysSchema = z.object({
client_id: z.string().min(1),
client_secret: z.string().min(1),
});
export const appDataSchema = z.object({});

View File

@ -30,6 +30,7 @@ export const metadata = {
},
},
key: { apikey: randomString(12) },
dirName: "huddle01video",
} as AppMeta;
export default metadata;

View File

@ -28,6 +28,7 @@ export const metadata = {
label: "Jitsi Video",
},
},
dirName: "jitsivideo",
} as AppMeta;
export default metadata;

View File

@ -20,6 +20,7 @@ export const metadata = {
url: "https://larksuite.com/",
verified: true,
email: "alan@larksuite.com",
dirName: "larkcalendar",
} as AppMeta;
export default metadata;

View File

@ -0,0 +1,9 @@
import { z } from "zod";
export const appDataSchema = z.object({});
export const appKeysSchema = z.object({
app_id: z.string().min(1),
app_secret: z.string().min(1),
open_verfication_token: z.string().min(1),
});

View File

@ -4,6 +4,7 @@ import config from "./config.json";
export const metadata = {
category: "automation",
dirName: "n8n",
...config,
} as AppMeta;

View File

@ -19,6 +19,7 @@ export const metadata = {
url: "https://cal.com/",
verified: true,
email: "help@cal.com",
dirName: "office365calendar",
} as AppMeta;
export default metadata;

View File

@ -0,0 +1,11 @@
import { z } from "zod";
export const appKeysSchema = z.object({
client_id: z.string().min(1),
client_secret: z.string().min(1),
});
export const appDataSchema = z.object({
client_id: z.string(),
client_secret: z.string(),
});

View File

@ -1,31 +1,10 @@
import type { AppMeta } from "@calcom/types/App";
import _package from "./package.json";
import config from "./config.json";
export const metadata = {
name: "Microsoft 365/Teams (Requires work/school account)",
description: _package.description,
type: "office365_video",
imageSrc: "/api/app-store/office365video/icon.svg",
variant: "conferencing",
logo: "/api/app-store/office365video/icon.svg",
publisher: "Cal.com",
url: "https://www.microsoft.com/en-ca/microsoft-teams/group-chat-software",
verified: true,
rating: 4.3, // TODO: placeholder for now, pull this from TrustPilot or G2
reviews: 69, // TODO: placeholder for now, pull this from TrustPilot or G2
category: "video",
slug: "msteams",
title: "MS Teams (Requires work/school account)",
trending: true,
email: "help@cal.com",
appData: {
location: {
linkType: "dynamic",
type: "integrations:office365_video",
label: "MS Teams",
},
},
dirName: "office365video",
...config,
} as AppMeta;
export default metadata;

View File

@ -0,0 +1,25 @@
{
"name": "Microsoft 365/Teams (Requires work/school account)",
"description": "Microsoft Teams is a business communication platform and collaborative workspace included in Microsoft 365. It offers workspace chat and video conferencing, file storage, and application integration. Both web versions and desktop/mobile applications are available. NOTE: MUST HAVE A WORK / SCHOOL ACCOUNT",
"type": "office365_video",
"imageSrc": "/api/app-store/office365video/icon.svg",
"variant": "conferencing",
"logo": "/api/app-store/office365video/icon.svg",
"publisher": "Cal.com",
"url": "https://www.microsoft.com/en-ca/microsoft-teams/group-chat-software",
"verified": true,
"rating": 4.3,
"reviews": 69,
"category": "video",
"slug": "msteams",
"title": "MS Teams (Requires work/school account)",
"trending": true,
"email": "help@cal.com",
"appData": {
"location": {
"linkType": "dynamic",
"type": "integrations:office365_video",
"label": "MS Teams"
}
}
}

View File

@ -0,0 +1,11 @@
import { z } from "zod";
export const appKeysSchema = z.object({
client_id: z.string().min(1),
client_secret: z.string().min(1),
});
export const appDataSchema = z.object({
client_id: z.string(),
client_secret: z.string(),
});

View File

@ -19,6 +19,7 @@ export const metadata = {
urlRegExp: "^http(s)?:\\/\\/(www\\.)?ping.gg\\/call\\/[a-zA-Z0-9]*",
},
},
dirName: "ping",
...config,
} as AppMeta;

View File

@ -4,6 +4,7 @@ import config from "./config.json";
export const metadata = {
category: "other",
dirName: "pipedream",
...config,
} as AppMeta;

View File

@ -7,3 +7,5 @@ export const appDataSchema = eventTypeAppCardZod.merge(
trackedDomain: z.string(),
})
);
export const appKeysSchema = z.object({});

View File

@ -4,6 +4,7 @@ import config from "./config.json";
export const metadata = {
category: "other",
dirName: "qr_code",
...config,
} as AppMeta;

View File

@ -1,3 +1,7 @@
import { z } from "zod";
import { eventTypeAppCardZod } from "../eventTypeAppCardZod";
export const appDataSchema = eventTypeAppCardZod;
export const appKeysSchema = z.object({});

View File

@ -4,6 +4,7 @@ import config from "./config.json";
export const metadata = {
category: "other",
dirName: "rainbow",
...config,
} as AppMeta;

View File

@ -8,3 +8,5 @@ export const appDataSchema = eventTypeAppCardZod.merge(
blockchainId: z.number().optional(),
})
);
export const appKeysSchema = z.object({});

View File

@ -4,6 +4,7 @@ import config from "./config.json";
export const metadata = {
category: "other",
dirName: "raycast",
...config,
} as AppMeta;

View File

@ -19,6 +19,7 @@ export const metadata = {
linkType: "static",
},
},
dirName: "riverside",
...config,
} as AppMeta;

View File

@ -4,6 +4,7 @@ import config from "./config.json";
export const metadata = {
category: "other",
dirName: "salesforce",
...config,
} as AppMeta;

View File

@ -0,0 +1,8 @@
import { z } from "zod";
export const appDataSchema = z.object({});
export const appKeysSchema = z.object({
consumer_key: z.string().min(1),
consumer_secret: z.string().min(1),
});

View File

@ -4,6 +4,7 @@ import config from "./config.json";
export const metadata = {
category: "other",
dirName: "sendgrid",
...config,
} as AppMeta;

View File

@ -4,6 +4,7 @@ import config from "./config.json";
export const metadata = {
category: "other",
dirName: "signal",
...config,
} as AppMeta;

View File

@ -4,6 +4,7 @@ import config from "./config.json";
export const metadata = {
category: "other",
dirName: "sirius_video",
...config,
} as AppMeta;

View File

@ -26,6 +26,7 @@ export const metadata = {
extendsFeature: "EventType",
verified: true,
email: "help@cal.com",
dirName: "stripepayment",
} as AppMeta;
export default metadata;

View File

@ -8,3 +8,12 @@ export const appDataSchema = eventTypeAppCardZod.merge(
currency: z.string(),
})
);
export const appKeysSchema = z.object({
client_id: z.string().startsWith("ca_").min(1),
client_secret: z.string().startsWith("sk_").min(1),
public_key: z.string().startsWith("pk_").min(1),
webhook_secret: z.string().startsWith("whsec_").min(1),
payment_fee_fixed: z.number().int().min(0),
payment_fee_percentage: z.number().min(0),
});

View File

@ -27,6 +27,7 @@ export const metadata = {
label: "Tandem Video",
},
},
dirName: "tandemvideo",
} as AppMeta;
export default metadata;

View File

@ -0,0 +1,9 @@
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),
base_url: z.string().min(1),
});

View File

@ -4,6 +4,7 @@ import config from "./config.json";
export const metadata = {
category: "other",
dirName: "telegram",
...config,
} as AppMeta;

View File

@ -4,6 +4,7 @@ import config from "./config.json";
export const metadata = {
category: "other",
dirName: "typeform",
...config,
} as AppMeta;

View File

@ -26,10 +26,10 @@ const ALL_APPS_MAP = Object.keys(appStoreMetadata).reduce((store, key) => {
}, {} as Record<string, AppMeta>);
const credentialData = Prisma.validator<Prisma.CredentialArgs>()({
select: { id: true, type: true, key: true, userId: true, appId: true },
select: { id: true, type: true, key: true, userId: true, appId: true, invalid: true },
});
type CredentialData = Prisma.CredentialGetPayload<typeof credentialData>;
export type CredentialData = Prisma.CredentialGetPayload<typeof credentialData>;
export enum InstalledAppVariants {
"conferencing" = "conferencing",
@ -146,6 +146,7 @@ function getApps(userCredentials: CredentialData[]) {
key: appMeta.key!,
userId: +new Date().getTime(),
appId: appMeta.slug,
invalid: false,
});
}
@ -174,6 +175,10 @@ function getApps(userCredentials: CredentialData[]) {
return apps;
}
export function getLocalAppMetadata() {
return ALL_APPS;
}
export function hasIntegrationInstalled(type: App["type"]): boolean {
return ALL_APPS.some((app) => app.type === type && !!app.installed);
}

View File

@ -22,6 +22,7 @@ export const metadata = {
variant: "other",
verified: true,
email: "support@tryvital.io",
dirName: "vital",
} as AppMeta;
export default metadata;

View File

@ -0,0 +1,10 @@
import { z } from "zod";
export const appDataSchema = z.object({});
export const appKeysSchema = z.object({
mode: z.string().min(1),
region: z.string().min(1),
api_key: z.string().min(1),
webhook_secret: z.string().min(1),
});

View File

@ -4,6 +4,7 @@ import config from "./config.json";
export const metadata = {
category: "other",
dirName: "weather_in_your_calendar",
...config,
} as AppMeta;

View File

@ -4,6 +4,7 @@ import config from "./config.json";
export const metadata = {
category: "other",
dirName: "whatsapp",
...config,
} as AppMeta;

View File

@ -19,6 +19,7 @@ export const metadata = {
urlRegExp: "^http(s)?:\\/\\/(www\\.)?(team.)?whereby.com\\/[a-zA-Z0-9]*",
},
},
dirName: "whereby",
...config,
} as AppMeta;

View File

@ -21,6 +21,7 @@ export const metadata = {
variant: "other",
verified: true,
email: "help@cal.com",
dirName: "wipemycalother",
} as AppMeta;
export default metadata;

View File

@ -7,3 +7,5 @@ export const appDataSchema = eventTypeAppCardZod.merge(
isSunrise: z.boolean(),
})
);
export const appKeysSchema = z.object({});

View File

@ -20,6 +20,7 @@ export const metadata = {
variant: "automation",
verified: true,
email: "help@cal.com",
dirName: "zapier",
} as AppMeta;
export default metadata;

View File

@ -0,0 +1,7 @@
import { z } from "zod";
export const appDataSchema = z.object({});
export const appKeysSchema = z.object({
invite_link: z.string().min(1),
});

View File

@ -28,6 +28,7 @@ export const metadata = {
label: "Zoom Video",
},
},
dirName: "zoomvideo",
} as AppMeta;
export default metadata;

View File

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

View File

@ -6,6 +6,7 @@ import { getDailyAppKeys } from "@calcom/app-store/dailyvideo/lib/getDailyAppKey
import { sendBrokenIntegrationEmail } from "@calcom/emails";
import { getUid } from "@calcom/lib/CalEventParser";
import logger from "@calcom/lib/logger";
import { prisma } from "@calcom/prisma";
import type { CalendarEvent, EventBusyDate } from "@calcom/types/Calendar";
import { CredentialPayload, CredentialWithAppName } from "@calcom/types/Credential";
import type { EventResult, PartialReference } from "@calcom/types/EventManager";
@ -37,7 +38,7 @@ const getBusyVideoTimes = (withCredentials: CredentialPayload[]) =>
const createMeeting = async (credential: CredentialWithAppName, calEvent: CalendarEvent) => {
const uid: string = getUid(calEvent);
if (!credential) {
if (!credential || !credential.appId) {
throw new Error(
"Credentials must be set! Video platforms are optional, so this method shouldn't even be called when no video credentials are set."
);
@ -46,26 +47,37 @@ const createMeeting = async (credential: CredentialWithAppName, calEvent: Calend
const videoAdapters = getVideoAdapters([credential]);
const [firstVideoAdapter] = videoAdapters;
let createdMeeting;
let returnObject: {
appName: string;
type: string;
uid: string;
originalEvent: CalendarEvent;
success: boolean;
createdEvent: VideoCallData | undefined;
} = {
appName: credential.appName,
type: credential.type,
uid,
originalEvent: calEvent,
success: false,
createdEvent: undefined,
};
try {
if (!calEvent.location) {
const defaultMeeting = await createMeetingWithCalVideo(calEvent);
if (defaultMeeting) {
createdMeeting = defaultMeeting;
calEvent.location = "integrations:dailyvideo";
}
}
// Check to see if video app is enabled
const enabledApp = await prisma.app.findFirst({
where: {
slug: credential.appId,
},
select: {
enabled: true,
},
});
if (!enabledApp?.enabled) throw "Current location app is not enabled";
createdMeeting = await firstVideoAdapter?.createMeeting(calEvent);
if (!createdMeeting) {
return {
appName: credential.appName,
type: credential.type,
success: false,
uid,
originalEvent: calEvent,
};
}
returnObject = { ...returnObject, createdEvent: createdMeeting, success: true };
} catch (err) {
await sendBrokenIntegrationEmail(calEvent, "video");
console.error("createMeeting failed", err, calEvent);
@ -73,19 +85,13 @@ const createMeeting = async (credential: CredentialWithAppName, calEvent: Calend
// Default to calVideo
const defaultMeeting = await createMeetingWithCalVideo(calEvent);
if (defaultMeeting) {
createdMeeting = defaultMeeting;
calEvent.location = "integrations:dailyvideo";
}
returnObject = { ...returnObject, createdEvent: defaultMeeting };
}
return {
appName: credential.appName,
type: credential.type,
success: true,
uid,
createdEvent: createdMeeting,
originalEvent: calEvent,
};
return returnObject;
};
const updateMeeting = async (
@ -155,6 +161,7 @@ const createMeetingWithCalVideo = async (calEvent: CalendarEvent) => {
type: "daily_video",
userId: null,
key: dailyAppKeys,
invalid: false,
},
]);
return videoAdapter?.createMeeting(calEvent);

View File

@ -1,3 +1,5 @@
import { TFunction } from "next-i18next";
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
import AttendeeAwaitingPaymentEmail from "./templates/attendee-awaiting-payment-email";
@ -9,6 +11,7 @@ import AttendeeRescheduledEmail from "./templates/attendee-rescheduled-email";
import AttendeeScheduledEmail from "./templates/attendee-scheduled-email";
import AttendeeWasRequestedToRescheduleEmail from "./templates/attendee-was-requested-to-reschedule-email";
import BrokenIntegrationEmail from "./templates/broken-integration-email";
import DisabledAppEmail from "./templates/disabled-app-email";
import FeedbackEmail, { Feedback } from "./templates/feedback-email";
import ForgotPasswordEmail, { PasswordReset } from "./templates/forgot-password-email";
import OrganizerCancelledEmail from "./templates/organizer-cancelled-email";
@ -328,3 +331,28 @@ export const sendBrokenIntegrationEmail = async (evt: CalendarEvent, type: "vide
}
});
};
export const sendDisabledAppEmail = async ({
email,
appName,
appType,
t,
title = undefined,
eventTypeId = undefined,
}: {
email: string;
appName: string;
appType: string[];
t: TFunction;
title?: string;
eventTypeId?: number;
}) => {
await new Promise((resolve, reject) => {
try {
const disabledPaymentEmail = new DisabledAppEmail(email, appName, appType, t, title, eventTypeId);
resolve(disabledPaymentEmail.sendEmail());
} catch (e) {
reject(console.error("DisabledPaymentEmail.sendEmail failed", e));
}
});
};

View File

@ -0,0 +1,85 @@
import { TFunction } from "next-i18next";
import { CAL_URL } from "@calcom/lib/constants";
import { BaseEmailHtml, CallToAction } from "../components";
export const DisabledAppEmail = (
props: {
appName: string;
appType: string[];
t: TFunction;
title?: string;
eventTypeId?: number;
} & Partial<React.ComponentProps<typeof BaseEmailHtml>>
) => {
const { title, appName, eventTypeId, t, appType } = props;
return (
<BaseEmailHtml subject={t("app_disabled", { appName: appName })}>
{appType.some((type) => type === "payment") ? (
<>
<p>
<>{t("disabled_app_affects_event_type", { appName: appName, eventType: title })}</>
</p>
<p style={{ fontWeight: 400, lineHeight: "24px" }}>
<>{t("payment_disabled_still_able_to_book")}</>
</p>
<hr style={{ marginBottom: "24px" }} />
<CallToAction
label={t("edit_event_type")}
href={`${CAL_URL}/event-types/${eventTypeId}?tabName=apps`}
/>
</>
) : title && eventTypeId ? (
<>
<p>
<>{(t("app_disabled_with_event_type"), { appName: appName, title: title })}</>
</p>
<hr style={{ marginBottom: "24px" }} />
<CallToAction
label={t("edit_event_type")}
href={`${CAL_URL}/event-types/${eventTypeId}?tabName=apps`}
/>
</>
) : appType.some((type) => type === "video") ? (
<>
<p>
<>{t("app_disabled_video", { appName: appName })}</>
</p>
<hr style={{ marginBottom: "24px" }} />
<CallToAction label={t("navigate_installed_apps")} href={`${CAL_URL}/apps/installed`} />
</>
) : appType.some((type) => type === "calendar") ? (
<>
<p>
<>{t("admin_has_disabled", { appName: appName })}</>
</p>
<p style={{ fontWeight: 400, lineHeight: "24px" }}>
<>{t("disabled_calendar")}</>
</p>
<hr style={{ marginBottom: "24px" }} />
<CallToAction label={t("navigate_installed_apps")} href={`${CAL_URL}/apps/installed`} />
</>
) : (
<>
<p>
<>{t("admin_has_disabled", { appName: appName })}</>
</p>
<hr style={{ marginBottom: "24px" }} />
<CallToAction label={t("navigate_installed_apps")} href={`${CAL_URL}/apps/installed`} />
</>
)}
</BaseEmailHtml>
);
};

View File

@ -6,6 +6,7 @@ export { AttendeeRequestEmail } from "./AttendeeRequestEmail";
export { AttendeeWasRequestedToRescheduleEmail } from "./AttendeeWasRequestedToRescheduleEmail";
export { AttendeeRescheduledEmail } from "./AttendeeRescheduledEmail";
export { AttendeeScheduledEmail } from "./AttendeeScheduledEmail";
export { DisabledAppEmail } from "./DisabledAppEmail";
export { FeedbackEmail } from "./FeedbackEmail";
export { ForgotPasswordEmail } from "./ForgotPasswordEmail";
export { OrganizerCancelledEmail } from "./OrganizerCancelledEmail";

View File

@ -0,0 +1,57 @@
import { TFunction } from "next-i18next";
import { renderEmail } from "..";
import BaseEmail from "./_base-email";
export default class DisabledAppEmail extends BaseEmail {
email: string;
appName: string;
appType: string[];
t: TFunction;
title?: string;
eventTypeId?: number;
constructor(
email: string,
appName: string,
appType: string[],
t: TFunction,
title?: string,
eventTypeId?: number
) {
super();
this.email = email;
this.appName = appName;
this.appType = appType;
this.t = t;
this.title = title;
this.eventTypeId = eventTypeId;
}
protected getNodeMailerPayload(): Record<string, unknown> {
return {
from: `Cal.com <${this.getMailerOptions().from}>`,
to: this.email,
subject:
this.title && this.eventTypeId
? this.t("disabled_app_affects_event_type", { appName: this.appName, eventType: this.title })
: this.t("admin_has_disabled", { appName: this.appName }),
html: renderEmail("DisabledAppEmail", {
title: this.title,
appName: this.appName,
eventTypeId: this.eventTypeId,
appType: this.appType,
t: this.t,
}),
text: this.getTextBody(),
};
}
protected getTextBody(): string {
return this.appType.some((type) => type === "payment")
? this.t("disable_payment_app", { appName: this.appName, title: this.title })
: this.appType.some((type) => type === "video")
? this.t("app_disabled_video", { appName: this.appName })
: this.t("app_disabled", { appName: this.appName });
}
}

View File

@ -0,0 +1,236 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { AppCategories } from "@prisma/client";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@radix-ui/react-collapsible";
import { useRouter } from "next/router";
import { useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { z } from "zod";
import AppCategoryNavigation from "@calcom/app-store/_components/AppCategoryNavigation";
import { appKeysSchemas } from "@calcom/app-store/apps.keys-schemas.generated";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { RouterOutputs, trpc } from "@calcom/trpc/react";
import {
Button,
ConfirmationDialogContent,
Dialog,
EmptyScreen,
Form,
Icon,
showToast,
SkeletonButton,
SkeletonContainer,
SkeletonText,
Switch,
TextField,
VerticalDivider,
} from "@calcom/ui";
const IntegrationContainer = ({
app,
lastEntry,
category,
}: {
app: RouterOutputs["viewer"]["appsRouter"]["listLocal"][number];
lastEntry: boolean;
category: string;
}) => {
const { t } = useLocale();
const utils = trpc.useContext();
const [disableDialog, setDisableDialog] = useState(false);
const [showKeys, setShowKeys] = useState(false);
const appKeySchema = appKeysSchemas[app.dirName as keyof typeof appKeysSchemas];
const formMethods = useForm({
resolver: zodResolver(appKeySchema),
});
const enableAppMutation = trpc.viewer.appsRouter.toggle.useMutation({
onSuccess: (enabled) => {
utils.viewer.appsRouter.listLocal.invalidate({ category });
setDisableDialog(false);
showToast(
enabled ? t("app_is_enabled", { appName: app.name }) : t("app_is_disabled", { appName: app.name }),
"success"
);
},
onError: (error) => {
showToast(error.message, "error");
},
});
const saveKeysMutation = trpc.viewer.appsRouter.saveKeys.useMutation({
onSuccess: () => {
showToast(t("keys_have_been_saved"), "success");
},
onError: (error) => {
showToast(error.message, "error");
},
});
return (
<>
<Collapsible key={app.name} open={showKeys}>
<div className={`${!lastEntry && "border-b"}`}>
<div className="flex w-full flex-1 items-center justify-between space-x-3 p-4 rtl:space-x-reverse md:max-w-3xl">
{app.logo && <img className="h-10 w-10" src={app.logo} alt={app.title} />}
<div className="flex-grow truncate pl-2">
<h3 className="truncate text-sm font-medium text-neutral-900">
<p>{app.name || app.title}</p>
</h3>
<p className="truncate text-sm text-gray-500">{app.description}</p>
</div>
<div className="flex justify-self-end">
<>
<Switch
checked={app.enabled}
onClick={() => {
if (app.enabled) {
setDisableDialog(true);
} else {
enableAppMutation.mutate({ slug: app.slug, enabled: app.enabled });
setShowKeys(true);
}
}}
/>
{app.keys && (
<>
<VerticalDivider className="h-10" />
<CollapsibleTrigger>
<Button
color="secondary"
size="icon"
tooltip={t("edit_keys")}
onClick={() => setShowKeys(!showKeys)}>
<Icon.FiEdit />
</Button>
</CollapsibleTrigger>
</>
)}
</>
</div>
</div>
<CollapsibleContent>
{!!app.keys && typeof app.keys === "object" && (
<Form
form={formMethods}
handleSubmit={(values) =>
saveKeysMutation.mutate({
slug: app.slug,
type: app.type,
keys: values,
dirName: app.dirName,
})
}
className="px-4 pb-4">
{Object.keys(app.keys).map((key) => (
<Controller
name={key}
key={key}
control={formMethods.control}
defaultValue={app.keys && app.keys[key] ? app?.keys[key] : ""}
render={({ field: { value } }) => (
<TextField
label={key}
key={key}
name={key}
value={value}
onChange={(e) => {
formMethods.setValue(key, e?.target.value);
}}
/>
)}
/>
))}
<Button type="submit" loading={saveKeysMutation.isLoading}>
{t("save")}
</Button>
</Form>
)}
</CollapsibleContent>
</div>
</Collapsible>
<Dialog open={disableDialog} onOpenChange={setDisableDialog}>
<ConfirmationDialogContent
title={t("disable_app")}
variety="danger"
onConfirm={() => {
enableAppMutation.mutate({ slug: app.slug, enabled: app.enabled });
}}>
{t("disable_app_description")}
</ConfirmationDialogContent>
</Dialog>
</>
);
};
const querySchema = z.object({
category: z
.nativeEnum({ ...AppCategories, conferencing: "conferencing" })
.optional()
.default(AppCategories.calendar),
});
const AdminAppsList = ({ baseURL, className }: { baseURL: string; className?: string }) => (
<AppCategoryNavigation
baseURL={baseURL}
containerClassname="w-full xl:mx-5 xl:w-2/3 xl:pr-5"
className={className}>
<AdminAppsListContainer />
</AppCategoryNavigation>
);
const AdminAppsListContainer = () => {
const { t } = useLocale();
const router = useRouter();
const { category } = querySchema.parse(router.query);
const { data: apps, isLoading } = trpc.viewer.appsRouter.listLocal.useQuery(
{ category },
{ enabled: router.isReady }
);
if (isLoading) return <SkeletonLoader />;
if (!apps) {
return (
<EmptyScreen
Icon={Icon.FiAlertCircle}
headline={t("no_available_apps")}
description={t("no_available_apps_description")}
/>
);
}
return (
<div className="rounded-md border border-gray-200">
{apps.map((app, index) => (
<IntegrationContainer
app={app}
lastEntry={index === apps.length - 1}
key={app.name}
category={category}
/>
))}
</div>
);
};
export default AdminAppsList;
const SkeletonLoader = () => {
return (
<SkeletonContainer>
<div className="mt-6 mb-8 space-y-6">
<SkeletonText className="h-8 w-full" />
<SkeletonText className="h-8 w-full" />
<SkeletonText className="h-8 w-full" />
<SkeletonText className="h-8 w-full" />
<SkeletonButton className="mr-6 h-8 w-20 rounded-md p-5" />
</div>
</SkeletonContainer>
);
};

View File

@ -0,0 +1,21 @@
import getApps from "@calcom/app-store/utils";
import type { CredentialData } from "@calcom/app-store/utils";
import { prisma } from "@calcom/prisma";
const getEnabledApps = async (userCredentials: CredentialData[]) => {
const enabledApps = await prisma.app.findMany({ select: { slug: true, enabled: true } });
const apps = getApps(userCredentials);
const filteredApps = enabledApps.reduce((reducedArray, app) => {
const appMetadata = apps.find((metadata) => metadata.slug === app.slug);
if (appMetadata) {
reducedArray.push({ ...appMetadata, enabled: app.enabled });
}
return reducedArray;
}, [] as (ReturnType<typeof getApps>[number] & { enabled: boolean })[]);
return filteredApps;
};
export default getEnabledApps;

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "App" ADD COLUMN "enabled" BOOLEAN NOT NULL DEFAULT false;

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Credential" ALTER COLUMN "invalid" SET DEFAULT false;

View File

@ -0,0 +1,3 @@
-- AlterTable
-- The new default is false but we set true for all current apps
UPDATE "App" SET "enabled" = true;

View File

@ -100,7 +100,7 @@ model Credential {
// How to make it a required column?
appId String?
destinationCalendars DestinationCalendar[]
invalid Boolean?
invalid Boolean? @default(false)
}
enum UserPlan {
@ -510,6 +510,7 @@ model App {
credentials Credential[]
Webhook Webhook[]
ApiKey ApiKey[]
enabled Boolean @default(false)
}
model App_RoutingForms_Form {

View File

@ -158,8 +158,8 @@ async function createApp(
) {
await prisma.app.upsert({
where: { slug },
create: { slug, dirName, categories, keys },
update: { dirName, categories, keys },
create: { slug, dirName, categories, keys, enabled: true },
update: { dirName, categories, keys, enabled: true },
});
await prisma.credential.updateMany({
where: { type },

Some files were not shown because too many files have changed in this diff Show More