Admin Architecture
Purpose
Deep-dive into apps/admin — the Next.js 14 enterprise dashboard
used by counselors, marketing staff, and admins. Start here if your
task touches pages, layout, components, client-side realtime, or
the NextAuth session flow.
Prerequisites
- Architecture overview
- Setup complete, admin running
- Familiarity with Next.js 14 App Router
Tech stack
- Next.js 14 — App Router, React Server Components
- React 18
- Tailwind CSS — with design tokens in
tailwind.config.ts - shadcn/ui (new-york style) — primary component library
- Recharts — analytics charts
- NextAuth (Auth.js v5, JWE session strategy) — Zitadel SSO provider only (Credentials provider was removed Week 3 of the Zitadel migration, Apr 2026)
- Socket.io client — realtime events
- SWR (where used) or polling — data fetching
Process and hosting
- Dev port: 3103 (
npm run dev:admin) - Prod: systemd service
huph-admin.service, port 47293 - Nginx:
admin.huph.val.idproxies to127.0.0.1:47293 - Restart prod:
sudo systemctl restart huph-admin
Enterprise shell pattern
The root authenticated layout uses a fixed-shell pattern:
sidebar and header stay put while content scrolls. This is
implemented in src/components/layout/authenticated-layout.tsx:
┌──────────────────────────────────────────────┐
│ AppHeader (fixed, never scrolls) │
├────────┬─────────────────────────────────────┤
│ Sidebar│ Main content area │
│ (fixed)│ (overflow-y-auto, scrolls) │
│ │ │
│ │ │
└────────┴─────────────────────────────────────┘
Critical rule (from feedback_enterprise_shell_pattern): the
root must use h-screen overflow-hidden, NOT min-h-screen. The
column wrapper is overflow-hidden and the main pane is
flex-1 overflow-y-auto overflow-x-hidden.
Using min-h-screen re-breaks the scroll — the body grows as
content grows, pushing sidebar and header off-screen. This was a
known bug, root-caused and fixed 2026-04-08. DO NOT regress.
Research-backed: Gmail, Slack, Linear, Notion, Intercom, and Salesforce Lightning all use this flex-based fixed shell.
Key pages
| Path | Purpose |
|---|---|
/dashboard |
System dashboard — workload, recent config changes, bot health card |
/executive-summary |
Cross-cluster KPIs for leadership (super_admin / system_admin / marketing_admin / faculty_admin) |
/analytics-v2 |
Leads + Bot Health analytics (13 charts) |
/conversations |
3-pane inbox (list + detail + chat thread) |
/conversations/[id] |
Single conversation detail |
/conversations/inbox |
Alternative inbox view |
/conversations/pipeline |
Conversations grouped by lead stage (kanban) |
/leads-v2 |
Lead list with cluster + stage filters, scoring badges, 30s polling |
/leads-v2/[id] |
Lead detail with Reassign, status, funnel, counselor assignment, Activity Timeline |
/follow-up |
Follow-up queue, rules, stats tabs |
/tracking-links |
Campaign tracking links (CRUD, QR codes, click stats) |
/knowledge/* |
KB documents, sources, eval (dashboard/golden/sandbox), gaps, FAQ |
/notifications |
In-app notifications (hot leads, lead assigned, escalation, alerts) |
/audit |
Audit log viewer with config-changes-only filter + before/after diff + CSV export |
/settings/chatbot |
Persona, guard, FAQ-threshold knob (flag-gated) |
/settings/intents + /[id] + /new |
Intent rule CRUD |
/settings/escalation-rules + /[id] + /new |
Escalation rule CRUD |
/settings/rules |
Audience rules + guidance rules |
/settings/users |
Admin user CRUD (Zitadel-backed) |
/settings/clusters |
College cluster CRUD |
/settings/system |
Cron run log + system info |
Auth flow (Zitadel SSO)
- User opens
admin.huph.val.id - nginx forwards to Next.js (port 47293)
- Middleware checks NextAuth session via JWE cookie
- Unauthenticated → redirect to
/login, then to Zitadel (auth.huph.val.id) via NextAuth's Zitadel provider - Zitadel handles password / MFA / passkey (HUPH does NOT store
passwords; the legacy
password_hashcolumn is dormant) - Zitadel returns ID token + access token via OIDC redirect
- NextAuth's
jwtcallback runs Model 3 Hybrid: - Look up local
admin_usersshadow row byzitadel_sub(or by email + link on first SSO login) - Copy app-specific fields (role, cluster_id, faculty_id, campus_id) from the shadow into the JWE
- DO NOT store id_token / refresh_token in the JWE — would inflate Set-Cookie past nginx's 4–8 KB proxy_buffer_size
- JWE session cookie issued, user redirected to requested page
- The
audclaim on the access token MUST containZITADEL_PROJECT_ID— provided by theurn:zitadel:iam:org:project:id:<PROJECT_ID>:audscope. Without it,apps/apiJWKS-verifies a token whoseaudis theclient_idonly and rejects with 401 invalid_token. This was a real bug landed ine530ef4(2026-04-25).
Session cookie expires after 24 hours; access token refresh is
handled by Zitadel's offline_access scope.
API proxy pattern
All admin API calls go through Next.js Route Handlers at
src/app/api/*. These proxy to apps/api via the shared
serverFetch helper in src/lib/serverFetch.ts:
Browser → Next.js /api/leads-v2 (Route Handler)
→ serverFetch (injects X-Internal-Secret + X-Forwarded-Session JWE)
→ apps/api /api/v1/leads/v2 (gated by middleware)
Critical: serverFetch is the ONLY way admin should talk to
the API. Direct fetch() bypasses auth injection and will 401
under API_AUTH_MODE=enforce. 21 proxy routes were migrated to
serverFetch in commit e3ec319.
Realtime via Socket.io
src/lib/socket.tsx provides a SocketProvider React context that
wraps the authenticated layout. Pages subscribe via the
useSocketEvent hook in src/hooks/useSocketEvent.ts:
useSocketEvent('message.new', (payload) => {
// payload includes conversation_id, cluster_id, etc
if (payload.conversation_id === currentConvId) {
addMessageToThread(payload);
}
});
Socket connection authenticated with NextAuth JWE (via
X-Forwarded-Session header on the upgrade request).
Room scoping: counselor in cluster A never receives events for
cluster B leads. Enforced server-side in
apps/api/src/realtime/rooms.ts resolveRoomsForUser.
Health indicator: ConnectionStatus component shows
Connected/Connecting/Offline in the header. Offline typically
means NEXTAUTH_SECRET missing from API container env.
RBAC (Phase 1.5)
Cluster-based access control implemented at the admin proxy + Prisma route layer. Rules:
marketing_counselorwithcluster_id = X→ sees only cluster X leads and conversationsmarketing_staffandmarketing_admin→ see all (global)admin→ see all, can reassign clusters
Filter applied in:
- /admin/leads-v2 API proxy (list endpoint)
- /admin/analytics-v2 API proxy
- /admin/counselor-dashboard API proxy
- Prisma routes (where used)
Spec: docs/superpowers/specs/2026-04-08-rbac-phase15-design.md.
Shared components
Component library in src/components/:
ui/— shadcn/ui primitives (Button, Card, Dialog, Tabs, etc.)shared/—StatCard,ChartCard,EmptyState,ConnectionStatus,Skeleton,ConfirmDialoglayout/—AppHeader,AppSidebar,authenticated-layoutleads/—LeadCard,LeadFilterChips,ClusterBadgeconversations/—ThreadList,ChatThread,ReplyBox,FeedbackActions,CorrectionPanel
No test infrastructure
The admin app has no Jest/Vitest/Playwright setup. Verification
happens via npx tsc --noEmit, npm run build, and manual browser
smoke. See the feedback_admin_no_test_infra memory and the
running-tests page for the full
rationale.
Gotchas
- Enterprise shell must use
h-screen, notmin-h-screen. See the pattern description above — reverting re-breaks sidebar and header scroll. apps/webchat-enterpriseused to conflict on port 3103. Deleted in Apr 8 cleanup. If you see port conflicts, check for a stray process, not the old webchat.serverFetchis mandatory for API calls. Directfetch()skips auth injection and breaks underAPI_AUTH_MODE=enforce.- Dev port 3103 vs prod 47293. Don't hardcode — use env vars.
- **
module-level throws breaknext build.** The originalserverFetch.tshad a top-levelthrow` if env missing, which fired during Next.js static analysis. Move env checks inside function bodies. - Dark mode is defined but not activated. CSS vars exist in
tailwind.config.tsbut the theme toggle is not wired up. Low priority follow-up. - Realtime depends on
NEXTAUTH_SECRETin API container env. Missing secret → Socket.io clients can't decode JWE → Offline forever. Add todocker-compose.yml+ root.env. CorrectionPanel+FeedbackActionslabels are in Indonesian, the rest of admin is mostly English. Minor inconsistency, needs product decision.
See also
- Architecture overview
- API — the backend admin talks to
- Running tests — admin no-test rationale
- Admin redesign spec:
docs/superpowers/specs/2026-03-21-enterprise-admin-redesign-design.md