Skip to main content

Permission Unification + Read-Only Communications — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.
Goal: Collapse OdontoX’s three drifting UI permission lists into one CI-guarded source, redesign the clinic-admin permission editor into a clean two-pane layout at Settings → People & Access, and make Settings → Communications read-only for reception + doctor. Architecture: The server (server/src/lib/permissions.ts) stays the canonical enforced source. On the UI side, ui/src/lib/permissions.ts (PERMISSION_TREE) becomes the single source; permissions-keys.ts is deleted and the superadmin editor reads the same tree. A hardened vitest parity test asserts the UI tree’s key-set and per-role/per-plan defaults equal the server’s. The editor reuses the existing PermissionTree component, wrapped in category tabs + search + a members pane. Read-only Communications is gated by a new settings.communications.manage key (admin-only by default). Tech Stack: React + TypeScript + Vite (ui), Hono + Drizzle on Cloudflare Workers (server), TanStack Query, vitest, shadcn/ui, lucide-react. Phases & dependencies: Phase A (foundation) → then Phase B (editor) and Phase C (read-only comms) are independent of each other and may run in parallel. Phase D verifies.

File Structure

Phase A — single source + drift fix
  • Modify: ui/src/lib/permissions.ts — add groupPermissions + PermissionKey export; add 5 keys to tree; align role-default arrays.
  • Modify: ui/src/components/superadmin/Tenants/UserPanel.tsx:35,321 — import from @/lib/permissions.
  • Delete: ui/src/lib/permissions-keys.ts.
  • Modify: server/src/lib/permissions.ts — add settings.communications.manage to PERMISSION_KEYS.
  • Modify: server/src/lib/permissions-ui-parity.test.ts — key-set + all-tier/all-role parity.
Phase B — editor redesign
  • Modify: ui/src/lib/permissions.ts — add MODULE_CATEGORIES + category on modules.
  • Modify: ui/src/components/settings/permissions/PermissionTree.tsxcategory + searchQuery filter props.
  • Modify: ui/src/components/settings/permissions/PermissionTemplatesPage.tsx — two-pane layout, category tabs, search, members pane, deep-link role+cat, admin read-only.
  • Modify: ui/src/components/settings/PeopleAccessHubSettings.tsx — tab label “Roles & Permissions”.
Phase C — read-only Communications
  • Modify: ui/src/components/settings/CommunicationsHubSettings.tsx — compute canManage, pass to children.
  • Modify: ui/src/components/settings/WhatsAppSettings.tsx — read-only view for non-managers.
  • Modify: ui/src/components/settings/NotificationSettings.tsx — disable toggles/Save for non-managers.
  • Modify: ui/src/components/settings/WebsiteLeadsSettings.tsx — disable form-config controls without leads.configure.
  • Modify: server/src/routes/clinics.ts:834 — inline guard on notificationPreferences.

PHASE A — Single drift-free permission source

Task A1: Export groupPermissions + PermissionKey from the UI tree module

Files:
  • Modify: ui/src/lib/permissions.ts (after the ALL_PERMISSION_KEYS export, ~line 568)
  • Step 1: Add the type + helper
Append after export const ALL_PERMISSION_KEYS = ... (line ~568):
// Single UI source of truth. `permissions-keys.ts` has been removed; the
// superadmin per-user override editor derives its key list + grouping here.
export type PermissionKey = string;

// Human-readable groups for the overrides UI: bucket keys by leading namespace
// (`appointments.*`, `patients.*`, …). Mirrors the old permissions-keys.ts helper.
export function groupPermissions(keys: readonly string[]): Record<string, string[]> {
  const groups: Record<string, string[]> = {};
  for (const k of keys) {
    const ns = k.split('.')[0];
    (groups[ns] ||= []).push(k);
  }
  return groups;
}
  • Step 2: Typecheck
Run: cd ui && npx tsc --noEmit Expected: PASS (no new errors).
  • Step 3: Commit
git add ui/src/lib/permissions.ts
git commit -m "feat(permissions): export groupPermissions + PermissionKey from the UI tree module"

Task A2: Add the 5 missing keys to PERMISSION_TREE

The UI tree is missing 4 keys the server enforces (billing.request_addon, leads.configure, leads.delete, bookings.forms.configure) plus we introduce settings.communications.manage. Files:
  • Modify: ui/src/lib/permissions.ts
  • Step 1: Add billing.request_addon
In the billing module, add a new section after the insurance section (after line ~275, before the module’s closing ]):
      {
        id: 'addons',
        label: 'Add-ons',
        items: [
          { key: 'billing.request_addon', label: 'Request plan add-ons' },
        ],
      },
  • Step 2: Add lead config keys + booking-form key to the leads module
Replace the leads module’s items array (lines ~556-560) with:
      items: [
        { key: 'leads.view', label: 'View leads inbox' },
        { key: 'leads.manage', label: 'Update lead status, assign, add notes' },
        { key: 'leads.convert', label: 'Convert a lead into a patient' },
        { key: 'leads.configure', label: 'Configure lead & booking form routing' },
        { key: 'leads.delete', label: 'Delete leads' },
        { key: 'bookings.forms.configure', label: 'Create / edit public booking forms' },
      ],
  • Step 3: Add settings.communications.manage to the settings module
In the settings module, add a new section after the modules section (after line ~505, before the module’s closing ]):
      {
        id: 'communications',
        label: 'Communications',
        items: [
          { key: 'settings.communications.manage', label: 'Edit Communications settings (WhatsApp & Notifications)' },
        ],
      },
  • Step 4: Typecheck
