diff --git a/apps/web/components/AppListCard.tsx b/apps/web/components/AppListCard.tsx index 3887e29be4..51cc21592d 100644 --- a/apps/web/components/AppListCard.tsx +++ b/apps/web/components/AppListCard.tsx @@ -3,9 +3,10 @@ import type { ReactNode } from "react"; import { useEffect, useRef, useState } from "react"; import { z } from "zod"; +import type { CredentialOwner } from "@calcom/app-store/types"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useTypedQuery } from "@calcom/lib/hooks/useTypedQuery"; -import { Badge, ListItemText } from "@calcom/ui"; +import { Badge, ListItemText, Avatar } from "@calcom/ui"; import { AlertCircle } from "@calcom/ui/components/icon"; type ShouldHighlight = { slug: string; shouldHighlight: true } | { shouldHighlight?: never; slug?: never }; @@ -19,6 +20,7 @@ type AppListCardProps = { isTemplate?: boolean; invalidCredential?: boolean; children?: ReactNode; + credentialOwner?: CredentialOwner; } & ShouldHighlight; const schema = z.object({ hl: z.string().optional() }); @@ -36,6 +38,7 @@ export default function AppListCard(props: AppListCardProps) { isTemplate, invalidCredential, children, + credentialOwner, } = props; const { data: { hl }, @@ -65,7 +68,7 @@ export default function AppListCard(props: AppListCardProps) { return (
-
+
{logo ? {`${title} : null}
@@ -85,6 +88,21 @@ export default function AppListCard(props: AppListCardProps) {
)}
+ {credentialOwner && ( +
+ +
+ + {credentialOwner.name} +
+
+
+ )} {actions}
diff --git a/apps/web/components/apps/App.tsx b/apps/web/components/apps/App.tsx index 838383e398..6f63ab15d7 100644 --- a/apps/web/components/apps/App.tsx +++ b/apps/web/components/apps/App.tsx @@ -1,18 +1,33 @@ import Link from "next/link"; import type { IframeHTMLAttributes } from "react"; -import React, { useState } from "react"; +import React, { useState, useEffect } from "react"; +import { useRouter } from "next/router"; +import { CAL_URL } from "@calcom/lib/constants"; +import type { RouterOutputs } from "@calcom/trpc/react"; import useAddAppMutation from "@calcom/app-store/_utils/useAddAppMutation"; import { InstallAppButton, AppDependencyComponent } from "@calcom/app-store/components"; import DisconnectIntegration from "@calcom/features/apps/components/DisconnectIntegration"; import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired"; +import type { UserAdminTeams } from "@calcom/features/ee/teams/lib/getUserAdminTeams"; import Shell from "@calcom/features/shell/Shell"; import classNames from "@calcom/lib/classNames"; import { APP_NAME, COMPANY_NAME, SUPPORT_MAIL_ADDRESS } from "@calcom/lib/constants"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { trpc } from "@calcom/trpc/react"; import type { App as AppType } from "@calcom/types/App"; +import type { ButtonProps } from "@calcom/ui"; +import type { AppFrontendPayload as App } from "@calcom/types/App"; import { Button, showToast, SkeletonButton, SkeletonText, HeadSeo, Badge } from "@calcom/ui"; +import { + Dropdown, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuPortal, + DropdownMenuLabel, + DropdownItem, + Avatar, +} from "@calcom/ui"; import { BookOpen, Check, ExternalLink, File, Flag, Mail, Plus, Shield } from "@calcom/ui/components/icon"; /* These app slugs all require Google Cal to be installed */ @@ -60,11 +75,14 @@ const Component = ({ }).format(price); const [existingCredentials, setExistingCredentials] = useState([]); - const appCredentials = trpc.viewer.appCredentialsByType.useQuery( + const [showDisconnectIntegration, setShowDisconnectIntegration] = useState(false); + const appDbQuery = trpc.viewer.appCredentialsByType.useQuery( { appType: type }, { - onSuccess(data) { - setExistingCredentials(data); + onSettled(data) { + const credentialsCount = data?.credentials.length || 0 + setShowDisconnectIntegration(data?.userAdminTeams.length ? credentialsCount >= data?.userAdminTeams.length : credentialsCount > 0); + setExistingCredentials(data?.credentials.map(credential => credential.id) || []); }, } ); @@ -141,7 +159,7 @@ const Component = ({ )}
- {!appCredentials.isLoading ? ( + {!appDbQuery.isLoading ? ( isGlobal || (existingCredentials.length > 0 && allowedMultipleInstalls ? (
@@ -166,28 +184,19 @@ const Component = ({ }; } return ( - + ); }} /> )}
- ) : existingCredentials.length > 0 ? ( + ) : showDisconnectIntegration ? ( { - appCredentials.refetch(); + appDbQuery.refetch(); }} /> ) : ( @@ -206,15 +215,7 @@ const Component = ({ }; } return ( - + ); }} /> @@ -385,3 +386,98 @@ export default function App(props: { ); } + +const InstallAppButtonChild = ({ + userAdminTeams, + addAppMutationInput, + appCategories, + multiInstall, + credentials, + ...props +}: { + userAdminTeams?: UserAdminTeams; + addAppMutationInput: { type: App["type"]; variant: string; slug: string }; + appCategories: string[]; + multiInstall?: boolean; + credentials?: RouterOutputs["viewer"]["appCredentialsByType"]["credentials"]; +} & ButtonProps) => { + const { t } = useLocale(); + const router = useRouter(); + + const mutation = useAddAppMutation(null, { + onSuccess: (data) => { + if (data?.setupPending) return; + showToast(t("app_successfully_installed"), "success"); + }, + onError: (error) => { + if (error instanceof Error) showToast(error.message || t("app_could_not_be_installed"), "error"); + }, + }); + + if (!userAdminTeams?.length || appCategories.some((category) => category === "calendar")) { + return + + } + + return ( + + + + + + + {t("install_app_on")} + {userAdminTeams.map((team) => { + + const isInstalled = credentials && + credentials.some((credential) => + credential?.teamId ? credential?.teamId === team.id : credential.userId === team.id + ) + + return ( + ( + + )} + onClick={() => { + mutation.mutate( + team.isUser ? addAppMutationInput : { ...addAppMutationInput, teamId: team.id } + ); + }}> +

{team.name}{" "} + {isInstalled && + `(${t("installed")})`}

+
+)})} +
+
+
+ ); +}; diff --git a/apps/web/components/dialog/EditLocationDialog.tsx b/apps/web/components/dialog/EditLocationDialog.tsx index ba5f16b353..bb2ce3fc5d 100644 --- a/apps/web/components/dialog/EditLocationDialog.tsx +++ b/apps/web/components/dialog/EditLocationDialog.tsx @@ -34,12 +34,12 @@ interface ISetLocationDialog { saveLocation: (newLocationType: EventLocationType["type"], details: { [key: string]: string }) => void; selection?: LocationOption; booking?: BookingItem; - isTeamEvent?: boolean; defaultValues?: LocationObject[]; setShowLocationModal: React.Dispatch>; isOpenDialog: boolean; setSelectedLocation?: (param: LocationOption | undefined) => void; setEditingLocationType?: (param: string) => void; + teamId?: number; } const LocationInput = (props: { @@ -79,15 +79,15 @@ export const EditLocationDialog = (props: ISetLocationDialog) => { saveLocation, selection, booking, - isTeamEvent, setShowLocationModal, isOpenDialog, defaultValues, setSelectedLocation, setEditingLocationType, + teamId, } = props; const { t } = useLocale(); - const locationsQuery = trpc.viewer.locationOptions.useQuery(); + const locationsQuery = trpc.viewer.locationOptions.useQuery({ teamId }); useEffect(() => { if (selection) { @@ -103,6 +103,7 @@ export const EditLocationDialog = (props: ISetLocationDialog) => { locationType: z.string(), phone: z.string().optional().nullable(), locationAddress: z.string().optional(), + credentialId: z.number().optional(), locationLink: z .string() .optional() @@ -296,6 +297,9 @@ export const EditLocationDialog = (props: ISetLocationDialog) => { }; } + if (values.credentialId) { + details = { ...details, credentialId: values.credentialId }; + } saveLocation(newLocation, details); setShowLocationModal(false); setSelectedLocation?.(undefined); @@ -311,7 +315,7 @@ export const EditLocationDialog = (props: ISetLocationDialog) => { success={({ data }) => { if (!data.length) return null; const locationOptions = [...data].map((option) => { - if (isTeamEvent) { + if (teamId) { // Let host's Default conferencing App option show for Team Event return option; } diff --git a/apps/web/components/eventtype/EventAppsTab.tsx b/apps/web/components/eventtype/EventAppsTab.tsx index b036e7bf3d..d42ab0a04e 100644 --- a/apps/web/components/eventtype/EventAppsTab.tsx +++ b/apps/web/components/eventtype/EventAppsTab.tsx @@ -16,12 +16,16 @@ export type EventType = Pick["eventType"] & export const EventAppsTab = ({ eventType }: { eventType: EventType }) => { const { t } = useLocale(); - const { data: eventTypeApps, isLoading } = trpc.viewer.apps.useQuery({ + const { data: eventTypeApps, isLoading } = trpc.viewer.integrations.useQuery({ extendsFeature: "EventType", + teamId: eventType.team?.id || eventType.parent?.teamId, }); + const methods = useFormContext(); - const installedApps = eventTypeApps?.filter((app) => app.credentials.length); - const notInstalledApps = eventTypeApps?.filter((app) => !app.credentials.length); + const installedApps = + eventTypeApps?.items.filter((app) => app.userCredentialIds.length || app.teams.length) || []; + const notInstalledApps = + eventTypeApps?.items.filter((app) => !app.userCredentialIds.length && !app.teams.length) || []; const allAppsData = methods.watch("metadata")?.apps || {}; const setAllAppsData = (_allAppsData: typeof allAppsData) => { @@ -62,11 +66,54 @@ export const EventAppsTab = ({ eventType }: { eventType: EventType }) => { t("locked_fields_member_description") ); + const appsWithTeamCredentials = eventTypeApps?.items.filter((app) => app.teams.length) || []; + const cardsForAppsWithTeams = appsWithTeamCredentials.map((app) => { + const appCards = []; + + if (app.userCredentialIds.length) { + appCards.push( + + ); + } + + for (const team of app.teams) { + if (team) { + appCards.push( + + ); + } + } + return appCards; + }); + return ( <>
- {!installedApps?.length && isManagedEventType && ( + {isManagedEventType && ( { } /> ) : null} - {installedApps?.map((app) => ( - - ))} + {cardsForAppsWithTeams.map((apps) => apps.map((cards) => cards))} + {installedApps.map((app) => { + if (!app.teams.length) + return ( + + ); + })}
{!shouldLockDisableProps("apps").disabled && ( diff --git a/apps/web/components/eventtype/EventSetupTab.tsx b/apps/web/components/eventtype/EventSetupTab.tsx index 17382ba553..009957b52c 100644 --- a/apps/web/components/eventtype/EventSetupTab.tsx +++ b/apps/web/components/eventtype/EventSetupTab.tsx @@ -530,7 +530,6 @@ export const EventSetupTab = ( {/* We portal this modal so we can submit the form inside. Otherwise we get issues submitting two forms at once */}
); diff --git a/apps/web/components/eventtype/EventTypeSingleLayout.tsx b/apps/web/components/eventtype/EventTypeSingleLayout.tsx index e7721cbecc..c989ea01d0 100644 --- a/apps/web/components/eventtype/EventTypeSingleLayout.tsx +++ b/apps/web/components/eventtype/EventTypeSingleLayout.tsx @@ -210,7 +210,7 @@ function EventTypeSingleLayout({ } if (isManagedEventType || isChildrenManagedEventType) { // Removing apps and workflows for manageg event types by admins v1 - navigation.splice(-2, 1); + navigation.splice(0, 1); } else { navigation.push({ name: "webhooks", diff --git a/apps/web/components/getting-started/steps-views/ConnectedVideoStep.tsx b/apps/web/components/getting-started/steps-views/ConnectedVideoStep.tsx index e00a7e36a2..46108e9553 100644 --- a/apps/web/components/getting-started/steps-views/ConnectedVideoStep.tsx +++ b/apps/web/components/getting-started/steps-views/ConnectedVideoStep.tsx @@ -20,7 +20,7 @@ const ConnectedVideoStep = (props: ConnectedAppStepProps) => { const { t } = useLocale(); const hasAnyInstalledVideoApps = queryConnectedVideoApps?.items.some( - (item) => item.credentialIds.length > 0 + (item) => item.userCredentialIds.length > 0 ); return ( @@ -38,7 +38,7 @@ const ConnectedVideoStep = (props: ConnectedAppStepProps) => { title={item.name} description={item.description} logo={item.logo} - installed={item.credentialIds.length > 0} + installed={item.userCredentialIds.length > 0} /> )} diff --git a/apps/web/components/ui/form/LocationSelect.tsx b/apps/web/components/ui/form/LocationSelect.tsx index 95f1a7eebe..c435430617 100644 --- a/apps/web/components/ui/form/LocationSelect.tsx +++ b/apps/web/components/ui/form/LocationSelect.tsx @@ -40,11 +40,13 @@ export default function LocationSelect(props: Props ( - - - - ), + Option: (props) => { + return ( + + + + ); + }, SingleValue: (props) => ( diff --git a/apps/web/next.config.js b/apps/web/next.config.js index 73c0d38219..77ebb5d999 100644 --- a/apps/web/next.config.js +++ b/apps/web/next.config.js @@ -4,8 +4,17 @@ const os = require("os"); const englishTranslation = require("./public/static/locales/en/common.json"); const { withAxiom } = require("next-axiom"); const { i18n } = require("./next-i18next.config"); -const { pages } = require("./pages"); -const { getSubdomainRegExp } = require("./getSubdomainRegExp"); +const { + userTypeRoutePath, + teamTypeRoutePath, + privateLinkRoutePath, + embedUserTypeRoutePath, + embedTeamTypeRoutePath, + orgHostPath, + orgUserRoutePath, + orgUserTypeRoutePath, + orgUserTypeEmbedRoutePath, +} = require("./pagesAndRewritePaths"); if (!process.env.NEXTAUTH_SECRET) throw new Error("Please set NEXTAUTH_SECRET"); if (!process.env.CALENDSO_ENCRYPTION_KEY) throw new Error("Please set CALENDSO_ENCRYPTION_KEY"); @@ -81,52 +90,44 @@ if (process.env.ANALYZE === "true") { } plugins.push(withAxiom); - -// .* matches / as well(Note: *(i.e wildcard) doesn't match / but .*(i.e. RegExp) does) -// It would match /free/30min but not /bookings/upcoming because 'bookings' is an item in pages -// It would also not match /free/30min/embed because we are ensuring just two slashes -// ?!book ensures it doesn't match /free/book page which doesn't have a corresponding new-booker page. -// [^/]+ makes the RegExp match the full path, it seems like a partial match doesn't work. -// book$ ensures that only /book is excluded from rewrite(which is at the end always) and not /booked - -// Important Note: When modifying these RegExps update apps/web/test/lib/next-config.test.ts as well -const userTypeRouteRegExp = `/:user((?!${pages.join("/|")})[^/]*)/:type((?!book$)[^/]+)`; -const teamTypeRouteRegExp = "/team/:slug/:type((?!book$)[^/]+)"; -const privateLinkRouteRegExp = "/d/:link/:slug((?!book$)[^/]+)"; -const embedUserTypeRouteRegExp = `/:user((?!${pages.join("/|")})[^/]*)/:type/embed`; -const embedTeamTypeRouteRegExp = "/team/:slug/:type/embed"; -const subdomainRegExp = getSubdomainRegExp(process.env.NEXT_PUBLIC_WEBAPP_URL); -// Important Note: Do update the RegExp in apps/web/test/lib/next-config.test.ts when changing it. -const orgHostRegExp = `^(?${subdomainRegExp})\\..*`; - const matcherConfigRootPath = { has: [ { type: "host", - value: orgHostRegExp, + value: orgHostPath, }, ], source: "/", }; -const matcherConfigOrgMemberPath = { +const matcherConfigUserRoute = { has: [ { type: "host", - value: orgHostRegExp, + value: orgHostPath, }, ], - source: `/:user((?!${pages.join("|")}|_next|public)[a-zA-Z0-9\-_]+)`, + source: orgUserRoutePath, }; -const matcherConfigUserPath = { +const matcherConfigUserTypeRoute = { has: [ { type: "host", - value: `^(?${subdomainRegExp}[^.]+)\\..*`, + value: orgHostPath, }, ], - source: `/:user((?!${pages.join("|")}|_next|public))/:path*`, + source: orgUserTypeRoutePath, +}; + +const matcherConfigUserTypeEmbedRoute = { + has: [ + { + type: "host", + value: orgHostPath, + }, + ], + source: orgUserTypeEmbedRoutePath, }; /** @type {import("next").NextConfig} */ @@ -220,6 +221,7 @@ const nextConfig = { }, async rewrites() { const beforeFiles = [ + // These rewrites are other than booking pages rewrites and so that they aren't redirected to org pages ensure that they happen in beforeFiles ...(process.env.ORGANIZATIONS_ENABLED ? [ { @@ -227,12 +229,16 @@ const nextConfig = { destination: "/team/:orgSlug", }, { - ...matcherConfigOrgMemberPath, + ...matcherConfigUserRoute, destination: "/org/:orgSlug/:user", }, { - ...matcherConfigUserPath, - destination: "/:user/:path*", + ...matcherConfigUserTypeRoute, + destination: "/org/:orgSlug/:user/:type", + }, + { + ...matcherConfigUserTypeEmbedRoute, + destination: "/org/:orgSlug/:user/:type/embed", }, ] : []), @@ -243,64 +249,70 @@ const nextConfig = { source: "/org/:slug", destination: "/team/:slug", }, - { - source: "/:user/avatar.png", - destination: "/api/user/avatar?username=:user", - }, { source: "/team/:teamname/avatar.png", destination: "/api/user/avatar?teamname=:teamname", }, - { - source: "/forms/:formQuery*", - destination: "/apps/routing-forms/routing-link/:formQuery*", - }, - { - source: "/router", - destination: "/apps/routing-forms/router", - }, - { - source: "/success/:path*", - has: [ - { - type: "query", - key: "uid", - value: "(?.*)", - }, - ], - destination: "/booking/:uid/:path*", - }, - { - source: "/cancel/:path*", - destination: "/booking/:path*", - }, + + // When updating this also update pagesAndRewritePaths.js + ...[ + { + source: "/:user/avatar.png", + destination: "/api/user/avatar?username=:user", + }, + { + source: "/forms/:formQuery*", + destination: "/apps/routing-forms/routing-link/:formQuery*", + }, + { + source: "/router", + destination: "/apps/routing-forms/router", + }, + { + source: "/success/:path*", + has: [ + { + type: "query", + key: "uid", + value: "(?.*)", + }, + ], + destination: "/booking/:uid/:path*", + }, + { + source: "/cancel/:path*", + destination: "/booking/:path*", + }, + ], + // Keep cookie based booker enabled just in case we disable new-booker globally ...[ { - source: userTypeRouteRegExp, + source: userTypeRoutePath, destination: "/new-booker/:user/:type", has: [{ type: "cookie", key: "new-booker-enabled" }], }, { - source: teamTypeRouteRegExp, + source: teamTypeRoutePath, destination: "/new-booker/team/:slug/:type", has: [{ type: "cookie", key: "new-booker-enabled" }], }, { - source: privateLinkRouteRegExp, + source: privateLinkRoutePath, destination: "/new-booker/d/:link/:slug", has: [{ type: "cookie", key: "new-booker-enabled" }], }, ], + // Keep cookie based booker enabled to test new-booker embed in production ...[ { - source: embedUserTypeRouteRegExp, + source: embedUserTypeRoutePath, destination: "/new-booker/:user/:type/embed", has: [{ type: "cookie", key: "new-booker-enabled" }], }, { - source: embedTeamTypeRouteRegExp, + source: embedTeamTypeRoutePath, destination: "/new-booker/team/:slug/:type/embed", has: [{ type: "cookie", key: "new-booker-enabled" }], }, @@ -321,11 +333,11 @@ const nextConfig = { afterFiles.push( ...[ { - source: embedUserTypeRouteRegExp, + source: embedUserTypeRoutePath, destination: "/new-booker/:user/:type/embed", }, { - source: embedTeamTypeRouteRegExp, + source: embedTeamTypeRoutePath, destination: "/new-booker/team/:slug/:type/embed", }, ] @@ -338,15 +350,15 @@ const nextConfig = { afterFiles.push( ...[ { - source: userTypeRouteRegExp, + source: userTypeRoutePath, destination: "/new-booker/:user/:type", }, { - source: teamTypeRouteRegExp, + source: teamTypeRoutePath, destination: "/new-booker/team/:slug/:type", }, { - source: privateLinkRouteRegExp, + source: privateLinkRoutePath, destination: "/new-booker/d/:link/:slug", }, ] @@ -401,7 +413,7 @@ const nextConfig = { ], }, { - ...matcherConfigOrgMemberPath, + ...matcherConfigUserRoute, headers: [ { key: "X-Cal-Org-path", @@ -410,11 +422,20 @@ const nextConfig = { ], }, { - ...matcherConfigUserPath, + ...matcherConfigUserTypeRoute, headers: [ { key: "X-Cal-Org-path", - value: "/:user/:path", + value: "/org/:orgSlug/:user/:type", + }, + ], + }, + { + ...matcherConfigUserTypeEmbedRoute, + headers: [ + { + key: "X-Cal-Org-path", + value: "/org/:orgSlug/:user/:type/embed", }, ], }, diff --git a/apps/web/pages.js b/apps/web/pages.js deleted file mode 100644 index 3d886afdd5..0000000000 --- a/apps/web/pages.js +++ /dev/null @@ -1,14 +0,0 @@ -const glob = require("glob"); - -/** Needed to rewrite public booking page, gets all static pages but [user] */ -const pages = glob - .sync("pages/**/[^_]*.{tsx,js,ts}", { cwd: __dirname }) - .map((filename) => - filename - .substr(6) - .replace(/(\.tsx|\.js|\.ts)/, "") - .replace(/\/.*/, "") - ) - .filter((v, i, self) => self.indexOf(v) === i && !v.startsWith("[user]")); - -exports.pages = pages; diff --git a/apps/web/pages/api/integrations/[...args].ts b/apps/web/pages/api/integrations/[...args].ts index 9ecd9e1b53..94fdfadc02 100644 --- a/apps/web/pages/api/integrations/[...args].ts +++ b/apps/web/pages/api/integrations/[...args].ts @@ -13,12 +13,14 @@ const defaultIntegrationAddHandler = async ({ supportsMultipleInstalls, appType, user, + teamId = undefined, createCredential, }: { slug: string; supportsMultipleInstalls: boolean; appType: string; user?: Session["user"]; + teamId?: number; createCredential: AppDeclarativeHandler["createCredential"]; }) => { if (!user?.id) { @@ -28,21 +30,21 @@ const defaultIntegrationAddHandler = async ({ const alreadyInstalled = await prisma.credential.findFirst({ where: { appId: slug, - userId: user.id, + ...(teamId ? { AND: [{ userId: user.id }, { teamId }] } : { userId: user.id }), }, }); if (alreadyInstalled) { throw new Error("App is already installed"); } } - await createCredential({ user: user, appType, slug }); + await createCredential({ user: user, appType, slug, teamId }); }; const handler = async (req: NextApiRequest, res: NextApiResponse) => { // Check that user is authenticated req.session = await getServerSession({ req, res }); - const { args } = req.query; + const { args, teamId } = req.query; if (!Array.isArray(args)) { return res.status(404).json({ message: `API route not found` }); @@ -62,7 +64,7 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => { if (typeof handler === "function") { await handler(req, res); } else { - await defaultIntegrationAddHandler({ user: req.session?.user, ...handler }); + await defaultIntegrationAddHandler({ user: req.session?.user, teamId: Number(teamId), ...handler }); redirectUrl = handler.redirect?.url || getInstalledAppPath(handler); res.json({ url: redirectUrl, newTab: handler.redirect?.newTab }); } diff --git a/apps/web/pages/apps/index.tsx b/apps/web/pages/apps/index.tsx index 3253ce61d4..f5c24cbfee 100644 --- a/apps/web/pages/apps/index.tsx +++ b/apps/web/pages/apps/index.tsx @@ -4,6 +4,8 @@ import { useState } from "react"; import { getAppRegistry, getAppRegistryWithCredentials } from "@calcom/app-store/_appRegistry"; import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; +import getUserAdminTeams from "@calcom/features/ee/teams/lib/getUserAdminTeams"; +import type { UserAdminTeams } from "@calcom/features/ee/teams/lib/getUserAdminTeams"; import { classNames } from "@calcom/lib"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import type { AppCategories } from "@calcom/prisma/enums"; @@ -48,7 +50,11 @@ function AppsSearch({ ); } -export default function Apps({ categories, appStore }: inferSSRProps) { +export default function Apps({ + categories, + appStore, + userAdminTeams, +}: inferSSRProps) { const { t } = useLocale(); const [searchText, setSearchText] = useState(undefined); @@ -80,6 +86,7 @@ export default function Apps({ categories, appStore }: inferSSRProps category.name)} + userAdminTeams={userAdminTeams} /> @@ -95,11 +102,13 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => const session = await getServerSession({ req, res }); - let appStore; + let appStore, userAdminTeams: UserAdminTeams; if (session?.user?.id) { - appStore = await getAppRegistryWithCredentials(session.user.id); + userAdminTeams = await getUserAdminTeams({ userId: session.user.id, getUserInfo: true }); + appStore = await getAppRegistryWithCredentials(session.user.id, userAdminTeams); } else { appStore = await getAppRegistry(); + userAdminTeams = []; } const categoryQuery = appStore.map(({ categories }) => ({ @@ -111,6 +120,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => } return c; }, {} as Record); + return { props: { categories: Object.entries(categories) @@ -122,6 +132,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => return b.count - a.count; }), appStore, + userAdminTeams, trpcState: ssr.dehydrate(), }, }; diff --git a/apps/web/pages/apps/installed/[category].tsx b/apps/web/pages/apps/installed/[category].tsx index 03caec4c47..95e31390a9 100644 --- a/apps/web/pages/apps/installed/[category].tsx +++ b/apps/web/pages/apps/installed/[category].tsx @@ -6,6 +6,7 @@ import { AppSettings } from "@calcom/app-store/_components/AppSettings"; import { InstallAppButton } from "@calcom/app-store/components"; import type { EventLocationType } from "@calcom/app-store/locations"; import { getEventLocationTypeFromApp } from "@calcom/app-store/locations"; +import type { CredentialOwner } from "@calcom/app-store/types"; import { AppSetDefaultLinkDialog } from "@calcom/features/apps/components/AppSetDefaultLinkDialog"; import DisconnectIntegrationModal from "@calcom/features/apps/components/DisconnectIntegrationModal"; import { BulkEditDefaultConferencingModal } from "@calcom/features/eventtypes/components/BulkEditDefaultConferencingModal"; @@ -52,16 +53,16 @@ import { CalendarListContainer } from "@components/apps/CalendarListContainer"; import InstalledAppsLayout from "@components/apps/layouts/InstalledAppsLayout"; function ConnectOrDisconnectIntegrationMenuItem(props: { - credentialIds: number[]; + credentialId: number; type: App["type"]; isGlobal?: boolean; installed?: boolean; invalidCredentialIds?: number[]; - handleDisconnect: (credentialId: number) => void; + teamId?: number; + handleDisconnect: (credentialId: number, teamId?: number) => void; }) { - const { type, credentialIds, isGlobal, installed, handleDisconnect } = props; + const { type, credentialId, isGlobal, installed, handleDisconnect, teamId } = props; const { t } = useLocale(); - const [credentialId] = credentialIds; const utils = trpc.useContext(); const handleOpenChange = () => { @@ -73,7 +74,7 @@ function ConnectOrDisconnectIntegrationMenuItem(props: { handleDisconnect(credentialId)} + onClick={() => handleDisconnect(credentialId, teamId)} disabled={isGlobal} StartIcon={Trash}> {t("remove_app")} @@ -138,72 +139,113 @@ const IntegrationsList = ({ data, handleDisconnect, variant }: IntegrationsListP }, }); + const ChildAppCard = ({ + item, + }: { + item: RouterOutputs["viewer"]["integrations"]["items"][number] & { + credentialOwner?: CredentialOwner; + }; + }) => { + const appSlug = item?.slug; + const appIsDefault = + appSlug === defaultConferencingApp?.appSlug || + (appSlug === "daily-video" && !defaultConferencingApp?.appSlug); + return ( + 0 : false} + credentialOwner={item?.credentialOwner} + actions={ + !item.credentialOwner?.readOnly ? ( +
+ + +
+ ) : null + }> + +
+ ); + }; + + const appsWithTeamCredentials = data.items.filter((app) => app.teams.length); + const cardsForAppsWithTeams = appsWithTeamCredentials.map((app) => { + const appCards = []; + + if (app.userCredentialIds.length) { + appCards.push(); + } + for (const team of app.teams) { + if (team) { + appCards.push( + + ); + } + } + return appCards; + }); + const { t } = useLocale(); return ( <> + {cardsForAppsWithTeams.map((apps) => apps.map((cards) => cards))} {data.items .filter((item) => item.invalidCredentialIds) .map((item) => { - const appSlug = item?.slug; - const appIsDefault = - appSlug === defaultConferencingApp?.appSlug || - (appSlug === "daily-video" && !defaultConferencingApp?.appSlug); - return ( - 0} - actions={ -
- - -
- }> - -
- ); + if (!item.teams.length) return ; })}
{locationType && ( @@ -227,7 +269,12 @@ const IntegrationsContainer = ({ handleDisconnect, }: IntegrationsContainerProps): JSX.Element => { const { t } = useLocale(); - const query = trpc.viewer.integrations.useQuery({ variant, exclude, onlyInstalled: true }); + const query = trpc.viewer.integrations.useQuery({ + variant, + exclude, + onlyInstalled: true, + includeTeamInstalledApps: true, + }); // TODO: Refactor and reuse getAppCategories? const emptyIcon: Record = { @@ -299,6 +346,7 @@ type querySchemaType = z.infer; type ModalState = { isOpen: boolean; credentialId: null | number; + teamId?: number; }; export default function InstalledApps() { @@ -323,8 +371,8 @@ export default function InstalledApps() { updateData({ isOpen: false, credentialId: null }); }; - const handleDisconnect = (credentialId: number) => { - updateData({ isOpen: true, credentialId }); + const handleDisconnect = (credentialId: number, teamId?: number) => { + updateData({ isOpen: true, credentialId, teamId }); }; return ( @@ -346,6 +394,7 @@ export default function InstalledApps() { handleModelClose={handleModelClose} isOpen={data.isOpen} credentialId={data.credentialId} + teamId={data.teamId} /> ); diff --git a/apps/web/pages/event-types/[type]/index.tsx b/apps/web/pages/event-types/[type]/index.tsx index ef9189a40d..0f846d1f1b 100644 --- a/apps/web/pages/event-types/[type]/index.tsx +++ b/apps/web/pages/event-types/[type]/index.tsx @@ -101,6 +101,7 @@ export type FormValues = { displayLocationPublicly?: boolean; phone?: string; hostDefault?: string; + credentialId?: number; }[]; customInputs: CustomInputParsed[]; schedule: number | null; @@ -161,8 +162,10 @@ const EventTypePage = (props: EventTypeSetupProps) => { data: { tabName }, } = useTypedQuery(querySchema); - const { data: eventTypeApps } = trpc.viewer.apps.useQuery({ + const { data: eventTypeApps } = trpc.viewer.integrations.useQuery({ extendsFeature: "EventType", + teamId: props.eventType.team?.id || props.eventType.parent?.teamId, + onlyInstalled: true, }); const { eventType, locationOptions, team, teamMembers, currentUserMembership, destinationCalendar } = props; @@ -306,12 +309,12 @@ const EventTypePage = (props: EventTypeSetupProps) => { const appsMetadata = formMethods.getValues("metadata")?.apps; const availability = formMethods.watch("availability"); - const numberOfInstalledApps = eventTypeApps?.filter((app) => app.isInstalled).length || 0; let numberOfActiveApps = 0; if (appsMetadata) { numberOfActiveApps = Object.entries(appsMetadata).filter( - ([appId, appData]) => eventTypeApps?.find((app) => app.slug === appId)?.isInstalled && appData.enabled + ([appId, appData]) => + eventTypeApps?.items.find((app) => app.slug === appId)?.isInstalled && appData.enabled ).length; } @@ -423,7 +426,7 @@ const EventTypePage = (props: EventTypeSetupProps) => { <> ; +export type PageProps = inferSSRProps; export default function Type({ slug, user, booking, away, isBrandingHidden }: PageProps) { const isEmbed = typeof window !== "undefined" && window?.isEmbed?.(); diff --git a/apps/web/pages/new-booker/team/[slug]/[type].tsx b/apps/web/pages/new-booker/team/[slug]/[type].tsx index 6e9fab27dc..2317f60386 100644 --- a/apps/web/pages/new-booker/team/[slug]/[type].tsx +++ b/apps/web/pages/new-booker/team/[slug]/[type].tsx @@ -13,7 +13,7 @@ import type { inferSSRProps } from "@lib/types/inferSSRProps"; import PageWrapper from "@components/PageWrapper"; -type PageProps = inferSSRProps; +export type PageProps = inferSSRProps; export default function Type({ slug, user, booking, away, isBrandingHidden }: PageProps) { const isEmbed = typeof window !== "undefined" && window?.isEmbed?.(); @@ -94,6 +94,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => booking, away: false, user: teamSlug, + teamId: team.id, slug: meetingSlug, trpcState: ssr.dehydrate(), isBrandingHidden: team?.hideBranding, diff --git a/apps/web/pages/org/[orgSlug]/[user]/[type].ts b/apps/web/pages/org/[orgSlug]/[user]/[type].ts index 395a19279c..f486141837 100644 --- a/apps/web/pages/org/[orgSlug]/[user]/[type].ts +++ b/apps/web/pages/org/[orgSlug]/[user]/[type].ts @@ -1 +1,62 @@ -export { getServerSideProps, default } from "../../../[user]/[type]"; +import type { GetServerSidePropsContext } from "next"; + +import prisma from "@calcom/prisma"; + +import PageWrapper from "@components/PageWrapper"; + +import type { PageProps as UserTypePageProps } from "../../../new-booker/[user]/[type]"; +import UserTypePage, { getServerSideProps as GSSUserTypePage } from "../../../new-booker/[user]/[type]"; +import TeamTypePage, { getServerSideProps as GSSTeamTypePage } from "../../../new-booker/team/[slug]/[type]"; +import type { PageProps as TeamTypePageProps } from "../../../new-booker/team/[slug]/[type]"; + +export const getServerSideProps = async (ctx: GetServerSidePropsContext) => { + const team = await prisma.team.findFirst({ + where: { + slug: ctx.query.user as string, + parentId: { + not: null, + }, + parent: { + slug: ctx.query.orgSlug as string, + }, + }, + select: { + id: true, + }, + }); + if (team) { + const params = { slug: ctx.query.user, type: ctx.query.type }; + return GSSTeamTypePage({ + ...ctx, + params: { + ...ctx.params, + ...params, + }, + query: { + ...ctx.query, + ...params, + }, + }); + } + const params = { user: ctx.query.user, type: ctx.query.type }; + return GSSUserTypePage({ + ...ctx, + params: { + ...ctx.params, + ...params, + }, + query: { + ...ctx.query, + ...params, + }, + }); +}; + +type Props = UserTypePageProps | TeamTypePageProps; + +export default function Page(props: Props) { + if ((props as TeamTypePageProps)?.teamId) return TeamTypePage(props as TeamTypePageProps); + return UserTypePage(props as UserTypePageProps); +} + +Page.PageWrapper = PageWrapper; diff --git a/apps/web/pages/org/[orgSlug]/[user]/[type]/embed.tsx b/apps/web/pages/org/[orgSlug]/[user]/[type]/embed.tsx new file mode 100644 index 0000000000..b9384e607e --- /dev/null +++ b/apps/web/pages/org/[orgSlug]/[user]/[type]/embed.tsx @@ -0,0 +1,19 @@ +import type { GetServerSidePropsContext } from "next"; + +import { getServerSideProps as _getServerSideProps } from "../[type]"; + +export { default } from "../[type]"; + +export const getServerSideProps = async (context: GetServerSidePropsContext) => { + const ssrResponse = await _getServerSideProps(context); + if (ssrResponse.notFound) { + return ssrResponse; + } + return { + ...ssrResponse, + props: { + ...ssrResponse.props, + isEmbed: true, + }, + }; +}; diff --git a/apps/web/pages/settings/my-account/conferencing.tsx b/apps/web/pages/settings/my-account/conferencing.tsx index 4a46339c93..923b155f8f 100644 --- a/apps/web/pages/settings/my-account/conferencing.tsx +++ b/apps/web/pages/settings/my-account/conferencing.tsx @@ -160,7 +160,7 @@ const ConferencingLayout = () => { disabled={app.isGlobal} StartIcon={Trash} onClick={() => { - setDeleteCredentialId(app.credentialIds[0]); + setDeleteCredentialId(app.userCredentialIds[0]); setDeleteAppModal(true); }}> {t("remove_app")} diff --git a/apps/web/pages/team/[slug].tsx b/apps/web/pages/team/[slug].tsx index 5250faf3ed..0d97d1cf16 100644 --- a/apps/web/pages/team/[slug].tsx +++ b/apps/web/pages/team/[slug].tsx @@ -65,7 +65,7 @@ function TeamPage({ team, isUnpublished, markdownStrippedBio, isValidOrgDomain } } // slug is a route parameter, we don't want to forward it to the next route - const { slug: _slug, ...queryParamsToForward } = router.query; + const { slug: _slug, orgSlug: _orgSlug, user: _user, ...queryParamsToForward } = router.query; const EventTypes = () => (
    diff --git a/apps/web/pagesAndRewritePaths.js b/apps/web/pagesAndRewritePaths.js new file mode 100644 index 0000000000..d6ad8284c8 --- /dev/null +++ b/apps/web/pagesAndRewritePaths.js @@ -0,0 +1,43 @@ +const glob = require("glob"); +const { getSubdomainRegExp } = require("./getSubdomainRegExp"); +/** Needed to rewrite public booking page, gets all static pages but [user] */ +let pages = (exports.pages = glob + .sync("pages/**/[^_]*.{tsx,js,ts}", { cwd: __dirname }) + .map((filename) => + filename + .substr(6) + .replace(/(\.tsx|\.js|\.ts)/, "") + .replace(/\/.*/, "") + ) + .filter((v, i, self) => self.indexOf(v) === i && !v.startsWith("[user]"))); + +// Following routes don't exist but they work by doing rewrite. Thus they need to be excluded from matching the orgRewrite patterns +// Make sure to keep it upto date as more nonExistingRouteRewrites are added. +const otherNonExistingRoutePrefixes = ["forms", "router", "success", "cancel"]; + +// .* matches / as well(Note: *(i.e wildcard) doesn't match / but .*(i.e. RegExp) does) +// It would match /free/30min but not /bookings/upcoming because 'bookings' is an item in pages +// It would also not match /free/30min/embed because we are ensuring just two slashes +// ?!book ensures it doesn't match /free/book page which doesn't have a corresponding new-booker page. +// [^/]+ makes the RegExp match the full path, it seems like a partial match doesn't work. +// book$ ensures that only /book is excluded from rewrite(which is at the end always) and not /booked + +const afterFilesRewriteExcludePages = pages; +exports.userTypeRoutePath = `/:user((?!${afterFilesRewriteExcludePages.join( + "/|" +)})[^/]*)/:type((?!book$)[^/]+)`; +exports.teamTypeRoutePath = "/team/:slug/:type((?!book$)[^/]+)"; +exports.privateLinkRoutePath = "/d/:link/:slug((?!book$)[^/]+)"; +exports.embedUserTypeRoutePath = `/:user((?!${afterFilesRewriteExcludePages.join("/|")})[^/]*)/:type/embed`; +exports.embedTeamTypeRoutePath = "/team/:slug/:type/embed"; +let subdomainRegExp = (exports.subdomainRegExp = getSubdomainRegExp(process.env.NEXT_PUBLIC_WEBAPP_URL)); +exports.orgHostPath = `^(?${subdomainRegExp})\\..*`; + +let beforeRewriteExcludePages = pages.concat(otherNonExistingRoutePrefixes); +exports.orgUserRoutePath = `/:user((?!${beforeRewriteExcludePages.join("|")}|_next|public)[a-zA-Z0-9\-_]+)`; +exports.orgUserTypeRoutePath = `/:user((?!${beforeRewriteExcludePages.join( + "/|" +)}|_next/|public/)[^/]+)/:type((?!avatar\.png)[^/]+)`; +exports.orgUserTypeEmbedRoutePath = `/:user((?!${beforeRewriteExcludePages.join( + "/|" +)}|_next/|public/)[^/]+)/:type/embed`; diff --git a/apps/web/playwright/webhook.e2e.ts b/apps/web/playwright/webhook.e2e.ts index cee6639f6d..24ed897f29 100644 --- a/apps/web/playwright/webhook.e2e.ts +++ b/apps/web/playwright/webhook.e2e.ts @@ -414,6 +414,7 @@ test.describe("FORM_SUBMITTED", async () => { // eslint-disable-next-line playwright/no-conditional-in-test await page.click('[data-testid="install-app-button"]'); + await page.click('[data-testid="install-app-button-personal"]'); await page.waitForLoadState("networkidle"); await page.goto(`/settings/developer/webhooks/new`); diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index ece4273076..4f3d962afb 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -118,7 +118,7 @@ "team_info": "Team Info", "request_another_invitation_email": "If you prefer not to use {{toEmail}} as your {{appName}} email or already have a {{appName}} account, please request another invitation to that email.", "you_have_been_invited": "You have been invited to join the team {{teamName}}", - "user_invited_you": "{{user}} invited you to join the team {{team}} on {{appName}}", + "user_invited_you": "{{user}} invited you to join the {{entity}} {{team}} on {{appName}}", "hidden_team_member_title": "You are hidden in this team", "hidden_team_member_message": "Your seat is not paid for, either Upgrade to PRO or let the team owner know they can pay for your seat.", "hidden_team_owner_message": "You need a pro account to use teams, you are hidden until you upgrade.", @@ -1875,6 +1875,7 @@ "connect_google_workspace": "Connect Google Workspace", "google_workspace_admin_tooltip": "You must be a Workspace Admin to use this feature", "first_event_type_webhook_description": "Create your first webhook for this event type", + "install_app_on": "Install app on", "create_for": "Create for", "setup_organization": "Setup an Organization", "organization_banner_description": "Create an environments where your teams can create shared apps, workflows and event types with round-robin and collective scheduling.", diff --git a/apps/web/test/lib/next-config.test.ts b/apps/web/test/lib/next-config.test.ts index 0e7acdb7aa..3f49255777 100644 --- a/apps/web/test/lib/next-config.test.ts +++ b/apps/web/test/lib/next-config.test.ts @@ -1,165 +1,171 @@ -import { it, expect, describe, beforeAll, afterAll } from "vitest"; +import { it, expect, describe, beforeAll } from "vitest"; // eslint-disable-next-line @typescript-eslint/no-var-requires const { getSubdomainRegExp } = require("../../getSubdomainRegExp"); -let userTypeRouteRegExp: RegExp; -let teamTypeRouteRegExp:RegExp; -let privateLinkRouteRegExp:RegExp -let embedUserTypeRouteRegExp:RegExp -let embedTeamTypeRouteRegExp:RegExp +// eslint-disable-next-line @typescript-eslint/no-var-requires +const {match, pathToRegexp} = require("next/dist/compiled/path-to-regexp"); +type MatcherRes = (path: string) => {params: Record} +let userTypeRouteMatch: MatcherRes; +let teamTypeRouteMatch:MatcherRes; +let privateLinkRouteMatch:MatcherRes +let embedUserTypeRouteMatch:MatcherRes +let embedTeamTypeRouteMatch:MatcherRes +let orgUserTypeRouteMatch:MatcherRes +let orgUserRouteMatch: MatcherRes -const getRegExpFromNextJsRewriteRegExp = (nextJsRegExp:string) => { - // const parts = nextJsRegExp.split(':'); +beforeAll(async()=>{ + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore + process.env.NEXT_PUBLIC_WEBAPP_URL = "http://example.com" + const { + userTypeRoutePath, + teamTypeRoutePath, + privateLinkRoutePath, + embedUserTypeRoutePath, + embedTeamTypeRoutePath, + orgUserRoutePath, + orgUserTypeRoutePath, + // eslint-disable-next-line @typescript-eslint/no-var-requires + } = require("../../pagesAndRewritePaths") + + userTypeRouteMatch = match(userTypeRoutePath); - // const validNamedGroupRegExp = parts.map((part, index)=>{ - // if (index === 0) { - // return part; - // } - // if (part.match(/^[a-zA-Z0-9]+$/)) { - // return `(?<${part}>[^/]+)` - // } - // part = part.replace(new RegExp('([^(]+)(.*)'), '(?<$1>$2)'); - // return part - // }).join(''); + teamTypeRouteMatch = match(teamTypeRoutePath); - // TODO: If we can easily convert the exported rewrite regexes from next.config.js to a valid named capturing group regex, it would be best - // Next.js does an exact match as per my testing. - return new RegExp(`^${nextJsRegExp}$`) -} + privateLinkRouteMatch = match(privateLinkRoutePath); + + embedUserTypeRouteMatch = match(embedUserTypeRoutePath); + + embedTeamTypeRouteMatch = match(embedTeamTypeRoutePath); + + orgUserTypeRouteMatch = match(orgUserTypeRoutePath) + + orgUserRouteMatch = match(orgUserRoutePath) + console.log({ + regExps: { + userTypeRouteMatch: pathToRegexp(userTypeRoutePath), + + teamTypeRouteMatch:pathToRegexp(teamTypeRoutePath), + + privateLinkRouteMatch:pathToRegexp(privateLinkRoutePath), + + embedUserTypeRouteMatch:pathToRegexp(embedUserTypeRoutePath), + + embedTeamTypeRouteMatch:pathToRegexp(embedTeamTypeRoutePath), + + orgUserTypeRouteMatch:pathToRegexp(orgUserTypeRoutePath), + + orgUserRouteMatch:pathToRegexp(orgUserRoutePath) + } + }) +}); describe('next.config.js - RegExp', ()=>{ - beforeAll(async()=>{ - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - // process.env.NEXTAUTH_SECRET = process.env.NEXTAUTH_URL = process.env.CALENDSO_ENCRYPTION_KEY = 1 - // eslint-disable-next-line @typescript-eslint/no-var-requires - const pages = require("../../pages").pages - - // How to convert a Next.js rewrite RegExp/wildcard to a valid JS named capturing Group RegExp? - // - /:user/ -> (?[^/]+) - // - /:user(?!404)[^/]+/ -> (?((?!404)[^/]+)) - - // userTypeRouteRegExp = `/:user((?!${pages.join("/|")})[^/]*)/:type((?!book$)[^/]+)`; - userTypeRouteRegExp = getRegExpFromNextJsRewriteRegExp(`/(?((?!${pages.join("/|")})[^/]*))/(?((?!book$)[^/]+))`); - - // teamTypeRouteRegExp = "/team/:slug/:type((?!book$)[^/]+)"; - teamTypeRouteRegExp = getRegExpFromNextJsRewriteRegExp("/team/(?[^/]+)/(?((?!book$)[^/]+))"); - - // privateLinkRouteRegExp = "/d/:link/:slug((?!book$)[^/]+)"; - privateLinkRouteRegExp = getRegExpFromNextJsRewriteRegExp("/d/(?[^/]+)/(?((?!book$)[^/]+))"); - - // embedUserTypeRouteRegExp = `/:user((?!${pages.join("/|")})[^/]*)/:type/embed`; - embedUserTypeRouteRegExp = getRegExpFromNextJsRewriteRegExp(`/(?((?!${pages.join("/|")})[^/]*))/(?[^/]+)/embed`); - - // embedTeamTypeRouteRegExp = "/team/:slug/:type/embed"; - embedTeamTypeRouteRegExp = getRegExpFromNextJsRewriteRegExp("/team/(?[^/]+)/(?[^/]+)/embed"); - }); - - afterAll(()=>{ - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - //@ts-ignore - process.env.NEXTAUTH_SECRET = process.env.NEXTAUTH_URL = process.env.CALENDSO_ENCRYPTION_KEY = undefined - }) - it("Booking Urls", async () => { - expect(userTypeRouteRegExp.exec('/free/30')?.groups).toContain({ + expect(userTypeRouteMatch('/free/30')?.params).toContain({ user: 'free', type: '30' }) // Edgecase of username starting with team also works - expect(userTypeRouteRegExp.exec('/teampro/30')?.groups).toContain({ + expect(userTypeRouteMatch('/teampro/30')?.params).toContain({ user: 'teampro', type: '30' }) - expect(userTypeRouteRegExp.exec('/teampro+pro/30')?.groups).toContain({ + // Edgecase of username starting with team also works + expect(userTypeRouteMatch('/workflowteam/30')?.params).toContain({ + user: 'workflowteam', + type: '30' + }) + + expect(userTypeRouteMatch('/teampro+pro/30')?.params).toContain({ user: 'teampro+pro', type: '30' }) - expect(userTypeRouteRegExp.exec('/teampro+pro/book')).toEqual(null) + expect(userTypeRouteMatch('/teampro+pro/book')).toEqual(false) // Because /book doesn't have a corresponding new-booker route. - expect(userTypeRouteRegExp.exec('/free/book')).toEqual(null) + expect(userTypeRouteMatch('/free/book')).toEqual(false) // Because /booked is a normal event name - expect(userTypeRouteRegExp.exec('/free/booked')?.groups).toEqual({ + expect(userTypeRouteMatch('/free/booked')?.params).toEqual({ user: 'free', type: 'booked' }) - expect(embedUserTypeRouteRegExp.exec('/free/30/embed')?.groups).toContain({ + expect(embedUserTypeRouteMatch('/free/30/embed')?.params).toContain({ user: 'free', type:'30' }) // Edgecase of username starting with team also works - expect(embedUserTypeRouteRegExp.exec('/teampro/30/embed')?.groups).toContain({ + expect(embedUserTypeRouteMatch('/teampro/30/embed')?.params).toContain({ user: 'teampro', type: '30' }) - expect(teamTypeRouteRegExp.exec('/team/seeded/30')?.groups).toContain({ + expect(teamTypeRouteMatch('/team/seeded/30')?.params).toContain({ slug: 'seeded', type: '30' }) // Because /book doesn't have a corresponding new-booker route. - expect(teamTypeRouteRegExp.exec('/team/seeded/book')).toEqual(null) + expect(teamTypeRouteMatch('/team/seeded/book')).toEqual(false) - expect(teamTypeRouteRegExp.exec('/team/seeded/30/embed')).toEqual(null) + expect(teamTypeRouteMatch('/team/seeded/30/embed')).toEqual(false) - expect(embedTeamTypeRouteRegExp.exec('/team/seeded/30/embed')?.groups).toContain({ + expect(embedTeamTypeRouteMatch('/team/seeded/30/embed')?.params).toContain({ slug: 'seeded', type:'30' }) - expect(privateLinkRouteRegExp.exec('/d/3v4s321CXRJZx5TFxkpPvd/30min')?.groups).toContain({ + expect(privateLinkRouteMatch('/d/3v4s321CXRJZx5TFxkpPvd/30min')?.params).toContain({ link: '3v4s321CXRJZx5TFxkpPvd', slug: '30min' }) - expect(privateLinkRouteRegExp.exec('/d/3v4s321CXRJZx5TFxkpPvd/30min')?.groups).toContain({ + expect(privateLinkRouteMatch('/d/3v4s321CXRJZx5TFxkpPvd/30min')?.params).toContain({ link: '3v4s321CXRJZx5TFxkpPvd', slug: '30min' }) // Because /book doesn't have a corresponding new-booker route. - expect(privateLinkRouteRegExp.exec('/d/3v4s321CXRJZx5TFxkpPvd/book')).toEqual(null) + expect(privateLinkRouteMatch('/d/3v4s321CXRJZx5TFxkpPvd/book')).toEqual(false) }); it('Non booking Urls', ()=>{ - expect(userTypeRouteRegExp.exec('/404')).toEqual(null) - expect(teamTypeRouteRegExp.exec('/404')).toEqual(null) + expect(userTypeRouteMatch('/404/')).toEqual(false) + expect(teamTypeRouteMatch('/404/')).toEqual(false) - expect(userTypeRouteRegExp.exec('/404/30')).toEqual(null) - expect(teamTypeRouteRegExp.exec('/404/30')).toEqual(null) + expect(userTypeRouteMatch('/404/30')).toEqual(false) + expect(teamTypeRouteMatch('/404/30')).toEqual(false) - expect(userTypeRouteRegExp.exec('/api')).toEqual(null) - expect(teamTypeRouteRegExp.exec('/api')).toEqual(null) + expect(userTypeRouteMatch('/api')).toEqual(false) + expect(teamTypeRouteMatch('/api')).toEqual(false) - expect(userTypeRouteRegExp.exec('/api/30')).toEqual(null) - expect(teamTypeRouteRegExp.exec('/api/30')).toEqual(null) + expect(userTypeRouteMatch('/api/30')).toEqual(false) + expect(teamTypeRouteMatch('/api/30')).toEqual(false) - expect(userTypeRouteRegExp.exec('/workflows/30')).toEqual(null) - expect(teamTypeRouteRegExp.exec('/workflows/30')).toEqual(null) + expect(userTypeRouteMatch('/workflows/30')).toEqual(false) + expect(teamTypeRouteMatch('/workflows/30')).toEqual(false) - expect(userTypeRouteRegExp.exec('/event-types/30')).toEqual(null) - expect(teamTypeRouteRegExp.exec('/event-types/30')).toEqual(null) + expect(userTypeRouteMatch('/event-types/30')).toEqual(false) + expect(teamTypeRouteMatch('/event-types/30')).toEqual(false) - expect(userTypeRouteRegExp.exec('/teams/1')).toEqual(null) - expect(teamTypeRouteRegExp.exec('/teams/1')).toEqual(null) + expect(userTypeRouteMatch('/teams/1')).toEqual(false) + expect(teamTypeRouteMatch('/teams/1')).toEqual(false) - expect(userTypeRouteRegExp.exec('/teams')).toEqual(null) - expect(teamTypeRouteRegExp.exec('/teams')).toEqual(null) + expect(userTypeRouteMatch('/teams')).toEqual(false) + expect(teamTypeRouteMatch('/teams')).toEqual(false) // Note that even though it matches /embed/embed.js, but it's served from /public and the regexes are in afterEach, it won't hit the flow. - // expect(userTypeRouteRegExp.exec('/embed/embed.js')).toEqual(null) - // expect(teamTypeRouteRegExp.exec('/embed/embed.js')).toEqual(null) + // expect(userTypeRouteRegExp('/embed/embed.js')).toEqual(false) + // expect(teamTypeRouteRegExp('/embed/embed.js')).toEqual(false) }) }) @@ -167,7 +173,7 @@ describe('next.config.js - RegExp', ()=>{ describe('next.config.js - Org Rewrite', ()=> { // RegExp copied from next.config.js const orgHostRegExp = (subdomainRegExp:string)=> new RegExp(`^(?${subdomainRegExp})\\..*`) - describe('SubDomain Retrieval from NEXT_PUBLIC_WEBAPP_URL', ()=>{ + describe('Host matching based on NEXT_PUBLIC_WEBAPP_URL', ()=>{ it('https://app.cal.com', ()=>{ const subdomainRegExp = getSubdomainRegExp('https://app.cal.com'); expect(orgHostRegExp(subdomainRegExp).exec('app.cal.com')).toEqual(null) @@ -195,4 +201,42 @@ describe('next.config.js - Org Rewrite', ()=> { expect(orgHostRegExp(subdomainRegExp).exec('some-other.company.com')?.groups?.orgSlug).toEqual('some-other') }) }) + + describe('Rewrite', () =>{ + it('booking pages', () => { + expect(orgUserTypeRouteMatch('/user/type')?.params).toContain({ + user: 'user', + type: 'type' + }) + + // User slug starting with 404(which is a page route) will work + expect(orgUserTypeRouteMatch('/404a/def')?.params).toEqual({ + user: '404a', + type: 'def' + }) + + // Team Page won't match - There is no /team prefix required for Org team event pages + expect(orgUserTypeRouteMatch('/team/abc')).toEqual(false) + + expect(orgUserTypeRouteMatch('/abc')).toEqual(false) + + expect(orgUserRouteMatch('/abc')?.params).toContain({ + user: 'abc' + }) + }) + + it('Non booking pages', () => { + expect(orgUserTypeRouteMatch('/_next/def')).toEqual(false) + expect(orgUserTypeRouteMatch('/public/def')).toEqual(false) + expect(orgUserRouteMatch('/_next/')).toEqual(false) + expect(orgUserRouteMatch('/public/')).toEqual(false) + expect(orgUserRouteMatch('/event-types')).toEqual(false) + expect(orgUserTypeRouteMatch('/event-types')).toEqual(false) + expect(orgUserTypeRouteMatch('/john/avatar.png')).toEqual(false) + expect(orgUserTypeRouteMatch('/cancel/abcd')).toEqual(false) + expect(orgUserTypeRouteMatch('/success/abcd')).toEqual(false) + expect(orgUserRouteMatch('/forms/xdsdf-sd')).toEqual(false) + expect(orgUserRouteMatch('/router?form=')).toEqual(false) + }) + }) }) \ No newline at end of file diff --git a/packages/app-store/EventTypeAppContext.tsx b/packages/app-store/EventTypeAppContext.tsx index 042044d8d8..0ddaf7a821 100644 --- a/packages/app-store/EventTypeAppContext.tsx +++ b/packages/app-store/EventTypeAppContext.tsx @@ -3,8 +3,15 @@ import type { z, ZodType } from "zod"; export type GetAppData = (key: string) => unknown; export type SetAppData = (key: string, value: unknown) => void; +type LockedIcon = JSX.Element | false | undefined; +type Disabled = boolean | undefined; // eslint-disable-next-line @typescript-eslint/no-empty-function -const EventTypeAppContext = React.createContext<[GetAppData, SetAppData]>([() => ({}), () => {}]); +const EventTypeAppContext = React.createContext<[GetAppData, SetAppData, LockedIcon, Disabled]>([ + () => ({}), + () => ({}), + undefined, + undefined, +]); export type SetAppDataGeneric = < TKey extends keyof z.infer, @@ -22,7 +29,7 @@ export const useAppContextWithSchema = () => { type GetAppData = GetAppDataGeneric; type SetAppData = SetAppDataGeneric; // TODO: Not able to do it without type assertion here - const context = React.useContext(EventTypeAppContext) as [GetAppData, SetAppData]; + const context = React.useContext(EventTypeAppContext) as [GetAppData, SetAppData, LockedIcon, Disabled]; return context; }; export default EventTypeAppContext; diff --git a/packages/app-store/_appRegistry.ts b/packages/app-store/_appRegistry.ts index 81a4665d55..4a0eb47212 100644 --- a/packages/app-store/_appRegistry.ts +++ b/packages/app-store/_appRegistry.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData"; import { getAppFromSlug } from "@calcom/app-store/utils"; +import type { UserAdminTeams } from "@calcom/features/ee/teams/lib/getUserAdminTeams"; import prisma, { safeAppSelect, safeCredentialSelect } from "@calcom/prisma"; import { userMetadata } from "@calcom/prisma/zod-utils"; import type { AppFrontendPayload as App } from "@calcom/types/App"; @@ -56,13 +57,21 @@ export async function getAppRegistry() { return apps; } -export async function getAppRegistryWithCredentials(userId: number) { +export async function getAppRegistryWithCredentials(userId: number, userAdminTeams: UserAdminTeams = []) { + // Get teamIds to grab existing credentials + const teamIds = []; + for (const team of userAdminTeams) { + if (!team.isUser) { + teamIds.push(team.id); + } + } + const dbApps = await prisma.app.findMany({ where: { enabled: true }, select: { ...safeAppSelect, credentials: { - where: { userId }, + where: { OR: [{ userId }, { teamId: { in: teamIds } }] }, select: safeCredentialSelect, }, }, diff --git a/packages/app-store/_components/AppCard.tsx b/packages/app-store/_components/AppCard.tsx index a6f0d5efa5..b7f54db828 100644 --- a/packages/app-store/_components/AppCard.tsx +++ b/packages/app-store/_components/AppCard.tsx @@ -3,10 +3,11 @@ import Link from "next/link"; import { classNames } from "@calcom/lib"; import type { RouterOutputs } from "@calcom/trpc/react"; -import { Switch } from "@calcom/ui"; +import { Switch, Badge, Avatar } from "@calcom/ui"; import type { SetAppDataGeneric } from "../EventTypeAppContext"; import type { eventTypeAppCardZod } from "../eventTypeAppCardZod"; +import type { CredentialOwner } from "../types"; import OmniInstallAppButton from "./OmniInstallAppButton"; export default function AppCard({ @@ -17,14 +18,20 @@ export default function AppCard({ children, setAppData, returnTo, + teamId, + disableSwitch, + LockedIcon, }: { - app: RouterOutputs["viewer"]["apps"][number]; + app: RouterOutputs["viewer"]["integrations"]["items"][number] & { credentialOwner?: CredentialOwner }; description?: React.ReactNode; switchChecked?: boolean; switchOnClick?: (e: boolean) => void; children?: React.ReactNode; setAppData: SetAppDataGeneric; returnTo?: string; + teamId?: number; + disableSwitch?: boolean; + LockedIcon?: React.ReactNode; }) { const [animationRef] = useAutoAnimate(); @@ -55,26 +62,45 @@ export default function AppCard({ {description || app?.description}

    - {app?.isInstalled ? ( -
    - { - if (switchOnClick) { - switchOnClick(enabled); - } - setAppData("enabled", enabled); - }} - checked={switchChecked} +
    + {app.credentialOwner && ( +
    + +
    + + {app.credentialOwner.name} +
    +
    +
    + )} + {app?.isInstalled || app.credentialOwner ? ( +
    + { + if (switchOnClick) { + switchOnClick(enabled); + } + setAppData("enabled", enabled); + }} + checked={switchChecked} + LockedIcon={LockedIcon} + /> +
    + ) : ( + -
    - ) : ( - - )} + )} +
    diff --git a/packages/app-store/_components/EventTypeAppCardInterface.tsx b/packages/app-store/_components/EventTypeAppCardInterface.tsx index 559d40d69f..e312297007 100644 --- a/packages/app-store/_components/EventTypeAppCardInterface.tsx +++ b/packages/app-store/_components/EventTypeAppCardInterface.tsx @@ -4,19 +4,22 @@ import { EventTypeAddonMap } from "@calcom/app-store/apps.browser.generated"; import type { RouterOutputs } from "@calcom/trpc/react"; import { ErrorBoundary } from "@calcom/ui"; -import type { EventTypeAppCardComponentProps } from "../types"; +import type { EventTypeAppCardComponentProps, CredentialOwner } from "../types"; import { DynamicComponent } from "./DynamicComponent"; export const EventTypeAppCard = (props: { - app: RouterOutputs["viewer"]["apps"][number]; + app: RouterOutputs["viewer"]["integrations"]["items"][number] & { credentialOwner?: CredentialOwner }; eventType: EventTypeAppCardComponentProps["eventType"]; getAppData: GetAppData; setAppData: SetAppData; + // For event type apps, get these props from shouldLockDisableProps + LockedIcon?: JSX.Element | false; + disabled?: boolean; }) => { - const { app, getAppData, setAppData } = props; + const { app, getAppData, setAppData, LockedIcon, disabled } = props; return ( - + { //TODO: viewer.appById might be replaced with viewer.apps so that a single query needs to be invalidated. utils.viewer.appById.invalidate({ appId }); - utils.viewer.apps.invalidate({ extendsFeature: "EventType" }); + utils.viewer.integrations.invalidate({ + extendsFeature: "EventType", + ...(teamId && { teamId }), + }); if (data?.setupPending) return; showToast(t("app_successfully_installed"), "success"); }, @@ -53,7 +58,13 @@ export default function OmniInstallAppButton({ props = { ...props, onClick: () => { - mutation.mutate({ type: app.type, variant: app.variant, slug: app.slug, isOmniInstall: true }); + mutation.mutate({ + type: app.type, + variant: app.variant, + slug: app.slug, + isOmniInstall: true, + ...(teamId && { teamId }), + }); }, }; } diff --git a/packages/app-store/_utils/createOAuthAppCredential.ts b/packages/app-store/_utils/createOAuthAppCredential.ts new file mode 100644 index 0000000000..f647852b4e --- /dev/null +++ b/packages/app-store/_utils/createOAuthAppCredential.ts @@ -0,0 +1,65 @@ +import type { NextApiRequest } from "next"; + +import prisma from "@calcom/prisma"; + +import { decodeOAuthState } from "./decodeOAuthState"; + +/** + * This function is used to create app credentials for either a user or a team + * + * @param appData information about the app + * @param appData.type the app slug + * @param appData.appId the app slug + * @param key the keys for the app's credentials + * @param req the request object from the API call. Used to determine if the credential belongs to a user or a team + */ +const createOAuthAppCredential = async ( + appData: { type: string; appId: string }, + key: unknown, + req: NextApiRequest +) => { + const userId = req.session?.user.id; + // For OAuth flows, see if a teamId was passed through the state + const state = decodeOAuthState(req); + if (state?.teamId) { + // Check that the user belongs to the team + const team = await prisma.team.findFirst({ + where: { + id: state.teamId, + members: { + some: { + userId: req.session?.user.id, + accepted: true, + }, + }, + }, + select: { id: true, members: { select: { userId: true } } }, + }); + + if (!team) throw new Error("User does not belong to the team"); + + await prisma.credential.create({ + data: { + type: appData.type, + key: key || {}, + teamId: state.teamId, + appId: appData.appId, + }, + }); + + return; + } + + await prisma.credential.create({ + data: { + type: appData.type, + key: key || {}, + userId, + appId: appData.appId, + }, + }); + + return; +}; + +export default createOAuthAppCredential; diff --git a/packages/app-store/_utils/getCalendar.ts b/packages/app-store/_utils/getCalendar.ts index b2ed77fdce..dd864929be 100644 --- a/packages/app-store/_utils/getCalendar.ts +++ b/packages/app-store/_utils/getCalendar.ts @@ -1,11 +1,28 @@ import logger from "@calcom/lib/logger"; -import type { Calendar } from "@calcom/types/Calendar"; +import type { Calendar, CalendarClass } from "@calcom/types/Calendar"; import type { CredentialPayload } from "@calcom/types/Credential"; import appStore from ".."; +interface CalendarApp { + lib: { + CalendarService: CalendarClass; + }; +} + const log = logger.getChildLogger({ prefix: ["CalendarManager"] }); +/** + * @see [Using type predicates](https://www.typescriptlang.org/docs/handbook/2/narrowing.html#using-type-predicates) + */ +const isCalendarService = (x: unknown): x is CalendarApp => + !!x && + typeof x === "object" && + "lib" in x && + typeof x.lib === "object" && + !!x.lib && + "CalendarService" in x.lib; + export const getCalendar = async (credential: CredentialPayload | null): Promise => { if (!credential || !credential.key) return null; let { type: calendarType } = credential; @@ -20,7 +37,8 @@ export const getCalendar = async (credential: CredentialPayload | null): Promise } const calendarApp = await calendarAppImportFn(); - if (!(calendarApp && "lib" in calendarApp && "CalendarService" in calendarApp.lib)) { + + if (!isCalendarService(calendarApp)) { log.warn(`calendar of type ${calendarType} is not implemented`); return null; } diff --git a/packages/app-store/_utils/installation.ts b/packages/app-store/_utils/installation.ts index 3819591647..eb635fb6d9 100644 --- a/packages/app-store/_utils/installation.ts +++ b/packages/app-store/_utils/installation.ts @@ -20,17 +20,19 @@ export async function createDefaultInstallation({ userId, slug, key = {}, + teamId, }: { appType: string; userId: number; slug: string; key?: Prisma.InputJsonValue; + teamId?: number; }) { const installation = await prisma.credential.create({ data: { type: appType, key, - userId, + ...(teamId ? { teamId } : { userId }), appId: slug, }, }); diff --git a/packages/app-store/_utils/useAddAppMutation.ts b/packages/app-store/_utils/useAddAppMutation.ts index 3bfd2770cc..d8fd1683f3 100644 --- a/packages/app-store/_utils/useAddAppMutation.ts +++ b/packages/app-store/_utils/useAddAppMutation.ts @@ -31,10 +31,11 @@ function useAddAppMutation(_type: App["type"] | null, allOptions?: UseAddAppMuta const mutation = useMutation< AddAppMutationData, Error, - { type?: App["type"]; variant?: string; slug?: string; isOmniInstall?: boolean } | "" + { type?: App["type"]; variant?: string; slug?: string; isOmniInstall?: boolean; teamId?: number } | "" >(async (variables) => { let type: string | null | undefined; let isOmniInstall; + const teamId = variables && variables.teamId ? variables.teamId : undefined; if (variables === "") { type = _type; } else { @@ -57,9 +58,11 @@ function useAddAppMutation(_type: App["type"] | null, allOptions?: UseAddAppMuta location.search ), ...(type === "google_calendar" && { installGoogleVideo: options?.installGoogleVideo }), + ...(teamId && { teamId }), }; + const stateStr = encodeURIComponent(JSON.stringify(state)); - const searchParams = `?state=${stateStr}`; + const searchParams = `?state=${stateStr}${teamId ? `&teamId=${teamId}` : ""}`; const res = await fetch(`/api/integrations/${type}/add` + searchParams); @@ -70,7 +73,6 @@ function useAddAppMutation(_type: App["type"] | null, allOptions?: UseAddAppMuta const json = await res.json(); const externalUrl = /https?:\/\//.test(json.url) && !json.url.startsWith(window.location.origin); - if (!isOmniInstall) { gotoUrl(json.url, json.newTab); return { setupPending: externalUrl || json.url.endsWith("/setup") }; diff --git a/packages/app-store/_utils/useIsAppEnabled.ts b/packages/app-store/_utils/useIsAppEnabled.ts new file mode 100644 index 0000000000..7a020d30ff --- /dev/null +++ b/packages/app-store/_utils/useIsAppEnabled.ts @@ -0,0 +1,34 @@ +import { useState } from "react"; + +import { useAppContextWithSchema } from "@calcom/app-store/EventTypeAppContext"; + +import type { EventTypeAppCardApp } from "../types"; + +function useIsAppEnabled(app: EventTypeAppCardApp) { + const [getAppData, setAppData] = useAppContextWithSchema(); + const [enabled, setEnabled] = useState(() => { + if (!app.credentialOwner) { + return getAppData("enabled"); + } + const credentialId = getAppData("credentialId"); + const isAppEnabledForCredential = + app.userCredentialIds.some((id) => id === credentialId) || + app.credentialOwner.credentialId === credentialId; + return isAppEnabledForCredential; + }); + + const updateEnabled = (newValue: boolean) => { + if (!newValue) { + setAppData("credentialId", undefined); + } + + if (newValue && (app.userCredentialIds?.length || app.credentialOwner?.credentialId)) { + setAppData("credentialId", app.credentialOwner?.credentialId || app.userCredentialIds[0]); + } + setEnabled(newValue); + }; + + return { enabled, updateEnabled }; +} + +export default useIsAppEnabled; diff --git a/packages/app-store/amie/api/add.ts b/packages/app-store/amie/api/add.ts index d939f9cf79..6d1b57f0ff 100644 --- a/packages/app-store/amie/api/add.ts +++ b/packages/app-store/amie/api/add.ts @@ -13,8 +13,8 @@ const handler: AppDeclarativeHandler = { newTab: true, url: "https://amie.so/signup", }, - createCredential: ({ appType, user, slug }) => - createDefaultInstallation({ appType, userId: user.id, slug, key: {} }), + createCredential: ({ appType, user, slug, teamId }) => + createDefaultInstallation({ appType, userId: user.id, slug, key: {}, teamId }), }; export default handler; diff --git a/packages/app-store/around/api/_getAdd.ts b/packages/app-store/around/api/_getAdd.ts index 00d4db0c0d..b4866b0b28 100644 --- a/packages/app-store/around/api/_getAdd.ts +++ b/packages/app-store/around/api/_getAdd.ts @@ -12,6 +12,7 @@ export async function getHandler(req: NextApiRequest) { const slug = appConfig.slug; const variant = appConfig.variant; const appType = appConfig.type; + const teamId = req.query.teamId ? Number(req.query.teamId) : undefined; await checkInstalled(slug, session.user.id); await createDefaultInstallation({ @@ -19,6 +20,7 @@ export async function getHandler(req: NextApiRequest) { userId: session.user.id, slug, key: {}, + teamId, }); return { url: getInstalledAppPath({ variant, slug }) }; diff --git a/packages/app-store/campfire/api/add.ts b/packages/app-store/campfire/api/add.ts index 44d56bb0fa..1e4629d298 100644 --- a/packages/app-store/campfire/api/add.ts +++ b/packages/app-store/campfire/api/add.ts @@ -9,8 +9,8 @@ const handler: AppDeclarativeHandler = { slug: appConfig.slug, supportsMultipleInstalls: false, handlerType: "add", - createCredential: ({ appType, user, slug }) => - createDefaultInstallation({ appType, userId: user.id, slug, key: {} }), + createCredential: ({ appType, user, slug, teamId }) => + createDefaultInstallation({ appType, userId: user.id, slug, key: {}, teamId }), }; export default handler; diff --git a/packages/app-store/cron/api/add.ts b/packages/app-store/cron/api/add.ts index a0ddbe97f4..c4f2d56da2 100644 --- a/packages/app-store/cron/api/add.ts +++ b/packages/app-store/cron/api/add.ts @@ -13,8 +13,8 @@ const handler: AppDeclarativeHandler = { newTab: true, url: "https://cron.com", }, - createCredential: ({ appType, user, slug }) => - createDefaultInstallation({ appType, userId: user.id, slug, key: {} }), + createCredential: ({ appType, user, slug, teamId }) => + createDefaultInstallation({ appType, userId: user.id, slug, key: {}, teamId }), }; export default handler; diff --git a/packages/app-store/dailyvideo/lib/VideoApiAdapter.ts b/packages/app-store/dailyvideo/lib/VideoApiAdapter.ts index 174511772e..bff9234217 100644 --- a/packages/app-store/dailyvideo/lib/VideoApiAdapter.ts +++ b/packages/app-store/dailyvideo/lib/VideoApiAdapter.ts @@ -62,6 +62,7 @@ export const FAKE_DAILY_CREDENTIAL: CredentialPayload & { invalid: boolean } = { userId: +new Date().getTime(), appId: "daily-video", invalid: false, + teamId: undefined, }; export const fetcher = async (endpoint: string, init?: RequestInit | undefined) => { diff --git a/packages/app-store/discord/api/add.ts b/packages/app-store/discord/api/add.ts index 9a4afb9b2c..6ab3106577 100644 --- a/packages/app-store/discord/api/add.ts +++ b/packages/app-store/discord/api/add.ts @@ -9,8 +9,8 @@ const handler: AppDeclarativeHandler = { slug: appConfig.slug, supportsMultipleInstalls: false, handlerType: "add", - createCredential: ({ appType, user, slug }) => - createDefaultInstallation({ appType, userId: user.id, slug, key: {} }), + createCredential: ({ appType, user, slug, teamId }) => + createDefaultInstallation({ appType, userId: user.id, slug, key: {}, teamId }), }; export default handler; diff --git a/packages/app-store/eightxeight/api/add.ts b/packages/app-store/eightxeight/api/add.ts index 9a4afb9b2c..6ab3106577 100644 --- a/packages/app-store/eightxeight/api/add.ts +++ b/packages/app-store/eightxeight/api/add.ts @@ -9,8 +9,8 @@ const handler: AppDeclarativeHandler = { slug: appConfig.slug, supportsMultipleInstalls: false, handlerType: "add", - createCredential: ({ appType, user, slug }) => - createDefaultInstallation({ appType, userId: user.id, slug, key: {} }), + createCredential: ({ appType, user, slug, teamId }) => + createDefaultInstallation({ appType, userId: user.id, slug, key: {}, teamId }), }; export default handler; diff --git a/packages/app-store/eventTypeAppCardZod.ts b/packages/app-store/eventTypeAppCardZod.ts index a41b2e45c5..28c3878eb1 100644 --- a/packages/app-store/eventTypeAppCardZod.ts +++ b/packages/app-store/eventTypeAppCardZod.ts @@ -3,6 +3,7 @@ import { z } from "zod"; export const eventTypeAppCardZod = z.object({ enabled: z.boolean().optional(), + credentialId: z.number().optional(), }); export const appKeysSchema = z.object({}); diff --git a/packages/app-store/facetime/api/add.ts b/packages/app-store/facetime/api/add.ts index 44d56bb0fa..1e4629d298 100644 --- a/packages/app-store/facetime/api/add.ts +++ b/packages/app-store/facetime/api/add.ts @@ -9,8 +9,8 @@ const handler: AppDeclarativeHandler = { slug: appConfig.slug, supportsMultipleInstalls: false, handlerType: "add", - createCredential: ({ appType, user, slug }) => - createDefaultInstallation({ appType, userId: user.id, slug, key: {} }), + createCredential: ({ appType, user, slug, teamId }) => + createDefaultInstallation({ appType, userId: user.id, slug, key: {}, teamId }), }; export default handler; diff --git a/packages/app-store/fathom/api/add.ts b/packages/app-store/fathom/api/add.ts index 44d56bb0fa..1e4629d298 100644 --- a/packages/app-store/fathom/api/add.ts +++ b/packages/app-store/fathom/api/add.ts @@ -9,8 +9,8 @@ const handler: AppDeclarativeHandler = { slug: appConfig.slug, supportsMultipleInstalls: false, handlerType: "add", - createCredential: ({ appType, user, slug }) => - createDefaultInstallation({ appType, userId: user.id, slug, key: {} }), + createCredential: ({ appType, user, slug, teamId }) => + createDefaultInstallation({ appType, userId: user.id, slug, key: {}, teamId }), }; export default handler; diff --git a/packages/app-store/fathom/components/EventTypeAppCardInterface.tsx b/packages/app-store/fathom/components/EventTypeAppCardInterface.tsx index d86dc6a17c..44e3ea5c72 100644 --- a/packages/app-store/fathom/components/EventTypeAppCardInterface.tsx +++ b/packages/app-store/fathom/components/EventTypeAppCardInterface.tsx @@ -1,32 +1,31 @@ -import { useState } from "react"; - import { useAppContextWithSchema } from "@calcom/app-store/EventTypeAppContext"; import AppCard from "@calcom/app-store/_components/AppCard"; +import useIsAppEnabled from "@calcom/app-store/_utils/useIsAppEnabled"; import type { EventTypeAppCardComponent } from "@calcom/app-store/types"; import { TextField } from "@calcom/ui"; import type { appDataSchema } from "../zod"; -const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app }) { - const [getAppData, setAppData] = useAppContextWithSchema(); +const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType }) { + const [getAppData, setAppData, LockedIcon, disabled] = useAppContextWithSchema(); const trackingId = getAppData("trackingId"); - const [enabled, setEnabled] = useState(getAppData("enabled")); + const { enabled, updateEnabled } = useIsAppEnabled(app); return ( { - if (!e) { - setEnabled(false); - } else { - setEnabled(true); - } + updateEnabled(e); }} - switchChecked={enabled}> + switchChecked={enabled} + teamId={eventType.team?.id || undefined}> { setAppData("trackingId", e.target.value); }} diff --git a/packages/app-store/ga4/api/add.ts b/packages/app-store/ga4/api/add.ts index 44d56bb0fa..1e4629d298 100644 --- a/packages/app-store/ga4/api/add.ts +++ b/packages/app-store/ga4/api/add.ts @@ -9,8 +9,8 @@ const handler: AppDeclarativeHandler = { slug: appConfig.slug, supportsMultipleInstalls: false, handlerType: "add", - createCredential: ({ appType, user, slug }) => - createDefaultInstallation({ appType, userId: user.id, slug, key: {} }), + createCredential: ({ appType, user, slug, teamId }) => + createDefaultInstallation({ appType, userId: user.id, slug, key: {}, teamId }), }; export default handler; diff --git a/packages/app-store/ga4/components/EventTypeAppCardInterface.tsx b/packages/app-store/ga4/components/EventTypeAppCardInterface.tsx index d86dc6a17c..44e3ea5c72 100644 --- a/packages/app-store/ga4/components/EventTypeAppCardInterface.tsx +++ b/packages/app-store/ga4/components/EventTypeAppCardInterface.tsx @@ -1,32 +1,31 @@ -import { useState } from "react"; - import { useAppContextWithSchema } from "@calcom/app-store/EventTypeAppContext"; import AppCard from "@calcom/app-store/_components/AppCard"; +import useIsAppEnabled from "@calcom/app-store/_utils/useIsAppEnabled"; import type { EventTypeAppCardComponent } from "@calcom/app-store/types"; import { TextField } from "@calcom/ui"; import type { appDataSchema } from "../zod"; -const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app }) { - const [getAppData, setAppData] = useAppContextWithSchema(); +const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType }) { + const [getAppData, setAppData, LockedIcon, disabled] = useAppContextWithSchema(); const trackingId = getAppData("trackingId"); - const [enabled, setEnabled] = useState(getAppData("enabled")); + const { enabled, updateEnabled } = useIsAppEnabled(app); return ( { - if (!e) { - setEnabled(false); - } else { - setEnabled(true); - } + updateEnabled(e); }} - switchChecked={enabled}> + switchChecked={enabled} + teamId={eventType.team?.id || undefined}> { setAppData("trackingId", e.target.value); }} diff --git a/packages/app-store/giphy/api/add.ts b/packages/app-store/giphy/api/add.ts index 2e8ee75c09..01a9b4af8c 100644 --- a/packages/app-store/giphy/api/add.ts +++ b/packages/app-store/giphy/api/add.ts @@ -14,11 +14,14 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) return res.status(401).json({ message: "You must be logged in to do this" }); } const appType = "giphy_other"; + const credentialOwner = req.query.teamId + ? { teamId: Number(req.query.teamId) } + : { userId: req.session.user.id }; try { const alreadyInstalled = await prisma.credential.findFirst({ where: { type: appType, - userId: req.session.user.id, + ...credentialOwner, }, }); if (alreadyInstalled) { @@ -28,7 +31,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) data: { type: appType, key: {}, - userId: req.session.user.id, + ...credentialOwner, appId: "giphy", }, }); diff --git a/packages/app-store/giphy/components/EventTypeAppCardInterface.tsx b/packages/app-store/giphy/components/EventTypeAppCardInterface.tsx index ad5e54a857..970b7bc099 100644 --- a/packages/app-store/giphy/components/EventTypeAppCardInterface.tsx +++ b/packages/app-store/giphy/components/EventTypeAppCardInterface.tsx @@ -1,17 +1,17 @@ -import { useState } from "react"; - import { useAppContextWithSchema } from "@calcom/app-store/EventTypeAppContext"; import AppCard from "@calcom/app-store/_components/AppCard"; +import useIsAppEnabled from "@calcom/app-store/_utils/useIsAppEnabled"; import { SelectGifInput } from "@calcom/app-store/giphy/components"; import type { EventTypeAppCardComponent } from "@calcom/app-store/types"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import type { appDataSchema } from "../zod"; -const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app }) { - const [getAppData, setAppData] = useAppContextWithSchema(); +const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType }) { + const [getAppData, setAppData, LockedIcon, disabled] = useAppContextWithSchema(); const thankYouPage = getAppData("thankYouPage"); - const [showGifSelection, setShowGifSelection] = useState(getAppData("enabled")); + const { enabled: showGifSelection, updateEnabled: setShowGifSelection } = useIsAppEnabled(app); + const { t } = useLocale(); return ( @@ -19,13 +19,17 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ setAppData={setAppData} app={app} description={t("confirmation_page_gif")} + disableSwitch={disabled} + LockedIcon={LockedIcon} switchOnClick={(e) => { setShowGifSelection(e); }} - switchChecked={showGifSelection}> + switchChecked={showGifSelection} + teamId={eventType.team?.id || undefined}> {showGifSelection && ( { setAppData("thankYouPage", url); }} diff --git a/packages/app-store/giphy/components/SelectGifInput.tsx b/packages/app-store/giphy/components/SelectGifInput.tsx index 2bc6dcaaaa..0583723fcb 100644 --- a/packages/app-store/giphy/components/SelectGifInput.tsx +++ b/packages/app-store/giphy/components/SelectGifInput.tsx @@ -9,6 +9,7 @@ import { SearchDialog } from "./SearchDialog"; interface ISelectGifInput { defaultValue?: string | null; onChange: (url: string) => void; + disabled?: boolean; } export default function SelectGifInput(props: ISelectGifInput) { const { t } = useLocale(); @@ -24,11 +25,21 @@ export default function SelectGifInput(props: ISelectGifInput) { )}
    {selectedGif ? ( - ) : ( - )} @@ -41,7 +52,8 @@ export default function SelectGifInput(props: ISelectGifInput) { onClick={() => { setSelectedGif(""); props.onChange(""); - }}> + }} + disabled={props.disabled}> {t("remove")} )} diff --git a/packages/app-store/gtm/api/add.ts b/packages/app-store/gtm/api/add.ts index 9a4afb9b2c..6ab3106577 100644 --- a/packages/app-store/gtm/api/add.ts +++ b/packages/app-store/gtm/api/add.ts @@ -9,8 +9,8 @@ const handler: AppDeclarativeHandler = { slug: appConfig.slug, supportsMultipleInstalls: false, handlerType: "add", - createCredential: ({ appType, user, slug }) => - createDefaultInstallation({ appType, userId: user.id, slug, key: {} }), + createCredential: ({ appType, user, slug, teamId }) => + createDefaultInstallation({ appType, userId: user.id, slug, key: {}, teamId }), }; export default handler; diff --git a/packages/app-store/gtm/components/EventTypeAppCardInterface.tsx b/packages/app-store/gtm/components/EventTypeAppCardInterface.tsx index d86dc6a17c..44e3ea5c72 100644 --- a/packages/app-store/gtm/components/EventTypeAppCardInterface.tsx +++ b/packages/app-store/gtm/components/EventTypeAppCardInterface.tsx @@ -1,32 +1,31 @@ -import { useState } from "react"; - import { useAppContextWithSchema } from "@calcom/app-store/EventTypeAppContext"; import AppCard from "@calcom/app-store/_components/AppCard"; +import useIsAppEnabled from "@calcom/app-store/_utils/useIsAppEnabled"; import type { EventTypeAppCardComponent } from "@calcom/app-store/types"; import { TextField } from "@calcom/ui"; import type { appDataSchema } from "../zod"; -const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app }) { - const [getAppData, setAppData] = useAppContextWithSchema(); +const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType }) { + const [getAppData, setAppData, LockedIcon, disabled] = useAppContextWithSchema(); const trackingId = getAppData("trackingId"); - const [enabled, setEnabled] = useState(getAppData("enabled")); + const { enabled, updateEnabled } = useIsAppEnabled(app); return ( { - if (!e) { - setEnabled(false); - } else { - setEnabled(true); - } + updateEnabled(e); }} - switchChecked={enabled}> + switchChecked={enabled} + teamId={eventType.team?.id || undefined}> { setAppData("trackingId", e.target.value); }} diff --git a/packages/app-store/hubspot/api/callback.ts b/packages/app-store/hubspot/api/callback.ts index 4b02a07d23..8d31b7922c 100644 --- a/packages/app-store/hubspot/api/callback.ts +++ b/packages/app-store/hubspot/api/callback.ts @@ -4,8 +4,8 @@ import type { NextApiRequest, NextApiResponse } from "next"; import { WEBAPP_URL } from "@calcom/lib/constants"; import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl"; -import prisma from "@calcom/prisma"; +import createOAuthAppCredential from "../../_utils/createOAuthAppCredential"; import { decodeOAuthState } from "../../_utils/decodeOAuthState"; import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug"; import getInstalledAppPath from "../../_utils/getInstalledAppPath"; @@ -46,15 +46,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) // set expiry date as offset from current time. hubspotToken.expiryDate = Math.round(Date.now() + hubspotToken.expiresIn * 1000); - await prisma.credential.create({ - data: { - type: "hubspot_other_calendar", - // eslint-disable-next-line @typescript-eslint/no-explicit-any - key: hubspotToken as any, - userId: req.session.user.id, - appId: "hubspot", - }, - }); + + createOAuthAppCredential({ appId: "hubspot", type: "hubspot_other_calendar" }, hubspotToken as any, req); const state = decodeOAuthState(req); res.redirect( diff --git a/packages/app-store/index.ts b/packages/app-store/index.ts index ea25764aa5..54168d2e8d 100644 --- a/packages/app-store/index.ts +++ b/packages/app-store/index.ts @@ -1,6 +1,7 @@ const appStore = { // example: () => import("./example"), applecalendar: () => import("./applecalendar"), + aroundvideo: () => import("./around"), caldavcalendar: () => import("./caldavcalendar"), closecom: () => import("./closecom"), dailyvideo: () => import("./dailyvideo"), diff --git a/packages/app-store/locations.ts b/packages/app-store/locations.ts index 95055bfaba..b72de3fbb8 100644 --- a/packages/app-store/locations.ts +++ b/packages/app-store/locations.ts @@ -165,6 +165,7 @@ export type LocationObject = { type: string; address?: string; displayLocationPublicly?: boolean; + credentialId?: number; } & Partial< Record<"address" | "attendeeAddress" | "link" | "hostPhoneNumber" | "hostDefault" | "phone", string> >; diff --git a/packages/app-store/metapixel/api/add.ts b/packages/app-store/metapixel/api/add.ts index 9a4afb9b2c..6ab3106577 100644 --- a/packages/app-store/metapixel/api/add.ts +++ b/packages/app-store/metapixel/api/add.ts @@ -9,8 +9,8 @@ const handler: AppDeclarativeHandler = { slug: appConfig.slug, supportsMultipleInstalls: false, handlerType: "add", - createCredential: ({ appType, user, slug }) => - createDefaultInstallation({ appType, userId: user.id, slug, key: {} }), + createCredential: ({ appType, user, slug, teamId }) => + createDefaultInstallation({ appType, userId: user.id, slug, key: {}, teamId }), }; export default handler; diff --git a/packages/app-store/metapixel/components/EventTypeAppCardInterface.tsx b/packages/app-store/metapixel/components/EventTypeAppCardInterface.tsx index 58cf89df67..d8d3940c84 100644 --- a/packages/app-store/metapixel/components/EventTypeAppCardInterface.tsx +++ b/packages/app-store/metapixel/components/EventTypeAppCardInterface.tsx @@ -1,22 +1,29 @@ -import { useState } from "react"; - import { useAppContextWithSchema } from "@calcom/app-store/EventTypeAppContext"; import AppCard from "@calcom/app-store/_components/AppCard"; +import useIsAppEnabled from "@calcom/app-store/_utils/useIsAppEnabled"; import type { EventTypeAppCardComponent } from "@calcom/app-store/types"; import { TextField } from "@calcom/ui"; import type { appDataSchema } from "../zod"; -const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app }) { - const [getAppData, setAppData] = useAppContextWithSchema(); +const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType }) { + const [getAppData, setAppData, LockedIcon, disabled] = useAppContextWithSchema(); const trackingId = getAppData("trackingId"); - const [enabled, setEnabled] = useState(getAppData("enabled")); + const { enabled, updateEnabled } = useIsAppEnabled(app); return ( - + { setAppData("trackingId", e.target.value); }} diff --git a/packages/app-store/mirotalk/api/add.ts b/packages/app-store/mirotalk/api/add.ts index 9a4afb9b2c..6ab3106577 100644 --- a/packages/app-store/mirotalk/api/add.ts +++ b/packages/app-store/mirotalk/api/add.ts @@ -9,8 +9,8 @@ const handler: AppDeclarativeHandler = { slug: appConfig.slug, supportsMultipleInstalls: false, handlerType: "add", - createCredential: ({ appType, user, slug }) => - createDefaultInstallation({ appType, userId: user.id, slug, key: {} }), + createCredential: ({ appType, user, slug, teamId }) => + createDefaultInstallation({ appType, userId: user.id, slug, key: {}, teamId }), }; export default handler; diff --git a/packages/app-store/n8n/api/add.ts b/packages/app-store/n8n/api/add.ts index 49ac6be693..d3206b9b1b 100644 --- a/packages/app-store/n8n/api/add.ts +++ b/packages/app-store/n8n/api/add.ts @@ -13,8 +13,8 @@ const handler: AppDeclarativeHandler = { url: "https://n8n.io/integrations/cal-trigger/", newTab: true, }, - createCredential: ({ appType, user, slug }) => - createDefaultInstallation({ appType, userId: user.id, slug, key: {} }), + createCredential: ({ appType, user, slug, teamId }) => + createDefaultInstallation({ appType, userId: user.id, slug, key: {}, teamId }), }; export default handler; diff --git a/packages/app-store/office365video/api/callback.ts b/packages/app-store/office365video/api/callback.ts index cad0632511..ff69b8a85b 100644 --- a/packages/app-store/office365video/api/callback.ts +++ b/packages/app-store/office365video/api/callback.ts @@ -4,6 +4,7 @@ import { WEBAPP_URL } from "@calcom/lib/constants"; import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl"; import prisma from "@calcom/prisma"; +import createOAuthAppCredential from "../../_utils/createOAuthAppCredential"; import { decodeOAuthState } from "../../_utils/decodeOAuthState"; import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug"; import getInstalledAppPath from "../../_utils/getInstalledAppPath"; @@ -92,14 +93,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) await prisma.credential.deleteMany({ where: { id: { in: credentialIdsToDelete }, userId } }); } - await prisma.credential.create({ - data: { - type: "office365_video", - key: responseBody, - userId, - appId: "msteams", - }, - }); + createOAuthAppCredential({ appId: "msteams", type: "office365_video" }, responseBody, req); const state = decodeOAuthState(req); return res.redirect( diff --git a/packages/app-store/pipedream/api/add.ts b/packages/app-store/pipedream/api/add.ts index 269ab32227..3cb370772e 100644 --- a/packages/app-store/pipedream/api/add.ts +++ b/packages/app-store/pipedream/api/add.ts @@ -13,8 +13,8 @@ const handler: AppDeclarativeHandler = { newTab: true, url: "https://pipedream.com/apps/cal-com", }, - createCredential: ({ appType, user, slug }) => - createDefaultInstallation({ appType, userId: user.id, slug, key: {} }), + createCredential: ({ appType, user, slug, teamId }) => + createDefaultInstallation({ appType, userId: user.id, slug, key: {}, teamId }), }; export default handler; diff --git a/packages/app-store/plausible/api/add.ts b/packages/app-store/plausible/api/add.ts index 44d56bb0fa..1e4629d298 100644 --- a/packages/app-store/plausible/api/add.ts +++ b/packages/app-store/plausible/api/add.ts @@ -9,8 +9,8 @@ const handler: AppDeclarativeHandler = { slug: appConfig.slug, supportsMultipleInstalls: false, handlerType: "add", - createCredential: ({ appType, user, slug }) => - createDefaultInstallation({ appType, userId: user.id, slug, key: {} }), + createCredential: ({ appType, user, slug, teamId }) => + createDefaultInstallation({ appType, userId: user.id, slug, key: {}, teamId }), }; export default handler; diff --git a/packages/app-store/plausible/components/EventTypeAppCardInterface.tsx b/packages/app-store/plausible/components/EventTypeAppCardInterface.tsx index 33cb11f4d0..40645726b9 100644 --- a/packages/app-store/plausible/components/EventTypeAppCardInterface.tsx +++ b/packages/app-store/plausible/components/EventTypeAppCardInterface.tsx @@ -1,40 +1,40 @@ -import { useState } from "react"; - import { useAppContextWithSchema } from "@calcom/app-store/EventTypeAppContext"; import AppCard from "@calcom/app-store/_components/AppCard"; +import useIsAppEnabled from "@calcom/app-store/_utils/useIsAppEnabled"; import type { EventTypeAppCardComponent } from "@calcom/app-store/types"; import { TextField } from "@calcom/ui"; import type { appDataSchema } from "../zod"; -const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app }) { - const [getAppData, setAppData] = useAppContextWithSchema(); +const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType }) { + const [getAppData, setAppData, LockedIcon, disabled] = useAppContextWithSchema(); const plausibleUrl = getAppData("PLAUSIBLE_URL"); const trackingId = getAppData("trackingId"); - const [enabled, setEnabled] = useState(getAppData("enabled")); + const { enabled, updateEnabled } = useIsAppEnabled(app); return ( { - if (!e) { - setEnabled(false); - } else { - setEnabled(true); - } + updateEnabled(e); }} - switchChecked={enabled}> + switchChecked={enabled} + teamId={eventType.team?.id || undefined}> { setAppData("PLAUSIBLE_URL", e.target.value); }} /> - createDefaultInstallation({ appType, userId: user.id, slug, key: {} }), + createCredential: ({ appType, user, slug, teamId }) => + createDefaultInstallation({ appType, userId: user.id, slug, key: {}, teamId }), }; export default handler; diff --git a/packages/app-store/qr_code/components/EventTypeAppCardInterface.tsx b/packages/app-store/qr_code/components/EventTypeAppCardInterface.tsx index fbb89118a2..cdf6de5083 100644 --- a/packages/app-store/qr_code/components/EventTypeAppCardInterface.tsx +++ b/packages/app-store/qr_code/components/EventTypeAppCardInterface.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import { useAppContextWithSchema } from "@calcom/app-store/EventTypeAppContext"; import AppCard from "@calcom/app-store/_components/AppCard"; +import useIsAppEnabled from "@calcom/app-store/_utils/useIsAppEnabled"; import type { EventTypeAppCardComponent } from "@calcom/app-store/types"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { Tooltip, TextField } from "@calcom/ui"; @@ -10,9 +11,9 @@ import type { appDataSchema } from "../zod"; const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ eventType, app }) { const { t } = useLocale(); - const [getAppData, setAppData] = useAppContextWithSchema(); - const [enabled, setEnabled] = useState(getAppData("enabled")); + const [getAppData, setAppData, LockedIcon, disabled] = useAppContextWithSchema(); const [additionalParameters, setAdditionalParameters] = useState(""); + const { enabled, updateEnabled } = useIsAppEnabled(app); const query = additionalParameters !== "" ? `?${additionalParameters}` : ""; const eventTypeURL = eventType.URL + query; @@ -38,18 +39,18 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ { - if (!e) { - setEnabled(false); - } else { - setEnabled(true); - } + updateEnabled(e); }} - switchChecked={enabled}> + switchChecked={enabled} + teamId={eventType.team?.id || undefined}>
    setAdditionalParameters(e.target.value)} label={t("additional_url_parameters")} diff --git a/packages/app-store/raycast/api/add.ts b/packages/app-store/raycast/api/add.ts index 8b788d621c..042a1d7a1e 100644 --- a/packages/app-store/raycast/api/add.ts +++ b/packages/app-store/raycast/api/add.ts @@ -12,8 +12,8 @@ const handler: AppDeclarativeHandler = { redirect: { url: "raycast://extensions/eluce2/cal-com-share-meeting-links?source=webstore", }, - createCredential: ({ appType, user, slug }) => - createDefaultInstallation({ appType, userId: user.id, slug, key: {} }), + createCredential: ({ appType, user, slug, teamId }) => + createDefaultInstallation({ appType, userId: user.id, slug, key: {}, teamId }), }; export default handler; diff --git a/packages/app-store/routing-forms/api/add.ts b/packages/app-store/routing-forms/api/add.ts index b80e0605f9..5037d70571 100644 --- a/packages/app-store/routing-forms/api/add.ts +++ b/packages/app-store/routing-forms/api/add.ts @@ -9,12 +9,12 @@ const handler: AppDeclarativeHandler = { slug: appConfig.slug, supportsMultipleInstalls: false, handlerType: "add", - createCredential: async ({ user, appType, slug }) => { + createCredential: async ({ user, appType, slug, teamId }) => { return await prisma.credential.create({ data: { type: appType, key: {}, - userId: user.id, + ...(teamId ? { teamId } : { userId: user.id }), appId: slug, }, }); diff --git a/packages/app-store/routing-forms/components/FormActions.tsx b/packages/app-store/routing-forms/components/FormActions.tsx index d5199af869..6abba3f51b 100644 --- a/packages/app-store/routing-forms/components/FormActions.tsx +++ b/packages/app-store/routing-forms/components/FormActions.tsx @@ -8,7 +8,7 @@ import { v4 as uuidv4 } from "uuid"; import { z } from "zod"; import { classNames } from "@calcom/lib"; -import { CAL_URL } from "@calcom/lib/constants"; +import getOrgAwareUrlOnClient from "@calcom/lib/getOrgAwareUrl"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { trpc } from "@calcom/trpc/react"; import type { ButtonProps } from "@calcom/ui"; @@ -195,7 +195,6 @@ function Dialogs({ deleteDialogFormId: string | null; }) { const utils = trpc.useContext(); - const router = useRouter(); const { t } = useLocale(); const deleteMutation = trpc.viewer.appRoutingForms.deleteForm.useMutation({ onMutate: async ({ id: formId }) => { @@ -402,11 +401,11 @@ export const FormAction = forwardRef(function FormAction { - redirectUrl += `&${getFieldIdentifier(field)}={Recalled_Response_For_This_Field}`; + relativeRedirectUrl += `&${getFieldIdentifier(field)}={Recalled_Response_For_This_Field}`; }); const { t } = useLocale(); @@ -416,12 +415,12 @@ export const FormAction = forwardRef(function FormAction["render"] } > = { preview: { - href: formLink, + href: formRelativeLink, }, copyLink: { onClick: () => { showToast(t("link_copied"), "success"); - navigator.clipboard.writeText(formLink); + navigator.clipboard.writeText(getOrgAwareUrlOnClient(formRelativeLink)); }, }, duplicate: { @@ -448,7 +447,7 @@ export const FormAction = forwardRef(function FormAction { - navigator.clipboard.writeText(redirectUrl); + navigator.clipboard.writeText(getOrgAwareUrlOnClient(relativeRedirectUrl)); showToast(t("typeform_redirect_url_copied"), "success"); }, }, diff --git a/packages/app-store/routing-forms/playwright/tests/basic.e2e.ts b/packages/app-store/routing-forms/playwright/tests/basic.e2e.ts index 94efe1a227..b452da3060 100644 --- a/packages/app-store/routing-forms/playwright/tests/basic.e2e.ts +++ b/packages/app-store/routing-forms/playwright/tests/basic.e2e.ts @@ -186,6 +186,7 @@ test.describe("Routing Forms", () => { // Install app await page.goto(`/apps/routing-forms`); await page.click('[data-testid="install-app-button"]'); + (await page.waitForSelector('[data-testid="install-app-button-personal"]')).click(); await page.waitForURL((url) => url.pathname === `/apps/routing-forms/forms`); }); @@ -217,6 +218,7 @@ test.describe("Routing Forms", () => { // Install app await page.goto(`/apps/routing-forms`); await page.click('[data-testid="install-app-button"]'); + (await page.waitForSelector('[data-testid="install-app-button-personal"]')).click(); await page.waitForURL((url) => url.pathname === `/apps/routing-forms/forms`); return user; }; diff --git a/packages/app-store/salesforce/api/callback.ts b/packages/app-store/salesforce/api/callback.ts index 51aed420ea..f7d885309f 100644 --- a/packages/app-store/salesforce/api/callback.ts +++ b/packages/app-store/salesforce/api/callback.ts @@ -3,8 +3,8 @@ import type { NextApiRequest, NextApiResponse } from "next"; import { WEBAPP_URL } from "@calcom/lib/constants"; import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl"; -import prisma from "@calcom/prisma"; +import createOAuthAppCredential from "../../_utils/createOAuthAppCredential"; import { decodeOAuthState } from "../../_utils/decodeOAuthState"; import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug"; import getInstalledAppPath from "../../_utils/getInstalledAppPath"; @@ -38,15 +38,11 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) const salesforceTokenInfo = await conn.oauth2.requestToken(code as string); - await prisma.credential.create({ - data: { - type: "salesforce_other_calendar", - // eslint-disable-next-line @typescript-eslint/no-explicit-any - key: salesforceTokenInfo as any, - userId: req.session.user.id, - appId: "salesforce", - }, - }); + createOAuthAppCredential( + { appId: "salesforce", type: "salesforce_other_calendar" }, + salesforceTokenInfo as any, + req + ); const state = decodeOAuthState(req); res.redirect( diff --git a/packages/app-store/signal/api/add.ts b/packages/app-store/signal/api/add.ts index 44d56bb0fa..1e4629d298 100644 --- a/packages/app-store/signal/api/add.ts +++ b/packages/app-store/signal/api/add.ts @@ -9,8 +9,8 @@ const handler: AppDeclarativeHandler = { slug: appConfig.slug, supportsMultipleInstalls: false, handlerType: "add", - createCredential: ({ appType, user, slug }) => - createDefaultInstallation({ appType, userId: user.id, slug, key: {} }), + createCredential: ({ appType, user, slug, teamId }) => + createDefaultInstallation({ appType, userId: user.id, slug, key: {}, teamId }), }; export default handler; diff --git a/packages/app-store/sirius_video/api/add.ts b/packages/app-store/sirius_video/api/add.ts index 44d56bb0fa..1e4629d298 100644 --- a/packages/app-store/sirius_video/api/add.ts +++ b/packages/app-store/sirius_video/api/add.ts @@ -9,8 +9,8 @@ const handler: AppDeclarativeHandler = { slug: appConfig.slug, supportsMultipleInstalls: false, handlerType: "add", - createCredential: ({ appType, user, slug }) => - createDefaultInstallation({ appType, userId: user.id, slug, key: {} }), + createCredential: ({ appType, user, slug, teamId }) => + createDefaultInstallation({ appType, userId: user.id, slug, key: {}, teamId }), }; export default handler; diff --git a/packages/app-store/stripepayment/api/callback.ts b/packages/app-store/stripepayment/api/callback.ts index 7386d6c50e..cbcc593129 100644 --- a/packages/app-store/stripepayment/api/callback.ts +++ b/packages/app-store/stripepayment/api/callback.ts @@ -2,8 +2,8 @@ import type { Prisma } from "@prisma/client"; import type { NextApiRequest, NextApiResponse } from "next"; import { stringify } from "querystring"; -import prisma from "@calcom/prisma"; - +import createOAuthAppCredential from "../../_utils/createOAuthAppCredential"; +import { decodeOAuthState } from "../../_utils/decodeOAuthState"; import getInstalledAppPath from "../../_utils/getInstalledAppPath"; import type { StripeData } from "../lib/server"; import stripe from "../lib/server"; @@ -31,6 +31,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) return res.status(401).json({ message: "You must be logged in to do this" }); } + const state = decodeOAuthState(req); + const response = await stripe.oauth.token({ grant_type: "authorization_code", code: code?.toString(), @@ -42,14 +44,11 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) data["default_currency"] = account.default_currency; } - await prisma.credential.create({ - data: { - type: "stripe_payment", - key: data as unknown as Prisma.InputJsonObject, - userId: req.session.user.id, - appId: "stripe", - }, - }); + createOAuthAppCredential( + { appId: "stripe", type: "stripe_payment" }, + data as unknown as Prisma.InputJsonObject, + req + ); const returnTo = getReturnToValueFromQueryState(req); res.redirect(returnTo || getInstalledAppPath({ variant: "payment", slug: "stripe" })); diff --git a/packages/app-store/stripepayment/components/EventTypeAppCardInterface.tsx b/packages/app-store/stripepayment/components/EventTypeAppCardInterface.tsx index f17473c7cc..972d68bb19 100644 --- a/packages/app-store/stripepayment/components/EventTypeAppCardInterface.tsx +++ b/packages/app-store/stripepayment/components/EventTypeAppCardInterface.tsx @@ -1,8 +1,8 @@ import { useRouter } from "next/router"; -import { useState } from "react"; import { useAppContextWithSchema } from "@calcom/app-store/EventTypeAppContext"; import AppCard from "@calcom/app-store/_components/AppCard"; +import useIsAppEnabled from "@calcom/app-store/_utils/useIsAppEnabled"; import type { EventTypeAppCardComponent } from "@calcom/app-store/types"; import { WEBAPP_URL } from "@calcom/lib/constants"; import { useLocale } from "@calcom/lib/hooks/useLocale"; @@ -15,12 +15,13 @@ type Option = { value: string; label: string }; const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType }) { const { asPath } = useRouter(); - const [getAppData, setAppData] = useAppContextWithSchema(); + const [getAppData, setAppData, LockedIcon, disabled] = useAppContextWithSchema(); const price = getAppData("price"); const currency = getAppData("currency"); const paymentOption = getAppData("paymentOption"); const paymentOptionSelectValue = paymentOptions.find((option) => paymentOption === option.value); - const [requirePayment, setRequirePayment] = useState(getAppData("enabled")); + const { enabled: requirePayment, updateEnabled: setRequirePayment } = useIsAppEnabled(app); + const { t } = useLocale(); const recurringEventDefined = eventType.recurringEvent?.count !== undefined; const seatsEnabled = !!eventType.seatsPerTimeSlot; @@ -39,6 +40,8 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ returnTo={WEBAPP_URL + asPath} setAppData={setAppData} app={app} + disableSwitch={disabled} + LockedIcon={LockedIcon} switchChecked={requirePayment} switchOnClick={(enabled) => { setRequirePayment(enabled); @@ -55,48 +58,48 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ }> <> - {recurringEventDefined ? ( + {recurringEventDefined && ( - ) : ( - requirePayment && ( - <> -
    - {currency ? getCurrencySymbol("en", currency) : ""}} - addOnClassname="h-[38px]" - step="0.01" - min="0.5" - type="number" - required - placeholder="Price" - onChange={(e) => { - setAppData("price", Number(e.target.value) * 100); - }} - value={price > 0 ? price / 100 : undefined} - /> - - defaultValue={ - paymentOptionSelectValue - ? { ...paymentOptionSelectValue, label: t(paymentOptionSelectValue.label) } - : { ...paymentOptions[0], label: t(paymentOptions[0].label) } - } - options={paymentOptions.map((option) => { - return { ...option, label: t(option.label) || option.label }; - })} - onChange={(input) => { - if (input) setAppData("paymentOption", input.value); - }} - className="mb-1 h-[38px] w-full" - isDisabled={seatsEnabled} - /> -
    - {seatsEnabled && paymentOption === "HOLD" && ( - - )} - - ) + )} + {!recurringEventDefined && requirePayment && ( + <> +
    + {currency ? getCurrencySymbol("en", currency) : ""}} + addOnClassname="h-[38px]" + step="0.01" + min="0.5" + type="number" + required + placeholder="Price" + disabled={disabled} + onChange={(e) => { + setAppData("price", Number(e.target.value) * 100); + }} + value={price > 0 ? price / 100 : undefined} + /> + + defaultValue={ + paymentOptionSelectValue + ? { ...paymentOptionSelectValue, label: t(paymentOptionSelectValue.label) } + : { ...paymentOptions[0], label: t(paymentOptions[0].label) } + } + options={paymentOptions.map((option) => { + return { ...option, label: t(option.label) || option.label }; + })} + onChange={(input) => { + if (input) setAppData("paymentOption", input.value); + }} + className="mb-1 h-[38px] w-full" + isDisabled={seatsEnabled || disabled} + /> +
    + {seatsEnabled && paymentOption === "HOLD" && ( + + )} + )} diff --git a/packages/app-store/tandemvideo/api/callback.ts b/packages/app-store/tandemvideo/api/callback.ts index b9b32cee5c..f213a328ae 100644 --- a/packages/app-store/tandemvideo/api/callback.ts +++ b/packages/app-store/tandemvideo/api/callback.ts @@ -2,6 +2,7 @@ import type { NextApiRequest, NextApiResponse } from "next"; import prisma from "@calcom/prisma"; +import createOAuthAppCredential from "../../_utils/createOAuthAppCredential"; import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug"; import getInstalledAppPath from "../../_utils/getInstalledAppPath"; @@ -59,21 +60,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) responseBody.expiry_date = Math.round(Date.now() + responseBody.expires_in * 1000); delete responseBody.expires_in; - await prisma.user.update({ - where: { - id: req.session?.user.id, - }, - data: { - credentials: { - create: { - type: "tandem_video", - key: responseBody, - appId: "tandem", - }, - }, - }, - }); - } + createOAuthAppCredential({ appId: "tandem", type: "tandem_video" }, responseBody, req); - res.redirect(getInstalledAppPath({ variant: "conferencing", slug: "tandem" })); + res.redirect(getInstalledAppPath({ variant: "conferencing", slug: "tandem" })); + } } diff --git a/packages/app-store/telegram/api/add.ts b/packages/app-store/telegram/api/add.ts index 44d56bb0fa..1e4629d298 100644 --- a/packages/app-store/telegram/api/add.ts +++ b/packages/app-store/telegram/api/add.ts @@ -9,8 +9,8 @@ const handler: AppDeclarativeHandler = { slug: appConfig.slug, supportsMultipleInstalls: false, handlerType: "add", - createCredential: ({ appType, user, slug }) => - createDefaultInstallation({ appType, userId: user.id, slug, key: {} }), + createCredential: ({ appType, user, slug, teamId }) => + createDefaultInstallation({ appType, userId: user.id, slug, key: {}, teamId }), }; export default handler; diff --git a/packages/app-store/templates/basic/api/add.ts b/packages/app-store/templates/basic/api/add.ts index 9a4afb9b2c..6ab3106577 100644 --- a/packages/app-store/templates/basic/api/add.ts +++ b/packages/app-store/templates/basic/api/add.ts @@ -9,8 +9,8 @@ const handler: AppDeclarativeHandler = { slug: appConfig.slug, supportsMultipleInstalls: false, handlerType: "add", - createCredential: ({ appType, user, slug }) => - createDefaultInstallation({ appType, userId: user.id, slug, key: {} }), + createCredential: ({ appType, user, slug, teamId }) => + createDefaultInstallation({ appType, userId: user.id, slug, key: {}, teamId }), }; export default handler; diff --git a/packages/app-store/templates/booking-pages-tag/api/add.ts b/packages/app-store/templates/booking-pages-tag/api/add.ts index 9a4afb9b2c..6ab3106577 100644 --- a/packages/app-store/templates/booking-pages-tag/api/add.ts +++ b/packages/app-store/templates/booking-pages-tag/api/add.ts @@ -9,8 +9,8 @@ const handler: AppDeclarativeHandler = { slug: appConfig.slug, supportsMultipleInstalls: false, handlerType: "add", - createCredential: ({ appType, user, slug }) => - createDefaultInstallation({ appType, userId: user.id, slug, key: {} }), + createCredential: ({ appType, user, slug, teamId }) => + createDefaultInstallation({ appType, userId: user.id, slug, key: {}, teamId }), }; export default handler; diff --git a/packages/app-store/templates/booking-pages-tag/components/EventTypeAppCardInterface.tsx b/packages/app-store/templates/booking-pages-tag/components/EventTypeAppCardInterface.tsx index d86dc6a17c..ae14000501 100644 --- a/packages/app-store/templates/booking-pages-tag/components/EventTypeAppCardInterface.tsx +++ b/packages/app-store/templates/booking-pages-tag/components/EventTypeAppCardInterface.tsx @@ -7,7 +7,7 @@ import { TextField } from "@calcom/ui"; import type { appDataSchema } from "../zod"; -const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app }) { +const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType }) { const [getAppData, setAppData] = useAppContextWithSchema(); const trackingId = getAppData("trackingId"); const [enabled, setEnabled] = useState(getAppData("enabled")); @@ -23,7 +23,8 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ setEnabled(true); } }} - switchChecked={enabled}> + switchChecked={enabled} + teamId={eventType.team?.id || undefined}> - createDefaultInstallation({ appType, userId: user.id, slug, key: {} }), + createCredential: ({ appType, user, slug, teamId }) => + createDefaultInstallation({ appType, userId: user.id, slug, key: {}, teamId }), }; export default handler; diff --git a/packages/app-store/templates/event-type-app-card/components/EventTypeAppCardInterface.tsx b/packages/app-store/templates/event-type-app-card/components/EventTypeAppCardInterface.tsx index c9e524f889..85f4a6a504 100644 --- a/packages/app-store/templates/event-type-app-card/components/EventTypeAppCardInterface.tsx +++ b/packages/app-store/templates/event-type-app-card/components/EventTypeAppCardInterface.tsx @@ -1,31 +1,33 @@ -import { useState } from "react"; - import { useAppContextWithSchema } from "@calcom/app-store/EventTypeAppContext"; import AppCard from "@calcom/app-store/_components/AppCard"; +import useIsAppEnabled from "@calcom/app-store/_utils/useIsAppEnabled"; import type { EventTypeAppCardComponent } from "@calcom/app-store/types"; import { Sunrise, Sunset } from "@calcom/ui/components/icon"; import type { appDataSchema } from "../zod"; const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ eventType, app }) { - const [getAppData, setAppData] = useAppContextWithSchema(); + const [getAppData, setAppData, LockedIcon, disabled] = useAppContextWithSchema(); const isSunrise = getAppData("isSunrise"); - const [enabled, setEnabled] = useState(getAppData("enabled")); + const { enabled, updateEnabled } = useIsAppEnabled(app); return ( { if (!e) { - setEnabled(false); + updateEnabled(false); setAppData("isSunrise", false); } else { - setEnabled(true); + updateEnabled(true); setAppData("isSunrise", true); } }} - switchChecked={enabled}> + switchChecked={enabled} + teamId={eventType.team?.id || undefined}>
    {isSunrise ? : }I am an AppCard for diff --git a/packages/app-store/templates/event-type-location-video-static/api/add.ts b/packages/app-store/templates/event-type-location-video-static/api/add.ts index 9a4afb9b2c..6ab3106577 100644 --- a/packages/app-store/templates/event-type-location-video-static/api/add.ts +++ b/packages/app-store/templates/event-type-location-video-static/api/add.ts @@ -9,8 +9,8 @@ const handler: AppDeclarativeHandler = { slug: appConfig.slug, supportsMultipleInstalls: false, handlerType: "add", - createCredential: ({ appType, user, slug }) => - createDefaultInstallation({ appType, userId: user.id, slug, key: {} }), + createCredential: ({ appType, user, slug, teamId }) => + createDefaultInstallation({ appType, userId: user.id, slug, key: {}, teamId }), }; export default handler; diff --git a/packages/app-store/templates/general-app-settings/api/add.ts b/packages/app-store/templates/general-app-settings/api/add.ts index 9a4afb9b2c..6ab3106577 100644 --- a/packages/app-store/templates/general-app-settings/api/add.ts +++ b/packages/app-store/templates/general-app-settings/api/add.ts @@ -9,8 +9,8 @@ const handler: AppDeclarativeHandler = { slug: appConfig.slug, supportsMultipleInstalls: false, handlerType: "add", - createCredential: ({ appType, user, slug }) => - createDefaultInstallation({ appType, userId: user.id, slug, key: {} }), + createCredential: ({ appType, user, slug, teamId }) => + createDefaultInstallation({ appType, userId: user.id, slug, key: {}, teamId }), }; export default handler; diff --git a/packages/app-store/templates/link-as-an-app/api/add.ts b/packages/app-store/templates/link-as-an-app/api/add.ts index 37dda83e44..d01c11ee22 100644 --- a/packages/app-store/templates/link-as-an-app/api/add.ts +++ b/packages/app-store/templates/link-as-an-app/api/add.ts @@ -13,8 +13,8 @@ const handler: AppDeclarativeHandler = { newTab: true, url: "https://example.com/link", }, - createCredential: ({ appType, user, slug }) => - createDefaultInstallation({ appType, userId: user.id, slug, key: {} }), + createCredential: ({ appType, user, slug, teamId }) => + createDefaultInstallation({ appType, userId: user.id, slug, key: {}, teamId }), }; export default handler; diff --git a/packages/app-store/typeform/api/add.ts b/packages/app-store/typeform/api/add.ts index 7f6af388be..8d7cd2976d 100644 --- a/packages/app-store/typeform/api/add.ts +++ b/packages/app-store/typeform/api/add.ts @@ -12,8 +12,8 @@ const handler: AppDeclarativeHandler = { redirect: { url: "/apps/typeform/how-to-use", }, - createCredential: ({ appType, user, slug }) => - createDefaultInstallation({ appType, userId: user.id, slug, key: {} }), + createCredential: ({ appType, user, slug, teamId }) => + createDefaultInstallation({ appType, userId: user.id, slug, key: {}, teamId }), }; export default handler; diff --git a/packages/app-store/typeform/playwright/tests/basic.e2e.ts b/packages/app-store/typeform/playwright/tests/basic.e2e.ts index 7fb17c1fb0..9ddc1ee603 100644 --- a/packages/app-store/typeform/playwright/tests/basic.e2e.ts +++ b/packages/app-store/typeform/playwright/tests/basic.e2e.ts @@ -19,9 +19,11 @@ const installApps = async (page: Page, users: Fixtures["users"]) => { await user.login(); await page.goto(`/apps/routing-forms`); await page.click('[data-testid="install-app-button"]'); + (await page.waitForSelector('[data-testid="install-app-button-personal"]')).click(); await page.waitForURL((url) => url.pathname === `/apps/routing-forms/forms`); await page.goto(`/apps/typeform`); await page.click('[data-testid="install-app-button"]'); + (await page.waitForSelector('[data-testid="install-app-button-personal"]')).click(); await page.waitForURL((url) => url.pathname === `/apps/typeform/how-to-use`); }; diff --git a/packages/app-store/types.d.ts b/packages/app-store/types.d.ts index 624f3ca35b..8127b37539 100644 --- a/packages/app-store/types.d.ts +++ b/packages/app-store/types.d.ts @@ -1,13 +1,26 @@ import type React from "react"; import type { z } from "zod"; -import type { _EventTypeModel } from "@calcom/prisma/zod"; import type { RouterOutputs } from "@calcom/trpc/react"; import type { ButtonProps } from "@calcom/ui"; export type IntegrationOAuthCallbackState = { returnTo: string; installGoogleVideo?: boolean; + teamId?: number; +}; + +export type CredentialOwner = { + name: string | null; + avatar?: string | null; + teamId?: number; + credentialId?: number; + readOnly?: boolean; +}; + +export type EventTypeAppCardApp = RouterOutputs["viewer"]["integrations"]["items"][number] & { + credentialOwner?: CredentialOwner; + credentialIds?: number[]; }; type AppScript = { attrs?: Record } & { src?: string; content?: string }; @@ -29,11 +42,13 @@ export interface InstallAppButtonProps { export type EventTypeAppCardComponentProps = { // Limit what data should be accessible to apps eventType: Pick< - z.infer, - "id" | "title" | "description" | "teamId" | "length" | "recurringEvent" | "seatsPerTimeSlot" + z.infer, + "id" | "title" | "description" | "teamId" | "length" | "recurringEvent" | "seatsPerTimeSlot" | "team" > & { URL: string; }; - app: RouterOutputs["viewer"]["apps"][number]; + app: EventTypeAppCardApp; + disabled?: boolean; + LockedIcon?: JSX.Element | false; }; export type EventTypeAppCardComponent = React.FC; diff --git a/packages/app-store/vimcal/api/add.ts b/packages/app-store/vimcal/api/add.ts index 8a7e247023..c7e2cf256e 100644 --- a/packages/app-store/vimcal/api/add.ts +++ b/packages/app-store/vimcal/api/add.ts @@ -13,8 +13,8 @@ const handler: AppDeclarativeHandler = { newTab: true, url: "https://cal.com/blog/cal-plus-vimcal", }, - createCredential: ({ appType, user, slug }) => - createDefaultInstallation({ appType, userId: user.id, slug, key: {} }), + createCredential: ({ appType, user, slug, teamId }) => + createDefaultInstallation({ appType, userId: user.id, slug, key: {}, teamId }), }; export default handler; diff --git a/packages/app-store/weather_in_your_calendar/api/add.ts b/packages/app-store/weather_in_your_calendar/api/add.ts index 44d56bb0fa..1e4629d298 100644 --- a/packages/app-store/weather_in_your_calendar/api/add.ts +++ b/packages/app-store/weather_in_your_calendar/api/add.ts @@ -9,8 +9,8 @@ const handler: AppDeclarativeHandler = { slug: appConfig.slug, supportsMultipleInstalls: false, handlerType: "add", - createCredential: ({ appType, user, slug }) => - createDefaultInstallation({ appType, userId: user.id, slug, key: {} }), + createCredential: ({ appType, user, slug, teamId }) => + createDefaultInstallation({ appType, userId: user.id, slug, key: {}, teamId }), }; export default handler; diff --git a/packages/app-store/webex/api/callback.ts b/packages/app-store/webex/api/callback.ts index f5ca865423..c6c6a8ad98 100644 --- a/packages/app-store/webex/api/callback.ts +++ b/packages/app-store/webex/api/callback.ts @@ -3,6 +3,7 @@ import type { NextApiRequest, NextApiResponse } from "next"; import { WEBAPP_URL } from "@calcom/lib/constants"; import prisma from "@calcom/prisma"; +import createOAuthAppCredential from "../../_utils/createOAuthAppCredential"; import getInstalledAppPath from "../../_utils/getInstalledAppPath"; import config from "../config.json"; import { getWebexAppKeys } from "../lib/getWebexAppKeys"; @@ -80,20 +81,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) await prisma.credential.deleteMany({ where: { id: { in: credentialIdsToDelete }, userId } }); } - await prisma.user.update({ - where: { - id: req.session?.user.id, - }, - data: { - credentials: { - create: { - type: config.type, - key: responseBody, - appId: config.slug, - }, - }, - }, - }); + createOAuthAppCredential({ appId: config.slug, type: config.type }, responseBody, req); res.redirect(getInstalledAppPath({ variant: config.variant, slug: config.slug })); } diff --git a/packages/app-store/whatsapp/api/add.ts b/packages/app-store/whatsapp/api/add.ts index 44d56bb0fa..1e4629d298 100644 --- a/packages/app-store/whatsapp/api/add.ts +++ b/packages/app-store/whatsapp/api/add.ts @@ -9,8 +9,8 @@ const handler: AppDeclarativeHandler = { slug: appConfig.slug, supportsMultipleInstalls: false, handlerType: "add", - createCredential: ({ appType, user, slug }) => - createDefaultInstallation({ appType, userId: user.id, slug, key: {} }), + createCredential: ({ appType, user, slug, teamId }) => + createDefaultInstallation({ appType, userId: user.id, slug, key: {}, teamId }), }; export default handler; diff --git a/packages/app-store/wipemycalother/components/wipeMyCalActionButton.tsx b/packages/app-store/wipemycalother/components/wipeMyCalActionButton.tsx index da768923e2..4887c4ac73 100644 --- a/packages/app-store/wipemycalother/components/wipeMyCalActionButton.tsx +++ b/packages/app-store/wipemycalother/components/wipeMyCalActionButton.tsx @@ -23,7 +23,7 @@ const WipeMyCalActionButton = (props: IWipeMyCalActionButtonProps) => { } const wipeMyCalCredentials = data?.items.find((item: { type: string }) => item.type === "wipemycal_other"); - const [credentialId] = wipeMyCalCredentials?.credentialIds || [false]; + const [credentialId] = wipeMyCalCredentials?.userCredentialIds || [false]; return ( <> diff --git a/packages/app-store/wordpress/api/add.ts b/packages/app-store/wordpress/api/add.ts index 8f15564741..8e8f4f9a7b 100644 --- a/packages/app-store/wordpress/api/add.ts +++ b/packages/app-store/wordpress/api/add.ts @@ -13,8 +13,8 @@ const handler: AppDeclarativeHandler = { newTab: true, url: "https://wordpress.org/plugins/cal-com/", }, - createCredential: ({ appType, user, slug }) => - createDefaultInstallation({ appType, userId: user.id, slug, key: {} }), + createCredential: ({ appType, user, slug, teamId }) => + createDefaultInstallation({ appType, userId: user.id, slug, key: {}, teamId }), }; export default handler; diff --git a/packages/app-store/zapier/pages/setup/index.tsx b/packages/app-store/zapier/pages/setup/index.tsx index c332e435af..a1b91debea 100644 --- a/packages/app-store/zapier/pages/setup/index.tsx +++ b/packages/app-store/zapier/pages/setup/index.tsx @@ -22,10 +22,10 @@ export default function ZapierSetup(props: IZapierSetupProps) { const oldApiKey = trpc.viewer.apiKeys.findKeyOfType.useQuery({ appId: ZAPIER }); const deleteApiKey = trpc.viewer.apiKeys.delete.useMutation(); - const zapierCredentials: { credentialIds: number[] } | undefined = integrations.data?.items.find( + const zapierCredentials: { userCredentialIds: number[] } | undefined = integrations.data?.items.find( (item: { type: string }) => item.type === "zapier_automation" ); - const [credentialId] = zapierCredentials?.credentialIds || [false]; + const [credentialId] = zapierCredentials?.userCredentialIds || [false]; const showContent = integrations.data && integrations.isSuccess && credentialId; const isCalDev = process.env.NEXT_PUBLIC_WEBAPP_URL === "https://app.cal.dev"; diff --git a/packages/app-store/zoho-bigin/api/callback.ts b/packages/app-store/zoho-bigin/api/callback.ts index c68d2f35d5..aa11e74125 100644 --- a/packages/app-store/zoho-bigin/api/callback.ts +++ b/packages/app-store/zoho-bigin/api/callback.ts @@ -4,8 +4,8 @@ import qs from "qs"; import { WEBAPP_URL } from "@calcom/lib/constants"; import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl"; -import prisma from "@calcom/prisma"; +import createOAuthAppCredential from "../../_utils/createOAuthAppCredential"; import { decodeOAuthState } from "../../_utils/decodeOAuthState"; import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug"; import getInstalledAppPath from "../../_utils/getInstalledAppPath"; @@ -52,14 +52,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) tokenInfo.data.expiryDate = Math.round(Date.now() + tokenInfo.data.expires_in); tokenInfo.data.accountServer = accountsServer; - await prisma.credential.create({ - data: { - type: appConfig.type, - key: tokenInfo.data, - userId: req.session.user.id, - appId: appConfig.slug, - }, - }); + createOAuthAppCredential({ appId: appConfig.slug, type: appConfig.type }, tokenInfo.data, req); const state = decodeOAuthState(req); res.redirect( diff --git a/packages/app-store/zohocrm/api/callback.ts b/packages/app-store/zohocrm/api/callback.ts index a045455280..f161beb8b2 100644 --- a/packages/app-store/zohocrm/api/callback.ts +++ b/packages/app-store/zohocrm/api/callback.ts @@ -4,8 +4,8 @@ import qs from "qs"; import { WEBAPP_URL } from "@calcom/lib/constants"; import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl"; -import prisma from "@calcom/prisma"; +import createOAuthAppCredential from "../../_utils/createOAuthAppCredential"; import { decodeOAuthState } from "../../_utils/decodeOAuthState"; import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug"; import getInstalledAppPath from "../../_utils/getInstalledAppPath"; @@ -51,15 +51,11 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) zohoCrmTokenInfo.data.expiryDate = Math.round(Date.now() + 60 * 60); zohoCrmTokenInfo.data.accountServer = req.query["accounts-server"]; - await prisma.credential.create({ - data: { - type: "zohocrm_other_calendar", - // eslint-disable-next-line @typescript-eslint/no-explicit-any - key: zohoCrmTokenInfo.data as any, - userId: req.session.user.id, - appId: "zohocrm", - }, - }); + createOAuthAppCredential( + { appId: "zohocrm", type: "zohocrm_other_calendar" }, + zohoCrmTokenInfo.data as any, + req + ); const state = decodeOAuthState(req); res.redirect( diff --git a/packages/app-store/zoomvideo/api/add.ts b/packages/app-store/zoomvideo/api/add.ts index 35041a4371..5083556051 100644 --- a/packages/app-store/zoomvideo/api/add.ts +++ b/packages/app-store/zoomvideo/api/add.ts @@ -5,6 +5,7 @@ import { WEBAPP_URL } from "@calcom/lib/constants"; import { defaultHandler, defaultResponder } from "@calcom/lib/server"; import prisma from "@calcom/prisma"; +import { encodeOAuthState } from "../../_utils/encodeOAuthState"; import { getZoomAppKeys } from "../lib"; async function handler(req: NextApiRequest) { @@ -19,11 +20,13 @@ async function handler(req: NextApiRequest) { }); const { client_id } = await getZoomAppKeys(); + const state = encodeOAuthState(req); const params = { response_type: "code", client_id, redirect_uri: WEBAPP_URL + "/api/integrations/zoomvideo/callback", + state, }; const query = stringify(params); const url = `https://zoom.us/oauth/authorize?${query}`; diff --git a/packages/app-store/zoomvideo/api/callback.ts b/packages/app-store/zoomvideo/api/callback.ts index c4b990c1fb..461d7fd46e 100644 --- a/packages/app-store/zoomvideo/api/callback.ts +++ b/packages/app-store/zoomvideo/api/callback.ts @@ -3,6 +3,7 @@ import type { NextApiRequest, NextApiResponse } from "next"; import { WEBAPP_URL } from "@calcom/lib/constants"; import prisma from "@calcom/prisma"; +import createOAuthAppCredential from "../../_utils/createOAuthAppCredential"; import getInstalledAppPath from "../../_utils/getInstalledAppPath"; import { getZoomAppKeys } from "../lib"; @@ -69,20 +70,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) await prisma.credential.deleteMany({ where: { id: { in: credentialIdsToDelete }, userId } }); } - await prisma.user.update({ - where: { - id: req.session?.user.id, - }, - data: { - credentials: { - create: { - type: "zoom_video", - key: responseBody, - appId: "zoom", - }, - }, - }, - }); + createOAuthAppCredential({ appId: "zoom", type: "zoom_video" }, responseBody, req); res.redirect(getInstalledAppPath({ variant: "conferencing", slug: "zoom" })); } diff --git a/packages/core/EventManager.ts b/packages/core/EventManager.ts index 283b49e047..e496964891 100644 --- a/packages/core/EventManager.ts +++ b/packages/core/EventManager.ts @@ -404,7 +404,7 @@ export default class EventManager { * @param event * @private */ - private createVideoEvent(event: CalendarEvent) { + private async createVideoEvent(event: CalendarEvent) { const credential = this.getVideoCredential(event); if (credential) { @@ -538,7 +538,7 @@ export default class EventManager { * @param booking * @private */ - private updateVideoEvent(event: CalendarEvent, booking: PartialBooking) { + private async updateVideoEvent(event: CalendarEvent, booking: PartialBooking) { const credential = this.getVideoCredential(event); if (credential) { diff --git a/packages/features/apps/components/DisconnectIntegrationModal.tsx b/packages/features/apps/components/DisconnectIntegrationModal.tsx index fe7d24bdb4..24c9da5e0a 100644 --- a/packages/features/apps/components/DisconnectIntegrationModal.tsx +++ b/packages/features/apps/components/DisconnectIntegrationModal.tsx @@ -6,12 +6,14 @@ interface DisconnectIntegrationModalProps { credentialId: number | null; isOpen: boolean; handleModelClose: () => void; + teamId?: number; } export default function DisconnectIntegrationModal({ credentialId, isOpen, handleModelClose, + teamId, }: DisconnectIntegrationModalProps) { const { t } = useLocale(); const utils = trpc.useContext(); @@ -31,17 +33,17 @@ export default function DisconnectIntegrationModal({ return ( - { - if (credentialId) { - mutation.mutate({ id: credentialId }); - } - }}> -

    {t("are_you_sure_you_want_to_remove_this_app")}

    -
    + { + if (credentialId) { + mutation.mutate({ id: credentialId, teamId }); + } + }}> +

    {t("are_you_sure_you_want_to_remove_this_app")}

    +
    ); } diff --git a/packages/features/bookings/lib/handleCancelBooking.ts b/packages/features/bookings/lib/handleCancelBooking.ts index af5522cffd..ff1fea4488 100644 --- a/packages/features/bookings/lib/handleCancelBooking.ts +++ b/packages/features/bookings/lib/handleCancelBooking.ts @@ -25,7 +25,7 @@ import prisma, { bookingMinimalSelect } from "@calcom/prisma"; import { BookingStatus, MembershipRole, WorkflowMethods } from "@calcom/prisma/enums"; import { schemaBookingCancelParams } from "@calcom/prisma/zod-utils"; import type { CalendarEvent } from "@calcom/types/Calendar"; -import type { IAbstractPaymentService } from "@calcom/types/PaymentService"; +import type { IAbstractPaymentService, PaymentApp } from "@calcom/types/PaymentService"; async function getBookingToDelete(id: number | undefined, uid: string | undefined) { return await prisma.booking.findUnique({ @@ -445,7 +445,10 @@ async function handler(req: CustomRequest) { /** TODO: Remove this without breaking functionality */ if (bookingToDelete.location === DailyLocationType) { - bookingToDelete.user.credentials.push(FAKE_DAILY_CREDENTIAL); + bookingToDelete.user.credentials.push({ + ...FAKE_DAILY_CREDENTIAL, + teamId: bookingToDelete.eventType?.teamId || null, + }); } const apiDeletes = []; @@ -593,8 +596,10 @@ async function handler(req: CustomRequest) { } // Posible to refactor TODO: - const paymentApp = await appStore[paymentAppCredential?.app?.dirName as keyof typeof appStore](); - if (!(paymentApp && "lib" in paymentApp && "PaymentService" in paymentApp.lib)) { + const paymentApp = (await appStore[ + paymentAppCredential?.app?.dirName as keyof typeof appStore + ]()) as PaymentApp; + if (!paymentApp?.lib?.PaymentService) { console.warn(`payment App service of type ${paymentApp} is not implemented`); return null; } diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index 24834c7c6b..7019290e08 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -1544,7 +1544,9 @@ async function handler( if (!Number.isNaN(paymentAppData.price) && paymentAppData.price > 0 && !!booking) { const credentialPaymentAppCategories = await prisma.credential.findMany({ where: { - userId: organizerUser.id, + ...(paymentAppData.credentialId + ? { id: paymentAppData.credentialId } + : { userId: organizerUser.id }), app: { categories: { hasSome: ["payment"], @@ -1769,7 +1771,9 @@ async function handler( await prisma.credential.findFirstOrThrow({ where: { appId: paymentAppData.appId, - userId: organizerUser.id, + ...(paymentAppData.credentialId + ? { id: paymentAppData.credentialId } + : { userId: organizerUser.id }), }, select: { id: true, @@ -2084,7 +2088,7 @@ async function handler( // Load credentials.app.categories const credentialPaymentAppCategories = await prisma.credential.findMany({ where: { - userId: organizerUser.id, + ...(paymentAppData.credentialId ? { id: paymentAppData.credentialId } : { userId: organizerUser.id }), app: { categories: { hasSome: ["payment"], diff --git a/packages/features/ee/managed-event-types/hooks/useLockedFieldsManager.tsx b/packages/features/ee/managed-event-types/hooks/useLockedFieldsManager.tsx index 14f5a4c165..b719d3e2ac 100644 --- a/packages/features/ee/managed-event-types/hooks/useLockedFieldsManager.tsx +++ b/packages/features/ee/managed-event-types/hooks/useLockedFieldsManager.tsx @@ -8,7 +8,7 @@ import type { _EventTypeModel } from "@calcom/prisma/zod/eventtype"; import { Tooltip } from "@calcom/ui"; import { Lock } from "@calcom/ui/components/icon"; -const Indicator = (label: string) => ( +export const LockedIndicator = (label: string) => ( {label}}>
    @@ -39,7 +39,7 @@ const useLockedFieldsManager = ( } else { locked = locked && unlockedFields[fieldName as keyof Omit] === undefined; } - return locked && Indicator(isManagedEventType ? adminLabel : memberLabel); + return locked && LockedIndicator(isManagedEventType ? adminLabel : memberLabel); }; const shouldLockDisableProps = (fieldName: string) => { diff --git a/packages/features/ee/organizations/components/AddNewTeamsForm.tsx b/packages/features/ee/organizations/components/AddNewTeamsForm.tsx index 382325231f..6c3f624393 100644 --- a/packages/features/ee/organizations/components/AddNewTeamsForm.tsx +++ b/packages/features/ee/organizations/components/AddNewTeamsForm.tsx @@ -90,7 +90,7 @@ export const AddNewTeamsForm = () => { disabled={createTeamsMutation.isLoading} onClick={() => { if (inputValues.includes("")) { - showToast(t("team_name_empty"), "error"); + showToast(t("team_names_empty"), "error"); } else { const duplicates = inputValues.filter((item, index) => inputValues.indexOf(item) !== index); if (duplicates.length) { diff --git a/packages/features/ee/teams/lib/getUserAdminTeams.ts b/packages/features/ee/teams/lib/getUserAdminTeams.ts new file mode 100644 index 0000000000..2424bd7a05 --- /dev/null +++ b/packages/features/ee/teams/lib/getUserAdminTeams.ts @@ -0,0 +1,91 @@ +import type { Prisma } from "@prisma/client"; + +import { prisma } from "@calcom/prisma"; +import { MembershipRole } from "@calcom/prisma/enums"; + +export type UserAdminTeams = (Prisma.TeamGetPayload<{ + select: { + id: true; + name: true; + logo: true; + credentials?: true; + parent?: { + select: { + id: true; + name: true; + logo: true; + credentials: true; + }; + }; + }; +}> & { isUser?: boolean })[]; + +/** Get a user's team & orgs they are admins/owners of. Abstracted to a function to call in tRPC endpoint and SSR. */ +const getUserAdminTeams = async ({ + userId, + getUserInfo, + getParentInfo, + includeCredentials = false, +}: { + userId: number; + getUserInfo?: boolean; + getParentInfo?: boolean; + includeCredentials?: boolean; +}): Promise => { + const teams = await prisma.team.findMany({ + where: { + members: { + some: { + userId: userId, + accepted: true, + role: { in: [MembershipRole.ADMIN, MembershipRole.OWNER] }, + }, + }, + }, + select: { + id: true, + name: true, + logo: true, + ...(includeCredentials && { credentials: true }), + ...(getParentInfo && { + parent: { + select: { + id: true, + name: true, + logo: true, + credentials: true, + }, + }, + }), + }, + }); + + if (teams.length && getUserInfo) { + const user = await prisma.user.findUnique({ + where: { + id: userId, + }, + select: { + id: true, + name: true, + avatar: true, + ...(includeCredentials && { credentials: true }), + }, + }); + + if (user) { + const userObject = { + id: user.id, + name: user.name || "Nameless", + logo: user?.avatar, + isUser: true, + ...(includeCredentials && { credentials: user.credentials }), + }; + teams.unshift(userObject); + } + } + + return teams; +}; + +export default getUserAdminTeams; diff --git a/packages/lib/getEventTypeById.ts b/packages/lib/getEventTypeById.ts index 6f0646ea28..7213e3c72c 100644 --- a/packages/lib/getEventTypeById.ts +++ b/packages/lib/getEventTypeById.ts @@ -109,10 +109,13 @@ export default async function getEventTypeById({ teamId: true, }, }, + teamId: true, team: { select: { id: true, + name: true, slug: true, + parentId: true, members: { where: { accepted: true, diff --git a/packages/lib/getOrgAwareUrl.ts b/packages/lib/getOrgAwareUrl.ts new file mode 100644 index 0000000000..7cd0b794a7 --- /dev/null +++ b/packages/lib/getOrgAwareUrl.ts @@ -0,0 +1,20 @@ +import { CAL_URL, WEBAPP_URL } from "./constants"; + +/** + * It is a simpler(no HTTP request) alternative to get full URL of a path + * Should be used on app.cal.com Pages and not Booking Pages(which can be accessed through website URL also) + */ +export function getOrgAwareUrlOnClient(path: string) { + if (!path.startsWith("/")) { + throw new Error("path must start with /"); + } + const documentURLObj = new URL(document.URL); + const webAppUrlObj = new URL(WEBAPP_URL); + const isNonOrgDomain = documentURLObj.host === webAppUrlObj.host; + if (isNonOrgDomain) { + return `${CAL_URL}${path}`; + } + return `${documentURLObj.href.replace(/\/$/, "")}${path}`; +} + +export default getOrgAwareUrlOnClient; diff --git a/packages/lib/getPaymentAppData.ts b/packages/lib/getPaymentAppData.ts index 4d04d5dd86..08e32182e0 100644 --- a/packages/lib/getPaymentAppData.ts +++ b/packages/lib/getPaymentAppData.ts @@ -28,6 +28,7 @@ export default function getPaymentAppData( currency: string; appId: EventTypeAppsList | null; paymentOption: typeof paymentOptionEnum; + credentialId?: number; } | null = null; for (const appId of paymentAppIds) { const appData = getEventTypeAppData(eventType, appId, forcedGet); @@ -41,6 +42,13 @@ export default function getPaymentAppData( // This is the current expectation of system to have price and currency set always(using DB Level defaults). // Newly added apps code should assume that their app data might not be set. return ( - paymentAppData || { enabled: false, price: 0, currency: "usd", appId: null, paymentOption: "ON_BOOKING" } + paymentAppData || { + enabled: false, + price: 0, + currency: "usd", + appId: null, + paymentOption: "ON_BOOKING", + credentialId: undefined, + } ); } diff --git a/packages/lib/payment/deletePayment.ts b/packages/lib/payment/deletePayment.ts index 195f221ae4..23f8846e99 100644 --- a/packages/lib/payment/deletePayment.ts +++ b/packages/lib/payment/deletePayment.ts @@ -2,7 +2,7 @@ import type { Payment, Prisma } from "@prisma/client"; import appStore from "@calcom/app-store"; import type { AppCategories } from "@calcom/prisma/enums"; -import type { IAbstractPaymentService } from "@calcom/types/PaymentService"; +import type { IAbstractPaymentService, PaymentApp } from "@calcom/types/PaymentService"; const deletePayment = async ( paymentId: Payment["id"], @@ -15,8 +15,10 @@ const deletePayment = async ( } | null; } ): Promise => { - const paymentApp = await appStore[paymentAppCredentials?.app?.dirName as keyof typeof appStore](); - if (!(paymentApp && "lib" in paymentApp && "PaymentService" in paymentApp.lib)) { + const paymentApp = (await appStore[ + paymentAppCredentials?.app?.dirName as keyof typeof appStore + ]()) as PaymentApp; + if (!paymentApp?.lib?.PaymentService) { console.warn(`payment App service of type ${paymentApp} is not implemented`); return false; } diff --git a/packages/lib/payment/handlePayment.ts b/packages/lib/payment/handlePayment.ts index 7723bc904c..3cbb6a224b 100644 --- a/packages/lib/payment/handlePayment.ts +++ b/packages/lib/payment/handlePayment.ts @@ -4,7 +4,7 @@ import appStore from "@calcom/app-store"; import type { EventTypeAppsList } from "@calcom/app-store/utils"; import type { EventTypeModel } from "@calcom/prisma/zod"; import type { CalendarEvent } from "@calcom/types/Calendar"; -import type { IAbstractPaymentService } from "@calcom/types/PaymentService"; +import type { IAbstractPaymentService, PaymentApp } from "@calcom/types/PaymentService"; const handlePayment = async ( evt: CalendarEvent, @@ -25,8 +25,10 @@ const handlePayment = async ( }, bookerEmail: string ) => { - const paymentApp = await appStore[paymentAppCredentials?.app?.dirName as keyof typeof appStore](); - if (!(paymentApp && "lib" in paymentApp && "PaymentService" in paymentApp.lib)) { + const paymentApp = (await appStore[ + paymentAppCredentials?.app?.dirName as keyof typeof appStore + ]()) as PaymentApp; + if (!paymentApp?.lib?.PaymentService) { console.warn(`payment App service of type ${paymentApp} is not implemented`); return null; } diff --git a/packages/prisma/migrations/20230606202918_add_team_id_to_credential/migration.sql b/packages/prisma/migrations/20230606202918_add_team_id_to_credential/migration.sql new file mode 100644 index 0000000000..1afef33f1a --- /dev/null +++ b/packages/prisma/migrations/20230606202918_add_team_id_to_credential/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "Credential" ADD COLUMN "teamId" INTEGER; + +-- AddForeignKey +ALTER TABLE "Credential" ADD CONSTRAINT "Credential_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 2b63e64885..c2dacbd0a1 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -126,6 +126,8 @@ model Credential { key Json user User? @relation(fields: [userId], references: [id], onDelete: Cascade) userId Int? + team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade) + teamId Int? app App? @relation(fields: [appId], references: [slug], onDelete: Cascade) // How to make it a required column? appId String? @@ -274,6 +276,7 @@ model Team { timeZone String @default("Europe/London") weekStart String @default("Sunday") routingForms App_RoutingForms_Form[] + credentials Credential[] @@unique([slug, parentId]) } diff --git a/packages/prisma/selects/credential.ts b/packages/prisma/selects/credential.ts index a48d4b7e95..bb002644ea 100644 --- a/packages/prisma/selects/credential.ts +++ b/packages/prisma/selects/credential.ts @@ -6,6 +6,7 @@ export const safeCredentialSelect = Prisma.validator()( /** Omitting to avoid frontend leaks */ // key: true, userId: true, + teamId: true, appId: true, invalid: true, }); diff --git a/packages/prisma/zod-utils.ts b/packages/prisma/zod-utils.ts index b99ee957bc..6d4de34cb7 100644 --- a/packages/prisma/zod-utils.ts +++ b/packages/prisma/zod-utils.ts @@ -136,6 +136,7 @@ export const eventTypeLocations = z.array( link: z.string().url().optional(), displayLocationPublicly: z.boolean().optional(), hostPhoneNumber: z.string().optional(), + credentialId: z.number().optional(), }) ); diff --git a/packages/trpc/server/middlewares/sessionMiddleware.ts b/packages/trpc/server/middlewares/sessionMiddleware.ts index 84b023dfb2..bc6cc0b4bf 100644 --- a/packages/trpc/server/middlewares/sessionMiddleware.ts +++ b/packages/trpc/server/middlewares/sessionMiddleware.ts @@ -51,6 +51,7 @@ export async function getUserFromSession(ctx: TRPCContextInner, session: Maybe { - if (!UNSTABLE_HANDLER_CACHE.apps) { - UNSTABLE_HANDLER_CACHE.apps = (await import("./apps.handler")).appsHandler; - } - - // Unreachable code but required for type safety - if (!UNSTABLE_HANDLER_CACHE.apps) { - throw new Error("Failed to load handler"); - } - - return UNSTABLE_HANDLER_CACHE.apps({ ctx, input }); - }), - appCredentialsByType: authedProcedure .input(ZAppCredentialsByTypeInputSchema) .query(async ({ ctx, input }) => { @@ -260,7 +246,7 @@ export const loggedInViewerRouter = router({ return UNSTABLE_HANDLER_CACHE.submitFeedback({ ctx, input }); }), - locationOptions: authedProcedure.query(async ({ ctx }) => { + locationOptions: authedProcedure.input(ZLocationOptionsInputSchema).query(async ({ ctx, input }) => { if (!UNSTABLE_HANDLER_CACHE.locationOptions) { UNSTABLE_HANDLER_CACHE.locationOptions = ( await import("./locationOptions.handler") @@ -272,7 +258,7 @@ export const loggedInViewerRouter = router({ throw new Error("Failed to load handler"); } - return UNSTABLE_HANDLER_CACHE.locationOptions({ ctx }); + return UNSTABLE_HANDLER_CACHE.locationOptions({ ctx, input }); }), deleteCredential: authedProcedure.input(ZDeleteCredentialInputSchema).mutation(async ({ ctx, input }) => { diff --git a/packages/trpc/server/routers/loggedInViewer/appCredentialsByType.handler.ts b/packages/trpc/server/routers/loggedInViewer/appCredentialsByType.handler.ts index 6d5f4e2ee9..33f59f4ba7 100644 --- a/packages/trpc/server/routers/loggedInViewer/appCredentialsByType.handler.ts +++ b/packages/trpc/server/routers/loggedInViewer/appCredentialsByType.handler.ts @@ -1,3 +1,5 @@ +import getUserAdminTeams from "@calcom/features/ee/teams/lib/getUserAdminTeams"; +import { prisma } from "@calcom/prisma"; import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; import type { TAppCredentialsByTypeInputSchema } from "./appCredentialsByType.schema"; @@ -9,7 +11,34 @@ type AppCredentialsByTypeOptions = { input: TAppCredentialsByTypeInputSchema; }; +/** Used for grabbing credentials on specific app pages */ export const appCredentialsByTypeHandler = async ({ ctx, input }: AppCredentialsByTypeOptions) => { const { user } = ctx; - return user.credentials.filter((app) => app.type == input.appType).map((credential) => credential.id); + const userAdminTeams = await getUserAdminTeams({ userId: ctx.user.id, getUserInfo: true }); + + const teamIds = userAdminTeams.reduce((teamIds, team) => { + if (!team.isUser) teamIds.push(team.id); + return teamIds; + }, [] as number[]); + + const credentials = await prisma.credential.findMany({ + where: { + OR: [ + { userId: user.id }, + { + teamId: { + in: teamIds, + }, + }, + ], + type: input.appType, + }, + }); + + // For app pages need to return which teams the user can install the app on + // return user.credentials.filter((app) => app.type == input.appType).map((credential) => credential.id); + return { + credentials, + userAdminTeams, + }; }; diff --git a/packages/trpc/server/routers/loggedInViewer/apps.handler.ts b/packages/trpc/server/routers/loggedInViewer/apps.handler.ts deleted file mode 100644 index 85e510009a..0000000000 --- a/packages/trpc/server/routers/loggedInViewer/apps.handler.ts +++ /dev/null @@ -1,24 +0,0 @@ -import getEnabledApps from "@calcom/lib/apps/getEnabledApps"; -import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; - -import type { TAppsInputSchema } from "./apps.schema"; - -type AppsOptions = { - ctx: { - user: NonNullable; - }; - input: TAppsInputSchema; -}; - -export const appsHandler = async ({ ctx, input }: AppsOptions) => { - const { user } = ctx; - const { credentials } = user; - - const apps = await getEnabledApps(credentials); - return apps - .filter((app) => app.extendsFeature?.includes(input.extendsFeature)) - .map((app) => ({ - ...app, - isInstalled: !!app.credentials?.length, - })); -}; diff --git a/packages/trpc/server/routers/loggedInViewer/apps.schema.ts b/packages/trpc/server/routers/loggedInViewer/apps.schema.ts deleted file mode 100644 index 3ab2115e8d..0000000000 --- a/packages/trpc/server/routers/loggedInViewer/apps.schema.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { z } from "zod"; - -export const ZAppsInputSchema = z.object({ - extendsFeature: z.literal("EventType"), -}); - -export type TAppsInputSchema = z.infer; diff --git a/packages/trpc/server/routers/loggedInViewer/deleteCredential.handler.ts b/packages/trpc/server/routers/loggedInViewer/deleteCredential.handler.ts index 0de95dbd76..c3b1cd7c4f 100644 --- a/packages/trpc/server/routers/loggedInViewer/deleteCredential.handler.ts +++ b/packages/trpc/server/routers/loggedInViewer/deleteCredential.handler.ts @@ -26,12 +26,12 @@ type DeleteCredentialOptions = { }; export const deleteCredentialHandler = async ({ ctx, input }: DeleteCredentialOptions) => { - const { id, externalId } = input; + const { id, externalId, teamId } = input; const credential = await prisma.credential.findFirst({ where: { id: id, - userId: ctx.user.id, + ...(teamId ? { teamId } : { userId: ctx.user.id }), }, select: { key: true, diff --git a/packages/trpc/server/routers/loggedInViewer/deleteCredential.schema.ts b/packages/trpc/server/routers/loggedInViewer/deleteCredential.schema.ts index 8814240beb..769178381d 100644 --- a/packages/trpc/server/routers/loggedInViewer/deleteCredential.schema.ts +++ b/packages/trpc/server/routers/loggedInViewer/deleteCredential.schema.ts @@ -3,6 +3,7 @@ import { z } from "zod"; export const ZDeleteCredentialInputSchema = z.object({ id: z.number(), externalId: z.string().optional(), + teamId: z.number().optional(), }); export type TDeleteCredentialInputSchema = z.infer; diff --git a/packages/trpc/server/routers/loggedInViewer/integrations.handler.ts b/packages/trpc/server/routers/loggedInViewer/integrations.handler.ts index e312c9b2d7..dbceb3a2b2 100644 --- a/packages/trpc/server/routers/loggedInViewer/integrations.handler.ts +++ b/packages/trpc/server/routers/loggedInViewer/integrations.handler.ts @@ -1,7 +1,10 @@ import getEnabledApps from "@calcom/lib/apps/getEnabledApps"; +import prisma from "@calcom/prisma"; +import { MembershipRole } from "@calcom/prisma/enums"; import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; import type { TIntegrationsInputSchema } from "./integrations.schema"; +import type { Prisma, Credential } from ".prisma/client"; type IntegrationsOptions = { ctx: { @@ -10,23 +13,129 @@ type IntegrationsOptions = { input: TIntegrationsInputSchema; }; +type TeamQuery = Prisma.TeamGetPayload<{ + select: { + id: true; + credentials?: true; + name: true; + logo: true; + members: { + select: { + role: true; + }; + }; + }; +}>; + +// type TeamQueryWithParent = TeamQuery & { +// parent?: TeamQuery | null; +// }; + export const integrationsHandler = async ({ ctx, input }: IntegrationsOptions) => { const { user } = ctx; - const { variant, exclude, onlyInstalled } = input; - const { credentials } = user; + const { variant, exclude, onlyInstalled, includeTeamInstalledApps, extendsFeature, teamId } = input; + let { credentials } = user; + let userTeams: TeamQuery[] = []; + + if (includeTeamInstalledApps || teamId) { + const teamsQuery = await prisma.team.findMany({ + where: { + members: { + some: { + userId: user.id, + accepted: true, + }, + }, + }, + select: { + id: true, + credentials: true, + name: true, + logo: true, + members: { + where: { + userId: user.id, + }, + select: { + role: true, + }, + }, + parent: { + select: { + id: true, + credentials: true, + name: true, + logo: true, + members: { + where: { + userId: user.id, + }, + select: { + role: true, + }, + }, + }, + }, + }, + }); + // If a team is a part of an org then include those apps + // Don't want to iterate over these parent teams + const filteredTeams: TeamQuery[] = []; + const parentTeams: TeamQuery[] = []; + // Only loop and grab parent teams if a teamId was given. If not then all teams will be queried + if (teamId) { + teamsQuery.forEach((team) => { + if (team?.parent) { + const { parent, ...filteredTeam } = team; + filteredTeams.push(filteredTeam); + parentTeams.push(parent); + } + }); + } + + userTeams = [...teamsQuery, ...parentTeams]; + + const teamAppCredentials: Credential[] = userTeams.flatMap((teamApp) => { + return teamApp.credentials ? teamApp.credentials.flat() : []; + }); + if (!includeTeamInstalledApps || teamId) { + credentials = teamAppCredentials; + } else { + credentials = credentials.concat(teamAppCredentials); + } + } const enabledApps = await getEnabledApps(credentials); //TODO: Refactor this to pick up only needed fields and prevent more leaking let apps = enabledApps.map( ({ credentials: _, credential: _1, key: _2 /* don't leak to frontend */, ...app }) => { - const credentialIds = credentials.filter((c) => c.type === app.type).map((c) => c.id); + const userCredentialIds = credentials.filter((c) => c.type === app.type && !c.teamId).map((c) => c.id); const invalidCredentialIds = credentials .filter((c) => c.type === app.type && c.invalid) .map((c) => c.id); + const teams = credentials + .filter((c) => c.type === app.type && c.teamId) + .map((c) => { + const team = userTeams.find((team) => team.id === c.teamId); + if (!team) { + return null; + } + return { + teamId: team.id, + name: team.name, + logo: team.logo, + credentialId: c.id, + isAdmin: + team.members[0].role === MembershipRole.ADMIN || team.members[0].role === MembershipRole.OWNER, + }; + }); return { ...app, - credentialIds, + ...(teams.length && { credentialOwner: { name: user.name, avatar: user.avatar } }), + userCredentialIds, invalidCredentialIds, + teams, + isInstalled: !!userCredentialIds.length || !!teams.length || app.isGlobal, }; } ); @@ -44,8 +153,20 @@ export const integrationsHandler = async ({ ctx, input }: IntegrationsOptions) = } if (onlyInstalled) { - apps = apps.flatMap((item) => (item.credentialIds.length > 0 || item.isGlobal ? [item] : [])); + apps = apps.flatMap((item) => + item.userCredentialIds.length > 0 || item.teams.length || item.isGlobal ? [item] : [] + ); } + + if (extendsFeature) { + apps = apps + .filter((app) => app.extendsFeature?.includes(extendsFeature)) + .map((app) => ({ + ...app, + isInstalled: !!app.userCredentialIds?.length || !!app.teams?.length || app.isGlobal, + })); + } + return { items: apps, }; diff --git a/packages/trpc/server/routers/loggedInViewer/integrations.schema.ts b/packages/trpc/server/routers/loggedInViewer/integrations.schema.ts index 19ac998e71..71aa807081 100644 --- a/packages/trpc/server/routers/loggedInViewer/integrations.schema.ts +++ b/packages/trpc/server/routers/loggedInViewer/integrations.schema.ts @@ -4,6 +4,9 @@ export const ZIntegrationsInputSchema = z.object({ variant: z.string().optional(), exclude: z.array(z.string()).optional(), onlyInstalled: z.boolean().optional(), + includeTeamInstalledApps: z.boolean().optional(), + extendsFeature: z.literal("EventType").optional(), + teamId: z.union([z.number(), z.null()]).optional(), }); export type TIntegrationsInputSchema = z.infer; diff --git a/packages/trpc/server/routers/loggedInViewer/locationOptions.handler.ts b/packages/trpc/server/routers/loggedInViewer/locationOptions.handler.ts index 4a09416c3e..61f13064ef 100644 --- a/packages/trpc/server/routers/loggedInViewer/locationOptions.handler.ts +++ b/packages/trpc/server/routers/loggedInViewer/locationOptions.handler.ts @@ -2,18 +2,27 @@ import { getLocationGroupedOptions } from "@calcom/app-store/utils"; import getEnabledApps from "@calcom/lib/apps/getEnabledApps"; import { getTranslation } from "@calcom/lib/server/i18n"; import { prisma } from "@calcom/prisma"; +import { AppCategories } from "@calcom/prisma/enums"; import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; +import type { TLocationOptionsInputSchema } from "./locationOptions.schema"; + type LocationOptionsOptions = { ctx: { user: NonNullable; }; + input: TLocationOptionsInputSchema; }; -export const locationOptionsHandler = async ({ ctx }: LocationOptionsOptions) => { +export const locationOptionsHandler = async ({ ctx, input }: LocationOptionsOptions) => { const credentials = await prisma.credential.findMany({ where: { userId: ctx.user.id, + app: { + categories: { + has: AppCategories.video, + }, + }, }, select: { id: true, @@ -30,6 +39,14 @@ export const locationOptionsHandler = async ({ ctx }: LocationOptionsOptions) => const t = await getTranslation(ctx.user.locale ?? "en", "common"); const locationOptions = getLocationGroupedOptions(integrations, t); + // If it is a team event then move the "use host location" option to top + if (input.teamId) { + const conferencingIndex = locationOptions.findIndex((option) => option.label === "Conferencing"); + if (conferencingIndex !== -1) { + const conferencingObject = locationOptions.splice(conferencingIndex, 1)[0]; + locationOptions.unshift(conferencingObject); + } + } return locationOptions; }; diff --git a/packages/trpc/server/routers/loggedInViewer/locationOptions.schema.ts b/packages/trpc/server/routers/loggedInViewer/locationOptions.schema.ts index cb0ff5c3b5..36dcd657c3 100644 --- a/packages/trpc/server/routers/loggedInViewer/locationOptions.schema.ts +++ b/packages/trpc/server/routers/loggedInViewer/locationOptions.schema.ts @@ -1 +1,7 @@ -export {}; +import { z } from "zod"; + +export const ZLocationOptionsInputSchema = z.object({ + teamId: z.number().optional(), +}); + +export type TLocationOptionsInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/bookings/confirm.handler.ts b/packages/trpc/server/routers/viewer/bookings/confirm.handler.ts index 95fdc49af5..1ce4f63571 100644 --- a/packages/trpc/server/routers/viewer/bookings/confirm.handler.ts +++ b/packages/trpc/server/routers/viewer/bookings/confirm.handler.ts @@ -11,7 +11,7 @@ import { getTranslation } from "@calcom/lib/server"; import { prisma } from "@calcom/prisma"; import { BookingStatus, MembershipRole, SchedulingType, WebhookTriggerEvents } from "@calcom/prisma/enums"; import type { CalendarEvent } from "@calcom/types/Calendar"; -import type { IAbstractPaymentService } from "@calcom/types/PaymentService"; +import type { IAbstractPaymentService, PaymentApp } from "@calcom/types/PaymentService"; import { TRPCError } from "@trpc/server"; @@ -277,8 +277,10 @@ export const confirmHandler = async ({ ctx, input }: ConfirmOptions) => { } // Posible to refactor TODO: - const paymentApp = await appStore[paymentAppCredential?.app?.dirName as keyof typeof appStore](); - if (!(paymentApp && "lib" in paymentApp && "PaymentService" in paymentApp.lib)) { + const paymentApp = (await appStore[ + paymentAppCredential?.app?.dirName as keyof typeof appStore + ]()) as PaymentApp; + if (!paymentApp?.lib?.PaymentService) { console.warn(`payment App service of type ${paymentApp} is not implemented`); return null; } diff --git a/packages/trpc/server/routers/viewer/eventTypes/create.handler.ts b/packages/trpc/server/routers/viewer/eventTypes/create.handler.ts index 7b7308f072..0e86578220 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/create.handler.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/create.handler.ts @@ -40,7 +40,6 @@ export const createHandler = async ({ ctx, input }: CreateOptions) => { locations = [{ type: DailyLocationType }]; } - // If its defaulting to daily no point handling compute as its done if (defaultConferencingData && defaultConferencingData.appSlug !== "daily-video") { const credentials = ctx.user.credentials; const foundApp = getApps(credentials).filter((app) => app.slug === defaultConferencingData.appSlug)[0]; // There is only one possible install here so index [0] is the one we are looking for ; diff --git a/packages/trpc/server/routers/viewer/payments/chargeCard.handler.ts b/packages/trpc/server/routers/viewer/payments/chargeCard.handler.ts index 17bb7bc520..8d5e654d6d 100644 --- a/packages/trpc/server/routers/viewer/payments/chargeCard.handler.ts +++ b/packages/trpc/server/routers/viewer/payments/chargeCard.handler.ts @@ -4,7 +4,7 @@ import { sendNoShowFeeChargedEmail } from "@calcom/emails"; import { getTranslation } from "@calcom/lib/server/i18n"; import type { PrismaClient } from "@calcom/prisma/client"; import type { CalendarEvent } from "@calcom/types/Calendar"; -import type { IAbstractPaymentService } from "@calcom/types/PaymentService"; +import type { IAbstractPaymentService, PaymentApp } from "@calcom/types/PaymentService"; import { TRPCError } from "@trpc/server"; @@ -94,9 +94,11 @@ export const chargeCardHandler = async ({ ctx, input }: ChargeCardHandlerOptions throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid payment credential" }); } - const paymentApp = await appStore[paymentCredential?.app?.dirName as keyof typeof appStore](); + const paymentApp = (await appStore[ + paymentCredential?.app?.dirName as keyof typeof appStore + ]()) as PaymentApp; - if (!("lib" in paymentApp && "PaymentService" in paymentApp.lib)) { + if (!paymentApp?.lib?.PaymentService) { throw new TRPCError({ code: "BAD_REQUEST", message: "Payment service not found" }); } // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/packages/trpc/server/routers/viewer/teams/_router.tsx b/packages/trpc/server/routers/viewer/teams/_router.tsx index 5480bddff2..2985f04aab 100644 --- a/packages/trpc/server/routers/viewer/teams/_router.tsx +++ b/packages/trpc/server/routers/viewer/teams/_router.tsx @@ -9,6 +9,7 @@ import { ZDeleteInviteInputSchema } from "./deleteInvite.schema"; import { ZGetInputSchema } from "./get.schema"; import { ZGetMemberAvailabilityInputSchema } from "./getMemberAvailability.schema"; import { ZGetMembershipbyUserInputSchema } from "./getMembershipbyUser.schema"; +import { ZGetUserAdminTeamsInputSchema } from "./getUserAdminTeams.schema"; import { ZInviteMemberInputSchema } from "./inviteMember/inviteMember.schema"; import { ZInviteMemberByTokenSchemaInputSchema } from "./inviteMemberByToken.schema"; import { ZListMembersInputSchema } from "./listMembers.schema"; @@ -36,6 +37,7 @@ type TeamsRouterHandlerCache = { listMembers?: typeof import("./listMembers.handler").listMembersHandler; hasTeamPlan?: typeof import("./hasTeamPlan.handler").hasTeamPlanHandler; listInvites?: typeof import("./listInvites.handler").listInvitesHandler; + getUserAdminTeams?: typeof import("./getUserAdminTeams.handler").getUserAdminTeamsHandler; createInvite?: typeof import("./createInvite.handler").createInviteHandler; setInviteExpiration?: typeof import("./setInviteExpiration.handler").setInviteExpirationHandler; deleteInvite?: typeof import("./deleteInvite.handler").deleteInviteHandler; @@ -342,6 +344,25 @@ export const viewerTeamsRouter = router({ ctx, }); }), + + getUserAdminTeams: authedProcedure.input(ZGetUserAdminTeamsInputSchema).query(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.getUserAdminTeams) { + UNSTABLE_HANDLER_CACHE.getUserAdminTeams = await import("./getUserAdminTeams.handler").then( + (mod) => mod.getUserAdminTeamsHandler + ); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.getUserAdminTeams) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.getUserAdminTeams({ + ctx, + input, + }); + }), + createInvite: authedProcedure.input(ZCreateInviteInputSchema).mutation(async ({ ctx, input }) => { if (!UNSTABLE_HANDLER_CACHE.createInvite) { UNSTABLE_HANDLER_CACHE.createInvite = await import("./createInvite.handler").then( @@ -349,7 +370,6 @@ export const viewerTeamsRouter = router({ ); } - // Unreachable code but required for type safety if (!UNSTABLE_HANDLER_CACHE.createInvite) { throw new Error("Failed to load handler"); } @@ -359,6 +379,7 @@ export const viewerTeamsRouter = router({ input, }); }), + setInviteExpiration: authedProcedure .input(ZSetInviteExpirationInputSchema) .mutation(async ({ ctx, input }) => { diff --git a/packages/trpc/server/routers/viewer/teams/getUserAdminTeams.handler.ts b/packages/trpc/server/routers/viewer/teams/getUserAdminTeams.handler.ts new file mode 100644 index 0000000000..d4b2a3829e --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/getUserAdminTeams.handler.ts @@ -0,0 +1,17 @@ +import getUserAdminTeams from "@calcom/features/ee/teams/lib/getUserAdminTeams"; + +import type { TrpcSessionUser } from "../../../trpc"; +import type { TGetUserAdminTeamsInputSchema } from "./getUserAdminTeams.schema"; + +type ListOptions = { + ctx: { + user: NonNullable; + }; + input: TGetUserAdminTeamsInputSchema; +}; + +export const getUserAdminTeamsHandler = async ({ ctx, input }: ListOptions) => { + const teams = await getUserAdminTeams({ userId: ctx.user.id, getUserInfo: input.getUserInfo }); + // TODO display install options for app pages and disable if already installed + return teams; +}; diff --git a/packages/trpc/server/routers/viewer/teams/getUserAdminTeams.schema.ts b/packages/trpc/server/routers/viewer/teams/getUserAdminTeams.schema.ts new file mode 100644 index 0000000000..cbe40c7c53 --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/getUserAdminTeams.schema.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +export const ZGetUserAdminTeamsInputSchema = z.object({ + getUserInfo: z.boolean().optional(), +}); + +export type TGetUserAdminTeamsInputSchema = z.infer; diff --git a/packages/types/AppHandler.d.ts b/packages/types/AppHandler.d.ts index 9b4eda52d6..81a5132d9e 100644 --- a/packages/types/AppHandler.d.ts +++ b/packages/types/AppHandler.d.ts @@ -9,7 +9,12 @@ export type AppDeclarativeHandler = { variant: string; supportsMultipleInstalls: false; handlerType: "add"; - createCredential: (arg: { user: Session["user"]; appType: string; slug: string }) => Promise; + createCredential: (arg: { + user: Session["user"]; + appType: string; + slug: string; + teamId?: number; + }) => Promise; supportsMultipleInstalls: boolean; redirect?: { newTab?: boolean; diff --git a/packages/types/Calendar.d.ts b/packages/types/Calendar.d.ts index 1025884e59..52deb0f43a 100644 --- a/packages/types/Calendar.d.ts +++ b/packages/types/Calendar.d.ts @@ -9,6 +9,7 @@ import type { bookingResponse } from "@calcom/features/bookings/lib/getBookingRe import type { Calendar } from "@calcom/features/calendars/weeklyview"; import type { TimeFormat } from "@calcom/lib/timeFormat"; import type { Frequency } from "@calcom/prisma/zod-utils"; +import type { CredentialPayload } from "@calcom/types/Credential"; import type { Ensure } from "./utils"; @@ -232,3 +233,10 @@ export interface Calendar { listCalendars(event?: CalendarEvent): Promise; } + +/** + * @see [How to inference class type that implements an interface](https://stackoverflow.com/a/64765554/6297100) + */ +type Class = new (...args: Args) => I; + +export type CalendarClass = Class; diff --git a/packages/types/Credential.d.ts b/packages/types/Credential.d.ts index 9db77c2ff7..2737c06024 100644 --- a/packages/types/Credential.d.ts +++ b/packages/types/Credential.d.ts @@ -11,6 +11,7 @@ export type CredentialPayload = Prisma.CredentialGetPayload<{ appId: true; type: true; userId: true; + teamId?: true; key: true; invalid: true; }; diff --git a/packages/types/PaymentService.d.ts b/packages/types/PaymentService.d.ts index 1e7fdfff7f..26994b1bd3 100644 --- a/packages/types/PaymentService.d.ts +++ b/packages/types/PaymentService.d.ts @@ -2,6 +2,12 @@ import type { Payment, Prisma, Booking, PaymentOption } from "@prisma/client"; import type { CalendarEvent } from "@calcom/types/Calendar"; +export interface PaymentApp { + lib?: { + PaymentService: IAbstractPaymentService; + }; +} + export interface IAbstractPaymentService { /* This method is for creating charges at the time of booking */ create( diff --git a/packages/ui/components/apps/AllApps.tsx b/packages/ui/components/apps/AllApps.tsx index f14bedf794..71c93c221a 100644 --- a/packages/ui/components/apps/AllApps.tsx +++ b/packages/ui/components/apps/AllApps.tsx @@ -3,6 +3,7 @@ import { useRouter } from "next/router"; import type { UIEvent } from "react"; import { useEffect, useRef, useState } from "react"; +import type { UserAdminTeams } from "@calcom/features/ee/teams/lib/getUserAdminTeams"; import { classNames } from "@calcom/lib"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import type { AppFrontendPayload as App } from "@calcom/types/App"; @@ -42,6 +43,7 @@ type AllAppsPropsType = { apps: (App & { credentials?: Credential[] })[]; searchText?: string; categories: string[]; + userAdminTeams?: UserAdminTeams; }; interface CategoryTabProps { @@ -130,7 +132,7 @@ function CategoryTab({ selectedCategory, categories, searchText }: CategoryTabPr ); } -export function AllApps({ apps, searchText, categories }: AllAppsPropsType) { +export function AllApps({ apps, searchText, categories, userAdminTeams }: AllAppsPropsType) { const router = useRouter(); const { t } = useLocale(); const [selectedCategory, setSelectedCategory] = useState(null); @@ -172,7 +174,13 @@ export function AllApps({ apps, searchText, categories }: AllAppsPropsType) { className="grid gap-3 lg:grid-cols-4 [@media(max-width:1270px)]:grid-cols-3 [@media(max-width:500px)]:grid-cols-1 [@media(max-width:730px)]:grid-cols-1" ref={appsContainerRef}> {filteredApps.map((app) => ( - + ))}{" "}
    ) : ( diff --git a/packages/ui/components/apps/AppCard.tsx b/packages/ui/components/apps/AppCard.tsx index 302909b1a1..c840c15437 100644 --- a/packages/ui/components/apps/AppCard.tsx +++ b/packages/ui/components/apps/AppCard.tsx @@ -3,10 +3,22 @@ import { useEffect, useState } from "react"; import useAddAppMutation from "@calcom/app-store/_utils/useAddAppMutation"; import { InstallAppButton } from "@calcom/app-store/components"; +import type { UserAdminTeams } from "@calcom/features/ee/teams/lib/getUserAdminTeams"; import classNames from "@calcom/lib/classNames"; +import { CAL_URL } from "@calcom/lib/constants"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import type { AppFrontendPayload as App } from "@calcom/types/App"; import type { CredentialFrontendPayload as Credential } from "@calcom/types/Credential"; +import type { ButtonProps } from "@calcom/ui"; +import { + Dropdown, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuPortal, + DropdownMenuLabel, + DropdownItem, + Avatar, +} from "@calcom/ui"; import { Button } from "../button"; import { Plus } from "../icon"; @@ -16,25 +28,18 @@ interface AppCardProps { app: App; credentials?: Credential[]; searchText?: string; + userAdminTeams?: UserAdminTeams; } -export function AppCard({ app, credentials, searchText }: AppCardProps) { +export function AppCard({ app, credentials, searchText, userAdminTeams }: AppCardProps) { const { t } = useLocale(); - const router = useRouter(); - const mutation = useAddAppMutation(null, { - onSuccess: (data) => { - // Refresh SSR page content without actual reload - router.replace(router.asPath); - if (data?.setupPending) return; - showToast(t("app_successfully_installed"), "success"); - }, - onError: (error) => { - if (error instanceof Error) showToast(error.message || t("app_could_not_be_installed"), "error"); - }, - }); const allowedMultipleInstalls = app.categories && app.categories.indexOf("calendar") > -1; const appAdded = (credentials && credentials.length) || 0; + const appInstalled = userAdminTeams?.length + ? userAdminTeams.length && appAdded >= userAdminTeams.length + : appAdded > 0; + const [searchTextIndex, setSearchTextIndex] = useState(undefined); useEffect(() => { @@ -103,25 +108,21 @@ export function AppCard({ app, credentials, searchText }: AppCardProps) { if (useDefaultComponent) { props = { ...props, - onClick: () => { - mutation.mutate({ type: app.type, variant: app.variant, slug: app.slug }); - }, }; } return ( - + ); }} /> ) : credentials && - credentials.length === 0 && ( + !appInstalled && ( { - mutation.mutate({ type: app.type, variant: app.variant, slug: app.slug }); - }, disabled: !!props.disabled, }; } return ( - + ); }} /> )}
    - {appAdded > 0 && ( + {appInstalled ? ( {t("installed", { count: appAdded })} - )} + ) : null} {app.isTemplate && ( Template )} @@ -170,3 +167,97 @@ export function AppCard({ app, credentials, searchText }: AppCardProps) {
    ); } + +const InstallAppButtonChild = ({ + userAdminTeams, + addAppMutationInput, + appCategories, + credentials, + ...props +}: { + userAdminTeams?: UserAdminTeams; + addAppMutationInput: { type: App["type"]; variant: string; slug: string }; + appCategories: string[]; + credentials?: Credential[]; +} & ButtonProps) => { + const { t } = useLocale(); + const router = useRouter(); + + const mutation = useAddAppMutation(null, { + onSuccess: (data) => { + // Refresh SSR page content without actual reload + router.replace(router.asPath); + if (data?.setupPending) return; + showToast(t("app_successfully_installed"), "success"); + }, + onError: (error) => { + if (error instanceof Error) showToast(error.message || t("app_could_not_be_installed"), "error"); + }, + }); + + if ( + !userAdminTeams?.length || + appCategories.some((category) => category === "calendar" || category === "video") + ) { + return ( + + ); + } + + return ( + + + + + + + {t("install_app_on")} + {userAdminTeams.map((team) => { + const isInstalledTeamOrUser = + credentials && + credentials.some((credential) => + credential?.teamId ? credential?.teamId === team.id : credential.userId === team.id + ); + return ( + ( + + )} + onClick={() => { + mutation.mutate( + team.isUser ? addAppMutationInput : { ...addAppMutationInput, teamId: team.id } + ); + }}> +

    + {team.name} {isInstalledTeamOrUser && `(${t("installed")})`} +

    +
    + ); + })} +
    +
    +
    + ); +}; diff --git a/packages/ui/components/form/switch/Switch.tsx b/packages/ui/components/form/switch/Switch.tsx index ac4198de0e..b37305f9e6 100644 --- a/packages/ui/components/form/switch/Switch.tsx +++ b/packages/ui/components/form/switch/Switch.tsx @@ -24,9 +24,10 @@ const Switch = ( container?: string; thumb?: string; }; + LockedIcon?: React.ReactNode; } ) => { - const { label, fitToHeight, classNames, labelOnLeading, ...primitiveProps } = props; + const { label, fitToHeight, classNames, labelOnLeading, LockedIcon, ...primitiveProps } = props; const id = useId(); const isChecked = props.checked || props.defaultChecked; return ( @@ -38,6 +39,7 @@ const Switch = ( labelOnLeading && "flex-row-reverse", classNames?.container )}> + {LockedIcon &&
    {LockedIcon}
    }