From 4b16860d07fb595d7f3c45cdbc8c0461bdf0a017 Mon Sep 17 00:00:00 2001 From: Hariom Balhara Date: Tue, 19 Dec 2023 08:14:48 +0530 Subject: [PATCH] fix: Correctly prefill username in case of non-org email invite in an org (#12854) * fix: Correctly prefill username in case of non-org email invite in an org * Update test --------- Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com> --- .../UsernameAvailability/PremiumTextfield.tsx | 3 +- .../UsernameTextfield.tsx | 3 +- apps/web/pages/api/username.ts | 9 +++- apps/web/pages/signup.tsx | 24 +++++++--- .../organization-invitation.e2e.ts | 46 +++++++++++++++++-- packages/lib/fetchUsername.ts | 3 +- 6 files changed, 73 insertions(+), 15 deletions(-) diff --git a/apps/web/components/ui/UsernameAvailability/PremiumTextfield.tsx b/apps/web/components/ui/UsernameAvailability/PremiumTextfield.tsx index fb33a0f899cf5d..8f746a1726ba14 100644 --- a/apps/web/components/ui/UsernameAvailability/PremiumTextfield.tsx +++ b/apps/web/components/ui/UsernameAvailability/PremiumTextfield.tsx @@ -74,7 +74,8 @@ const PremiumTextfield = (props: ICustomUsernameProps) => { const debouncedApiCall = useMemo( () => debounce(async (username: string) => { - const { data } = await fetchUsername(username); + // TODO: Support orgSlug + const { data } = await fetchUsername(username, null); setMarkAsError(!data.available && !!currentUsername && username !== currentUsername); setIsInputUsernamePremium(data.premium); setUsernameIsAvailable(data.available); diff --git a/apps/web/components/ui/UsernameAvailability/UsernameTextfield.tsx b/apps/web/components/ui/UsernameAvailability/UsernameTextfield.tsx index 44421edbb96da6..a3a14bf4c226b7 100644 --- a/apps/web/components/ui/UsernameAvailability/UsernameTextfield.tsx +++ b/apps/web/components/ui/UsernameAvailability/UsernameTextfield.tsx @@ -44,7 +44,8 @@ const UsernameTextfield = (props: ICustomUsernameProps & Partial debounce(async (username) => { - const { data } = await fetchUsername(username); + // TODO: Support orgSlug + const { data } = await fetchUsername(username, null); setMarkAsError(!data.available); setUsernameIsAvailable(data.available); }, 150), diff --git a/apps/web/pages/api/username.ts b/apps/web/pages/api/username.ts index ea34666f7fc94d..19315208a0e5b6 100644 --- a/apps/web/pages/api/username.ts +++ b/apps/web/pages/api/username.ts @@ -1,4 +1,5 @@ import type { NextApiRequest, NextApiResponse } from "next"; +import { z } from "zod"; import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains"; import { checkUsername } from "@calcom/lib/server/checkUsername"; @@ -8,8 +9,14 @@ type Response = { premium: boolean; }; +const bodySchema = z.object({ + username: z.string(), + orgSlug: z.string().optional(), +}); + export default async function handler(req: NextApiRequest, res: NextApiResponse): Promise { const { currentOrgDomain } = orgDomainConfig(req); - const result = await checkUsername(req.body.username, currentOrgDomain); + const { username, orgSlug } = bodySchema.parse(req.body); + const result = await checkUsername(username, currentOrgDomain || orgSlug); return res.status(200).json(result); } diff --git a/apps/web/pages/signup.tsx b/apps/web/pages/signup.tsx index 080a767d52061c..10a977fdc37f77 100644 --- a/apps/web/pages/signup.tsx +++ b/apps/web/pages/signup.tsx @@ -73,6 +73,7 @@ function UsernameField({ setPremium, premium, setUsernameTaken, + orgSlug, usernameTaken, ...props }: React.ComponentProps & { @@ -80,6 +81,7 @@ function UsernameField({ setPremium: (value: boolean) => void; premium: boolean; usernameTaken: boolean; + orgSlug?: string; setUsernameTaken: (value: boolean) => void; }) { const { t } = useLocale(); @@ -95,7 +97,7 @@ function UsernameField({ setUsernameTaken(false); return; } - fetchUsername(debouncedUsername).then(({ data }) => { + fetchUsername(debouncedUsername, orgSlug ?? null).then(({ data }) => { setPremium(data.premium); setUsernameTaken(!data.available); }); @@ -276,6 +278,7 @@ export default function Signup({ {/* Username */} {!isOrgInviteByLink ? ( { metadata: teamMetadataSchema.parse(verificationToken?.team?.metadata), }; + const isATeamInOrganization = tokenTeam?.parentId !== null; + const isOrganization = tokenTeam.metadata?.isOrganization; // Detect if the team is an org by either the metadata flag or if it has a parent team - const isOrganization = tokenTeam.metadata?.isOrganization || tokenTeam?.parentId !== null; + const isOrganizationOrATeamInOrganization = isOrganization || isATeamInOrganization; // If we are dealing with an org, the slug may come from the team itself or its parent - const orgSlug = isOrganization + const orgSlug = isOrganizationOrATeamInOrganization ? tokenTeam.metadata?.requestedSlug || tokenTeam.parent?.slug || tokenTeam.slug : null; // Org context shouldn't check if a username is premium - if (!IS_SELF_HOSTED && !isOrganization) { + if (!IS_SELF_HOSTED && !isOrganizationOrATeamInOrganization) { // Im not sure we actually hit this because of next redirects signup to website repo - but just in case this is pretty cool :) const { available, suggestion } = await checkPremiumUsername(username); @@ -626,7 +631,7 @@ export const getServerSideProps = async (ctx: GetServerSidePropsContext) => { } const isValidEmail = checkValidEmail(verificationToken.identifier); - const isOrgInviteByLink = isOrganization && !isValidEmail; + const isOrgInviteByLink = isOrganizationOrATeamInOrganization && !isValidEmail; const parentMetaDataForSubteam = tokenTeam?.parent?.metadata ? teamMetadataSchema.parse(tokenTeam.parent.metadata) : null; @@ -638,7 +643,14 @@ export const getServerSideProps = async (ctx: GetServerSidePropsContext) => { prepopulateFormValues: !isOrgInviteByLink ? { email: verificationToken.identifier, - username: slugify(username), + username: isOrganizationOrATeamInOrganization + ? getOrgUsernameFromEmail( + verificationToken.identifier, + (isOrganization + ? tokenTeam.metadata?.orgAutoAcceptEmail + : parentMetaDataForSubteam?.orgAutoAcceptEmail) || "" + ) + : slugify(username), } : null, orgSlug, diff --git a/apps/web/playwright/organization/organization-invitation.e2e.ts b/apps/web/playwright/organization/organization-invitation.e2e.ts index f51ce3470e2410..f931baf9d77b2a 100644 --- a/apps/web/playwright/organization/organization-invitation.e2e.ts +++ b/apps/web/playwright/organization/organization-invitation.e2e.ts @@ -45,7 +45,12 @@ test.describe.serial("Organization", () => { }); assertInviteLink(inviteLink); - await signupFromEmailInviteLink(browser, inviteLink); + await signupFromEmailInviteLink({ + browser, + inviteLink, + expectedEmail: invitedUserEmail, + expectedUsername: usernameDerivedFromEmail, + }); const dbUser = await prisma.user.findUnique({ where: { email: invitedUserEmail } }); expect(dbUser?.username).toBe(usernameDerivedFromEmail); @@ -118,7 +123,12 @@ test.describe.serial("Organization", () => { assertInviteLink(inviteLink); - await signupFromEmailInviteLink(browser, inviteLink); + await signupFromEmailInviteLink({ + browser, + inviteLink, + expectedEmail: invitedUserEmail, + expectedUsername: usernameDerivedFromEmail, + }); const dbUser = await prisma.user.findUnique({ where: { email: invitedUserEmail } }); expect(dbUser?.username).toBe(usernameDerivedFromEmail); @@ -200,7 +210,12 @@ test.describe.serial("Organization", () => { }); assertInviteLink(inviteLink); - await signupFromEmailInviteLink(browser, inviteLink); + await signupFromEmailInviteLink({ + browser, + inviteLink, + expectedEmail: invitedUserEmail, + expectedUsername: usernameDerivedFromEmail, + }); const dbUser = await prisma.user.findUnique({ where: { email: invitedUserEmail } }); expect(dbUser?.username).toBe(usernameDerivedFromEmail); @@ -276,7 +291,12 @@ test.describe.serial("Organization", () => { assertInviteLink(inviteLink); - await signupFromEmailInviteLink(browser, inviteLink); + await signupFromEmailInviteLink({ + browser, + inviteLink, + expectedEmail: invitedUserEmail, + expectedUsername: usernameDerivedFromEmail, + }); const dbUser = await prisma.user.findUnique({ where: { email: invitedUserEmail } }); expect(dbUser?.username).toBe(usernameDerivedFromEmail); @@ -356,14 +376,30 @@ async function signupFromInviteLink({ return { email }; } -async function signupFromEmailInviteLink(browser: Browser, inviteLink: string) { +async function signupFromEmailInviteLink({ + browser, + inviteLink, + expectedUsername, + expectedEmail, +}: { + browser: Browser; + inviteLink: string; + expectedUsername: string; + expectedEmail: string; +}) { // Follow invite link in new window const context = await browser.newContext(); const signupPage = await context.newPage(); signupPage.goto(inviteLink); + await signupPage.waitForLoadState("networkidle"); await expect(signupPage.locator(`[data-testid="signup-usernamefield"]`)).toBeDisabled(); + expect(await signupPage.locator(`[data-testid="signup-usernamefield"]`).inputValue()).toBe( + expectedUsername + ); await expect(signupPage.locator(`[data-testid="signup-emailfield"]`)).toBeDisabled(); + expect(await signupPage.locator(`[data-testid="signup-emailfield"]`).inputValue()).toBe(expectedEmail); + await signupPage.waitForLoadState("networkidle"); // Check required fields await signupPage.locator("input[name=password]").fill(`P4ssw0rd!`); diff --git a/packages/lib/fetchUsername.ts b/packages/lib/fetchUsername.ts index e33aedad461671..e94ec32d5da65a 100644 --- a/packages/lib/fetchUsername.ts +++ b/packages/lib/fetchUsername.ts @@ -5,12 +5,13 @@ type ResponseUsernameApi = { suggestion?: string; }; -export async function fetchUsername(username: string) { +export async function fetchUsername(username: string, orgSlug: string | null) { const response = await fetch("/api/username", { credentials: "include", method: "POST", body: JSON.stringify({ username: username.trim(), + orgSlug: orgSlug ?? undefined, }), headers: { "Content-Type": "application/json",