diff --git a/apps/web/components/ui/UsernameAvailability/PremiumTextfield.tsx b/apps/web/components/ui/UsernameAvailability/PremiumTextfield.tsx index 418133f9d9..903ffc9748 100644 --- a/apps/web/components/ui/UsernameAvailability/PremiumTextfield.tsx +++ b/apps/web/components/ui/UsernameAvailability/PremiumTextfield.tsx @@ -51,7 +51,7 @@ const PremiumTextfield = (props: ICustomUsernameProps) => { const pathname = usePathname(); const router = useRouter(); const { t } = useLocale(); - const { data: session, update } = useSession(); + const { update } = useSession(); const { currentUsername, setCurrentUsername = noop, diff --git a/apps/web/components/ui/UsernameAvailability/UsernameTextfield.tsx b/apps/web/components/ui/UsernameAvailability/UsernameTextfield.tsx index e2c046f7aa..ade47726cb 100644 --- a/apps/web/components/ui/UsernameAvailability/UsernameTextfield.tsx +++ b/apps/web/components/ui/UsernameAvailability/UsernameTextfield.tsx @@ -25,7 +25,7 @@ interface ICustomUsernameProps { const UsernameTextfield = (props: ICustomUsernameProps & Partial>) => { const { t } = useLocale(); - const { data: session, update } = useSession(); + const { update } = useSession(); const { currentUsername, diff --git a/apps/web/components/ui/UsernameAvailability/index.tsx b/apps/web/components/ui/UsernameAvailability/index.tsx index b96ab4a464..a2fe787f52 100644 --- a/apps/web/components/ui/UsernameAvailability/index.tsx +++ b/apps/web/components/ui/UsernameAvailability/index.tsx @@ -1,26 +1,27 @@ +import dynamic from "next/dynamic"; import { useSearchParams } from "next/navigation"; import { useState } from "react"; import { Controller, useForm } from "react-hook-form"; import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider"; -import { IS_SELF_HOSTED } from "@calcom/lib/constants"; -import { CAL_URL } from "@calcom/lib/constants"; +import { CAL_URL, IS_SELF_HOSTED } from "@calcom/lib/constants"; import type { TRPCClientErrorLike } from "@calcom/trpc/client"; import { trpc } from "@calcom/trpc/react"; import type { AppRouter } from "@calcom/trpc/server/routers/_app"; import useRouterQuery from "@lib/hooks/useRouterQuery"; -import { PremiumTextfield } from "./PremiumTextfield"; -import { UsernameTextfield } from "./UsernameTextfield"; - -export const UsernameAvailability = IS_SELF_HOSTED ? UsernameTextfield : PremiumTextfield; - interface UsernameAvailabilityFieldProps { onSuccessMutation?: () => void; onErrorMutation?: (error: TRPCClientErrorLike) => void; } +export const getUsernameAvailabilityComponent = (isPremium: boolean) => { + if (isPremium) + return dynamic(() => import("./PremiumTextfield").then((m) => m.PremiumTextfield), { ssr: false }); + return dynamic(() => import("./UsernameTextfield").then((m) => m.UsernameTextfield), { ssr: false }); +}; + export const UsernameAvailabilityField = ({ onSuccessMutation, onErrorMutation, @@ -39,6 +40,7 @@ export const UsernameAvailabilityField = ({ }, }); + const UsernameAvailability = getUsernameAvailabilityComponent(!IS_SELF_HOSTED && !user.organization?.id); const orgBranding = useOrgBranding(); const usernamePrefix = orgBranding @@ -59,6 +61,7 @@ export const UsernameAvailabilityField = ({ setInputUsernameValue={onChange} onSuccessMutation={onSuccessMutation} onErrorMutation={onErrorMutation} + disabled={!!user.organization?.id} addOnLeading={`${usernamePrefix}/`} /> ); diff --git a/apps/web/pages/api/auth/signup.ts b/apps/web/pages/api/auth/signup.ts index 19519b5b89..150d027921 100644 --- a/apps/web/pages/api/auth/signup.ts +++ b/apps/web/pages/api/auth/signup.ts @@ -8,7 +8,7 @@ import { sendEmailVerification } from "@calcom/features/auth/lib/verifyEmail"; import { IS_CALCOM } from "@calcom/lib/constants"; import slugify from "@calcom/lib/slugify"; import { closeComUpsertTeamUser } from "@calcom/lib/sync/SyncServiceManager"; -import { validateUsernameInOrg } from "@calcom/lib/validateUsernameInOrg"; +import { validateUsernameInTeam, validateUsername } from "@calcom/lib/validateUsername"; import prisma from "@calcom/prisma"; import { IdentityProvider } from "@calcom/prisma/enums"; import { teamMetadataSchema } from "@calcom/prisma/zod-utils"; @@ -39,6 +39,19 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) const username = slugify(data.username); const userEmail = email.toLowerCase(); + const validationResponse = ( + incomingEmail: string, + validation: { isValid: boolean; email: string | undefined } + ) => { + const { isValid, email } = validation; + if (!isValid) { + const message: string = + email !== incomingEmail ? "Username already taken" : "Email address is already registered"; + + return res.status(409).json({ message }); + } + }; + if (!username) { res.status(422).json({ message: "Invalid username" }); return; @@ -65,42 +78,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) return res.status(401).json({ message: "Token expired" }); } if (foundToken?.teamId) { - const isValidUsername = await validateUsernameInOrg(username, foundToken?.teamId); - - if (!isValidUsername) { - return res.status(409).json({ message: "Username already taken" }); - } + const teamUserValidation = await validateUsernameInTeam(username, userEmail, foundToken?.teamId); + return validationResponse(userEmail, teamUserValidation); } } else { - // There is an existingUser if the username matches - // OR if the email matches AND either the email is verified - // or both username and password are set - const existingUser = await prisma.user.findFirst({ - where: { - OR: [ - { username }, - { - AND: [ - { email: userEmail }, - { - OR: [ - { emailVerified: { not: null } }, - { - AND: [{ password: { not: null } }, { username: { not: null } }], - }, - ], - }, - ], - }, - ], - }, - }); - if (existingUser) { - const message: string = - existingUser.email !== userEmail ? "Username already taken" : "Email address is already registered"; - - return res.status(409).json({ message }); - } + const userValidation = await validateUsername(username, userEmail); + return validationResponse(userEmail, userValidation); } const hashedPassword = await hashPassword(password); diff --git a/apps/web/pages/signup.tsx b/apps/web/pages/signup.tsx index 484c76780f..ea0c0a0974 100644 --- a/apps/web/pages/signup.tsx +++ b/apps/web/pages/signup.tsx @@ -14,6 +14,7 @@ import { useFlagMap } from "@calcom/features/flags/context/provider"; import { getFeatureFlagMap } from "@calcom/features/flags/server/utils"; import { IS_SELF_HOSTED, WEBAPP_URL } from "@calcom/lib/constants"; import { useLocale } from "@calcom/lib/hooks/useLocale"; +import slugify from "@calcom/lib/slugify"; import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry"; import { teamMetadataSchema } from "@calcom/prisma/zod-utils"; import type { inferSSRProps } from "@calcom/types/inferSSRProps"; @@ -130,10 +131,11 @@ export default function Signup({ prepopulateFormValues, token, orgSlug }: Signup { }; if (process.env.NEXT_PUBLIC_DISABLE_SIGNUP === "true" || flags["disable-signup"]) { - console.log({ flag: flags["disable-signup"] }); - return { notFound: true, }; @@ -210,6 +210,20 @@ export const getServerSideProps = async (ctx: GetServerSidePropsContext) => { where: { token, }, + include: { + team: { + select: { + metadata: true, + parentId: true, + parent: { + select: { + slug: true, + }, + }, + slug: true, + }, + }, + }, }); if (!verificationToken || verificationToken.expires < new Date()) { @@ -249,41 +263,35 @@ export const getServerSideProps = async (ctx: GetServerSidePropsContext) => { let username = guessUsernameFromEmail(verificationToken.identifier); - const orgInfo = await prisma.user.findFirst({ - where: { - email: verificationToken?.identifier, - }, - select: { - organization: { - select: { - slug: true, - metadata: true, - }, - }, - }, - }); + const tokenTeam = { + ...verificationToken?.team, + metadata: teamMetadataSchema.parse(verificationToken?.team?.metadata), + }; - const userOrgMetadata = teamMetadataSchema.parse(orgInfo?.organization?.metadata ?? {}); + // 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; + // If we are dealing with an org, the slug may come from the team itself or its parent + const orgSlug = isOrganization + ? tokenTeam.slug || tokenTeam.metadata?.requestedSlug || tokenTeam.parent?.slug + : null; - if (!IS_SELF_HOSTED) { + // Org context shouldn't check if a username is premium + if (!IS_SELF_HOSTED && !isOrganization) { // 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); username = available ? username : suggestion || username; } - // Transform all + to - in username - username = username.replace(/\+/g, "-"); - return { props: { ...props, token, prepopulateFormValues: { email: verificationToken.identifier, - username, + username: slugify(username), }, - orgSlug: (orgInfo?.organization?.slug || userOrgMetadata?.requestedSlug) ?? null, + orgSlug, }, }; }; diff --git a/packages/features/ee/organizations/lib/orgDomains.ts b/packages/features/ee/organizations/lib/orgDomains.ts index a405debae7..646d0b747a 100644 --- a/packages/features/ee/organizations/lib/orgDomains.ts +++ b/packages/features/ee/organizations/lib/orgDomains.ts @@ -48,7 +48,7 @@ export function subdomainSuffix() { } export function getOrgFullDomain(slug: string, options: { protocol: boolean } = { protocol: true }) { - return `${options.protocol ? `${new URL(WEBAPP_URL).protocol}//` : ""}${slug}.${subdomainSuffix()}`; + return `${options.protocol ? `${new URL(WEBAPP_URL).protocol}//` : ""}${slug}.${subdomainSuffix()}/`; } export function getSlugOrRequestedSlug(slug: string) { diff --git a/packages/lib/slugify.ts b/packages/lib/slugify.ts index 70b5191043..93e76658c9 100644 --- a/packages/lib/slugify.ts +++ b/packages/lib/slugify.ts @@ -7,7 +7,8 @@ export const slugify = (str: string) => { .replace(/[^\p{L}\p{N}\p{Zs}\p{Emoji}]+/gu, "-") // Replace any non-alphanumeric characters (including Unicode) with a dash .replace(/[\s_]+/g, "-") // Replace whitespace and underscores with a single dash .replace(/^-+/, "") // Remove dashes from start - .replace(/-+$/, ""); // Remove dashes from end + .replace(/-+$/, "") // Remove dashes from end + .replace(/\+/g, "-"); // Transform all + to - }; export default slugify; diff --git a/packages/lib/validateUsername.ts b/packages/lib/validateUsername.ts new file mode 100644 index 0000000000..12acb7572a --- /dev/null +++ b/packages/lib/validateUsername.ts @@ -0,0 +1,61 @@ +import prisma from "@calcom/prisma"; +import { teamMetadataSchema } from "@calcom/prisma/zod-utils"; + +export const validateUsername = async (username: string, email: string, organizationId?: number) => { + // There is an existingUser if, within an org context or not, the username matches + // OR if the email matches AND either the email is verified + // or both username and password are set + const existingUser = await prisma.user.findFirst({ + where: { + ...(organizationId ? { organizationId } : {}), + OR: [ + { username }, + { + AND: [ + { email }, + { + OR: [ + { emailVerified: { not: null } }, + { + AND: [{ password: { not: null } }, { username: { not: null } }], + }, + ], + }, + ], + }, + ], + }, + select: { + email: true, + }, + }); + return { isValid: !existingUser, email: existingUser?.email }; +}; + +export const validateUsernameInTeam = async (username: string, email: string, teamId: number) => { + try { + const team = await prisma.team.findFirst({ + where: { + id: teamId, + }, + select: { + metadata: true, + parentId: true, + }, + }); + + const teamData = { ...team, metadata: teamMetadataSchema.parse(team?.metadata) }; + + if (teamData.metadata?.isOrganization || teamData.parentId) { + // Organization context -> org-context username check + const orgId = teamData.parentId || teamId; + return validateUsername(username, email, orgId); + } else { + // Regular team context -> regular username check + return validateUsername(username, email); + } + } catch (error) { + console.error(error); + return { isValid: false, email: undefined }; + } +}; diff --git a/packages/lib/validateUsernameInOrg.ts b/packages/lib/validateUsernameInOrg.ts deleted file mode 100644 index 7505b3ba2a..0000000000 --- a/packages/lib/validateUsernameInOrg.ts +++ /dev/null @@ -1,58 +0,0 @@ -import prisma from "@calcom/prisma"; - -import slugify from "./slugify"; - -/** Scenarios: - * 1 org 1 child team: - * 1 org 2+ child teams: - * 1 org 1 child team and 1 child team of first child team: Is this supported? - */ - -export const validateUsernameInOrg = async (usernameSlug: string, teamId: number): Promise => { - try { - let takenSlugs = []; - const teamsFound = await prisma.team.findMany({ - where: { - OR: [{ id: teamId }, { parentId: teamId }], - }, - select: { - slug: true, - parentId: true, - }, - }); - - const usersFound = await prisma.user.findMany({ - where: { - organizationId: teamId, - }, - select: { - username: true, - }, - }); - - takenSlugs = usersFound.map((user) => user.username); - - // If only one team is found and it has a parent, then it's an child team - // and we can use the parent id to find all the teams that belong to this org - if (teamsFound && teamsFound.length === 1 && teamsFound[0].parentId) { - // Let's find all the teams that belong to this org - const childTeams = await prisma.team.findMany({ - where: { - // With this we include org team slug and child teams slugs - OR: [{ id: teamsFound[0].parentId }, { parentId: teamsFound[0].parentId }], - }, - select: { - slug: true, - }, - }); - takenSlugs = takenSlugs.concat(childTeams.map((team) => team.slug)); - } else { - takenSlugs = takenSlugs.concat(teamsFound.map((team) => team.slug)); - } - - return !takenSlugs.includes(slugify(usernameSlug)); - } catch (error) { - console.error(error); - return false; - } -};