diff --git a/server/auth/2fa.ts b/server/auth/2fa.ts index cb215ddd..970b14a4 100644 --- a/server/auth/2fa.ts +++ b/server/auth/2fa.ts @@ -4,11 +4,12 @@ import { twoFactorBackupCodes } from "@server/db/schema"; import { eq } from "drizzle-orm"; import { decodeHex } from "oslo/encoding"; import { TOTPController } from "oslo/otp"; +import { verifyPassword } from "./password"; export async function verifyTotpCode( code: string, secret: string, - userId: string, + userId: string ): Promise { if (code.length !== 6) { const validBackupCode = await verifyBackUpCode(code, userId); @@ -16,7 +17,7 @@ export async function verifyTotpCode( } else { const validOTP = await new TOTPController().verify( code, - decodeHex(secret), + decodeHex(secret) ); return validOTP; @@ -25,7 +26,7 @@ export async function verifyTotpCode( export async function verifyBackUpCode( code: string, - userId: string, + userId: string ): Promise { const allHashed = await db .select() @@ -38,12 +39,7 @@ export async function verifyBackUpCode( let validId; for (const hashedCode of allHashed) { - const validCode = await verify(hashedCode.codeHash, code, { - memoryCost: 19456, - timeCost: 2, - outputLen: 32, - parallelism: 1, - }); + const validCode = await verifyPassword(code, hashedCode.codeHash); if (validCode) { validId = hashedCode.codeId; } diff --git a/server/auth/resourceOtp.ts b/server/auth/resourceOtp.ts index 523b4011..a9de7499 100644 --- a/server/auth/resourceOtp.ts +++ b/server/auth/resourceOtp.ts @@ -8,6 +8,7 @@ import { sendEmail } from "@server/emails"; import ResourceOTPCode from "@server/emails/templates/ResourceOTPCode"; import config from "@server/config"; import { hash, verify } from "@node-rs/argon2"; +import { hashPassword } from "./password"; export async function sendResourceOtpEmail( email: string, @@ -47,12 +48,7 @@ export async function generateResourceOtpCode( const otp = generateRandomString(8, alphabet("0-9", "A-Z", "a-z")); - const otpHash = await hash(otp, { - memoryCost: 19456, - timeCost: 2, - outputLen: 32, - parallelism: 1, - }); + const otpHash = await hashPassword(otp); await db.insert(resourceOtp).values({ resourceId, @@ -84,12 +80,7 @@ export async function isValidOtp( return false; } - const validCode = await verify(record[0].otpHash, otp, { - memoryCost: 19456, - timeCost: 2, - outputLen: 32, - parallelism: 1 - }); + const validCode = await verifyPassword(otp, record[0].otpHash); if (!validCode) { return false; } diff --git a/server/db/schema.ts b/server/db/schema.ts index 5ab72289..36b384b7 100644 --- a/server/db/schema.ts +++ b/server/db/schema.ts @@ -150,6 +150,7 @@ export const emailVerificationCodes = sqliteTable("emailVerificationCodes", { export const passwordResetTokens = sqliteTable("passwordResetTokens", { tokenId: integer("id").primaryKey({ autoIncrement: true }), + email: text("email").notNull(), userId: text("userId") .notNull() .references(() => users.userId, { onDelete: "cascade" }), diff --git a/server/emails/templates/ResetPasswordCode.tsx b/server/emails/templates/ResetPasswordCode.tsx new file mode 100644 index 00000000..60c2f0bb --- /dev/null +++ b/server/emails/templates/ResetPasswordCode.tsx @@ -0,0 +1,70 @@ +import { + Body, + Container, + Head, + Heading, + Html, + Preview, + Section, + Text, + Tailwind +} from "@react-email/components"; +import * as React from "react"; + +interface Props { + email: string; + code: string; + link: string; +} + +export const ResetPasswordCode = ({ email, code, link }: Props) => { + const previewText = `Reset your password, ${email}`; + + return ( + + + {previewText} + + + + + You've requested to reset your password + + + Hi {email || "there"}, + + + You’ve requested to reset your password. Please{" "} + + click here + {" "} + and follow the instructions to reset your + password, or manually enter the following code: + +
+ + {code} + +
+ + If you didn’t request this, you can safely ignore + this email. + +
+ +
+ + ); +}; + +export default ResetPasswordCode; diff --git a/server/routers/accessToken/generateAccessToken.ts b/server/routers/accessToken/generateAccessToken.ts index b1b5a58f..bc396370 100644 --- a/server/routers/accessToken/generateAccessToken.ts +++ b/server/routers/accessToken/generateAccessToken.ts @@ -19,6 +19,7 @@ import { z } from "zod"; import { fromError } from "zod-validation-error"; import logger from "@server/logger"; import { createDate, TimeSpan } from "oslo"; +import { hashPassword } from "@server/auth/password"; export const generateAccessTokenBodySchema = z .object({ @@ -91,12 +92,7 @@ export async function generateAccessToken( const token = generateIdFromEntropySize(25); - const tokenHash = await hash(token, { - memoryCost: 19456, - timeCost: 2, - outputLen: 32, - parallelism: 1 - }); + const tokenHash = await hashPassword(token); const id = generateId(15); const [result] = await db diff --git a/server/routers/auth/login.ts b/server/routers/auth/login.ts index d2956bdb..41bd83ce 100644 --- a/server/routers/auth/login.ts +++ b/server/routers/auth/login.ts @@ -3,7 +3,7 @@ import { createSession, generateSessionToken, serializeSessionCookie, - verifySession, + verifySession } from "@server/auth"; import db from "@server/db"; import { users } from "@server/db/schema"; @@ -17,12 +17,15 @@ import { fromError } from "zod-validation-error"; import { verifyTotpCode } from "@server/auth/2fa"; import config from "@server/config"; import logger from "@server/logger"; +import { verifyPassword } from "@server/auth/password"; -export const loginBodySchema = z.object({ - email: z.string().email(), - password: z.string(), - code: z.string().optional(), -}).strict(); +export const loginBodySchema = z + .object({ + email: z.string().email(), + password: z.string(), + code: z.string().optional() + }) + .strict(); export type LoginBody = z.infer; @@ -57,7 +60,7 @@ export async function login( success: true, error: false, message: "Already logged in", - status: HttpCode.OK, + status: HttpCode.OK }); } @@ -76,15 +79,9 @@ export async function login( const existingUser = existingUserRes[0]; - const validPassword = await verify( - existingUser.passwordHash, + const validPassword = await verifyPassword( password, - { - memoryCost: 19456, - timeCost: 2, - outputLen: 32, - parallelism: 1, - } + existingUser.passwordHash ); if (!validPassword) { return next( @@ -102,7 +99,7 @@ export async function login( success: true, error: false, message: "Two-factor authentication required", - status: HttpCode.ACCEPTED, + status: HttpCode.ACCEPTED }); } @@ -137,7 +134,7 @@ export async function login( success: true, error: false, message: "Email verification code sent", - status: HttpCode.OK, + status: HttpCode.OK }); } @@ -146,7 +143,7 @@ export async function login( success: true, error: false, message: "Logged in successfully", - status: HttpCode.OK, + status: HttpCode.OK }); } catch (e) { logger.error(e); diff --git a/server/routers/auth/requestPasswordReset.ts b/server/routers/auth/requestPasswordReset.ts index 1e901677..62902c0a 100644 --- a/server/routers/auth/requestPasswordReset.ts +++ b/server/routers/auth/requestPasswordReset.ts @@ -7,16 +7,22 @@ import { response } from "@server/utils"; import { db } from "@server/db"; import { passwordResetTokens, users } from "@server/db/schema"; import { eq } from "drizzle-orm"; -import { sha256 } from "oslo/crypto"; +import { alphabet, generateRandomString, sha256 } from "oslo/crypto"; import { encodeHex } from "oslo/encoding"; import { createDate } from "oslo"; import logger from "@server/logger"; import { generateIdFromEntropySize } from "@server/auth"; import { TimeSpan } from "oslo"; +import config from "@server/config"; +import { sendEmail } from "@server/emails"; +import ResetPasswordCode from "@server/emails/templates/ResetPasswordCode"; +import { hashPassword } from "@server/auth/password"; -export const requestPasswordResetBody = z.object({ - email: z.string().email(), -}).strict(); +export const requestPasswordResetBody = z + .object({ + email: z.string().email() + }) + .strict(); export type RequestPasswordResetBody = z.infer; @@ -27,7 +33,7 @@ export type RequestPasswordResetResponse = { export async function requestPasswordReset( req: Request, res: Response, - next: NextFunction, + next: NextFunction ): Promise { const parsedBody = requestPasswordResetBody.safeParse(req.body); @@ -35,8 +41,8 @@ export async function requestPasswordReset( return next( createHttpError( HttpCode.BAD_REQUEST, - fromError(parsedBody.error).toString(), - ), + fromError(parsedBody.error).toString() + ) ); } @@ -52,8 +58,8 @@ export async function requestPasswordReset( return next( createHttpError( HttpCode.BAD_REQUEST, - "No user with that email exists", - ), + "A user with that email does not exist" + ) ); } @@ -61,36 +67,47 @@ export async function requestPasswordReset( .delete(passwordResetTokens) .where(eq(passwordResetTokens.userId, existingUser[0].userId)); - const token = generateIdFromEntropySize(25); - const tokenHash = encodeHex( - await sha256(new TextEncoder().encode(token)), - ); + const token = generateRandomString(8, alphabet("0-9", "A-Z", "a-z")); + const tokenHash = await hashPassword(token); await db.insert(passwordResetTokens).values({ userId: existingUser[0].userId, + email: existingUser[0].email, tokenHash, - expiresAt: createDate(new TimeSpan(2, "h")).getTime(), + expiresAt: createDate(new TimeSpan(2, "h")).getTime() }); - // TODO: send email with link to reset password on dashboard - // something like: https://example.com/auth/reset-password?email=${email}&?token=${token} - // for now, just log the token + const url = `${config.app.base_url}/auth/reset-password?email=${email}&token=${token}`; + + await sendEmail( + ResetPasswordCode({ + email, + code: token, + link: url + }), + { + from: config.email?.no_reply, + to: email, + subject: "Reset your password" + } + ); + return response(res, { data: { - sentEmail: true, + sentEmail: true }, success: true, error: false, - message: "Password reset email sent", - status: HttpCode.OK, + message: "Password reset requested", + status: HttpCode.OK }); } catch (e) { logger.error(e); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, - "Failed to process password reset request", - ), + "Failed to process password reset request" + ) ); } } diff --git a/server/routers/auth/requestTotpSecret.ts b/server/routers/auth/requestTotpSecret.ts index 473d77db..1c99ebba 100644 --- a/server/routers/auth/requestTotpSecret.ts +++ b/server/routers/auth/requestTotpSecret.ts @@ -13,6 +13,7 @@ import { verify } from "@node-rs/argon2"; import { createTOTPKeyURI } from "oslo/otp"; import config from "@server/config"; import logger from "@server/logger"; +import { verifyPassword } from "@server/auth/password"; export const requestTotpSecretBody = z .object({ @@ -47,12 +48,7 @@ export async function requestTotpSecret( const user = req.user as User; try { - const validPassword = await verify(user.passwordHash, password, { - memoryCost: 19456, - timeCost: 2, - outputLen: 32, - parallelism: 1 - }); + const validPassword = await verifyPassword(password, user.passwordHash); if (!validPassword) { return next(unauthorized()); } diff --git a/server/routers/auth/resetPassword.ts b/server/routers/auth/resetPassword.ts index 020fabf0..366d8d26 100644 --- a/server/routers/auth/resetPassword.ts +++ b/server/routers/auth/resetPassword.ts @@ -8,7 +8,7 @@ import { db } from "@server/db"; import { passwordResetTokens, users } from "@server/db/schema"; import { eq } from "drizzle-orm"; import { sha256 } from "oslo/crypto"; -import { hashPassword } from "@server/auth/password"; +import { hashPassword, verifyPassword } from "@server/auth/password"; import { verifyTotpCode } from "@server/auth/2fa"; import { passwordSchema } from "@server/auth/passwordSchema"; import { encodeHex } from "oslo/encoding"; @@ -18,9 +18,10 @@ import logger from "@server/logger"; export const resetPasswordBody = z .object({ - token: z.string(), + email: z.string().email(), + token: z.string(), // reset secret code newPassword: passwordSchema, - code: z.string().optional() + code: z.string().optional() // 2fa code }) .strict(); @@ -46,27 +47,28 @@ export async function resetPassword( ); } - const { token, newPassword, code } = parsedBody.data; + const { token, newPassword, code, email } = parsedBody.data; try { - const tokenHash = encodeHex( - await sha256(new TextEncoder().encode(token)) - ); - const resetRequest = await db .select() .from(passwordResetTokens) - .where(eq(passwordResetTokens.tokenHash, tokenHash)); + .where(eq(passwordResetTokens.email, email)); - if ( - !resetRequest || - !resetRequest.length || - !isWithinExpirationDate(new Date(resetRequest[0].expiresAt)) - ) { + if (!resetRequest || !resetRequest.length) { return next( createHttpError( HttpCode.BAD_REQUEST, - "Invalid or expired password reset token" + "Invalid password reset token" + ) + ); + } + + if (!isWithinExpirationDate(new Date(resetRequest[0].expiresAt))) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Password reset token has expired" ) ); } @@ -112,6 +114,20 @@ export async function resetPassword( } } + const isTokenValid = await verifyPassword( + token, + resetRequest[0].tokenHash + ); + + if (!isTokenValid) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Invalid password reset token" + ) + ); + } + const passwordHash = await hashPassword(newPassword); await invalidateAllSessions(resetRequest[0].userId); @@ -123,7 +139,7 @@ export async function resetPassword( await db .delete(passwordResetTokens) - .where(eq(passwordResetTokens.tokenHash, tokenHash)); + .where(eq(passwordResetTokens.email, email)); // TODO: send email to user confirming password reset diff --git a/server/routers/auth/signup.ts b/server/routers/auth/signup.ts index 8f332c2c..0dbf2f4a 100644 --- a/server/routers/auth/signup.ts +++ b/server/routers/auth/signup.ts @@ -21,6 +21,7 @@ import { import { ActionsEnum } from "@server/auth/actions"; import config from "@server/config"; import logger from "@server/logger"; +import { hashPassword } from "@server/auth/password"; export const signupBodySchema = z.object({ email: z.string().email(), @@ -51,12 +52,7 @@ export async function signup( const { email, password } = parsedBody.data; - const passwordHash = await hash(password, { - memoryCost: 19456, - timeCost: 2, - outputLen: 32, - parallelism: 1, - }); + const passwordHash = await hashPassword(password); const userId = generateId(15); try { diff --git a/server/routers/newt/createNewt.ts b/server/routers/newt/createNewt.ts index 1d2dac04..4dbeb1e9 100644 --- a/server/routers/newt/createNewt.ts +++ b/server/routers/newt/createNewt.ts @@ -11,6 +11,7 @@ import moment from "moment"; import { generateSessionToken } from "@server/auth"; import { createNewtSession } from "@server/auth/newt"; import { fromError } from "zod-validation-error"; +import { hashPassword } from "@server/auth/password"; export const createNewtBodySchema = z.object({}); @@ -54,13 +55,7 @@ export async function createNewt( ); } - // generate a newtId and secret - const secretHash = await hash(secret, { - memoryCost: 19456, - timeCost: 2, - outputLen: 32, - parallelism: 1, - }); + const secretHash = await hashPassword(secret); await db.insert(newts).values({ newtId: newtId, @@ -99,7 +94,7 @@ export async function createNewt( ); } else { console.error(e); - + return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, diff --git a/server/routers/newt/getToken.ts b/server/routers/newt/getToken.ts index 259409c0..6c02b615 100644 --- a/server/routers/newt/getToken.ts +++ b/server/routers/newt/getToken.ts @@ -2,7 +2,7 @@ import { verify } from "@node-rs/argon2"; import { createSession, generateSessionToken, - verifySession, + verifySession } from "@server/auth"; import db from "@server/db"; import { newts } from "@server/db/schema"; @@ -14,11 +14,12 @@ import createHttpError from "http-errors"; import { z } from "zod"; import { fromError } from "zod-validation-error"; import { createNewtSession, validateNewtSessionToken } from "@server/auth/newt"; +import { verifyPassword } from "@server/auth/password"; export const newtGetTokenBodySchema = z.object({ newtId: z.string(), secret: z.string(), - token: z.string().optional(), + token: z.string().optional() }); export type NewtGetTokenBody = z.infer; @@ -43,16 +44,14 @@ export async function getToken( try { if (token) { - const { session, newt } = await validateNewtSessionToken( - token - ); + const { session, newt } = await validateNewtSessionToken(token); if (session) { return response(res, { data: null, success: true, error: false, message: "Token session already valid", - status: HttpCode.OK, + status: HttpCode.OK }); } } @@ -72,22 +71,13 @@ export async function getToken( const existingNewt = existingNewtRes[0]; - const validSecret = await verify( - existingNewt.secretHash, + const validSecret = await verifyPassword( secret, - { - memoryCost: 19456, - timeCost: 2, - outputLen: 32, - parallelism: 1, - } + existingNewt.secretHash ); if (!validSecret) { return next( - createHttpError( - HttpCode.BAD_REQUEST, - "Secret is incorrect" - ) + createHttpError(HttpCode.BAD_REQUEST, "Secret is incorrect") ); } @@ -101,7 +91,7 @@ export async function getToken( success: true, error: false, message: "Token created successfully", - status: HttpCode.OK, + status: HttpCode.OK }); } catch (e) { console.error(e); diff --git a/server/routers/resource/authWithAccessToken.ts b/server/routers/resource/authWithAccessToken.ts index 0ca1084c..997f9380 100644 --- a/server/routers/resource/authWithAccessToken.ts +++ b/server/routers/resource/authWithAccessToken.ts @@ -16,6 +16,7 @@ import config from "@server/config"; import logger from "@server/logger"; import { verify } from "@node-rs/argon2"; import { isWithinExpirationDate } from "oslo"; +import { verifyPassword } from "@server/auth/password"; const authWithAccessTokenBodySchema = z .object({ @@ -104,12 +105,8 @@ export async function authWithAccessToken( ); } - const validCode = await verify(tokenItem.tokenHash, accessToken, { - memoryCost: 19456, - timeCost: 2, - outputLen: 32, - parallelism: 1 - }); + const validCode = await verifyPassword(tokenItem.tokenHash, accessToken); + if (!validCode) { return next( createHttpError(HttpCode.UNAUTHORIZED, "Invalid access token") diff --git a/server/routers/resource/authWithPassword.ts b/server/routers/resource/authWithPassword.ts index ba1c73f9..47c8c050 100644 --- a/server/routers/resource/authWithPassword.ts +++ b/server/routers/resource/authWithPassword.ts @@ -15,6 +15,7 @@ import { } from "@server/auth/resource"; import config from "@server/config"; import logger from "@server/logger"; +import { verifyPassword } from "@server/auth/password"; export const authWithPasswordBodySchema = z .object({ @@ -105,15 +106,9 @@ export async function authWithPassword( ); } - const validPassword = await verify( - definedPassword.passwordHash, + const validPassword = await verifyPassword( password, - { - memoryCost: 19456, - timeCost: 2, - outputLen: 32, - parallelism: 1 - } + definedPassword.passwordHash ); if (!validPassword) { return next( diff --git a/server/routers/resource/authWithPincode.ts b/server/routers/resource/authWithPincode.ts index f57629c6..773049a3 100644 --- a/server/routers/resource/authWithPincode.ts +++ b/server/routers/resource/authWithPincode.ts @@ -23,6 +23,7 @@ import logger from "@server/logger"; import config from "@server/config"; import { AuthWithPasswordResponse } from "./authWithPassword"; import { isValidOtp, sendResourceOtpEmail } from "@server/auth/resourceOtp"; +import { verifyPassword } from "@server/auth/password"; export const authWithPincodeBodySchema = z .object({ @@ -116,12 +117,10 @@ export async function authWithPincode( ); } - const validPincode = await verify(definedPincode.pincodeHash, pincode, { - memoryCost: 19456, - timeCost: 2, - outputLen: 32, - parallelism: 1 - }); + const validPincode = verifyPassword( + pincode, + definedPincode.pincodeHash + ); if (!validPincode) { return next( createHttpError(HttpCode.UNAUTHORIZED, "Incorrect PIN") diff --git a/server/routers/resource/setResourcePassword.ts b/server/routers/resource/setResourcePassword.ts index 81a4e1e3..f6fa6322 100644 --- a/server/routers/resource/setResourcePassword.ts +++ b/server/routers/resource/setResourcePassword.ts @@ -9,6 +9,7 @@ import { fromError } from "zod-validation-error"; import { hash } from "@node-rs/argon2"; import { response } from "@server/utils"; import logger from "@server/logger"; +import { hashPassword } from "@server/auth/password"; const setResourceAuthMethodsParamsSchema = z.object({ resourceId: z.string().transform(Number).pipe(z.number().int().positive()) @@ -57,12 +58,7 @@ export async function setResourcePassword( .where(eq(resourcePassword.resourceId, resourceId)); if (password) { - const passwordHash = await hash(password, { - memoryCost: 19456, - timeCost: 2, - outputLen: 32, - parallelism: 1 - }); + const passwordHash = await hashPassword(password); await trx .insert(resourcePassword) diff --git a/server/routers/resource/setResourcePincode.ts b/server/routers/resource/setResourcePincode.ts index da64a96c..f1b58469 100644 --- a/server/routers/resource/setResourcePincode.ts +++ b/server/routers/resource/setResourcePincode.ts @@ -10,6 +10,7 @@ import { hash } from "@node-rs/argon2"; import { response } from "@server/utils"; import stoi from "@server/utils/stoi"; import logger from "@server/logger"; +import { hashPassword } from "@server/auth/password"; const setResourceAuthMethodsParamsSchema = z.object({ resourceId: z.string().transform(Number).pipe(z.number().int().positive()), @@ -61,12 +62,7 @@ export async function setResourcePincode( .where(eq(resourcePincode.resourceId, resourceId)); if (pincode) { - const pincodeHash = await hash(pincode, { - memoryCost: 19456, - timeCost: 2, - outputLen: 32, - parallelism: 1, - }); + const pincodeHash = await hashPassword(pincode); await trx .insert(resourcePincode) diff --git a/server/routers/site/createSite.ts b/server/routers/site/createSite.ts index 99a74bee..458e0f63 100644 --- a/server/routers/site/createSite.ts +++ b/server/routers/site/createSite.ts @@ -13,6 +13,7 @@ import { fromError } from "zod-validation-error"; import { hash } from "@node-rs/argon2"; import { newts } from "@server/db/schema"; import moment from "moment"; +import { hashPassword } from "@server/auth/password"; const createSiteParamsSchema = z .object({ @@ -122,12 +123,7 @@ export async function createSite( // add the peer to the exit node if (type == "newt") { - const secretHash = await hash(secret!, { - memoryCost: 19456, - timeCost: 2, - outputLen: 32, - parallelism: 1 - }); + const secretHash = await hashPassword(secret!); await db.insert(newts).values({ newtId: newtId!, diff --git a/server/routers/user/acceptInvite.ts b/server/routers/user/acceptInvite.ts index 172fa464..3c3b720b 100644 --- a/server/routers/user/acceptInvite.ts +++ b/server/routers/user/acceptInvite.ts @@ -10,6 +10,7 @@ import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { isWithinExpirationDate } from "oslo"; +import { verifyPassword } from "@server/auth/password"; const acceptInviteBodySchema = z .object({ @@ -62,12 +63,10 @@ export async function acceptInvite( ); } - const validToken = await verify(existingInvite[0].tokenHash, token, { - memoryCost: 19456, - timeCost: 2, - outputLen: 32, - parallelism: 1 - }); + const validToken = await verifyPassword( + token, + existingInvite[0].tokenHash + ); if (!validToken) { return next( createHttpError( diff --git a/src/app/auth/reset-password/ResetPasswordForm.tsx b/src/app/auth/reset-password/ResetPasswordForm.tsx new file mode 100644 index 00000000..44da8487 --- /dev/null +++ b/src/app/auth/reset-password/ResetPasswordForm.tsx @@ -0,0 +1,470 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle +} from "@/components/ui/card"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { + InputOTP, + InputOTPGroup, + InputOTPSeparator, + InputOTPSlot +} from "@/components/ui/input-otp"; +import { AxiosResponse } from "axios"; +import { + RequestPasswordResetBody, + RequestPasswordResetResponse, + resetPasswordBody, + ResetPasswordBody, + ResetPasswordResponse +} from "@server/routers/auth"; +import { Loader2 } from "lucide-react"; +import { Alert, AlertDescription } from "../../../components/ui/alert"; +import { useToast } from "@app/hooks/useToast"; +import { useRouter } from "next/navigation"; +import { formatAxiosError } from "@app/lib/utils"; +import { createApiClient } from "@app/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { passwordSchema } from "@server/auth/passwordSchema"; +import { get } from "http"; + +const requestSchema = z.object({ + email: z.string().email() +}); + +const formSchema = z + .object({ + email: z.string().email({ message: "Invalid email address" }), + token: z.string().min(8, { message: "Invalid token" }), + password: passwordSchema, + confirmPassword: passwordSchema + }) + .refine((data) => data.password === data.confirmPassword, { + path: ["confirmPassword"], + message: "Passwords do not match" + }); + +const mfaSchema = z.object({ + code: z.string().length(6, { message: "Invalid code" }) +}); + +export type ResetPasswordFormProps = { + emailParam?: string; + tokenParam?: string; + redirect?: string; +}; + +export default function ResetPasswordForm({ + emailParam, + tokenParam, + redirect +}: ResetPasswordFormProps) { + const router = useRouter(); + + const [error, setError] = useState(null); + const [successMessage, setSuccessMessage] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + + function getState() { + if (emailParam && !tokenParam) { + return "request"; + } + + if (emailParam && tokenParam) { + return "reset"; + } + + return "request"; + } + + const [state, setState] = useState<"request" | "reset" | "mfa">(getState()); + + const { toast } = useToast(); + + const api = createApiClient(useEnvContext()); + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + email: emailParam || "", + token: tokenParam || "", + password: "", + confirmPassword: "" + } + }); + + const mfaForm = useForm>({ + resolver: zodResolver(mfaSchema), + defaultValues: { + code: "" + } + }); + + const requestForm = useForm>({ + resolver: zodResolver(requestSchema), + defaultValues: { + email: emailParam || "" + } + }); + + async function onRequest(data: z.infer) { + const { email } = data; + + setIsSubmitting(true); + + const res = await api + .post>( + "/auth/reset-password/request", + { + email + } as RequestPasswordResetBody + ) + .catch((e) => { + setError(formatAxiosError(e, "An error occurred")); + console.error("Failed to request reset:", e); + setIsSubmitting(false); + }); + + if (res && res.data?.data) { + setError(null); + setState("reset"); + setIsSubmitting(false); + form.setValue("email", email); + } + } + + async function onReset(data: any) { + setIsSubmitting(true); + + const { password, email, token } = form.getValues(); + const { code } = mfaForm.getValues(); + + const res = await api + .post>( + "/auth/reset-password", + { + email, + token, + newPassword: password, + code + } as ResetPasswordBody + ) + .catch((e) => { + setError(formatAxiosError(e, "An error occurred")); + console.error("Failed to reset password:", e); + setIsSubmitting(false); + }); + + console.log(res); + + if (res) { + setError(null); + + if (res.data.data?.codeRequested) { + setState("mfa"); + setIsSubmitting(false); + mfaForm.reset(); + return; + } + + setSuccessMessage("Password reset successfully! Back to login..."); + + setTimeout(() => { + if (redirect && redirect.includes("http")) { + window.location.href = redirect; + } + if (redirect) { + router.push(redirect); + } else { + router.push("/login"); + } + setIsSubmitting(false); + }, 1500); + } + } + + return ( +
+ + + Reset Password + + Follow the steps to reset your password + + + +
+ {state === "request" && ( +
+ + ( + + Email + + + + + We'll send a password reset + code to this email address. + + + + )} + /> + + + )} + + {state === "reset" && ( +
+ + ( + + Email + + + + + + )} + /> + + {!tokenParam && ( + ( + + + Reset Code + + + + + + + )} + /> + )} + + ( + + + New Password + + + + + + + )} + /> + ( + + + Confirm New Password + + + + + + + )} + /> + + + )} + + {state === "mfa" && ( +
+ + ( + + + Authenticator Code + + +
+ + + + + + + + + + + + + +
+
+ +
+ )} + /> + + + )} + + {error && ( + + {error} + + )} + + {successMessage && ( + + + {successMessage} + + + )} + +
+ {(state === "reset" || state === "mfa") && ( + + )} + + {state === "request" && ( + + )} + + {state === "mfa" && ( + + )} + + {(state === "mfa" || state === "reset") && ( + + )} +
+
+
+
+
+ ); +} diff --git a/src/app/auth/reset-password/page.tsx b/src/app/auth/reset-password/page.tsx new file mode 100644 index 00000000..c893a61c --- /dev/null +++ b/src/app/auth/reset-password/page.tsx @@ -0,0 +1,32 @@ +import { verifySession } from "@app/lib/auth/verifySession"; +import { redirect } from "next/navigation"; +import { cache } from "react"; +import ResetPasswordForm from "./ResetPasswordForm"; + +export const dynamic = "force-dynamic"; + +export default async function Page(props: { + searchParams: Promise<{ + redirect: string | undefined; + email: string | undefined; + token: string | undefined; + }>; +}) { + const searchParams = await props.searchParams; + const getUser = cache(verifySession); + const user = await getUser(); + + if (user) { + redirect("/"); + } + + return ( + <> + + + ); +} diff --git a/src/components/LoginForm.tsx b/src/components/LoginForm.tsx index 8d8e2e62..3f3b9b5f 100644 --- a/src/components/LoginForm.tsx +++ b/src/components/LoginForm.tsx @@ -35,6 +35,7 @@ import { InputOTPSeparator, InputOTPSlot } from "./ui/input-otp"; +import Link from "next/link"; type LoginFormProps = { redirect?: string; @@ -79,7 +80,7 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) { async function onSubmit(values: any) { const { email, password } = form.getValues(); - const { code } = mfaForm.getValues() + const { code } = mfaForm.getValues(); setLoading(true); @@ -151,23 +152,36 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) { )} /> - ( - - Password - - - - - - )} - /> + +
+ ( + + Password + + + + + + )} + /> + +
+ + Forgot password? Click here + +
+
+ {error && ( {error}