08-portals-and-i18nwave: W5filled12 citations

Admin Portal — Clinic Administration

Audiences: clinical-buyer, developer, internal

Admin Portal — Clinic Administration

Three admin tiers — generic /portal/admin (the auth-permissive admin dashboard), /portal/clinic-admin (the strict clinic operator), and /portal/org-admin (multi-clinic owner) — share the role-portal factory but expose three distinct route surfaces and RBAC scopes.

Business Purpose

A clinic has more than one "administrator". The owner-operator who runs financial settings, the office manager who hires/fires, the regional/franchise owner who oversees three locations — none of these should see the others' surface by default, and none should be the same role as the IT super-admin. The HMS expresses this as three sibling portals, each with its own RBAC tier:

  • clinic-admin/CLINIC_ADMIN_ONLY: the office-manager's seat, the richest of the three (HR, departments, appointment templates, approvals, reports, waiting room, locations, settings).
  • admin/ADMIN_PLUS = ['ADMIN', 'SUPER_ADMIN', 'STAFF', 'ACCOUNTANT']: a thinner admin surface that doubles as the catch-all back-office for roles that need some admin tooling without the full clinic-admin badge.
  • org-admin/ORG_ADMIN_ONLY: cross-clinic owner; minimal surface today (dashboard, cms, messenger) — designed for the multi-clinic group buyer.

This three-way split is what separates HMS from single-tenant clinic software. Selling to a five-clinic dermatology group means the group director gets org-admin, each clinic manager gets clinic-admin, the shared bookkeeper gets accountant, and the IT vendor gets super-admin — all on the same product, all with distinct surfaces.

Audiences

  • Investor: three RBAC-distinct admin tiers prove the HMS isn't a single-tenant tool that's been multi-tenanted as an afterthought — it understands clinic groups out of the box.
  • Clinical buyer (clinic owner): pick the seat for each staffer; a new hire who manages the schedule needs clinic-admin, an external CPA gets accountant, the franchise director gets org-admin. None of them ever see another clinic's data because the gateway tags every call with the current X-Clinic-ID.
  • Developer/partner: the three configs are good examples of three patterns — strict-single-role (CLINIC_ADMIN_ONLY), multi-role permissive (ADMIN_PLUS), and a new portal scaffold (ORG_ADMIN_ONLY, still being filled).
  • Internal (ops/support): clarifies which dashboard a customer is describing — "the admin page" is ambiguous; verify the URL segment.

Architecture

Three configs

ddx-web/src/lib/portal/role-configs.ts:

  • ADMIN_CONFIG (:93-103): authRequirement: 'ADMIN_PLUS', icon ShieldCheckIcon, badge variant warning, brand key portals.admin.
  • CLINIC_ADMIN_CONFIG (:117-128): authRequirement: 'CLINIC_ADMIN_ONLY', icon BuildingsIcon, variant warning, brand key clinicAdmin.title (namespace admin).
  • ORG_ADMIN_CONFIG (:53-63): authRequirement: 'ORG_ADMIN_ONLY', icon BuildingOfficeIcon, variant warning, brand key portals.orgAdmin.

All three use the default factory (no needsBackendStatus, no extraElements, no taglineFormatter, no fetchOrg: false).

Layouts

portal/admin/layout.tsx       → createRoleLayout(ADMIN_CONFIG, AdminNav)
portal/clinic-admin/layout.tsx → createRoleLayout(CLINIC_ADMIN_CONFIG, ClinicAdminNav)
portal/org-admin/layout.tsx   → createRoleLayout(ORG_ADMIN_CONFIG, OrgAdminNav)

All three layouts are <20 lines — the createRoleLayout factory at ddx-web/src/components/portal/createRoleLayout.tsx does the RBAC gate, org fetch, brand resolution, and shell composition.

Route surfaces (the substantive difference)

portal/clinic-admin/        ← 32+ routes, the operator workbench
  accounting/, activity-log/, ai/, analytics/, appointment-templates/,
  appointments/, approvals/, billing/, cms/, communications/,
  dashboard/, departments/, diagnosis-engine/, document-generation/,
  flowagent/, help/, holidays/, hr/, inventory/, invitations/,
  locations/, messages/, messenger/, messenger-admin/, notes/, profile/,
  recurring-appointments/, reports/, settings/, staff/, users/,
  waiting-room/, waitlist/

portal/admin/               ← ~20 routes, the catch-all back-office
  accounting/, billing/, cms/, dashboard/, departments/,
  diagnosis-engine/, flowagent/, forms/, help/, holidays/, hr/,
  intake-rules/, inventory/, locations/, messages/, messenger/,
  notes/, profile/, settings/

portal/org-admin/           ← 5 routes, the multi-clinic owner shell
  cms/, dashboard/, messages/, messenger/, messenger-admin/

The admin/ route set is intentionally a subset of clinic-admin/: the auth scope is wider (ADMIN_PLUS includes STAFF, ACCOUNTANT, SUPER_ADMIN), so the surface is narrower — fewer routes that any of those roles can legitimately use. clinic-admin/ is for one role (the office manager) and accordingly the deepest.

RBAC enforcement

  • Layout — RBAC asserted by requireRoleAccess(config.authRequirement, locale) inside the factory.
  • Backend — every route's RBAC entry lives in ddx-web/src/lib/api/rbac-config.ts:29-46; missing entry → 403 for every role except SUPER_ADMIN.
  • Per-page — sensitive pages (e.g. clinic-admin/dashboard/page.tsx) may also call requireAuth(locale) defensively.

