feat: email verification (#9081)
* Verify - inital email commit * Add token type - api redirect - migration * Redirect and valid api callback route * Update email design * Change signup URL to redirect to verify-email * Add feature flag - add a11y text to email - add top banner * Shell shouldnt redirect to onboarding if the user needs to verify account * Move flag check to server * Cleanup * Rate limit * Fix redirects * Remove api signup mess * Double negation for forced bool * Fix props * Update packages/emails/templates/account-verify-email.ts * Enable migration by default * Fix typos * Fix google verify issue * Update packages/features/auth/lib/verifyEmail.ts Co-authored-by: Hariom Balhara <hariombalhara@gmail.com> * NITS: @harioms addressed * Remove schema changes * Fix NITs+ improvments * Update apps/web/pages/api/auth/verify-email.ts Co-authored-by: Omar López <zomars@me.com> * Update packages/features/ee/common/components/LicenseRequired.tsx Co-authored-by: Omar López <zomars@me.com> * Update apps/web/pages/api/auth/verify-email.ts Co-authored-by: Omar López <zomars@me.com> * Always preloads feature flags * Update verifyEmail.ts * Update schema.prisma * Type fix --------- Co-authored-by: Hariom Balhara <hariombalhara@gmail.com> Co-authored-by: Omar López <zomars@me.com>
This commit is contained in:
parent
057cf273dd
commit
9e70a985e4
|
@ -1,11 +1,20 @@
|
|||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { z } from "zod";
|
||||
|
||||
import { hashPassword } from "@calcom/features/auth/lib/hashPassword";
|
||||
import { sendEmailVerification } from "@calcom/features/auth/lib/verifyEmail";
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
import { closeComUpsertTeamUser } from "@calcom/lib/sync/SyncServiceManager";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { IdentityProvider } from "@calcom/prisma/enums";
|
||||
|
||||
const signupSchema = z.object({
|
||||
username: z.string(),
|
||||
email: z.string().email(),
|
||||
password: z.string().min(7),
|
||||
language: z.string().optional(),
|
||||
});
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method !== "POST") {
|
||||
return;
|
||||
|
@ -17,7 +26,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||
}
|
||||
|
||||
const data = req.body;
|
||||
const { email, password } = data;
|
||||
const { email, password, language } = signupSchema.parse(data);
|
||||
|
||||
const username = slugify(data.username);
|
||||
const userEmail = email.toLowerCase();
|
||||
|
||||
|
@ -26,16 +36,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||
return;
|
||||
}
|
||||
|
||||
if (!userEmail || !userEmail.includes("@")) {
|
||||
res.status(422).json({ message: "Invalid email" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!password || password.trim().length < 7) {
|
||||
res.status(422).json({ message: "Invalid input - password should be at least 7 characters long." });
|
||||
return;
|
||||
}
|
||||
|
||||
// There is an existingUser if the username matches
|
||||
// OR if the email matches AND either the email is verified
|
||||
// or both username and password are set
|
||||
|
@ -106,5 +106,11 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||
}
|
||||
}
|
||||
|
||||
await sendEmailVerification({
|
||||
email: userEmail,
|
||||
username,
|
||||
language,
|
||||
});
|
||||
|
||||
res.status(201).json({ message: "Created user" });
|
||||
}
|
||||
|
|
|
@ -0,0 +1,48 @@
|
|||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { z } from "zod";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
|
||||
const verifySchema = z.object({
|
||||
token: z.string(),
|
||||
});
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const { token } = verifySchema.parse(req.query);
|
||||
|
||||
const foundToken = await prisma.verificationToken.findFirst({
|
||||
where: {
|
||||
token,
|
||||
},
|
||||
});
|
||||
|
||||
if (!foundToken) {
|
||||
return res.status(401).json({ message: "No token found" });
|
||||
}
|
||||
|
||||
if (dayjs(foundToken?.expires).isBefore(dayjs())) {
|
||||
return res.status(401).json({ message: "Token expired" });
|
||||
}
|
||||
|
||||
const user = await prisma.user.update({
|
||||
where: {
|
||||
email: foundToken?.identifier,
|
||||
},
|
||||
data: {
|
||||
emailVerified: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
// Delete token from DB after it has been used
|
||||
await prisma.verificationToken.delete({
|
||||
where: {
|
||||
id: foundToken?.id,
|
||||
},
|
||||
});
|
||||
|
||||
const hasCompletedOnboarding = user.completedOnboarding;
|
||||
|
||||
res.redirect(`${WEBAPP_URL}/${hasCompletedOnboarding ? "/event-types" : "/getting-started"}`);
|
||||
}
|
|
@ -60,9 +60,14 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
|||
res.setHeader("Content-Type", "text/html");
|
||||
res.setHeader("Cache-Control", "no-cache, no-store, private, must-revalidate");
|
||||
res.write(
|
||||
renderEmail("OrganizerRequestEmail", {
|
||||
calEvent: evt,
|
||||
attendee: evt.organizer,
|
||||
renderEmail("VerifyAccountEmail", {
|
||||
language: t,
|
||||
user: {
|
||||
name: "Pro Example",
|
||||
email: "pro@example.com",
|
||||
},
|
||||
verificationEmailLink:
|
||||
"http://localhost:3000/api/auth/verify-email?token=b91af0eee5a9a24a8d83a3d3d6a58c1606496e94ced589441649273c66100f5b",
|
||||
})
|
||||
);
|
||||
res.end();
|
||||
|
|
|
@ -0,0 +1,60 @@
|
|||
import { MailOpenIcon } from "lucide-react";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { APP_NAME } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
import useEmailVerifyCheck from "@calcom/trpc/react/hooks/useEmailVerifyCheck";
|
||||
import { Button, EmptyScreen, showToast } from "@calcom/ui";
|
||||
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
|
||||
function VerifyEmailPage() {
|
||||
const { data } = useEmailVerifyCheck();
|
||||
const { data: session } = useSession();
|
||||
const router = useRouter();
|
||||
const { t } = useLocale();
|
||||
const mutation = trpc.viewer.auth.resendVerifyEmail.useMutation();
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.isVerified) {
|
||||
router.replace("/getting-started");
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [data?.isVerified]);
|
||||
|
||||
return (
|
||||
<div className="h-[100vh] w-full ">
|
||||
<div className="flex h-full w-full flex-col items-center justify-center">
|
||||
<div className="max-w-3xl">
|
||||
<EmptyScreen
|
||||
border
|
||||
dashedBorder={false}
|
||||
Icon={MailOpenIcon}
|
||||
headline={t("check_your_email")}
|
||||
description={t("verify_email_page_body", { email: session?.user?.email, appName: APP_NAME })}
|
||||
className="bg-default"
|
||||
buttonRaw={
|
||||
<Button
|
||||
color="minimal"
|
||||
className="underline"
|
||||
loading={mutation.isLoading}
|
||||
onClick={() => {
|
||||
showToast("Send email", "success");
|
||||
mutation.mutate();
|
||||
}}>
|
||||
Resend Email
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default VerifyEmailPage;
|
||||
|
||||
VerifyEmailPage.PageWrapper = PageWrapper;
|
|
@ -9,6 +9,7 @@ import { z } from "zod";
|
|||
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
|
||||
import { checkPremiumUsername } from "@calcom/features/ee/common/lib/checkPremiumUsername";
|
||||
import { isSAMLLoginEnabled } from "@calcom/features/ee/sso/lib/saml";
|
||||
import { useFlagMap } from "@calcom/features/flags/context/provider";
|
||||
import { getFeatureFlagMap } from "@calcom/features/flags/server/utils";
|
||||
import { IS_SELF_HOSTED, WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
|
@ -29,10 +30,10 @@ type FormValues = {
|
|||
};
|
||||
|
||||
export default function Signup({ prepopulateFormValues, token }: inferSSRProps<typeof getServerSideProps>) {
|
||||
const { t } = useLocale();
|
||||
const { t, i18n } = useLocale();
|
||||
const router = useRouter();
|
||||
const flags = useFlagMap();
|
||||
const telemetry = useTelemetry();
|
||||
|
||||
const methods = useForm<FormValues>({
|
||||
defaultValues: prepopulateFormValues,
|
||||
});
|
||||
|
@ -52,6 +53,7 @@ export default function Signup({ prepopulateFormValues, token }: inferSSRProps<t
|
|||
await fetch("/api/auth/signup", {
|
||||
body: JSON.stringify({
|
||||
...data,
|
||||
language: i18n.language,
|
||||
}),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
|
@ -61,11 +63,12 @@ export default function Signup({ prepopulateFormValues, token }: inferSSRProps<t
|
|||
.then(handleErrors)
|
||||
.then(async () => {
|
||||
telemetry.event(telemetryEventTypes.signup, collectPageParameters());
|
||||
const verifyOrGettingStarted = flags["email-verification"] ? "auth/verify-email" : "getting-started";
|
||||
await signIn<"credentials">("credentials", {
|
||||
...data,
|
||||
callbackUrl: router.query.callbackUrl
|
||||
? `${WEBAPP_URL}/${router.query.callbackUrl}`
|
||||
: `${WEBAPP_URL}/getting-started`,
|
||||
: `${WEBAPP_URL}/${verifyOrGettingStarted}`,
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
|
|
|
@ -11,6 +11,16 @@
|
|||
"calcom_explained_new_user": "Finish setting up your {{appName}} account! You’re just a few steps away from solving all your scheduling problems.",
|
||||
"have_any_questions": "Have questions? We're here to help.",
|
||||
"reset_password_subject": "{{appName}}: Reset password instructions",
|
||||
"verify_email_subject": "{{appName}}: Verify your account",
|
||||
"check_your_email": "Check your email",
|
||||
"verify_email_page_body": "We've sent an email to {{email}}. It is important to verify your email address to guarantee the best email and calendar deliverability from {{appName}}.",
|
||||
"verify_email_banner_body": "Please verify your email adress to guarantee the best email and calendar deliverability from {{appName}}.",
|
||||
"verify_email_banner_button": "Send email",
|
||||
"verify_email_email_header": "Verify your email address",
|
||||
"verify_email_email_button": "Verify email",
|
||||
"verify_email_email_body": "Please verify your email address by clicking the button below.",
|
||||
"verify_email_email_link_text": "Here's the link incase you don't like clicking buttons:",
|
||||
"email_sent": "Email sent successfully",
|
||||
"event_declined_subject": "Declined: {{title}} at {{date}}",
|
||||
"event_cancelled_subject": "Cancelled: {{title}} at {{date}}",
|
||||
"event_request_declined": "Your event request has been declined",
|
||||
|
|
|
@ -6,6 +6,8 @@ import { getEventName } from "@calcom/core/event";
|
|||
import type BaseEmail from "@calcom/emails/templates/_base-email";
|
||||
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
|
||||
|
||||
import type { EmailVerifyLink } from "./templates/account-verify-email";
|
||||
import AccountVerifyEmail from "./templates/account-verify-email";
|
||||
import AttendeeAwaitingPaymentEmail from "./templates/attendee-awaiting-payment-email";
|
||||
import AttendeeCancelledEmail from "./templates/attendee-cancelled-email";
|
||||
import AttendeeCancelledSeatEmail from "./templates/attendee-cancelled-seat-email";
|
||||
|
@ -240,6 +242,10 @@ export const sendTeamInviteEmail = async (teamInviteEvent: TeamInvite) => {
|
|||
await sendEmail(() => new TeamInviteEmail(teamInviteEvent));
|
||||
};
|
||||
|
||||
export const sendEmailVerificationLink = async (verificationInput: EmailVerifyLink) => {
|
||||
await sendEmail(() => new AccountVerifyEmail(verificationInput));
|
||||
};
|
||||
|
||||
export const sendRequestRescheduleEmail = async (
|
||||
calEvent: CalendarEvent,
|
||||
metadata: { rescheduleLink: string }
|
||||
|
|
|
@ -0,0 +1,60 @@
|
|||
import type { TFunction } from "next-i18next";
|
||||
|
||||
import { APP_NAME, SUPPORT_MAIL_ADDRESS } from "@calcom/lib/constants";
|
||||
|
||||
import { BaseEmailHtml, CallToAction } from "../components";
|
||||
|
||||
export type EmailVerifyLink = {
|
||||
language: TFunction;
|
||||
user: {
|
||||
name?: string | null;
|
||||
email: string;
|
||||
};
|
||||
verificationEmailLink: string;
|
||||
};
|
||||
|
||||
export const VerifyAccountEmail = (
|
||||
props: EmailVerifyLink & Partial<React.ComponentProps<typeof BaseEmailHtml>>
|
||||
) => {
|
||||
return (
|
||||
<BaseEmailHtml subject={props.language("verify_email_subject", { appName: APP_NAME })}>
|
||||
<p
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
fontSize: "32px",
|
||||
lineHeight: "38px",
|
||||
}}>
|
||||
<>{props.language("verify_email_email_header")}</>
|
||||
</p>
|
||||
<p style={{ fontWeight: 400 }}>
|
||||
<>{props.language("hi_user_name", { name: props.user.name })}!</>
|
||||
</p>
|
||||
<p style={{ fontWeight: 400, lineHeight: "24px" }}>
|
||||
<>{props.language("verify_email_email_body", { appName: APP_NAME })}</>
|
||||
</p>
|
||||
<CallToAction label={props.language("verify_email_email_button")} href={props.verificationEmailLink} />
|
||||
|
||||
<div style={{ lineHeight: "6px" }}>
|
||||
<p style={{ fontWeight: 400, lineHeight: "24px" }}>
|
||||
<>{props.language("verify_email_email_link_text")}</>
|
||||
<br />
|
||||
<a href={props.verificationEmailLink}>{props.verificationEmailLink}</a>
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ lineHeight: "6px" }}>
|
||||
<p style={{ fontWeight: 400, lineHeight: "24px" }}>
|
||||
<>
|
||||
{props.language("happy_scheduling")} <br />
|
||||
<a
|
||||
href={`mailto:${SUPPORT_MAIL_ADDRESS}`}
|
||||
style={{ color: "#3E3E3E" }}
|
||||
target="_blank"
|
||||
rel="noreferrer">
|
||||
<>{props.language("the_calcom_team")}</>
|
||||
</a>
|
||||
</>
|
||||
</p>
|
||||
</div>
|
||||
</BaseEmailHtml>
|
||||
);
|
||||
};
|
|
@ -23,5 +23,6 @@ export { TeamInviteEmail } from "./TeamInviteEmail";
|
|||
export { BrokenIntegrationEmail } from "./BrokenIntegrationEmail";
|
||||
export { OrganizerAttendeeCancelledSeatEmail } from "./OrganizerAttendeeCancelledSeatEmail";
|
||||
export { NoShowFeeChargedEmail } from "./NoShowFeeChargedEmail";
|
||||
export { VerifyAccountEmail } from "./VerifyAccountEmail";
|
||||
export * from "@calcom/app-store/routing-forms/emails/components";
|
||||
export { AttendeeDailyVideoDownloadRecordingEmail } from "./AttendeeDailyVideoDownloadRecordingEmail";
|
||||
|
|
|
@ -0,0 +1,50 @@
|
|||
import type { TFunction } from "next-i18next";
|
||||
|
||||
import { APP_NAME } from "@calcom/lib/constants";
|
||||
|
||||
import { renderEmail } from "../";
|
||||
import BaseEmail from "./_base-email";
|
||||
|
||||
export type EmailVerifyLink = {
|
||||
language: TFunction;
|
||||
user: {
|
||||
name?: string | null;
|
||||
email: string;
|
||||
};
|
||||
verificationEmailLink: string;
|
||||
};
|
||||
|
||||
|
||||
export default class AccountVerifyEmail extends BaseEmail {
|
||||
verifyAccountInput: EmailVerifyLink;
|
||||
|
||||
constructor(passwordEvent: EmailVerifyLink) {
|
||||
super();
|
||||
this.name = "SEND_ACCOUNT_VERIFY_EMAIL";
|
||||
this.verifyAccountInput = passwordEvent;
|
||||
}
|
||||
|
||||
protected getNodeMailerPayload(): Record<string, unknown> {
|
||||
return {
|
||||
to: `${this.verifyAccountInput.user.name} <${this.verifyAccountInput.user.email}>`,
|
||||
from: `${APP_NAME} <${this.getMailerOptions().from}>`,
|
||||
subject: this.verifyAccountInput.language("verify_email_subject", {
|
||||
appName: APP_NAME,
|
||||
}),
|
||||
html: renderEmail("VerifyAccountEmail", this.verifyAccountInput),
|
||||
text: this.getTextBody(),
|
||||
};
|
||||
}
|
||||
|
||||
protected getTextBody(): string {
|
||||
return `
|
||||
${this.verifyAccountInput.language("verify_email_subject", { appName: APP_NAME })}
|
||||
${this.verifyAccountInput.language("verify_email_email_header")}
|
||||
${this.verifyAccountInput.language("hi_user_name", { name: this.verifyAccountInput.user.name })},
|
||||
${this.verifyAccountInput.language("verify_email_email_body", { appName: APP_NAME })}
|
||||
${this.verifyAccountInput.language("verify_email_email_link_text")}
|
||||
${this.verifyAccountInput.verificationEmailLink}
|
||||
${this.verifyAccountInput.language("happy_scheduling")} ${this.verifyAccountInput.language("the_calcom_team")}
|
||||
`.replace(/(<([^>]+)>)/gi, "");
|
||||
}
|
||||
}
|
|
@ -0,0 +1,66 @@
|
|||
import { randomBytes } from "crypto";
|
||||
|
||||
import { sendEmailVerificationLink } from "@calcom/emails/email-manager";
|
||||
import { getFeatureFlagMap } from "@calcom/features/flags/server/utils";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import rateLimit from "@calcom/lib/rateLimit";
|
||||
import { getTranslation } from "@calcom/lib/server/i18n";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { TRPCError } from "@calcom/trpc/server";
|
||||
|
||||
const log = logger.getChildLogger({ prefix: [`[[Auth] `] });
|
||||
|
||||
const limiter = rateLimit({
|
||||
intervalInMs: 60 * 1000, // 1 minute
|
||||
});
|
||||
|
||||
interface VerifyEmailType {
|
||||
username?: string;
|
||||
email: string;
|
||||
language?: string;
|
||||
}
|
||||
|
||||
export const sendEmailVerification = async ({ email, language, username }: VerifyEmailType) => {
|
||||
const token = randomBytes(32).toString("hex");
|
||||
const translation = await getTranslation(language ?? "en", "common");
|
||||
const flags = await getFeatureFlagMap(prisma);
|
||||
|
||||
if (!flags["email-verification"]) {
|
||||
log.warn("Email verification is disabled - Skipping");
|
||||
return { ok: true, skipped: true };
|
||||
}
|
||||
|
||||
await prisma.verificationToken.create({
|
||||
data: {
|
||||
identifier: email,
|
||||
token,
|
||||
expires: new Date(Date.now() + 24 * 3600 * 1000), // +1 day
|
||||
},
|
||||
});
|
||||
|
||||
const params = new URLSearchParams({
|
||||
token,
|
||||
});
|
||||
|
||||
const { isRateLimited } = limiter.check(10, email); // 10 requests per minute
|
||||
|
||||
if (isRateLimited) {
|
||||
throw new TRPCError({
|
||||
code: "TOO_MANY_REQUESTS",
|
||||
message: "An unexpected error occurred, please try again later.",
|
||||
cause: "Too many requests",
|
||||
});
|
||||
}
|
||||
|
||||
await sendEmailVerificationLink({
|
||||
language: translation,
|
||||
verificationEmailLink: `${WEBAPP_URL}/api/auth/verify-email?${params.toString()}`,
|
||||
user: {
|
||||
email,
|
||||
name: username,
|
||||
},
|
||||
});
|
||||
|
||||
return { ok: true, skipped: false };
|
||||
};
|
|
@ -9,6 +9,7 @@ export type AppFlags = {
|
|||
webhooks: boolean;
|
||||
workflows: boolean;
|
||||
"managed-event-types": boolean;
|
||||
"email-verification": boolean;
|
||||
"booker-layouts": boolean;
|
||||
"google-workspace-directory": boolean;
|
||||
"disable-signup": boolean;
|
||||
|
|
|
@ -18,6 +18,7 @@ import { useFlagMap } from "@calcom/features/flags/context/provider";
|
|||
import { KBarContent, KBarRoot, KBarTrigger } from "@calcom/features/kbar/Kbar";
|
||||
import TimezoneChangeDialog from "@calcom/features/settings/TimezoneChangeDialog";
|
||||
import AdminPasswordBanner from "@calcom/features/users/components/AdminPasswordBanner";
|
||||
import VerifyEmailBanner from "@calcom/features/users/components/VerifyEmailBanner";
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import { APP_NAME, DESKTOP_APP_LINK, JOIN_SLACK, ROADMAP, WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import getBrandColours from "@calcom/lib/getBrandColours";
|
||||
|
@ -26,6 +27,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
|
|||
import { isKeyInObject } from "@calcom/lib/isKeyInObject";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import useAvatarQuery from "@calcom/trpc/react/hooks/useAvatarQuery";
|
||||
import useEmailVerifyCheck from "@calcom/trpc/react/hooks/useEmailVerifyCheck";
|
||||
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
|
||||
import type { SVGComponent } from "@calcom/types/SVGComponent";
|
||||
import {
|
||||
|
@ -123,17 +125,23 @@ function useRedirectToOnboardingIfNeeded() {
|
|||
const router = useRouter();
|
||||
const query = useMeQuery();
|
||||
const user = query.data;
|
||||
const flags = useFlagMap();
|
||||
|
||||
const { data: email } = useEmailVerifyCheck();
|
||||
|
||||
const needsEmailVerification = !email?.isVerified && flags["email-verification"];
|
||||
|
||||
const isRedirectingToOnboarding = user && shouldShowOnboarding(user);
|
||||
|
||||
useEffect(() => {
|
||||
if (isRedirectingToOnboarding) {
|
||||
if (isRedirectingToOnboarding && !needsEmailVerification) {
|
||||
router.replace({
|
||||
pathname: "/getting-started",
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isRedirectingToOnboarding]);
|
||||
}, [isRedirectingToOnboarding, needsEmailVerification]);
|
||||
|
||||
return {
|
||||
isRedirectingToOnboarding,
|
||||
};
|
||||
|
@ -182,6 +190,7 @@ const Layout = (props: LayoutProps) => {
|
|||
<TeamsUpgradeBanner />
|
||||
<ImpersonatingBanner />
|
||||
<AdminPasswordBanner />
|
||||
<VerifyEmailBanner />
|
||||
</div>
|
||||
<div className="flex flex-1" data-testid="dashboard-shell">
|
||||
{props.SidebarContainer || <SideBarContainer bannersHeight={bannersHeight} />}
|
||||
|
|
|
@ -0,0 +1,36 @@
|
|||
import { APP_NAME } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
import useEmailVerifyCheck from "@calcom/trpc/react/hooks/useEmailVerifyCheck";
|
||||
import { Button, TopBanner, showToast } from "@calcom/ui";
|
||||
|
||||
import { useFlagMap } from "../../flags/context/provider";
|
||||
|
||||
function VerifyEmailBanner() {
|
||||
const flags = useFlagMap();
|
||||
const { t } = useLocale();
|
||||
const { data } = useEmailVerifyCheck();
|
||||
const mutation = trpc.viewer.auth.resendVerifyEmail.useMutation();
|
||||
|
||||
if (data?.isVerified || !flags["email-verification"]) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<TopBanner
|
||||
text={t("verify_email_banner_body", { appName: APP_NAME })}
|
||||
variant="warning"
|
||||
actions={
|
||||
<Button
|
||||
onClick={() => {
|
||||
mutation.mutate();
|
||||
showToast(t("email_sent"), "success");
|
||||
}}>
|
||||
{t("verify_email_banner_button")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default VerifyEmailBanner;
|
|
@ -0,0 +1,9 @@
|
|||
INSERT INTO
|
||||
"Feature" (slug, enabled, description, "type")
|
||||
VALUES
|
||||
(
|
||||
'email-verification',
|
||||
true,
|
||||
'Enable email verification for new users',
|
||||
'OPERATIONAL'
|
||||
) ON CONFLICT (slug) DO NOTHING;
|
|
@ -0,0 +1,13 @@
|
|||
import { trpc } from "../trpc";
|
||||
|
||||
export function useEmailVerifyCheck() {
|
||||
const emailCheck = trpc.viewer.shouldVerifyEmail.useQuery(undefined, {
|
||||
retry(failureCount) {
|
||||
return failureCount > 3;
|
||||
},
|
||||
});
|
||||
|
||||
return emailCheck;
|
||||
}
|
||||
|
||||
export default useEmailVerifyCheck;
|
|
@ -25,6 +25,7 @@ export async function getUserFromSession(ctx: TRPCContextInner, session: Maybe<S
|
|||
username: true,
|
||||
name: true,
|
||||
email: true,
|
||||
emailVerified: true,
|
||||
bio: true,
|
||||
timeZone: true,
|
||||
weekStart: true,
|
||||
|
|
|
@ -18,6 +18,7 @@ import { ZUpdateUserDefaultConferencingAppInputSchema } from "./updateUserDefaul
|
|||
|
||||
type AppsRouterHandlerCache = {
|
||||
me?: typeof import("./me.handler").meHandler;
|
||||
shouldVerifyEmail?: typeof import("./shouldVerifyEmail.handler").shouldVerifyEmailHandler;
|
||||
avatar?: typeof import("./avatar.handler").avatarHandler;
|
||||
deleteMe?: typeof import("./deleteMe.handler").deleteMeHandler;
|
||||
deleteMeWithoutPassword?: typeof import("./deleteMeWithoutPassword.handler").deleteMeWithoutPasswordHandler;
|
||||
|
@ -368,4 +369,18 @@ export const loggedInViewerRouter = router({
|
|||
|
||||
return UNSTABLE_HANDLER_CACHE.updateUserDefaultConferencingApp({ ctx, input });
|
||||
}),
|
||||
shouldVerifyEmail: authedProcedure.query(async ({ ctx }) => {
|
||||
if (!UNSTABLE_HANDLER_CACHE.shouldVerifyEmail) {
|
||||
UNSTABLE_HANDLER_CACHE.shouldVerifyEmail = (
|
||||
await import("./shouldVerifyEmail.handler")
|
||||
).shouldVerifyEmailHandler;
|
||||
}
|
||||
|
||||
// Unreachable code but required for type safety
|
||||
if (!UNSTABLE_HANDLER_CACHE.shouldVerifyEmail) {
|
||||
throw new Error("Failed to load handler");
|
||||
}
|
||||
|
||||
return UNSTABLE_HANDLER_CACHE.shouldVerifyEmail({ ctx });
|
||||
}),
|
||||
});
|
||||
|
|
|
@ -0,0 +1,21 @@
|
|||
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
|
||||
|
||||
type ShouldVerifyEmailType = {
|
||||
ctx: {
|
||||
user: NonNullable<TrpcSessionUser>;
|
||||
};
|
||||
};
|
||||
|
||||
export const shouldVerifyEmailHandler = async ({ ctx }: ShouldVerifyEmailType) => {
|
||||
const { user } = ctx;
|
||||
const isVerified = !!user.emailVerified;
|
||||
const isCalProvider = user.identityProvider === "CAL"; // We dont need to verify on OAUTH providers as they are already verified by the provider
|
||||
|
||||
const obj = {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
isVerified: isVerified || !isCalProvider,
|
||||
};
|
||||
|
||||
return obj;
|
||||
};
|
|
@ -0,0 +1 @@
|
|||
export {};
|
|
@ -6,6 +6,7 @@ import { ZVerifyPasswordInputSchema } from "./verifyPassword.schema";
|
|||
type AuthRouterHandlerCache = {
|
||||
changePassword?: typeof import("./changePassword.handler").changePasswordHandler;
|
||||
verifyPassword?: typeof import("./verifyPassword.handler").verifyPasswordHandler;
|
||||
resendVerifyEmail?: typeof import("./resendVerifyEmail.handler").resendVerifyEmail;
|
||||
};
|
||||
|
||||
const UNSTABLE_HANDLER_CACHE: AuthRouterHandlerCache = {};
|
||||
|
@ -46,4 +47,19 @@ export const authRouter = router({
|
|||
input,
|
||||
});
|
||||
}),
|
||||
resendVerifyEmail: authedProcedure.mutation(async ({ ctx }) => {
|
||||
if (!UNSTABLE_HANDLER_CACHE.resendVerifyEmail) {
|
||||
UNSTABLE_HANDLER_CACHE.resendVerifyEmail = await import("./resendVerifyEmail.handler").then(
|
||||
(mod) => mod.resendVerifyEmail
|
||||
);
|
||||
}
|
||||
|
||||
if (!UNSTABLE_HANDLER_CACHE.resendVerifyEmail) {
|
||||
throw new Error("Failed to load handler");
|
||||
}
|
||||
|
||||
return UNSTABLE_HANDLER_CACHE.resendVerifyEmail({
|
||||
ctx,
|
||||
});
|
||||
}),
|
||||
});
|
||||
|
|
|
@ -0,0 +1,30 @@
|
|||
import { sendEmailVerification } from "@calcom/features/auth/lib/verifyEmail";
|
||||
import logger from "@calcom/lib/logger";
|
||||
|
||||
import type { TrpcSessionUser } from "../../../trpc";
|
||||
|
||||
type ResendEmailOptions = {
|
||||
ctx: {
|
||||
user: NonNullable<TrpcSessionUser>;
|
||||
};
|
||||
};
|
||||
|
||||
const log = logger.getChildLogger({ prefix: [`[[Auth] `] });
|
||||
|
||||
export const resendVerifyEmail = async ({ ctx }: ResendEmailOptions) => {
|
||||
if (ctx.user.emailVerified) {
|
||||
log.info(`User ${ctx.user.id} already verified email`);
|
||||
return {
|
||||
ok: true,
|
||||
skipped: true,
|
||||
};
|
||||
}
|
||||
|
||||
const email = await sendEmailVerification({
|
||||
email: ctx.user.email,
|
||||
username: ctx.user?.username ?? undefined,
|
||||
language: ctx.user.locale,
|
||||
});
|
||||
|
||||
return email;
|
||||
};
|
|
@ -16,6 +16,8 @@ export function EmptyScreen({
|
|||
buttonOnClick,
|
||||
buttonRaw,
|
||||
border = true,
|
||||
dashedBorder = true,
|
||||
className,
|
||||
}: {
|
||||
Icon?: SVGComponent | IconType;
|
||||
avatar?: React.ReactElement;
|
||||
|
@ -25,14 +27,17 @@ export function EmptyScreen({
|
|||
buttonOnClick?: (event: React.MouseEvent<HTMLElement, MouseEvent>) => void;
|
||||
buttonRaw?: ReactNode; // Used incase you want to provide your own button.
|
||||
border?: boolean;
|
||||
}) {
|
||||
dashedBorder?: boolean;
|
||||
} & React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
data-testid="empty-screen"
|
||||
className={classNames(
|
||||
"min-h-80 flex w-full flex-col items-center justify-center rounded-md p-7 lg:p-20",
|
||||
border && "border-subtle border border-dashed"
|
||||
border && "border-subtle border",
|
||||
dashedBorder && "border-dashed",
|
||||
className
|
||||
)}>
|
||||
{!avatar ? null : (
|
||||
<div className="flex h-[72px] w-[72px] items-center justify-center rounded-full">{avatar}</div>
|
||||
|
|
Loading…
Reference in New Issue
Block a user