reset password flow
This commit is contained in:
parent
9c37036a39
commit
f224bfa4ee
22 changed files with 739 additions and 184 deletions
|
@ -4,11 +4,12 @@ import { twoFactorBackupCodes } from "@server/db/schema";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { decodeHex } from "oslo/encoding";
|
import { decodeHex } from "oslo/encoding";
|
||||||
import { TOTPController } from "oslo/otp";
|
import { TOTPController } from "oslo/otp";
|
||||||
|
import { verifyPassword } from "./password";
|
||||||
|
|
||||||
export async function verifyTotpCode(
|
export async function verifyTotpCode(
|
||||||
code: string,
|
code: string,
|
||||||
secret: string,
|
secret: string,
|
||||||
userId: string,
|
userId: string
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
if (code.length !== 6) {
|
if (code.length !== 6) {
|
||||||
const validBackupCode = await verifyBackUpCode(code, userId);
|
const validBackupCode = await verifyBackUpCode(code, userId);
|
||||||
|
@ -16,7 +17,7 @@ export async function verifyTotpCode(
|
||||||
} else {
|
} else {
|
||||||
const validOTP = await new TOTPController().verify(
|
const validOTP = await new TOTPController().verify(
|
||||||
code,
|
code,
|
||||||
decodeHex(secret),
|
decodeHex(secret)
|
||||||
);
|
);
|
||||||
|
|
||||||
return validOTP;
|
return validOTP;
|
||||||
|
@ -25,7 +26,7 @@ export async function verifyTotpCode(
|
||||||
|
|
||||||
export async function verifyBackUpCode(
|
export async function verifyBackUpCode(
|
||||||
code: string,
|
code: string,
|
||||||
userId: string,
|
userId: string
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const allHashed = await db
|
const allHashed = await db
|
||||||
.select()
|
.select()
|
||||||
|
@ -38,12 +39,7 @@ export async function verifyBackUpCode(
|
||||||
|
|
||||||
let validId;
|
let validId;
|
||||||
for (const hashedCode of allHashed) {
|
for (const hashedCode of allHashed) {
|
||||||
const validCode = await verify(hashedCode.codeHash, code, {
|
const validCode = await verifyPassword(code, hashedCode.codeHash);
|
||||||
memoryCost: 19456,
|
|
||||||
timeCost: 2,
|
|
||||||
outputLen: 32,
|
|
||||||
parallelism: 1,
|
|
||||||
});
|
|
||||||
if (validCode) {
|
if (validCode) {
|
||||||
validId = hashedCode.codeId;
|
validId = hashedCode.codeId;
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,6 +8,7 @@ import { sendEmail } from "@server/emails";
|
||||||
import ResourceOTPCode from "@server/emails/templates/ResourceOTPCode";
|
import ResourceOTPCode from "@server/emails/templates/ResourceOTPCode";
|
||||||
import config from "@server/config";
|
import config from "@server/config";
|
||||||
import { hash, verify } from "@node-rs/argon2";
|
import { hash, verify } from "@node-rs/argon2";
|
||||||
|
import { hashPassword } from "./password";
|
||||||
|
|
||||||
export async function sendResourceOtpEmail(
|
export async function sendResourceOtpEmail(
|
||||||
email: string,
|
email: string,
|
||||||
|
@ -47,12 +48,7 @@ export async function generateResourceOtpCode(
|
||||||
|
|
||||||
const otp = generateRandomString(8, alphabet("0-9", "A-Z", "a-z"));
|
const otp = generateRandomString(8, alphabet("0-9", "A-Z", "a-z"));
|
||||||
|
|
||||||
const otpHash = await hash(otp, {
|
const otpHash = await hashPassword(otp);
|
||||||
memoryCost: 19456,
|
|
||||||
timeCost: 2,
|
|
||||||
outputLen: 32,
|
|
||||||
parallelism: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
await db.insert(resourceOtp).values({
|
await db.insert(resourceOtp).values({
|
||||||
resourceId,
|
resourceId,
|
||||||
|
@ -84,12 +80,7 @@ export async function isValidOtp(
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const validCode = await verify(record[0].otpHash, otp, {
|
const validCode = await verifyPassword(otp, record[0].otpHash);
|
||||||
memoryCost: 19456,
|
|
||||||
timeCost: 2,
|
|
||||||
outputLen: 32,
|
|
||||||
parallelism: 1
|
|
||||||
});
|
|
||||||
if (!validCode) {
|
if (!validCode) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
|
@ -150,6 +150,7 @@ export const emailVerificationCodes = sqliteTable("emailVerificationCodes", {
|
||||||
|
|
||||||
export const passwordResetTokens = sqliteTable("passwordResetTokens", {
|
export const passwordResetTokens = sqliteTable("passwordResetTokens", {
|
||||||
tokenId: integer("id").primaryKey({ autoIncrement: true }),
|
tokenId: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
|
email: text("email").notNull(),
|
||||||
userId: text("userId")
|
userId: text("userId")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => users.userId, { onDelete: "cascade" }),
|
.references(() => users.userId, { onDelete: "cascade" }),
|
||||||
|
|
70
server/emails/templates/ResetPasswordCode.tsx
Normal file
70
server/emails/templates/ResetPasswordCode.tsx
Normal file
|
@ -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 (
|
||||||
|
<Html>
|
||||||
|
<Head />
|
||||||
|
<Preview>{previewText}</Preview>
|
||||||
|
<Tailwind
|
||||||
|
config={{
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
primary: "#F97317"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Body className="font-sans">
|
||||||
|
<Container className="bg-white border border-solid border-gray-200 p-6 max-w-lg mx-auto my-8 rounded-lg">
|
||||||
|
<Heading className="text-2xl font-semibold text-gray-800 text-center">
|
||||||
|
You've requested to reset your password
|
||||||
|
</Heading>
|
||||||
|
<Text className="text-base text-gray-700 mt-4">
|
||||||
|
Hi {email || "there"},
|
||||||
|
</Text>
|
||||||
|
<Text className="text-base text-gray-700 mt-2">
|
||||||
|
You’ve requested to reset your password. Please{" "}
|
||||||
|
<a href={link} className="text-primary">
|
||||||
|
click here
|
||||||
|
</a>{" "}
|
||||||
|
and follow the instructions to reset your
|
||||||
|
password, or manually enter the following code:
|
||||||
|
</Text>
|
||||||
|
<Section className="text-center my-6">
|
||||||
|
<Text className="inline-block bg-primary text-xl font-bold text-white py-2 px-4 border border-gray-300 rounded-xl">
|
||||||
|
{code}
|
||||||
|
</Text>
|
||||||
|
</Section>
|
||||||
|
<Text className="text-base text-gray-700 mt-2">
|
||||||
|
If you didn’t request this, you can safely ignore
|
||||||
|
this email.
|
||||||
|
</Text>
|
||||||
|
</Container>
|
||||||
|
</Body>
|
||||||
|
</Tailwind>
|
||||||
|
</Html>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ResetPasswordCode;
|
|
@ -19,6 +19,7 @@ import { z } from "zod";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { createDate, TimeSpan } from "oslo";
|
import { createDate, TimeSpan } from "oslo";
|
||||||
|
import { hashPassword } from "@server/auth/password";
|
||||||
|
|
||||||
export const generateAccessTokenBodySchema = z
|
export const generateAccessTokenBodySchema = z
|
||||||
.object({
|
.object({
|
||||||
|
@ -91,12 +92,7 @@ export async function generateAccessToken(
|
||||||
|
|
||||||
const token = generateIdFromEntropySize(25);
|
const token = generateIdFromEntropySize(25);
|
||||||
|
|
||||||
const tokenHash = await hash(token, {
|
const tokenHash = await hashPassword(token);
|
||||||
memoryCost: 19456,
|
|
||||||
timeCost: 2,
|
|
||||||
outputLen: 32,
|
|
||||||
parallelism: 1
|
|
||||||
});
|
|
||||||
|
|
||||||
const id = generateId(15);
|
const id = generateId(15);
|
||||||
const [result] = await db
|
const [result] = await db
|
||||||
|
|
|
@ -3,7 +3,7 @@ import {
|
||||||
createSession,
|
createSession,
|
||||||
generateSessionToken,
|
generateSessionToken,
|
||||||
serializeSessionCookie,
|
serializeSessionCookie,
|
||||||
verifySession,
|
verifySession
|
||||||
} from "@server/auth";
|
} from "@server/auth";
|
||||||
import db from "@server/db";
|
import db from "@server/db";
|
||||||
import { users } from "@server/db/schema";
|
import { users } from "@server/db/schema";
|
||||||
|
@ -17,12 +17,15 @@ import { fromError } from "zod-validation-error";
|
||||||
import { verifyTotpCode } from "@server/auth/2fa";
|
import { verifyTotpCode } from "@server/auth/2fa";
|
||||||
import config from "@server/config";
|
import config from "@server/config";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
|
import { verifyPassword } from "@server/auth/password";
|
||||||
|
|
||||||
export const loginBodySchema = z.object({
|
export const loginBodySchema = z
|
||||||
email: z.string().email(),
|
.object({
|
||||||
password: z.string(),
|
email: z.string().email(),
|
||||||
code: z.string().optional(),
|
password: z.string(),
|
||||||
}).strict();
|
code: z.string().optional()
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
export type LoginBody = z.infer<typeof loginBodySchema>;
|
export type LoginBody = z.infer<typeof loginBodySchema>;
|
||||||
|
|
||||||
|
@ -57,7 +60,7 @@ export async function login(
|
||||||
success: true,
|
success: true,
|
||||||
error: false,
|
error: false,
|
||||||
message: "Already logged in",
|
message: "Already logged in",
|
||||||
status: HttpCode.OK,
|
status: HttpCode.OK
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -76,15 +79,9 @@ export async function login(
|
||||||
|
|
||||||
const existingUser = existingUserRes[0];
|
const existingUser = existingUserRes[0];
|
||||||
|
|
||||||
const validPassword = await verify(
|
const validPassword = await verifyPassword(
|
||||||
existingUser.passwordHash,
|
|
||||||
password,
|
password,
|
||||||
{
|
existingUser.passwordHash
|
||||||
memoryCost: 19456,
|
|
||||||
timeCost: 2,
|
|
||||||
outputLen: 32,
|
|
||||||
parallelism: 1,
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
if (!validPassword) {
|
if (!validPassword) {
|
||||||
return next(
|
return next(
|
||||||
|
@ -102,7 +99,7 @@ export async function login(
|
||||||
success: true,
|
success: true,
|
||||||
error: false,
|
error: false,
|
||||||
message: "Two-factor authentication required",
|
message: "Two-factor authentication required",
|
||||||
status: HttpCode.ACCEPTED,
|
status: HttpCode.ACCEPTED
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -137,7 +134,7 @@ export async function login(
|
||||||
success: true,
|
success: true,
|
||||||
error: false,
|
error: false,
|
||||||
message: "Email verification code sent",
|
message: "Email verification code sent",
|
||||||
status: HttpCode.OK,
|
status: HttpCode.OK
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -146,7 +143,7 @@ export async function login(
|
||||||
success: true,
|
success: true,
|
||||||
error: false,
|
error: false,
|
||||||
message: "Logged in successfully",
|
message: "Logged in successfully",
|
||||||
status: HttpCode.OK,
|
status: HttpCode.OK
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(e);
|
logger.error(e);
|
||||||
|
|
|
@ -7,16 +7,22 @@ import { response } from "@server/utils";
|
||||||
import { db } from "@server/db";
|
import { db } from "@server/db";
|
||||||
import { passwordResetTokens, users } from "@server/db/schema";
|
import { passwordResetTokens, users } from "@server/db/schema";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { sha256 } from "oslo/crypto";
|
import { alphabet, generateRandomString, sha256 } from "oslo/crypto";
|
||||||
import { encodeHex } from "oslo/encoding";
|
import { encodeHex } from "oslo/encoding";
|
||||||
import { createDate } from "oslo";
|
import { createDate } from "oslo";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { generateIdFromEntropySize } from "@server/auth";
|
import { generateIdFromEntropySize } from "@server/auth";
|
||||||
import { TimeSpan } from "oslo";
|
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({
|
export const requestPasswordResetBody = z
|
||||||
email: z.string().email(),
|
.object({
|
||||||
}).strict();
|
email: z.string().email()
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
export type RequestPasswordResetBody = z.infer<typeof requestPasswordResetBody>;
|
export type RequestPasswordResetBody = z.infer<typeof requestPasswordResetBody>;
|
||||||
|
|
||||||
|
@ -27,7 +33,7 @@ export type RequestPasswordResetResponse = {
|
||||||
export async function requestPasswordReset(
|
export async function requestPasswordReset(
|
||||||
req: Request,
|
req: Request,
|
||||||
res: Response,
|
res: Response,
|
||||||
next: NextFunction,
|
next: NextFunction
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
const parsedBody = requestPasswordResetBody.safeParse(req.body);
|
const parsedBody = requestPasswordResetBody.safeParse(req.body);
|
||||||
|
|
||||||
|
@ -35,8 +41,8 @@ export async function requestPasswordReset(
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.BAD_REQUEST,
|
HttpCode.BAD_REQUEST,
|
||||||
fromError(parsedBody.error).toString(),
|
fromError(parsedBody.error).toString()
|
||||||
),
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -52,8 +58,8 @@ export async function requestPasswordReset(
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.BAD_REQUEST,
|
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)
|
.delete(passwordResetTokens)
|
||||||
.where(eq(passwordResetTokens.userId, existingUser[0].userId));
|
.where(eq(passwordResetTokens.userId, existingUser[0].userId));
|
||||||
|
|
||||||
const token = generateIdFromEntropySize(25);
|
const token = generateRandomString(8, alphabet("0-9", "A-Z", "a-z"));
|
||||||
const tokenHash = encodeHex(
|
const tokenHash = await hashPassword(token);
|
||||||
await sha256(new TextEncoder().encode(token)),
|
|
||||||
);
|
|
||||||
|
|
||||||
await db.insert(passwordResetTokens).values({
|
await db.insert(passwordResetTokens).values({
|
||||||
userId: existingUser[0].userId,
|
userId: existingUser[0].userId,
|
||||||
|
email: existingUser[0].email,
|
||||||
tokenHash,
|
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
|
const url = `${config.app.base_url}/auth/reset-password?email=${email}&token=${token}`;
|
||||||
// something like: https://example.com/auth/reset-password?email=${email}&?token=${token}
|
|
||||||
// for now, just log the token
|
await sendEmail(
|
||||||
|
ResetPasswordCode({
|
||||||
|
email,
|
||||||
|
code: token,
|
||||||
|
link: url
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
from: config.email?.no_reply,
|
||||||
|
to: email,
|
||||||
|
subject: "Reset your password"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
return response<RequestPasswordResetResponse>(res, {
|
return response<RequestPasswordResetResponse>(res, {
|
||||||
data: {
|
data: {
|
||||||
sentEmail: true,
|
sentEmail: true
|
||||||
},
|
},
|
||||||
success: true,
|
success: true,
|
||||||
error: false,
|
error: false,
|
||||||
message: "Password reset email sent",
|
message: "Password reset requested",
|
||||||
status: HttpCode.OK,
|
status: HttpCode.OK
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(e);
|
logger.error(e);
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.INTERNAL_SERVER_ERROR,
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
"Failed to process password reset request",
|
"Failed to process password reset request"
|
||||||
),
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,6 +13,7 @@ import { verify } from "@node-rs/argon2";
|
||||||
import { createTOTPKeyURI } from "oslo/otp";
|
import { createTOTPKeyURI } from "oslo/otp";
|
||||||
import config from "@server/config";
|
import config from "@server/config";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
|
import { verifyPassword } from "@server/auth/password";
|
||||||
|
|
||||||
export const requestTotpSecretBody = z
|
export const requestTotpSecretBody = z
|
||||||
.object({
|
.object({
|
||||||
|
@ -47,12 +48,7 @@ export async function requestTotpSecret(
|
||||||
const user = req.user as User;
|
const user = req.user as User;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const validPassword = await verify(user.passwordHash, password, {
|
const validPassword = await verifyPassword(password, user.passwordHash);
|
||||||
memoryCost: 19456,
|
|
||||||
timeCost: 2,
|
|
||||||
outputLen: 32,
|
|
||||||
parallelism: 1
|
|
||||||
});
|
|
||||||
if (!validPassword) {
|
if (!validPassword) {
|
||||||
return next(unauthorized());
|
return next(unauthorized());
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,7 +8,7 @@ import { db } from "@server/db";
|
||||||
import { passwordResetTokens, users } from "@server/db/schema";
|
import { passwordResetTokens, users } from "@server/db/schema";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { sha256 } from "oslo/crypto";
|
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 { verifyTotpCode } from "@server/auth/2fa";
|
||||||
import { passwordSchema } from "@server/auth/passwordSchema";
|
import { passwordSchema } from "@server/auth/passwordSchema";
|
||||||
import { encodeHex } from "oslo/encoding";
|
import { encodeHex } from "oslo/encoding";
|
||||||
|
@ -18,9 +18,10 @@ import logger from "@server/logger";
|
||||||
|
|
||||||
export const resetPasswordBody = z
|
export const resetPasswordBody = z
|
||||||
.object({
|
.object({
|
||||||
token: z.string(),
|
email: z.string().email(),
|
||||||
|
token: z.string(), // reset secret code
|
||||||
newPassword: passwordSchema,
|
newPassword: passwordSchema,
|
||||||
code: z.string().optional()
|
code: z.string().optional() // 2fa code
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
|
@ -46,27 +47,28 @@ export async function resetPassword(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { token, newPassword, code } = parsedBody.data;
|
const { token, newPassword, code, email } = parsedBody.data;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const tokenHash = encodeHex(
|
|
||||||
await sha256(new TextEncoder().encode(token))
|
|
||||||
);
|
|
||||||
|
|
||||||
const resetRequest = await db
|
const resetRequest = await db
|
||||||
.select()
|
.select()
|
||||||
.from(passwordResetTokens)
|
.from(passwordResetTokens)
|
||||||
.where(eq(passwordResetTokens.tokenHash, tokenHash));
|
.where(eq(passwordResetTokens.email, email));
|
||||||
|
|
||||||
if (
|
if (!resetRequest || !resetRequest.length) {
|
||||||
!resetRequest ||
|
|
||||||
!resetRequest.length ||
|
|
||||||
!isWithinExpirationDate(new Date(resetRequest[0].expiresAt))
|
|
||||||
) {
|
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.BAD_REQUEST,
|
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);
|
const passwordHash = await hashPassword(newPassword);
|
||||||
|
|
||||||
await invalidateAllSessions(resetRequest[0].userId);
|
await invalidateAllSessions(resetRequest[0].userId);
|
||||||
|
@ -123,7 +139,7 @@ export async function resetPassword(
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.delete(passwordResetTokens)
|
.delete(passwordResetTokens)
|
||||||
.where(eq(passwordResetTokens.tokenHash, tokenHash));
|
.where(eq(passwordResetTokens.email, email));
|
||||||
|
|
||||||
// TODO: send email to user confirming password reset
|
// TODO: send email to user confirming password reset
|
||||||
|
|
||||||
|
|
|
@ -21,6 +21,7 @@ import {
|
||||||
import { ActionsEnum } from "@server/auth/actions";
|
import { ActionsEnum } from "@server/auth/actions";
|
||||||
import config from "@server/config";
|
import config from "@server/config";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
|
import { hashPassword } from "@server/auth/password";
|
||||||
|
|
||||||
export const signupBodySchema = z.object({
|
export const signupBodySchema = z.object({
|
||||||
email: z.string().email(),
|
email: z.string().email(),
|
||||||
|
@ -51,12 +52,7 @@ export async function signup(
|
||||||
|
|
||||||
const { email, password } = parsedBody.data;
|
const { email, password } = parsedBody.data;
|
||||||
|
|
||||||
const passwordHash = await hash(password, {
|
const passwordHash = await hashPassword(password);
|
||||||
memoryCost: 19456,
|
|
||||||
timeCost: 2,
|
|
||||||
outputLen: 32,
|
|
||||||
parallelism: 1,
|
|
||||||
});
|
|
||||||
const userId = generateId(15);
|
const userId = generateId(15);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
|
@ -11,6 +11,7 @@ import moment from "moment";
|
||||||
import { generateSessionToken } from "@server/auth";
|
import { generateSessionToken } from "@server/auth";
|
||||||
import { createNewtSession } from "@server/auth/newt";
|
import { createNewtSession } from "@server/auth/newt";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
|
import { hashPassword } from "@server/auth/password";
|
||||||
|
|
||||||
export const createNewtBodySchema = z.object({});
|
export const createNewtBodySchema = z.object({});
|
||||||
|
|
||||||
|
@ -54,13 +55,7 @@ export async function createNewt(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// generate a newtId and secret
|
const secretHash = await hashPassword(secret);
|
||||||
const secretHash = await hash(secret, {
|
|
||||||
memoryCost: 19456,
|
|
||||||
timeCost: 2,
|
|
||||||
outputLen: 32,
|
|
||||||
parallelism: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
await db.insert(newts).values({
|
await db.insert(newts).values({
|
||||||
newtId: newtId,
|
newtId: newtId,
|
||||||
|
|
|
@ -2,7 +2,7 @@ import { verify } from "@node-rs/argon2";
|
||||||
import {
|
import {
|
||||||
createSession,
|
createSession,
|
||||||
generateSessionToken,
|
generateSessionToken,
|
||||||
verifySession,
|
verifySession
|
||||||
} from "@server/auth";
|
} from "@server/auth";
|
||||||
import db from "@server/db";
|
import db from "@server/db";
|
||||||
import { newts } from "@server/db/schema";
|
import { newts } from "@server/db/schema";
|
||||||
|
@ -14,11 +14,12 @@ import createHttpError from "http-errors";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import { createNewtSession, validateNewtSessionToken } from "@server/auth/newt";
|
import { createNewtSession, validateNewtSessionToken } from "@server/auth/newt";
|
||||||
|
import { verifyPassword } from "@server/auth/password";
|
||||||
|
|
||||||
export const newtGetTokenBodySchema = z.object({
|
export const newtGetTokenBodySchema = z.object({
|
||||||
newtId: z.string(),
|
newtId: z.string(),
|
||||||
secret: z.string(),
|
secret: z.string(),
|
||||||
token: z.string().optional(),
|
token: z.string().optional()
|
||||||
});
|
});
|
||||||
|
|
||||||
export type NewtGetTokenBody = z.infer<typeof newtGetTokenBodySchema>;
|
export type NewtGetTokenBody = z.infer<typeof newtGetTokenBodySchema>;
|
||||||
|
@ -43,16 +44,14 @@ export async function getToken(
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (token) {
|
if (token) {
|
||||||
const { session, newt } = await validateNewtSessionToken(
|
const { session, newt } = await validateNewtSessionToken(token);
|
||||||
token
|
|
||||||
);
|
|
||||||
if (session) {
|
if (session) {
|
||||||
return response<null>(res, {
|
return response<null>(res, {
|
||||||
data: null,
|
data: null,
|
||||||
success: true,
|
success: true,
|
||||||
error: false,
|
error: false,
|
||||||
message: "Token session already valid",
|
message: "Token session already valid",
|
||||||
status: HttpCode.OK,
|
status: HttpCode.OK
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -72,22 +71,13 @@ export async function getToken(
|
||||||
|
|
||||||
const existingNewt = existingNewtRes[0];
|
const existingNewt = existingNewtRes[0];
|
||||||
|
|
||||||
const validSecret = await verify(
|
const validSecret = await verifyPassword(
|
||||||
existingNewt.secretHash,
|
|
||||||
secret,
|
secret,
|
||||||
{
|
existingNewt.secretHash
|
||||||
memoryCost: 19456,
|
|
||||||
timeCost: 2,
|
|
||||||
outputLen: 32,
|
|
||||||
parallelism: 1,
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
if (!validSecret) {
|
if (!validSecret) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(HttpCode.BAD_REQUEST, "Secret is incorrect")
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
"Secret is incorrect"
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -101,7 +91,7 @@ export async function getToken(
|
||||||
success: true,
|
success: true,
|
||||||
error: false,
|
error: false,
|
||||||
message: "Token created successfully",
|
message: "Token created successfully",
|
||||||
status: HttpCode.OK,
|
status: HttpCode.OK
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
|
|
|
@ -16,6 +16,7 @@ import config from "@server/config";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { verify } from "@node-rs/argon2";
|
import { verify } from "@node-rs/argon2";
|
||||||
import { isWithinExpirationDate } from "oslo";
|
import { isWithinExpirationDate } from "oslo";
|
||||||
|
import { verifyPassword } from "@server/auth/password";
|
||||||
|
|
||||||
const authWithAccessTokenBodySchema = z
|
const authWithAccessTokenBodySchema = z
|
||||||
.object({
|
.object({
|
||||||
|
@ -104,12 +105,8 @@ export async function authWithAccessToken(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const validCode = await verify(tokenItem.tokenHash, accessToken, {
|
const validCode = await verifyPassword(tokenItem.tokenHash, accessToken);
|
||||||
memoryCost: 19456,
|
|
||||||
timeCost: 2,
|
|
||||||
outputLen: 32,
|
|
||||||
parallelism: 1
|
|
||||||
});
|
|
||||||
if (!validCode) {
|
if (!validCode) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(HttpCode.UNAUTHORIZED, "Invalid access token")
|
createHttpError(HttpCode.UNAUTHORIZED, "Invalid access token")
|
||||||
|
|
|
@ -15,6 +15,7 @@ import {
|
||||||
} from "@server/auth/resource";
|
} from "@server/auth/resource";
|
||||||
import config from "@server/config";
|
import config from "@server/config";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
|
import { verifyPassword } from "@server/auth/password";
|
||||||
|
|
||||||
export const authWithPasswordBodySchema = z
|
export const authWithPasswordBodySchema = z
|
||||||
.object({
|
.object({
|
||||||
|
@ -105,15 +106,9 @@ export async function authWithPassword(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const validPassword = await verify(
|
const validPassword = await verifyPassword(
|
||||||
definedPassword.passwordHash,
|
|
||||||
password,
|
password,
|
||||||
{
|
definedPassword.passwordHash
|
||||||
memoryCost: 19456,
|
|
||||||
timeCost: 2,
|
|
||||||
outputLen: 32,
|
|
||||||
parallelism: 1
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
if (!validPassword) {
|
if (!validPassword) {
|
||||||
return next(
|
return next(
|
||||||
|
|
|
@ -23,6 +23,7 @@ import logger from "@server/logger";
|
||||||
import config from "@server/config";
|
import config from "@server/config";
|
||||||
import { AuthWithPasswordResponse } from "./authWithPassword";
|
import { AuthWithPasswordResponse } from "./authWithPassword";
|
||||||
import { isValidOtp, sendResourceOtpEmail } from "@server/auth/resourceOtp";
|
import { isValidOtp, sendResourceOtpEmail } from "@server/auth/resourceOtp";
|
||||||
|
import { verifyPassword } from "@server/auth/password";
|
||||||
|
|
||||||
export const authWithPincodeBodySchema = z
|
export const authWithPincodeBodySchema = z
|
||||||
.object({
|
.object({
|
||||||
|
@ -116,12 +117,10 @@ export async function authWithPincode(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const validPincode = await verify(definedPincode.pincodeHash, pincode, {
|
const validPincode = verifyPassword(
|
||||||
memoryCost: 19456,
|
pincode,
|
||||||
timeCost: 2,
|
definedPincode.pincodeHash
|
||||||
outputLen: 32,
|
);
|
||||||
parallelism: 1
|
|
||||||
});
|
|
||||||
if (!validPincode) {
|
if (!validPincode) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(HttpCode.UNAUTHORIZED, "Incorrect PIN")
|
createHttpError(HttpCode.UNAUTHORIZED, "Incorrect PIN")
|
||||||
|
|
|
@ -9,6 +9,7 @@ import { fromError } from "zod-validation-error";
|
||||||
import { hash } from "@node-rs/argon2";
|
import { hash } from "@node-rs/argon2";
|
||||||
import { response } from "@server/utils";
|
import { response } from "@server/utils";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
|
import { hashPassword } from "@server/auth/password";
|
||||||
|
|
||||||
const setResourceAuthMethodsParamsSchema = z.object({
|
const setResourceAuthMethodsParamsSchema = z.object({
|
||||||
resourceId: z.string().transform(Number).pipe(z.number().int().positive())
|
resourceId: z.string().transform(Number).pipe(z.number().int().positive())
|
||||||
|
@ -57,12 +58,7 @@ export async function setResourcePassword(
|
||||||
.where(eq(resourcePassword.resourceId, resourceId));
|
.where(eq(resourcePassword.resourceId, resourceId));
|
||||||
|
|
||||||
if (password) {
|
if (password) {
|
||||||
const passwordHash = await hash(password, {
|
const passwordHash = await hashPassword(password);
|
||||||
memoryCost: 19456,
|
|
||||||
timeCost: 2,
|
|
||||||
outputLen: 32,
|
|
||||||
parallelism: 1
|
|
||||||
});
|
|
||||||
|
|
||||||
await trx
|
await trx
|
||||||
.insert(resourcePassword)
|
.insert(resourcePassword)
|
||||||
|
|
|
@ -10,6 +10,7 @@ import { hash } from "@node-rs/argon2";
|
||||||
import { response } from "@server/utils";
|
import { response } from "@server/utils";
|
||||||
import stoi from "@server/utils/stoi";
|
import stoi from "@server/utils/stoi";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
|
import { hashPassword } from "@server/auth/password";
|
||||||
|
|
||||||
const setResourceAuthMethodsParamsSchema = z.object({
|
const setResourceAuthMethodsParamsSchema = z.object({
|
||||||
resourceId: z.string().transform(Number).pipe(z.number().int().positive()),
|
resourceId: z.string().transform(Number).pipe(z.number().int().positive()),
|
||||||
|
@ -61,12 +62,7 @@ export async function setResourcePincode(
|
||||||
.where(eq(resourcePincode.resourceId, resourceId));
|
.where(eq(resourcePincode.resourceId, resourceId));
|
||||||
|
|
||||||
if (pincode) {
|
if (pincode) {
|
||||||
const pincodeHash = await hash(pincode, {
|
const pincodeHash = await hashPassword(pincode);
|
||||||
memoryCost: 19456,
|
|
||||||
timeCost: 2,
|
|
||||||
outputLen: 32,
|
|
||||||
parallelism: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
await trx
|
await trx
|
||||||
.insert(resourcePincode)
|
.insert(resourcePincode)
|
||||||
|
|
|
@ -13,6 +13,7 @@ import { fromError } from "zod-validation-error";
|
||||||
import { hash } from "@node-rs/argon2";
|
import { hash } from "@node-rs/argon2";
|
||||||
import { newts } from "@server/db/schema";
|
import { newts } from "@server/db/schema";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
|
import { hashPassword } from "@server/auth/password";
|
||||||
|
|
||||||
const createSiteParamsSchema = z
|
const createSiteParamsSchema = z
|
||||||
.object({
|
.object({
|
||||||
|
@ -122,12 +123,7 @@ export async function createSite(
|
||||||
|
|
||||||
// add the peer to the exit node
|
// add the peer to the exit node
|
||||||
if (type == "newt") {
|
if (type == "newt") {
|
||||||
const secretHash = await hash(secret!, {
|
const secretHash = await hashPassword(secret!);
|
||||||
memoryCost: 19456,
|
|
||||||
timeCost: 2,
|
|
||||||
outputLen: 32,
|
|
||||||
parallelism: 1
|
|
||||||
});
|
|
||||||
|
|
||||||
await db.insert(newts).values({
|
await db.insert(newts).values({
|
||||||
newtId: newtId!,
|
newtId: newtId!,
|
||||||
|
|
|
@ -10,6 +10,7 @@ import createHttpError from "http-errors";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import { isWithinExpirationDate } from "oslo";
|
import { isWithinExpirationDate } from "oslo";
|
||||||
|
import { verifyPassword } from "@server/auth/password";
|
||||||
|
|
||||||
const acceptInviteBodySchema = z
|
const acceptInviteBodySchema = z
|
||||||
.object({
|
.object({
|
||||||
|
@ -62,12 +63,10 @@ export async function acceptInvite(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const validToken = await verify(existingInvite[0].tokenHash, token, {
|
const validToken = await verifyPassword(
|
||||||
memoryCost: 19456,
|
token,
|
||||||
timeCost: 2,
|
existingInvite[0].tokenHash
|
||||||
outputLen: 32,
|
);
|
||||||
parallelism: 1
|
|
||||||
});
|
|
||||||
if (!validToken) {
|
if (!validToken) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
|
|
470
src/app/auth/reset-password/ResetPasswordForm.tsx
Normal file
470
src/app/auth/reset-password/ResetPasswordForm.tsx
Normal file
|
@ -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<string | null>(null);
|
||||||
|
const [successMessage, setSuccessMessage] = useState<string | null>(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<z.infer<typeof formSchema>>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
defaultValues: {
|
||||||
|
email: emailParam || "",
|
||||||
|
token: tokenParam || "",
|
||||||
|
password: "",
|
||||||
|
confirmPassword: ""
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const mfaForm = useForm<z.infer<typeof mfaSchema>>({
|
||||||
|
resolver: zodResolver(mfaSchema),
|
||||||
|
defaultValues: {
|
||||||
|
code: ""
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const requestForm = useForm<z.infer<typeof requestSchema>>({
|
||||||
|
resolver: zodResolver(requestSchema),
|
||||||
|
defaultValues: {
|
||||||
|
email: emailParam || ""
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function onRequest(data: z.infer<typeof requestSchema>) {
|
||||||
|
const { email } = data;
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
|
||||||
|
const res = await api
|
||||||
|
.post<AxiosResponse<RequestPasswordResetResponse>>(
|
||||||
|
"/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<AxiosResponse<ResetPasswordResponse>>(
|
||||||
|
"/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 (
|
||||||
|
<div>
|
||||||
|
<Card className="w-full max-w-md">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Reset Password</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Follow the steps to reset your password
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-8">
|
||||||
|
{state === "request" && (
|
||||||
|
<Form {...requestForm}>
|
||||||
|
<form
|
||||||
|
onSubmit={requestForm.handleSubmit(
|
||||||
|
onRequest
|
||||||
|
)}
|
||||||
|
className="space-y-4"
|
||||||
|
id="form"
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={requestForm.control}
|
||||||
|
name="email"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Email</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="Enter your email"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
We'll send a password reset
|
||||||
|
code to this email address.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state === "reset" && (
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
onSubmit={form.handleSubmit(onReset)}
|
||||||
|
className="space-y-4"
|
||||||
|
id="form"
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="email"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Email</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="Email"
|
||||||
|
{...field}
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!tokenParam && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="token"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
Reset Code
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="Enter reset code sent to your email"
|
||||||
|
type="password"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="password"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
New Password
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
placeholder="Password"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="confirmPassword"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
Confirm New Password
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
placeholder="Confirm Password"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state === "mfa" && (
|
||||||
|
<Form {...mfaForm}>
|
||||||
|
<form
|
||||||
|
onSubmit={mfaForm.handleSubmit(onReset)}
|
||||||
|
className="space-y-4"
|
||||||
|
id="form"
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={mfaForm.control}
|
||||||
|
name="code"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
Authenticator Code
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<InputOTP
|
||||||
|
maxLength={6}
|
||||||
|
{...field}
|
||||||
|
>
|
||||||
|
<InputOTPGroup>
|
||||||
|
<InputOTPSlot
|
||||||
|
index={0}
|
||||||
|
/>
|
||||||
|
<InputOTPSlot
|
||||||
|
index={1}
|
||||||
|
/>
|
||||||
|
<InputOTPSlot
|
||||||
|
index={2}
|
||||||
|
/>
|
||||||
|
</InputOTPGroup>
|
||||||
|
<InputOTPSeparator />
|
||||||
|
<InputOTPGroup>
|
||||||
|
<InputOTPSlot
|
||||||
|
index={3}
|
||||||
|
/>
|
||||||
|
<InputOTPSlot
|
||||||
|
index={4}
|
||||||
|
/>
|
||||||
|
<InputOTPSlot
|
||||||
|
index={5}
|
||||||
|
/>
|
||||||
|
</InputOTPGroup>
|
||||||
|
</InputOTP>
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertDescription>{error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{successMessage && (
|
||||||
|
<Alert variant="success">
|
||||||
|
<AlertDescription>
|
||||||
|
{successMessage}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{(state === "reset" || state === "mfa") && (
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
form="form"
|
||||||
|
className="w-full"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
{isSubmitting && (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
)}
|
||||||
|
{state === "reset"
|
||||||
|
? "Reset Password"
|
||||||
|
: "Submit Code"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state === "request" && (
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
form="form"
|
||||||
|
className="w-full"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
{isSubmitting && (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
)}
|
||||||
|
Request Reset
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state === "mfa" && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="w-full"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setState("reset");
|
||||||
|
mfaForm.reset();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Back to Password
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(state === "mfa" || state === "reset") && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="w-full"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setState("request");
|
||||||
|
form.reset();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Back to Email
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
32
src/app/auth/reset-password/page.tsx
Normal file
32
src/app/auth/reset-password/page.tsx
Normal file
|
@ -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 (
|
||||||
|
<>
|
||||||
|
<ResetPasswordForm
|
||||||
|
redirect={searchParams.redirect}
|
||||||
|
tokenParam={searchParams.token}
|
||||||
|
emailParam={searchParams.email}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -35,6 +35,7 @@ import {
|
||||||
InputOTPSeparator,
|
InputOTPSeparator,
|
||||||
InputOTPSlot
|
InputOTPSlot
|
||||||
} from "./ui/input-otp";
|
} from "./ui/input-otp";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
type LoginFormProps = {
|
type LoginFormProps = {
|
||||||
redirect?: string;
|
redirect?: string;
|
||||||
|
@ -79,7 +80,7 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) {
|
||||||
|
|
||||||
async function onSubmit(values: any) {
|
async function onSubmit(values: any) {
|
||||||
const { email, password } = form.getValues();
|
const { email, password } = form.getValues();
|
||||||
const { code } = mfaForm.getValues()
|
const { code } = mfaForm.getValues();
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
|
@ -151,23 +152,36 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) {
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<FormField
|
|
||||||
control={form.control}
|
<div className="space-y-4">
|
||||||
name="password"
|
<FormField
|
||||||
render={({ field }) => (
|
control={form.control}
|
||||||
<FormItem>
|
name="password"
|
||||||
<FormLabel>Password</FormLabel>
|
render={({ field }) => (
|
||||||
<FormControl>
|
<FormItem>
|
||||||
<Input
|
<FormLabel>Password</FormLabel>
|
||||||
type="password"
|
<FormControl>
|
||||||
placeholder="Enter your password"
|
<Input
|
||||||
{...field}
|
type="password"
|
||||||
/>
|
placeholder="Enter your password"
|
||||||
</FormControl>
|
{...field}
|
||||||
<FormMessage />
|
/>
|
||||||
</FormItem>
|
</FormControl>
|
||||||
)}
|
<FormMessage />
|
||||||
/>
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
<Link
|
||||||
|
href={`/auth/reset-password${form.getValues().email ? `?email=${form.getValues().email}` : ""}`}
|
||||||
|
className="text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
Forgot password? Click here
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<Alert variant="destructive">
|
<Alert variant="destructive">
|
||||||
<AlertDescription>{error}</AlertDescription>
|
<AlertDescription>{error}</AlertDescription>
|
||||||
|
|
Loading…
Add table
Reference in a new issue