Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
266 changes: 226 additions & 40 deletions src/auth/reset_password.ts
Original file line number Diff line number Diff line change
@@ -1,69 +1,255 @@
import crypto from "crypto";
import jwt from "jsonwebtoken";
import { Request, Response } from "express";
import { read_function, update_function } from "../utils/db_methods";
import { UserModelAttributes } from "../types/model";
import sendEmail from "../helpers/email";
import {
OrganizationModelAttributes,
UserModelAttributes,
} from "../types/model";
import sendEmail from "../helpers/email.simple";
import bcrypt from "bcrypt";

const JWT_SECRET = process.env.JWT_SECRET || "your_jwt_secret";
const FRONTEND_URL = process.env.FRONTEND_URL || "http://localhost:3000";
const NON_ADMIN_FRONTEND_URL =
process.env.NON_ADMIN_FRONTEND_URL || "https://qiew-code-dev2.netlify.app";

type PasswordResetAccountType = "user" | "organization";
type AuthTokenPurpose = "password_reset" | "account_setup";

interface PasswordResetTokenPayload {
id: string;
email: string;
accountType: PasswordResetAccountType;
purpose: AuthTokenPurpose;
}

export const generatePasswordResetToken = ({
id,
email,
accountType,
expiresIn = "1h",
}: {
id: string;
email: string;
accountType: PasswordResetAccountType;
expiresIn?: "1h" | "7d";
}): string => {
const payload: PasswordResetTokenPayload = {
id,
email,
accountType,
purpose: "password_reset",
};

return jwt.sign(payload, JWT_SECRET, {
expiresIn,
algorithm: "HS256",
});
};

export const generateAccountSetupToken = ({
id,
email,
accountType,
expiresIn = "7d",
}: {
id: string;
email: string;
accountType: PasswordResetAccountType;
expiresIn?: "1h" | "7d";
}): string => {
const payload: PasswordResetTokenPayload = {
id,
email,
accountType,
purpose: "account_setup",
};

return jwt.sign(payload, JWT_SECRET, {
expiresIn,
algorithm: "HS256",
});
};

export const buildPasswordResetUrl = (token: string): string =>
`${FRONTEND_URL}/auth/reset-password/${token}`;

export const buildAccountPasswordSetupUrl = (token: string): string =>
`${NON_ADMIN_FRONTEND_URL}/auth/set-account-password?token=${encodeURIComponent(token)}`;

