diff --git a/.example.env b/.example.env index faac594f343..57727058adc 100644 --- a/.example.env +++ b/.example.env @@ -144,6 +144,33 @@ REACT_CUSTOM_REMOTE_I18N_URL= # ]' REACT_CUSTOM_SHORTCUTS= +# Custom links inserted into the left navbar. +# Each link can have: title, url, icon (optional), openInNewTab (optional), +# placement (optional) +# url: an internal app path ("/...") or an absolute http(s) URL. Absolute http(s) +# URLs are treated as external automatically and open in a new tab by default; +# set openInNewTab to override. Other schemes (javascript:, data:, //host) are rejected. +# placement controls which sidebar contexts show the link: +# facility | organization | location | service | admin | patient | all (default: ["all"]) +# Available icons: Book, BookOpen, Box, Building2, Calendar, Database, ExternalLink, +# FileText, Globe, HelpCircle, Link, Settings, Stethoscope, Users +# Example: +# REACT_CUSTOM_NAV_LINKS='[ +# { +# "title": "Documentation", +# "url": "https://care-be-docs.ohc.network", +# "icon": "BookOpen", +# "placement": ["all"] +# }, +# { +# "title": "Facility Handbook", +# "url": "https://example.com/handbook", +# "icon": "FileText", +# "placement": ["facility"] +# } +# ]' +REACT_CUSTOM_NAV_LINKS= + # Set to "true" to enable automatic invoice creation after dispensing REACT_ENABLE_AUTO_INVOICE_AFTER_DISPENSE=false diff --git a/care.config.ts b/care.config.ts index 4fdf3a40e1b..2b809bc3b85 100644 --- a/care.config.ts +++ b/care.config.ts @@ -305,6 +305,15 @@ const careConfig = { customShortcuts: env.REACT_CUSTOM_SHORTCUTS ? JSON.parse(env.REACT_CUSTOM_SHORTCUTS) : [], + /** + * Custom navigation links injected into the left sidebar from environment. + * Format: JSON string with an array of CustomNavLink objects. + * Each link may declare placement (which nav contexts it appears in) and + * openInNewTab. Absolute http(s) URLs are treated as external automatically. + */ + customNavLinks: env.REACT_CUSTOM_NAV_LINKS + ? JSON.parse(env.REACT_CUSTOM_NAV_LINKS) + : [], /** * System identifier for patient phone number configuration */ diff --git a/public/locale/en.json b/public/locale/en.json index 2f2b67d9109..5c3cab64a29 100644 --- a/public/locale/en.json +++ b/public/locale/en.json @@ -4064,6 +4064,7 @@ "open_pdf": "Open PDF", "open_queue_board": "Open queue board", "open_sidebar": "Open sidebar", + "opens_in_new_tab": "Opens in new tab", "operate": "Operate", "operation": "Operation", "operational_status": "Operational Status", diff --git a/scripts/validate-env.ts b/scripts/validate-env.ts index 83b5bb74002..db907559973 100644 --- a/scripts/validate-env.ts +++ b/scripts/validate-env.ts @@ -4,6 +4,10 @@ import { ENCOUNTER_DISCHARGE_DISPOSITION, EncounterDischargeDisposition, } from "../src/types/emr/encounter/encounter"; +// eslint-disable-next-line no-relative-import-paths/no-relative-import-paths +import { customNavLinksSchema } from "../src/types/nav/customNavLink"; +// eslint-disable-next-line no-relative-import-paths/no-relative-import-paths +import { isSafeNavUrl } from "../src/Utils/url"; import { z } from "zod"; @@ -54,6 +58,22 @@ const customShortcutsSchemaString = jsonAsStringSchema .transform((val) => JSON.parse(val)) .pipe(customShortcutSchema); +const customNavLinksSchemaString = jsonAsStringSchema + .transform((val) => JSON.parse(val)) + .pipe(customNavLinksSchema) + .superRefine((links, ctx) => { + links.forEach((link, index) => { + if (!isSafeNavUrl(link.url)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + "url must be an internal app path starting with / (and not //) or an absolute http(s) URL", + path: [index, "url"], + }); + } + }); + }); + const VALID_ROUNDING_METHODS = [ "ROUND_UP", "ROUND_DOWN", @@ -157,6 +177,7 @@ const envSchema = z REACT_PATIENT_REGISTRATION_DEFAULT_GEO_ORG: z.string().uuid().optional(), REACT_CUSTOM_REMOTE_I18N_URL: z.string().url().optional(), REACT_CUSTOM_SHORTCUTS: customShortcutsSchemaString.optional(), + REACT_CUSTOM_NAV_LINKS: customNavLinksSchemaString.optional(), REACT_AUTO_REFRESH_INTERVAL: numberAsString.optional(), REACT_AUTO_REFRESH_BY_DEFAULT: booleanAsStringSchema.optional(), REACT_APP_UPDATE_CHECK_INTERVAL: numberAsString.optional(), diff --git a/src/Utils/url.ts b/src/Utils/url.ts new file mode 100644 index 00000000000..7300c1d5424 --- /dev/null +++ b/src/Utils/url.ts @@ -0,0 +1,36 @@ +/** + * URL-safety predicates. + * + * This module is intentionally dependency-free so it can be reused by both the + * app runtime and build-time scripts (e.g. scripts/validate-env.ts) without + * pulling in the heavier @/Utils/utils dependency graph. + */ + +/** + * Checks whether a URL is a safe external link. + * Only absolute http(s) URLs are allowed; anything else (e.g. javascript:, + * data:) is rejected to avoid script injection via configuration. + */ +export const isSafeExternalUrl = (url: string): boolean => { + try { + const protocol = new URL(url).protocol; + return protocol === "http:" || protocol === "https:"; + } catch { + return false; + } +}; + +/** + * Checks whether a URL is an internal app path ("/..." but not a + * protocol-relative "//..." URL, which resolves to an external origin). + */ +export const isInternalNavPath = (url: string): boolean => + url.startsWith("/") && !url.startsWith("//"); + +/** + * Checks whether a URL is safe to render as an anchor-based nav link. + * Allows internal app paths ("/..." but not protocol-relative "//...") and + * absolute http(s) URLs; rejects javascript:, data:, and other schemes. + */ +export const isSafeNavUrl = (url: string): boolean => + isInternalNavPath(url) || isSafeExternalUrl(url); diff --git a/src/components/ui/sidebar/app-sidebar.tsx b/src/components/ui/sidebar/app-sidebar.tsx index 31bc6882181..1269591a1e7 100644 --- a/src/components/ui/sidebar/app-sidebar.tsx +++ b/src/components/ui/sidebar/app-sidebar.tsx @@ -15,6 +15,7 @@ import { useSidebar, } from "@/components/ui/sidebar"; import { AdminNav } from "@/components/ui/sidebar/admin-nav"; +import { CustomNavLinks } from "@/components/ui/sidebar/custom-nav-links"; import { FacilityNav } from "@/components/ui/sidebar/facility/facility-nav"; import { FacilitySwitcher } from "@/components/ui/sidebar/facility/facility-switcher"; import { LocationNav } from "@/components/ui/sidebar/facility/location/location-nav"; @@ -37,6 +38,7 @@ import { ServiceSwitcher } from "./facility/service/service-switcher"; import PinPageDialog from "@/components/Common/PinPageDialog"; import { FacilityBareMinimum } from "@/types/facility/facility"; +import type { NavScope } from "@/types/nav/customNavLink"; import { CurrentUserRead } from "@/types/user/user"; interface AppSidebarProps extends React.ComponentProps { @@ -96,6 +98,16 @@ export function AppSidebar({ return user.organizations.find((org) => org.id === organizationId); }, [user?.organizations, organizationId]); + const navScope: NavScope = (() => { + if (facilityLocationSidebar) return "location"; + if (facilityServiceSidebar) return "service"; + if (facilitySidebar) return "facility"; + if (adminSidebar) return "admin"; + if (patientSidebar) return "patient"; + if (selectedOrganization) return "organization"; + return "all"; + })(); + React.useEffect(() => { if (!user?.facilities || !facilityId || !facilitySidebar) { setSelectedFacility(null); @@ -183,6 +195,7 @@ export function AppSidebar({ )} {patientSidebar && } {adminSidebar && } + {(facilitySidebar || facilityLocationSidebar || facilityServiceSidebar || diff --git a/src/components/ui/sidebar/custom-nav-links.tsx b/src/components/ui/sidebar/custom-nav-links.tsx new file mode 100644 index 00000000000..80c834795e3 --- /dev/null +++ b/src/components/ui/sidebar/custom-nav-links.tsx @@ -0,0 +1,20 @@ +import { NavMain } from "@/components/ui/sidebar/nav-main"; + +import { useCustomNavLinks } from "@/hooks/useCustomNavLinks"; + +import type { NavScope } from "@/types/nav/customNavLink"; + +/** + * Renders configuration- and plugin-provided custom links for the given sidebar + * scope. Returns nothing when no links apply, so it is safe to mount in every + * sidebar context. + */ +export function CustomNavLinks({ scope }: { scope: NavScope }) { + const links = useCustomNavLinks(scope); + + if (links.length === 0) { + return null; + } + + return ; +} diff --git a/src/components/ui/sidebar/nav-main.tsx b/src/components/ui/sidebar/nav-main.tsx index f4e5ba2d448..e1464c448b2 100644 --- a/src/components/ui/sidebar/nav-main.tsx +++ b/src/components/ui/sidebar/nav-main.tsx @@ -1,3 +1,4 @@ +import { t } from "i18next"; import { useAtom } from "jotai"; import { ChevronRight } from "lucide-react"; import { ActiveLink, useFullPath } from "raviger"; @@ -29,6 +30,8 @@ import { import { Avatar } from "@/components/Common/Avatar"; +import { isSafeExternalUrl, isSafeNavUrl } from "@/Utils/url"; + const isChildActive = (link: NavigationLink) => { if (!link.children) return false; const currentPath = window.location.pathname; @@ -51,9 +54,59 @@ export interface NavigationLink { url: string; icon?: ReactNode; visibility?: boolean; + openInNewTab?: boolean; // Open in a new tab; defaults to true for absolute http(s) URLs. children?: NavigationLink[]; } +const isRenderableNavLink = (link: NavigationLink) => isSafeNavUrl(link.url); + +/** + * Renders a nav destination as a raviger `ActiveLink` (internal, same-tab) or a + * plain anchor (absolute http(s) URLs, or links opening in a new tab). Returns a + * DOM element directly so it can be used as the child of an `asChild` button. + */ +function renderNavLink( + link: NavigationLink, + children: ReactNode, + opts: { + className?: string; + activeClass?: string; + exactActiveClass?: string; + onClick?: () => void; + } = {}, +) { + const isExternal = isSafeExternalUrl(link.url); + if (isExternal || link.openInNewTab) { + const openInNewTab = link.openInNewTab ?? isExternal; + return ( + + {children} + {openInNewTab && ( + {t("opens_in_new_tab")} + )} + + ); + } + + return ( + + {children} + + ); +} + export function NavMain({ links }: { links: NavigationLink[] }) { const { state } = useSidebar(); const isCollapsed = state === "collapsed"; @@ -76,6 +129,7 @@ export function NavMain({ links }: { links: NavigationLink[] }) { {links .filter((link) => link.visibility !== false) + .filter(isRenderableNavLink) .map((link) => ( {link.children ? ( @@ -93,24 +147,27 @@ export function NavMain({ links }: { links: NavigationLink[] }) { "text-gray-600 transition font-normal hover:bg-gray-200 hover:text-green-700" } > - - {link.icon ? ( - link.icon - ) : ( - - )} - - - {link.name} - - + {renderNavLink( + link, + <> + {link.icon ? ( + link.icon + ) : ( + + )} + + + {link.name} + + , + { + activeClass: "bg-white text-green-700 shadow-sm", + exactActiveClass: "bg-white text-green-700 shadow-sm", + }, + )} )} @@ -158,6 +215,7 @@ function CollapsibleNavItem({ {link.children ?.filter((link) => link.visibility !== false) + .filter(isRenderableNavLink) .map((subItem) => ( {subItem.header && ( @@ -175,19 +233,16 @@ function CollapsibleNavItem({ "text-gray-600 transition font-normal hover:bg-gray-200 hover:text-green-700" } > - fullPathMap[part]) && "bg-white text-green-700 shadow", - )} - exactActiveClass="bg-white text-green-700 shadow" - > - {subItem.name} - + ), + exactActiveClass: "bg-white text-green-700 shadow", + })} @@ -228,17 +283,16 @@ function PopoverMenu({ link }: { link: NavigationLink }) { onCloseAutoFocus={(e) => e.preventDefault()} >
- {link.children?.map((subItem) => ( - setOpen(false)} - className="w-full rounded-md px-2 py-1.5 text-sm outline-none transition-colors hover:bg-gray-100 focus:bg-gray-100" - activeClass="bg-gray-100 text-green-700" - exactActiveClass="bg-gray-100 text-green-700" - > - {subItem.name} - + {link.children?.filter(isRenderableNavLink).map((subItem) => ( + + {renderNavLink(subItem, subItem.name, { + className: + "w-full rounded-md px-2 py-1.5 text-sm outline-none transition-colors hover:bg-gray-100 focus:bg-gray-100", + activeClass: "bg-gray-100 text-green-700", + exactActiveClass: "bg-gray-100 text-green-700", + onClick: () => setOpen(false), + })} + ))}
diff --git a/src/hooks/useCustomNavLinks.tsx b/src/hooks/useCustomNavLinks.tsx new file mode 100644 index 00000000000..7c1fb64109e --- /dev/null +++ b/src/hooks/useCustomNavLinks.tsx @@ -0,0 +1,104 @@ +import { + Book, + BookOpen, + Box, + Building2, + Calendar, + Database, + ExternalLink, + FileText, + Globe, + HelpCircle, + Link as LinkIcon, + LucideIcon, + Settings, + Stethoscope, + Users, +} from "lucide-react"; +import { useTranslation } from "react-i18next"; + +import type { NavigationLink } from "@/components/ui/sidebar/nav-main"; + +import { useCareApps } from "@/hooks/useCareApps"; + +import { + type CustomNavLink, + type NavScope, + customNavLinkSchema, +} from "@/types/nav/customNavLink"; + +import { isSafeExternalUrl, isSafeNavUrl } from "@/Utils/url"; + +import careConfig from "@careConfig"; + +/** + * Allow-list of icon names that can be referenced from configuration. + * Restricting the set avoids importing the entire lucide library and keeps + * untrusted config from rendering arbitrary components. + */ +const iconMap: Record = { + Book, + BookOpen, + Box, + Building2, + Calendar, + Database, + ExternalLink, + FileText, + Globe, + HelpCircle, + Link: LinkIcon, + Settings, + Stethoscope, + Users, +}; + +function parseLinks(value: unknown): CustomNavLink[] { + if (!Array.isArray(value)) return []; + // Validate each entry independently so one malformed link (e.g. from a + // runtime-loaded plugin manifest) doesn't drop the entire list. + return value.flatMap((item) => { + const parsed = customNavLinkSchema.safeParse(item); + if (parsed.success) return [parsed.data]; + if (import.meta.env.DEV) { + console.warn("Skipping invalid custom nav link:", parsed.error.issues); + } + return []; + }); +} + +/** + * Resolves the custom navigation links configured for a given sidebar scope. + * + * Links are sourced from the deployment env (`careConfig.customNavLinks`) and + * from loaded plugin manifests (`customNavLinks`), then filtered by placement + * and nav-URL safety before being mapped to `NavigationLink`s that `NavMain` + * can render. + */ +export function useCustomNavLinks(scope: NavScope): NavigationLink[] { + const { t } = useTranslation(); + const careApps = useCareApps(); + + const envLinks = parseLinks(careConfig.customNavLinks); + const pluginLinks = parseLinks( + careApps.flatMap((app) => + !app.isLoading && app.customNavLinks ? app.customNavLinks : [], + ), + ); + + return [...envLinks, ...pluginLinks] + .filter( + (link) => + link.placement.includes(scope) || link.placement.includes("all"), + ) + .filter((link) => isSafeNavUrl(link.url)) + .map((link) => { + const Icon = link.icon ? iconMap[link.icon] : undefined; + return { + name: t(link.title), + url: link.url, + openInNewTab: link.openInNewTab ?? isSafeExternalUrl(link.url), + icon: Icon ? : undefined, + } satisfies NavigationLink; + }); +} diff --git a/src/pluginTypes.ts b/src/pluginTypes.ts index 18cabbd3eb2..7182a855918 100644 --- a/src/pluginTypes.ts +++ b/src/pluginTypes.ts @@ -10,6 +10,7 @@ import { PublicPatientRead, } from "@/types/emr/patient/patient"; import { FacilityRead } from "@/types/facility/facility"; +import type { CustomNavLink } from "@/types/nav/customNavLink"; import { PlugConfigMeta } from "@/types/plugConfig"; import { UserReadMinimal } from "@/types/user/user"; import { ComponentType, LazyExoticComponent, ReactNode } from "react"; @@ -207,6 +208,7 @@ export type PluginManifest = { billingNavItems?: NavigationLink[]; userNavItems?: NavigationLink[]; adminNavItems?: NavigationLink[]; + customNavLinks?: CustomNavLink[]; organizationTabs?: PluginOrganizationTab[]; components?: PluginComponentMap; encounterTabs?: Record< diff --git a/src/types/nav/customNavLink.ts b/src/types/nav/customNavLink.ts new file mode 100644 index 00000000000..2cc529376e7 --- /dev/null +++ b/src/types/nav/customNavLink.ts @@ -0,0 +1,41 @@ +import { z } from "zod"; + +/** + * Sidebar contexts a custom nav link can be placed in. + * "all" makes the link appear in every context (persistent surface). + */ +export const NAV_SCOPES = [ + "facility", + "organization", + "location", + "service", + "admin", + "patient", + "all", +] as const; + +export type NavScope = (typeof NAV_SCOPES)[number]; + +/** + * A single custom navigation link, sourced from the `REACT_CUSTOM_NAV_LINKS` + * env config or a plugin manifest. + * + * - `title`: display label, passed through i18n (falls back to literal text). + * - `url`: internal route path ("/...") or an absolute http(s) URL. Absolute + * http(s) URLs are treated as external automatically (rendered as a sanitized + * anchor); anything else must be an internal path. + * - `icon`: optional name from the allow-listed lucide-react icons. + * - `openInNewTab`: open in a new tab; defaults to true for absolute http(s) URLs. + * - `placement`: sidebar contexts the link appears in; defaults to every context. + */ +export const customNavLinkSchema = z.object({ + title: z.string(), + url: z.string(), + icon: z.string().optional(), + openInNewTab: z.boolean().optional(), + placement: z.array(z.enum(NAV_SCOPES)).default(["all"]), +}); + +export type CustomNavLink = z.infer; + +export const customNavLinksSchema = z.array(customNavLinkSchema); diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 314ab505d76..49aed8cd8a4 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -54,6 +54,7 @@ interface ImportMetaEnv { readonly REACT_DEFAULT_COUNTRY?: string; readonly REACT_MAPS_FALLBACK_URL_TEMPLATE?: string; readonly REACT_CUSTOM_SHORTCUTS?: string; + readonly REACT_CUSTOM_NAV_LINKS?: string; } interface ImportMeta {