Skip to main content

Permission System Unification + Read-Only Communications — Design Spec

Date: 2026-06-02 Status: Approved (clinic-admin scope); superadmin parity = Phase 2 Author: ssh + Claude

1. 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). Missing marketplace.{view,request,cancel}, bookings.forms.configure.
    • ui/src/lib/permissions.ts (PERMISSION_TREE) → feeds the clinic-admin editor (PermissionTemplatesPage). Missing billing.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.configure the reverse).
  • Role-default drift. UI DEFAULT_PERMISSIONS_BY_ROLE omits leads.* for doctor/receptionist (the server’s DEFAULT tier grants them) and omits comms.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:270 shows the hub to ['admin','superadmin','doctor','receptionist'] and CommunicationsHubSettings.tsx does 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_templates already exists but is global (no clinicId); per-clinic customization needs its own data-model decision.
  • Custom named roles (“Add Role”). OdontoX role is 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.permissions vs user_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 server PERMISSION_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 UserPanel editor: it currently imports PERMISSION_KEYS + groupPermissions from ui/src/lib/permissions-keys.ts. Change it to derive its key list and grouping from PERMISSION_TREE (export an ALL_PERMISSION_KEYS + a groupPermissions helper from permissions.ts if not already present).
  • Delete ui/src/lib/permissions-keys.ts (or reduce it to a thin re-export from permissions.ts for 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, into PERMISSION_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).
Confirm 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

Update ui/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.manage to PERMISSION_KEYS (Settings group).
  • Add it to admin defaults only (admins get full PERMISSION_KEYS automatically, 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)

Extend server/src/lib/permissions-ui-parity.test.ts to assert:
  1. Key-set equality: the set of leaf keys in the UI PERMISSION_TREE === server PERMISSION_KEYS (both directions; print the diff on failure).
  2. Role-default equality across ALL tiers and ALL roles: loop ['default','pro','pro_plus'] × ['admin','doctor','receptionist','patient'], comparing UI getDefaultsForPlan output to the server’s. (Today it only loops ['pro_plus','pro'] × ['receptionist','doctor'].)
This is the mechanism that makes “one place, in sync” structurally durable without a shared build step.
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):
Settings › People & Access › Roles & Permissions
┌────────────────────────┬───────────────────────────────────────────────┐
│ LEFT (role detail)     │ RIGHT (Role Permissions)                        │
│                        │  [Clinical Care][Front Office][Communications]  │
│ Role: ● Doctor         │  [Insights][Administration]      [search…]      │
│       ○ Receptionist   │  ─────────────────────────────  Save · Reset   │
│       ○ Patient        │  ▸ Appointments      ▣ (bulk grant/deny)        │
│       (Admin: locked)  │  ▸ Patients          ▣                          │
│ Description            │  ▸ Clinical          ▣ … (orange = deviation)   │
│ ─────────────────────  │                                                 │
│ Members in role (12)   │                                                 │
│  • Dr. Khan   active   │                                                 │
│  • Dr. Ali    active   │                                                 │
│  • …                   │                                                 │
└────────────────────────┴───────────────────────────────────────────────┘

B1. Category tabs (fold 15 modules into 5)

Add a category field to each module in PERMISSION_TREE (or a separate MODULE_CATEGORIES map) and render category tabs that filter which modules PermissionTree shows:
CategoryModules
Clinical Careappointments, patients, clinical
Front Officebilling, inventory, lab, bridge
Communicationscomms, notifications, leads
Insightsreports, ai
Administrationsettings, marketplace, audit
Add a 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 StaffManagement already uses — qk.staff.list() / existing staff endpoint), filter by activeRole, 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 (readOnly prop) 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

  • role and cat are reflected in the URL via useSearchParams (mirror the pattern in CommunicationsHubSettings.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: compute const canManage = has('settings.communications.manage') and pass canManage (or readOnly={!canManage}) into all three children (currently user isn’t even passed to NotificationSettings/WebsiteLeadsSettings).
  • WhatsAppSettings: replace the receptionist hard-block (isReceptionist empty state, ~:68/:267) with a visible-but-disabled view driven by !canManage — generalize the existing fieldsLocked pattern (: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; isReceptionist is only used cosmetically at :303).
  • WebsiteLeadsSettings: disable the config controls (lead forms, routing) when !canManage. The lead inbox actions (convert/assign) stay governed by leads.manage and 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.ts PUT (~:85-91): replace the hard role==='admin'||'superadmin' check with requirePermission('settings.communications.manage').
  • Notification-prefs PUT route (in notifications.ts): add requirePermission('settings.communications.manage').
  • Website-leads config mutation routes (forms/routing): add requirePermission('settings.communications.manage'). Leave leads inbox routes on leads.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.manage via full PERMISSION_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: requirePermission middleware. 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-office restores 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.ts is 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-deploy skill; 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.