Guide for building the MMTP Admin Portal (MMAP) — a banking-grade administrative interface for the MyMoolah Treasury Platform. Covers RBAC, dashboard architecture, data tables, maker-checker workflows, admin audit logging, and overlay patterns. Use when building portal screens, admin APIs, or management interfaces.
You are an expert full-stack developer building the MyMoolah Treasury Platform Admin Portal (MMAP). When this skill is active you MUST follow the patterns, file conventions, and security requirements documented below for every portal screen, admin API endpoint, dashboard widget, and management interface you create or modify.
This skill activates when the context involves:
/api/v1/admin/portal/ directory treeRelationship to Wallet Frontend: The portal is a separate React
app from the wallet (mymoolah-wallet-frontend/). It shares the same
PostgreSQL database via Cloud SQL but runs its own Express backend and
Vite dev server. Portal is desktop-first; wallet is mobile-first.
Both use Tailwind CSS and the MyMoolah design system.
portal/
├── admin/
│ └── frontend/ # React + Vite admin SPA
│ ├── src/
│ │ ├── App.tsx # Root component
│ │ ├── main.tsx # Vite entry point
│ │ ├── index.css # Tailwind imports + global styles
│ │ ├── pages/
│ │ │ ├── AdminDashboard.tsx
│ │ │ ├── AdminLogin.tsx
│ │ │ └── AdminLoginSimple.tsx
│ │ ├── components/
│ │ │ ├── routing/
│ │ │ │ └── RouteConfig.tsx # All portal routes
│ │ │ ├── layout/
│ │ │ │ └── AppLayoutWrapper.tsx # Sidebar + top bar shell
│ │ │ ├── admin-overlays/ # Feature screens
│ │ │ │ ├── UnallocatedDepositsOverlay.tsx ← LIVE
│ │ │ │ ├── DisbursementRunsOverlay.tsx ← LIVE
│ │ │ │ ├── DisbursementRunDetailOverlay.tsx ← LIVE
│ │ │ │ ├── CreateDisbursementRunOverlay.tsx ← LIVE
│ │ │ │ ├── UserManagementOverlay.tsx ← PLACEHOLDER
│ │ │ │ ├── TransactionMonitoringOverlay.tsx ← PLACEHOLDER
│ │ │ │ ├── FloatManagementOverlay.tsx ← PLACEHOLDER
│ │ │ │ ├── SettlementManagementOverlay.tsx ← PLACEHOLDER
│ │ │ │ ├── ReportingAnalyticsOverlay.tsx ← PLACEHOLDER
│ │ │ │ ├── ServiceManagementOverlay.tsx ← PLACEHOLDER
│ │ │ │ ├── SystemConfigurationOverlay.tsx ← PLACEHOLDER
│ │ │ │ ├── SecurityAuditOverlay.tsx ← PLACEHOLDER
│ │ │ │ └── PartnerOnboardingOverlay.tsx ← PLACEHOLDER
│ │ │ ├── ui/ # Shadcn-style primitives
│ │ │ │ ├── button.tsx
│ │ │ │ ├── card.tsx
│ │ │ │ ├── table.tsx
│ │ │ │ ├── badge.tsx
│ │ │ │ ├── dialog.tsx
│ │ │ │ ├── input.tsx
│ │ │ │ ├── label.tsx
│ │ │ │ ├── alert.tsx
│ │ │ │ ├── checkbox.tsx
│ │ │ │ ├── separator.tsx
│ │ │ │ └── dropdown-menu.tsx
│ │ │ ├── common/
│ │ │ │ └── ErrorBoundary.tsx
│ │ │ └── providers/
│ │ │ └── AppProviders.tsx
│ │ ├── contexts/
│ │ │ ├── AuthContext.tsx
│ │ │ └── MoolahContext.tsx
│ │ └── lib/
│ │ └── utils.ts # cn() helper (clsx + tailwind-merge)
│ ├── tailwind.config.js
│ ├── vite.config.ts
│ └── package.json
├── backend/ # Express API for portal
│ ├── app.js # Express app setup
│ ├── server.js # HTTP listener
│ ├── routes/
│ │ ├── admin.js # /api/v1/admin/* routes
│ │ └── auth.js # /api/v1/auth/* routes
│ ├── controllers/
│ │ ├── adminController.js
│ │ └── authController.js
│ ├── middleware/
│ │ └── portalAuth.js # JWT auth + RBAC middleware
│ ├── models/
│ │ ├── index.js
│ │ ├── PortalUser.js
│ │ └── DualRoleFloat.js
│ ├── migrations/
│ │ └── 20250904_create_portal_tables.js
│ ├── seeders/
│ │ └── 20250904_seed_admin_user.js
│ ├── config/
│ │ └── config.json
│ └── package.json
├── shared/ # Cross-portal shared components
│ ├── components/
│ │ ├── Cards/
│ │ │ └── MetricCard.tsx
│ │ └── Layout/
│ │ └── PortalLayout.tsx
│ └── styles/
│ ├── portal-config.css
│ └── globals.css
├── package.json
└── env.template
| Concern | Wallet | Admin Portal |
|---|---|---|
| Codebase | mymoolah-wallet-frontend/ | portal/admin/frontend/ |
| Backend | Main Express app (server.js, routes/) | portal/backend/ |
| Design focus | Mobile-first | Desktop-first |
| Users | End-user wallet holders | Ops staff, admins, managers |
| Auth | JWT via main /api/v1/auth | JWT via portal/backend/middleware/portalAuth.js |
| Database | Cloud SQL (via db-connection-helper.js) | Same Cloud SQL (via db-connection-helper.js) |
The portal backend connects to the same Cloud SQL instances as the
main MMTP app. All queries MUST use scripts/db-connection-helper.js:
// portal/backend/controllers/adminController.js
const { getUATClient, getStagingClient, getProductionClient } = require('../../../scripts/db-connection-helper');
async function getFloatBalances(req, res) {
const client = await getProductionClient();
try {
const result = await client.query(
`SELECT la.code, la.name, la.balance
FROM ledger_accounts la
WHERE la.code LIKE '1200-%'
ORDER BY la.code`,
);
res.json({ success: true, data: result.rows, timestamp: new Date().toISOString() });
} finally {
client.release();
}
}
Never use new Sequelize(...), new Pool(...), or raw
process.env.DATABASE_URL in portal code.
The portal reads from the same tables as the main app. In addition, portal-specific tables exist:
| Table | Model | Purpose |
|---|---|---|
portal_users | PortalUser | Admin/supplier/merchant login accounts |
dual_role_floats | DualRoleFloat | Float tracking for dual-role entities |
When querying main-app tables (e.g. users, transactions,
journal_entries, ledger_accounts) from the portal backend, use
db-connection-helper.js raw queries — do NOT import main-app Sequelize
models.
Portal authentication is handled by portal/backend/middleware/portalAuth.js.
Exported middleware functions:
| Middleware | Purpose | Usage |
|---|---|---|
portalAuth(portalType) | Verify JWT + check portal access | portalAuth('admin') on every admin route |
requireRole(roles) | Restrict to specific roles | requireRole(['admin', 'manager']) |
requirePermission(perm) | Check granular permission | requirePermission('float.topup') |
requireDualRole(role) | Check dual-role access | requireDualRole('supplier') |
requireEntityOwnership(param) | Scope to own entity data | requireEntityOwnership('entityId') |
auditLog(action) | Log admin action to audit trail | auditLog('VIEW_USERS') |
Chaining middleware on a route:
router.post('/floats/:id/topup',
portalAuth('admin'),
requireRole(['admin', 'manager']),
auditLog('FLOAT_TOPUP'),
strictLimit,
[
body('amount').isFloat({ min: 0.01 }).withMessage('amount must be positive'),
body('reason').notEmpty().isLength({ max: 500 }),
],
adminController.topUpFloat.bind(adminController)
);
admin Full access. Can approve own-entity changes, manage all entities.
└── manager Can initiate financial operations, manage users within scope.
└── user Can view data, initiate non-financial operations.
└── viewer Read-only access to dashboards and reports.
The admin role implicitly has all permissions. Lower roles require
explicit entries in the permissions JSONB column of portal_users.
supplier — VAS suppliers (Flash, MobileMart, eeziCash, etc.)
client — Corporate clients using treasury services
merchant — Point-of-sale merchants (NFC deposits, cashout)
reseller — Sub-distributors reselling VAS products
admin — MyMoolah internal operations staff
Each PortalUser has an entityType and entityId linking them to the
corresponding entity in the core MMTP database. A user with
hasDualRole: true can access multiple portal types (e.g. a merchant
who is also a supplier).
// In a controller — check granular permission
if (!req.portalUser.role === 'admin' && !req.portalUser.permissions?.['settlement.approve']) {
return res.status(403).json({ success: false, error: 'Insufficient permissions.', timestamp: new Date().toISOString() });
}
On the frontend, use the AuthContext to gate UI elements:
import { useAuth } from '../contexts/AuthContext';
function SettlementActions() {
const { user } = useAuth();
const canApprove = user?.role === 'admin' || user?.permissions?.['settlement.approve'];
return canApprove ? <Button onClick={handleApprove}>Approve</Button> : null;
}
localStorage as portal_tokenportal_userProtectedRoute component (in RouteConfig.tsx) checks both
exist before rendering protected routes; redirects to /admin/login
otherwisePORTAL_JWT_SECRET env var)The shared MetricCard component (portal/shared/components/Cards/MetricCard.tsx)
renders a single KPI tile:
import MetricCard from '../../shared/components/Cards/MetricCard';
import { DollarSign } from 'lucide-react';
<MetricCard
title="Total Float Balance"
value={2450000}
format="currency" // 'currency' | 'number' | 'percentage' | 'text'
color="green" // 'green' | 'blue' | 'red' | 'orange' | 'purple' | 'gray'
icon={DollarSign}
subtitle="Across all suppliers"
change={{ value: 4.2, type: 'increase' }}
loading={isLoading}
/>
Props:
| Prop | Type | Description |
|---|---|---|
title | string | KPI label |
value | string | number | Metric value |
format | 'currency' | 'number' | 'percentage' | 'text' | How to format value |
color | string | Card accent color |
icon | LucideIcon | Icon from lucide-react |
subtitle | string? | Secondary text below value |
change | { value: number, type: 'increase' | 'decrease' | 'neutral' }? | Trend indicator |
loading | boolean? | Show placeholder while fetching |
Use a single aggregate API endpoint to fetch all dashboard metrics in one call, reducing round trips and ensuring consistent timestamps:
// Backend: GET /api/v1/admin/dashboard
async getDashboard(req, res) {
const client = await getProductionClient();
try {
const [metrics, alerts, recentTxns] = await Promise.all([
client.query(`
SELECT
(SELECT COUNT(*) FROM users WHERE "isActive" = true) AS total_users,
(SELECT SUM(balance) FROM ledger_accounts WHERE code LIKE '1200-%') AS total_float,
(SELECT COUNT(*) FROM transactions WHERE "createdAt" > NOW() - INTERVAL '24 hours') AS txns_24h
`),
client.query(`SELECT * FROM admin_alerts WHERE resolved = false ORDER BY created_at DESC LIMIT 10`),
client.query(`SELECT * FROM transactions ORDER BY "createdAt" DESC LIMIT 20`),
]);
res.json({
success: true,
data: { metrics: metrics.rows[0], alerts: alerts.rows, recentTransactions: recentTxns.rows },
timestamp: new Date().toISOString(),
});
} finally {
client.release();
}
}
| Strategy | When to Use | Implementation |
|---|---|---|
| Polling (30s) | Dashboard metrics, float balances | setInterval + useEffect cleanup |
| Polling (5s) | Active settlement processing, disbursement runs | Short interval during active operations only |
| Manual refresh | Data tables, transaction search | Refresh button in header |
| SSE/WebSocket | Future: real-time alerts | Not implemented yet |
useEffect(() => {
fetchDashboard();
const interval = setInterval(fetchDashboard, 30_000);
return () => clearInterval(interval);
}, []);
Financial KPIs:
2600-01-01)Operational KPIs:
Compliance KPIs:
| Chart | Use For |
|---|---|
| Line | Transaction volume over time, revenue trends |
| Bar | Supplier comparison, daily volumes |
| Donut/Pie | Entity distribution, KYC tier breakdown |
| Sparkline | Inline trend in MetricCard subtitle |
| Heatmap | Transaction volume by hour/day |
Use a lightweight chart library (recharts or chart.js) — avoid
pulling in heavy visualization bundles.
All portal data tables MUST use server-side pagination. Client-side pagination is forbidden for banking data because:
Backend pattern:
async getTransactions(req, res) {
const page = parseInt(req.query.page) || 1;
const limit = Math.min(parseInt(req.query.limit) || 20, 100);
const offset = (page - 1) * limit;
const client = await getProductionClient();
try {
const [dataResult, countResult] = await Promise.all([
client.query(
`SELECT id, type, amount, status, "createdAt"
FROM transactions
ORDER BY "createdAt" DESC
LIMIT $1 OFFSET $2`,
[limit, offset]
),
client.query(`SELECT COUNT(*) AS total FROM transactions`),
]);
const total = parseInt(countResult.rows[0].total);
res.json({
success: true,
data: dataResult.rows,
pagination: { page, limit, total, totalPages: Math.ceil(total / limit) },
timestamp: new Date().toISOString(),
});
} finally {
client.release();
}
}
Frontend pagination component:
function Pagination({ pagination, onPageChange }: {
pagination: { page: number; limit: number; total: number; totalPages: number };
onPageChange: (page: number) => void;
}) {
return (
<div className="flex items-center justify-between text-sm text-gray-500">
<span>
Showing {((pagination.page - 1) * pagination.limit) + 1}–
{Math.min(pagination.page * pagination.limit, pagination.total)} of {pagination.total}
</span>
<div className="flex gap-2">
<Button variant="outline" size="sm"
onClick={() => onPageChange(pagination.page - 1)}
disabled={pagination.page <= 1}>
Previous
</Button>
<Button variant="outline" size="sm"
onClick={() => onPageChange(pagination.page + 1)}
disabled={pagination.page >= pagination.totalPages}>
Next
</Button>
</div>
</div>
);
}
Pass filter params as query strings. Always validate with express-validator:
router.get('/transactions',
portalAuth('admin'),
standardLimit,
[
query('page').optional().isInt({ min: 1 }),
query('limit').optional().isInt({ min: 1, max: 100 }),
query('status').optional().isIn(['pending', 'completed', 'failed', 'reversed']),
query('type').optional().isIn(['deposit', 'withdrawal', 'transfer', 'purchase']),
query('search').optional().isLength({ max: 255 }).trim().escape(),
query('dateFrom').optional().isISO8601(),
query('dateTo').optional().isISO8601(),
query('minAmount').optional().isFloat({ min: 0 }),
query('maxAmount').optional().isFloat({ min: 0 }),
],
adminController.getTransactions.bind(adminController)
);
Build WHERE clauses dynamically with parameterized queries:
const conditions = [];
const params = [];
let paramIdx = 1;
if (req.query.status && req.query.status !== 'all') {
conditions.push(`status = $${paramIdx++}`);
params.push(req.query.status);
}
if (req.query.search) {
conditions.push(`(reference ILIKE $${paramIdx} OR description ILIKE $${paramIdx})`);
params.push(`%${req.query.search}%`);
paramIdx++;
}
if (req.query.dateFrom) {
conditions.push(`"createdAt" >= $${paramIdx++}`);
params.push(req.query.dateFrom);
}
const whereClause = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
Accept sortBy and sortOrder query params. Whitelist allowed columns
to prevent SQL injection:
const SORTABLE_COLUMNS = ['createdAt', 'amount', 'status', 'type', 'reference'];
const sortBy = SORTABLE_COLUMNS.includes(req.query.sortBy) ? `"${req.query.sortBy}"` : '"createdAt"';
const sortOrder = req.query.sortOrder === 'ASC' ? 'ASC' : 'DESC';
For operations on multiple rows (e.g. approve all pending settlements):
body('ids').isArray({ min: 1, max: 100 }).withMessage('ids must be 1–100 items'),
body('ids.*').isInt({ min: 1 }).withMessage('each id must be a positive integer'),
Standard row actions for data tables:
| Action | Icon | Permission | Confirmation |
|---|---|---|---|
| View | Eye | Any role | None |
| Edit | Pencil | manager+ | None |
| Approve | CheckCircle | admin | Maker-checker dialog |
| Reject | XCircle | admin | Reason required |
| Export | Download | manager+ | None |
CSV export for operational data:
router.get('/transactions/export',
portalAuth('admin'),
requireRole(['admin', 'manager']),
auditLog('EXPORT_TRANSACTIONS'),
strictLimit,
adminController.exportTransactions.bind(adminController)
);
Set response headers for download:
res.setHeader('Content-Type', 'text/csv');
res.setHeader('Content-Disposition', `attachment; filename="transactions_${Date.now()}.csv"`);
PDF export for audit reports — use pdfkit or puppeteer server-side.
Never generate PDFs on the client with sensitive data.
All monetary values displayed in the portal MUST use South African