const forgotPassword = async (req: Request, res: Response): Promise<void> => {
try {
const { email } = req.body;

const user = await read_function<UserModelAttributes>("User", "findOne", {
where: { email },
});
if (!user) {
res.status(404).json({ message: "User not found" });
if (!email) {
res.status(400).json({ message: "Email is required" });
return;
}
else {
const resetToken = crypto.randomBytes(32).toString("hex");
const resetTokenExpires = new Date(Date.now() + 3600000);
await update_function("User", "update", { resetToken, resetTokenExpires }, { where: { email } });
const resetUrl = `http://localhost:3000/auth/reset-password/${resetToken}`;
const message = `You are receiving this because you requested a password reset. Please click on the following link, or paste this into your browser to complete the process:\n\n${resetUrl}`;

await sendEmail({
to: user.email,
subject: "Password Reset",
type: 'notification',
data: { message },
});

res.status(200).json({ message: "Reset email sent" });

const normalizedEmail = email.toLowerCase();

const [user, organization] = await Promise.all([
read_function<UserModelAttributes>("User", "findOne", {
where: { email: normalizedEmail },
}),
read_function<OrganizationModelAttributes>("Organization", "findOne", {
where: { email: normalizedEmail },
}),
]);

const account = user || organization;
if (!account) {
res.status(404).json({ message: "Account not found" });
return;
}

const isUserAccount = Boolean(user);
const plainAccount =
account && typeof (account as any).get === "function"
? (account as any).get({ plain: true })
: account;

const resetToken = generatePasswordResetToken({
id: plainAccount.id,
email: plainAccount.email,
accountType: isUserAccount ? "user" : "organization",
});
const resetUrl = buildPasswordResetUrl(resetToken);

await sendEmail({
to: isUserAccount ? plainAccount.email : plainAccount.ownerEmail,
subject: "Reset Your QueCode Password",
type: "password_reset",
data: {
title: "Reset Your Password",
name: isUserAccount
? `${plainAccount.firstName} ${plainAccount.lastName}`
: plainAccount.ownerName || plainAccount.name,
email: plainAccount.email,
message:
"We received a request to reset your password. Use the secure link below to choose a new one.",
buttonText: "Reset Password",
resetUrl,
expiryTime: "1 hour",
},
});

res.status(200).json({ message: "Reset email sent" });
} catch (error) {
console.error("Forgot password error:", error);
res.status(500).json({ message: "An error occurred while requesting password reset" });
res
.status(500)
.json({ message: "An error occurred while requesting password reset" });
}
};
const resetPassword = async (req: Request, res: Response): Promise<void> => {
try {
const { token, newPassword } = req.body;

const user = await read_function<UserModelAttributes>("User", "findOne", {
where: {
resetToken: token,
},
if (!token || !newPassword) {
res.status(400).json({ message: "Token and new password are required" });
return;
}

let decoded: PasswordResetTokenPayload;

try {
decoded = jwt.verify(token, JWT_SECRET) as PasswordResetTokenPayload;
} catch (jwtError) {
res.status(400).json({ message: "Invalid or expired reset token" });
return;
}

if (decoded.purpose !== "password_reset") {
res.status(400).json({ message: "Invalid or expired reset token" });
return;
}

const modelName =
decoded.accountType === "organization" ? "Organization" : "User";
const account = await read_function<any>(modelName, "findOne", {
where: { id: decoded.id, email: decoded.email },
});

if (!user) {
if (!account) {
res.status(400).json({ message: "Invalid or expired reset token" });
} else {
const hashedPassword = await bcrypt.hash(newPassword, 10);
await update_function("User", "update", {
return;
}

const hashedPassword = await bcrypt.hash(newPassword, 10);
await update_function(
modelName as any,
"update",
{
password: hashedPassword,
resetToken: null,
resetTokenExpires: null,
}, { where: { id: user.id } });
},
{ where: { id: decoded.id } },
);

res.status(200).json({ message: "Password reset successful" });
}
}
catch (error) {
res.status(200).json({ message: "Password reset successful" });
} catch (error) {
console.error("Reset password error:", error);
res.status(500).json({ message: "An error occurred while resetting password" });
res
.status(500)
.json({ message: "An error occurred while resetting password" });
}
};

const setAccountPassword = async (
req: Request,
res: Response,
): Promise<void> => {
try {
const { token, newPassword } = req.body;

if (!token || !newPassword) {
res.status(400).json({ message: "Token and new password are required" });
return;
}

let decoded: PasswordResetTokenPayload;

try {
decoded = jwt.verify(token, JWT_SECRET) as PasswordResetTokenPayload;
} catch (jwtError) {
res.status(400).json({ message: "Invalid or expired setup token" });
return;
}

if (decoded.purpose !== "account_setup") {
res.status(400).json({ message: "Invalid or expired setup token" });
return;
}

const modelName =
decoded.accountType === "organization" ? "Organization" : "User";
const account = await read_function<any>(modelName, "findOne", {
where: { id: decoded.id, email: decoded.email },
});

if (!account) {
res.status(400).json({ message: "Invalid or expired setup token" });
return;
}

const hashedPassword = await bcrypt.hash(newPassword, 10);
await update_function(
modelName as any,
"update",
{
password: hashedPassword,
},
{ where: { id: decoded.id } },
);

res.status(200).json({ message: "Account password set successfully" });
} catch (error) {
console.error("Set account password error:", error);
res
.status(500)
.json({ message: "An error occurred while setting account password" });
}
};

export default { forgotPassword, resetPassword };
export default { forgotPassword, resetPassword, setAccountPassword };
38 changes: 25 additions & 13 deletions src/controllers/orgController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ import bcrypt from "bcrypt";
import sendEmail from "../helpers/email.simple";
import QRCode from "qrcode";
import jwt from "jsonwebtoken";
import {
buildAccountPasswordSetupUrl,
generateAccountSetupToken,
} from "../auth/reset_password";
import cloudinary from "../helpers/cloudinary";
import fs from "fs";

Expand Down Expand Up @@ -786,7 +790,7 @@ const get_organization_category = async (
}
};

// Admin creates a new organization (auto-approved, sends credentials via email)
// Admin creates a new organization (auto-approved, sends password setup email)
const admin_create_organization = async (
req: Request,
res: Response,
Expand Down Expand Up @@ -945,24 +949,34 @@ const admin_create_organization = async (
console.error("Error creating wallet for organization:", walletError);
}

// Send welcome email with credentials to organization owner
const passwordSetupToken = generateAccountSetupToken({
id: orgId,
email: email.toLowerCase(),
accountType: "organization",
expiresIn: "7d",
});
const passwordSetupUrl = buildAccountPasswordSetupUrl(passwordSetupToken);

// Send password setup email to organization owner
let emailSent = false;
try {
await sendEmail({
to: ownerEmail.toLowerCase(),
subject:
"Your Organization Account Has Been Created - Welcome to QueCode!",
type: "admin_organization_creation",
subject: "Set Up Your Organization QueCode Password",
type: "password_reset",
data: {
organizationName: name,
name: ownerName,
email: email.toLowerCase(),
password: orgPassword,
loginUrl: `${process.env.FRONTEND_URL || "http://localhost:3000"}/auth/login`,
title: `Set Password for ${name}`,
message: `An administrator created the organization account for ${name}. Use the secure link below to set the password and access the account.`,
buttonText: "Set Organization Password",
resetUrl: passwordSetupUrl,
expiryTime: "7 days",
},
});
emailSent = true;
} catch (emailError: any) {
console.error("Failed to send welcome email:", emailError);
console.error("Failed to send password setup email:", emailError);
}

const plainOrg = isSequelizeInstance(newOrg)
Expand All @@ -971,15 +985,13 @@ const admin_create_organization = async (
const { password: _, ...orgWithoutPassword } = plainOrg;

const message = emailSent
? "Organization created successfully. Login credentials have been sent to the owner's email."
: "Organization created successfully. However, we couldn't send the welcome email. Please provide credentials manually.";
? "Organization created successfully. A password setup email has been sent to the owner's email."
: "Organization created successfully. However, we couldn't send the password setup email. Ask the owner to use forgot password to create their password.";

res.status(201).json({
message,
data: orgWithoutPassword,
emailSent,
// Only return password in response if email failed
...(!emailSent && { temporaryPassword: orgPassword }),
});
} catch (error: any) {
console.error("Admin organization creation error:", error);
Expand Down
Loading
Loading