[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 <hariombalhara@gmail.com>
Co-authored-by: zomars <zomars@me.com>
This commit is contained in:
sean-brydon 2023-05-24 02:01:31 +01:00 committed by GitHub
parent 3671f9dfed
commit d63e7372cb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 738 additions and 170 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 = () => (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_4178_176214)">
<path
d="M8.31875 15.36C4.26 15.36 0.9575 12.0588 0.9575 8.00001C0.9575 3.94126 4.26 0.640015 8.31875 0.640015C10.1575 0.640015 11.9175 1.32126 13.2763 2.55876L13.5238 2.78501L11.0963 5.21251L10.8713 5.02001C10.1588 4.41001 9.2525 4.07376 8.31875 4.07376C6.15375 4.07376 4.39125 5.83501 4.39125 8.00001C4.39125 10.165 6.15375 11.9263 8.31875 11.9263C9.88 11.9263 11.1138 11.1288 11.695 9.77001H7.99875V6.45626L15.215 6.46626L15.2688 6.72001C15.645 8.50626 15.3438 11.1338 13.8188 13.0138C12.5563 14.57 10.7063 15.36 8.31875 15.36Z"
fill="#6B7280"
/>
</g>
<defs>
<clipPath id="clip0_4178_176214">
<rect width="16" height="16" fill="white" />
</clipPath>
</defs>
</svg>
);
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 (
<div className="flex gap-2">
<Tooltip content={t("google_workspace_admin_tooltip")}>
<Button
color="secondary"
onClick={() => {
mutation.mutate();
}}
className="w-full justify-center gap-2"
StartIcon={UsersIcon}
loading={mutation.isLoading}>
{t("import_from_google_workspace")}
</Button>
</Tooltip>
<Tooltip content="Remove workspace connection">
<Button
color="secondary"
loading={removeConnectionMutation.isLoading}
StartIcon={XIcon}
onClick={() => {
removeConnectionMutation.mutate();
utils.viewer.googleWorkspace.checkForGWorkspace.invalidate();
}}
variant="icon"
/>
</Tooltip>
</div>
);
}
// else show invite button
return (
<Button
type="button"
color="secondary"
loading={googleWorkspaceLoading}
StartIcon={GoogleIcon}
onClick={async () => {
setGoogleWorkspaceLoading(true);
const params = new URLSearchParams({
teamId: teamId.toString(),
});
const res = await fetch(`/api/teams/googleworkspace/add?${params}`);
if (!res.ok) {
const errorBody = await res.json();
throw new Error(errorBody.message || "Something went wrong");
}
setGoogleWorkspaceLoading(false);
const json = await res.json();
gotoUrl(json.url, json.newTab);
}}
className="justify-center gap-2">
{t("connect_google_workspace")}
</Button>
);
}

View File