Run: cd ui && npx tsc --noEmit Expected: PASS.
  • Step 5: Commit
git add ui/src/lib/permissions.ts
git commit -m "feat(permissions): surface request_addon, leads config/delete, booking-form + new comms.manage keys in the UI tree"

Task A3: Repoint UserPanel to the single source, delete permissions-keys.ts

permissions-keys.ts is imported by exactly one file (UserPanel.tsx, lines 35 & 321). Files:
  • Modify: ui/src/components/superadmin/Tenants/UserPanel.tsx:35,321
  • Delete: ui/src/lib/permissions-keys.ts
  • Step 1: Update the import (line 35)
Replace:
import { PERMISSION_KEYS, groupPermissions, type PermissionKey } from '@/lib/permissions-keys';
with:
import { ALL_PERMISSION_KEYS, groupPermissions, type PermissionKey } from '@/lib/permissions';
  • Step 2: Update the usage (line 321)
Replace:
  const groups = useMemo(() => groupPermissions(PERMISSION_KEYS), []);
with:
  const groups = useMemo(() => groupPermissions(ALL_PERMISSION_KEYS), []);
(If PERMISSION_KEYS is referenced anywhere else in UserPanel.tsx, replace those with ALL_PERMISSION_KEYS too — grep first: grep -n "PERMISSION_KEYS" ui/src/components/superadmin/Tenants/UserPanel.tsx.)
  • Step 3: Delete the dead file
git rm ui/src/lib/permissions-keys.ts
  • Step 4: Verify no other importers + typecheck
Run:
grep -rn "permissions-keys" ui/src | grep -v node_modules
cd ui && npx tsc --noEmit
Expected: grep returns nothing; tsc PASS.
  • Step 5: Commit
git add ui/src/components/superadmin/Tenants/UserPanel.tsx ui/src/lib/permissions-keys.ts
git commit -m "refactor(permissions): superadmin editor reads the single UI tree; remove duplicate permissions-keys.ts"

Task A4: Add settings.communications.manage to the server canonical list

Files:
  • Modify: server/src/lib/permissions.ts (the PERMISSION_KEYS array, Settings group ~line 201)
  • Step 1: Add the key
In PERMISSION_KEYS, after 'settings.modules.manage', (line ~201) add:
  'settings.communications.manage',
Do NOT add it to DEFAULT/PRO/PRO_PLUS_PERMISSIONS_BY_ROLE for doctor/receptionist/patient — only admin gets it (admins receive all keys automatically). This is what makes Communications read-only for reception + doctor.
  • Step 2: Typecheck
Run: cd server && npx tsc --noEmit Expected: PASS.
  • Step 3: Commit
git add server/src/lib/permissions.ts
git commit -m "feat(permissions): add settings.communications.manage key (admin-only) for read-only comms gating"

Task A5: Align UI role-default arrays to the server

The UI DEFAULT_PERMISSIONS_BY_ROLE omits leads.* for doctor/receptionist and comms.messages.* for patient that the server’s defaults grant. Files:
  • Modify: ui/src/lib/permissions.tsDEFAULT_PERMISSIONS_BY_ROLE (and verify PRO/PRO_PLUS).
  • Step 1: Add leads to DEFAULT doctor
In DEFAULT_PERMISSIONS_BY_ROLE.doctor, after 'audit.activity.view', (end of array, ~line 630) add:
    // Website Leads — doctors see the inbox read-only (visibility, not action).
    'leads.view',
  • Step 2: Add leads to DEFAULT receptionist
In DEFAULT_PERMISSIONS_BY_ROLE.receptionist, after 'audit.activity.view', (~line 676) add:
    // Website Leads — receptionist works the inbox + converts to patient.
    'leads.view', 'leads.manage', 'leads.convert',
  • Step 3: Add comms.messages to patient
In DEFAULT_PERMISSIONS_BY_ROLE.patient, after 'notifications.manage', (~line 691) add:
    'comms.messages.view', 'comms.messages.send_patient', 'comms.messages.mark_read',
  • Step 4: Cross-check PRO/PRO_PLUS against the server
Run a one-off diff to confirm the remaining tiers match the server (the test in A6 will enforce this, but verify now):
cd server && npx vitest run src/lib/permissions-ui-parity.test.ts 2>&1 | tail -30
If the (still-old) test fails for pro/pro_plus, sync those UI arrays to match server/src/lib/permissions.ts exactly. Expected after sync: PASS.
  • Step 5: Commit
git add ui/src/lib/permissions.ts
git commit -m "fix(permissions): align UI DEFAULT-tier role defaults (leads.*, patient comms) with the server"

Task A6: Harden the parity test — the drift tripwire

Files:
  • Modify: server/src/lib/permissions-ui-parity.test.ts
  • Step 1: Replace the test file with key-set + all-tier/all-role parity
Replace the entire contents of server/src/lib/permissions-ui-parity.test.ts with:
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'fs';
import { resolve } from 'path';
import { getDefaultsForPlan, PERMISSION_KEYS } from './permissions';

// The UI (ui/src/lib/permissions.ts) duplicates the permission key list and the
// DEFAULT/PRO/PRO_PLUS per-role defaults. This test is the tripwire that keeps
// them identical to the server's enforced source. The server vitest config
// can't import the UI module, so we parse the UI source file.

const UI_PERMISSIONS_PATH = resolve(__dirname, '../../../ui/src/lib/permissions.ts');

function uiSrc(): string {
  return readFileSync(UI_PERMISSIONS_PATH, 'utf8');
}

