frontend: mirror pharmacy/lab roles with area-based route gating and nav

Mirror the backend role changes (drop viewer, add pharmacy + lab and the lab
statement). Replace the binary requiresClinical gating with access areas
(clinical / pharmacy / lab) probed from Better Auth permissions —
prescription:delete marks full clinicians, prescription:write the pharmacy
area, lab:write the lab area — so pharmacy doesn't inherit the AI chat or
notes. Pharmacy and lab land on their new dashboards and get their own nav
items; reception behavior is unchanged. Includes the i18n keys for the new
pages and the messages polish that lands in the following commits.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Khalid Abdi
2026-06-11 21:03:27 +03:00
parent e37d131800
commit a4f692d59d
5 changed files with 192 additions and 45 deletions
+27 -5
View File
@@ -14,16 +14,21 @@ export const statements = {
appointment: ["read", "write", "delete"], appointment: ["read", "write", "delete"],
prescription: ["read", "write", "delete"], prescription: ["read", "write", "delete"],
task: ["read", "write", "delete"], task: ["read", "write", "delete"],
lab: ["read", "write"],
} as const; } as const;
export const ac = createAccessControl(statements); export const ac = createAccessControl(statements);
// NOTE: `prescription: ["delete"]` doubles as the "full clinician" marker the
// route gating in lib/roles.ts probes (owner/admin/doctor/member only) — don't
// grant it to department roles like pharmacy.
export const owner = ac.newRole({ export const owner = ac.newRole({
...ownerAc.statements, ...ownerAc.statements,
patient: ["read", "write", "delete"], patient: ["read", "write", "delete"],
appointment: ["read", "write", "delete"], appointment: ["read", "write", "delete"],
prescription: ["read", "write", "delete"], prescription: ["read", "write", "delete"],
task: ["read", "write", "delete"], task: ["read", "write", "delete"],
lab: ["read", "write"],
}); });
export const admin = ac.newRole({ export const admin = ac.newRole({
@@ -32,6 +37,7 @@ export const admin = ac.newRole({
appointment: ["read", "write", "delete"], appointment: ["read", "write", "delete"],
prescription: ["read", "write", "delete"], prescription: ["read", "write", "delete"],
task: ["read", "write", "delete"], task: ["read", "write", "delete"],
lab: ["read", "write"],
}); });
export const member = ac.newRole({ export const member = ac.newRole({
@@ -40,6 +46,7 @@ export const member = ac.newRole({
appointment: ["read", "write", "delete"], appointment: ["read", "write", "delete"],
prescription: ["read", "write", "delete"], prescription: ["read", "write", "delete"],
task: ["read", "write", "delete"], task: ["read", "write", "delete"],
lab: ["read", "write"],
}); });
// doctor (clinician): mirrors backend/src/lib/access.ts — same clinical access // doctor (clinician): mirrors backend/src/lib/access.ts — same clinical access
@@ -50,6 +57,7 @@ export const doctor = ac.newRole({
appointment: ["read", "write", "delete"], appointment: ["read", "write", "delete"],
prescription: ["read", "write", "delete"], prescription: ["read", "write", "delete"],
task: ["read", "write", "delete"], task: ["read", "write", "delete"],
lab: ["read", "write"],
}); });
// reception (front desk): scheduling + registration only, no clinical records. // reception (front desk): scheduling + registration only, no clinical records.
@@ -60,14 +68,27 @@ export const reception = ac.newRole({
task: ["read", "write"], task: ["read", "write"],
}); });
export const viewer = ac.newRole({ // pharmacy (dispensing): read patients/appointments, read/write prescriptions
// (status updates, NOT delete), and work the task queue.
export const pharmacy = ac.newRole({
...memberAc.statements,
patient: ["read"], patient: ["read"],
appointment: ["read"], appointment: ["read"],
prescription: ["read"], prescription: ["read", "write"],
task: ["read"], task: ["read", "write"],
}); });
export const roles = { owner, admin, doctor, reception, member, viewer }; // lab (analyses): submits lab results via the dedicated `lab` statement (no
// patient:write) and works the lab task queue. No prescription statement.
export const lab = ac.newRole({
...memberAc.statements,
patient: ["read"],
appointment: ["read"],
task: ["read", "write"],
lab: ["read", "write"],
});
export const roles = { owner, admin, doctor, reception, pharmacy, lab, member };
// Human-readable labels for the role keys used in the UI. // Human-readable labels for the role keys used in the UI.
export const ROLE_LABELS: Record<keyof typeof roles, string> = { export const ROLE_LABELS: Record<keyof typeof roles, string> = {
@@ -75,6 +96,7 @@ export const ROLE_LABELS: Record<keyof typeof roles, string> = {
admin: "Admin", admin: "Admin",
doctor: "Doctor", doctor: "Doctor",
reception: "Reception", reception: "Reception",
pharmacy: "Pharmacy",
lab: "Lab",
member: "Clinician", member: "Clinician",
viewer: "Viewer",
}; };
@@ -114,6 +114,8 @@
"appointments": "Appointments", "appointments": "Appointments",
"prescriptions": "Prescriptions", "prescriptions": "Prescriptions",
"analysis": "Analysis", "analysis": "Analysis",
"pharmacy": "Pharmacy",
"lab": "Lab",
"notes": "Notes", "notes": "Notes",
"messages": "Messages", "messages": "Messages",
"tasks": "Tasks", "tasks": "Tasks",
@@ -314,6 +316,66 @@
"addedTitle": "Prescription added" "addedTitle": "Prescription added"
} }
}, },
"pharmacy": {
"title": "Pharmacy",
"subtitle": "Dispensing queue for the clinic's prescriptions.",
"searchPlaceholder": "Search prescriptions",
"kpi": {
"active": "Active prescriptions",
"expiring": "Expiring within 7 days",
"patients": "Patients on medication"
},
"queue": {
"title": "Dispensing queue",
"description": "Active prescriptions, oldest first. Mark a course completed once dispensed.",
"empty": "No active prescriptions.",
"noMatches": "No prescriptions match your search."
},
"expiringBadge": "Expiring",
"markCompleted": "Mark completed",
"completedTitle": "Prescription completed",
"completedBody": "{{medication}} for {{name}} marked as completed.",
"completeFailedTitle": "Couldn't update prescription",
"completeFailedBody": "Please try again."
},
"lab": {
"title": "Lab",
"subtitle": "The lab's work queue and analysis results.",
"queue": {
"title": "Work queue",
"description": "Tasks assigned to the Lab department.",
"empty": "No lab tasks right now."
},
"addResult": {
"button": "Add result",
"title": "Add analysis result",
"description": "Record an analysis performed in the lab on a patient's record.",
"patient": "Patient",
"patientPlaceholder": "Search by name or file number",
"noPatients": "No patients match your search.",
"changePatient": "Change",
"test": "Test",
"testPlaceholder": "e.g. Hemoglobin",
"value": "Value",
"valuePlaceholder": "e.g. 14.2 g/dL",
"flag": "Flag",
"takenAt": "Date",
"cancel": "Cancel",
"submit": "Add result",
"needPatientTitle": "Pick a patient",
"needPatientBody": "Search and select a patient first.",
"needFieldsTitle": "Missing details",
"needFieldsBody": "Enter the test name and its value.",
"addedTitle": "Result added",
"addedBody": "{{test}} added to {{name}}'s record.",
"failedTitle": "Couldn't add the result",
"failedBody": "Please try again."
},
"toast": {
"updateFailedTitle": "Couldn't update the task",
"updateFailedBody": "Please try again."
}
},
"tasks": { "tasks": {
"title": "Tasks", "title": "Tasks",
"subtitle": "Care-team to-dos. Click a task to see its details.", "subtitle": "Care-team to-dos. Click a task to see its details.",
@@ -394,6 +456,9 @@
"send": "Send", "send": "Send",
"emptyTitle": "No conversation selected", "emptyTitle": "No conversation selected",
"emptyDescription": "Choose a conversation from the inbox, or start a new one.", "emptyDescription": "Choose a conversation from the inbox, or start a new one.",
"startConversation": "Start a conversation",
"today": "Today",
"yesterday": "Yesterday",
"compose": { "compose": {
"title": "New message", "title": "New message",
"description": "Start a conversation with a member of your clinic.", "description": "Start a conversation with a member of your clinic.",
+33 -9
View File
@@ -1,6 +1,8 @@
import { import {
BarChart3, BarChart3,
CalendarClock, CalendarClock,
Cross,
FlaskConical,
History, History,
ListTodo, ListTodo,
type LucideIcon, type LucideIcon,
@@ -12,14 +14,22 @@ import {
Users, Users,
} from "lucide-react"; } from "lucide-react";
// Access areas gate routes/nav by role capability (probed against the Better
// Auth permissions in lib/access.ts — see canAccessArea in lib/roles.ts):
// - "clinical": full clinicians only (owner/admin/doctor/member).
// - "pharmacy": pharmacy + full clinicians.
// - "lab": lab + full clinicians.
// Defined here (not roles.ts) because roles.ts imports nav.ts.
export type AccessArea = "clinical" | "pharmacy" | "lab";
export type NavSubItem = { export type NavSubItem = {
id: string; id: string;
// i18n key resolved with t() at render time. // i18n key resolved with t() at render time.
labelKey: string; labelKey: string;
icon?: LucideIcon; icon?: LucideIcon;
link: string; link: string;
// Hidden from non-clinical roles (e.g. reception). See lib/roles.ts. // Hidden from roles outside this access area. See lib/roles.ts.
requiresClinical?: boolean; access?: AccessArea;
}; };
export type NavItem = { export type NavItem = {
@@ -30,8 +40,8 @@ export type NavItem = {
link: string; link: string;
// Optional sub-pages revealed under this item in the sidebar. // Optional sub-pages revealed under this item in the sidebar.
subs?: NavSubItem[]; subs?: NavSubItem[];
// Hidden from non-clinical roles (e.g. reception). See lib/roles.ts. // Hidden from roles outside this access area. See lib/roles.ts.
requiresClinical?: boolean; access?: AccessArea;
}; };
// Single source of truth for the primary navigation. Consumed by the sidebar // Single source of truth for the primary navigation. Consumed by the sidebar
@@ -43,7 +53,7 @@ export const navItems: NavItem[] = [
labelKey: "nav.newChat", labelKey: "nav.newChat",
icon: Plus, icon: Plus,
link: "/", link: "/",
requiresClinical: true, access: "clinical",
}, },
{ {
id: "patients", id: "patients",
@@ -63,7 +73,7 @@ export const navItems: NavItem[] = [
labelKey: "nav.prescriptions", labelKey: "nav.prescriptions",
icon: Pill, icon: Pill,
link: "/prescriptions", link: "/prescriptions",
requiresClinical: true, access: "clinical",
}, },
], ],
}, },
@@ -72,7 +82,21 @@ export const navItems: NavItem[] = [
labelKey: "nav.analysis", labelKey: "nav.analysis",
icon: BarChart3, icon: BarChart3,
link: "/analysis", link: "/analysis",
requiresClinical: true, access: "clinical",
},
{
id: "pharmacy",
labelKey: "nav.pharmacy",
icon: Cross,
link: "/pharmacy",
access: "pharmacy",
},
{
id: "lab",
labelKey: "nav.lab",
icon: FlaskConical,
link: "/lab",
access: "lab",
}, },
{ id: "messages", labelKey: "nav.messages", icon: Mail, link: "/messages" }, { id: "messages", labelKey: "nav.messages", icon: Mail, link: "/messages" },
{ {
@@ -80,7 +104,7 @@ export const navItems: NavItem[] = [
labelKey: "nav.notes", labelKey: "nav.notes",
icon: NotebookPen, icon: NotebookPen,
link: "/notes", link: "/notes",
requiresClinical: true, access: "clinical",
}, },
{ id: "tasks", labelKey: "nav.tasks", icon: ListTodo, link: "/tasks" }, { id: "tasks", labelKey: "nav.tasks", icon: ListTodo, link: "/tasks" },
{ {
@@ -88,7 +112,7 @@ export const navItems: NavItem[] = [
labelKey: "nav.activity", labelKey: "nav.activity",
icon: History, icon: History,
link: "/activity", link: "/activity",
requiresClinical: true, access: "clinical",
}, },
{ id: "settings", labelKey: "nav.settings", icon: Settings, link: "/settings" }, { id: "settings", labelKey: "nav.settings", icon: Settings, link: "/settings" },
]; ];
+66 -30
View File
@@ -4,7 +4,7 @@ import { useEffect, useState } from "react";
import { type roles } from "@/lib/access"; import { type roles } from "@/lib/access";
import { authClient } from "@/lib/auth-client"; import { authClient } from "@/lib/auth-client";
import { type NavItem, navItems } from "@/lib/nav"; import { type AccessArea, type NavItem, navItems } from "@/lib/nav";
export type RoleKey = keyof typeof roles; export type RoleKey = keyof typeof roles;
@@ -14,12 +14,19 @@ export const PROVISIONABLE_ROLES: RoleKey[] = [
"admin", "admin",
"doctor", "doctor",
"reception", "reception",
"viewer", "pharmacy",
"lab",
]; ];
// Departments a task can be assigned to (member roles). Mirrors the backend's // Departments a task can be assigned to (member roles). Mirrors the backend's
// TASK_DEPARTMENTS in lib/task-validation.ts. // TASK_DEPARTMENTS in lib/task-validation.ts.
export const DEPARTMENTS = ["admin", "doctor", "reception"] as const; export const DEPARTMENTS = [
"admin",
"doctor",
"reception",
"pharmacy",
"lab",
] as const;
// The current user's role in the active clinic (null while loading or if they // The current user's role in the active clinic (null while loading or if they
// aren't a member). Re-fetches when the active organization changes. // aren't a member). Re-fetches when the active organization changes.
@@ -52,6 +59,7 @@ export const CLINICAL_RESOURCES = [
"appointment", "appointment",
"prescription", "prescription",
"task", "task",
"lab",
] as const; ] as const;
const RESOURCE_ACTIONS = ["read", "write", "delete"] as const; const RESOURCE_ACTIONS = ["read", "write", "delete"] as const;
@@ -81,37 +89,61 @@ export function rolePermissionSummary(
}); });
} }
// Whether a role may see clinical records (AI lookup, prescriptions, notes, // Permission probes per access area, all derived from lib/access.ts so route
// analysis). Driven by Better Auth permissions so it stays in lock-step with // gating can never drift from RBAC:
// lib/access.ts: the `reception` role has no `prescription` statement, so this // - clinical: `prescription:delete` is held ONLY by full clinicians
// is false for them and true for every clinical role. // (owner/admin/doctor/member) — pharmacy has read/write but not delete,
export function hasClinicalAccess(role: string | null | undefined): boolean { // reception/lab no prescription statement at all.
// - pharmacy: `prescription:write` (pharmacy + full clinicians).
// - lab: `lab:write` (lab + full clinicians).
const AREA_PROBES: Record<AccessArea, PermissionArg> = {
clinical: { prescription: ["delete"] } as PermissionArg,
pharmacy: { prescription: ["write"] } as PermissionArg,
lab: { lab: ["write"] } as PermissionArg,
};
// Whether a role belongs to an access area (see AccessArea in lib/nav.ts).
export function canAccessArea(
role: string | null | undefined,
area: AccessArea,
): boolean {
if (!role) return false; if (!role) return false;
try { try {
return authClient.organization.checkRolePermission({ return authClient.organization.checkRolePermission({
role: role as RoleKey, role: role as RoleKey,
permissions: { prescription: ["read"] }, permissions: AREA_PROBES[area],
}); });
} catch { } catch {
return false; return false;
} }
} }
// Where a role lands after sign-in. Reception has no AI chat, so they start on // Whether a role may see clinical records (AI lookup, prescriptions, notes,
// the appointments board; clinical roles start on the chat home. // analysis) — true for full clinicians (owner/admin/doctor/member) only.
export function defaultLandingFor(role: string | null | undefined): string { export function hasClinicalAccess(role: string | null | undefined): boolean {
return hasClinicalAccess(role) ? "/" : "/appointments"; return canAccessArea(role, "clinical");
} }
// Clinical-only routes — a non-clinical role (reception) is redirected away. // Where a role lands after sign-in: clinicians on the chat home, pharmacy/lab
// Keyed by path; "/" matches exactly, others match themselves + nested paths. // on their department dashboards, reception on the appointments board.
const CLINICAL_ROUTES = [ export function defaultLandingFor(role: string | null | undefined): string {
"/", if (canAccessArea(role, "clinical")) return "/";
"/prescriptions", if (canAccessArea(role, "pharmacy")) return "/pharmacy";
"/analysis", if (canAccessArea(role, "lab")) return "/lab";
"/notes", return "/appointments";
"/activity", }
];
// Area-gated routes — a role outside the area is redirected away. Keyed by
// path; "/" matches exactly, others match themselves + nested paths.
const ROUTE_AREAS: Record<string, AccessArea> = {
"/": "clinical",
"/prescriptions": "clinical",
"/analysis": "clinical",
"/notes": "clinical",
"/activity": "clinical",
"/pharmacy": "pharmacy",
"/lab": "lab",
};
// Whether `path` is reachable by `role`. Returns true while the role is still // Whether `path` is reachable by `role`. Returns true while the role is still
// loading to avoid redirect flicker; the authoritative check is the backend's // loading to avoid redirect flicker; the authoritative check is the backend's
@@ -121,22 +153,26 @@ export function canAccessRoute(
role: string | null | undefined, role: string | null | undefined,
): boolean { ): boolean {
if (role == null) return true; if (role == null) return true;
if (hasClinicalAccess(role)) return true; const match = Object.entries(ROUTE_AREAS).find(([route]) =>
return !CLINICAL_ROUTES.some((r) => route === "/" ? path === "/" : path === route || path.startsWith(`${route}/`),
r === "/" ? path === "/" : path === r || path.startsWith(`${r}/`),
); );
if (!match) return true;
return canAccessArea(role, match[1]);
} }
// Nav items visible to a role, with clinical-only items (and sub-items) removed // Nav items visible to a role, with area-gated items (and sub-items) removed
// for non-clinical roles. While the role is loading we optimistically show // for roles outside the area. While the role is loading we optimistically show
// everything (clinical users are the common case) — the flash is sub-second. // everything (clinical users are the common case) — the flash is sub-second.
// Full clinicians pass every area probe, so they also see the Pharmacy and Lab
// items (intentional oversight access).
export function visibleNavItems(role: string | null | undefined): NavItem[] { export function visibleNavItems(role: string | null | undefined): NavItem[] {
if (role == null) return navItems; if (role == null) return navItems;
const clinical = hasClinicalAccess(role);
return navItems return navItems
.filter((item) => !item.requiresClinical || clinical) .filter((item) => !item.access || canAccessArea(role, item.access))
.map((item) => ({ .map((item) => ({
...item, ...item,
subs: item.subs?.filter((sub) => !sub.requiresClinical || clinical), subs: item.subs?.filter(
(sub) => !sub.access || canAccessArea(role, sub.access),
),
})); }));
} }
+1 -1
View File
@@ -2,7 +2,7 @@ import { apiFetch } from "@/lib/api-client";
// A clinician who can be assigned as a patient's primary provider. Returned by // A clinician who can be assigned as a patient's primary provider. Returned by
// the backend's GET /api/staff/providers (clinical roles only — excludes // the backend's GET /api/staff/providers (clinical roles only — excludes
// reception/viewer). Readable by any clinic member. // reception/pharmacy/lab). Readable by any clinic member.
export type Provider = { export type Provider = {
userId: string; userId: string;
name: string; name: string;