From 20467201f5594f3a5a8efd7c55271a34c9a389e5 Mon Sep 17 00:00:00 2001 From: yash-learner Date: Tue, 23 Jun 2026 00:00:26 +0530 Subject: [PATCH 1/7] feat: add support for custom links in the left sidebar --- .example.env | 27 ++++ care.config.ts | 9 ++ scripts/validate-env.ts | 7 + src/Utils/utils.ts | 14 ++ src/components/ui/sidebar/app-sidebar.tsx | 13 ++ .../ui/sidebar/custom-nav-links.tsx | 20 +++ src/components/ui/sidebar/nav-main.tsx | 128 ++++++++++++------ src/hooks/useCustomNavLinks.tsx | 96 +++++++++++++ src/pluginTypes.ts | 2 + src/types/nav/customNavLink.ts | 41 ++++++ src/vite-env.d.ts | 1 + 11 files changed, 320 insertions(+), 38 deletions(-) create mode 100644 src/components/ui/sidebar/custom-nav-links.tsx create mode 100644 src/hooks/useCustomNavLinks.tsx create mode 100644 src/types/nav/customNavLink.ts diff --git a/.example.env b/.example.env index faac594f343..1c8e84a25cb 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), external (optional), +# openInNewTab (optional), placement (optional) +# placement controls which sidebar contexts show the link: +# facility | organization | location | service | admin | patient | all (default: ["all"]) +# external: set true for absolute http(s) URLs; openInNewTab defaults to external. +# 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", +# "external": true, +# "placement": ["all"] +# }, +# { +# "title": "Facility Handbook", +# "url": "https://example.com/handbook", +# "icon": "FileText", +# "external": true, +# "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..16e4da3d407 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), + * external (renders as a sanitized anchor), and openInNewTab. + */ + customNavLinks: env.REACT_CUSTOM_NAV_LINKS + ? JSON.parse(env.REACT_CUSTOM_NAV_LINKS) + : [], /** * System identifier for patient phone number configuration */ diff --git a/scripts/validate-env.ts b/scripts/validate-env.ts index 83b5bb74002..89242cbb051 100644 --- a/scripts/validate-env.ts +++ b/scripts/validate-env.ts @@ -4,6 +4,8 @@ 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"; import { z } from "zod"; @@ -54,6 +56,10 @@ const customShortcutsSchemaString = jsonAsStringSchema .transform((val) => JSON.parse(val)) .pipe(customShortcutSchema); +const customNavLinksSchemaString = jsonAsStringSchema + .transform((val) => JSON.parse(val)) + .pipe(customNavLinksSchema); + const VALID_ROUNDING_METHODS = [ "ROUND_UP", "ROUND_DOWN", @@ -157,6 +163,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/utils.ts b/src/Utils/utils.ts index feed0817390..699f505b429 100644 --- a/src/Utils/utils.ts +++ b/src/Utils/utils.ts @@ -137,6 +137,20 @@ export const isValidLongitude = (longitude: number) => { return Number.isFinite(longitude) && longitude >= -180 && longitude <= 180; }; +/** + * 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; + } +}; + const getRelativeDateSuffix = (abbreviated: boolean) => { return { day: abbreviated ? "d" : "days", diff --git a/src/components/ui/sidebar/app-sidebar.tsx b/src/components/ui/sidebar/app-sidebar.tsx index 31bc6882181..0b7fb661055 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 { 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..e438cc64aa0 --- /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 { 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..d413c97e182 100644 --- a/src/components/ui/sidebar/nav-main.tsx +++ b/src/components/ui/sidebar/nav-main.tsx @@ -29,6 +29,8 @@ import { import { Avatar } from "@/components/Common/Avatar"; +import { isSafeExternalUrl } from "@/Utils/utils"; + const isChildActive = (link: NavigationLink) => { if (!link.children) return false; const currentPath = window.location.pathname; @@ -51,9 +53,58 @@ export interface NavigationLink { url: string; icon?: ReactNode; visibility?: boolean; + external?: boolean; // Marks the url as external, rendered as a sanitized anchor. Also defaults openInNewTab to true. + openInNewTab?: boolean; children?: NavigationLink[]; } +/** Drops external links whose url is not a safe http(s) URL. */ +const isRenderableNavLink = (link: NavigationLink) => + !link.external || isSafeExternalUrl(link.url); + +/** + * Renders a nav destination as a raviger `ActiveLink` (internal, same-tab) or a + * plain anchor (external links, 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; + } = {}, +) { + if (link.external || link.openInNewTab) { + const openInNewTab = link.openInNewTab ?? !!link.external; + return ( + + {children} + + ); + } + + return ( + + {children} + + ); +} + export function NavMain({ links }: { links: NavigationLink[] }) { const { state } = useSidebar(); const isCollapsed = state === "collapsed"; @@ -76,6 +127,7 @@ export function NavMain({ links }: { links: NavigationLink[] }) { {links .filter((link) => link.visibility !== false) + .filter(isRenderableNavLink) .map((link) => ( {link.children ? ( @@ -93,24 +145,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 +213,7 @@ function CollapsibleNavItem({ {link.children ?.filter((link) => link.visibility !== false) + .filter(isRenderableNavLink) .map((subItem) => ( {subItem.header && ( @@ -175,19 +231,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 +281,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..6a4d4e755aa --- /dev/null +++ b/src/hooks/useCustomNavLinks.tsx @@ -0,0 +1,96 @@ +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 { NavigationLink } from "@/components/ui/sidebar/nav-main"; + +import { useCareApps } from "@/hooks/useCareApps"; + +import { + CustomNavLink, + NavScope, + customNavLinksSchema, +} from "@/types/nav/customNavLink"; + +import { isSafeExternalUrl } from "@/Utils/utils"; + +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[] { + const result = customNavLinksSchema.safeParse(value ?? []); + return result.success ? result.data : []; +} + +/** + * 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, + * visibility, and external-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) => !link.external || isSafeExternalUrl(link.url)) + .map((link) => { + const Icon = link.icon ? iconMap[link.icon] : undefined; + return { + name: t(link.title), + url: link.url, + external: link.external, + openInNewTab: link.openInNewTab ?? link.external, + icon: Icon ? : undefined, + } satisfies NavigationLink; + }); +} diff --git a/src/pluginTypes.ts b/src/pluginTypes.ts index e631933d94c..67579b4c738 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 { CustomNavLink } from "@/types/nav/customNavLink"; import { PlugConfigMeta } from "@/types/plugConfig"; import { UserReadMinimal } from "@/types/user/user"; import { ComponentType, LazyExoticComponent, ReactNode } from "react"; @@ -201,6 +202,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..c5f1407e49d --- /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 when `external`. + * - `icon`: optional name from the allow-listed lucide-react icons. + * - `external`: marks the url as outside the app (rendered as a sanitized anchor). + * - `openInNewTab`: open in a new tab; defaults to the value of `external`. + * - `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(), + external: z.boolean().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 { From 6ed9db9e591c7c396807c15ed3801c2ffd2f914e Mon Sep 17 00:00:00 2001 From: yash-learner Date: Tue, 23 Jun 2026 17:05:01 +0530 Subject: [PATCH 2/7] Address copilot review comments https://github.com/ohcnetwork/care_fe/pull/16472#pullrequestreview-4547108399 --- scripts/validate-env.ts | 23 ++++++++++++- src/Utils/url.ts | 33 +++++++++++++++++++ src/Utils/utils.ts | 14 -------- src/components/ui/sidebar/app-sidebar.tsx | 2 +- .../ui/sidebar/custom-nav-links.tsx | 2 +- src/components/ui/sidebar/nav-main.tsx | 5 ++- src/hooks/useCustomNavLinks.tsx | 12 ++++--- src/pluginTypes.ts | 2 +- 8 files changed, 67 insertions(+), 26 deletions(-) create mode 100644 src/Utils/url.ts diff --git a/scripts/validate-env.ts b/scripts/validate-env.ts index 89242cbb051..c75d9b5a660 100644 --- a/scripts/validate-env.ts +++ b/scripts/validate-env.ts @@ -6,6 +6,8 @@ import { } 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 { isSafeExternalUrl, isSafeNavUrl } from "../src/Utils/url"; import { z } from "zod"; @@ -58,7 +60,26 @@ const customShortcutsSchemaString = jsonAsStringSchema const customNavLinksSchemaString = jsonAsStringSchema .transform((val) => JSON.parse(val)) - .pipe(customNavLinksSchema); + .pipe(customNavLinksSchema) + .superRefine((links, ctx) => { + links.forEach((link, index) => { + if (link.external && !isSafeExternalUrl(link.url)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "external links must use an absolute http(s) URL", + path: [index, "url"], + }); + } + if (link.openInNewTab && !isSafeNavUrl(link.url)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + "openInNewTab links must use a safe internal path or an absolute http(s) URL", + path: [index, "url"], + }); + } + }); + }); const VALID_ROUNDING_METHODS = [ "ROUND_UP", diff --git a/src/Utils/url.ts b/src/Utils/url.ts new file mode 100644 index 00000000000..172f5f98c29 --- /dev/null +++ b/src/Utils/url.ts @@ -0,0 +1,33 @@ +/** + * 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 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 => { + if (url.startsWith("/") && !url.startsWith("//")) { + return true; + } + return isSafeExternalUrl(url); +}; diff --git a/src/Utils/utils.ts b/src/Utils/utils.ts index 699f505b429..feed0817390 100644 --- a/src/Utils/utils.ts +++ b/src/Utils/utils.ts @@ -137,20 +137,6 @@ export const isValidLongitude = (longitude: number) => { return Number.isFinite(longitude) && longitude >= -180 && longitude <= 180; }; -/** - * 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; - } -}; - const getRelativeDateSuffix = (abbreviated: boolean) => { return { day: abbreviated ? "d" : "days", diff --git a/src/components/ui/sidebar/app-sidebar.tsx b/src/components/ui/sidebar/app-sidebar.tsx index 0b7fb661055..1269591a1e7 100644 --- a/src/components/ui/sidebar/app-sidebar.tsx +++ b/src/components/ui/sidebar/app-sidebar.tsx @@ -38,7 +38,7 @@ import { ServiceSwitcher } from "./facility/service/service-switcher"; import PinPageDialog from "@/components/Common/PinPageDialog"; import { FacilityBareMinimum } from "@/types/facility/facility"; -import { NavScope } from "@/types/nav/customNavLink"; +import type { NavScope } from "@/types/nav/customNavLink"; import { CurrentUserRead } from "@/types/user/user"; interface AppSidebarProps extends React.ComponentProps { diff --git a/src/components/ui/sidebar/custom-nav-links.tsx b/src/components/ui/sidebar/custom-nav-links.tsx index e438cc64aa0..80c834795e3 100644 --- a/src/components/ui/sidebar/custom-nav-links.tsx +++ b/src/components/ui/sidebar/custom-nav-links.tsx @@ -2,7 +2,7 @@ import { NavMain } from "@/components/ui/sidebar/nav-main"; import { useCustomNavLinks } from "@/hooks/useCustomNavLinks"; -import { NavScope } from "@/types/nav/customNavLink"; +import type { NavScope } from "@/types/nav/customNavLink"; /** * Renders configuration- and plugin-provided custom links for the given sidebar diff --git a/src/components/ui/sidebar/nav-main.tsx b/src/components/ui/sidebar/nav-main.tsx index d413c97e182..0fd402cb18e 100644 --- a/src/components/ui/sidebar/nav-main.tsx +++ b/src/components/ui/sidebar/nav-main.tsx @@ -29,7 +29,7 @@ import { import { Avatar } from "@/components/Common/Avatar"; -import { isSafeExternalUrl } from "@/Utils/utils"; +import { isSafeNavUrl } from "@/Utils/url"; const isChildActive = (link: NavigationLink) => { if (!link.children) return false; @@ -58,9 +58,8 @@ export interface NavigationLink { children?: NavigationLink[]; } -/** Drops external links whose url is not a safe http(s) URL. */ const isRenderableNavLink = (link: NavigationLink) => - !link.external || isSafeExternalUrl(link.url); + !(link.external || link.openInNewTab) || isSafeNavUrl(link.url); /** * Renders a nav destination as a raviger `ActiveLink` (internal, same-tab) or a diff --git a/src/hooks/useCustomNavLinks.tsx b/src/hooks/useCustomNavLinks.tsx index 6a4d4e755aa..8a09e8c184d 100644 --- a/src/hooks/useCustomNavLinks.tsx +++ b/src/hooks/useCustomNavLinks.tsx @@ -27,7 +27,7 @@ import { customNavLinksSchema, } from "@/types/nav/customNavLink"; -import { isSafeExternalUrl } from "@/Utils/utils"; +import { isSafeNavUrl } from "@/Utils/url"; import careConfig from "@careConfig"; @@ -62,9 +62,9 @@ function parseLinks(value: unknown): CustomNavLink[] { * 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, - * visibility, and external-URL safety before being mapped to `NavigationLink`s - * that `NavMain` can render. + * 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(); @@ -82,7 +82,9 @@ export function useCustomNavLinks(scope: NavScope): NavigationLink[] { (link) => link.placement.includes(scope) || link.placement.includes("all"), ) - .filter((link) => !link.external || isSafeExternalUrl(link.url)) + .filter( + (link) => !(link.external || link.openInNewTab) || isSafeNavUrl(link.url), + ) .map((link) => { const Icon = link.icon ? iconMap[link.icon] : undefined; return { diff --git a/src/pluginTypes.ts b/src/pluginTypes.ts index 67579b4c738..605a806e30a 100644 --- a/src/pluginTypes.ts +++ b/src/pluginTypes.ts @@ -10,7 +10,7 @@ import { PublicPatientRead, } from "@/types/emr/patient/patient"; import { FacilityRead } from "@/types/facility/facility"; -import { CustomNavLink } from "@/types/nav/customNavLink"; +import type { CustomNavLink } from "@/types/nav/customNavLink"; import { PlugConfigMeta } from "@/types/plugConfig"; import { UserReadMinimal } from "@/types/user/user"; import { ComponentType, LazyExoticComponent, ReactNode } from "react"; From 13f123ea31c773bafb6d5329baf647284ba2f75b Mon Sep 17 00:00:00 2001 From: yash-learner Date: Tue, 23 Jun 2026 19:02:57 +0530 Subject: [PATCH 3/7] Address copilot review comments https://github.com/ohcnetwork/care_fe/pull/16472#pullrequestreview-4552912511 --- scripts/validate-env.ts | 18 +++++++++++++++++- src/Utils/url.ts | 15 +++++++++------ src/components/ui/sidebar/nav-main.tsx | 6 ++++-- src/hooks/useCustomNavLinks.tsx | 17 ++++++++++------- 4 files changed, 40 insertions(+), 16 deletions(-) diff --git a/scripts/validate-env.ts b/scripts/validate-env.ts index c75d9b5a660..39ac8ccc738 100644 --- a/scripts/validate-env.ts +++ b/scripts/validate-env.ts @@ -7,7 +7,11 @@ import { // 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 { isSafeExternalUrl, isSafeNavUrl } from "../src/Utils/url"; +import { + isInternalNavPath, + isSafeExternalUrl, + isSafeNavUrl, +} from "../src/Utils/url"; import { z } from "zod"; @@ -78,6 +82,18 @@ const customNavLinksSchemaString = jsonAsStringSchema path: [index, "url"], }); } + if ( + !link.external && + !link.openInNewTab && + !isInternalNavPath(link.url) + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + "internal links must use an absolute app path starting with / (and not //)", + path: [index, "url"], + }); + } }); }); diff --git a/src/Utils/url.ts b/src/Utils/url.ts index 172f5f98c29..7300c1d5424 100644 --- a/src/Utils/url.ts +++ b/src/Utils/url.ts @@ -20,14 +20,17 @@ export const isSafeExternalUrl = (url: string): boolean => { } }; +/** + * 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 => { - if (url.startsWith("/") && !url.startsWith("//")) { - return true; - } - return isSafeExternalUrl(url); -}; +export const isSafeNavUrl = (url: string): boolean => + isInternalNavPath(url) || isSafeExternalUrl(url); diff --git a/src/components/ui/sidebar/nav-main.tsx b/src/components/ui/sidebar/nav-main.tsx index 0fd402cb18e..f95ac25e2d5 100644 --- a/src/components/ui/sidebar/nav-main.tsx +++ b/src/components/ui/sidebar/nav-main.tsx @@ -29,7 +29,7 @@ import { import { Avatar } from "@/components/Common/Avatar"; -import { isSafeNavUrl } from "@/Utils/url"; +import { isInternalNavPath, isSafeNavUrl } from "@/Utils/url"; const isChildActive = (link: NavigationLink) => { if (!link.children) return false; @@ -59,7 +59,9 @@ export interface NavigationLink { } const isRenderableNavLink = (link: NavigationLink) => - !(link.external || link.openInNewTab) || isSafeNavUrl(link.url); + link.external || link.openInNewTab + ? isSafeNavUrl(link.url) + : isInternalNavPath(link.url); /** * Renders a nav destination as a raviger `ActiveLink` (internal, same-tab) or a diff --git a/src/hooks/useCustomNavLinks.tsx b/src/hooks/useCustomNavLinks.tsx index 8a09e8c184d..24b94e77e0e 100644 --- a/src/hooks/useCustomNavLinks.tsx +++ b/src/hooks/useCustomNavLinks.tsx @@ -17,17 +17,17 @@ import { } from "lucide-react"; import { useTranslation } from "react-i18next"; -import { NavigationLink } from "@/components/ui/sidebar/nav-main"; +import type { NavigationLink } from "@/components/ui/sidebar/nav-main"; import { useCareApps } from "@/hooks/useCareApps"; import { - CustomNavLink, - NavScope, + type CustomNavLink, + type NavScope, customNavLinksSchema, } from "@/types/nav/customNavLink"; -import { isSafeNavUrl } from "@/Utils/url"; +import { isInternalNavPath, isSafeNavUrl } from "@/Utils/url"; import careConfig from "@careConfig"; @@ -82,9 +82,12 @@ export function useCustomNavLinks(scope: NavScope): NavigationLink[] { (link) => link.placement.includes(scope) || link.placement.includes("all"), ) - .filter( - (link) => !(link.external || link.openInNewTab) || isSafeNavUrl(link.url), - ) + .filter((link) => { + if (link.external || link.openInNewTab) { + return isSafeNavUrl(link.url); + } + return isInternalNavPath(link.url); + }) .map((link) => { const Icon = link.icon ? iconMap[link.icon] : undefined; return { From 29583b892149dd087cac2a1fe08b3b456f467226 Mon Sep 17 00:00:00 2001 From: yash-learner Date: Tue, 23 Jun 2026 19:59:23 +0530 Subject: [PATCH 4/7] Address https://github.com/ohcnetwork/care_fe/pull/16472#discussion_r3460220567 --- src/hooks/useCustomNavLinks.tsx | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/hooks/useCustomNavLinks.tsx b/src/hooks/useCustomNavLinks.tsx index 24b94e77e0e..a60ef7388ae 100644 --- a/src/hooks/useCustomNavLinks.tsx +++ b/src/hooks/useCustomNavLinks.tsx @@ -24,7 +24,7 @@ import { useCareApps } from "@/hooks/useCareApps"; import { type CustomNavLink, type NavScope, - customNavLinksSchema, + customNavLinkSchema, } from "@/types/nav/customNavLink"; import { isInternalNavPath, isSafeNavUrl } from "@/Utils/url"; @@ -54,8 +54,17 @@ const iconMap: Record = { }; function parseLinks(value: unknown): CustomNavLink[] { - const result = customNavLinksSchema.safeParse(value ?? []); - return result.success ? result.data : []; + 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 []; + }); } /** From 0b9d4775e995d0d21a4fd1f08c926010e0b5891f Mon Sep 17 00:00:00 2001 From: yash-learner Date: Tue, 23 Jun 2026 20:53:02 +0530 Subject: [PATCH 5/7] Address https://github.com/ohcnetwork/care_fe/pull/16472#discussion_r3460594702 --- src/hooks/useCustomNavLinks.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/hooks/useCustomNavLinks.tsx b/src/hooks/useCustomNavLinks.tsx index a60ef7388ae..ea375584851 100644 --- a/src/hooks/useCustomNavLinks.tsx +++ b/src/hooks/useCustomNavLinks.tsx @@ -27,7 +27,11 @@ import { customNavLinkSchema, } from "@/types/nav/customNavLink"; -import { isInternalNavPath, isSafeNavUrl } from "@/Utils/url"; +import { + isInternalNavPath, + isSafeExternalUrl, + isSafeNavUrl, +} from "@/Utils/url"; import careConfig from "@careConfig"; @@ -92,7 +96,10 @@ export function useCustomNavLinks(scope: NavScope): NavigationLink[] { link.placement.includes(scope) || link.placement.includes("all"), ) .filter((link) => { - if (link.external || link.openInNewTab) { + if (link.external) { + return isSafeExternalUrl(link.url); + } + if (link.openInNewTab) { return isSafeNavUrl(link.url); } return isInternalNavPath(link.url); From b0dedca7ce76a38428ac1a09ba3dcd2cd4a5670a Mon Sep 17 00:00:00 2001 From: yash-learner Date: Tue, 23 Jun 2026 22:31:29 +0530 Subject: [PATCH 6/7] Remove unnecessary external prop which is causing complex code and more review comments from copilot --- .example.env | 10 ++++----- care.config.ts | 4 ++-- scripts/validate-env.ts | 29 +++----------------------- src/components/ui/sidebar/nav-main.tsx | 19 +++++++---------- src/hooks/useCustomNavLinks.tsx | 19 +++-------------- src/types/nav/customNavLink.ts | 8 +++---- 6 files changed, 25 insertions(+), 64 deletions(-) diff --git a/.example.env b/.example.env index 1c8e84a25cb..57727058adc 100644 --- a/.example.env +++ b/.example.env @@ -145,11 +145,13 @@ REACT_CUSTOM_REMOTE_I18N_URL= REACT_CUSTOM_SHORTCUTS= # Custom links inserted into the left navbar. -# Each link can have: title, url, icon (optional), external (optional), -# openInNewTab (optional), placement (optional) +# 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"]) -# external: set true for absolute http(s) URLs; openInNewTab defaults to external. # Available icons: Book, BookOpen, Box, Building2, Calendar, Database, ExternalLink, # FileText, Globe, HelpCircle, Link, Settings, Stethoscope, Users # Example: @@ -158,14 +160,12 @@ REACT_CUSTOM_SHORTCUTS= # "title": "Documentation", # "url": "https://care-be-docs.ohc.network", # "icon": "BookOpen", -# "external": true, # "placement": ["all"] # }, # { # "title": "Facility Handbook", # "url": "https://example.com/handbook", # "icon": "FileText", -# "external": true, # "placement": ["facility"] # } # ]' diff --git a/care.config.ts b/care.config.ts index 16e4da3d407..2b809bc3b85 100644 --- a/care.config.ts +++ b/care.config.ts @@ -308,8 +308,8 @@ const careConfig = { /** * 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), - * external (renders as a sanitized anchor), and openInNewTab. + * 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) diff --git a/scripts/validate-env.ts b/scripts/validate-env.ts index 39ac8ccc738..db907559973 100644 --- a/scripts/validate-env.ts +++ b/scripts/validate-env.ts @@ -7,11 +7,7 @@ import { // 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 { - isInternalNavPath, - isSafeExternalUrl, - isSafeNavUrl, -} from "../src/Utils/url"; +import { isSafeNavUrl } from "../src/Utils/url"; import { z } from "zod"; @@ -67,30 +63,11 @@ const customNavLinksSchemaString = jsonAsStringSchema .pipe(customNavLinksSchema) .superRefine((links, ctx) => { links.forEach((link, index) => { - if (link.external && !isSafeExternalUrl(link.url)) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "external links must use an absolute http(s) URL", - path: [index, "url"], - }); - } - if (link.openInNewTab && !isSafeNavUrl(link.url)) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: - "openInNewTab links must use a safe internal path or an absolute http(s) URL", - path: [index, "url"], - }); - } - if ( - !link.external && - !link.openInNewTab && - !isInternalNavPath(link.url) - ) { + if (!isSafeNavUrl(link.url)) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: - "internal links must use an absolute app path starting with / (and not //)", + "url must be an internal app path starting with / (and not //) or an absolute http(s) URL", path: [index, "url"], }); } diff --git a/src/components/ui/sidebar/nav-main.tsx b/src/components/ui/sidebar/nav-main.tsx index f95ac25e2d5..3b38bbe62d1 100644 --- a/src/components/ui/sidebar/nav-main.tsx +++ b/src/components/ui/sidebar/nav-main.tsx @@ -29,7 +29,7 @@ import { import { Avatar } from "@/components/Common/Avatar"; -import { isInternalNavPath, isSafeNavUrl } from "@/Utils/url"; +import { isSafeExternalUrl, isSafeNavUrl } from "@/Utils/url"; const isChildActive = (link: NavigationLink) => { if (!link.children) return false; @@ -53,20 +53,16 @@ export interface NavigationLink { url: string; icon?: ReactNode; visibility?: boolean; - external?: boolean; // Marks the url as external, rendered as a sanitized anchor. Also defaults openInNewTab to true. - openInNewTab?: boolean; + openInNewTab?: boolean; // Open in a new tab; defaults to true for absolute http(s) URLs. children?: NavigationLink[]; } -const isRenderableNavLink = (link: NavigationLink) => - link.external || link.openInNewTab - ? isSafeNavUrl(link.url) - : isInternalNavPath(link.url); +const isRenderableNavLink = (link: NavigationLink) => isSafeNavUrl(link.url); /** * Renders a nav destination as a raviger `ActiveLink` (internal, same-tab) or a - * plain anchor (external links, or links opening in a new tab). Returns a DOM - * element directly so it can be used as the child of an `asChild` button. + * 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, @@ -78,8 +74,9 @@ function renderNavLink( onClick?: () => void; } = {}, ) { - if (link.external || link.openInNewTab) { - const openInNewTab = link.openInNewTab ?? !!link.external; + const isExternal = isSafeExternalUrl(link.url); + if (isExternal || link.openInNewTab) { + const openInNewTab = link.openInNewTab ?? isExternal; return ( link.placement.includes(scope) || link.placement.includes("all"), ) - .filter((link) => { - if (link.external) { - return isSafeExternalUrl(link.url); - } - if (link.openInNewTab) { - return isSafeNavUrl(link.url); - } - return isInternalNavPath(link.url); - }) + .filter((link) => isSafeNavUrl(link.url)) .map((link) => { const Icon = link.icon ? iconMap[link.icon] : undefined; return { name: t(link.title), url: link.url, - external: link.external, - openInNewTab: link.openInNewTab ?? link.external, + openInNewTab: link.openInNewTab ?? isSafeExternalUrl(link.url), icon: Icon ? : undefined, } satisfies NavigationLink; }); diff --git a/src/types/nav/customNavLink.ts b/src/types/nav/customNavLink.ts index c5f1407e49d..2cc529376e7 100644 --- a/src/types/nav/customNavLink.ts +++ b/src/types/nav/customNavLink.ts @@ -21,17 +21,17 @@ export type NavScope = (typeof NAV_SCOPES)[number]; * 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 when `external`. + * - `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. - * - `external`: marks the url as outside the app (rendered as a sanitized anchor). - * - `openInNewTab`: open in a new tab; defaults to the value of `external`. + * - `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(), - external: z.boolean().optional(), openInNewTab: z.boolean().optional(), placement: z.array(z.enum(NAV_SCOPES)).default(["all"]), }); From 41e1672fbca4a5dbaebe55a063391ef965402d47 Mon Sep 17 00:00:00 2001 From: yash-learner Date: Tue, 23 Jun 2026 23:23:47 +0530 Subject: [PATCH 7/7] Address https://github.com/ohcnetwork/care_fe/pull/16472#discussion_r3461587139 --- public/locale/en.json | 1 + src/components/ui/sidebar/nav-main.tsx | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/public/locale/en.json b/public/locale/en.json index 88fbb919d3a..c47f2d0e3ec 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/src/components/ui/sidebar/nav-main.tsx b/src/components/ui/sidebar/nav-main.tsx index 3b38bbe62d1..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"; @@ -86,6 +87,9 @@ function renderNavLink( rel={openInNewTab ? "noopener noreferrer" : undefined} > {children} + {openInNewTab && ( + {t("opens_in_new_tab")} + )} ); }