add reset password email and route

This commit is contained in:
creations 2025-06-11 19:56:12 -04:00
parent fff3c3ca50
commit d36c34dd17
Signed by: creations
GPG key ID: 8F553AA4320FC711
12 changed files with 433 additions and 68 deletions

View file

@ -21,7 +21,7 @@
"@biomejs/biome",
],
"packages": {
"@atums/echo": ["@atums/echo@1.0.3", "", { "dependencies": { "date-fns-tz": "^3.2.0" } }, "sha512-WQ2d4oWTaE+6VeLIu2FepmZipdwUrM+SiiO5moHhSsP4P+MaQCjq5qp34nwB/vOHv2jd9UcBzy27iUziTffCjg=="],
"@atums/echo": ["@atums/echo@1.0.6", "", { "dependencies": { "date-fns-tz": "^3.2.0" } }, "sha512-2v0coX0Ptau6pjh4aTJDXMMJ2z/Q+0r8tvLokjeyUnLWGOPMwg+i4saBrkvDtHvQbNiq/NiEwMFLCxeIlxEyLQ=="],
"@biomejs/biome": ["@biomejs/biome@1.9.4", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "1.9.4", "@biomejs/cli-darwin-x64": "1.9.4", "@biomejs/cli-linux-arm64": "1.9.4", "@biomejs/cli-linux-arm64-musl": "1.9.4", "@biomejs/cli-linux-x64": "1.9.4", "@biomejs/cli-linux-x64-musl": "1.9.4", "@biomejs/cli-win32-arm64": "1.9.4", "@biomejs/cli-win32-x64": "1.9.4" }, "bin": { "biome": "bin/biome" } }, "sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog=="],

View file

@ -5,6 +5,7 @@
"rotate": true,
"maxFiles": 3,
"fileNameFormat": "yyyy-MM-dd",
"console": true,
"consoleColor": true,

View file

@ -0,0 +1,47 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{subject}}</title>
<style>
body {
font-family: Arial, sans-serif;
max-width: 600px;
margin: 0 auto;
padding: 20px;
line-height: 1.5;
}
a {
color: #0066cc;
}
</style>
</head>
<body>
<h1>Password Reset - {{companyName}}</h1>
<p>Hi {{displayName}},</p>
<p>You requested a password reset for your account. Click the link below to reset your password:</p>
<p><a href="{{resetUrl}}">{{resetUrl}}</a></p>
<p>{{willExpire}} for security reasons.</p>
<p>If you did not request this password reset, please ignore this email. Your password will remain unchanged.</p>
<p>Questions? Contact {{supportEmail}}</p>
<p>Best regards,<br>
The {{companyName}} team</p>
<hr>
<p><small>User ID: {{id}} | © {{currentYear}} {{companyName}}</small></p>
</body>
</html>

View file

@ -11,89 +11,35 @@
max-width: 600px;
margin: 0 auto;
padding: 20px;
line-height: 1.6;
background-color: #1a1a1a;
color: #e0e0e0;
}
h2 {
color: #4a9eff;
margin-bottom: 20px;
line-height: 1.5;
}
a {
color: #4a9eff;
}
.button {
display: inline-block;
padding: 12px 24px;
background-color: #2d7a2d;
color: white;
text-decoration: none;
border-radius: 4px;
margin: 10px 0;
}
.expiry-notice {
background-color: #2a2a2a;
border: 1px solid #444;
padding: 10px;
border-radius: 4px;
margin: 15px 0;
color: #ffa500;
}
.fallback-url {
word-break: break-all;
background-color: #2a2a2a;
border: 1px solid #444;
padding: 8px;
border-radius: 4px;
font-family: monospace;
font-size: 12px;
color: #999;
}
.footer {
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #444;
font-size: 12px;
color: #999;
color: #0066cc;
}
</style>
</head>
<body>
<h2>Welcome to {{companyName}}!</h2>
<h1>Welcome to {{companyName}}!</h1>
<p>Hi {{displayName}},</p>
<p>Thanks for signing up! Please verify your email address to activate your account:</p>
<p>Please verify your email address:</p>
<p><a href="{{verificationUrl}}" class="button">Verify Email Address</a></p>
<p><a href="{{verificationUrl}}">{{verificationUrl}}</a></p>
<div class="expiry-notice">
<strong>⏰ Important:</strong> {{willExpire}} for security reasons.
</div>
<p>{{willExpire}} for security reasons.</p>
<p><strong>If the button doesn't work:</strong></p>
<p>Copy and paste this link into your browser:</p>
<div class="fallback-url">{{verificationUrl}}</div>
<p>Questions? Contact {{supportEmail}}</p>
<p>Once verified, you'll have full access to your {{companyName}} account!</p>
<p>Questions? Reply to this email or contact us at {{supportEmail}}</p>
<p>Best regards,<br>
The {{companyName}} team</p>
<hr>
<p>Best regards,<br>The {{companyName}} team</p>
<p><small>User ID: {{id}} | © {{currentYear}} {{companyName}}</small></p>
<div class="footer">
<p>User ID: {{id}}</p>
<p>© {{currentYear}} {{companyName}}. All rights reserved.</p>
<p><small>This is an automated message. Please do not reply directly to this email.</small></p>
</div>
</body>
</html>

View file

@ -0,0 +1,154 @@
import { echo } from "@atums/echo";
import { redis } from "bun";
import { environment } from "#environment/config";
import { extraValues } from "#environment/extra";
import { cassandra } from "#lib/database";
import { mailerService } from "#lib/mailer";
import { isValidEmail } from "#lib/validation";
import type {
BaseResponse,
ExtendedRequest,
ForgotPasswordRequest,
RouteDef,
} from "#types/server";
const routeDef: RouteDef = {
method: "POST",
accepts: "application/json",
returns: "application/json",
needsBody: "json",
};
async function handler(
_request: ExtendedRequest,
requestBody: unknown,
): Promise<Response> {
try {
const { email } = requestBody as ForgotPasswordRequest;
if (!email) {
const response: BaseResponse = {
code: 400,
success: false,
error: "Email is required",
};
return Response.json(response, { status: 400 });
}
const emailValidation = isValidEmail(email);
if (!emailValidation.valid) {
const response: BaseResponse = {
code: 400,
success: false,
error: emailValidation.error || "Invalid email",
};
return Response.json(response, { status: 400 });
}
const userQuery = `
SELECT id, username, display_name, email, is_verified
FROM users WHERE email = ? LIMIT 1
`;
const userResult = (await cassandra.execute(userQuery, [
email.trim().toLowerCase(),
])) as {
rows: Array<{
id: string;
username: string;
display_name: string | null;
email: string;
is_verified: boolean;
}>;
};
if (!userResult?.rows || userResult.rows.length === 0) {
const response: BaseResponse = {
code: 200,
success: true,
message: "If the email exists, a password reset link has been sent",
};
return Response.json(response, { status: 200 });
}
const user = userResult.rows[0];
if (!user) {
const response: BaseResponse = {
code: 200,
success: true,
message: "If the email exists, a password reset link has been sent",
};
return Response.json(response, { status: 200 });
}
const resetToken = Bun.randomUUIDv7();
const resetKey = `password-reset:${resetToken}`;
try {
await redis.set(
resetKey,
JSON.stringify({
userId: user.id,
email: user.email,
}),
"EX",
1 * 60 * 60, // 1 hour
);
const emailVariables = {
subject: `Password Reset - ${extraValues.companyName}`,
companyName: extraValues.companyName,
id: user.id,
displayName: user.display_name || user.username,
willExpire: "This link will expire in 1 hour",
resetUrl: `${environment.frontendFqdn}/user/reset-password?token=${resetToken}`,
supportEmail: extraValues.supportEmail,
currentYear: new Date().getFullYear(),
};
await mailerService.sendTemplateEmail(
user.email,
`Password Reset - ${extraValues.companyName}`,
"forgot-password",
emailVariables,
);
const response: BaseResponse = {
code: 200,
success: true,
message: "If the email exists, a password reset link has been sent",
};
return Response.json(response, { status: 200 });
} catch (error) {
await redis.del(resetKey).catch(() => {});
echo.error({
message: "Failed to send password reset email",
error,
userId: user.id,
email: user.email,
});
const response: BaseResponse = {
code: 500,
success: false,
error: "Failed to send password reset email. Please try again.",
};
return Response.json(response, { status: 500 });
}
} catch (error) {
echo.error({
message: "Password reset request failed",
error,
});
const response: BaseResponse = {
code: 500,
success: false,
error: "Internal server error",
};
return Response.json(response, { status: 500 });
}
}
export { handler, routeDef };

View file

