-
Notifications
You must be signed in to change notification settings - Fork 0
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Email verification #19
base: main
Are you sure you want to change the base?
Changes from all commits
5e26b5a
25f070b
2d2b04b
724ba4f
54fb2e7
1005f6a
257d63c
bb5c703
97bb5f3
70dc2bf
39fe413
2595d91
a5e3b7a
d502d7d
ccb756f
c746a2b
9e06816
44860f0
c08bd05
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,13 @@ | ||
-- CreateTable | ||
CREATE TABLE "EmailVerificationCode" ( | ||
"email" TEXT NOT NULL, | ||
"code" TEXT NOT NULL, | ||
"emailConfirmed" BOOLEAN NOT NULL DEFAULT false, | ||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, | ||
"expiresAt" TIMESTAMP(3) NOT NULL, | ||
|
||
CONSTRAINT "EmailVerificationCode_pkey" PRIMARY KEY ("email") | ||
); | ||
|
||
-- CreateIndex | ||
CREATE UNIQUE INDEX "EmailVerificationCode_email_key" ON "EmailVerificationCode"("email"); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,9 @@ | ||
import baseConfig from "@good-dog/eslint/base"; | ||
|
||
/** @type {import('typescript-eslint').Config} */ | ||
export default [ | ||
{ | ||
ignores: [], | ||
}, | ||
...baseConfig, | ||
]; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,30 @@ | ||
{ | ||
"name": "@good-dog/email", | ||
"private": true, | ||
"version": "0.1.0", | ||
"type": "module", | ||
"exports": { | ||
"./verification-email": "./src/verification-email.ts", | ||
"./email-service": "./src/email-service.ts" | ||
}, | ||
"license": "MIT", | ||
"scripts": { | ||
"clean": "rm -rf .turbo node_modules", | ||
"format": "prettier --check . --ignore-path ../../.gitignore", | ||
"lint": "eslint", | ||
"typecheck": "tsc --noEmit --emitDeclarationOnly false" | ||
}, | ||
"devDependencies": { | ||
"@good-dog/eslint": "workspace:*", | ||
"@good-dog/prettier": "workspace:*", | ||
"@good-dog/typescript": "workspace:*", | ||
"eslint": "9.10.0", | ||
"prettier": "3.2.5", | ||
"typescript": "5.4.5" | ||
}, | ||
"prettier": "@good-dog/prettier", | ||
"dependencies": { | ||
"@good-dog/env": "workspace:*", | ||
"@sendgrid/mail": "^8.1.4" | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,39 @@ | ||
import sgMail from "@sendgrid/mail"; | ||
|
||
// These functions are an abstraction over the sgMail module from sendgrid. The main | ||
// purpose is to throw runtime errors for blank api keys/from emails so we are alerted of | ||
// the issue ahead of time. | ||
|
||
function setApiKey(apiKey: string) { | ||
if (apiKey == "") { | ||
throw new TypeError("Invalid api key: Expected a non-empty string."); | ||
} | ||
|
||
sgMail.setApiKey(apiKey); | ||
} | ||
|
||
interface EmailMessage { | ||
to: string; | ||
from: string; | ||
subject: string; | ||
html: string; | ||
} | ||
|
||
async function send(msg: EmailMessage): Promise<boolean> { | ||
if (msg.from == "") { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Instead of passing the |
||
throw new TypeError("Invalid from email: Expected a non-empty string."); | ||
} | ||
|
||
try { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It might be worth throwing the error directly, letting the consumer of the service deal with errors just repalce the whole try catch with |
||
await sgMail.send(msg); | ||
return true; | ||
} catch (error) { | ||
void error; | ||
return false; | ||
} | ||
} | ||
|
||
export default { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Instead of |
||
setApiKey, | ||
send, | ||
}; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,18 @@ | ||
import emailService from "@good-dog/email/email-service"; | ||
import { env } from "@good-dog/env"; | ||
|
||
export async function sendEmailVerification( | ||
toEmail: string, | ||
code: string, | ||
): Promise<boolean> { | ||
emailService.setApiKey(env.SENDGRID_API_KEY ?? ""); | ||
|
||
const msg = { | ||
to: toEmail, | ||
from: env.VERIFICATION_FROM_EMAIL ?? "", | ||
subject: "Verify Your Email - Good Dog Licensing", | ||
html: `<p>Your Verification Code: <strong>${code}</strong></p>`, | ||
}; | ||
|
||
return await emailService.send(msg); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,10 @@ | ||
{ | ||
"extends": "@good-dog/typescript/base.json", | ||
"compilerOptions": { | ||
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json", | ||
"lib": ["es2022"], | ||
"types": ["bun"] | ||
}, | ||
"include": ["*.ts", "src"], | ||
"exclude": ["node_modules"] | ||
} |
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I recommend moving these procedures either to their own file individually, or making a new file for email related procedures, this file is getting a bit bloated |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -3,6 +3,7 @@ import { z } from "zod"; | |
|
||
import { deleteSessionCookie, setSessionCookie } from "@good-dog/auth/cookies"; | ||
import { comparePassword, hashPassword } from "@good-dog/auth/password"; | ||
import { sendEmailVerification } from "@good-dog/email/verification-email"; | ||
|
||
import { | ||
authenticatedProcedureBuilder, | ||
|
@@ -12,6 +13,126 @@ import { | |
const getNewSessionExpirationDate = () => | ||
new Date(Date.now() + 60_000 * 60 * 24 * 30); | ||
|
||
const getNewEmailVerificationCodeExpirationDate = () => | ||
new Date(Date.now() + 60_000 * 15); | ||
|
||
export const sendEmailVerificationProcedure = notAuthenticatedProcedureBuilder | ||
.input( | ||
z.object({ | ||
email: z.string().email(), | ||
}), | ||
) | ||
.mutation(async ({ ctx, input }) => { | ||
// Check if there is already an email verification code for the given email | ||
const existingEmailVerificationCode = | ||
await ctx.prisma.emailVerificationCode.findUnique({ | ||
where: { | ||
email: input.email, | ||
}, | ||
}); | ||
// If email already verified, throw error | ||
if (existingEmailVerificationCode?.emailConfirmed) { | ||
throw new TRPCError({ | ||
code: "BAD_REQUEST", | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Let's use |
||
message: "Email already verified", | ||
}); | ||
} | ||
// If email not verified, delete current email verification code to create a new one | ||
if (existingEmailVerificationCode !== null) { | ||
await ctx.prisma.emailVerificationCode.delete({ | ||
where: { | ||
email: input.email, | ||
}, | ||
}); | ||
} | ||
|
||
// Generate 6 digit code for email verification | ||
let emailCode = ""; | ||
for (let i = 0; i < 6; i++) { | ||
emailCode += Math.floor(Math.random() * 10); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Might be worth splitting the code generation here out to a standalone function in the email service. This way we can mock it in testing (and thus remove randomness where needed) |
||
} | ||
// Send email. If sending fails, throw error. | ||
// eslint-disable-next-line @typescript-eslint/no-unsafe-call | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Question: does this lint error go away after restarting vscode? |
||
if (!(await sendEmailVerification(input.email, emailCode))) { | ||
throw new TRPCError({ | ||
code: "INTERNAL_SERVER_ERROR", | ||
message: `Email confirmation to ${input.email} failed to send.`, | ||
}); | ||
} | ||
// Create the email verification code in the database | ||
await ctx.prisma.emailVerificationCode.create({ | ||
data: { | ||
code: emailCode, | ||
email: input.email, | ||
expiresAt: getNewEmailVerificationCodeExpirationDate(), | ||
}, | ||
}); | ||
|
||
return { | ||
message: `Email verification code sent to ${input.email}`, | ||
}; | ||
}); | ||
|
||
export const confirmEmailProcedure = notAuthenticatedProcedureBuilder | ||
.input( | ||
z.object({ | ||
email: z.string().email(), | ||
code: z.string(), | ||
}), | ||
) | ||
.mutation(async ({ ctx, input }) => { | ||
// Get email verification from database | ||
const emailVerificationCode = | ||
await ctx.prisma.emailVerificationCode.findUnique({ | ||
where: { | ||
email: input.email, | ||
}, | ||
}); | ||
|
||
// If email already verified, return a success | ||
if (emailVerificationCode?.emailConfirmed) { | ||
return { | ||
message: `Email was successfully verified. Email: ${input.email}.`, | ||
}; | ||
} | ||
|
||
// If email verification not found, throw error | ||
if (emailVerificationCode === null) { | ||
throw new TRPCError({ | ||
code: "NOT_FOUND", | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nitpick: I think "UNAUTHORIZED" might be better for this error and the subsequent checks, technically we don't necessarily want to be too detailed with failed authentication attempts |
||
message: `${input.email} is not waiting to be confirmed.`, | ||
}); | ||
} | ||
// If given code is wrong, throw error | ||
if (input.code !== emailVerificationCode.code) { | ||
throw new TRPCError({ | ||
code: "BAD_REQUEST", | ||
message: `Given code is incorrect for ${input.email}`, | ||
}); | ||
} | ||
// If given code is expired, throw error | ||
if (emailVerificationCode.expiresAt < new Date()) { | ||
throw new TRPCError({ | ||
code: "BAD_REQUEST", | ||
message: "Given code is expired.", | ||
}); | ||
} | ||
|
||
// If here, the given code was valid, so we can update the emailVerificationCode. | ||
await ctx.prisma.emailVerificationCode.update({ | ||
where: { | ||
email: input.email, | ||
}, | ||
data: { | ||
emailConfirmed: true, | ||
}, | ||
}); | ||
|
||
return { | ||
message: `Email was successfully verified. Email: ${input.email}.`, | ||
}; | ||
}); | ||
|
||
export const signUpProcedure = notAuthenticatedProcedureBuilder | ||
.input( | ||
z.object({ | ||
|
@@ -21,6 +142,21 @@ export const signUpProcedure = notAuthenticatedProcedureBuilder | |
}), | ||
) | ||
.mutation(async ({ ctx, input }) => { | ||
// Throw error if email is not verified | ||
const emailVerificationCode = | ||
await ctx.prisma.emailVerificationCode.findUnique({ | ||
where: { | ||
email: input.email, | ||
}, | ||
}); | ||
|
||
if (!emailVerificationCode?.emailConfirmed) { | ||
throw new TRPCError({ | ||
code: "FORBIDDEN", | ||
message: "Email has not been verified.", | ||
}); | ||
} | ||
|
||
const existingUserWithEmail = await ctx.prisma.user.findUnique({ | ||
where: { | ||
email: input.email, | ||
|
@@ -64,7 +200,7 @@ export const signUpProcedure = notAuthenticatedProcedureBuilder | |
setSessionCookie(session.id, session.expiresAt); | ||
|
||
return { | ||
message: `Successfully signed up and logged in as ${input.email}`, | ||
message: `Successfully signed up and logged in as ${input.email}.`, | ||
}; | ||
}); | ||
|
||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We can do just
if (!apiKey)
, which will also catchundefined