diff --git a/backend/server.js b/backend/server.js
index 13686d2..e203585 100644
--- a/backend/server.js
+++ b/backend/server.js
@@ -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: `
+
+
Reset your password
+
Hello ${username || 'there'},
+
We received a password reset request for your account.
+
+ Reset Password
+
+
Or copy this URL into your browser:
+
${resetUrl}
+
This link will expire in ${PASSWORD_RESET_TOKEN_TTL_MINUTES} minutes.
+
If you did not request this reset, you can ignore this message.
+
+ `
+ });
+
+ 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();
diff --git a/public/pages/login.html b/public/pages/login.html
index a847897..d5656d0 100644
--- a/public/pages/login.html
+++ b/public/pages/login.html
@@ -42,7 +42,7 @@
Account Management System
-
+
@@ -86,14 +86,19 @@
-
-
-
+
+
+
+
+
+
@@ -117,6 +122,115 @@
+
+
+
+