chore: App router migration - /auth/forgot-password/*, /auth/login, /auth/logout (#16467)

* migrate auth/forgot-password/*, auth/login, auth/logout

* revert

* fix forgot password

* fix folder structure

* fix logout

* fix middleware

* remove TODO log

* refactor logout
This commit is contained in:
Benny Joo
2024-09-03 18:53:16 +09:00
committed by GitHub
parent 12a9c74eb6
commit d059b1399b
16 changed files with 744 additions and 662 deletions
@@ -1,9 +1,11 @@
import SetNewUserPassword from "@pages/auth/forgot-password/[id]";
import { withAppDirSsr } from "app/WithAppDirSsr";
import { _generateMetadata } from "app/_utils";
import { WithLayout } from "app/layoutHOC";
import { getServerSideProps } from "@server/lib/forgot-password/[id]/getServerSideProps";
import { getServerSideProps } from "@server/lib/auth/forgot-password/[id]/getServerSideProps";
import type { PageProps } from "~/auth/forgot-password/[id]/forgot-password-single-view";
import SetNewUserPassword from "~/auth/forgot-password/[id]/forgot-password-single-view";
export const generateMetadata = async () => {
return await _generateMetadata(
@@ -15,5 +17,5 @@ export const generateMetadata = async () => {
export default WithLayout({
getLayout: null,
Page: SetNewUserPassword,
getData: withAppDirSsr(getServerSideProps),
getData: withAppDirSsr<PageProps>(getServerSideProps),
})<"P">;
@@ -1,19 +1,21 @@
import ForgotPassword from "@pages/auth/forgot-password";
import { withAppDirSsr } from "app/WithAppDirSsr";
import { _generateMetadata } from "app/_utils";
import { WithLayout } from "app/layoutHOC";
import { getServerSideProps } from "@server/lib/forgot-password/getServerSideProps";
import { getServerSidePropsAppDir } from "@server/lib/auth/forgot-password/getServerSideProps";
import type { PageProps } from "~/auth/forgot-password/forgot-password-view";
import ForgotPassword from "~/auth/forgot-password/forgot-password-view";
export const generateMetadata = async () => {
return await _generateMetadata(
(t) => t("reset_password"),
(t) => t("change_your_password")
(t) => t("forgot_password"),
(t) => t("request_password_reset")
);
};
export default WithLayout({
getLayout: null,
Page: ForgotPassword,
getData: withAppDirSsr(getServerSideProps),
getData: withAppDirSsr<PageProps>(getServerSidePropsAppDir),
})<"P">;
+5 -4
View File
@@ -1,14 +1,15 @@
import Login from "@pages/auth/login";
import { withAppDirSsr } from "app/WithAppDirSsr";
import { _generateMetadata } from "app/_utils";
import { WithLayout } from "app/layoutHOC";
import type { InferGetServerSidePropsType } from "next";
import { getServerSideProps } from "@server/lib/auth/login/getServerSideProps";
import type { PageProps } from "~/auth/login-view";
import Login from "~/auth/login-view";
export const generateMetadata = async () => {
return await _generateMetadata(
(t) => `${t("login")}`,
(t) => t("login"),
(t) => t("login")
);
};
@@ -16,5 +17,5 @@ export const generateMetadata = async () => {
export default WithLayout({
getLayout: null,
Page: Login,
getData: withAppDirSsr<InferGetServerSidePropsType<typeof getServerSideProps>>(getServerSideProps),
getData: withAppDirSsr<PageProps>(getServerSideProps),
})<"P">;
+36 -5
View File
@@ -1,7 +1,38 @@
import Logout from "@pages/auth/logout";
import { withAppDirSsr } from "app/WithAppDirSsr";
import { WithLayout } from "app/layoutHOC";
import type { PageProps } from "app/_types";
import { _generateMetadata } from "app/_utils";
import { cookies, headers } from "next/headers";
import { getServerSideProps } from "@server/lib/auth/logout/getServerSideProps";
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
export default WithLayout({ getLayout: null, Page: Logout, getData: withAppDirSsr(getServerSideProps) })<"P">;
import PageWrapper from "@components/PageWrapperAppDir";
import { ssrInit } from "@server/lib/ssr";
import Logout from "~/auth/logout-view";
export const generateMetadata = async () => {
return await _generateMetadata(
(t) => t("logged_out"),
(t) => t("youve_been_logged_out")
);
};
const Page = async ({ params, searchParams }: PageProps) => {
// cookie will be cleared in `/apps/web/middleware.ts`
const h = headers();
const nonce = h.get("x-nonce") ?? undefined;
const context = buildLegacyCtx(h, cookies(), params, searchParams);
const ssr = await ssrInit(context);
const props = {
trpcState: ssr.dehydrate(),
query: context.query,
};
return (
<PageWrapper requiresLicense={false} getLayout={null} nonce={nonce} themeBasis={null}>
<Logout {...props} />
</PageWrapper>
);
};
export default Page;
+4 -1
View File
@@ -93,7 +93,10 @@ const middleware = async (req: NextRequest): Promise<NextResponse<unknown>> => {
}
if (url.pathname.startsWith("/future/auth/logout")) {
cookies().delete("next-auth.session-token");
cookies().set("next-auth.session-token", "", {
path: "/",
expires: new Date(0),
});
}
requestHeaders.set("x-pathname", url.pathname);
@@ -0,0 +1,138 @@
"use client";
import Link from "next/link";
import type { CSSProperties } from "react";
import { useForm } from "react-hook-form";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { inferSSRProps } from "@calcom/types/inferSSRProps";
import { Button, PasswordField, Form } from "@calcom/ui";
import AuthContainer from "@components/ui/AuthContainer";
import type { getServerSideProps } from "@server/lib/auth/forgot-password/[id]/getServerSideProps";
export type PageProps = inferSSRProps<typeof getServerSideProps>;
export default function Page({ requestId, isRequestExpired, csrfToken }: PageProps) {
const { t } = useLocale();
const formMethods = useForm<{ new_password: string }>();
const success = formMethods.formState.isSubmitSuccessful;
const loading = formMethods.formState.isSubmitting;
const passwordValue = formMethods.watch("new_password");
const isEmpty = passwordValue?.length === 0;
const submitChangePassword = async ({ password, requestId }: { password: string; requestId: string }) => {
const res = await fetch("/api/auth/reset-password", {
method: "POST",
body: JSON.stringify({ requestId, password }),
headers: {
"Content-Type": "application/json",
},
});
const json = await res.json();
if (!res.ok) return formMethods.setError("new_password", { type: "server", message: json.message });
};
const Success = () => {
return (
<>
<div className="space-y-6">
<div>
<h2 className="font-cal text-emphasis mt-6 text-center text-3xl font-extrabold">
{t("password_updated")}
</h2>
</div>
<Button href="/auth/login" className="w-full justify-center">
{t("login")}
</Button>
</div>
</>
);
};
const Expired = () => {
return (
<>
<div className="space-y-6">
<div>
<h2 className="font-cal text-emphasis mt-6 text-center text-3xl font-extrabold">{t("whoops")}</h2>
<h2 className="text-emphasis text-center text-3xl font-extrabold">{t("request_is_expired")}</h2>
</div>
<p>{t("request_is_expired_instructions")}</p>
<Link href="/auth/forgot-password" passHref legacyBehavior>
<button
type="button"
className="flex w-full justify-center px-4 py-2 text-sm font-medium text-blue-600 focus:outline-none focus:ring-2 focus:ring-black focus:ring-offset-2">
{t("try_again")}
</button>
</Link>
</div>
</>
);
};
return (
<AuthContainer
showLogo
title={t("reset_password")}
description={t("change_your_password")}
heading={!success ? t("reset_password") : undefined}>
{isRequestExpired && <Expired />}
{!isRequestExpired && !success && (
<>
<Form
className="space-y-6"
form={formMethods}
style={
{
"--cal-brand": "#111827",
"--cal-brand-emphasis": "#101010",
"--cal-brand-text": "white",
"--cal-brand-subtle": "#9CA3AF",
} as CSSProperties
}
handleSubmit={async (values) => {
await submitChangePassword({
password: values.new_password,
requestId,
});
}}>
<input name="csrfToken" type="hidden" defaultValue={csrfToken} hidden />
<div className="mt-1">
<PasswordField
{...formMethods.register("new_password", {
minLength: {
message: t("password_hint_min"),
value: 7, // We don't have user here so we can't check if they are admin or not
},
pattern: {
message: "Should contain a number, uppercase and lowercase letters",
value: /^(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[a-zA-Z]).*$/gm,
},
})}
label={t("new_password")}
/>
</div>
<div>
<Button
loading={loading}
color="primary"
type="submit"
disabled={loading || isEmpty}
className="w-full justify-center">
{t("reset_password")}
</Button>
</div>
</Form>
</>
)}
{!isRequestExpired && success && (
<>
<Success />
</>
)}
</AuthContainer>
);
}
@@ -0,0 +1,147 @@
"use client";
// eslint-disable-next-line no-restricted-imports
import { debounce } from "lodash";
import Link from "next/link";
import type { CSSProperties, SyntheticEvent } from "react";
import React from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button, EmailField } from "@calcom/ui";
import { type inferSSRProps } from "@lib/types/inferSSRProps";
import AuthContainer from "@components/ui/AuthContainer";
import type { getServerSideProps } from "@server/lib/auth/forgot-password/getServerSideProps";
export type PageProps = inferSSRProps<typeof getServerSideProps>;
export default function ForgotPassword(props: PageProps) {
const csrfToken = "csrfToken" in props ? (props.csrfToken as string) : undefined;
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 [email, setEmail] = React.useState("");
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 }),
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 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 (
<div className="space-y-6 text-sm leading-normal ">
<p className="">{t("password_reset_email", { email })}</p>
<p className="">{t("password_reset_leading")}</p>
{error && <p className="text-center text-red-600">{error.message}</p>}
<Button color="secondary" className="w-full justify-center" href="/auth/login">
{t("back_to_signin")}
</Button>
</div>
);
};
return (
<AuthContainer
showLogo
title={!success ? t("forgot_password") : t("reset_link_sent")}
heading={!success ? t("forgot_password") : t("reset_link_sent")}
description={t("request_password_reset")}
footerText={
!success && (
<>
<Link href="/auth/login" className="text-emphasis font-medium">
{t("back_to_signin")}
</Link>
</>
)
}>
{success && <Success />}
{!success && (
<>
<div className="space-y-6">{error && <p className="text-red-600">{error.message}</p>}</div>
<form
className="space-y-6"
onSubmit={handleSubmit}
action="#"
style={
{
"--cal-brand": "#111827",
"--cal-brand-emphasis": "#101010",
"--cal-brand-text": "white",
"--cal-brand-subtle": "#9CA3AF",
} as CSSProperties
}>
<input name="csrfToken" type="hidden" defaultValue={csrfToken} hidden />
<EmailField
onChange={handleChange}
id="email"
name="email"
label={t("email_address")}
placeholder="john.doe@example.com"
required
/>
<div className="space-y-2">
<Button
className="w-full justify-center dark:bg-white dark:text-black"
type="submit"
color="primary"
disabled={loading}
aria-label={t("request_password_reset")}
loading={loading}>
{t("request_password_reset")}
</Button>
</div>
</form>
</>
)}
</AuthContainer>
);
}
+285
View File
@@ -0,0 +1,285 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import classNames from "classnames";
import { signIn } from "next-auth/react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { FormProvider, useForm } from "react-hook-form";
import { z } from "zod";
import { SAMLLogin } from "@calcom/features/auth/SAMLLogin";
import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
import { HOSTED_CAL_FEATURES, WEBAPP_URL, WEBSITE_URL } from "@calcom/lib/constants";
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useLastUsed, LastUsed } from "@calcom/lib/hooks/useLastUsed";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
import { trpc } from "@calcom/trpc/react";
import { Alert, Button, EmailField, PasswordField } from "@calcom/ui";
import type { inferSSRProps } from "@lib/types/inferSSRProps";
import type { WithNonceProps } from "@lib/withNonce";
import AddToHomescreen from "@components/AddToHomescreen";
import BackupCode from "@components/auth/BackupCode";
import TwoFactor from "@components/auth/TwoFactor";
import AuthContainer from "@components/ui/AuthContainer";
import type { getServerSideProps } from "@server/lib/auth/login/getServerSideProps";
interface LoginValues {
email: string;
password: string;
totpCode: string;
backupCode: string;
csrfToken: string;
}
const GoogleIcon = () => (
<img className="text-subtle mr-2 h-4 w-4 dark:invert" src="/google-icon.svg" alt="" />
);
export type PageProps = inferSSRProps<typeof getServerSideProps>;
export default function Login({
csrfToken,
isGoogleLoginEnabled,
isSAMLLoginEnabled,
samlTenantID,
samlProductID,
totpEmail,
}: // eslint-disable-next-line @typescript-eslint/ban-types
PageProps & WithNonceProps<{}>) {
const searchParams = useCompatSearchParams();
const { t } = useLocale();
const router = useRouter();
const formSchema = z
.object({
email: z
.string()
.min(1, `${t("error_required_field")}`)
.email(`${t("enter_valid_email")}`),
...(!!totpEmail ? {} : { password: z.string().min(1, `${t("error_required_field")}`) }),
})
// Passthrough other fields like totpCode
.passthrough();
const methods = useForm<LoginValues>({ resolver: zodResolver(formSchema) });
const { register, formState } = methods;
const [twoFactorRequired, setTwoFactorRequired] = useState(!!totpEmail || false);
const [twoFactorLostAccess, setTwoFactorLostAccess] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [lastUsed, setLastUsed] = useLastUsed();
const errorMessages: { [key: string]: string } = {
// [ErrorCode.SecondFactorRequired]: t("2fa_enabled_instructions"),
// Don't leak information about whether an email is registered or not
[ErrorCode.IncorrectEmailPassword]: t("incorrect_email_password"),
[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 = searchParams?.get("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 = (
<Link href={`${WEBSITE_URL}/signup`} className="text-brand-500 font-medium">
{t("dont_have_an_account")}
</Link>
);
const TwoFactorFooter = (
<>
<Button
onClick={() => {
if (twoFactorLostAccess) {
setTwoFactorLostAccess(false);
methods.setValue("backupCode", "");
} else {
setTwoFactorRequired(false);
methods.setValue("totpCode", "");
}
setErrorMessage(null);
}}
StartIcon="arrow-left"
color="minimal">
{t("go_back")}
</Button>
{!twoFactorLostAccess ? (
<Button
onClick={() => {
setTwoFactorLostAccess(true);
setErrorMessage(null);
methods.setValue("totpCode", "");
}}
StartIcon="lock"
color="minimal">
{t("lost_access")}
</Button>
) : null}
</>
);
const ExternalTotpFooter = (
<Button
onClick={() => {
window.location.replace("/");
}}
color="minimal">
{t("cancel")}
</Button>
);
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) {
setLastUsed("credentials");
router.push(callbackUrl);
} else if (res.error === ErrorCode.SecondFactorRequired) setTwoFactorRequired(true);
else if (res.error === ErrorCode.IncorrectBackupCode) setErrorMessage(t("incorrect_backup_code"));
else if (res.error === ErrorCode.MissingBackupCodes) setErrorMessage(t("missing_backup_codes"));
// fallback if error not found
else setErrorMessage(errorMessages[res.error] || t("something_went_wrong"));
};
const { data, isPending, error } = trpc.viewer.public.ssoConnections.useQuery();
useEffect(
function refactorMeWithoutEffect() {
if (error) {
setErrorMessage(error.message);
}
},
[error]
);
const displaySSOLogin = HOSTED_CAL_FEATURES
? true
: isSAMLLoginEnabled && !isPending && data?.connectionExists;
return (
<div className="dark:bg-brand dark:text-brand-contrast text-emphasis min-h-screen [--cal-brand-emphasis:#101010] [--cal-brand-subtle:#9CA3AF] [--cal-brand-text:white] [--cal-brand:#111827] dark:[--cal-brand-emphasis:#e1e1e1] dark:[--cal-brand-text:black] dark:[--cal-brand:white]">
<AuthContainer
title={t("login")}
description={t("login")}
showLogo
heading={twoFactorRequired ? t("2fa_code") : t("welcome_back")}
footerText={
twoFactorRequired
? !totpEmail
? TwoFactorFooter
: ExternalTotpFooter
: process.env.NEXT_PUBLIC_DISABLE_SIGNUP !== "true"
? LoginFooter
: null
}>
<FormProvider {...methods}>
<form onSubmit={methods.handleSubmit(onSubmit)} noValidate data-testid="login-form">
<div>
<input defaultValue={csrfToken || undefined} type="hidden" hidden {...register("csrfToken")} />
</div>
<div className="space-y-6">
<div className={classNames("space-y-6", { hidden: twoFactorRequired })}>
<EmailField
id="email"
label={t("email_address")}
defaultValue={totpEmail || (searchParams?.get("email") as string)}
placeholder="john.doe@example.com"
required
{...register("email")}
/>
<div className="relative">
<PasswordField
id="password"
autoComplete="off"
required={!totpEmail}
className="mb-0"
{...register("password")}
/>
<div className="absolute -top-[2px] ltr:right-0 rtl:left-0">
<Link
href="/auth/forgot-password"
tabIndex={-1}
className="text-default text-sm font-medium">
{t("forgot")}
</Link>
</div>
</div>
</div>
{twoFactorRequired ? !twoFactorLostAccess ? <TwoFactor center /> : <BackupCode center /> : null}
{errorMessage && <Alert severity="error" title={errorMessage} />}
<Button
type="submit"
color="primary"
disabled={formState.isSubmitting}
className="w-full justify-center">
<span>{twoFactorRequired ? t("submit") : t("sign_in")}</span>
{lastUsed === "credentials" && (
<span className="absolute right-3 text-xs text-gray-600">{t("last_used")}</span>
)}
</Button>
</div>
</form>
{!twoFactorRequired && (
<>
{(isGoogleLoginEnabled || displaySSOLogin) && <hr className="border-subtle my-8" />}
<div className="space-y-3">
{isGoogleLoginEnabled && (
<Button
color="secondary"
className="w-full justify-center"
disabled={formState.isSubmitting}
data-testid="google"
CustomStartIcon={<GoogleIcon />}
onClick={async (e) => {
e.preventDefault();
const res = await signIn("google", {
callbackUrl,
});
if (res && !res.error) {
setLastUsed("google");
}
}}>
<span>{t("signin_with_google")}</span>
{lastUsed === "google" && <LastUsed />}
</Button>
)}
{displaySSOLogin && (
<SAMLLogin
samlTenantID={samlTenantID}
samlProductID={samlProductID}
setErrorMessage={setErrorMessage}
/>
)}
</div>
</>
)}
</FormProvider>
</AuthContainer>
<AddToHomescreen />
</div>
);
}
+70
View File
@@ -0,0 +1,70 @@
"use client";
import { signOut, useSession } from "next-auth/react";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { WEBSITE_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button } from "@calcom/ui";
import { Icon } from "@calcom/ui";
import type { inferSSRProps } from "@lib/types/inferSSRProps";
import AuthContainer from "@components/ui/AuthContainer";
import type { getServerSideProps } from "@server/lib/auth/logout/getServerSideProps";
export type PageProps = inferSSRProps<typeof getServerSideProps>;
export function Logout(props: PageProps) {
const [btnLoading, setBtnLoading] = useState<boolean>(false);
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();
const message = () => {
if (props.query?.passReset === "true") return "reset_your_password";
if (props.query?.emailChange === "true") return "email_change";
return "hope_to_see_you_soon";
};
const navigateToLogin = () => {
setBtnLoading(true);
router.push("/auth/login");
};
return (
<AuthContainer title={t("logged_out")} description={t("youve_been_logged_out")} showLogo>
<div className="mb-4">
<div className="bg-success mx-auto flex h-12 w-12 items-center justify-center rounded-full">
<Icon name="check" className="h-6 w-6 text-green-600" />
</div>
<div className="mt-3 text-center sm:mt-5">
<h3 className="text-emphasis text-lg font-medium leading-6" id="modal-title">
{t("youve_been_logged_out")}
</h3>
<div className="mt-2">
<p className="text-subtle text-sm">{t(message())}</p>
</div>
</div>
</div>
<Button
data-testid="logout-btn"
onClick={navigateToLogin}
className="flex w-full justify-center"
loading={btnLoading}>
{t("go_back_login")}
</Button>
</AuthContainer>
);
}
export default Logout;
+7 -141
View File
@@ -1,145 +1,11 @@
"use client";
import Link from "next/link";
import type { CSSProperties } from "react";
import { useForm } from "react-hook-form";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button, PasswordField, Form } from "@calcom/ui";
import PageWrapper from "@components/PageWrapper";
import AuthContainer from "@components/ui/AuthContainer";
import { getServerSideProps } from "@server/lib/forgot-password/[id]/getServerSideProps";
type Props = {
requestId: string;
isRequestExpired: boolean;
csrfToken: string | undefined;
};
export default function Page({ requestId, isRequestExpired, csrfToken }: Props) {
const { t } = useLocale();
const formMethods = useForm<{ new_password: string }>();
const success = formMethods.formState.isSubmitSuccessful;
const loading = formMethods.formState.isSubmitting;
const passwordValue = formMethods.watch("new_password");
const isEmpty = passwordValue?.length === 0;
const submitChangePassword = async ({ password, requestId }: { password: string; requestId: string }) => {
const res = await fetch("/api/auth/reset-password", {
method: "POST",
body: JSON.stringify({ requestId, password }),
headers: {
"Content-Type": "application/json",
},
});
const json = await res.json();
if (!res.ok) return formMethods.setError("new_password", { type: "server", message: json.message });
};
const Success = () => {
return (
<>
<div className="space-y-6">
<div>
<h2 className="font-cal text-emphasis mt-6 text-center text-3xl font-extrabold">
{t("password_updated")}
</h2>
</div>
<Button href="/auth/login" className="w-full justify-center">
{t("login")}
</Button>
</div>
</>
);
};
const Expired = () => {
return (
<>
<div className="space-y-6">
<div>
<h2 className="font-cal text-emphasis mt-6 text-center text-3xl font-extrabold">{t("whoops")}</h2>
<h2 className="text-emphasis text-center text-3xl font-extrabold">{t("request_is_expired")}</h2>
</div>
<p>{t("request_is_expired_instructions")}</p>
<Link href="/auth/forgot-password" passHref legacyBehavior>
<button
type="button"
className="flex w-full justify-center px-4 py-2 text-sm font-medium text-blue-600 focus:outline-none focus:ring-2 focus:ring-black focus:ring-offset-2">
{t("try_again")}
</button>
</Link>
</div>
</>
);
};
return (
<AuthContainer
showLogo
title={t("reset_password")}
description={t("change_your_password")}
heading={!success ? t("reset_password") : undefined}>
{isRequestExpired && <Expired />}
{!isRequestExpired && !success && (
<>
<Form
className="space-y-6"
form={formMethods}
style={
{
"--cal-brand": "#111827",
"--cal-brand-emphasis": "#101010",
"--cal-brand-text": "white",
"--cal-brand-subtle": "#9CA3AF",
} as CSSProperties
}
handleSubmit={async (values) => {
await submitChangePassword({
password: values.new_password,
requestId,
});
}}>
<input name="csrfToken" type="hidden" defaultValue={csrfToken} hidden />
<div className="mt-1">
<PasswordField
{...formMethods.register("new_password", {
minLength: {
message: t("password_hint_min"),
value: 7, // We don't have user here so we can't check if they are admin or not
},
pattern: {
message: "Should contain a number, uppercase and lowercase letters",
value: /^(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[a-zA-Z]).*$/gm,
},
})}
label={t("new_password")}
/>
</div>
<div>
<Button
loading={loading}
color="primary"
type="submit"
disabled={loading || isEmpty}
className="w-full justify-center">
{t("reset_password")}
</Button>
</div>
</Form>
</>
)}
{!isRequestExpired && success && (
<>
<Success />
</>
)}
</AuthContainer>
);
}
import type { PageProps } from "~/auth/forgot-password/[id]/forgot-password-single-view";
import ForgotPassword from "~/auth/forgot-password/[id]/forgot-password-single-view";
const Page = (props: PageProps) => <ForgotPassword {...props} />;
Page.PageWrapper = PageWrapper;
export { getServerSideProps };
export default Page;
export { getServerSideProps } from "@server/lib/auth/forgot-password/[id]/getServerSideProps";
+6 -145
View File
@@ -1,150 +1,11 @@
"use client";
// eslint-disable-next-line no-restricted-imports
import { debounce } from "lodash";
import Link from "next/link";
import type { CSSProperties, SyntheticEvent } from "react";
import React from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button, EmailField } from "@calcom/ui";
import { type inferSSRProps } from "@lib/types/inferSSRProps";
import PageWrapper from "@components/PageWrapper";
import AuthContainer from "@components/ui/AuthContainer";
import { getServerSideProps } from "@server/lib/forgot-password/getServerSideProps";
import type { PageProps } from "~/auth/forgot-password/forgot-password-view";
import ForgotPassword from "~/auth/forgot-password/forgot-password-view";
export default function ForgotPassword(props: inferSSRProps<typeof getServerSideProps>) {
const csrfToken = "csrfToken" in props ? (props.csrfToken as string) : undefined;
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 [email, setEmail] = React.useState("");
const Page = (props: PageProps) => <ForgotPassword {...props} />;
Page.PageWrapper = PageWrapper;
const handleChange = (e: SyntheticEvent) => {
const target = e.target as typeof e.target & { value: string };
setEmail(target.value);
};
export default Page;
const submitForgotPasswordRequest = async ({ email }: { email: string }) => {
try {
const res = await fetch("/api/auth/forgot-password", {
method: "POST",
body: JSON.stringify({ email }),
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 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 (
<div className="space-y-6 text-sm leading-normal ">
<p className="">{t("password_reset_email", { email })}</p>
<p className="">{t("password_reset_leading")}</p>
{error && <p className="text-center text-red-600">{error.message}</p>}
<Button color="secondary" className="w-full justify-center" href="/auth/login">
{t("back_to_signin")}
</Button>
</div>
);
};
return (
<AuthContainer
showLogo
title={!success ? t("forgot_password") : t("reset_link_sent")}
heading={!success ? t("forgot_password") : t("reset_link_sent")}
description={t("request_password_reset")}
footerText={
!success && (
<>
<Link href="/auth/login" className="text-emphasis font-medium">
{t("back_to_signin")}
</Link>
</>
)
}>
{success && <Success />}
{!success && (
<>
<div className="space-y-6">{error && <p className="text-red-600">{error.message}</p>}</div>
<form
className="space-y-6"
onSubmit={handleSubmit}
action="#"
style={
{
"--cal-brand": "#111827",
"--cal-brand-emphasis": "#101010",
"--cal-brand-text": "white",
"--cal-brand-subtle": "#9CA3AF",
} as CSSProperties
}>
<input name="csrfToken" type="hidden" defaultValue={csrfToken} hidden />
<EmailField
onChange={handleChange}
id="email"
name="email"
label={t("email_address")}
placeholder="john.doe@example.com"
required
/>
<div className="space-y-2">
<Button
className="w-full justify-center dark:bg-white dark:text-black"
type="submit"
color="primary"
disabled={loading}
aria-label={t("request_password_reset")}
loading={loading}>
{t("request_password_reset")}
</Button>
</div>
</form>
</>
)}
</AuthContainer>
);
}
ForgotPassword.PageWrapper = PageWrapper;
export { getServerSideProps };
export { getServerSideProps } from "@server/lib/auth/forgot-password/getServerSideProps";
+6 -285
View File
@@ -1,289 +1,10 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import classNames from "classnames";
import { signIn } from "next-auth/react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { FormProvider, useForm } from "react-hook-form";
import { z } from "zod";
import { SAMLLogin } from "@calcom/features/auth/SAMLLogin";
import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
import { HOSTED_CAL_FEATURES, WEBAPP_URL, WEBSITE_URL } from "@calcom/lib/constants";
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useLastUsed, LastUsed } from "@calcom/lib/hooks/useLastUsed";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
import { trpc } from "@calcom/trpc/react";
import { Alert, Button, EmailField, PasswordField } from "@calcom/ui";
import type { inferSSRProps } from "@lib/types/inferSSRProps";
import type { WithNonceProps } from "@lib/withNonce";
import AddToHomescreen from "@components/AddToHomescreen";
import PageWrapper from "@components/PageWrapper";
import BackupCode from "@components/auth/BackupCode";
import TwoFactor from "@components/auth/TwoFactor";
import AuthContainer from "@components/ui/AuthContainer";
import { getServerSideProps } from "@server/lib/auth/login/getServerSideProps";
import type { PageProps } from "~/auth/login-view";
import Login from "~/auth/login-view";
interface LoginValues {
email: string;
password: string;
totpCode: string;
backupCode: string;
csrfToken: string;
}
const Page = (props: PageProps) => <Login {...props} />;
Page.PageWrapper = PageWrapper;
const GoogleIcon = () => (
<img className="text-subtle mr-2 h-4 w-4 dark:invert" src="/google-icon.svg" alt="" />
);
export default function Login({
csrfToken,
isGoogleLoginEnabled,
isSAMLLoginEnabled,
samlTenantID,
samlProductID,
totpEmail,
}: // eslint-disable-next-line @typescript-eslint/ban-types
inferSSRProps<typeof getServerSideProps> & WithNonceProps<{}>) {
const searchParams = useCompatSearchParams();
const { t } = useLocale();
const router = useRouter();
const formSchema = z
.object({
email: z
.string()
.min(1, `${t("error_required_field")}`)
.email(`${t("enter_valid_email")}`),
...(!!totpEmail ? {} : { password: z.string().min(1, `${t("error_required_field")}`) }),
})
// Passthrough other fields like totpCode
.passthrough();
const methods = useForm<LoginValues>({ resolver: zodResolver(formSchema) });
const { register, formState } = methods;
const [twoFactorRequired, setTwoFactorRequired] = useState(!!totpEmail || false);
const [twoFactorLostAccess, setTwoFactorLostAccess] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [lastUsed, setLastUsed] = useLastUsed();
const errorMessages: { [key: string]: string } = {
// [ErrorCode.SecondFactorRequired]: t("2fa_enabled_instructions"),
// Don't leak information about whether an email is registered or not
[ErrorCode.IncorrectEmailPassword]: t("incorrect_email_password"),
[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 = searchParams?.get("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 = (
<Link href={`${WEBSITE_URL}/signup`} className="text-brand-500 font-medium">
{t("dont_have_an_account")}
</Link>
);
const TwoFactorFooter = (
<>
<Button
onClick={() => {
if (twoFactorLostAccess) {
setTwoFactorLostAccess(false);
methods.setValue("backupCode", "");
} else {
setTwoFactorRequired(false);
methods.setValue("totpCode", "");
}
setErrorMessage(null);
}}
StartIcon="arrow-left"
color="minimal">
{t("go_back")}
</Button>
{!twoFactorLostAccess ? (
<Button
onClick={() => {
setTwoFactorLostAccess(true);
setErrorMessage(null);
methods.setValue("totpCode", "");
}}
StartIcon="lock"
color="minimal">
{t("lost_access")}
</Button>
) : null}
</>
);
const ExternalTotpFooter = (
<Button
onClick={() => {
window.location.replace("/");
}}
color="minimal">
{t("cancel")}
</Button>
);
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) {
setLastUsed("credentials");
router.push(callbackUrl);
} else if (res.error === ErrorCode.SecondFactorRequired) setTwoFactorRequired(true);
else if (res.error === ErrorCode.IncorrectBackupCode) setErrorMessage(t("incorrect_backup_code"));
else if (res.error === ErrorCode.MissingBackupCodes) setErrorMessage(t("missing_backup_codes"));
// fallback if error not found
else setErrorMessage(errorMessages[res.error] || t("something_went_wrong"));
};
const { data, isPending, error } = trpc.viewer.public.ssoConnections.useQuery();
useEffect(
function refactorMeWithoutEffect() {
if (error) {
setErrorMessage(error.message);
}
},
[error]
);
const displaySSOLogin = HOSTED_CAL_FEATURES
? true
: isSAMLLoginEnabled && !isPending && data?.connectionExists;
return (
<div className="dark:bg-brand dark:text-brand-contrast text-emphasis min-h-screen [--cal-brand-emphasis:#101010] [--cal-brand-subtle:#9CA3AF] [--cal-brand-text:white] [--cal-brand:#111827] dark:[--cal-brand-emphasis:#e1e1e1] dark:[--cal-brand-text:black] dark:[--cal-brand:white]">
<AuthContainer
title={t("login")}
description={t("login")}
showLogo
heading={twoFactorRequired ? t("2fa_code") : t("welcome_back")}
footerText={
twoFactorRequired
? !totpEmail
? TwoFactorFooter
: ExternalTotpFooter
: process.env.NEXT_PUBLIC_DISABLE_SIGNUP !== "true"
? LoginFooter
: null
}>
<FormProvider {...methods}>
<form onSubmit={methods.handleSubmit(onSubmit)} noValidate data-testid="login-form">
<div>
<input defaultValue={csrfToken || undefined} type="hidden" hidden {...register("csrfToken")} />
</div>
<div className="space-y-6">
<div className={classNames("space-y-6", { hidden: twoFactorRequired })}>
<EmailField
id="email"
label={t("email_address")}
defaultValue={totpEmail || (searchParams?.get("email") as string)}
placeholder="john.doe@example.com"
required
{...register("email")}
/>
<div className="relative">
<PasswordField
id="password"
autoComplete="off"
required={!totpEmail}
className="mb-0"
{...register("password")}
/>
<div className="absolute -top-[2px] ltr:right-0 rtl:left-0">
<Link
href="/auth/forgot-password"
tabIndex={-1}
className="text-default text-sm font-medium">
{t("forgot")}
</Link>
</div>
</div>
</div>
{twoFactorRequired ? !twoFactorLostAccess ? <TwoFactor center /> : <BackupCode center /> : null}
{errorMessage && <Alert severity="error" title={errorMessage} />}
<Button
type="submit"
color="primary"
disabled={formState.isSubmitting}
className="w-full justify-center">
<span>{twoFactorRequired ? t("submit") : t("sign_in")}</span>
{lastUsed === "credentials" && (
<span className="absolute right-3 text-xs text-gray-600">{t("last_used")}</span>
)}
</Button>
</div>
</form>
{!twoFactorRequired && (
<>
{(isGoogleLoginEnabled || displaySSOLogin) && <hr className="border-subtle my-8" />}
<div className="space-y-3">
{isGoogleLoginEnabled && (
<Button
color="secondary"
className="w-full justify-center"
disabled={formState.isSubmitting}
data-testid="google"
CustomStartIcon={<GoogleIcon />}
onClick={async (e) => {
e.preventDefault();
const res = await signIn("google", {
callbackUrl,
});
if (res && !res.error) {
setLastUsed("google");
}
}}>
<span>{t("signin_with_google")}</span>
{lastUsed === "google" && <LastUsed />}
</Button>
)}
{displaySSOLogin && (
<SAMLLogin
samlTenantID={samlTenantID}
samlProductID={samlProductID}
setErrorMessage={setErrorMessage}
/>
)}
</div>
</>
)}
</FormProvider>
</AuthContainer>
<AddToHomescreen />
</div>
);
}
export { getServerSideProps };
Login.PageWrapper = PageWrapper;
export default Page;
export { getServerSideProps } from "@server/lib/auth/login/getServerSideProps";
+6 -70
View File
@@ -1,74 +1,10 @@
"use client";
import { signOut, useSession } from "next-auth/react";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { WEBSITE_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button } from "@calcom/ui";
import { Icon } from "@calcom/ui";
import type { inferSSRProps } from "@lib/types/inferSSRProps";
import PageWrapper from "@components/PageWrapper";
import AuthContainer from "@components/ui/AuthContainer";
import { getServerSideProps } from "@server/lib/auth/logout/getServerSideProps";
import type { PageProps } from "~/auth/logout-view";
import Logout from "~/auth/logout-view";
type Props = inferSSRProps<typeof getServerSideProps>;
const Page = (props: PageProps) => <Logout {...props} />;
Page.PageWrapper = PageWrapper;
export function Logout(props: Props) {
const [btnLoading, setBtnLoading] = useState<boolean>(false);
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();
const message = () => {
if (props.query?.passReset === "true") return "reset_your_password";
if (props.query?.emailChange === "true") return "email_change";
return "hope_to_see_you_soon";
};
const navigateToLogin = () => {
setBtnLoading(true);
router.push("/auth/login");
};
return (
<AuthContainer title={t("logged_out")} description={t("youve_been_logged_out")} showLogo>
<div className="mb-4">
<div className="bg-success mx-auto flex h-12 w-12 items-center justify-center rounded-full">
<Icon name="check" className="h-6 w-6 text-green-600" />
</div>
<div className="mt-3 text-center sm:mt-5">
<h3 className="text-emphasis text-lg font-medium leading-6" id="modal-title">
{t("youve_been_logged_out")}
</h3>
<div className="mt-2">
<p className="text-subtle text-sm">{t(message())}</p>
</div>
</div>
</div>
<Button
data-testid="logout-btn"
onClick={navigateToLogin}
className="flex w-full justify-center"
loading={btnLoading}>
{t("go_back_login")}
</Button>
</AuthContainer>
);
}
Logout.PageWrapper = PageWrapper;
export default Logout;
export { getServerSideProps };
export default Page;
export { getServerSideProps } from "@server/lib/auth/logout/getServerSideProps";
@@ -10,7 +10,6 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
const session = await getServerSession({ req });
// @TODO res will not be available in future pages (app dir)
if (session) {
res.writeHead(302, { Location: "/" });
res.end();
@@ -25,3 +24,23 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
},
};
}
export async function getServerSidePropsAppDir(context: GetServerSidePropsContext) {
const { req } = context;
const session = await getServerSession({ req });
if (session) {
const redirect = await import("next/navigation").then((mod) => mod.redirect);
redirect("/");
return { props: {} };
}
const locale = await getLocale(context.req);
return {
props: {
csrfToken: await getCsrfToken(context),
...(await serverSideTranslations(locale, ["common"])),
},
};
}
@@ -12,9 +12,9 @@ import { IS_GOOGLE_LOGIN_ENABLED } from "@server/lib/constants";
import { ssrInit } from "@server/lib/ssr";
export async function getServerSideProps(context: GetServerSidePropsContext) {
const { req, res, query } = context;
const { req, query } = context;
const session = await getServerSession({ req, res });
const session = await getServerSession({ req });
const ssr = await ssrInit(context);
const verifyJwt = (jwt: string) => {