Merge commit '20f17903b9efef194d6846616bab4e67d5fc1a59' into teste2e-bookCollective

This commit is contained in:
gitstart-calcom 2023-11-28 17:49:08 +00:00
commit da3b51c974
52 changed files with 1936 additions and 519 deletions

View File

@ -1,218 +1,53 @@
import type { NextApiRequest, NextApiResponse } from "next";
import type { NextApiResponse } from "next";
import dayjs from "@calcom/dayjs";
import { checkPremiumUsername } from "@calcom/ee/common/lib/checkPremiumUsername";
import { hashPassword } from "@calcom/features/auth/lib/hashPassword";
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 { 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";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== "POST") {
return res.status(405).end();
}
import calcomSignupHandler from "@calcom/feature-auth/signup/handlers/calcomHandler";
import selfHostedSignupHandler from "@calcom/feature-auth/signup/handlers/selfHostedHandler";
import { type RequestWithUsernameStatus } from "@calcom/features/auth/signup/username";
import { IS_PREMIUM_USERNAME_ENABLED } from "@calcom/lib/constants";
import { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
function ensureSignupIsEnabled() {
if (process.env.NEXT_PUBLIC_DISABLE_SIGNUP === "true") {
res.status(403).json({ message: "Signup is disabled" });
return;
}
const data = req.body;
const { email, password, language, token } = signupSchema.parse(data);
const username = slugify(data.username);
const userEmail = email.toLowerCase();
if (!username) {
res.status(422).json({ message: "Invalid username" });
return;
}
let foundToken: { id: number; teamId: number | null; expires: Date } | null = null;
if (token) {
foundToken = await prisma.verificationToken.findFirst({
where: {
token,
},
select: {
id: true,
expires: true,
teamId: true,
},
});
if (!foundToken) {
return res.status(401).json({ message: "Invalid Token" });
}
if (dayjs(foundToken?.expires).isBefore(dayjs())) {
return res.status(401).json({ message: "Token expired" });
}
if (foundToken?.teamId) {
const teamUserValidation = await validateUsernameInTeam(username, userEmail, foundToken?.teamId);
if (!teamUserValidation.isValid) {
return res.status(409).json({ message: "Username or email is already taken" });
}
}
} else {
const userValidation = await validateUsername(username, userEmail);
if (!userValidation.isValid) {
return res.status(409).json({ message: "Username or email is already taken" });
}
}
const hashedPassword = await hashPassword(password);
if (foundToken && foundToken?.teamId) {
const team = await prisma.team.findUnique({
where: {
id: foundToken.teamId,
},
});
if (team) {
const teamMetadata = teamMetadataSchema.parse(team?.metadata);
if (IS_CALCOM && (!teamMetadata?.isOrganization || !!team.parentId)) {
const checkUsername = await checkPremiumUsername(username);
if (checkUsername.premium) {
// This signup page is ONLY meant for team invites and local setup. Not for every day users.
// In singup redesign/refactor coming up @sean will tackle this to make them the same API/page instead of two.
return res.status(422).json({
message: "Sign up from https://cal.com/signup to claim your premium username",
});
}
}
// 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: {
username,
password: hashedPassword,
emailVerified: new Date(Date.now()),
identityProvider: IdentityProvider.CAL,
organizationId: orgId,
},
create: {
username,
email: userEmail,
password: hashedPassword,
identityProvider: IdentityProvider.CAL,
organizationId: orgId,
},
});
const membership = await prisma.membership.upsert({
where: {
userId_teamId: { userId: user.id, teamId: team.id },
},
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 and create a membership for the org itself
if (team.parentId) {
// Create (when invite link is used) or Update (when regular email invitation is used) membership for the organization itself
await prisma.membership.upsert({
where: {
userId_teamId: { userId: user.id, teamId: team.parentId },
},
update: {
accepted: true,
},
create: {
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
await prisma.membership.updateMany({
where: {
userId: user.id,
team: {
id: team.parentId,
},
accepted: false,
},
data: {
accepted: true,
},
});
// Join any other invites
await prisma.membership.updateMany({
where: {
userId: user.id,
team: {
parentId: team.parentId,
},
accepted: false,
},
data: {
accepted: true,
},
});
}
}
// Cleanup token after use
await prisma.verificationToken.delete({
where: {
id: foundToken.id,
},
});
} else {
if (IS_CALCOM) {
const checkUsername = await checkPremiumUsername(username);
if (checkUsername.premium) {
res.status(422).json({
message: "Sign up from https://cal.com/signup to claim your premium username",
});
return;
}
}
await prisma.user.upsert({
where: { email: userEmail },
update: {
username,
password: hashedPassword,
emailVerified: new Date(Date.now()),
identityProvider: IdentityProvider.CAL,
},
create: {
username,
email: userEmail,
password: hashedPassword,
identityProvider: IdentityProvider.CAL,
},
});
await sendEmailVerification({
email: userEmail,
username,
language,
throw new HttpError({
statusCode: 403,
message: "Signup is disabled",
});
}
res.status(201).json({ message: "Created user" });
}
function ensureReqIsPost(req: RequestWithUsernameStatus) {
if (req.method !== "POST") {
throw new HttpError({
statusCode: 405,
message: "Method not allowed",
});
}
}
export default async function handler(req: RequestWithUsernameStatus, res: NextApiResponse) {
// Use a try catch instead of returning res every time
try {
ensureReqIsPost(req);
ensureSignupIsEnabled();
/**
* Im not sure its worth merging these two handlers. They are different enough to be separate.
* Calcom handles things like creating a stripe customer - which we don't need to do for self hosted.
* It also handles things like premium username.
* TODO: (SEAN) - Extract a lot of the logic from calcomHandler into a separate file and import it into both handlers.
* @zomars: We need to be able to test this with E2E. They way it's done RN it will never run on CI.
*/
if (IS_PREMIUM_USERNAME_ENABLED) {
return await calcomSignupHandler(req, res);
}
return await selfHostedSignupHandler(req, res);
} catch (e) {
if (e instanceof HttpError) {
return res.status(e.statusCode).json({ message: e.message });
}
logger.error(e);
return res.status(500).json({ message: "Internal server error" });
}
}

View File

@ -98,9 +98,9 @@ inferSSRProps<typeof _getServerSideProps> & WithNonceProps<{}>) {
callbackUrl = safeCallbackUrl || "";
const LoginFooter = (
<a href={`${WEBSITE_URL}/signup`} className="text-brand-500 font-medium">
<Link href={`${WEBSITE_URL}/signup`} className="text-brand-500 font-medium">
{t("dont_have_an_account")}
</a>
</Link>
);
const TwoFactorFooter = (

View File

@ -9,6 +9,7 @@ import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomain
import stripe from "@calcom/features/ee/payments/server/stripe";
import { hostedCal, isSAMLLoginEnabled, samlProductID, samlTenantID } from "@calcom/features/ee/sso/lib/saml";
import { ssoTenantProduct } from "@calcom/features/ee/sso/lib/sso";
import { IS_PREMIUM_USERNAME_ENABLED } from "@calcom/lib/constants";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { checkUsername } from "@calcom/lib/server/checkUsername";
import prisma from "@calcom/prisma";
@ -72,7 +73,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
// Validating if username is Premium, while this is true an email its required for stripe user confirmation
if (usernameParam && session.user.email) {
const availability = await checkUsername(usernameParam, currentOrgDomain);
if (availability.available && availability.premium) {
if (availability.available && availability.premium && IS_PREMIUM_USERNAME_ENABLED) {
const stripePremiumUrl = await getStripePremiumUsernameUrl({
userEmail: session.user.email,
username: usernameParam,

View File

@ -1,25 +1,34 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { CalendarHeart, Info, Link2, ShieldCheckIcon, StarIcon, Users } from "lucide-react";
import type { GetServerSidePropsContext } from "next";
import { signIn } from "next-auth/react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import type { CSSProperties } from "react";
import { useState, useEffect } from "react";
import type { SubmitHandler } from "react-hook-form";
import { FormProvider, useForm } from "react-hook-form";
import { useForm, useFormContext } from "react-hook-form";
import { z } from "zod";
import getStripe from "@calcom/app-store/stripepayment/lib/client";
import { getPremiumPlanPriceValue } from "@calcom/app-store/stripepayment/lib/utils";
import { checkPremiumUsername } from "@calcom/features/ee/common/lib/checkPremiumUsername";
import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains";
import { isSAMLLoginEnabled } from "@calcom/features/ee/sso/lib/saml";
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 { classNames } from "@calcom/lib";
import { APP_NAME, IS_CALCOM, IS_SELF_HOSTED, WEBAPP_URL, WEBSITE_URL } from "@calcom/lib/constants";
import { fetchUsername } from "@calcom/lib/fetchUsername";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useDebounce } from "@calcom/lib/hooks/useDebounce";
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 { signupSchema as apiSignupSchema } from "@calcom/prisma/zod-utils";
import type { inferSSRProps } from "@calcom/types/inferSSRProps";
import { Alert, Button, EmailField, HeadSeo, PasswordField, TextField } from "@calcom/ui";
import { Button, HeadSeo, PasswordField, TextField, Form, Alert } from "@calcom/ui";
import PageWrapper from "@components/PageWrapper";
@ -34,7 +43,29 @@ type FormValues = z.infer<typeof signupSchema>;
type SignupProps = inferSSRProps<typeof getServerSideProps>;
const checkValidEmail = (email: string) => z.string().email().safeParse(email).success;
const FEATURES = [
{
title: "connect_all_calendars",
description: "connect_all_calendars_description",
i18nOptions: {
appName: APP_NAME,
},
icon: CalendarHeart,
},
{
title: "set_availability",
description: "set_availbility_description",
icon: Users,
},
{
title: "share_a_link_or_embed",
description: "share_a_link_or_embed_description",
icon: Link2,
i18nOptions: {
appName: APP_NAME,
},
},
];
const getOrgUsernameFromEmail = (email: string, autoAcceptEmailDomain: string) => {
const [emailUser, emailDomain = ""] = email.split("@");
@ -46,31 +77,124 @@ const getOrgUsernameFromEmail = (email: string, autoAcceptEmailDomain: string) =
return username;
};
function UsernameField({
username,
setPremium,
premium,
setUsernameTaken,
usernameTaken,
...props
}: React.ComponentProps<typeof TextField> & {
username: string;
setPremium: (value: boolean) => void;
premium: boolean;
usernameTaken: boolean;
setUsernameTaken: (value: boolean) => void;
}) {
const { t } = useLocale();
const { register, formState } = useFormContext<FormValues>();
const debouncedUsername = useDebounce(username, 600);
useEffect(() => {
if (formState.isSubmitting || formState.isSubmitSuccessful) return;
async function checkUsername() {
if (!debouncedUsername) {
setPremium(false);
setUsernameTaken(false);
return;
}
fetchUsername(debouncedUsername).then(({ data }) => {
setPremium(data.premium);
setUsernameTaken(!data.available);
});
}
checkUsername();
}, [debouncedUsername, setPremium, setUsernameTaken, formState.isSubmitting, formState.isSubmitSuccessful]);
return (
<div>
<TextField
{...props}
{...register("username")}
data-testid="signup-usernamefield"
addOnFilled={false}
/>
{(!formState.isSubmitting || !formState.isSubmitted) && (
<div className="text-gray text-default flex items-center text-sm">
<p className="flex items-center text-sm ">
{usernameTaken ? (
<div className="text-error">
<Info className="mr-1 inline-block h-4 w-4" />
{t("already_in_use_error")}
</div>
) : premium ? (
<div data-testid="premium-username-warning">
<StarIcon className="mr-1 inline-block h-4 w-4" />
{t("premium_username", {
price: getPremiumPlanPriceValue(),
})}
</div>
) : null}
</p>
</div>
)}
</div>
);
}
const checkValidEmail = (email: string) => z.string().email().safeParse(email).success;
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) {
export default function Signup({
prepopulateFormValues,
token,
orgSlug,
isGoogleLoginEnabled,
isSAMLLoginEnabled,
orgAutoAcceptEmail,
}: SignupProps) {
const [premiumUsername, setPremiumUsername] = useState(false);
const [usernameTaken, setUsernameTaken] = useState(false);
const searchParams = useCompatSearchParams();
const telemetry = useTelemetry();
const { t, i18n } = useLocale();
const router = useRouter();
const flags = useFlagMap();
const methods = useForm<FormValues>({
mode: "onChange",
const formMethods = useForm<FormValues>({
resolver: zodResolver(signupSchema),
defaultValues: prepopulateFormValues,
defaultValues: prepopulateFormValues satisfies FormValues,
mode: "onChange",
});
const {
register,
formState: { errors, isSubmitting },
} = methods;
watch,
formState: { isSubmitting, errors, isSubmitSuccessful },
} = formMethods;
const handleErrors = async (resp: Response) => {
const loadingSubmitState = isSubmitSuccessful || isSubmitting;
const handleErrorsAndStripe = async (resp: Response) => {
if (!resp.ok) {
const err = await resp.json();
throw new Error(err.message);
if (err.checkoutSessionId) {
const stripe = await getStripe();
if (stripe) {
console.log("Redirecting to stripe checkout");
const { error } = await stripe.redirectToCheckout({
sessionId: err.checkoutSessionId,
});
console.warn(error.message);
}
} else {
throw new Error(err.message);
}
}
};
@ -88,7 +212,7 @@ export default function Signup({ prepopulateFormValues, token, orgSlug, orgAutoA
},
method: "POST",
})
.then(handleErrors)
.then(handleErrorsAndStripe)
.then(async () => {
telemetry.event(telemetryEventTypes.signup, collectPageParameters());
const verifyOrGettingStarted = flags["email-verification"] ? "auth/verify-email" : "getting-started";
@ -106,109 +230,262 @@ export default function Signup({ prepopulateFormValues, token, orgSlug, orgAutoA
});
})
.catch((err) => {
methods.setError("apiError", { message: err.message });
formMethods.setError("apiError", { message: err.message });
});
};
return (
<>
<div
className="bg-muted flex min-h-screen flex-col justify-center "
style={
{
"--cal-brand": "#111827",
"--cal-brand-emphasis": "#101010",
"--cal-brand-text": "white",
"--cal-brand-subtle": "#9CA3AF",
} as CSSProperties
}
aria-labelledby="modal-title"
role="dialog"
aria-modal="true">
<div
className="light bg-muted 2xl:bg-default flex min-h-screen w-full flex-col items-center justify-center"
style={
{
"--cal-brand": "#111827",
"--cal-brand-emphasis": "#101010",
"--cal-brand-text": "white",
"--cal-brand-subtle": "#9CA3AF",
} as CSSProperties
}>
<div className="bg-muted 2xl:border-subtle grid max-h-[800px] w-full max-w-[1440px] grid-cols-1 grid-rows-1 lg:grid-cols-2 2xl:rounded-lg 2xl:border ">
<HeadSeo title={t("sign_up")} description={t("sign_up")} />
<div className="sm:mx-auto sm:w-full sm:max-w-md">
<h2 className="font-cal text-emphasis text-center text-3xl font-extrabold">
{t("create_your_account")}
</h2>
</div>
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<div className="bg-default mx-2 p-6 shadow sm:rounded-lg lg:p-8">
<FormProvider {...methods}>
<form
onSubmit={(event) => {
event.preventDefault();
event.stopPropagation();
<div className="flex w-full flex-col px-4 py-6 sm:px-16 md:px-24 2xl:px-28">
{/* Header */}
{errors.apiError && (
<Alert severity="error" message={errors.apiError?.message} data-testid="signup-error-message" />
)}
<div className="flex flex-col gap-1">
<h1 className="font-cal text-[28px] ">
{IS_CALCOM ? t("create_your_calcom_account") : t("create_your_account")}
</h1>
{IS_CALCOM ? (
<p className="text-subtle text-base font-medium leading-6">{t("cal_signup_description")}</p>
) : (
<p className="text-subtle text-base font-medium leading-6">
{t("calcom_explained", {
appName: APP_NAME,
})}
</p>
)}
</div>
{/* Form Container */}
<div className="mt-10">
<Form
className="flex flex-col gap-4"
form={formMethods}
handleSubmit={async (values) => {
let updatedValues = values;
if (!formMethods.getValues().username && isOrgInviteByLink && orgAutoAcceptEmail) {
updatedValues = {
...values,
username: getOrgUsernameFromEmail(values.email, orgAutoAcceptEmail),
};
}
await signUp(updatedValues);
}}>
{/* Username */}
<UsernameField
label={t("username")}
username={watch("username")}
premium={premiumUsername}
usernameTaken={usernameTaken}
setUsernameTaken={(value) => setUsernameTaken(value)}
data-testid="signup-usernamefield"
setPremium={(value) => setPremiumUsername(value)}
addOnLeading={
orgSlug
? `${getOrgFullOrigin(orgSlug, { protocol: true })}/`
: `${process.env.NEXT_PUBLIC_WEBSITE_URL}/`
}
/>
{/* Email */}
<TextField
{...register("email")}
label={t("email")}
type="email"
data-testid="signup-emailfield"
/>
if (methods.formState?.errors?.apiError) {
methods.clearErrors("apiError");
}
if (!methods.getValues().username && 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">
{!isOrgInviteByLink && (
<TextField
addOnLeading={
orgSlug
? `${getOrgFullOrigin(orgSlug, { protocol: true })}/`
: `${process.env.NEXT_PUBLIC_WEBSITE_URL}/`
{/* Password */}
<PasswordField
data-testid="signup-passwordfield"
label={t("password")}
{...register("password")}
hintErrors={["caplow", "min", "num"]}
/>
<Button
type="submit"
className="my-2 w-full justify-center"
loading={loadingSubmitState}
disabled={
!!formMethods.formState.errors.username ||
!!formMethods.formState.errors.email ||
usernameTaken
}>
{premiumUsername && !usernameTaken
? `Create Account for ${getPremiumPlanPriceValue()}`
: t("create_account")}
</Button>
</Form>
{/* Continue with Social Logins */}
{token || (!isGoogleLoginEnabled && !isSAMLLoginEnabled) ? null : (
<div className="mt-6">
<div className="relative flex items-center">
<div className="border-subtle flex-grow border-t" />
<span className="text-subtle leadning-none mx-2 flex-shrink text-sm font-normal ">
{t("or_continue_with")}
</span>
<div className="border-subtle flex-grow border-t" />
</div>
</div>
)}
{/* Social Logins */}
{!token && (
<div className="mt-6 flex flex-col gap-2 md:flex-row">
{isGoogleLoginEnabled ? (
<Button
color="secondary"
disabled={!!formMethods.formState.errors.username || premiumUsername}
className={classNames(
"w-full justify-center rounded-md text-center",
formMethods.formState.errors.username ? "opacity-50" : ""
)}
onClick={async () => {
const username = formMethods.getValues("username");
const baseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL;
const GOOGLE_AUTH_URL = `${baseUrl}/auth/sso/google`;
if (username) {
// If username is present we save it in query params to check for premium
const searchQueryParams = new URLSearchParams();
searchQueryParams.set("username", formMethods.getValues("username"));
localStorage.setItem("username", username);
router.push(`${GOOGLE_AUTH_URL}?${searchQueryParams.toString()}`);
return;
}
{...register("username")}
disabled={!!orgSlug}
required
router.push(GOOGLE_AUTH_URL);
}}>
<img
className={classNames("text-emphasis mr-2 h-5 w-5", premiumUsername && "opacity-50")}
src="/google-icon.svg"
alt=""
/>
)}
<EmailField
{...register("email")}
disabled={prepopulateFormValues?.email}
className="disabled:bg-emphasis disabled:hover:cursor-not-allowed"
/>
<PasswordField
labelProps={{
className: "block text-sm font-medium text-default",
}}
{...register("password")}
hintErrors={["caplow", "min", "num"]}
className="border-default mt-1 block w-full rounded-md border px-3 py-2 shadow-sm focus:border-black focus:outline-none focus:ring-black sm:text-sm"
/>
</div>
<div className="flex space-x-2 rtl:space-x-reverse">
<Button type="submit" loading={isSubmitting} className="w-full justify-center">
{t("create_account")}
Google
</Button>
{!token && (
<Button
color="secondary"
className="w-full justify-center"
onClick={() =>
signIn("Cal.com", {
callbackUrl: searchParams?.get("callbackUrl")
? `${WEBAPP_URL}/${searchParams.get("callbackUrl")}`
: `${WEBAPP_URL}/getting-started`,
})
}>
{t("login_instead")}
</Button>
)}
</div>
</form>
</FormProvider>
) : null}
{isSAMLLoginEnabled ? (
<Button
color="secondary"
disabled={
!!formMethods.formState.errors.username ||
!!formMethods.formState.errors.email ||
premiumUsername
}
className={classNames(
"w-full justify-center rounded-md text-center",
formMethods.formState.errors.username && formMethods.formState.errors.email
? "opacity-50"
: ""
)}
onClick={() => {
if (!formMethods.getValues("username")) {
formMethods.trigger("username");
}
if (!formMethods.getValues("email")) {
formMethods.trigger("email");
return;
}
const username = formMethods.getValues("username");
localStorage.setItem("username", username);
const sp = new URLSearchParams();
// @NOTE: don't remove username query param as it's required right now for stripe payment page
sp.set("username", formMethods.getValues("username"));
sp.set("email", formMethods.getValues("email"));
router.push(
`${process.env.NEXT_PUBLIC_WEBAPP_URL}/auth/sso/saml` + `?${sp.toString()}`
);
}}>
<ShieldCheckIcon className="mr-2 h-5 w-5" />
{t("saml_sso")}
</Button>
) : null}
</div>
)}
</div>
{/* Already have an account & T&C */}
<div className="mt-6">
<div className="flex flex-col text-sm">
<Link href="/auth/login" className="text-emphasis hover:underline">
{t("already_have_account")}
</Link>
<div className="text-subtle">
By signing up, you agree to our{" "}
<Link className="text-emphasis hover:underline" href={`${WEBSITE_URL}/terms`}>
Terms of Service{" "}
</Link>
<span>and</span>{" "}
<Link className="text-emphasis hover:underline" href={`${WEBSITE_URL}/privacy`}>
Privacy Policy.
</Link>
</div>
</div>
</div>
</div>
<div className="bg-subtle border-subtle hidden w-full flex-col justify-between rounded-l-2xl py-12 pl-12 lg:flex">
{IS_CALCOM && (
<div className="mb-12 mr-12 grid h-full w-full grid-cols-4 gap-4 ">
<div className="">
<img src="/product-cards/trustpilot.svg" className="h-[54px] w-full" alt="#" />
</div>
<div>
<img src="/product-cards/g2.svg" className="h-[54px] w-full" alt="#" />
</div>
<div>
<img src="/product-cards/producthunt.svg" className="h-[54px] w-full" alt="#" />
</div>
</div>
)}
<div
className="rounded-2xl border-y border-l border-dashed border-[#D1D5DB5A] py-[6px] pl-[6px]"
style={{
backgroundColor: "rgba(236,237,239,0.9)",
}}>
<img src="/mock-event-type-list.svg" alt="#" className="" />
</div>
<div className="mr-12 mt-8 grid h-full w-full grid-cols-3 gap-4 overflow-hidden">
{!IS_CALCOM &&
FEATURES.map((feature) => (
<>
<div className="flex flex-col leading-none">
<div className="text-emphasis items-center">
<feature.icon className="mb-1 h-4 w-4" />
<span className="text-sm font-medium">{t(feature.title)}</span>
</div>
<div className="text-subtle text-sm">
<p>
{t(
feature.description,
feature.i18nOptions && {
...feature.i18nOptions,
}
)}
</p>
</div>
</div>
</>
))}
</div>
</div>
</div>
</>
</div>
);
}
const querySchema = z.object({
username: z
.string()
.optional()
.transform((val) => val || ""),
email: z.string().email().optional(),
});
export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
const prisma = await import("@calcom/prisma").then((mod) => mod.default);
const flags = await getFeatureFlagMap(prisma);
@ -222,6 +499,9 @@ export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
prepopulateFormValues: undefined,
};
// username + email prepopulated from query params
const { username: preFillusername, email: prefilEmail } = querySchema.parse(ctx.query);
if (process.env.NEXT_PUBLIC_DISABLE_SIGNUP === "true" || flags["disable-signup"]) {
return {
notFound: true,
@ -231,7 +511,15 @@ export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
// no token given, treat as a normal signup without verification token
if (!token) {
return {
props: JSON.parse(JSON.stringify(props)),
props: JSON.parse(
JSON.stringify({
...props,
prepopulateFormValues: {
username: preFillusername || null,
email: prefilEmail || null,
},
})
),
};
}

View File

@ -0,0 +1,50 @@
import type { Page } from "@playwright/test";
import type { Feature } from "@prisma/client";
import type { AppFlags } from "@calcom/features/flags/config";
import { prisma } from "@calcom/prisma";
type FeatureSlugs = keyof AppFlags;
export const createFeatureFixture = (page: Page) => {
const store = { features: [], page } as { features: Feature[]; page: typeof page };
let initalFeatures: Feature[] = [];
// IIF to add all feautres to store on creation
return {
init: async () => {
const features = await prisma.feature.findMany();
store.features = features;
initalFeatures = features;
return features;
},
getAll: () => store.features,
get: (slug: FeatureSlugs) => store.features.find((b) => b.slug === slug),
deleteAll: async () => {
await prisma.feature.deleteMany({
where: { slug: { in: store.features.map((feature) => feature.slug) } },
});
store.features = [];
},
delete: async (slug: FeatureSlugs) => {
await prisma.feature.delete({ where: { slug } });
store.features = store.features.filter((b) => b.slug !== slug);
},
toggleFeature: async (slug: FeatureSlugs) => {
const feature = store.features.find((b) => b.slug === slug);
if (feature) {
const enabled = !feature.enabled;
await prisma.feature.update({ where: { slug }, data: { enabled } });
store.features = store.features.map((b) => (b.slug === slug ? { ...b, enabled } : b));
}
},
set: async (slug: FeatureSlugs, enabled: boolean) => {
const feature = store.features.find((b) => b.slug === slug);
if (feature) {
store.features = store.features.map((b) => (b.slug === slug ? { ...b, enabled } : b));
await prisma.feature.update({ where: { slug }, data: { enabled } });
}
},
reset: () => (store.features = initalFeatures),
};
};

View File

@ -143,6 +143,17 @@ const createTeamAndAddUser = async (
export const createUsersFixture = (page: Page, emails: API | undefined, workerInfo: WorkerInfo) => {
const store = { users: [], page } as { users: UserFixture[]; page: typeof page };
return {
buildForSignup: (opts?: Pick<CustomUserOpts, "email" | "username" | "useExactUsername" | "password">) => {
const uname =
opts?.useExactUsername && opts?.username
? opts.username
: `${opts?.username || "user"}-${workerInfo.workerIndex}-${Date.now()}`;
return {
username: uname,
email: opts?.email ?? `${uname}@example.com`,
password: opts?.password ?? uname,
};
},
create: async (
opts?: CustomUserOpts | null,
scenario: {
@ -396,6 +407,15 @@ export const createUsersFixture = (page: Page, emails: API | undefined, workerIn
await prisma.user.delete({ where: { id } });
store.users = store.users.filter((b) => b.id !== id);
},
deleteByEmail: async (email: string) => {
// Use deleteMany instead of delete to avoid the findUniqueOrThrow error that happens before the delete
await prisma.user.deleteMany({
where: {
email,
},
});
store.users = store.users.filter((b) => b.email !== email);
},
set: async (email: string) => {
const user = await prisma.user.findUniqueOrThrow({
where: { email },

View File

@ -10,6 +10,7 @@ import prisma from "@calcom/prisma";
import type { ExpectedUrlDetails } from "../../../../playwright.config";
import { createBookingsFixture } from "../fixtures/bookings";
import { createEmbedsFixture } from "../fixtures/embeds";
import { createFeatureFixture } from "../fixtures/features";
import { createOrgsFixture } from "../fixtures/orgs";
import { createPaymentsFixture } from "../fixtures/payments";
import { createBookingPageFixture } from "../fixtures/regularBookings";
@ -29,6 +30,7 @@ export interface Fixtures {
emails?: API;
routingForms: ReturnType<typeof createRoutingFormsFixture>;
bookingPage: ReturnType<typeof createBookingPageFixture>;
features: ReturnType<typeof createFeatureFixture>;
}
declare global {
@ -95,4 +97,9 @@ export const test = base.extend<Fixtures>({
const bookingPage = createBookingPageFixture(page);
await use(bookingPage);
},
features: async ({ page }, use) => {
const features = createFeatureFixture(page);
await features.init();
await use(features);
},
});

View File

@ -0,0 +1,231 @@
import { expect } from "@playwright/test";
import { randomBytes } from "crypto";
import { APP_NAME, IS_PREMIUM_USERNAME_ENABLED, IS_MAILHOG_ENABLED } from "@calcom/lib/constants";
import { test } from "./lib/fixtures";
import { getEmailsReceivedByUser } from "./lib/testUtils";
test.describe.configure({ mode: "parallel" });
test.describe("Signup Flow Test", async () => {
test.beforeEach(async ({ features }) => {
features.reset(); // This resets to the inital state not an empt yarray
});
test.afterAll(async ({ users }) => {
await users.deleteAll();
});
test("Username is taken", async ({ page, users }) => {
// log in trail user
await test.step("Sign up", async () => {
await users.create({
username: "pro",
});
await page.goto("/signup");
const alertMessage = "Username or email is already taken";
// Fill form
await page.locator('input[name="username"]').fill("pro");
await page.locator('input[name="email"]').fill("pro@example.com");
await page.locator('input[name="password"]').fill("Password99!");
// Submit form
await page.click('button[type="submit"]');
const alert = await page.waitForSelector('[data-testid="alert"]');
const alertMessageInner = await alert.innerText();
expect(alertMessage).toBeDefined();
expect(alertMessageInner).toContain(alertMessageInner);
});
});
test("Email is taken", async ({ page, users }) => {
// log in trail user
await test.step("Sign up", async () => {
const user = await users.create({
username: "pro",
});
await page.goto("/signup");
const alertMessage = "Username or email is already taken";
// Fill form
await page.locator('input[name="username"]').fill("randomuserwhodoesntexist");
await page.locator('input[name="email"]').fill(user.email);
await page.locator('input[name="password"]').fill("Password99!");
// Submit form
await page.click('button[type="submit"]');
const alert = await page.waitForSelector('[data-testid="alert"]');
const alertMessageInner = await alert.innerText();
expect(alertMessage).toBeDefined();
expect(alertMessageInner).toContain(alertMessageInner);
});
});
test("Premium Username Flow - creates stripe checkout", async ({ page, users, prisma }) => {
// eslint-disable-next-line playwright/no-skipped-test
test.skip(!IS_PREMIUM_USERNAME_ENABLED, "Only run on Cal.com");
const userToCreate = users.buildForSignup({
username: "rock",
password: "Password99!",
});
// Ensure the premium username is available
await prisma.user.deleteMany({ where: { username: "rock" } });
// Signup with premium username name
await page.goto("/signup");
// Fill form
await page.locator('input[name="username"]').fill("rock");
await page.locator('input[name="email"]').fill(userToCreate.email);
await page.locator('input[name="password"]').fill(userToCreate.password);
await page.click('button[type="submit"]');
// Check that stripe checkout is present
const expectedUrl = "https://checkout.stripe.com";
await page.waitForURL((url) => url.href.startsWith(expectedUrl));
const url = page.url();
// Check that the URL matches the expected URL
expect(url).toContain(expectedUrl);
// TODO: complete the stripe checkout flow
});
test("Signup with valid (non premium) username", async ({ page, users, features }) => {
const userToCreate = users.buildForSignup({
username: "rick-jones",
password: "Password99!",
});
await page.goto("/signup");
// Fill form
await page.locator('input[name="username"]').fill(userToCreate.username);
await page.locator('input[name="email"]').fill(userToCreate.email);
await page.locator('input[name="password"]').fill(userToCreate.password);
await page.click('button[type="submit"]');
await page.waitForLoadState("networkidle");
// Find the newly created user and add it to the fixture store
const newUser = await users.set(userToCreate.email);
expect(newUser).not.toBeNull();
// Check that the URL matches the expected URL
expect(page.url()).toContain("/auth/verify-email");
});
test("Signup fields prefilled with query params", async ({ page, users }) => {
const signupUrlWithParams = "/signup?username=rick-jones&email=rick-jones%40example.com";
await page.goto(signupUrlWithParams);
// Fill form
const usernameInput = page.locator('input[name="username"]');
const emailInput = page.locator('input[name="email"]');
expect(await usernameInput.inputValue()).toBe("rick-jones");
expect(await emailInput.inputValue()).toBe("rick-jones@example.com");
});
test("Signup with token prefils correct fields", async ({ page, users, prisma }) => {
//Create a user and create a token
const token = randomBytes(32).toString("hex");
const userToCreate = users.buildForSignup({
username: "rick-team",
});
const createdtoken = await prisma.verificationToken.create({
data: {
identifier: userToCreate.email,
token,
expires: new Date(new Date().setHours(168)), // +1 week
team: {
create: {
name: "Rick's Team",
slug: `${userToCreate.username}-team`,
},
},
},
});
// create a user with the same email as the token
const rickTeamUser = await prisma.user.create({
data: {
email: userToCreate.email,
username: userToCreate.username,
},
});
// Create provitional membership
await prisma.membership.create({
data: {
teamId: createdtoken.teamId ?? -1,
userId: rickTeamUser.id,
role: "ADMIN",
accepted: false,
},
});
const signupUrlWithToken = `/signup?token=${token}`;
await page.goto(signupUrlWithToken);
const usernameField = page.locator('input[name="username"]');
const emailField = page.locator('input[name="email"]');
expect(await usernameField.inputValue()).toBe(userToCreate.username);
expect(await emailField.inputValue()).toBe(userToCreate.email);
// Cleanup specific to this test
// Clean up the user and token
await prisma.user.deleteMany({ where: { email: userToCreate.email } });
await prisma.verificationToken.deleteMany({ where: { identifier: createdtoken.identifier } });
await prisma.team.deleteMany({ where: { id: createdtoken.teamId! } });
});
test("Email verification sent if enabled", async ({ page, prisma, emails, users, features }) => {
const EmailVerifyFlag = features.get("email-verification")?.enabled;
// eslint-disable-next-line playwright/no-skipped-test
test.skip(!EmailVerifyFlag || !IS_MAILHOG_ENABLED, "Skipping check - Email verify disabled");
// Ensure email verification before testing (TODO: this could break other tests but we can fix that later)
await prisma.feature.update({
where: { slug: "email-verification" },
data: { enabled: true },
});
const userToCreate = users.buildForSignup({
username: "email-verify",
password: "Password99!",
});
await page.goto("/signup");
// Fill form
await page.locator('input[name="username"]').fill(userToCreate.username);
await page.locator('input[name="email"]').fill(userToCreate.email);
await page.locator('input[name="password"]').fill(userToCreate.password);
await page.click('button[type="submit"]');
await page.waitForURL((url) => url.pathname.includes("/auth/verify-email"));
// Find the newly created user and add it to the fixture store
const newUser = await users.set(userToCreate.email);
expect(newUser).not.toBeNull();
const receivedEmails = await getEmailsReceivedByUser({
emails,
userEmail: userToCreate.email,
});
// We need to wait for emails to be sent
// eslint-disable-next-line playwright/no-wait-for-timeout
await page.waitForTimeout(5000);
expect(receivedEmails?.total).toBe(1);
const verifyEmail = receivedEmails?.items[0];
expect(verifyEmail?.subject).toBe(`${APP_NAME}: Verify your account`);
});
});

View File

@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.31877 15.36C4.26002 15.36 0.95752 12.0588 0.95752 8.00001C0.95752 3.94126 4.26002 0.640015 8.31877 0.640015C10.1575 0.640015 11.9175 1.32126 13.2763 2.55876L13.5238 2.78501L11.0963 5.21251L10.8713 5.02001C10.1588 4.41001 9.25252 4.07376 8.31877 4.07376C6.15377 4.07376 4.39127 5.83501 4.39127 8.00001C4.39127 10.165 6.15377 11.9263 8.31877 11.9263C9.88002 11.9263 11.1138 11.1288 11.695 9.77001H7.99877V6.45626L15.215 6.46626L15.2688 6.72001C15.645 8.50626 15.3438 11.1338 13.8188 13.0138C12.5563 14.57 10.7063 15.36 8.31877 15.36Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 670 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 347 KiB

View File

@ -0,0 +1,17 @@
<svg width="115" height="54" viewBox="0 0 115 54" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_12158_115893)">
<path d="M8.95263 15.0632L3.41053 17.0526L3.55263 11.1553L0 6.53684L5.61316 4.83158L8.95263 0L12.2921 4.83158L17.9053 6.53684L14.3526 11.1553L14.4947 17.0526L8.95263 15.0632Z" fill="#FF492C"/>
<path d="M32.9684 15.0632L27.4263 17.0526L27.6394 11.1553L24.0157 6.53684L29.6289 4.83158L32.9684 0L36.3079 4.83158L41.921 6.53684L38.3684 11.1553L38.5105 17.0526L32.9684 15.0632Z" fill="#FF492C"/>
<path d="M56.9842 15.0632L51.4421 17.0526L51.6553 11.1553L48.0316 6.53684L53.7158 4.83158L56.9842 0L60.3237 4.83158L65.9369 6.53684L62.3843 11.1553L62.5264 17.0526L56.9842 15.0632Z" fill="#FF492C"/>
<path d="M81 15.0632L75.4579 17.0526L75.671 11.1553L72.0474 6.53684L77.7316 4.83158L81 0L84.3395 4.83158L90.0237 6.53684L86.4 11.1553L86.5421 17.0526L81 15.0632Z" fill="#FF492C"/>
<path d="M105.016 0L101.747 4.83158L96.0631 6.53684L99.6868 11.1553L99.4736 17.0526L105.016 15.0632V0Z" fill="#FF492C"/>
<path d="M110.416 11.1553L114.039 6.53684L108.355 4.83158L105.016 0V15.0632L110.558 17.0526L110.416 11.1553Z" fill="white"/>
<path d="M28.4682 40.2593C28.4682 47.4831 22.6148 53.3365 15.391 53.3365C8.1672 53.3365 2.31384 47.4831 2.31384 40.2593C2.31384 33.0355 8.1672 27.1821 15.391 27.1821C22.6148 27.1821 28.4682 33.0407 28.4682 40.2593Z" fill="#FF492C"/>
<path d="M21.0507 38.1252H17.6716V37.9683C17.6716 37.3929 17.7866 36.9168 18.0168 36.5454C18.247 36.1689 18.6445 35.8393 19.2199 35.5464L19.4815 35.4156C19.947 35.1802 20.0673 34.9762 20.0673 34.7356C20.0673 34.4479 19.8163 34.2386 19.4135 34.2386C18.9322 34.2386 18.5713 34.4897 18.3202 34.9971L17.6716 34.3485C17.8128 34.0451 18.043 33.8045 18.3464 33.611C18.655 33.4174 18.995 33.3233 19.3664 33.3233C19.832 33.3233 20.2347 33.4436 20.5642 33.6947C20.9043 33.9458 21.0716 34.2909 21.0716 34.7251C21.0716 35.4208 20.6793 35.8445 19.947 36.2212L19.5338 36.4304C19.0944 36.6501 18.8799 36.8488 18.8171 37.1993H21.0507V38.1252ZM20.7526 39.1923H17.0544L15.2079 42.3936H18.9061L20.7578 45.5948L22.6043 42.3936L20.7526 39.1923ZM15.527 44.533C13.173 44.533 11.2585 42.6185 11.2585 40.2646C11.2585 37.9107 13.173 35.9962 15.527 35.9962L16.9916 32.9362C16.5156 32.842 16.0291 32.7949 15.527 32.7949C11.3998 32.7949 8.052 36.1427 8.052 40.2646C8.052 44.3918 11.3945 47.7396 15.527 47.7396C17.1694 47.7396 18.6916 47.206 19.9261 46.3063L18.3045 43.5026C17.5618 44.1407 16.5888 44.533 15.527 44.533Z" fill="white"/>
</g>
<defs>
<clipPath id="clip0_12158_115893">
<rect width="114.395" height="54" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.6 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.3 KiB

View File

@ -0,0 +1,9 @@
<svg width="161" height="75" viewBox="0 0 161 75" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12.6 21.2L4.8 24L5 15.7L0 9.2L7.9 6.8L12.6 0L17.3 6.8L25.2 9.2L20.2 15.7L20.4 24L12.6 21.2Z" fill="#E9A944"/>
<path d="M46.4 21.2L38.6 24L38.9 15.7L33.8 9.2L41.7 6.8L46.4 0L51.1 6.8L59 9.2L54 15.7L54.2 24L46.4 21.2Z" fill="#E9A944"/>
<path d="M80.2 21.2L72.4 24L72.7 15.7L67.6 9.2L75.6 6.8L80.2 0L84.9 6.8L92.8 9.2L87.8 15.7L88 24L80.2 21.2Z" fill="#E9A944"/>
<path d="M114 21.2L106.2 24L106.5 15.7L101.4 9.2L109.4 6.8L114 0L118.7 6.8L126.7 9.2L121.6 15.7L121.8 24L114 21.2Z" fill="#E9A944"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M40 56.5C40 66.7175 31.7175 75 21.5 75C11.2825 75 3 66.7175 3 56.5C3 46.2825 11.2825 38 21.5 38C31.7175 38 40 46.2825 40 56.5Z" fill="#FF6154"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M23.967 56.5H18.725V50.95H23.967C24.703 50.95 25.4088 51.2424 25.9292 51.7628C26.4496 52.2832 26.742 52.989 26.742 53.725C26.742 54.461 26.4496 55.1668 25.9292 55.6872C25.4088 56.2076 24.703 56.5 23.967 56.5ZM23.967 47.25H15.025V65.75H18.725V60.2H23.967C25.6843 60.2 27.3312 59.5178 28.5455 58.3035C29.7598 57.0892 30.442 55.4423 30.442 53.725C30.442 52.0077 29.7598 50.3608 28.5455 49.1465C27.3312 47.9322 25.6843 47.25 23.967 47.25Z" fill="white"/>
<path d="M147.6 21.2L139.8 24L140.1 15.7L135 9.2L143 6.8L147.6 0L152.3 6.8L160.3 9.2L155.2 15.7L155.4 24L147.6 21.2Z" fill="#E9A944"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.8 KiB

View File

@ -1150,6 +1150,7 @@
"active_on": "إجراء في",
"workflow_updated_successfully": "تم تحديث سير العمل {{workflowName}} بنجاح",
"premium_to_standard_username_description": "هذا اسم مستخدم قياسي، سوف ينقلك هذا التحديث إلى صفحة الفوترة لخفض المستوى.",
"premium_username": "هذا اسم مستخدم مميز، احصل على اسم المستخدم لك مقابل {{price}}",
"current": "الحالي",
"premium": "مميز",
"standard": "قياسي",
@ -1530,6 +1531,7 @@
"you": "أنت",
"resend_email": "إعادة إرسال رسالة البريد الإلكتروني",
"member_already_invited": "تمت دعوة العضو بالفعل",
"already_in_use_error": "اسم المستخدم مستخدم مسبقاً",
"enter_email_or_username": "أدخل بريد إلكتروني أو اسم مستخدم",
"team_name_taken": "هذا الاسم مأخوذ بالفعل",
"must_enter_team_name": "يجب إدخال اسم فريق",
@ -2057,6 +2059,9 @@
"include_calendar_event": "إدراج فعاليات في التقويم",
"oAuth": "OAuth",
"recently_added": "تمت الإضافة مؤخراً",
"connect_all_calendars": "قم بربط جميع التقويمات",
"workflow_automation": "أتمتة سير العمل",
"scheduling_for_your_team": "أتمتة سير العمل",
"no_members_found": "لم يُعثر على أعضاء",
"event_setup_length_error": "إعداد الفعالية: يجب أن تكون المدة لدقيقة على الأقل.",
"availability_schedules": "جدولة التوافر",
@ -2080,6 +2085,7 @@
"edit_users_availability": "تعديل توافر المستخدم: {{username}}",
"resend_invitation": "إعادة إرسال الدعوة",
"invitation_resent": "تم إعادة إرسال الدعوة.",
"saml_sso": "SAML",
"add_client": "إضافة عميل",
"copy_client_secret_info": "لن تتمكن من مطالعة السر بعد نسخه بعد الآن",
"add_new_client": "إضافة عميل جديد",

View File

@ -1150,6 +1150,7 @@
"active_on": "Aktivní pro:",
"workflow_updated_successfully": "Pracovní postup {{workflowName}} byl aktualizován",
"premium_to_standard_username_description": "Toto je standardní uživatelské jméno a při aktualizaci přejdete k fakturaci, kde provedete snížení úrovně.",
"premium_username": "Toto je prémiové uživatelské jméno, získejte ho za {{price}}",
"current": "Aktuální",
"premium": "prémiové",
"standard": "standardní",
@ -1530,6 +1531,7 @@
"you": "Vy",
"resend_email": "Znovu odeslat e-mail",
"member_already_invited": "Člen byl už pozván",
"already_in_use_error": "Uživatelské jméno se již používá",
"enter_email_or_username": "Zadejte e-mail nebo uživatelské jméno",
"team_name_taken": "Toto jméno je už obsazeno",
"must_enter_team_name": "Musíte zadat název týmu",
@ -2057,6 +2059,9 @@
"include_calendar_event": "Zahrnout událost kalendáře",
"oAuth": "OAuth",
"recently_added": "Nedávno přidáno",
"connect_all_calendars": "Propojte všechny své kalendáře",
"workflow_automation": "Automatizace pracovních postupů",
"scheduling_for_your_team": "Automatizace pracovních postupů",
"no_members_found": "Nenalezeni žádní členové",
"event_setup_length_error": "Nastavení události: Doba trvání musí být alespoň 1 minuta.",
"availability_schedules": "Plány dostupnosti",
@ -2080,6 +2085,7 @@
"edit_users_availability": "Upravte dostupnost uživatele: {{username}}",
"resend_invitation": "Znovu odeslat pozvánku",
"invitation_resent": "Pozvánka byla odeslána znovu.",
"saml_sso": "SAML",
"add_client": "Přidat klienta",
"copy_client_secret_info": "Po zkopírování již nebude možné tajný klíč zobrazit",
"add_new_client": "Přidat nového klienta",

View File

@ -1150,6 +1150,7 @@
"active_on": "Aktiv am",
"workflow_updated_successfully": "{{workflowName}} Workflow erfolgreich aktualisiert",
"premium_to_standard_username_description": "Dies ist ein Standard-Benutzername und die Aktualisierung führt Sie zur Rechnungsstellung, um ein Downgrade durchzuführen.",
"premium_username": "Dies ist ein Premium-Benutzername, holen Sie sich Ihren für {{price}}",
"current": "Aktuell",
"premium": "Premium",
"standard": "Standard",
@ -1530,6 +1531,7 @@
"you": "Sie",
"resend_email": "E-Mail erneut senden",
"member_already_invited": "Mitglied wurde bereits eingeladen",
"already_in_use_error": "Benutzername wird bereits verwendet",
"enter_email_or_username": "E-Mail oder Benutzername eingeben",
"team_name_taken": "Dieser Name ist bereits vergeben",
"must_enter_team_name": "Team-Name muss eingegeben werden",
@ -2057,6 +2059,9 @@
"include_calendar_event": "Kalenderereignis hinzufügen",
"oAuth": "OAuth",
"recently_added": "Kürzlich hinzugefügt",
"connect_all_calendars": "Verbinden Sie alle Ihre Kalender",
"workflow_automation": "Workflow-Automatisierung",
"scheduling_for_your_team": "Workflow-Automatisierung",
"no_members_found": "Keine Mitglieder gefunden",
"event_setup_length_error": "Ereignis-Einrichtung: Die Dauer muss mindestens 1 Minute betragen.",
"availability_schedules": "Verfügbarkeitspläne",
@ -2080,6 +2085,7 @@
"edit_users_availability": "Benutzerverfügbarkeit bearbeiten: {{username}}",
"resend_invitation": "Einladung erneut senden",
"invitation_resent": "Die Einladung wurde erneut gesendet.",
"saml_sso": "SAML",
"add_client": "Kunde hinzufügen",
"copy_client_secret_info": "Nach dem Kopieren des Geheimnisses können Sie es nicht mehr ansehen",
"add_new_client": "Neuen Kunden hinzufügen",

View File

@ -78,6 +78,7 @@
"cannot_repackage_codebase": "You can not repackage or sell the codebase",
"acquire_license": "Acquire a commercial license to remove these terms by emailing",
"terms_summary": "Summary of terms",
"signing_up_terms":"By signing up, you agree to our <2>Terms of Service</2> and <3>Privacy Policy</3>.",
"open_env": "Open .env and agree to our License",
"env_changed": "I've changed my .env",
"accept_license": "Accept License",
@ -240,6 +241,7 @@
"reset_your_password": "Set your new password with the instructions sent to your email address.",
"email_change": "Log back in with your new email address and password.",
"create_your_account": "Create your account",
"create_your_calcom_account": "Create your Cal.com account",
"sign_up": "Sign up",
"youve_been_logged_out": "You've been logged out",
"hope_to_see_you_soon": "We hope to see you again soon!",
@ -277,6 +279,9 @@
"nearly_there_instructions": "Last thing, a brief description about you and a photo really helps you get bookings and let people know who theyre booking with.",
"set_availability_instructions": "Define ranges of time when you are available on a recurring basis. You can create more of these later and assign them to different calendars.",
"set_availability": "Set your availability",
"set_availbility_description":"Set schedules for the times you want to be booked.",
"share_a_link_or_embed":"Share a link or embed",
"share_a_link_or_embed_description":"Share your {{appName}} link or embed on your site.",
"availability_settings": "Availability Settings",
"continue_without_calendar": "Continue without calendar",
"continue_with": "Continue with {{appName}}",
@ -1049,6 +1054,7 @@
"user_impersonation_heading": "User Impersonation",
"user_impersonation_description": "Allows our support team to temporarily sign in as you to help us quickly resolve any issues you report to us.",
"team_impersonation_description": "Allows your team Owners/Admins to temporarily sign in as you.",
"cal_signup_description":"Free for individuals. Team plans for collaborative features.",
"make_team_private": "Make team private",
"make_team_private_description": "Your team members won't be able to see other team members when this is turned on.",
"you_cannot_see_team_members": "You cannot see all the team members of a private team.",
@ -1164,6 +1170,7 @@
"active_on": "Active on",
"workflow_updated_successfully": "{{workflowName}} workflow updated successfully",
"premium_to_standard_username_description": "This is a standard username and updating will take you to billing to downgrade.",
"premium_username": "This is a premium username, get yours for {{price}}",
"current": "Current",
"premium": "premium",
"standard": "standard",
@ -1544,8 +1551,10 @@
"your_org_disbanded_successfully": "Your organization has been disbanded successfully",
"error_creating_team": "Error creating team",
"you": "You",
"or_continue_with": "Or continue with",
"resend_email": "Resend email",
"member_already_invited": "Member has already been invited",
"already_in_use_error": "Username already in use",
"enter_email_or_username": "Enter an email or username",
"team_name_taken": "This name is already taken",
"must_enter_team_name": "Must enter a team name",
@ -1599,6 +1608,7 @@
"enable_apps": "Enable Apps",
"enable_apps_description": "Enable apps that users can integrate with {{appName}}",
"purchase_license": "Purchase a License",
"already_have_account":"I already have an account",
"already_have_key": "I already have a key:",
"already_have_key_suggestion": "Please copy your existing CALCOM_LICENSE_KEY environment variable here.",
"app_is_enabled": "{{appName}} is enabled",
@ -2073,6 +2083,12 @@
"include_calendar_event": "Include calendar event",
"oAuth": "OAuth",
"recently_added":"Recently added",
"connect_all_calendars":"Connect all your calendars",
"connect_all_calendars_description":"{{appName}} reads availability from all your existing calendars.",
"workflow_automation":"Workflow automation",
"workflow_automation_description":"Personalise your scheduling experience with workflows",
"scheduling_for_your_team":"Workflow automation",
"scheduling_for_your_team_description":"Schedule for your team with collective and round-robin scheduling",
"no_members_found": "No members found",
"event_setup_length_error":"Event Setup: The duration must be at least 1 minute.",
"availability_schedules":"Availability Schedules",
@ -2096,6 +2112,7 @@
"edit_users_availability":"Edit user's availability: {{username}}",
"resend_invitation": "Resend invitation",
"invitation_resent": "The invitation was resent.",
"saml_sso": "SAML",
"add_client": "Add client",
"copy_client_secret_info": "After copying the secret you won't be able to view it anymore",
"add_new_client": "Add new Client",

View File

@ -1150,6 +1150,7 @@
"active_on": "Activo en",
"workflow_updated_successfully": "Flujo de trabajo {{workflowName}} actualizado correctamente",
"premium_to_standard_username_description": "Este es un nombre de usuario estándar y la actualización te llevará a la facturación para bajar de categoría.",
"premium_username": "Este es un nombre de usuario premium, obtenga el suyo por {{price}}",
"current": "Actual",
"premium": "premium",
"standard": "estándar",
@ -1530,6 +1531,7 @@
"you": "Usted",
"resend_email": "Reenviar correo electrónico",
"member_already_invited": "El miembro ya se ha invitado",
"already_in_use_error": "El nombre de usuario ya está en uso",
"enter_email_or_username": "Introduzca un correo electrónico o nombre de usuario",
"team_name_taken": "Este nombre ya está en uso",
"must_enter_team_name": "Debe introducir un nombre de equipo",
@ -2057,6 +2059,9 @@
"include_calendar_event": "Incluir evento del calendario",
"oAuth": "OAuth",
"recently_added": "Añadido recientemente",
"connect_all_calendars": "Conecte todos sus calendarios",
"workflow_automation": "Automatización del flujo de trabajo",
"scheduling_for_your_team": "Automatización del flujo de trabajo",
"no_members_found": "No se encontraron miembros",
"event_setup_length_error": "Configuración del evento: la duración debe ser de al menos 1 minuto.",
"availability_schedules": "Horarios de disponibilidad",
@ -2080,6 +2085,7 @@
"edit_users_availability": "Editar disponibilidad del usuario: {{username}}",
"resend_invitation": "Reenviar invitación",
"invitation_resent": "Se reenvió la invitación.",
"saml_sso": "SAML",
"add_client": "Agregar cliente",
"copy_client_secret_info": "Después de copiar el secreto, ya no podrá volver a verlo",
"add_new_client": "Agregar nuevo cliente",

View File

@ -1151,6 +1151,7 @@
"active_on": "Actif sur",
"workflow_updated_successfully": "Workflow {{workflowName}} mis à jour avec succès",
"premium_to_standard_username_description": "Il s'agit d'un nom d'utilisateur standard et l'appliquer vous redirigera vers la facturation pour résilier votre plan.",
"premium_username": "Il s'agit d'un nom d'utilisateur premium, obtenez-le pour {{price}}",
"current": "Actuel",
"premium": "premium",
"standard": "standard",
@ -1529,6 +1530,7 @@
"you": "Vous",
"resend_email": "Renvoyer le-mail",
"member_already_invited": "Le membre a déjà été invité",
"already_in_use_error": "Nom d'utilisateur déjà utilisé",
"enter_email_or_username": "Saisissez une adresse e-mail ou un nom d'utilisateur",
"team_name_taken": "Ce nom est déjà pris",
"must_enter_team_name": "Vous devez saisir un nom d'équipe",
@ -2053,6 +2055,9 @@
"seat_options_doesnt_multiple_durations": "L'option par place ne prend pas en charge les durées multiples",
"include_calendar_event": "Inclure l'événement du calendrier",
"recently_added": "Ajoutées récemment",
"connect_all_calendars": "Connectez vos calendriers",
"workflow_automation": "Automatisation de workflows",
"scheduling_for_your_team": "Automatisation de workflows",
"no_members_found": "Aucun membre trouvé",
"event_setup_length_error": "Configuration de l'événement : la durée doit être d'au moins 1 minute.",
"availability_schedules": "Horaires de disponibilité",
@ -2067,6 +2072,7 @@
"edit_users_availability": "Modifier la disponibilité de l'utilisateur : {{username}}",
"resend_invitation": "Renvoyer l'invitation",
"invitation_resent": "L'invitation a été renvoyée.",
"saml_sso": "SAML",
"add_client": "Ajouter un client",
"add_new_client": "Ajouter un nouveau client",
"as_csv": "au format CSV",

View File

@ -1150,6 +1150,7 @@
"active_on": "פעיל ב",
"workflow_updated_successfully": "עדכון תהליך העבודה {{workflowName}} בוצע בהצלחה",
"premium_to_standard_username_description": "זהו שם משתמש סטנדרטי. אם תעדכן/י אותו, תועבר/י לדף החיוב לצורך שנמוך.",
"premium_username": "זהו שם משתמש פרימיום, קבל/י אחד משלך תמורת {{price}}",
"current": "נוכחי",
"premium": "פרימיום",
"standard": "רגיל",
@ -1530,6 +1531,7 @@
"you": "את/ה",
"resend_email": "לשלוח שוב את הדוא״ל",
"member_already_invited": "החבר כבר הוזמן",
"already_in_use_error": "שם המשתמש כבר קיים",
"enter_email_or_username": "יש להזין כתובת דוא\"ל או שם משתמש",
"team_name_taken": "השם הזה כבר תפוס",
"must_enter_team_name": "יש להזין שם צוות",
@ -2057,6 +2059,9 @@
"include_calendar_event": "כלילת אירוע מלוח השנה",
"oAuth": "OAuth",
"recently_added": "נוספו לאחרונה",
"connect_all_calendars": "חבר את כל לוחות השנה שלך",
"workflow_automation": "אוטומצית תהליך עבודה",
"scheduling_for_your_team": "אוטומצית תהליך עבודה",
"no_members_found": "לא נמצא אף חבר",
"event_setup_length_error": "הגדרת אירוע: משך הזמן חייב להיות לפחות דקה אחת.",
"availability_schedules": "לוחות זמנים לזמינוּת",
@ -2080,6 +2085,7 @@
"edit_users_availability": "עריכת הזמינות של משתמש: {{username}}",
"resend_invitation": "שליחת ההזמנה מחדש",
"invitation_resent": "ההזמנה נשלחה מחדש.",
"saml_sso": "SAML",
"add_client": "הוספת לקוח",
"copy_client_secret_info": "לאחר העתקת הסוד, כבר לא תהיה לך אפשרות לראות אותו",
"add_new_client": "הוספת לקוח חדש",

View File

@ -1150,6 +1150,7 @@
"active_on": "Data attivazione",
"workflow_updated_successfully": "Flusso di lavoro {{workflowName}} aggiornato correttamente",
"premium_to_standard_username_description": "Questo è un nome utente standard e l'aggiornamento ti porterà alla fatturazione per il downgrade.",
"premium_username": "Questo è un nome utente premium, ottieni il tuo per {{price}}",
"current": "Corrente",
"premium": "premium",
"standard": "standard",
@ -1530,6 +1531,7 @@
"you": "Tu",
"resend_email": "Invia di nuovo l'e-mail",
"member_already_invited": "Membro già invitato",
"already_in_use_error": "Nome utente già in uso",
"enter_email_or_username": "Immettere un indirizzo e-mail o un nome utente",
"team_name_taken": "Nome già utilizzato",
"must_enter_team_name": "Necessario immettere un nome per il team",
@ -2057,6 +2059,9 @@
"include_calendar_event": "Includi evento del calendario",
"oAuth": "OAuth",
"recently_added": "Aggiunti di recente",
"connect_all_calendars": "Collega tutti i tuoi calendari",
"workflow_automation": "Automazione dei flussi di lavoro",
"scheduling_for_your_team": "Automazione dei flussi di lavoro",
"no_members_found": "Nessun membro trovato",
"event_setup_length_error": "Impostazione evento: la durata deve essere di almeno 1 minuto.",
"availability_schedules": "Calendario disponibilità",
@ -2080,6 +2085,7 @@
"edit_users_availability": "Modifica la disponibilità dell'utente: {{username}}",
"resend_invitation": "Invia di nuovo l'invito",
"invitation_resent": "L'invito è stato inviato di nuovo.",
"saml_sso": "SAML",
"add_client": "Aggiungi cliente",
"copy_client_secret_info": "Dopo aver copiato questa parola segreta non sarai più in grado di vederla",
"add_new_client": "Aggiungi nuovo cliente",

View File

@ -1150,6 +1150,7 @@
"active_on": "有効日時",
"workflow_updated_successfully": "{{workflowName}} が正常に更新されました",
"premium_to_standard_username_description": "これは標準のユーザー名です。更新するとダウングレードを行うための請求画面に移動します。",
"premium_username": "これはプレミアムユーザー名ですので、{{price}} で入手してください",
"current": "現在",
"premium": "プレミアム",
"standard": "スタンダード",
@ -1530,6 +1531,7 @@
"you": "あなた",
"resend_email": "メールを再送する",
"member_already_invited": "メンバーは既に招待されています",
"already_in_use_error": "ユーザー名は既に使われています",
"enter_email_or_username": "メールアドレスまたはユーザー名を入力してください",
"team_name_taken": "この名前は既に使用されています",
"must_enter_team_name": "チーム名の入力は必須です",
@ -2057,6 +2059,9 @@
"include_calendar_event": "カレンダーのイベントを含める",
"oAuth": "OAuth",
"recently_added": "最近追加されました",
"connect_all_calendars": "すべてのカレンダーに接続",
"workflow_automation": "ワークフローの自動化",
"scheduling_for_your_team": "ワークフローの自動化",
"no_members_found": "メンバーが見つかりません",
"event_setup_length_error": "イベント設定:時間は 1 分以上でなくてはいけません。",
"availability_schedules": "空き状況一覧",
@ -2080,6 +2085,7 @@
"edit_users_availability": "ユーザーの空き状況を編集:{{username}}",
"resend_invitation": "招待を再送",
"invitation_resent": "招待は再送されました。",
"saml_sso": "SAML",
"add_client": "顧客を追加",
"copy_client_secret_info": "このシークレットをコピーすると、もう表示できなくなります",
"add_new_client": "新しい顧客を追加",

View File

@ -1150,6 +1150,7 @@
"active_on": "유효일",
"workflow_updated_successfully": "{{workflowName}} 워크플로 업데이트 완료",
"premium_to_standard_username_description": "이것은 표준 사용자 이름이며 업데이트하면 다운그레이드를 위한 청구로 이동합니다.",
"premium_username": "프리미엄 사용자 이름입니다. {{price}}에 구입하세요",
"current": "기존",
"premium": "프리미엄",
"standard": "표준",
@ -1530,6 +1531,7 @@
"you": "귀하",
"resend_email": "이메일 다시 보내기",
"member_already_invited": "구성원이 이미 초대되었습니다",
"already_in_use_error": "이미 사용 중인 사용자명입니다.",
"enter_email_or_username": "이메일 또는 사용자 이름을 입력하세요",
"team_name_taken": "이미 사용 중인 이름입니다",
"must_enter_team_name": "팀 이름을 입력하세요",
@ -2057,6 +2059,9 @@
"include_calendar_event": "캘린더 이벤트 포함",
"oAuth": "OAuth",
"recently_added": "최근 추가됨",
"connect_all_calendars": "모든 캘린더 연결",
"workflow_automation": "워크플로 자동화",
"scheduling_for_your_team": "워크플로 자동화",
"no_members_found": "구성원 없음",
"event_setup_length_error": "이벤트 설정: 지속 시간은 1분 이상이어야 합니다.",
"availability_schedules": "사용 가능한 일정",
@ -2080,6 +2085,7 @@
"edit_users_availability": "사용자 가용성 편집: {{username}}",
"resend_invitation": "초대장 다시 보내기",
"invitation_resent": "초대장을 다시 보냈습니다.",
"saml_sso": "SAML",
"add_client": "클라이언트 추가",
"copy_client_secret_info": "비밀글을 복사한 후에는 더 이상 볼 수 없습니다",
"add_new_client": "새 클라이언트 추가",

View File

@ -1150,6 +1150,7 @@
"active_on": "Actief op",
"workflow_updated_successfully": "Werkstroom {{workflowName}} bijgewerkt",
"premium_to_standard_username_description": "Dit is een standaard gebruikersnaam. Als u deze bijwerkt, gaat u naar facturering om te downgraden.",
"premium_username": "Dit is een premium gebruikersnaam, koop de uwe voor {{price}}",
"current": "Huidig",
"premium": "premium",
"standard": "standaard",
@ -1530,6 +1531,7 @@
"you": "U",
"resend_email": "E-mail opnieuw verzenden",
"member_already_invited": "Lid is al uitgenodigd",
"already_in_use_error": "Gebruikersnaam al in gebruik",
"enter_email_or_username": "Voer een e-mailadres of gebruikersnaam in",
"team_name_taken": "Deze naam is al in gebruik",
"must_enter_team_name": "Moet een teamnaam invoeren",
@ -2057,6 +2059,9 @@
"include_calendar_event": "Agendagebeurtenis opnemen",
"oAuth": "OAuth",
"recently_added": "Recent toegevoegd",
"connect_all_calendars": "Koppel al uw agenda's",
"workflow_automation": "Automatisering van de werkstroom",
"scheduling_for_your_team": "Automatisering van de werkstroom",
"no_members_found": "Geen leden gevonden",
"event_setup_length_error": "Gebeurtenisconfiguratie: de duur moet minimaal 1 minuut zijn.",
"availability_schedules": "Beschikbaarheidsschema's",
@ -2080,6 +2085,7 @@
"edit_users_availability": "Beschikbaarheid van gebruiker bewerken: {{username}}",
"resend_invitation": "Uitnodiging opnieuw versturen",
"invitation_resent": "De uitnodiging is opnieuw verstuurd.",
"saml_sso": "SAML",
"add_client": "Klant toevoegen",
"copy_client_secret_info": "Na het kopiëren van het geheim kunt u het niet meer bekijken",
"add_new_client": "Nieuwe klant toevoegen",

View File

@ -1150,6 +1150,7 @@
"active_on": "Aktywny dnia",
"workflow_updated_successfully": "Pomyślnie zaktualizowano przepływ pracy {{workflowName}}",
"premium_to_standard_username_description": "To standardowa nazwa użytkownika i aktualizacja spowoduje przejście do rozliczenia za zmianę wersji na niższą.",
"premium_username": "To nazwa użytkownika premium, możesz ją mieć za {{price}}.",
"current": "Bieżąca",
"premium": "Premium",
"standard": "Standardowa",
@ -1530,6 +1531,7 @@
"you": "Ty",
"resend_email": "Wyślij ponownie wiadomość e-mail",
"member_already_invited": "Członek został już zaproszony",
"already_in_use_error": "Ta nazwa użytkownika jest już używana.",
"enter_email_or_username": "Wprowadź adres e-mail lub nazwę użytkownika",
"team_name_taken": "Ta nazwa jest już zajęta",
"must_enter_team_name": "Musisz wprowadzić nazwę zespołu",
@ -2057,6 +2059,9 @@
"include_calendar_event": "Uwzględnij wydarzenie z kalendarza",
"oAuth": "OAuth",
"recently_added": "Niedawno dodane",
"connect_all_calendars": "Podłącz wszystkie swoje kalendarze",
"workflow_automation": "Automatyzacja przepływu pracy",
"scheduling_for_your_team": "Automatyzacja przepływu pracy",
"no_members_found": "Nie znaleziono członków",
"event_setup_length_error": "Konfiguracja wydarzenia: czas trwania musi wynosić co najmniej minutę.",
"availability_schedules": "Harmonogramy dostępności",
@ -2080,6 +2085,7 @@
"edit_users_availability": "Edytuj dostępność użytkownika: {{username}}",
"resend_invitation": "Ponownie wyślij zaproszenie",
"invitation_resent": "Zaproszenie zostało wysłane ponownie.",
"saml_sso": "SAML",
"add_client": "Dodaj klienta",
"copy_client_secret_info": "Po skopiowaniu sekretu nie będzie można go już przeglądać",
"add_new_client": "Dodaj nowego klienta",

View File

@ -1150,6 +1150,7 @@
"active_on": "Ativar em",
"workflow_updated_successfully": "Fluxo de trabalho {{workflowName}} atualizado com sucesso",
"premium_to_standard_username_description": "Este é um nome de usuário padrão. Para atualizá-lo, levaremos você para fazer downgrade na cobrança.",
"premium_username": "Este é um nome de usuário premium, obtenha o seu por {{price}}",
"current": "Atual",
"premium": "premium",
"standard": "padrão",
@ -1530,6 +1531,7 @@
"you": "Você",
"resend_email": "Reenviar e-mail",
"member_already_invited": "O membro já foi convidado",
"already_in_use_error": "Este nome de usuário já está em uso",
"enter_email_or_username": "Insira um e-mail ou nome de usuário",
"team_name_taken": "Este nome já está em uso",
"must_enter_team_name": "É preciso inserir um nome de equipe",
@ -2057,6 +2059,9 @@
"include_calendar_event": "Incluir evento do calendário",
"oAuth": "OAuth",
"recently_added": "Adicionou recentemente",
"connect_all_calendars": "Conecte todos os seus calendários",
"workflow_automation": "Automação de fluxo de trabalho",
"scheduling_for_your_team": "Automação de fluxo de trabalho",
"no_members_found": "Nenhum membro encontrado",
"event_setup_length_error": "Configuração do evento: deve ter pelo menos 1 minuto de duração.",
"availability_schedules": "Agendamentos de disponibilidade",
@ -2080,6 +2085,7 @@
"edit_users_availability": "Editar disponibilidade do usuário: {{username}}",
"resend_invitation": "Reenviar convite",
"invitation_resent": "O convite foi reenviado.",
"saml_sso": "SAML",
"add_client": "Adicionar cliente",
"copy_client_secret_info": "Depois de copiar o segredo, você não poderá mais visualizá-lo",
"add_new_client": "Adicionar novo cliente",

View File

@ -1150,6 +1150,7 @@
"active_on": "Activo em",
"workflow_updated_successfully": "Fluxo de trabalho {{workflowName}} actualizado com sucesso",
"premium_to_standard_username_description": "Este é um nome de utilizador padrão, e ao atualizar irá para a faturação para fazer o downgrade.",
"premium_username": "Este é um nome de utilizador premium. Obtenha o seu por {{price}}",
"current": "Atual",
"premium": "premium",
"standard": "padrão",
@ -1530,6 +1531,7 @@
"you": "Você",
"resend_email": "Reenviar e-mail",
"member_already_invited": "O membro já foi convidado",
"already_in_use_error": "O nome de utilizador já está a ser utilizado",
"enter_email_or_username": "Especifique um e-mail ou nome de utilizador",
"team_name_taken": "Este nome já está a ser utilizado",
"must_enter_team_name": "Deve especificar um nome para a equipa",
@ -2057,6 +2059,9 @@
"include_calendar_event": "Incluir evento no calendário",
"oAuth": "OAuth",
"recently_added": "Adicionados recentemente",
"connect_all_calendars": "Associe todos os seus calendários",
"workflow_automation": "Automação de fluxo de trabalho",
"scheduling_for_your_team": "Automação de fluxo de trabalho",
"no_members_found": "Nenhum membro encontrado",
"event_setup_length_error": "Configuração do Evento: a duração deve ser de, pelo menos, 1 minuto.",
"availability_schedules": "Horários de Disponibilidade",
@ -2080,6 +2085,7 @@
"edit_users_availability": "Editar a disponibilidade do utilizador: {{username}}",
"resend_invitation": "Reenviar convite",
"invitation_resent": "O convite foi reenviado.",
"saml_sso": "SAML",
"add_client": "Adicionar cliente",
"copy_client_secret_info": "Após copiar o segredo, não será possível ver o mesmo novamente",
"add_new_client": "Adicionar novo Cliente",

View File

@ -1150,6 +1150,7 @@
"active_on": "Activ pe",
"workflow_updated_successfully": "Fluxul de lucru {{workflowName}} a fost actualizat cu succes",
"premium_to_standard_username_description": "Acesta este un nume de utilizator standard, iar actualizarea vă va duce la facturare pentru retrogradare.",
"premium_username": "Acesta este un nume de utilizator premium. Puteți obține unul pentru {{price}}",
"current": "Actual",
"premium": "premium",
"standard": "standard",
@ -1530,6 +1531,7 @@
"you": "Dvs.",
"resend_email": "Retrimitere e-mail",
"member_already_invited": "Membrul a fost deja invitat",
"already_in_use_error": "Numele de utilizator există deja",
"enter_email_or_username": "Introduceți un e-mail sau un nume de utilizator",
"team_name_taken": "Acest nume este folosit deja",
"must_enter_team_name": "Trebuie să introduceți un nume de echipă",
@ -2057,6 +2059,9 @@
"include_calendar_event": "Includeți eveniment din calendar",
"oAuth": "OAuth",
"recently_added": "Adăugate recent",
"connect_all_calendars": "Conectați-vă toate calendarele",
"workflow_automation": "Automatizarea fluxului de lucru",
"scheduling_for_your_team": "Automatizarea fluxului de lucru",
"no_members_found": "Nu s-a găsit niciun membru",
"event_setup_length_error": "Configurare eveniment: Durata trebuie să fie de cel puțin 1 minut.",
"availability_schedules": "Programe de disponibilitate",
@ -2080,6 +2085,7 @@
"edit_users_availability": "Editați disponibilitatea utilizatorului: {{username}}",
"resend_invitation": "Retrimitere invitație",
"invitation_resent": "Invitația a fost trimisă din nou.",
"saml_sso": "SAML",
"add_client": "Adăugare client",
"copy_client_secret_info": "După ce copiați secretul, nu îl veți mai putea vedea",
"add_new_client": "Adăugare client nou",

View File

@ -1150,6 +1150,7 @@
"active_on": "Активен с",
"workflow_updated_successfully": "Рабочий процесс {{workflowName}} обновлен",
"premium_to_standard_username_description": "Это стандартное имя пользователя. В случае изменения вы будете перенаправлены на страницу оплаты для смены тарифа.",
"premium_username": "Это имя пользователя уровня premium. Такое имя пользователя можно получить за {{price}}",
"current": "Текущее",
"premium": "премиум",
"standard": "стандартное",
@ -1530,6 +1531,7 @@
"you": "Вы",
"resend_email": "Отправить письмо еще раз",
"member_already_invited": "Участник уже приглашен",
"already_in_use_error": "Имя пользователя уже используется",
"enter_email_or_username": "Введите адрес электронной почты или имя пользователя",
"team_name_taken": "Это название уже занято",
"must_enter_team_name": "Необходимо ввести название команды",
@ -2057,6 +2059,9 @@
"include_calendar_event": "Включить событие календаря",
"oAuth": "OAuth",
"recently_added": "Недавно добавленные",
"connect_all_calendars": "Подключите все свои календари",
"workflow_automation": "Автоматизация при помощи рабочих процессов",
"scheduling_for_your_team": "Автоматизация при помощи рабочих процессов",
"no_members_found": "Участники не найдены",
"event_setup_length_error": "Настройка события: продолжительность должна быть не менее 1 минуты.",
"availability_schedules": "График с информацией о доступности",
@ -2080,6 +2085,7 @@
"edit_users_availability": "Редактировать данные о доступности пользователя: {{username}}",
"resend_invitation": "Повторно отправить приглашение",
"invitation_resent": "Приглашение отправлено еще раз.",
"saml_sso": "SAML",
"add_client": "Добавить клиента",
"copy_client_secret_info": "После копирования секретного ключа вы больше не сможете его просматривать",
"add_new_client": "Добавить нового клиента",

View File

@ -1150,6 +1150,7 @@
"active_on": "Aktivan uključen",
"workflow_updated_successfully": "Radni tok {{workflowName}} je uspešno ažuriran",
"premium_to_standard_username_description": "Ovo je standardno korisničko ime i ažuriranje će vas poslati na naplatu da biste prešli na nižu verziju.",
"premium_username": "Ovo je premium korisničko ime, nabavite svoje za {{price}}",
"current": "Trenutno",
"premium": "premijum",
"standard": "standardno",
@ -1530,6 +1531,7 @@
"you": "Vi",
"resend_email": "Ponovo pošalji imejl",
"member_already_invited": "Član je već pozvan",
"already_in_use_error": "Korisničko ime je već u upotrebi",
"enter_email_or_username": "Unesite imejl ili korisničko ime",
"team_name_taken": "Ime je zauzeto",
"must_enter_team_name": "Morate uneti naziv tima",
@ -2057,6 +2059,9 @@
"include_calendar_event": "Uključite događaj u kalendaru",
"oAuth": "OAuth",
"recently_added": "Nedavno dodato",
"connect_all_calendars": "Povežite sve svoje kalendare",
"workflow_automation": "Automatizacija radnog toka",
"scheduling_for_your_team": "Automatizacija radnog toka",
"no_members_found": "Članovi nisu pronađeni",
"event_setup_length_error": "Podešavanje događaja: Trajanje mora da bude barem 1 minut.",
"availability_schedules": "Raspored dostupnosti",
@ -2080,6 +2085,7 @@
"edit_users_availability": "Uredi dostupnost korisnika: {{username}}",
"resend_invitation": "Ponovo pošaljite pozivnicu",
"invitation_resent": "Pozivnica je ponovo poslata.",
"saml_sso": "SAML",
"add_client": "Dodajte klijenta",
"copy_client_secret_info": "Nakon kopiranja tajne, nećete moći više da je vidite",
"add_new_client": "Dodajte novog klijenta",

View File

@ -1150,6 +1150,7 @@
"active_on": "Aktiv på",
"workflow_updated_successfully": "{{workflowName}} uppdaterades framgångsrikt",
"premium_to_standard_username_description": "Det här är ett förinställt användarnamn och uppdateringen tar dig till fakturering för att nedgradera.",
"premium_username": "Detta är ett premiumanvändarnamn, få ditt för {{price}}",
"current": "Nuvarande",
"premium": "premium",
"standard": "standard",
@ -1530,6 +1531,7 @@
"you": "Du",
"resend_email": "Skicka e-post igen",
"member_already_invited": "Medlemmen har redan bjudits in",
"already_in_use_error": "Användarnamnet används redan",
"enter_email_or_username": "Ange en e-postadress eller ett användarnamn",
"team_name_taken": "Det här namnet används redan",
"must_enter_team_name": "Teamnamn måste anges",
@ -2057,6 +2059,9 @@
"include_calendar_event": "Inkludera kalenderhändelse",
"oAuth": "OAuth",
"recently_added": "Nyligen tillagda",
"connect_all_calendars": "Anslut alla dina kalendrar",
"workflow_automation": "Automatisering av arbetsflöde",
"scheduling_for_your_team": "Automatisering av arbetsflöde",
"no_members_found": "Inga medlemmar hittades",
"event_setup_length_error": "Konfigurering av evenemang: Längden måste vara minst 1 minut.",
"availability_schedules": "Scheman för tillgänglighet",
@ -2080,6 +2085,7 @@
"edit_users_availability": "Redigera användarens tillgänglighet: {{username}}",
"resend_invitation": "Skicka inbjudan igen",
"invitation_resent": "Inbjudan skickades på nytt.",
"saml_sso": "SAML",
"add_client": "Lägg till kund",
"copy_client_secret_info": "När du har kopierat hemligheten kommer du inte att kunna se den längre",
"add_new_client": "Lägg till ny kund",

View File

@ -75,7 +75,9 @@
"rescheduled_event_type_subject": "மறுஅட்டவணைக்கான கோரிக்கை அனுப்பப்பட்டது: {{eventType}} உடன் {{name}} உடன் {{date}}",
"requested_to_reschedule_subject_attendee": "மறுஅட்டவணை செயல் தேவை: {{eventType}} க்கு {{name}} உடன் புதிய நேரத்தை முன்பதிவு செய்யவும்",
"hi_user_name": "வணக்கம் {{name}}",
"premium_username": "இது ஒரு பிரீமியம் பயனர்பெயர், {{price}} க்கு உங்களுடையதைப் பெறுங்கள்",
"turn_on": "இயக்கவும்",
"already_in_use_error": "பயனர் பெயர் முன்னரே உபயோகத்தில் உள்ளது",
"email_no_user_invite_subheading": "{{invitedBy}} உங்களை {{appName}} குழுவில் சேர அழைத்துள்ளார். {{appName}} என்பது ஈமெயில் இல்லாமலேயே உங்களையும் உங்கள் குழுவையும் கூட்டங்களைத் திட்டமிடுவதற்கு உதவும் திட்டமிடல் பயன்பாடு.",
"email_no_user_step_one": "உங்கள் பயனர்பெயரை தேர்வு செய்யவும்",
"email_no_user_step_two": "உங்கள் காலெண்டர் கணக்கை இணைக்கவும்",

View File

@ -1150,6 +1150,7 @@
"active_on": "Aktif",
"workflow_updated_successfully": "{{workflowName}} iş akışı başarıyla güncellendi",
"premium_to_standard_username_description": "Bu, standart bir kullanıcı adıdır ve güncelleme işlemi eski sürüme geçmeniz için sizi faturalandırma sayfasına yönlendirir.",
"premium_username": "Bu premium bir kullanıcı adıdır, {{price}} karşılığında kendi adınızı alın",
"current": "Mevcut",
"premium": "premium",
"standard": "standart",
@ -1530,6 +1531,7 @@
"you": "Siz",
"resend_email": "E-postayı tekrar gönder",
"member_already_invited": "Üye zaten davet edildi",
"already_in_use_error": "Kullanıcı adı zaten kullanımda",
"enter_email_or_username": "Bir e-posta veya kullanıcı adı girin",
"team_name_taken": "Bu ad zaten kullanılıyor",
"must_enter_team_name": "Ekip adı girilmelidir",
@ -2057,6 +2059,9 @@
"include_calendar_event": "Takvim etkinliğini dahil et",
"oAuth": "OAuth",
"recently_added": "Son eklenen",
"connect_all_calendars": "Tüm takvimlerinizi bağlayın",
"workflow_automation": "İş akışı otomasyonu",
"scheduling_for_your_team": "İş akışı otomasyonu",
"no_members_found": "Üye bulunamadı",
"event_setup_length_error": "Etkinlik Kurulumu: Süre en az 1 dakika olmalıdır.",
"availability_schedules": "Müsaitlik Planları",
@ -2080,6 +2085,7 @@
"edit_users_availability": "Kullanıcının müsaitlik durumunu düzenleyin: {{username}}",
"resend_invitation": "Davetiyeyi yeniden gönder",
"invitation_resent": "Davetiye tekrar gönderildi.",
"saml_sso": "SAML",
"add_client": "Müşteri ekle",
"copy_client_secret_info": "Gizli bilgiyi kopyaladıktan sonra artık görüntüleyemeyeceksiniz",
"add_new_client": "Yeni Müşteri Ekle",

View File

@ -1150,6 +1150,7 @@
"active_on": "Активується",
"workflow_updated_successfully": "Робочий процес «{{workflowName}}» оновлено",
"premium_to_standard_username_description": "Це стандартне ім’я користувача. Якщо оновити його, ви перейдете до оформлення підписки з вужчим функціоналом.",
"premium_username": "Це ім’я користувача класу преміум, отримай своє за {{price}}",
"current": "Поточна",
"premium": "преміум",
"standard": "стандарт",
@ -1530,6 +1531,7 @@
"you": "Ви",
"resend_email": "Повторно надіслати лист",
"member_already_invited": "Учасника вже запрошено",
"already_in_use_error": "Це ім’я користувача вже використовується",
"enter_email_or_username": "Введіть електронну адресу або ім’я користувача",
"team_name_taken": "Це ім’я вже зайнято",
"must_enter_team_name": "Потрібно ввести назву команди",
@ -2057,6 +2059,9 @@
"include_calendar_event": "Включити календарний захід",
"oAuth": "OAuth",
"recently_added": "Нещодавно додані",
"connect_all_calendars": "Можливість підключення всіх календарів",
"workflow_automation": "Автоматизація робочого процесу",
"scheduling_for_your_team": "Автоматизація робочого процесу",
"no_members_found": "Учасників не знайдено",
"event_setup_length_error": "Налаштування заходу: мінімальна тривалість — 1 хвилина.",
"availability_schedules": "Розклад доступності",
@ -2080,6 +2085,7 @@
"edit_users_availability": "Редагувати доступність користувача: {{username}}",
"resend_invitation": "Повторно надіслати запрошення",
"invitation_resent": "Запрошення надіслано повторно.",
"saml_sso": "SAML",
"add_client": "Додати клієнта",
"copy_client_secret_info": "Скопіювавши секрет, ви більше не зможете його переглянути",
"add_new_client": "Додати нового клієнта",

View File

@ -1150,6 +1150,7 @@
"active_on": "Hoạt động vào",
"workflow_updated_successfully": "Đã cập nhật thành công tiến độ công việc {{workflowName}}",
"premium_to_standard_username_description": "Đây là tên người dùng tiêu chuẩn và việc cập nhật sẽ đưa bạn đến khâu thanh toán để hạ cấp độ.",
"premium_username": "Đây là tên người dùng cao cấp, hãy nhận lấy một cái cho bạn với mức {{price}}",
"current": "Hiện tại",
"premium": "cao cấp",
"standard": "tiêu chuẩn",
@ -1530,6 +1531,7 @@
"you": "Bạn",
"resend_email": "Gửi lại email",
"member_already_invited": "Thành viên đã được mời rồi",
"already_in_use_error": "Tên người dùng đã được dùng",
"enter_email_or_username": "Nhập một email hoặc tên người dùng",
"team_name_taken": "Tên này đã có người lấy rồi",
"must_enter_team_name": "Cần phải nhập một tên nhóm",
@ -2057,6 +2059,9 @@
"include_calendar_event": "Bao gồm sự kiện lịch",
"oAuth": "OAuth",
"recently_added": "Đã thêm vào gần đây",
"connect_all_calendars": "Kết nối tất cả lịch của bạn",
"workflow_automation": "Tự động hoá dòng công việc",
"scheduling_for_your_team": "Tự động hoá dòng công việc",
"no_members_found": "Không tìm thấy thành viên nào",
"event_setup_length_error": "Thiết lập sự kiện: Thời lượng phải ít nhất 1 phút.",
"availability_schedules": "Lịch trống",
@ -2080,6 +2085,7 @@
"edit_users_availability": "Sửa tình trạng trống lịch của người dùng: {{username}}",
"resend_invitation": "Gửi lại lời mời",
"invitation_resent": "Lời mời đã được gửi lại.",
"saml_sso": "SAML",
"add_client": "Thêm khách hàng",
"copy_client_secret_info": "Sau khi sao chép bí mật, bạn sẽ không thể xem nó được nữa",
"add_new_client": "Thêm khách hàng mới",

View File

@ -1151,6 +1151,7 @@
"active_on": "活跃于",
"workflow_updated_successfully": "{{workflowName}} 工作流程已成功更新",
"premium_to_standard_username_description": "这是一个标准用户名,更新流程会将您引导至账单页面进行降级。",
"premium_username": "这是高级用户名,支付 {{price}} 即可获得您的高级用户名",
"current": "当前",
"premium": "高级",
"standard": "标准",
@ -1531,6 +1532,7 @@
"you": "您",
"resend_email": "重新发送电子邮件",
"member_already_invited": "成员已被邀请",
"already_in_use_error": "用户名已在使用中",
"enter_email_or_username": "输入电子邮件或用户名",
"team_name_taken": "此名称已被使用",
"must_enter_team_name": "必须输入团队名称",
@ -2058,6 +2060,9 @@
"include_calendar_event": "包括日历活动",
"oAuth": "OAuth",
"recently_added": "最近添加",
"connect_all_calendars": "连接您的所有日历",
"workflow_automation": "工作流程自动化",
"scheduling_for_your_team": "工作流程自动化",
"no_members_found": "未找到成员",
"event_setup_length_error": "获得设置:持续时间必须至少为 1 分钟。",
"availability_schedules": "可预约时间表",
@ -2081,6 +2086,7 @@
"edit_users_availability": "编辑用户的可预约时间:{{username}}",
"resend_invitation": "重新发送邀请",
"invitation_resent": "邀请已重新发送。",
"saml_sso": "SAML",
"add_client": "添加客户",
"copy_client_secret_info": "复制密码后,您将无法再查看",
"add_new_client": "添加新客户",

View File

@ -1150,6 +1150,7 @@
"active_on": "啟用類型:",
"workflow_updated_successfully": "已成功更新 {{workflowName}}",
"premium_to_standard_username_description": "此為標準使用者名稱,更新系統會帶您前往付費頁面進行降級。",
"premium_username": "此為高級使用者名稱,支付 {{price}} 即可擁有",
"current": "目前",
"premium": "高級",
"standard": "標準",
@ -1530,6 +1531,7 @@
"you": "您",
"resend_email": "重新傳送電子郵件",
"member_already_invited": "已邀請成員",
"already_in_use_error": "此使用者名稱已在使用中",
"enter_email_or_username": "輸入電子郵件或使用者名稱",
"team_name_taken": "已有人使用此名稱",
"must_enter_team_name": "團隊名稱為必填",
@ -2057,6 +2059,9 @@
"include_calendar_event": "包含行事曆活動",
"oAuth": "OAuth",
"recently_added": "最近新增",
"connect_all_calendars": "連接您的所有行事曆",
"workflow_automation": "工作流程自動化",
"scheduling_for_your_team": "工作流程自動化",
"no_members_found": "找不到成員",
"event_setup_length_error": "活動設定:持續時間至少必須為 1 分鐘。",
"availability_schedules": "可預約時間行程表",
@ -2080,6 +2085,7 @@
"edit_users_availability": "編輯使用者的可預約時間:{{username}}",
"resend_invitation": "重新傳送邀請",
"invitation_resent": "邀請已重新傳送。",
"saml_sso": "SAML",
"add_client": "新增用戶端",
"copy_client_secret_info": "密碼複製後即無法再查看",
"add_new_client": "新增客戶",

View File

@ -0,0 +1,207 @@
import type { NextApiResponse } from "next";
import stripe from "@calcom/app-store/stripepayment/lib/server";
import { getPremiumMonthlyPlanPriceId } from "@calcom/app-store/stripepayment/lib/utils";
import { hashPassword } from "@calcom/features/auth/lib/hashPassword";
import { sendEmailVerification } from "@calcom/features/auth/lib/verifyEmail";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { getLocaleFromRequest } from "@calcom/lib/getLocaleFromRequest";
import { HttpError } from "@calcom/lib/http-error";
import { usernameHandler, type RequestWithUsernameStatus } from "@calcom/lib/server/username";
import { createWebUser as syncServicesCreateWebUser } from "@calcom/lib/sync/SyncServiceManager";
import { closeComUpsertTeamUser } from "@calcom/lib/sync/SyncServiceManager";
import { validateUsername } from "@calcom/lib/validateUsername";
import { prisma } from "@calcom/prisma";
import { IdentityProvider, MembershipRole } from "@calcom/prisma/enums";
import { signupSchema, teamMetadataSchema } from "@calcom/prisma/zod-utils";
import { joinAnyChildTeamOnOrgInvite } from "../utils/organization";
import { findTokenByToken, throwIfTokenExpired, validateUsernameForTeam } from "../utils/token";
async function handler(req: RequestWithUsernameStatus, res: NextApiResponse) {
const {
email: _email,
password,
token,
} = signupSchema
.pick({
email: true,
password: true,
token: true,
})
.parse(req.body);
let username: string | null = req.usernameStatus.requestedUserName;
let checkoutSessionId: string | null = null;
// Check for premium username
if (req.usernameStatus.statusCode === 418) {
return res.status(req.usernameStatus.statusCode).json(req.usernameStatus.json);
}
// Validate the user
if (!username) {
throw new HttpError({
statusCode: 422,
message: "Invalid username",
});
}
const email = _email.toLowerCase();
let foundToken: { id: number; teamId: number | null; expires: Date } | null = null;
if (token) {
foundToken = await findTokenByToken({ token });
throwIfTokenExpired(foundToken?.expires);
await validateUsernameForTeam({ username, email, teamId: foundToken?.teamId ?? null });
} else {
const usernameAndEmailValidation = await validateUsername(username, email);
if (!usernameAndEmailValidation.isValid) {
throw new HttpError({
statusCode: 409,
message: "Username or email is already taken",
});
}
}
// Create the customer in Stripe
const customer = await stripe.customers.create({
email,
metadata: {
email /* Stripe customer email can be changed, so we add this to keep track of which email was used to signup */,
username,
},
});
const returnUrl = `${WEBAPP_URL}/api/integrations/stripepayment/paymentCallback?checkoutSessionId={CHECKOUT_SESSION_ID}&callbackUrl=/auth/verify?sessionId={CHECKOUT_SESSION_ID}`;
// Pro username, must be purchased
if (req.usernameStatus.statusCode === 402) {
const checkoutSession = await stripe.checkout.sessions.create({
mode: "subscription",
payment_method_types: ["card"],
customer: customer.id,
line_items: [
{
price: getPremiumMonthlyPlanPriceId(),
quantity: 1,
},
],
success_url: returnUrl,
cancel_url: returnUrl,
allow_promotion_codes: true,
});
/** We create a username-less user until he pays */
checkoutSessionId = checkoutSession.id;
username = null;
}
// Hash the password
const hashedPassword = await hashPassword(password);
if (foundToken && foundToken?.teamId) {
const team = await prisma.team.findUnique({
where: {
id: foundToken.teamId,
},
});
if (team) {
const teamMetadata = teamMetadataSchema.parse(team?.metadata);
const user = await prisma.user.upsert({
where: { email },
update: {
username,
password: hashedPassword,
emailVerified: new Date(Date.now()),
identityProvider: IdentityProvider.CAL,
},
create: {
username,
email,
password: hashedPassword,
identityProvider: IdentityProvider.CAL,
},
});
// Wrapping in a transaction as if one fails we want to rollback the whole thing to preventa any data inconsistencies
const membership = await prisma.$transaction(async (tx) => {
if (teamMetadata?.isOrganization) {
await tx.user.update({
where: {
id: user.id,
},
data: {
organizationId: team.id,
},
});
}
const membership = await tx.membership.upsert({
where: {
userId_teamId: { userId: user.id, teamId: team.id },
},
update: {
accepted: true,
},
create: {
userId: user.id,
teamId: team.id,
role: MembershipRole.MEMBER,
accepted: true,
},
});
return membership;
});
closeComUpsertTeamUser(team, user, membership.role);
// Accept any child team invites for orgs.
if (team.parentId) {
await joinAnyChildTeamOnOrgInvite({
userId: user.id,
orgId: team.parentId,
});
}
}
// Cleanup token after use
await prisma.verificationToken.delete({
where: {
id: foundToken.id,
},
});
} else {
// Create the user
const user = await prisma.user.create({
data: {
username,
email,
password: hashedPassword,
metadata: {
stripeCustomerId: customer.id,
checkoutSessionId,
},
},
});
sendEmailVerification({
email,
language: await getLocaleFromRequest(req),
username: username || "",
});
// Sync Services
await syncServicesCreateWebUser(user);
}
if (checkoutSessionId) {
console.log("Created user but missing payment", checkoutSessionId);
return res.status(402).json({
message: "Created user but missing payment",
checkoutSessionId,
});
}
return res.status(201).json({ message: "Created user", stripeCustomerId: customer.id });
}
export default usernameHandler(handler);

View File

@ -0,0 +1,147 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { checkPremiumUsername } from "@calcom/ee/common/lib/checkPremiumUsername";
import { hashPassword } from "@calcom/features/auth/lib/hashPassword";
import { sendEmailVerification } from "@calcom/features/auth/lib/verifyEmail";
import { IS_PREMIUM_USERNAME_ENABLED } from "@calcom/lib/constants";
import slugify from "@calcom/lib/slugify";
import { closeComUpsertTeamUser } from "@calcom/lib/sync/SyncServiceManager";
import { validateUsername } from "@calcom/lib/validateUsername";
import prisma from "@calcom/prisma";
import { IdentityProvider, MembershipRole } from "@calcom/prisma/enums";
import { signupSchema } from "@calcom/prisma/zod-utils";
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
import { joinAnyChildTeamOnOrgInvite } from "../utils/organization";
import { findTokenByToken, throwIfTokenExpired, validateUsernameForTeam } from "../utils/token";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const data = req.body;
const { email, password, language, token } = signupSchema.parse(data);
const username = slugify(data.username);
const userEmail = email.toLowerCase();
if (!username) {
res.status(422).json({ message: "Invalid username" });
return;
}
let foundToken: { id: number; teamId: number | null; expires: Date } | null = null;
if (token) {
foundToken = await findTokenByToken({ token });
throwIfTokenExpired(foundToken?.expires);
await validateUsernameForTeam({ username, email: userEmail, teamId: foundToken?.teamId });
} else {
const userValidation = await validateUsername(username, userEmail);
if (!userValidation.isValid) {
return res.status(409).json({ message: "Username or email is already taken" });
}
}
const hashedPassword = await hashPassword(password);
if (foundToken && foundToken?.teamId) {
const team = await prisma.team.findUnique({
where: {
id: foundToken.teamId,
},
});
if (team) {
const teamMetadata = teamMetadataSchema.parse(team?.metadata);
const user = await prisma.user.upsert({
where: { email: userEmail },
update: {
username,
password: hashedPassword,
emailVerified: new Date(Date.now()),
identityProvider: IdentityProvider.CAL,
},
create: {
username,
email: userEmail,
password: hashedPassword,
identityProvider: IdentityProvider.CAL,
},
});
const membership = await prisma.$transaction(async (tx) => {
if (teamMetadata?.isOrganization) {
await tx.user.update({
where: {
id: user.id,
},
data: {
organizationId: team.id,
},
});
}
const membership = await tx.membership.upsert({
where: {
userId_teamId: { userId: user.id, teamId: team.id },
},
update: {
accepted: true,
},
create: {
userId: user.id,
teamId: team.id,
role: MembershipRole.MEMBER,
accepted: true,
},
});
return membership;
});
closeComUpsertTeamUser(team, user, membership.role);
// Accept any child team invites for orgs.
if (team.parentId) {
await joinAnyChildTeamOnOrgInvite({
userId: user.id,
orgId: team.parentId,
});
}
}
// Cleanup token after use
await prisma.verificationToken.delete({
where: {
id: foundToken.id,
},
});
} else {
if (IS_PREMIUM_USERNAME_ENABLED) {
const checkUsername = await checkPremiumUsername(username);
if (checkUsername.premium) {
res.status(422).json({
message: "Sign up from https://cal.com/signup to claim your premium username",
});
return;
}
}
await prisma.user.upsert({
where: { email: userEmail },
update: {
username,
password: hashedPassword,
emailVerified: new Date(Date.now()),
identityProvider: IdentityProvider.CAL,
},
create: {
username,
email: userEmail,
password: hashedPassword,
identityProvider: IdentityProvider.CAL,
},
});
await sendEmailVerification({
email: userEmail,
username,
language,
});
}
res.status(201).json({ message: "Created user" });
}

View File

@ -0,0 +1,124 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { z } from "zod";
import notEmpty from "@calcom/lib/notEmpty";
import { isPremiumUserName, generateUsernameSuggestion } from "@calcom/lib/server/username";
import slugify from "@calcom/lib/slugify";
import prisma from "@calcom/prisma";
export type RequestWithUsernameStatus = NextApiRequest & {
usernameStatus: {
/**
* ```text
* 200: Username is available
* 402: Pro username, must be purchased
* 418: A user exists with that username
* ```
*/
statusCode: 200 | 402 | 418;
requestedUserName: string;
json: {
available: boolean;
premium: boolean;
message?: string;
suggestion?: string;
};
};
};
export const usernameStatusSchema = z.object({
statusCode: z.union([z.literal(200), z.literal(402), z.literal(418)]),
requestedUserName: z.string(),
json: z.object({
available: z.boolean(),
premium: z.boolean(),
message: z.string().optional(),
suggestion: z.string().optional(),
}),
});
type CustomNextApiHandler<T = unknown> = (
req: RequestWithUsernameStatus,
res: NextApiResponse<T>
) => void | Promise<void>;
const usernameHandler =
(handler: CustomNextApiHandler) =>
async (req: RequestWithUsernameStatus, res: NextApiResponse): Promise<void> => {
const username = slugify(req.body.username);
const check = await usernameCheck(username);
req.usernameStatus = {
statusCode: 200,
requestedUserName: username,
json: {
available: true,
premium: false,
message: "Username is available",
},
};
if (check.premium) {
req.usernameStatus.statusCode = 402;
req.usernameStatus.json.premium = true;
req.usernameStatus.json.message = "This is a premium username.";
}
if (!check.available) {
req.usernameStatus.statusCode = 418;
req.usernameStatus.json.available = false;
req.usernameStatus.json.message = "A user exists with that username";
}
req.usernameStatus.json.suggestion = check.suggestedUsername;
return handler(req, res);
};
const usernameCheck = async (usernameRaw: string) => {
const response = {
available: true,
premium: false,
suggestedUsername: "",
};
const username = slugify(usernameRaw);
const user = await prisma.user.findFirst({
where: { username, organizationId: null },
select: {
username: true,
},
});
if (user) {
response.available = false;
}
if (await isPremiumUserName(username)) {
response.premium = true;
}
// get list of similar usernames in the db
const users = await prisma.user.findMany({
where: {
username: {
contains: username,
},
},
select: {
username: true,
},
});
// We only need suggestedUsername if the username is not available
if (!response.available) {
response.suggestedUsername = await generateUsernameSuggestion(
users.map((user) => user.username).filter(notEmpty),
username
);
}
return response;
};
export { usernameHandler, usernameCheck };

View File

@ -0,0 +1,55 @@
import prisma from "@calcom/prisma";
export async function joinOrganization({
organizationId,
userId,
}: {
userId: number;
organizationId: number;
}) {
return await prisma.user.update({
where: {
id: userId,
},
data: {
organizationId: organizationId,
},
});
}
export async function joinAnyChildTeamOnOrgInvite({ userId, orgId }: { userId: number; orgId: number }) {
await prisma.$transaction([
prisma.user.update({
where: {
id: userId,
},
data: {
organizationId: orgId,
},
}),
prisma.membership.updateMany({
where: {
userId,
team: {
id: orgId,
},
accepted: false,
},
data: {
accepted: true,
},
}),
prisma.membership.updateMany({
where: {
userId,
team: {
parentId: orgId,
},
accepted: false,
},
data: {
accepted: true,
},
}),
]);
}

View File

@ -0,0 +1,55 @@
import dayjs from "@calcom/dayjs";
import { HttpError } from "@calcom/lib/http-error";
import { validateUsernameInTeam } from "@calcom/lib/validateUsername";
import { prisma } from "@calcom/prisma";
export async function findTokenByToken({ token }: { token: string }) {
const foundToken = await prisma.verificationToken.findFirst({
where: {
token,
},
select: {
id: true,
expires: true,
teamId: true,
},
});
if (!foundToken) {
throw new HttpError({
statusCode: 401,
message: "Invalid Token",
});
}
return foundToken;
}
export function throwIfTokenExpired(expires?: Date) {
if (!expires) return;
if (dayjs(expires).isBefore(dayjs())) {
throw new HttpError({
statusCode: 401,
message: "Token expired",
});
}
}
export async function validateUsernameForTeam({
username,
email,
teamId,
}: {
username: string;
email: string;
teamId: number | null;
}) {
if (!teamId) return;
const teamUserValidation = await validateUsernameInTeam(username, email, teamId);
if (!teamUserValidation.isValid) {
throw new HttpError({
statusCode: 409,
message: "Username or email is already taken",
});
}
}

View File

@ -115,3 +115,7 @@ export const AB_TEST_BUCKET_PROBABILITY = defaultOnNaN(
parseInt(process.env.AB_TEST_BUCKET_PROBABILITY ?? "10", 10),
10
);
export const IS_PREMIUM_USERNAME_ENABLED =
(IS_CALCOM || (process.env.NEXT_PUBLIC_IS_E2E && IS_STRIPE_ENABLED)) &&
process.env.NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE_MONTHLY;

View File

@ -1,6 +1,6 @@
import { checkPremiumUsername } from "@calcom/features/ee/common/lib/checkPremiumUsername";
import { IS_SELF_HOSTED } from "@calcom/lib/constants";
import { IS_PREMIUM_USERNAME_ENABLED } from "@calcom/lib/constants";
import { checkRegularUsername } from "./checkRegularUsername";
import { usernameCheck as checkPremiumUsername } from "./username";
export const checkUsername = IS_SELF_HOSTED ? checkRegularUsername : checkPremiumUsername;
export const checkUsername = !IS_PREMIUM_USERNAME_ENABLED ? checkRegularUsername : checkPremiumUsername;

View File

@ -0,0 +1,156 @@
import type { NextApiRequest, NextApiResponse } from "next";
import slugify from "@calcom/lib/slugify";
import prisma from "@calcom/prisma";
import { IS_PREMIUM_USERNAME_ENABLED } from "../constants";
import notEmpty from "../notEmpty";
const cachedData: Set<string> = new Set();
export type RequestWithUsernameStatus = NextApiRequest & {
usernameStatus: {
/**
* ```text
* 200: Username is available
* 402: Pro username, must be purchased
* 418: A user exists with that username
* ```
*/
statusCode: 200 | 402 | 418;
requestedUserName: string;
json: {
available: boolean;
premium: boolean;
message?: string;
suggestion?: string;
};
};
};
type CustomNextApiHandler<T = unknown> = (
req: RequestWithUsernameStatus,
res: NextApiResponse<T>
) => void | Promise<void>;
export async function isBlacklisted(username: string) {
// NodeJS forEach is very, very fast (these days) so even though we only have to construct the Set
// once every few iterations, it doesn't add much overhead.
if (!cachedData.size && process.env.USERNAME_BLACKLIST_URL) {
await fetch(process.env.USERNAME_BLACKLIST_URL).then(async (resp) =>
(await resp.text()).split("\n").forEach(cachedData.add, cachedData)
);
}
return cachedData.has(username);
}
export const isPremiumUserName = IS_PREMIUM_USERNAME_ENABLED
? async (username: string) => {
return username.length <= 4 || isBlacklisted(username);
}
: // outside of cal.com the concept of premium username needs not exist.
() => Promise.resolve(false);
export const generateUsernameSuggestion = async (users: string[], username: string) => {
const limit = username.length < 2 ? 9999 : 999;
let rand = 1;
while (users.includes(username + String(rand).padStart(4 - rand.toString().length, "0"))) {
rand = Math.ceil(1 + Math.random() * (limit - 1));
}
return username + String(rand).padStart(4 - rand.toString().length, "0");
};
const processResult = (
result: "ok" | "username_exists" | "is_premium"
): // explicitly assign return value to ensure statusCode is typehinted
{ statusCode: RequestWithUsernameStatus["usernameStatus"]["statusCode"]; message: string } => {
// using a switch statement instead of multiple ifs to make sure typescript knows
// there is only limited options
switch (result) {
case "ok":
return {
statusCode: 200,
message: "Username is available",
};
case "username_exists":
return {
statusCode: 418,
message: "A user exists with that username",
};
case "is_premium":
return { statusCode: 402, message: "This is a premium username." };
}
};
const usernameHandler =
(handler: CustomNextApiHandler) =>
async (req: RequestWithUsernameStatus, res: NextApiResponse): Promise<void> => {
const username = slugify(req.body.username);
const check = await usernameCheck(username);
let result: Parameters<typeof processResult>[0] = "ok";
if (check.premium) result = "is_premium";
if (!check.available) result = "username_exists";
const { statusCode, message } = processResult(result);
req.usernameStatus = {
statusCode,
requestedUserName: username,
json: {
available: result !== "username_exists",
premium: result === "is_premium",
message,
suggestion: check.suggestedUsername,
},
};
return handler(req, res);
};
const usernameCheck = async (usernameRaw: string) => {
const response = {
available: true,
premium: false,
suggestedUsername: "",
};
const username = slugify(usernameRaw);
const user = await prisma.user.findFirst({
where: { username, organizationId: null },
select: {
username: true,
},
});
if (user) {
response.available = false;
}
if (await isPremiumUserName(username)) {
response.premium = true;
}
// get list of similar usernames in the db
const users = await prisma.user.findMany({
where: {
username: {
contains: username,
},
},
select: {
username: true,
},
});
// We only need suggestedUsername if the username is not available
if (!response.available) {
response.suggestedUsername = await generateUsernameSuggestion(
users.map((user) => user.username).filter(notEmpty),
username
);
}
return response;
};
export { usernameHandler, usernameCheck };

View File

@ -2,6 +2,9 @@
// For eg:- "test-slug" is the slug user wants to set but while typing "test-" would get replace to "test" becauser of replace(/-+$/, "")
export const slugify = (str: string, forDisplayingInput?: boolean) => {
if (!str) {
return "";
}
const s = str
.toLowerCase() // Convert to lowercase
.trim() // Remove whitespace from both sides

View File

@ -59,6 +59,7 @@ const uploadAvatar = async ({ userId, avatar: data }: { userId: number; avatar:
export const updateProfileHandler = async ({ ctx, input }: UpdateProfileOptions) => {
const { user } = ctx;
const userMetadata = handleUserMetadata({ ctx, input });
const locale = input.locale || user.locale;
const data: Prisma.UserUpdateInput = {
...input,
// DO NOT OVERWRITE AVATAR.
@ -73,7 +74,7 @@ export const updateProfileHandler = async ({ ctx, input }: UpdateProfileOptions)
const layoutError = validateBookerLayouts(input?.metadata?.defaultBookerLayouts || null);
if (layoutError) {
const t = await getTranslation("en", "common");
const t = await getTranslation(locale, "common");
throw new TRPCError({ code: "BAD_REQUEST", message: t(layoutError) });
}
@ -85,7 +86,8 @@ export const updateProfileHandler = async ({ ctx, input }: UpdateProfileOptions)
const response = await checkUsername(username);
isPremiumUsername = response.premium;
if (!response.available) {
throw new TRPCError({ code: "BAD_REQUEST", message: response.message });
const t = await getTranslation(locale, "common");
throw new TRPCError({ code: "BAD_REQUEST", message: t("username_already_taken") });
}
}
}

View File

@ -325,6 +325,7 @@
"TWILIO_VERIFY_SID",
"UPSTASH_REDIS_REST_TOKEN",
"UPSTASH_REDIS_REST_URL",
"USERNAME_BLACKLIST_URL",
"VERCEL_ENV",
"VERCEL_URL",
"VITAL_API_KEY",

231
yarn.lock
View File

@ -3190,15 +3190,6 @@ __metadata:
languageName: node
linkType: hard
"@babel/runtime@npm:^7.14.5, @babel/runtime@npm:^7.17.2, @babel/runtime@npm:^7.18.6":
version: 7.23.4
resolution: "@babel/runtime@npm:7.23.4"
dependencies:
regenerator-runtime: ^0.14.0
checksum: 8eb6a6b2367f7d60e7f7dd83f477cc2e2fdb169e5460694d7614ce5c730e83324bcf29251b70940068e757ad1ee56ff8073a372260d90cad55f18a825caf97cd
languageName: node
linkType: hard
"@babel/runtime@npm:^7.21.0":
version: 7.23.1
resolution: "@babel/runtime@npm:7.23.1"
@ -3552,13 +3543,15 @@ __metadata:
"@calcom/ui": "*"
"@types/node": 16.9.1
"@types/react": 18.0.26
"@types/react-dom": 18.0.9
"@types/react-dom": ^18.0.9
eslint: ^8.34.0
eslint-config-next: ^13.2.1
next: ^13.2.1
next-auth: ^4.20.1
next: ^13.4.6
next-auth: ^4.22.1
postcss: ^8.4.18
react: ^18.2.0
react-dom: ^18.2.0
tailwindcss: ^3.3.3
typescript: ^4.9.4
languageName: unknown
linkType: soft
@ -3652,7 +3645,7 @@ __metadata:
"@calcom/ui": "*"
"@headlessui/react": ^1.5.0
"@heroicons/react": ^1.0.6
"@prisma/client": ^4.13.0
"@prisma/client": ^5.4.2
"@tailwindcss/forms": ^0.5.2
"@types/node": 16.9.1
"@types/react": 18.0.26
@ -3660,21 +3653,21 @@ __metadata:
chart.js: ^3.7.1
client-only: ^0.0.1
eslint: ^8.34.0
next: ^13.2.1
next-auth: ^4.20.1
next-i18next: ^11.3.0
next: ^13.4.6
next-auth: ^4.22.1
next-i18next: ^13.2.2
postcss: ^8.4.18
prisma: ^4.13.0
prisma: ^5.4.2
prisma-field-encryption: ^1.4.0
react: ^18.2.0
react-chartjs-2: ^4.0.1
react-dom: ^18.2.0
react-hook-form: ^7.43.3
react-live-chat-loader: ^2.7.3
react-live-chat-loader: ^2.8.1
swr: ^1.2.2
tailwindcss: ^3.2.1
tailwindcss: ^3.3.3
typescript: ^4.9.4
zod: ^3.20.2
zod: ^3.22.2
languageName: unknown
linkType: soft
@ -4762,7 +4755,7 @@ __metadata:
remark: ^14.0.2
remark-html: ^14.0.1
remeda: ^1.24.1
stripe: ^9.16.0
stripe: ^14.5.0
tailwind-merge: ^1.13.2
tailwindcss: ^3.3.3
ts-node: ^10.9.1
@ -8485,20 +8478,6 @@ __metadata:
languageName: node
linkType: hard
"@prisma/client@npm:^4.13.0":
version: 4.16.2
resolution: "@prisma/client@npm:4.16.2"
dependencies:
"@prisma/engines-version": 4.16.1-1.4bc8b6e1b66cb932731fb1bdbbc550d1e010de81
peerDependencies:
prisma: "*"
peerDependenciesMeta:
prisma:
optional: true
checksum: 38e1356644a764946c69c8691ea4bbed0ba37739d833a435625bd5435912bed4b9bdd7c384125f3a4ab8128faf566027985c0f0840a42741c338d72e40b5d565
languageName: node
linkType: hard
"@prisma/client@npm:^5.4.2":
version: 5.4.2
resolution: "@prisma/client@npm:5.4.2"
@ -8557,13 +8536,6 @@ __metadata:
languageName: node
linkType: hard
"@prisma/engines-version@npm:4.16.1-1.4bc8b6e1b66cb932731fb1bdbbc550d1e010de81":
version: 4.16.1-1.4bc8b6e1b66cb932731fb1bdbbc550d1e010de81
resolution: "@prisma/engines-version@npm:4.16.1-1.4bc8b6e1b66cb932731fb1bdbbc550d1e010de81"
checksum: b42c6abe7c1928e546f15449e40ffa455701ef2ab1f62973628ecb4e19ff3652e34609a0d83196d1cbd0864adb44c55e082beec852b11929acf1c15fb57ca45a
languageName: node
linkType: hard
"@prisma/engines-version@npm:5.4.1-2.ac9d7041ed77bcc8a8dbd2ab6616b39013829574":
version: 5.4.1-2.ac9d7041ed77bcc8a8dbd2ab6616b39013829574
resolution: "@prisma/engines-version@npm:5.4.1-2.ac9d7041ed77bcc8a8dbd2ab6616b39013829574"
@ -8571,13 +8543,6 @@ __metadata:
languageName: node
linkType: hard
"@prisma/engines@npm:4.16.2":
version: 4.16.2
resolution: "@prisma/engines@npm:4.16.2"
checksum: f423e6092c3e558cd089a68ae87459fba7fd390c433df087342b3269c3b04163965b50845150dfe47d01f811781bfff89d5ae81c95ca603c59359ab69ebd810f
languageName: node
linkType: hard
"@prisma/engines@npm:5.1.1":
version: 5.1.1
resolution: "@prisma/engines@npm:5.1.1"
@ -24703,13 +24668,6 @@ __metadata:
languageName: node
linkType: hard
"i18next-fs-backend@npm:^1.1.4":
version: 1.2.0
resolution: "i18next-fs-backend@npm:1.2.0"
checksum: da74d20f2b007f8e34eaf442fa91ad12aaff3b9891e066c6addd6d111b37e370c62370dfbc656730ab2f8afd988f2e7ea1c48301ebb19ccb716fb5965600eddf
languageName: node
linkType: hard
"i18next-fs-backend@npm:^2.1.1":
version: 2.1.3
resolution: "i18next-fs-backend@npm:2.1.3"
@ -24717,15 +24675,6 @@ __metadata:
languageName: node
linkType: hard
"i18next@npm:^21.8.13":
version: 21.10.0
resolution: "i18next@npm:21.10.0"
dependencies:
"@babel/runtime": ^7.17.2
checksum: f997985e2d4d15a62a0936a82ff6420b97f3f971e776fe685bdd50b4de0cb4dc2198bc75efe6b152844794ebd5040d8060d6d152506a687affad534834836d81
languageName: node
linkType: hard
"i18next@npm:^23.2.3":
version: 23.2.3
resolution: "i18next@npm:23.2.3"
@ -26394,15 +26343,6 @@ __metadata:
languageName: node
linkType: hard
"jiti@npm:^1.19.1":
version: 1.21.0
resolution: "jiti@npm:1.21.0"
bin:
jiti: bin/jiti.js
checksum: a7bd5d63921c170eaec91eecd686388181c7828e1fa0657ab374b9372bfc1f383cf4b039e6b272383d5cb25607509880af814a39abdff967322459cca41f2961
languageName: node
linkType: hard
"joi@npm:^17.7.0":
version: 17.10.2
resolution: "joi@npm:17.10.2"
@ -30129,31 +30069,6 @@ __metadata:
languageName: node
linkType: hard
"next-auth@npm:^4.20.1":
version: 4.24.5
resolution: "next-auth@npm:4.24.5"
dependencies:
"@babel/runtime": ^7.20.13
"@panva/hkdf": ^1.0.2
cookie: ^0.5.0
jose: ^4.11.4
oauth: ^0.9.15
openid-client: ^5.4.0
preact: ^10.6.3
preact-render-to-string: ^5.1.19
uuid: ^8.3.2
peerDependencies:
next: ^12.2.5 || ^13 || ^14
nodemailer: ^6.6.5
react: ^17.0.2 || ^18
react-dom: ^17.0.2 || ^18
peerDependenciesMeta:
nodemailer:
optional: true
checksum: 7cc49385123690ccb908f4552b75012717c4e45205a9fdc7cf48cd730dbcc7823a3e33e2a2073ecf1edae5c1980123f68678fd4af9198ea21ab0decb630cc71e
languageName: node
linkType: hard
"next-auth@npm:^4.22.1":
version: 4.22.1
resolution: "next-auth@npm:4.22.1"
@ -30221,24 +30136,6 @@ __metadata:
languageName: node
linkType: hard
"next-i18next@npm:^11.3.0":
version: 11.3.0
resolution: "next-i18next@npm:11.3.0"
dependencies:
"@babel/runtime": ^7.18.6
"@types/hoist-non-react-statics": ^3.3.1
core-js: ^3
hoist-non-react-statics: ^3.3.2
i18next: ^21.8.13
i18next-fs-backend: ^1.1.4
react-i18next: ^11.18.0
peerDependencies:
next: ">= 10.0.0"
react: ">= 16.8.0"
checksum: fbce97a4fbf9ad846c08652471a833c7f173c3e7ddc7cafa1423625b4a684715bb85f76ae06fe9cbed3e70f12b8e78e2459e5bc1a3c3f5c517743f17648f8939
languageName: node
linkType: hard
"next-i18next@patch:next-i18next@npm%3A13.3.0#./.yarn/patches/next-i18next-npm-13.3.0-bf25b0943c.patch::locator=calcom-monorepo%40workspace%3A.":
version: 13.3.0
resolution: "next-i18next@patch:next-i18next@npm%3A13.3.0#./.yarn/patches/next-i18next-npm-13.3.0-bf25b0943c.patch::version=13.3.0&hash=bcbde7&locator=calcom-monorepo%40workspace%3A."
@ -30320,7 +30217,7 @@ __metadata:
languageName: node
linkType: hard
"next@npm:^13.2.1, next@npm:^13.4.6":
"next@npm:^13.4.6":
version: 13.5.6
resolution: "next@npm:13.5.6"
dependencies:
@ -32994,18 +32891,6 @@ __metadata:
languageName: node
linkType: hard
"prisma@npm:^4.13.0":
version: 4.16.2
resolution: "prisma@npm:4.16.2"
dependencies:
"@prisma/engines": 4.16.2
bin:
prisma: build/index.js
prisma2: build/index.js
checksum: 1d0ed616abd7f8de22441e333b976705f1cb05abcb206965df3fc6a7ea03911ef467dd484a4bc51fdc6cff72dd9857b9852be5f232967a444af0a98c49bfdb76
languageName: node
linkType: hard
"prisma@npm:^5.4.2":
version: 5.4.2
resolution: "prisma@npm:5.4.2"
@ -33380,6 +33265,15 @@ __metadata:
languageName: node
linkType: hard
"qs@npm:^6.11.0":
version: 6.11.2
resolution: "qs@npm:6.11.2"
dependencies:
side-channel: ^1.0.4
checksum: e812f3c590b2262548647d62f1637b6989cc56656dc960b893fe2098d96e1bd633f36576f4cd7564dfbff9db42e17775884db96d846bebe4f37420d073ecdc0b
languageName: node
linkType: hard
"qs@npm:~6.5.2":
version: 6.5.3
resolution: "qs@npm:6.5.3"
@ -33903,24 +33797,6 @@ __metadata:
languageName: node
linkType: hard
"react-i18next@npm:^11.18.0":
version: 11.18.6
resolution: "react-i18next@npm:11.18.6"
dependencies:
"@babel/runtime": ^7.14.5
html-parse-stringify: ^3.0.1
peerDependencies:
i18next: ">= 19.0.0"
react: ">= 16.8.0"
peerDependenciesMeta:
react-dom:
optional: true
react-native:
optional: true
checksum: 624c0a0313fac4e0d18560b83c99a8bd0a83abc02e5db8d01984e0643ac409d178668aa3a4720d01f7a0d9520d38598dcbff801d6f69a970bae67461de6cd852
languageName: node
linkType: hard
"react-i18next@npm:^12.2.0":
version: 12.3.1
resolution: "react-i18next@npm:12.3.1"
@ -34044,15 +33920,6 @@ __metadata:
languageName: node
linkType: hard
"react-live-chat-loader@npm:^2.7.3":
version: 2.8.2
resolution: "react-live-chat-loader@npm:2.8.2"
peerDependencies:
react: ^16.14.0 || ^17.0.0 || ^18.0.0
checksum: 30de0d27693f1c80641347f0efc9c846e0c8d52231eb3181b68d684ef580764d3bd8393d77ff61f3066af5cc65977fc1a108726965181ddbbd6a0feb0a9ebcb9
languageName: node
linkType: hard
"react-live-chat-loader@npm:^2.8.1":
version: 2.8.1
resolution: "react-live-chat-loader@npm:2.8.1"
@ -37458,6 +37325,16 @@ __metadata:
languageName: node
linkType: hard
"stripe@npm:^14.5.0":
version: 14.5.0
resolution: "stripe@npm:14.5.0"
dependencies:
"@types/node": ">=8.1.0"
qs: ^6.11.0
checksum: ad2f0205d1b0ce4691bf54dfd0953e197b2fb3c37c489e9f1799dfa75fbd21a49cb5ddb7bcf0a708242db565500ba494927e321aee91980f360c389a60048a0c
languageName: node
linkType: hard
"stripe@npm:^9.16.0":
version: 9.16.0
resolution: "stripe@npm:9.16.0"
@ -37917,39 +37794,6 @@ __metadata:
languageName: node
linkType: hard
"tailwindcss@npm:^3.2.1":
version: 3.3.5
resolution: "tailwindcss@npm:3.3.5"
dependencies:
"@alloc/quick-lru": ^5.2.0
arg: ^5.0.2
chokidar: ^3.5.3
didyoumean: ^1.2.2
dlv: ^1.1.3
fast-glob: ^3.3.0
glob-parent: ^6.0.2
is-glob: ^4.0.3
jiti: ^1.19.1
lilconfig: ^2.1.0
micromatch: ^4.0.5
normalize-path: ^3.0.0
object-hash: ^3.0.0
picocolors: ^1.0.0
postcss: ^8.4.23
postcss-import: ^15.1.0
postcss-js: ^4.0.1
postcss-load-config: ^4.0.1
postcss-nested: ^6.0.1
postcss-selector-parser: ^6.0.11
resolve: ^1.22.2
sucrase: ^3.32.0
bin:
tailwind: lib/cli.js
tailwindcss: lib/cli.js
checksum: e04bb3bb7f9f17e9b6db0c7ace755ef0d6d05bff36ebeb9e5006e13c018ed5566f09db30a1a34380e38fa93ebbb4ae0e28fe726879d5e9ddd8c5b52bffd26f14
languageName: node
linkType: hard
"tailwindcss@npm:^3.3.3":
version: 3.3.3
resolution: "tailwindcss@npm:3.3.3"
@ -42130,13 +41974,6 @@ __metadata:
languageName: node
linkType: hard
"zod@npm:^3.20.2":
version: 3.22.4
resolution: "zod@npm:3.22.4"
checksum: 80bfd7f8039b24fddeb0718a2ec7c02aa9856e4838d6aa4864335a047b6b37a3273b191ef335bf0b2002e5c514ef261ffcda5a589fb084a48c336ffc4cdbab7f
languageName: node
linkType: hard
"zod@npm:^3.21.4, zod@npm:^3.22.2":
version: 3.22.2
resolution: "zod@npm:3.22.2"