Skip to content

Commit

Permalink
Fix/email templates enum (#61)
Browse files Browse the repository at this point in the history
* refactor email verification template and reset password template

* update `sendMail` function for better reusability

* send email to the receiver on production only
  • Loading branch information
iamtouha authored Apr 16, 2024
1 parent 3f23687 commit d7231cd
Show file tree
Hide file tree
Showing 5 changed files with 105 additions and 163 deletions.
126 changes: 26 additions & 100 deletions src/lib/auth/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,8 @@ import {
type SignupInput,
resetPasswordSchema,
} from "@/lib/validators/auth";
import {
emailVerificationCodes,
passwordResetTokens,
users,
} from "@/server/db/schema";
import { sendMail } from "@/server/send-mail";
import { renderVerificationCodeEmail } from "@/lib/email-templates/email-verification";
import { renderResetPasswordEmail } from "@/lib/email-templates/reset-password";
import { emailVerificationCodes, passwordResetTokens, users } from "@/server/db/schema";
import { sendMail, EmailTemplate } from "@/lib/email";
import { validateRequest } from "@/lib/auth/validate-request";
import { Paths } from "../constants";
import { env } from "@/env";
Expand All @@ -35,10 +29,7 @@ export interface ActionResponse<T> {
formError?: string;
}

export async function login(
_: any,
formData: FormData,
): Promise<ActionResponse<LoginInput>> {
export async function login(_: any, formData: FormData): Promise<ActionResponse<LoginInput>> {
const obj = Object.fromEntries(formData.entries());

const parsed = loginSchema.safeParse(obj);
Expand Down Expand Up @@ -70,10 +61,7 @@ export async function login(
};
}

const validPassword = await new Scrypt().verify(
existingUser.hashedPassword,
password,
);
const validPassword = await new Scrypt().verify(existingUser.hashedPassword, password);
if (!validPassword) {
return {
formError: "Incorrect email or password",
Expand All @@ -82,18 +70,11 @@ export async function login(

const session = await lucia.createSession(existingUser.id, {});
const sessionCookie = lucia.createSessionCookie(session.id);
cookies().set(
sessionCookie.name,
sessionCookie.value,
sessionCookie.attributes,
);
cookies().set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes);
return redirect(Paths.Dashboard);
}

export async function signup(
_: any,
formData: FormData,
): Promise<ActionResponse<SignupInput>> {
export async function signup(_: any, formData: FormData): Promise<ActionResponse<SignupInput>> {
const obj = Object.fromEntries(formData.entries());

const parsed = signupSchema.safeParse(obj);
Expand Down Expand Up @@ -129,19 +110,11 @@ export async function signup(
});

const verificationCode = await generateEmailVerificationCode(userId, email);
await sendMail({
to: email,
subject: "Verify your account",
body: renderVerificationCodeEmail({ code: verificationCode }),
});
await sendMail(email, EmailTemplate.EmailVerification, { code: verificationCode });

const session = await lucia.createSession(userId, {});
const sessionCookie = lucia.createSessionCookie(session.id);
cookies().set(
sessionCookie.name,
sessionCookie.value,
sessionCookie.attributes,
);
cookies().set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes);
return redirect(Paths.VerifyEmail);
}

Expand All @@ -154,11 +127,7 @@ export async function logout(): Promise<{ error: string } | void> {
}
await lucia.invalidateSession(session.id);
const sessionCookie = lucia.createBlankSessionCookie();
cookies().set(
sessionCookie.name,
sessionCookie.value,
sessionCookie.attributes,
);
cookies().set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes);
return redirect("/");
}

Expand All @@ -180,23 +149,13 @@ export async function resendVerificationEmail(): Promise<{
error: `Please wait ${timeFromNow(lastSent.expiresAt)} before resending`,
};
}
const verificationCode = await generateEmailVerificationCode(
user.id,
user.email,
);
await sendMail({
to: user.email,
subject: "Verify your account",
body: renderVerificationCodeEmail({ code: verificationCode }),
});
const verificationCode = await generateEmailVerificationCode(user.id, user.email);
await sendMail(user.email, EmailTemplate.EmailVerification, { code: verificationCode });

return { success: true };
}

