Admin Portal — Clinic Administration
Audiences: clinical-buyer, developer, internal
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 getsaccountant, the franchise director getsorg-admin. None of them ever see another clinic's data because the gateway tags every call with the currentX-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', iconShieldCheckIcon, badge variantwarning, brand keyportals.admin.CLINIC_ADMIN_CONFIG(:117-128):authRequirement: 'CLINIC_ADMIN_ONLY', iconBuildingsIcon, variantwarning, brand keyclinicAdmin.title(namespaceadmin).ORG_ADMIN_CONFIG(:53-63):authRequirement: 'ORG_ADMIN_ONLY', iconBuildingOfficeIcon, variantwarning, brand keyportals.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 exceptSUPER_ADMIN. - Per-page — sensitive pages (e.g.
clinic-admin/dashboard/page.tsx) may also callrequireAuth(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
| Layer | Choice | Why |
|---|---|---|
| Factory | createRoleLayout per portal | Three configs, three sidebars, one shell composition pipeline. |
| Auth scopes | ADMIN_PLUS vs CLINIC_ADMIN_ONLY vs ORG_ADMIN_ONLY | Three different audiences require three different role unions; never collapse. |
| Brand label | portals.admin, clinicAdmin.title (ns admin), portals.orgAdmin | Brand namespace lets the clinic-admin "Clinic Administration" label live in the admin translation file. |
| Icon | ShieldCheck / Buildings / BuildingOffice | Visual distinction in the badge — investors see three icons during the demo. |
| Nav | PortalNavigation 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
- Office manager logs in → JWT
role: CLINIC_ADMIN,organizationId: acme. - Browser hits
acm.dudoxx.com/en/portal/clinic-admin. proxy.tsconfirms session, resolves trigram, setsX-Clinic-ID: acme-clinic.clinic-admin/layout.tsxrunscreateRoleLayout(CLINIC_ADMIN_CONFIG, ClinicAdminNav):requireRoleAccess('CLINIC_ADMIN_ONLY', locale)→ returns user (or redirects to/unauthorized); fetches org; mounts sidebar + chrome.- Page-level server fetches: e.g.
clinic-admin/staff/page.tsxcallsbackendGet<StaffMember[]>('users?role=ALL_STAFF')through the gateway —X-Gateway-User-Role: CLINIC_ADMIN, ddx-api re-checks RBAC. - Cross-clinic owner (
ORG_ADMIN) sees only the multi-clinic shell — their dashboard aggregates across clinics they own (resolved server-side fromJWT.organizationIdarray — never client-trusted).
Implicated Code
ddx-web/src/lib/portal/role-configs.ts:93-103—ADMIN_CONFIG.ddx-web/src/lib/portal/role-configs.ts:117-128—CLINIC_ADMIN_CONFIG(notebrandNameNamespace: 'admin'on:127).ddx-web/src/lib/portal/role-configs.ts:53-63—ORG_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 todashboard.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: truemeans missing entries return 403.
Operational Notes
/portal/adminvs/portal/clinic-adminis 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_PLUSis a superset — adding a new route toadmin/exposes it toSTAFFandACCOUNTANTas well. If the route should be manager-only, ship it underclinic-admin/instead.CLINIC_ADMIN_ONLYbrand label uses namespaceadmin— the config setsbrandNameKey: '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 toadmin/without renaming the auth scope./unauthorizedroute required —requireRoleAccessredirects to/<locale>/unauthorizedwhen role mismatches; if you renamed it, every admin tier breaks together.- Per-route RBAC = mandatory —
defaultDeny: trueinlib/api/rbac-config.ts:44means a newadmin/fooroute that does not have a corresponding*_PERMISSIONSentry inlib/api/rbac-permissions/returns 403 for ADMIN (only SUPER_ADMIN can reach it). Update the relevant permissions module when shipping.
Related Topics
- Multi-Portal Architecture — the shared factory.
- Super Admin Portal — the platform-wide counterpart (one tier above ORG_ADMIN).
- Accountant Portal — the finance peer (member of
ADMIN_PLUS). - RBAC Roles —
ADMIN_PLUS,CLINIC_ADMIN_ONLY,ORG_ADMIN_ONLYmembership and inheritance. - Tenant Isolation — how
X-Clinic-IDand the JWTorganizationIdinteract with admin actions.