feat: Enable Apps for Teams & Orgs [CAL-1782] (#9337)

* 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 <udit.07814802719@cse.mait.ac.in>

* 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 <hariombalhara@gmail.com>

* chore: Button Component Tidy up (#9888)

Co-authored-by: Peer Richelsen <peeroke@gmail.com>

* feat: Make Team Private

## What does this PR do?

Fixes https://github.com/calcom/cal.com/issues/8974

1) When user is admin

<img width="1440" alt="Screenshot 2023-07-03 at 6 45 50 PM" src="https://github.com/calcom/cal.com/assets/53316345/ce15158f-d278-4f1a-ba2e-8b63e4274793">

2) When user is not admin and team is private

<img width="1440" alt="Screenshot 2023-07-03 at 6 47 15 PM" src="https://github.com/calcom/cal.com/assets/53316345/ce23560e-690a-4c42-a76d-49691260aa4d">

3) 
<img width="1440" alt="Screenshot 2023-07-03 at 6 51 56 PM" src="https://github.com/calcom/cal.com/assets/53316345/13af38f8-5618-4dae-b359-b24dc91e4eb4">


## Type of change

<!-- Please delete bullets that are not relevant. -->

- 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 <udit.07814802719@cse.mait.ac.in>
Co-authored-by: Leo Giovanetti <hello@leog.me>
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 <alannnc@gmail.com>
Co-authored-by: zomars <zomars@me.com>
Co-authored-by: Efraín Rochín <roae.85@gmail.com>
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
This commit is contained in:
Joe Au-Yeung 2023-07-06 12:48:39 -04:00 committed by GitHub
parent 54374cf43e
commit 5003ada671
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
138 changed files with 1817 additions and 785 deletions

View File

@ -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 (
<div className={`${highlight ? "dark:bg-muted bg-yellow-100" : ""}`}>
<div className="flex gap-x-3 px-5 py-4">
<div className="flex items-center gap-x-3 px-5 py-4">
{logo ? <img className="h-10 w-10" src={logo} alt={`${title} logo`} /> : null}
<div className="flex grow flex-col gap-y-1 truncate">
<div className="flex items-center gap-x-2">
@ -85,6 +88,21 @@ export default function AppListCard(props: AppListCardProps) {
</div>
)}
</div>
{credentialOwner && (
<div>
<Badge variant="gray">
<div className="flex items-center">
<Avatar
className="mr-2"
alt={credentialOwner.name || "Nameless"}
size="xs"
imageSrc={credentialOwner.avatar}
/>
{credentialOwner.name}
</div>
</Badge>
</div>
)}
{actions}
</div>

View File

