> **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).
- 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
- 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.
- 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)**
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:
(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:"
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.
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)
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
varAdminUser=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){
varpage=parseInt(req.query.page)||1;
varperPage=20;
varisAdmin=req.session.role==="admin";
varfilter={};
if(!isAdmin){
filter.createdByUsername=req.session.username;
}elseif(req.query.staff){
filter.createdByUsername=req.query.staff;
}
functionrender(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);
(`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 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;
}
varel=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:"
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`.
- 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**
-[]**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):
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.
-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.
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).
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
<divclass="page-header">
<div>
<h1>Lịch sử giao dịch</h1>
<pclass="subtitle">Danh sách giao dịch đã tạo và trạng thái thanh toán</p>
(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:
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:
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**
-[]**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:
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**
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.