Admin Panel v2 Live
Operations command center for KA26. Rebuilt 2026-04-27 from a top-nav stats dashboard into a full operational toolkit: side-nav layout, ⌘K command palette, universal entity drawer, segmented broadcasts, trust & safety queue, audit log, real impersonation, suspend cascade with force-logout, affected-user notifications, and live orders monitor.
Today the admin is gated by seller.role === "admin". RBAC for multi-admin teams is stubbed (requireAdminRole(["super_admin", "finance"]) exists but is a no-op pass-through) — when team admins land we just enforce the role check inside that helper without rewriting any endpoints.
1. Architectural moves (the v2 rebuild)
| Move | Why | Where |
|---|---|---|
| Side nav (collapsible) replaces top nav | Top nav had 10+ items and no room for badges or future sections | src/components/admin/AdminShell.tsx |
| ⌘K command palette | Keyboard-first jump to any entity (sellers/orders/products/...) | src/components/admin/CommandPalette.tsx + /api/admin/search |
| Universal entity drawer | Click any entity name → slide-in case file with quick actions, no navigation tax. Stacks (open multiple). | src/components/admin/EntityDrawer.tsx + /api/admin/entities/[type]/[id] |
| Single chokepoint for destructive actions | Every suspend / hide / delete / dismiss / impersonate goes through one route → mandatory reason → audit log → notify affected user | /api/admin/entities/[type]/[id]/actions |
| Operations homepage replaces stats dashboard | "What needs me right now?" answer instead of charts | src/app/admin/page.tsx |
| Audit log surfaces every destructive action | Multi-admin accountability + customer service appeals + regulatory CYA | AdminAction model + /admin/audit-log page |
| Cmd-K, Cmd-K, Cmd-K | Operations is keyboard work | mounted globally |
2. Pages
| Path | Purpose |
|---|---|
/admin | Operations homepage — KPIs + 4 swim-lanes (verifications, moderation, stuck orders, recent admin activity). Auto-refreshes every 15s. |
/admin/sellers | Legacy sellers list (preserved from old admin) |
/admin/audit-log | Filterable AdminAction viewer with before/after JSON diff |
/admin/moderation | T&S queue (j/k keyboard nav) |
/admin/communications | Compose broadcast (5-step wizard + sticky review pane) |
/admin/communications/templates | Template CRUD |
/admin/communications/audit | Send history (last 50 broadcasts) |
/admin/orders-monitor | Live orders, auto-refresh 10s |
/admin/observability | System health dashboard (see System Health) |
/admin/verification | Pending seller verifications |
/admin/stores, /admin/consumers, /admin/doctors, /admin/delivery-partners, /admin/creators | Per-segment lists |
/admin/website | Website contact + job apps |
3. Database models added (v2)
| Model | Purpose | Critical fields |
|---|---|---|
AdminAction | Append-only audit log | adminId, adminEmail, category, action, entityType?, entityId?, reason?, beforeState?, afterState?, ipAddress?, userAgent?, rolledBack |
BroadcastTemplate | Reusable pre-composed messages | name @unique, type, title, body, audience (JSON), channels[], usageCount |
ScheduledBroadcast | One row per send | type, title, body, audience (JSON), channels[], status, scheduledFor?, sentAt?, recipientCount, deliveredCount, failedCount, clickCount, contentHash |
BroadcastDelivery | Per-recipient audit | broadcastId, recipientType, recipientId, status, pushSent, notifWritten, ackAt?, clickedAt? |
ModerationTicket | T&S queue items | entityType, entityId, source (auto_flag / user_report / admin_review), reason?, visionScores?, status, reporterType?, reporterId?, reviewedBy?, resolution?, adminActionId? |
SavedFilter | Per-admin per-page list views | adminId, page, name, filterJson, pinned |
ImpersonationSession | Short-lived "view as user" tokens | adminId, targetType, targetId, token @unique, mode, reason, expiresAt, endedAt? |
Plus tokenInvalidatedAt DateTime? on Seller, Consumer, Doctor for force-logout.
4. The single rule for destructive actions
All destructive admin actions flow through one route:
POST /api/admin/entities/{type}/{id}/actions
{ "actionKey": "suspend", "reason": "Sold counterfeit goods" }
- Reason is mandatory (≥3 chars). Enforced server-side, surfaced in the
ConfirmModalUI. - High-stakes verbs (
suspend,delete,reject) require typing the verb verbatim ("SUSPEND") to confirm — prevents accidental clicks. - Every action writes an
AdminActionrow with before/after JSON snapshot + IP + user-agent + admin id. - Affected user notified: when admin suspends/deletes/hides, the affected user gets a
SellerNotification/Notification/DoctorNotificationrow + push with the admin's reason.
Suspend cascade (the headline action)
"Suspend seller" performs in one transaction:
Seller.status = "disabled"Seller.tokenInvalidatedAt = new Date()(force logout from every device)Store.updateMany({ where: { sellerId }, isActive: false })Product.updateMany({ where: { sellerId }, isActive: false })- Write
AdminActionrow notifyAffected("seller", id, …)— push + write SellerNotification with admin's reason
Reactivate reverses 1+3+4 but does NOT re-bump tokenInvalidatedAt — the seller must re-login. Safer default than re-enabling tokens issued during the disabled period.
5. Communications studio
Three notification types, each with different runtime behavior:
| Type | Push? | DB notification row? | Use for |
|---|---|---|---|
| ephemeral | yes | NO | "Quote of the day", holiday wishes, one-time engagement |
| persistent | yes | yes | Fraud warnings, policy updates, important announcements |
| ack_required | (planned) | yes + must-ack flag | Legally-binding policy changes (UI disabled today) |
Linear 5-step compose flow
- Audience — segments (Sellers / Consumers / Doctors) + filters (city, active-within-N-days, seller-status, min-orders)
- Notification type — ephemeral / persistent / ack_required
- Channels — which apps to push to (defaults to audience, override to push to a subset)
- Compose — title (120) + body (500) + optional CTA label + deep link
- When — immediate or schedule
Sticky right-side review pane shows: live recipient count + per-app pill breakdown + push preview rendered in each app's brand color (consumer blue, seller emerald, doctor purple). The Send button label tells you exactly what's missing when disabled — "Pick an audience" / "Write a title + body" / "No matching users" — instead of staying greyed out with no explanation.
Throttle + quiet hours
- 5-minute throttle on identical content.
contentHash = sha256(title + body + sorted(channels)). A second send of the same payload within 5 minutes returns 409duplicate_recent_send. Prevents accidental double-sends. - Quiet hours (10pm-7am IST) block ephemeral pushes unless
overrideQuietHours: trueis passed. Persistent and ack_required ignore the guard (they go to the bell whether the device buzzes or not).
6. Token revocation + impersonation
Force logout
getAuthenticatedSeller() and resolveConsumerId() decode the JWT's iat claim and reject if iat * 1000 < user.tokenInvalidatedAt.getTime(). Legacy tokens without iat are grandfathered (the user just needs to re-login once to get a token with iat).
Bumping the timestamp is how every "suspend cascade" instantly kills the seller's existing sessions across web + mobile.
Real impersonation
Two-step flow because the admin's own session must stay alive:
- Admin clicks "Impersonate" in the entity drawer
- Server creates an
ImpersonationSessionwith a single-use opaque token (30-min TTL, mandatory reason, defaultmode: "read_only") - Drawer opens
/api/admin/impersonate/{token}in a new tab - That endpoint redeems the token, mints a JWT for the target user with extra claims:
impersonatedBy: <adminId>impersonationMode: "read_only"
- Sets the appropriate cookie (
ka26_consumer_token/seller_token/ka26_doctor_token) - Redirects to the target app's home
ImpersonationBanner is mounted in the root layout. It reads /api/auth/who-am-i, and if the active cookie has impersonatedBy, renders a sticky red ribbon at the top of every page in the impersonated session with an "End session" button (which hits /api/admin/impersonate/end to clear the cookies).
Today's caveat: the read_only mode is informational. The banner says it, the JWT carries it, but the per-app middleware that should reject mutations from impersonated cookies isn't wired yet. Listed as Phase E follow-up.
7. APIs
| Endpoint | Method | Purpose |
|---|---|---|
/api/admin/operations/summary | GET | Drives Operations homepage + nav badges |
/api/admin/search?q= | GET | Cmd-K entity search |
/api/admin/entities/{type}/{id} | GET | Case-file payload for the EntityDrawer |
/api/admin/entities/{type}/{id}/actions | POST | Single chokepoint for destructive actions |
/api/admin/audit-log | GET | List AdminAction rows (filterable) |
/api/admin/broadcasts | GET, POST | Send broadcast / list last 50 |
/api/admin/broadcasts/audience-count | GET | Live recipient count for compose UI |
/api/admin/broadcasts/templates | GET, POST, DELETE | Template CRUD |
/api/admin/moderation | GET, POST | List tickets (admin) / create ticket (consumer-side <ReportButton/>) |
/api/admin/orders-monitor | GET | Live orders (KPIs + 100 most recent) |
/api/admin/impersonate/{token} | GET | Redeem token → issues 30-min cookie for target user |
/api/admin/impersonate/end | POST | Clear all 3 user-app cookies |
/api/auth/who-am-i | GET | Drives the ImpersonationBanner |
8. Consumer "Report this" button
The moderation queue's user-report input. Drop-in component:
<ReportButton entityType="product" entityId={product.id} />
Opens a modal with category dropdown (Inappropriate, Misleading, Spam/scam, Counterfeit, Pricing fraud, Wrong category, Other) + free-text. POSTs to /api/admin/moderation → ticket lands in T&S queue with source=user_report, reporter linked back via reporterType/reporterId.
Wired today on the consumer product detail page. Other entities (reviews, reels, profile) can be added by dropping the same component in.
9. Tests
tests/admin-v2.test.ts— 63 contract tests covering schema, API exports, UI sections, helpers, security (token-revocation cascade, audit-log mandatory reason, impersonation TTL), notification-type contract.tests/admin-dashboard.test.ts— 139 tests covering legacy admin pages + APIs.
10. Helpers (src/lib/)
| File | Exports |
|---|---|
admin-actions.ts | requireAdmin() / requireAdminRole(roles) / logAdminAction() / requestForensics() |
broadcast-engine.ts | resolveAudience() / contentHash() / findRecentDuplicate() / isQuietHourIST() |
11. Unified entity action vocabulary (v2.3, 2026-04-27)
Pre-v2.3, each list page had its own action verbs:
| Page | Pre-v2.3 verbs |
|---|---|
| Sellers | Disable, Edit, Delete (icon) |
| Consumers | View, Suspend, Ban |
| Doctors | Deactivate, Delete |
| Delivery Partners | Suspend, Delete |
| Creators | View, Suspend |
5 dialects, no muscle-memory transfer. v2.3 unified all of them through one shared component <AdminEntityActions> and one verb set.
Standard verbs (every entity, same position)
| Verb | Meaning | Available |
|---|---|---|
| View | Open the EntityDrawer case file | All |
| Edit | Modify entity details | Sellers (limit), Stores, Products |
| Suspend | Soft-disable, reversible. Bumps tokenInvalidatedAt + hides content + notifies user with reason. Requires typing SUSPEND to confirm. | All |
| Reactivate | Reverse Suspend. | All |
| Ban | Permanent intent. Same DB change as Suspend but flagged in audit + user notification. Requires typing BAN. | Sellers, Consumers, Doctors |
| Unban | Reverse Ban. | Sellers, Consumers |
| Delete | Soft-delete (status flip + audit row). Requires typing DELETE. Hard delete is gated separately. | All |
| Impersonate | 30-min read-only session as the user. | Sellers, Consumers, Doctors |
Component contract
src/components/admin/AdminEntityActions.tsx accepts:
entityType:"seller" | "consumer" | "doctor" | "delivery_partner"entityId: numberstatus?: lowercase status — drives which verbs render (e.g. Suspend hides when already suspended)label?: human-readable name shown in confirm dialogonChanged?: called after a successful destructive action (parent list re-fetches)
Single chokepoint API
POST /api/admin/entities/[type]/[id]/actions handles every entity type. The pre-v2.3 per-entity admin routes (/api/admin/consumers/[id] etc) are preserved for backwards compat but now also write to AdminAction + call notifyAffected.
Cascade preview
Each high-stakes verb shows a preview in the ConfirmModal so the admin sees what will happen before confirming:
Suspend will:
- Hide all stores + products
- Force-logout from every device
- Notify seller with your reason
Plus a required reason textarea (≥3 chars) AND a typing gate ("Type SUSPEND to confirm").
Tests
tests/admin-entity-actions.test.ts — 45 cases pinning the component, the wiring on all 5 list pages, the unified API verb coverage per entity, and the notification + audit + token-revocation contract.
13. Known gotchas / current limits
- Read-only impersonation is informational — banner says it, JWT carries the claim, but mutations from impersonated cookies still execute. Phase E follow-up.
- Cloud Vision auto-flagging not wired —
ModerationTicket.visionScoresis in the schema and the queue UI handlessource=auto_flag, but no upload-side hook enqueues tickets yet. The queue's only real input today is consumer reports + admin-initiated. - RBAC stub —
requireAdminRole()exists but accepts any admin. Today every admin is super_admin. - Saved filters — model exists, no per-page UI yet.
- Bulk multi-select on lists — not yet.
- Rollback —
AdminAction.rolledBackcolumn exists, no UI to actually rollback yet (24h window is the design intent).
14. Related
- Authentication — seller auth (admin is a seller with role=admin); JWT payload + cookie names
- System Health — observability dashboard + 12 health checks
- Bidding — destructive admin actions can affect PriceOffer rows
- Notifications — broadcast types + notification routing
- CHANGELOG: 2026-04-27 Admin v2 + v2.1 + v2.2