diff --git a/apps/api b/apps/api index 41d22c8ccb..3d84ce68c9 160000 --- a/apps/api +++ b/apps/api @@ -1 +1 @@ -Subproject commit 41d22c8ccb64f30a8f2a4e5ed106828e0c075027 +Subproject commit 3d84ce68c9baa5d4ce7c85a37a9b8678f399b7a7 diff --git a/apps/web/pages/api/email.ts b/apps/web/pages/api/email.ts index 5b6b01da87..04c25824a0 100644 --- a/apps/web/pages/api/email.ts +++ b/apps/web/pages/api/email.ts @@ -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(); diff --git a/apps/web/pages/apps/index.tsx b/apps/web/pages/apps/index.tsx index ffe4a16fa0..8381290721 100644 --- a/apps/web/pages/apps/index.tsx +++ b/apps/web/pages/apps/index.tsx @@ -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) { +export default function Apps({ + categories, + appStore, +}: InferGetServerSidePropsType) { const { t } = useLocale(); const [searchText, setSearchText] = useState(undefined); @@ -42,7 +44,8 @@ export default function Apps({ appStore, categories }: InferGetStaticPropsType ( setSearchText(e.target.value)} /> - )}> + )} + emptyStore={!appStore.length}> {!searchText && ( <> @@ -54,7 +57,7 @@ export default function Apps({ appStore, categories }: InferGetStaticPropsType { +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); 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(), }, }; }; diff --git a/apps/web/pages/auth/setup/index.tsx b/apps/web/pages/auth/setup/index.tsx new file mode 100644 index 0000000000..c9513543d3 --- /dev/null +++ b/apps/web/pages/auth/setup/index.tsx @@ -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) { + 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 ? : , + isLoading: isLoadingStep1, + }, + { + title: t("enable_apps"), + description: t("enable_apps_description"), + content: , + isLoading: false, + }, + ]; + + return ( + <> +
+ +
+ + ); +} + +export const getServerSideProps = async () => { + const userCount = await prisma.user.count(); + return { + props: { + userCount, + }, + }; +}; diff --git a/apps/web/pages/auth/setup.tsx b/apps/web/pages/auth/setup/steps/SetupFormStep1.tsx similarity index 74% rename from apps/web/pages/auth/setup.tsx rename to apps/web/pages/auth/setup/steps/SetupFormStep1.tsx index d01a7ef56f..271b1a3369 100644 --- a/apps/web/pages/auth/setup.tsx +++ b/apps/web/pages/auth/setup/steps/SetupFormStep1.tsx @@ -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 ( -
-
- -
-
-

{t("all_done")}

-
-
- ); -}; +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 ( @@ -201,37 +187,4 @@ const SetupFormStep1 = (props: { setIsLoading: (val: boolean) => void }) => { ); }; -export default function Setup(props: inferSSRProps) { - 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 ? : , - enabled: props.userCount === 0, // to check if the wizard should show buttons to navigate through more steps - isLoading: isLoadingStep1, - }, - ]; - - return ( - <> -
- -
- - ); -} - -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; diff --git a/apps/web/pages/auth/setup/steps/StepDone.tsx b/apps/web/pages/auth/setup/steps/StepDone.tsx new file mode 100644 index 0000000000..706d1dfc89 --- /dev/null +++ b/apps/web/pages/auth/setup/steps/StepDone.tsx @@ -0,0 +1,19 @@ +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { Icon } from "@calcom/ui"; + +const StepDone = () => { + const { t } = useLocale(); + + return ( +
+
+ +
+
+

{t("all_done")}

+
+
+ ); +}; + +export default StepDone; diff --git a/apps/web/pages/event-types/[type]/index.tsx b/apps/web/pages/event-types/[type]/index.tsx index 396a4dd461..4cfb3b547f 100644 --- a/apps/web/pages/event-types/[type]/index.tsx +++ b/apps/web/pages/event-types/[type]/index.tsx @@ -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, { diff --git a/apps/web/pages/settings/admin/apps.tsx b/apps/web/pages/settings/admin/apps/[category].tsx similarity index 82% rename from apps/web/pages/settings/admin/apps.tsx rename to apps/web/pages/settings/admin/apps/[category].tsx index d6e3dcd6b5..588bec41ae 100644 --- a/apps/web/pages/settings/admin/apps.tsx +++ b/apps/web/pages/settings/admin/apps/[category].tsx @@ -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 ( <> -

{t("apps_listing")}

+ ); } diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 90cc695bf1..3a98eba29e 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -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}}", diff --git a/apps/website b/apps/website index 925f81ed92..c358039b1c 160000 --- a/apps/website +++ b/apps/website @@ -1 +1 @@ -Subproject commit 925f81ed92ee54cb1512c2836077e06d68c95123 +Subproject commit c358039b1c1d0c5c0b7901c6489261c4e8e8c6e0 diff --git a/packages/app-store-cli/src/app-store.ts b/packages/app-store-cli/src/app-store.ts index 78cb008e30..2a3a3ec519 100644 --- a/packages/app-store-cli/src/app-store.ts +++ b/packages/app-store-cli/src/app-store.ts @@ -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")}`)); diff --git a/packages/app-store/_appRegistry.ts b/packages/app-store/_appRegistry.ts index 542e8218df..b2bc447a74 100644 --- a/packages/app-store/_appRegistry.ts +++ b/packages/app-store/_appRegistry.ts @@ -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[]; 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 & { credentials: Credential[]; })[]; diff --git a/packages/app-store/_components/AppCard.tsx b/packages/app-store/_components/AppCard.tsx index 3844b94585..302fb1ba63 100644 --- a/packages/app-store/_components/AppCard.tsx +++ b/packages/app-store/_components/AppCard.tsx @@ -26,7 +26,7 @@ export default function AppCard({ const [animationRef] = useAutoAnimate(); return ( -
+
{/* 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 ? (
{ if (switchOnClick) { switchOnClick(enabled); @@ -54,7 +55,7 @@ export default function AppCard({ />
) : ( - + )}
diff --git a/packages/app-store/_components/AppCategoryNavigation.tsx b/packages/app-store/_components/AppCategoryNavigation.tsx new file mode 100644 index 0000000000..ce3f183218 --- /dev/null +++ b/packages/app-store/_components/AppCategoryNavigation.tsx @@ -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 ( +
+
+ +
+
+ +
+
{children}
+
+ ); +}; + +export default AppCategoryNavigation; diff --git a/packages/app-store/_templates/zod.ts b/packages/app-store/_templates/zod.ts index 14cccd01a9..99e1f6da5d 100644 --- a/packages/app-store/_templates/zod.ts +++ b/packages/app-store/_templates/zod.ts @@ -7,3 +7,5 @@ export const appDataSchema = eventTypeAppCardZod.merge( isSunrise: z.boolean(), }) ); + +export const appKeysSchema = z.object({}); diff --git a/packages/app-store/_utils/getAppCategories.ts b/packages/app-store/_utils/getAppCategories.ts new file mode 100644 index 0000000000..39a2ba60af --- /dev/null +++ b/packages/app-store/_utils/getAppCategories.ts @@ -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; diff --git a/packages/app-store/applecalendar/_metadata.ts b/packages/app-store/applecalendar/_metadata.ts index 0fb01d7f33..ab84900af1 100644 --- a/packages/app-store/applecalendar/_metadata.ts +++ b/packages/app-store/applecalendar/_metadata.ts @@ -20,6 +20,7 @@ export const metadata = { url: "https://cal.com/", verified: true, email: "help@cal.com", + dirName: "applecalendar", } as AppMeta; export default metadata; diff --git a/packages/app-store/applecalendar/api/add.ts b/packages/app-store/applecalendar/api/add.ts index e9c0048839..a5e17893fe 100644 --- a/packages/app-store/applecalendar/api/add.ts +++ b/packages/app-store/applecalendar/api/add.ts @@ -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 { diff --git a/packages/app-store/apps.keys-schemas.generated.ts b/packages/app-store/apps.keys-schemas.generated.ts new file mode 100644 index 0000000000..23864a1892 --- /dev/null +++ b/packages/app-store/apps.keys-schemas.generated.ts @@ -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, +}; diff --git a/packages/app-store/apps.schemas.generated.ts b/packages/app-store/apps.schemas.generated.ts index e0d145f1b6..113eeadc47 100644 --- a/packages/app-store/apps.schemas.generated.ts +++ b/packages/app-store/apps.schemas.generated.ts @@ -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, }; diff --git a/packages/app-store/around/_metadata.ts b/packages/app-store/around/_metadata.ts index 8394d0477c..671ff49ce1 100644 --- a/packages/app-store/around/_metadata.ts +++ b/packages/app-store/around/_metadata.ts @@ -4,6 +4,7 @@ import config from "./config.json"; export const metadata = { category: "other", + dirName: "around", appData: { location: { linkType: "static", diff --git a/packages/app-store/caldavcalendar/_metadata.ts b/packages/app-store/caldavcalendar/_metadata.ts index 51443321c7..f3f6408f1c 100644 --- a/packages/app-store/caldavcalendar/_metadata.ts +++ b/packages/app-store/caldavcalendar/_metadata.ts @@ -20,6 +20,7 @@ export const metadata = { url: "https://cal.com/", verified: true, email: "ali@cal.com", + dirName: "caldavcalendar", } as AppMeta; export default metadata; diff --git a/packages/app-store/caldavcalendar/api/add.ts b/packages/app-store/caldavcalendar/api/add.ts index 6930083c57..98fa3fe297 100644 --- a/packages/app-store/caldavcalendar/api/add.ts +++ b/packages/app-store/caldavcalendar/api/add.ts @@ -28,6 +28,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) ), userId: user.id, appId: "caldav-calendar", + invalid: false, }; try { diff --git a/packages/app-store/campfire/_metadata.ts b/packages/app-store/campfire/_metadata.ts index 9c7f2aa320..c255f913c1 100644 --- a/packages/app-store/campfire/_metadata.ts +++ b/packages/app-store/campfire/_metadata.ts @@ -4,6 +4,7 @@ import config from "./config.json"; export const metadata = { category: "other", + dirName: "caldavcalendar", ...config, } as AppMeta; diff --git a/packages/app-store/closecom/_metadata.ts b/packages/app-store/closecom/_metadata.ts index 5092c47c4a..f38eb267f6 100644 --- a/packages/app-store/closecom/_metadata.ts +++ b/packages/app-store/closecom/_metadata.ts @@ -12,6 +12,7 @@ export const metadata = { verified: true, licenseRequired: true, isProOnly: true, + dirName: "closecom", ...config, } as App; diff --git a/packages/app-store/dailyvideo/_metadata.ts b/packages/app-store/dailyvideo/_metadata.ts index d7805df2f1..49cc2f136d 100644 --- a/packages/app-store/dailyvideo/_metadata.ts +++ b/packages/app-store/dailyvideo/_metadata.ts @@ -29,6 +29,7 @@ export const metadata = { }, }, key: { apikey: process.env.DAILY_API_KEY }, + dirName: "dailyvideo", } as AppMeta; export default metadata; diff --git a/packages/app-store/dailyvideo/zod.ts b/packages/app-store/dailyvideo/zod.ts new file mode 100644 index 0000000000..461e857911 --- /dev/null +++ b/packages/app-store/dailyvideo/zod.ts @@ -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({}); diff --git a/packages/app-store/ee/routing-forms/_metadata.ts b/packages/app-store/ee/routing-forms/_metadata.ts index 8e24e2085f..7dffa94a91 100644 --- a/packages/app-store/ee/routing-forms/_metadata.ts +++ b/packages/app-store/ee/routing-forms/_metadata.ts @@ -12,6 +12,7 @@ export const metadata = { verified: true, licenseRequired: true, isProOnly: true, + dirName: "routing-forms", ...config, } as AppMeta; diff --git a/packages/app-store/ee/routing-forms/zod.ts b/packages/app-store/ee/routing-forms/zod.ts index 7f54dc0aa2..6031402ac3 100644 --- a/packages/app-store/ee/routing-forms/zod.ts +++ b/packages/app-store/ee/routing-forms/zod.ts @@ -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({}); diff --git a/packages/app-store/exchange2013calendar/_metadata.ts b/packages/app-store/exchange2013calendar/_metadata.ts index 1c2a311ab9..b7371da47d 100644 --- a/packages/app-store/exchange2013calendar/_metadata.ts +++ b/packages/app-store/exchange2013calendar/_metadata.ts @@ -21,6 +21,7 @@ export const metadata = { url: "https://cal.com/", verified: true, email: "help@cal.com", + dirName: "exchange2013calendar", } as AppMeta; export default metadata; diff --git a/packages/app-store/exchange2013calendar/api/add.ts b/packages/app-store/exchange2013calendar/api/add.ts index dcf77c2f16..81964a8b6c 100644 --- a/packages/app-store/exchange2013calendar/api/add.ts +++ b/packages/app-store/exchange2013calendar/api/add.ts @@ -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 { diff --git a/packages/app-store/exchange2016calendar/_metadata.ts b/packages/app-store/exchange2016calendar/_metadata.ts index aa76fb1c16..ad7e028cfe 100644 --- a/packages/app-store/exchange2016calendar/_metadata.ts +++ b/packages/app-store/exchange2016calendar/_metadata.ts @@ -21,6 +21,7 @@ export const metadata = { url: "https://cal.com/", verified: true, email: "help@cal.com", + dirName: "exchange2016calendar", } as AppMeta; export default metadata; diff --git a/packages/app-store/exchange2016calendar/api/add.ts b/packages/app-store/exchange2016calendar/api/add.ts index 0d58ec4af7..f1ea1d9b79 100644 --- a/packages/app-store/exchange2016calendar/api/add.ts +++ b/packages/app-store/exchange2016calendar/api/add.ts @@ -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 { diff --git a/packages/app-store/exchangecalendar/_metadata.ts b/packages/app-store/exchangecalendar/_metadata.ts index 5812a65828..338dedb30c 100644 --- a/packages/app-store/exchangecalendar/_metadata.ts +++ b/packages/app-store/exchangecalendar/_metadata.ts @@ -9,6 +9,7 @@ export const metadata = { reviews: 0, trending: true, verified: true, + dirName: "exchangecalendar", ...config, } as App; diff --git a/packages/app-store/exchangecalendar/api/_postAdd.ts b/packages/app-store/exchangecalendar/api/_postAdd.ts index e7f70a82de..769a484620 100644 --- a/packages/app-store/exchangecalendar/api/_postAdd.ts +++ b/packages/app-store/exchangecalendar/api/_postAdd.ts @@ -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 }); diff --git a/packages/app-store/fathom/_metadata.ts b/packages/app-store/fathom/_metadata.ts index f8e160d47d..64f996cd4b 100644 --- a/packages/app-store/fathom/_metadata.ts +++ b/packages/app-store/fathom/_metadata.ts @@ -4,6 +4,7 @@ import config from "./config.json"; export const metadata = { category: "analytics", + dirName: "fathom", ...config, } as AppMeta; diff --git a/packages/app-store/fathom/zod.ts b/packages/app-store/fathom/zod.ts index 0009ed8209..a9f8ba3d56 100644 --- a/packages/app-store/fathom/zod.ts +++ b/packages/app-store/fathom/zod.ts @@ -7,3 +7,5 @@ export const appDataSchema = eventTypeAppCardZod.merge( trackingId: z.string(), }) ); + +export const appKeysSchema = z.object({}); diff --git a/packages/app-store/ga4/_metadata.ts b/packages/app-store/ga4/_metadata.ts index 9c7f2aa320..c77cfbbf99 100644 --- a/packages/app-store/ga4/_metadata.ts +++ b/packages/app-store/ga4/_metadata.ts @@ -4,6 +4,7 @@ import config from "./config.json"; export const metadata = { category: "other", + dirName: "ga4", ...config, } as AppMeta; diff --git a/packages/app-store/ga4/zod.ts b/packages/app-store/ga4/zod.ts index 0009ed8209..a9f8ba3d56 100644 --- a/packages/app-store/ga4/zod.ts +++ b/packages/app-store/ga4/zod.ts @@ -7,3 +7,5 @@ export const appDataSchema = eventTypeAppCardZod.merge( trackingId: z.string(), }) ); + +export const appKeysSchema = z.object({}); diff --git a/packages/app-store/giphy/_metadata.ts b/packages/app-store/giphy/_metadata.ts index 9a4a9e15b7..c714136b2f 100644 --- a/packages/app-store/giphy/_metadata.ts +++ b/packages/app-store/giphy/_metadata.ts @@ -22,6 +22,7 @@ export const metadata = { verified: true, extendsFeature: "EventType", email: "help@cal.com", + dirName: "giphy", } as AppMeta; export default metadata; diff --git a/packages/app-store/giphy/zod.ts b/packages/app-store/giphy/zod.ts index e98a7d02d1..bd23a07d54 100644 --- a/packages/app-store/giphy/zod.ts +++ b/packages/app-store/giphy/zod.ts @@ -7,3 +7,7 @@ export const appDataSchema = eventTypeAppCardZod.merge( thankYouPage: z.string().optional(), }) ); + +export const appKeysSchema = z.object({ + app_key: z.string().min(1), +}); diff --git a/packages/app-store/googlecalendar/_metadata.ts b/packages/app-store/googlecalendar/_metadata.ts index 0a164b2fb0..dba292b667 100644 --- a/packages/app-store/googlecalendar/_metadata.ts +++ b/packages/app-store/googlecalendar/_metadata.ts @@ -21,6 +21,7 @@ export const metadata = { url: "https://cal.com/", verified: true, email: "help@cal.com", + dirName: "googlecalendar", } as AppMeta; export default metadata; diff --git a/packages/app-store/googlecalendar/zod.ts b/packages/app-store/googlecalendar/zod.ts new file mode 100644 index 0000000000..ce88e594ff --- /dev/null +++ b/packages/app-store/googlecalendar/zod.ts @@ -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), +}); diff --git a/packages/app-store/googlevideo/_metadata.ts b/packages/app-store/googlevideo/_metadata.ts index 1bee99e60e..b3866ff43f 100644 --- a/packages/app-store/googlevideo/_metadata.ts +++ b/packages/app-store/googlevideo/_metadata.ts @@ -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; diff --git a/packages/app-store/hubspot/_metadata.ts b/packages/app-store/hubspot/_metadata.ts index ddae52fc4d..3ea08eef0c 100644 --- a/packages/app-store/hubspot/_metadata.ts +++ b/packages/app-store/hubspot/_metadata.ts @@ -21,6 +21,7 @@ export const metadata = { title: "HubSpot CRM", trending: true, email: "help@cal.com", + dirName: "hubspot", } as AppMeta; export default metadata; diff --git a/packages/app-store/hubspot/zod.ts b/packages/app-store/hubspot/zod.ts new file mode 100644 index 0000000000..fe378bc4bc --- /dev/null +++ b/packages/app-store/hubspot/zod.ts @@ -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({}); diff --git a/packages/app-store/huddle01video/_metadata.ts b/packages/app-store/huddle01video/_metadata.ts index a4d40af3d4..1812dd29e7 100644 --- a/packages/app-store/huddle01video/_metadata.ts +++ b/packages/app-store/huddle01video/_metadata.ts @@ -30,6 +30,7 @@ export const metadata = { }, }, key: { apikey: randomString(12) }, + dirName: "huddle01video", } as AppMeta; export default metadata; diff --git a/packages/app-store/jitsivideo/_metadata.ts b/packages/app-store/jitsivideo/_metadata.ts index 94150d6fdb..ba16746d81 100644 --- a/packages/app-store/jitsivideo/_metadata.ts +++ b/packages/app-store/jitsivideo/_metadata.ts @@ -28,6 +28,7 @@ export const metadata = { label: "Jitsi Video", }, }, + dirName: "jitsivideo", } as AppMeta; export default metadata; diff --git a/packages/app-store/larkcalendar/_metadata.ts b/packages/app-store/larkcalendar/_metadata.ts index 45827b484b..81c174f73e 100644 --- a/packages/app-store/larkcalendar/_metadata.ts +++ b/packages/app-store/larkcalendar/_metadata.ts @@ -20,6 +20,7 @@ export const metadata = { url: "https://larksuite.com/", verified: true, email: "alan@larksuite.com", + dirName: "larkcalendar", } as AppMeta; export default metadata; diff --git a/packages/app-store/larkcalendar/zod.ts b/packages/app-store/larkcalendar/zod.ts new file mode 100644 index 0000000000..8be7bb3254 --- /dev/null +++ b/packages/app-store/larkcalendar/zod.ts @@ -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), +}); diff --git a/packages/app-store/n8n/_metadata.ts b/packages/app-store/n8n/_metadata.ts index 85ffde521b..3e8fdda618 100644 --- a/packages/app-store/n8n/_metadata.ts +++ b/packages/app-store/n8n/_metadata.ts @@ -4,6 +4,7 @@ import config from "./config.json"; export const metadata = { category: "automation", + dirName: "n8n", ...config, } as AppMeta; diff --git a/packages/app-store/office365calendar/_metadata.ts b/packages/app-store/office365calendar/_metadata.ts index 3e4d91c7ac..62df3e4752 100644 --- a/packages/app-store/office365calendar/_metadata.ts +++ b/packages/app-store/office365calendar/_metadata.ts @@ -19,6 +19,7 @@ export const metadata = { url: "https://cal.com/", verified: true, email: "help@cal.com", + dirName: "office365calendar", } as AppMeta; export default metadata; diff --git a/packages/app-store/office365calendar/zod.ts b/packages/app-store/office365calendar/zod.ts new file mode 100644 index 0000000000..a69e2e86d8 --- /dev/null +++ b/packages/app-store/office365calendar/zod.ts @@ -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(), +}); diff --git a/packages/app-store/office365video/_metadata.ts b/packages/app-store/office365video/_metadata.ts index 206dfb19df..e9f43ba742 100644 --- a/packages/app-store/office365video/_metadata.ts +++ b/packages/app-store/office365video/_metadata.ts @@ -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; diff --git a/packages/app-store/office365video/config.json b/packages/app-store/office365video/config.json new file mode 100644 index 0000000000..ec88857877 --- /dev/null +++ b/packages/app-store/office365video/config.json @@ -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" + } + } +} diff --git a/packages/app-store/office365video/zod.ts b/packages/app-store/office365video/zod.ts new file mode 100644 index 0000000000..a69e2e86d8 --- /dev/null +++ b/packages/app-store/office365video/zod.ts @@ -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(), +}); diff --git a/packages/app-store/ping/_metadata.ts b/packages/app-store/ping/_metadata.ts index 92463e32a3..cd629e91f9 100644 --- a/packages/app-store/ping/_metadata.ts +++ b/packages/app-store/ping/_metadata.ts @@ -19,6 +19,7 @@ export const metadata = { urlRegExp: "^http(s)?:\\/\\/(www\\.)?ping.gg\\/call\\/[a-zA-Z0-9]*", }, }, + dirName: "ping", ...config, } as AppMeta; diff --git a/packages/app-store/pipedream/_metadata.ts b/packages/app-store/pipedream/_metadata.ts index 9c7f2aa320..fae46693c0 100644 --- a/packages/app-store/pipedream/_metadata.ts +++ b/packages/app-store/pipedream/_metadata.ts @@ -4,6 +4,7 @@ import config from "./config.json"; export const metadata = { category: "other", + dirName: "pipedream", ...config, } as AppMeta; diff --git a/packages/app-store/plausible/zod.ts b/packages/app-store/plausible/zod.ts index 5d99948edc..082c15a2a5 100644 --- a/packages/app-store/plausible/zod.ts +++ b/packages/app-store/plausible/zod.ts @@ -7,3 +7,5 @@ export const appDataSchema = eventTypeAppCardZod.merge( trackedDomain: z.string(), }) ); + +export const appKeysSchema = z.object({}); diff --git a/packages/app-store/qr_code/_metadata.ts b/packages/app-store/qr_code/_metadata.ts index 9c7f2aa320..5dda41b44b 100644 --- a/packages/app-store/qr_code/_metadata.ts +++ b/packages/app-store/qr_code/_metadata.ts @@ -4,6 +4,7 @@ import config from "./config.json"; export const metadata = { category: "other", + dirName: "qr_code", ...config, } as AppMeta; diff --git a/packages/app-store/qr_code/zod.ts b/packages/app-store/qr_code/zod.ts index 48f9750401..b2245912f0 100644 --- a/packages/app-store/qr_code/zod.ts +++ b/packages/app-store/qr_code/zod.ts @@ -1,3 +1,7 @@ +import { z } from "zod"; + import { eventTypeAppCardZod } from "../eventTypeAppCardZod"; export const appDataSchema = eventTypeAppCardZod; + +export const appKeysSchema = z.object({}); diff --git a/packages/app-store/rainbow/_metadata.ts b/packages/app-store/rainbow/_metadata.ts index 9c7f2aa320..e7c2745dd9 100644 --- a/packages/app-store/rainbow/_metadata.ts +++ b/packages/app-store/rainbow/_metadata.ts @@ -4,6 +4,7 @@ import config from "./config.json"; export const metadata = { category: "other", + dirName: "rainbow", ...config, } as AppMeta; diff --git a/packages/app-store/rainbow/zod.ts b/packages/app-store/rainbow/zod.ts index 3074022775..dcba3bdc16 100644 --- a/packages/app-store/rainbow/zod.ts +++ b/packages/app-store/rainbow/zod.ts @@ -8,3 +8,5 @@ export const appDataSchema = eventTypeAppCardZod.merge( blockchainId: z.number().optional(), }) ); + +export const appKeysSchema = z.object({}); diff --git a/packages/app-store/raycast/_metadata.ts b/packages/app-store/raycast/_metadata.ts index 9c7f2aa320..8b1576f514 100644 --- a/packages/app-store/raycast/_metadata.ts +++ b/packages/app-store/raycast/_metadata.ts @@ -4,6 +4,7 @@ import config from "./config.json"; export const metadata = { category: "other", + dirName: "raycast", ...config, } as AppMeta; diff --git a/packages/app-store/riverside/_metadata.ts b/packages/app-store/riverside/_metadata.ts index a572d05dac..7b1c590735 100644 --- a/packages/app-store/riverside/_metadata.ts +++ b/packages/app-store/riverside/_metadata.ts @@ -19,6 +19,7 @@ export const metadata = { linkType: "static", }, }, + dirName: "riverside", ...config, } as AppMeta; diff --git a/packages/app-store/salesforce/_metadata.ts b/packages/app-store/salesforce/_metadata.ts index 9c7f2aa320..5336e9b798 100644 --- a/packages/app-store/salesforce/_metadata.ts +++ b/packages/app-store/salesforce/_metadata.ts @@ -4,6 +4,7 @@ import config from "./config.json"; export const metadata = { category: "other", + dirName: "salesforce", ...config, } as AppMeta; diff --git a/packages/app-store/salesforce/zod.ts b/packages/app-store/salesforce/zod.ts new file mode 100644 index 0000000000..d305b76272 --- /dev/null +++ b/packages/app-store/salesforce/zod.ts @@ -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), +}); diff --git a/packages/app-store/sendgrid/_metadata.ts b/packages/app-store/sendgrid/_metadata.ts index 9c7f2aa320..1811df8a21 100644 --- a/packages/app-store/sendgrid/_metadata.ts +++ b/packages/app-store/sendgrid/_metadata.ts @@ -4,6 +4,7 @@ import config from "./config.json"; export const metadata = { category: "other", + dirName: "sendgrid", ...config, } as AppMeta; diff --git a/packages/app-store/signal/_metadata.ts b/packages/app-store/signal/_metadata.ts index 9c7f2aa320..3265637d31 100644 --- a/packages/app-store/signal/_metadata.ts +++ b/packages/app-store/signal/_metadata.ts @@ -4,6 +4,7 @@ import config from "./config.json"; export const metadata = { category: "other", + dirName: "signal", ...config, } as AppMeta; diff --git a/packages/app-store/sirius_video/_metadata.ts b/packages/app-store/sirius_video/_metadata.ts index 9c7f2aa320..d507b79142 100644 --- a/packages/app-store/sirius_video/_metadata.ts +++ b/packages/app-store/sirius_video/_metadata.ts @@ -4,6 +4,7 @@ import config from "./config.json"; export const metadata = { category: "other", + dirName: "sirius_video", ...config, } as AppMeta; diff --git a/packages/app-store/stripepayment/_metadata.ts b/packages/app-store/stripepayment/_metadata.ts index 892e06d911..8e928a45a7 100644 --- a/packages/app-store/stripepayment/_metadata.ts +++ b/packages/app-store/stripepayment/_metadata.ts @@ -26,6 +26,7 @@ export const metadata = { extendsFeature: "EventType", verified: true, email: "help@cal.com", + dirName: "stripepayment", } as AppMeta; export default metadata; diff --git a/packages/app-store/stripepayment/zod.ts b/packages/app-store/stripepayment/zod.ts index e9bd45774c..1fe4cfeb78 100644 --- a/packages/app-store/stripepayment/zod.ts +++ b/packages/app-store/stripepayment/zod.ts @@ -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), +}); diff --git a/packages/app-store/tandemvideo/_metadata.ts b/packages/app-store/tandemvideo/_metadata.ts index b24b24aa35..30c30a4d80 100644 --- a/packages/app-store/tandemvideo/_metadata.ts +++ b/packages/app-store/tandemvideo/_metadata.ts @@ -27,6 +27,7 @@ export const metadata = { label: "Tandem Video", }, }, + dirName: "tandemvideo", } as AppMeta; export default metadata; diff --git a/packages/app-store/tandemvideo/zod.ts b/packages/app-store/tandemvideo/zod.ts new file mode 100644 index 0000000000..c84d14ee25 --- /dev/null +++ b/packages/app-store/tandemvideo/zod.ts @@ -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), +}); diff --git a/packages/app-store/telegram/_metadata.ts b/packages/app-store/telegram/_metadata.ts index 9c7f2aa320..feeed54b84 100644 --- a/packages/app-store/telegram/_metadata.ts +++ b/packages/app-store/telegram/_metadata.ts @@ -4,6 +4,7 @@ import config from "./config.json"; export const metadata = { category: "other", + dirName: "telegram", ...config, } as AppMeta; diff --git a/packages/app-store/typeform/_metadata.ts b/packages/app-store/typeform/_metadata.ts index 9c7f2aa320..b7f4a67680 100644 --- a/packages/app-store/typeform/_metadata.ts +++ b/packages/app-store/typeform/_metadata.ts @@ -4,6 +4,7 @@ import config from "./config.json"; export const metadata = { category: "other", + dirName: "typeform", ...config, } as AppMeta; diff --git a/packages/app-store/utils.ts b/packages/app-store/utils.ts index 4eb0f6f98a..bde67c3294 100644 --- a/packages/app-store/utils.ts +++ b/packages/app-store/utils.ts @@ -26,10 +26,10 @@ const ALL_APPS_MAP = Object.keys(appStoreMetadata).reduce((store, key) => { }, {} as Record); const credentialData = Prisma.validator()({ - 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; +export type CredentialData = Prisma.CredentialGetPayload; 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); } diff --git a/packages/app-store/vital/_metadata.ts b/packages/app-store/vital/_metadata.ts index f08386a25c..92ac7ff29c 100644 --- a/packages/app-store/vital/_metadata.ts +++ b/packages/app-store/vital/_metadata.ts @@ -22,6 +22,7 @@ export const metadata = { variant: "other", verified: true, email: "support@tryvital.io", + dirName: "vital", } as AppMeta; export default metadata; diff --git a/packages/app-store/vital/zod.ts b/packages/app-store/vital/zod.ts new file mode 100644 index 0000000000..e8b1456c62 --- /dev/null +++ b/packages/app-store/vital/zod.ts @@ -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), +}); diff --git a/packages/app-store/weather_in_your_calendar/_metadata.ts b/packages/app-store/weather_in_your_calendar/_metadata.ts index 9c7f2aa320..8c414ba2da 100644 --- a/packages/app-store/weather_in_your_calendar/_metadata.ts +++ b/packages/app-store/weather_in_your_calendar/_metadata.ts @@ -4,6 +4,7 @@ import config from "./config.json"; export const metadata = { category: "other", + dirName: "weather_in_your_calendar", ...config, } as AppMeta; diff --git a/packages/app-store/whatsapp/_metadata.ts b/packages/app-store/whatsapp/_metadata.ts index 9c7f2aa320..d03408c42b 100644 --- a/packages/app-store/whatsapp/_metadata.ts +++ b/packages/app-store/whatsapp/_metadata.ts @@ -4,6 +4,7 @@ import config from "./config.json"; export const metadata = { category: "other", + dirName: "whatsapp", ...config, } as AppMeta; diff --git a/packages/app-store/whereby/_metadata.ts b/packages/app-store/whereby/_metadata.ts index 361395c9e3..97574d63ad 100644 --- a/packages/app-store/whereby/_metadata.ts +++ b/packages/app-store/whereby/_metadata.ts @@ -19,6 +19,7 @@ export const metadata = { urlRegExp: "^http(s)?:\\/\\/(www\\.)?(team.)?whereby.com\\/[a-zA-Z0-9]*", }, }, + dirName: "whereby", ...config, } as AppMeta; diff --git a/packages/app-store/wipemycalother/_metadata.ts b/packages/app-store/wipemycalother/_metadata.ts index 4a677cc445..32e196685a 100644 --- a/packages/app-store/wipemycalother/_metadata.ts +++ b/packages/app-store/wipemycalother/_metadata.ts @@ -21,6 +21,7 @@ export const metadata = { variant: "other", verified: true, email: "help@cal.com", + dirName: "wipemycalother", } as AppMeta; export default metadata; diff --git a/packages/app-store/wordpress/zod.ts b/packages/app-store/wordpress/zod.ts index 14cccd01a9..99e1f6da5d 100644 --- a/packages/app-store/wordpress/zod.ts +++ b/packages/app-store/wordpress/zod.ts @@ -7,3 +7,5 @@ export const appDataSchema = eventTypeAppCardZod.merge( isSunrise: z.boolean(), }) ); + +export const appKeysSchema = z.object({}); diff --git a/packages/app-store/zapier/_metadata.ts b/packages/app-store/zapier/_metadata.ts index a67c213f75..8d7015f90a 100644 --- a/packages/app-store/zapier/_metadata.ts +++ b/packages/app-store/zapier/_metadata.ts @@ -20,6 +20,7 @@ export const metadata = { variant: "automation", verified: true, email: "help@cal.com", + dirName: "zapier", } as AppMeta; export default metadata; diff --git a/packages/app-store/zapier/zod.ts b/packages/app-store/zapier/zod.ts new file mode 100644 index 0000000000..4a98d79116 --- /dev/null +++ b/packages/app-store/zapier/zod.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +export const appDataSchema = z.object({}); + +export const appKeysSchema = z.object({ + invite_link: z.string().min(1), +}); diff --git a/packages/app-store/zoomvideo/_metadata.ts b/packages/app-store/zoomvideo/_metadata.ts index 2bf28fa617..309456b896 100644 --- a/packages/app-store/zoomvideo/_metadata.ts +++ b/packages/app-store/zoomvideo/_metadata.ts @@ -28,6 +28,7 @@ export const metadata = { label: "Zoom Video", }, }, + dirName: "zoomvideo", } as AppMeta; export default metadata; diff --git a/packages/app-store/zoomvideo/zod.ts b/packages/app-store/zoomvideo/zod.ts new file mode 100644 index 0000000000..0a84054ebe --- /dev/null +++ b/packages/app-store/zoomvideo/zod.ts @@ -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), +}); diff --git a/packages/core/videoClient.ts b/packages/core/videoClient.ts index 96ac48280c..614d6cb27a 100644 --- a/packages/core/videoClient.ts +++ b/packages/core/videoClient.ts @@ -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); diff --git a/packages/emails/email-manager.ts b/packages/emails/email-manager.ts index e112d40932..f3d3c4f422 100644 --- a/packages/emails/email-manager.ts +++ b/packages/emails/email-manager.ts @@ -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)); + } + }); +}; diff --git a/packages/emails/src/templates/DisabledAppEmail.tsx b/packages/emails/src/templates/DisabledAppEmail.tsx new file mode 100644 index 0000000000..4753502395 --- /dev/null +++ b/packages/emails/src/templates/DisabledAppEmail.tsx @@ -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> +) => { + const { title, appName, eventTypeId, t, appType } = props; + + return ( + + {appType.some((type) => type === "payment") ? ( + <> +

+ <>{t("disabled_app_affects_event_type", { appName: appName, eventType: title })} +

+

+ <>{t("payment_disabled_still_able_to_book")} +

+ +
+ + + + ) : title && eventTypeId ? ( + <> +

+ <>{(t("app_disabled_with_event_type"), { appName: appName, title: title })} +

+ +
+ + + + ) : appType.some((type) => type === "video") ? ( + <> +

+ <>{t("app_disabled_video", { appName: appName })} +

+ +
+ + + + ) : appType.some((type) => type === "calendar") ? ( + <> +

+ <>{t("admin_has_disabled", { appName: appName })} +

+

+ <>{t("disabled_calendar")} +

+ +
+ + + + ) : ( + <> +

+ <>{t("admin_has_disabled", { appName: appName })} +

+ +
+ + + + )} +
+ ); +}; diff --git a/packages/emails/src/templates/index.ts b/packages/emails/src/templates/index.ts index ee8fe10b19..95fc76a10f 100644 --- a/packages/emails/src/templates/index.ts +++ b/packages/emails/src/templates/index.ts @@ -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"; diff --git a/packages/emails/templates/disabled-app-email.ts b/packages/emails/templates/disabled-app-email.ts new file mode 100644 index 0000000000..0a927a179c --- /dev/null +++ b/packages/emails/templates/disabled-app-email.ts @@ -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 { + 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 }); + } +} diff --git a/packages/features/apps/AdminAppsList.tsx b/packages/features/apps/AdminAppsList.tsx new file mode 100644 index 0000000000..3740ef28a3 --- /dev/null +++ b/packages/features/apps/AdminAppsList.tsx @@ -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 ( + <> + +
+
+ {app.logo && {app.title}} +
+

+

{app.name || app.title}

+

+

{app.description}

+
+
+ <> + { + if (app.enabled) { + setDisableDialog(true); + } else { + enableAppMutation.mutate({ slug: app.slug, enabled: app.enabled }); + setShowKeys(true); + } + }} + /> + {app.keys && ( + <> + + + + + + + )} + +
+
+ + {!!app.keys && typeof app.keys === "object" && ( +
+ saveKeysMutation.mutate({ + slug: app.slug, + type: app.type, + keys: values, + dirName: app.dirName, + }) + } + className="px-4 pb-4"> + {Object.keys(app.keys).map((key) => ( + ( + { + formMethods.setValue(key, e?.target.value); + }} + /> + )} + /> + ))} + + + )} +
+
+
+ + + { + enableAppMutation.mutate({ slug: app.slug, enabled: app.enabled }); + }}> + {t("disable_app_description")} + + + + ); +}; + +const querySchema = z.object({ + category: z + .nativeEnum({ ...AppCategories, conferencing: "conferencing" }) + .optional() + .default(AppCategories.calendar), +}); + +const AdminAppsList = ({ baseURL, className }: { baseURL: string; className?: string }) => ( + + + +); + +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 ; + + if (!apps) { + return ( + + ); + } + + return ( +
+ {apps.map((app, index) => ( + + ))} +
+ ); +}; + +export default AdminAppsList; + +const SkeletonLoader = () => { + return ( + +
+ + + + + + +
+
+ ); +}; diff --git a/packages/lib/apps/getEnabledApps.ts b/packages/lib/apps/getEnabledApps.ts new file mode 100644 index 0000000000..4918a77679 --- /dev/null +++ b/packages/lib/apps/getEnabledApps.ts @@ -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[number] & { enabled: boolean })[]); + + return filteredApps; +}; + +export default getEnabledApps; diff --git a/packages/prisma/migrations/20221111152547_add_enabled_col_to_apps/migration.sql b/packages/prisma/migrations/20221111152547_add_enabled_col_to_apps/migration.sql new file mode 100644 index 0000000000..58f7a5676f --- /dev/null +++ b/packages/prisma/migrations/20221111152547_add_enabled_col_to_apps/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "App" ADD COLUMN "enabled" BOOLEAN NOT NULL DEFAULT false; diff --git a/packages/prisma/migrations/20221201191836_credential_invalid_col_default_false/migration.sql b/packages/prisma/migrations/20221201191836_credential_invalid_col_default_false/migration.sql new file mode 100644 index 0000000000..0e3ae435e3 --- /dev/null +++ b/packages/prisma/migrations/20221201191836_credential_invalid_col_default_false/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Credential" ALTER COLUMN "invalid" SET DEFAULT false; diff --git a/packages/prisma/migrations/20221206152547_set_enabled_to_current_apps/migration.sql b/packages/prisma/migrations/20221206152547_set_enabled_to_current_apps/migration.sql new file mode 100644 index 0000000000..fd08153002 --- /dev/null +++ b/packages/prisma/migrations/20221206152547_set_enabled_to_current_apps/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +-- The new default is false but we set true for all current apps +UPDATE "App" SET "enabled" = true; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 261f11391b..9af9e270d2 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -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 { diff --git a/packages/prisma/seed-app-store.ts b/packages/prisma/seed-app-store.ts index 1f2c294a52..d22f5aada2 100644 --- a/packages/prisma/seed-app-store.ts +++ b/packages/prisma/seed-app-store.ts @@ -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 }, diff --git a/packages/trpc/server/createContext.ts b/packages/trpc/server/createContext.ts index 50bcc5fdaa..10bec69e98 100644 --- a/packages/trpc/server/createContext.ts +++ b/packages/trpc/server/createContext.ts @@ -75,6 +75,7 @@ async function getUserFromSession({ timeFormat: true, trialEndsAt: true, metadata: true, + role: true, }, }); diff --git a/packages/trpc/server/routers/viewer.tsx b/packages/trpc/server/routers/viewer.tsx index 42b6488fc0..0f68d751dd 100644 --- a/packages/trpc/server/routers/viewer.tsx +++ b/packages/trpc/server/routers/viewer.tsx @@ -17,6 +17,7 @@ import dayjs from "@calcom/dayjs"; import { sendCancelledEmails, sendFeedbackEmail } from "@calcom/emails"; import { samlTenantProduct } from "@calcom/features/ee/sso/lib/saml"; import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib"; +import getEnabledApps from "@calcom/lib/apps/getEnabledApps"; import { ErrorCode, verifyPassword } from "@calcom/lib/auth"; import { CAL_URL } from "@calcom/lib/constants"; import { symmetricDecrypt } from "@calcom/lib/crypto"; @@ -37,6 +38,7 @@ import { TRPCError } from "@trpc/server"; import { authedProcedure, mergeRouters, publicProcedure, router } from "../trpc"; import { apiKeysRouter } from "./viewer/apiKeys"; +import { appsRouter } from "./viewer/apps"; import { authRouter } from "./viewer/auth"; import { availabilityRouter } from "./viewer/availability"; import { bookingsRouter } from "./viewer/bookings"; @@ -299,9 +301,19 @@ const loggedInViewerRouter = router({ }); }), connectedCalendars: authedProcedure.query(async ({ ctx }) => { - const { user } = ctx; + const { user, prisma } = ctx; + + const userCredentials = await prisma.credential.findMany({ + where: { + app: { + categories: { has: AppCategories.calendar }, + enabled: true, + }, + }, + }); + // get user's credentials + their connected integrations - const calendarCredentials = getCalendarCredentials(user.credentials); + const calendarCredentials = getCalendarCredentials(userCredentials); // get all the connected integrations' calendars (from third party) const connectedCalendars = await getConnectedCalendars(calendarCredentials, user.selectedCalendars); @@ -412,7 +424,10 @@ const loggedInViewerRouter = router({ const { user } = ctx; const { variant, exclude, onlyInstalled } = input; const { credentials } = user; - let apps = getApps(credentials).map( + + const enabledApps = await getEnabledApps(credentials); + + let apps = enabledApps.map( ({ credentials: _, credential: _1 /* don't leak to frontend */, ...app }) => { const credentialIds = credentials.filter((c) => c.type === app.type).map((c) => c.id); const invalidCredentialIds = credentials @@ -426,19 +441,22 @@ const loggedInViewerRouter = router({ } ); - if (exclude) { - // exclusion filter - apps = apps.filter((item) => (exclude ? !exclude.includes(item.variant) : true)); - } if (variant) { // `flatMap()` these work like `.filter()` but infers the types correctly apps = apps // variant check .flatMap((item) => (item.variant.startsWith(variant) ? [item] : [])); } + + if (exclude) { + // exclusion filter + apps = apps.filter((item) => (exclude ? !exclude.includes(item.variant) : true)); + } + if (onlyInstalled) { apps = apps.flatMap((item) => (item.credentialIds.length > 0 || item.isGlobal ? [item] : [])); } + return { items: apps, }; @@ -476,12 +494,12 @@ const loggedInViewerRouter = router({ const { user } = ctx; const { credentials } = user; - const apps = getApps(credentials); + const apps = await getEnabledApps(credentials); return apps .filter((app) => app.extendsFeature?.includes(input.extendsFeature)) .map((app) => ({ ...app, - isInstalled: !!app.credentials.length, + isInstalled: !!app.credentials?.length, })); }), appCredentialsByType: authedProcedure @@ -781,9 +799,11 @@ const loggedInViewerRouter = router({ key: true, userId: true, appId: true, + invalid: true, }, }); - const integrations = getApps(credentials); + + const integrations = await getEnabledApps(credentials); const t = await getTranslation(ctx.user.locale ?? "en", "common"); @@ -1143,5 +1163,6 @@ export const viewerRouter = mergeRouters( // After that there would just one merge call here for all the apps. appRoutingForms: app_RoutingForms, eth: ethRouter, + appsRouter, }) ); diff --git a/packages/trpc/server/routers/viewer/apps.tsx b/packages/trpc/server/routers/viewer/apps.tsx new file mode 100644 index 0000000000..f6097950dc --- /dev/null +++ b/packages/trpc/server/routers/viewer/apps.tsx @@ -0,0 +1,271 @@ +import { AppCategories } from "@prisma/client"; +import type { Prisma } from "@prisma/client"; +import z from "zod"; + +import { appKeysSchemas } from "@calcom/app-store/apps.keys-schemas.generated"; +import { getLocalAppMetadata } from "@calcom/app-store/utils"; +import { sendDisabledAppEmail } from "@calcom/emails"; +import { deriveAppDictKeyFromType } from "@calcom/lib/deriveAppDictKeyFromType"; +import { getTranslation } from "@calcom/lib/server/i18n"; + +import { TRPCError } from "@trpc/server"; + +import { authedAdminProcedure, router } from "../../trpc"; + +interface FilteredApp { + name: string; + slug: string; + logo: string; + title?: string; + type: string; + description: string; + dirName: string; + keys: Prisma.JsonObject | null; + enabled: boolean; +} + +export const appsRouter = router({ + listLocal: authedAdminProcedure + .input( + z.object({ + category: z.nativeEnum({ ...AppCategories, conferencing: "conferencing" }), + }) + ) + .query(async ({ ctx, input }) => { + const category = input.category === "conferencing" ? "video" : input.category; + const localApps = getLocalAppMetadata().filter( + (app) => app.categories?.some((appCategory) => appCategory === category) || app.category === category + ); + + const dbApps = await ctx.prisma.app.findMany({ + where: { + categories: { + has: AppCategories[category as keyof typeof AppCategories], + }, + }, + select: { + slug: true, + keys: true, + enabled: true, + dirName: true, + }, + }); + + const filteredApps: FilteredApp[] = []; + + for (const app of localApps) { + // Find app metadata + const dbData = dbApps.find((dbApp) => dbApp.slug === app.slug); + + // If the app already contains keys then return + if (dbData?.keys) { + filteredApps.push({ + name: app.name, + slug: app.slug, + logo: app.logo, + title: app.title, + type: app.type, + description: app.description, + // We know that keys are going to be an object or null. Prisma can not type check against JSON fields + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore + keys: dbData.keys, + dirName: app.dirName || app.slug, + enabled: dbData?.enabled || false, + }); + } else { + const keysSchema = appKeysSchemas[app.dirName as keyof typeof appKeysSchemas]; + + const keys: Record = {}; + + if (typeof keysSchema !== "undefined") { + Object.values(keysSchema.keyof()._def.values).reduce((keysObject, key) => { + keys[key as string] = ""; + return keysObject; + }, {} as Record); + } + + filteredApps.push({ + name: app.name, + slug: app.slug, + logo: app.logo, + type: app.type, + title: app.title, + description: app.description, + enabled: dbData?.enabled || false, + dirName: app.dirName || app.slug, + keys: Object.keys(keys).length === 0 ? null : keys, + }); + } + } + + return filteredApps; + }), + toggle: authedAdminProcedure + .input( + z.object({ + slug: z.string(), + enabled: z.boolean(), + }) + ) + .mutation(async ({ ctx, input }) => { + const { prisma } = ctx; + + // Get app name from metadata + const localApps = getLocalAppMetadata(); + const appMetadata = localApps.find((localApp) => localApp.slug === input.slug); + + if (!appMetadata) + throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "App metadata could not be found" }); + + const app = await prisma.app.upsert({ + where: { + slug: input.slug, + }, + update: { + enabled: !input.enabled, + }, + create: { + slug: input.slug, + dirName: appMetadata?.dirName || "", + categories: + (appMetadata?.categories as AppCategories[]) || + ([appMetadata?.category] as AppCategories[]) || + undefined, + keys: undefined, + }, + }); + + // If disabling an app then we need to alert users basesd on the app type + if (input.enabled) { + if (app.categories.some((category) => ["calendar", "video"].includes(category))) { + // Find all users with the app credentials + const appCredentials = await prisma.credential.findMany({ + where: { + appId: app.slug, + }, + select: { + user: { + select: { + email: true, + locale: true, + }, + }, + }, + }); + + Promise.all( + appCredentials.map(async (credential) => { + const t = await getTranslation(credential.user?.locale || "en", "common"); + + if (credential.user?.email) { + await sendDisabledAppEmail({ + email: credential.user.email, + appName: appMetadata?.name || app.slug, + appType: app.categories, + t, + }); + } + }) + ); + } else { + const eventTypesWithApp = await prisma.eventType.findMany({ + where: { + metadata: { + path: ["apps", app.slug as string, "enabled"], + equals: true, + }, + }, + select: { + id: true, + title: true, + users: { + select: { + email: true, + locale: true, + }, + }, + metadata: true, + }, + }); + + Promise.all( + eventTypesWithApp.map(async (eventType) => { + await prisma.eventType.update({ + where: { + id: eventType.id, + }, + data: { + metadata: { + ...(eventType.metadata as object), + apps: { + // From this comment we can not type JSON fields in Prisma https://github.com/prisma/prisma/issues/3219#issuecomment-670202980 + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore + ...eventType.metadata?.apps, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore + [app.slug]: { ...eventType.metadata?.apps[app.slug], enabled: false }, + }, + }, + }, + }); + + eventType.users.map(async (user) => { + const t = await getTranslation(user.locale || "en", "common"); + + await sendDisabledAppEmail({ + email: user.email, + appName: appMetadata?.name || app.slug, + appType: app.categories, + t, + title: eventType.title, + eventTypeId: eventType.id, + }); + }); + }) + ); + } + } + + return app.enabled; + }), + saveKeys: authedAdminProcedure + .input( + z.object({ + slug: z.string(), + dirName: z.string(), + type: z.string(), + // Validate w/ app specific schema + keys: z.unknown(), + }) + ) + .mutation(async ({ ctx, input }) => { + const appKey = deriveAppDictKeyFromType(input.type, appKeysSchemas); + const keysSchema = appKeysSchemas[appKey as keyof typeof appKeysSchemas]; + const keys = keysSchema.parse(input.keys); + + // Get app name from metadata + const localApps = getLocalAppMetadata(); + const appMetadata = localApps.find((localApp) => localApp.slug === input.slug); + + if (!appMetadata?.dirName && appMetadata?.categories) + throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "App metadata could not be found" }); + + await ctx.prisma.app.upsert({ + where: { + slug: input.slug, + }, + update: { keys }, + create: { + slug: input.slug, + dirName: appMetadata?.dirName || "", + categories: + (appMetadata?.categories as AppCategories[]) || + ([appMetadata?.category] as AppCategories[]) || + undefined, + keys: (input.keys as Prisma.InputJsonObject) || undefined, + }, + }); + }), +}); diff --git a/packages/trpc/server/trpc.ts b/packages/trpc/server/trpc.ts index 54e59282d0..dafa1da90b 100644 --- a/packages/trpc/server/trpc.ts +++ b/packages/trpc/server/trpc.ts @@ -29,8 +29,22 @@ const isAuthedMiddleware = t.middleware(({ ctx, next }) => { }); }); +const isAdminMiddleware = t.middleware(({ ctx, next }) => { + if (!ctx.user || !ctx.session || ctx.user.role !== "ADMIN") { + throw new TRPCError({ code: "UNAUTHORIZED" }); + } + return next({ + ctx: { + // infers that `user` and `session` are non-nullable to downstream procedures + session: ctx.session, + user: ctx.user, + }, + }); +}); + export const router = t.router; export const mergeRouters = t.mergeRouters; export const middleware = t.middleware; export const publicProcedure = t.procedure.use(perfMiddleware); export const authedProcedure = t.procedure.use(perfMiddleware).use(isAuthedMiddleware); +export const authedAdminProcedure = t.procedure.use(perfMiddleware).use(isAdminMiddleware); diff --git a/packages/types/App.d.ts b/packages/types/App.d.ts index d326dc5dcd..6c22c0fd29 100644 --- a/packages/types/App.d.ts +++ b/packages/types/App.d.ts @@ -135,6 +135,7 @@ export interface App { licenseRequired?: boolean; isProOnly?: boolean; appData?: EventLocationAppData; + dirName?: string; } export type AppMeta = Optional; diff --git a/packages/types/Credential.d.ts b/packages/types/Credential.d.ts index dc9b9d2963..bd357fe32f 100644 --- a/packages/types/Credential.d.ts +++ b/packages/types/Credential.d.ts @@ -12,6 +12,7 @@ export type CredentialPayload = Prisma.CredentialGetPayload<{ type: true; userId: true; key: true; + invalid: true; }; }>; diff --git a/packages/ui/components/form/inputs/Input.tsx b/packages/ui/components/form/inputs/Input.tsx index db5158e830..d79c642c30 100644 --- a/packages/ui/components/form/inputs/Input.tsx +++ b/packages/ui/components/form/inputs/Input.tsx @@ -1,5 +1,5 @@ import React, { forwardRef, ReactElement, ReactNode, Ref, useCallback, useId, useState } from "react"; -import { Eye, EyeOff } from "react-feather"; +import { Edit2, Eye, EyeOff } from "react-feather"; import { FieldValues, FormProvider, SubmitHandler, useFormContext, UseFormReturn } from "react-hook-form"; import classNames from "@calcom/lib/classNames"; @@ -96,6 +96,7 @@ export const InputField = forwardRef(function hintErrors, labelSrOnly, containerClassName, + readOnly, // eslint-disable-next-line @typescript-eslint/no-unused-vars t: __t, ...passThrough @@ -142,6 +143,8 @@ export const InputField = forwardRef(function type === "search" && "pr-8", "!my-0 !ring-0" )} + {...passThrough} + readOnly={readOnly} ref={ref} /> {addOnSuffix && ( diff --git a/packages/ui/v2/core/EmptyScreen.tsx b/packages/ui/v2/core/EmptyScreen.tsx index 99edf9bed0..07962a6cf6 100644 --- a/packages/ui/v2/core/EmptyScreen.tsx +++ b/packages/ui/v2/core/EmptyScreen.tsx @@ -30,7 +30,7 @@ export default function EmptyScreen({
-

{headline}

+

{headline}

{description}
diff --git a/packages/ui/v2/core/Stepper.tsx b/packages/ui/v2/core/Stepper.tsx index 4e51d7752d..d82e0a8505 100644 --- a/packages/ui/v2/core/Stepper.tsx +++ b/packages/ui/v2/core/Stepper.tsx @@ -4,7 +4,12 @@ type DefaultStep = { title: string; }; -function Stepper(props: { href: string; step: number; steps: T[] }) { +function Stepper(props: { + href: string; + step: number; + steps: T[]; + disableSteps?: boolean; +}) { const { href, steps } = props; return ( <> @@ -16,7 +21,7 @@ function Stepper(props: { href: string; step: number; ste
    {steps.map((mapStep, index) => (
  1. - + {index + 1 < props.step ? ( {mapStep.title} diff --git a/packages/ui/v2/core/WizardForm.tsx b/packages/ui/v2/core/WizardForm.tsx index 4c10f09bc9..f54bdefa0c 100644 --- a/packages/ui/v2/core/WizardForm.tsx +++ b/packages/ui/v2/core/WizardForm.tsx @@ -12,7 +12,12 @@ type DefaultStep = { isLoading: boolean; }; -function WizardForm(props: { href: string; steps: T[]; containerClassname?: string }) { +function WizardForm(props: { + href: string; + steps: T[]; + disableNavigation?: boolean; + containerClassname?: string; +}) { const { href, steps } = props; const router = useRouter(); const step = parseInt((router.query.step as string) || "1"); @@ -22,7 +27,7 @@ function WizardForm(props: { href: string; steps: T[]; co }; return ( -
    +
    {/* eslint-disable-next-line @next/next/no-img-element */} Cal.com Logo
    (props: { href: string; steps: T[]; co

    {currentStep.title}

    {currentStep.description}

    -
    {currentStep.content}
    - {currentStep.enabled !== false && ( -
    - {step > 1 && ( - - )} - -
    +
    {currentStep.content}
    + {!props.disableNavigation && ( + <> + {currentStep.enabled !== false && ( +
    + {step > 1 && ( + + )} + + +
    + )} + )}
    -
    - -
    + {!props.disableNavigation && ( +
    + +
    + )}
    ); } diff --git a/packages/ui/v2/core/layouts/AdminLayout.tsx b/packages/ui/v2/core/layouts/AdminLayout.tsx index 1dcd4b9208..357694026c 100644 --- a/packages/ui/v2/core/layouts/AdminLayout.tsx +++ b/packages/ui/v2/core/layouts/AdminLayout.tsx @@ -23,7 +23,7 @@ export default function AdminLayout({ return ( -
    +
    {children}
    diff --git a/packages/ui/v2/core/layouts/AppsLayout.tsx b/packages/ui/v2/core/layouts/AppsLayout.tsx index f357e5d589..9101a5b992 100644 --- a/packages/ui/v2/core/layouts/AppsLayout.tsx +++ b/packages/ui/v2/core/layouts/AppsLayout.tsx @@ -1,5 +1,10 @@ +import { useSession } from "next-auth/react"; +import { useRouter } from "next/router"; import React, { ComponentProps } from "react"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { EmptyScreen, Icon } from "@calcom/ui"; + import Shell from "../Shell"; import type { HorizontalTabItemProps } from "../navigation/tabs/HorizontalTabItem"; import HorizontalTabs from "../navigation/tabs/HorizontalTabs"; @@ -18,16 +23,35 @@ const tabs: HorizontalTabItemProps[] = [ type AppsLayoutProps = { children: React.ReactNode; actions?: (className?: string) => JSX.Element; + emptyStore?: boolean; } & Omit, "actions">; -export default function AppsLayout({ children, actions, ...rest }: AppsLayoutProps) { +export default function AppsLayout({ children, actions, emptyStore, ...rest }: AppsLayoutProps) { + const { t } = useLocale(); + const session = useSession(); + const router = useRouter(); + + if (session.status === "loading") return <>; + return (
    -
    {children}
    +
    + {emptyStore ? ( + router.push("/settings/admin/apps/calendar")} + /> + ) : ( + <>{children} + )} +
    ); diff --git a/packages/ui/v2/core/layouts/InstalledAppsLayout.tsx b/packages/ui/v2/core/layouts/InstalledAppsLayout.tsx index 4d16811fa8..6981e1a48a 100644 --- a/packages/ui/v2/core/layouts/InstalledAppsLayout.tsx +++ b/packages/ui/v2/core/layouts/InstalledAppsLayout.tsx @@ -1,14 +1,13 @@ import React, { ComponentProps } from "react"; +import AppCategoryNavigation from "@calcom/app-store/_components/AppCategoryNavigation"; import { InstalledAppVariants } from "@calcom/app-store/utils"; import { trpc } from "@calcom/trpc/react"; import { Icon } from "../../../Icon"; import Shell from "../Shell"; import type { HorizontalTabItemProps } from "../navigation/tabs/HorizontalTabItem"; -import HorizontalTabs from "../navigation/tabs/HorizontalTabs"; import type { VerticalTabItemProps } from "../navigation/tabs/VerticalTabItem"; -import VerticalTabs from "../navigation/tabs/VerticalTabs"; const tabs: (VerticalTabItemProps | HorizontalTabItemProps)[] = [ { @@ -57,15 +56,9 @@ export default function InstalledAppsLayout({ } return ( -
    -
    - -
    -
    - -
    -
    {children}
    -
    + + {children} +
    ); } diff --git a/packages/ui/v2/core/layouts/SettingsLayout.tsx b/packages/ui/v2/core/layouts/SettingsLayout.tsx index 84c06b50f4..c71299b38d 100644 --- a/packages/ui/v2/core/layouts/SettingsLayout.tsx +++ b/packages/ui/v2/core/layouts/SettingsLayout.tsx @@ -73,7 +73,7 @@ const tabs: VerticalTabItemProps[] = [ children: [ // { name: "impersonation", href: "/settings/admin/impersonation" }, - { name: "apps", href: "/settings/admin/apps" }, + { name: "apps", href: "/settings/admin/apps/calendar" }, { name: "users", href: "/settings/admin/users" }, ], }, @@ -141,7 +141,7 @@ const SettingsSidebarContainer = ({ className = "" }) => { return tab.name !== "teams" ? (
    -
    +
    {tab && tab.icon && ( )}