export async function verifyEmail(
_: any,
formData: FormData,
): Promise<{ error: string } | void> {
export async function verifyEmail(_: any, formData: FormData): Promise<{ error: string } | void> {
const code = formData.get("code");
if (typeof code !== "string" || code.length !== 8) {
return { error: "Invalid code" };
Expand All @@ -211,33 +170,22 @@ export async function verifyEmail(
where: (table, { eq }) => eq(table.userId, user.id),
});
if (item) {
await tx
.delete(emailVerificationCodes)
.where(eq(emailVerificationCodes.id, item.id));
await tx.delete(emailVerificationCodes).where(eq(emailVerificationCodes.id, item.id));
}
return item;
});

if (!dbCode || dbCode.code !== code)
return { error: "Invalid verification code" };
if (!dbCode || dbCode.code !== code) return { error: "Invalid verification code" };

if (!isWithinExpirationDate(dbCode.expiresAt))
return { error: "Verification code expired" };
if (!isWithinExpirationDate(dbCode.expiresAt)) return { error: "Verification code expired" };

if (dbCode.email !== user.email) return { error: "Email does not match" };

await lucia.invalidateUserSessions(user.id);
await db
.update(users)
.set({ emailVerified: true })
.where(eq(users.id, user.id));
await db.update(users).set({ emailVerified: true }).where(eq(users.id, user.id));
const session = await lucia.createSession(user.id, {});
const sessionCookie = lucia.createSessionCookie(session.id);
cookies().set(
sessionCookie.name,
sessionCookie.value,
sessionCookie.attributes,
);
cookies().set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes);
redirect(Paths.Dashboard);
}

Expand All @@ -255,18 +203,13 @@ export async function sendPasswordResetLink(
where: (table, { eq }) => eq(table.email, parsed.data),
});

if (!user || !user.emailVerified)
return { error: "Provided email is invalid." };
if (!user || !user.emailVerified) return { error: "Provided email is invalid." };

const verificationToken = await generatePasswordResetToken(user.id);

const verificationLink = `${env.NEXT_PUBLIC_APP_URL}/reset-password/${verificationToken}`;

await sendMail({
to: user.email,
subject: "Reset your password",
body: renderResetPasswordEmail({ link: verificationLink }),
});
await sendMail(user.email, EmailTemplate.PasswordReset, { link: verificationLink });

return { success: true };
} catch (error) {
Expand Down Expand Up @@ -295,31 +238,21 @@ export async function resetPassword(
where: (table, { eq }) => eq(table.id, token),
});
if (item) {
await tx
.delete(passwordResetTokens)
.where(eq(passwordResetTokens.id, item.id));
await tx.delete(passwordResetTokens).where(eq(passwordResetTokens.id, item.id));
}
return item;
});

if (!dbToken) return { error: "Invalid password reset link" };

if (!isWithinExpirationDate(dbToken.expiresAt))
return { error: "Password reset link expired." };
if (!isWithinExpirationDate(dbToken.expiresAt)) return { error: "Password reset link expired." };

await lucia.invalidateUserSessions(dbToken.userId);
const hashedPassword = await new Scrypt().hash(password);
await db
.update(users)
.set({ hashedPassword })
.where(eq(users.id, dbToken.userId));
await db.update(users).set({ hashedPassword }).where(eq(users.id, dbToken.userId));
const session = await lucia.createSession(dbToken.userId, {});
const sessionCookie = lucia.createSessionCookie(session.id);
cookies().set(
sessionCookie.name,
sessionCookie.value,
sessionCookie.attributes,
);
cookies().set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes);
redirect(Paths.Dashboard);
}

Expand All @@ -331,13 +264,8 @@ const timeFromNow = (time: Date) => {
return `${minutes}m ${seconds}s`;
};

