Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .example.env
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,33 @@ REACT_CUSTOM_REMOTE_I18N_URL=
# ]'
REACT_CUSTOM_SHORTCUTS=

# Custom links inserted into the left navbar.
Comment thread
yash-learner marked this conversation as resolved.
# 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

Expand Down
9 changes: 9 additions & 0 deletions care.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down
44 changes: 44 additions & 0 deletions scripts/validate-env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@ 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 {
isInternalNavPath,
isSafeExternalUrl,
isSafeNavUrl,
} from "../src/Utils/url";

import { z } from "zod";

Expand Down Expand Up @@ -54,6 +62,41 @@ 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 (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)
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message:
"internal links must use an absolute app path starting with / (and not //)",
path: [index, "url"],
});
}
});
});
Comment thread
yash-learner marked this conversation as resolved.

const VALID_ROUNDING_METHODS = [
"ROUND_UP",
"ROUND_DOWN",
Expand Down Expand Up @@ -157,6 +200,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(),
Expand Down
36 changes: 36 additions & 0 deletions src/Utils/url.ts
Original file line number Diff line number Diff line change
@@ -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;
}
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/**
* 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);
13 changes: 13 additions & 0 deletions src/components/ui/sidebar/app-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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<typeof Sidebar> {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -183,6 +195,7 @@ export function AppSidebar({
)}
{patientSidebar && <PatientNav />}
{adminSidebar && <AdminNav />}
<CustomNavLinks scope={navScope} />
{(facilitySidebar ||
Comment thread
yash-learner marked this conversation as resolved.
Comment thread
yash-learner marked this conversation as resolved.
facilityLocationSidebar ||
facilityServiceSidebar ||
Expand Down
20 changes: 20 additions & 0 deletions src/components/ui/sidebar/custom-nav-links.tsx
Original file line number Diff line number Diff line change
@@ -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 <NavMain links={links} />;
}
129 changes: 91 additions & 38 deletions src/components/ui/sidebar/nav-main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ import {

import { Avatar } from "@/components/Common/Avatar";

import { isInternalNavPath, isSafeNavUrl } from "@/Utils/url";

const isChildActive = (link: NavigationLink) => {
if (!link.children) return false;
const currentPath = window.location.pathname;
Expand All @@ -51,9 +53,59 @@ 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[];
}

const isRenderableNavLink = (link: NavigationLink) =>
link.external || link.openInNewTab
? isSafeNavUrl(link.url)
: isInternalNavPath(link.url);
Comment thread
yash-learner marked this conversation as resolved.
Outdated

/**
* 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 (
<a
href={link.url}
onClick={opts.onClick}
className={opts.className}
target={openInNewTab ? "_blank" : undefined}
rel={openInNewTab ? "noopener noreferrer" : undefined}
Comment thread
yash-learner marked this conversation as resolved.
>
{children}
</a>
);
Comment thread
yash-learner marked this conversation as resolved.
}

return (
<ActiveLink
href={link.url}
onClick={opts.onClick}
className={opts.className}
activeClass={opts.activeClass}
exactActiveClass={opts.exactActiveClass}
>
{children}
</ActiveLink>
);
}

export function NavMain({ links }: { links: NavigationLink[] }) {
const { state } = useSidebar();
const isCollapsed = state === "collapsed";
Expand All @@ -76,6 +128,7 @@ export function NavMain({ links }: { links: NavigationLink[] }) {
<SidebarMenu>
{links
.filter((link) => link.visibility !== false)
.filter(isRenderableNavLink)
.map((link) => (
<Fragment key={link.name}>
{link.children ? (
Expand All @@ -93,24 +146,27 @@ export function NavMain({ links }: { links: NavigationLink[] }) {
"text-gray-600 transition font-normal hover:bg-gray-200 hover:text-green-700"
}
>
<ActiveLink
href={link.url}
activeClass="bg-white text-green-700 shadow-sm"
exactActiveClass="bg-white text-green-700 shadow-sm"
>
{link.icon ? (
link.icon
) : (
<Avatar
name={link.name}
className="size-6 -m-1 rounded-sm"
/>
)}

<span className="group-data-[collapsible=icon]:hidden ml-1">
{link.name}
</span>
</ActiveLink>
{renderNavLink(
link,
<>
{link.icon ? (
link.icon
) : (
<Avatar
name={link.name}
className="size-6 -m-1 rounded-sm"
/>
)}

<span className="group-data-[collapsible=icon]:hidden ml-1">
{link.name}
</span>
</>,
{
activeClass: "bg-white text-green-700 shadow-sm",
exactActiveClass: "bg-white text-green-700 shadow-sm",
},
)}
</SidebarMenuButton>
</SidebarMenuItem>
)}
Expand Down Expand Up @@ -158,6 +214,7 @@ function CollapsibleNavItem({
<SidebarMenuSub className="border-l border-gray-300">
{link.children
?.filter((link) => link.visibility !== false)
.filter(isRenderableNavLink)
.map((subItem) => (
<Fragment key={subItem.name}>
{subItem.header && (
Expand All @@ -175,19 +232,16 @@ function CollapsibleNavItem({
"text-gray-600 transition font-normal hover:bg-gray-200 hover:text-green-700"
}
>
<ActiveLink
href={subItem.url}
className="w-full"
activeClass={cn(
{renderNavLink(subItem, subItem.name, {
className: "w-full",
activeClass: cn(
subItem.url
.split("/")
.every((part) => fullPathMap[part]) &&
"bg-white text-green-700 shadow",
)}
exactActiveClass="bg-white text-green-700 shadow"
>
{subItem.name}
</ActiveLink>
),
exactActiveClass: "bg-white text-green-700 shadow",
})}
</SidebarMenuSubButton>
</SidebarMenuSubItem>
</Fragment>
Expand Down Expand Up @@ -228,17 +282,16 @@ function PopoverMenu({ link }: { link: NavigationLink }) {
onCloseAutoFocus={(e) => e.preventDefault()}
>
<div className="flex flex-col gap-1">
{link.children?.map((subItem) => (
<ActiveLink
key={subItem.name}
href={subItem.url}
onClick={() => 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}
</ActiveLink>
{link.children?.filter(isRenderableNavLink).map((subItem) => (
<Fragment key={subItem.name}>
{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),
})}
</Fragment>
))}
</div>
</PopoverContent>
Expand Down
Loading
Loading