@ -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<number[]>([]);
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 = ({
)}
</header>
</div>
{!appCredentials.isLoading ? (
{!appDbQuery.isLoading ? (
isGlobal ||
(existingCredentials.length > 0 && allowedMultipleInstalls ? (
<div className="flex space-x-3">
@ -166,28 +184,19 @@ const Component = ({
};
}
return (
<Button
StartIcon={Plus}
{...props}
// @TODO: Overriding color and size prevent us from
// having to duplicate InstallAppButton for now.
color="primary"
size="base"
data-testid="install-app-button">
{t("install_another")}
</Button>
<InstallAppButtonChild appCategories={categories} userAdminTeams={appDbQuery.data?.userAdminTeams} addAppMutationInput={{ type, variant, slug }} multiInstall {...props} />
);
}}
/>
)}
</div>
) : existingCredentials.length > 0 ? (
) : showDisconnectIntegration ? (
<DisconnectIntegration
buttonProps={{ color: "secondary" }}
label={t("disconnect")}
credentialId={existingCredentials[0]}
onSuccess={() => {
appCredentials.refetch();
appDbQuery.refetch();
}}
/>
) : (
@ -206,15 +215,7 @@ const Component = ({
};
}
return (
<Button
data-testid="install-app-button"
{...props}
// @TODO: Overriding color and size prevent us from
// having to duplicate InstallAppButton for now.
color="primary"
size="base">
{t("install_app")}
</Button>
<InstallAppButtonChild appCategories={categories} userAdminTeams={appDbQuery.data?.userAdminTeams} addAppMutationInput={{type, variant, slug}} credentials={appDbQuery.data?.credentials} {...props} />
);
}}
/>
@ -385,3 +386,98 @@ export default function App(props: {
</Shell>
);
}
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 <Button
data-testid="install-app-button"
{...props}
// @TODO: Overriding color and size prevent us from
// having to duplicate InstallAppButton for now.
color="primary"
size="base">
{multiInstall ? t("install_another") : t("install_app")}
</Button>
}
return (
<Dropdown>
<DropdownMenuTrigger asChild>
<Button
data-testid="install-app-button"
{...props}
// @TODO: Overriding color and size prevent us from
// having to duplicate InstallAppButton for now.
color="primary"
size="base">
{multiInstall ? t("install_another") : t("install_app")}
</Button>
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent>
<DropdownMenuLabel>{t("install_app_on")}</DropdownMenuLabel>
{userAdminTeams.map((team) => {
const isInstalled = credentials &&
credentials.some((credential) =>
credential?.teamId ? credential?.teamId === team.id : credential.userId === team.id
)
return (
<DropdownItem
type="button"
data-testid={team.isUser ? "install-app-button-personal" : "anything else"}
key={team.id}
disabled={
isInstalled
}
StartIcon={(props) => (
<Avatar
alt={team.logo || ""}
imageSrc={team.logo || `${CAL_URL}/${team.logo}/avatar.png`} // if no image, use default avatar
size="sm"
{...props}
/>
)}
onClick={() => {
mutation.mutate(
team.isUser ? addAppMutationInput : { ...addAppMutationInput, teamId: team.id }
);
}}>
<p>{team.name}{" "}
{isInstalled &&
`(${t("installed")})`}</p>
</DropdownItem>
)})}
</DropdownMenuContent>
</DropdownMenuPortal>
</Dropdown>
);
};

View File

@ -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<React.SetStateAction<boolean>>;
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;
}

View File

@ -16,12 +16,16 @@ export type EventType = Pick<EventTypeSetupProps, "eventType">["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<FormValues>();
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(
<EventTypeAppCard
getAppData={getAppDataGetter(app.slug as EventTypeAppsList)}
setAppData={getAppDataSetter(app.slug as EventTypeAppsList)}
key={app.slug}
app={app}
eventType={eventType}
{...shouldLockDisableProps("apps")}
/>
);
}
for (const team of app.teams) {
if (team) {
appCards.push(
<EventTypeAppCard
getAppData={getAppDataGetter(app.slug as EventTypeAppsList)}
setAppData={getAppDataSetter(app.slug as EventTypeAppsList)}
key={app.slug + team?.credentialId}
app={{
...app,
// credentialIds: team?.credentialId ? [team.credentialId] : [],
credentialOwner: {
name: team.name,
avatar: team.logo,
teamId: team.teamId,
credentialId: team.credentialId,
},
}}
eventType={eventType}
{...shouldLockDisableProps("apps")}
/>
);
}
}
return appCards;
});
return (
<>
<div>
<div className="before:border-0">
{!installedApps?.length && isManagedEventType && (
{isManagedEventType && (
<Alert
severity="neutral"
className="mb-2"
@ -92,15 +139,20 @@ export const EventAppsTab = ({ eventType }: { eventType: EventType }) => {
}
/>
) : null}
{installedApps?.map((app) => (
<EventTypeAppCard
getAppData={getAppDataGetter(app.slug as EventTypeAppsList)}
setAppData={getAppDataSetter(app.slug as EventTypeAppsList)}
key={app.slug}
app={app}
eventType={eventType}
/>
))}
{cardsForAppsWithTeams.map((apps) => apps.map((cards) => cards))}
{installedApps.map((app) => {
if (!app.teams.length)
return (
<EventTypeAppCard
getAppData={getAppDataGetter(app.slug as EventTypeAppsList)}
setAppData={getAppDataSetter(app.slug as EventTypeAppsList)}
key={app.slug}
app={app}
eventType={eventType}
{...shouldLockDisableProps("apps")}
/>
);
})}
</div>
</div>
{!shouldLockDisableProps("apps").disabled && (

View File

@ -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 */}
<EditLocationDialog
isTeamEvent={!!team}
isOpenDialog={showLocationModal}
setShowLocationModal={setShowLocationModal}
saveLocation={saveLocation}
@ -553,6 +552,7 @@ export const EventSetupTab = (
}
setSelectedLocation={setSelectedLocation}
setEditingLocationType={setEditingLocationType}
teamId={eventType.team?.id}
/>
</div>
);

View File

@ -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",

View File

@ -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}
/>
)}
</li>

