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:
sean-brydon 2023-06-07 08:27:48 +01:00 committed by GitHub
parent 057cf273dd
commit 9e70a985e4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 493 additions and 21 deletions

View File

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

View File

@ -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"}`);
}

View File

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

View File

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

View File

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

View File

@ -11,6 +11,16 @@
"calcom_explained_new_user": "Finish setting up your {{appName}} account! Youre 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",

View File

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

View File

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

View File

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

View File

@ -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, "");
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1 @@
export {};

View File

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

View File

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

View File

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