@ -0,0 +1,190 @@
import { echo } from "@atums/echo";
import { redis } from "bun";
import { sessionManager } from "#lib/auth";
import { cassandra } from "#lib/database";
import { isValidPassword } from "#lib/validation";
import type {
ExtendedRequest,
ResetData,
ResetPasswordRequest,
ResetPasswordResponse,
RouteDef,
UserRow,
} from "#types/server";
const routeDef: RouteDef = {
method: "POST",
accepts: "application/json",
returns: "application/json",
needsBody: "json",
};
async function handler(
_request: ExtendedRequest,
requestBody: unknown,
): Promise<Response> {
try {
const {
token,
newPassword,
logoutAllSessions = true,
} = requestBody as ResetPasswordRequest;
if (!token || !newPassword) {
const response: ResetPasswordResponse = {
code: 400,
success: false,
error: "Token and new password are required",
};
return Response.json(response, { status: 400 });
}
const passwordValidation = isValidPassword(newPassword);
if (!passwordValidation.valid) {
const response: ResetPasswordResponse = {
code: 400,
success: false,
error: passwordValidation.error || "Invalid password",
};
return Response.json(response, { status: 400 });
}
const resetKey = `password-reset:${token}`;
const resetDataRaw = await redis.get(resetKey);
if (!resetDataRaw) {
const response: ResetPasswordResponse = {
code: 400,
success: false,
error: "Invalid or expired reset token",
};
return Response.json(response, { status: 400 });
}
let resetData: ResetData;
try {
resetData = JSON.parse(resetDataRaw);
} catch {
await redis.del(resetKey);
const response: ResetPasswordResponse = {
code: 400,
success: false,
error: "Invalid reset token format",
};
return Response.json(response, { status: 400 });
}
if (!resetData.userId || !resetData.email) {
await redis.del(resetKey);
const response: ResetPasswordResponse = {
code: 400,
success: false,
error: "Invalid reset data",
};
return Response.json(response, { status: 400 });
}
const userQuery = `
SELECT id, username, display_name, email, password, is_verified, created_at, updated_at
FROM users WHERE id = ? LIMIT 1
`;
const userResult = (await cassandra.execute(userQuery, [
resetData.userId,
])) as { rows: UserRow[] };
if (!userResult?.rows || userResult.rows.length === 0) {
await redis.del(resetKey);
const response: ResetPasswordResponse = {
code: 404,
success: false,
error: "User not found",
};
return Response.json(response, { status: 404 });
}
const user = userResult.rows[0];
if (!user) {
await redis.del(resetKey);
const response: ResetPasswordResponse = {
code: 404,
success: false,
error: "User not found",
};
return Response.json(response, { status: 404 });
}
if (user.email !== resetData.email) {
await redis.del(resetKey);
const response: ResetPasswordResponse = {
code: 400,
success: false,
error: "Reset token does not match current email address",
};
return Response.json(response, { status: 400 });
}
const isSamePassword = await Bun.password.verify(
newPassword,
user.password,
);
if (isSamePassword) {
const response: ResetPasswordResponse = {
code: 400,
success: false,
error: "New password must be different from current password",
};
return Response.json(response, { status: 400 });
}
const hashedNewPassword = await Bun.password.hash(newPassword, {
algorithm: "argon2id",
memoryCost: 4096,
timeCost: 3,
});
const updateQuery = `
UPDATE users
SET password = ?, updated_at = ?
WHERE id = ?
`;
await cassandra.execute(updateQuery, [
hashedNewPassword,
new Date(),
user.id,
]);
await redis.del(resetKey);
let invalidatedCount = 0;
if (logoutAllSessions) {
invalidatedCount = await sessionManager.invalidateAllSessionsForUser(
user.id,
);
}
const response: ResetPasswordResponse = {
code: 200,
success: true,
message: logoutAllSessions
? `Password reset successfully. Logged out from ${invalidatedCount} session(s).`
: "Password reset successfully.",
loggedOutSessions: invalidatedCount,
};
return Response.json(response, { status: 200 });
} catch (error) {
echo.error({
message: "Password reset failed",
error,
});
const response: ResetPasswordResponse = {
code: 500,
success: false,
error: "Internal server error",
};
return Response.json(response, { status: 500 });
}
}
export { handler, routeDef };

View file

@ -134,7 +134,7 @@ async function handler(
const response: UpdatePasswordResponse = {
code: 200,
success: true,
message: `Password updated successfully. Logged out from ${invalidatedCount} sessions.`,
message: `Password updated successfully. Logged out from ${invalidatedCount} session(s).`,
loggedOutSessions: invalidatedCount,
};
@ -185,7 +185,7 @@ async function handler(
success: true,
message:
invalidatedCount > 0
? `Password updated successfully. Logged out from ${invalidatedCount} other sessions.`
? `Password updated successfully. Logged out from ${invalidatedCount} other session(s).`
: "Password updated successfully.",
loggedOutSessions: invalidatedCount,
};

View file

@ -0,0 +1 @@
export * from "./password";

View file

@ -0,0 +1,5 @@
interface ForgotPasswordRequest {
email: string;
}
export type { ForgotPasswordRequest };

View file

@ -5,3 +5,5 @@ export * from "./login";
export * from "./verify";
export * from "./update";
export * from "./forgot";
export * from "./reset";

View file

@ -0,0 +1 @@
export * from "./password";

View file

@ -0,0 +1,18 @@
import type { BaseResponse } from "../../base";
interface ResetPasswordRequest {
token: string;
newPassword: string;
logoutAllSessions?: boolean;
}
interface ResetPasswordResponse extends BaseResponse {
loggedOutSessions?: number;
}
interface ResetData {
userId: string;
email: string;
}
export type { ResetPasswordRequest, ResetPasswordResponse, ResetData };