View File

@ -40,11 +40,13 @@ export default function LocationSelect(props: Props<LocationOption, false, Group
name="location"
id="location-select"
components={{
Option: (props) => (
<components.Option {...props}>
<OptionWithIcon icon={props.data.icon} label={props.data.label} />
</components.Option>
),
Option: (props) => {
return (
<components.Option {...props}>
<OptionWithIcon icon={props.data.icon} label={props.data.label} />
</components.Option>
);
},
SingleValue: (props) => (
<components.SingleValue {...props}>
<OptionWithIcon icon={props.data.icon} label={props.data.label} />

View File

@ -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 = `^(?<orgSlug>${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: `^(?<orgSlug>${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: "(?<uid>.*)",
},
],
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: "(?<uid>.*)",
},
],
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",
},
],
},

View File

@ -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;

View File

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

View File

@ -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<typeof getServerSideProps>) {
export default function Apps({
categories,
appStore,
userAdminTeams,
}: inferSSRProps<typeof getServerSideProps>) {
const { t } = useLocale();
const [searchText, setSearchText] = useState<string | undefined>(undefined);
@ -80,6 +86,7 @@ export default function Apps({ categories, appStore }: inferSSRProps<typeof getS
apps={appStore}
searchText={searchText}
categories={categories.map((category) => category.name)}
userAdminTeams={userAdminTeams}
/>
</div>
</AppsLayout>
@ -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<string, number>);
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(),
},
};

View File

@ -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: {
<DropdownMenuItem>
<DropdownItem
color="destructive"
onClick={() => 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 (
<AppListCard
key={item.name}
description={item.description}
title={item.name}
logo={item.logo}
isDefault={appIsDefault}
shouldHighlight
slug={item.slug}
invalidCredential={item?.invalidCredentialIds ? item.invalidCredentialIds.length > 0 : false}
credentialOwner={item?.credentialOwner}
actions={
!item.credentialOwner?.readOnly ? (
<div className="flex justify-end">
<Dropdown modal={false}>
<DropdownMenuTrigger asChild>
<Button StartIcon={MoreHorizontal} variant="icon" color="secondary" />
</DropdownMenuTrigger>
<DropdownMenuContent>
{!appIsDefault && variant === "conferencing" && (
<DropdownMenuItem>
<DropdownItem
type="button"
color="secondary"
StartIcon={Video}
onClick={() => {
const locationType = getEventLocationTypeFromApp(item?.locationOption?.value ?? "");
if (locationType?.linkType === "static") {
setLocationType({ ...locationType, slug: appSlug });
} else {
updateDefaultAppMutation.mutate({
appSlug,
});
setBulkUpdateModal(true);
}
}}>
{t("set_as_default")}
</DropdownItem>
</DropdownMenuItem>
)}
<ConnectOrDisconnectIntegrationMenuItem
credentialId={item.credentialOwner?.credentialId || item.userCredentialIds[0]}
type={item.type}
isGlobal={item.isGlobal}
installed
invalidCredentialIds={item.invalidCredentialIds}
handleDisconnect={handleDisconnect}
teamId={item.credentialOwner ? item.credentialOwner?.teamId : undefined}
/>
</DropdownMenuContent>
</Dropdown>
</div>
) : null
}>
<AppSettings slug={item.slug} />
</AppListCard>
);
};
const appsWithTeamCredentials = data.items.filter((app) => app.teams.length);
const cardsForAppsWithTeams = appsWithTeamCredentials.map((app) => {
const appCards = [];
if (app.userCredentialIds.length) {
appCards.push(<ChildAppCard item={app} />);
}
for (const team of app.teams) {
if (team) {
appCards.push(
<ChildAppCard
item={{
...app,
credentialOwner: {
name: team.name,
avatar: team.logo,
teamId: team.teamId,
credentialId: team.credentialId,
readOnly: !team.isAdmin,
},
}}
/>
);
}
}
return appCards;
});
const { t } = useLocale();
return (
<>
<List>
{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 (
<AppListCard
key={item.name}
description={item.description}
title={item.name}
logo={item.logo}
isDefault={appIsDefault}
shouldHighlight
slug={item.slug}
invalidCredential={item.invalidCredentialIds.length > 0}
actions={
<div className="flex justify-end">
<Dropdown modal={false}>
<DropdownMenuTrigger asChild>
<Button StartIcon={MoreHorizontal} variant="icon" color="secondary" />
</DropdownMenuTrigger>
<DropdownMenuContent>
{!appIsDefault && variant === "conferencing" && (
<DropdownMenuItem>
<DropdownItem
type="button"
color="secondary"
StartIcon={Video}
onClick={() => {
const locationType = getEventLocationTypeFromApp(
item?.locationOption?.value ?? ""
);
if (locationType?.linkType === "static") {
setLocationType({ ...locationType, slug: appSlug });
} else {
updateDefaultAppMutation.mutate({
appSlug,
});
setBulkUpdateModal(true);
}
}}>
{t("set_as_default")}
</DropdownItem>
</DropdownMenuItem>
)}
<ConnectOrDisconnectIntegrationMenuItem
credentialIds={item.credentialIds}
type={item.type}
isGlobal={item.isGlobal}
installed
invalidCredentialIds={item.invalidCredentialIds}
handleDisconnect={handleDisconnect}
/>
</DropdownMenuContent>
</Dropdown>
</div>
}>
<AppSettings slug={item.slug} />
</AppListCard>
);
if (!item.teams.length) return <ChildAppCard item={item} />;
})}
</List>
{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<AppCategories, LucideIcon> = {
@ -299,6 +346,7 @@ type querySchemaType = z.infer<typeof querySchema>;
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}
/>
</>
);

