diff --git a/src/common/Permissions.tsx b/src/common/Permissions.tsx index ad2f6c3658d..354cfbd4fc6 100644 --- a/src/common/Permissions.tsx +++ b/src/common/Permissions.tsx @@ -12,6 +12,8 @@ export const PERMISSION_SUBMIT_PATIENT_QUESTIONNAIRE = export const PERMISSION_CREATE_ENCOUNTER = "can_create_encounter"; export const PERMISSION_LIST_ENCOUNTERS = "can_list_encounter"; export const PERMISSION_WRITE_ENCOUNTER = "can_write_encounter"; +export const PERMISSION_WRITE_ENCOUNTER_CLINICAL_DATA = + "can_write_encounter_clinical_data"; export const PERMISSION_READ_ENCOUNTER = "can_read_encounter"; export const PERMISSION_READ_ENCOUNTER_CLINICAL_DATA = "can_read_encounter_clinical_data"; @@ -138,6 +140,8 @@ export interface Permissions { canListEncounters: boolean; /** Permission slug: "can_write_encounter" */ canWriteEncounter: boolean; + /** Permission slug: "can_write_encounter_clinical_data" */ + canWriteEncounterClinicalData: boolean; /** Permission slug: "can_read_encounter" */ canReadEncounter: boolean; /** Permission slug: "can_read_encounter_clinical_data" */ @@ -310,6 +314,10 @@ export function getPermissions( canCreateEncounter: hasPermission(PERMISSION_CREATE_ENCOUNTER, permissions), canListEncounters: hasPermission(PERMISSION_LIST_ENCOUNTERS, permissions), canWriteEncounter: hasPermission(PERMISSION_WRITE_ENCOUNTER, permissions), + canWriteEncounterClinicalData: hasPermission( + PERMISSION_WRITE_ENCOUNTER_CLINICAL_DATA, + permissions, + ), canReadEncounter: hasPermission(PERMISSION_READ_ENCOUNTER, permissions), canReadEncounterClinicalData: hasPermission( PERMISSION_READ_ENCOUNTER_CLINICAL_DATA, diff --git a/src/components/Notes/NoteManager.tsx b/src/components/Notes/NoteManager.tsx index 1ba249b0203..ed2fd15b877 100644 --- a/src/components/Notes/NoteManager.tsx +++ b/src/components/Notes/NoteManager.tsx @@ -452,6 +452,9 @@ export function NoteManager({ }, [messagesData]); const handleCreateThread = (title: string) => { + if (!canWrite) { + return; + } if (title.trim()) { if ( threadsData?.results.some((thread) => thread.title === title.trim()) @@ -470,7 +473,10 @@ export function NoteManager({ e.preventDefault(); e.stopPropagation(); const canSend = - newMessage.trim() && selectedThread && !createMessageMutation.isPending; + canWrite && + newMessage.trim() && + selectedThread && + !createMessageMutation.isPending; if (canSend) { createMessageMutation.mutate({ message: newMessage.trim() }); } @@ -551,18 +557,20 @@ export function NoteManager({ {t("notes__all_discussions")} - + {canWrite && ( + + )} @@ -623,7 +631,9 @@ export function NoteManager({ ) : (
- {t("notes__select_create_thread")} + {canWrite + ? t("notes__select_create_thread") + : t("notes__no_discussions")}
)} @@ -748,14 +758,15 @@ export function NoteManager({

{t("notes__welcome_description")}

- + {canWrite && ( + + )} )} @@ -769,13 +780,15 @@ export function NoteManager({ canWrite={canWrite} /> - setShowNewThreadDialog(false)} - onCreate={handleCreateThread} - isCreating={createThreadMutation.isPending} - threadsUnused={threads} - /> + {canWrite && ( + setShowNewThreadDialog(false)} + onCreate={handleCreateThread} + isCreating={createThreadMutation.isPending} + threadsUnused={threads} + /> + )} ); } diff --git a/src/components/Patient/PatientDetailsTab/PatientNotes.tsx b/src/components/Patient/PatientDetailsTab/PatientNotes.tsx index 966442e00ad..223a896a843 100644 --- a/src/components/Patient/PatientDetailsTab/PatientNotes.tsx +++ b/src/components/Patient/PatientDetailsTab/PatientNotes.tsx @@ -1,13 +1,23 @@ import { NoteManager } from "@/components/Notes/NoteManager"; +import { getPermissions } from "@/common/Permissions"; + +import { usePermissions } from "@/context/PermissionContext"; + import { PatientProps } from "."; export const PatientNotesTab = (props: PatientProps) => { + const { hasPermission } = usePermissions(); + const { canViewClinicalData, canWritePatient } = getPermissions( + hasPermission, + props.patientData.permissions, + ); + return (
{ const { selectedEncounterId: encounterId, + selectedEncounter, canWriteSelectedEncounter, - canReadSelectedEncounter, + canReadClinicalData, patientId, } = useEncounter(); + const { hasPermission } = usePermissions(); + const { canWriteEncounterClinicalData } = getPermissions( + hasPermission, + selectedEncounter?.permissions ?? [], + ); + + const canAccess = canReadClinicalData; + const canWrite = + canAccess && canWriteSelectedEncounter && canWriteEncounterClinicalData; return (
diff --git a/tests/facility/patient/notes/notesPermission.spec.ts b/tests/facility/patient/notes/notesPermission.spec.ts new file mode 100644 index 00000000000..cf4e04ec875 --- /dev/null +++ b/tests/facility/patient/notes/notesPermission.spec.ts @@ -0,0 +1,176 @@ +import { expect, type Page, test } from "@playwright/test"; +import { getEncounterId } from "tests/support/encounterId"; +import { getFacilityId } from "tests/support/facilityId"; +import { getPatientId } from "tests/support/patientId"; + +/** + * Patient notes: view = can_view_clinical_data, write = can_write_patient. + * Encounter notes: view = clinical-data read, write = can_write_encounter_clinical_data. + * + * Write without view hides the Notes tab. View without write keeps the tab + * (and existing threads) but must not show create or send controls. + * + * Object permissions are stubbed on the patient/encounter GET payloads so the + * cases do not depend on a dedicated role. Nurse is used because admin is a + * superuser and bypasses object-level checks. + */ + +test.use({ + storageState: "tests/.auth/nurse.json", + viewport: { width: 1536, height: 900 }, +}); + +const PATIENT_DETAIL = /\/api\/v1\/patient\/[0-9a-fA-F-]{36}\/?(\?|$)/; +const ENCOUNTER_DETAIL = /\/api\/v1\/encounter\/[0-9a-fA-F-]{36}\/?(\?|$)/; + +function withoutPermission(permissions: string[], slug: string) { + return permissions.filter((permission) => permission !== slug); +} + +async function stubObjectPermissions( + page: Page, + options: { + patient?: (permissions: string[]) => string[]; + encounter?: (permissions: string[]) => string[]; + }, +) { + if (options.patient) { + await page.route(PATIENT_DETAIL, async (route) => { + if (route.request().method() !== "GET") { + await route.continue(); + return; + } + const response = await route.fetch(); + const json = await response.json(); + if (Array.isArray(json.permissions)) { + json.permissions = options.patient!(json.permissions); + } + await route.fulfill({ response, json }); + }); + } + + if (options.encounter) { + await page.route(ENCOUNTER_DETAIL, async (route) => { + if (route.request().method() !== "GET") { + await route.continue(); + return; + } + const response = await route.fetch(); + const json = await response.json(); + if (Array.isArray(json.permissions)) { + json.permissions = options.encounter!(json.permissions); + } + await route.fulfill({ response, json }); + }); + } +} + +function notesUrls() { + const facilityId = getFacilityId(); + const patientId = getPatientId(); + const encounterId = getEncounterId(); + return { + encounter: `/facility/${facilityId}/patient/${patientId}/encounter/${encounterId}/notes`, + patient: `/facility/${facilityId}/patient/${patientId}/notes`, + }; +} + +async function expectCreateNotesHidden(page: Page) { + await expect( + page.getByRole("button", { name: "New", exact: true }), + ).toHaveCount(0); + await expect( + page.getByRole("button", { name: "Start New Discussion" }), + ).toHaveCount(0); + await expect(page.getByPlaceholder("Type your message...")).toHaveCount(0); +} + +async function expectCreateNotesAvailable(page: Page) { + const newButton = page.getByRole("button", { name: "New", exact: true }); + const startButton = page.getByRole("button", { + name: "Start New Discussion", + }); + await expect(newButton.or(startButton).first()).toBeVisible(); +} + +async function waitForNotesSurface(page: Page) { + await expect( + page + .getByRole("heading", { name: "Discussions" }) + .or(page.getByRole("heading", { name: "Welcome to Discussions" })), + ).toBeVisible(); +} + +test.describe("Notes create-button permissions", () => { + test("nurse with write access can create notes on encounter and patient pages", async ({ + page, + }) => { + const urls = notesUrls(); + + await page.goto(urls.encounter); + await expect(page.getByRole("tab", { name: "Notes" })).toHaveAttribute( + "data-state", + "active", + ); + await waitForNotesSurface(page); + await expectCreateNotesAvailable(page); + + await page.goto(urls.patient); + await expect(page.getByRole("tab", { name: "Notes" })).toHaveAttribute( + "aria-selected", + "true", + ); + await waitForNotesSurface(page); + await expectCreateNotesAvailable(page); + }); + + test("hides create actions when the user can view but not write", async ({ + page, + }) => { + await stubObjectPermissions(page, { + patient: (permissions) => + withoutPermission(permissions, "can_write_patient"), + encounter: (permissions) => + withoutPermission(permissions, "can_write_encounter_clinical_data"), + }); + + const urls = notesUrls(); + + await page.goto(urls.encounter); + await expect(page.getByRole("tab", { name: "Notes" })).toHaveAttribute( + "data-state", + "active", + ); + await waitForNotesSurface(page); + await expectCreateNotesHidden(page); + + await page.goto(urls.patient); + await expect(page.getByRole("tab", { name: "Notes" })).toHaveAttribute( + "aria-selected", + "true", + ); + await waitForNotesSurface(page); + await expectCreateNotesHidden(page); + }); + + test("hides the Notes tab when the user can write but not view", async ({ + page, + }) => { + await stubObjectPermissions(page, { + patient: (permissions) => + withoutPermission(permissions, "can_view_clinical_data"), + encounter: (permissions) => + withoutPermission(permissions, "can_read_encounter_clinical_data"), + }); + + const urls = notesUrls(); + + await page.goto(urls.encounter); + await expect(page.getByRole("tab", { name: "Notes" })).toHaveCount(0); + await expectCreateNotesHidden(page); + + await page.goto(urls.patient); + await expect(page.getByRole("tab", { name: "Notes" })).toHaveCount(0); + await expectCreateNotesHidden(page); + }); +});