Commit 8cfa1225 by tdgiang

Add implementation plan for admin account management

parent fe1dd3b3
# Admin Account Management Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace the single shared Basic Auth credential protecting `/admin/*` with real multi-user login, so an admin can create staff accounts, and each staff account only sees the transactions it created while the admin sees everything.
**Architecture:** Session-based login built on the Redis-backed `express-session` already wired in `config/express.js`. A new `AdminUser` Mongoose model holds accounts (username, bcrypt password hash, role, active flag). Two new middlewares (`requireLogin`, `requireAdmin`) replace `basicAuth`. `AdminTransaction` gains a `createdByUsername` field that scopes every read/write. A one-time boot-time bootstrap seeds the first admin from the existing `ADMIN_USER`/`ADMIN_PASSWORD` env vars and backfills any transaction created before this feature existed.
**Tech Stack:** Express 4, Mongoose 5.7, `express-session` + `connect-redis` (already present), Swig templates, `bcryptjs` (new dependency — pure JS, no native compile step).
**Spec:** `docs/superpowers/specs/2026-08-29-admin-account-management-design.md`
## Global Constraints
- No real automated test framework exists in this repo — verification for every task is a throwaway `scratch/` script (gitignored) run against a real MongoDB, exactly like the prior admin-transaction-management plan. `scratch/` and `.worktrees/` are already gitignored.
- Password comparison must never leak whether a username exists — the login error message is always the generic "Sai tên đăng nhập hoặc mật khẩu" for both "no such user" and "wrong password".
- `basicAuth` is fully replaced, not layered — `app/middlewares/basicAuth.js` is deleted once nothing requires it (Task 5).
- Only 2 roles exist: `admin` and `staff`. No account deletion — only `active: true/false`.
- `/admin/pay/:merTrxId`, `/admin/epay/return`, `/admin/epay/ipn` (public, customer/MegaPay-facing) and the entire `/epay/*` legacy flow are never touched by this plan.
- New admin-facing views follow the visual system already established for the admin pages in this repo: Inter font (Google Fonts), navy/blue palette (`--color-primary: #1E40AF`), card-on-`#F1F5F9`-background layout, pill badges for status/role — match `app/views/admin/transactions-list.server.view.html` and `app/views/admin/transactions-new.server.view.html` exactly (same `<style>` token names) rather than inventing a new look.
- `POST /admin/transactions` keeps its existing `fetch()`/JSON contract unchanged (`{code, data}` shape) — this plan only adds a `LOGIN_REQUIRED` data value for the 401 case, consumed by a small addition to that page's existing script.
- Every new route file/middleware require path follows the exact relative-path conventions already used in `app/routes/admin.server.routes.js` (`"../../app/controllers/..."` for controllers, `"../middlewares/..."` for middlewares) and in `app/controllers/admin.server.controller.js` (`require(__config_path + "/config")`, `require("../models/...")`, `require("../libs/...")`).
---
### Task 1: Password hashing lib + `AdminUser` model
**Files:**
- Modify: `package.json` (add `bcryptjs` dependency)
- Create: `app/libs/passwordHash.js`
- Create: `app/models/AdminUser.js`
- Test: `scratch/test-admin-user-model.js`
**Interfaces:**
- Produces: `passwordHash.hash(plain)` → String (bcrypt hash), `passwordHash.compare(plain, hash)` → Boolean. `mongoose.model("AdminUser")` with fields `username` (String, unique, lowercase, trim), `passwordHash` (String), `role` (String enum `admin`|`staff`, default `staff`), `active` (Boolean, default `true`), plus `createdAt`/`updatedAt`.
- Consumed by: Tasks 3, 4, 5, 6, 7.
- [ ] **Step 1: Add the `bcryptjs` dependency**
```bash
npm install bcryptjs --save --legacy-peer-deps
```
Expected: `package.json`'s `dependencies` gains a `"bcryptjs": "^..."` line, `node_modules/bcryptjs` exists.
- [ ] **Step 2: Write the failing verification script**
```bash
mkdir -p scratch
cat > scratch/test-admin-user-model.js << 'EOF'
require("dotenv").config();
var mongoose = require("mongoose");
global.__config_path = __dirname + "/../config";
var config = require("../config/config");
mongoose.connect(config.mongoUri, { useNewUrlParser: true, useUnifiedTopology: true });
mongoose.connection.once("open", function () {
var passwordHash = require("../app/libs/passwordHash");
var AdminUser = require("../app/models/AdminUser");
var hash1 = passwordHash.hash("secret123");
console.log("hash differs from plain:", hash1 !== "secret123");
console.log("compare correct password:", passwordHash.compare("secret123", hash1) === true);
console.log("compare wrong password:", passwordHash.compare("wrongpass", hash1) === false);
AdminUser.create(
{ username: "ModelTestUser", passwordHash: hash1, role: "staff" },
function (err, user) {
if (err) {
console.log("CREATE FAILED:", err.message);
process.exit(1);
}
console.log("username lowercased:", user.username === "modeltestuser");
console.log("role default applied:", user.role === "staff");
console.log("active defaults true:", user.active === true);
mongoose.connection.close();
}
);
});
EOF
node scratch/test-admin-user-model.js
```
Expected: fails — `Cannot find module '../app/libs/passwordHash'` (neither file exists yet).
- [ ] **Step 3: Implement `app/libs/passwordHash.js`**
```js
"use strict";
var bcrypt = require("bcryptjs");
function hash(plain) {
return bcrypt.hashSync(plain, 10);
}
function compare(plain, hashed) {
return bcrypt.compareSync(plain, hashed);
}
module.exports = {
hash: hash,
compare: compare,
};
```
- [ ] **Step 4: Implement `app/models/AdminUser.js`**
```js
"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);
```
- [ ] **Step 5: Run the verification script again (standalone Mongo must be running)**
```bash
docker run -d --rm --name plan-mongo -p 27017:27017 mongo:7
sleep 2
node scratch/test-admin-user-model.js
docker stop plan-mongo
```
Expected: all 5 lines print `true`/`true`/`true`/`true`/`true` (matching the `console.log` labels above).
- [ ] **Step 6: Commit**
```bash
git add package.json package-lock.json app/libs/passwordHash.js app/models/AdminUser.js
git commit -m "Add bcryptjs password hashing lib and AdminUser model"
```
---
### Task 2: `requireLogin` + `requireAdmin` middlewares
**Files:**
- Create: `app/middlewares/requireLogin.js`
- Create: `app/middlewares/requireAdmin.js`
- Test: `scratch/test-admin-middlewares.js`
**Interfaces:**
- Consumes: nothing new — reads `req.session.userId`/`req.session.role`, set by Task 4's login handler.
- Produces: `requireLogin(req, res, next)` — calls `next()` if `req.session.userId` is truthy; otherwise responds `401 {code:"99", data:"LOGIN_REQUIRED"}` if the request's own `Content-Type` is JSON (`req.is("json")`), else redirects to `/admin/login`. `requireAdmin(req, res, next)` — calls `next()` if `req.session.role === "admin"`; otherwise `403`. Consumed by Task 5 (transaction routes), Task 6 (account routes), Task 7 (password routes).
This task is self-contained: the verification script fakes `req.session` directly (no real session store needed), so no Mongo/Redis required.
- [ ] **Step 1: Write the failing verification script**
```bash
cat > scratch/test-admin-middlewares.js << 'EOF'
var express = require("express");
var app = express();
app.use(express.json());
app.use(function (req, res, next) {
// Standalone test stand-in for express-session — real app sets req.session via
// the Redis-backed session middleware already wired in config/express.js.
req.session = req.headers["x-fake-session"] ? JSON.parse(req.headers["x-fake-session"]) : null;
next();
});
var requireLogin = require("../app/middlewares/requireLogin");
var requireAdmin = require("../app/middlewares/requireAdmin");
app.get("/protected", requireLogin, function (req, res) { res.send("ok"); });
app.get("/admin-only", requireLogin, requireAdmin, function (req, res) { res.send("ok"); });
app.post("/api-style", requireLogin, function (req, res) { res.json({ ok: true }); });
var server = app.listen(8098, function () {
var http = require("http");
function get(path, sessionObj, cb) {
var options = { host: "localhost", port: 8098, path: path, headers: {} };
if (sessionObj) options.headers["x-fake-session"] = JSON.stringify(sessionObj);
http.get(options, function (res) { cb(res.statusCode); });
}
function postJson(path, sessionObj, cb) {
var options = {
host: "localhost", port: 8098, path: path, method: "POST",
headers: { "Content-Type": "application/json", "Content-Length": 2 }
};
if (sessionObj) options.headers["x-fake-session"] = JSON.stringify(sessionObj);
var req = http.request(options, function (res) { cb(res.statusCode); });
req.end("{}");
}
get("/protected", null, function (status) {
console.log("no session -> /protected status", status, status === 302 ? "PASS" : "FAIL");
get("/protected", { userId: "1", username: "staffa", role: "staff" }, function (status2) {
console.log("with session -> /protected status", status2, status2 === 200 ? "PASS" : "FAIL");
get("/admin-only", { userId: "1", username: "staffa", role: "staff" }, function (status3) {
console.log("staff -> /admin-only status", status3, status3 === 403 ? "PASS" : "FAIL");
get("/admin-only", { userId: "2", username: "admin", role: "admin" }, function (status4) {
console.log("admin -> /admin-only status", status4, status4 === 200 ? "PASS" : "FAIL");
postJson("/api-style", null, function (status5) {
console.log("no session, json request -> /api-style status", status5, status5 === 401 ? "PASS" : "FAIL");
server.close();
});
});
});
});
});
});
EOF
node scratch/test-admin-middlewares.js
```
Expected: fails — `Cannot find module '../app/middlewares/requireLogin'`.
- [ ] **Step 2: Implement `app/middlewares/requireLogin.js`**
```js
"use strict";
module.exports = function requireLogin(req, res, next) {
if (req.session && req.session.userId) {
return next();
}
if (req.is("json")) {
return res.status(401).json({ code: "99", data: "LOGIN_REQUIRED" });
}
return res.redirect("/admin/login");
};
```
- [ ] **Step 3: Implement `app/middlewares/requireAdmin.js`**
```js
"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.");
};
```
- [ ] **Step 4: Run the verification script again**
```bash
node scratch/test-admin-middlewares.js
```
Expected: all 5 lines print `PASS`.
- [ ] **Step 5: Commit**
```bash
git add app/middlewares/requireLogin.js app/middlewares/requireAdmin.js
git commit -m "Add requireLogin and requireAdmin middlewares"
```
---
### Task 3: `AdminTransaction.createdByUsername` field + boot-time bootstrap (seed admin, backfill old transactions)
**Files:**
- Modify: `app/models/AdminTransaction.js` (add `createdByUsername` field)
- Create: `app/libs/adminBootstrap.js`
- Modify: `server.js` (call the bootstrap once Mongo connects)
- Test: `scratch/test-admin-bootstrap.js`
**Interfaces:**
- Consumes: `AdminUser` (Task 1), `passwordHash.hash` (Task 1), `config.admin.user`/`config.admin.password` (existing, read from `ADMIN_USER`/`ADMIN_PASSWORD`).
- Produces: `module.exports = function run(config, callback)` in `app/libs/adminBootstrap.js``callback(err)`. Idempotent: safe to call on every boot. Consumed by `server.js` only.
- [ ] **Step 1: Add `createdByUsername` to `AdminTransaction`**
In `app/models/AdminTransaction.js`, add one field to the schema (after `resultMsg`, before `paidAt` — order doesn't matter functionally):
```js
createdByUsername: { type: String, default: null },
```
(`default: null` rather than `required: true` — this field is populated by application code on every new create from Task 5 onward, but must not reject the one-time backfill `updateMany` this task performs, nor break `mongoose.model` re-registration for any already-open connection in a running process.)
- [ ] **Step 2: Write the failing verification script**
```bash
cat > scratch/test-admin-bootstrap.js << 'EOF'
require("dotenv").config();
var mongoose = require("mongoose");
global.__config_path = __dirname + "/../config";
var config = require("../config/config");
// Force test-only seed credentials, independent of whatever is in the developer's own .env.
config.admin = { user: "admin", password: "seedpass123" };
mongoose.connect(config.mongoUri, { useNewUrlParser: true, useUnifiedTopology: true });
mongoose.connection.once("open", function () {
var AdminUser = require("../app/models/AdminUser");
var AdminTransaction = require("../app/models/AdminTransaction");
var adminBootstrap = require("../app/libs/adminBootstrap");
// Simulate a transaction created before this feature existed (no createdByUsername).
AdminTransaction.collection.insertOne(
{
merTrxId: "HY_LEGACYTEST_" + Date.now(),
transCode: "HY_RT_LEGACYTEST",
customerName: "Legacy Customer",
customerPhone: "0900000000",
customerAddress: "Addr",
amount: 10000,
payType: "DC",
status: "pending",
merchantToken: "fixture",
timeStamp: "20260101120000",
resultMsg: "",
paidAt: null,
createdAt: new Date(),
updatedAt: new Date(),
},
function (err, insertResult) {
if (err) {
console.log("SETUP FAILED:", err.message);
process.exit(1);
}
var legacyId = insertResult.insertedId;
adminBootstrap(config, function (bootstrapErr) {
if (bootstrapErr) {
console.log("BOOTSTRAP FAILED:", bootstrapErr.message);
process.exit(1);
}
AdminUser.findOne({ role: "admin" }, function (err2, admin) {
console.log("admin seeded:", !!admin, "username:", admin && admin.username);
AdminTransaction.findById(legacyId, function (err3, tx) {
console.log("legacy tx backfilled:", tx.createdByUsername === (admin && admin.username));
// Running bootstrap a second time must stay idempotent (no duplicate admin).
adminBootstrap(config, function () {
AdminUser.countDocuments({ role: "admin" }, function (err4, count) {
console.log("idempotent - still exactly 1 admin:", count === 1);
mongoose.connection.close();
});
});
});
});
});
}
);
});
EOF
node scratch/test-admin-bootstrap.js
```
Expected: fails — `Cannot find module '../app/libs/adminBootstrap'`.
- [ ] **Step 3: Implement `app/libs/adminBootstrap.js`**
```js
"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);
}
AdminUser.create(
{
username: String(config.admin.user).trim().toLowerCase(),
passwordHash: passwordHash.hash(config.admin.password),
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);
}
);
}
```
- [ ] **Step 4: Wire the bootstrap into `server.js`**
In `server.js`, the current Mongo-connected callback reads:
```js
mongoose.connection.once('open', function () {
console.log('MongoDB connected');
});
```
Replace it with:
```js
mongoose.connection.once('open', function () {
console.log('MongoDB connected');
require('./app/libs/adminBootstrap')(config, function (err) {
if (err) {
console.error('adminBootstrap failed:', err.message);
}
});
});
```
- [ ] **Step 5: Run the verification script again (standalone Mongo must be running)**
```bash
docker run -d --rm --name plan-mongo -p 27017:27017 mongo:7
sleep 2
node scratch/test-admin-bootstrap.js
docker stop plan-mongo
```
Expected:
```
admin seeded: true username: admin
legacy tx backfilled: true
idempotent - still exactly 1 admin: true
```
- [ ] **Step 6: Commit**
```bash
git add app/models/AdminTransaction.js app/libs/adminBootstrap.js server.js
git commit -m "Add createdByUsername field and boot-time admin seed/backfill"
```
---
### Task 4: Login + logout (controller, view, routes)
**Files:**
- Create: `app/controllers/adminAuth.server.controller.js` (only `loginForm`, `login`, `logout` in this task — more exports added in Tasks 6, 7)
- Create: `app/views/admin/login.server.view.html`
- Modify: `app/routes/admin.server.routes.js` (add `/admin/login`, `/admin/logout` only — the transaction routes keep `basicAuth` for now, replaced in Task 5)
- Test: `scratch/test-admin-login.js`
**Interfaces:**
- Consumes: `AdminUser` (Task 1), `passwordHash.compare` (Task 1), `requireLogin` (Task 2, applied to `/admin/logout` only in this task).
- Produces: `exports.loginForm(req, res)`, `exports.login(req, res)`, `exports.logout(req, res)`. On success, `login` sets `req.session.userId`, `req.session.username`, `req.session.role` and redirects to `/admin/transactions`. Consumed by Task 5 onward (every protected route relies on this session shape).
This is the first task that needs a real HTTP + session round-trip — verification boots the actual app (`NODE_ENV=test node server.js`), which needs both a real Mongo and a real Redis reachable (the existing Redis-backed session store in `config/express.js`).
- [ ] **Step 1: Write the failing verification script (creates a test user directly, then drives the real app over HTTP)**
```bash
cat > scratch/test-admin-login.js << 'EOF'
require("dotenv").config();
var mongoose = require("mongoose");
global.__config_path = __dirname + "/../config";
var config = require("../config/config");
mongoose.connect(config.mongoUri, { useNewUrlParser: true, useUnifiedTopology: true });
mongoose.connection.once("open", function () {
var AdminUser = require("../app/models/AdminUser");
var passwordHash = require("../app/libs/passwordHash");
AdminUser.create(
{
username: "logintestuser",
passwordHash: passwordHash.hash("correcthorse"),
role: "staff",
active: true,
},
function (err) {
if (err) {
console.log("SETUP FAILED:", err.message);
process.exit(1);
}
console.log("test user ready - now run the curl checks below against the running app");
mongoose.connection.close();
}
);
});
EOF
node scratch/test-admin-login.js
```
Expected (before Step 3 exists): the setup script itself runs fine (it only touches `AdminUser`, already built in Task 1) — the actual failure to verify is that `/admin/login` doesn't exist yet:
```bash
NODE_ENV=test node server.js &
sleep 2
curl -sS -o /dev/null -w "%{http_code}\n" http://localhost:8092/admin/login
kill %1
```
Expected: `404` (route not registered yet).
- [ ] **Step 2: Implement `app/controllers/adminAuth.server.controller.js`**
```js
"use strict";
var AdminUser = require("../models/AdminUser");
var passwordHash = require("../libs/passwordHash");
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" });
}
if (!user || !passwordHash.compare(password, user.passwordHash)) {
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.userId = user._id.toString();
req.session.username = user.username;
req.session.role = user.role;
return res.redirect("/admin/transactions");
});
};
exports.logout = function (req, res) {
req.session.destroy(function () {
res.redirect("/admin/login");
});
};
```
- [ ] **Step 3: Implement `app/views/admin/login.server.view.html`**
```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>
```
- [ ] **Step 4: Wire `/admin/login` and `/admin/logout` into `app/routes/admin.server.routes.js`**
Add near the top of the routes function (before the existing `/admin/transactions` block, which is untouched in this task):
```js
var adminAuth = require("../../app/controllers/adminAuth.server.controller");
var requireLogin = require("../middlewares/requireLogin");
app.route("/admin/login")
.get(adminAuth.loginForm)
.post(adminAuth.login);
app.route("/admin/logout").post(requireLogin, adminAuth.logout);
```
(Add these two `var` lines alongside the file's existing `var admin = require(...)` and `var basicAuth = require(...)` lines at the top of the exported function — don't remove the existing lines, this task only adds to them.)
- [ ] **Step 5: Run the verification (standalone Mongo + Redis must be running)**
```bash
docker run -d --rm --name plan-mongo -p 27017:27017 mongo:7
docker run -d --rm --name plan-redis -p 6379:6379 redis:7-alpine
sleep 2
node scratch/test-admin-login.js
NODE_ENV=test node server.js &
sleep 2
echo "wrong password -> expect login page re-rendered (200) with the error message:"
curl -sS -o /tmp/wrong-body.html -w "%{http_code}\n" \
-X POST http://localhost:8092/admin/login -d "username=logintestuser&password=wrongpass"
grep -c "Sai tên đăng nhập hoặc mật khẩu" /tmp/wrong-body.html
echo "correct password -> expect 302 redirect:"
curl -sS -c /tmp/plan-cookies-ok.txt -o /dev/null -w "%{http_code}\n" \
-X POST http://localhost:8092/admin/login -d "username=logintestuser&password=correcthorse"
echo "logout with that now-authenticated session -> expect 302 redirect to /admin/login:"
curl -sS -b /tmp/plan-cookies-ok.txt -o /dev/null -w "%{http_code}\n" -X POST http://localhost:8092/admin/logout
kill %1
docker stop plan-mongo plan-redis
rm -f /tmp/plan-cookies-ok.txt /tmp/wrong-body.html
```
Expected: wrong-password POST returns `200` (re-rendered login page, `login()` only calls `res.redirect` on the success path) with the error text present (grep count `1`); correct-password POST returns `302`; logout with that session returns `302`.
Note: this app's session middleware uses `saveUninitialized: true` (`config/express.js`), so a `connect.sid` cookie is set on essentially every request regardless of login outcome — cookie *presence* is not a valid signal of a successful login. Distinguish success from failure by HTTP status and response body only, as above.
- [ ] **Step 6: Commit**
```bash
git add app/controllers/adminAuth.server.controller.js app/views/admin/login.server.view.html app/routes/admin.server.routes.js
git commit -m "Add admin login/logout with session-based auth"
```
---
### Task 5: Enforce login + data ownership on transaction routes; retire `basicAuth`
**Files:**
- Modify: `app/controllers/admin.server.controller.js` (`createTransaction` stamps `createdByUsername`; `listTransactions` scopes by role and supports `?staff=` filter for admins)
- Modify: `app/routes/admin.server.routes.js` (swap `basicAuth``requireLogin` on the 3 transaction routes)
- Modify: `app/views/admin/transactions-new.server.view.html` (handle the new `LOGIN_REQUIRED` JSON value from a session timeout mid-form)
- Delete: `app/middlewares/basicAuth.js` (nothing requires it after this task)
- Test: `scratch/test-admin-transaction-ownership.js`
**Interfaces:**
- Consumes: `requireLogin` (Task 2), `req.session.username`/`req.session.role` (Task 4), `AdminUser` (Task 1, for the staff dropdown source list).
- Produces: `listTransactions` now renders `admin/transactions-list` with extra locals `isAdmin` (Boolean), `username` (String), `staffOptions` (Array of String usernames, admin only), `selectedStaff` (String). Consumed by Task 8 (view update for the "Người tạo" column + filter dropdown).
- [ ] **Step 1: Write the failing verification script**
```bash
cat > scratch/test-admin-transaction-ownership.js << 'EOF'
require("dotenv").config();
var mongoose = require("mongoose");
global.__config_path = __dirname + "/../config";
var config = require("../config/config");
mongoose.connect(config.mongoUri, { useNewUrlParser: true, useUnifiedTopology: true });
mongoose.connection.once("open", function () {
var AdminUser = require("../app/models/AdminUser");
var passwordHash = require("../app/libs/passwordHash");
AdminUser.create(
[
{ username: "staffa", passwordHash: passwordHash.hash("passA123"), role: "staff", active: true },
{ username: "staffb", passwordHash: passwordHash.hash("passB123"), role: "staff", active: true },
{ username: "ownershiptestadmin", passwordHash: passwordHash.hash("adminpass123"), role: "admin", active: true },
],
function (err) {
if (err) {
console.log("SETUP FAILED:", err.message);
process.exit(1);
}
console.log("3 test users ready - now run the curl checks below against the running app");
mongoose.connection.close();
}
);
});
EOF
node scratch/test-admin-transaction-ownership.js
```
Expected (setup runs fine — `AdminUser` already exists from Task 1). The actual behavior to verify needs the real app running:
```bash
NODE_ENV=test node server.js &
sleep 2
curl -sS -o /dev/null -w "not logged in -> /admin/transactions expect 302: %{http_code}\n" http://localhost:8092/admin/transactions
kill %1
```
Expected: `302` already passes even before this task's changes (old `basicAuth` also redirects-equivalent... actually `basicAuth` returns `401`, not `302` — so this specific check is the one that currently FAILS pre-implementation): current behavior is `401` (Basic Auth challenge), not the `302` this task introduces. Confirms the task's change is needed.
- [ ] **Step 2: Modify `createTransaction` in `app/controllers/admin.server.controller.js`**
Add `require("../models/AdminUser")` to the top of the file alongside the existing requires:
```js
var AdminUser = require("../models/AdminUser");
```
In the `AdminTransaction.create({...})` call inside `createTransaction`, add one field to the object literal (alongside `merTrxId`, `transCode`, etc.):
```js
createdByUsername: req.session.username,
```
- [ ] **Step 3: Replace `listTransactions` in `app/controllers/admin.server.controller.js`**
Replace the whole existing `exports.listTransactions = function (req, res) {...}` block with:
```js
exports.listTransactions = function (req, res) {
var page = parseInt(req.query.page) || 1;
var perPage = 20;
var isAdmin = req.session.role === "admin";
var filter = {};
if (!isAdmin) {
filter.createdByUsername = req.session.username;
} else if (req.query.staff) {
filter.createdByUsername = req.query.staff;
}
function render(staffOptions) {
AdminTransaction.find(filter)
.sort({ createdAt: -1 })
.skip((page - 1) * perPage)
.limit(perPage)
.exec(function (err, list) {
if (err) {
console.error("listTransactions: DB error:", err.message);
return res.status(500).send("Lỗi cơ sở dữ liệu");
}
var transactions = list.map(function (tx) {
return {
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 || "",
});
});
}
if (isAdmin) {
AdminUser.find({ role: "staff" }, "username")
.sort({ username: 1 })
.exec(function (err, staffUsers) {
if (err) {
console.error("listTransactions: DB error loading staff list:", err.message);
return render([]);
}
render(staffUsers.map(function (u) { return u.username; }));
});
} else {
render([]);
}
};
```
- [ ] **Step 4: Also pass session info to `newTransactionForm`**
Replace:
```js
exports.newTransactionForm = function (req, res) {
res.render("admin/transactions-new", {});
};
```
with:
```js
exports.newTransactionForm = function (req, res) {
res.render("admin/transactions-new", {
username: req.session.username,
isAdmin: req.session.role === "admin",
});
};
```
- [ ] **Step 5: Swap `basicAuth` for `requireLogin` in `app/routes/admin.server.routes.js`**
Change:
```js
var basicAuth = require("../middlewares/basicAuth");
app.route("/admin/transactions")
.get(basicAuth, admin.listTransactions)
.post(basicAuth, admin.createTransaction);
app.route("/admin/transactions/new").get(basicAuth, admin.newTransactionForm);
```
to:
```js
app.route("/admin/transactions")
.get(requireLogin, admin.listTransactions)
.post(requireLogin, admin.createTransaction);
app.route("/admin/transactions/new").get(requireLogin, admin.newTransactionForm);
```
(`requireLogin` is already required at the top of the file from Task 4 — don't add a second `require`. The `basicAuth` require line is deleted entirely, not just unused.)
- [ ] **Step 6: Delete `app/middlewares/basicAuth.js`**
```bash
git rm app/middlewares/basicAuth.js
```
- [ ] **Step 7: Handle a session timeout in `app/views/admin/transactions-new.server.view.html`'s existing script**
In the `.then(function (res) {...})` callback of the existing `fetch('/admin/transactions', ...)` call, add a check for the new `LOGIN_REQUIRED` value as the very first line inside that callback, before the existing `if (res.code === '00')` check:
```js
.then(function (res) {
if (res.data === 'LOGIN_REQUIRED') {
window.location.href = '/admin/login';
return;
}
var el = document.getElementById('result');
```
(This is the only change to that file in this task — everything else in the script and the rest of the page stays exactly as it is.)
- [ ] **Step 8: Run the verification again (real app, full role-scoped HTTP flow)**
```bash
docker run -d --rm --name plan-mongo -p 27017:27017 mongo:7
docker run -d --rm --name plan-redis -p 6379:6379 redis:7-alpine
sleep 2
node scratch/test-admin-transaction-ownership.js
NODE_ENV=test node server.js &
sleep 2
echo "not logged in -> /admin/transactions expect 302:"
curl -sS -o /dev/null -w "%{http_code}\n" http://localhost:8092/admin/transactions
echo "not logged in, json POST -> expect 401 LOGIN_REQUIRED:"
curl -sS -X POST http://localhost:8092/admin/transactions -H "Content-Type: application/json" -d '{}'
curl -sS -c /tmp/plan-cookie-staffa.txt -o /dev/null http://localhost:8092/admin/login
curl -sS -c /tmp/plan-cookie-staffa.txt -b /tmp/plan-cookie-staffa.txt -o /dev/null \
-X POST http://localhost:8092/admin/login -d "username=staffa&password=passA123"
curl -sS -c /tmp/plan-cookie-staffb.txt -o /dev/null http://localhost:8092/admin/login
curl -sS -c /tmp/plan-cookie-staffb.txt -b /tmp/plan-cookie-staffb.txt -o /dev/null \
-X POST http://localhost:8092/admin/login -d "username=staffb&password=passB123"
curl -sS -c /tmp/plan-cookie-admin.txt -o /dev/null http://localhost:8092/admin/login
curl -sS -c /tmp/plan-cookie-admin.txt -b /tmp/plan-cookie-admin.txt -o /dev/null \
-X POST http://localhost:8092/admin/login -d "username=ownershiptestadmin&password=adminpass123"
echo "staffa creates a transaction:"
curl -sS -b /tmp/plan-cookie-staffa.txt -X POST http://localhost:8092/admin/transactions \
-H "Content-Type: application/json" \
-d '{"customerName":"A Customer","customerPhone":"0911111111","customerAddress":"Addr A","amount":"11000"}'
echo "staffb creates a transaction:"
curl -sS -b /tmp/plan-cookie-staffb.txt -X POST http://localhost:8092/admin/transactions \
-H "Content-Type: application/json" \
-d '{"customerName":"B Customer","customerPhone":"0922222222","customerAddress":"Addr B","amount":"22000"}'
echo "staffa's own list -> expect to see 'A Customer' only:"
curl -sS -b /tmp/plan-cookie-staffa.txt http://localhost:8092/admin/transactions | grep -c "A Customer"
curl -sS -b /tmp/plan-cookie-staffa.txt http://localhost:8092/admin/transactions | grep -c "B Customer"
echo "staffb trying ?staff=staffa is ignored -> expect to still see only 'B Customer':"
curl -sS -b /tmp/plan-cookie-staffb.txt "http://localhost:8092/admin/transactions?staff=staffa" | grep -c "A Customer"
curl -sS -b /tmp/plan-cookie-staffb.txt "http://localhost:8092/admin/transactions?staff=staffa" | grep -c "B Customer"
echo "admin's list -> expect to see BOTH:"
curl -sS -b /tmp/plan-cookie-admin.txt http://localhost:8092/admin/transactions | grep -c "A Customer"
curl -sS -b /tmp/plan-cookie-admin.txt http://localhost:8092/admin/transactions | grep -c "B Customer"
echo "admin filtered ?staff=staffb -> expect only 'B Customer':"
curl -sS -b /tmp/plan-cookie-admin.txt "http://localhost:8092/admin/transactions?staff=staffb" | grep -c "A Customer"
curl -sS -b /tmp/plan-cookie-admin.txt "http://localhost:8092/admin/transactions?staff=staffb" | grep -c "B Customer"
kill %1
docker stop plan-mongo plan-redis
rm -f /tmp/plan-cookie-staffa.txt /tmp/plan-cookie-staffb.txt /tmp/plan-cookie-admin.txt
```
Expected: `302`, then `{"code":"99","data":"LOGIN_REQUIRED"}` with `401`, both creates return `{"code":"00",...}`, staffa's list shows `1`/`0` (A present, B absent), staffb's filtered-attempt list shows `0`/`1` (A absent despite the query param, B present because it's their own), admin's unfiltered list shows `1`/`1`, admin's `?staff=staffb` shows `0`/`1`.
- [ ] **Step 9: Commit**
```bash
git add app/controllers/admin.server.controller.js app/routes/admin.server.routes.js app/views/admin/transactions-new.server.view.html
git commit -m "Scope transaction creation and listing by logged-in account; retire basicAuth"
```
---
### Task 6: Account management (admin-only: create, list, lock/unlock, reset password)
**Files:**
- Modify: `app/controllers/adminAuth.server.controller.js` (add `accountsList`, `createAccount`, `toggleAccount`, `resetAccountPassword`)
- Create: `app/views/admin/accounts-list.server.view.html`
- Modify: `app/routes/admin.server.routes.js` (add the 4 `/admin/accounts*` routes, admin-only)
- Test: `scratch/test-admin-accounts.js`
**Interfaces:**
- Consumes: `AdminUser` (Task 1), `passwordHash.hash` (Task 1), `requireLogin` + `requireAdmin` (Task 2).
- Produces: `exports.accountsList`, `exports.createAccount`, `exports.toggleAccount`, `exports.resetAccountPassword` on the existing `adminAuth.server.controller.js` module (appended after `logout`, which stays untouched). Not consumed by any later task in this plan — this is the leaf feature.
- [ ] **Step 1: Write the failing verification script**
```bash
cat > scratch/test-admin-accounts.js << 'EOF'
require("dotenv").config();
var mongoose = require("mongoose");
global.__config_path = __dirname + "/../config";
var config = require("../config/config");
mongoose.connect(config.mongoUri, { useNewUrlParser: true, useUnifiedTopology: true });
mongoose.connection.once("open", function () {
var AdminUser = require("../app/models/AdminUser");
var passwordHash = require("../app/libs/passwordHash");
AdminUser.create(
{ username: "accountstestadmin", passwordHash: passwordHash.hash("adminpass123"), role: "admin", active: true },
function (err) {
if (err) {
console.log("SETUP FAILED:", err.message);
process.exit(1);
}
console.log("admin test user ready - now run the curl checks below against the running app");
mongoose.connection.close();
}
);
});
EOF
node scratch/test-admin-accounts.js
```
Expected (setup runs fine). The behavior to verify needs the real app:
```bash
NODE_ENV=test node server.js &
sleep 2
curl -sS -c /tmp/plan-cookie-acctadmin.txt -o /dev/null http://localhost:8092/admin/login
curl -sS -c /tmp/plan-cookie-acctadmin.txt -b /tmp/plan-cookie-acctadmin.txt -o /dev/null \
-X POST http://localhost:8092/admin/login -d "username=accountstestadmin&password=adminpass123"
curl -sS -b /tmp/plan-cookie-acctadmin.txt -o /dev/null -w "%{http_code}\n" http://localhost:8092/admin/accounts
kill %1
```
Expected: `404` (route not registered yet).
- [ ] **Step 2: Append the account-management exports to `app/controllers/adminAuth.server.controller.js`**
Append after the existing `exports.logout` function (imports at the top of the file are unchanged — `AdminUser` and `passwordHash` are already required there from Task 4):
```js
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");
}
AdminUser.create(
{
username: username,
passwordHash: passwordHash.hash(password),
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) {
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");
}
user.active = !user.active;
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");
});
});
};
exports.resetAccountPassword = function (req, res) {
var newPassword = String(req.body.newPassword || "");
if (!newPassword) {
return res.redirect("/admin/accounts?error=MISSING_FIELDS");
}
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");
}
user.passwordHash = passwordHash.hash(newPassword);
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");
});
});
};
```
- [ ] **Step 3: Implement `app/views/admin/accounts-list.server.view.html`**
```html
<!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 %}
<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>
</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">
<form method="POST" action="/admin/accounts/{{u._id}}/toggle">
<button type="submit" class="secondary">{% if u.active %}Khoá{% else %}Mở khoá{% endif %}</button>
</form>
<form method="POST" action="/admin/accounts/{{u._id}}/reset-password">
<input type="password" name="newPassword" placeholder="Mật khẩu mới" required>
<button type="submit" class="secondary">Đặt lại</button>
</form>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</body>
</html>
```
- [ ] **Step 4: Wire the account routes into `app/routes/admin.server.routes.js`**
Add `requireAdmin` to the file's requires alongside `requireLogin`:
```js
var requireAdmin = require("../middlewares/requireAdmin");
```
Add the routes (near the `/admin/logout` block):
```js
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);
```
- [ ] **Step 5: Run the verification again**
```bash
docker run -d --rm --name plan-mongo -p 27017:27017 mongo:7
docker run -d --rm --name plan-redis -p 6379:6379 redis:7-alpine
sleep 2
node scratch/test-admin-accounts.js
NODE_ENV=test node server.js &
sleep 2
curl -sS -c /tmp/plan-cookie-acctadmin.txt -o /dev/null http://localhost:8092/admin/login
curl -sS -c /tmp/plan-cookie-acctadmin.txt -b /tmp/plan-cookie-acctadmin.txt -o /dev/null \
-X POST http://localhost:8092/admin/login -d "username=accountstestadmin&password=adminpass123"
echo "admin GET /admin/accounts -> expect 200:"
curl -sS -b /tmp/plan-cookie-acctadmin.txt -o /dev/null -w "%{http_code}\n" http://localhost:8092/admin/accounts
echo "admin creates a staff account 'newstaffuser' -> expect redirect then it shows up in the list:"
curl -sS -b /tmp/plan-cookie-acctadmin.txt -o /dev/null -w "%{http_code}\n" -X POST http://localhost:8092/admin/accounts \
-d "username=newstaffuser&password=initialpass123&role=staff"
curl -sS -b /tmp/plan-cookie-acctadmin.txt http://localhost:8092/admin/accounts | grep -c "newstaffuser"
echo "new staff can log in with the initial password:"
curl -sS -c /tmp/plan-cookie-newstaff.txt -o /dev/null http://localhost:8092/admin/login
curl -sS -c /tmp/plan-cookie-newstaff.txt -b /tmp/plan-cookie-newstaff.txt -o /dev/null -w "%{http_code}\n" \
-X POST http://localhost:8092/admin/login -d "username=newstaffuser&password=initialpass123"
curl -sS -b /tmp/plan-cookie-newstaff.txt -o /dev/null -w "staff -> /admin/accounts expect 403: %{http_code}\n" http://localhost:8092/admin/accounts
NEW_STAFF_ID=$(node -e "
require('dotenv').config();
var mongoose = require('mongoose');
global.__config_path = __dirname + '/config';
var config = require('./config/config');
mongoose.connect(config.mongoUri, { useNewUrlParser: true, useUnifiedTopology: true });
mongoose.connection.once('open', function () {
var AdminUser = require('./app/models/AdminUser');
AdminUser.findOne({ username: 'newstaffuser' }, function (err, user) {
console.log(user._id.toString());
mongoose.connection.close();
});
});
")
echo "toggling account off:"
curl -sS -b /tmp/plan-cookie-acctadmin.txt -o /dev/null -w "%{http_code}\n" -X POST "http://localhost:8092/admin/accounts/$NEW_STAFF_ID/toggle"
echo "locked account can no longer log in even with the correct password:"
curl -sS -c /tmp/plan-cookie-locked.txt -o /dev/null http://localhost:8092/admin/login
curl -sS -c /tmp/plan-cookie-locked.txt -b /tmp/plan-cookie-locked.txt -o /tmp/locked-body.html \
-X POST http://localhost:8092/admin/login -d "username=newstaffuser&password=initialpass123"
grep -c "đã bị khoá" /tmp/locked-body.html
echo "unlocking, then resetting password, then old password fails and new one works:"
curl -sS -b /tmp/plan-cookie-acctadmin.txt -o /dev/null -X POST "http://localhost:8092/admin/accounts/$NEW_STAFF_ID/toggle"
curl -sS -b /tmp/plan-cookie-acctadmin.txt -o /dev/null -w "%{http_code}\n" -X POST "http://localhost:8092/admin/accounts/$NEW_STAFF_ID/reset-password" \
-d "newPassword=brandnewpass456"
curl -sS -c /tmp/plan-cookie-oldpass.txt -o /dev/null http://localhost:8092/admin/login
curl -sS -c /tmp/plan-cookie-oldpass.txt -b /tmp/plan-cookie-oldpass.txt -o /tmp/oldpass-body.html \
-X POST http://localhost:8092/admin/login -d "username=newstaffuser&password=initialpass123"
grep -c "Sai tên đăng nhập" /tmp/oldpass-body.html
curl -sS -c /tmp/plan-cookie-newpass.txt -o /dev/null http://localhost:8092/admin/login
curl -sS -c /tmp/plan-cookie-newpass.txt -b /tmp/plan-cookie-newpass.txt -o /dev/null -w "%{http_code}\n" \
-X POST http://localhost:8092/admin/login -d "username=newstaffuser&password=brandnewpass456"
kill %1
docker stop plan-mongo plan-redis
rm -f /tmp/plan-cookie-*.txt /tmp/locked-body.html /tmp/oldpass-body.html
```
Expected: `200` for admin accounts page; `1` for the new username appearing in the list; `302` then `200` for the new staff's own login; `403` for that staff hitting `/admin/accounts`; `302` for the toggle; `1` for the "đã bị khoá" text after trying to log in locked; `302` for the reset; `1` for "Sai tên đăng nhập" with the old password; `302` for the new password succeeding.
- [ ] **Step 6: Commit**
```bash
git add app/controllers/adminAuth.server.controller.js app/views/admin/accounts-list.server.view.html app/routes/admin.server.routes.js
git commit -m "Add admin-only account management: create, list, lock/unlock, reset password"
```
---
### Task 7: Self-service change password
**Files:**
- Modify: `app/controllers/adminAuth.server.controller.js` (add `changePasswordForm`, `changePassword`)
- Create: `app/views/admin/change-password.server.view.html`
- Modify: `app/routes/admin.server.routes.js` (add `/admin/account/password`, any logged-in role)
- Test: `scratch/test-admin-change-password.js`
**Interfaces:**
- Consumes: `AdminUser`, `passwordHash` (already required in `adminAuth.server.controller.js` from Task 4), `requireLogin` (Task 2).
- Produces: `exports.changePasswordForm`, `exports.changePassword`. Not consumed by any later task.
- [ ] **Step 1: Write the failing verification script**
```bash
cat > scratch/test-admin-change-password.js << 'EOF'
require("dotenv").config();
var mongoose = require("mongoose");
global.__config_path = __dirname + "/../config";
var config = require("../config/config");
mongoose.connect(config.mongoUri, { useNewUrlParser: true, useUnifiedTopology: true });
mongoose.connection.once("open", function () {
var AdminUser = require("../app/models/AdminUser");
var passwordHash = require("../app/libs/passwordHash");
AdminUser.create(
{ username: "changepasstestuser", passwordHash: passwordHash.hash("oldpass123"), role: "staff", active: true },
function (err) {
if (err) {
console.log("SETUP FAILED:", err.message);
process.exit(1);
}
console.log("test user ready - now run the curl checks below against the running app");
mongoose.connection.close();
}
);
});
EOF
node scratch/test-admin-change-password.js
```
Expected (setup runs fine). The behavior to verify needs the real app:
```bash
NODE_ENV=test node server.js &
sleep 2
curl -sS -c /tmp/plan-cookie-cp.txt -o /dev/null http://localhost:8092/admin/login
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"
curl -sS -b /tmp/plan-cookie-cp.txt -o /dev/null -w "%{http_code}\n" http://localhost:8092/admin/account/password
kill %1
```
Expected: `404` (route not registered yet).
- [ ] **Step 2: Append `changePasswordForm` and `changePassword` to `app/controllers/adminAuth.server.controller.js`**
```js
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 || !passwordHash.compare(currentPassword, user.passwordHash)) {
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 });
}
user.passwordHash = passwordHash.hash(newPassword);
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 });
});
});
};
```
- [ ] **Step 3: Implement `app/views/admin/change-password.server.view.html`**
```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>
```
- [ ] **Step 4: Wire `/admin/account/password` into `app/routes/admin.server.routes.js`**
```js
app.route("/admin/account/password")
.get(requireLogin, adminAuth.changePasswordForm)
.post(requireLogin, adminAuth.changePassword);
```
- [ ] **Step 5: Run the verification again**
```bash
docker run -d --rm --name plan-mongo -p 27017:27017 mongo:7
docker run -d --rm --name plan-redis -p 6379:6379 redis:7-alpine
sleep 2
node scratch/test-admin-change-password.js
NODE_ENV=test node server.js &
sleep 2
curl -sS -c /tmp/plan-cookie-cp.txt -o /dev/null http://localhost:8092/admin/login
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"
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 \
-X POST -d "currentPassword=wrongcurrent&newPassword=newpass456" -o /tmp/cp-wrong-body.html
grep -c "không đúng" /tmp/cp-wrong-body.html
echo "correct current password -> expect success:"
curl -sS -b /tmp/plan-cookie-cp.txt http://localhost:8092/admin/account/password \
-X POST -d "currentPassword=oldpass123&newPassword=newpass456" -o /tmp/cp-ok-body.html
grep -c "thành công" /tmp/cp-ok-body.html
echo "old password no longer logs in:"
curl -sS -c /tmp/plan-cookie-cp-old.txt -o /dev/null http://localhost:8092/admin/login
curl -sS -c /tmp/plan-cookie-cp-old.txt -b /tmp/plan-cookie-cp-old.txt -o /tmp/cp-old-login.html \
-X POST http://localhost:8092/admin/login -d "username=changepasstestuser&password=oldpass123"
grep -c "Sai tên đăng nhập" /tmp/cp-old-login.html
echo "new password logs in:"
curl -sS -c /tmp/plan-cookie-cp-new.txt -o /dev/null http://localhost:8092/admin/login
curl -sS -c /tmp/plan-cookie-cp-new.txt -b /tmp/plan-cookie-cp-new.txt -o /dev/null -w "%{http_code}\n" \
-X POST http://localhost:8092/admin/login -d "username=changepasstestuser&password=newpass456"
kill %1
docker stop plan-mongo plan-redis
rm -f /tmp/plan-cookie-cp*.txt /tmp/cp-*.html
```
Expected: `1` for the wrong-current-password error text, `1` for the success text, `1` for "Sai tên đăng nhập" with the old password, `302` for the new password login.
- [ ] **Step 6: Commit**
```bash
git add app/controllers/adminAuth.server.controller.js app/views/admin/change-password.server.view.html app/routes/admin.server.routes.js
git commit -m "Add self-service change-password for logged-in accounts"
```
---
### Task 8: UI integration — nav links, "Người tạo" column, staff filter
**Files:**
- Modify: `app/views/admin/transactions-list.server.view.html`
- Modify: `app/views/admin/transactions-new.server.view.html`
- Test: `scratch/test-admin-ui-integration.js`
**Interfaces:**
- Consumes: `isAdmin`, `username`, `staffOptions`, `selectedStaff` locals from `listTransactions` (Task 5); `username`, `isAdmin` locals from `newTransactionForm` (Task 5).
- Produces: nothing new — this is the last task before the full end-to-end check (Task 9).
- [ ] **Step 1: Write the verification script (renders both views standalone with Swig, no HTTP needed)**
```bash
cat > scratch/test-admin-ui-integration.js << 'EOF'
global.__config_path = __dirname + "/../config";
var swig = require("swig");
var app = require("express")();
app.engine("server.view.html", swig.renderFile);
app.set("view engine", "server.view.html");
app.set("views", __dirname + "/../app/views");
app.get("/list-as-staff", function (req, res) {
res.render("admin/transactions-list", {
transactions: [{ merTrxId: "HY_X", customerName: "Cust", customerPhone: "09", amount: 1000, status: "pending", resultMsg: "", createdByUsername: "staffa", createdAt: "01/01/2026 00:00", paymentUrl: "http://x" }],
page: 1, prevPage: 0, nextPage: 2, hasPrev: false, hasNext: false,
isAdmin: false, username: "staffa", staffOptions: [], selectedStaff: "",
});
});
app.get("/list-as-admin", function (req, res) {
res.render("admin/transactions-list", {
transactions: [{ merTrxId: "HY_X", customerName: "Cust", customerPhone: "09", amount: 1000, status: "pending", resultMsg: "", createdByUsername: "staffa", createdAt: "01/01/2026 00:00", paymentUrl: "http://x" }],
page: 1, prevPage: 0, nextPage: 2, hasPrev: false, hasNext: false,
isAdmin: true, username: "adminuser", staffOptions: ["staffa", "staffb"], selectedStaff: "",
});
});
app.get("/new-as-staff", function (req, res) {
res.render("admin/transactions-new", { username: "staffa", isAdmin: false });
});
app.listen(9101, function () {
var http = require("http");
function getBody(path, cb) {
http.get({ host: "localhost", port: 9101, path: path }, function (res) {
var body = "";
res.on("data", function (chunk) { body += chunk; });
res.on("end", function () { cb(body); });
});
}
getBody("/list-as-staff", function (body) {
console.log("staff list shows 'Người tạo' column value:", body.indexOf("staffa") !== -1);
console.log("staff list has NO 'Quản lý tài khoản' link:", body.indexOf("Quản lý tài khoản") === -1);
console.log("staff list has NO staff filter dropdown:", body.indexOf('name="staff"') === -1);
getBody("/list-as-admin", function (body2) {
console.log("admin list HAS 'Quản lý tài khoản' link:", body2.indexOf("Quản lý tài khoản") !== -1);
console.log("admin list HAS staff filter dropdown:", body2.indexOf('name="staff"') !== -1);
getBody("/new-as-staff", function (body3) {
console.log("new-transaction page has 'Đăng xuất':", body3.indexOf("Đăng xuất") !== -1);
process.exit(0);
});
});
});
});
EOF
node scratch/test-admin-ui-integration.js
```
Expected: the first two lines print `true`, the "has NO" checks print `true` (because the views don't render admin-only elements yet for staff, which is already correct even before this task's changes — the checks that will initially FAIL are the two `admin list HAS ...` lines: `false`, since `transactions-list.server.view.html` doesn't render a "Quản lý tài khoản" link or a staff filter at all yet).
- [ ] **Step 2: Update `app/views/admin/transactions-list.server.view.html`**
Replace the `.page-header` block (currently just the title + "Thêm mới giao dịch" button) with a version that adds a small nav row above it and, for admins, a staff filter next to the header:
Replace:
```html
<div class="page-header">
<div>
<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>
</div>
<a class="btn-primary" href="/admin/transactions/new">
```
with:
```html
<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>
<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>
</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">
```
(The closing `</a>` and the rest of the original header markup — the SVG icon and "Thêm mới giao dịch" text — stay exactly as they are; only the opening portion shown above changes.)
Then add a "Người tạo" column to the table. Change the `<thead>` row from:
```html
<th>Thời gian</th>
<th>Mã giao dịch</th>
<th>Khách hàng</th>
```
to:
```html
<th>Thời gian</th>
<th>Mã giao dịch</th>
<th>Người tạo</th>
<th>Khách hàng</th>
```
and the corresponding `<tbody>` row from:
```html
<td class="cell-muted">{{tx.createdAt}}</td>
<td class="cell-mono">{{tx.merTrxId}}</td>
<td>{{tx.customerName}}</td>
```
to:
```html
<td class="cell-muted">{{tx.createdAt}}</td>
<td class="cell-mono">{{tx.merTrxId}}</td>
<td class="cell-muted">{{tx.createdByUsername}}</td>
<td>{{tx.customerName}}</td>
```
- [ ] **Step 3: Update `app/views/admin/transactions-new.server.view.html`**
Add the same small nav row used on the list page, right after the opening `<div class="page">` and before the existing `<a class="breadcrumb" ...>` link:
```html
<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>
```
- [ ] **Step 4: Run the verification script again**
```bash
node scratch/test-admin-ui-integration.js
```
Expected: all 6 lines print `true`.
- [ ] **Step 5: Commit**
```bash
git add app/views/admin/transactions-list.server.view.html app/views/admin/transactions-new.server.view.html
git commit -m "Add account nav, creator column, and staff filter to admin transaction views"
```
---
### Task 9: Full Docker Compose integration test
**Files:** none (verification-only task)
**Interfaces:** none new — exercises everything from Tasks 1–8 together in the real deployment shape (`docker-compose.yml`, `NODE_ENV: production` inside the container), the same way the prior admin-transaction-management plan's final task did.
- [ ] **Step 1: Stop any standalone dev Mongo/Redis from earlier tasks**
```bash
docker stop plan-mongo plan-redis 2>/dev/null || true
```
- [ ] **Step 2: Confirm the real `.env` used by Docker Compose has what this feature needs**
`MONGO_URI`, `ADMIN_USER`, `ADMIN_PASSWORD`, `SESSION_SECRET` should already be set from the prior admin-transaction-management plan's own Task 11 — no new env vars are introduced by this plan. Just confirm none of the four are blank:
```bash
grep -E "^(MONGO_URI|ADMIN_USER|ADMIN_PASSWORD|SESSION_SECRET)=" .env
```
- [ ] **Step 3: Build and start the full stack**
```bash
docker compose up -d --build
docker compose ps
```
Expected: `app`, `redis`, `mongo` all `Up` (`app` reaches `healthy`).
- [ ] **Step 4: Verify the boot-time bootstrap ran**
```bash
docker compose logs app | grep -iE "adminbootstrap|mongodb connected"
```
Expected: `MongoDB connected`, then either `adminBootstrap: seeded first admin account: <ADMIN_USER value>` (first-ever boot) or no bootstrap line at all (already seeded on a prior boot — both are correct, idempotent by design).
- [ ] **Step 5: End-to-end RBAC smoke test through the real container**
```bash
ADMIN_PASS=$(grep '^ADMIN_PASSWORD=' .env | cut -d= -f2-)
ADMIN_USERNAME=$(grep '^ADMIN_USER=' .env | cut -d= -f2-)
echo "not logged in -> expect 302:"
curl -sS -o /dev/null -w "%{http_code}\n" http://localhost:7003/admin/transactions
curl -sS -c /tmp/final-cookie-admin.txt -o /dev/null http://localhost:7003/admin/login
curl -sS -c /tmp/final-cookie-admin.txt -b /tmp/final-cookie-admin.txt -o /dev/null -w "admin login -> %{http_code}\n" \
-X POST http://localhost:7003/admin/login --data-urlencode "username=$ADMIN_USERNAME" --data-urlencode "password=$ADMIN_PASS"
echo "admin creates a staff account:"
curl -sS -b /tmp/final-cookie-admin.txt -o /dev/null -w "%{http_code}\n" -X POST http://localhost:7003/admin/accounts \
-d "username=finalteststaff&password=finalpass123&role=staff"
curl -sS -c /tmp/final-cookie-staff.txt -o /dev/null http://localhost:7003/admin/login
curl -sS -c /tmp/final-cookie-staff.txt -b /tmp/final-cookie-staff.txt -o /dev/null -w "staff login -> %{http_code}\n" \
-X POST http://localhost:7003/admin/login -d "username=finalteststaff&password=finalpass123"
echo "staff creates a transaction:"
curl -sS -b /tmp/final-cookie-staff.txt -X POST http://localhost:7003/admin/transactions \
-H "Content-Type: application/json" \
-d '{"customerName":"Final Test","customerPhone":"0900000001","customerAddress":"Addr","amount":"15000"}'
echo "staff sees own transaction, admin sees it too:"
curl -sS -b /tmp/final-cookie-staff.txt http://localhost:7003/admin/transactions | grep -c "Final Test"
curl -sS -b /tmp/final-cookie-admin.txt http://localhost:7003/admin/transactions | grep -c "Final Test"
echo "staff blocked from account management:"
curl -sS -b /tmp/final-cookie-staff.txt -o /dev/null -w "%{http_code}\n" http://localhost:7003/admin/accounts
rm -f /tmp/final-cookie-admin.txt /tmp/final-cookie-staff.txt
```
Expected: `302`, `302` (login), `302` (account created), `302` (staff login), `{"code":"00",...}`, `1`/`1` (both see it), `403` (blocked).
- [ ] **Step 6: Verify persistence directly in MongoDB**
```bash
docker compose exec -T mongo mongosh haiyen_admin --quiet --eval "db.adminusers.find({}, {username:1, role:1, active:1, _id:0}).toArray()"
docker compose exec -T mongo mongosh haiyen_admin --quiet --eval "db.admintransactions.findOne({customerName:'Final Test'}, {createdByUsername:1, _id:0})"
```
Expected: the account list includes the seeded admin and `finalteststaff` (role `staff`, `active: true`); the transaction's `createdByUsername` is `finalteststaff`.
- [ ] **Step 7: Tear down**
```bash
docker compose down
```
- [ ] **Step 8: Final commit (if anything changed)**
```bash
git status --short
```
If nothing is staged, there's nothing to commit — Task 9 is verification-only.
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