feat: org invite billing (#9291)

* Initial commit

* Adding feature flag

* Desktop first banner, mobile pending

* Removing dead code and img

* AppInstallButtonBase

* WIP

* Adds Email verification template+translations for organizations (#9202)

* feat: Orgs Schema Changing `scopedMembers` to `orgUsers` (#9209)

* Change scopedMembers to orgMembers

* Change to orgUsers

* First step done

* Merge branch 'feat/organizations-onboarding' of github.com:calcom/cal.com into feat/organizations-onboarding

* Session logic to show org label

* Step 2 done, avatar not working

* List orgs and list teams specific if orgs exist

* Conditionally show org - fix settings layout - add labels for all pages

* Profile Page + update

* Org specific team creation

* appearance page

* Ensure members cant of org cant update settings in UI

* Fix update handler imports

* hide billing on sub teams

* Update profile slug page

* Letting duplicate slugs for teams to support orgs

* Add slug coliisions for org

* Covering null on unique clauses

* Covering null on unique clauses

* Extract to utils

* Update settings to use subdomain path in team url , team + org

* Supporting having the orgId in the session cookie

* Onboarding admins step

* Last step to create teams

* Update handler comments

* Upgrade ORG banner - disabled team banner for child teams

* Handle publishing ORGS

* Fix licenese issue

* Update packages/trpc/server/routers/viewer/teams/create.handler.ts

* Split into function calls to make this file more explisit

* Update parents stripe sub not teamID

* Moving change password handler, improving verifying code flow

* Clearing error before submitting

* Reverting email testing api changes

* Reverting having the banner for now

* Consistent exported components

* Remove unneeded files from banner

* Removing uneeded code

* Fixing avatar selector

* Using meta component for head/descr

* Missing i18n strings

* Create org membership also - billing portal page

* A11ly

* Hide create team if no valid permisisons

* Get Org members router

* Handle updating subscription if orgId

* Fix double upgrade banner

* Update constants

* Feedback

* Copy change

* Making an org avatar (temp)

* Add slug colission detection for user and team name

* Fix Import

* Remove update password func

* Fix module import over relative

* feat: organization event type filter (#9253)

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

* Missing changes to support orgs schema changes

* Fix import again

* Throw no team found before auth error

* Check if invited found user is already in differnt org

* Move to for of loop to throw errors in usenamelist

* Remove app install button sa its in 9337

* Remove i18n key not being used

* feat: Onboarding process to create an organization (#9184)

* Desktop first banner, mobile pending

* Removing dead code and img

* WIP

* Adds Email verification template+translations for organizations (#9202)

* First step done

* Merge branch 'feat/organizations-onboarding' of github.com:calcom/cal.com into feat/organizations-onboarding

* Step 2 done, avatar not working

* Covering null on unique clauses

* Onboarding admins step

* Last step to create teams

* Moving change password handler, improving verifying code flow

* Clearing error before submitting

* Reverting email testing api changes

* Reverting having the banner for now

* Consistent exported components

* Remove unneeded files from banner

* Removing uneeded code

* Fixing avatar selector

* Using meta component for head/descr

* Missing i18n strings

* Feedback

* Making an org avatar (temp)

* Check for subteams slug clashes with usernames

* Fixing create teams onsuccess

* feedback

* Making sure we check requestedSlug now

---------

Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>

* feat: [CAL-1816] Organization subdomain support (#9345)

* Desktop first banner, mobile pending

* Removing dead code and img

* WIP

* Adds Email verification template+translations for organizations (#9202)

* First step done

* Merge branch 'feat/organizations-onboarding' of github.com:calcom/cal.com into feat/organizations-onboarding

* Step 2 done, avatar not working

* Covering null on unique clauses

* Onboarding admins step

* Last step to create teams

* Moving change password handler, improving verifying code flow

* Clearing error before submitting

* Reverting email testing api changes

* Reverting having the banner for now

* Consistent exported components

* Remove unneeded files from banner

* Removing uneeded code

* Fixing avatar selector

* Using meta component for head/descr

* Missing i18n strings

* Feedback

* Making an org avatar (temp)

* Check for subteams slug clashes with usernames

* Fixing create teams onsuccess

* Covering users and subteams, excluding non-org users

* Unpublished teams shows correctly

* Create subdomain in Vercel

* feedback

* Renaming Vercel env vars

* Vercel domain check before creation

* Supporting cal-staging.com

* Change to have vercel detect it

* vercel domain check data message error

* Remove check domain

* Making sure we check requestedSlug now

* Feedback and unneeded code

* Reverting unneeded changes

* Unneeded changes

---------

Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>

* Vercel subdomain creation in PROD only

* Fix router

* feat: organization settings general and members page (#9266)

* feat: organization settings general page

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

* feat: add members page

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

* chore: remove

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

* fix: use invalidate

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

* fix: delete mutation

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

* fix: remove organization id

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

* chore

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

* fix: use zod schema

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

---------

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>

* Type fixes

* Use org Stripe product when upgrading

* Removed unused code

* Reverting changes

* Update UsernameTextfield.tsx

* More reverts

* Update next-auth-options.ts

* Update common.json

* Type fixes

* Include invite token for orgs

* Update org schema

* Make token settings optional as it isnt used in orgs yet

* Reverts

* remove yarn.lock from commit

* Fix types

* feat: orgs unverified (#9415)

Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
Co-authored-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>

* Fix wrong banner being displayed

* Type fix

* Fix type issues

* Update packages/trpc/server/routers/viewer/teams/inviteMember.handler.ts

Co-authored-by: alannnc <alannnc@gmail.com>

* fix missing input on trpc query

* Fix for parentId value for createProvisionalMembership

---------

Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>
Co-authored-by: Leo Giovanetti <hello@leog.me>
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: zomars <zomars@me.com>
Co-authored-by: Joe Au-Yeung <j.auyeung419@gmail.com>
Co-authored-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: alannnc <alannnc@gmail.com>
This commit is contained in:
sean-brydon 2023-07-02 19:40:12 +01:00 committed by GitHub
parent 05655a92ca
commit 583571247c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
33 changed files with 1154 additions and 186 deletions

View File

@ -135,6 +135,7 @@ NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE=
NEXT_PUBLIC_IS_PREMIUM_NEW_PLAN=0
NEXT_PUBLIC_STRIPE_PREMIUM_NEW_PLAN_PRICE=
STRIPE_TEAM_MONTHLY_PRICE_ID=
STRIPE_ORG_MONTHLY_PRICE_ID=
STRIPE_WEBHOOK_SECRET=
STRIPE_PRIVATE_KEY=
STRIPE_CLIENT_ID=

View File

@ -1,18 +1,21 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { z } from "zod";
import dayjs from "@calcom/dayjs";
import { hashPassword } from "@calcom/features/auth/lib/hashPassword";
import { sendEmailVerification } from "@calcom/features/auth/lib/verifyEmail";
import slugify from "@calcom/lib/slugify";
import { closeComUpsertTeamUser } from "@calcom/lib/sync/SyncServiceManager";
import prisma from "@calcom/prisma";
import { IdentityProvider } from "@calcom/prisma/enums";
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
const signupSchema = z.object({
username: z.string(),
email: z.string().email(),
password: z.string().min(7),
language: z.string().optional(),
token: z.string().optional(),
});
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
@ -26,7 +29,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
}
const data = req.body;
const { email, password, language } = signupSchema.parse(data);
const { email, password, language, token } = signupSchema.parse(data);
const username = slugify(data.username);
const userEmail = email.toLowerCase();
@ -85,25 +88,100 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
},
});
// If user has been invitedTo a team, we accept the membership
if (user.invitedTo) {
const team = await prisma.team.findFirst({
where: { id: user.invitedTo },
if (token) {
const foundToken = await prisma.verificationToken.findFirst({
where: {
token,
},
});
if (team) {
const membership = await prisma.membership.update({
if (!foundToken) {
return res.status(401).json({ message: "Invalid Token" });
}
if (dayjs(foundToken?.expires).isBefore(dayjs())) {
return res.status(401).json({ message: "Token expired" });
}
if (foundToken.teamId) {
const team = await prisma.team.findUnique({
where: {
userId_teamId: { userId: user.id, teamId: user.invitedTo },
},
data: {
accepted: true,
id: foundToken.teamId,
},
});
// Sync Services: Close.com
closeComUpsertTeamUser(team, user, membership.role);
if (team) {
const teamMetadata = teamMetadataSchema.parse(team?.metadata);
if (teamMetadata?.isOrganization) {
await prisma.user.update({
where: {
id: user.id,
},
data: {
organizationId: team.id,
},
});
}
const membership = await prisma.membership.update({
where: {
userId_teamId: { userId: user.id, teamId: team.id },
},
data: {
accepted: true,
},
});
closeComUpsertTeamUser(team, user, membership.role);
// Accept any child team invites for orgs.
if (team.parentId) {
// Join ORG
await prisma.user.update({
where: {
id: user.id,
},
data: {
organizationId: team.parentId,
},
});
/** We do a membership update twice so we can join the ORG invite if the user is invited to a team witin a ORG. */
await prisma.membership.updateMany({
where: {
userId: user.id,
team: {
id: team.parentId,
},
accepted: false,
},
data: {
accepted: true,
},
});
// Join any other invites
await prisma.membership.updateMany({
where: {
userId: user.id,
team: {
parentId: team.parentId,
},
accepted: false,
},
data: {
accepted: true,
},
});
}
}
}
// Cleanup token after use
await prisma.verificationToken.delete({
where: {
id: foundToken.id,
},
});
}
await sendEmailVerification({

View File

@ -0,0 +1,9 @@
import UnverifiedOrgsPage from "@calcom/features/ee/organizations/pages/settings/admin/unverifiedOrgPage";
import type { CalPageWrapper } from "@components/PageWrapper";
import PageWrapper from "@components/PageWrapper";
const Page = UnverifiedOrgsPage as CalPageWrapper;
Page.PageWrapper = PageWrapper;
export default Page;

View File

@ -0,0 +1,9 @@
import TeamBillingView from "@calcom/features/ee/teams/pages/team-billing-view";
import type { CalPageWrapper } from "@components/PageWrapper";
import PageWrapper from "@components/PageWrapper";
const Page = TeamBillingView as CalPageWrapper;
Page.PageWrapper = PageWrapper;
export default Page;

View File

@ -27,6 +27,7 @@ type FormValues = {
email: string;
password: string;
apiError: string;
token?: string;
};
export default function Signup({ prepopulateFormValues, token }: inferSSRProps<typeof getServerSideProps>) {
@ -54,6 +55,7 @@ export default function Signup({ prepopulateFormValues, token }: inferSSRProps<t
body: JSON.stringify({
...data,
language: i18n.language,
token,
}),
headers: {
"Content-Type": "application/json",

View File

@ -1894,6 +1894,8 @@
"verify_email_organization": "Verify your email to create an organization",
"code_provided_invalid": "The code provided is not valid, try again",
"email_already_used": "Email already being used",
"organization_admin_invited_heading":"You've been invited to join {{orgName}}",
"organization_admin_invited_body":"Join your team at {{orgName}} and start focusing on meeting, not making meetings!",
"duplicated_slugs_warning": "The following teams couldn't be created due to duplicated slugs: {{slugs}}",
"team_names_empty": "Team names can't be empty",
"team_names_repeated": "Team names can't be repeated",

View File

@ -11,7 +11,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
return res.status(405).json({ message: "Method not allowed" });
const { referer } = req.headers;
if (!referer) return res.status(400).json({ message: "Missing referrer" });
// if (!referer) return res.status(400).json({ message: "Missing referrer" });
if (!req.session?.user?.id) return res.status(401).json({ message: "Not authenticated" });

View File

@ -25,6 +25,8 @@ import FeedbackEmail from "./templates/feedback-email";
import type { PasswordReset } from "./templates/forgot-password-email";
import ForgotPasswordEmail from "./templates/forgot-password-email";
import NoShowFeeChargedEmail from "./templates/no-show-fee-charged-email";
import type { OrgAutoInvite } from "./templates/org-auto-join-invite";
import OrgAutoJoinEmail from "./templates/org-auto-join-invite";
import type { OrganizationEmailVerify } from "./templates/organization-email-verification";
import OrganizationEmailVerification from "./templates/organization-email-verification";
import OrganizerAttendeeCancelledSeatEmail from "./templates/organizer-attendee-cancelled-seat-email";
@ -264,6 +266,10 @@ export const sendTeamInviteEmail = async (teamInviteEvent: TeamInvite) => {
await sendEmail(() => new TeamInviteEmail(teamInviteEvent));
};
export const sendOrganizationAutoJoinEmail = async (orgInviteEvent: OrgAutoInvite) => {
await sendEmail(() => new OrgAutoJoinEmail(orgInviteEvent));
};
export const sendEmailVerificationLink = async (verificationInput: EmailVerifyLink) => {
await sendEmail(() => new AccountVerifyEmail(verificationInput));
};

View File

@ -0,0 +1,103 @@
import type { TFunction } from "next-i18next";
import { APP_NAME, WEBAPP_URL, IS_PRODUCTION } from "@calcom/lib/constants";
import { V2BaseEmailHtml, CallToAction } from "../components";
type TeamInvite = {
language: TFunction;
from: string;
to: string;
orgName: string;
joinLink: string;
};
export const OrgAutoInviteEmail = (
props: TeamInvite & Partial<React.ComponentProps<typeof V2BaseEmailHtml>>
) => {
return (
<V2BaseEmailHtml
subject={props.language("user_invited_you", {
user: props.from,
team: props.orgName,
appName: APP_NAME,
entity: "organization",
})}>
<p style={{ fontSize: "24px", marginBottom: "16px", textAlign: "center" }}>
<>
{props.language("organization_admin_invited_heading", {
orgName: props.orgName,
})}
</>
</p>
<img
style={{
borderRadius: "16px",
height: "270px",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
src={
IS_PRODUCTION
? WEBAPP_URL + "/emails/calendar-email-hero.png"
: "http://localhost:3000/emails/calendar-email-hero.png"
}
alt=""
/>
<p
style={{
fontWeight: 400,
lineHeight: "24px",
marginBottom: "32px",
marginTop: "32px",
lineHeightStep: "24px",
}}>
<>
{props.language("organization_admin_invited_body", {
orgName: props.orgName,
})}
</>
</p>
<div style={{ display: "flex", justifyContent: "center" }}>
<CallToAction
label={props.language("email_user_cta", {
entity: "organization",
})}
href={props.joinLink}
endIconName="linkIcon"
/>
</div>
<div className="">
<p
style={{
fontWeight: 400,
lineHeight: "24px",
marginBottom: "32px",
marginTop: "32px",
lineHeightStep: "24px",
}}>
<>
{props.language("email_no_user_signoff", {
appName: APP_NAME,
entity: props.language("organization").toLowerCase(),
})}
</>
</p>
</div>
<div style={{ borderTop: "1px solid #E1E1E1", marginTop: "32px", paddingTop: "32px" }}>
<p style={{ fontWeight: 400, margin: 0 }}>
<>
{props.language("have_any_questions")}{" "}
<a href="mailto:support@cal.com" style={{ color: "#3E3E3E" }} target="_blank" rel="noreferrer">
<>{props.language("contact")}</>
</a>{" "}
{props.language("our_support_team")}
</>
</p>
</div>
</V2BaseEmailHtml>
);
};

View File

@ -27,3 +27,4 @@ export { VerifyAccountEmail } from "./VerifyAccountEmail";
export * from "@calcom/app-store/routing-forms/emails/components";
export { AttendeeDailyVideoDownloadRecordingEmail } from "./AttendeeDailyVideoDownloadRecordingEmail";
export { OrganisationAccountVerifyEmail } from "./OrganizationAccountVerifyEmail";
export { OrgAutoInviteEmail } from "./OrgAutoInviteEmail";

View File

@ -0,0 +1,39 @@
import type { TFunction } from "next-i18next";
import { APP_NAME } from "@calcom/lib/constants";
import { renderEmail } from "..";
import BaseEmail from "./_base-email";
export type OrgAutoInvite = {
language: TFunction;
from: string;
to: string;
orgName: string;
joinLink: string;
};
export default class OrgAutoJoinEmail extends BaseEmail {
orgAutoInviteEvent: OrgAutoInvite;
constructor(orgAutoInviteEvent: OrgAutoInvite) {
super();
this.name = "SEND_TEAM_INVITE_EMAIL";
this.orgAutoInviteEvent = orgAutoInviteEvent;
}
protected getNodeMailerPayload(): Record<string, unknown> {
return {
to: this.orgAutoInviteEvent.to,
from: `${APP_NAME} <${this.getMailerOptions().from}>`,
subject: this.orgAutoInviteEvent.language("user_invited_you", {
user: this.orgAutoInviteEvent.from,
team: this.orgAutoInviteEvent.orgName,
appName: APP_NAME,
entity: this.orgAutoInviteEvent.language("organization").toLowerCase(),
}),
html: renderEmail("OrgAutoInviteEmail", this.orgAutoInviteEvent),
text: "",
};
}
}

View File

@ -129,7 +129,7 @@ export default function ApiKeyDialogForm({
}
}}
className="space-y-4">
<div className="mt-1 mb-4">
<div className="mb-4 mt-1">
<h2 className="font-semi-bold font-cal text-emphasis text-xl tracking-wide">
{defaultValues ? t("edit_api_key") : t("create_api_key")}
</h2>

View File

@ -8,7 +8,7 @@ export function OrgUpgradeBanner() {
const { t } = useLocale();
const router = useRouter();
const { data } = trpc.viewer.organizations.checkIfOrgNeedsUpgrade.useQuery();
const publishTeamMutation = trpc.viewer.organizations.publish.useMutation({
const publishOrgMutation = trpc.viewer.organizations.publish.useMutation({
onSuccess(data) {
router.push(data.url);
},
@ -29,7 +29,7 @@ export function OrgUpgradeBanner() {
<button
className="border-b border-b-black"
onClick={() => {
publishTeamMutation.mutate();
publishOrgMutation.mutate();
}}>
{t("upgrade_banner_action")}
</button>

View File

@ -0,0 +1,102 @@
import NoSSR from "@calcom/core/components/NoSSR";
import LicenseRequired from "@calcom/ee/common/components/LicenseRequired";
import { extractDomainFromWebsiteUrl } from "@calcom/ee/organizations/lib/utils";
import { trpc } from "@calcom/trpc/react";
import { Meta } from "@calcom/ui";
import { DropdownActions, showToast, Table } from "@calcom/ui";
import { Check, X } from "@calcom/ui/components/icon";
import { getLayout } from "../../../../../settings/layouts/SettingsLayout";
const { Body, Cell, ColumnTitle, Header, Row } = Table;
function UnverifiedOrgTable() {
const utils = trpc.useContext();
const [data] = trpc.viewer.organizations.adminGetUnverified.useSuspenseQuery();
const mutation = trpc.viewer.organizations.adminVerify.useMutation({
onSuccess: async () => {
showToast("Org has been processed", "success");
await utils.viewer.organizations.adminGetUnverified.invalidate();
},
onError: (err) => {
console.error(err.message);
showToast("There has been an error processing this org.", "error");
},
});
return (
<div>
<Table>
<Header>
<ColumnTitle widthClassNames="w-auto">Organization</ColumnTitle>
<ColumnTitle widthClassNames="w-auto">Owner</ColumnTitle>
<ColumnTitle widthClassNames="w-auto">
<span className="sr-only">Edit</span>
</ColumnTitle>
</Header>
<Body>
{data.map((org) => (
<Row key={org.id}>
<Cell widthClassNames="w-auto">
<div className="text-subtle font-medium">
<span className="text-default">{org.name}</span>
<br />
<span className="text-muted">
{org.slug}.{extractDomainFromWebsiteUrl}
</span>
</div>
</Cell>
<Cell widthClassNames="w-auto">
<span className="break-all">{org.members[0].user.email}</span>
</Cell>
<Cell widthClassNames="w-auto">
<div className="flex w-full justify-end">
<DropdownActions
actions={[
{
id: "accept",
label: "Accept",
onClick: () => {
mutation.mutate({
orgId: org.id,
status: "ACCEPT",
});
},
icon: Check,
},
{
id: "reject",
label: "Reject",
icon: X,
},
]}
/>
</div>
</Cell>
</Row>
))}
</Body>
</Table>
</div>
);
}
const UnverifiedOrgList = () => {
return (
<LicenseRequired>
<Meta
title="Organizations"
description="A list of all organizations that need verification based on their email domain. Accepting an organization will allow all users with that email domain to sign up WITHOUT email verifciation."
/>
<NoSSR>
<UnverifiedOrgTable />
</NoSSR>
</LicenseRequired>
);
};
UnverifiedOrgList.getLayout = getLayout;
export default UnverifiedOrgList;

View File

@ -91,7 +91,7 @@ const CreateConnectionDialog = ({
<h2 className="font-semi-bold font-cal text-emphasis text-xl tracking-wide">
{t("sso_oidc_configuration_title")}
</h2>
<p className="text-subtle mt-1 mb-4 text-sm">{t("sso_oidc_configuration_description")}</p>
<p className="text-subtle mb-4 mt-1 text-sm">{t("sso_oidc_configuration_description")}</p>
<div className="space-y-5">
<Controller
control={form.control}

View File

@ -1,4 +1,4 @@
import { PaperclipIcon, UserIcon, Users } from "lucide-react";
import { BuildingIcon, PaperclipIcon, UserIcon, Users } from "lucide-react";
import { Trans } from "next-i18next";
import { useMemo, useState } from "react";
import type { FormEvent } from "react";
@ -8,6 +8,7 @@ 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 type { RouterOutputs } from "@calcom/trpc";
import { trpc } from "@calcom/trpc";
import {
Button,
@ -31,6 +32,7 @@ import { GoogleWorkspaceInviteButton } from "./GoogleWorkspaceInviteButton";
type MemberInvitationModalProps = {
isOpen: boolean;
onExit: () => void;
orgMembers?: RouterOutputs["viewer"]["organizations"]["getMembers"];
onSubmit: (values: NewMemberForm, resetFields: () => void) => void;
onSettingsOpen?: () => void;
teamId: number;
@ -86,6 +88,25 @@ export default function MemberInvitationModal(props: MemberInvitationModalProps)
];
}, [t]);
const toggleGroupOptions = useMemo(() => {
const array = [
{
value: "INDIVIDUAL",
label: t("invite_team_individual_segment"),
iconLeft: <UserIcon />,
},
{ value: "BULK", label: t("invite_team_bulk_segment"), iconLeft: <Users /> },
];
if (props.orgMembers) {
array.unshift({
value: "ORGANIZATION",
label: t("organization"),
iconLeft: <BuildingIcon />,
});
}
return array;
}, [t, props.orgMembers]);
const newMemberFormMethods = useForm<NewMemberForm>();
const validateUniqueInvite = (value: string) => {
@ -149,23 +170,12 @@ export default function MemberInvitationModal(props: MemberInvitationModalProps)
isFullWidth={true}
onValueChange={(val) => setModalInputMode(val as ModalMode)}
defaultValue="INDIVIDUAL"
options={[
{
value: "INDIVIDUAL",
label: <span className="line-clamp-1">{t("invite_team_individual_segment")}</span>,
iconLeft: <UserIcon />,
},
{
value: "BULK",
label: <span className="line-clamp-1">{t("invite_team_bulk_segment")}</span>,
iconLeft: <Users />,
},
]}
options={toggleGroupOptions}
/>
</div>
<Form form={newMemberFormMethods} handleSubmit={(values) => props.onSubmit(values, resetFields)}>
<div className="mb-12 mt-6 space-y-6">
<div className="mb-10 mt-6 space-y-6">
{/* Indivdual Invite */}
{modalImportMode === "INDIVIDUAL" && (
<Controller
@ -299,39 +309,39 @@ export default function MemberInvitationModal(props: MemberInvitationModalProps)
)}
</div>
<DialogFooter showDivider>
<div className="flex w-full flex-col items-end gap-2 md:flex-row md:items-center md:justify-between">
<div>
<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>
</div>
<div className="flex gap-2">
<Button
type="button"
color="minimal"
onClick={() => {
props.onExit();
resetFields();
}}>
{t("cancel")}
</Button>
<Button type="submit" color="primary" data-testid="invite-new-member-button">
{t("send_invite")}
</Button>
</div>
<div className="relative right-40">
<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>
</div>
<Button
type="button"
color="minimal"
onClick={() => {
props.onExit();
resetFields();
}}>
{t("cancel")}
</Button>
<Button
type="submit"
color="primary"
className="me-2 ms-2"
data-testid="invite-new-member-button">
{t("send_invite")}
</Button>
</DialogFooter>
</Form>
</DialogContent>

View File

@ -28,10 +28,17 @@ export const checkIfTeamPaymentRequired = async ({ teamId = -1 }) => {
return { url: `${WEBAPP_URL}/api/teams/${teamId}/upgrade?session_id=${metadata.paymentId}` };
};
export const purchaseTeamSubscription = async (input: { teamId: number; seats: number; userId: number }) => {
const { teamId, seats, userId } = input;
export const purchaseTeamSubscription = async (input: {
teamId: number;
seats: number;
userId: number;
isOrg?: boolean;
}) => {
const { teamId, seats, userId, isOrg } = input;
const { url } = await checkIfTeamPaymentRequired({ teamId });
if (url) return { url };
// For orgs, enforce minimum of 30 seats
const quantity = isOrg ? (seats < 30 ? 30 : seats) : seats;
const customer = await getStripeCustomerIdFromUserId(userId);
const session = await stripe.checkout.sessions.create({
customer,
@ -42,8 +49,8 @@ export const purchaseTeamSubscription = async (input: { teamId: number; seats: n
line_items: [
{
/** We only need to set the base price and we can upsell it directly on Stripe's checkout */
price: process.env.STRIPE_TEAM_MONTHLY_PRICE_ID,
quantity: seats,
price: isOrg ? process.env.STRIPE_ORG_MONTHLY_PRICE_ID : process.env.STRIPE_TEAM_MONTHLY_PRICE_ID,
quantity: quantity,
},
],
customer_update: {
@ -95,8 +102,22 @@ export const updateQuantitySubscriptionFromStripe = async (teamId: number) => {
if (!url) return;
const team = await getTeamWithPaymentMetadata(teamId);
const { subscriptionId, subscriptionItemId } = team.metadata;
const membershipCount = team.members.length;
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
const subscriptionQuantity = subscription.items.data.find(
(sub) => sub.id === subscriptionItemId
)?.quantity;
if (!subscriptionQuantity) throw new Error("Subscription not found");
if (membershipCount < subscriptionQuantity) {
console.info(`Team ${teamId} has less members than seats, skipping updating subscription.`);
return;
}
const newQuantity = membershipCount - subscriptionQuantity;
await stripe.subscriptions.update(subscriptionId, {
items: [{ quantity: team.members.length, id: subscriptionItemId }],
items: [{ quantity: membershipCount + newQuantity, id: subscriptionItemId }],
});
console.info(
`Updated subscription ${subscriptionId} for team ${teamId} to ${team.members.length} seats.`

View File

@ -74,7 +74,7 @@ export default function EmptyScreen(props: { isFilteredView: boolean }) {
</div>
<div className="max-w-[420px] text-center">
<h2 className="text-semibold font-cal mt-6 text-xl dark:text-gray-300">{t("workflows")}</h2>
<p className="text-default line-clamp-2 mt-3 text-sm font-normal leading-6 dark:text-gray-300">
<p className="text-default mt-3 line-clamp-2 text-sm font-normal leading-6 dark:text-gray-300">
{t("no_workflows_description")}
</p>
<div className="mt-8 ">

View File

@ -46,7 +46,7 @@ export const CheckedTeamSelect = ({
key={option.value}
className={`flex px-3 py-2 ${index === value.length - 1 ? "" : "border-subtle border-b"}`}>
<Avatar size="sm" imageSrc={option.avatar} alt={option.label} />
<p className="text-emphasis ms-3 my-auto text-sm">{option.label}</p>
<p className="text-emphasis my-auto ms-3 text-sm">{option.label}</p>
<X
onClick={() => props.onChange(value.filter((item) => item.value !== option.value))}
className="my-auto ml-auto h-4 w-4"

View File

@ -119,6 +119,7 @@ const tabs: VerticalTabItemProps[] = [
{ name: "impersonation", href: "/settings/admin/impersonation" },
{ name: "apps", href: "/settings/admin/apps/calendar" },
{ name: "users", href: "/settings/admin/users" },
{ name: "organizations", href: "/settings/admin/organizations" },
],
},
];
@ -193,6 +194,7 @@ const SettingsSidebarContainer = ({
useState<{ teamId: number | undefined; teamMenuOpen: boolean }[]>();
const { data: teams } = trpc.viewer.teams.list.useQuery();
const { data: currentOrg } = trpc.viewer.organizations.listCurrent.useQuery();
useEffect(() => {
if (teams) {
@ -385,14 +387,15 @@ const SettingsSidebarContainer = ({
</Collapsible>
);
})}
<VerticalTabItem
name={t("add_a_team")}
href={`${WEBAPP_URL}/settings/teams/new`}
textClassNames="px-3 items-center mt-2 text-emphasis font-medium text-sm"
icon={Plus}
iconClassName="me-3"
disableChevron
/>
{(!currentOrg || (currentOrg && currentOrg?.user?.role !== "MEMBER")) && (
<VerticalTabItem
name={t("add_a_team")}
href={`${WEBAPP_URL}/settings/teams/new`}
textClassNames="px-3 items-center mt-2 text-emphasis font-medium text-sm"
icon={Plus}
disableChevron
/>
)}
</div>
</React.Fragment>
);

View File

@ -313,6 +313,8 @@ export const teamMetadataSchema = z
subscriptionId: z.string().nullable(),
subscriptionItemId: z.string().nullable(),
isOrganization: z.boolean().nullable(),
isOrganizationVerified: z.boolean().nullable(),
orgAutoAcceptEmail: z.string().nullable(),
})
.partial()
.nullable();

View File

@ -1,7 +1,9 @@
import authedProcedure from "../../../procedures/authedProcedure";
import authedProcedure, { authedAdminProcedure } from "../../../procedures/authedProcedure";
import { router } from "../../../trpc";
import { ZAdminVerifyInput } from "./adminVerify.schema";
import { ZCreateInputSchema } from "./create.schema";
import { ZCreateTeamsSchema } from "./createTeams.schema";
import { ZGetMembersInput } from "./getMembers.schema";
import { ZSetPasswordSchema } from "./setPassword.schema";
import { ZUpdateInputSchema } from "./update.schema";
import { ZVerifyCodeInputSchema } from "./verifyCode.schema";
@ -15,6 +17,9 @@ type OrganizationsRouterHandlerCache = {
verifyCode?: typeof import("./verifyCode.handler").verifyCodeHandler;
createTeams?: typeof import("./createTeams.handler").createTeamsHandler;
setPassword?: typeof import("./setPassword.handler").setPasswordHandler;
adminGetUnverified?: typeof import("./adminGetUnverified.handler").adminGetUnverifiedHandler;
adminVerify?: typeof import("./adminVerify.handler").adminVerifyHandler;
getMembers?: typeof import("./getMembers.handler").getMembersHandler;
listMembers?: typeof import("./listMembers.handler").listMembersHandler;
getBrand?: typeof import("./getBrand.handler").getBrandHandler;
};
@ -141,6 +146,37 @@ export const viewerOrganizationsRouter = router({
input,
});
}),
adminGetUnverified: authedAdminProcedure.query(async ({ ctx }) => {
if (!UNSTABLE_HANDLER_CACHE.adminGetUnverified) {
UNSTABLE_HANDLER_CACHE.adminGetUnverified = await import("./adminGetUnverified.handler").then(
(mod) => mod.adminGetUnverifiedHandler
);
}
if (!UNSTABLE_HANDLER_CACHE.adminGetUnverified) {
throw new Error("Failed to load handler");
}
return UNSTABLE_HANDLER_CACHE.adminGetUnverified({
ctx,
});
}),
adminVerify: authedAdminProcedure.input(ZAdminVerifyInput).mutation(async ({ input, ctx }) => {
if (!UNSTABLE_HANDLER_CACHE.adminVerify) {
UNSTABLE_HANDLER_CACHE.adminVerify = await import("./adminVerify.handler").then(
(mod) => mod.adminVerifyHandler
);
}
// Unreachable code but required for type safety
if (!UNSTABLE_HANDLER_CACHE.adminVerify) {
throw new Error("Failed to load handler");
}
return UNSTABLE_HANDLER_CACHE.adminVerify({
ctx,
input,
});
}),
listMembers: authedProcedure.query(async ({ ctx }) => {
if (!UNSTABLE_HANDLER_CACHE.listMembers) {
UNSTABLE_HANDLER_CACHE.listMembers = await import("./listMembers.handler").then(
@ -171,4 +207,21 @@ export const viewerOrganizationsRouter = router({
ctx,
});
}),
getMembers: authedProcedure.input(ZGetMembersInput).query(async ({ ctx, input }) => {
if (!UNSTABLE_HANDLER_CACHE.getMembers) {
UNSTABLE_HANDLER_CACHE.getMembers = await import("./getMembers.handler").then(
(mod) => mod.getMembersHandler
);
}
// Unreachable code but required for type safety
if (!UNSTABLE_HANDLER_CACHE.getMembers) {
throw new Error("Failed to load handler");
}
return UNSTABLE_HANDLER_CACHE.getMembers({
ctx,
input,
});
}),
});

View File

@ -0,0 +1,51 @@
import { prisma } from "@calcom/prisma";
import type { TrpcSessionUser } from "../../../trpc";
type AdminGetUnverifiedOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
};
export const adminGetUnverifiedHandler = async ({ ctx }: AdminGetUnverifiedOptions) => {
const unVerifiedTeams = await prisma.team.findMany({
where: {
AND: [
{
metadata: {
path: ["isOrganization"],
equals: true,
},
},
{
metadata: {
path: ["isOrganizationVerified"],
equals: false,
},
},
],
},
select: {
id: true,
name: true,
slug: true,
members: {
where: {
role: "OWNER",
},
select: {
user: {
select: {
id: true,
name: true,
email: true,
},
},
},
},
},
});
return unVerifiedTeams;
};

View File

@ -0,0 +1,61 @@
import { prisma } from "@calcom/prisma";
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
import { TRPCError } from "@trpc/server";
import type { TrpcSessionUser } from "../../../trpc";
import type { TAdminVerifyInput } from "./adminVerify.schema";
type AdminVerifyOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
input: TAdminVerifyInput;
};
export const adminVerifyHandler = async ({ input }: AdminVerifyOptions) => {
const foundOrg = await prisma.team.findFirst({
where: {
id: input.orgId,
metadata: {
path: ["isOrganization"],
equals: true,
},
},
include: {
members: {
where: {
role: "OWNER",
},
include: {
user: true,
},
},
},
});
if (!foundOrg)
throw new TRPCError({
code: "FORBIDDEN",
message: "This team isnt a org or doesnt exist",
});
const acceptedEmailDomain = foundOrg.members[0].user.email.split("@")[1];
const metaDataParsed = teamMetadataSchema.parse(foundOrg.metadata);
await prisma.team.update({
where: {
id: input.orgId,
},
data: {
metadata: {
...metaDataParsed,
isOrganizationVerified: true,
orgAutoAcceptEmail: acceptedEmailDomain,
},
},
});
return { ok: true, message: "Verified Organization" };
};

View File

@ -0,0 +1,10 @@
import { z } from "zod";
const statusSchema = z.enum(["ACCEPT", "DENY"] as const);
export const ZAdminVerifyInput = z.object({
orgId: z.number(),
status: statusSchema,
});
export type TAdminVerifyInput = z.infer<typeof ZAdminVerifyInput>;

View File

@ -75,6 +75,8 @@ export const createHandler = async ({ input }: CreateOptions) => {
.digest("hex");
const hashedPassword = await hashPassword(password);
const emailDomain = adminEmail.split("@")[1];
if (check === false) {
const createOwnerOrg = await prisma.user.create({
data: {
@ -89,6 +91,8 @@ export const createHandler = async ({ input }: CreateOptions) => {
metadata: {
...(IS_TEAM_BILLING_ENABLED && { requestedSlug: slug }),
isOrganization: true,
isOrganizationVerified: false,
orgAutoAcceptEmail: emailDomain,
},
},
},

View File

@ -0,0 +1,42 @@
import { prisma } from "@calcom/prisma";
import type { TrpcSessionUser } from "../../../trpc";
import type { TGetMembersInputSchema } from "./getMembers.schema";
type CreateOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
input: TGetMembersInputSchema;
};
export const getMembersHandler = async ({ input, ctx }: CreateOptions) => {
const { teamIdToExclude } = input;
if (!ctx.user.organizationId) return null;
const users = await prisma.membership.findMany({
where: {
user: {
organizationId: ctx.user.organizationId,
},
...(teamIdToExclude && {
teamId: {
not: teamIdToExclude,
},
}),
},
include: {
user: {
select: {
id: true,
username: true,
email: true,
completedOnboarding: true,
},
},
},
});
return users;
};

View File

@ -0,0 +1,7 @@
import { z } from "zod";
export const ZGetMembersInput = z.object({
teamIdToExclude: z.number().optional(),
});
export type TGetMembersInputSchema = z.infer<typeof ZGetMembersInput>;

View File

@ -35,12 +35,13 @@ export const publishHandler = async ({ ctx }: PublishOptions) => {
const metadata = teamMetadataSchema.safeParse(prevTeam.metadata);
if (!metadata.success) throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid team metadata" });
// Since this is an ORG we neeed to make sure ORG members are scyned with the team. Every time a user is added to the TEAM, we need to add them to the ORG
// Since this is an ORG we need to make sure ORG members are scyned with the team. Every time a user is added to the TEAM, we need to add them to the ORG
if (IS_TEAM_BILLING_ENABLED) {
const checkoutSession = await purchaseTeamSubscription({
teamId: prevTeam.id,
seats: prevTeam.members.length,
userId: ctx.user.id,
isOrg: true,
});
if (!checkoutSession.url)
throw new TRPCError({

View File

@ -37,6 +37,7 @@ export const getUpgradeableHandler = async ({ ctx }: GetUpgradeableOptions) => {
teams = teams.filter((m) => {
const metadata = teamMetadataSchema.safeParse(m.team.metadata);
if (metadata.success && metadata.data?.subscriptionId) return false;
if (metadata.success && metadata.data?.isOrganization) return false; // We also dont return ORGs as it will be handled in OrgUpgradeBanner
if (m.team.children.length > 0) return false; // We also dont return ORGs as it will be handled in OrgUpgradeBanner
return true;
});

View File

@ -1,13 +1,17 @@
import { Prisma } from "@prisma/client";
import { randomBytes } from "crypto";
import type { TFunction } from "next-i18next";
import { sendTeamInviteEmail } from "@calcom/emails";
import { sendOrganizationAutoJoinEmail, sendTeamInviteEmail } from "@calcom/emails";
import { updateQuantitySubscriptionFromStripe } from "@calcom/features/ee/teams/lib/payments";
import { IS_TEAM_BILLING_ENABLED, WEBAPP_URL } from "@calcom/lib/constants";
import { getTranslation } from "@calcom/lib/server/i18n";
import { isTeamAdmin, isTeamOwner } from "@calcom/lib/server/queries/teams";
import { isTeamAdmin } from "@calcom/lib/server/queries";
import { isOrganisationAdmin } from "@calcom/lib/server/queries/organisations";
import { prisma } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
import type { Team, User } from "@calcom/prisma/client";
import type { MembershipRole } from "@calcom/prisma/enums";
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
import { TRPCError } from "@trpc/server";
@ -22,106 +26,359 @@ type InviteMemberOptions = {
input: TInviteMemberInputSchema;
};
export const inviteMemberHandler = async ({ ctx, input }: InviteMemberOptions) => {
if (!(await isTeamAdmin(ctx.user?.id, input.teamId))) throw new TRPCError({ code: "UNAUTHORIZED" });
if (input.role === MembershipRole.OWNER && !(await isTeamOwner(ctx.user?.id, input.teamId)))
throw new TRPCError({ code: "UNAUTHORIZED" });
const translation = await getTranslation(input.language ?? "en", "common");
type TeamWithParent = Team & {
parent: Team | null;
};
async function checkPermissions({
userId,
teamId,
isOrg,
}: {
userId: number;
teamId: number;
isOrg?: boolean;
}) {
// Checks if the team they are inviteing to IS the org. Not a child team
if (isOrg) {
if (!(await isOrganisationAdmin(userId, teamId))) throw new TRPCError({ code: "UNAUTHORIZED" });
} else {
// TODO: do some logic here to check if the user is inviting a NEW user to a team that ISNT in the same org
if (!(await isTeamAdmin(userId, teamId))) throw new TRPCError({ code: "UNAUTHORIZED" });
}
}
async function getTeamOrThrow(teamId: number, isOrg?: boolean) {
const team = await prisma.team.findFirst({
where: {
id: input.teamId,
id: teamId,
},
include: {
parent: true,
},
});
if (!team)
throw new TRPCError({ code: "NOT_FOUND", message: `${input.isOrg ? "Organization" : "Team"} not found` });
throw new TRPCError({ code: "NOT_FOUND", message: `${isOrg ? "Organization" : "Team"} not found` });
const emailsToInvite = Array.isArray(input.usernameOrEmail)
? input.usernameOrEmail
: [input.usernameOrEmail];
return team;
}
emailsToInvite.forEach(async (usernameOrEmail) => {
const invitee = await prisma.user.findFirst({
where: {
OR: [{ username: usernameOrEmail }, { email: usernameOrEmail }],
async function getEmailsToInvite(usernameOrEmail: string | string[]) {
const emailsToInvite = Array.isArray(usernameOrEmail) ? usernameOrEmail : [usernameOrEmail];
if (emailsToInvite.length === 0) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "You must provide at least one email address to invite.",
});
}
return emailsToInvite;
}
async function getUserToInviteOrThrowIfExists({
usernameOrEmail,
orgId,
isOrg,
}: {
usernameOrEmail: string;
orgId: number;
isOrg?: boolean;
}) {
// Check if user exists in ORG or exists all together
const invitee = await prisma.user.findFirst({
where: {
OR: [{ username: usernameOrEmail, organizationId: orgId }, { email: usernameOrEmail }],
},
});
// We throw on error cause we can't have two users in the same org with the same username
if (isOrg && invitee) {
throw new TRPCError({
code: "NOT_FOUND",
message: `Email ${usernameOrEmail} already exists, you can't invite existing users.`,
});
}
return invitee;
}
function checkInputEmailIsValid(email: string) {
if (!isEmail(email))
throw new TRPCError({
code: "BAD_REQUEST",
message: `Invite failed because ${email} is not a valid email address`,
});
}
async function createNewUserConnectToOrgIfExists({
usernameOrEmail,
input,
parentId,
connectionInfo,
}: {
usernameOrEmail: string;
input: InviteMemberOptions["input"];
parentId?: number | null;
connectionInfo: ReturnType<typeof getOrgConnectionInfo>;
}) {
const { orgId, autoAccept } = connectionInfo;
const createdUser = await prisma.user.create({
data: {
email: usernameOrEmail,
invitedTo: input.teamId,
organizationId: orgId || null, // If the user is invited to a child team, they are automatically added to the parent org
teams: {
create: {
teamId: input.teamId,
role: input.role as MembershipRole,
accepted: autoAccept, // If the user is invited to a child team, they are automatically accepted
},
},
},
});
// We also need to create the membership in the parent org if it exists
if (parentId) {
await prisma.membership.create({
data: {
teamId: parentId,
userId: createdUser.id,
role: input.role as MembershipRole,
accepted: autoAccept,
},
});
}
}
async function createProvisionalMembership({
input,
invitee,
parentId,
}: {
input: InviteMemberOptions["input"];
invitee: User;
parentId?: number;
}) {
try {
await prisma.membership.create({
data: {
teamId: input.teamId,
userId: invitee.id,
role: input.role as MembershipRole,
},
});
// Create the membership in the parent also if it exists
if (parentId) {
await prisma.membership.create({
data: {
teamId: parentId,
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;
}
}
async function sendVerificationEmail({
usernameOrEmail,
team,
translation,
ctx,
input,
connectionInfo,
}: {
usernameOrEmail: string;
team: Awaited<ReturnType<typeof getTeamOrThrow>>;
translation: TFunction;
ctx: { user: NonNullable<TrpcSessionUser> };
input: {
teamId: number;
role: "ADMIN" | "MEMBER" | "OWNER";
usernameOrEmail: string | string[];
language: string;
sendEmailInvitation: boolean;
isOrg: boolean;
};
connectionInfo: ReturnType<typeof getOrgConnectionInfo>;
}) {
const token: string = randomBytes(32).toString("hex");
if (!connectionInfo.autoAccept) {
await prisma.verificationToken.create({
data: {
identifier: usernameOrEmail,
token,
expires: new Date(new Date().setHours(168)), // +1 week
team: {
connect: {
id: connectionInfo.orgId || input.teamId,
},
},
},
});
await sendTeamInviteEmail({
language: translation,
from: ctx.user.name || `${team.name}'s admin`,
to: usernameOrEmail,
teamName: team?.parent?.name || team.name,
joinLink: `${WEBAPP_URL}/signup?token=${token}&callbackUrl=/getting-started`,
isCalcomMember: false,
isOrg: input.isOrg,
});
} else {
// we have already joined the team in createNewUserConnectToOrgIfExists so we dont need to connect via token
await prisma.verificationToken.create({
data: {
identifier: usernameOrEmail,
token,
expires: new Date(new Date().setHours(168)), // +1 week
},
});
if (input.isOrg && invitee) {
throw new TRPCError({
code: "NOT_FOUND",
message: `Email ${usernameOrEmail} already exists, you can't invite existing users.`,
});
await sendOrganizationAutoJoinEmail({
language: translation,
from: ctx.user.name || `${team.name}'s admin`,
to: usernameOrEmail,
orgName: team?.parent?.name || team.name,
joinLink: `${WEBAPP_URL}/signup?token=${token}&callbackUrl=/getting-started`,
});
}
}
function checkIfUserIsInDifOrg(
invitee: User,
team: Team & {
parent: Team | null;
}
) {
if (invitee.organizationId !== team.parentId) {
throw new TRPCError({
code: "FORBIDDEN",
message: `User ${invitee.username} is already a member of another organization.`,
});
}
}
function getIsOrgVerified(
isOrg: boolean,
team: Team & {
parent: Team | null;
}
) {
const teamMetadata = teamMetadataSchema.parse(team.metadata);
const orgMetadataSafeParse = teamMetadataSchema.safeParse(team.parent?.metadata);
const orgMetadataIfExists = orgMetadataSafeParse.success ? orgMetadataSafeParse.data : null;
if (isOrg && teamMetadata?.orgAutoAcceptEmail) {
return {
isInOrgScope: true,
orgVerified: teamMetadata.isOrganizationVerified,
autoAcceptEmailDomain: teamMetadata.orgAutoAcceptEmail,
};
} else if (orgMetadataIfExists?.orgAutoAcceptEmail) {
return {
isInOrgScope: true,
orgVerified: orgMetadataIfExists.isOrganizationVerified,
autoAcceptEmailDomain: orgMetadataIfExists.orgAutoAcceptEmail,
};
}
return {
isInOrgScope: false,
} as { isInOrgScope: false; orgVerified: never; autoAcceptEmailDomain: never };
}
function getOrgConnectionInfo({
orgAutoAcceptDomain,
orgVerified,
isOrg,
usersEmail,
team,
}: {
orgAutoAcceptDomain?: string | null;
orgVerified?: boolean | null;
usersEmail: string;
team: TeamWithParent;
isOrg: boolean;
}) {
let orgId: number | undefined = undefined;
let autoAccept = false;
if (team.parentId || isOrg) {
orgId = team.parentId || team.id;
if (usersEmail.split("@")[1] == orgAutoAcceptDomain) {
autoAccept = orgVerified ?? true;
} else {
// No longer throw error - not needed we just dont auto accept them
orgId = undefined;
autoAccept = false;
}
}
return { orgId, autoAccept };
}
export const inviteMemberHandler = async ({ ctx, input }: InviteMemberOptions) => {
const team = await getTeamOrThrow(input.teamId, input.isOrg);
const { autoAcceptEmailDomain, orgVerified } = getIsOrgVerified(input.isOrg, team);
await checkPermissions({ userId: ctx.user.id, teamId: input.teamId, isOrg: input.isOrg });
const translation = await getTranslation(input.language ?? "en", "common");
const emailsToInvite = await getEmailsToInvite(input.usernameOrEmail);
for (const usernameOrEmail of emailsToInvite) {
const connectionInfo = getOrgConnectionInfo({
orgVerified,
orgAutoAcceptDomain: autoAcceptEmailDomain,
usersEmail: usernameOrEmail,
team,
isOrg: input.isOrg,
});
const invitee = await getUserToInviteOrThrowIfExists({
usernameOrEmail,
orgId: input.teamId,
isOrg: input.isOrg,
});
if (!invitee) {
// liberal email match
if (!isEmail(usernameOrEmail))
throw new TRPCError({
code: "NOT_FOUND",
message: `Invite failed because there is no corresponding user for ${usernameOrEmail}`,
});
checkInputEmailIsValid(usernameOrEmail);
// valid email given, create User and add to team
await prisma.user.create({
data: {
email: usernameOrEmail,
invitedTo: input.teamId,
...(input.isOrg && { organizationId: input.teamId }),
teams: {
create: {
teamId: input.teamId,
role: input.role as MembershipRole,
},
},
},
await createNewUserConnectToOrgIfExists({
usernameOrEmail,
input,
connectionInfo,
parentId: team.parentId,
});
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 (team?.name) {
await sendTeamInviteEmail({
language: translation,
from: ctx.user.name || `${team.name}'s admin`,
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,
isOrg: input.isOrg,
});
}
await sendVerificationEmail({ usernameOrEmail, team, translation, ctx, input, connectionInfo });
} else {
checkIfUserIsInDifOrg(invitee, team);
// 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 createProvisionalMembership({
input,
invitee,
...(team.parentId ? { parentId: team.parentId } : {}),
});
let sendTo = usernameOrEmail;
if (!isEmail(usernameOrEmail)) {
@ -161,7 +418,14 @@ export const inviteMemberHandler = async ({ ctx, input }: InviteMemberOptions) =
});
}
}
});
if (IS_TEAM_BILLING_ENABLED) await updateQuantitySubscriptionFromStripe(input.teamId);
}
if (IS_TEAM_BILLING_ENABLED) {
if (team.parentId) {
await updateQuantitySubscriptionFromStripe(team.parentId);
} else {
await updateQuantitySubscriptionFromStripe(input.teamId);
}
}
return input;
};

View File

@ -1,6 +1,12 @@
import type { Prisma } from "@prisma/client";
import { getRequestedSlugError } from "@calcom/app-store/stripepayment/lib/team-billing";
import { purchaseTeamSubscription } from "@calcom/features/ee/teams/lib/payments";
import {
purchaseTeamSubscription,
updateQuantitySubscriptionFromStripe,
} from "@calcom/features/ee/teams/lib/payments";
import { IS_TEAM_BILLING_ENABLED, WEBAPP_URL } from "@calcom/lib/constants";
import { isOrganisationAdmin } from "@calcom/lib/server/queries/organisations";
import { isTeamAdmin } from "@calcom/lib/server/queries/teams";
import { closeComUpdateTeam } from "@calcom/lib/sync/SyncServiceManager";
import { prisma } from "@calcom/prisma";
@ -8,16 +14,103 @@ import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
import { TRPCError } from "@trpc/server";
import type { TrpcSessionUser } from "../../../trpc";
import type { TPublishInputSchema } from "./publish.schema";
type PublishOptions = {
ctx: {
user: NonNullable<{ id: number }>;
user: NonNullable<TrpcSessionUser>;
};
input: TPublishInputSchema;
};
const parseMetadataOrThrow = (metadata: Prisma.JsonValue) => {
const parsedMetadata = teamMetadataSchema.safeParse(metadata);
if (!parsedMetadata.success || !parsedMetadata.data)
throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid team metadata" });
return parsedMetadata.data;
};
const generateCheckoutSession = async ({
teamId,
seats,
userId,
}: {
teamId: number;
seats: number;
userId: number;
}) => {
if (!IS_TEAM_BILLING_ENABLED) return;
const checkoutSession = await purchaseTeamSubscription({
teamId,
seats,
userId,
});
if (!checkoutSession.url)
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed retrieving a checkout session URL.",
});
return { url: checkoutSession.url, message: "Payment required to publish team" };
};
const publishOrganizationTeamHandler = async ({ ctx, input }: PublishOptions) => {
if (!ctx.user.organizationId) throw new TRPCError({ code: "UNAUTHORIZED" });
if (!isOrganisationAdmin(ctx.user.id, ctx.user?.organizationId))
throw new TRPCError({ code: "UNAUTHORIZED" });
const createdTeam = await prisma.team.findFirst({
where: { id: input.teamId, parentId: ctx.user.organizationId },
include: {
parent: {
include: {
members: true,
},
},
},
});
if (!createdTeam || !createdTeam.parentId)
throw new TRPCError({ code: "NOT_FOUND", message: "Team not found." });
const metadata = parseMetadataOrThrow(createdTeam.metadata);
// We update the quantity of the parent ID (organization) subscription
if (IS_TEAM_BILLING_ENABLED) {
await updateQuantitySubscriptionFromStripe(createdTeam.parentId);
}
if (!metadata?.requestedSlug) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Can't publish team without `requestedSlug`" });
}
const { requestedSlug, ...newMetadata } = metadata;
let updatedTeam: Awaited<ReturnType<typeof prisma.team.update>>;
try {
updatedTeam = await prisma.team.update({
where: { id: createdTeam.id },
data: {
slug: requestedSlug,
metadata: { ...newMetadata },
},
});
} catch (error) {
const { message } = getRequestedSlugError(error, requestedSlug);
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message });
}
return {
url: `${WEBAPP_URL}/settings/teams/${updatedTeam.id}/profile`,
message: "Team published successfully",
};
};
export const publishHandler = async ({ ctx, input }: PublishOptions) => {
if (ctx.user.organizationId) return publishOrganizationTeamHandler({ ctx, input });
if (!(await isTeamAdmin(ctx.user.id, input.teamId))) throw new TRPCError({ code: "UNAUTHORIZED" });
const { teamId: id } = input;
@ -25,30 +118,22 @@ export const publishHandler = async ({ ctx, input }: PublishOptions) => {
if (!prevTeam) throw new TRPCError({ code: "NOT_FOUND", message: "Team not found." });
const metadata = teamMetadataSchema.safeParse(prevTeam.metadata);
if (!metadata.success) throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid team metadata" });
const metadata = parseMetadataOrThrow(prevTeam.metadata);
// if payment needed, respond with checkout url
if (IS_TEAM_BILLING_ENABLED) {
const checkoutSession = await purchaseTeamSubscription({
teamId: prevTeam.id,
seats: prevTeam.members.length,
userId: ctx.user.id,
});
if (!checkoutSession.url)
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed retrieving a checkout session URL.",
});
return { url: checkoutSession.url, message: "Payment required to publish team" };
}
const checkoutSession = await generateCheckoutSession({
teamId: prevTeam.id,
seats: prevTeam.members.length,
userId: ctx.user.id,
});
if (!metadata.data?.requestedSlug) {
if (checkoutSession) return checkoutSession;
if (!metadata?.requestedSlug) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Can't publish team without `requestedSlug`" });
}
const { requestedSlug, ...newMetadata } = metadata.data;
const { requestedSlug, ...newMetadata } = metadata;
let updatedTeam: Awaited<ReturnType<typeof prisma.team.update>>;
try {

View File

@ -33,7 +33,8 @@
"NEXT_PUBLIC_WEBAPP_URL",
"NEXT_PUBLIC_WEBSITE_URL",
"STRIPE_PREMIUM_PLAN_PRODUCT_ID",
"STRIPE_TEAM_MONTHLY_PRICE_ID"
"STRIPE_TEAM_MONTHLY_PRICE_ID",
"STRIPE_ORG_MONTHLY_PRICE_ID"
]
},
"@calcom/web#dx": {