diff --git a/DEPLOYMENT_GUIDE.md b/DEPLOYMENT_GUIDE.md
index d8b7e53..d476afb 100644
--- a/DEPLOYMENT_GUIDE.md
+++ b/DEPLOYMENT_GUIDE.md
@@ -91,11 +91,13 @@ Mẹo PowerShell (cập nhật nhanh):
(Get-Content .env) -replace '^DOCKER_IMAGE=.*', 'DOCKER_IMAGE=toiiiiday/accmanager:1.0.3' | Set-Content .env
```
-### Bước 6: Copy .env lên server
+### Bước 6: Copy confi lên server
Từ máy dev:
```powershell
scp .env robotics@172.20.235.176:~/accmanager/.env
+scp docker-compose.yml robotics@172.20.235.176:~/accmanager/docker-compose.yml
+scp docker-compose.image.yml robotics@172.20.235.176:~/accmanager/docker-compose.image.yml
```
---
diff --git a/README.md b/README.md
index e578b40..101cd67 100644
--- a/README.md
+++ b/README.md
@@ -50,6 +50,25 @@ Thay vì lưu rải rác tài khoản ứng dụng ở nhiều nơi, AccManager
- Hướng dẫn triển khai: [DEPLOYMENT_GUIDE.md](DEPLOYMENT_GUIDE.md)
+## Cau hinh email xac thuc dang ky
+
+He thong da ho tro xac thuc email cho:
+- Dang ky tai khoan moi.
+- Doi email trong phan ho so ca nhan.
+
+Can cau hinh cac bien moi truong SMTP sau:
+
+- `APP_BASE_URL`: URL truy cap he thong (vi du `http://localhost:3000` hoac domain production).
+- `SMTP_HOST`: Host SMTP (vi du `smtp.gmail.com`).
+- `SMTP_PORT`: Port SMTP (thuong la `587` cho TLS hoac `465` cho SSL).
+- `SMTP_SECURE`: `true` neu dung SSL (port 465), nguoc lai `false`.
+- `SMTP_USER`: Tai khoan gui email.
+- `SMTP_PASS`: Mat khau app/email.
+- `SMTP_FROM`: Dia chi hien thi nguoi gui (co the de trong de dung `SMTP_USER`).
+- `EMAIL_VERIFY_TOKEN_TTL_MINUTES`: So phut het han link xac thuc (mac dinh 30).
+
+Neu chua cau hinh SMTP, he thong van tao tai khoan nhung se tra ve link xac thuc dang preview cho moi truong dev.
+
---
Phiên bản tài liệu: 3.0.0
diff --git a/backend/server.js b/backend/server.js
index f15dec2..f9fb9bd 100644
--- a/backend/server.js
+++ b/backend/server.js
@@ -6,6 +6,7 @@ const sql = require('mssql');
const cors = require('cors');
const bcrypt = require('bcrypt');
const crypto = require('crypto');
+const nodemailer = require('nodemailer');
const dotenv = require('dotenv');
const app = express();
@@ -32,6 +33,17 @@ const BCRYPT_ROUNDS = Number(process.env.BCRYPT_ROUNDS || 12);
const PASSWORD_VIEW_SECRET = process.env.PASSWORD_VIEW_SECRET || 'change-this-password-view-secret';
const PASSWORD_VIEW_KEY = crypto.createHash('sha256').update(String(PASSWORD_VIEW_SECRET)).digest();
const PASSWORD_VIEW_PREFIX = 'enc:v1';
+const PORT = process.env.PORT || 3000;
+const APP_BASE_URL = String(process.env.APP_BASE_URL || `http://localhost:${PORT}`).replace(/\/+$/, '');
+const SMTP_HOST = process.env.SMTP_HOST || '';
+const SMTP_PORT = Number(process.env.SMTP_PORT || 587);
+const SMTP_SECURE = envBool('SMTP_SECURE', false);
+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);
+
+let mailTransporter;
function isBcryptHash(value) {
return typeof value === 'string' && /^\$2[aby]\$\d{2}\$[./A-Za-z0-9]{53}$/.test(value);
@@ -85,6 +97,93 @@ function decryptPasswordForView(payload) {
}
}
+function hashVerificationToken(token) {
+ return crypto.createHash('sha256').update(String(token)).digest('hex');
+}
+
+function generateEmailVerificationToken() {
+ const token = crypto.randomBytes(32).toString('hex');
+ return {
+ token,
+ tokenHash: hashVerificationToken(token)
+ };
+}
+
+function getEmailVerificationUrl(token) {
+ return `${APP_BASE_URL}/pages/verify-email.html?token=${encodeURIComponent(token)}`;
+}
+
+function canSendEmails() {
+ return Boolean(SMTP_HOST && SMTP_USER && SMTP_PASS);
+}
+
+function getMailTransporter() {
+ if (!mailTransporter) {
+ mailTransporter = nodemailer.createTransport({
+ host: SMTP_HOST,
+ port: SMTP_PORT,
+ secure: SMTP_SECURE,
+ auth: {
+ user: SMTP_USER,
+ pass: SMTP_PASS
+ }
+ });
+ }
+
+ return mailTransporter;
+}
+
+async function sendVerificationEmail({ email, username, token }) {
+ const verifyUrl = getEmailVerificationUrl(token);
+
+ if (!canSendEmails()) {
+ console.warn(`SMTP is not configured. Verification URL for ${email}: ${verifyUrl}`);
+ return {
+ sent: false,
+ previewUrl: verifyUrl,
+ reason: 'SMTP is not configured'
+ };
+ }
+
+ try {
+ const transporter = getMailTransporter();
+ await transporter.sendMail({
+ from: SMTP_FROM,
+ to: email,
+ subject: 'AccManager - Confirm your email',
+ text: `Hello ${username || 'there'},\n\nPlease confirm your email by opening this link:\n${verifyUrl}\n\nThis link will expire in ${EMAIL_VERIFY_TOKEN_TTL_MINUTES} minutes.\n\nIf you did not register, please ignore this email.`,
+ html: `
+
+
Confirm your email
+
Hello ${username || 'there'},
+
Thank you for registering. Please confirm your email by clicking the button below:
+
+ Confirm Email
+
+
Or copy this URL into your browser:
+
${verifyUrl}
+
This link will expire in ${EMAIL_VERIFY_TOKEN_TTL_MINUTES} minutes.
+
If you did not register this account, you can ignore this message.
+
+ `
+ });
+
+ return { sent: true };
+ } catch (err) {
+ console.error('Send verification 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);
+ return Number.isInteger(userId) && userId > 0 ? userId : null;
+}
+
// Middleware
app.use(cors());
app.use(express.json());
@@ -284,6 +383,11 @@ async function createTables() {
await pool.request().query(`IF COL_LENGTH('dbo.Applications','Url') IS NULL ALTER TABLE Applications ADD Url NVARCHAR(255);`);
await pool.request().query(`IF COL_LENGTH('dbo.Applications','Description') IS NULL ALTER TABLE Applications ADD Description NVARCHAR(500);`);
await pool.request().query(`IF COL_LENGTH('dbo.Users','ViewPassword') IS NULL ALTER TABLE Users ADD ViewPassword NVARCHAR(1024);`);
+ await pool.request().query(`IF COL_LENGTH('dbo.Users','EmailVerified') IS NULL ALTER TABLE Users ADD EmailVerified BIT NOT NULL CONSTRAINT DF_Users_EmailVerified DEFAULT(0);`);
+ 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(`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;`);
} catch (err) {
@@ -302,8 +406,13 @@ async function createTables() {
.input('fullname', sql.NVarChar, 'Administrator')
.input('role', sql.NVarChar, 'admin')
.query(`IF NOT EXISTS (SELECT * FROM Users WHERE Username = @username)
- INSERT INTO Users (Username, Password, ViewPassword, Email, FullName, Role, IsActive)
- VALUES (@username, @password, @viewPassword, @email, @fullname, @role, 1)`);
+ INSERT INTO Users (Username, Password, ViewPassword, Email, FullName, Role, IsActive, EmailVerified, EmailVerifiedAt)
+ VALUES (@username, @password, @viewPassword, @email, @fullname, @role, 1, 1, GETDATE())
+ ELSE
+ UPDATE Users
+ SET EmailVerified = 1,
+ EmailVerifiedAt = ISNULL(EmailVerifiedAt, GETDATE())
+ WHERE Username = @username`);
console.log('✓ Admin user created: admin / admin');
} catch (err) {
console.error('Admin user error:', err.message);
@@ -345,7 +454,10 @@ app.post('/api/auth/login', async (req, res) => {
const result = await pool.request()
.input('username', sql.NVarChar, username)
- .query('SELECT UserId, Username, Email, FullName, Role, RoleId, Status, Password FROM Users WHERE Username = @username AND IsActive = 1');
+ .query(`SELECT UserId, Username, Email, FullName, Role, RoleId, Status, Password, EmailVerified
+ FROM Users
+ WHERE (Username = @username OR Email = @username)
+ AND IsActive = 1`);
if (result.recordset.length > 0) {
const dbUser = result.recordset[0];
@@ -358,6 +470,16 @@ app.post('/api/auth/login', async (req, res) => {
});
}
+ if (!dbUser.EmailVerified) {
+ return res.status(403).json({
+ success: false,
+ message: 'Please confirm your email before signing in',
+ requiresEmailVerification: true,
+ email: dbUser.Email,
+ username: dbUser.Username
+ });
+ }
+
// Upgrade old plain-text passwords after successful legacy login.
if (!isBcryptHash(dbUser.Password)) {
const upgradedHash = await hashPassword(password);
@@ -398,23 +520,37 @@ app.post('/api/auth/register', async (req, res) => {
try {
const { username, password, email, fullname } = req.body;
- if (!username || !password) {
- return res.status(400).json({ success: false, message: 'Username and password are required' });
+ if (!username || !password || !email) {
+ return res.status(400).json({ success: false, message: 'Username, password and email are required' });
}
- // Prevent duplicate usernames
+ const normalizedEmail = String(email).trim().toLowerCase();
+ const safeUsername = String(username).trim();
+ const isEmailFormatValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalizedEmail);
+ if (!isEmailFormatValid) {
+ return res.status(400).json({ success: false, message: 'Email format is invalid' });
+ }
+
+ // Prevent duplicate usernames/emails
const existing = await pool.request()
- .input('username', sql.NVarChar, username)
- .query('SELECT UserId FROM Users WHERE Username = @username');
+ .input('username', sql.NVarChar, safeUsername)
+ .input('email', sql.NVarChar, normalizedEmail)
+ .query('SELECT TOP 1 UserId, Username, Email FROM Users WHERE Username = @username OR Email = @email');
if (existing.recordset.length > 0) {
- return res.status(409).json({ success: false, message: 'Username already exists' });
+ const existed = existing.recordset[0];
+ const duplicateByEmail = String(existed.Email || '').toLowerCase() === normalizedEmail;
+ return res.status(409).json({
+ success: false,
+ message: duplicateByEmail ? 'Email already exists' : 'Username already exists'
+ });
}
const hashedPassword = await hashPassword(password);
const viewPassword = encryptPasswordForView(password);
+ const { token, tokenHash } = generateEmailVerificationToken();
- const safeFullname = fullname && fullname.trim() ? fullname.trim() : username;
+ const safeFullname = fullname && fullname.trim() ? fullname.trim() : safeUsername;
let guestRoleName = 'guest';
let guestRoleId = null;
@@ -449,31 +585,34 @@ app.post('/api/auth/register', async (req, res) => {
if (hasRoleIdColumn && guestRoleId !== null) {
result = await pool.request()
- .input('username', sql.NVarChar, username)
+ .input('username', sql.NVarChar, safeUsername)
.input('password', sql.NVarChar, hashedPassword)
- .input('email', sql.NVarChar, email || null)
+ .input('email', sql.NVarChar, normalizedEmail)
.input('fullname', sql.NVarChar, safeFullname)
.input('roleId', sql.Int, guestRoleId)
.input('role', sql.NVarChar, guestRoleName)
.input('viewPassword', sql.NVarChar, viewPassword)
- .query(`INSERT INTO Users (Username, Password, ViewPassword, Email, FullName, RoleId, Role, Status, IsActive)
+ .input('emailVerifyToken', sql.NVarChar, tokenHash)
+ .input('tokenTtlMinutes', sql.Int, EMAIL_VERIFY_TOKEN_TTL_MINUTES)
+ .query(`INSERT INTO Users (Username, Password, ViewPassword, Email, FullName, RoleId, Role, Status, IsActive, EmailVerified, EmailVerifyToken, EmailVerifyTokenExpires)
OUTPUT INSERTED.UserId, INSERTED.Username, INSERTED.Email, INSERTED.FullName, INSERTED.Role, INSERTED.RoleId
- VALUES (@username, @password, @viewPassword, @email, @fullname, @roleId, @role, 'Active', 1)`);
+ VALUES (@username, @password, @viewPassword, @email, @fullname, @roleId, @role, 'Active', 1, 0, @emailVerifyToken, DATEADD(MINUTE, @tokenTtlMinutes, GETDATE()))`);
} else {
result = await pool.request()
- .input('username', sql.NVarChar, username)
+ .input('username', sql.NVarChar, safeUsername)
.input('password', sql.NVarChar, hashedPassword)
- .input('email', sql.NVarChar, email || null)
+ .input('email', sql.NVarChar, normalizedEmail)
.input('fullname', sql.NVarChar, safeFullname)
.input('role', sql.NVarChar, guestRoleName)
.input('viewPassword', sql.NVarChar, viewPassword)
- .query(`INSERT INTO Users (Username, Password, ViewPassword, Email, FullName, Role, Status, IsActive)
+ .input('emailVerifyToken', sql.NVarChar, tokenHash)
+ .input('tokenTtlMinutes', sql.Int, EMAIL_VERIFY_TOKEN_TTL_MINUTES)
+ .query(`INSERT INTO Users (Username, Password, ViewPassword, Email, FullName, Role, Status, IsActive, EmailVerified, EmailVerifyToken, EmailVerifyTokenExpires)
OUTPUT INSERTED.UserId, INSERTED.Username, INSERTED.Email, INSERTED.FullName, INSERTED.Role
- VALUES (@username, @password, @viewPassword, @email, @fullname, @role, 'Active', 1)`);
+ VALUES (@username, @password, @viewPassword, @email, @fullname, @role, 'Active', 1, 0, @emailVerifyToken, DATEADD(MINUTE, @tokenTtlMinutes, GETDATE()))`);
}
const inserted = result.recordset[0];
- const user = { ...inserted, role: inserted.Role || guestRoleName || 'guest' };
// Repair previous self-registered users that were wrongly assigned Admin RoleId.
if (hasRoleIdColumn && guestRoleId !== null) {
@@ -486,13 +625,162 @@ app.post('/api/auth/register', async (req, res) => {
AND (RoleId IS NULL OR RoleId <> @guestRoleId)`);
}
- res.json({ success: true, message: 'Registration successful', user });
+ const emailResult = await sendVerificationEmail({
+ email: normalizedEmail,
+ username: safeUsername,
+ token
+ });
+
+ const responsePayload = {
+ success: true,
+ message: emailResult.sent
+ ? 'Registration successful. Please check your email to confirm your account.'
+ : 'Registration successful, but email sending is not configured. Please contact administrator or use verification preview link in development.',
+ registrationPendingVerification: true,
+ emailSent: emailResult.sent,
+ userId: inserted?.UserId
+ };
+
+ if (emailResult.previewUrl) {
+ responsePayload.verificationPreviewUrl = emailResult.previewUrl;
+ }
+
+ if (emailResult.reason && !emailResult.sent) {
+ responsePayload.emailError = emailResult.reason;
+ }
+
+ res.json(responsePayload);
} catch (err) {
console.error('Registration error:', err);
res.status(500).json({ success: false, message: 'Registration failed' });
}
});
+app.get('/api/auth/verify-email', async (req, res) => {
+ try {
+ const token = String(req.query.token || '').trim();
+ if (!token) {
+ return res.status(400).json({ success: false, message: 'Verification token is required' });
+ }
+
+ const tokenHash = hashVerificationToken(token);
+ const result = await pool.request()
+ .input('token', sql.NVarChar, tokenHash)
+ .query(`SELECT UserId, Username, Email, FullName, Role, RoleId, Status, IsActive, EmailVerifyTokenExpires
+ FROM Users
+ WHERE EmailVerifyToken = @token`);
+
+ if (result.recordset.length === 0) {
+ return res.status(400).json({ success: false, message: 'Verification token is invalid or already used' });
+ }
+
+ const verifiedUser = result.recordset[0];
+ const expiresAt = verifiedUser.EmailVerifyTokenExpires ? new Date(verifiedUser.EmailVerifyTokenExpires) : null;
+ if (!expiresAt || expiresAt.getTime() < Date.now()) {
+ return res.status(400).json({ success: false, message: 'Verification token has expired. Please request a new email.' });
+ }
+
+ await pool.request()
+ .input('userId', sql.Int, verifiedUser.UserId)
+ .query(`UPDATE Users
+ SET EmailVerified = 1,
+ EmailVerifiedAt = GETDATE(),
+ EmailVerifyToken = NULL,
+ EmailVerifyTokenExpires = NULL
+ WHERE UserId = @userId`);
+
+ if (!verifiedUser.IsActive) {
+ return res.status(403).json({
+ success: false,
+ message: 'Email confirmed, but account is inactive. Please contact administrator.'
+ });
+ }
+
+ // Align verification flow with login payload shape to support auto-login on verify page.
+ const { EmailVerifyTokenExpires: _expires, ...safeUser } = verifiedUser;
+ const user = { ...safeUser, role: safeUser.Role || safeUser.role || 'guest' };
+
+ await pool.request()
+ .input('userId', sql.Int, verifiedUser.UserId)
+ .query('UPDATE Users SET LastLogin = GETDATE() WHERE UserId = @userId');
+
+ res.json({
+ success: true,
+ message: 'Email confirmed successfully. Logging you in...',
+ autoLogin: true,
+ user
+ });
+ } catch (err) {
+ console.error('Verify email error:', err.message);
+ res.status(500).json({ success: false, message: 'Email verification failed' });
+ }
+});
+
+app.post('/api/auth/resend-verification', async (req, res) => {
+ try {
+ const identifier = String(req.body?.identifier || req.body?.email || '').trim();
+ if (!identifier) {
+ return res.status(400).json({ success: false, message: 'Username or email is required' });
+ }
+
+ const result = await pool.request()
+ .input('identifier', sql.NVarChar, identifier)
+ .query(`SELECT TOP 1 UserId, Username, Email, EmailVerified
+ FROM Users
+ WHERE Username = @identifier OR Email = @identifier`);
+
+ if (result.recordset.length === 0) {
+ return res.json({ success: true, message: 'If the account exists, a confirmation email has been sent.' });
+ }
+
+ const user = result.recordset[0];
+ if (user.EmailVerified) {
+ return res.json({ success: true, message: 'This email is already confirmed.' });
+ }
+
+ if (!user.Email) {
+ return res.status(400).json({ success: false, message: 'This account has no email. Please contact administrator.' });
+ }
+
+ const { token, tokenHash } = generateEmailVerificationToken();
+ await pool.request()
+ .input('userId', sql.Int, user.UserId)
+ .input('tokenHash', sql.NVarChar, tokenHash)
+ .input('tokenTtlMinutes', sql.Int, EMAIL_VERIFY_TOKEN_TTL_MINUTES)
+ .query(`UPDATE Users
+ SET EmailVerifyToken = @tokenHash,
+ EmailVerifyTokenExpires = DATEADD(MINUTE, @tokenTtlMinutes, GETDATE())
+ WHERE UserId = @userId`);
+
+ const emailResult = await sendVerificationEmail({
+ email: user.Email,
+ username: user.Username,
+ token
+ });
+
+ const payload = {
+ success: true,
+ message: emailResult.sent
+ ? 'Verification email sent. Please check your inbox.'
+ : 'SMTP is not configured. Use development preview link or configure SMTP.',
+ emailSent: emailResult.sent
+ };
+
+ if (emailResult.previewUrl) {
+ payload.verificationPreviewUrl = emailResult.previewUrl;
+ }
+
+ if (emailResult.reason && !emailResult.sent) {
+ payload.emailError = emailResult.reason;
+ }
+
+ res.json(payload);
+ } catch (err) {
+ console.error('Resend verification error:', err.message);
+ res.status(500).json({ success: false, message: 'Cannot resend verification email right now' });
+ }
+});
+
// Middleware for role-based access control
const requireAdmin = (req, res, next) => {
const userRole = req.headers['x-user-role'] || req.query.userRole;
@@ -613,7 +901,7 @@ app.get('/api/users', async (req, res) => {
try {
const result = await pool.request()
.query(`SELECT u.UserId, u.Username, u.Email, u.FullName, u.Role, u.RoleId, r.RoleName,
- u.Status, u.CreatedDate, u.LastLogin, u.IsActive
+ u.Status, u.CreatedDate, u.LastLogin, u.IsActive, u.EmailVerified, u.EmailVerifiedAt
FROM Users u
LEFT JOIN Roles r ON u.RoleId = r.RoleId
ORDER BY u.CreatedDate DESC`);
@@ -623,6 +911,159 @@ app.get('/api/users', async (req, res) => {
}
});
+app.get('/api/users/me', async (req, res) => {
+ try {
+ const userId = getUserIdFromRequest(req);
+ if (!userId) {
+ return res.status(401).json({ success: false, message: 'Missing or invalid user id' });
+ }
+
+ const result = await pool.request()
+ .input('userId', sql.Int, userId)
+ .query(`SELECT UserId, Username, Email, FullName, Role, RoleId, Status, IsActive,
+ CreatedDate, LastLogin, EmailVerified, EmailVerifiedAt
+ FROM Users
+ WHERE UserId = @userId`);
+
+ if (result.recordset.length === 0) {
+ return res.status(404).json({ success: false, message: 'User not found' });
+ }
+
+ res.json({ success: true, data: result.recordset[0] });
+ } catch (err) {
+ res.status(500).json({ success: false, message: err.message });
+ }
+});
+
+app.put('/api/users/me', async (req, res) => {
+ try {
+ const userId = getUserIdFromRequest(req);
+ if (!userId) {
+ return res.status(401).json({ success: false, message: 'Missing or invalid user id' });
+ }
+
+ const fullname = String(req.body?.fullname || '').trim();
+ const incomingEmail = String(req.body?.email || '').trim().toLowerCase();
+ const currentPassword = String(req.body?.currentPassword || '');
+ const newPassword = String(req.body?.newPassword || '').trim();
+
+ if (!fullname) {
+ return res.status(400).json({ success: false, message: 'Full name is required' });
+ }
+
+ if (!incomingEmail) {
+ return res.status(400).json({ success: false, message: 'Email is required' });
+ }
+
+ const isEmailFormatValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(incomingEmail);
+ if (!isEmailFormatValid) {
+ return res.status(400).json({ success: false, message: 'Email format is invalid' });
+ }
+
+ const userResult = await pool.request()
+ .input('userId', sql.Int, userId)
+ .query(`SELECT UserId, Username, Email, Password, Role, RoleId
+ FROM Users
+ WHERE UserId = @userId`);
+
+ if (userResult.recordset.length === 0) {
+ return res.status(404).json({ success: false, message: 'User not found' });
+ }
+
+ const existingUser = userResult.recordset[0];
+ const emailChanged = String(existingUser.Email || '').toLowerCase() !== incomingEmail;
+ const shouldChangePassword = newPassword.length > 0;
+
+ if (shouldChangePassword) {
+ if (!currentPassword) {
+ return res.status(400).json({ success: false, message: 'Current password is required to set a new password' });
+ }
+
+ const isCurrentPasswordValid = await verifyPassword(currentPassword, existingUser.Password);
+ if (!isCurrentPasswordValid) {
+ return res.status(400).json({ success: false, message: 'Current password is incorrect' });
+ }
+ }
+
+ if (emailChanged) {
+ const duplicateEmailResult = await pool.request()
+ .input('email', sql.NVarChar, incomingEmail)
+ .input('userId', sql.Int, userId)
+ .query('SELECT TOP 1 UserId FROM Users WHERE Email = @email AND UserId <> @userId');
+
+ if (duplicateEmailResult.recordset.length > 0) {
+ return res.status(409).json({ success: false, message: 'Email already exists' });
+ }
+ }
+
+ const request = pool.request()
+ .input('userId', sql.Int, userId)
+ .input('fullname', sql.NVarChar, fullname)
+ .input('email', sql.NVarChar, incomingEmail);
+
+ let token;
+ if (emailChanged) {
+ const tokenResult = generateEmailVerificationToken();
+ token = tokenResult.token;
+ request.input('emailVerifyToken', sql.NVarChar, tokenResult.tokenHash);
+ request.input('tokenTtlMinutes', sql.Int, EMAIL_VERIFY_TOKEN_TTL_MINUTES);
+ }
+
+ if (shouldChangePassword) {
+ const hashedPassword = await hashPassword(newPassword);
+ const viewPassword = encryptPasswordForView(newPassword);
+ request.input('password', sql.NVarChar, hashedPassword);
+ request.input('viewPassword', sql.NVarChar, viewPassword);
+ }
+
+ await request.query(`UPDATE Users
+ SET FullName = @fullname,
+ Email = @email
+ ${shouldChangePassword ? ', Password = @password, ViewPassword = @viewPassword' : ''}
+ ${emailChanged ? ', EmailVerified = 0, EmailVerifiedAt = NULL, EmailVerifyToken = @emailVerifyToken, EmailVerifyTokenExpires = DATEADD(MINUTE, @tokenTtlMinutes, GETDATE())' : ''}
+ WHERE UserId = @userId`);
+
+ let emailResult = { sent: true };
+ if (emailChanged) {
+ emailResult = await sendVerificationEmail({
+ email: incomingEmail,
+ username: existingUser.Username,
+ token
+ });
+ }
+
+ const safeResult = await pool.request()
+ .input('userId', sql.Int, userId)
+ .query(`SELECT UserId, Username, Email, FullName, Role, RoleId, Status, IsActive,
+ CreatedDate, LastLogin, EmailVerified, EmailVerifiedAt
+ FROM Users
+ WHERE UserId = @userId`);
+
+ const payload = {
+ success: true,
+ message: emailChanged
+ ? 'Profile updated. Please confirm your new email address.'
+ : (shouldChangePassword ? 'Profile and password updated successfully' : 'Profile updated successfully'),
+ user: safeResult.recordset[0],
+ verificationRequired: emailChanged,
+ emailSent: emailChanged ? emailResult.sent : undefined
+ };
+
+ if (emailChanged && emailResult.previewUrl) {
+ payload.verificationPreviewUrl = emailResult.previewUrl;
+ }
+
+ if (emailChanged && emailResult.reason && !emailResult.sent) {
+ payload.emailError = emailResult.reason;
+ }
+
+ res.json(payload);
+ } catch (err) {
+ console.error('Update profile error:', err.message);
+ res.status(500).json({ success: false, message: 'Cannot update profile right now' });
+ }
+});
+
// Get user by ID
app.get('/api/users/:id', requireAdmin, async (req, res) => {
try {
@@ -1012,8 +1453,6 @@ app.use((err, req, res, next) => {
// Server Startup
// ==========================================
-const PORT = process.env.PORT || 3000;
-
async function startServer() {
try {
await initializeDatabase();
diff --git a/docker-compose.image.yml b/docker-compose.image.yml
index c3b4e7d..f265fd0 100644
--- a/docker-compose.image.yml
+++ b/docker-compose.image.yml
@@ -15,4 +15,12 @@ services:
DB_ENCRYPT: ${DB_ENCRYPT:-false}
DB_TRUST_CERTIFICATE: ${DB_TRUST_CERTIFICATE:-true}
DB_CONNECT_TIMEOUT: ${DB_CONNECT_TIMEOUT:-30000}
- BCRYPT_ROUNDS: ${BCRYPT_ROUNDS:-12}
\ No newline at end of file
+ BCRYPT_ROUNDS: ${BCRYPT_ROUNDS:-12}
+ APP_BASE_URL: ${APP_BASE_URL:-http://localhost:3000}
+ SMTP_HOST: ${SMTP_HOST:-}
+ SMTP_PORT: ${SMTP_PORT:-587}
+ SMTP_SECURE: ${SMTP_SECURE:-false}
+ SMTP_USER: ${SMTP_USER:-}
+ SMTP_PASS: ${SMTP_PASS:-}
+ SMTP_FROM: ${SMTP_FROM:-}
+ EMAIL_VERIFY_TOKEN_TTL_MINUTES: ${EMAIL_VERIFY_TOKEN_TTL_MINUTES:-30}
\ No newline at end of file
diff --git a/docker-compose.yml b/docker-compose.yml
index 002c3b6..df6093b 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -17,4 +17,12 @@ services:
DB_ENCRYPT: ${DB_ENCRYPT:-false}
DB_TRUST_CERTIFICATE: ${DB_TRUST_CERTIFICATE:-true}
DB_CONNECT_TIMEOUT: ${DB_CONNECT_TIMEOUT:-30000}
- BCRYPT_ROUNDS: ${BCRYPT_ROUNDS:-12}
\ No newline at end of file
+ BCRYPT_ROUNDS: ${BCRYPT_ROUNDS:-12}
+ APP_BASE_URL: ${APP_BASE_URL:-http://localhost:3000}
+ SMTP_HOST: ${SMTP_HOST:-}
+ SMTP_PORT: ${SMTP_PORT:-587}
+ SMTP_SECURE: ${SMTP_SECURE:-false}
+ SMTP_USER: ${SMTP_USER:-}
+ SMTP_PASS: ${SMTP_PASS:-}
+ SMTP_FROM: ${SMTP_FROM:-}
+ EMAIL_VERIFY_TOKEN_TTL_MINUTES: ${EMAIL_VERIFY_TOKEN_TTL_MINUTES:-30}
\ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
index 1bdd988..7e20733 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -13,7 +13,8 @@
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"express": "^4.18.2",
- "mssql": "^9.1.1"
+ "mssql": "^9.1.1",
+ "nodemailer": "^8.0.4"
},
"devDependencies": {
"@tailwindcss/container-queries": "^0.1.1",
@@ -2821,6 +2822,15 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/nodemailer": {
+ "version": "8.0.4",
+ "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.4.tgz",
+ "integrity": "sha512-k+jf6N8PfQJ0Fe8ZhJlgqU5qJU44Lpvp2yvidH3vp1lPnVQMgi4yEEMPXg5eJS1gFIJTVq1NHBk7Ia9ARdSBdQ==",
+ "license": "MIT-0",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
"node_modules/nodemon": {
"version": "3.1.14",
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz",
diff --git a/package.json b/package.json
index 6c226a2..513d655 100644
--- a/package.json
+++ b/package.json
@@ -22,7 +22,8 @@
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"express": "^4.18.2",
- "mssql": "^9.1.1"
+ "mssql": "^9.1.1",
+ "nodemailer": "^8.0.4"
},
"devDependencies": {
"@tailwindcss/container-queries": "^0.1.1",
diff --git a/public/js/app.js b/public/js/app.js
index 8fa684b..474a7c7 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -94,6 +94,19 @@ class AccountManager {
return this.getCurrentUserRole() === 'admin';
}
+ getAuthHeaders(includeJson = false) {
+ const headers = {
+ 'x-user-id': String(this.getUserId()),
+ 'x-user-role': this.getCurrentUserRole()
+ };
+
+ if (includeJson) {
+ headers['Content-Type'] = 'application/json';
+ }
+
+ return headers;
+ }
+
async init() {
await this.fetchApplications();
await this.fetchAccounts();
@@ -282,6 +295,11 @@ class AccountManager {
logoutBtn.addEventListener('click', () => this.handleLogout());
}
+ const profileBtn = document.getElementById('profileBtn');
+ if (profileBtn) {
+ profileBtn.addEventListener('click', () => this.openProfileModal());
+ }
+
// Update account display
this.updateAccountDisplay();
@@ -1247,6 +1265,218 @@ class AccountManager {
});
}
+ async openProfileModal() {
+ try {
+ const response = await fetch(`${this.apiBase}/users/me`, {
+ headers: this.getAuthHeaders(false)
+ });
+ const data = await response.json();
+
+ if (!data.success || !data.data) {
+ this.notifyFailure(data.message || 'Cannot load profile');
+ return;
+ }
+
+ this.renderProfileModal(data.data);
+ } catch (err) {
+ console.error(err);
+ this.notifyFailure('Cannot load profile');
+ }
+ }
+
+ renderProfileModal(profile) {
+ const isVerified = profile?.EmailVerified === true || profile?.EmailVerified === 1;
+ const html = `
+
+
+
My Profile
+
Update personal info and password in one place.
+
+
+
+ `;
+
+ const containerId = 'profileModalContainer';
+ let container = document.getElementById(containerId);
+ if (!container) {
+ container = document.createElement('div');
+ container.id = containerId;
+ document.body.appendChild(container);
+ }
+ container.innerHTML = html;
+
+ const form = document.getElementById('profileForm');
+ if (form) {
+ form.addEventListener('submit', (e) => this.saveProfile(e));
+ }
+
+ this.setupProfilePasswordToggles();
+
+ const modal = document.getElementById('profileModal');
+ if (modal) {
+ modal.addEventListener('click', (e) => {
+ if (e.target === modal) {
+ closeProfileModal();
+ }
+ });
+ }
+ }
+
+ setupProfilePasswordToggles() {
+ document.querySelectorAll('[data-password-toggle]').forEach((toggleBtn) => {
+ if (toggleBtn.dataset.bound === 'true') {
+ return;
+ }
+
+ toggleBtn.addEventListener('click', () => {
+ const inputId = toggleBtn.dataset.passwordToggle;
+ if (!inputId) return;
+
+ const input = document.getElementById(inputId);
+ const icon = document.getElementById(`${inputId}Icon`);
+ if (!input) return;
+
+ const isHidden = input.type === 'password';
+ input.type = isHidden ? 'text' : 'password';
+ if (icon) {
+ icon.textContent = isHidden ? 'visibility_off' : 'visibility';
+ }
+ });
+
+ toggleBtn.dataset.bound = 'true';
+ });
+ }
+
+ async saveProfile(e) {
+ e.preventDefault();
+
+ const fullname = document.getElementById('profileFullName')?.value.trim() || '';
+ const email = document.getElementById('profileEmail')?.value.trim() || '';
+ const currentPassword = document.getElementById('profileCurrentPassword')?.value || '';
+ const newPassword = document.getElementById('profileNewPassword')?.value || '';
+ const confirmPassword = document.getElementById('profileConfirmPassword')?.value || '';
+
+ if (!fullname || !email) {
+ this.notifyFailure('Full name and email are required');
+ return;
+ }
+
+ if (newPassword && newPassword !== confirmPassword) {
+ this.notifyFailure('New password and confirm password do not match');
+ return;
+ }
+
+ if (newPassword && !currentPassword) {
+ this.notifyFailure('Current password is required to change password');
+ return;
+ }
+
+ try {
+ const response = await fetch(`${this.apiBase}/users/me`, {
+ method: 'PUT',
+ headers: this.getAuthHeaders(true),
+ body: JSON.stringify({
+ fullname,
+ email,
+ currentPassword,
+ newPassword
+ })
+ });
+ const data = await response.json();
+
+ if (!data.success) {
+ this.notifyFailure(data.message || 'Update profile failed');
+ return;
+ }
+
+ if (data.user) {
+ this.currentUser = {
+ ...this.currentUser,
+ ...data.user,
+ role: data.user.role || data.user.Role || this.currentUser.role || this.currentUser.Role
+ };
+ this.saveToStorage('currentUser', this.currentUser);
+ this.updateAccountDisplay();
+ }
+
+ closeProfileModal();
+ this.notifySuccess(data.message || 'Profile updated');
+
+ if (data.verificationRequired && data.emailSent === false) {
+ if (data.verificationPreviewUrl) {
+ this.notifyWarning(`Email confirmation link (dev): ${data.verificationPreviewUrl}`);
+ } else {
+ this.notifyWarning('Email changed but verification email could not be sent.');
+ }
+ }
+ } catch (err) {
+ console.error(err);
+ this.notifyFailure('Update profile failed');
+ }
+ }
+
loadFromStorage(key) {
const data = localStorage.getItem(key);
return data ? JSON.parse(data) : null;
@@ -1618,7 +1848,7 @@ class AccountManager {
try {
const response = await fetch(url, {
method,
- headers: { 'Content-Type': 'application/json', 'x-user-role': this.getCurrentUserRole() },
+ headers: this.getAuthHeaders(true),
body: JSON.stringify(payload)
});
@@ -1643,7 +1873,7 @@ class AccountManager {
async viewUserDetails(userId) {
try {
const response = await fetch(`${this.apiBase}/users/${userId}`, {
- headers: { 'x-user-role': this.getCurrentUserRole() }
+ headers: this.getAuthHeaders(false)
});
const data = await response.json();
@@ -1804,7 +2034,7 @@ class AccountManager {
try {
const response = await fetch(`${this.apiBase}/users/${userId}`, {
method: 'DELETE',
- headers: { 'x-user-role': this.getCurrentUserRole() }
+ headers: this.getAuthHeaders(false)
});
const data = await response.json();
@@ -1873,6 +2103,13 @@ function closeUserDetailsModal() {
}
}
+function closeProfileModal() {
+ const profileContainer = document.getElementById('profileModalContainer');
+ if (profileContainer) {
+ profileContainer.innerHTML = '';
+ }
+}
+
// Initialize app when DOM is ready
let app;
document.addEventListener('DOMContentLoaded', () => {
diff --git a/public/pages/index.html b/public/pages/index.html
index 38d9107..b2f3859 100644
--- a/public/pages/index.html
+++ b/public/pages/index.html
@@ -88,13 +88,13 @@
-
+
+
diff --git a/public/pages/login.html b/public/pages/login.html
index 04cae2a..2d40221 100644
--- a/public/pages/login.html
+++ b/public/pages/login.html
@@ -107,6 +107,14 @@
+
+
+
Please confirm your email before signing in.
+
+
@@ -186,6 +194,7 @@
+
@@ -210,12 +219,17 @@
const rememberCheckbox = document.getElementById('remember');
const registerForm = document.getElementById('registerForm');
const registerErrorMessage = document.getElementById('registerErrorMessage');
+ const registerSuccessMessage = document.getElementById('registerSuccessMessage');
+ const verifyNotice = document.getElementById('verifyNotice');
+ const verifyNoticeText = document.getElementById('verifyNoticeText');
+ const resendVerifyBtn = document.getElementById('resendVerifyBtn');
const regFullnameInput = document.getElementById('regFullname');
const regEmailInput = document.getElementById('regEmail');
const regUsernameInput = document.getElementById('regUsername');
const regPasswordInput = document.getElementById('regPassword');
const loginTab = document.getElementById('loginTab');
const registerTab = document.getElementById('registerTab');
+ let pendingVerificationIdentifier = '';
let currentMode = 'login';
const setMode = (mode) => {
@@ -226,6 +240,8 @@
registerForm.classList.toggle('hidden', isLogin);
errorMessage.classList.add('hidden');
registerErrorMessage.classList.add('hidden');
+ registerSuccessMessage.classList.add('hidden');
+ verifyNotice.classList.add('hidden');
const activate = (btn, active) => {
btn.classList.toggle('bg-primary', active);
@@ -262,6 +278,7 @@
loginForm.addEventListener('submit', async (e) => {
e.preventDefault();
errorMessage.classList.add('hidden');
+ verifyNotice.classList.add('hidden');
const username = usernameInput.value.trim();
const password = passwordInput.value;
@@ -291,6 +308,10 @@
// Redirect to dashboard
window.location.href = './index.html';
+ } else if (data.requiresEmailVerification) {
+ pendingVerificationIdentifier = data.username || data.email || username;
+ verifyNoticeText.textContent = data.message || 'Please confirm your email before signing in';
+ verifyNotice.classList.remove('hidden');
} else {
// Show error
errorMessage.textContent = data.message || 'Invalid username or password';
@@ -308,6 +329,7 @@
registerForm.addEventListener('submit', async (e) => {
e.preventDefault();
registerErrorMessage.classList.add('hidden');
+ registerSuccessMessage.classList.add('hidden');
const payload = {
fullname: regFullnameInput.value.trim(),
@@ -316,8 +338,8 @@
password: regPasswordInput.value
};
- if (!payload.username || !payload.password) {
- registerErrorMessage.textContent = 'Username and password are required';
+ if (!payload.username || !payload.password || !payload.email) {
+ registerErrorMessage.textContent = 'Username, password and email are required';
registerErrorMessage.classList.remove('hidden');
return;
}
@@ -333,10 +355,19 @@
const isJson = response.headers.get('content-type')?.includes('application/json');
const data = isJson ? await response.json() : null;
- if (response.ok && data?.success && data.user) {
- localStorage.setItem('currentUser', JSON.stringify(data.user));
- localStorage.setItem('rememberedUsername', payload.username);
- window.location.href = './index.html';
+ if (response.ok && data?.success) {
+ const lines = [data.message || 'Registration successful. Please check your email to confirm account.'];
+ if (data.verificationPreviewUrl) {
+ lines.push(`Development verification link: ${data.verificationPreviewUrl}`);
+ }
+
+ regPasswordInput.value = '';
+ setMode('login');
+ usernameInput.value = payload.username;
+ passwordInput.value = '';
+ pendingVerificationIdentifier = payload.email || payload.username;
+ verifyNoticeText.textContent = lines.join(' ');
+ verifyNotice.classList.remove('hidden');
} else {
const fallback = isJson ? (data?.message || 'Registration failed') : 'Server error (HTML response)';
registerErrorMessage.textContent = fallback;
@@ -348,6 +379,35 @@
console.error('Register error:', error);
}
});
+
+ resendVerifyBtn.addEventListener('click', async () => {
+ const identifier = pendingVerificationIdentifier || usernameInput.value.trim();
+ if (!identifier) {
+ verifyNoticeText.textContent = 'Enter username/email first, then try resending verification.';
+ verifyNotice.classList.remove('hidden');
+ return;
+ }
+
+ try {
+ const response = await fetch('/api/auth/resend-verification', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify({ identifier })
+ });
+ const data = await response.json();
+ verifyNoticeText.textContent = data.message || 'Verification email processed.';
+ verifyNotice.classList.remove('hidden');
+ if (data.verificationPreviewUrl) {
+ verifyNoticeText.textContent += ` Development link: ${data.verificationPreviewUrl}`;
+ }
+ } catch (error) {
+ verifyNoticeText.textContent = 'Cannot resend verification email right now.';
+ verifyNotice.classList.remove('hidden');
+ console.error('Resend verification error:', error);
+ }
+ });