fix: org invite (#11356)

Co-authored-by: Leo Giovanetti <hello@leog.me>
This commit is contained in:
Udit Takkar
2023-09-19 15:46:33 +05:30
committed by GitHub
co-authored by Leo Giovanetti
parent 45cded6b48
commit 50970dc249
8 changed files with 122 additions and 46 deletions
+23 -21
View File
@@ -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,
+66 -20
View File
@@ -34,7 +34,25 @@ type FormValues = z.infer<typeof signupSchema>;
type SignupProps = inferSSRProps<typeof getServerSideProps>;
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<FormValues> = 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 && <Alert severity="error" message={errors.apiError?.message} />}
{}
<div className="space-y-4">
<TextField
addOnLeading={
orgSlug
? `${getOrgFullDomain(orgSlug, { protocol: true })}/`
: `${process.env.NEXT_PUBLIC_WEBSITE_URL}/`
}
{...register("username")}
disabled={!!orgSlug}
required
/>
{!isOrgInviteByLink && (
<TextField
addOnLeading={
orgSlug
? `${getOrgFullDomain(orgSlug, { protocol: true })}/`
: `${process.env.NEXT_PUBLIC_WEBSITE_URL}/`
}
{...register("username")}
disabled={!!orgSlug}
required
/>
)}
<EmailField
{...register("email")}
disabled={prepopulateFormValues?.email}
@@ -213,6 +247,7 @@ export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
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,
},
};
};
+15
View File
@@ -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() } };
};
@@ -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<ModalMode>(
@@ -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");
};
@@ -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");
},
@@ -60,6 +60,7 @@ export function InviteMemberModal(props: Props) {
});
}}
teamId={orgId}
isOrg={true}
justEmailInvites={!!orgId}
isLoading={inviteMemberMutation.isLoading}
onSubmit={(values) => {
+6 -1
View File
@@ -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,
@@ -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,
},
});