fix: org username check [CAL-2352] (#10773)

Co-authored-by: zomars <zomars@me.com>
pull/9195/head
Leo Giovanetti 2023-08-18 18:03:55 -03:00 committed by GitHub
parent e8e0d03265
commit 8995dcc82a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 125 additions and 127 deletions

View File

@ -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,

View File

@ -25,7 +25,7 @@ interface ICustomUsernameProps {
const UsernameTextfield = (props: ICustomUsernameProps & Partial<React.ComponentProps<typeof TextField>>) => {
const { t } = useLocale();
const { data: session, update } = useSession();
const { update } = useSession();
const {
currentUsername,

View File

@ -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<AppRouter>) => 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}/`}
/>
);

View File

@ -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);

View File

@ -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
<TextField
addOnLeading={
orgSlug
? getOrgFullDomain(orgSlug, { protocol: false })
? getOrgFullDomain(orgSlug, { protocol: true })
: `${process.env.NEXT_PUBLIC_WEBSITE_URL}/`
}
{...register("username")}
disabled={!!orgSlug}
required
/>
<EmailField
@ -192,8 +194,6 @@ export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
};
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,
},
};
};

View File

@ -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) {

View File

@ -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;

View File

@ -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 };
}
};

View File

@ -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<boolean> => {
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;
}
};