08-portals-and-i18nwave: W5filled28 citations

Multi-Portal Architecture — Role-Based Frontend Routing

Audiences: developer, internal, investor

Multi-Portal Architecture — Role-Based Frontend Routing

Eleven role-scoped portals share one Next.js 16 shell, one i18n routing layer, and one role-aware layout factory — each role only sees the routes, navigation, and data its backend RBAC permits.

Business Purpose

The Dudoxx HMS frontend serves eleven distinct user roles under one Next.js application: patients, doctors, nurses, receptionists, accountants, clinic admins, org admins, partners, staff, admins, and super-admins. Each role needs a different home, different navigation, different KPI surface, and different write permissions — but they must all share a single deployed bundle, one auth session, one i18n catalog, and one design system.

Building eleven separate SPAs would balloon DevOps and split the design vocabulary; building one shell with role-gated content keeps every clinical buyer (clinic admin) and every investor demo on the same code path. The cost of an additional role is one config object, not a new application.

Audiences

  • Investor: shows how a single product surface serves every constituent in a clinic (provider, support staff, finance, leadership) — a structural moat against single-portal point solutions.
  • Clinical buyer (doctor/nurse/receptionist): explains why the doctor's appointment list differs from the receptionist's appointment list even though both are "appointments". Role scoping happens at three layers (proxy gate, layout-level RBAC, backend authorization).
  • Developer/partner: covers the one factory function (createRoleLayout) and one registry (ROLE_CONFIGS) that govern every portal — and the deliberate "bare parent layout" pattern that lets doctor/(with-nav)/ coexist with doctor/help/.
  • Internal (ops/support): locates which file owns navigation, brand label, role badge, and backend-status gating for any given role.

Architecture

Three gates per request

Browser → proxy.ts                          (locale + session-cookie gate)
       → [locale]/portal/<role>/layout      (RBAC requireRoleAccess)
       → backend ddx-api                    (final authoritative RBAC)

The Next.js proxy at ddx-web/src/proxy.ts:248-319 performs a coarse gate: it strips the locale, classifies the route as protected/auth/public, and redirects to /<locale>/auth/login when no session cookie is present. It deliberately does not know roles — that decision lives in the layout factory and the backend.

One layout factory, eleven configs

Each role layout is one to two dozen lines:

typescript
// ddx-web/src/app/[locale]/portal/nurse/layout.tsx
export default createRoleLayout(NURSE_CONFIG, NurseNav);

The factory at ddx-web/src/components/portal/createRoleLayout.tsx:49-131 does the heavy lifting:

  1. await params (Next.js 16 async params) → resolve locale.
  2. requireRoleAccess(config.authRequirement, locale) → server-side RBAC gate; redirects to login or /unauthorized and returns a typed SessionUser.
  3. Fetch the organization (skip for super-admin via fetchOrg: false).
  4. Resolve brand name, organization name, and tagline from the configured i18n namespace.
  5. Compose <DDXHeader> + <PortalShell> + <AppStatusBar> + the role's NavigationComponent + optional <BackendStatusProvider> wrapper.
  6. Mount <SSEBootstrap userId={user.id}> for SSE channel subscriptions.

Role registry

The eleven configs live in ddx-web/src/lib/portal/role-configs.ts:29-198. Each entry captures: roleKey (URL segment), authRequirement (RBAC constant), roleBadge (icon + variant + label key), brandNameKey, optional taglineFormatter, optional extraElements, and optional needsBackendStatus with scope: 'shell' | 'children'. The full registry is exported as ROLE_CONFIGS (ddx-web/src/lib/portal/role-configs.ts:187-199).

The doctor "bare parent" pattern

Doctor is the only portal that hosts chrome-free sibling routes (help/, cms/help/) alongside the full sidebar shell. Next.js nested layouts compose top-down — a child layout cannot strip chrome added by a parent. The codebase solves this by making ddx-web/src/app/[locale]/portal/doctor/layout.tsx:1-26 a transparent passthrough (return <>{children}</>) and pushing the full shell into a route group: ddx-web/src/app/[locale]/portal/doctor/(with-nav)/layout.tsx:1-29. New doctor pages must live under (with-nav)/ to inherit DDXHeader + DoctorNav + AppStatusBar.

Tech Stack & Choices

LayerChoiceWhy
App routerNext.js ^16.1.6 App RouterServer Components by default → tokens never reach the browser; nested layouts compose RBAC + chrome.
AuthAuth.js v5 (next-auth 5.0.0-beta.30)JWT strategy; session-cookie validation in proxy.ts; full RBAC in backend.
RBAC gaterequireRoleAccess(authRequirement, locale)One Server Component check per layout; throws redirect when role mismatches.
Shell compositioncreateRoleLayout factory + RolePortalConfig registryEleven layouts in <30 lines each; one place to evolve chrome.
i18nnext-intl v4 with localePrefix: 'always'Locale is part of the URL (/en/portal/doctor); no client locale switching ambiguity.
NavigationPortalNavigation roleKey="<role>" (config-driven)Sectioned or flat nav configs in lib/portal/nav-configs; replaces 11 per-role nav components.

