Skip to main content

ADR 0021 — Split the Tenant Dashboard and the Platform-Operator Console into Two Apps

Status: Accepted Context date: 2026-07-26

Context

Since ADR 0020 the single Next.js app at admin/dashboard has served two very different audiences from one codebase and one deployment:

  • Tenant admins — customers managing their own organization (members, rooms, recordings, billing, API keys) on the public app. host.
  • Platform operators — EthioConnect staff running the platform (tenant directory, suspend/activate, infrastructure health, audit log) on the internal admin. host.

The single app carried the split entirely in conditionals: useSession() exposed isPlatformOperator / isTenantAdmin, and the sidebar, middleware, API proxy, and roughly half the pages branched on role — relabeling headings ("Users" vs "Members"), hiding columns and buttons, host-gating /ops/* in middleware, and forwarding both credential types (admin_session session cookie and tenant_token JWT) through one catch-all proxy. Keeping the operator surface unreachable on the public origin even required a snippet-based ingress (ethioconnect-app-ops-block) that ingress-nginx ≥ v1.9 silently ignores unless allow-snippet-annotations is enabled.

The practical cost: every change to the tenant dashboard — the surface under active development — had to be reasoned about twice, once per role, and could silently break the operator view (and vice versa). The two surfaces also have different security postures (public self-serve vs internal-only) but shared one build artifact.

Decision

Split into two separate Next.js apps, both talking to the unchanged admin gateway:

Tenant dashboardOperator console
Directoryadmin/dashboard (evolved in place)admin/ops-dashboard (new)
Hostapp.ethioconnect.example.comadmin.ethioconnect.example.com
K8s service / imageadmin-dashboard (unchanged)ops-dashboard
Dev port33003400
Credentialtenant_token (JWT cookie)admin_session (session cookie)
Routes/ overview, /users (Members), /rooms, /recordings, /billing, /webhooks, /api-keys, /settings, /login, /signup, /onboarding//tenants, /tenants (+detail), /users (+suspend), /recordings (+delete), /infrastructure, /audit, /login

Key choices:

  1. admin/dashboard stays and becomes the tenant app. Most pages and all deploy wiring (compose service, k8s manifests, helm chart, ingress backend) belong to the tenant surface; renaming the directory would touch ~15 deploy files for zero functional gain. Operator-only pages were git mved out so history follows them.
  2. Both apps keep basePath: '/dashboard'. The admin. ingress already proxies /dashboard/*, probes hit /dashboard, and the gateway hardcodes cookie Path=/dashboard/api/auth — so the split needs zero Go changes. Operator routes flatten from /dashboard/ops/* to the ops app's own /dashboard/* (login moves to /dashboard/login on the admin host; the ingress app-root annotation was updated accordingly).
  3. Per-app credential forwarding. Each app's /api/[...path] proxy forwards only its own credential to the gateway and returns 404 for the other surface's login route (tenant app 404s /api/auth/login; ops app 404s /api/auth/tenant-login and /api/auth/signup). This holds in local dev and on any misconfigured ingress, so the fragile snippet-based ethioconnect-app-ops-block Ingress was deleted — the operator surface simply does not exist in the tenant app anymore. Host-only cookies (ADR 0020) remain the other isolation layer.
  4. Small shared files are duplicated, not extracted. api.ts, useSession.ts, usePaginatedList.ts, ErrorNotice, Pagination (~300 lines) are copied into the ops app, following the existing precedent of portal/ owning its own API client. Divergence per surface (different cookie names, different 401 targets, no role union types) is exactly the entanglement the split removes; npm workspaces for this would be tooling overhead the repo doesn't otherwise need.
  5. Minimal operator surface. The ops app received only the pages operators need for actions that exist nowhere else: tenant CRUD + suspend/activate, user suspend/activate, recording delete, infrastructure health, audit log. Read-only views (rooms, billing, webhooks) were deliberately left out and can be added when a concrete operator need appears.

Middleware simplified on both sides to a single-cookie presence check — the tenant app lost the host-gating of /ops/* (nothing to gate), and the gateway remains the real enforcer (RequirePlatformOperator, EnforceTenantScope).

Consequences

Positive

  • Tenant-dashboard changes can no longer break the operator console, and vice versa; each app is a smaller, single-audience codebase with zero role conditionals.
  • The operator login is structurally unreachable from the public origin — no reliance on ingress snippet annotations being enabled.
  • Each surface can evolve its security posture independently (the internal console can later sit behind a VPN/IAP without touching the tenant app).
  • Operator console deploys with 1 replica (low-traffic internal tool) instead of inflating the public tier's footprint.

Negative / accepted

  • ~300 lines of shared UI code are now duplicated and may drift; accepted per the portal/ precedent, revisit with npm workspaces only if a third web app appears.
  • Two builds, two images, two deployments (compose service ops-dashboard on :3001, k8s deploy/k8s/base/ops-dashboard/, helm chart ops-dashboard, extra release-workflow step).
  • Operator bookmarks to /dashboard/ops/login break (the admin host's app-root now redirects / to /dashboard/login).

Wiring changed: admin/docker-compose.override.yml (new service), deploy/k8s/base/ops-dashboard/ + kustomization.yaml, deploy/k8s/base/ingress.yaml (admin host → ops-dashboard:3000, ops-block Ingress removed), deploy/k8s/base/network-policies/allow-ingress-to-web.yaml (ops-dashboard added to ingress/egress rules), deploy/helm/charts/ops-dashboard/ + umbrella chart, .github/workflows/release.yml (ops image step; dashboard build contexts corrected to the app directories).