1519 lines
60 KiB
JavaScript
1519 lines
60 KiB
JavaScript
// Backend Server for AccManager
|
|
// Express.js + mssql
|
|
|
|
const express = require('express');
|
|
const sql = require('mssql');
|
|
const cors = require('cors');
|
|
const bcrypt = require('bcrypt');
|
|
const crypto = require('crypto');
|
|
const nodemailer = require('nodemailer');
|
|
const multer = require('multer');
|
|
const XLSX = require('xlsx');
|
|
const dotenv = require('dotenv');
|
|
const app = express();
|
|
|
|
dotenv.config();
|
|
|
|
function envBool(name, defaultValue) {
|
|
const value = process.env[name];
|
|
if (value === undefined) {
|
|
return defaultValue;
|
|
}
|
|
|
|
return ['1', 'true', 'yes', 'on'].includes(String(value).toLowerCase());
|
|
}
|
|
|
|
const DB_SERVER = process.env.DB_SERVER || '172.20.235.176';
|
|
const DB_USER = process.env.DB_USER || 'sa';
|
|
const DB_PASSWORD = process.env.DB_PASSWORD || 'robotics@2022';
|
|
const DB_NAME = process.env.DB_NAME || 'AccManager';
|
|
const DB_ENCRYPT = envBool('DB_ENCRYPT', false);
|
|
const DB_TRUST_CERTIFICATE = envBool('DB_TRUST_CERTIFICATE', true);
|
|
const DB_CONNECT_TIMEOUT = Number(process.env.DB_CONNECT_TIMEOUT || 30000);
|
|
|
|
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);
|
|
}
|
|
|
|
async function hashPassword(plainPassword) {
|
|
return bcrypt.hash(String(plainPassword), BCRYPT_ROUNDS);
|
|
}
|
|
|
|
async function verifyPassword(plainPassword, storedPassword) {
|
|
if (isBcryptHash(storedPassword)) {
|
|
return bcrypt.compare(String(plainPassword), storedPassword);
|
|
}
|
|
|
|
// Legacy fallback for old plain-text records.
|
|
return String(plainPassword) === String(storedPassword || '');
|
|
}
|
|
|
|
function encryptPasswordForView(plainPassword) {
|
|
const iv = crypto.randomBytes(12);
|
|
const cipher = crypto.createCipheriv('aes-256-gcm', PASSWORD_VIEW_KEY, iv);
|
|
const encrypted = Buffer.concat([
|
|
cipher.update(String(plainPassword), 'utf8'),
|
|
cipher.final()
|
|
]);
|
|
const tag = cipher.getAuthTag();
|
|
|
|
return `${PASSWORD_VIEW_PREFIX}:${iv.toString('base64')}:${tag.toString('base64')}:${encrypted.toString('base64')}`;
|
|
}
|
|
|
|
function decryptPasswordForView(payload) {
|
|
try {
|
|
if (typeof payload !== 'string' || !payload.startsWith(`${PASSWORD_VIEW_PREFIX}:`)) {
|
|
return null;
|
|
}
|
|
|
|
const parts = payload.split(':');
|
|
if (parts.length !== 5) {
|
|
return null;
|
|
}
|
|
|
|
const iv = Buffer.from(parts[2], 'base64');
|
|
const tag = Buffer.from(parts[3], 'base64');
|
|
const encrypted = Buffer.from(parts[4], 'base64');
|
|
const decipher = crypto.createDecipheriv('aes-256-gcm', PASSWORD_VIEW_KEY, iv);
|
|
decipher.setAuthTag(tag);
|
|
const plain = Buffer.concat([decipher.update(encrypted), decipher.final()]);
|
|
return plain.toString('utf8');
|
|
} catch (err) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
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());
|
|
const upload = multer({
|
|
storage: multer.memoryStorage(),
|
|
limits: { fileSize: 15 * 1024 * 1024 }
|
|
});
|
|
|
|
// Serve static files from /public
|
|
const path = require('path');
|
|
const publicDir = path.join(__dirname, '..', 'public');
|
|
app.use(express.static(publicDir));
|
|
|
|
// Root route
|
|
app.get('/', (req, res) => {
|
|
res.sendFile(path.join(publicDir, 'pages', 'login.html'));
|
|
});
|
|
|
|
// SQL Server Configuration
|
|
const sqlConfig = {
|
|
server: DB_SERVER,
|
|
authentication: {
|
|
type: 'default',
|
|
options: {
|
|
userName: DB_USER,
|
|
password: DB_PASSWORD
|
|
}
|
|
},
|
|
options: {
|
|
database: DB_NAME,
|
|
trustServerCertificate: DB_TRUST_CERTIFICATE,
|
|
enableKeepAlive: true,
|
|
connectTimeout: DB_CONNECT_TIMEOUT,
|
|
encrypt: DB_ENCRYPT
|
|
}
|
|
};
|
|
|
|
// Initialize Database Pool
|
|
let pool;
|
|
|
|
async function initializeDatabase() {
|
|
try {
|
|
pool = new sql.ConnectionPool(sqlConfig);
|
|
await pool.connect();
|
|
console.log('✓ Connected to SQL Server');
|
|
|
|
// Check and create database if not exists
|
|
const masterConnection = new sql.ConnectionPool({
|
|
server: DB_SERVER,
|
|
authentication: { type: 'default', options: { userName: DB_USER, password: DB_PASSWORD } },
|
|
options: {
|
|
connectTimeout: DB_CONNECT_TIMEOUT,
|
|
database: 'master',
|
|
trustServerCertificate: DB_TRUST_CERTIFICATE,
|
|
encrypt: DB_ENCRYPT
|
|
}
|
|
});
|
|
|
|
await masterConnection.connect();
|
|
const createDbResult = await masterConnection.request()
|
|
.query(`IF NOT EXISTS (SELECT name FROM sys.databases WHERE name = 'AccManager')
|
|
BEGIN
|
|
CREATE DATABASE AccManager;
|
|
END`);
|
|
await masterConnection.close();
|
|
|
|
// Now create tables in AccManager
|
|
await createTables();
|
|
await migrateLegacyPasswords();
|
|
console.log('✓ Database and tables created');
|
|
|
|
} catch (err) {
|
|
console.error('Database connection failed:', err);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
async function migrateLegacyPasswords() {
|
|
try {
|
|
const usersResult = await pool.request()
|
|
.query('SELECT UserId, Password, ViewPassword FROM Users WHERE Password IS NOT NULL');
|
|
|
|
let migratedCount = 0;
|
|
for (const row of usersResult.recordset) {
|
|
const request = pool.request()
|
|
.input('userId', sql.Int, row.UserId);
|
|
|
|
let hasUpdates = false;
|
|
const rawPassword = String(row.Password || '');
|
|
|
|
if (!row.ViewPassword && !isBcryptHash(rawPassword)) {
|
|
request.input('viewPassword', sql.NVarChar, encryptPasswordForView(rawPassword));
|
|
hasUpdates = true;
|
|
}
|
|
|
|
if (!isBcryptHash(row.Password)) {
|
|
const hashedPassword = await hashPassword(row.Password);
|
|
request.input('password', sql.NVarChar, hashedPassword);
|
|
hasUpdates = true;
|
|
migratedCount += 1;
|
|
}
|
|
|
|
if (hasUpdates) {
|
|
await request.query(`UPDATE Users
|
|
SET ${!isBcryptHash(rawPassword) ? 'Password = @password' : 'Password = Password'}
|
|
${(!row.ViewPassword && !isBcryptHash(rawPassword)) ? ', ViewPassword = @viewPassword' : ''}
|
|
WHERE UserId = @userId`);
|
|
}
|
|
}
|
|
|
|
if (migratedCount > 0) {
|
|
console.log(`✓ Migrated ${migratedCount} legacy plain-text password(s) to bcrypt`);
|
|
}
|
|
} catch (err) {
|
|
console.error('Password migration error:', err.message);
|
|
}
|
|
}
|
|
|
|
async function createTables() {
|
|
const queries = [
|
|
// Users Table
|
|
`IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'Users')
|
|
BEGIN
|
|
CREATE TABLE Users (
|
|
UserId INT PRIMARY KEY IDENTITY(1,1),
|
|
Username NVARCHAR(50) UNIQUE NOT NULL,
|
|
Password NVARCHAR(255) NOT NULL,
|
|
Email NVARCHAR(100),
|
|
FullName NVARCHAR(100),
|
|
Role NVARCHAR(50) NOT NULL,
|
|
Status NVARCHAR(20) DEFAULT 'Active',
|
|
CreatedDate DATETIME DEFAULT GETDATE(),
|
|
LastLogin DATETIME,
|
|
IsActive BIT DEFAULT 1
|
|
)
|
|
END`,
|
|
|
|
// Applications Table
|
|
`IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'Applications')
|
|
BEGIN
|
|
CREATE TABLE Applications (
|
|
AppId INT PRIMARY KEY IDENTITY(1,1),
|
|
Name NVARCHAR(100) NOT NULL,
|
|
Type NVARCHAR(50),
|
|
Status NVARCHAR(20) DEFAULT 'online',
|
|
Icon NVARCHAR(50),
|
|
Description NVARCHAR(500),
|
|
Url NVARCHAR(255),
|
|
CreatedDate DATETIME DEFAULT GETDATE(),
|
|
UpdatedDate DATETIME DEFAULT GETDATE()
|
|
)
|
|
END`,
|
|
|
|
// Accounts Table
|
|
`IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'Accounts')
|
|
BEGIN
|
|
CREATE TABLE Accounts (
|
|
AccountId INT PRIMARY KEY IDENTITY(1,1),
|
|
UserId INT NOT NULL,
|
|
AppId INT NOT NULL,
|
|
AccountUsername NVARCHAR(100),
|
|
AccountPassword NVARCHAR(255),
|
|
Email NVARCHAR(100),
|
|
AccessLevel NVARCHAR(50),
|
|
Status NVARCHAR(20) DEFAULT 'Active',
|
|
Notes NVARCHAR(MAX),
|
|
CreatedDate DATETIME DEFAULT GETDATE(),
|
|
UpdatedDate DATETIME DEFAULT GETDATE(),
|
|
FOREIGN KEY (UserId) REFERENCES Users(UserId) ON DELETE CASCADE,
|
|
FOREIGN KEY (AppId) REFERENCES Applications(AppId) ON DELETE CASCADE
|
|
)
|
|
END`,
|
|
|
|
// AuditLog Table
|
|
`IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'AuditLog')
|
|
BEGIN
|
|
CREATE TABLE AuditLog (
|
|
LogId INT PRIMARY KEY IDENTITY(1,1),
|
|
UserId INT,
|
|
Action NVARCHAR(50),
|
|
TableName NVARCHAR(50),
|
|
RecordId INT,
|
|
OldValue NVARCHAR(MAX),
|
|
NewValue NVARCHAR(MAX),
|
|
Timestamp DATETIME DEFAULT GETDATE(),
|
|
FOREIGN KEY (UserId) REFERENCES Users(UserId)
|
|
)
|
|
END`
|
|
];
|
|
|
|
for (let query of queries) {
|
|
try {
|
|
await pool.request().query(query);
|
|
} catch (err) {
|
|
console.error('Table creation error:', err.message);
|
|
}
|
|
}
|
|
|
|
// Ensure AssetInventory indexes exist for lookup/filter performance
|
|
try {
|
|
await pool.request().query(`IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_AssetInventory_AssetCode') CREATE INDEX IX_AssetInventory_AssetCode ON AssetInventory(AssetCode);`);
|
|
await pool.request().query(`IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_AssetInventory_Status') CREATE INDEX IX_AssetInventory_Status ON AssetInventory(Status);`);
|
|
} catch (err) {
|
|
console.error('AssetInventory index creation error:', err.message);
|
|
}
|
|
|
|
// Ensure new columns exist on Applications for migrations
|
|
try {
|
|
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) {
|
|
console.error('Column addition error (Applications):', err.message);
|
|
}
|
|
|
|
// Insert initial admin user
|
|
try {
|
|
const adminPasswordHash = await hashPassword('admin');
|
|
const adminViewPassword = encryptPasswordForView('admin');
|
|
await pool.request()
|
|
.input('username', sql.NVarChar, 'admin')
|
|
.input('password', sql.NVarChar, adminPasswordHash)
|
|
.input('viewPassword', sql.NVarChar, adminViewPassword)
|
|
.input('email', sql.NVarChar, 'admin@accmanager.local')
|
|
.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, 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);
|
|
}
|
|
|
|
// Insert sample applications
|
|
try {
|
|
await pool.request()
|
|
.query(`IF (SELECT COUNT(*) FROM Applications) = 0
|
|
BEGIN
|
|
INSERT INTO Applications (Name, Type, Status, Icon, Description, Url)
|
|
VALUES
|
|
('AWS', 'Cloud', 'online', 'cloud', 'Amazon Web Services', 'https://aws.amazon.com'),
|
|
('GitHub', 'VCS', 'online', 'code', 'GitHub - Version Control', 'https://github.com'),
|
|
('Google Workspace', 'Collaboration', 'online', 'mail', 'Google Workspace', 'https://workspace.google.com'),
|
|
('Nginx Proxy', 'Infra', 'offline', 'dns', 'Nginx Web Server', 'https://nginx.org')
|
|
END`);
|
|
console.log('✓ Sample applications created');
|
|
} catch (err) {
|
|
console.error('Applications error:', err.message);
|
|
}
|
|
}
|
|
|
|
// ==========================================
|
|
// API ROUTES - Authentication
|
|
// ==========================================
|
|
|
|
// Login endpoint
|
|
app.post('/api/auth/login', async (req, res) => {
|
|
try {
|
|
const { username, password } = req.body;
|
|
|
|
if (!username || !password) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
message: 'Username and password are required'
|
|
});
|
|
}
|
|
|
|
const result = await pool.request()
|
|
.input('username', sql.NVarChar, username)
|
|
.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];
|
|
const isValidPassword = await verifyPassword(password, dbUser.Password);
|
|
|
|
if (!isValidPassword) {
|
|
return res.status(401).json({
|
|
success: false,
|
|
message: 'Invalid username or password'
|
|
});
|
|
}
|
|
|
|
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);
|
|
await pool.request()
|
|
.input('userId', sql.Int, dbUser.UserId)
|
|
.input('password', sql.NVarChar, upgradedHash)
|
|
.input('viewPassword', sql.NVarChar, encryptPasswordForView(password))
|
|
.query('UPDATE Users SET Password = @password, ViewPassword = ISNULL(ViewPassword, @viewPassword) WHERE UserId = @userId');
|
|
}
|
|
|
|
const { Password: _, ...safeUser } = dbUser;
|
|
const user = { ...safeUser, role: safeUser.Role || safeUser.role || 'guest' };
|
|
|
|
// Update last login
|
|
await pool.request()
|
|
.input('userId', sql.Int, user.UserId)
|
|
.query('UPDATE Users SET LastLogin = GETDATE() WHERE UserId = @userId');
|
|
|
|
res.json({
|
|
success: true,
|
|
message: 'Login successful',
|
|
user: user
|
|
});
|
|
} else {
|
|
res.status(401).json({
|
|
success: false,
|
|
message: 'Invalid username or password'
|
|
});
|
|
}
|
|
} catch (err) {
|
|
console.error('Login error:', err);
|
|
res.status(500).json({ success: false, message: err.message });
|
|
}
|
|
});
|
|
|
|
// Public registration endpoint
|
|
app.post('/api/auth/register', async (req, res) => {
|
|
try {
|
|
const { username, password, email, fullname } = req.body;
|
|
|
|
if (!username || !password || !email) {
|
|
return res.status(400).json({ success: false, message: 'Username, password and email are required' });
|
|
}
|
|
|
|
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, 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) {
|
|
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() : safeUsername;
|
|
let guestRoleName = 'guest';
|
|
let guestRoleId = null;
|
|
|
|
const hasRoleIdColResult = await pool.request()
|
|
.query("SELECT CASE WHEN COL_LENGTH('dbo.Users','RoleId') IS NULL THEN 0 ELSE 1 END AS HasRoleId");
|
|
const hasRoleIdColumn = hasRoleIdColResult.recordset[0].HasRoleId === 1;
|
|
|
|
const hasRolesTableResult = await pool.request()
|
|
.query("SELECT CASE WHEN OBJECT_ID('dbo.Roles','U') IS NULL THEN 0 ELSE 1 END AS HasRolesTable");
|
|
const hasRolesTable = hasRolesTableResult.recordset[0].HasRolesTable === 1;
|
|
|
|
if (hasRolesTable) {
|
|
const guestRoleResult = await pool.request().query(`
|
|
IF NOT EXISTS (SELECT 1 FROM Roles WHERE LOWER(RoleName) = 'guest')
|
|
BEGIN
|
|
INSERT INTO Roles (RoleName, Description)
|
|
VALUES ('Guest', 'Default role for self-registered users');
|
|
END
|
|
|
|
SELECT TOP 1 RoleId, RoleName
|
|
FROM Roles
|
|
WHERE LOWER(RoleName) = 'guest';
|
|
`);
|
|
|
|
if (guestRoleResult.recordset.length > 0) {
|
|
guestRoleId = guestRoleResult.recordset[0].RoleId;
|
|
guestRoleName = guestRoleResult.recordset[0].RoleName || 'guest';
|
|
}
|
|
}
|
|
|
|
let result;
|
|
|
|
if (hasRoleIdColumn && guestRoleId !== null) {
|
|
result = await pool.request()
|
|
.input('username', sql.NVarChar, safeUsername)
|
|
.input('password', sql.NVarChar, hashedPassword)
|
|
.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)
|
|
.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, 0, @emailVerifyToken, DATEADD(MINUTE, @tokenTtlMinutes, GETDATE()))`);
|
|
} else {
|
|
result = await pool.request()
|
|
.input('username', sql.NVarChar, safeUsername)
|
|
.input('password', sql.NVarChar, hashedPassword)
|
|
.input('email', sql.NVarChar, normalizedEmail)
|
|
.input('fullname', sql.NVarChar, safeFullname)
|
|
.input('role', sql.NVarChar, guestRoleName)
|
|
.input('viewPassword', sql.NVarChar, viewPassword)
|
|
.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, 0, @emailVerifyToken, DATEADD(MINUTE, @tokenTtlMinutes, GETDATE()))`);
|
|
}
|
|
|
|
const inserted = result.recordset[0];
|
|
|
|
// Repair previous self-registered users that were wrongly assigned Admin RoleId.
|
|
if (hasRoleIdColumn && guestRoleId !== null) {
|
|
await pool.request()
|
|
.input('guestRoleId', sql.Int, guestRoleId)
|
|
.query(`UPDATE Users
|
|
SET RoleId = @guestRoleId,
|
|
Role = 'Guest'
|
|
WHERE LOWER(Role) = 'guest'
|
|
AND (RoleId IS NULL OR RoleId <> @guestRoleId)`);
|
|
}
|
|
|
|
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
|
|
function normalizeRole(value) {
|
|
return String(value || '').trim().toLowerCase();
|
|
}
|
|
|
|
function requireRoles(roles = [], message = 'Access denied') {
|
|
const allowedRoles = new Set(roles.map(normalizeRole));
|
|
|
|
return (req, res, next) => {
|
|
const userRole = normalizeRole(req.headers['x-user-role'] || req.query.userRole);
|
|
if (!allowedRoles.has(userRole)) {
|
|
return res.status(403).json({ success: false, message });
|
|
}
|
|
next();
|
|
};
|
|
}
|
|
|
|
const requireAdmin = requireRoles(['admin'], 'Admin access required');
|
|
const requireAssetOrAdmin = requireRoles(['asset', 'admin'], 'Asset or Admin access required');
|
|
|
|
// ==========================================
|
|
// API ROUTES - Roles
|
|
// ==========================================
|
|
|
|
// Get all roles
|
|
app.get('/api/roles', async (req, res) => {
|
|
try {
|
|
const result = await pool.request()
|
|
.query('SELECT RoleId, RoleName, Description, CreatedDate FROM Roles ORDER BY RoleName');
|
|
res.json({ success: true, data: result.recordset });
|
|
} catch (err) {
|
|
res.status(500).json({ success: false, message: err.message });
|
|
}
|
|
});
|
|
|
|
// Get role by ID
|
|
app.get('/api/roles/:id', async (req, res) => {
|
|
try {
|
|
const result = await pool.request()
|
|
.input('roleId', sql.Int, req.params.id)
|
|
.query('SELECT * FROM Roles WHERE RoleId = @roleId');
|
|
|
|
if (result.recordset.length > 0) {
|
|
res.json({ success: true, data: result.recordset[0] });
|
|
} else {
|
|
res.status(404).json({ success: false, message: 'Role not found' });
|
|
}
|
|
} catch (err) {
|
|
res.status(500).json({ success: false, message: err.message });
|
|
}
|
|
});
|
|
|
|
// Create new role (Admin only)
|
|
app.post('/api/roles', requireAdmin, async (req, res) => {
|
|
try {
|
|
const { roleName, description } = req.body;
|
|
|
|
const result = await pool.request()
|
|
.input('roleName', sql.NVarChar, roleName)
|
|
.input('description', sql.NVarChar, description)
|
|
.query(`IF NOT EXISTS (SELECT * FROM Roles WHERE RoleName = @roleName)
|
|
BEGIN
|
|
INSERT INTO Roles (RoleName, Description)
|
|
VALUES (@roleName, @description);
|
|
SELECT SCOPE_IDENTITY() as RoleId
|
|
END
|
|
ELSE
|
|
BEGIN
|
|
SELECT RoleId FROM Roles WHERE RoleName = @roleName
|
|
END`);
|
|
|
|
res.json({ success: true, message: 'Role created', roleId: result.recordset[0].RoleId });
|
|
} catch (err) {
|
|
res.status(500).json({ success: false, message: err.message });
|
|
}
|
|
});
|
|
|
|
// Update role (Admin only)
|
|
app.put('/api/roles/:id', requireAdmin, async (req, res) => {
|
|
try {
|
|
const { roleName, description } = req.body;
|
|
|
|
await pool.request()
|
|
.input('roleId', sql.Int, req.params.id)
|
|
.input('roleName', sql.NVarChar, roleName)
|
|
.input('description', sql.NVarChar, description)
|
|
.query(`UPDATE Roles
|
|
SET RoleName = @roleName,
|
|
Description = @description
|
|
WHERE RoleId = @roleId`);
|
|
|
|
res.json({ success: true, message: 'Role updated' });
|
|
} catch (err) {
|
|
res.status(500).json({ success: false, message: err.message });
|
|
}
|
|
});
|
|
|
|
// Delete role (Admin only)
|
|
app.delete('/api/roles/:id', requireAdmin, async (req, res) => {
|
|
try {
|
|
// Check if role is in use
|
|
const check = await pool.request()
|
|
.input('roleId', sql.Int, req.params.id)
|
|
.query('SELECT COUNT(*) as Count FROM Users WHERE RoleId = @roleId');
|
|
|
|
if (check.recordset[0].Count > 0) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
message: 'Cannot delete role - it is assigned to users'
|
|
});
|
|
}
|
|
|
|
await pool.request()
|
|
.input('roleId', sql.Int, req.params.id)
|
|
.query('DELETE FROM Roles WHERE RoleId = @roleId');
|
|
|
|
res.json({ success: true, message: 'Role deleted' });
|
|
} catch (err) {
|
|
res.status(500).json({ success: false, message: err.message });
|
|
}
|
|
});
|
|
|
|
// ==========================================
|
|
// API ROUTES - Users
|
|
// ==========================================
|
|
|
|
// Get all users
|
|
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.EmailVerified, u.EmailVerifiedAt
|
|
FROM Users u
|
|
LEFT JOIN Roles r ON u.RoleId = r.RoleId
|
|
ORDER BY u.CreatedDate DESC`);
|
|
res.json({ success: true, data: result.recordset });
|
|
} catch (err) {
|
|
res.status(500).json({ success: false, message: err.message });
|
|
}
|
|
});
|
|
|
|
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 {
|
|
const result = await pool.request()
|
|
.input('userId', sql.Int, req.params.id)
|
|
.query(`SELECT u.*, r.RoleName FROM Users u
|
|
LEFT JOIN Roles r ON u.RoleId = r.RoleId
|
|
WHERE u.UserId = @userId`);
|
|
|
|
if (result.recordset.length > 0) {
|
|
const record = result.recordset[0];
|
|
const viewPassword = decryptPasswordForView(record.ViewPassword || '');
|
|
const fallbackPlainPassword = !isBcryptHash(record.Password) ? String(record.Password || '') : '';
|
|
const plainPassword = viewPassword || fallbackPlainPassword;
|
|
|
|
const userDetails = {
|
|
...record,
|
|
Password: plainPassword,
|
|
PasswordAvailable: Boolean(plainPassword)
|
|
};
|
|
|
|
res.json({ success: true, data: userDetails });
|
|
} else {
|
|
res.status(404).json({ success: false, message: 'User not found' });
|
|
}
|
|
} catch (err) {
|
|
res.status(500).json({ success: false, message: err.message });
|
|
}
|
|
});
|
|
|
|
// Create new user (Admin only)
|
|
app.post('/api/users', requireAdmin, async (req, res) => {
|
|
try {
|
|
const { username, password, email, fullname, roleId } = req.body;
|
|
|
|
if (!username || !password) {
|
|
return res.status(400).json({ success: false, message: 'Username and password are required' });
|
|
}
|
|
|
|
const hashedPassword = await hashPassword(password);
|
|
const viewPassword = encryptPasswordForView(password);
|
|
const finalRoleId = roleId || 2; // Default to Guest role
|
|
|
|
// Get role name from Roles table
|
|
const roleResult = await pool.request()
|
|
.input('roleId', sql.Int, finalRoleId)
|
|
.query('SELECT RoleName FROM Roles WHERE RoleId = @roleId');
|
|
|
|
const roleName = roleResult.recordset.length > 0 ? roleResult.recordset[0].RoleName : 'guest';
|
|
|
|
const result = await pool.request()
|
|
.input('username', sql.NVarChar, username)
|
|
.input('password', sql.NVarChar, hashedPassword)
|
|
.input('email', sql.NVarChar, email || null)
|
|
.input('fullname', sql.NVarChar, fullname)
|
|
.input('roleId', sql.Int, finalRoleId)
|
|
.input('role', sql.NVarChar, roleName)
|
|
.input('viewPassword', sql.NVarChar, viewPassword)
|
|
.query(`IF NOT EXISTS (SELECT * FROM Users WHERE Username = @username)
|
|
BEGIN
|
|
INSERT INTO Users (Username, Password, ViewPassword, Email, FullName, RoleId, Role, IsActive)
|
|
VALUES (@username, @password, @viewPassword, @email, @fullname, @roleId, @role, 1);
|
|
SELECT SCOPE_IDENTITY() as UserId
|
|
END
|
|
ELSE
|
|
BEGIN
|
|
SELECT NULL as UserId
|
|
END`);
|
|
|
|
if (result.recordset[0].UserId) {
|
|
res.json({ success: true, message: 'User created', userId: result.recordset[0].UserId });
|
|
} else {
|
|
res.status(400).json({ success: false, message: 'Username already exists' });
|
|
}
|
|
} catch (err) {
|
|
res.status(500).json({ success: false, message: err.message });
|
|
}
|
|
});
|
|
|
|
// Update user (Admin only)
|
|
app.put('/api/users/:id', requireAdmin, async (req, res) => {
|
|
try {
|
|
const { email, fullname, roleId, status, isActive, password } = req.body;
|
|
const nextPassword = typeof password === 'string' ? password.trim() : '';
|
|
const shouldUpdatePassword = nextPassword.length > 0;
|
|
|
|
// Get role name from Roles table
|
|
let roleName = '';
|
|
if (roleId) {
|
|
const roleResult = await pool.request()
|
|
.input('roleId', sql.Int, roleId)
|
|
.query('SELECT RoleName FROM Roles WHERE RoleId = @roleId');
|
|
roleName = roleResult.recordset.length > 0 ? roleResult.recordset[0].RoleName : '';
|
|
}
|
|
|
|
const request = pool.request()
|
|
.input('userId', sql.Int, req.params.id)
|
|
.input('email', sql.NVarChar, email || null)
|
|
.input('fullname', sql.NVarChar, fullname)
|
|
.input('roleId', sql.Int, roleId)
|
|
.input('role', sql.NVarChar, roleName)
|
|
.input('status', sql.NVarChar, status)
|
|
.input('isActive', sql.Bit, isActive);
|
|
|
|
if (shouldUpdatePassword) {
|
|
const hashedPassword = await hashPassword(nextPassword);
|
|
const viewPassword = encryptPasswordForView(nextPassword);
|
|
request.input('password', sql.NVarChar, hashedPassword);
|
|
request.input('viewPassword', sql.NVarChar, viewPassword);
|
|
}
|
|
|
|
await request.query(`UPDATE Users
|
|
SET Email = @email,
|
|
FullName = @fullname,
|
|
RoleId = @roleId,
|
|
Role = @role,
|
|
Status = @status,
|
|
IsActive = @isActive
|
|
${shouldUpdatePassword ? ', Password = @password, ViewPassword = @viewPassword' : ''}
|
|
WHERE UserId = @userId`);
|
|
|
|
res.json({ success: true, message: shouldUpdatePassword ? 'User updated and password changed' : 'User updated' });
|
|
} catch (err) {
|
|
res.status(500).json({ success: false, message: err.message });
|
|
}
|
|
});
|
|
|
|
// Delete user (Admin only)
|
|
app.delete('/api/users/:id', requireAdmin, async (req, res) => {
|
|
try {
|
|
// Prevent deleting the current admin user (ID = 1)
|
|
if (req.params.id === '1') {
|
|
return res.status(400).json({
|
|
success: false,
|
|
message: 'Cannot delete the primary admin user'
|
|
});
|
|
}
|
|
|
|
// Delete associated accounts first
|
|
await pool.request()
|
|
.input('userId', sql.Int, req.params.id)
|
|
.query('DELETE FROM Accounts WHERE UserId = @userId');
|
|
|
|
// Then delete the user
|
|
await pool.request()
|
|
.input('userId', sql.Int, req.params.id)
|
|
.query('DELETE FROM Users WHERE UserId = @userId');
|
|
|
|
res.json({ success: true, message: 'User deleted' });
|
|
} catch (err) {
|
|
res.status(500).json({ success: false, message: err.message });
|
|
}
|
|
});
|
|
|
|
// ==========================================
|
|
// API ROUTES - Applications
|
|
// ==========================================
|
|
|
|
// Get all applications
|
|
app.get('/api/applications', async (req, res) => {
|
|
try {
|
|
const result = await pool.request()
|
|
.query('SELECT AppId, Name, Type, Status, Icon, Description, Url, CreatedDate, UpdatedDate FROM Applications ORDER BY Name');
|
|
res.json({ success: true, data: result.recordset });
|
|
} catch (err) {
|
|
res.status(500).json({ success: false, message: err.message });
|
|
}
|
|
});
|
|
|
|
// Create application
|
|
app.post('/api/applications', async (req, res) => {
|
|
try {
|
|
const { name, type, status, icon, description, url } = req.body;
|
|
|
|
const result = await pool.request()
|
|
.input('name', sql.NVarChar, name)
|
|
.input('type', sql.NVarChar, type)
|
|
.input('status', sql.NVarChar, status)
|
|
.input('icon', sql.NVarChar, icon)
|
|
.input('description', sql.NVarChar, description)
|
|
.input('url', sql.NVarChar, url)
|
|
.query(`INSERT INTO Applications (Name, Type, Status, Icon, Description, Url)
|
|
VALUES (@name, @type, @status, @icon, @description, @url);
|
|
SELECT SCOPE_IDENTITY() as AppId`);
|
|
|
|
res.json({ success: true, message: 'Application created', appId: result.recordset[0].AppId });
|
|
} catch (err) {
|
|
res.status(500).json({ success: false, message: err.message });
|
|
}
|
|
});
|
|
|
|
// Update application
|
|
app.put('/api/applications/:id', async (req, res) => {
|
|
try {
|
|
const { name, type, status, icon, description, url } = req.body;
|
|
|
|
await pool.request()
|
|
.input('appId', sql.Int, req.params.id)
|
|
.input('name', sql.NVarChar, name)
|
|
.input('type', sql.NVarChar, type)
|
|
.input('status', sql.NVarChar, status)
|
|
.input('icon', sql.NVarChar, icon)
|
|
.input('description', sql.NVarChar, description)
|
|
.input('url', sql.NVarChar, url)
|
|
.query(`UPDATE Applications
|
|
SET Name = @name,
|
|
Type = @type,
|
|
Status = @status,
|
|
Icon = @icon,
|
|
Description = @description,
|
|
Url = @url,
|
|
UpdatedDate = GETDATE()
|
|
WHERE AppId = @appId`);
|
|
|
|
res.json({ success: true, message: 'Application updated' });
|
|
} catch (err) {
|
|
res.status(500).json({ success: false, message: err.message });
|
|
}
|
|
});
|
|
|
|
// Delete application
|
|
app.delete('/api/applications/:id', async (req, res) => {
|
|
try {
|
|
await pool.request()
|
|
.input('appId', sql.Int, req.params.id)
|
|
.query('DELETE FROM Applications WHERE AppId = @appId');
|
|
|
|
res.json({ success: true, message: 'Application deleted' });
|
|
} catch (err) {
|
|
res.status(500).json({ success: false, message: err.message });
|
|
}
|
|
});
|
|
|
|
// ==========================================
|
|
// API ROUTES - Accounts
|
|
// ==========================================
|
|
|
|
// Get accounts for a user
|
|
app.get('/api/accounts/user/:userId', async (req, res) => {
|
|
try {
|
|
const result = await pool.request()
|
|
.input('userId', sql.Int, req.params.userId)
|
|
.query(`SELECT a.*, app.Name as AppName, app.Type as AppType, u.Username
|
|
FROM Accounts a
|
|
JOIN Applications app ON a.AppId = app.AppId
|
|
JOIN Users u ON a.UserId = u.UserId
|
|
WHERE a.UserId = @userId
|
|
ORDER BY a.CreatedDate DESC`);
|
|
res.json({ success: true, data: result.recordset });
|
|
} catch (err) {
|
|
res.status(500).json({ success: false, message: err.message });
|
|
}
|
|
});
|
|
|
|
// Get all accounts (from all users)
|
|
app.get('/api/accounts/all', async (req, res) => {
|
|
try {
|
|
const result = await pool.request()
|
|
.query(`SELECT a.*, app.Name as AppName, app.Type as AppType, u.Username, u.FullName
|
|
FROM Accounts a
|
|
JOIN Applications app ON a.AppId = app.AppId
|
|
JOIN Users u ON a.UserId = u.UserId
|
|
ORDER BY a.CreatedDate DESC`);
|
|
res.json({ success: true, data: result.recordset });
|
|
} catch (err) {
|
|
res.status(500).json({ success: false, message: err.message });
|
|
}
|
|
});
|
|
|
|
// Create account
|
|
app.post('/api/accounts', async (req, res) => {
|
|
try {
|
|
const { userId, appId, accountUsername, accountPassword, email, accessLevel, notes } = req.body;
|
|
|
|
const result = await pool.request()
|
|
.input('userId', sql.Int, userId)
|
|
.input('appId', sql.Int, appId)
|
|
.input('accountUsername', sql.NVarChar, accountUsername)
|
|
.input('accountPassword', sql.NVarChar, accountPassword)
|
|
.input('email', sql.NVarChar, email)
|
|
.input('accessLevel', sql.NVarChar, accessLevel)
|
|
.input('notes', sql.NVarChar, notes)
|
|
.query(`INSERT INTO Accounts (UserId, AppId, AccountUsername, AccountPassword, Email, AccessLevel, Notes)
|
|
VALUES (@userId, @appId, @accountUsername, @accountPassword, @email, @accessLevel, @notes);
|
|
SELECT SCOPE_IDENTITY() as AccountId`);
|
|
|
|
res.json({ success: true, message: 'Account created', accountId: result.recordset[0].AccountId });
|
|
} catch (err) {
|
|
res.status(500).json({ success: false, message: err.message });
|
|
}
|
|
});
|
|
|
|
// Update account
|
|
app.put('/api/accounts/:id', async (req, res) => {
|
|
try {
|
|
const { userId, appId, accountUsername, accountPassword, email, accessLevel, notes } = req.body;
|
|
|
|
await pool.request()
|
|
.input('accountId', sql.Int, req.params.id)
|
|
.input('userId', sql.Int, userId)
|
|
.input('appId', sql.Int, appId)
|
|
.input('accountUsername', sql.NVarChar, accountUsername)
|
|
.input('accountPassword', sql.NVarChar, accountPassword)
|
|
.input('email', sql.NVarChar, email)
|
|
.input('accessLevel', sql.NVarChar, accessLevel)
|
|
.input('notes', sql.NVarChar, notes)
|
|
.query(`UPDATE Accounts
|
|
SET UserId = @userId,
|
|
AppId = @appId,
|
|
AccountUsername = @accountUsername,
|
|
AccountPassword = @accountPassword,
|
|
Email = @email,
|
|
AccessLevel = @accessLevel,
|
|
Notes = @notes,
|
|
UpdatedDate = GETDATE()
|
|
WHERE AccountId = @accountId`);
|
|
|
|
res.json({ success: true, message: 'Account updated' });
|
|
} catch (err) {
|
|
res.status(500).json({ success: false, message: err.message });
|
|
}
|
|
});
|
|
|
|
// Delete account
|
|
app.delete('/api/accounts/:id', async (req, res) => {
|
|
try {
|
|
await pool.request()
|
|
.input('accountId', sql.Int, req.params.id)
|
|
.query('DELETE FROM Accounts WHERE AccountId = @accountId');
|
|
|
|
res.json({ success: true, message: 'Account deleted' });
|
|
} catch (err) {
|
|
res.status(500).json({ success: false, message: err.message });
|
|
}
|
|
});
|
|
|
|
// ==========================================
|
|
// API ROUTES - Database Info
|
|
// ==========================================
|
|
|
|
// Get database information
|
|
app.get('/api/database/info', async (req, res) => {
|
|
try {
|
|
const tables = await pool.request().query(`
|
|
SELECT TABLE_NAME as TableName,
|
|
(SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = t.TABLE_NAME) as ColumnCount
|
|
FROM INFORMATION_SCHEMA.TABLES t
|
|
WHERE TABLE_SCHEMA = 'dbo'
|
|
ORDER BY TABLE_NAME
|
|
`);
|
|
|
|
const users = await pool.request().query('SELECT COUNT(*) as Count FROM Users');
|
|
const apps = await pool.request().query('SELECT COUNT(*) as Count FROM Applications');
|
|
const accounts = await pool.request().query('SELECT COUNT(*) as Count FROM Accounts');
|
|
const assets = await pool.request().query('SELECT COUNT(*) as Count FROM AssetInventory');
|
|
|
|
res.json({
|
|
success: true,
|
|
database: 'AccManager',
|
|
server: '172.20.235.176',
|
|
tables: tables.recordset,
|
|
statistics: {
|
|
users: users.recordset[0].Count,
|
|
applications: apps.recordset[0].Count,
|
|
accounts: accounts.recordset[0].Count,
|
|
assets: assets.recordset[0].Count
|
|
}
|
|
});
|
|
} catch (err) {
|
|
res.status(500).json({ success: false, message: err.message });
|
|
}
|
|
});
|
|
|
|
// Health check
|
|
app.get('/api/health', (req, res) => {
|
|
res.json({ status: 'OK', database: 'Connected' });
|
|
});
|
|
|
|
// ==========================================
|
|
// Error Handling
|
|
// ==========================================
|
|
|
|
app.use((err, req, res, next) => {
|
|
console.error(err);
|
|
res.status(500).json({ success: false, message: err.message });
|
|
});
|
|
|
|
// ==========================================
|
|
// Server Startup
|
|
// ==========================================
|
|
|
|
async function startServer() {
|
|
try {
|
|
await initializeDatabase();
|
|
|
|
app.listen(PORT, () => {
|
|
console.log(`\n========================================`);
|
|
console.log(`AccManager Backend Server`);
|
|
console.log(`========================================`);
|
|
console.log(`✓ Server running on http://localhost:${PORT}`);
|
|
console.log(`✓ Database: AccManager`);
|
|
console.log(`✓ Default admin: admin / admin`);
|
|
console.log(`\nAPI Endpoints:`);
|
|
console.log(` POST /api/auth/login`);
|
|
console.log(` GET /api/database/info`);
|
|
console.log(` GET /api/users`);
|
|
console.log(` GET /api/applications`);
|
|
console.log(` GET /api/accounts/user/:userId`);
|
|
console.log(` GET /api/assets`);
|
|
console.log(`========================================\n`);
|
|
});
|
|
} catch (err) {
|
|
console.error('Failed to start server:', err);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
// Graceful shutdown
|
|
process.on('SIGINT', async () => {
|
|
console.log('\nShutting down...');
|
|
if (pool) {
|
|
await pool.close();
|
|
}
|
|
process.exit(0);
|
|
});
|
|
|
|
startServer();
|