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"],
prescription: ["read", "write", "delete"],
task: ["read", "write", "delete"],
lab: ["read", "write"],
} as const;
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({
...ownerAc.statements,
patient: ["read", "write", "delete"],
appointment: ["read", "write", "delete"],
prescription: ["read", "write", "delete"],
task: ["read", "write", "delete"],
lab: ["read", "write"],
});
export const admin = ac.newRole({
@@ -32,6 +37,7 @@ export const admin = ac.newRole({
appointment: ["read", "write", "delete"],
prescription: ["read", "write", "delete"],
task: ["read", "write", "delete"],
lab: ["read", "write"],
});
export const member = ac.newRole({
@@ -40,6 +46,7 @@ export const member = ac.newRole({
appointment: ["read", "write", "delete"],
prescription: ["read", "write", "delete"],
task: ["read", "write", "delete"],
lab: ["read", "write"],
});
// doctor (clinician): mirrors backend/src/lib/access.ts — same clinical access
@@ -50,6 +57,7 @@ export const doctor = ac.newRole({
appointment: ["read", "write", "delete"],
prescription: ["read", "write", "delete"],
task: ["read", "write", "delete"],
lab: ["read", "write"],
});
// reception (front desk): scheduling + registration only, no clinical records.
@@ -60,14 +68,27 @@ export const reception = ac.newRole({
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"],
appointment: ["read"],
prescription: ["read"],
task: ["read"],
prescription: ["read", "write"],
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.
export const ROLE_LABELS: Record<keyof typeof roles, string> = {
@@ -75,6 +96,7 @@ export const ROLE_LABELS: Record<keyof typeof roles, string> = {
admin: "Admin",
doctor: "Doctor",
reception: "Reception",
pharmacy: "Pharmacy",
lab: "Lab",
member: "Clinician",
viewer: "Viewer",
};
@@ -114,6 +114,8 @@
"appointments": "Appointments",
"prescriptions": "Prescriptions",
"analysis": "Analysis",
"pharmacy": "Pharmacy",
"lab": "Lab",
"notes": "Notes",
"messages": "Messages",
"tasks": "Tasks",
@@ -314,6 +316,66 @@
"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": {
"title": "Tasks",
"subtitle": "Care-team to-dos. Click a task to see its details.",
@@ -394,6 +456,9 @@
"send": "Send",
"emptyTitle": "No conversation selected",
"emptyDescription": "Choose a conversation from the inbox, or start a new one.",
"startConversation": "Start a conversation",
"today": "Today",
"yesterday": "Yesterday",
"compose": {
"title": "New message",
"description": "Start a conversation with a member of your clinic.",
+33 -9
View File
@@ -1,6 +1,8 @@
import {
BarChart3,
CalendarClock,
Cross,
FlaskConical,
History,
ListTodo,
type LucideIcon,
@@ -12,14 +14,22 @@ import {
Users,
} 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 = {
id: string;
// i18n key resolved with t() at render time.
labelKey: string;
icon?: LucideIcon;
link: string;
// Hidden from non-clinical roles (e.g. reception). See lib/roles.ts.
requiresClinical?: boolean;
// Hidden from roles outside this access area. See lib/roles.ts.
access?: AccessArea;
};
export type NavItem = {
@@ -30,8 +40,8 @@ export type NavItem = {
link: string;
// Optional sub-pages revealed under this item in the sidebar.
subs?: NavSubItem[];
// Hidden from non-clinical roles (e.g. reception). See lib/roles.ts.
requiresClinical?: boolean;
// Hidden from roles outside this access area. See lib/roles.ts.
access?: AccessArea;
};
// Single source of truth for the primary navigation. Consumed by the sidebar
@@ -43,7 +53,7 @@ export const navItems: NavItem[] = [
labelKey: "nav.newChat",
icon: Plus,
link: "/",
requiresClinical: true,
access: "clinical",
},
{
id: "patients",
@@ -63,7 +73,7 @@ export const navItems: NavItem[] = [
labelKey: "nav.prescriptions",
icon: Pill,
link: "/prescriptions",
requiresClinical: true,
access: "clinical",
},
],
},
@@ -72,7 +82,21 @@ export const navItems: NavItem[] = [
labelKey: "nav.analysis",
icon: BarChart3,
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" },
{
@@ -80,7 +104,7 @@ export const navItems: NavItem[] = [
labelKey: "nav.notes",
icon: NotebookPen,
link: "/notes",
requiresClinical: true,
access: "clinical",
},
{ id: "tasks", labelKey: "nav.tasks", icon: ListTodo, link: "/tasks" },
{
@@ -88,7 +112,7 @@ export const navItems: NavItem[] = [
labelKey: "nav.activity",
icon: History,
link: "/activity",
requiresClinical: true,
access: "clinical",
},
{ 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 { 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;
@@ -14,12 +14,19 @@ export const PROVISIONABLE_ROLES: RoleKey[] = [
"admin",
"doctor",
"reception",
"viewer",
"pharmacy",
"lab",
];
// Departments a task can be assigned to (member roles). Mirrors the backend's
// 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
// aren't a member). Re-fetches when the active organization changes.
@@ -52,6 +59,7 @@ export const CLINICAL_RESOURCES = [
"appointment",
"prescription",
"task",
"lab",
] 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,
// analysis). Driven by Better Auth permissions so it stays in lock-step with
// lib/access.ts: the `reception` role has no `prescription` statement, so this
// is false for them and true for every clinical role.
export function hasClinicalAccess(role: string | null | undefined): boolean {
// Permission probes per access area, all derived from lib/access.ts so route
// gating can never drift from RBAC:
// - clinical: `prescription:delete` is held ONLY by full clinicians
// (owner/admin/doctor/member) — pharmacy has read/write but not delete,
// 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;
try {
return authClient.organization.checkRolePermission({
role: role as RoleKey,
permissions: { prescription: ["read"] },
permissions: AREA_PROBES[area],
});
} catch {
return false;
}
}
// Where a role lands after sign-in. Reception has no AI chat, so they start on
// the appointments board; clinical roles start on the chat home.
export function defaultLandingFor(role: string | null | undefined): string {
return hasClinicalAccess(role) ? "/" : "/appointments";
// Whether a role may see clinical records (AI lookup, prescriptions, notes,
// analysis) — true for full clinicians (owner/admin/doctor/member) only.
export function hasClinicalAccess(role: string | null | undefined): boolean {
return canAccessArea(role, "clinical");
}
// Clinical-only routes — a non-clinical role (reception) is redirected away.
// Keyed by path; "/" matches exactly, others match themselves + nested paths.
const CLINICAL_ROUTES = [
"/",
"/prescriptions",
"/analysis",
"/notes",
"/activity",
];
// Where a role lands after sign-in: clinicians on the chat home, pharmacy/lab
// on their department dashboards, reception on the appointments board.
export function defaultLandingFor(role: string | null | undefined): string {
if (canAccessArea(role, "clinical")) return "/";
if (canAccessArea(role, "pharmacy")) return "/pharmacy";
if (canAccessArea(role, "lab")) return "/lab";
return "/appointments";
}
// 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
// loading to avoid redirect flicker; the authoritative check is the backend's
@@ -121,22 +153,26 @@ export function canAccessRoute(
role: string | null | undefined,
): boolean {
if (role == null) return true;
if (hasClinicalAccess(role)) return true;
return !CLINICAL_ROUTES.some((r) =>
r === "/" ? path === "/" : path === r || path.startsWith(`${r}/`),
const match = Object.entries(ROUTE_AREAS).find(([route]) =>
route === "/" ? path === "/" : path === route || path.startsWith(`${route}/`),
);
if (!match) return true;
return canAccessArea(role, match[1]);
}
// Nav items visible to a role, with clinical-only items (and sub-items) removed
// for non-clinical roles. While the role is loading we optimistically show
// Nav items visible to a role, with area-gated items (and sub-items) removed
// 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.
// 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[] {
if (role == null) return navItems;
const clinical = hasClinicalAccess(role);
return navItems
.filter((item) => !item.requiresClinical || clinical)
.filter((item) => !item.access || canAccessArea(role, item.access))
.map((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
// 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 = {
userId: string;
name: string;