fix: org invite (#11356)
Co-authored-by: Leo Giovanetti <hello@leog.me>
This commit is contained in:
co-authored by
Leo Giovanetti
parent
45cded6b48
commit
50970dc249
@@ -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
@@ -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,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user