View File

@ -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) => {
<>
<EventTypeSingleLayout
enabledAppsNumber={numberOfActiveApps}
installedAppsNumber={numberOfInstalledApps}
installedAppsNumber={eventTypeApps?.items.length || 0}
enabledWorkflowsNumber={eventType.workflows.length}
eventType={eventType}
team={team}

View File

@ -15,7 +15,7 @@ import type { inferSSRProps } from "@lib/types/inferSSRProps";
import PageWrapper from "@components/PageWrapper";
type PageProps = inferSSRProps<typeof getServerSideProps>;
export type PageProps = inferSSRProps<typeof getServerSideProps>;
export default function Type({ slug, user, booking, away, isBrandingHidden }: PageProps) {
const isEmbed = typeof window !== "undefined" && window?.isEmbed?.();

View File

@ -13,7 +13,7 @@ import type { inferSSRProps } from "@lib/types/inferSSRProps";
import PageWrapper from "@components/PageWrapper";
type PageProps = inferSSRProps<typeof getServerSideProps>;
export type PageProps = inferSSRProps<typeof getServerSideProps>;
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,

View File

@ -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;

View File

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

View File

@ -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")}

View File

@ -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 = () => (
<ul className="border-subtle rounded-md border">

View File

@ -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 = `^(?<orgSlug>${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`;

View File

@ -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`);

View File

@ -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.",

View File

@ -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<string, string>}
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>[^/]+)
// - /:user(?!404)[^/]+/ -> (?<user>((?!404)[^/]+))
// userTypeRouteRegExp = `/:user((?!${pages.join("/|")})[^/]*)/:type((?!book$)[^/]+)`;
userTypeRouteRegExp = getRegExpFromNextJsRewriteRegExp(`/(?<user>((?!${pages.join("/|")})[^/]*))/(?<type>((?!book$)[^/]+))`);
// teamTypeRouteRegExp = "/team/:slug/:type((?!book$)[^/]+)";
teamTypeRouteRegExp = getRegExpFromNextJsRewriteRegExp("/team/(?<slug>[^/]+)/(?<type>((?!book$)[^/]+))");
// privateLinkRouteRegExp = "/d/:link/:slug((?!book$)[^/]+)";
privateLinkRouteRegExp = getRegExpFromNextJsRewriteRegExp("/d/(?<link>[^/]+)/(?<slug>((?!book$)[^/]+))");
// embedUserTypeRouteRegExp = `/:user((?!${pages.join("/|")})[^/]*)/:type/embed`;
embedUserTypeRouteRegExp = getRegExpFromNextJsRewriteRegExp(`/(?<user>((?!${pages.join("/|")})[^/]*))/(?<type>[^/]+)/embed`);
// embedTeamTypeRouteRegExp = "/team/:slug/:type/embed";
embedTeamTypeRouteRegExp = getRegExpFromNextJsRewriteRegExp("/team/(?<slug>[^/]+)/(?<type>[^/]+)/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(`^(?<orgSlug>${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)
})
})
})

