confirm email
This commit is contained in:
@@ -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: `
|
||||
<div style="font-family: Arial, sans-serif; line-height: 1.5; color: #1f2937;">
|
||||
<h2 style="margin-bottom: 8px;">Confirm your email</h2>
|
||||
<p>Hello ${username || 'there'},</p>
|
||||
<p>Thank you for registering. Please confirm your email by clicking the button below:</p>
|
||||
<p style="margin: 18px 0;">
|
||||
<a href="${verifyUrl}" style="background:#2563eb;color:#ffffff;padding:10px 16px;border-radius:8px;text-decoration:none;font-weight:700;display:inline-block;">Confirm Email</a>
|
||||
</p>
|
||||
<p>Or copy this URL into your browser:</p>
|
||||
<p><a href="${verifyUrl}">${verifyUrl}</a></p>
|
||||
<p>This link will expire in ${EMAIL_VERIFY_TOKEN_TTL_MINUTES} minutes.</p>
|
||||
<p>If you did not register this account, you can ignore this message.</p>
|
||||
</div>
|
||||
`
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user