From 7bc14e8cf9be5d996f9775c0261fc2ad88cf682f Mon Sep 17 00:00:00 2001 From: zomars Date: Tue, 17 Jan 2023 16:02:16 -0700 Subject: [PATCH] Revert abstraction --- apps/auth/pages/auth/error.tsx | 58 ++++- apps/auth/pages/auth/forgot-password/[id].tsx | 195 +++++++++++++- .../auth/pages/auth/forgot-password/index.tsx | 146 ++++++++++- apps/auth/pages/auth/index.tsx | 1 - apps/auth/pages/auth/login.tsx | 240 +++++++++++++++++- apps/auth/pages/auth/logout.tsx | 65 ++++- apps/auth/pages/auth/new.tsx | 7 +- apps/auth/pages/auth/setup.tsx | 1 - apps/auth/pages/auth/setup/index.tsx | 70 +++++ apps/auth/pages/auth/signin.tsx | 43 +++- apps/auth/pages/auth/sso/[provider].tsx | 181 ++++++++++++- apps/auth/pages/auth/verify.tsx | 177 ++++++++++++- apps/web/pages/auth/error.tsx | 58 ++++- apps/web/pages/auth/forgot-password/[id].tsx | 195 +++++++++++++- apps/web/pages/auth/forgot-password/index.tsx | 147 ++++++++++- apps/web/pages/auth/index.tsx | 1 - apps/web/pages/auth/login.tsx | 240 +++++++++++++++++- apps/web/pages/auth/logout.tsx | 65 ++++- apps/web/pages/auth/new.tsx | 7 +- apps/web/pages/auth/setup.tsx | 1 - apps/web/pages/auth/setup/index.tsx | 70 +++++ apps/web/pages/auth/signin.tsx | 43 +++- apps/web/pages/auth/sso/[provider].tsx | 181 ++++++++++++- apps/web/pages/auth/verify.tsx | 177 ++++++++++++- 24 files changed, 2347 insertions(+), 22 deletions(-) delete mode 100644 apps/auth/pages/auth/index.tsx delete mode 100644 apps/auth/pages/auth/setup.tsx create mode 100644 apps/auth/pages/auth/setup/index.tsx delete mode 100644 apps/web/pages/auth/index.tsx delete mode 100644 apps/web/pages/auth/setup.tsx create mode 100644 apps/web/pages/auth/setup/index.tsx diff --git a/apps/auth/pages/auth/error.tsx b/apps/auth/pages/auth/error.tsx index 9ebab32841..50d709b635 100644 --- a/apps/auth/pages/auth/error.tsx +++ b/apps/auth/pages/auth/error.tsx @@ -1 +1,57 @@ -export { default, getStaticProps } from "@calcom/features/auth/pages/error"; +import { GetStaticPropsContext } from "next"; +import Link from "next/link"; +import { useRouter } from "next/router"; +import z from "zod"; + +import AuthContainer from "@calcom/features/auth/components/AuthContainer"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { ssgInit } from "@calcom/trpc/server/ssg"; +import { Button, Icon, SkeletonText } from "@calcom/ui"; + +const querySchema = z.object({ + error: z.string().optional(), +}); + +export default function Error() { + const { t } = useLocale(); + const router = useRouter(); + const { error } = querySchema.parse(router.query); + const isTokenVerificationError = error?.toLowerCase() === "verification"; + let errorMsg = ; + if (router.isReady) { + errorMsg = isTokenVerificationError ? t("token_invalid_expired") : t("error_during_login"); + } + + return ( + +
+
+ +
+
+ +
+

{errorMsg}

+
+
+
+
+ + + +
+
+ ); +} + +export const getStaticProps = async (context: GetStaticPropsContext) => { + const ssr = await ssgInit(context); + + return { + props: { + trpcState: ssr.dehydrate(), + }, + }; +}; diff --git a/apps/auth/pages/auth/forgot-password/[id].tsx b/apps/auth/pages/auth/forgot-password/[id].tsx index bf19f379f2..a4840b09b4 100644 --- a/apps/auth/pages/auth/forgot-password/[id].tsx +++ b/apps/auth/pages/auth/forgot-password/[id].tsx @@ -1 +1,194 @@ -export { default, getServerSideProps } from "@calcom/features/auth/pages/forgot-password/[id]"; +import { ResetPasswordRequest } from "@prisma/client"; +import debounce from "lodash/debounce"; +import { GetServerSidePropsContext } from "next"; +import { getCsrfToken } from "next-auth/react"; +import Link from "next/link"; +import React, { useMemo } from "react"; + +import dayjs from "@calcom/dayjs"; +import AuthContainer from "@calcom/features/auth/components/AuthContainer"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import prisma from "@calcom/prisma"; +import { Button, TextField } from "@calcom/ui"; + +type Props = { + id: string; + resetPasswordRequest: ResetPasswordRequest; + csrfToken: string; +}; + +export default function Page({ resetPasswordRequest, csrfToken }: Props) { + const { t } = useLocale(); + const [loading, setLoading] = React.useState(false); + const [error, setError] = React.useState<{ message: string } | null>(null); + const [success, setSuccess] = React.useState(false); + + const [password, setPassword] = React.useState(""); + + const submitChangePassword = async ({ password, requestId }: { password: string; requestId: string }) => { + try { + const res = await fetch("/api/auth/reset-password", { + method: "POST", + body: JSON.stringify({ requestId: requestId, password: password }), + headers: { + "Content-Type": "application/json", + }, + }); + + const json = await res.json(); + + if (!res.ok) { + setError(json); + } else { + setSuccess(true); + } + + return json; + } catch (reason) { + setError({ message: t("unexpected_error_try_again") }); + } finally { + setLoading(false); + } + }; + + const debouncedChangePassword = debounce(submitChangePassword, 250); + + const Success = () => { + return ( + <> +
+
+

+ {t("password_updated")} +

+
+ +
+ + ); + }; + + const Expired = () => { + return ( + <> +
+
+

{t("whoops")}

+

{t("request_is_expired")}

+
+

{t("request_is_expired_instructions")}

+ + + +
+ + ); + }; + + const isRequestExpired = useMemo(() => { + const now = dayjs(); + return dayjs(resetPasswordRequest.expires).isBefore(now); + }, [resetPasswordRequest]); + + return ( + + {isRequestExpired && } + {!isRequestExpired && !success && ( + <> +
{ + e.preventDefault(); + + if (!password) { + return; + } + + if (loading) { + return; + } + + setLoading(true); + setError(null); + setSuccess(false); + + await debouncedChangePassword({ password, requestId: resetPasswordRequest.id }); + }} + action="#"> + +
+ { + setPassword(e.target.value); + }} + id="password" + name="password" + type="password" + autoComplete="password" + required + /> +
+ +
+ +
+
+ + )} + {!isRequestExpired && success && ( + <> + + + )} +
+ ); +} + +export async function getServerSideProps(context: GetServerSidePropsContext) { + const id = context.params?.id as string; + + try { + const resetPasswordRequest = await prisma.resetPasswordRequest.findUniqueOrThrow({ + where: { + id, + }, + select: { + id: true, + expires: true, + }, + }); + + return { + props: { + resetPasswordRequest: { + ...resetPasswordRequest, + expires: resetPasswordRequest.expires.toString(), + }, + id, + csrfToken: await getCsrfToken({ req: context.req }), + }, + }; + } catch (reason) { + return { + notFound: true, + }; + } +} diff --git a/apps/auth/pages/auth/forgot-password/index.tsx b/apps/auth/pages/auth/forgot-password/index.tsx index 7d956ef910..ab849beeef 100644 --- a/apps/auth/pages/auth/forgot-password/index.tsx +++ b/apps/auth/pages/auth/forgot-password/index.tsx @@ -1 +1,145 @@ -export { default } from "@calcom/features/auth/pages/forgot-password"; +import debounce from "lodash/debounce"; +import { GetServerSidePropsContext } from "next"; +import { getCsrfToken } from "next-auth/react"; +import Link from "next/link"; +import { useRouter } from "next/router"; +import React, { SyntheticEvent } from "react"; + +import AuthContainer from "@calcom/features/auth/components/AuthContainer"; +import { getSession } from "@calcom/features/auth/lib"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { Button, EmailField } from "@calcom/ui"; + +export default function ForgotPassword({ csrfToken }: { csrfToken: string }) { + const { t, i18n } = useLocale(); + const [loading, setLoading] = React.useState(false); + const [error, setError] = React.useState<{ message: string } | null>(null); + const [success, setSuccess] = React.useState(false); + const [email, setEmail] = React.useState(""); + const router = useRouter(); + + const handleChange = (e: SyntheticEvent) => { + const target = e.target as typeof e.target & { value: string }; + setEmail(target.value); + }; + + const submitForgotPasswordRequest = async ({ email }: { email: string }) => { + try { + const res = await fetch("/api/auth/forgot-password", { + method: "POST", + body: JSON.stringify({ email: email, language: i18n.language }), + headers: { + "Content-Type": "application/json", + }, + }); + + const json = await res.json(); + if (!res.ok) { + setError(json); + } else if ("resetLink" in json) { + router.push(json.resetLink); + } else { + setSuccess(true); + } + + return json; + } catch (reason) { + setError({ message: t("unexpected_error_try_again") }); + } finally { + setLoading(false); + } + }; + + const debouncedHandleSubmitPasswordRequest = debounce(submitForgotPasswordRequest, 250); + + const handleSubmit = async (e: SyntheticEvent) => { + e.preventDefault(); + + if (!email) { + return; + } + + if (loading) { + return; + } + + setLoading(true); + setError(null); + setSuccess(false); + + await debouncedHandleSubmitPasswordRequest({ email }); + }; + + const Success = () => { + return ( +
+

{t("password_reset_email", { email })}

+

{t("password_reset_leading")}

+ {error &&

{error.message}

} + +
+ ); + }; + + return ( + + + {t("back_to_signin")} + + + ) + }> + {success && } + {!success && ( + <> +
{error &&

{error.message}

}
+
+ + +
+ +
+ + + )} +
+ ); +} + +ForgotPassword.getInitialProps = async (context: GetServerSidePropsContext) => { + const { req, res } = context; + const session = await getSession({ req }); + + if (session) { + res.writeHead(302, { Location: "/" }); + res.end(); + return; + } + + return { + csrfToken: await getCsrfToken(context), + }; +}; diff --git a/apps/auth/pages/auth/index.tsx b/apps/auth/pages/auth/index.tsx deleted file mode 100644 index f7f1efa653..0000000000 --- a/apps/auth/pages/auth/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export { default, getServerSideProps } from "@calcom/features/auth/pages/login"; diff --git a/apps/auth/pages/auth/login.tsx b/apps/auth/pages/auth/login.tsx index f7f1efa653..52be8d1507 100644 --- a/apps/auth/pages/auth/login.tsx +++ b/apps/auth/pages/auth/login.tsx @@ -1 +1,239 @@ -export { default, getServerSideProps } from "@calcom/features/auth/pages/login"; +import classNames from "classnames"; +import { GetServerSidePropsContext } from "next"; +import { getCsrfToken, signIn } from "next-auth/react"; +import Link from "next/link"; +import { useRouter } from "next/router"; +import { useState } from "react"; +import { FormProvider, useForm } from "react-hook-form"; +import { FaGoogle } from "react-icons/fa"; + +import AuthContainer from "@calcom/features/auth/components/AuthContainer"; +import { SAMLLogin } from "@calcom/features/auth/components/SAMLLogin"; +import TwoFactor from "@calcom/features/auth/components/TwoFactor"; +import { ErrorCode, getSession } from "@calcom/features/auth/lib"; +import { isSAMLLoginEnabled, samlProductID, samlTenantID } from "@calcom/features/ee/sso/lib/saml"; +import { WEBAPP_URL, WEBSITE_URL } from "@calcom/lib/constants"; +import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry"; +import prisma from "@calcom/prisma"; +import { ssrInit } from "@calcom/trpc/server/ssr"; +import { inferSSRProps } from "@calcom/types/inferSSRProps"; +import { AddToHomescreen, Alert, Button, EmailField, Icon, PasswordField } from "@calcom/ui"; +// TODO: Fix this import +import { IS_GOOGLE_LOGIN_ENABLED } from "@calcom/web/server/lib/constants"; + +interface LoginValues { + email: string; + password: string; + totpCode: string; + csrfToken: string; +} + +export default function Login({ + csrfToken, + isGoogleLoginEnabled, + isSAMLLoginEnabled, + samlTenantID, + samlProductID, +}: inferSSRProps) { + const { t } = useLocale(); + const router = useRouter(); + const methods = useForm(); + + const { register, formState } = methods; + + const [twoFactorRequired, setTwoFactorRequired] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); + + const errorMessages: { [key: string]: string } = { + // [ErrorCode.SecondFactorRequired]: t("2fa_enabled_instructions"), + [ErrorCode.IncorrectPassword]: `${t("incorrect_password")} ${t("please_try_again")}`, + [ErrorCode.UserNotFound]: t("no_account_exists"), + [ErrorCode.IncorrectTwoFactorCode]: `${t("incorrect_2fa_code")} ${t("please_try_again")}`, + [ErrorCode.InternalServerError]: `${t("something_went_wrong")} ${t("please_try_again_and_contact_us")}`, + [ErrorCode.ThirdPartyIdentityProviderEnabled]: t("account_created_with_identity_provider"), + }; + + const telemetry = useTelemetry(); + + let callbackUrl = typeof router.query?.callbackUrl === "string" ? router.query.callbackUrl : ""; + + if (/"\//.test(callbackUrl)) callbackUrl = callbackUrl.substring(1); + + // If not absolute URL, make it absolute + if (!/^https?:\/\//.test(callbackUrl)) { + callbackUrl = `${WEBAPP_URL}/${callbackUrl}`; + } + + const safeCallbackUrl = getSafeRedirectUrl(callbackUrl); + + callbackUrl = safeCallbackUrl || ""; + + const LoginFooter = ( + + {t("dont_have_an_account")} + + ); + + const TwoFactorFooter = ( + + ); + + const onSubmit = async (values: LoginValues) => { + setErrorMessage(null); + telemetry.event(telemetryEventTypes.login, collectPageParameters()); + const res = await signIn<"credentials">("credentials", { + ...values, + callbackUrl, + redirect: false, + }); + if (!res) setErrorMessage(errorMessages[ErrorCode.InternalServerError]); + // we're logged in! let's do a hard refresh to the desired url + else if (!res.error) router.push(callbackUrl); + // reveal two factor input if required + else if (res.error === ErrorCode.SecondFactorRequired) setTwoFactorRequired(true); + // fallback if error not found + else setErrorMessage(errorMessages[res.error] || t("something_went_wrong")); + }; + + return ( + <> + + +
+
+ +
+
+
+ +
+
+ + {t("forgot")} + +
+ +
+
+ + {twoFactorRequired && } + + {errorMessage && } + +
+
+ {!twoFactorRequired && ( + <> + {(isGoogleLoginEnabled || isSAMLLoginEnabled) &&
} +
+ {isGoogleLoginEnabled && ( + + )} + {isSAMLLoginEnabled && ( + + )} +
+ + )} +
+
+ + + ); +} + +export async function getServerSideProps(context: GetServerSidePropsContext) { + const { req } = context; + const session = await getSession({ req }); + const ssr = await ssrInit(context); + + if (session) { + return { + redirect: { + destination: "/", + permanent: false, + }, + }; + } + + const userCount = await prisma.user.count(); + if (userCount === 0) { + // Proceed to new onboarding to create first admin user + return { + redirect: { + destination: "/auth/setup", + permanent: false, + }, + }; + } + + return { + props: { + csrfToken: await getCsrfToken(context), + trpcState: ssr.dehydrate(), + isGoogleLoginEnabled: IS_GOOGLE_LOGIN_ENABLED, + isSAMLLoginEnabled, + samlTenantID, + samlProductID, + }, + }; +} diff --git a/apps/auth/pages/auth/logout.tsx b/apps/auth/pages/auth/logout.tsx index 3e62ef9dff..0f75c48ed2 100644 --- a/apps/auth/pages/auth/logout.tsx +++ b/apps/auth/pages/auth/logout.tsx @@ -1 +1,64 @@ -export { default, getServerSideProps } from "@calcom/features/auth/pages/logout"; +import { GetServerSidePropsContext } from "next"; +import { signOut, useSession } from "next-auth/react"; +import { useRouter } from "next/router"; +import { useEffect } from "react"; + +import AuthContainer from "@calcom/features/auth/components/AuthContainer"; +import { WEBSITE_URL } from "@calcom/lib/constants"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { ssrInit } from "@calcom/trpc/server/ssr"; +import { Button, Icon } from "@calcom/ui"; + +import { inferSSRProps } from "@lib/types/inferSSRProps"; + +type Props = inferSSRProps; + +export default function Logout(props: Props) { + const { status } = useSession(); + if (status === "authenticated") signOut({ redirect: false }); + const router = useRouter(); + useEffect(() => { + if (props.query?.survey === "true") { + router.push(`${WEBSITE_URL}/cancellation`); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [props.query?.survey]); + const { t } = useLocale(); + + return ( + +
+
+ +
+
+ +
+

{t("hope_to_see_you_soon")}

+
+
+
+ +
+ ); +} + +export async function getServerSideProps(context: GetServerSidePropsContext) { + const ssr = await ssrInit(context); + // Deleting old cookie manually, remove this code after all existing cookies have expired + context.res.setHeader( + "Set-Cookie", + "next-auth.session-token=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT;" + ); + + return { + props: { + trpcState: ssr.dehydrate(), + query: context.query, + }, + }; +} diff --git a/apps/auth/pages/auth/new.tsx b/apps/auth/pages/auth/new.tsx index 77cf0c5cdc..ea4eb23669 100644 --- a/apps/auth/pages/auth/new.tsx +++ b/apps/auth/pages/auth/new.tsx @@ -1 +1,6 @@ -export { default } from "@calcom/features/auth/pages/new"; +export default function NewUserPage() { + if (typeof window !== "undefined") { + window.location.assign(process.env.NEXT_PUBLIC_WEBAPP_URL || "https://app.cal.com"); + } + return null; +} diff --git a/apps/auth/pages/auth/setup.tsx b/apps/auth/pages/auth/setup.tsx deleted file mode 100644 index eba06cc52e..0000000000 --- a/apps/auth/pages/auth/setup.tsx +++ /dev/null @@ -1 +0,0 @@ -export { default, getServerSideProps } from "@calcom/features/auth/pages/setup"; diff --git a/apps/auth/pages/auth/setup/index.tsx b/apps/auth/pages/auth/setup/index.tsx new file mode 100644 index 0000000000..028764b490 --- /dev/null +++ b/apps/auth/pages/auth/setup/index.tsx @@ -0,0 +1,70 @@ +import { UserPermissionRole } from "@prisma/client"; +import { GetServerSidePropsContext } from "next"; +import { useState } from "react"; + +import AdminAppsList from "@calcom/features/apps/AdminAppsList"; +import { getSession } from "@calcom/features/auth/lib"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import prisma from "@calcom/prisma"; +import { inferSSRProps } from "@calcom/types/inferSSRProps"; +import { WizardForm } from "@calcom/ui"; + +import SetupFormStep1 from "../../components/SetupFormStep1"; +import StepDone from "../../components/StepDone"; + +export default function Setup(props: inferSSRProps) { + const { t } = useLocale(); + const [isLoadingStep1, setIsLoadingStep1] = useState(false); + const shouldDisable = props.userCount !== 0; + + const steps = [ + { + title: t("administrator_user"), + description: t("lets_create_first_administrator_user"), + content: shouldDisable ? : , + isLoading: isLoadingStep1, + }, + { + title: t("enable_apps"), + description: t("enable_apps_description"), + content: , + isLoading: false, + }, + ]; + + return ( + <> +
+ t("current_step_of_total", { currentStep, maxSteps })} + /> +
+ + ); +} + +export const getServerSideProps = async (context: GetServerSidePropsContext) => { + const userCount = await prisma.user.count(); + const { req } = context; + const session = await getSession({ req }); + + if (session?.user.role && session?.user.role !== UserPermissionRole.ADMIN) { + return { + redirect: { + destination: `/404`, + permanent: false, + }, + }; + } + + return { + props: { + userCount, + }, + }; +}; diff --git a/apps/auth/pages/auth/signin.tsx b/apps/auth/pages/auth/signin.tsx index 92aeb44ab1..0d0e78f173 100644 --- a/apps/auth/pages/auth/signin.tsx +++ b/apps/auth/pages/auth/signin.tsx @@ -1 +1,42 @@ -export { default, getServerSideProps } from "@calcom/features/auth/pages/signin"; +import { GetServerSidePropsContext } from "next"; +import { getProviders, signIn, getSession, getCsrfToken } from "next-auth/react"; + +import { Button } from "@calcom/ui"; + +type Provider = { + name: string; + id: string; +}; + +function signin({ providers }: { providers: Provider[] }) { + return ( +
+ {Object.values(providers).map((provider) => { + return ( +
+ +
+ ); + })} +
+ ); +} + +export default signin; + +export async function getServerSideProps(context: GetServerSidePropsContext) { + const session = await getSession(context); + const csrfToken = await getCsrfToken(context); + const providers = await getProviders(); + if (session) { + return { + redirect: { destination: "/" }, + }; + } + return { + props: { + csrfToken, + providers, + }, + }; +} diff --git a/apps/auth/pages/auth/sso/[provider].tsx b/apps/auth/pages/auth/sso/[provider].tsx index 25fe19818b..9adbc85ef8 100644 --- a/apps/auth/pages/auth/sso/[provider].tsx +++ b/apps/auth/pages/auth/sso/[provider].tsx @@ -1 +1,180 @@ -export { default, getServerSideProps } from "@calcom/features/auth/pages/sso/[provider]"; +import { GetServerSidePropsContext } from "next"; +import { signIn } from "next-auth/react"; +import { useRouter } from "next/router"; +import { useEffect } from "react"; +import { z } from "zod"; + +import { getPremiumMonthlyPlanPriceId } from "@calcom/app-store/stripepayment/lib/utils"; +import stripe from "@calcom/features/ee/payments/server/stripe"; +import { + hostedCal, + isSAMLLoginEnabled, + samlProductID, + samlTenantID, + samlTenantProduct, +} from "@calcom/features/ee/sso/lib/saml"; +import { checkUsername } from "@calcom/lib/server/checkUsername"; +import prisma from "@calcom/prisma"; +// TODO: Fix this import +import { ssrInit } from "@calcom/trpc/server/ssr"; +import { inferSSRProps } from "@calcom/types/inferSSRProps"; + +import { getSession } from "../../lib"; + +type SSOProviderPageProps = inferSSRProps; + +export default function Provider(props: SSOProviderPageProps) { + const router = useRouter(); + + useEffect(() => { + if (props.provider === "saml") { + const email = typeof router.query?.email === "string" ? router.query?.email : null; + + if (!email) { + router.push("/auth/error?error=" + "Email not provided"); + return; + } + + if (!props.isSAMLLoginEnabled) { + router.push("/auth/error?error=" + "SAML login not enabled"); + return; + } + + signIn("saml", {}, { tenant: props.tenant, product: props.product }); + } else { + signIn(props.provider); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + return null; +} + +const querySchema = z.object({ + provider: z.union([z.string(), z.null()]).optional().default(null), + email: z.union([z.string(), z.null()]).optional().default(null), + username: z.union([z.string(), z.null()]).optional().default(null), +}); + +export const getServerSideProps = async (context: GetServerSidePropsContext) => { + const { + provider: providerParam, + email: emailParam, + username: usernameParam, + } = querySchema.parse(context.query); + const successDestination = "/getting-started" + (usernameParam ? `?username=${usernameParam}` : ""); + if (!providerParam) { + throw new Error(`File is not named sso/[provider]`); + } + + const { req } = context; + + const session = await getSession({ req }); + const ssr = await ssrInit(context); + + if (session) { + // 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); + if (availability.available && availability.premium) { + const stripePremiumUrl = await getStripePremiumUsernameUrl({ + userEmail: session.user.email, + username: usernameParam, + successDestination, + }); + if (stripePremiumUrl) { + return { + redirect: { + destination: stripePremiumUrl, + permanent: false, + }, + }; + } + } + } + + return { + redirect: { + destination: successDestination, + permanent: false, + }, + }; + } + + let error: string | null = null; + + let tenant = samlTenantID; + let product = samlProductID; + + if (providerParam === "saml" && hostedCal) { + if (!emailParam) { + error = "Email not provided"; + } else { + try { + const ret = await samlTenantProduct(prisma, emailParam); + tenant = ret.tenant; + product = ret.product; + } catch (e: any) { + error = e.message; + } + } + } + + if (error) { + return { + redirect: { + destination: "/auth/error?error=" + error, + permanent: false, + }, + }; + } + + return { + props: { + trpcState: ssr.dehydrate(), + provider: providerParam, + isSAMLLoginEnabled, + hostedCal, + tenant, + product, + error, + }, + }; +}; + +type GetStripePremiumUsernameUrl = { + userEmail: string; + username: string; + successDestination: string; +}; + +const getStripePremiumUsernameUrl = async ({ + userEmail, + username, + successDestination, +}: GetStripePremiumUsernameUrl): Promise => { + // @TODO: probably want to check if stripe user email already exists? or not + const customer = await stripe.customers.create({ + email: userEmail, + metadata: { + email: userEmail, + username, + }, + }); + + const checkoutSession = await stripe.checkout.sessions.create({ + mode: "subscription", + payment_method_types: ["card"], + customer: customer.id, + line_items: [ + { + price: getPremiumMonthlyPlanPriceId(), + quantity: 1, + }, + ], + success_url: `${process.env.NEXT_PUBLIC_WEBAPP_URL}${successDestination}&session_id={CHECKOUT_SESSION_ID}`, + cancel_url: process.env.NEXT_PUBLIC_WEBAPP_URL || "https://app.cal.com", + allow_promotion_codes: true, + }); + + return checkoutSession.url; +}; diff --git a/apps/auth/pages/auth/verify.tsx b/apps/auth/pages/auth/verify.tsx index e93b29337d..21c3c6b1b2 100644 --- a/apps/auth/pages/auth/verify.tsx +++ b/apps/auth/pages/auth/verify.tsx @@ -1 +1,176 @@ -export { default } from "@calcom/features/auth/pages/verify"; +import { CheckIcon, ExclamationIcon, MailOpenIcon } from "@heroicons/react/outline"; +import { signIn } from "next-auth/react"; +import Head from "next/head"; +import { useRouter } from "next/router"; +import { useEffect, useRef, useState } from "react"; +import z from "zod"; + +import { APP_NAME, WEBAPP_URL } from "@calcom/lib/constants"; +import { trpc } from "@calcom/trpc/react"; +import { Button, Loader, showToast } from "@calcom/ui"; + +async function sendVerificationLogin(email: string, username: string) { + await signIn("email", { + email: email.toLowerCase(), + username: username.toLowerCase(), + redirect: false, + callbackUrl: WEBAPP_URL || "https://app.cal.com", + }) + .then(() => { + showToast("Verification email sent", "success"); + }) + .catch((err) => { + showToast(err, "error"); + }); +} + +function useSendFirstVerificationLogin({ + email, + username, +}: { + email: string | undefined; + username: string | undefined; +}) { + const sent = useRef(false); + useEffect(() => { + if (!email || !username || sent.current) { + return; + } + (async () => { + await sendVerificationLogin(email, username); + sent.current = true; + })(); + }, [email, username]); +} + +const querySchema = z.object({ + stripeCustomerId: z.string().optional(), + sessionId: z.string().optional(), + t: z.string().optional(), +}); + +export default function Verify() { + const router = useRouter(); + const { t, sessionId, stripeCustomerId } = querySchema.parse(router.query); + const [secondsLeft, setSecondsLeft] = useState(30); + const { data } = trpc.viewer.public.stripeCheckoutSession.useQuery({ + stripeCustomerId, + checkoutSessionId: sessionId, + }); + useSendFirstVerificationLogin({ email: data?.customer?.email, username: data?.customer?.username }); + // @note: check for t=timestamp and apply disabled state and secondsLeft accordingly + // to avoid refresh to skip waiting 30 seconds to re-send email + useEffect(() => { + const lastSent = new Date(parseInt(`${t}`)); + // @note: This double round() looks ugly but it's the only way I came up to get the time difference in seconds + const difference = Math.round(Math.round(new Date().getTime() - lastSent.getTime()) / 1000); + if (difference < 30) { + // If less than 30 seconds, set the seconds left to 30 - difference + setSecondsLeft(30 - difference); + } else { + // else set the seconds left to 0 and disabled false + setSecondsLeft(0); + } + }, [t]); + // @note: here we make sure each second is decremented if disabled up to 0. + useEffect(() => { + if (secondsLeft > 0) { + const interval = setInterval(() => { + if (secondsLeft > 0) { + setSecondsLeft(secondsLeft - 1); + } + }, 1000); + return () => clearInterval(interval); + } + }, [secondsLeft]); + + if (!router.isReady || !data) { + // Loading state + return ; + } + const { valid, hasPaymentFailed, customer } = data; + if (!valid) { + throw new Error("Invalid session or customer id"); + } + + if (!stripeCustomerId && !sessionId) { + return
Invalid Link
; + } + + return ( +
+ + + {/* @note: Ternary can look ugly ant his might be extracted later but I think at 3 it's not yet worth + it or too hard to read. */} + {hasPaymentFailed + ? "Your payment failed" + : sessionId + ? "Payment successful!" + : "Verify your email" + " | " + APP_NAME} + + +
+
+
+ {hasPaymentFailed ? ( + + ) : sessionId ? ( + + ) : ( + + )} +
+

+ {hasPaymentFailed + ? "Your payment failed" + : sessionId + ? "Payment successful!" + : "Check your Inbox"} +

+ {hasPaymentFailed && ( +

Your account has been created, but your premium has not been reserved.

+ )} +

+ We have sent an email to {customer?.email} with a link to activate your account.{" "} + {hasPaymentFailed && + "Once you activate your account you will be able to try purchase your premium username again or select a different one."} +

+

+ Don't see an email? Click the button below to send another email. +

+ +
+ + +
+
+
+
+ ); +} diff --git a/apps/web/pages/auth/error.tsx b/apps/web/pages/auth/error.tsx index 9ebab32841..50d709b635 100644 --- a/apps/web/pages/auth/error.tsx +++ b/apps/web/pages/auth/error.tsx @@ -1 +1,57 @@ -export { default, getStaticProps } from "@calcom/features/auth/pages/error"; +import { GetStaticPropsContext } from "next"; +import Link from "next/link"; +import { useRouter } from "next/router"; +import z from "zod"; + +import AuthContainer from "@calcom/features/auth/components/AuthContainer"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { ssgInit } from "@calcom/trpc/server/ssg"; +import { Button, Icon, SkeletonText } from "@calcom/ui"; + +const querySchema = z.object({ + error: z.string().optional(), +}); + +export default function Error() { + const { t } = useLocale(); + const router = useRouter(); + const { error } = querySchema.parse(router.query); + const isTokenVerificationError = error?.toLowerCase() === "verification"; + let errorMsg = ; + if (router.isReady) { + errorMsg = isTokenVerificationError ? t("token_invalid_expired") : t("error_during_login"); + } + + return ( + +
+
+ +
+
+ +
+

{errorMsg}

+
+
+
+
+ + + +
+
+ ); +} + +export const getStaticProps = async (context: GetStaticPropsContext) => { + const ssr = await ssgInit(context); + + return { + props: { + trpcState: ssr.dehydrate(), + }, + }; +}; diff --git a/apps/web/pages/auth/forgot-password/[id].tsx b/apps/web/pages/auth/forgot-password/[id].tsx index bf19f379f2..a4840b09b4 100644 --- a/apps/web/pages/auth/forgot-password/[id].tsx +++ b/apps/web/pages/auth/forgot-password/[id].tsx @@ -1 +1,194 @@ -export { default, getServerSideProps } from "@calcom/features/auth/pages/forgot-password/[id]"; +import { ResetPasswordRequest } from "@prisma/client"; +import debounce from "lodash/debounce"; +import { GetServerSidePropsContext } from "next"; +import { getCsrfToken } from "next-auth/react"; +import Link from "next/link"; +import React, { useMemo } from "react"; + +import dayjs from "@calcom/dayjs"; +import AuthContainer from "@calcom/features/auth/components/AuthContainer"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import prisma from "@calcom/prisma"; +import { Button, TextField } from "@calcom/ui"; + +type Props = { + id: string; + resetPasswordRequest: ResetPasswordRequest; + csrfToken: string; +}; + +export default function Page({ resetPasswordRequest, csrfToken }: Props) { + const { t } = useLocale(); + const [loading, setLoading] = React.useState(false); + const [error, setError] = React.useState<{ message: string } | null>(null); + const [success, setSuccess] = React.useState(false); + + const [password, setPassword] = React.useState(""); + + const submitChangePassword = async ({ password, requestId }: { password: string; requestId: string }) => { + try { + const res = await fetch("/api/auth/reset-password", { + method: "POST", + body: JSON.stringify({ requestId: requestId, password: password }), + headers: { + "Content-Type": "application/json", + }, + }); + + const json = await res.json(); + + if (!res.ok) { + setError(json); + } else { + setSuccess(true); + } + + return json; + } catch (reason) { + setError({ message: t("unexpected_error_try_again") }); + } finally { + setLoading(false); + } + }; + + const debouncedChangePassword = debounce(submitChangePassword, 250); + + const Success = () => { + return ( + <> +
+
+

+ {t("password_updated")} +

+
+ +
+ + ); + }; + + const Expired = () => { + return ( + <> +
+
+

{t("whoops")}

+

{t("request_is_expired")}

+
+

{t("request_is_expired_instructions")}

+ + + +
+ + ); + }; + + const isRequestExpired = useMemo(() => { + const now = dayjs(); + return dayjs(resetPasswordRequest.expires).isBefore(now); + }, [resetPasswordRequest]); + + return ( + + {isRequestExpired && } + {!isRequestExpired && !success && ( + <> +
{ + e.preventDefault(); + + if (!password) { + return; + } + + if (loading) { + return; + } + + setLoading(true); + setError(null); + setSuccess(false); + + await debouncedChangePassword({ password, requestId: resetPasswordRequest.id }); + }} + action="#"> + +
+ { + setPassword(e.target.value); + }} + id="password" + name="password" + type="password" + autoComplete="password" + required + /> +
+ +
+ +
+
+ + )} + {!isRequestExpired && success && ( + <> + + + )} +
+ ); +} + +export async function getServerSideProps(context: GetServerSidePropsContext) { + const id = context.params?.id as string; + + try { + const resetPasswordRequest = await prisma.resetPasswordRequest.findUniqueOrThrow({ + where: { + id, + }, + select: { + id: true, + expires: true, + }, + }); + + return { + props: { + resetPasswordRequest: { + ...resetPasswordRequest, + expires: resetPasswordRequest.expires.toString(), + }, + id, + csrfToken: await getCsrfToken({ req: context.req }), + }, + }; + } catch (reason) { + return { + notFound: true, + }; + } +} diff --git a/apps/web/pages/auth/forgot-password/index.tsx b/apps/web/pages/auth/forgot-password/index.tsx index 7d956ef910..c78c9d3225 100644 --- a/apps/web/pages/auth/forgot-password/index.tsx +++ b/apps/web/pages/auth/forgot-password/index.tsx @@ -1 +1,146 @@ -export { default } from "@calcom/features/auth/pages/forgot-password"; +import debounce from "lodash/debounce"; +import { GetServerSidePropsContext } from "next"; +import { getCsrfToken } from "next-auth/react"; +import Link from "next/link"; +import { useRouter } from "next/router"; +import React, { SyntheticEvent } from "react"; + +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { Button, EmailField } from "@calcom/ui"; + +import AuthContainer from "../../components/AuthContainer"; +import { getSession } from "../../lib"; + +export default function ForgotPassword({ csrfToken }: { csrfToken: string }) { + const { t, i18n } = useLocale(); + const [loading, setLoading] = React.useState(false); + const [error, setError] = React.useState<{ message: string } | null>(null); + const [success, setSuccess] = React.useState(false); + const [email, setEmail] = React.useState(""); + const router = useRouter(); + + const handleChange = (e: SyntheticEvent) => { + const target = e.target as typeof e.target & { value: string }; + setEmail(target.value); + }; + + const submitForgotPasswordRequest = async ({ email }: { email: string }) => { + try { + const res = await fetch("/api/auth/forgot-password", { + method: "POST", + body: JSON.stringify({ email: email, language: i18n.language }), + headers: { + "Content-Type": "application/json", + }, + }); + + const json = await res.json(); + if (!res.ok) { + setError(json); + } else if ("resetLink" in json) { + router.push(json.resetLink); + } else { + setSuccess(true); + } + + return json; + } catch (reason) { + setError({ message: t("unexpected_error_try_again") }); + } finally { + setLoading(false); + } + }; + + const debouncedHandleSubmitPasswordRequest = debounce(submitForgotPasswordRequest, 250); + + const handleSubmit = async (e: SyntheticEvent) => { + e.preventDefault(); + + if (!email) { + return; + } + + if (loading) { + return; + } + + setLoading(true); + setError(null); + setSuccess(false); + + await debouncedHandleSubmitPasswordRequest({ email }); + }; + + const Success = () => { + return ( +
+

{t("password_reset_email", { email })}

+

{t("password_reset_leading")}

+ {error &&

{error.message}

} + +
+ ); + }; + + return ( + + + {t("back_to_signin")} + + + ) + }> + {success && } + {!success && ( + <> +
{error &&

{error.message}

}
+
+ + +
+ +
+ + + )} +
+ ); +} + +ForgotPassword.getInitialProps = async (context: GetServerSidePropsContext) => { + const { req, res } = context; + const session = await getSession({ req }); + + if (session) { + res.writeHead(302, { Location: "/" }); + res.end(); + return; + } + + return { + csrfToken: await getCsrfToken(context), + }; +}; diff --git a/apps/web/pages/auth/index.tsx b/apps/web/pages/auth/index.tsx deleted file mode 100644 index f7f1efa653..0000000000 --- a/apps/web/pages/auth/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export { default, getServerSideProps } from "@calcom/features/auth/pages/login"; diff --git a/apps/web/pages/auth/login.tsx b/apps/web/pages/auth/login.tsx index f7f1efa653..52be8d1507 100644 --- a/apps/web/pages/auth/login.tsx +++ b/apps/web/pages/auth/login.tsx @@ -1 +1,239 @@ -export { default, getServerSideProps } from "@calcom/features/auth/pages/login"; +import classNames from "classnames"; +import { GetServerSidePropsContext } from "next"; +import { getCsrfToken, signIn } from "next-auth/react"; +import Link from "next/link"; +import { useRouter } from "next/router"; +import { useState } from "react"; +import { FormProvider, useForm } from "react-hook-form"; +import { FaGoogle } from "react-icons/fa"; + +import AuthContainer from "@calcom/features/auth/components/AuthContainer"; +import { SAMLLogin } from "@calcom/features/auth/components/SAMLLogin"; +import TwoFactor from "@calcom/features/auth/components/TwoFactor"; +import { ErrorCode, getSession } from "@calcom/features/auth/lib"; +import { isSAMLLoginEnabled, samlProductID, samlTenantID } from "@calcom/features/ee/sso/lib/saml"; +import { WEBAPP_URL, WEBSITE_URL } from "@calcom/lib/constants"; +import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry"; +import prisma from "@calcom/prisma"; +import { ssrInit } from "@calcom/trpc/server/ssr"; +import { inferSSRProps } from "@calcom/types/inferSSRProps"; +import { AddToHomescreen, Alert, Button, EmailField, Icon, PasswordField } from "@calcom/ui"; +// TODO: Fix this import +import { IS_GOOGLE_LOGIN_ENABLED } from "@calcom/web/server/lib/constants"; + +interface LoginValues { + email: string; + password: string; + totpCode: string; + csrfToken: string; +} + +export default function Login({ + csrfToken, + isGoogleLoginEnabled, + isSAMLLoginEnabled, + samlTenantID, + samlProductID, +}: inferSSRProps) { + const { t } = useLocale(); + const router = useRouter(); + const methods = useForm(); + + const { register, formState } = methods; + + const [twoFactorRequired, setTwoFactorRequired] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); + + const errorMessages: { [key: string]: string } = { + // [ErrorCode.SecondFactorRequired]: t("2fa_enabled_instructions"), + [ErrorCode.IncorrectPassword]: `${t("incorrect_password")} ${t("please_try_again")}`, + [ErrorCode.UserNotFound]: t("no_account_exists"), + [ErrorCode.IncorrectTwoFactorCode]: `${t("incorrect_2fa_code")} ${t("please_try_again")}`, + [ErrorCode.InternalServerError]: `${t("something_went_wrong")} ${t("please_try_again_and_contact_us")}`, + [ErrorCode.ThirdPartyIdentityProviderEnabled]: t("account_created_with_identity_provider"), + }; + + const telemetry = useTelemetry(); + + let callbackUrl = typeof router.query?.callbackUrl === "string" ? router.query.callbackUrl : ""; + + if (/"\//.test(callbackUrl)) callbackUrl = callbackUrl.substring(1); + + // If not absolute URL, make it absolute + if (!/^https?:\/\//.test(callbackUrl)) { + callbackUrl = `${WEBAPP_URL}/${callbackUrl}`; + } + + const safeCallbackUrl = getSafeRedirectUrl(callbackUrl); + + callbackUrl = safeCallbackUrl || ""; + + const LoginFooter = ( + + {t("dont_have_an_account")} + + ); + + const TwoFactorFooter = ( + + ); + + const onSubmit = async (values: LoginValues) => { + setErrorMessage(null); + telemetry.event(telemetryEventTypes.login, collectPageParameters()); + const res = await signIn<"credentials">("credentials", { + ...values, + callbackUrl, + redirect: false, + }); + if (!res) setErrorMessage(errorMessages[ErrorCode.InternalServerError]); + // we're logged in! let's do a hard refresh to the desired url + else if (!res.error) router.push(callbackUrl); + // reveal two factor input if required + else if (res.error === ErrorCode.SecondFactorRequired) setTwoFactorRequired(true); + // fallback if error not found + else setErrorMessage(errorMessages[res.error] || t("something_went_wrong")); + }; + + return ( + <> + + +
+
+ +
+
+
+ +
+
+ + {t("forgot")} + +
+ +
+
+ + {twoFactorRequired && } + + {errorMessage && } + +
+
+ {!twoFactorRequired && ( + <> + {(isGoogleLoginEnabled || isSAMLLoginEnabled) &&
} +
+ {isGoogleLoginEnabled && ( + + )} + {isSAMLLoginEnabled && ( + + )} +
+ + )} +
+
+ + + ); +} + +export async function getServerSideProps(context: GetServerSidePropsContext) { + const { req } = context; + const session = await getSession({ req }); + const ssr = await ssrInit(context); + + if (session) { + return { + redirect: { + destination: "/", + permanent: false, + }, + }; + } + + const userCount = await prisma.user.count(); + if (userCount === 0) { + // Proceed to new onboarding to create first admin user + return { + redirect: { + destination: "/auth/setup", + permanent: false, + }, + }; + } + + return { + props: { + csrfToken: await getCsrfToken(context), + trpcState: ssr.dehydrate(), + isGoogleLoginEnabled: IS_GOOGLE_LOGIN_ENABLED, + isSAMLLoginEnabled, + samlTenantID, + samlProductID, + }, + }; +} diff --git a/apps/web/pages/auth/logout.tsx b/apps/web/pages/auth/logout.tsx index 3e62ef9dff..0f75c48ed2 100644 --- a/apps/web/pages/auth/logout.tsx +++ b/apps/web/pages/auth/logout.tsx @@ -1 +1,64 @@ -export { default, getServerSideProps } from "@calcom/features/auth/pages/logout"; +import { GetServerSidePropsContext } from "next"; +import { signOut, useSession } from "next-auth/react"; +import { useRouter } from "next/router"; +import { useEffect } from "react"; + +import AuthContainer from "@calcom/features/auth/components/AuthContainer"; +import { WEBSITE_URL } from "@calcom/lib/constants"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { ssrInit } from "@calcom/trpc/server/ssr"; +import { Button, Icon } from "@calcom/ui"; + +import { inferSSRProps } from "@lib/types/inferSSRProps"; + +type Props = inferSSRProps; + +export default function Logout(props: Props) { + const { status } = useSession(); + if (status === "authenticated") signOut({ redirect: false }); + const router = useRouter(); + useEffect(() => { + if (props.query?.survey === "true") { + router.push(`${WEBSITE_URL}/cancellation`); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [props.query?.survey]); + const { t } = useLocale(); + + return ( + +
+
+ +
+
+ +
+

{t("hope_to_see_you_soon")}

+
+
+
+ +
+ ); +} + +export async function getServerSideProps(context: GetServerSidePropsContext) { + const ssr = await ssrInit(context); + // Deleting old cookie manually, remove this code after all existing cookies have expired + context.res.setHeader( + "Set-Cookie", + "next-auth.session-token=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT;" + ); + + return { + props: { + trpcState: ssr.dehydrate(), + query: context.query, + }, + }; +} diff --git a/apps/web/pages/auth/new.tsx b/apps/web/pages/auth/new.tsx index 77cf0c5cdc..ea4eb23669 100644 --- a/apps/web/pages/auth/new.tsx +++ b/apps/web/pages/auth/new.tsx @@ -1 +1,6 @@ -export { default } from "@calcom/features/auth/pages/new"; +export default function NewUserPage() { + if (typeof window !== "undefined") { + window.location.assign(process.env.NEXT_PUBLIC_WEBAPP_URL || "https://app.cal.com"); + } + return null; +} diff --git a/apps/web/pages/auth/setup.tsx b/apps/web/pages/auth/setup.tsx deleted file mode 100644 index eba06cc52e..0000000000 --- a/apps/web/pages/auth/setup.tsx +++ /dev/null @@ -1 +0,0 @@ -export { default, getServerSideProps } from "@calcom/features/auth/pages/setup"; diff --git a/apps/web/pages/auth/setup/index.tsx b/apps/web/pages/auth/setup/index.tsx new file mode 100644 index 0000000000..028764b490 --- /dev/null +++ b/apps/web/pages/auth/setup/index.tsx @@ -0,0 +1,70 @@ +import { UserPermissionRole } from "@prisma/client"; +import { GetServerSidePropsContext } from "next"; +import { useState } from "react"; + +import AdminAppsList from "@calcom/features/apps/AdminAppsList"; +import { getSession } from "@calcom/features/auth/lib"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import prisma from "@calcom/prisma"; +import { inferSSRProps } from "@calcom/types/inferSSRProps"; +import { WizardForm } from "@calcom/ui"; + +import SetupFormStep1 from "../../components/SetupFormStep1"; +import StepDone from "../../components/StepDone"; + +export default function Setup(props: inferSSRProps) { + const { t } = useLocale(); + const [isLoadingStep1, setIsLoadingStep1] = useState(false); + const shouldDisable = props.userCount !== 0; + + const steps = [ + { + title: t("administrator_user"), + description: t("lets_create_first_administrator_user"), + content: shouldDisable ? : , + isLoading: isLoadingStep1, + }, + { + title: t("enable_apps"), + description: t("enable_apps_description"), + content: , + isLoading: false, + }, + ]; + + return ( + <> +
+ t("current_step_of_total", { currentStep, maxSteps })} + /> +
+ + ); +} + +export const getServerSideProps = async (context: GetServerSidePropsContext) => { + const userCount = await prisma.user.count(); + const { req } = context; + const session = await getSession({ req }); + + if (session?.user.role && session?.user.role !== UserPermissionRole.ADMIN) { + return { + redirect: { + destination: `/404`, + permanent: false, + }, + }; + } + + return { + props: { + userCount, + }, + }; +}; diff --git a/apps/web/pages/auth/signin.tsx b/apps/web/pages/auth/signin.tsx index 92aeb44ab1..0d0e78f173 100644 --- a/apps/web/pages/auth/signin.tsx +++ b/apps/web/pages/auth/signin.tsx @@ -1 +1,42 @@ -export { default, getServerSideProps } from "@calcom/features/auth/pages/signin"; +import { GetServerSidePropsContext } from "next"; +import { getProviders, signIn, getSession, getCsrfToken } from "next-auth/react"; + +import { Button } from "@calcom/ui"; + +type Provider = { + name: string; + id: string; +}; + +function signin({ providers }: { providers: Provider[] }) { + return ( +
+ {Object.values(providers).map((provider) => { + return ( +
+ +
+ ); + })} +
+ ); +} + +export default signin; + +export async function getServerSideProps(context: GetServerSidePropsContext) { + const session = await getSession(context); + const csrfToken = await getCsrfToken(context); + const providers = await getProviders(); + if (session) { + return { + redirect: { destination: "/" }, + }; + } + return { + props: { + csrfToken, + providers, + }, + }; +} diff --git a/apps/web/pages/auth/sso/[provider].tsx b/apps/web/pages/auth/sso/[provider].tsx index 25fe19818b..9adbc85ef8 100644 --- a/apps/web/pages/auth/sso/[provider].tsx +++ b/apps/web/pages/auth/sso/[provider].tsx @@ -1 +1,180 @@ -export { default, getServerSideProps } from "@calcom/features/auth/pages/sso/[provider]"; +import { GetServerSidePropsContext } from "next"; +import { signIn } from "next-auth/react"; +import { useRouter } from "next/router"; +import { useEffect } from "react"; +import { z } from "zod"; + +import { getPremiumMonthlyPlanPriceId } from "@calcom/app-store/stripepayment/lib/utils"; +import stripe from "@calcom/features/ee/payments/server/stripe"; +import { + hostedCal, + isSAMLLoginEnabled, + samlProductID, + samlTenantID, + samlTenantProduct, +} from "@calcom/features/ee/sso/lib/saml"; +import { checkUsername } from "@calcom/lib/server/checkUsername"; +import prisma from "@calcom/prisma"; +// TODO: Fix this import +import { ssrInit } from "@calcom/trpc/server/ssr"; +import { inferSSRProps } from "@calcom/types/inferSSRProps"; + +import { getSession } from "../../lib"; + +type SSOProviderPageProps = inferSSRProps; + +export default function Provider(props: SSOProviderPageProps) { + const router = useRouter(); + + useEffect(() => { + if (props.provider === "saml") { + const email = typeof router.query?.email === "string" ? router.query?.email : null; + + if (!email) { + router.push("/auth/error?error=" + "Email not provided"); + return; + } + + if (!props.isSAMLLoginEnabled) { + router.push("/auth/error?error=" + "SAML login not enabled"); + return; + } + + signIn("saml", {}, { tenant: props.tenant, product: props.product }); + } else { + signIn(props.provider); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + return null; +} + +const querySchema = z.object({ + provider: z.union([z.string(), z.null()]).optional().default(null), + email: z.union([z.string(), z.null()]).optional().default(null), + username: z.union([z.string(), z.null()]).optional().default(null), +}); + +export const getServerSideProps = async (context: GetServerSidePropsContext) => { + const { + provider: providerParam, + email: emailParam, + username: usernameParam, + } = querySchema.parse(context.query); + const successDestination = "/getting-started" + (usernameParam ? `?username=${usernameParam}` : ""); + if (!providerParam) { + throw new Error(`File is not named sso/[provider]`); + } + + const { req } = context; + + const session = await getSession({ req }); + const ssr = await ssrInit(context); + + if (session) { + // 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); + if (availability.available && availability.premium) { + const stripePremiumUrl = await getStripePremiumUsernameUrl({ + userEmail: session.user.email, + username: usernameParam, + successDestination, + }); + if (stripePremiumUrl) { + return { + redirect: { + destination: stripePremiumUrl, + permanent: false, + }, + }; + } + } + } + + return { + redirect: { + destination: successDestination, + permanent: false, + }, + }; + } + + let error: string | null = null; + + let tenant = samlTenantID; + let product = samlProductID; + + if (providerParam === "saml" && hostedCal) { + if (!emailParam) { + error = "Email not provided"; + } else { + try { + const ret = await samlTenantProduct(prisma, emailParam); + tenant = ret.tenant; + product = ret.product; + } catch (e: any) { + error = e.message; + } + } + } + + if (error) { + return { + redirect: { + destination: "/auth/error?error=" + error, + permanent: false, + }, + }; + } + + return { + props: { + trpcState: ssr.dehydrate(), + provider: providerParam, + isSAMLLoginEnabled, + hostedCal, + tenant, + product, + error, + }, + }; +}; + +type GetStripePremiumUsernameUrl = { + userEmail: string; + username: string; + successDestination: string; +}; + +const getStripePremiumUsernameUrl = async ({ + userEmail, + username, + successDestination, +}: GetStripePremiumUsernameUrl): Promise => { + // @TODO: probably want to check if stripe user email already exists? or not + const customer = await stripe.customers.create({ + email: userEmail, + metadata: { + email: userEmail, + username, + }, + }); + + const checkoutSession = await stripe.checkout.sessions.create({ + mode: "subscription", + payment_method_types: ["card"], + customer: customer.id, + line_items: [ + { + price: getPremiumMonthlyPlanPriceId(), + quantity: 1, + }, + ], + success_url: `${process.env.NEXT_PUBLIC_WEBAPP_URL}${successDestination}&session_id={CHECKOUT_SESSION_ID}`, + cancel_url: process.env.NEXT_PUBLIC_WEBAPP_URL || "https://app.cal.com", + allow_promotion_codes: true, + }); + + return checkoutSession.url; +}; diff --git a/apps/web/pages/auth/verify.tsx b/apps/web/pages/auth/verify.tsx index e93b29337d..21c3c6b1b2 100644 --- a/apps/web/pages/auth/verify.tsx +++ b/apps/web/pages/auth/verify.tsx @@ -1 +1,176 @@ -export { default } from "@calcom/features/auth/pages/verify"; +import { CheckIcon, ExclamationIcon, MailOpenIcon } from "@heroicons/react/outline"; +import { signIn } from "next-auth/react"; +import Head from "next/head"; +import { useRouter } from "next/router"; +import { useEffect, useRef, useState } from "react"; +import z from "zod"; + +import { APP_NAME, WEBAPP_URL } from "@calcom/lib/constants"; +import { trpc } from "@calcom/trpc/react"; +import { Button, Loader, showToast } from "@calcom/ui"; + +async function sendVerificationLogin(email: string, username: string) { + await signIn("email", { + email: email.toLowerCase(), + username: username.toLowerCase(), + redirect: false, + callbackUrl: WEBAPP_URL || "https://app.cal.com", + }) + .then(() => { + showToast("Verification email sent", "success"); + }) + .catch((err) => { + showToast(err, "error"); + }); +} + +function useSendFirstVerificationLogin({ + email, + username, +}: { + email: string | undefined; + username: string | undefined; +}) { + const sent = useRef(false); + useEffect(() => { + if (!email || !username || sent.current) { + return; + } + (async () => { + await sendVerificationLogin(email, username); + sent.current = true; + })(); + }, [email, username]); +} + +const querySchema = z.object({ + stripeCustomerId: z.string().optional(), + sessionId: z.string().optional(), + t: z.string().optional(), +}); + +export default function Verify() { + const router = useRouter(); + const { t, sessionId, stripeCustomerId } = querySchema.parse(router.query); + const [secondsLeft, setSecondsLeft] = useState(30); + const { data } = trpc.viewer.public.stripeCheckoutSession.useQuery({ + stripeCustomerId, + checkoutSessionId: sessionId, + }); + useSendFirstVerificationLogin({ email: data?.customer?.email, username: data?.customer?.username }); + // @note: check for t=timestamp and apply disabled state and secondsLeft accordingly + // to avoid refresh to skip waiting 30 seconds to re-send email + useEffect(() => { + const lastSent = new Date(parseInt(`${t}`)); + // @note: This double round() looks ugly but it's the only way I came up to get the time difference in seconds + const difference = Math.round(Math.round(new Date().getTime() - lastSent.getTime()) / 1000); + if (difference < 30) { + // If less than 30 seconds, set the seconds left to 30 - difference + setSecondsLeft(30 - difference); + } else { + // else set the seconds left to 0 and disabled false + setSecondsLeft(0); + } + }, [t]); + // @note: here we make sure each second is decremented if disabled up to 0. + useEffect(() => { + if (secondsLeft > 0) { + const interval = setInterval(() => { + if (secondsLeft > 0) { + setSecondsLeft(secondsLeft - 1); + } + }, 1000); + return () => clearInterval(interval); + } + }, [secondsLeft]); + + if (!router.isReady || !data) { + // Loading state + return ; + } + const { valid, hasPaymentFailed, customer } = data; + if (!valid) { + throw new Error("Invalid session or customer id"); + } + + if (!stripeCustomerId && !sessionId) { + return
Invalid Link
; + } + + return ( +
+ + + {/* @note: Ternary can look ugly ant his might be extracted later but I think at 3 it's not yet worth + it or too hard to read. */} + {hasPaymentFailed + ? "Your payment failed" + : sessionId + ? "Payment successful!" + : "Verify your email" + " | " + APP_NAME} + + +
+
+
+ {hasPaymentFailed ? ( + + ) : sessionId ? ( + + ) : ( + + )} +
+

+ {hasPaymentFailed + ? "Your payment failed" + : sessionId + ? "Payment successful!" + : "Check your Inbox"} +

+ {hasPaymentFailed && ( +

Your account has been created, but your premium has not been reserved.

+ )} +

+ We have sent an email to {customer?.email} with a link to activate your account.{" "} + {hasPaymentFailed && + "Once you activate your account you will be able to try purchase your premium username again or select a different one."} +

+

+ Don't see an email? Click the button below to send another email. +

+ +
+ + +
+
+
+
+ ); +}