From 5003ada6718f659edd493d816fd24dd4faf3a5fc Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com> Date: Thu, 6 Jul 2023 12:48:39 -0400 Subject: [PATCH] feat: Enable Apps for Teams & Orgs [CAL-1782] (#9337) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Initial commit * Adding feature flag * Add schema relation for teams and credentials * feat: Orgs Schema Changing `scopedMembers` to `orgUsers` (#9209) * Change scopedMembers to orgMembers * Change to orgUsers * Create getUserAdminTeams function & tRPC endpoint * Get user admin teams on app store page * Create UserAdminTeams type * Add user query to getUserAdminTeams * Letting duplicate slugs for teams to support orgs * Covering null on unique clauses * Add dropdown to install button on app store * Supporting having the orgId in the session cookie * On app page, only show dropdown if there are teams * Add teamId to OAuth state * Create team credential for OAuth flow * Create team credential for GCal * Add create user or team credential for Stripe * Create webex credentials for users or teams * Fix type error on useAddAppMutation * Hubspot create credential on user or team * Zoho create create credential for user or team * Zoom create credentials on user or team * Salesforce create credential on user or teams * OAuth create credentials for user or teams * Revert Outlook changes * Revert GCal changes * Default app instal, create credential on user or team * Add teamId to credential creation * Disable installing for teams for calendars * Include teams when querying installed apps * Render team credentials on installed page * Uninstall team apps * Type fix on app card * Add input to include user in teams query * Add dropdown to install app page for user or team * Type fixes on category page * Install app from eventType page to user or team * Render user and team apps on event type app page * feat: organization event type filter (#9253) Signed-off-by: Udit Takkar * Missing changes to support orgs schema changes * Render user and team apps on event type app page * Add credentialOwner to eventTypeAppCard types * Type fixes * Create hook to check if app is enabled * Clean up console.logs * Fix useIsAppEnabled by returning not an array * Convert event type apps to useIsAppEnabled * Abstract credential owner type * Remove console.logs * On installed app page, show apps if only team credential is installed * Clean up commented lines * Handle installing app to just an team event from event type page * Fix early return when creating team app credential * Zoom add state to callback * Get team location credentials and save credential id to location * feat: Onboarding process to create an organization (#9184) * Desktop first banner, mobile pending * Removing dead code and img * WIP * Adds Email verification template+translations for organizations (#9202) * First step done * Merge branch 'feat/organizations-onboarding' of github.com:calcom/cal.com into feat/organizations-onboarding * Step 2 done, avatar not working * Covering null on unique clauses * Onboarding admins step * Last step to create teams * Moving change password handler, improving verifying code flow * Clearing error before submitting * Reverting email testing api changes * Reverting having the banner for now * Consistent exported components * Remove unneeded files from banner * Removing uneeded code * Fixing avatar selector * Using meta component for head/descr * Missing i18n strings * Feedback * Making an org avatar (temp) * Check for subteams slug clashes with usernames * Fixing create teams onsuccess * feedback * Making sure we check requestedSlug now --------- Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com> * Type fix * Grab team location credentials * Add isInstalled to eventType apps query * feat: [CAL-1816] Organization subdomain support (#9345) * Desktop first banner, mobile pending * Removing dead code and img * WIP * Adds Email verification template+translations for organizations (#9202) * First step done * Merge branch 'feat/organizations-onboarding' of github.com:calcom/cal.com into feat/organizations-onboarding * Step 2 done, avatar not working * Covering null on unique clauses * Onboarding admins step * Last step to create teams * Moving change password handler, improving verifying code flow * Clearing error before submitting * Reverting email testing api changes * Reverting having the banner for now * Consistent exported components * Remove unneeded files from banner * Removing uneeded code * Fixing avatar selector * Using meta component for head/descr * Missing i18n strings * Feedback * Making an org avatar (temp) * Check for subteams slug clashes with usernames * Fixing create teams onsuccess * Covering users and subteams, excluding non-org users * Unpublished teams shows correctly * Create subdomain in Vercel * feedback * Renaming Vercel env vars * Vercel domain check before creation * Supporting cal-staging.com * Change to have vercel detect it * vercel domain check data message error * Remove check domain * Making sure we check requestedSlug now * Feedback and unneeded code * Reverting unneeded changes * Unneeded changes --------- Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com> * Vercel subdomain creation in PROD only * Enable payment apps for team credentials * Fix for team-user apps for event types * Fix layout and add teamId to app card * Disable apps on managed event types * Add managed event type fields to event type apps * Include organizations in query * Change createAppCredential to createOAuthAppCredential * Show app installed on teams * Making sure we let localhost still work * UI show installed for which team * Type fixes * For team events move use host location to top * Add around to appStore * New team event types organizer default conf app * Fix app card bug * Clean up * Search for teamId or userId when deleting credential * Type fixes * Type fixes * Type fixes * Type fixes * Address feedback * Feedback * Type check fixes * feat: Organization branding in side menu (#9279) * Desktop first banner, mobile pending * Removing dead code and img * WIP * Adds Email verification template+translations for organizations (#9202) * First step done * Merge branch 'feat/organizations-onboarding' of github.com:calcom/cal.com into feat/organizations-onboarding * Step 2 done, avatar not working * Covering null on unique clauses * Onboarding admins step * Last step to create teams * Moving change password handler, improving verifying code flow * Clearing error before submitting * Reverting email testing api changes * Reverting having the banner for now * Consistent exported components * Remove unneeded files from banner * Removing uneeded code * Fixing avatar selector * Org branding provider used in shell sidebar * Using meta component for head/descr * Missing i18n strings * Feedback * Making an org avatar (temp) * Using org avatar (temp) * Not showing org logo if not set * User onboarding with org branding (slug) * Check for subteams slug clashes with usernames * Fixing create teams onsuccess * feedback * Feedback * Org public profile * Public profiles for team event types * Added setup profile alert * Using org avatar on subteams avatar * Making sure we show the set up profile on org only * Profile username availability rely on org hook * Update apps/web/pages/team/[slug].tsx Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com> * Update apps/web/pages/team/[slug].tsx Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com> --------- Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com> * feat: Organization support for event types page (#9449) * Desktop first banner, mobile pending * Removing dead code and img * WIP * Adds Email verification template+translations for organizations (#9202) * First step done * Merge branch 'feat/organizations-onboarding' of github.com:calcom/cal.com into feat/organizations-onboarding * Step 2 done, avatar not working * Covering null on unique clauses * Onboarding admins step * Last step to create teams * Moving change password handler, improving verifying code flow * Clearing error before submitting * Reverting email testing api changes * Reverting having the banner for now * Consistent exported components * Remove unneeded files from banner * Removing uneeded code * Fixing avatar selector * Org branding provider used in shell sidebar * Using meta component for head/descr * Missing i18n strings * Feedback * Making an org avatar (temp) * Using org avatar (temp) * Not showing org logo if not set * User onboarding with org branding (slug) * Check for subteams slug clashes with usernames * Fixing create teams onsuccess * feedback * Feedback * Org public profile * Public profiles for team event types * Added setup profile alert * Using org avatar on subteams avatar * Processing orgs and children as profile options * Reverting change not belonging to this PR * Making sure we show the set up profile on org only * Removing console.log * Comparing memberships to choose the highest one --------- Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com> * Type errors * Refactor and type fixes * Update orgDomains.ts * Feedback * Reverting * NIT * Address feedback * fix issue getting org slug from domain * Improving orgDomains util * Host comes with port * Update useRouterQuery.ts * Fix app card bug * Fix schema * Type fixes * Revert changes to location apps * Remove console.log * Fix app store test * Handle install app dropdown * Add CalendarApp to `getCalendar` * Add PaymentApp type fix * Payment type fix * Type fixes * Match with main * Change type to account for team * Fix app count for team events * Type fixes * More type fixes * Type fix? * Fix the type fix * Remove UserAdminTeams empty array union * Type fix * Type fix * Type fix * Uses type predicates * Use teamId. Fixes installation for teams after user installation * Fix Team Events not working * Get embed for org events working * Fix rewrites * Address feedback * Type fix * Fixes * Add useAppContextWithSchema in useIsAppEnabled * Type fix for apps using useIsAppEnabled * Integrations.handler change credentialIds to userCredentialIds * Remove apps endpoint * Add LockedIcon and disabled props to event type app context * Type fixes * Type fix * Type fixes * Show team installed apps for members * Type fix * Reverting findFirst * Revert findFirst * Avoid a possible 500 * Fix missing tanslation * Avoid possible 500 * Undo default app for teams * Type fix * Fix test * Update package.json * feat: Fix invite bug - added tests (#9945) Co-authored-by: Hariom Balhara * chore: Button Component Tidy up (#9888) Co-authored-by: Peer Richelsen * feat: Make Team Private ## What does this PR do? Fixes https://github.com/calcom/cal.com/issues/8974 1) When user is admin Screenshot 2023-07-03 at 6 45 50 PM 2) When user is not admin and team is private Screenshot 2023-07-03 at 6 47 15 PM 3) Screenshot 2023-07-03 at 6 51 56 PM ## Type of change - New feature (non-breaking change which adds functionality) ## How should this be tested? 1) go to Team members page and turn on switch Make Team Private. Now after making the team private only admin would be able to see all the members list in the settings. There will not be a button to Book a team member instead on the team page like before. ## Mandatory Tasks - [ ] Make sure you have self-reviewed the code. A decent size PR without self-review might be rejected. --------- Signed-off-by: Udit Takkar Co-authored-by: Leo Giovanetti Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com> Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com> Co-authored-by: Alan Co-authored-by: zomars Co-authored-by: Efraín Rochín Co-authored-by: Hariom Balhara Co-authored-by: Peer Richelsen Co-authored-by: Keith Williams --- apps/web/components/AppListCard.tsx | 22 +- apps/web/components/apps/App.tsx | 148 ++++++++++-- .../components/dialog/EditLocationDialog.tsx | 12 +- .../web/components/eventtype/EventAppsTab.tsx | 78 +++++- .../components/eventtype/EventSetupTab.tsx | 2 +- .../eventtype/EventTypeSingleLayout.tsx | 2 +- .../steps-views/ConnectedVideoStep.tsx | 4 +- .../web/components/ui/form/LocationSelect.tsx | 12 +- apps/web/next.config.js | 161 +++++++------ apps/web/pages.js | 14 -- apps/web/pages/api/integrations/[...args].ts | 10 +- apps/web/pages/apps/index.tsx | 17 +- apps/web/pages/apps/installed/[category].tsx | 183 +++++++++------ apps/web/pages/event-types/[type]/index.tsx | 11 +- apps/web/pages/new-booker/[user]/[type].tsx | 2 +- .../pages/new-booker/team/[slug]/[type].tsx | 3 +- apps/web/pages/org/[orgSlug]/[user]/[type].ts | 63 ++++- .../org/[orgSlug]/[user]/[type]/embed.tsx | 19 ++ .../settings/my-account/conferencing.tsx | 2 +- apps/web/pages/team/[slug].tsx | 2 +- apps/web/pagesAndRewritePaths.js | 43 ++++ apps/web/playwright/webhook.e2e.ts | 1 + apps/web/public/static/locales/en/common.json | 3 +- apps/web/test/lib/next-config.test.ts | 222 +++++++++++------- packages/app-store/EventTypeAppContext.tsx | 11 +- packages/app-store/_appRegistry.ts | 13 +- packages/app-store/_components/AppCard.tsx | 68 ++++-- .../_components/EventTypeAppCardInterface.tsx | 11 +- .../_components/OmniInstallAppButton.tsx | 15 +- .../_utils/createOAuthAppCredential.ts | 65 +++++ packages/app-store/_utils/getCalendar.ts | 22 +- packages/app-store/_utils/installation.ts | 4 +- .../app-store/_utils/useAddAppMutation.ts | 8 +- packages/app-store/_utils/useIsAppEnabled.ts | 34 +++ packages/app-store/amie/api/add.ts | 4 +- packages/app-store/around/api/_getAdd.ts | 2 + packages/app-store/campfire/api/add.ts | 4 +- packages/app-store/cron/api/add.ts | 4 +- .../dailyvideo/lib/VideoApiAdapter.ts | 1 + packages/app-store/discord/api/add.ts | 4 +- packages/app-store/eightxeight/api/add.ts | 4 +- packages/app-store/eventTypeAppCardZod.ts | 1 + packages/app-store/facetime/api/add.ts | 4 +- packages/app-store/fathom/api/add.ts | 4 +- .../components/EventTypeAppCardInterface.tsx | 21 +- packages/app-store/ga4/api/add.ts | 4 +- .../components/EventTypeAppCardInterface.tsx | 21 +- packages/app-store/giphy/api/add.ts | 7 +- .../components/EventTypeAppCardInterface.tsx | 16 +- .../giphy/components/SelectGifInput.tsx | 18 +- packages/app-store/gtm/api/add.ts | 4 +- .../components/EventTypeAppCardInterface.tsx | 21 +- packages/app-store/hubspot/api/callback.ts | 13 +- packages/app-store/index.ts | 1 + packages/app-store/locations.ts | 1 + packages/app-store/metapixel/api/add.ts | 4 +- .../components/EventTypeAppCardInterface.tsx | 19 +- packages/app-store/mirotalk/api/add.ts | 4 +- packages/app-store/n8n/api/add.ts | 4 +- .../app-store/office365video/api/callback.ts | 10 +- packages/app-store/pipedream/api/add.ts | 4 +- packages/app-store/plausible/api/add.ts | 4 +- .../components/EventTypeAppCardInterface.tsx | 22 +- packages/app-store/qr_code/api/add.ts | 4 +- .../components/EventTypeAppCardInterface.tsx | 17 +- packages/app-store/raycast/api/add.ts | 4 +- packages/app-store/routing-forms/api/add.ts | 4 +- .../routing-forms/components/FormActions.tsx | 15 +- .../playwright/tests/basic.e2e.ts | 2 + packages/app-store/salesforce/api/callback.ts | 16 +- packages/app-store/signal/api/add.ts | 4 +- packages/app-store/sirius_video/api/add.ts | 4 +- .../app-store/stripepayment/api/callback.ts | 19 +- .../components/EventTypeAppCardInterface.tsx | 91 +++---- .../app-store/tandemvideo/api/callback.ts | 20 +- packages/app-store/telegram/api/add.ts | 4 +- packages/app-store/templates/basic/api/add.ts | 4 +- .../templates/booking-pages-tag/api/add.ts | 4 +- .../components/EventTypeAppCardInterface.tsx | 5 +- .../templates/event-type-app-card/api/add.ts | 4 +- .../components/EventTypeAppCardInterface.tsx | 16 +- .../api/add.ts | 4 +- .../templates/general-app-settings/api/add.ts | 4 +- .../templates/link-as-an-app/api/add.ts | 4 +- packages/app-store/typeform/api/add.ts | 4 +- .../typeform/playwright/tests/basic.e2e.ts | 2 + packages/app-store/types.d.ts | 23 +- packages/app-store/vimcal/api/add.ts | 4 +- .../weather_in_your_calendar/api/add.ts | 4 +- packages/app-store/webex/api/callback.ts | 16 +- packages/app-store/whatsapp/api/add.ts | 4 +- .../components/wipeMyCalActionButton.tsx | 2 +- packages/app-store/wordpress/api/add.ts | 4 +- .../app-store/zapier/pages/setup/index.tsx | 4 +- packages/app-store/zoho-bigin/api/callback.ts | 11 +- packages/app-store/zohocrm/api/callback.ts | 16 +- packages/app-store/zoomvideo/api/add.ts | 3 + packages/app-store/zoomvideo/api/callback.ts | 16 +- packages/core/EventManager.ts | 4 +- .../components/DisconnectIntegrationModal.tsx | 24 +- .../bookings/lib/handleCancelBooking.ts | 13 +- .../features/bookings/lib/handleNewBooking.ts | 10 +- .../hooks/useLockedFieldsManager.tsx | 4 +- .../components/AddNewTeamsForm.tsx | 2 +- .../ee/teams/lib/getUserAdminTeams.ts | 91 +++++++ packages/lib/getEventTypeById.ts | 3 + packages/lib/getOrgAwareUrl.ts | 20 ++ packages/lib/getPaymentAppData.ts | 10 +- packages/lib/payment/deletePayment.ts | 8 +- packages/lib/payment/handlePayment.ts | 8 +- .../migration.sql | 5 + packages/prisma/schema.prisma | 3 + packages/prisma/selects/credential.ts | 1 + packages/prisma/zod-utils.ts | 1 + .../server/middlewares/sessionMiddleware.ts | 1 + .../server/routers/loggedInViewer/_router.tsx | 20 +- .../appCredentialsByType.handler.ts | 31 ++- .../routers/loggedInViewer/apps.handler.ts | 24 -- .../routers/loggedInViewer/apps.schema.ts | 7 - .../deleteCredential.handler.ts | 4 +- .../loggedInViewer/deleteCredential.schema.ts | 1 + .../loggedInViewer/integrations.handler.ts | 131 ++++++++++- .../loggedInViewer/integrations.schema.ts | 3 + .../loggedInViewer/locationOptions.handler.ts | 19 +- .../loggedInViewer/locationOptions.schema.ts | 8 +- .../viewer/bookings/confirm.handler.ts | 8 +- .../viewer/eventTypes/create.handler.ts | 1 - .../viewer/payments/chargeCard.handler.ts | 8 +- .../server/routers/viewer/teams/_router.tsx | 23 +- .../viewer/teams/getUserAdminTeams.handler.ts | 17 ++ .../viewer/teams/getUserAdminTeams.schema.ts | 7 + packages/types/AppHandler.d.ts | 7 +- packages/types/Calendar.d.ts | 8 + packages/types/Credential.d.ts | 1 + packages/types/PaymentService.d.ts | 6 + packages/ui/components/apps/AllApps.tsx | 12 +- packages/ui/components/apps/AppCard.tsx | 165 ++++++++++--- packages/ui/components/form/switch/Switch.tsx | 4 +- 138 files changed, 1817 insertions(+), 785 deletions(-) delete mode 100644 apps/web/pages.js create mode 100644 apps/web/pages/org/[orgSlug]/[user]/[type]/embed.tsx create mode 100644 apps/web/pagesAndRewritePaths.js create mode 100644 packages/app-store/_utils/createOAuthAppCredential.ts create mode 100644 packages/app-store/_utils/useIsAppEnabled.ts create mode 100644 packages/features/ee/teams/lib/getUserAdminTeams.ts create mode 100644 packages/lib/getOrgAwareUrl.ts create mode 100644 packages/prisma/migrations/20230606202918_add_team_id_to_credential/migration.sql delete mode 100644 packages/trpc/server/routers/loggedInViewer/apps.handler.ts delete mode 100644 packages/trpc/server/routers/loggedInViewer/apps.schema.ts create mode 100644 packages/trpc/server/routers/viewer/teams/getUserAdminTeams.handler.ts create mode 100644 packages/trpc/server/routers/viewer/teams/getUserAdminTeams.schema.ts 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}
    }