// Extract every leaf key from the PERMISSION_TREE block: items shaped
// `{ key: 'x.y.z', label: '...' }`.
function uiTreeKeys(): string[] {
  const src = uiSrc();
  const start = src.indexOf('export const PERMISSION_TREE');
  const end = src.indexOf('export const ALL_PERMISSION_KEYS');
  const block = src.slice(start, end === -1 ? undefined : end);
  return Array.from(block.matchAll(/key:\s*'([a-z][a-z0-9_.]+)'/g)).map(m => m[1]);
}

function extractBlock(src: string, header: RegExp): string[] {
  const match = src.match(header);
  if (!match) return [];
  const startIdx = match.index! + match[0].length;
  let depth = 1;
  let i = startIdx;
  while (i < src.length && depth > 0) {
    if (src[i] === '[') depth++;
    if (src[i] === ']') depth--;
    if (depth === 0) break;
    i++;
  }
  const body = src.slice(startIdx, i).replace(/\/\/.*$/gm, '');
  return Array.from(body.matchAll(/'([a-z][a-z0-9_.]+)'/g)).map(m => m[1]).sort();
}

function uiKeys(role: string, tier: 'default' | 'pro' | 'pro_plus'): string[] {
  const src = uiSrc();
  const tierConst =
    tier === 'pro_plus' ? 'PRO_PLUS_PERMISSIONS_BY_ROLE'
    : tier === 'pro' ? 'PRO_PERMISSIONS_BY_ROLE'
    : 'DEFAULT_PERMISSIONS_BY_ROLE';
  const header = new RegExp(`${tierConst}[^]*?\\n\\s*${role}:\\s*\\[`);
  const inline = extractBlock(src, header);
  if (inline.length > 0) return inline;
  // Resolve a spread reference like `doctor: [...DEFAULT_PERMISSIONS_BY_ROLE.doctor],`
  const spreadHeader = new RegExp(`${tierConst}[^]*?\\n\\s*${role}:\\s*\\[\\s*\\.\\.\\.([A-Z_]+)\\.([a-z_]+)`);
  const spreadMatch = src.match(spreadHeader);
  if (!spreadMatch) return [];
  const [, refConst, refRole] = spreadMatch;
  return extractBlock(src, new RegExp(`${refConst}[^]*?\\n\\s*${refRole}:\\s*\\[`));
}

function diff(server: string[], ui: string[]) {
  const s = new Set(server), u = new Set(ui);
  return { serverOnly: server.filter(k => !u.has(k)), uiOnly: ui.filter(k => !s.has(k)) };
}

describe('UI vs server permission KEY-SET parity', () => {
  it('UI PERMISSION_TREE leaf keys === server PERMISSION_KEYS', () => {
    const ui = Array.from(new Set(uiTreeKeys())).sort();
    const server = [...PERMISSION_KEYS].sort();
    expect(ui.length).toBeGreaterThan(0);
    const { serverOnly, uiOnly } = diff(server, ui);
    if (serverOnly.length || uiOnly.length) {
      throw new Error(
        `Permission KEY-SET drift between UI tree and server.\n` +
        `Server has, UI tree missing: ${serverOnly.join(', ') || '(none)'}\n` +
        `UI tree has, server missing: ${uiOnly.join(', ') || '(none)'}\n` +
        `Fix: add/remove keys in ui/src/lib/permissions.ts PERMISSION_TREE and/or server PERMISSION_KEYS.`
      );
    }
  });
});

describe('UI vs server permission-DEFAULTS parity', () => {
  for (const role of ['doctor', 'receptionist', 'patient'] as const) {
    for (const tier of ['default', 'pro', 'pro_plus'] as const) {
      it(`${role} @ ${tier} default lists match`, () => {
        const server = [...getDefaultsForPlan(role, tier)].sort();
        const ui = uiKeys(role, tier);
        expect(ui.length).toBeGreaterThan(0);
        const { serverOnly, uiOnly } = diff(server, ui);
        if (serverOnly.length || uiOnly.length) {
          throw new Error(
            `Permission defaults drift for ${role} @ ${tier}.\n` +
            `Server has, UI missing: ${serverOnly.join(', ') || '(none)'}\n` +
            `UI has, server missing: ${uiOnly.join(', ') || '(none)'}\n` +
            `Fix: sync the matching ui/src/lib/permissions.ts table.`
          );
        }
      });
    }
  }
});
  • Step 2: Run it to verify it PASSES (after A2/A5 fixes)
Run: cd server && npx vitest run src/lib/permissions-ui-parity.test.ts Expected: PASS. If it fails, the failure message names exactly which keys to add/remove — fix ui/src/lib/permissions.ts accordingly and re-run.
  • Step 3: Sanity — confirm it CATCHES drift
Temporarily delete one key from getDefaultsForPlan’s doctor list (or comment a UI key), re-run, confirm a clear failure, then revert.
  • Step 4: Commit
git add server/src/lib/permissions-ui-parity.test.ts
git commit -m "test(permissions): parity guard now covers full key-set + all tiers/roles (default included)"

PHASE B — Redesigned clinic-admin permission editor

Task B1: Module categories + category/search filters on PermissionTree

Files:
  • Modify: ui/src/lib/permissions.ts — add MODULE_CATEGORIES.
  • Modify: ui/src/components/settings/permissions/PermissionTree.tsxcategory + searchQuery props.
  • Step 1: Add the category map to permissions.ts