Rejected: separate apps per role, role detection at request time without URL prefix, client-side role enforcement. All three either fragment the design system or shift authority to a place the browser can tamper with.

Data Flow

  1. User visits https://acm.dudoxx.com/en/portal/doctor/dashboard.
  2. proxy.ts matches the host trigram acm (ddx-web/src/proxy.ts:51-57, :72-113), resolves the slug via /organizations/by-trigram/acm, sets X-Clinic-ID: <slug> header. The trigram regex is strict (ddx-web/src/proxy.ts:35) — apex/www/non-3-letter hosts never inject a header.
  3. Proxy checks session cookie via hasSessionCookie() (ddx-web/src/proxy.ts:221-229); if missing on a protected route, redirects to /<locale>/auth/login?callbackUrl=....
  4. Next.js routes the request to [locale]/portal/doctor/(with-nav)/dashboard/page.tsx. The (with-nav) layout from createRoleLayout(DOCTOR_CONFIG, DoctorNav) runs:
    • requireRoleAccess('DOCTOR_ONLY', locale) → returns the doctor user or redirects.
    • Fetches the doctor's organization.
    • Composes <DDXHeader>, <PortalShell>, <DoctorNav> (PortalNavigation roleKey="doctor"), and <AppStatusBar> inside a <BackendStatusProvider> (shell scope).
  5. The page Server Component runs its own server-side data fetch via backendGet(...) — every API call carries the user's JWT and X-Clinic-ID; the backend re-validates RBAC and tenant scope authoritatively.

Implicated Code

  • ddx-web/src/app/[locale]/portal/layout.tsx:16-20 — intentionally thin parent; provider stack lives in each role layout.
  • ddx-web/src/components/portal/createRoleLayout.tsx:49-131 — the single factory: RBAC, org fetch, brand resolution, shell composition.
  • ddx-web/src/lib/portal/role-configs.ts:29-199 — eleven RolePortalConfig entries and the ROLE_CONFIGS registry.
  • ddx-web/src/app/[locale]/portal/doctor/layout.tsx:1-26 — the deliberate bare passthrough parent.
  • ddx-web/src/app/[locale]/portal/doctor/(with-nav)/layout.tsx:1-29 — full doctor shell inside route group; pages must live here to inherit chrome.
  • ddx-web/src/app/[locale]/portal/patient/layout.tsx:14, ddx-web/src/app/[locale]/portal/nurse/layout.tsx:17, ddx-web/src/app/[locale]/portal/receptionist/layout.tsx:19 — one-line createRoleLayout invocations.
  • ddx-web/src/proxy.ts:115-126PROTECTED_ROUTES (/portal, /dashboard, etc.) and the locale-stripping helper.
  • ddx-web/src/proxy.ts:248-319 — proxy entry: trigram resolution → session gate → intl middleware.
  • ddx-web/src/lib/auth/protection.ts:38-46requireAuth(locale) server helper.
  • ddx-web/src/lib/api/rbac-config.ts:29-46 — backend-facing RBAC registry (mirrors the eleven roles).
  • ddx-web/src/components/portal/navigation/PortalNavigation.tsx:43-67 — shared navigation component; takes a roleKey and resolves the nav config.

Operational Notes

  • The (with-nav)/ pitfall — adding a new doctor page directly under portal/doctor/ (not under (with-nav)/) yields a chrome-free page with no sidebar. This is a regular source of regressions; the pattern is documented at ddx-web/src/app/[locale]/portal/doctor/layout.tsx:1-16.
  • Never useTranslations in a layoutcreateRoleLayout is a Server Component and uses getTranslations from next-intl/server (createRoleLayout.tsx:9). Mixing hooks in here crashes at render.
  • X-Clinic-ID is advisoryproxy.ts:277-285 only injects when present trigram resolves; the backend treats the header as a hint and re-derives tenant scope from the JWT. Never read request.headers.get('host') directly in feature code without going through extractTrigramFromHost() (host header is attacker-controlled — see warning at ddx-web/src/proxy.ts:31-34).
  • RBAC fall-throughdefaultDeny: true in lib/api/rbac-config.ts:44 means a new route with no permission entry returns 403 for every role except SUPER_ADMIN. Update the matching *_PERMISSIONS module under lib/api/rbac-permissions/ when shipping a new endpoint.
  • No middleware.ts — the file is proxy.ts per project convention (ddx-web/CLAUDE.md:48-49). Creating middleware.ts will silently double-mount.