admin/dashboard vs clinic-admin/dashboard

ddx-web/src/app/[locale]/portal/admin/dashboard/page.tsx:17-27 is a minimal requireAuth → AdminDashboardClient render — the catch-all back-office home.

The clinic-admin dashboard owns the staff, departments, approvals, locations, and reports surface — much richer.

Tech Stack & Choices

LayerChoiceWhy
FactorycreateRoleLayout per portalThree configs, three sidebars, one shell composition pipeline.
Auth scopesADMIN_PLUS vs CLINIC_ADMIN_ONLY vs ORG_ADMIN_ONLYThree different audiences require three different role unions; never collapse.
Brand labelportals.admin, clinicAdmin.title (ns admin), portals.orgAdminBrand namespace lets the clinic-admin "Clinic Administration" label live in the admin translation file.
IconShieldCheck / Buildings / BuildingOfficeVisual distinction in the badge — investors see three icons during the demo.
NavPortalNavigation roleKey="<role>" (sectioned configs)Each role has its own nav-configs/<role>.ts.

Rejected: collapsing the three into one admin/ route with role-detect (would scatter every if (role === 'CLINIC_ADMIN') ... across every page); making clinic-admin inherit ADMIN_PLUS (would let STAFF and ACCOUNTANT reach office-manager tooling).

Data Flow

  1. Office manager logs in → JWT role: CLINIC_ADMIN, organizationId: acme.
  2. Browser hits acm.dudoxx.com/en/portal/clinic-admin.
  3. proxy.ts confirms session, resolves trigram, sets X-Clinic-ID: acme-clinic.
  4. clinic-admin/layout.tsx runs createRoleLayout(CLINIC_ADMIN_CONFIG, ClinicAdminNav): requireRoleAccess('CLINIC_ADMIN_ONLY', locale) → returns user (or redirects to /unauthorized); fetches org; mounts sidebar + chrome.
  5. Page-level server fetches: e.g. clinic-admin/staff/page.tsx calls backendGet<StaffMember[]>('users?role=ALL_STAFF') through the gateway — X-Gateway-User-Role: CLINIC_ADMIN, ddx-api re-checks RBAC.
  6. Cross-clinic owner (ORG_ADMIN) sees only the multi-clinic shell — their dashboard aggregates across clinics they own (resolved server-side from JWT.organizationId array — never client-trusted).

Implicated Code

  • ddx-web/src/lib/portal/role-configs.ts:93-103ADMIN_CONFIG.
  • ddx-web/src/lib/portal/role-configs.ts:117-128CLINIC_ADMIN_CONFIG (note brandNameNamespace: 'admin' on :127).
  • ddx-web/src/lib/portal/role-configs.ts:53-63ORG_ADMIN_CONFIG.
  • ddx-web/src/lib/portal/role-configs.ts:189-193 — registry entries (admin, clinic-admin, org-admin).
  • ddx-web/src/app/[locale]/portal/admin/layout.tsx:15-19 — admin layout (one-line factory).
  • ddx-web/src/app/[locale]/portal/clinic-admin/layout.tsx — clinic-admin layout (same pattern).
  • ddx-web/src/app/[locale]/portal/org-admin/layout.tsx — org-admin layout.
  • ddx-web/src/app/[locale]/portal/admin/dashboard/page.tsx:17-27 — admin dashboard server page (requireAuth + PageContainer + AdminDashboardClient).
  • ddx-web/src/app/[locale]/portal/admin/page.tsx:7-12, clinic-admin/page.tsx, org-admin/page.tsx — root redirects to dashboard.
  • ddx-web/src/app/[locale]/portal/clinic-admin/ — 30+ route directories (staff, hr, departments, locations, etc.).
  • ddx-web/src/lib/api/rbac-config.ts:29-46 — backend RBAC entries per route; defaultDeny: true means missing entries return 403.

Operational Notes

  • /portal/admin vs /portal/clinic-admin is an audit concern — customers describing "the admin page" must include the URL. The two surfaces and their RBAC tiers are different; a misrouted bug report reproduces against the wrong portal.
  • ADMIN_PLUS is a superset — adding a new route to admin/ exposes it to STAFF and ACCOUNTANT as well. If the route should be manager-only, ship it under clinic-admin/ instead.
  • CLINIC_ADMIN_ONLY brand label uses namespace admin — the config sets brandNameKey: 'clinicAdmin.title', brandNameNamespace: 'admin' (role-configs.ts:126-127). Other portals use the default brand namespace; if you fork the factory, propagate the namespace parameter or the label falls back to the slug.
  • org-admin/ is intentionally thin — only five route directories exist today. The multi-clinic dashboards are mostly TUCAN / reports surfaces resolved server-side from the org array on the JWT. Adding routes here is fine; do not promote them to admin/ without renaming the auth scope.
  • /unauthorized route requiredrequireRoleAccess redirects to /<locale>/unauthorized when role mismatches; if you renamed it, every admin tier breaks together.
  • Per-route RBAC = mandatorydefaultDeny: true in lib/api/rbac-config.ts:44 means a new admin/foo route that does not have a corresponding *_PERMISSIONS entry in lib/api/rbac-permissions/ returns 403 for ADMIN (only SUPER_ADMIN can reach it). Update the relevant permissions module when shipping.