Append after PERMISSION_TREE (before ALL_PERMISSION_KEYS):
// Groups the 15 modules into 5 admin-friendly tabs for the editor.
export const PERMISSION_CATEGORIES: { id: string; label: string; moduleIds: string[] }[] = [
  { id: 'clinical-care',  label: 'Clinical Care',  moduleIds: ['appointments', 'patients', 'clinical'] },
  { id: 'front-office',   label: 'Front Office',   moduleIds: ['billing', 'inventory', 'lab', 'bridge'] },
  { id: 'communications', label: 'Communications', moduleIds: ['comms', 'notifications', 'leads'] },
  { id: 'insights',       label: 'Insights',       moduleIds: ['reports', 'ai'] },
  { id: 'administration', label: 'Administration', moduleIds: ['settings', 'marketplace', 'audit'] },
];

export function moduleIdsForCategory(categoryId: string): string[] {
  return PERMISSION_CATEGORIES.find(c => c.id === categoryId)?.moduleIds ?? [];
}
  • Step 2: Add filter props to PermissionTree
In PermissionTree.tsx, extend the props interface (line 7-12):
interface PermissionTreeProps {
  permissions: Record<string, boolean>;
  templateDefaults: Record<string, boolean>;
  onChange: (key: string, value: boolean) => void;
  readOnly?: boolean;
  /** Only render modules whose id is in this list. Undefined = all modules. */
  moduleIds?: string[];
  /** Case-insensitive filter on item label OR key. Empty = no filter. */
  searchQuery?: string;
}
  • Step 3: Apply the filters in the render
