feat: Add copy invite link (#8355)
* feat: Add shared invite link * refactor: Rename Invite to Team Invite model * feat: add admin check for team invite link procedures * Replace TeamInvite with VerificationToken * Add team invite null checks * Migrates tRPC procedures to new format * Type fixes * Update common.json --------- Co-authored-by: zomars <zomars@me.com> Co-authored-by: Peer Richelsen <peeroke@gmail.com> Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com> Co-authored-by: alannnc <alannnc@gmail.com> Co-authored-by: Efraín Rochín <roae.85@gmail.com> Co-authored-by: Keith Williams <keithwillcode@gmail.com>
This commit is contained in:
parent
c24f5200f5
commit
10f965570b
|
@ -1685,7 +1685,7 @@
|
|||
"not_enough_seats": "Not enough seats",
|
||||
"form_builder_field_already_exists": "A field with this name already exists",
|
||||
"form_builder_field_add_subtitle": "Customize the questions asked on the booking page",
|
||||
"show_on_booking_page":"Show on booking page",
|
||||
"show_on_booking_page": "Show on booking page",
|
||||
"get_started_zapier_templates": "Get started with Zapier templates",
|
||||
"team_is_unpublished": "{{team}} is unpublished",
|
||||
"team_is_unpublished_description": "This team link is currently not available. Please contact the team owner or ask them publish it.",
|
||||
|
@ -1817,6 +1817,17 @@
|
|||
"complete_your_booking": "Complete your booking",
|
||||
"complete_your_booking_subject": "Complete your booking: {{title}} on {{date}}",
|
||||
"confirm_your_details": "Confirm your details",
|
||||
"copy_invite_link": "Copy invite link",
|
||||
"edit_invite_link": "Edit link settings",
|
||||
"invite_link_copied": "Invite link copied",
|
||||
"invite_link_deleted": "Invite link deleted",
|
||||
"invite_link_updated": "Invite link settings saved",
|
||||
"link_expires_after": "Links set to expire after...",
|
||||
"one_day": "1 day",
|
||||
"seven_days": "7 days",
|
||||
"thirty_days": "30 days",
|
||||
"never_expire": "Never expires",
|
||||
"team_invite_received": "You have been invited to join {{teamName}}",
|
||||
"currency_string": "{{amount, currency}}",
|
||||
"charge_card_dialog_body": "You are about to charge the attendee {{amount, currency}}. Are you sure you want to continue?",
|
||||
"charge_attendee": "Charge attendee {{amount, currency}}",
|
||||
|
|
|
@ -3,14 +3,23 @@ import { useRouter } from "next/router";
|
|||
import { useState } from "react";
|
||||
import { z } from "zod";
|
||||
|
||||
import InviteLinkSettingsModal from "@calcom/features/ee/teams/components/InviteLinkSettingsModal";
|
||||
import MemberInvitationModal from "@calcom/features/ee/teams/components/MemberInvitationModal";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { WEBAPP_URL, APP_NAME } from "@calcom/lib/constants";
|
||||
import { APP_NAME, WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Avatar, Badge, Button, showToast, SkeletonContainer, SkeletonText } from "@calcom/ui";
|
||||
import { Plus, ArrowRight, Trash2 } from "@calcom/ui/components/icon";
|
||||
import {
|
||||
Avatar,
|
||||
Badge,
|
||||
Button,
|
||||
showToast,
|
||||
SkeletonButton,
|
||||
SkeletonContainer,
|
||||
SkeletonText,
|
||||
} from "@calcom/ui";
|
||||
import { ArrowRight, Plus, Trash2 } from "@calcom/ui/components/icon";
|
||||
|
||||
const querySchema = z.object({
|
||||
id: z.string().transform((val) => parseInt(val)),
|
||||
|
@ -40,10 +49,16 @@ export const AddNewTeamMembersForm = ({
|
|||
teamId: number;
|
||||
}) => {
|
||||
const { t, i18n } = useLocale();
|
||||
|
||||
const router = useRouter();
|
||||
const utils = trpc.useContext();
|
||||
|
||||
const showDialog = router.query.inviteModal === "true";
|
||||
const [memberInviteModal, setMemberInviteModal] = useState(showDialog);
|
||||
const utils = trpc.useContext();
|
||||
const [inviteLinkSettingsModal, setInviteLinkSettingsModal] = useState(false);
|
||||
|
||||
const { data: team, isLoading } = trpc.viewer.teams.get.useQuery({ teamId });
|
||||
|
||||
const inviteMemberMutation = trpc.viewer.teams.inviteMember.useMutation({
|
||||
async onSuccess(data) {
|
||||
await utils.viewer.teams.get.invalidate();
|
||||
|
@ -70,6 +85,7 @@ export const AddNewTeamMembersForm = ({
|
|||
showToast(error.message, "error");
|
||||
},
|
||||
});
|
||||
|
||||
const publishTeamMutation = trpc.viewer.teams.publish.useMutation({
|
||||
onSuccess(data) {
|
||||
router.push(data.url);
|
||||
|
@ -96,20 +112,44 @@ export const AddNewTeamMembersForm = ({
|
|||
{t("add_team_member")}
|
||||
</Button>
|
||||
</div>
|
||||
<MemberInvitationModal
|
||||
isOpen={memberInviteModal}
|
||||
onExit={() => setMemberInviteModal(false)}
|
||||
onSubmit={(values) => {
|
||||
inviteMemberMutation.mutate({
|
||||
teamId,
|
||||
language: i18n.language,
|
||||
role: values.role,
|
||||
usernameOrEmail: values.emailOrUsername,
|
||||
sendEmailInvitation: values.sendInviteEmail,
|
||||
});
|
||||
}}
|
||||
members={defaultValues.members}
|
||||
/>
|
||||
{isLoading ? (
|
||||
<SkeletonButton />
|
||||
) : (
|
||||
<>
|
||||
<MemberInvitationModal
|
||||
isOpen={memberInviteModal}
|
||||
teamId={teamId}
|
||||
token={team?.inviteToken?.token}
|
||||
onExit={() => setMemberInviteModal(false)}
|
||||
onSubmit={(values) => {
|
||||
inviteMemberMutation.mutate({
|
||||
teamId,
|
||||
language: i18n.language,
|
||||
role: values.role,
|
||||
usernameOrEmail: values.emailOrUsername,
|
||||
sendEmailInvitation: values.sendInviteEmail,
|
||||
});
|
||||
}}
|
||||
onSettingsOpen={() => {
|
||||
setMemberInviteModal(false);
|
||||
setInviteLinkSettingsModal(true);
|
||||
}}
|
||||
members={defaultValues.members}
|
||||
/>
|
||||
{team?.inviteToken && (
|
||||
<InviteLinkSettingsModal
|
||||
isOpen={inviteLinkSettingsModal}
|
||||
teamId={team.id}
|
||||
token={team.inviteToken?.token}
|
||||
expiresInDays={team.inviteToken?.expiresInDays || undefined}
|
||||
onExit={() => {
|
||||
setInviteLinkSettingsModal(false);
|
||||
setMemberInviteModal(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<hr className="border-subtle my-6" />
|
||||
<Button
|
||||
EndIcon={ArrowRight}
|
||||
|
|
|
@ -0,0 +1,115 @@
|
|||
import { useMemo } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
import { Button, Dialog, DialogContent, DialogFooter, Form, Label, Select, showToast } from "@calcom/ui";
|
||||
|
||||
type InvitationLinkSettingsModalProps = {
|
||||
isOpen: boolean;
|
||||
teamId: number;
|
||||
token: string;
|
||||
expiresInDays?: number;
|
||||
onExit: () => void;
|
||||
};
|
||||
|
||||
export interface LinkSettingsForm {
|
||||
expiresInDays: number | undefined;
|
||||
}
|
||||
|
||||
export default function InviteLinkSettingsModal(props: InvitationLinkSettingsModalProps) {
|
||||
const { t } = useLocale();
|
||||
const trpcContext = trpc.useContext();
|
||||
|
||||
const deleteInviteMutation = trpc.viewer.teams.deleteInvite.useMutation({
|
||||
onSuccess: () => {
|
||||
showToast(t("invite_link_deleted"), "success");
|
||||
trpcContext.viewer.teams.get.invalidate();
|
||||
trpcContext.viewer.teams.list.invalidate();
|
||||
props.onExit();
|
||||
},
|
||||
onError: (e) => {
|
||||
showToast(e.message, "error");
|
||||
},
|
||||
});
|
||||
|
||||
const setInviteExpirationMutation = trpc.viewer.teams.setInviteExpiration.useMutation({
|
||||
onSuccess: () => {
|
||||
showToast(t("invite_link_updated"), "success");
|
||||
trpcContext.viewer.teams.get.invalidate();
|
||||
trpcContext.viewer.teams.list.invalidate();
|
||||
},
|
||||
onError: (e) => {
|
||||
showToast(e.message, "error");
|
||||
},
|
||||
});
|
||||
|
||||
const expiresInDaysOption = useMemo(() => {
|
||||
return [
|
||||
{ value: 1, label: t("one_day") },
|
||||
{ value: 7, label: t("seven_days") },
|
||||
{ value: 30, label: t("thirty_days") },
|
||||
{ value: undefined, label: t("never_expire") },
|
||||
];
|
||||
}, [t]);
|
||||
|
||||
const linkSettingsFormMethods = useForm<LinkSettingsForm>();
|
||||
|
||||
const handleSubmit = (values: LinkSettingsForm) => {
|
||||
setInviteExpirationMutation.mutate({
|
||||
token: props.token,
|
||||
expiresInDays: values.expiresInDays,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={props.isOpen}
|
||||
onOpenChange={() => {
|
||||
props.onExit();
|
||||
linkSettingsFormMethods.reset();
|
||||
}}>
|
||||
<DialogContent type="creation" title="Invite link settings">
|
||||
<Form form={linkSettingsFormMethods} handleSubmit={handleSubmit}>
|
||||
<Controller
|
||||
name="expiresInDays"
|
||||
control={linkSettingsFormMethods.control}
|
||||
render={({ field: { onChange } }) => (
|
||||
<div>
|
||||
<Label className="text-emphasis font-medium" htmlFor="expiresInDays">
|
||||
{t("link_expires_after")}
|
||||
</Label>
|
||||
<Select
|
||||
options={expiresInDaysOption}
|
||||
defaultValue={expiresInDaysOption.find((option) => option.value === props.expiresInDays)}
|
||||
className="w-full"
|
||||
onChange={(val) => onChange(val?.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
color="secondary"
|
||||
onClick={() => deleteInviteMutation.mutate({ token: props.token })}
|
||||
className="mr-auto"
|
||||
data-testid="copy-invite-link-button">
|
||||
{t("delete")}
|
||||
</Button>
|
||||
<Button type="button" color="minimal" onClick={props.onExit}>
|
||||
{t("back")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
color="primary"
|
||||
className="ms-2 me-2"
|
||||
data-testid="invite-new-member-button">
|
||||
{t("save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
|
@ -3,9 +3,11 @@ import { Trans } from "next-i18next";
|
|||
import { useMemo, useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
|
||||
import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { IS_TEAM_BILLING_ENABLED, WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
import {
|
||||
Button,
|
||||
Checkbox as CheckboxField,
|
||||
|
@ -13,12 +15,14 @@ import {
|
|||
DialogContent,
|
||||
DialogFooter,
|
||||
Form,
|
||||
TextField,
|
||||
Label,
|
||||
showToast,
|
||||
TextField,
|
||||
ToggleGroup,
|
||||
Select,
|
||||
TextAreaField,
|
||||
} from "@calcom/ui";
|
||||
import { Link } from "@calcom/ui/components/icon";
|
||||
|
||||
import type { PendingMember } from "../lib/types";
|
||||
import { GoogleWorkspaceInviteButton } from "./GoogleWorkspaceInviteButton";
|
||||
|
@ -27,7 +31,10 @@ type MemberInvitationModalProps = {
|
|||
isOpen: boolean;
|
||||
onExit: () => void;
|
||||
onSubmit: (values: NewMemberForm) => void;
|
||||
onSettingsOpen: () => void;
|
||||
teamId: number;
|
||||
members: PendingMember[];
|
||||
token?: string;
|
||||
};
|
||||
|
||||
type MembershipRoleOption = {
|
||||
|
@ -45,7 +52,27 @@ type ModalMode = "INDIVIDUAL" | "BULK";
|
|||
|
||||
export default function MemberInvitationModal(props: MemberInvitationModalProps) {
|
||||
const { t } = useLocale();
|
||||
const trpcContext = trpc.useContext();
|
||||
|
||||
const [modalImportMode, setModalInputMode] = useState<ModalMode>("INDIVIDUAL");
|
||||
|
||||
const createInviteMutation = trpc.viewer.teams.createInvite.useMutation({
|
||||
onSuccess(token) {
|
||||
copyInviteLinkToClipboard(token);
|
||||
trpcContext.viewer.teams.get.invalidate();
|
||||
trpcContext.viewer.teams.list.invalidate();
|
||||
},
|
||||
onError: (error) => {
|
||||
showToast(error.message, "error");
|
||||
},
|
||||
});
|
||||
|
||||
const copyInviteLinkToClipboard = async (token: string) => {
|
||||
const inviteLink = `${WEBAPP_URL}/teams?token=${token}`;
|
||||
await navigator.clipboard.writeText(inviteLink);
|
||||
showToast(t("invite_link_copied"), "success");
|
||||
};
|
||||
|
||||
const options: MembershipRoleOption[] = useMemo(() => {
|
||||
return [
|
||||
{ value: MembershipRole.MEMBER, label: t("member") },
|
||||
|
@ -215,6 +242,35 @@ export default function MemberInvitationModal(props: MemberInvitationModalProps)
|
|||
/>
|
||||
</div>
|
||||
<DialogFooter showDivider>
|
||||
<div className="mr-auto flex">
|
||||
<Button
|
||||
type="button"
|
||||
color="minimal"
|
||||
variant="icon"
|
||||
onClick={() =>
|
||||
props.token
|
||||
? copyInviteLinkToClipboard(props.token)
|
||||
: createInviteMutation.mutate({ teamId: props.teamId })
|
||||
}
|
||||
className={classNames("gap-2", props.token && "opacity-50")}
|
||||
data-testid="copy-invite-link-button">
|
||||
<Link className="text-default h-4 w-4" aria-hidden="true" />
|
||||
{t("copy_invite_link")}
|
||||
</Button>
|
||||
{props.token && (
|
||||
<Button
|
||||
type="button"
|
||||
color="minimal"
|
||||
className="ms-2 me-2"
|
||||
onClick={() => {
|
||||
props.onSettingsOpen();
|
||||
newMemberFormMethods.reset();
|
||||
}}
|
||||
data-testid="edit-invite-link-button">
|
||||
{t("edit_invite_link")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
color="minimal"
|
||||
|
|
|
@ -2,6 +2,7 @@ import Link from "next/link";
|
|||
import { useRouter } from "next/router";
|
||||
import { useState } from "react";
|
||||
|
||||
import InviteLinkSettingsModal from "@calcom/ee/teams/components/InviteLinkSettingsModal";
|
||||
import MemberInvitationModal from "@calcom/ee/teams/components/MemberInvitationModal";
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
|
||||
|
@ -26,16 +27,16 @@ import {
|
|||
Tooltip,
|
||||
} from "@calcom/ui";
|
||||
import {
|
||||
MoreHorizontal,
|
||||
Check,
|
||||
X,
|
||||
Link as LinkIcon,
|
||||
Edit2,
|
||||
ExternalLink,
|
||||
Trash,
|
||||
LogOut,
|
||||
Globe,
|
||||
Link as LinkIcon,
|
||||
LogOut,
|
||||
MoreHorizontal,
|
||||
Send,
|
||||
Trash,
|
||||
X,
|
||||
} from "@calcom/ui/components/icon";
|
||||
|
||||
import { TeamRole } from "./TeamPill";
|
||||
|
@ -51,11 +52,15 @@ interface Props {
|
|||
|
||||
export default function TeamListItem(props: Props) {
|
||||
const { t, i18n } = useLocale();
|
||||
|
||||
const router = useRouter();
|
||||
const utils = trpc.useContext();
|
||||
const team = props.team;
|
||||
const router = useRouter();
|
||||
|
||||
const showDialog = router.query.inviteModal === "true";
|
||||
const [openMemberInvitationModal, setOpenMemberInvitationModal] = useState(showDialog);
|
||||
const [openInviteLinkSettingsModal, setOpenInviteLinkSettingsModal] = useState(false);
|
||||
|
||||
const teamQuery = trpc.viewer.teams.get.useQuery({ teamId: team?.id });
|
||||
const inviteMemberMutation = trpc.viewer.teams.inviteMember.useMutation({
|
||||
async onSuccess(data) {
|
||||
|
@ -129,6 +134,8 @@ export default function TeamListItem(props: Props) {
|
|||
<li className="">
|
||||
<MemberInvitationModal
|
||||
isOpen={openMemberInvitationModal}
|
||||
teamId={team.id}
|
||||
token={team.inviteToken?.token}
|
||||
onExit={() => {
|
||||
setOpenMemberInvitationModal(false);
|
||||
}}
|
||||
|
@ -141,8 +148,24 @@ export default function TeamListItem(props: Props) {
|
|||
sendEmailInvitation: values.sendInviteEmail,
|
||||
});
|
||||
}}
|
||||
onSettingsOpen={() => {
|
||||
setOpenMemberInvitationModal(false);
|
||||
setOpenInviteLinkSettingsModal(true);
|
||||
}}
|
||||
members={teamQuery?.data?.members || []}
|
||||
/>
|
||||
{team.inviteToken && (
|
||||
<InviteLinkSettingsModal
|
||||
isOpen={openInviteLinkSettingsModal}
|
||||
teamId={team.id}
|
||||
token={team.inviteToken?.token}
|
||||
expiresInDays={team.inviteToken?.expiresInDays || undefined}
|
||||
onExit={() => {
|
||||
setOpenInviteLinkSettingsModal(false);
|
||||
setOpenMemberInvitationModal(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className={classNames("flex items-center justify-between", !isInvitee && "hover:bg-muted group")}>
|
||||
{!isInvitee ? (
|
||||
<Link
|
||||
|
|
|
@ -1,10 +1,11 @@
|
|||
import { useState, useMemo } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { WEBAPP_URL, APP_NAME } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Alert, Button, ButtonGroup, Label } from "@calcom/ui";
|
||||
import { Users, RefreshCcw, UserPlus, Mail, Video, EyeOff } from "@calcom/ui/components/icon";
|
||||
import { Alert, Button, ButtonGroup, Label, showToast } from "@calcom/ui";
|
||||
import { EyeOff, Mail, RefreshCcw, UserPlus, Users, Video } from "@calcom/ui/components/icon";
|
||||
|
||||
import { UpgradeTip } from "../../../tips";
|
||||
import SkeletonLoaderTeamList from "./SkeletonloaderTeamList";
|
||||
|
@ -12,14 +13,32 @@ import TeamList from "./TeamList";
|
|||
|
||||
export function TeamsListing() {
|
||||
const { t } = useLocale();
|
||||
const trpcContext = trpc.useContext();
|
||||
const router = useRouter();
|
||||
|
||||
const [inviteTokenChecked, setInviteTokenChecked] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
|
||||
const { data, isLoading } = trpc.viewer.teams.list.useQuery(undefined, {
|
||||
enabled: inviteTokenChecked,
|
||||
onError: (e) => {
|
||||
setErrorMessage(e.message);
|
||||
},
|
||||
});
|
||||
|
||||
const { mutate: inviteMemberByToken } = trpc.viewer.teams.inviteMemberByToken.useMutation({
|
||||
onSuccess: (teamName) => {
|
||||
trpcContext.viewer.teams.list.invalidate();
|
||||
showToast(t("team_invite_received", { teamName }), "success");
|
||||
},
|
||||
onError: (e) => {
|
||||
showToast(e.message, "error");
|
||||
},
|
||||
onSettled: () => {
|
||||
setInviteTokenChecked(true);
|
||||
},
|
||||
});
|
||||
|
||||
const teams = useMemo(() => data?.filter((m) => m.accepted) || [], [data]);
|
||||
const invites = useMemo(() => data?.filter((m) => !m.accepted) || [], [data]);
|
||||
|
||||
|
@ -56,7 +75,13 @@ export function TeamsListing() {
|
|||
},
|
||||
];
|
||||
|
||||
if (isLoading) {
|
||||
useEffect(() => {
|
||||
if (!router) return;
|
||||
if (router.query.token) inviteMemberByToken({ token: router.query.token as string });
|
||||
else setInviteTokenChecked(true);
|
||||
}, [router, inviteMemberByToken, setInviteTokenChecked]);
|
||||
|
||||
if (isLoading || !inviteTokenChecked) {
|
||||
return <SkeletonLoaderTeamList />;
|
||||
}
|
||||
|
||||
|
|
|
@ -11,6 +11,7 @@ import { Plus } from "@calcom/ui/components/icon";
|
|||
|
||||
import { getLayout } from "../../../settings/layouts/SettingsLayout";
|
||||
import DisableTeamImpersonation from "../components/DisableTeamImpersonation";
|
||||
import InviteLinkSettingsModal from "../components/InviteLinkSettingsModal";
|
||||
import MemberInvitationModal from "../components/MemberInvitationModal";
|
||||
import MemberListItem from "../components/MemberListItem";
|
||||
import TeamInviteList from "../components/TeamInviteList";
|
||||
|
@ -63,12 +64,16 @@ function MembersList(props: MembersListProps) {
|
|||
|
||||
const MembersView = () => {
|
||||
const { t, i18n } = useLocale();
|
||||
|
||||
const router = useRouter();
|
||||
const session = useSession();
|
||||
|
||||
const utils = trpc.useContext();
|
||||
const teamId = Number(router.query.id);
|
||||
|
||||
const showDialog = router.query.inviteModal === "true";
|
||||
const [showMemberInvitationModal, setShowMemberInvitationModal] = useState(showDialog);
|
||||
const teamId = Number(router.query.id);
|
||||
const [showInviteLinkSettingsModal, setInviteLinkSettingsModal] = useState(false);
|
||||
|
||||
const { data: team, isLoading } = trpc.viewer.teams.get.useQuery(
|
||||
{ teamId },
|
||||
|
@ -169,6 +174,8 @@ const MembersView = () => {
|
|||
<MemberInvitationModal
|
||||
isOpen={showMemberInvitationModal}
|
||||
members={team.members}
|
||||
teamId={team.id}
|
||||
token={team.inviteToken?.token}
|
||||
onExit={() => setShowMemberInvitationModal(false)}
|
||||
onSubmit={(values) => {
|
||||
inviteMemberMutation.mutate({
|
||||
|
@ -179,6 +186,22 @@ const MembersView = () => {
|
|||
sendEmailInvitation: values.sendInviteEmail,
|
||||
});
|
||||
}}
|
||||
onSettingsOpen={() => {
|
||||
setShowMemberInvitationModal(false);
|
||||
setInviteLinkSettingsModal(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{showInviteLinkSettingsModal && team?.inviteToken && (
|
||||
<InviteLinkSettingsModal
|
||||
isOpen={showInviteLinkSettingsModal}
|
||||
teamId={team.id}
|
||||
token={team.inviteToken.token}
|
||||
expiresInDays={team.inviteToken.expiresInDays || undefined}
|
||||
onExit={() => {
|
||||
setInviteLinkSettingsModal(false);
|
||||
setShowMemberInvitationModal(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
|
|
@ -7,6 +7,7 @@ import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
|||
import { WEBAPP_URL } from "../../../constants";
|
||||
|
||||
export type TeamWithMembers = Awaited<ReturnType<typeof getTeamWithMembers>>;
|
||||
|
||||
export async function getTeamWithMembers(id?: number, slug?: string, userId?: number) {
|
||||
const userSelect = Prisma.validator<Prisma.UserSelect>()({
|
||||
username: true,
|
||||
|
@ -52,6 +53,13 @@ export async function getTeamWithMembers(id?: number, slug?: string, userId?: nu
|
|||
...baseEventTypeSelect,
|
||||
},
|
||||
},
|
||||
inviteToken: {
|
||||
select: {
|
||||
token: true,
|
||||
expires: true,
|
||||
expiresInDays: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const where: Prisma.TeamFindFirstArgs["where"] = {};
|
||||
|
@ -82,6 +90,7 @@ export async function getTeamWithMembers(id?: number, slug?: string, userId?: nu
|
|||
}));
|
||||
return { ...team, eventTypes, members };
|
||||
}
|
||||
|
||||
// also returns team
|
||||
export async function isTeamAdmin(userId: number, teamId: number) {
|
||||
return (
|
||||
|
@ -95,6 +104,7 @@ export async function isTeamAdmin(userId: number, teamId: number) {
|
|||
})) || false
|
||||
);
|
||||
}
|
||||
|
||||
export async function isTeamOwner(userId: number, teamId: number) {
|
||||
return !!(await prisma.membership.findFirst({
|
||||
where: {
|
||||
|
|
|
@ -0,0 +1,15 @@
|
|||
/*
|
||||
Warnings:
|
||||
|
||||
- A unique constraint covering the columns `[teamId]` on the table `VerificationToken` will be added. If there are existing duplicate values, this will fail.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "VerificationToken" ADD COLUMN "expiresInDays" INTEGER,
|
||||
ADD COLUMN "teamId" INTEGER;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "VerificationToken_teamId_key" ON "VerificationToken"("teamId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "VerificationToken" ADD CONSTRAINT "VerificationToken_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
|
@ -234,27 +234,28 @@ model User {
|
|||
}
|
||||
|
||||
model Team {
|
||||
id Int @id @default(autoincrement())
|
||||
id Int @id @default(autoincrement())
|
||||
/// @zod.min(1)
|
||||
name String
|
||||
/// @zod.min(1)
|
||||
slug String? @unique
|
||||
slug String? @unique
|
||||
logo String?
|
||||
appLogo String?
|
||||
appIconLogo String?
|
||||
bio String?
|
||||
hideBranding Boolean @default(false)
|
||||
hideBookATeamMember Boolean @default(false)
|
||||
hideBranding Boolean @default(false)
|
||||
hideBookATeamMember Boolean @default(false)
|
||||
members Membership[]
|
||||
eventTypes EventType[]
|
||||
workflows Workflow[]
|
||||
createdAt DateTime @default(now())
|
||||
createdAt DateTime @default(now())
|
||||
/// @zod.custom(imports.teamMetadataSchema)
|
||||
metadata Json?
|
||||
theme String?
|
||||
brandColor String @default("#292929")
|
||||
darkBrandColor String @default("#fafafa")
|
||||
brandColor String @default("#292929")
|
||||
darkBrandColor String @default("#fafafa")
|
||||
verifiedNumbers VerifiedNumber[]
|
||||
inviteToken VerificationToken?
|
||||
webhooks Webhook[]
|
||||
}
|
||||
|
||||
|
@ -279,12 +280,15 @@ model Membership {
|
|||
}
|
||||
|
||||
model VerificationToken {
|
||||
id Int @id @default(autoincrement())
|
||||
identifier String
|
||||
token String @unique
|
||||
expires DateTime
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
id Int @id @default(autoincrement())
|
||||
identifier String
|
||||
token String @unique
|
||||
expires DateTime
|
||||
expiresInDays Int?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
teamId Int? @unique
|
||||
team Team? @relation(fields: [teamId], references: [id])
|
||||
|
||||
@@unique([identifier, token])
|
||||
@@index([token])
|
||||
|
|
|
@ -3,14 +3,18 @@ import { router } from "../../../trpc";
|
|||
import { ZAcceptOrLeaveInputSchema } from "./acceptOrLeave.schema";
|
||||
import { ZChangeMemberRoleInputSchema } from "./changeMemberRole.schema";
|
||||
import { ZCreateInputSchema } from "./create.schema";
|
||||
import { ZCreateInviteInputSchema } from "./createInvite.schema";
|
||||
import { ZDeleteInputSchema } from "./delete.schema";
|
||||
import { ZDeleteInviteInputSchema } from "./deleteInvite.schema";
|
||||
import { ZGetInputSchema } from "./get.schema";
|
||||
import { ZGetMemberAvailabilityInputSchema } from "./getMemberAvailability.schema";
|
||||
import { ZGetMembershipbyUserInputSchema } from "./getMembershipbyUser.schema";
|
||||
import { ZInviteMemberInputSchema } from "./inviteMember.schema";
|
||||
import { ZInviteMemberByTokenSchemaInputSchema } from "./inviteMemberByToken.schema";
|
||||
import { ZListMembersInputSchema } from "./listMembers.schema";
|
||||
import { ZPublishInputSchema } from "./publish.schema";
|
||||
import { ZRemoveMemberInputSchema } from "./removeMember.schema";
|
||||
import { ZSetInviteExpirationInputSchema } from "./setInviteExpiration.schema";
|
||||
import { ZUpdateInputSchema } from "./update.schema";
|
||||
import { ZUpdateMembershipInputSchema } from "./updateMembership.schema";
|
||||
|
||||
|
@ -32,6 +36,10 @@ type TeamsRouterHandlerCache = {
|
|||
listMembers?: typeof import("./listMembers.handler").listMembersHandler;
|
||||
hasTeamPlan?: typeof import("./hasTeamPlan.handler").hasTeamPlanHandler;
|
||||
listInvites?: typeof import("./listInvites.handler").listInvitesHandler;
|
||||
createInvite?: typeof import("./createInvite.handler").createInviteHandler;
|
||||
setInviteExpiration?: typeof import("./setInviteExpiration.handler").setInviteExpirationHandler;
|
||||
deleteInvite?: typeof import("./deleteInvite.handler").deleteInviteHandler;
|
||||
inviteMemberByToken?: typeof import("./inviteMemberByToken.handler").inviteMemberByTokenHandler;
|
||||
};
|
||||
|
||||
const UNSTABLE_HANDLER_CACHE: TeamsRouterHandlerCache = {};
|
||||
|
@ -334,4 +342,76 @@ export const viewerTeamsRouter = router({
|
|||
ctx,
|
||||
});
|
||||
}),
|
||||
createInvite: authedProcedure.input(ZCreateInviteInputSchema).mutation(async ({ ctx, input }) => {
|
||||
if (!UNSTABLE_HANDLER_CACHE.createInvite) {
|
||||
UNSTABLE_HANDLER_CACHE.createInvite = await import("./createInvite.handler").then(
|
||||
(mod) => mod.createInviteHandler
|
||||
);
|
||||
}
|
||||
|
||||
// Unreachable code but required for type safety
|
||||
if (!UNSTABLE_HANDLER_CACHE.createInvite) {
|
||||
throw new Error("Failed to load handler");
|
||||
}
|
||||
|
||||
return UNSTABLE_HANDLER_CACHE.createInvite({
|
||||
ctx,
|
||||
input,
|
||||
});
|
||||
}),
|
||||
setInviteExpiration: authedProcedure
|
||||
.input(ZSetInviteExpirationInputSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
if (!UNSTABLE_HANDLER_CACHE.setInviteExpiration) {
|
||||
UNSTABLE_HANDLER_CACHE.setInviteExpiration = await import("./setInviteExpiration.handler").then(
|
||||
(mod) => mod.setInviteExpirationHandler
|
||||
);
|
||||
}
|
||||
|
||||
// Unreachable code but required for type safety
|
||||
if (!UNSTABLE_HANDLER_CACHE.setInviteExpiration) {
|
||||
throw new Error("Failed to load handler");
|
||||
}
|
||||
|
||||
return UNSTABLE_HANDLER_CACHE.setInviteExpiration({
|
||||
ctx,
|
||||
input,
|
||||
});
|
||||
}),
|
||||
deleteInvite: authedProcedure.input(ZDeleteInviteInputSchema).mutation(async ({ ctx, input }) => {
|
||||
if (!UNSTABLE_HANDLER_CACHE.deleteInvite) {
|
||||
UNSTABLE_HANDLER_CACHE.deleteInvite = await import("./deleteInvite.handler").then(
|
||||
(mod) => mod.deleteInviteHandler
|
||||
);
|
||||
}
|
||||
|
||||
// Unreachable code but required for type safety
|
||||
if (!UNSTABLE_HANDLER_CACHE.deleteInvite) {
|
||||
throw new Error("Failed to load handler");
|
||||
}
|
||||
|
||||
return UNSTABLE_HANDLER_CACHE.deleteInvite({
|
||||
ctx,
|
||||
input,
|
||||
});
|
||||
}),
|
||||
inviteMemberByToken: authedProcedure
|
||||
.input(ZInviteMemberByTokenSchemaInputSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
if (!UNSTABLE_HANDLER_CACHE.inviteMemberByToken) {
|
||||
UNSTABLE_HANDLER_CACHE.inviteMemberByToken = await import("./inviteMemberByToken.handler").then(
|
||||
(mod) => mod.inviteMemberByTokenHandler
|
||||
);
|
||||
}
|
||||
|
||||
// Unreachable code but required for type safety
|
||||
if (!UNSTABLE_HANDLER_CACHE.inviteMemberByToken) {
|
||||
throw new Error("Failed to load handler");
|
||||
}
|
||||
|
||||
return UNSTABLE_HANDLER_CACHE.inviteMemberByToken({
|
||||
ctx,
|
||||
input,
|
||||
});
|
||||
}),
|
||||
});
|
||||
|
|
|
@ -0,0 +1,32 @@
|
|||
import { randomBytes } from "crypto";
|
||||
|
||||
import { isTeamAdmin } from "@calcom/lib/server/queries/teams";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { TRPCError } from "@calcom/trpc/server";
|
||||
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
|
||||
|
||||
import type { TCreateInviteInputSchema } from "./createInvite.schema";
|
||||
|
||||
type CreateInviteOptions = {
|
||||
ctx: {
|
||||
user: NonNullable<TrpcSessionUser>;
|
||||
};
|
||||
input: TCreateInviteInputSchema;
|
||||
};
|
||||
|
||||
export const createInviteHandler = async ({ ctx, input }: CreateInviteOptions) => {
|
||||
const { teamId } = input;
|
||||
|
||||
if (!(await isTeamAdmin(ctx.user.id, teamId))) throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
|
||||
const token = randomBytes(32).toString("hex");
|
||||
await prisma.verificationToken.create({
|
||||
data: {
|
||||
identifier: "",
|
||||
token,
|
||||
expires: new Date(),
|
||||
teamId,
|
||||
},
|
||||
});
|
||||
return token;
|
||||
};
|
|
@ -0,0 +1,7 @@
|
|||
import { z } from "zod";
|
||||
|
||||
export const ZCreateInviteInputSchema = z.object({
|
||||
teamId: z.number(),
|
||||
});
|
||||
|
||||
export type TCreateInviteInputSchema = z.infer<typeof ZCreateInviteInputSchema>;
|
|
@ -0,0 +1,33 @@
|
|||
import { isTeamAdmin } from "@calcom/lib/server/queries/teams";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { TRPCError } from "@calcom/trpc/server";
|
||||
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
|
||||
|
||||
import type { TDeleteInviteInputSchema } from "./deleteInvite.schema";
|
||||
|
||||
type DeleteInviteOptions = {
|
||||
ctx: {
|
||||
user: NonNullable<TrpcSessionUser>;
|
||||
};
|
||||
input: TDeleteInviteInputSchema;
|
||||
};
|
||||
|
||||
export const deleteInviteHandler = async ({ ctx, input }: DeleteInviteOptions) => {
|
||||
const { token } = input;
|
||||
|
||||
const verificationToken = await prisma.verificationToken.findFirst({
|
||||
where: {
|
||||
token: token,
|
||||
},
|
||||
select: {
|
||||
teamId: true,
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!verificationToken) throw new TRPCError({ code: "NOT_FOUND" });
|
||||
if (!verificationToken.teamId || !(await isTeamAdmin(ctx.user.id, verificationToken.teamId)))
|
||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
|
||||
await prisma.verificationToken.delete({ where: { id: verificationToken.id } });
|
||||
};
|
|
@ -0,0 +1,7 @@
|
|||
import { z } from "zod";
|
||||
|
||||
export const ZDeleteInviteInputSchema = z.object({
|
||||
token: z.string(),
|
||||
});
|
||||
|
||||
export type TDeleteInviteInputSchema = z.infer<typeof ZDeleteInviteInputSchema>;
|
|
@ -19,8 +19,7 @@ export const getUpgradeableHandler = async ({ ctx }: GetUpgradeableOptions) => {
|
|||
/** We only need to return teams that don't have a `subscriptionId` on their metadata */
|
||||
teams = teams.filter((m) => {
|
||||
const metadata = teamMetadataSchema.safeParse(m.team.metadata);
|
||||
if (metadata.success && metadata.data?.subscriptionId) return false;
|
||||
return true;
|
||||
return !(metadata.success && metadata.data?.subscriptionId);
|
||||
});
|
||||
return teams;
|
||||
};
|
||||
|
|
|
@ -0,0 +1,66 @@
|
|||
import { Prisma } from "@prisma/client";
|
||||
|
||||
import { updateQuantitySubscriptionFromStripe } from "@calcom/ee/teams/lib/payments";
|
||||
import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
import { TRPCError } from "@calcom/trpc/server";
|
||||
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
|
||||
|
||||
import type { TInviteMemberByTokenSchemaInputSchema } from "./inviteMemberByToken.schema";
|
||||
|
||||
type InviteMemberByTokenOptions = {
|
||||
ctx: {
|
||||
user: NonNullable<TrpcSessionUser>;
|
||||
};
|
||||
input: TInviteMemberByTokenSchemaInputSchema;
|
||||
};
|
||||
|
||||
export const inviteMemberByTokenHandler = async ({ ctx, input }: InviteMemberByTokenOptions) => {
|
||||
const { token } = input;
|
||||
|
||||
const verificationToken = await prisma.verificationToken.findFirst({
|
||||
where: {
|
||||
token,
|
||||
OR: [{ expiresInDays: null }, { expires: { gte: new Date() } }],
|
||||
},
|
||||
include: {
|
||||
team: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!verificationToken) throw new TRPCError({ code: "NOT_FOUND", message: "Invite not found" });
|
||||
if (!verificationToken.teamId || !verificationToken.team)
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Invite token is not associated with any team",
|
||||
});
|
||||
|
||||
try {
|
||||
await prisma.membership.create({
|
||||
data: {
|
||||
teamId: verificationToken.teamId,
|
||||
userId: ctx.user.id,
|
||||
role: MembershipRole.MEMBER,
|
||||
accepted: false,
|
||||
},
|
||||
});
|
||||
} 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;
|
||||
}
|
||||
|
||||
if (IS_TEAM_BILLING_ENABLED) await updateQuantitySubscriptionFromStripe(verificationToken.teamId);
|
||||
|
||||
return verificationToken.team.name;
|
||||
};
|
|
@ -0,0 +1,7 @@
|
|||
import { z } from "zod";
|
||||
|
||||
export const ZInviteMemberByTokenSchemaInputSchema = z.object({
|
||||
token: z.string(),
|
||||
});
|
||||
|
||||
export type TInviteMemberByTokenSchemaInputSchema = z.infer<typeof ZInviteMemberByTokenSchemaInputSchema>;
|
|
@ -14,7 +14,11 @@ export const listHandler = async ({ ctx }: ListOptions) => {
|
|||
userId: ctx.user.id,
|
||||
},
|
||||
include: {
|
||||
team: true,
|
||||
team: {
|
||||
include: {
|
||||
inviteToken: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { role: "desc" },
|
||||
});
|
||||
|
|
|
@ -43,7 +43,7 @@ export const listMembersHandler = async ({ ctx, input }: ListMembersOptions) =>
|
|||
});
|
||||
|
||||
type UserMap = Record<number, (typeof teams)[number]["members"][number]["user"]>;
|
||||
// flattern users to be unique by id
|
||||
// flatten users to be unique by id
|
||||
const users = teams
|
||||
.flatMap((t) => t.members)
|
||||
.reduce((acc, m) => (m.user.id in acc ? acc : { ...acc, [m.user.id]: m.user }), {} as UserMap);
|
||||
|
|
|
@ -0,0 +1,41 @@
|
|||
import { isTeamAdmin } from "@calcom/lib/server/queries/teams";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { TRPCError } from "@calcom/trpc/server";
|
||||
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
|
||||
|
||||
import type { TSetInviteExpirationInputSchema } from "./setInviteExpiration.schema";
|
||||
|
||||
type SetInviteExpirationOptions = {
|
||||
ctx: {
|
||||
user: NonNullable<TrpcSessionUser>;
|
||||
};
|
||||
input: TSetInviteExpirationInputSchema;
|
||||
};
|
||||
|
||||
export const setInviteExpirationHandler = async ({ ctx, input }: SetInviteExpirationOptions) => {
|
||||
const { token, expiresInDays } = input;
|
||||
|
||||
const verificationToken = await prisma.verificationToken.findFirst({
|
||||
where: {
|
||||
token: token,
|
||||
},
|
||||
select: {
|
||||
teamId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!verificationToken) throw new TRPCError({ code: "NOT_FOUND" });
|
||||
if (!verificationToken.teamId || !(await isTeamAdmin(ctx.user.id, verificationToken.teamId)))
|
||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
|
||||
const oneDay = 24 * 60 * 60 * 1000;
|
||||
const expires = expiresInDays ? new Date(Date.now() + expiresInDays * oneDay) : new Date();
|
||||
|
||||
await prisma.verificationToken.update({
|
||||
where: { token },
|
||||
data: {
|
||||
expires,
|
||||
expiresInDays,
|
||||
},
|
||||
});
|
||||
};
|
|
@ -0,0 +1,8 @@
|
|||
import { z } from "zod";
|
||||
|
||||
export const ZSetInviteExpirationInputSchema = z.object({
|
||||
token: z.string(),
|
||||
expiresInDays: z.number().optional(),
|
||||
});
|
||||
|
||||
export type TSetInviteExpirationInputSchema = z.infer<typeof ZSetInviteExpirationInputSchema>;
|
Loading…
Reference in New Issue
Block a user