Compare commits

...

2 Commits

Author SHA1 Message Date
a78769cfde Merge branch 'main' of https://git.pnkr.asia/DungTT/ManagerAccount 2026-04-02 15:24:03 +07:00
5a7bf191d0 confirm email 2026-04-02 15:23:32 +07:00
11 changed files with 893 additions and 39 deletions

View File

@@ -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
```
---

View File

@@ -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

View File

@@ -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();

View File

@@ -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}
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}

View File

@@ -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}
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}

12
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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 = `
<div class="fixed inset-0 bg-black/50 flex items-center justify-center z-50 modal-backdrop open" id="profileModal" style="display:flex;align-items:center;justify-content:center;padding:16px;">
<div class="bg-white dark:bg-slate-900 rounded-lg p-6 modal-content" style="width:min(720px,calc(100vw - 32px));max-height:calc(100vh - 32px);overflow:auto;">
<h2 class="text-xl font-bold text-slate-900 dark:text-slate-50">My Profile</h2>
<p class="text-xs text-slate-500 mt-1 mb-4">Update personal info and password in one place.</p>
<form id="profileForm" class="space-y-5">
<div class="rounded-xl border border-outline-variant/20 bg-surface-container-low/40 dark:bg-slate-800/40 p-4 space-y-3">
<div>
<label class="block text-sm font-medium mb-1">Username</label>
<input type="text" value="${profile?.Username || ''}" readonly class="w-full px-3 py-2 border border-outline-variant/30 rounded-lg bg-surface-container-low dark:bg-slate-800 opacity-80">
</div>
<div>
<label class="block text-sm font-medium mb-1">Full Name</label>
<input type="text" id="profileFullName" value="${profile?.FullName || ''}" class="w-full px-3 py-2 border border-outline-variant/30 rounded-lg bg-white dark:bg-slate-900" required>
</div>
<div>
<label class="block text-sm font-medium mb-1">Email</label>
<input type="email" id="profileEmail" value="${profile?.Email || ''}" class="w-full px-3 py-2 border border-outline-variant/30 rounded-lg bg-white dark:bg-slate-900" required>
<p class="text-xs mt-1 ${isVerified ? 'text-green-600' : 'text-amber-600'}">
${isVerified ? 'Email is confirmed' : 'Email is not confirmed yet'}
</p>
</div>
</div>
<div class="rounded-xl border border-outline-variant/20 bg-surface-container-low/40 dark:bg-slate-800/40 p-4">
<label class="block text-sm font-semibold mb-3">Change Password</label>
<div class="space-y-3">
<div>
<label class="block text-xs font-semibold text-slate-600 dark:text-slate-300 mb-1">Current password</label>
<div class="flex items-center gap-2">
<input type="password" id="profileCurrentPassword" placeholder="Enter current password" class="flex-1 px-3 py-2 border border-outline-variant/30 rounded-lg bg-white dark:bg-slate-900">
<button type="button" data-password-toggle="profileCurrentPassword" class="shrink-0 px-2.5 py-2 border border-outline-variant/30 rounded-lg text-slate-500 hover:text-slate-700 hover:bg-slate-100 dark:hover:text-slate-200 dark:hover:bg-slate-800" aria-label="Show current password">
<span class="material-symbols-outlined text-base" id="profileCurrentPasswordIcon">visibility</span>
</button>
</div>
</div>
<div>
<label class="block text-xs font-semibold text-slate-600 dark:text-slate-300 mb-1">New password</label>
<div class="flex items-center gap-2">
<input type="password" id="profileNewPassword" placeholder="Enter new password" class="flex-1 px-3 py-2 border border-outline-variant/30 rounded-lg bg-white dark:bg-slate-900">
<button type="button" data-password-toggle="profileNewPassword" class="shrink-0 px-2.5 py-2 border border-outline-variant/30 rounded-lg text-slate-500 hover:text-slate-700 hover:bg-slate-100 dark:hover:text-slate-200 dark:hover:bg-slate-800" aria-label="Show new password">
<span class="material-symbols-outlined text-base" id="profileNewPasswordIcon">visibility</span>
</button>
</div>
</div>
<div>
<label class="block text-xs font-semibold text-slate-600 dark:text-slate-300 mb-1">Confirm new password</label>
<div class="flex items-center gap-2">
<input type="password" id="profileConfirmPassword" placeholder="Confirm new password" class="flex-1 px-3 py-2 border border-outline-variant/30 rounded-lg bg-white dark:bg-slate-900">
<button type="button" data-password-toggle="profileConfirmPassword" class="shrink-0 px-2.5 py-2 border border-outline-variant/30 rounded-lg text-slate-500 hover:text-slate-700 hover:bg-slate-100 dark:hover:text-slate-200 dark:hover:bg-slate-800" aria-label="Show confirm password">
<span class="material-symbols-outlined text-base" id="profileConfirmPasswordIcon">visibility</span>
</button>
</div>
</div>
<p class="text-xs text-slate-500">Leave password fields empty if you only want to update profile info.</p>
</div>
</div>
<div class="flex gap-3">
<button type="button" onclick="closeProfileModal()" class="flex-1 px-4 py-2 bg-outline/10 hover:bg-outline/20 rounded-lg transition-colors">Cancel</button>
<button type="submit" class="flex-1 px-4 py-2 bg-primary hover:bg-primary-dim text-on-primary font-bold rounded-lg transition-colors">Save</button>
</div>
</form>
</div>
</div>
`;
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', () => {

View File

@@ -88,13 +88,13 @@
</div>
<div class="flex items-center gap-4">
<div class="flex items-center gap-2 px-4 py-2 rounded-lg bg-slate-100 dark:bg-slate-800">
<button id="profileBtn" type="button" class="flex items-center gap-2 px-4 py-2 rounded-lg bg-slate-100 dark:bg-slate-800 hover:bg-slate-200 dark:hover:bg-slate-700 transition-colors" title="Edit profile">
<span class="material-symbols-outlined text-slate-600 dark:text-slate-400">account_circle</span>
<div class="flex flex-col">
<span id="accountUsername" class="text-xs font-semibold text-slate-900 dark:text-slate-50">User Account</span>
<span id="accountRole" class="text-[10px] text-slate-500 dark:text-slate-400">Administrator</span>
</div>
</div>
</button>
<button id="logoutBtn" class="p-2 rounded-lg text-slate-600 dark:text-slate-300 hover:bg-red-100 dark:hover:bg-red-950 hover:text-red-700 dark:hover:text-red-300 transition-colors" title="Logout">
<span class="material-symbols-outlined">logout</span>
</button>

View File

@@ -107,6 +107,14 @@
<!-- Error Message -->
<div id="errorMessage" class="hidden bg-error-container/20 text-error/80 border border-error/30 rounded-lg px-4 py-3 text-xs font-medium"></div>
<div id="verifyNotice" class="hidden bg-amber-50 text-amber-800 border border-amber-200 rounded-lg px-4 py-3 text-xs font-medium space-y-2">
<p id="verifyNoticeText">Please confirm your email before signing in.</p>
<button id="resendVerifyBtn" type="button" class="inline-flex items-center gap-1 px-3 py-1.5 rounded-md bg-amber-100 hover:bg-amber-200 text-amber-900 font-semibold transition-colors">
<span class="material-symbols-outlined text-sm">forward_to_inbox</span>
<span>Resend confirmation email</span>
</button>
</div>
</form>
<!-- Register Form -->
@@ -186,6 +194,7 @@
</button>
<div id="registerErrorMessage" class="hidden bg-error-container/20 text-error/80 border border-error/30 rounded-lg px-4 py-3 text-xs font-medium"></div>
<div id="registerSuccessMessage" class="hidden bg-green-50 text-green-800 border border-green-200 rounded-lg px-4 py-3 text-xs font-medium"></div>
</form>
<!-- Footer -->
@@ -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);
}
});
</script>
</body>
</html>

View File

@@ -0,0 +1,70 @@
<!DOCTYPE html>
<html lang="en" class="light">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Confirm Email - AccManager</title>
<link rel="stylesheet" href="../css/main.css" />
<style>
body { font-family: 'Inter', sans-serif; min-height: 100vh; }
</style>
</head>
<body class="bg-gradient-to-br from-slate-100 via-white to-blue-100 text-slate-900 antialiased">
<main class="min-h-screen flex items-center justify-center px-4">
<section class="w-full max-w-md bg-white rounded-2xl border border-slate-200 shadow-lg p-8">
<h1 class="text-2xl font-black tracking-tight mb-2">Email Confirmation</h1>
<p class="text-sm text-slate-600 mb-6">We are confirming your account.</p>
<div id="statusBox" class="rounded-lg border px-4 py-3 text-sm bg-slate-50 border-slate-200 text-slate-700">
Confirming your email, please wait...
</div>
<div class="mt-6 flex gap-3">
<a href="./login.html" class="flex-1 text-center px-4 py-2 rounded-lg bg-slate-100 hover:bg-slate-200 text-slate-700 font-semibold transition-colors">Back to Login</a>
<a id="signInBtn" href="./login.html" class="hidden flex-1 text-center px-4 py-2 rounded-lg bg-blue-600 hover:bg-blue-700 text-white font-semibold transition-colors">Sign In</a>
</div>
</section>
</main>
<script>
(async () => {
const statusBox = document.getElementById('statusBox');
const signInBtn = document.getElementById('signInBtn');
const params = new URLSearchParams(window.location.search);
const token = params.get('token');
if (!token) {
statusBox.className = 'rounded-lg border px-4 py-3 text-sm bg-red-50 border-red-200 text-red-700';
statusBox.textContent = 'Missing verification token.';
return;
}
try {
const response = await fetch(`/api/auth/verify-email?token=${encodeURIComponent(token)}`);
const data = await response.json();
if (response.ok && data.success) {
statusBox.className = 'rounded-lg border px-4 py-3 text-sm bg-green-50 border-green-200 text-green-700';
if (data.autoLogin && data.user) {
localStorage.setItem('currentUser', JSON.stringify(data.user));
statusBox.textContent = data.message || 'Email confirmed. Redirecting to dashboard...';
setTimeout(() => {
window.location.href = './index.html';
}, 1000);
} else {
statusBox.textContent = data.message || 'Email confirmed successfully.';
signInBtn.classList.remove('hidden');
}
} else {
statusBox.className = 'rounded-lg border px-4 py-3 text-sm bg-red-50 border-red-200 text-red-700';
statusBox.textContent = data.message || 'Email confirmation failed.';
}
} catch (error) {
statusBox.className = 'rounded-lg border px-4 py-3 text-sm bg-red-50 border-red-200 text-red-700';
statusBox.textContent = 'Cannot verify email right now. Please try again later.';
console.error('Verify email page error:', error);
}
})();
</script>
</body>
</html>