Skip to content
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

Forgot password #23

Merged
merged 20 commits into from
Nov 24, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
-- CreateTable
CREATE TABLE "PasswordResetReq" (
"passwordResetId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expiresAt" TIMESTAMP(3) NOT NULL,

CONSTRAINT "PasswordResetReq_pkey" PRIMARY KEY ("passwordResetId")
);

-- AddForeignKey
ALTER TABLE "PasswordResetReq" ADD CONSTRAINT "PasswordResetReq_passwordResetId_fkey" FOREIGN KEY ("passwordResetId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
24 changes: 16 additions & 8 deletions packages/db/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -23,21 +23,22 @@ enum Role {
}

model User {
userId String @id @default(uuid()) @map("id")
email String @unique
hashedPassword String @map("password")
userId String @id @default(uuid()) @map("id")
email String @unique
hashedPassword String @map("password")
firstName String
lastName String
stageName String?
role Role
isSongWriter Boolean @default(false)
isAscapAffiliated Boolean @default(false)
isBmiAffiliated Boolean @default(false)
isSongWriter Boolean @default(false)
isAscapAffiliated Boolean @default(false)
isBmiAffiliated Boolean @default(false)
groups Group[]
passwordResetReq PasswordResetReq?
sessions Session[]
sentInvites GroupInvite[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}

model Session {
Expand Down Expand Up @@ -84,3 +85,10 @@ model EmailVerificationCode {
updatedAt DateTime @updatedAt
expiresAt DateTime
}

model PasswordResetReq {
passwordResetId String @id @default(cuid())
user User @relation(fields: [passwordResetId], references: [userId], onDelete: Cascade)
createdAt DateTime @default(now())
expiresAt DateTime
}
3 changes: 2 additions & 1 deletion packages/email/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
"type": "module",
"exports": {
"./verification-email": "./src/verification-email.ts",
"./email-service": "./src/email-service.ts"
"./email-service": "./src/email-service.ts",
"./password-reset-email": "./src/password-reset-email.ts"
},
"license": "MIT",
"scripts": {
Expand Down
19 changes: 19 additions & 0 deletions packages/email/src/password-reset-email.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { send, setApiKey } from "@good-dog/email/email-service";
import { env } from "@good-dog/env";

export async function sendPasswordResetEmail(toEmail: string, cuid: string) {
setApiKey(env.SENDGRID_API_KEY ?? "");

let baseURL = "http://localhost:3000";
if (env.VERCEL_URL) {
baseURL = `https://${env.VERCEL_URL}`;
}

const msg = {
to: toEmail,
subject: "Reset Your Password - Good Dog Licensing",
html: `<p>Follow <a href="${baseURL}/pwdreset/reset_id?=${cuid}">this link</a> to reset your password.`,
};

return await send(msg, env.VERIFICATION_FROM_EMAIL ?? "");
}
6 changes: 6 additions & 0 deletions packages/trpc/src/internal/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ import {
confirmEmailProcedure,
sendEmailVerificationProcedure,
} from "../procedures/email-verification";
import {
confirmPasswordResetProcedure,
sendForgotPasswordEmailProcedure,
} from "../procedures/forgot-password";
import { onboardingProcedure } from "../procedures/onboarding";
import {
getAuthenticatedUserProcedure,
Expand All @@ -25,6 +29,8 @@ export const appRouter = createTRPCRouter({
deleteAccount: deleteAccountProcedure,
authenticatedUser: getAuthenticatedUserProcedure,
user: getUserProcedure,
sendForgotPasswordEmail: sendForgotPasswordEmailProcedure,
confirmPasswordReset: confirmPasswordResetProcedure,
});

export type AppRouter = typeof appRouter;
132 changes: 132 additions & 0 deletions packages/trpc/src/procedures/forgot-password.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod";

import { hashPassword } from "@good-dog/auth/password";
import { sendPasswordResetEmail } from "@good-dog/email/password-reset-email";
import { env } from "@good-dog/env";

import { baseProcedureBuilder } from "../internal/init";

// click forgot passwd -> enter email -> receive unique link -> enter new passwd at given link

const getNewPasswordResetExpirationDate = () =>
new Date(Date.now() + 60_000 * 15);

export const sendForgotPasswordEmailProcedure = baseProcedureBuilder
.input(
z.object({
email: z.string().email(),
}),
)
.mutation(async ({ ctx, input }) => {
// Find user with the given email
const user = await ctx.prisma.user.findUnique({
where: {
email: input.email,
},
});

// If user with that email doesn't exist, return.
if (!user) {
jpraissman marked this conversation as resolved.
Show resolved Hide resolved
return {
message: `If a user exists for ${input.email}, a password reset link was sent to the email.`,
};
}

const [_, pwdResetReq] = await ctx.prisma.$transaction([
// Delete any existing password reset requests for the user
ctx.prisma.passwordResetReq.deleteMany({
where: {
user: user,
},
}),
// Create new password reset request with updated expired at time
ctx.prisma.passwordResetReq.create({
data: {
user: {
connect: {
userId: user.userId,
},
},
expiresAt: getNewPasswordResetExpirationDate(),
},
}),
]);

// Send the password reset email
try {
await sendPasswordResetEmail(input.email, pwdResetReq.passwordResetId);
} catch (error) {
if (env.NODE_ENV === "development") {
console.error(error);
} else {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: `Password reset email to ${input.email} failed to send.`,
cause: error,
});
}
}

return {
message: `If a user exists for ${input.email}, a password reset link was sent to the email.`,
};
});

export const confirmPasswordResetProcedure = baseProcedureBuilder
.input(
z.object({
passwordResetId: z.string(),
newPassword: z.string(),
}),
)
.mutation(async ({ ctx, input }) => {
// Find password reset request for given passwordResetId
const passwordResetReq = await ctx.prisma.passwordResetReq.findUnique({
where: {
passwordResetId: input.passwordResetId,
},
include: {
user: true,
},
});

// If password reset request doesn't exist, throw error
if (!passwordResetReq) {
throw new TRPCError({
code: "NOT_FOUND",
message: `No password reset request found for given id.`,
});
}

// If password reset request is expired, throw error
if (passwordResetReq.expiresAt < new Date()) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: `Password reset request is expired.`,
});
}

await ctx.prisma.$transaction([
// Update user's password
ctx.prisma.user.update({
where: {
email: passwordResetReq.user.email,
},
data: {
hashedPassword: await hashPassword(input.newPassword),
},
}),

// Delete the password reset request
ctx.prisma.passwordResetReq.delete({
where: {
passwordResetId: passwordResetReq.passwordResetId,
},
}),
]);

return {
message: "Password reset.",
};
});
Loading