In PermissionTree.tsx, change the destructure (line 36) to include the new props, and filter the module list at the top of the .map. Replace line 36 and the {PERMISSION_TREE.map((module) => { opening (line 74) with:
export function PermissionTree({ permissions, templateDefaults, onChange, readOnly = false, moduleIds, searchQuery = '' }: PermissionTreeProps) {
and replace {PERMISSION_TREE.map((module) => { (line 74) with:
      {PERMISSION_TREE
        .filter((m) => !moduleIds || moduleIds.includes(m.id))
        .map((module) => {
Then guard each item row by search. Inside the section.items.map((item) => { block (line 223), add at the very top of the callback:
                          const q = searchQuery.trim().toLowerCase();
                          if (q && !item.label.toLowerCase().includes(q) && !item.key.toLowerCase().includes(q)) {
                            return null;
                          }
  • Step 4: Typecheck
Run: cd ui && npx tsc --noEmit Expected: PASS.
  • Step 5: Commit
git add ui/src/lib/permissions.ts ui/src/components/settings/permissions/PermissionTree.tsx
git commit -m "feat(permissions): module categories + category/search filters on PermissionTree"

Files:
  • Modify: ui/src/components/settings/permissions/PermissionTemplatesPage.tsx
  • Step 1: Confirm the staff list query shape
Run: grep -n "qk.staff\|staff.list\|listStaff\|fetchStaff\|getStaff" ui/src/lib/queryKeys.ts ui/src/components/settings/StaffManagement.tsx | head Note the exact query key + fetcher StaffManagement uses (e.g. qk.staff.list() + a serverComm function). Use the same in Step 2 so the members pane shares cache.
  • Step 2: Rewrite PermissionTemplatesPage.tsx
Replace the file with the two-pane layout below. It keeps the existing query/mutation logic and adds: a left role rail (Admin shown but read-only) + members-in-role list, a right pane with category tabs + search over the existing PermissionTree, and role/cat URL sync.
// ui/src/components/settings/permissions/PermissionTemplatesPage.tsx
import { useState, useEffect, useMemo } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Loader2, RotateCcw, Save, ShieldCog, Search, Users } from 'lucide-react';
import { Skeleton } from '@/components/ui/skeleton';
import { Input } from '@/components/ui/input';
import { toast } from '@/lib/toast';
import { Button } from '@/components/ui/button';
import { PermissionTree } from './PermissionTree';
import {
  DEFAULT_PERMISSIONS_BY_ROLE, buildPermissionsMap, ALL_PERMISSION_KEYS,
  PERMISSION_CATEGORIES, moduleIdsForCategory,
} from '@/lib/permissions';
import { getApiBaseUrl } from '@/lib/api-url';
import { getAuthHeaders } from '@/lib/serverComm';
import { qk } from '@/lib/queryKeys';
import { cn } from '@/lib/utils';

// Admin is shown but READ-ONLY: admins always receive the full plan-tier set
// (templates/overrides only control non-admin roles), so editing it is a
// privilege-escalation footgun. Doctor/Receptionist/Patient are editable.
const ROLES = [
  { id: 'admin',        label: 'Admin',        color: 'text-red-600 dark:text-red-400',     editable: false },
  { id: 'doctor',       label: 'Doctor',       color: 'text-blue-600 dark:text-blue-400',   editable: true },
  { id: 'receptionist', label: 'Receptionist', color: 'text-green-600 dark:text-green-400', editable: true },
  { id: 'patient',      label: 'Patient',      color: 'text-purple-600 dark:text-purple-400', editable: true },
] as const;

type RoleId = typeof ROLES[number]['id'];
const EDITABLE_ROLE_IDS = ROLES.filter(r => r.editable).map(r => r.id) as RoleId[];

async function fetchPermissionTemplates(): Promise<Record<string, Record<string, boolean>>> {
  try {
    const res = await fetch(`${getApiBaseUrl()}/api/v1/protected/clinic/permission-templates`, { headers: getAuthHeaders() });
    if (!res.ok) throw new Error('Failed to fetch templates');
    const data = await res.json() as { templates?: Record<string, Record<string, boolean>> };
    if (!data?.templates || typeof data.templates !== 'object') throw new Error('Unexpected response shape');
    return data.templates;
  } catch {
    const defaults: Record<string, Record<string, boolean>> = {};
    for (const { id } of EDITABLE_ROLE_IDS.map(id => ({ id }))) {
      defaults[id] = buildPermissionsMap(DEFAULT_PERMISSIONS_BY_ROLE[id] || []);
    }
    return defaults;
  }
}

// Minimal staff row — reuse the clinic staff list so the members pane shares cache.
interface StaffRow { id: string; name?: string; firstName?: string; lastName?: string; email?: string; role?: string; status?: string; }
async function fetchStaff(): Promise<StaffRow[]> {
  try {
    const res = await fetch(`${getApiBaseUrl()}/api/v1/protected/clinic/staff`, { headers: getAuthHeaders() });
    if (!res.ok) return [];
    const data = await res.json() as { staff?: StaffRow[] } | StaffRow[];
    return Array.isArray(data) ? data : (data.staff ?? []);
  } catch { return []; }
}

export function PermissionTemplatesPage() {
  const queryClient = useQueryClient();
  const [searchParams, setSearchParams] = useSearchParams();

  const activeRole: RoleId = useMemo(() => {
    const r = searchParams.get('role') as RoleId | null;
    return r && ROLES.some(x => x.id === r) ? r : 'doctor';
  }, [searchParams]);
  const activeCat = useMemo(() => {
    const c = searchParams.get('cat');
    return c && PERMISSION_CATEGORIES.some(x => x.id === c) ? c : PERMISSION_CATEGORIES[0].id;
  }, [searchParams]);

  const setParam = (key: string, value: string) => setSearchParams(prev => {
    const next = new URLSearchParams(prev); next.set(key, value); return next;
  }, { replace: false });

  const [search, setSearch] = useState('');
  const [templates, setTemplates] = useState<Record<string, Record<string, boolean>>>({});

  const templatesQuery = useQuery({ queryKey: qk.permissionTemplates.list(), queryFn: fetchPermissionTemplates });
  const staffQuery = useQuery({ queryKey: ['staff', 'permission-members'], queryFn: fetchStaff });
  const loading = templatesQuery.isLoading;

  useEffect(() => { if (templatesQuery.data) setTemplates(templatesQuery.data); }, [templatesQuery.data]);

  const roleMeta = ROLES.find(r => r.id === activeRole)!;
  const isEditable = roleMeta.editable;

  const handleChange = (key: string, value: boolean) => {
    if (!isEditable) return;
    setTemplates(prev => ({ ...prev, [activeRole]: { ...prev[activeRole], [key]: value } }));
  };

  const resetMutation = useMutation({
    mutationFn: async (role: RoleId) => {
      await fetch(`${getApiBaseUrl()}/api/v1/protected/clinic/permission-templates/${role}`, { method: 'DELETE', headers: getAuthHeaders() });
      return role;
    },
    onSuccess: (role) => {
      setTemplates(prev => ({ ...prev, [role]: buildPermissionsMap(DEFAULT_PERMISSIONS_BY_ROLE[role] || []) }));
      toast.success(`${role} template reset to defaults`);
      queryClient.invalidateQueries({ queryKey: qk.permissionTemplates.all() });
    },
    onError: () => toast.error('Failed to reset template'),
  });

  const saveMutation = useMutation({
    mutationFn: async (role: RoleId) => {
      const res = await fetch(`${getApiBaseUrl()}/api/v1/protected/clinic/permission-templates/${role}`, {
        method: 'PUT', headers: { ...getAuthHeaders(), 'Content-Type': 'application/json' },
        body: JSON.stringify({ permissions: templates[role] || {} }),
      });
      if (!res.ok) throw new Error('Failed to save template');
      return role;
    },
    onSuccess: (role) => { toast.success(`${role} template saved`); queryClient.invalidateQueries({ queryKey: qk.permissionTemplates.all() }); },
    onError: () => toast.error('Failed to save template'),
  });

  const saving = saveMutation.isPending;
  const globalDefault = buildPermissionsMap(DEFAULT_PERMISSIONS_BY_ROLE[activeRole] || []);
  // Admin baseline = full grant (admins always have everything).
  const currentTemplate = !isEditable
    ? buildPermissionsMap(ALL_PERMISSION_KEYS)
    : (templates[activeRole] || globalDefault);
  const deviationCount = isEditable ? ALL_PERMISSION_KEYS.filter(k => currentTemplate[k] !== globalDefault[k]).length : 0;

  const members = (staffQuery.data ?? []).filter(s => (s.role ?? '').toLowerCase() === activeRole);
  const memberName = (s: StaffRow) => s.name || [s.firstName, s.lastName].filter(Boolean).join(' ') || s.email || 'Unknown';

  if (loading) {
    return (
      <div className="space-y-3">
        <div className="flex gap-2 mb-4">{Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-9 w-24" />)}</div>
        {Array.from({ length: 8 }).map((_, i) => (
          <div key={i} className="flex items-center gap-3 py-2"><Skeleton className="h-4 w-4" /><Skeleton className="h-4 w-48" /><Skeleton className="h-4 w-16 ml-auto" /></div>
        ))}
      </div>
    );
  }

  return (
    <div className="space-y-6">
      <div className="flex items-center gap-2">
        <ShieldCog className="h-5 w-5 text-primary" />
        <div>
          <h2 className="text-lg font-black tracking-tight">Roles &amp; Permissions</h2>
          <p className="text-sm text-muted-foreground">Set the default permissions each role gets. New staff invited with a role start from its template.</p>
        </div>
      </div>

      <div className="grid grid-cols-1 lg:grid-cols-[260px_1fr] gap-6">
        {/* LEFT: role rail + members */}
        <div className="space-y-4">
          <div className="rounded-2xl border border-border/40 p-2 space-y-1">
            {ROLES.map(role => (
              <button
                key={role.id}
                type="button"
                onClick={() => setParam('role', role.id)}
                className={cn(
                  'w-full text-left px-3 py-2 rounded-xl text-sm font-bold transition-colors flex items-center justify-between',
                  activeRole === role.id ? `bg-muted ${role.color}` : 'text-muted-foreground hover:bg-muted/50'
                )}
              >
                <span>{role.label}</span>
                {!role.editable && <span className="text-[9px] font-bold uppercase tracking-wider text-muted-foreground/60">read-only</span>}
              </button>
            ))}
          </div>

          <div className="rounded-2xl border border-border/40 p-4 space-y-2">
            <div className="flex items-center gap-2 text-muted-foreground">
              <Users className="h-4 w-4" />
              <span className="text-[10px] font-black uppercase tracking-[0.2em]">Members in role ({members.length})</span>
            </div>
            <div className="space-y-1 max-h-72 overflow-y-auto">
              {members.length === 0 ? (
                <p className="text-xs text-muted-foreground/70 py-2">No active members in this role.</p>
              ) : members.map(m => (
                <div key={m.id} className="flex items-center justify-between text-xs py-1">
                  <span className="truncate">{memberName(m)}</span>
                  {m.status && m.status !== 'active' && (
                    <span className="text-[9px] font-bold uppercase text-orange-500 bg-orange-500/10 px-1.5 py-0.5 rounded-full">{m.status}</span>
                  )}
                </div>
              ))}
            </div>
          </div>
        </div>

        {/* RIGHT: category tabs + search + tree */}
        <div className="space-y-4">
          <div className="flex flex-wrap items-center justify-between gap-3">
            <div className="flex flex-wrap gap-1.5">
              {PERMISSION_CATEGORIES.map(cat => (
                <button
                  key={cat.id}
                  type="button"
                  onClick={() => setParam('cat', cat.id)}
                  className={cn(
                    'px-3 py-1.5 rounded-xl text-xs font-bold border transition-colors',
                    activeCat === cat.id ? 'bg-primary/10 border-primary/30 text-primary' : 'border-border/40 text-muted-foreground hover:bg-muted/50'
                  )}
                >
                  {cat.label}
                </button>
              ))}
            </div>
            {isEditable && (
              <div className="flex items-center gap-2 flex-shrink-0">
                {deviationCount > 0 && (
                  <Button variant="outline" size="sm" onClick={() => resetMutation.mutate(activeRole)} disabled={saving} className="gap-1.5 text-orange-600 dark:text-orange-400 border-orange-200 hover:bg-orange-50">
                    <RotateCcw className="h-3.5 w-3.5" /> Reset
                  </Button>
                )}
                <Button size="sm" onClick={() => saveMutation.mutate(activeRole)} disabled={saving} className="gap-1.5">
                  {saving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Save className="h-3.5 w-3.5" />} Save template
                </Button>
              </div>
            )}
          </div>

          <div className="relative">
            <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground/50" />
            <Input value={search} onChange={(e) => setSearch(e.target.value)} placeholder="Search permissions in this category…" className="pl-9" />
          </div>

          {!isEditable && (
            <p className="text-xs text-muted-foreground bg-muted/40 rounded-xl px-3 py-2">Admins always have full accessthis template is read-only.</p>
          )}

          <PermissionTree
            permissions={currentTemplate}
            templateDefaults={isEditable ? globalDefault : currentTemplate}
            onChange={handleChange}
            readOnly={!isEditable}
            moduleIds={moduleIdsForCategory(activeCat)}
            searchQuery={search}
          />
        </div>
      </div>
    </div>
  );
}
  • Step 3: Reconcile the staff fetcher with the real endpoint
If Step 1 showed StaffManagement uses a serverComm function (not a raw /clinic/staff fetch), replace fetchStaff + its query with that function and qk.staff.list() so the members pane shares cache. Keep the .filter(role) + memberName logic.
  • Step 4: Typecheck
Run: cd ui && npx tsc --noEmit Expected: PASS.
  • Step 5: Commit
git add ui/src/components/settings/permissions/PermissionTemplatesPage.tsx
git commit -m "feat(permissions): two-pane Roles & Permissions editor (role rail + members + category tabs + search + deep-links)"

Task B3: Rename the People & Access tab to “Roles & Permissions”

Files:
  • Modify: ui/src/components/settings/PeopleAccessHubSettings.tsx (the templates TabsTrigger, ~line 56-59)
  • Step 1: Update the visible label
In the templates TabsTrigger (lines ~56-59), change the displayed label text to Roles & Permissions (keep value="templates" and the ?tab=templates URL key unchanged — deep-links must not break).
  • Step 2: Typecheck + commit
cd ui && npx tsc --noEmit
git add ui/src/components/settings/PeopleAccessHubSettings.tsx
git commit -m "feat(settings): rename People & Access templates tab to 'Roles & Permissions'"

PHASE C — Read-only Settings → Communications for reception + doctor

Task C1: Plumb canManage through the Communications hub

Files:
  • Modify: ui/src/components/settings/CommunicationsHubSettings.tsx
  • Step 1: Compute permission + pass to children
Add the import and the hook, then pass canManage to all three children:
import { usePermissions } from '@/hooks/usePermissions';
Inside the component (after the activeTab memo):
  const { has } = usePermissions();
  const canManage = has('settings.communications.manage');
Update the three TabsContent children to receive the flag:
          {activeTab === 'whatsapp' ? <WhatsAppSettings user={user} canManage={canManage} /> : null}
          {activeTab === 'notifications' ? <NotificationSettings canManage={canManage} /> : null}
          {activeTab === 'leads' ? <WebsiteLeadsSettings /> : null}
(Website Leads config is already gated server-side by leads.configure; it reads its own permission in C4 — no prop needed.)
  • Step 2: Typecheck (expect errors until C2/C3 add the props)
Run: cd ui && npx tsc --noEmit Expected: errors that canManage is not a prop of WhatsAppSettings/NotificationSettings — resolved in C2/C3. (Commit after C3.)

Task C2: WhatsAppSettings — read-only view for non-managers (replaces receptionist hard-block)

Files:
  • Modify: ui/src/components/settings/WhatsAppSettings.tsx
  • Step 1: Accept the prop + derive a single lock
Add canManage?: boolean to the component’s props. Then make the credential/field lock and all mutating buttons respect it. Near the existing isReceptionist/fieldsLocked (lines 68 & 90), add:
  // Read-only mode for roles without settings.communications.manage
  // (doctor + receptionist). They SEE the config but cannot change it.
  const readOnly = canManage === false;
  • Step 2: Remove the receptionist empty-state hard-block
Delete the if (isReceptionist) { … } early-return block (lines ~298-… ; the empty state). Reception now falls through to the same view as everyone else, rendered read-only.
  • Step 3: Force fields locked + disable mutating controls when readOnly
Change fieldsLocked (line 90) to also lock when read-only:
  const fieldsLocked = (form.configured && !unlocked) || readOnly;
Disable the credential “Edit credentials/Lock” toggle, the pause/activate Buttons, the register-phone Buttons, and handleSave when readOnly. For each relevant <Button …> add disabled={readOnly || <existing condition>}, and at the top of handleSave() (line 175) add:
    if (readOnly) return;
Hide the “Edit credentials” toggle entirely when readOnly (it makes no sense): wrap it in {!readOnly && ( … )}.
  • Step 4: Add a read-only banner
At the top of the returned JSX (inside the root container), add:
      {readOnly && (
        <div className="text-xs text-muted-foreground bg-muted/40 rounded-xl px-3 py-2">
          View only — ask an admin to change WhatsApp settings.
        </div>
      )}
  • Step 5: Typecheck
Run: cd ui && npx tsc --noEmit Expected: WhatsAppSettings errors resolved.

Task C3: NotificationSettings — disable matrix + Save for non-managers

Files:
  • Modify: ui/src/components/settings/NotificationSettings.tsx
  • Step 1: Accept the prop
Add canManage?: boolean to the component props and derive:
  const readOnly = canManage === false;
  • Step 2: Short-circuit the mutating handlers
At the top of toggleCell, silenceColumnAcross, resetAll, saveGoogleUrl, and the WhatsApp-trigger toggle handler, add if (readOnly) return;. (Find them via the onClick/onCheckedChange refs at lines 230, 263, 287, 312, 420.)
  • Step 3: Disable the controls
Add disabled={readOnly} to: the matrix <Switch>/checkbox onCheckedChange cells (lines ~263, 287), the Reset to defaults button (line 225), the per-column silence buttons (line 230), the Google URL Save button (line 312), and the WhatsApp trigger <Switch> (line 417-420, combine with existing disabled). Example for the matrix cells:
                              onCheckedChange={() => toggleCell(row.key, c.key)}
                              disabled={readOnly}
  • Step 4: Read-only banner
At the top of the returned JSX add:
      {readOnly && (
        <div className="text-xs text-muted-foreground bg-muted/40 rounded-xl px-3 py-2">
          View only — ask an admin to change notification preferences.
        </div>
      )}
  • Step 5: Typecheck + commit Phase C UI so far
cd ui && npx tsc --noEmit
git add ui/src/components/settings/CommunicationsHubSettings.tsx ui/src/components/settings/WhatsAppSettings.tsx ui/src/components/settings/NotificationSettings.tsx
git commit -m "feat(settings): read-only WhatsApp + Notifications for reception/doctor via settings.communications.manage"

Task C4: WebsiteLeadsSettings — disable form-config controls without leads.configure

The lead-inbox actions (convert/assign) stay available (reception keeps leads.manage/leads.convert); only the form config (create/edit booking forms) is gated. Files:
  • Modify: ui/src/components/settings/WebsiteLeadsSettings.tsx
  • Step 1: Read the permission
Add:
import { usePermissions } from '@/hooks/usePermissions';
Inside the component:
  const { has } = usePermissions();
  const canConfigureForms = has('leads.configure');
  • Step 2: Gate the form-config buttons
On the ”+ Booking Form” create button (line ~175-177) and each per-row Edit button (line ~217-219), add disabled={!canConfigureForms}. Wrap the whole “Forms management” card header action in a tooltip or hide the create button when !canConfigureForms:
            disabled={!canConfigureForms}
Leave loadLeads, the leads table, status buttons, and Convert flow (lines 259, 515-519, 527) untouched — those are inbox actions gated by leads.manage/leads.convert.
  • Step 3: Typecheck + commit
cd ui && npx tsc --noEmit
git add ui/src/components/settings/WebsiteLeadsSettings.tsx
git commit -m "feat(settings): gate Website Leads form-config controls behind leads.configure (inbox actions untouched)"

Task C5: Server inline guard on notification-preferences updates

notificationPreferences is updated through the general PUT /api/v1/protected/clinics/:id handler (clinics.ts:834). Guard that specific field so reception/doctor cannot mutate it via a hand-crafted request. (WhatsApp config PUT is already hard admin-only at whatsapp-config.ts:91 — no change needed there.) Files:
  • Modify: server/src/routes/clinics.ts (around line 834, where body.notificationPreferences is handled)
  • Test: server/src/routes/__tests__/clinics-notification-guard.test.ts (new)
  • Step 1: Write the failing test
Create server/src/routes/__tests__/clinics-notification-guard.test.ts:
import { describe, it, expect } from 'vitest';
import { canManageCommunications } from '../clinics';

describe('canManageCommunications', () => {
  it('allows admin', () => {
    expect(canManageCommunications({ role: 'admin', permissions: {} })).toBe(true);
  });
  it('allows a user with settings.communications.manage', () => {
    expect(canManageCommunications({ role: 'doctor', permissions: { 'settings.communications.manage': true } })).toBe(true);
  });
  it('denies doctor/receptionist by default', () => {
    expect(canManageCommunications({ role: 'doctor', permissions: {} })).toBe(false);
    expect(canManageCommunications({ role: 'receptionist', permissions: {} })).toBe(false);
  });
});
  • Step 2: Run it to verify it FAILS
Run: cd server && npx vitest run src/routes/__tests__/clinics-notification-guard.test.ts Expected: FAIL — canManageCommunications is not exported.
  • Step 3: Add the helper + apply it in the handler
In server/src/routes/clinics.ts, export a small pure helper near the top:
// Reception/doctor see Communications settings but cannot change them.
// Mirrors canManageLeadForms in leads.ts.
export function canManageCommunications(user: { role?: string; permissions?: Record<string, boolean> } | null | undefined): boolean {
  if (!user) return false;
  if (user.role === 'admin' || user.role === 'superadmin') return true;
  return user.permissions?.['settings.communications.manage'] === true;
}
Then in the PUT /:id handler, where body.notificationPreferences !== undefined is handled (line ~834), guard it:
    if (body.notificationPreferences !== undefined) {
      if (!canManageCommunications(c.get('user'))) {
        return c.json({ error: 'You do not have permission to change Communications settings' }, 403);
      }
      const incoming = { ...body.notificationPreferences };
      // … existing logic unchanged …
    }
(Confirm how the route reads the user — match the existing pattern in this file, e.g. c.get('user').)
  • Step 4: Run the test to verify it PASSES
Run: cd server && npx vitest run src/routes/__tests__/clinics-notification-guard.test.ts Expected: PASS.
  • Step 5: Commit
git add server/src/routes/clinics.ts server/src/routes/__tests__/clinics-notification-guard.test.ts
git commit -m "feat(server): gate clinic notificationPreferences updates behind settings.communications.manage"

PHASE D — Verification

Task D1: Full typecheck, tests, and visual pass

  • Step 1: Typecheck both packages
Run: cd ui && npx tsc --noEmit && cd ../server && npx tsc --noEmit Expected: PASS for both.
  • Step 2: Run the permission test suite
Run: cd server && npx vitest run src/lib/permissions-ui-parity.test.ts src/routes/__tests__/clinics-notification-guard.test.ts src/routes/__tests__/leads-form-permissions.test.ts Expected: PASS (parity, comms guard, existing leads-form guard all green).
  • Step 3: Build the UI
Run: cd ui && npm run build Expected: build succeeds.
  • Step 4: Visual pass (REQUIRED — tsc/build does not prove UI quality)
Run the app and, on the test tenant ssh & Associates (b6d3a3f3-…), verify by reload+screenshot or Playwright:
  1. Admin at Settings → People & Access → ?tab=templates: two-pane layout renders; role rail switches; Members in role populates; category tabs filter modules; search filters items; deep-link ?tab=templates&role=receptionist&cat=front-office restores state on reload; Save/Reset work; Admin tab is read-only.
  2. Receptionist login → Settings → Communications: WhatsApp, Notifications all render disabled with the read-only banner; the Leads inbox (main nav) still lets them convert/assign.
  3. Doctor login → Settings → Communications: identical read-only view (no longer fully editable WhatsApp).
  • Step 5: Deploy
Use the odontox-commit-deploy skill: stage explicit paths only (never git add -A), build + deploy committed HEAD, force-promote the Cloudflare canonical after the Pages deploy. Stash any unrelated working-tree files from other sessions first, pop after.

Self-Review (completed inline)

  • Spec coverage: A1–A6 cover spec §5 (single source + parity); B1–B3 cover §6 (redesigned editor, deep-links, members, categories, search, admin read-only); C1–C5 cover §7 (read-only comms, with the refinement that notification prefs live in clinics.ts and WhatsApp PUT is already admin-only). §8 reuse confirmed. §9 testing = D1. Deferred items (§3) carry no tasks by design.
  • Placeholder scan: none — every code step shows the code; the only “match the existing pattern” notes (staff fetcher in B2.3, user accessor in C5.3) are explicit reconciliation steps with grep commands, not vague TODOs.
  • Type consistency: canManage prop name used consistently (C1/C2/C3); moduleIds/searchQuery props match between B1 (definition) and B2 (usage); groupPermissions/ALL_PERMISSION_KEYS names match between A1 (export) and A3 (import); canManageCommunications signature matches between test (C5.1) and impl (C5.3).