Compare commits
2 Commits
b3c1283694
...
a78769cfde
| Author | SHA1 | Date | |
|---|---|---|---|
| a78769cfde | |||
| 5a7bf191d0 |
@@ -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
|
(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:
|
Từ máy dev:
|
||||||
```powershell
|
```powershell
|
||||||
scp .env robotics@172.20.235.176:~/accmanager/.env
|
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
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
19
README.md
19
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)
|
- 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
|
Phiên bản tài liệu: 3.0.0
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const sql = require('mssql');
|
|||||||
const cors = require('cors');
|
const cors = require('cors');
|
||||||
const bcrypt = require('bcrypt');
|
const bcrypt = require('bcrypt');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
|
const nodemailer = require('nodemailer');
|
||||||
const dotenv = require('dotenv');
|
const dotenv = require('dotenv');
|
||||||
const app = express();
|
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_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_KEY = crypto.createHash('sha256').update(String(PASSWORD_VIEW_SECRET)).digest();
|
||||||
const PASSWORD_VIEW_PREFIX = 'enc:v1';
|
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) {
|
function isBcryptHash(value) {
|
||||||
return typeof value === 'string' && /^\$2[aby]\$\d{2}\$[./A-Za-z0-9]{53}$/.test(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
|
// Middleware
|
||||||
app.use(cors());
|
app.use(cors());
|
||||||
app.use(express.json());
|
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','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.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','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
|
// Backfill Url to empty string to avoid undefined in responses
|
||||||
await pool.request().query(`UPDATE Applications SET Url = '' WHERE Url IS NULL;`);
|
await pool.request().query(`UPDATE Applications SET Url = '' WHERE Url IS NULL;`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -302,8 +406,13 @@ async function createTables() {
|
|||||||
.input('fullname', sql.NVarChar, 'Administrator')
|
.input('fullname', sql.NVarChar, 'Administrator')
|
||||||
.input('role', sql.NVarChar, 'admin')
|
.input('role', sql.NVarChar, 'admin')
|
||||||
.query(`IF NOT EXISTS (SELECT * FROM Users WHERE Username = @username)
|
.query(`IF NOT EXISTS (SELECT * FROM Users WHERE Username = @username)
|
||||||
INSERT INTO Users (Username, Password, ViewPassword, Email, FullName, Role, IsActive)
|
INSERT INTO Users (Username, Password, ViewPassword, Email, FullName, Role, IsActive, EmailVerified, EmailVerifiedAt)
|
||||||
VALUES (@username, @password, @viewPassword, @email, @fullname, @role, 1)`);
|
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');
|
console.log('✓ Admin user created: admin / admin');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Admin user error:', err.message);
|
console.error('Admin user error:', err.message);
|
||||||
@@ -345,7 +454,10 @@ app.post('/api/auth/login', async (req, res) => {
|
|||||||
|
|
||||||
const result = await pool.request()
|
const result = await pool.request()
|
||||||
.input('username', sql.NVarChar, username)
|
.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) {
|
if (result.recordset.length > 0) {
|
||||||
const dbUser = result.recordset[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.
|
// Upgrade old plain-text passwords after successful legacy login.
|
||||||
if (!isBcryptHash(dbUser.Password)) {
|
if (!isBcryptHash(dbUser.Password)) {
|
||||||
const upgradedHash = await hashPassword(password);
|
const upgradedHash = await hashPassword(password);
|
||||||
@@ -398,23 +520,37 @@ app.post('/api/auth/register', async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const { username, password, email, fullname } = req.body;
|
const { username, password, email, fullname } = req.body;
|
||||||
|
|
||||||
if (!username || !password) {
|
if (!username || !password || !email) {
|
||||||
return res.status(400).json({ success: false, message: 'Username and password are required' });
|
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()
|
const existing = await pool.request()
|
||||||
.input('username', sql.NVarChar, username)
|
.input('username', sql.NVarChar, safeUsername)
|
||||||
.query('SELECT UserId FROM Users WHERE Username = @username');
|
.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) {
|
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 hashedPassword = await hashPassword(password);
|
||||||
const viewPassword = encryptPasswordForView(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 guestRoleName = 'guest';
|
||||||
let guestRoleId = null;
|
let guestRoleId = null;
|
||||||
|
|
||||||
@@ -449,31 +585,34 @@ app.post('/api/auth/register', async (req, res) => {
|
|||||||
|
|
||||||
if (hasRoleIdColumn && guestRoleId !== null) {
|
if (hasRoleIdColumn && guestRoleId !== null) {
|
||||||
result = await pool.request()
|
result = await pool.request()
|
||||||
.input('username', sql.NVarChar, username)
|
.input('username', sql.NVarChar, safeUsername)
|
||||||
.input('password', sql.NVarChar, hashedPassword)
|
.input('password', sql.NVarChar, hashedPassword)
|
||||||
.input('email', sql.NVarChar, email || null)
|
.input('email', sql.NVarChar, normalizedEmail)
|
||||||
.input('fullname', sql.NVarChar, safeFullname)
|
.input('fullname', sql.NVarChar, safeFullname)
|
||||||
.input('roleId', sql.Int, guestRoleId)
|
.input('roleId', sql.Int, guestRoleId)
|
||||||
.input('role', sql.NVarChar, guestRoleName)
|
.input('role', sql.NVarChar, guestRoleName)
|
||||||
.input('viewPassword', sql.NVarChar, viewPassword)
|
.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
|
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 {
|
} else {
|
||||||
result = await pool.request()
|
result = await pool.request()
|
||||||
.input('username', sql.NVarChar, username)
|
.input('username', sql.NVarChar, safeUsername)
|
||||||
.input('password', sql.NVarChar, hashedPassword)
|
.input('password', sql.NVarChar, hashedPassword)
|
||||||
.input('email', sql.NVarChar, email || null)
|
.input('email', sql.NVarChar, normalizedEmail)
|
||||||
.input('fullname', sql.NVarChar, safeFullname)
|
.input('fullname', sql.NVarChar, safeFullname)
|
||||||
.input('role', sql.NVarChar, guestRoleName)
|
.input('role', sql.NVarChar, guestRoleName)
|
||||||
.input('viewPassword', sql.NVarChar, viewPassword)
|
.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
|
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 inserted = result.recordset[0];
|
||||||
const user = { ...inserted, role: inserted.Role || guestRoleName || 'guest' };
|
|
||||||
|
|
||||||
// Repair previous self-registered users that were wrongly assigned Admin RoleId.
|
// Repair previous self-registered users that were wrongly assigned Admin RoleId.
|
||||||
if (hasRoleIdColumn && guestRoleId !== null) {
|
if (hasRoleIdColumn && guestRoleId !== null) {
|
||||||
@@ -486,13 +625,162 @@ app.post('/api/auth/register', async (req, res) => {
|
|||||||
AND (RoleId IS NULL OR RoleId <> @guestRoleId)`);
|
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) {
|
} catch (err) {
|
||||||
console.error('Registration error:', err);
|
console.error('Registration error:', err);
|
||||||
res.status(500).json({ success: false, message: 'Registration failed' });
|
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
|
// Middleware for role-based access control
|
||||||
const requireAdmin = (req, res, next) => {
|
const requireAdmin = (req, res, next) => {
|
||||||
const userRole = req.headers['x-user-role'] || req.query.userRole;
|
const userRole = req.headers['x-user-role'] || req.query.userRole;
|
||||||
@@ -613,7 +901,7 @@ app.get('/api/users', async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const result = await pool.request()
|
const result = await pool.request()
|
||||||
.query(`SELECT u.UserId, u.Username, u.Email, u.FullName, u.Role, u.RoleId, r.RoleName,
|
.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
|
FROM Users u
|
||||||
LEFT JOIN Roles r ON u.RoleId = r.RoleId
|
LEFT JOIN Roles r ON u.RoleId = r.RoleId
|
||||||
ORDER BY u.CreatedDate DESC`);
|
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
|
// Get user by ID
|
||||||
app.get('/api/users/:id', requireAdmin, async (req, res) => {
|
app.get('/api/users/:id', requireAdmin, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -1012,8 +1453,6 @@ app.use((err, req, res, next) => {
|
|||||||
// Server Startup
|
// Server Startup
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|
||||||
const PORT = process.env.PORT || 3000;
|
|
||||||
|
|
||||||
async function startServer() {
|
async function startServer() {
|
||||||
try {
|
try {
|
||||||
await initializeDatabase();
|
await initializeDatabase();
|
||||||
|
|||||||
@@ -15,4 +15,12 @@ services:
|
|||||||
DB_ENCRYPT: ${DB_ENCRYPT:-false}
|
DB_ENCRYPT: ${DB_ENCRYPT:-false}
|
||||||
DB_TRUST_CERTIFICATE: ${DB_TRUST_CERTIFICATE:-true}
|
DB_TRUST_CERTIFICATE: ${DB_TRUST_CERTIFICATE:-true}
|
||||||
DB_CONNECT_TIMEOUT: ${DB_CONNECT_TIMEOUT:-30000}
|
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}
|
||||||
@@ -17,4 +17,12 @@ services:
|
|||||||
DB_ENCRYPT: ${DB_ENCRYPT:-false}
|
DB_ENCRYPT: ${DB_ENCRYPT:-false}
|
||||||
DB_TRUST_CERTIFICATE: ${DB_TRUST_CERTIFICATE:-true}
|
DB_TRUST_CERTIFICATE: ${DB_TRUST_CERTIFICATE:-true}
|
||||||
DB_CONNECT_TIMEOUT: ${DB_CONNECT_TIMEOUT:-30000}
|
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
12
package-lock.json
generated
@@ -13,7 +13,8 @@
|
|||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.3.1",
|
"dotenv": "^16.3.1",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"mssql": "^9.1.1"
|
"mssql": "^9.1.1",
|
||||||
|
"nodemailer": "^8.0.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/container-queries": "^0.1.1",
|
"@tailwindcss/container-queries": "^0.1.1",
|
||||||
@@ -2821,6 +2822,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/nodemon": {
|
||||||
"version": "3.1.14",
|
"version": "3.1.14",
|
||||||
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz",
|
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz",
|
||||||
|
|||||||
@@ -22,7 +22,8 @@
|
|||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.3.1",
|
"dotenv": "^16.3.1",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"mssql": "^9.1.1"
|
"mssql": "^9.1.1",
|
||||||
|
"nodemailer": "^8.0.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/container-queries": "^0.1.1",
|
"@tailwindcss/container-queries": "^0.1.1",
|
||||||
|
|||||||
243
public/js/app.js
243
public/js/app.js
@@ -94,6 +94,19 @@ class AccountManager {
|
|||||||
return this.getCurrentUserRole() === 'admin';
|
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() {
|
async init() {
|
||||||
await this.fetchApplications();
|
await this.fetchApplications();
|
||||||
await this.fetchAccounts();
|
await this.fetchAccounts();
|
||||||
@@ -282,6 +295,11 @@ class AccountManager {
|
|||||||
logoutBtn.addEventListener('click', () => this.handleLogout());
|
logoutBtn.addEventListener('click', () => this.handleLogout());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const profileBtn = document.getElementById('profileBtn');
|
||||||
|
if (profileBtn) {
|
||||||
|
profileBtn.addEventListener('click', () => this.openProfileModal());
|
||||||
|
}
|
||||||
|
|
||||||
// Update account display
|
// Update account display
|
||||||
this.updateAccountDisplay();
|
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) {
|
loadFromStorage(key) {
|
||||||
const data = localStorage.getItem(key);
|
const data = localStorage.getItem(key);
|
||||||
return data ? JSON.parse(data) : null;
|
return data ? JSON.parse(data) : null;
|
||||||
@@ -1618,7 +1848,7 @@ class AccountManager {
|
|||||||
try {
|
try {
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
method,
|
method,
|
||||||
headers: { 'Content-Type': 'application/json', 'x-user-role': this.getCurrentUserRole() },
|
headers: this.getAuthHeaders(true),
|
||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(payload)
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1643,7 +1873,7 @@ class AccountManager {
|
|||||||
async viewUserDetails(userId) {
|
async viewUserDetails(userId) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${this.apiBase}/users/${userId}`, {
|
const response = await fetch(`${this.apiBase}/users/${userId}`, {
|
||||||
headers: { 'x-user-role': this.getCurrentUserRole() }
|
headers: this.getAuthHeaders(false)
|
||||||
});
|
});
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
@@ -1804,7 +2034,7 @@ class AccountManager {
|
|||||||
try {
|
try {
|
||||||
const response = await fetch(`${this.apiBase}/users/${userId}`, {
|
const response = await fetch(`${this.apiBase}/users/${userId}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { 'x-user-role': this.getCurrentUserRole() }
|
headers: this.getAuthHeaders(false)
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await response.json();
|
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
|
// Initialize app when DOM is ready
|
||||||
let app;
|
let app;
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
|||||||
@@ -88,13 +88,13 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-4">
|
<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>
|
<span class="material-symbols-outlined text-slate-600 dark:text-slate-400">account_circle</span>
|
||||||
<div class="flex flex-col">
|
<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="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>
|
<span id="accountRole" class="text-[10px] text-slate-500 dark:text-slate-400">Administrator</span>
|
||||||
</div>
|
</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">
|
<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>
|
<span class="material-symbols-outlined">logout</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -107,6 +107,14 @@
|
|||||||
|
|
||||||
<!-- Error Message -->
|
<!-- 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="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>
|
</form>
|
||||||
|
|
||||||
<!-- Register Form -->
|
<!-- Register Form -->
|
||||||
@@ -186,6 +194,7 @@
|
|||||||
</button>
|
</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="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>
|
</form>
|
||||||
|
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
@@ -210,12 +219,17 @@
|
|||||||
const rememberCheckbox = document.getElementById('remember');
|
const rememberCheckbox = document.getElementById('remember');
|
||||||
const registerForm = document.getElementById('registerForm');
|
const registerForm = document.getElementById('registerForm');
|
||||||
const registerErrorMessage = document.getElementById('registerErrorMessage');
|
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 regFullnameInput = document.getElementById('regFullname');
|
||||||
const regEmailInput = document.getElementById('regEmail');
|
const regEmailInput = document.getElementById('regEmail');
|
||||||
const regUsernameInput = document.getElementById('regUsername');
|
const regUsernameInput = document.getElementById('regUsername');
|
||||||
const regPasswordInput = document.getElementById('regPassword');
|
const regPasswordInput = document.getElementById('regPassword');
|
||||||
const loginTab = document.getElementById('loginTab');
|
const loginTab = document.getElementById('loginTab');
|
||||||
const registerTab = document.getElementById('registerTab');
|
const registerTab = document.getElementById('registerTab');
|
||||||
|
let pendingVerificationIdentifier = '';
|
||||||
let currentMode = 'login';
|
let currentMode = 'login';
|
||||||
|
|
||||||
const setMode = (mode) => {
|
const setMode = (mode) => {
|
||||||
@@ -226,6 +240,8 @@
|
|||||||
registerForm.classList.toggle('hidden', isLogin);
|
registerForm.classList.toggle('hidden', isLogin);
|
||||||
errorMessage.classList.add('hidden');
|
errorMessage.classList.add('hidden');
|
||||||
registerErrorMessage.classList.add('hidden');
|
registerErrorMessage.classList.add('hidden');
|
||||||
|
registerSuccessMessage.classList.add('hidden');
|
||||||
|
verifyNotice.classList.add('hidden');
|
||||||
|
|
||||||
const activate = (btn, active) => {
|
const activate = (btn, active) => {
|
||||||
btn.classList.toggle('bg-primary', active);
|
btn.classList.toggle('bg-primary', active);
|
||||||
@@ -262,6 +278,7 @@
|
|||||||
loginForm.addEventListener('submit', async (e) => {
|
loginForm.addEventListener('submit', async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
errorMessage.classList.add('hidden');
|
errorMessage.classList.add('hidden');
|
||||||
|
verifyNotice.classList.add('hidden');
|
||||||
|
|
||||||
const username = usernameInput.value.trim();
|
const username = usernameInput.value.trim();
|
||||||
const password = passwordInput.value;
|
const password = passwordInput.value;
|
||||||
@@ -291,6 +308,10 @@
|
|||||||
|
|
||||||
// Redirect to dashboard
|
// Redirect to dashboard
|
||||||
window.location.href = './index.html';
|
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 {
|
} else {
|
||||||
// Show error
|
// Show error
|
||||||
errorMessage.textContent = data.message || 'Invalid username or password';
|
errorMessage.textContent = data.message || 'Invalid username or password';
|
||||||
@@ -308,6 +329,7 @@
|
|||||||
registerForm.addEventListener('submit', async (e) => {
|
registerForm.addEventListener('submit', async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
registerErrorMessage.classList.add('hidden');
|
registerErrorMessage.classList.add('hidden');
|
||||||
|
registerSuccessMessage.classList.add('hidden');
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
fullname: regFullnameInput.value.trim(),
|
fullname: regFullnameInput.value.trim(),
|
||||||
@@ -316,8 +338,8 @@
|
|||||||
password: regPasswordInput.value
|
password: regPasswordInput.value
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!payload.username || !payload.password) {
|
if (!payload.username || !payload.password || !payload.email) {
|
||||||
registerErrorMessage.textContent = 'Username and password are required';
|
registerErrorMessage.textContent = 'Username, password and email are required';
|
||||||
registerErrorMessage.classList.remove('hidden');
|
registerErrorMessage.classList.remove('hidden');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -333,10 +355,19 @@
|
|||||||
const isJson = response.headers.get('content-type')?.includes('application/json');
|
const isJson = response.headers.get('content-type')?.includes('application/json');
|
||||||
const data = isJson ? await response.json() : null;
|
const data = isJson ? await response.json() : null;
|
||||||
|
|
||||||
if (response.ok && data?.success && data.user) {
|
if (response.ok && data?.success) {
|
||||||
localStorage.setItem('currentUser', JSON.stringify(data.user));
|
const lines = [data.message || 'Registration successful. Please check your email to confirm account.'];
|
||||||
localStorage.setItem('rememberedUsername', payload.username);
|
if (data.verificationPreviewUrl) {
|
||||||
window.location.href = './index.html';
|
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 {
|
} else {
|
||||||
const fallback = isJson ? (data?.message || 'Registration failed') : 'Server error (HTML response)';
|
const fallback = isJson ? (data?.message || 'Registration failed') : 'Server error (HTML response)';
|
||||||
registerErrorMessage.textContent = fallback;
|
registerErrorMessage.textContent = fallback;
|
||||||
@@ -348,6 +379,35 @@
|
|||||||
console.error('Register error:', error);
|
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>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
70
public/pages/verify-email.html
Normal file
70
public/pages/verify-email.html
Normal 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>
|
||||||
Reference in New Issue
Block a user