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:
parent
05655a92ca
commit
583571247c
|
@ -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=
|
||||
|
|
|
@ -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,26 +88,101 @@ 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 (!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: {
|
||||
id: foundToken.teamId,
|
||||
},
|
||||
});
|
||||
|
||||
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: user.invitedTo },
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
// Sync Services: Close.com
|
||||
closeComUpsertTeamUser(team, user, membership.role);
|
||||
// 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({
|
||||
email: userEmail,
|
||||
|
|
|
@ -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;
|
|
@ -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;
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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" });
|
||||
|
||||
|
|
|
@ -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));
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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";
|
||||
|
|
|
@ -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: "",
|
||||
};
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
|
@ -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}
|
||||
|
|
|
@ -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,8 +309,7 @@ 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>
|
||||
<div className="relative right-40">
|
||||
<Button
|
||||
type="button"
|
||||
color="minimal"
|
||||
|
@ -317,7 +326,6 @@ export default function MemberInvitationModal(props: MemberInvitationModalProps)
|
|||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
color="minimal"
|
||||
|
@ -327,11 +335,13 @@ export default function MemberInvitationModal(props: MemberInvitationModalProps)
|
|||
}}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button type="submit" color="primary" data-testid="invite-new-member-button">
|
||||
<Button
|
||||
type="submit"
|
||||
color="primary"
|
||||
className="me-2 ms-2"
|
||||
data-testid="invite-new-member-button">
|
||||
{t("send_invite")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
|
|
|
@ -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.`
|
||||
|
|
|
@ -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 ">
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
})}
|
||||
{(!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}
|
||||
iconClassName="me-3"
|
||||
disableChevron
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
}),
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
};
|
|
@ -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" };
|
||||
};
|
|
@ -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>;
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
@ -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;
|
||||
};
|
|
@ -0,0 +1,7 @@
|
|||
import { z } from "zod";
|
||||
|
||||
export const ZGetMembersInput = z.object({
|
||||
teamIdToExclude: z.number().optional(),
|
||||
});
|
||||
|
||||
export type TGetMembersInputSchema = z.infer<typeof ZGetMembersInput>;
|
|
@ -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({
|
||||
|
|
|
@ -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;
|
||||
});
|
||||
|
|
|
@ -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,85 +26,142 @@ 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) => {
|
||||
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 }, { email: usernameOrEmail }],
|
||||
OR: [{ username: usernameOrEmail, organizationId: orgId }, { email: usernameOrEmail }],
|
||||
},
|
||||
});
|
||||
|
||||
if (input.isOrg && invitee) {
|
||||
// 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.`,
|
||||
});
|
||||
}
|
||||
|
||||
if (!invitee) {
|
||||
// liberal email match
|
||||
return invitee;
|
||||
}
|
||||
|
||||
if (!isEmail(usernameOrEmail))
|
||||
function checkInputEmailIsValid(email: string) {
|
||||
if (!isEmail(email))
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: `Invite failed because there is no corresponding user for ${usernameOrEmail}`,
|
||||
code: "BAD_REQUEST",
|
||||
message: `Invite failed because ${email} is not a valid email address`,
|
||||
});
|
||||
}
|
||||
|
||||
// valid email given, create User and add to team
|
||||
await prisma.user.create({
|
||||
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,
|
||||
...(input.isOrg && { organizationId: 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
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const token: string = randomBytes(32).toString("hex");
|
||||
|
||||
await prisma.verificationToken.create({
|
||||
// We also need to create the membership in the parent org if it exists
|
||||
if (parentId) {
|
||||
await prisma.membership.create({
|
||||
data: {
|
||||
identifier: usernameOrEmail,
|
||||
token,
|
||||
expires: new Date(new Date().setHours(168)), // +1 week
|
||||
teamId: parentId,
|
||||
userId: createdUser.id,
|
||||
role: input.role as MembershipRole,
|
||||
accepted: autoAccept,
|
||||
},
|
||||
});
|
||||
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,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// create provisional membership
|
||||
}
|
||||
|
||||
async function createProvisionalMembership({
|
||||
input,
|
||||
invitee,
|
||||
parentId,
|
||||
}: {
|
||||
input: InviteMemberOptions["input"];
|
||||
invitee: User;
|
||||
parentId?: number;
|
||||
}) {
|
||||
try {
|
||||
await prisma.membership.create({
|
||||
data: {
|
||||
|
@ -109,6 +170,16 @@ export const inviteMemberHandler = async ({ ctx, input }: InviteMemberOptions) =
|
|||
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
|
||||
|
@ -122,6 +193,192 @@ export const inviteMemberHandler = async ({ ctx, input }: InviteMemberOptions) =
|
|||
}
|
||||
} 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
|
||||
},
|
||||
});
|
||||
|
||||
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) {
|
||||
checkInputEmailIsValid(usernameOrEmail);
|
||||
|
||||
// valid email given, create User and add to team
|
||||
await createNewUserConnectToOrgIfExists({
|
||||
usernameOrEmail,
|
||||
input,
|
||||
connectionInfo,
|
||||
parentId: team.parentId,
|
||||
});
|
||||
|
||||
await sendVerificationEmail({ usernameOrEmail, team, translation, ctx, input, connectionInfo });
|
||||
} else {
|
||||
checkIfUserIsInDifOrg(invitee, team);
|
||||
|
||||
// create provisional membership
|
||||
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;
|
||||
};
|
||||
|
|
|
@ -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,33 +14,39 @@ 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;
|
||||
};
|
||||
|
||||
export const publishHandler = async ({ ctx, input }: PublishOptions) => {
|
||||
if (!(await isTeamAdmin(ctx.user.id, input.teamId))) throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
const { teamId: id } = input;
|
||||
const parseMetadataOrThrow = (metadata: Prisma.JsonValue) => {
|
||||
const parsedMetadata = teamMetadataSchema.safeParse(metadata);
|
||||
|
||||
const prevTeam = await prisma.team.findFirst({ where: { id }, include: { members: true } });
|
||||
if (!parsedMetadata.success || !parsedMetadata.data)
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid team metadata" });
|
||||
return parsedMetadata.data;
|
||||
};
|
||||
|
||||
if (!prevTeam) throw new TRPCError({ code: "NOT_FOUND", message: "Team not found." });
|
||||
const generateCheckoutSession = async ({
|
||||
teamId,
|
||||
seats,
|
||||
userId,
|
||||
}: {
|
||||
teamId: number;
|
||||
seats: number;
|
||||
userId: number;
|
||||
}) => {
|
||||
if (!IS_TEAM_BILLING_ENABLED) return;
|
||||
|
||||
const metadata = teamMetadataSchema.safeParse(prevTeam.metadata);
|
||||
|
||||
if (!metadata.success) throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid team 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,
|
||||
teamId,
|
||||
seats,
|
||||
userId,
|
||||
});
|
||||
if (!checkoutSession.url)
|
||||
throw new TRPCError({
|
||||
|
@ -42,13 +54,86 @@ export const publishHandler = async ({ ctx, input }: PublishOptions) => {
|
|||
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.data?.requestedSlug) {
|
||||
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;
|
||||
|
||||
const prevTeam = await prisma.team.findFirst({ where: { id }, include: { members: true } });
|
||||
|
||||
if (!prevTeam) throw new TRPCError({ code: "NOT_FOUND", message: "Team not found." });
|
||||
|
||||
const metadata = parseMetadataOrThrow(prevTeam.metadata);
|
||||
|
||||
// if payment needed, respond with checkout url
|
||||
const checkoutSession = await generateCheckoutSession({
|
||||
teamId: prevTeam.id,
|
||||
seats: prevTeam.members.length,
|
||||
userId: ctx.user.id,
|
||||
});
|
||||
|
||||
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 {
|
||||
|
|
|
@ -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": {
|
||||
|
|
Loading…
Reference in New Issue
Block a user