forgot pass
This commit is contained in:
@@ -44,6 +44,7 @@ const SMTP_USER = process.env.SMTP_USER || '';
|
||||
const SMTP_PASS = process.env.SMTP_PASS || '';
|
||||
const SMTP_FROM = process.env.SMTP_FROM || SMTP_USER || 'no-reply@accmanager.local';
|
||||
const EMAIL_VERIFY_TOKEN_TTL_MINUTES = Number(process.env.EMAIL_VERIFY_TOKEN_TTL_MINUTES || 30);
|
||||
const PASSWORD_RESET_TOKEN_TTL_MINUTES = Number(process.env.PASSWORD_RESET_TOKEN_TTL_MINUTES || 30);
|
||||
|
||||
let mailTransporter;
|
||||
|
||||
@@ -115,6 +116,10 @@ function getEmailVerificationUrl(token) {
|
||||
return `${APP_BASE_URL}/pages/verify-email.html?token=${encodeURIComponent(token)}`;
|
||||
}
|
||||
|
||||
function getPasswordResetUrl(token) {
|
||||
return `${APP_BASE_URL}/pages/login.html?mode=reset-password&token=${encodeURIComponent(token)}`;
|
||||
}
|
||||
|
||||
function canSendEmails() {
|
||||
return Boolean(SMTP_HOST && SMTP_USER && SMTP_PASS);
|
||||
}
|
||||
@@ -180,6 +185,51 @@ async function sendVerificationEmail({ email, username, token }) {
|
||||
}
|
||||
}
|
||||
|
||||
async function sendPasswordResetEmail({ email, username, token }) {
|
||||
const resetUrl = getPasswordResetUrl(token);
|
||||
|
||||
if (!canSendEmails()) {
|
||||
console.warn(`SMTP is not configured. Password reset URL for ${email}: ${resetUrl}`);
|
||||
return {
|
||||
sent: false,
|
||||
previewUrl: resetUrl,
|
||||
reason: 'SMTP is not configured'
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const transporter = getMailTransporter();
|
||||
await transporter.sendMail({
|
||||
from: SMTP_FROM,
|
||||
to: email,
|
||||
subject: 'AccManager - Reset your password',
|
||||
text: `Hello ${username || 'there'},\n\nA password reset was requested for your account.\nOpen this link to set a new password:\n${resetUrl}\n\nThis link will expire in ${PASSWORD_RESET_TOKEN_TTL_MINUTES} minutes.\n\nIf you did not request this, please ignore this email.`,
|
||||
html: `
|
||||
<div style="font-family: Arial, sans-serif; line-height: 1.5; color: #1f2937;">
|
||||
<h2 style="margin-bottom: 8px;">Reset your password</h2>
|
||||
<p>Hello ${username || 'there'},</p>
|
||||
<p>We received a password reset request for your account.</p>
|
||||
<p style="margin: 18px 0;">
|
||||
<a href="${resetUrl}" style="background:#2563eb;color:#ffffff;padding:10px 16px;border-radius:8px;text-decoration:none;font-weight:700;display:inline-block;">Reset Password</a>
|
||||
</p>
|
||||
<p>Or copy this URL into your browser:</p>
|
||||
<p><a href="${resetUrl}">${resetUrl}</a></p>
|
||||
<p>This link will expire in ${PASSWORD_RESET_TOKEN_TTL_MINUTES} minutes.</p>
|
||||
<p>If you did not request this reset, you can ignore this message.</p>
|
||||
</div>
|
||||
`
|
||||
});
|
||||
|
||||
return { sent: true };
|
||||
} catch (err) {
|
||||
console.error('Send password reset email error:', err.message);
|
||||
return {
|
||||
sent: false,
|
||||
reason: err.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function getUserIdFromRequest(req) {
|
||||
const rawUserId = req.headers['x-user-id'] || req.query.userId;
|
||||
const userId = Number(rawUserId);
|
||||
@@ -236,6 +286,15 @@ async function syncAssetDepartmentsFromInventory() {
|
||||
`);
|
||||
}
|
||||
|
||||
async function ensurePasswordResetColumns() {
|
||||
if (!pool) {
|
||||
return;
|
||||
}
|
||||
|
||||
await pool.request().query(`IF COL_LENGTH('dbo.Users','PasswordResetToken') IS NULL ALTER TABLE Users ADD PasswordResetToken NVARCHAR(255) NULL;`);
|
||||
await pool.request().query(`IF COL_LENGTH('dbo.Users','PasswordResetTokenExpires') IS NULL ALTER TABLE Users ADD PasswordResetTokenExpires DATETIME NULL;`);
|
||||
}
|
||||
|
||||
async function ensureDepartmentExists(departmentName) {
|
||||
const normalized = normalizeDepartmentName(departmentName);
|
||||
if (!normalized || !pool) {
|
||||
@@ -1624,6 +1683,8 @@ async function createTables() {
|
||||
await pool.request().query(`IF COL_LENGTH('dbo.Users','EmailVerifiedAt') IS NULL ALTER TABLE Users ADD EmailVerifiedAt DATETIME NULL;`);
|
||||
await pool.request().query(`IF COL_LENGTH('dbo.Users','EmailVerifyToken') IS NULL ALTER TABLE Users ADD EmailVerifyToken NVARCHAR(255) NULL;`);
|
||||
await pool.request().query(`IF COL_LENGTH('dbo.Users','EmailVerifyTokenExpires') IS NULL ALTER TABLE Users ADD EmailVerifyTokenExpires DATETIME NULL;`);
|
||||
await pool.request().query(`IF COL_LENGTH('dbo.Users','PasswordResetToken') IS NULL ALTER TABLE Users ADD PasswordResetToken NVARCHAR(255) NULL;`);
|
||||
await pool.request().query(`IF COL_LENGTH('dbo.Users','PasswordResetTokenExpires') IS NULL ALTER TABLE Users ADD PasswordResetTokenExpires DATETIME NULL;`);
|
||||
await pool.request().query(`UPDATE Users SET EmailVerified = 1, EmailVerifiedAt = ISNULL(EmailVerifiedAt, GETDATE()) WHERE LOWER(ISNULL(Role, '')) = 'admin';`);
|
||||
// Backfill Url to empty string to avoid undefined in responses
|
||||
await pool.request().query(`UPDATE Applications SET Url = '' WHERE Url IS NULL;`);
|
||||
@@ -2025,6 +2086,130 @@ app.post('/api/auth/resend-verification', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/auth/forgot-password', async (req, res) => {
|
||||
try {
|
||||
await ensurePasswordResetColumns();
|
||||
|
||||
const username = String(req.body?.username || '').trim();
|
||||
const email = String(req.body?.email || '').trim().toLowerCase();
|
||||
|
||||
if (!username || !email) {
|
||||
return res.status(400).json({ success: false, message: 'Username and email are required' });
|
||||
}
|
||||
|
||||
const result = await pool.request()
|
||||
.input('username', sql.NVarChar, username)
|
||||
.input('email', sql.NVarChar, email)
|
||||
.query(`SELECT TOP 1 UserId, Username, Email, IsActive
|
||||
FROM Users
|
||||
WHERE Username = @username
|
||||
AND LOWER(ISNULL(Email, '')) = @email`);
|
||||
|
||||
if (result.recordset.length === 0 || !result.recordset[0].IsActive) {
|
||||
return res.json({
|
||||
success: true,
|
||||
message: 'If the username and email match, a password reset email has been sent.'
|
||||
});
|
||||
}
|
||||
|
||||
const user = result.recordset[0];
|
||||
const { token, tokenHash } = generateEmailVerificationToken();
|
||||
|
||||
await pool.request()
|
||||
.input('userId', sql.Int, user.UserId)
|
||||
.input('tokenHash', sql.NVarChar, tokenHash)
|
||||
.input('tokenTtlMinutes', sql.Int, PASSWORD_RESET_TOKEN_TTL_MINUTES)
|
||||
.query(`UPDATE Users
|
||||
SET PasswordResetToken = @tokenHash,
|
||||
PasswordResetTokenExpires = DATEADD(MINUTE, @tokenTtlMinutes, GETDATE())
|
||||
WHERE UserId = @userId`);
|
||||
|
||||
const emailResult = await sendPasswordResetEmail({
|
||||
email: user.Email,
|
||||
username: user.Username,
|
||||
token
|
||||
});
|
||||
|
||||
const payload = {
|
||||
success: true,
|
||||
message: emailResult.sent
|
||||
? 'Password reset email sent. Please check your inbox.'
|
||||
: 'SMTP is not configured. Use development reset link or configure SMTP.',
|
||||
emailSent: emailResult.sent
|
||||
};
|
||||
|
||||
if (emailResult.previewUrl) {
|
||||
payload.resetPreviewUrl = emailResult.previewUrl;
|
||||
}
|
||||
|
||||
if (emailResult.reason && !emailResult.sent) {
|
||||
payload.emailError = emailResult.reason;
|
||||
}
|
||||
|
||||
res.json(payload);
|
||||
} catch (err) {
|
||||
console.error('Forgot password error:', err.message);
|
||||
res.status(500).json({ success: false, message: 'Cannot process forgot password request right now' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/auth/reset-password', async (req, res) => {
|
||||
try {
|
||||
await ensurePasswordResetColumns();
|
||||
|
||||
const token = String(req.body?.token || '').trim();
|
||||
const newPassword = String(req.body?.newPassword || req.body?.password || '');
|
||||
|
||||
if (!token || !newPassword) {
|
||||
return res.status(400).json({ success: false, message: 'Reset token and new password are required' });
|
||||
}
|
||||
|
||||
if (newPassword.length < 6) {
|
||||
return res.status(400).json({ success: false, message: 'New password must be at least 6 characters' });
|
||||
}
|
||||
|
||||
const tokenHash = hashVerificationToken(token);
|
||||
const result = await pool.request()
|
||||
.input('token', sql.NVarChar, tokenHash)
|
||||
.query(`SELECT TOP 1 UserId, PasswordResetTokenExpires, IsActive
|
||||
FROM Users
|
||||
WHERE PasswordResetToken = @token`);
|
||||
|
||||
if (result.recordset.length === 0) {
|
||||
return res.status(400).json({ success: false, message: 'Reset token is invalid or already used' });
|
||||
}
|
||||
|
||||
const user = result.recordset[0];
|
||||
if (!user.IsActive) {
|
||||
return res.status(403).json({ success: false, message: 'Account is inactive. Please contact administrator.' });
|
||||
}
|
||||
|
||||
const expiresAt = user.PasswordResetTokenExpires ? new Date(user.PasswordResetTokenExpires) : null;
|
||||
if (!expiresAt || expiresAt.getTime() < Date.now()) {
|
||||
return res.status(400).json({ success: false, message: 'Reset token has expired. Please request a new reset email.' });
|
||||
}
|
||||
|
||||
const hashedPassword = await hashPassword(newPassword);
|
||||
const viewPassword = encryptPasswordForView(newPassword);
|
||||
|
||||
await pool.request()
|
||||
.input('userId', sql.Int, user.UserId)
|
||||
.input('password', sql.NVarChar, hashedPassword)
|
||||
.input('viewPassword', sql.NVarChar, viewPassword)
|
||||
.query(`UPDATE Users
|
||||
SET Password = @password,
|
||||
ViewPassword = @viewPassword,
|
||||
PasswordResetToken = NULL,
|
||||
PasswordResetTokenExpires = NULL
|
||||
WHERE UserId = @userId`);
|
||||
|
||||
res.json({ success: true, message: 'Password reset successful. You can sign in now.' });
|
||||
} catch (err) {
|
||||
console.error('Reset password error:', err.message);
|
||||
res.status(500).json({ success: false, message: 'Password reset failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// Middleware for role-based access control
|
||||
function normalizeRole(value) {
|
||||
return String(value || '').trim().toLowerCase();
|
||||
|
||||
Reference in New Issue
Block a user