View File

@ -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<TAppData extends ZodType> = <
TKey extends keyof z.infer<TAppData>,
@ -22,7 +29,7 @@ export const useAppContextWithSchema = <TAppData extends ZodType>() => {
type GetAppData = GetAppDataGeneric<TAppData>;
type SetAppData = SetAppDataGeneric<TAppData>;
// 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;

View File

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

View File

@ -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<typeof eventTypeAppCardZod>;
returnTo?: string;
teamId?: number;
disableSwitch?: boolean;
LockedIcon?: React.ReactNode;
}) {
const [animationRef] = useAutoAnimate<HTMLDivElement>();
@ -55,26 +62,45 @@ export default function AppCard({
{description || app?.description}
</p>
</div>
{app?.isInstalled ? (
<div className="ml-auto flex items-center">
<Switch
disabled={!app.enabled}
onCheckedChange={(enabled) => {
if (switchOnClick) {
switchOnClick(enabled);
}
setAppData("enabled", enabled);
}}
checked={switchChecked}
<div className="ml-auto flex items-center space-x-2">
{app.credentialOwner && (
<div className="ml-auto">
<Badge variant="gray">
<div className="flex items-center">
<Avatar
className="mr-2"
alt={app.credentialOwner.name || "Credential Owner Name"}
size="sm"
imageSrc={app.credentialOwner.avatar}
/>
{app.credentialOwner.name}
</div>
</Badge>
</div>
)}
{app?.isInstalled || app.credentialOwner ? (
<div className="ml-auto flex items-center">
<Switch
disabled={!app.enabled || disableSwitch}
onCheckedChange={(enabled) => {
if (switchOnClick) {
switchOnClick(enabled);
}
setAppData("enabled", enabled);
}}
checked={switchChecked}
LockedIcon={LockedIcon}
/>
</div>
) : (
<OmniInstallAppButton
className="ml-auto flex items-center"
appId={app.slug}
returnTo={returnTo}
teamId={teamId}
/>
</div>
) : (
<OmniInstallAppButton
className="ml-auto flex items-center"
appId={app.slug}
returnTo={returnTo}
/>
)}
)}
</div>
</div>
</div>
<div ref={animationRef}>

View File

@ -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 (
<ErrorBoundary message={`There is some problem with ${app.name} App`}>
<EventTypeAppContext.Provider value={[getAppData, setAppData]}>
<EventTypeAppContext.Provider value={[getAppData, setAppData, LockedIcon, disabled]}>
<DynamicComponent
slug={app.slug === "stripe" ? "stripepayment" : app.slug}
componentMap={EventTypeAddonMap}

View File

@ -16,10 +16,12 @@ export default function OmniInstallAppButton({
appId,
className,
returnTo,
teamId,
}: {
appId: string;
className: string;
returnTo?: string;
teamId?: number;
}) {
const { t } = useLocale();
const { data: app } = useApp(appId);
@ -30,7 +32,10 @@ export default function OmniInstallAppButton({
onSuccess: (data) => {
//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 }),
});
},
};
}

View File

@ -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;

View File

@ -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<Calendar | null> => {
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;
}

View File

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

View File

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

View File

@ -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;

View File

@ -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;

View File

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

View File

@ -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;

View File

@ -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;

View File

@ -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) => {

View File

@ -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;

View File

@ -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;

View File

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

View File

@ -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;

View File

@ -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;

View File

@ -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<typeof appDataSchema>();
const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType }) {
const [getAppData, setAppData, LockedIcon, disabled] = useAppContextWithSchema<typeof appDataSchema>();
const trackingId = getAppData("trackingId");
const [enabled, setEnabled] = useState(getAppData("enabled"));
const { enabled, updateEnabled } = useIsAppEnabled(app);
return (
<AppCard
setAppData={setAppData}
app={app}
disableSwitch={disabled}
LockedIcon={LockedIcon}
switchOnClick={(e) => {
if (!e) {
setEnabled(false);
} else {
setEnabled(true);
}
updateEnabled(e);
}}
switchChecked={enabled}>
switchChecked={enabled}
teamId={eventType.team?.id || undefined}>
<TextField
name="Tracking ID"
value={trackingId}
disabled={disabled}
onChange={(e) => {
setAppData("trackingId", e.target.value);
}}

View File

@ -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;

View File

@ -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<typeof appDataSchema>();
const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType }) {
const [getAppData, setAppData, LockedIcon, disabled] = useAppContextWithSchema<typeof appDataSchema>();
const trackingId = getAppData("trackingId");
const [enabled, setEnabled] = useState(getAppData("enabled"));
const { enabled, updateEnabled } = useIsAppEnabled(app);
return (
<AppCard
setAppData={setAppData}
app={app}
disableSwitch={disabled}
LockedIcon={LockedIcon}
switchOnClick={(e) => {
if (!e) {
setEnabled(false);
} else {
setEnabled(true);
}
updateEnabled(e);
}}
switchChecked={enabled}>
switchChecked={enabled}
teamId={eventType.team?.id || undefined}>
<TextField
name="Tracking ID"
value={trackingId}
disabled={disabled}
onChange={(e) => {
setAppData("trackingId", e.target.value);
}}

View File

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

View File

@ -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<typeof appDataSchema>();
const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType }) {
const [getAppData, setAppData, LockedIcon, disabled] = useAppContextWithSchema<typeof appDataSchema>();
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 && (
<SelectGifInput
defaultValue={thankYouPage}
disabled={disabled}
onChange={(url: string) => {
setAppData("thankYouPage", url);
}}

View File

@ -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) {
)}
<div className="flex">
{selectedGif ? (
<Button color="minimal" type="button" StartIcon={Edit} onClick={() => setShowDialog(true)}>
<Button
color="minimal"
type="button"
StartIcon={Edit}
onClick={() => setShowDialog(true)}
disabled={props.disabled}>
Change
</Button>
) : (
<Button color="minimal" type="button" StartIcon={Plus} onClick={() => setShowDialog(true)}>
<Button
color="minimal"
type="button"
StartIcon={Plus}
onClick={() => setShowDialog(true)}
disabled={props.disabled}>
Add from Giphy
</Button>
)}
@ -41,7 +52,8 @@ export default function SelectGifInput(props: ISelectGifInput) {
onClick={() => {
setSelectedGif("");
props.onChange("");
}}>
}}
disabled={props.disabled}>
{t("remove")}
</Button>
)}

