[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:
parent
3671f9dfed
commit
d63e7372cb
|
@ -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 });
|
||||||
|
}
|
||||||
|
}
|
|
@ -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`
|
||||||
|
);
|
||||||
|
}
|
|
@ -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);
|
|
@ -26,6 +26,8 @@
|
||||||
"rejection_confirmation": "Reject the booking",
|
"rejection_confirmation": "Reject the booking",
|
||||||
"manage_this_event": "Manage this event",
|
"manage_this_event": "Manage this event",
|
||||||
"invite_team_member": "Invite team member",
|
"invite_team_member": "Invite team member",
|
||||||
|
"invite_team_individual_segment": "Invite individual",
|
||||||
|
"invite_team_bulk_segment": "Bulk import",
|
||||||
"invite_team_notifcation_badge": "Inv.",
|
"invite_team_notifcation_badge": "Inv.",
|
||||||
"your_event_has_been_scheduled": "Your event has been scheduled",
|
"your_event_has_been_scheduled": "Your event has been scheduled",
|
||||||
"your_event_has_been_scheduled_recurring": "Your recurring 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",
|
"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.",
|
"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",
|
"request_password_reset": "Send reset email",
|
||||||
|
"send_invite": "Send invite",
|
||||||
"forgot_password": "Forgot Password?",
|
"forgot_password": "Forgot Password?",
|
||||||
"forgot": "Forgot?",
|
"forgot": "Forgot?",
|
||||||
"done": "Done",
|
"done": "Done",
|
||||||
|
@ -1809,6 +1812,7 @@
|
||||||
"charge_attendee": "Charge attendee {{amount, currency}}",
|
"charge_attendee": "Charge attendee {{amount, currency}}",
|
||||||
"payment_app_commission": "Require payment ({{paymentFeePercentage}}% + {{fee, currency}} commission per transaction)",
|
"payment_app_commission": "Require payment ({{paymentFeePercentage}}% + {{fee, currency}} commission per transaction)",
|
||||||
"email_invite_team": "{{email}} has been invited",
|
"email_invite_team": "{{email}} has been invited",
|
||||||
|
"email_invite_team_bulk": "{{userCount}} users have been invited",
|
||||||
"error_collecting_card": "Error collecting card",
|
"error_collecting_card": "Error collecting card",
|
||||||
"image_size_limit_exceed": "Uploaded image shouldn't exceed 5mb size limit",
|
"image_size_limit_exceed": "Uploaded image shouldn't exceed 5mb size limit",
|
||||||
"inline_embed": "Inline Embed",
|
"inline_embed": "Inline Embed",
|
||||||
|
@ -1819,12 +1823,16 @@
|
||||||
"open_dialog_with_element_click": "Open your Cal dialog when someone clicks an element.",
|
"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.",
|
"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",
|
"book_my_cal": "Book my Cal",
|
||||||
|
"invite_as":"Invite as",
|
||||||
"form_updated_successfully":"Form updated successfully.",
|
"form_updated_successfully":"Form updated successfully.",
|
||||||
"email_not_cal_member_cta": "Join your team",
|
"email_not_cal_member_cta": "Join your team",
|
||||||
"disable_attendees_confirmation_emails": "Disable default confirmation emails for attendees",
|
"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_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": "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.",
|
"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",
|
"first_event_type_webhook_description": "Create your first webhook for this event type",
|
||||||
"create_for": "Create for"
|
"create_for": "Create for"
|
||||||
}
|
}
|
||||||
|
|
|
@ -40,20 +40,30 @@ export const AddNewTeamMembersForm = ({
|
||||||
teamId: number;
|
teamId: number;
|
||||||
}) => {
|
}) => {
|
||||||
const { t, i18n } = useLocale();
|
const { t, i18n } = useLocale();
|
||||||
const [memberInviteModal, setMemberInviteModal] = useState(false);
|
|
||||||
const utils = trpc.useContext();
|
|
||||||
const router = useRouter();
|
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({
|
const inviteMemberMutation = trpc.viewer.teams.inviteMember.useMutation({
|
||||||
async onSuccess(data) {
|
async onSuccess(data) {
|
||||||
await utils.viewer.teams.get.invalidate();
|
await utils.viewer.teams.get.invalidate();
|
||||||
setMemberInviteModal(false);
|
setMemberInviteModal(false);
|
||||||
if (data.sendEmailInvitation) {
|
if (data.sendEmailInvitation) {
|
||||||
showToast(
|
if (Array.isArray(data.usernameOrEmail)) {
|
||||||
t("email_invite_team", {
|
showToast(
|
||||||
email: data.usernameOrEmail,
|
t("email_invite_team_bulk", {
|
||||||
}),
|
userCount: data.usernameOrEmail.length,
|
||||||
"success"
|
}),
|
||||||
);
|
"success"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
showToast(
|
||||||
|
t("email_invite_team", {
|
||||||
|
email: data.usernameOrEmail,
|
||||||
|
}),
|
||||||
|
"success"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
|
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,10 +1,11 @@
|
||||||
|
import { PaperclipIcon, UserIcon, Users } from "lucide-react";
|
||||||
import { Trans } from "next-i18next";
|
import { Trans } from "next-i18next";
|
||||||
import { useMemo } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { Controller, useForm } from "react-hook-form";
|
import { Controller, useForm } from "react-hook-form";
|
||||||
|
|
||||||
import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants";
|
import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import type { MembershipRole } from "@calcom/prisma/enums";
|
import { MembershipRole } from "@calcom/prisma/enums";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Checkbox as CheckboxField,
|
Checkbox as CheckboxField,
|
||||||
|
@ -15,9 +16,12 @@ import {
|
||||||
TextField,
|
TextField,
|
||||||
Label,
|
Label,
|
||||||
ToggleGroup,
|
ToggleGroup,
|
||||||
|
Select,
|
||||||
|
TextAreaField,
|
||||||
} from "@calcom/ui";
|
} from "@calcom/ui";
|
||||||
|
|
||||||
import type { PendingMember } from "../lib/types";
|
import type { PendingMember } from "../lib/types";
|
||||||
|
import { GoogleWorkspaceInviteButton } from "./GoogleWorkspaceInviteButton";
|
||||||
|
|
||||||
type MemberInvitationModalProps = {
|
type MemberInvitationModalProps = {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
|
@ -32,19 +36,21 @@ type MembershipRoleOption = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface NewMemberForm {
|
export interface NewMemberForm {
|
||||||
emailOrUsername: string;
|
emailOrUsername: string | string[];
|
||||||
role: MembershipRole;
|
role: MembershipRole;
|
||||||
sendInviteEmail: boolean;
|
sendInviteEmail: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ModalMode = "INDIVIDUAL" | "BULK";
|
||||||
|
|
||||||
export default function MemberInvitationModal(props: MemberInvitationModalProps) {
|
export default function MemberInvitationModal(props: MemberInvitationModalProps) {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
|
const [modalImportMode, setModalInputMode] = useState<ModalMode>("INDIVIDUAL");
|
||||||
const options: MembershipRoleOption[] = useMemo(() => {
|
const options: MembershipRoleOption[] = useMemo(() => {
|
||||||
return [
|
return [
|
||||||
{ value: "MEMBER", label: t("member") },
|
{ value: MembershipRole.MEMBER, label: t("member") },
|
||||||
{ value: "ADMIN", label: t("admin") },
|
{ value: MembershipRole.ADMIN, label: t("admin") },
|
||||||
{ value: "OWNER", label: t("owner") },
|
{ value: MembershipRole.OWNER, label: t("owner") },
|
||||||
];
|
];
|
||||||
}, [t]);
|
}, [t]);
|
||||||
|
|
||||||
|
@ -59,6 +65,7 @@ export default function MemberInvitationModal(props: MemberInvitationModalProps)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
|
name="inviteModal"
|
||||||
open={props.isOpen}
|
open={props.isOpen}
|
||||||
onOpenChange={() => {
|
onOpenChange={() => {
|
||||||
props.onExit();
|
props.onExit();
|
||||||
|
@ -66,7 +73,7 @@ export default function MemberInvitationModal(props: MemberInvitationModalProps)
|
||||||
}}>
|
}}>
|
||||||
<DialogContent
|
<DialogContent
|
||||||
type="creation"
|
type="creation"
|
||||||
title={t("invite_new_member")}
|
title={t("invite_team_member")}
|
||||||
description={
|
description={
|
||||||
IS_TEAM_BILLING_ENABLED ? (
|
IS_TEAM_BILLING_ENABLED ? (
|
||||||
<span className="text-subtle text-sm leading-tight">
|
<span className="text-subtle text-sm leading-tight">
|
||||||
|
@ -75,33 +82,104 @@ export default function MemberInvitationModal(props: MemberInvitationModalProps)
|
||||||
on your subscription.
|
on your subscription.
|
||||||
</Trans>
|
</Trans>
|
||||||
</span>
|
</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)}>
|
<Form form={newMemberFormMethods} handleSubmit={(values) => props.onSubmit(values)}>
|
||||||
<div className="mt-6 space-y-6">
|
<div className="space-y-6">
|
||||||
<Controller
|
{/* Indivdual Invite */}
|
||||||
name="emailOrUsername"
|
{modalImportMode === "INDIVIDUAL" && (
|
||||||
control={newMemberFormMethods.control}
|
<Controller
|
||||||
rules={{
|
name="emailOrUsername"
|
||||||
required: t("enter_email_or_username"),
|
control={newMemberFormMethods.control}
|
||||||
validate: (value) => validateUniqueInvite(value) || t("member_already_invited"),
|
rules={{
|
||||||
}}
|
required: t("enter_email_or_username"),
|
||||||
render={({ field: { onChange }, fieldState: { error } }) => (
|
validate: (value) => {
|
||||||
<>
|
if (typeof value === "string")
|
||||||
<TextField
|
return validateUniqueInvite(value) || t("member_already_invited");
|
||||||
label={t("email_or_username")}
|
},
|
||||||
id="inviteUser"
|
}}
|
||||||
name="inviteUser"
|
render={({ field: { onChange }, fieldState: { error } }) => (
|
||||||
placeholder="email@example.com"
|
<>
|
||||||
required
|
<TextField
|
||||||
onChange={(e) => onChange(e.target.value.trim().toLowerCase())}
|
label={t("email_or_username")}
|
||||||
/>
|
id="inviteUser"
|
||||||
{error && <span className="text-sm text-red-800">{error.message}</span>}
|
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
|
<Controller
|
||||||
name="role"
|
name="role"
|
||||||
control={newMemberFormMethods.control}
|
control={newMemberFormMethods.control}
|
||||||
|
@ -109,17 +187,15 @@ export default function MemberInvitationModal(props: MemberInvitationModalProps)
|
||||||
render={({ field: { onChange } }) => (
|
render={({ field: { onChange } }) => (
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-emphasis font-medium" htmlFor="role">
|
<Label className="text-emphasis font-medium" htmlFor="role">
|
||||||
{t("role")}
|
{t("invite_as")}
|
||||||
</Label>
|
</Label>
|
||||||
<ToggleGroup
|
<Select
|
||||||
isFullWidth={true}
|
|
||||||
id="role"
|
id="role"
|
||||||
onValueChange={onChange}
|
defaultValue={options[0]}
|
||||||
defaultValue={options[0].value}
|
options={options}
|
||||||
options={[
|
onChange={(val) => {
|
||||||
{ value: "ADMIN", label: t("admin") },
|
if (val) onChange(val.value);
|
||||||
{ value: "MEMBER", label: t("member") },
|
}}
|
||||||
]}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
@ -138,7 +214,7 @@ export default function MemberInvitationModal(props: MemberInvitationModalProps)
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter showDivider>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
color="minimal"
|
color="minimal"
|
||||||
|
@ -153,7 +229,7 @@ export default function MemberInvitationModal(props: MemberInvitationModalProps)
|
||||||
color="primary"
|
color="primary"
|
||||||
className="ms-2 me-2"
|
className="ms-2 me-2"
|
||||||
data-testid="invite-new-member-button">
|
data-testid="invite-new-member-button">
|
||||||
{t("invite")}
|
{t("send_invite")}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</Form>
|
</Form>
|
||||||
|
|
|
@ -53,19 +53,30 @@ export default function TeamListItem(props: Props) {
|
||||||
const { t, i18n } = useLocale();
|
const { t, i18n } = useLocale();
|
||||||
const utils = trpc.useContext();
|
const utils = trpc.useContext();
|
||||||
const team = props.team;
|
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 teamQuery = trpc.viewer.teams.get.useQuery({ teamId: team?.id });
|
||||||
const inviteMemberMutation = trpc.viewer.teams.inviteMember.useMutation({
|
const inviteMemberMutation = trpc.viewer.teams.inviteMember.useMutation({
|
||||||
async onSuccess(data) {
|
async onSuccess(data) {
|
||||||
await utils.viewer.teams.get.invalidate();
|
await utils.viewer.teams.get.invalidate();
|
||||||
setOpenMemberInvitationModal(false);
|
setOpenMemberInvitationModal(false);
|
||||||
if (data.sendEmailInvitation) {
|
if (data.sendEmailInvitation) {
|
||||||
showToast(
|
if (Array.isArray(data.usernameOrEmail)) {
|
||||||
t("email_invite_team", {
|
showToast(
|
||||||
email: data.usernameOrEmail,
|
t("email_invite_team_bulk", {
|
||||||
}),
|
userCount: data.usernameOrEmail.length,
|
||||||
"success"
|
}),
|
||||||
);
|
"success"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
showToast(
|
||||||
|
t("email_invite_team", {
|
||||||
|
email: data.usernameOrEmail,
|
||||||
|
}),
|
||||||
|
"success"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
|
|
|
@ -66,7 +66,8 @@ const MembersView = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const session = useSession();
|
const session = useSession();
|
||||||
const utils = trpc.useContext();
|
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 teamId = Number(router.query.id);
|
||||||
|
|
||||||
const { data: team, isLoading } = trpc.viewer.teams.get.useQuery(
|
const { data: team, isLoading } = trpc.viewer.teams.get.useQuery(
|
||||||
|
@ -83,12 +84,21 @@ const MembersView = () => {
|
||||||
await utils.viewer.teams.get.invalidate();
|
await utils.viewer.teams.get.invalidate();
|
||||||
setShowMemberInvitationModal(false);
|
setShowMemberInvitationModal(false);
|
||||||
if (data.sendEmailInvitation) {
|
if (data.sendEmailInvitation) {
|
||||||
showToast(
|
if (Array.isArray(data.usernameOrEmail)) {
|
||||||
t("email_invite_team", {
|
showToast(
|
||||||
email: data.usernameOrEmail,
|
t("email_invite_team_bulk", {
|
||||||
}),
|
userCount: data.usernameOrEmail.length,
|
||||||
"success"
|
}),
|
||||||
);
|
"success"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
showToast(
|
||||||
|
t("email_invite_team", {
|
||||||
|
email: data.usernameOrEmail,
|
||||||
|
}),
|
||||||
|
"success"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
|
|
|
@ -10,4 +10,5 @@ export type AppFlags = {
|
||||||
workflows: boolean;
|
workflows: boolean;
|
||||||
"v2-booking-page": boolean;
|
"v2-booking-page": boolean;
|
||||||
"managed-event-types": boolean;
|
"managed-event-types": boolean;
|
||||||
|
"google-workspace-directory": boolean;
|
||||||
};
|
};
|
||||||
|
|
|
@ -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;
|
|
@ -38,6 +38,7 @@ const ENDPOINTS = [
|
||||||
"webhook",
|
"webhook",
|
||||||
"workflows",
|
"workflows",
|
||||||
"appsRouter",
|
"appsRouter",
|
||||||
|
"googleWorkspace",
|
||||||
] as const;
|
] as const;
|
||||||
export type Endpoint = (typeof ENDPOINTS)[number];
|
export type Endpoint = (typeof ENDPOINTS)[number];
|
||||||
|
|
||||||
|
|
|
@ -14,6 +14,7 @@ import { availabilityRouter } from "./availability/_router";
|
||||||
import { bookingsRouter } from "./bookings/_router";
|
import { bookingsRouter } from "./bookings/_router";
|
||||||
import { deploymentSetupRouter } from "./deploymentSetup/_router";
|
import { deploymentSetupRouter } from "./deploymentSetup/_router";
|
||||||
import { eventTypesRouter } from "./eventTypes/_router";
|
import { eventTypesRouter } from "./eventTypes/_router";
|
||||||
|
import { googleWorkspaceRouter } from "./googleWorkspace/_router";
|
||||||
import { paymentsRouter } from "./payments/_router";
|
import { paymentsRouter } from "./payments/_router";
|
||||||
import { slotsRouter } from "./slots/_router";
|
import { slotsRouter } from "./slots/_router";
|
||||||
import { ssoRouter } from "./sso/_router";
|
import { ssoRouter } from "./sso/_router";
|
||||||
|
@ -46,5 +47,6 @@ export const viewerRouter = mergeRouters(
|
||||||
features: featureFlagRouter,
|
features: featureFlagRouter,
|
||||||
appsRouter,
|
appsRouter,
|
||||||
users: userAdminRouter,
|
users: userAdminRouter,
|
||||||
|
googleWorkspace: googleWorkspaceRouter,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import authedProcedure, { authedAdminProcedure } from "../../../procedures/authedProcedure";
|
import authedProcedure, { authedAdminProcedure } from "../../../procedures/authedProcedure";
|
||||||
import { router } from "../../../trpc";
|
import { router } from "../../../trpc";
|
||||||
|
import { checkGlobalKeysSchema } from "./checkGlobalKeys.schema";
|
||||||
import { ZListLocalInputSchema } from "./listLocal.schema";
|
import { ZListLocalInputSchema } from "./listLocal.schema";
|
||||||
import { ZQueryForDependenciesInputSchema } from "./queryForDependencies.schema";
|
import { ZQueryForDependenciesInputSchema } from "./queryForDependencies.schema";
|
||||||
import { ZSaveKeysInputSchema } from "./saveKeys.schema";
|
import { ZSaveKeysInputSchema } from "./saveKeys.schema";
|
||||||
|
@ -13,6 +14,7 @@ type AppsRouterHandlerCache = {
|
||||||
checkForGCal?: typeof import("./checkForGCal.handler").checkForGCalHandler;
|
checkForGCal?: typeof import("./checkForGCal.handler").checkForGCalHandler;
|
||||||
updateAppCredentials?: typeof import("./updateAppCredentials.handler").updateAppCredentialsHandler;
|
updateAppCredentials?: typeof import("./updateAppCredentials.handler").updateAppCredentialsHandler;
|
||||||
queryForDependencies?: typeof import("./queryForDependencies.handler").queryForDependenciesHandler;
|
queryForDependencies?: typeof import("./queryForDependencies.handler").queryForDependenciesHandler;
|
||||||
|
checkGlobalKeys?: typeof import("./checkGlobalKeys.handler").checkForGlobalKeysHandler;
|
||||||
};
|
};
|
||||||
|
|
||||||
const UNSTABLE_HANDLER_CACHE: AppsRouterHandlerCache = {};
|
const UNSTABLE_HANDLER_CACHE: AppsRouterHandlerCache = {};
|
||||||
|
@ -124,4 +126,21 @@ export const appsRouter = router({
|
||||||
input,
|
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,
|
||||||
|
});
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
|
@ -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;
|
||||||
|
};
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const checkGlobalKeysSchema = z.object({
|
||||||
|
slug: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type CheckGlobalKeysSchemaType = z.infer<typeof checkGlobalKeysSchema>;
|
|
@ -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,
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
});
|
|
@ -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 };
|
||||||
|
};
|
|
@ -37,113 +37,121 @@ export const inviteMemberHandler = async ({ ctx, input }: InviteMemberOptions) =
|
||||||
|
|
||||||
if (!team) throw new TRPCError({ code: "NOT_FOUND", message: "Team not found" });
|
if (!team) throw new TRPCError({ code: "NOT_FOUND", message: "Team not found" });
|
||||||
|
|
||||||
// A user can exist even if they have not completed onboarding
|
const emailsToInvite = Array.isArray(input.usernameOrEmail)
|
||||||
const invitee = await prisma.user.findFirst({
|
? input.usernameOrEmail
|
||||||
where: {
|
: [input.usernameOrEmail];
|
||||||
OR: [{ username: input.usernameOrEmail }, { email: input.usernameOrEmail }],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!invitee) {
|
emailsToInvite.forEach(async (usernameOrEmail) => {
|
||||||
// liberal email match
|
const invitee = await prisma.user.findFirst({
|
||||||
|
where: {
|
||||||
if (!isEmail(input.usernameOrEmail))
|
OR: [{ username: usernameOrEmail }, { email: 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,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const token: string = randomBytes(32).toString("hex");
|
if (!invitee) {
|
||||||
|
// liberal email match
|
||||||
|
|
||||||
await prisma.verificationToken.create({
|
if (!isEmail(usernameOrEmail))
|
||||||
data: {
|
throw new TRPCError({
|
||||||
identifier: input.usernameOrEmail,
|
code: "NOT_FOUND",
|
||||||
token,
|
message: `Invite failed because there is no corresponding user for ${usernameOrEmail}`,
|
||||||
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
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
inviteTeamOptions.joinLink = `${WEBAPP_URL}/signup?token=${token}&callbackUrl=/getting-started`;
|
// valid email given, create User and add to team
|
||||||
inviteTeamOptions.isCalcomMember = false;
|
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({
|
let sendTo = usernameOrEmail;
|
||||||
language: translation,
|
if (!isEmail(usernameOrEmail)) {
|
||||||
from: ctx.user.name,
|
sendTo = invitee.email;
|
||||||
to: sendTo,
|
}
|
||||||
teamName: team.name,
|
// inform user of membership by email
|
||||||
...inviteTeamOptions,
|
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);
|
if (IS_TEAM_BILLING_ENABLED) await updateQuantitySubscriptionFromStripe(input.teamId);
|
||||||
return input;
|
return input;
|
||||||
};
|
};
|
||||||
|
|
|
@ -4,7 +4,12 @@ import { MembershipRole } from "@calcom/prisma/enums";
|
||||||
|
|
||||||
export const ZInviteMemberInputSchema = z.object({
|
export const ZInviteMemberInputSchema = z.object({
|
||||||
teamId: z.number(),
|
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),
|
role: z.nativeEnum(MembershipRole),
|
||||||
language: z.string(),
|
language: z.string(),
|
||||||
sendEmailInvitation: z.boolean(),
|
sendEmailInvitation: z.boolean(),
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import type { ReactNode } from "react";
|
import type { PropsWithChildren, ReactNode } from "react";
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
|
|
||||||
import classNames from "@calcom/lib/classNames";
|
import classNames from "@calcom/lib/classNames";
|
||||||
|
@ -61,7 +61,7 @@ type DialogContentProps = React.ComponentProps<(typeof DialogPrimitive)["Content
|
||||||
size?: "xl" | "lg" | "md";
|
size?: "xl" | "lg" | "md";
|
||||||
type?: "creation" | "confirmation";
|
type?: "creation" | "confirmation";
|
||||||
title?: string;
|
title?: string;
|
||||||
description?: string | JSX.Element | undefined;
|
description?: string | JSX.Element | null;
|
||||||
closeText?: string;
|
closeText?: string;
|
||||||
actionDisabled?: boolean;
|
actionDisabled?: boolean;
|
||||||
Icon?: SVGComponent;
|
Icon?: SVGComponent;
|
||||||
|
@ -134,8 +134,11 @@ export function DialogHeader(props: DialogHeaderProps) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DialogFooter(props: { children: ReactNode }) {
|
// TODO: add divider
|
||||||
return <div className="mt-7 flex justify-end space-x-2 rtl:space-x-reverse ">{props.children}</div>;
|
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";
|
DialogContent.displayName = "DialogContent";
|
||||||
|
|
|
@ -6,7 +6,13 @@ import { classNames } from "@calcom/lib";
|
||||||
import { Tooltip } from "@calcom/ui";
|
import { Tooltip } from "@calcom/ui";
|
||||||
|
|
||||||
interface ToggleGroupProps extends Omit<RadixToggleGroup.ToggleGroupSingleProps, "type"> {
|
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;
|
isFullWidth?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -80,7 +86,10 @@ export const ToggleGroup = ({ options, onValueChange, isFullWidth, ...props }: T
|
||||||
}
|
}
|
||||||
return node;
|
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>
|
</RadixToggleGroup.Item>
|
||||||
</OptionalTooltipWrapper>
|
</OptionalTooltipWrapper>
|
||||||
))}
|
))}
|
||||||
|
|
Loading…
Reference in New Issue
Block a user