@ -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<ModalMode>("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 (
<Dialog
name="inviteModal"
open={props.isOpen}
onOpenChange={() => {
props.onExit();
@ -66,7 +73,7 @@ export default function MemberInvitationModal(props: MemberInvitationModalProps)
}}>
<DialogContent
type="creation"
title={t("invite_new_member")}
title={t("invite_team_member")}
description={
IS_TEAM_BILLING_ENABLED ? (
<span className="text-subtle text-sm leading-tight">
@ -75,33 +82,104 @@ export default function MemberInvitationModal(props: MemberInvitationModalProps)
on your subscription.
</Trans>
</span>
) : (
""
)
) : null
}>
<div>
<Label className="sr-only" htmlFor="role">
{t("import_mode")}
</Label>
<ToggleGroup
isFullWidth={true}
onValueChange={(val) => setModalInputMode(val as ModalMode)}
defaultValue="INDIVIDUAL"
options={[
{
value: "INDIVIDUAL",
label: t("invite_team_individual_segment"),
iconLeft: <UserIcon />,
},
{ value: "BULK", label: t("invite_team_bulk_segment"), iconLeft: <Users /> },
]}
/>
</div>
<Form form={newMemberFormMethods} handleSubmit={(values) => props.onSubmit(values)}>
<div className="mt-6 space-y-6">
<Controller
name="emailOrUsername"
control={newMemberFormMethods.control}
rules={{
required: t("enter_email_or_username"),
validate: (value) => validateUniqueInvite(value) || t("member_already_invited"),
}}
render={({ field: { onChange }, fieldState: { error } }) => (
<>
<TextField
label={t("email_or_username")}
id="inviteUser"
name="inviteUser"
placeholder="email@example.com"
required
onChange={(e) => onChange(e.target.value.trim().toLowerCase())}
/>
{error && <span className="text-sm text-red-800">{error.message}</span>}
</>
)}
/>
<div className="space-y-6">
{/* Indivdual Invite */}
{modalImportMode === "INDIVIDUAL" && (
<Controller
name="emailOrUsername"
control={newMemberFormMethods.control}
rules={{
required: t("enter_email_or_username"),
validate: (value) => {
if (typeof value === "string")
return validateUniqueInvite(value) || t("member_already_invited");
},
}}
render={({ field: { onChange }, fieldState: { error } }) => (
<>
<TextField
label={t("email_or_username")}
id="inviteUser"
name="inviteUser"
placeholder="email@example.com"
required
onChange={(e) => onChange(e.target.value.trim().toLowerCase())}
/>
{error && <span className="text-sm text-red-800">{error.message}</span>}
</>
)}
/>
)}
{/* Bulk Invite */}
{modalImportMode === "BULK" && (
<div className="bg-muted flex flex-col rounded-md p-4">
<Controller
name="emailOrUsername"
control={newMemberFormMethods.control}
rules={{
required: t("enter_email_or_username"),
}}
render={({ field: { onChange, value }, fieldState: { error } }) => (
<>
{/* TODO: Make this a fancy email input that styles on a successful email. */}
<TextAreaField
name="emails"
label="Invite via email"
rows={4}
autoCorrect="off"
placeholder="john@doe.com, alex@smith.com"
required
value={value}
onChange={(e) => {
const emails = e.target.value
.split(",")
.map((email) => email.trim().toLocaleLowerCase());
return onChange(emails);
}}
/>
{error && <span className="text-sm text-red-800">{error.message}</span>}
</>
)}
/>
<GoogleWorkspaceInviteButton
onSuccess={(data) => {
newMemberFormMethods.setValue("emailOrUsername", data);
}}
/>
<Button
disabled
type="button"
color="secondary"
StartIcon={PaperclipIcon}
className="mt-3 justify-center stroke-2">
Upload a .csv file
</Button>
</div>
)}
<Controller
name="role"
control={newMemberFormMethods.control}
@ -109,17 +187,15 @@ export default function MemberInvitationModal(props: MemberInvitationModalProps)
render={({ field: { onChange } }) => (
<div>
<Label className="text-emphasis font-medium" htmlFor="role">
{t("role")}
{t("invite_as")}
</Label>
<ToggleGroup
isFullWidth={true}
<Select
id="role"
onValueChange={onChange}
defaultValue={options[0].value}
options={[
{ value: "ADMIN", label: t("admin") },
{ value: "MEMBER", label: t("member") },
]}
defaultValue={options[0]}
options={options}
onChange={(val) => {
if (val) onChange(val.value);
}}
/>
</div>
)}
@ -138,7 +214,7 @@ export default function MemberInvitationModal(props: MemberInvitationModalProps)
)}
/>
</div>
<DialogFooter>
<DialogFooter showDivider>
<Button
type="button"
color="minimal"
@ -153,7 +229,7 @@ export default function MemberInvitationModal(props: MemberInvitationModalProps)
color="primary"
className="ms-2 me-2"
data-testid="invite-new-member-button">
{t("invite")}
{t("send_invite")}
</Button>
</DialogFooter>
</Form>

View File

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

View File

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

View File

@ -10,4 +10,5 @@ export type AppFlags = {
workflows: boolean;
"v2-booking-page": boolean;
"managed-event-types": boolean;
"google-workspace-directory": boolean;
};

View File

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

View File

@ -38,6 +38,7 @@ const ENDPOINTS = [
"webhook",
"workflows",
"appsRouter",
"googleWorkspace",
] as const;
export type Endpoint = (typeof ENDPOINTS)[number];

View File

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

View File

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

View File

@ -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<TrpcSessionUser>;
};
input: CheckGlobalKeysSchemaType;
};
export const checkForGlobalKeysHandler = async ({ ctx, input }: checkForGlobalKeys) => {
const appIsGloballyInstalled = await prisma.app.findUnique({
where: {
slug: input.slug,
},
});
return !!appIsGloballyInstalled;
};

View File

@ -0,0 +1,7 @@
import { z } from "zod";
export const checkGlobalKeysSchema = z.object({
slug: z.string(),
});
export type CheckGlobalKeysSchemaType = z.infer<typeof checkGlobalKeysSchema>;

View File

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

View File

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

View File

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

View File

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

View File

@ -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 <div className="mt-7 flex justify-end space-x-2 rtl:space-x-reverse ">{props.children}</div>;
// TODO: add divider
export function DialogFooter(props: PropsWithChildren<{ showDivider?: boolean }>) {
return (
<div className={classNames("mt-7 flex justify-end space-x-2 rtl:space-x-reverse")}>{props.children}</div>
);
}
DialogContent.displayName = "DialogContent";

View File

@ -6,7 +6,13 @@ import { classNames } from "@calcom/lib";
import { Tooltip } from "@calcom/ui";
interface ToggleGroupProps extends Omit<RadixToggleGroup.ToggleGroupSingleProps, "type"> {
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}
<div className="item-center flex justify-center ">
{option.iconLeft && <span className="mr-2 flex h-4 w-4 items-center">{option.iconLeft}</span>}
{option.label}
</div>
</RadixToggleGroup.Item>
</OptionalTooltipWrapper>
))}