Skip to content

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

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.id proxies to 127.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:

Text Only
┌──────────────────────────────────────────────┐
│ 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)

  1. User opens admin.huph.val.id
  2. nginx forwards to Next.js (port 47293)
  3. Middleware checks NextAuth session via JWE cookie
  4. Unauthenticated → redirect to /login, then to Zitadel (auth.huph.val.id) via NextAuth's Zitadel provider
  5. Zitadel handles password / MFA / passkey (HUPH does NOT store passwords; the legacy password_hash column is dormant)
  6. Zitadel returns ID token + access token via OIDC redirect
  7. NextAuth's jwt callback runs Model 3 Hybrid:
  8. Look up local admin_users shadow row by zitadel_sub (or by email + link on first SSO login)
  9. Copy app-specific fields (role, cluster_id, faculty_id, campus_id) from the shadow into the JWE
  10. DO NOT store id_token / refresh_token in the JWE — would inflate Set-Cookie past nginx's 4–8 KB proxy_buffer_size
  11. JWE session cookie issued, user redirected to requested page
  12. The aud claim on the access token MUST contain ZITADEL_PROJECT_ID — provided by the urn:zitadel:iam:org:project:id:<PROJECT_ID>:aud scope. Without it, apps/api JWKS-verifies a token whose aud is the client_id only and rejects with 401 invalid_token. This was a real bug landed in e530ef4 (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:

Text Only
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:

TSX
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_counselor with cluster_id = X → sees only cluster X leads and conversations
  • marketing_staff and marketing_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, ConfirmDialog
  • layout/AppHeader, AppSidebar, authenticated-layout
  • leads/LeadCard, LeadFilterChips, ClusterBadge
  • conversations/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

  1. Enterprise shell must use h-screen, not min-h-screen. See the pattern description above — reverting re-breaks sidebar and header scroll.
  2. apps/webchat-enterprise used to conflict on port 3103. Deleted in Apr 8 cleanup. If you see port conflicts, check for a stray process, not the old webchat.
  3. serverFetch is mandatory for API calls. Direct fetch() skips auth injection and breaks under API_AUTH_MODE=enforce.
  4. Dev port 3103 vs prod 47293. Don't hardcode — use env vars.
  5. **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.
  6. Dark mode is defined but not activated. CSS vars exist in tailwind.config.ts but the theme toggle is not wired up. Low priority follow-up.
  7. Realtime depends on NEXTAUTH_SECRET in API container env. Missing secret → Socket.io clients can't decode JWE → Offline forever. Add to docker-compose.yml + root .env.
  8. CorrectionPanel + FeedbackActions labels 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