async function generateEmailVerificationCode(
userId: string,
email: string,
): Promise<string> {
await db
.delete(emailVerificationCodes)
.where(eq(emailVerificationCodes.userId, userId));
async function generateEmailVerificationCode(userId: string, email: string): Promise<string> {
await db.delete(emailVerificationCodes).where(eq(emailVerificationCodes.userId, userId));
const code = generateRandomString(8, alphabet("0-9")); // 8 digit code
await db.insert(emailVerificationCodes).values({
userId,
Expand All @@ -349,9 +277,7 @@ async function generateEmailVerificationCode(
}

async function generatePasswordResetToken(userId: string): Promise<string> {
await db
.delete(passwordResetTokens)
.where(eq(passwordResetTokens.userId, userId));
await db.delete(passwordResetTokens).where(eq(passwordResetTokens.userId, userId));
const tokenId = generateId(40);
await db.insert(passwordResetTokens).values({
id: tokenId,
Expand Down
66 changes: 66 additions & 0 deletions src/lib/email/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import "server-only";

import { EmailVerificationTemplate } from "./templates/email-verification";
import { ResetPasswordTemplate } from "./templates/reset-password";
import { render } from "@react-email/render";
import { env } from "@/env";
import { EMAIL_SENDER } from "@/lib/constants";
import { createTransport, type TransportOptions } from "nodemailer";
import type { ComponentProps } from "react";

export enum EmailTemplate {
EmailVerification = "EmailVerification",
PasswordReset = "PasswordReset",
}

export type PropsMap = {
[EmailTemplate.EmailVerification]: ComponentProps<typeof EmailVerificationTemplate>;
[EmailTemplate.PasswordReset]: ComponentProps<typeof ResetPasswordTemplate>;
};

const getEmailTemplate = <T extends EmailTemplate>(template: T, props: PropsMap[NoInfer<T>]) => {
switch (template) {
case EmailTemplate.EmailVerification:
return {
subject: "Verify your email address",
body: render(
<EmailVerificationTemplate {...(props as PropsMap[EmailTemplate.EmailVerification])} />,
),
};
case EmailTemplate.PasswordReset:
return {
subject: "Verify your email address",
body: render(
<ResetPasswordTemplate {...(props as PropsMap[EmailTemplate.PasswordReset])} />,
),
};
default:
throw new Error("Invalid email template");
}
};

const smtpConfig = {
host: env.SMTP_HOST,
port: env.SMTP_PORT,
auth: {
user: env.SMTP_USER,
pass: env.SMTP_PASSWORD,
},
};

const transporter = createTransport(smtpConfig as TransportOptions);

export const sendMail = async <T extends EmailTemplate>(
to: string,
template: T,
props: PropsMap[NoInfer<T>],
) => {
if (env.NODE_ENV !== "production") {
console.log("📨 Email sent to:", to, "with template:", template, "and props:", props);
return;
}

const { subject, body } = getEmailTemplate(template, props);

return transporter.sendMail({ from: EMAIL_SENDER, to, subject, html: body });
};
Original file line number Diff line number Diff line change
@@ -1,35 +1,23 @@
import {
Body,
Container,
Head,
Html,
Preview,
Section,
Text,
} from "@react-email/components";
import { Body, Container, Head, Html, Preview, Section, Text } from "@react-email/components";
import { APP_TITLE } from "@/lib/constants";
import { render } from "@react-email/render";

interface Props {
export interface EmailVerificationTemplateProps {
code: string;
}

export const VerificationCodeEmail = ({ code }: Props) => {
export const EmailVerificationTemplate = ({ code }: EmailVerificationTemplateProps) => {
return (
<Html>
<Head />
<Preview>
Verify your email address to complete your {APP_TITLE} registration
</Preview>
<Preview>Verify your email address to complete your {APP_TITLE} registration</Preview>
<Body style={main}>
<Container style={container}>
<Section>
<Text style={title}>{APP_TITLE}</Text>
<Text style={text}>Hi,</Text>
<Text style={text}>
Thank you for registering for an account on {APP_TITLE}. To
complete your registration, please verify your your account by
using the following code:
Thank you for registering for an account on {APP_TITLE}. To complete your
registration, please verify your your account by using the following code:
</Text>
<Text style={codePlaceholder}>{code}</Text>

Expand All @@ -41,9 +29,6 @@ export const VerificationCodeEmail = ({ code }: Props) => {
);
};

export const renderVerificationCodeEmail = ({ code }: Props) =>
render(<VerificationCodeEmail code={code} />);

const main = {
backgroundColor: "#f6f9fc",
padding: "10px 0",
Expand Down
Loading

0 comments on commit d7231cd

Please sign in to comment.