View File

@ -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;

View File

@ -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<typeof appDataSchema>();
const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType }) {
const [getAppData, setAppData, LockedIcon, disabled] = useAppContextWithSchema<typeof appDataSchema>();
const trackingId = getAppData("trackingId");
const [enabled, setEnabled] = useState(getAppData("enabled"));
const { enabled, updateEnabled } = useIsAppEnabled(app);
return (
<AppCard
setAppData={setAppData}
app={app}
disableSwitch={disabled}
LockedIcon={LockedIcon}
switchOnClick={(e) => {
if (!e) {
setEnabled(false);
} else {
setEnabled(true);
}
updateEnabled(e);
}}
switchChecked={enabled}>
switchChecked={enabled}
teamId={eventType.team?.id || undefined}>
<TextField
name="Tracking ID"
value={trackingId}
disabled={disabled}
onChange={(e) => {
setAppData("trackingId", e.target.value);
}}

View File

@ -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(

View File

@ -1,6 +1,7 @@
const appStore = {
// example: () => import("./example"),
applecalendar: () => import("./applecalendar"),
aroundvideo: () => import("./around"),
caldavcalendar: () => import("./caldavcalendar"),
closecom: () => import("./closecom"),
dailyvideo: () => import("./dailyvideo"),

View File

@ -165,6 +165,7 @@ export type LocationObject = {
type: string;
address?: string;
displayLocationPublicly?: boolean;
credentialId?: number;
} & Partial<
Record<"address" | "attendeeAddress" | "link" | "hostPhoneNumber" | "hostDefault" | "phone", string>
>;

View File

@ -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;

View File

@ -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<typeof appDataSchema>();
const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType }) {
const [getAppData, setAppData, LockedIcon, disabled] = useAppContextWithSchema<typeof appDataSchema>();
const trackingId = getAppData("trackingId");
const [enabled, setEnabled] = useState(getAppData("enabled"));
const { enabled, updateEnabled } = useIsAppEnabled(app);
return (
<AppCard setAppData={setAppData} app={app} switchOnClick={setEnabled} switchChecked={enabled}>
<AppCard
setAppData={setAppData}
app={app}
disableSwitch={disabled}
LockedIcon={LockedIcon}
switchOnClick={updateEnabled}
switchChecked={enabled}
teamId={eventType.team?.id || undefined}>
<TextField
name="Pixel ID"
value={trackingId}
disabled={disabled}
onChange={(e) => {
setAppData("trackingId", e.target.value);
}}

View File

@ -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;

View File

@ -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;

View File

@ -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(

View File

@ -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;

View File

@ -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;

View File

@ -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<typeof appDataSchema>();
const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType }) {
const [getAppData, setAppData, LockedIcon, disabled] = useAppContextWithSchema<typeof appDataSchema>();
const plausibleUrl = getAppData("PLAUSIBLE_URL");
const trackingId = getAppData("trackingId");
const [enabled, setEnabled] = useState(getAppData("enabled"));
const { enabled, updateEnabled } = useIsAppEnabled(app);
return (
<AppCard
setAppData={setAppData}
app={app}
disableSwitch={disabled}
LockedIcon={LockedIcon}
switchOnClick={(e) => {
if (!e) {
setEnabled(false);
} else {
setEnabled(true);
}
updateEnabled(e);
}}
switchChecked={enabled}>
switchChecked={enabled}
teamId={eventType.team?.id || undefined}>
<TextField
name="Plausible URL"
defaultValue="https://plausible.io/js/script.js"
placeholder="https://plausible.io/js/script.js"
value={plausibleUrl}
disabled={disabled}
onChange={(e) => {
setAppData("PLAUSIBLE_URL", e.target.value);
}}
/>
<TextField
disabled={disabled}
name="Tracked Domain"
placeholder="yourdomain.com"
value={trackingId}

View File

@ -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;

View File

@ -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<typeof appDataSchema>();
const [enabled, setEnabled] = useState(getAppData("enabled"));
const [getAppData, setAppData, LockedIcon, disabled] = useAppContextWithSchema<typeof appDataSchema>();
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({
<AppCard
setAppData={setAppData}
app={app}
disableSwitch={disabled}
LockedIcon={LockedIcon}
switchOnClick={(e) => {
if (!e) {
setEnabled(false);
} else {
setEnabled(true);
}
updateEnabled(e);
}}
switchChecked={enabled}>
switchChecked={enabled}
teamId={eventType.team?.id || undefined}>
<div className="flex w-full flex-col gap-2 text-sm">
<div className="flex w-full">
<TextField
name="hello"
disabled={disabled}
value={additionalParameters}
onChange={(e) => setAdditionalParameters(e.target.value)}
label={t("additional_url_parameters")}

View File

@ -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;

View File

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

View File

@ -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<T extends typeof Button
const dropdownCtxValue = useContext(dropdownCtx);
const dropdown = dropdownCtxValue?.dropdown;
const embedLink = `forms/${routingForm?.id}`;
const formLink = `${CAL_URL}/${embedLink}`;
let redirectUrl = `${CAL_URL}/router?form=${routingForm?.id}`;
const formRelativeLink = `/${embedLink}`;
let relativeRedirectUrl = `/router?form=${routingForm?.id}`;
routingForm?.fields?.forEach((field) => {
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<T extends typeof Button
ButtonProps & { as?: React.ElementType; render?: FormActionProps<unknown>["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<T extends typeof Button
},
copyRedirectUrl: {
onClick: () => {
navigator.clipboard.writeText(redirectUrl);
navigator.clipboard.writeText(getOrgAwareUrlOnClient(relativeRedirectUrl));
showToast(t("typeform_redirect_url_copied"), "success");
},
},

View File

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

View File

@ -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(

View File

@ -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;

View File

@ -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;

View File

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

View File

@ -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<typeof appDataSchema>();
const [getAppData, setAppData, LockedIcon, disabled] = useAppContextWithSchema<typeof appDataSchema>();
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 && (
<Alert className="mt-2" severity="warning" title={t("warning_recurring_event_payment")} />
) : (
requirePayment && (
<>
<div className="mt-2 block items-center justify-start sm:flex sm:space-x-2">
<TextField
label=""
className="h-[38px]"
addOnLeading={<>{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}
/>
<Select<Option>
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}
/>
</div>
{seatsEnabled && paymentOption === "HOLD" && (
<Alert className="mt-2" severity="warning" title={t("seats_and_no_show_fee_error")} />
)}
</>
)
)}
{!recurringEventDefined && requirePayment && (
<>
<div className="mt-2 block items-center justify-start sm:flex sm:space-x-2">
<TextField
label=""
className="h-[38px]"
addOnLeading={<>{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}
/>
<Select<Option>
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}
/>
</div>
{seatsEnabled && paymentOption === "HOLD" && (
<Alert className="mt-2" severity="warning" title={t("seats_and_no_show_fee_error")} />
)}
</>
)}
</>
</AppCard>

View File

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

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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<typeof appDataSchema>();
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}>
<TextField
name="Tracking ID"
value={trackingId}

View File

@ -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;

View File

@ -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<typeof appDataSchema>();
const [getAppData, setAppData, LockedIcon, disabled] = useAppContextWithSchema<typeof appDataSchema>();
const isSunrise = getAppData("isSunrise");
const [enabled, setEnabled] = useState(getAppData("enabled"));
const { enabled, updateEnabled } = useIsAppEnabled(app);
return (
<AppCard
setAppData={setAppData}
app={app}
disableSwitch={disabled}
LockedIcon={LockedIcon}
switchOnClick={(e) => {
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}>
<div className="mt-2 text-sm">
<div className="flex">
<span className="ltr:mr-2 rtl:ml-2">{isSunrise ? <Sunrise /> : <Sunset />}</span>I am an AppCard for

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

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

View File

@ -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<string, string> } & { 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<typeof _EventTypeModel>,
"id" | "title" | "description" | "teamId" | "length" | "recurringEvent" | "seatsPerTimeSlot"
z.infer<typeof EventTypeModel>,
"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<EventTypeAppCardComponentProps>;

View File

@ -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;

View File

@ -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;

View File

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

View File

@ -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;

View File

@ -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 (
<>

View File

@ -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;

View File

@ -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";

View File

@ -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(

View File

@ -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(

View File

@ -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}`;

View File

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

View File

@ -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) {

View File

@ -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 (
<Dialog open={isOpen} onOpenChange={handleModelClose}>
<ConfirmationDialogContent
variety="danger"
title={t("remove_app")}
confirmBtnText={t("yes_remove_app")}
onConfirm={() => {
if (credentialId) {
mutation.mutate({ id: credentialId });
}
}}>
<p className="mt-5">{t("are_you_sure_you_want_to_remove_this_app")}</p>
</ConfirmationDialogContent>
<ConfirmationDialogContent
variety="danger"
title={t("remove_app")}
confirmBtnText={t("yes_remove_app")}
onConfirm={() => {
if (credentialId) {
mutation.mutate({ id: credentialId, teamId });
}
}}>
<p className="mt-5">{t("are_you_sure_you_want_to_remove_this_app")}</p>
</ConfirmationDialogContent>
</Dialog>
);
}

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