Permission System Unification + Read-Only Communications — Design Spec
Date: 2026-06-02 Status: Approved (clinic-admin scope); superadmin parity = Phase 2 Author: ssh + Claude1. Problem
OdontoX’s permission system is correct where it’s enforced but fragmented where it’s defined and edited:- Two UI permission lists that disagree with each other and with the server. The server (
server/src/lib/permissions.ts) is the only enforced source (~191 keys). The UI mirrors it in two files that have each drifted:ui/src/lib/permissions-keys.ts→ feeds the superadmin per-user override editor (UserPanel). Missingmarketplace.{view,request,cancel},bookings.forms.configure.ui/src/lib/permissions.ts(PERMISSION_TREE) → feeds the clinic-admin editor (PermissionTemplatesPage). Missingbilling.request_addon,leads.configure,leads.delete,bookings.forms.configure.- Net effect: a permission can be editable in one console and invisible in the other (e.g.
marketplace.*editable for clinic-admin but invisible to superadmin;leads.configurethe reverse).
- Role-default drift. UI
DEFAULT_PERMISSIONS_BY_ROLEomitsleads.*for doctor/receptionist (the server’s DEFAULT tier grants them) and omitscomms.messages.*for patient. The parity test (permissions-ui-parity.test.ts) only checks PRO/PRO_PLUS for doctor/receptionist, so DEFAULT-tier and key-set drift sail through CI. - Settings → Communications has no permission gate and inconsistent behavior.
SettingsModule.tsx:270shows the hub to['admin','superadmin','doctor','receptionist']andCommunicationsHubSettings.tsxdoes zero gating. Inside: WhatsApp hard-blocks receptionist (empty state) but leaves doctor fully editable; Notifications lets both mutate the whole matrix; Website Leads has no gate at all. No “read-only” concept exists anywhere in Settings — it’s binary see/don’t-see.
2. Goals (this round)
- A. One drift-free permission source + a CI tripwire so the clinic-admin editor and the (Phase 2) superadmin editor render the same tree, and the UI can never silently diverge from the enforced server list again.
- B. Redesigned clinic-admin permission editor at Settings → People & Access →
?tab=templates: a clean two-pane layout (role detail + members list | grouped permissions with category tabs + search), in our UI language, deep-linked so there are no stale URLs. - C. Read-only Settings → Communications for reception + doctor. All three sub-tabs become visible-but-disabled; the separate Leads inbox (convert/assign) is untouched.
3. Non-Goals / Deferred
- Email-notifications library (the JobLogic Email Notifications inspiration) — next round, its own design.
email_templatesalready exists but is global (noclinicId); per-clinic customization needs its own data-model decision. - Custom named roles (“Add Role”). OdontoX
roleis a hard DB enum (pending|superadmin|admin|doctor|receptionist|patient); a user can only be one of those. True custom roles need a schema + enforcement change — out of scope for a UI-only round. - Mobile permission system (
mobile_role_permissions, module-name toggles) — legacy, untouched. - Converging the dual write-tables (
userClinicAssignments.permissionsvsuser_permission_overrides) — flagged for Phase 2 (superadmin), not changed here. - Patient role — keep on existing defaults; no special treatment (per owner note).
4. Constraints
- UI-first, reuse existing backend. The only backend additions allowed this round are the irreducible core of requirement C: one new permission key + middleware on three existing routes. No new tables, no new engines, no enum changes.
- Don’t break enforcement. The server stays the canonical enforced source. We do not change
getEffectivePermissions()resolution semantics. - Don’t break other sessions’ work. Stage explicit paths; never
git add -A.
5. Part A — One drift-free permission source
Principle: the serverPERMISSION_KEYS remains the canonical, enforced list. The UI gets exactly one source, and a CI test asserts the UI source equals the server.
A1. Collapse the two UI lists into one
- Make
ui/src/lib/permissions.ts(PERMISSION_TREE+ALL_PERMISSION_KEYS+groupPermissions) the single UI source. - Repoint the superadmin
UserPaneleditor: it currently importsPERMISSION_KEYS+groupPermissionsfromui/src/lib/permissions-keys.ts. Change it to derive its key list and grouping fromPERMISSION_TREE(export anALL_PERMISSION_KEYS+ agroupPermissionshelper frompermissions.tsif not already present). - Delete
ui/src/lib/permissions-keys.ts(or reduce it to a thin re-export frompermissions.tsfor one release, then remove). Grep for all importers first.
A2. Make the UI tree’s key-set equal the server’s
Add the keys the UI tree is missing, with labels, intoPERMISSION_TREE:
billing.request_addon→ Billing module, new “Add-ons” section: “Request plan add-ons”.leads.configure→ Website Leads module: “Configure lead forms & routing”.leads.delete→ Website Leads module: “Delete leads”.bookings.forms.configure→ Website Leads module (or a “Public Booking” subsection): “Create / edit public booking forms”.- NEW
settings.communications.manage→ Settings module, new “Communications” section: “Edit Communications settings (WhatsApp, Notifications, lead forms)” (see Part C).
marketplace.{view,request,cancel} are present in the tree (they are in permissions.ts; they were the keys missing from the deleted permissions-keys.ts, so collapsing to one source fixes that half automatically).
A3. Align role defaults to the server
Updateui/src/lib/permissions.ts DEFAULT_PERMISSIONS_BY_ROLE, PRO_PERMISSIONS_BY_ROLE, PRO_PLUS_PERMISSIONS_BY_ROLE to match server/src/lib/permissions.ts exactly for every role and tier. Known fixes: add leads.* to DEFAULT-tier doctor (leads.view) and receptionist (leads.view/manage/convert); add comms.messages.{view,send_patient,mark_read} to patient. Do not add settings.communications.manage to doctor/receptionist (that gives them edit — see Part C).
A4. Add settings.communications.manage to the server canonical list
In server/src/lib/permissions.ts:
- Add
settings.communications.managetoPERMISSION_KEYS(Settings group). - Add it to admin defaults only (admins get full
PERMISSION_KEYSautomatically, so no array edit needed). Explicitly omit it from doctor/receptionist DEFAULT/PRO/PRO_PLUS arrays so those roles resolve to read-only.
A5. Harden the parity test (the tripwire)
Extendserver/src/lib/permissions-ui-parity.test.ts to assert:
- Key-set equality: the set of leaf keys in the UI
PERMISSION_TREE=== serverPERMISSION_KEYS(both directions; print the diff on failure). - Role-default equality across ALL tiers and ALL roles: loop
['default','pro','pro_plus']×['admin','doctor','receptionist','patient'], comparing UIgetDefaultsForPlanoutput to the server’s. (Today it only loops['pro_plus','pro']×['receptionist','doctor'].)
Note (future ideal, not this round): the cleanest long-term form is a shared/ package exporting pure permission data (keys + labels + per-role/plan defaults) imported by both server and UI, with icons mapped in the UI layer. That’s a bigger refactor; for now, single-UI-source + CI parity achieves the same guarantee at far lower risk.
6. Part B — Redesigned clinic-admin permission editor
Location:Settings → People & Access → ?tab=templates (existing slot in PeopleAccessHubSettings.tsx). Rename the tab label to “Roles & Permissions”. Deep-link role and category: ?tab=templates&role=doctor&cat=communications.
Layout (our UI language, JobLogic-inspired structure):
B1. Category tabs (fold 15 modules into 5)
Add acategory field to each module in PERMISSION_TREE (or a separate MODULE_CATEGORIES map) and render category tabs that filter which modules PermissionTree shows:
| Category | Modules |
|---|---|
| Clinical Care | appointments, patients, clinical |
| Front Office | billing, inventory, lab, bridge |
| Communications | comms, notifications, leads |
| Insights | reports, ai |
| Administration | settings, marketplace, audit |
category?: string filter prop (and a searchQuery?: string prop) to PermissionTree; it filters modules by active category and filters items/sections by search match (match on label OR key). Keep all existing behavior (collapsible modules/sections, tri-state bulk toggles, deviation highlighting, readOnly).
B2. Members-in-role pane (left)
- Fetch the clinic staff list (reuse the query
StaffManagementalready uses —qk.staff.list()/ existing staff endpoint), filter byactiveRole, render name + active/inactive badge + count. - Clicking a member can deep-link to their per-user permission editor (existing
StaffManagement?staffPermissionsId=flow) — optional nicety, not required for v1.
B3. Preserve existing save/reset/deviation
- Keep
PermissionTemplatesPage’s TanStack query (qk.permissionTemplates.list()),PUT/DELETE /clinic/permission-templates/:role, deviation count, Save/Reset. No backend change. - Admin role: render read-only (
readOnlyprop) with a one-line note “Admins always have full access” — do not allow editing (matches the existing intentional exclusion, but now visible instead of hidden).
B4. Deep-linking
roleandcatare reflected in the URL viauseSearchParams(mirror the pattern inCommunicationsHubSettings.tsx/PeopleAccessHubSettings.tsx). Invalid values fall back to defaults (role=doctor,cat=clinical-care). No stale URLs.
7. Part C — Read-only Settings → Communications for reception + doctor
Gate key:settings.communications.manage (added in A4; admin-only). Reception/doctor get the hub visible but read-only; admin edits normally.
C1. UI: plumb permission + read-only into the hub
CommunicationsHubSettings.tsx: computeconst canManage = has('settings.communications.manage')and passcanManage(orreadOnly={!canManage}) into all three children (currentlyuserisn’t even passed toNotificationSettings/WebsiteLeadsSettings).- WhatsAppSettings: replace the receptionist hard-block (
isReceptionistempty state, ~:68/:267) with a visible-but-disabled view driven by!canManage— generalize the existingfieldsLockedpattern (:90) so doctor and receptionist see identical read-only config. - NotificationSettings: disable all toggles/inputs and hide Save when
!canManage(currently both roles can mutate the matrix;isReceptionistis only used cosmetically at:303). - WebsiteLeadsSettings: disable the config controls (lead forms, routing) when
!canManage. The lead inbox actions (convert/assign) stay governed byleads.manageand are not disabled by this gate. - Read-only treatment standard: disabled inputs, hidden/disabled Save buttons, mutation handlers short-circuit on
!canManage. Add a small “View only — ask an admin to change Communications settings” banner.
C2. Server backstop (the irreducible backend bit)
whatsapp-config.tsPUT (~:85-91): replace the hardrole==='admin'||'superadmin'check withrequirePermission('settings.communications.manage').- Notification-prefs PUT route (in
notifications.ts): addrequirePermission('settings.communications.manage'). - Website-leads config mutation routes (forms/routing): add
requirePermission('settings.communications.manage'). Leave leads inbox routes onleads.manage/leads.convert. - GET routes stay open to the roles that can view (no change), so reception/doctor still see the settings.
C3. Defaults
- Admin: has
settings.communications.managevia fullPERMISSION_KEYS. ✓ - Doctor/Receptionist: key absent from their defaults → read-only. ✓
- Existing stored clinic templates won’t contain the new key; since it’s not in doctor/receptionist defaults, they correctly resolve read-only. Admins always get full defaults (templates not applied to admins), so admins are unaffected.
8. Reused data model & APIs (no changes except C2)
- Definition:
server/src/lib/permissions.ts(PERMISSION_KEYS,*_PERMISSIONS_BY_ROLE,getEffectivePermissions). +1 key (A4). - Storage:
clinic_permission_templates(clinic+role JSONB),userClinicAssignments.permissions,user_permission_overrides. No schema change. - Clinic-admin APIs:
GET/PUT/DELETE /api/v1/protected/clinic/permission-templates[/:role]. No change. - Enforcement:
requirePermissionmiddleware. C2 adds it to 3 existing routes.
9. Testing
- Parity test (A5): key-set equality + role-default equality across all tiers/roles. Must fail loudly with a printed diff.
- Read-only Communications: unit/integration — assert reception & doctor render disabled controls; assert the 3 PUT routes 403 without
settings.communications.manage; assert reception can still hit leads convert/assign (leads.manage). - Editor smoke: category tabs filter modules; search filters items; deep-link
?tab=templates&role=receptionist&cat=front-officerestores state; Save/Reset still work against the existing endpoints. - Visual pass (required per house rule): reload + screenshot (or Playwright) the redesigned editor and the read-only Communications view for a reception user on the test tenant (ssh & Associates,
b6d3a3f3-…).tsc/build does not prove UI quality.
10. Rollout / risk
- A1–A3 are data/lib edits with a CI tripwire — no enforcement behavior change. Risk: an importer of
permissions-keys.tsis missed → grep + tsc catch it. - C touches 3 server routes; the new key defaults to admin-only, so the blast radius is “reception/doctor can no longer edit Communications settings” — exactly the requirement.
- Deploy via the
odontox-commit-deployskill; stage explicit paths only; force-promote CF canonical after Pages deploy.
11. Phase 2 (priority 2) — superadmin parity
Reuse the same redesigned tree component in the superadmin tenants console so superadmin edits per-clinic templates and per-user overrides through the identical UI clinic-admins see (in sync by construction). Add an effective-permissions read-only view (role defaults → template → override resolved). Decide write-path convergence (userClinicAssignments.permissions vs user_permission_overrides) and add an override-count column. Tracked separately; not part of this round.
