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 = ` + + `; + + 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 @@ + + @@ -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); + } + }); diff --git a/public/pages/verify-email.html b/public/pages/verify-email.html new file mode 100644 index 0000000..17b4569 --- /dev/null +++ b/public/pages/verify-email.html @@ -0,0 +1,70 @@ + + + + + + Confirm Email - AccManager + + + + +
+
+

Email Confirmation

+

We are confirming your account.

+ +
+ Confirming your email, please wait... +
+ + +
+
+ + + +