From d63e7372cb56a66891f78d4269c6b11ebc8ea8f5 Mon Sep 17 00:00:00 2001 From: sean-brydon <55134778+sean-brydon@users.noreply.github.com> Date: Wed, 24 May 2023 02:01:31 +0100 Subject: [PATCH] [Feat] Bulk Invite of Users + Google Workspace (#8969) * Base UI work * Bulk invite users * WIP workspace oauth implementation * Seperate components - add existing gcal check * Move callback session to getServerSession * Implementation of state redirect back to teams on login * Add callback to populate text field * Change error message * Redirect from callback and open modal * Fix bulk translations * Fix translations for google button * Delete Query * Feature flag this * Update packages/trpc/server/routers/viewer/teams/inviteMember.handler.ts * Check if Gcal in installed globally * Add new router * Add missing [trpc] route * Feedback * Update packages/trpc/server/routers/viewer/googleWorkspace/googleWorkspace.handler.ts * Typefixes * More typefixes --------- Co-authored-by: Hariom Balhara Co-authored-by: zomars --- .../pages/api/teams/googleworkspace/add.ts | 35 +++ .../api/teams/googleworkspace/callback.ts | 63 ++++++ .../pages/api/trpc/googleWorkspace/[trpc].ts | 4 + apps/web/public/static/locales/en/common.json | 8 + .../ee/teams/components/AddNewTeamMembers.tsx | 26 ++- .../GoogleWorkspaceInviteButton.tsx | 126 +++++++++++ .../components/MemberInvitationModal.tsx | 164 ++++++++++---- .../ee/teams/components/TeamListItem.tsx | 25 ++- .../ee/teams/pages/team-members-view.tsx | 24 ++- packages/features/flags/config.ts | 1 + .../migration.sql | 9 + packages/trpc/react/trpc.ts | 1 + .../trpc/server/routers/viewer/_router.tsx | 2 + .../server/routers/viewer/apps/_router.tsx | 19 ++ .../viewer/apps/checkGlobalKeys.handler.ts | 21 ++ .../viewer/apps/checkGlobalKeys.schema.ts | 7 + .../viewer/googleWorkspace/_router.tsx | 61 ++++++ .../googleWorkspace.handler.ts | 79 +++++++ .../viewer/teams/inviteMember.handler.ts | 202 +++++++++--------- .../viewer/teams/inviteMember.schema.ts | 7 +- packages/ui/components/dialog/Dialog.tsx | 11 +- .../form/toggleGroup/ToggleGroup.tsx | 13 +- 22 files changed, 738 insertions(+), 170 deletions(-) create mode 100644 apps/web/pages/api/teams/googleworkspace/add.ts create mode 100644 apps/web/pages/api/teams/googleworkspace/callback.ts create mode 100644 apps/web/pages/api/trpc/googleWorkspace/[trpc].ts create mode 100644 packages/features/ee/teams/components/GoogleWorkspaceInviteButton.tsx create mode 100644 packages/prisma/migrations/20230518084145_add_feature_flag_google_workspace/migration.sql create mode 100644 packages/trpc/server/routers/viewer/apps/checkGlobalKeys.handler.ts create mode 100644 packages/trpc/server/routers/viewer/apps/checkGlobalKeys.schema.ts create mode 100644 packages/trpc/server/routers/viewer/googleWorkspace/_router.tsx create mode 100644 packages/trpc/server/routers/viewer/googleWorkspace/googleWorkspace.handler.ts diff --git a/apps/web/pages/api/teams/googleworkspace/add.ts b/apps/web/pages/api/teams/googleworkspace/add.ts new file mode 100644 index 0000000000..2955a03454 --- /dev/null +++ b/apps/web/pages/api/teams/googleworkspace/add.ts @@ -0,0 +1,35 @@ +import { google } from "googleapis"; +import type { NextApiRequest, NextApiResponse } from "next"; + +import getAppKeysFromSlug from "@calcom/app-store/_utils/getAppKeysFromSlug"; +import { WEBAPP_URL } from "@calcom/lib/constants"; + +const scopes = [ + "https://www.googleapis.com/auth/admin.directory.user.readonly", + "https://www.googleapis.com/auth/admin.directory.customer.readonly", +]; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method === "GET") { + // Get appKeys from google-calendar + const { client_id, client_secret } = await getAppKeysFromSlug("google-calendar"); + if (!client_id || typeof client_id !== "string") + return res.status(400).json({ message: "Google client_id missing." }); + if (!client_secret || typeof client_secret !== "string") + return res.status(400).json({ message: "Google client_secret missing." }); + + // use differnt callback to normal calendar connection + const redirect_uri = WEBAPP_URL + "/api/teams/googleworkspace/callback"; + const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uri); + + const authUrl = oAuth2Client.generateAuthUrl({ + access_type: "offline", + scope: scopes, + + prompt: "consent", + state: JSON.stringify({ teamId: req.query.teamId }), + }); + + res.status(200).json({ url: authUrl }); + } +} diff --git a/apps/web/pages/api/teams/googleworkspace/callback.ts b/apps/web/pages/api/teams/googleworkspace/callback.ts new file mode 100644 index 0000000000..079353b4af --- /dev/null +++ b/apps/web/pages/api/teams/googleworkspace/callback.ts @@ -0,0 +1,63 @@ +import { google } from "googleapis"; +import type { NextApiRequest, NextApiResponse } from "next"; +import { z } from "zod"; + +import getAppKeysFromSlug from "@calcom/app-store/_utils/getAppKeysFromSlug"; +import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; +import { WEBAPP_URL } from "@calcom/lib/constants"; +import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl"; +import prisma from "@calcom/prisma"; + +const stateSchema = z.object({ + teamId: z.string(), +}); + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + const session = await getServerSession({ req, res }); + + if (!session?.user?.id) { + return res.status(401).json({ message: "You must be logged in to do this" }); + } + + const { code, state } = req.query; + const parsedState = stateSchema.parse(JSON.parse(state as string)); + const { teamId } = parsedState; + + if (code && typeof code !== "string") { + res.status(400).json({ message: "`code` must be a string" }); + return; + } + + const { client_id, client_secret } = await getAppKeysFromSlug("google-calendar"); + + if (!client_id || typeof client_id !== "string") + return res.status(400).json({ message: "Google client_id missing." }); + if (!client_secret || typeof client_secret !== "string") + return res.status(400).json({ message: "Google client_secret missing." }); + + const redirect_uri = WEBAPP_URL + "/api/teams/googleworkspace/callback"; + const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uri); + + if (!code) { + throw new Error("No code provided"); + } + + const credentials = await oAuth2Client.getToken(code); + + await prisma.credential.create({ + data: { + type: "google_workspace_directory", + key: credentials.res?.data, + userId: session.user.id, + }, + }); + + if (!teamId) { + res.redirect(getSafeRedirectUrl(WEBAPP_URL + "/settings") ?? `${WEBAPP_URL}/teams`); + } + + res.redirect( + getSafeRedirectUrl(WEBAPP_URL + `/settings/teams/${teamId}/members?inviteModal=true&bulk=true`) ?? + `${WEBAPP_URL}/teams` + ); +} diff --git a/apps/web/pages/api/trpc/googleWorkspace/[trpc].ts b/apps/web/pages/api/trpc/googleWorkspace/[trpc].ts new file mode 100644 index 0000000000..e6f07468dd --- /dev/null +++ b/apps/web/pages/api/trpc/googleWorkspace/[trpc].ts @@ -0,0 +1,4 @@ +import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler"; +import { googleWorkspaceRouter } from "@calcom/trpc/server/routers/viewer/googleWorkspace/_router"; + +export default createNextApiHandler(googleWorkspaceRouter); diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 3c862b1202..b406c9ece6 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -26,6 +26,8 @@ "rejection_confirmation": "Reject the booking", "manage_this_event": "Manage this event", "invite_team_member": "Invite team member", + "invite_team_individual_segment": "Invite individual", + "invite_team_bulk_segment": "Bulk import", "invite_team_notifcation_badge": "Inv.", "your_event_has_been_scheduled": "Your event has been scheduled", "your_event_has_been_scheduled_recurring": "Your recurring event has been scheduled", @@ -222,6 +224,7 @@ "go_back_login": "Go back to the login page", "error_during_login": "An error occurred when logging you in. Head back to the login screen and try again.", "request_password_reset": "Send reset email", + "send_invite": "Send invite", "forgot_password": "Forgot Password?", "forgot": "Forgot?", "done": "Done", @@ -1809,6 +1812,7 @@ "charge_attendee": "Charge attendee {{amount, currency}}", "payment_app_commission": "Require payment ({{paymentFeePercentage}}% + {{fee, currency}} commission per transaction)", "email_invite_team": "{{email}} has been invited", + "email_invite_team_bulk": "{{userCount}} users have been invited", "error_collecting_card": "Error collecting card", "image_size_limit_exceed": "Uploaded image shouldn't exceed 5mb size limit", "inline_embed": "Inline Embed", @@ -1819,12 +1823,16 @@ "open_dialog_with_element_click": "Open your Cal dialog when someone clicks an element.", "need_help_embedding": "Need help? See our guides for embedding Cal on Wix, Squarespace, or WordPress, check our common questions, or explore advanced embed options.", "book_my_cal": "Book my Cal", + "invite_as":"Invite as", "form_updated_successfully":"Form updated successfully.", "email_not_cal_member_cta": "Join your team", "disable_attendees_confirmation_emails": "Disable default confirmation emails for attendees", "disable_attendees_confirmation_emails_description": "At least one workflow is active on this event type that sends an email to the attendees when the event is booked.", "disable_host_confirmation_emails": "Disable default confirmation emails for host", "disable_host_confirmation_emails_description": "At least one workflow is active on this event type that sends an email to the host when the event is booked.", + "import_from_google_workspace":"Import users from Google Workspace", + "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", "create_for": "Create for" } diff --git a/packages/features/ee/teams/components/AddNewTeamMembers.tsx b/packages/features/ee/teams/components/AddNewTeamMembers.tsx index f83836c074..cb9b48d628 100644 --- a/packages/features/ee/teams/components/AddNewTeamMembers.tsx +++ b/packages/features/ee/teams/components/AddNewTeamMembers.tsx @@ -40,20 +40,30 @@ export const AddNewTeamMembersForm = ({ teamId: number; }) => { const { t, i18n } = useLocale(); - const [memberInviteModal, setMemberInviteModal] = useState(false); - const utils = trpc.useContext(); const router = useRouter(); + const showDialog = router.query.inviteModal === "true"; + const [memberInviteModal, setMemberInviteModal] = useState(showDialog); + const utils = trpc.useContext(); const inviteMemberMutation = trpc.viewer.teams.inviteMember.useMutation({ async onSuccess(data) { await utils.viewer.teams.get.invalidate(); setMemberInviteModal(false); if (data.sendEmailInvitation) { - showToast( - t("email_invite_team", { - email: data.usernameOrEmail, - }), - "success" - ); + if (Array.isArray(data.usernameOrEmail)) { + showToast( + t("email_invite_team_bulk", { + userCount: data.usernameOrEmail.length, + }), + "success" + ); + } else { + showToast( + t("email_invite_team", { + email: data.usernameOrEmail, + }), + "success" + ); + } } }, onError: (error) => { diff --git a/packages/features/ee/teams/components/GoogleWorkspaceInviteButton.tsx b/packages/features/ee/teams/components/GoogleWorkspaceInviteButton.tsx new file mode 100644 index 0000000000..1c14fe4659 --- /dev/null +++ b/packages/features/ee/teams/components/GoogleWorkspaceInviteButton.tsx @@ -0,0 +1,126 @@ +import { UsersIcon, XIcon } from "lucide-react"; +import { useRouter } from "next/router"; +import { useState } from "react"; +import type { PropsWithChildren } from "react"; + +import { useFlagMap } from "@calcom/features/flags/context/provider"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { trpc } from "@calcom/trpc"; +import { Button, Tooltip, showToast } from "@calcom/ui"; + +const GoogleIcon = () => ( + + + + + + + + + + +); + +function gotoUrl(url: string, newTab?: boolean) { + if (newTab) { + window.open(url, "_blank"); + return; + } + window.location.href = url; +} + +export function GoogleWorkspaceInviteButton( + props: PropsWithChildren<{ onSuccess: (data: string[]) => void }> +) { + const router = useRouter(); + const featureFlags = useFlagMap(); + const utils = trpc.useContext(); + const { t } = useLocale(); + const teamId = Number(router.query.id); + const [googleWorkspaceLoading, setGoogleWorkspaceLoading] = useState(false); + const { data: credential } = trpc.viewer.googleWorkspace.checkForGWorkspace.useQuery(); + const { data: hasGcalInstalled } = trpc.viewer.appsRouter.checkGlobalKeys.useQuery({ + slug: "google-calendar", + }); + const mutation = trpc.viewer.googleWorkspace.getUsersFromGWorkspace.useMutation({ + onSuccess: (data) => { + if (Array.isArray(data) && data.length !== 0) { + props.onSuccess(data); + } + }, + }); + + const removeConnectionMutation = + trpc.viewer.googleWorkspace.removeCurrentGoogleWorkspaceConnection.useMutation({ + onSuccess: () => { + showToast(t("app_removed_successfully"), "success"); + }, + }); + + if (featureFlags["google-workspace-directory"] == false || !hasGcalInstalled) { + return null; + } + + // Show populate input button if they do + if (credential && credential?.id) { + return ( +
+ + + + +
+ ); + } + + // else show invite button + return ( + + ); +} diff --git a/packages/features/ee/teams/components/MemberInvitationModal.tsx b/packages/features/ee/teams/components/MemberInvitationModal.tsx index 2b9f123a52..b2a4b4aaf3 100644 --- a/packages/features/ee/teams/components/MemberInvitationModal.tsx +++ b/packages/features/ee/teams/components/MemberInvitationModal.tsx @@ -1,10 +1,11 @@ +import { PaperclipIcon, UserIcon, Users } from "lucide-react"; import { Trans } from "next-i18next"; -import { useMemo } from "react"; +import { useMemo, useState } from "react"; import { Controller, useForm } from "react-hook-form"; import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants"; import { useLocale } from "@calcom/lib/hooks/useLocale"; -import type { MembershipRole } from "@calcom/prisma/enums"; +import { MembershipRole } from "@calcom/prisma/enums"; import { Button, Checkbox as CheckboxField, @@ -15,9 +16,12 @@ import { TextField, Label, ToggleGroup, + Select, + TextAreaField, } from "@calcom/ui"; import type { PendingMember } from "../lib/types"; +import { GoogleWorkspaceInviteButton } from "./GoogleWorkspaceInviteButton"; type MemberInvitationModalProps = { isOpen: boolean; @@ -32,19 +36,21 @@ type MembershipRoleOption = { }; export interface NewMemberForm { - emailOrUsername: string; + emailOrUsername: string | string[]; role: MembershipRole; sendInviteEmail: boolean; } +type ModalMode = "INDIVIDUAL" | "BULK"; + export default function MemberInvitationModal(props: MemberInvitationModalProps) { const { t } = useLocale(); - + const [modalImportMode, setModalInputMode] = useState("INDIVIDUAL"); const options: MembershipRoleOption[] = useMemo(() => { return [ - { value: "MEMBER", label: t("member") }, - { value: "ADMIN", label: t("admin") }, - { value: "OWNER", label: t("owner") }, + { value: MembershipRole.MEMBER, label: t("member") }, + { value: MembershipRole.ADMIN, label: t("admin") }, + { value: MembershipRole.OWNER, label: t("owner") }, ]; }, [t]); @@ -59,6 +65,7 @@ export default function MemberInvitationModal(props: MemberInvitationModalProps) return ( { props.onExit(); @@ -66,7 +73,7 @@ export default function MemberInvitationModal(props: MemberInvitationModalProps) }}> @@ -75,33 +82,104 @@ export default function MemberInvitationModal(props: MemberInvitationModalProps) on your subscription. - ) : ( - "" - ) + ) : null }> +
+ + setModalInputMode(val as ModalMode)} + defaultValue="INDIVIDUAL" + options={[ + { + value: "INDIVIDUAL", + label: t("invite_team_individual_segment"), + iconLeft: , + }, + { value: "BULK", label: t("invite_team_bulk_segment"), iconLeft: }, + ]} + /> +
+
props.onSubmit(values)}> -
- validateUniqueInvite(value) || t("member_already_invited"), - }} - render={({ field: { onChange }, fieldState: { error } }) => ( - <> - onChange(e.target.value.trim().toLowerCase())} - /> - {error && {error.message}} - - )} - /> +
+ {/* Indivdual Invite */} + {modalImportMode === "INDIVIDUAL" && ( + { + if (typeof value === "string") + return validateUniqueInvite(value) || t("member_already_invited"); + }, + }} + render={({ field: { onChange }, fieldState: { error } }) => ( + <> + onChange(e.target.value.trim().toLowerCase())} + /> + {error && {error.message}} + + )} + /> + )} + {/* Bulk Invite */} + {modalImportMode === "BULK" && ( +
+ ( + <> + {/* TODO: Make this a fancy email input that styles on a successful email. */} + { + const emails = e.target.value + .split(",") + .map((email) => email.trim().toLocaleLowerCase()); + + return onChange(emails); + }} + /> + {error && {error.message}} + + )} + /> + + { + newMemberFormMethods.setValue("emailOrUsername", data); + }} + /> + +
+ )} (
- { + if (val) onChange(val.value); + }} />
)} @@ -138,7 +214,7 @@ export default function MemberInvitationModal(props: MemberInvitationModalProps) )} />
- + diff --git a/packages/features/ee/teams/components/TeamListItem.tsx b/packages/features/ee/teams/components/TeamListItem.tsx index 637b257313..a1b21c1c6a 100644 --- a/packages/features/ee/teams/components/TeamListItem.tsx +++ b/packages/features/ee/teams/components/TeamListItem.tsx @@ -53,19 +53,30 @@ export default function TeamListItem(props: Props) { const { t, i18n } = useLocale(); const utils = trpc.useContext(); const team = props.team; - const [openMemberInvitationModal, setOpenMemberInvitationModal] = useState(false); + const router = useRouter(); + const showDialog = router.query.inviteModal === "true"; + const [openMemberInvitationModal, setOpenMemberInvitationModal] = useState(showDialog); const teamQuery = trpc.viewer.teams.get.useQuery({ teamId: team?.id }); const inviteMemberMutation = trpc.viewer.teams.inviteMember.useMutation({ async onSuccess(data) { await utils.viewer.teams.get.invalidate(); setOpenMemberInvitationModal(false); if (data.sendEmailInvitation) { - showToast( - t("email_invite_team", { - email: data.usernameOrEmail, - }), - "success" - ); + if (Array.isArray(data.usernameOrEmail)) { + showToast( + t("email_invite_team_bulk", { + userCount: data.usernameOrEmail.length, + }), + "success" + ); + } else { + showToast( + t("email_invite_team", { + email: data.usernameOrEmail, + }), + "success" + ); + } } }, onError: (error) => { diff --git a/packages/features/ee/teams/pages/team-members-view.tsx b/packages/features/ee/teams/pages/team-members-view.tsx index c4b03227a7..e52dfde225 100644 --- a/packages/features/ee/teams/pages/team-members-view.tsx +++ b/packages/features/ee/teams/pages/team-members-view.tsx @@ -66,7 +66,8 @@ const MembersView = () => { const router = useRouter(); const session = useSession(); const utils = trpc.useContext(); - const [showMemberInvitationModal, setShowMemberInvitationModal] = useState(false); + const showDialog = router.query.inviteModal === "true"; + const [showMemberInvitationModal, setShowMemberInvitationModal] = useState(showDialog); const teamId = Number(router.query.id); const { data: team, isLoading } = trpc.viewer.teams.get.useQuery( @@ -83,12 +84,21 @@ const MembersView = () => { await utils.viewer.teams.get.invalidate(); setShowMemberInvitationModal(false); if (data.sendEmailInvitation) { - showToast( - t("email_invite_team", { - email: data.usernameOrEmail, - }), - "success" - ); + if (Array.isArray(data.usernameOrEmail)) { + showToast( + t("email_invite_team_bulk", { + userCount: data.usernameOrEmail.length, + }), + "success" + ); + } else { + showToast( + t("email_invite_team", { + email: data.usernameOrEmail, + }), + "success" + ); + } } }, onError: (error) => { diff --git a/packages/features/flags/config.ts b/packages/features/flags/config.ts index 08dac2c626..0883a9ff98 100644 --- a/packages/features/flags/config.ts +++ b/packages/features/flags/config.ts @@ -10,4 +10,5 @@ export type AppFlags = { workflows: boolean; "v2-booking-page": boolean; "managed-event-types": boolean; + "google-workspace-directory": boolean; }; diff --git a/packages/prisma/migrations/20230518084145_add_feature_flag_google_workspace/migration.sql b/packages/prisma/migrations/20230518084145_add_feature_flag_google_workspace/migration.sql new file mode 100644 index 0000000000..2d9e9ba13b --- /dev/null +++ b/packages/prisma/migrations/20230518084145_add_feature_flag_google_workspace/migration.sql @@ -0,0 +1,9 @@ +INSERT INTO + "Feature" (slug, enabled, description, "type") +VALUES + ( + 'google-workspace-directory', + false, + 'Enable Google Workspace Directory integration - Syncing of users and groups from Google Workspace to users teams.', + 'OPERATIONAL' + ) ON CONFLICT (slug) DO NOTHING; diff --git a/packages/trpc/react/trpc.ts b/packages/trpc/react/trpc.ts index 01339a319d..aa023cc3ad 100644 --- a/packages/trpc/react/trpc.ts +++ b/packages/trpc/react/trpc.ts @@ -38,6 +38,7 @@ const ENDPOINTS = [ "webhook", "workflows", "appsRouter", + "googleWorkspace", ] as const; export type Endpoint = (typeof ENDPOINTS)[number]; diff --git a/packages/trpc/server/routers/viewer/_router.tsx b/packages/trpc/server/routers/viewer/_router.tsx index 762ffb5bc7..25b595570f 100644 --- a/packages/trpc/server/routers/viewer/_router.tsx +++ b/packages/trpc/server/routers/viewer/_router.tsx @@ -14,6 +14,7 @@ import { availabilityRouter } from "./availability/_router"; import { bookingsRouter } from "./bookings/_router"; import { deploymentSetupRouter } from "./deploymentSetup/_router"; import { eventTypesRouter } from "./eventTypes/_router"; +import { googleWorkspaceRouter } from "./googleWorkspace/_router"; import { paymentsRouter } from "./payments/_router"; import { slotsRouter } from "./slots/_router"; import { ssoRouter } from "./sso/_router"; @@ -46,5 +47,6 @@ export const viewerRouter = mergeRouters( features: featureFlagRouter, appsRouter, users: userAdminRouter, + googleWorkspace: googleWorkspaceRouter, }) ); diff --git a/packages/trpc/server/routers/viewer/apps/_router.tsx b/packages/trpc/server/routers/viewer/apps/_router.tsx index 7a9c633017..86fe7d84e3 100644 --- a/packages/trpc/server/routers/viewer/apps/_router.tsx +++ b/packages/trpc/server/routers/viewer/apps/_router.tsx @@ -1,5 +1,6 @@ import authedProcedure, { authedAdminProcedure } from "../../../procedures/authedProcedure"; import { router } from "../../../trpc"; +import { checkGlobalKeysSchema } from "./checkGlobalKeys.schema"; import { ZListLocalInputSchema } from "./listLocal.schema"; import { ZQueryForDependenciesInputSchema } from "./queryForDependencies.schema"; import { ZSaveKeysInputSchema } from "./saveKeys.schema"; @@ -13,6 +14,7 @@ type AppsRouterHandlerCache = { checkForGCal?: typeof import("./checkForGCal.handler").checkForGCalHandler; updateAppCredentials?: typeof import("./updateAppCredentials.handler").updateAppCredentialsHandler; queryForDependencies?: typeof import("./queryForDependencies.handler").queryForDependenciesHandler; + checkGlobalKeys?: typeof import("./checkGlobalKeys.handler").checkForGlobalKeysHandler; }; const UNSTABLE_HANDLER_CACHE: AppsRouterHandlerCache = {}; @@ -124,4 +126,21 @@ export const appsRouter = router({ input, }); }), + checkGlobalKeys: authedProcedure.input(checkGlobalKeysSchema).query(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.checkGlobalKeys) { + UNSTABLE_HANDLER_CACHE.checkGlobalKeys = await import("./checkGlobalKeys.handler").then( + (mod) => mod.checkForGlobalKeysHandler + ); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.checkGlobalKeys) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.checkGlobalKeys({ + ctx, + input, + }); + }), }); diff --git a/packages/trpc/server/routers/viewer/apps/checkGlobalKeys.handler.ts b/packages/trpc/server/routers/viewer/apps/checkGlobalKeys.handler.ts new file mode 100644 index 0000000000..35610635d0 --- /dev/null +++ b/packages/trpc/server/routers/viewer/apps/checkGlobalKeys.handler.ts @@ -0,0 +1,21 @@ +import { prisma } from "@calcom/prisma"; + +import type { TrpcSessionUser } from "../../../trpc"; +import type { CheckGlobalKeysSchemaType } from "./checkGlobalKeys.schema"; + +type checkForGlobalKeys = { + ctx: { + user: NonNullable; + }; + input: CheckGlobalKeysSchemaType; +}; + +export const checkForGlobalKeysHandler = async ({ ctx, input }: checkForGlobalKeys) => { + const appIsGloballyInstalled = await prisma.app.findUnique({ + where: { + slug: input.slug, + }, + }); + + return !!appIsGloballyInstalled; +}; diff --git a/packages/trpc/server/routers/viewer/apps/checkGlobalKeys.schema.ts b/packages/trpc/server/routers/viewer/apps/checkGlobalKeys.schema.ts new file mode 100644 index 0000000000..59fc01c89f --- /dev/null +++ b/packages/trpc/server/routers/viewer/apps/checkGlobalKeys.schema.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +export const checkGlobalKeysSchema = z.object({ + slug: z.string(), +}); + +export type CheckGlobalKeysSchemaType = z.infer; diff --git a/packages/trpc/server/routers/viewer/googleWorkspace/_router.tsx b/packages/trpc/server/routers/viewer/googleWorkspace/_router.tsx new file mode 100644 index 0000000000..b0a0f7f1c1 --- /dev/null +++ b/packages/trpc/server/routers/viewer/googleWorkspace/_router.tsx @@ -0,0 +1,61 @@ +import authedProcedure from "../../../procedures/authedProcedure"; +import { router } from "../../../trpc"; + +type GoogleWorkspaceCache = { + checkForGWorkspace?: typeof import("./googleWorkspace.handler").checkForGWorkspace; + getUsersFromGWorkspace?: typeof import("./googleWorkspace.handler").getUsersFromGWorkspace; + removeCurrentGoogleWorkspaceConnection?: typeof import("./googleWorkspace.handler").removeCurrentGoogleWorkspaceConnection; +}; + +const UNSTABLE_HANDLER_CACHE: GoogleWorkspaceCache = {}; + +export const googleWorkspaceRouter = router({ + checkForGWorkspace: authedProcedure.query(async ({ ctx }) => { + if (!UNSTABLE_HANDLER_CACHE.checkForGWorkspace) { + UNSTABLE_HANDLER_CACHE.checkForGWorkspace = await import("./googleWorkspace.handler").then( + (mod) => mod.checkForGWorkspace + ); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.checkForGWorkspace) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.checkForGWorkspace({ + ctx, + }); + }), + getUsersFromGWorkspace: authedProcedure.mutation(async ({ ctx }) => { + if (!UNSTABLE_HANDLER_CACHE.getUsersFromGWorkspace) { + UNSTABLE_HANDLER_CACHE.getUsersFromGWorkspace = await import("./googleWorkspace.handler").then( + (mod) => mod.getUsersFromGWorkspace + ); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.getUsersFromGWorkspace) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.getUsersFromGWorkspace({ + ctx, + }); + }), + removeCurrentGoogleWorkspaceConnection: authedProcedure.mutation(async ({ ctx }) => { + if (!UNSTABLE_HANDLER_CACHE.removeCurrentGoogleWorkspaceConnection) { + UNSTABLE_HANDLER_CACHE.removeCurrentGoogleWorkspaceConnection = await import( + "./googleWorkspace.handler" + ).then((mod) => mod.removeCurrentGoogleWorkspaceConnection); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.removeCurrentGoogleWorkspaceConnection) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.removeCurrentGoogleWorkspaceConnection({ + ctx, + }); + }), +}); diff --git a/packages/trpc/server/routers/viewer/googleWorkspace/googleWorkspace.handler.ts b/packages/trpc/server/routers/viewer/googleWorkspace/googleWorkspace.handler.ts new file mode 100644 index 0000000000..867a21248b --- /dev/null +++ b/packages/trpc/server/routers/viewer/googleWorkspace/googleWorkspace.handler.ts @@ -0,0 +1,79 @@ +import { google } from "googleapis"; +import { z } from "zod"; + +import getAppKeysFromSlug from "@calcom/app-store/_utils/getAppKeysFromSlug"; +import { prisma } from "@calcom/prisma"; + +import type { TrpcSessionUser } from "../../../trpc"; + +type CheckForGCalOptions = { + ctx: { + user: NonNullable; + }; +}; + +const credentialsSchema = z.object({ + refresh_token: z.string().optional(), + expiry_date: z.number().optional(), + access_token: z.string().optional(), + token_type: z.string().optional(), + id_token: z.string().optional(), + scope: z.string().optional(), +}); + +export const checkForGWorkspace = async ({ ctx }: CheckForGCalOptions) => { + const gWorkspacePresent = await prisma.credential.findFirst({ + where: { + type: "google_workspace_directory", + userId: ctx.user.id, + }, + }); + + return { id: gWorkspacePresent?.id }; +}; + +export const getUsersFromGWorkspace = async ({ ctx }: CheckForGCalOptions) => { + const { client_id, client_secret } = await getAppKeysFromSlug("google-calendar"); + if (!client_id || typeof client_id !== "string") throw new Error("Google client_id missing."); + if (!client_secret || typeof client_secret !== "string") throw new Error("Google client_secret missing."); + + const hasExistingCredentials = await prisma.credential.findFirst({ + where: { + type: "google_workspace_directory", + }, + }); + if (!hasExistingCredentials) { + throw new Error("No workspace credentials found"); + } + + const credentials = credentialsSchema.parse(hasExistingCredentials.key); + + const oAuth2Client = new google.auth.OAuth2(client_id, client_secret); + + // Set users credentials instead of our app credentials - allowing us to make requests on their behalf + oAuth2Client.setCredentials(credentials); + + // Create a new instance of the Admin SDK directory API + const directory = google.admin({ version: "directory_v1", auth: oAuth2Client }); + + const { data } = await directory.users.list({ + maxResults: 200, // Up this if we ever need to get more than 200 users + customer: "my_customer", // This only works for single domain setups - we'll need to change this if we ever support multi-domain setups (unlikely we'll ever need to) + }); + + // We only want their email addresses + const emails = data.users?.map((user) => user.primaryEmail as string) ?? ([] as string[]); + return emails; +}; + +export const removeCurrentGoogleWorkspaceConnection = async ({ ctx }: CheckForGCalOptions) => { + // There should only ever be one google_workspace_directory credential per user but we delete many as we can't make type unique + const gWorkspacePresent = await prisma.credential.deleteMany({ + where: { + type: "google_workspace_directory", + userId: ctx.user.id, + }, + }); + + return { deleted: gWorkspacePresent?.count }; +}; diff --git a/packages/trpc/server/routers/viewer/teams/inviteMember.handler.ts b/packages/trpc/server/routers/viewer/teams/inviteMember.handler.ts index 97067acb7a..5e85783b6d 100644 --- a/packages/trpc/server/routers/viewer/teams/inviteMember.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/inviteMember.handler.ts @@ -37,113 +37,121 @@ export const inviteMemberHandler = async ({ ctx, input }: InviteMemberOptions) = if (!team) throw new TRPCError({ code: "NOT_FOUND", message: "Team not found" }); - // A user can exist even if they have not completed onboarding - const invitee = await prisma.user.findFirst({ - where: { - OR: [{ username: input.usernameOrEmail }, { email: input.usernameOrEmail }], - }, - }); + const emailsToInvite = Array.isArray(input.usernameOrEmail) + ? input.usernameOrEmail + : [input.usernameOrEmail]; - if (!invitee) { - // liberal email match - - if (!isEmail(input.usernameOrEmail)) - throw new TRPCError({ - code: "NOT_FOUND", - message: `Invite failed because there is no corresponding user for ${input.usernameOrEmail}`, - }); - - // valid email given, create User and add to team - await prisma.user.create({ - data: { - email: input.usernameOrEmail, - invitedTo: input.teamId, - teams: { - create: { - teamId: input.teamId, - role: input.role as MembershipRole, - }, - }, + emailsToInvite.forEach(async (usernameOrEmail) => { + const invitee = await prisma.user.findFirst({ + where: { + OR: [{ username: usernameOrEmail }, { email: usernameOrEmail }], }, }); - const token: string = randomBytes(32).toString("hex"); + if (!invitee) { + // liberal email match - await prisma.verificationToken.create({ - data: { - identifier: input.usernameOrEmail, - token, - expires: new Date(new Date().setHours(168)), // +1 week - }, - }); - if (ctx?.user?.name && team?.name) { - await sendTeamInviteEmail({ - language: translation, - from: ctx.user.name, - to: input.usernameOrEmail, - teamName: team.name, - joinLink: `${WEBAPP_URL}/signup?token=${token}&callbackUrl=/getting-started`, // we know that the user has not completed onboarding yet, so we can redirect them to the onboarding flow - isCalcomMember: false, - }); - } - } else { - // create provisional membership - try { - await prisma.membership.create({ - data: { - teamId: input.teamId, - userId: invitee.id, - role: input.role as MembershipRole, - }, - }); - } catch (e) { - if (e instanceof Prisma.PrismaClientKnownRequestError) { - if (e.code === "P2002") { - throw new TRPCError({ - code: "FORBIDDEN", - message: "This user is a member of this team / has a pending invitation.", - }); - } - } else throw e; - } - - let sendTo = input.usernameOrEmail; - if (!isEmail(input.usernameOrEmail)) { - sendTo = invitee.email; - } - // inform user of membership by email - if (input.sendEmailInvitation && ctx?.user?.name && team?.name) { - const inviteTeamOptions = { - joinLink: `${WEBAPP_URL}/auth/login?callbackUrl=/settings/teams`, - isCalcomMember: true, - }; - /** - * Here we want to redirect to a differnt place if onboarding has been completed or not. This prevents the flash of going to teams -> Then to onboarding - also show a differnt email template. - * This only changes if the user is a CAL user and has not completed onboarding and has no password - */ - if (!invitee.completedOnboarding && !invitee.password && invitee.identityProvider === "CAL") { - const token = randomBytes(32).toString("hex"); - await prisma.verificationToken.create({ - data: { - identifier: input.usernameOrEmail, - token, - expires: new Date(new Date().setHours(168)), // +1 week - }, + if (!isEmail(usernameOrEmail)) + throw new TRPCError({ + code: "NOT_FOUND", + message: `Invite failed because there is no corresponding user for ${usernameOrEmail}`, }); - inviteTeamOptions.joinLink = `${WEBAPP_URL}/signup?token=${token}&callbackUrl=/getting-started`; - inviteTeamOptions.isCalcomMember = false; + // valid email given, create User and add to team + await prisma.user.create({ + data: { + email: usernameOrEmail, + invitedTo: input.teamId, + teams: { + create: { + teamId: input.teamId, + role: input.role as MembershipRole, + }, + }, + }, + }); + + const token: string = randomBytes(32).toString("hex"); + + await prisma.verificationToken.create({ + data: { + identifier: usernameOrEmail, + token, + expires: new Date(new Date().setHours(168)), // +1 week + }, + }); + if (ctx?.user?.name && team?.name) { + await sendTeamInviteEmail({ + language: translation, + from: ctx.user.name, + to: usernameOrEmail, + teamName: team.name, + joinLink: `${WEBAPP_URL}/signup?token=${token}&callbackUrl=/getting-started`, // we know that the user has not completed onboarding yet, so we can redirect them to the onboarding flow + isCalcomMember: false, + }); + } + } else { + // create provisional membership + try { + await prisma.membership.create({ + data: { + teamId: input.teamId, + userId: invitee.id, + role: input.role as MembershipRole, + }, + }); + } catch (e) { + if (e instanceof Prisma.PrismaClientKnownRequestError) { + // Don't throw an error if the user is already a member of the team when inviting multiple users + if (!Array.isArray(input.usernameOrEmail) && e.code === "P2002") { + throw new TRPCError({ + code: "FORBIDDEN", + message: "This user is a member of this team / has a pending invitation.", + }); + } else { + console.log(`User ${invitee.id} is already a member of this team.`); + } + } else throw e; } - await sendTeamInviteEmail({ - language: translation, - from: ctx.user.name, - to: sendTo, - teamName: team.name, - ...inviteTeamOptions, - }); + let sendTo = usernameOrEmail; + if (!isEmail(usernameOrEmail)) { + sendTo = invitee.email; + } + // inform user of membership by email + if (input.sendEmailInvitation && ctx?.user?.name && team?.name) { + const inviteTeamOptions = { + joinLink: `${WEBAPP_URL}/auth/login?callbackUrl=/settings/teams`, + isCalcomMember: true, + }; + /** + * Here we want to redirect to a differnt place if onboarding has been completed or not. This prevents the flash of going to teams -> Then to onboarding - also show a differnt email template. + * This only changes if the user is a CAL user and has not completed onboarding and has no password + */ + if (!invitee.completedOnboarding && !invitee.password && invitee.identityProvider === "CAL") { + const token = randomBytes(32).toString("hex"); + await prisma.verificationToken.create({ + data: { + identifier: usernameOrEmail, + token, + expires: new Date(new Date().setHours(168)), // +1 week + }, + }); + + inviteTeamOptions.joinLink = `${WEBAPP_URL}/signup?token=${token}&callbackUrl=/getting-started`; + inviteTeamOptions.isCalcomMember = false; + } + + await sendTeamInviteEmail({ + language: translation, + from: ctx.user.name, + to: sendTo, + teamName: team.name, + ...inviteTeamOptions, + }); + } } - } + }); if (IS_TEAM_BILLING_ENABLED) await updateQuantitySubscriptionFromStripe(input.teamId); return input; }; diff --git a/packages/trpc/server/routers/viewer/teams/inviteMember.schema.ts b/packages/trpc/server/routers/viewer/teams/inviteMember.schema.ts index d743203f8e..c3d712a750 100644 --- a/packages/trpc/server/routers/viewer/teams/inviteMember.schema.ts +++ b/packages/trpc/server/routers/viewer/teams/inviteMember.schema.ts @@ -4,7 +4,12 @@ import { MembershipRole } from "@calcom/prisma/enums"; export const ZInviteMemberInputSchema = z.object({ teamId: z.number(), - usernameOrEmail: z.string().transform((usernameOrEmail) => usernameOrEmail.trim().toLowerCase()), + usernameOrEmail: z.union([z.string(), z.array(z.string())]).transform((usernameOrEmail) => { + if (typeof usernameOrEmail === "string") { + return usernameOrEmail.trim().toLowerCase(); + } + return usernameOrEmail.map((item) => item.trim().toLowerCase()); + }), role: z.nativeEnum(MembershipRole), language: z.string(), sendEmailInvitation: z.boolean(), diff --git a/packages/ui/components/dialog/Dialog.tsx b/packages/ui/components/dialog/Dialog.tsx index ff4bd32453..eb5222ce27 100644 --- a/packages/ui/components/dialog/Dialog.tsx +++ b/packages/ui/components/dialog/Dialog.tsx @@ -1,6 +1,6 @@ import * as DialogPrimitive from "@radix-ui/react-dialog"; import { useRouter } from "next/router"; -import type { ReactNode } from "react"; +import type { PropsWithChildren, ReactNode } from "react"; import React, { useState } from "react"; import classNames from "@calcom/lib/classNames"; @@ -61,7 +61,7 @@ type DialogContentProps = React.ComponentProps<(typeof DialogPrimitive)["Content size?: "xl" | "lg" | "md"; type?: "creation" | "confirmation"; title?: string; - description?: string | JSX.Element | undefined; + description?: string | JSX.Element | null; closeText?: string; actionDisabled?: boolean; Icon?: SVGComponent; @@ -134,8 +134,11 @@ export function DialogHeader(props: DialogHeaderProps) { ); } -export function DialogFooter(props: { children: ReactNode }) { - return
{props.children}
; +// TODO: add divider +export function DialogFooter(props: PropsWithChildren<{ showDivider?: boolean }>) { + return ( +
{props.children}
+ ); } DialogContent.displayName = "DialogContent"; diff --git a/packages/ui/components/form/toggleGroup/ToggleGroup.tsx b/packages/ui/components/form/toggleGroup/ToggleGroup.tsx index 08f0d7a336..dc5833302d 100644 --- a/packages/ui/components/form/toggleGroup/ToggleGroup.tsx +++ b/packages/ui/components/form/toggleGroup/ToggleGroup.tsx @@ -6,7 +6,13 @@ import { classNames } from "@calcom/lib"; import { Tooltip } from "@calcom/ui"; interface ToggleGroupProps extends Omit { - options: { value: string; label: string | ReactNode; disabled?: boolean; tooltip?: string }[]; + options: { + value: string; + label: string | ReactNode; + disabled?: boolean; + tooltip?: string; + iconLeft?: ReactNode; + }[]; isFullWidth?: boolean; } @@ -80,7 +86,10 @@ export const ToggleGroup = ({ options, onValueChange, isFullWidth, ...props }: T } return node; }}> - {option.label} +
+ {option.iconLeft && {option.iconLeft}} + {option.label} +
))}