Commit ddd32d38 by tdgiang

Merge branch 'feature/admin-account-management' into dev

# Conflicts:
#	app/views/admin/transactions-list.server.view.html
#	app/views/admin/transactions-new.server.view.html
parents 40388425 4bb319e9
...@@ -3,10 +3,14 @@ var moment = require("moment"); ...@@ -3,10 +3,14 @@ var moment = require("moment");
var uuidv4 = require("uuid").v4; var uuidv4 = require("uuid").v4;
var config = require(__config_path + "/config"); var config = require(__config_path + "/config");
var AdminTransaction = require("../models/AdminTransaction"); var AdminTransaction = require("../models/AdminTransaction");
var AdminUser = require("../models/AdminUser");
var epaySign = require("../libs/epaySign"); var epaySign = require("../libs/epaySign");
exports.newTransactionForm = function (req, res) { exports.newTransactionForm = function (req, res) {
res.render("admin/transactions-new", {}); res.render("admin/transactions-new", {
username: req.session.username,
isAdmin: req.session.role === "admin",
});
}; };
exports.createTransaction = function (req, res) { exports.createTransaction = function (req, res) {
...@@ -46,6 +50,7 @@ exports.createTransaction = function (req, res) { ...@@ -46,6 +50,7 @@ exports.createTransaction = function (req, res) {
amount: amount, amount: amount,
merchantToken: merchantToken, merchantToken: merchantToken,
timeStamp: timeStamp, timeStamp: timeStamp,
createdByUsername: req.session.username,
}, },
function (err, tx) { function (err, tx) {
if (err) { if (err) {
...@@ -195,35 +200,64 @@ exports.epayIPN = function (req, res) { ...@@ -195,35 +200,64 @@ exports.epayIPN = function (req, res) {
exports.listTransactions = function (req, res) { exports.listTransactions = function (req, res) {
var page = parseInt(req.query.page) || 1; var page = parseInt(req.query.page) || 1;
var perPage = 20; var perPage = 20;
var isAdmin = req.session.role === "admin";
var filter = {};
AdminTransaction.find({}) if (!isAdmin) {
.sort({ createdAt: -1 }) filter.createdByUsername = req.session.username;
.skip((page - 1) * perPage) } else if (req.query.staff) {
.limit(perPage) filter.createdByUsername = String(req.query.staff);
.exec(function (err, list) { }
if (err) {
console.error("listTransactions: DB error:", err.message); function render(staffOptions) {
return res.status(500).send("Lỗi cơ sở dữ liệu"); AdminTransaction.find(filter)
} .sort({ createdAt: -1 })
var transactions = list.map(function (tx) { .skip((page - 1) * perPage)
return { .limit(perPage)
merTrxId: tx.merTrxId, .exec(function (err, list) {
customerName: tx.customerName, if (err) {
customerPhone: tx.customerPhone, console.error("listTransactions: DB error:", err.message);
amount: tx.amount, return res.status(500).send("Lỗi cơ sở dữ liệu");
status: tx.status, }
resultMsg: tx.resultMsg, var transactions = list.map(function (tx) {
createdAt: moment(tx.createdAt).format("DD/MM/YYYY HH:mm"), return {
paymentUrl: config.epay.req_domain + "/admin/pay/" + tx.merTrxId, merTrxId: tx.merTrxId,
}; customerName: tx.customerName,
customerPhone: tx.customerPhone,
amount: tx.amount,
status: tx.status,
resultMsg: tx.resultMsg,
createdByUsername: tx.createdByUsername,
createdAt: moment(tx.createdAt).format("DD/MM/YYYY HH:mm"),
paymentUrl: config.epay.req_domain + "/admin/pay/" + tx.merTrxId,
};
});
res.render("admin/transactions-list", {
transactions: transactions,
page: page,
prevPage: page - 1,
nextPage: page + 1,
hasPrev: page > 1,
hasNext: transactions.length === perPage,
isAdmin: isAdmin,
username: req.session.username,
staffOptions: staffOptions || [],
selectedStaff: req.query.staff || "",
});
}); });
res.render("admin/transactions-list", { }
transactions: transactions,
page: page, if (isAdmin) {
prevPage: page - 1, AdminUser.find({ role: "staff" }, "username")
nextPage: page + 1, .sort({ username: 1 })
hasPrev: page > 1, .exec(function (err, staffUsers) {
hasNext: transactions.length === perPage, if (err) {
console.error("listTransactions: DB error loading staff list:", err.message);
return render([]);
}
render(staffUsers.map(function (u) { return u.username; }));
}); });
}); } else {
render([]);
}
}; };
"use strict";
var AdminUser = require("../models/AdminUser");
var passwordHash = require("../libs/passwordHash");
var MIN_PASSWORD_LENGTH = 8;
// Fixed, precomputed bcrypt hash of a value nobody will ever type as a real password.
// Used only to make the "unknown username" path cost the same time as "wrong password" —
// comparing against this constant takes the same ~70ms as a real compare, so an attacker
// timing responses cannot tell "no such user" apart from "wrong password".
var DUMMY_HASH = "$2b$10$v5huvbowopyU7O11oK7ykeCMpvpEJCt4.y9cv8kZ.7liCwdYEY8sS";
exports.loginForm = function (req, res) {
res.render("admin/login", { error: null });
};
exports.login = function (req, res) {
var username = String(req.body.username || "").trim().toLowerCase();
var password = String(req.body.password || "");
AdminUser.findOne({ username: username }, function (err, user) {
if (err) {
console.error("login: DB error:", err.message);
return res.status(503).render("admin/login", { error: "Hệ thống đang bận, vui lòng thử lại sau" });
}
var hashToCompare = user ? user.passwordHash : DUMMY_HASH;
passwordHash.compare(password, hashToCompare, function (compareErr, isMatch) {
if (compareErr) {
console.error("login: compare error:", compareErr.message);
return res.status(503).render("admin/login", { error: "Hệ thống đang bận, vui lòng thử lại sau" });
}
if (!user || !isMatch) {
return res.render("admin/login", { error: "Sai tên đăng nhập hoặc mật khẩu" });
}
if (!user.active) {
return res.render("admin/login", { error: "Tài khoản đã bị khoá, liên hệ admin" });
}
req.session.regenerate(function (regenErr) {
if (regenErr) {
console.error("login: session regenerate error:", regenErr.message);
return res.status(503).render("admin/login", { error: "Hệ thống đang bận, vui lòng thử lại sau" });
}
req.session.userId = user._id.toString();
req.session.username = user.username;
req.session.role = user.role;
req.session.save(function (saveErr) {
if (saveErr) {
console.error("login: session save error:", saveErr.message);
return res.status(503).render("admin/login", { error: "Hệ thống đang bận, vui lòng thử lại sau" });
}
return res.redirect("/admin/transactions");
});
});
});
});
};
exports.logout = function (req, res) {
req.session.destroy(function () {
res.redirect("/admin/login");
});
};
exports.accountsList = function (req, res) {
AdminUser.find({}).sort({ createdAt: 1 }).exec(function (err, users) {
if (err) {
console.error("accountsList: DB error:", err.message);
return res.status(500).send("Lỗi cơ sở dữ liệu");
}
res.render("admin/accounts-list", {
users: users,
currentUsername: req.session.username,
error: req.query.error || null,
created: req.query.created === "1",
reset: req.query.reset === "1",
});
});
};
exports.createAccount = function (req, res) {
var username = String(req.body.username || "").trim().toLowerCase();
var password = String(req.body.password || "");
var role = req.body.role === "admin" ? "admin" : "staff";
if (!username || !password) {
return res.redirect("/admin/accounts?error=MISSING_FIELDS");
}
if (password.length < MIN_PASSWORD_LENGTH) {
return res.redirect("/admin/accounts?error=PASSWORD_TOO_SHORT");
}
passwordHash.hash(password, function (hashErr, hashed) {
if (hashErr) {
console.error("createAccount: hash error:", hashErr.message);
return res.redirect("/admin/accounts?error=DB_ERROR");
}
AdminUser.create(
{
username: username,
passwordHash: hashed,
role: role,
active: true,
},
function (err) {
if (err) {
if (err.code === 11000) {
return res.redirect("/admin/accounts?error=DUPLICATE_USERNAME");
}
console.error("createAccount: DB error:", err.message);
return res.redirect("/admin/accounts?error=DB_ERROR");
}
return res.redirect("/admin/accounts?created=1");
}
);
});
};
exports.toggleAccount = function (req, res) {
if (req.params.id === req.session.userId) {
return res.redirect("/admin/accounts?error=CANNOT_LOCK_SELF");
}
AdminUser.findById(req.params.id, function (err, user) {
if (err) {
console.error("toggleAccount: DB error:", err.message);
return res.redirect("/admin/accounts?error=DB_ERROR");
}
if (!user) {
return res.status(404).send("Không tìm thấy tài khoản");
}
var nextActive = !user.active;
function applyToggle() {
user.active = nextActive;
user.save(function (saveErr) {
if (saveErr) {
console.error("toggleAccount: DB error saving:", saveErr.message);
return res.redirect("/admin/accounts?error=DB_ERROR");
}
return res.redirect("/admin/accounts");
});
}
if (!nextActive && user.role === "admin") {
AdminUser.countDocuments(
{ role: "admin", active: true, _id: { $ne: user._id } },
function (countErr, remaining) {
if (countErr) {
console.error("toggleAccount: DB error counting admins:", countErr.message);
return res.redirect("/admin/accounts?error=DB_ERROR");
}
if (remaining < 1) {
return res.redirect("/admin/accounts?error=LAST_ADMIN");
}
applyToggle();
}
);
} else {
applyToggle();
}
});
};
exports.resetAccountPassword = function (req, res) {
var newPassword = String(req.body.newPassword || "");
if (!newPassword) {
return res.redirect("/admin/accounts?error=MISSING_FIELDS");
}
if (newPassword.length < MIN_PASSWORD_LENGTH) {
return res.redirect("/admin/accounts?error=PASSWORD_TOO_SHORT");
}
AdminUser.findById(req.params.id, function (err, user) {
if (err) {
console.error("resetAccountPassword: DB error:", err.message);
return res.redirect("/admin/accounts?error=DB_ERROR");
}
if (!user) {
return res.status(404).send("Không tìm thấy tài khoản");
}
passwordHash.hash(newPassword, function (hashErr, hashed) {
if (hashErr) {
console.error("resetAccountPassword: hash error:", hashErr.message);
return res.redirect("/admin/accounts?error=DB_ERROR");
}
user.passwordHash = hashed;
user.save(function (saveErr) {
if (saveErr) {
console.error("resetAccountPassword: DB error saving:", saveErr.message);
return res.redirect("/admin/accounts?error=DB_ERROR");
}
return res.redirect("/admin/accounts?reset=1");
});
});
});
};
exports.changePasswordForm = function (req, res) {
res.render("admin/change-password", { error: null, success: false });
};
exports.changePassword = function (req, res) {
var currentPassword = String(req.body.currentPassword || "");
var newPassword = String(req.body.newPassword || "");
AdminUser.findById(req.session.userId, function (err, user) {
if (err) {
console.error("changePassword: DB error:", err.message);
return res.status(503).render("admin/change-password", { error: "Hệ thống đang bận, vui lòng thử lại sau", success: false });
}
if (!user) {
return res.status(503).render("admin/change-password", { error: "Hệ thống đang bận, vui lòng thử lại sau", success: false });
}
passwordHash.compare(currentPassword, user.passwordHash, function (compareErr, isMatch) {
if (compareErr) {
console.error("changePassword: compare error:", compareErr.message);
return res.status(503).render("admin/change-password", { error: "Hệ thống đang bận, vui lòng thử lại sau", success: false });
}
if (!isMatch) {
return res.render("admin/change-password", { error: "Mật khẩu hiện tại không đúng", success: false });
}
if (!newPassword) {
return res.render("admin/change-password", { error: "Vui lòng nhập mật khẩu mới", success: false });
}
if (newPassword.length < MIN_PASSWORD_LENGTH) {
return res.render("admin/change-password", { error: "Mật khẩu mới phải có ít nhất 8 ký tự", success: false });
}
passwordHash.hash(newPassword, function (hashErr, hashed) {
if (hashErr) {
console.error("changePassword: hash error:", hashErr.message);
return res.status(500).render("admin/change-password", { error: "Lỗi cơ sở dữ liệu", success: false });
}
user.passwordHash = hashed;
user.save(function (saveErr) {
if (saveErr) {
console.error("changePassword: DB error saving:", saveErr.message);
return res.status(500).render("admin/change-password", { error: "Lỗi cơ sở dữ liệu", success: false });
}
return res.render("admin/change-password", { error: null, success: true });
});
});
});
});
};
"use strict";
var AdminUser = require("../models/AdminUser");
var AdminTransaction = require("../models/AdminTransaction");
var passwordHash = require("./passwordHash");
module.exports = function run(config, callback) {
AdminUser.findOne({ role: "admin" }, function (err, existingAdmin) {
if (err) {
return callback(err);
}
if (existingAdmin) {
return backfillTransactions(existingAdmin.username, callback);
}
if (!config.admin || !config.admin.user || !config.admin.password) {
console.error("adminBootstrap: no admin exists and ADMIN_USER/ADMIN_PASSWORD not set - cannot seed");
return callback(null);
}
passwordHash.hash(config.admin.password, function (hashErr, hashed) {
if (hashErr) {
return callback(hashErr);
}
AdminUser.create(
{
username: String(config.admin.user).trim().toLowerCase(),
passwordHash: hashed,
role: "admin",
active: true,
},
function (createErr, admin) {
if (createErr) {
return callback(createErr);
}
console.log("adminBootstrap: seeded first admin account:", admin.username);
return backfillTransactions(admin.username, callback);
}
);
});
});
};
function backfillTransactions(adminUsername, callback) {
AdminTransaction.updateMany(
{ createdByUsername: null },
{ createdByUsername: adminUsername },
function (err, result) {
if (err) {
return callback(err);
}
if (result && result.nModified) {
console.log("adminBootstrap: backfilled", result.nModified, "old transactions to", adminUsername);
}
return callback(null);
}
);
}
"use strict";
var bcrypt = require("bcryptjs");
function hash(plain, callback) {
bcrypt.hash(plain, 10, callback);
}
function compare(plain, hashed, callback) {
bcrypt.compare(plain, hashed, callback);
}
module.exports = {
hash: hash,
compare: compare,
};
"use strict";
var crypto = require("crypto");
function safeEqual(a, b) {
var bufA = Buffer.from(String(a));
var bufB = Buffer.from(String(b));
if (bufA.length !== bufB.length) {
crypto.timingSafeEqual(bufA, bufA);
return false;
}
return crypto.timingSafeEqual(bufA, bufB);
}
module.exports = function basicAuth(req, res, next) {
var config = require(__config_path + "/config");
var expectedUser = config.admin && config.admin.user;
var expectedPassword = config.admin && config.admin.password;
if (!expectedUser || !expectedPassword) {
console.error("basicAuth: ADMIN_USER/ADMIN_PASSWORD not configured - denying all admin access");
return res.status(500).send("Admin auth not configured.");
}
var header = req.headers.authorization || "";
var token = header.slice(0, 6).toLowerCase() === "basic " ? header.slice(6) : "";
var decoded = Buffer.from(token, "base64").toString("utf8");
var sepIndex = decoded.indexOf(":");
var user = sepIndex === -1 ? decoded : decoded.slice(0, sepIndex);
var password = sepIndex === -1 ? "" : decoded.slice(sepIndex + 1);
if (safeEqual(user, expectedUser) && safeEqual(password, expectedPassword)) {
return next();
}
res.set("WWW-Authenticate", 'Basic realm="Admin"');
return res.status(401).send("Authentication required.");
};
"use strict";
var rateLimit = require("express-rate-limit");
module.exports = rateLimit({
windowMs: 60 * 1000,
max: 5,
standardHeaders: true,
legacyHeaders: false,
handler: function (req, res) {
return res.status(429).render("admin/login", {
error: "Quá nhiều lần thử đăng nhập, vui lòng thử lại sau ít phút.",
});
},
});
"use strict";
module.exports = function requireAdmin(req, res, next) {
if (req.session && req.session.role === "admin") {
return next();
}
return res.status(403).send("Bạn không có quyền truy cập trang này.");
};
"use strict";
var AdminUser = require("../models/AdminUser");
module.exports = function requireLogin(req, res, next) {
if (!req.session || !req.session.userId) {
if (req.is("json")) {
return res.status(401).json({ code: "99", data: "LOGIN_REQUIRED" });
}
return res.redirect("/admin/login");
}
AdminUser.findById(req.session.userId, function (err, user) {
if (err) {
console.error("requireLogin: DB error:", err.message);
if (req.is("json")) {
return res.status(503).json({ code: "99", data: "SERVICE_UNAVAILABLE" });
}
return res.status(503).send("Hệ thống đang bận, vui lòng thử lại sau.");
}
if (!user || !user.active) {
return req.session.destroy(function () {
if (req.is("json")) {
return res.status(401).json({ code: "99", data: "LOGIN_REQUIRED" });
}
return res.redirect("/admin/login");
});
}
// Keep the session's cached role/username in sync with the DB on every request,
// in case an admin changed either after this session was created.
req.session.role = user.role;
req.session.username = user.username;
return next();
});
};
...@@ -19,6 +19,7 @@ var AdminTransactionSchema = new Schema( ...@@ -19,6 +19,7 @@ var AdminTransactionSchema = new Schema(
merchantToken: { type: String, required: true }, merchantToken: { type: String, required: true },
timeStamp: { type: String, required: true }, timeStamp: { type: String, required: true },
resultMsg: { type: String, default: "" }, resultMsg: { type: String, default: "" },
createdByUsername: { type: String, default: null },
paidAt: { type: Date, default: null }, paidAt: { type: Date, default: null },
}, },
{ timestamps: true } { timestamps: true }
......
"use strict";
var mongoose = require("mongoose");
var Schema = mongoose.Schema;
var AdminUserSchema = new Schema(
{
username: { type: String, required: true, unique: true, lowercase: true, trim: true },
passwordHash: { type: String, required: true },
role: { type: String, enum: ["admin", "staff"], default: "staff" },
active: { type: Boolean, default: true },
},
{ timestamps: true }
);
module.exports = mongoose.model("AdminUser", AdminUserSchema);
"use strict"; "use strict";
module.exports = function (app) { module.exports = function (app) {
var admin = require("../../app/controllers/admin.server.controller"); var admin = require("../../app/controllers/admin.server.controller");
var basicAuth = require("../middlewares/basicAuth"); var adminAuth = require("../../app/controllers/adminAuth.server.controller");
var requireLogin = require("../middlewares/requireLogin");
var requireAdmin = require("../middlewares/requireAdmin");
var loginRateLimit = require("../middlewares/loginRateLimit");
app.route("/admin/login")
.get(adminAuth.loginForm)
.post(loginRateLimit, adminAuth.login);
app.route("/admin/logout").post(requireLogin, adminAuth.logout);
app.route("/admin/accounts")
.get(requireLogin, requireAdmin, adminAuth.accountsList)
.post(requireLogin, requireAdmin, adminAuth.createAccount);
app.route("/admin/accounts/:id/toggle").post(requireLogin, requireAdmin, adminAuth.toggleAccount);
app.route("/admin/accounts/:id/reset-password").post(requireLogin, requireAdmin, adminAuth.resetAccountPassword);
app.route("/admin/account/password")
.get(requireLogin, adminAuth.changePasswordForm)
.post(requireLogin, adminAuth.changePassword);
app.route("/admin/transactions") app.route("/admin/transactions")
.get(basicAuth, admin.listTransactions) .get(requireLogin, admin.listTransactions)
.post(basicAuth, admin.createTransaction); .post(requireLogin, admin.createTransaction);
app.route("/admin/transactions/new").get(basicAuth, admin.newTransactionForm); app.route("/admin/transactions/new").get(requireLogin, admin.newTransactionForm);
app.route("/admin/pay/:merTrxId").get(admin.payPage); app.route("/admin/pay/:merTrxId").get(admin.payPage);
app.route("/admin/epay/return").get(admin.epayReturn); app.route("/admin/epay/return").get(admin.epayReturn);
......
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Quản lý tài khoản</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
:root {
--color-bg: #F1F5F9;
--color-surface: #FFFFFF;
--color-primary: #1E40AF;
--color-primary-hover: #1D4ED8;
--color-text: #0F172A;
--color-text-muted: #64748B;
--color-border: #E2E8F0;
--color-header-bg: #F8FAFC;
--pill-admin-bg: #DBEAFE;
--pill-admin-fg: #1E3A8A;
--pill-staff-bg: #F1F5F9;
--pill-staff-fg: #334155;
--pill-active-bg: #DCFCE7;
--pill-active-fg: #166534;
--pill-locked-bg: #FEE2E2;
--pill-locked-fg: #991B1B;
--color-success-bg: #F0FDF4;
--color-success-border: #BBF7D0;
--color-danger-bg: #FEF2F2;
--color-danger-border: #FECACA;
--radius: 10px;
--shadow-card: 0 1px 2px rgba(15, 23, 42, 0.04), 0 8px 24px rgba(15, 23, 42, 0.06);
}
* { box-sizing: border-box; }
body {
margin: 0; min-height: 100vh; background: var(--color-bg);
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
color: var(--color-text); padding: 40px 20px;
}
.page { max-width: 900px; margin: 0 auto; }
.nav { display: flex; gap: 16px; font-size: 13px; margin-bottom: 20px; }
.nav a { color: var(--color-text-muted); text-decoration: none; font-weight: 500; }
.nav a:hover { color: var(--color-primary); }
h1 { font-size: 22px; font-weight: 700; letter-spacing: -0.01em; margin: 0 0 20px; }
.card {
background: var(--color-surface); border: 1px solid var(--color-border);
border-radius: var(--radius); box-shadow: var(--shadow-card); padding: 24px; margin-bottom: 24px;
}
.card h2 { font-size: 15px; font-weight: 700; margin: 0 0 16px; }
.form-row { display: flex; gap: 12px; flex-wrap: wrap; align-items: flex-end; }
.form-field { flex: 1; min-width: 160px; }
label { display: block; font-size: 12.5px; font-weight: 600; margin-bottom: 6px; }
input, select {
width: 100%; height: 40px; padding: 0 12px; font-size: 14px; font-family: inherit;
border: 1px solid var(--color-border); border-radius: 8px; outline: none;
}
input:focus, select:focus { border-color: var(--color-primary); box-shadow: 0 0 0 3px rgba(30, 64, 175, 0.12); }
button {
height: 40px; padding: 0 18px; background: var(--color-primary); color: #fff;
font-family: inherit; font-size: 14px; font-weight: 600; border: none; border-radius: 8px; cursor: pointer;
}
button:hover { background: var(--color-primary-hover); }
button.secondary { background: #fff; color: var(--color-text); border: 1px solid var(--color-border); }
button.secondary:hover { background: var(--color-header-bg); }
.alert { padding: 12px 14px; border-radius: 8px; font-size: 13.5px; margin-bottom: 20px; border: 1px solid; }
.alert-success { background: var(--color-success-bg); border-color: var(--color-success-border); color: #14532D; }
.alert-error { background: var(--color-danger-bg); border-color: var(--color-danger-border); color: #7F1D1D; }
table { width: 100%; border-collapse: collapse; font-size: 13.5px; }
thead th {
text-align: left; font-weight: 600; font-size: 12px; text-transform: uppercase; letter-spacing: 0.03em;
color: var(--color-text-muted); padding: 10px 12px; border-bottom: 1px solid var(--color-border);
}
tbody td { padding: 12px; border-bottom: 1px solid var(--color-border); vertical-align: middle; }
tbody tr:last-child td { border-bottom: none; }
.pill { display: inline-flex; padding: 3px 10px; border-radius: 999px; font-size: 12px; font-weight: 600; }
.pill-admin { background: var(--pill-admin-bg); color: var(--pill-admin-fg); }
.pill-staff { background: var(--pill-staff-bg); color: var(--pill-staff-fg); }
.pill-active { background: var(--pill-active-bg); color: var(--pill-active-fg); }
.pill-locked { background: var(--pill-locked-bg); color: var(--pill-locked-fg); }
.row-actions { display: flex; gap: 8px; flex-wrap: wrap; }
.row-actions form { display: inline-flex; gap: 6px; align-items: center; }
.row-actions input[type="password"] { height: 32px; width: 130px; font-size: 12.5px; }
.row-actions button { height: 32px; padding: 0 10px; font-size: 12.5px; }
</style>
</head>
<body>
<div class="page">
<div class="nav">
<a href="/admin/transactions">&laquo; Lịch sử giao dịch</a>
<a href="/admin/account/password">Đổi mật khẩu</a>
<form method="POST" action="/admin/logout" style="display:inline;">
<button type="submit" style="all:unset;cursor:pointer;color:inherit;font:inherit;">Đăng xuất</button>
</form>
</div>
<h1>Quản lý tài khoản</h1>
{% if created %}<div class="alert alert-success">Đã tạo tài khoản thành công.</div>{% endif %}
{% if reset %}<div class="alert alert-success">Đã đặt lại mật khẩu.</div>{% endif %}
{% if error == "MISSING_FIELDS" %}<div class="alert alert-error">Vui lòng nhập đủ thông tin.</div>{% endif %}
{% if error == "DUPLICATE_USERNAME" %}<div class="alert alert-error">Tên đăng nhập đã tồn tại.</div>{% endif %}
{% if error == "DB_ERROR" %}<div class="alert alert-error">Lỗi cơ sở dữ liệu, vui lòng thử lại.</div>{% endif %}
{% if error == "PASSWORD_TOO_SHORT" %}<div class="alert alert-error">Mật khẩu phải có ít nhất 8 ký tự.</div>{% endif %}
{% if error == "CANNOT_LOCK_SELF" %}<div class="alert alert-error">Bạn không thể khoá tài khoản của chính mình.</div>{% endif %}
{% if error == "LAST_ADMIN" %}<div class="alert alert-error">Không thể khoá — đây là tài khoản admin đang hoạt động cuối cùng.</div>{% endif %}
<div class="card">
<h2>Tạo tài khoản nhân viên mới</h2>
<form method="POST" action="/admin/accounts">
<div class="form-row">
<div class="form-field">
<label for="username">Tên đăng nhập</label>
<input type="text" id="username" name="username" required>
</div>
<div class="form-field">
<label for="password">Mật khẩu</label>
<input type="password" id="password" name="password" required minlength="8">
</div>
<div class="form-field" style="max-width:140px;">
<label for="role">Vai trò</label>
<select id="role" name="role">
<option value="staff">Nhân viên</option>
<option value="admin">Admin</option>
</select>
</div>
<button type="submit">Tạo tài khoản</button>
</div>
</form>
</div>
<div class="card">
<h2>Danh sách tài khoản</h2>
<table>
<thead>
<tr>
<th>Tên đăng nhập</th>
<th>Vai trò</th>
<th>Trạng thái</th>
<th>Thao tác</th>
</tr>
</thead>
<tbody>
{% for u in users %}
<tr>
<td>{{u.username}}</td>
<td><span class="pill pill-{{u.role}}">{% if u.role == "admin" %}Admin{% else %}Nhân viên{% endif %}</span></td>
<td><span class="pill pill-{% if u.active %}active{% else %}locked{% endif %}">{% if u.active %}Hoạt động{% else %}Đã khoá{% endif %}</span></td>
<td>
<div class="row-actions">
{% if u.username == currentUsername %}
<span style="font-size:12.5px;color:var(--color-text-muted);">Tài khoản của bạn</span>
{% else %}
<form method="POST" action="/admin/accounts/{{u._id}}/toggle">
<button type="submit" class="secondary">{% if u.active %}Khoá{% else %}Mở khoá{% endif %}</button>
</form>
{% endif %}
<form method="POST" action="/admin/accounts/{{u._id}}/reset-password">
<input type="password" name="newPassword" placeholder="Mật khẩu mới" required minlength="8">
<button type="submit" class="secondary">Đặt lại</button>
</form>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</body>
</html>
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Đổi mật khẩu</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
:root {
--color-bg: #F1F5F9;
--color-surface: #FFFFFF;
--color-primary: #1E40AF;
--color-primary-hover: #1D4ED8;
--color-text: #0F172A;
--color-text-muted: #64748B;
--color-border: #E2E8F0;
--color-danger-bg: #FEF2F2;
--color-danger-border: #FECACA;
--color-success-bg: #F0FDF4;
--color-success-border: #BBF7D0;
--radius: 10px;
--shadow-card: 0 1px 2px rgba(15, 23, 42, 0.04), 0 8px 24px rgba(15, 23, 42, 0.06);
}
* { box-sizing: border-box; }
body {
margin: 0; min-height: 100vh; background: var(--color-bg);
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
color: var(--color-text); display: flex; justify-content: center; padding: 40px 20px;
}
.page { width: 100%; max-width: 420px; }
.nav { display: flex; gap: 16px; font-size: 13px; margin-bottom: 20px; color: var(--color-text-muted); }
.nav a { color: inherit; text-decoration: none; font-weight: 500; }
.nav a:hover { color: var(--color-primary); }
.card {
background: var(--color-surface); border: 1px solid var(--color-border);
border-radius: var(--radius); box-shadow: var(--shadow-card); padding: 32px;
}
h1 { font-size: 20px; font-weight: 700; letter-spacing: -0.01em; margin: 0 0 20px; }
.field { margin-bottom: 16px; }
label { display: block; font-size: 13px; font-weight: 600; margin-bottom: 6px; }
input {
width: 100%; height: 44px; padding: 0 14px; font-size: 15px; font-family: inherit;
border: 1px solid var(--color-border); border-radius: 8px; outline: none;
}
input:focus { border-color: var(--color-primary); box-shadow: 0 0 0 3px rgba(30, 64, 175, 0.12); }
button {
width: 100%; height: 46px; margin-top: 8px; background: var(--color-primary); color: #fff;
font-family: inherit; font-size: 15px; font-weight: 600; border: none; border-radius: 8px; cursor: pointer;
}
button:hover { background: var(--color-primary-hover); }
.alert { padding: 12px 14px; border-radius: 8px; font-size: 13.5px; margin-bottom: 16px; border: 1px solid; }
.alert-error { background: var(--color-danger-bg); border-color: var(--color-danger-border); color: #7F1D1D; }
.alert-success { background: var(--color-success-bg); border-color: var(--color-success-border); color: #14532D; }
</style>
</head>
<body>
<div class="page">
<div class="nav">
<a href="/admin/transactions">&laquo; Lịch sử giao dịch</a>
</div>
<div class="card">
<h1>Đổi mật khẩu</h1>
{% if success %}<div class="alert alert-success">Đã đổi mật khẩu thành công.</div>{% endif %}
{% if error %}<div class="alert alert-error">{{error}}</div>{% endif %}
<form method="POST" action="/admin/account/password" id="changePasswordForm">
<div class="field">
<label for="currentPassword">Mật khẩu hiện tại</label>
<input type="password" id="currentPassword" name="currentPassword" autocomplete="current-password" required>
</div>
<div class="field">
<label for="newPassword">Mật khẩu mới</label>
<input type="password" id="newPassword" name="newPassword" autocomplete="new-password" required>
</div>
<div class="field">
<label for="confirmPassword">Nhập lại mật khẩu mới</label>
<input type="password" id="confirmPassword" autocomplete="new-password" required>
</div>
<button type="submit">Đổi mật khẩu</button>
</form>
</div>
</div>
<script>
document.getElementById('changePasswordForm').addEventListener('submit', function (e) {
var newPassword = document.getElementById('newPassword').value;
var confirmPassword = document.getElementById('confirmPassword').value;
if (newPassword !== confirmPassword) {
e.preventDefault();
alert('Mật khẩu mới nhập lại không khớp.');
}
});
</script>
</body>
</html>
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Đăng nhập</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
:root {
--color-bg: #F1F5F9;
--color-surface: #FFFFFF;
--color-primary: #1E40AF;
--color-primary-hover: #1D4ED8;
--color-text: #0F172A;
--color-text-muted: #64748B;
--color-border: #E2E8F0;
--color-danger: #DC2626;
--color-danger-bg: #FEF2F2;
--color-danger-border: #FECACA;
--radius: 10px;
--shadow-card: 0 1px 2px rgba(15, 23, 42, 0.04), 0 8px 24px rgba(15, 23, 42, 0.06);
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
background: var(--color-bg);
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
color: var(--color-text);
display: flex;
justify-content: center;
align-items: center;
padding: 20px;
}
.card {
width: 100%;
max-width: 380px;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius);
box-shadow: var(--shadow-card);
padding: 32px;
}
h1 { font-size: 20px; font-weight: 700; letter-spacing: -0.01em; margin: 0 0 4px; text-align: center; }
.subtitle { font-size: 14px; color: var(--color-text-muted); margin: 0 0 24px; text-align: center; }
.field { margin-bottom: 16px; }
label { display: block; font-size: 13px; font-weight: 600; margin-bottom: 6px; }
input {
width: 100%; height: 44px; padding: 0 14px; font-size: 15px; font-family: inherit;
border: 1px solid var(--color-border); border-radius: 8px; outline: none;
transition: border-color 150ms ease, box-shadow 150ms ease;
}
input:focus { border-color: var(--color-primary); box-shadow: 0 0 0 3px rgba(30, 64, 175, 0.12); }
button {
width: 100%; height: 46px; margin-top: 8px; background: var(--color-primary); color: #fff;
font-family: inherit; font-size: 15px; font-weight: 600; border: none; border-radius: 8px;
cursor: pointer; transition: background 150ms ease;
}
button:hover { background: var(--color-primary-hover); }
.alert-error {
display: flex; gap: 8px; padding: 12px 14px; margin-bottom: 16px;
background: var(--color-danger-bg); border: 1px solid var(--color-danger-border);
border-radius: 8px; font-size: 13.5px; color: #7F1D1D;
}
</style>
</head>
<body>
<div class="card">
<h1>Đăng nhập</h1>
<p class="subtitle">Hệ thống quản lý giao dịch</p>
{% if error %}
<div class="alert-error">{{error}}</div>
{% endif %}
<form method="POST" action="/admin/login">
<div class="field">
<label for="username">Tên đăng nhập</label>
<input type="text" id="username" name="username" autocomplete="username" required autofocus>
</div>
<div class="field">
<label for="password">Mật khẩu</label>
<input type="password" id="password" name="password" autocomplete="current-password" required>
</div>
<button type="submit">Đăng nhập</button>
</form>
</div>
</body>
</html>
...@@ -218,11 +218,29 @@ ...@@ -218,11 +218,29 @@
</head> </head>
<body> <body>
<div class="page"> <div class="page">
<div class="nav" style="display:flex;gap:16px;font-size:13px;color:var(--color-text-muted);margin-bottom:16px;">
<span>Xin chào, <strong>{{username}}</strong></span>
{% if isAdmin %}<a href="/admin/accounts" style="color:inherit;text-decoration:none;font-weight:500;">Quản lý tài khoản</a>{% endif %}
<a href="/admin/account/password" style="color:inherit;text-decoration:none;font-weight:500;">Đổi mật khẩu</a>
<form method="POST" action="/admin/logout" style="display:inline;">
<button type="submit" style="all:unset;cursor:pointer;color:inherit;font:inherit;">Đăng xuất</button>
</form>
</div>
<div class="page-header"> <div class="page-header">
<div> <div>
<h1>Lịch sử giao dịch</h1> <h1>Lịch sử giao dịch</h1>
<p class="subtitle">Danh sách giao dịch đã tạo và trạng thái thanh toán</p> <p class="subtitle">Danh sách giao dịch đã tạo và trạng thái thanh toán</p>
</div> </div>
{% if isAdmin %}
<form method="GET" action="/admin/transactions" style="display:flex;gap:8px;align-items:center;">
<select name="staff" onchange="this.form.submit()" style="height:40px;padding:0 10px;border:1px solid var(--color-border);border-radius:8px;font-family:inherit;font-size:13px;">
<option value="">Tất cả nhân viên</option>
{% for s in staffOptions %}
<option value="{{s}}" {% if s == selectedStaff %}selected{% endif %}>{{s}}</option>
{% endfor %}
</select>
</form>
{% endif %}
<a class="btn-primary" href="/admin/transactions/new"> <a class="btn-primary" href="/admin/transactions/new">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
Thêm mới giao dịch Thêm mới giao dịch
...@@ -237,6 +255,7 @@ ...@@ -237,6 +255,7 @@
<tr> <tr>
<th>Thời gian</th> <th>Thời gian</th>
<th>Mã giao dịch</th> <th>Mã giao dịch</th>
<th>Người tạo</th>
<th>Khách hàng</th> <th>Khách hàng</th>
<th>SĐT</th> <th>SĐT</th>
<th style="text-align:right">Số tiền</th> <th style="text-align:right">Số tiền</th>
...@@ -250,6 +269,7 @@ ...@@ -250,6 +269,7 @@
<tr> <tr>
<td class="cell-muted">{{tx.createdAt}}</td> <td class="cell-muted">{{tx.createdAt}}</td>
<td class="cell-mono">{{tx.merTrxId}}</td> <td class="cell-mono">{{tx.merTrxId}}</td>
<td class="cell-muted">{{tx.createdByUsername}}</td>
<td>{{tx.customerName}}</td> <td>{{tx.customerName}}</td>
<td class="cell-mono">{{tx.customerPhone}}</td> <td class="cell-mono">{{tx.customerPhone}}</td>
<td class="cell-amount">{{tx.amount}}</td> <td class="cell-amount">{{tx.amount}}</td>
...@@ -279,9 +299,9 @@ ...@@ -279,9 +299,9 @@
</div> </div>
<div class="pager"> <div class="pager">
{% if hasPrev %}<a href="/admin/transactions?page={{prevPage}}">&laquo; Trang trước</a>{% endif %} {% if hasPrev %}<a href="/admin/transactions?page={{prevPage}}{% if selectedStaff %}&staff={{selectedStaff}}{% endif %}">&laquo; Trang trước</a>{% endif %}
<span>Trang {{page}}</span> <span>Trang {{page}}</span>
{% if hasNext %}<a href="/admin/transactions?page={{nextPage}}">Trang sau &raquo;</a>{% endif %} {% if hasNext %}<a href="/admin/transactions?page={{nextPage}}{% if selectedStaff %}&staff={{selectedStaff}}{% endif %}">Trang sau &raquo;</a>{% endif %}
</div> </div>
</div> </div>
</body> </body>
......
...@@ -249,6 +249,14 @@ ...@@ -249,6 +249,14 @@
</head> </head>
<body> <body>
<div class="page"> <div class="page">
<div class="nav" style="display:flex;gap:16px;font-size:13px;color:var(--color-text-muted);margin-bottom:12px;">
<span>Xin chào, <strong>{{username}}</strong></span>
{% if isAdmin %}<a href="/admin/accounts" style="color:inherit;text-decoration:none;font-weight:500;">Quản lý tài khoản</a>{% endif %}
<a href="/admin/account/password" style="color:inherit;text-decoration:none;font-weight:500;">Đổi mật khẩu</a>
<form method="POST" action="/admin/logout" style="display:inline;">
<button type="submit" style="all:unset;cursor:pointer;color:inherit;font:inherit;">Đăng xuất</button>
</form>
</div>
<a class="breadcrumb" href="/admin/transactions"> <a class="breadcrumb" href="/admin/transactions">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 12H5M12 19l-7-7 7-7"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 12H5M12 19l-7-7 7-7"/></svg>
Lịch sử giao dịch Lịch sử giao dịch
...@@ -287,7 +295,6 @@ ...@@ -287,7 +295,6 @@
<div id="result"></div> <div id="result"></div>
</div> </div>
</div> </div>
<script> <script>
var iconCheck = '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M9 12l2 2 4-4"/></svg>'; var iconCheck = '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M9 12l2 2 4-4"/></svg>';
var iconAlert = '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>'; var iconAlert = '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>';
...@@ -313,6 +320,10 @@ ...@@ -313,6 +320,10 @@
}) })
.then(function (r) { return r.json(); }) .then(function (r) { return r.json(); })
.then(function (res) { .then(function (res) {
if (res.data === 'LOGIN_REQUIRED') {
window.location.href = '/admin/login';
return;
}
var el = document.getElementById('result'); var el = document.getElementById('result');
if (res.code === '00') { if (res.code === '00') {
el.innerHTML = el.innerHTML =
......
...@@ -100,6 +100,7 @@ module.exports = function() { ...@@ -100,6 +100,7 @@ module.exports = function() {
cookie: { cookie: {
secure: cookieSecure, secure: cookieSecure,
httpOnly: true, httpOnly: true,
sameSite: 'lax',
maxAge: config.session_max_age maxAge: config.session_max_age
}, },
// store: new mongoStore({ // store: new mongoStore({
......
...@@ -1630,7 +1630,7 @@ curl -sS -c /tmp/plan-cookie-cp.txt -b /tmp/plan-cookie-cp.txt -o /dev/null \ ...@@ -1630,7 +1630,7 @@ curl -sS -c /tmp/plan-cookie-cp.txt -b /tmp/plan-cookie-cp.txt -o /dev/null \
-X POST http://localhost:8092/admin/login -d "username=changepasstestuser&password=oldpass123" -X POST http://localhost:8092/admin/login -d "username=changepasstestuser&password=oldpass123"
echo "wrong current password -> expect error shown, no change:" echo "wrong current password -> expect error shown, no change:"
curl -sS -b /tmp/plan-cookie-cp.txt /tmp/plan-cookie-cp.txt http://localhost:8092/admin/account/password \ curl -sS -b /tmp/plan-cookie-cp.txt http://localhost:8092/admin/account/password \
-X POST -d "currentPassword=wrongcurrent&newPassword=newpass456" -o /tmp/cp-wrong-body.html -X POST -d "currentPassword=wrongcurrent&newPassword=newpass456" -o /tmp/cp-wrong-body.html
grep -c "không đúng" /tmp/cp-wrong-body.html grep -c "không đúng" /tmp/cp-wrong-body.html
......
...@@ -10,6 +10,7 @@ ...@@ -10,6 +10,7 @@
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"async": "^2.0.1", "async": "^2.0.1",
"bcryptjs": "^3.0.3",
"body-parser": "^1.9.3", "body-parser": "^1.9.3",
"bunyan": "^1.8.1", "bunyan": "^1.8.1",
"chalk": "^0.5.1", "chalk": "^0.5.1",
...@@ -23,6 +24,7 @@ ...@@ -23,6 +24,7 @@
"dateformat": "^3.0.3", "dateformat": "^3.0.3",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"express": "^4.14.0", "express": "^4.14.0",
"express-rate-limit": "^6.11.2",
"express-session": "^1.13.0", "express-session": "^1.13.0",
"glob": "^7.2.3", "glob": "^7.2.3",
"he": "^0.5.0", "he": "^0.5.0",
...@@ -491,6 +493,15 @@ ...@@ -491,6 +493,15 @@
"tweetnacl": "^0.14.3" "tweetnacl": "^0.14.3"
} }
}, },
"node_modules/bcryptjs": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
"integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
"license": "BSD-3-Clause",
"bin": {
"bcrypt": "bin/bcrypt"
}
},
"node_modules/binary-extensions": { "node_modules/binary-extensions": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.0.0.tgz", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.0.0.tgz",
...@@ -1840,6 +1851,18 @@ ...@@ -1840,6 +1851,18 @@
"node": ">=0.8.0" "node": ">=0.8.0"
} }
}, },
"node_modules/express-rate-limit": {
"version": "6.11.2",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-6.11.2.tgz",
"integrity": "sha512-a7uwwfNTh1U60ssiIkuLFWHt4hAC5yxlLGU2VP0X4YNlyEDZAqF4tK3GD3NSitVBrCQmQ0++0uOyFOgC2y4DDw==",
"license": "MIT",
"engines": {
"node": ">= 14"
},
"peerDependencies": {
"express": "^4 || ^5"
}
},
"node_modules/express-session": { "node_modules/express-session": {
"version": "1.17.0", "version": "1.17.0",
"resolved": "https://registry.npmjs.org/express-session/-/express-session-1.17.0.tgz", "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.17.0.tgz",
......
...@@ -15,6 +15,7 @@ ...@@ -15,6 +15,7 @@
}, },
"dependencies": { "dependencies": {
"async": "^2.0.1", "async": "^2.0.1",
"bcryptjs": "^3.0.3",
"body-parser": "^1.9.3", "body-parser": "^1.9.3",
"bunyan": "^1.8.1", "bunyan": "^1.8.1",
"chalk": "^0.5.1", "chalk": "^0.5.1",
...@@ -28,6 +29,7 @@ ...@@ -28,6 +29,7 @@
"dateformat": "^3.0.3", "dateformat": "^3.0.3",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"express": "^4.14.0", "express": "^4.14.0",
"express-rate-limit": "^6.11.2",
"express-session": "^1.13.0", "express-session": "^1.13.0",
"glob": "^7.2.3", "glob": "^7.2.3",
"he": "^0.5.0", "he": "^0.5.0",
......
...@@ -25,6 +25,11 @@ mongoose.connection.on('error', function (err) { ...@@ -25,6 +25,11 @@ mongoose.connection.on('error', function (err) {
}); });
mongoose.connection.once('open', function () { mongoose.connection.once('open', function () {
console.log('MongoDB connected'); console.log('MongoDB connected');
require('./app/libs/adminBootstrap')(config, function (err) {
if (err) {
console.error('adminBootstrap failed:', err.message);
}
});
}); });
/** /**
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment