From 50970dc249d65b6918566148afc99f537b1a3e44 Mon Sep 17 00:00:00 2001 From: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com> Date: Tue, 19 Sep 2023 15:46:33 +0530 Subject: [PATCH] fix: org invite (#11356) Co-authored-by: Leo Giovanetti --- apps/web/pages/api/auth/signup.ts | 44 +++++----- apps/web/pages/signup.tsx | 86 ++++++++++++++----- apps/web/pages/teams/index.tsx | 15 ++++ .../components/MemberInvitationModal.tsx | 10 ++- .../ee/teams/pages/team-members-view.tsx | 3 +- .../UserTable/InviteMemberModal.tsx | 1 + packages/lib/server/queries/teams/index.ts | 7 +- .../viewer/teams/createInvite.handler.ts | 2 +- 8 files changed, 122 insertions(+), 46 deletions(-) diff --git a/apps/web/pages/api/auth/signup.ts b/apps/web/pages/api/auth/signup.ts index f44c231fb3..4956b9ae8c 100644 --- a/apps/web/pages/api/auth/signup.ts +++ b/apps/web/pages/api/auth/signup.ts @@ -10,6 +10,7 @@ import { closeComUpsertTeamUser } from "@calcom/lib/sync/SyncServiceManager"; import { validateUsernameInTeam, validateUsername } from "@calcom/lib/validateUsername"; import prisma from "@calcom/prisma"; import { IdentityProvider } from "@calcom/prisma/enums"; +import { MembershipRole } from "@calcom/prisma/enums"; import { signupSchema } from "@calcom/prisma/zod-utils"; import { teamMetadataSchema } from "@calcom/prisma/zod-utils"; @@ -89,6 +90,10 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) } } + // Identify the org id in an org context signup, either the invited team is an org + // or has a parentId, otherwise parentId will be null, making orgId null + const orgId = teamMetadata?.isOrganization ? team.id : team.parentId; + const user = await prisma.user.upsert({ where: { email: userEmail }, update: { @@ -96,49 +101,46 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) password: hashedPassword, emailVerified: new Date(Date.now()), identityProvider: IdentityProvider.CAL, + organizationId: orgId, }, create: { username, email: userEmail, password: hashedPassword, identityProvider: IdentityProvider.CAL, + organizationId: orgId, }, }); - if (teamMetadata?.isOrganization) { - await prisma.user.update({ - where: { - id: user.id, - }, - data: { - organizationId: team.id, - }, - }); - } - - const membership = await prisma.membership.update({ + const membership = await prisma.membership.upsert({ where: { userId_teamId: { userId: user.id, teamId: team.id }, }, - data: { + update: { accepted: true, }, + create: { + userId: user.id, + teamId: team.id, + accepted: true, + role: MembershipRole.MEMBER, + }, }); closeComUpsertTeamUser(team, user, membership.role); - // Accept any child team invites for orgs. + // Accept any child team invites for orgs and create a membership for the org itself if (team.parentId) { - // Join ORG - await prisma.user.update({ - where: { - id: user.id, - }, + // Create a membership for the organization itself + await prisma.membership.create({ data: { - organizationId: team.parentId, + userId: user.id, + teamId: team.parentId, + accepted: true, + role: MembershipRole.MEMBER, }, }); - /** We do a membership update twice so we can join the ORG invite if the user is invited to a team witin a ORG. */ + // 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, diff --git a/apps/web/pages/signup.tsx b/apps/web/pages/signup.tsx index 104bed819e..781072c297 100644 --- a/apps/web/pages/signup.tsx +++ b/apps/web/pages/signup.tsx @@ -34,7 +34,25 @@ type FormValues = z.infer; type SignupProps = inferSSRProps; -export default function Signup({ prepopulateFormValues, token, orgSlug }: SignupProps) { +const checkValidEmail = (email: string) => z.string().email().safeParse(email).success; + +const getOrgUsernameFromEmail = (email: string, autoAcceptEmailDomain: string) => { + const [emailUser, emailDomain] = email.split("@"); + const username = + emailDomain === autoAcceptEmailDomain + ? slugify(emailUser) + : slugify(`${emailUser}-${emailDomain.split(".")[0]}`); + + return username; +}; + +function addOrUpdateQueryParam(url: string, key: string, value: string) { + const separator = url.includes("?") ? "&" : "?"; + const param = `${key}=${encodeURIComponent(value)}`; + return `${url}${separator}${param}`; +} + +export default function Signup({ prepopulateFormValues, token, orgSlug, orgAutoAcceptEmail }: SignupProps) { const searchParams = useSearchParams(); const telemetry = useTelemetry(); const { t, i18n } = useLocale(); @@ -56,6 +74,8 @@ export default function Signup({ prepopulateFormValues, token, orgSlug }: Signup } }; + const isOrgInviteByLink = orgSlug && !prepopulateFormValues?.username; + const signUp: SubmitHandler = async (data) => { await fetch("/api/auth/signup", { body: JSON.stringify({ @@ -72,13 +92,17 @@ export default function Signup({ prepopulateFormValues, token, orgSlug }: Signup .then(async () => { telemetry.event(telemetryEventTypes.signup, collectPageParameters()); const verifyOrGettingStarted = flags["email-verification"] ? "auth/verify-email" : "getting-started"; + const callBackUrl = `${ + searchParams?.get("callbackUrl") + ? isOrgInviteByLink + ? `${WEBAPP_URL}/${searchParams.get("callbackUrl")}` + : addOrUpdateQueryParam(`${WEBAPP_URL}/${searchParams.get("callbackUrl")}`, "from", "signup") + : `${WEBAPP_URL}/${verifyOrGettingStarted}?from=signup` + }`; + await signIn<"credentials">("credentials", { ...data, - callbackUrl: `${ - searchParams?.get("callbackUrl") - ? `${WEBAPP_URL}/${searchParams.get("callbackUrl")}` - : `${WEBAPP_URL}/${verifyOrGettingStarted}` - }?from=signup`, + callbackUrl: callBackUrl, }); }) .catch((err) => { @@ -118,21 +142,31 @@ export default function Signup({ prepopulateFormValues, token, orgSlug }: Signup if (methods.formState?.errors?.apiError) { methods.clearErrors("apiError"); } + + if (methods.getValues().username === undefined && isOrgInviteByLink && orgAutoAcceptEmail) { + methods.setValue( + "username", + getOrgUsernameFromEmail(methods.getValues().email, orgAutoAcceptEmail) + ); + } methods.handleSubmit(signUp)(event); }} className="bg-default space-y-6"> {errors.apiError && } + {}
- + {!isOrgInviteByLink && ( + + )} { parent: { select: { slug: true, + metadata: true, }, }, slug: true, @@ -278,15 +313,26 @@ export const getServerSideProps = async (ctx: GetServerSidePropsContext) => { username = available ? username : suggestion || username; } + const isValidEmail = checkValidEmail(verificationToken.identifier); + const isOrgInviteByLink = isOrganization && !isValidEmail; + const parentMetaDataForSubteam = tokenTeam?.parent?.metadata + ? teamMetadataSchema.parse(tokenTeam.parent.metadata) + : null; + return { props: { ...props, token, - prepopulateFormValues: { - email: verificationToken.identifier, - username: slugify(username), - }, + prepopulateFormValues: !isOrgInviteByLink + ? { + email: verificationToken.identifier, + username: slugify(username), + } + : null, orgSlug, + orgAutoAcceptEmail: isOrgInviteByLink + ? tokenTeam?.metadata?.orgAutoAcceptEmail ?? parentMetaDataForSubteam?.orgAutoAcceptEmail ?? null + : null, }, }; }; diff --git a/apps/web/pages/teams/index.tsx b/apps/web/pages/teams/index.tsx index 392c54f59e..4f06f34089 100644 --- a/apps/web/pages/teams/index.tsx +++ b/apps/web/pages/teams/index.tsx @@ -1,6 +1,7 @@ import type { GetServerSidePropsContext } from "next"; import { getLayout } from "@calcom/features/MainLayout"; +import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; import { TeamsListing } from "@calcom/features/ee/teams/components"; import { ShellMain } from "@calcom/features/shell/Shell"; import { WEBAPP_URL } from "@calcom/lib/constants"; @@ -41,6 +42,20 @@ function Teams() { export const getServerSideProps = async (context: GetServerSidePropsContext) => { const ssr = await ssrInit(context); await ssr.viewer.me.prefetch(); + const session = await getServerSession({ req: context.req, res: context.res }); + const token = Array.isArray(context.query?.token) ? context.query.token[0] : context.query?.token; + + const callbackUrl = token ? `/teams?token=${encodeURIComponent(token)}` : null; + + if (!session) { + return { + redirect: { + destination: callbackUrl ? `/auth/login?callbackUrl=${callbackUrl}` : "/auth/login", + permanent: false, + }, + props: {}, + }; + } return { props: { trpcState: ssr.dehydrate() } }; }; diff --git a/packages/features/ee/teams/components/MemberInvitationModal.tsx b/packages/features/ee/teams/components/MemberInvitationModal.tsx index 1ae2d3ccae..020a4a7302 100644 --- a/packages/features/ee/teams/components/MemberInvitationModal.tsx +++ b/packages/features/ee/teams/components/MemberInvitationModal.tsx @@ -42,6 +42,7 @@ type MemberInvitationModalProps = { token?: string; isLoading?: boolean; disableCopyLink?: boolean; + isOrg?: boolean; }; type MembershipRoleOption = { @@ -68,7 +69,7 @@ function toggleElementInArray(value: string[] | string | undefined, element: str export default function MemberInvitationModal(props: MemberInvitationModalProps) { const { t } = useLocale(); - const { disableCopyLink = false } = props; + const { disableCopyLink = false, isOrg = false } = props; const trpcContext = trpc.useContext(); const [modalImportMode, setModalInputMode] = useState( @@ -87,7 +88,12 @@ export default function MemberInvitationModal(props: MemberInvitationModalProps) }); const copyInviteLinkToClipboard = async (token: string) => { - const inviteLink = `${WEBAPP_URL}/teams?token=${token}`; + const isOrgInvite = isOrg; + const teamInviteLink = `${WEBAPP_URL}/teams?token=${token}`; + const orgInviteLink = `${WEBAPP_URL}/signup?token=${token}&callbackUrl=/getting-started`; + + const inviteLink = + isOrgInvite || (props?.orgMembers && props.orgMembers?.length > 0) ? orgInviteLink : teamInviteLink; await navigator.clipboard.writeText(inviteLink); showToast(t("invite_link_copied"), "success"); }; diff --git a/packages/features/ee/teams/pages/team-members-view.tsx b/packages/features/ee/teams/pages/team-members-view.tsx index d2871ea882..fae7208cb5 100644 --- a/packages/features/ee/teams/pages/team-members-view.tsx +++ b/packages/features/ee/teams/pages/team-members-view.tsx @@ -90,13 +90,14 @@ const MembersView = () => { distinctUser: true, }, { - enabled: searchParams !== null, + enabled: searchParams !== null && !!teamId, } ); const { data: team, isLoading: isTeamsLoading } = trpc.viewer.teams.get.useQuery( { teamId }, { + enabled: !!teamId, onError: () => { router.push("/settings"); }, diff --git a/packages/features/users/components/UserTable/InviteMemberModal.tsx b/packages/features/users/components/UserTable/InviteMemberModal.tsx index 9f8f16b880..c4201a3225 100644 --- a/packages/features/users/components/UserTable/InviteMemberModal.tsx +++ b/packages/features/users/components/UserTable/InviteMemberModal.tsx @@ -60,6 +60,7 @@ export function InviteMemberModal(props: Props) { }); }} teamId={orgId} + isOrg={true} justEmailInvites={!!orgId} isLoading={inviteMemberMutation.isLoading} onSubmit={(values) => { diff --git a/packages/lib/server/queries/teams/index.ts b/packages/lib/server/queries/teams/index.ts index c78e937286..2ae65dc038 100644 --- a/packages/lib/server/queries/teams/index.ts +++ b/packages/lib/server/queries/teams/index.ts @@ -170,10 +170,15 @@ export async function getTeamWithMembers(args: { })); /** Don't leak invite tokens to the frontend */ const { inviteTokens, ...teamWithoutInviteTokens } = team; + return { ...teamWithoutInviteTokens, /** To prevent breaking we only return non-email attached token here, if we have one */ - inviteToken: inviteTokens.find((token) => token.identifier === "invite-link-for-teamId-" + team.id), + inviteToken: inviteTokens.find( + (token) => + token.identifier === "invite-link-for-teamId-" + team.id && + token.expires > new Date(new Date().setHours(24)) + ), metadata: teamMetadataSchema.parse(team.metadata), eventTypes, members, diff --git a/packages/trpc/server/routers/viewer/teams/createInvite.handler.ts b/packages/trpc/server/routers/viewer/teams/createInvite.handler.ts index 18f391134f..e7c29847b0 100644 --- a/packages/trpc/server/routers/viewer/teams/createInvite.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/createInvite.handler.ts @@ -24,7 +24,7 @@ export const createInviteHandler = async ({ ctx, input }: CreateInviteOptions) = data: { identifier: "invite-link-for-teamId-" + teamId, token, - expires: new Date(), + expires: new Date(new Date().setHours(168)), // +1 week, teamId, }, });