From 8c9c48ef1d5313e751eea1d83d85b0f6c5197cf3 Mon Sep 17 00:00:00 2001 From: Gregg Stubberfield Date: Sun, 24 Aug 2025 13:53:38 -0400 Subject: [PATCH 01/35] feat: #309 - Add admin-only notice to login page --- frontend/src/pages/Login/LoginForm.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/frontend/src/pages/Login/LoginForm.tsx b/frontend/src/pages/Login/LoginForm.tsx index d4579ead..3c1bec01 100644 --- a/frontend/src/pages/Login/LoginForm.tsx +++ b/frontend/src/pages/Login/LoginForm.tsx @@ -1,6 +1,5 @@ import { useFormik } from "formik"; -// import { Link, useNavigate } from "react-router-dom"; -import { useNavigate } from "react-router-dom"; +import { Link, useNavigate } from "react-router-dom"; import { login, AppDispatch } from "../../services/actions/auth"; import { connect, useDispatch } from "react-redux"; import { RootState } from "../../services/actions/types"; @@ -59,11 +58,16 @@ function LoginForm({ isAuthenticated, loginError }: LoginFormProps) { onSubmit={handleSubmit} className="mb-4 rounded-md bg-white px-3 pb-12 pt-6 shadow-md ring-1 md:px-12" > -
+
{/* {errorMessage &&
{errorMessage}
} */}

Welcome

+ +
+

This login is for Code for Philly administrators. Providers can use all site features without logging in.

+ Return to Medication Suggester +
From a5965999280f94a6f3135ede2760607cb1b62738 Mon Sep 17 00:00:00 2001 From: Gregg Stubberfield Date: Sun, 24 Aug 2025 14:06:13 -0400 Subject: [PATCH 02/35] feat: #309 - add icon to login notice; add line break to return link --- frontend/src/pages/Login/LoginForm.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/frontend/src/pages/Login/LoginForm.tsx b/frontend/src/pages/Login/LoginForm.tsx index 3c1bec01..7639b937 100644 --- a/frontend/src/pages/Login/LoginForm.tsx +++ b/frontend/src/pages/Login/LoginForm.tsx @@ -6,6 +6,7 @@ import { RootState } from "../../services/actions/types"; import { useState, useEffect } from "react"; import ErrorMessage from "../../components/ErrorMessage"; import LoadingSpinner from "../../components/LoadingSpinner/LoadingSpinner"; +import { FaExclamationTriangle } from "react-icons/fa"; interface LoginFormProps { isAuthenticated: boolean; @@ -64,9 +65,13 @@ function LoginForm({ isAuthenticated, loginError }: LoginFormProps) { Welcome -
-

This login is for Code for Philly administrators. Providers can use all site features without logging in.

- Return to Medication Suggester +
+
+ +
+
+

This login is for Code for Philly administrators. Providers can use all site features without logging in. Return to Homepage

+
From f81704fc0858f2da932ee44affd2007321dcaf57 Mon Sep 17 00:00:00 2001 From: Gregg Stubberfield Date: Sun, 24 Aug 2025 14:08:26 -0400 Subject: [PATCH 03/35] feat: #309 - Remove extra padding above notice --- frontend/src/pages/Login/LoginForm.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/pages/Login/LoginForm.tsx b/frontend/src/pages/Login/LoginForm.tsx index 7639b937..3e3fd0f1 100644 --- a/frontend/src/pages/Login/LoginForm.tsx +++ b/frontend/src/pages/Login/LoginForm.tsx @@ -65,7 +65,7 @@ function LoginForm({ isAuthenticated, loginError }: LoginFormProps) { Welcome -
+
From 757e712ebd7daca39cf102abef537cb1d5ef03bd Mon Sep 17 00:00:00 2001 From: Gregg Stubberfield Date: Sun, 24 Aug 2025 14:31:37 -0400 Subject: [PATCH 04/35] feat: #309 - Remove forced login; update UI elements to reflect public vs. admin login --- frontend/src/components/Header/Header.tsx | 34 ++------ .../components/Header/LoginMenuDropDown.tsx | 78 ------------------- frontend/src/pages/Layout/Layout.tsx | 7 -- .../src/pages/Layout/Layout_V2_Header.tsx | 9 --- frontend/src/pages/Login/LoginForm.tsx | 5 -- 5 files changed, 6 insertions(+), 127 deletions(-) delete mode 100644 frontend/src/components/Header/LoginMenuDropDown.tsx diff --git a/frontend/src/components/Header/Header.tsx b/frontend/src/components/Header/Header.tsx index 32039605..50b14091 100644 --- a/frontend/src/components/Header/Header.tsx +++ b/frontend/src/components/Header/Header.tsx @@ -2,7 +2,6 @@ import { useState, useRef, useEffect, Fragment } from "react"; // import { useState, Fragment } from "react"; import accountLogo from "../../assets/account.svg"; import { Link, useNavigate, useLocation } from "react-router-dom"; -import LoginMenuDropDown from "./LoginMenuDropDown"; import "../../components/Header/header.css"; import Chat from "./Chat"; import { FeatureMenuDropDown } from "./FeatureMenuDropDown"; @@ -24,7 +23,6 @@ const Header: React.FC = ({ isAuthenticated, isSuperuser }) => { const dropdownRef = useRef(null); let delayTimeout: number | null = null; const [showChat, setShowChat] = useState(false); - const [showLoginMenu, setShowLoginMenu] = useState(false); const [redirect, setRedirect] = useState(false); const { setShowSummary, setEnterNewPatient, triggerFormReset, setIsEditing } = useGlobalContext(); @@ -36,19 +34,6 @@ const Header: React.FC = ({ isAuthenticated, isSuperuser }) => { setRedirect(false); }; - const guestLinks = () => ( - - ); - const authLinks = () => ( ); - const handleLoginMenu = () => { - setShowLoginMenu(!showLoginMenu); - }; - const handleMouseEnter = () => { if (delayTimeout !== null) { clearTimeout(delayTimeout); @@ -136,7 +117,7 @@ const Header: React.FC = ({ isAuthenticated, isSuperuser }) => { Balancer -
diff --git a/frontend/src/components/Header/LoginMenuDropDown.tsx b/frontend/src/components/Header/LoginMenuDropDown.tsx deleted file mode 100644 index 427fdf07..00000000 --- a/frontend/src/components/Header/LoginMenuDropDown.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import React from "react"; -import { Link } from "react-router-dom"; -import { classNames } from "../../utils/classNames"; - -interface LoginMenuDropDownProps { - showLoginMenu: boolean; - handleLoginMenu: () => void; -} - -const LoginMenuDropDown: React.FC = ({ - showLoginMenu, -}) => { - return ( - <> - - -
- - Balancer - - - -

- Balancer is an interactive and user-friendly research tool for bipolar - medications, powered by Code for Philly volunteers. -

-

- We built Balancer{" "} - - to improve the health and well-being of people with bipolar - disorder. - -

-

- Balancer is currently still being developed, so do not take any - information on the test site as actual medical advice. -

- - {/*

- You can log in or sign up for a Balancer account using your email, - gmail or Facebook account. -

*/} - - - - - {/* - - */} -
- - ); -}; - -const LoginMenu = ({ show }: { show: boolean }) => { - if (!show) return null; - - return
; -}; - -export default LoginMenuDropDown; diff --git a/frontend/src/pages/Layout/Layout.tsx b/frontend/src/pages/Layout/Layout.tsx index 3c12358b..afe880b8 100644 --- a/frontend/src/pages/Layout/Layout.tsx +++ b/frontend/src/pages/Layout/Layout.tsx @@ -2,7 +2,6 @@ import {ReactNode, useState, useEffect} from "react"; import Header from "../../components/Header/Header"; import Footer from "../../components/Footer/Footer"; -import LoginMenuDropDown from "../../components/Header/LoginMenuDropDown"; import {connect} from "react-redux"; import {useAuth} from "./authHooks.ts"; import {RootState} from "../../services/actions/types"; @@ -50,12 +49,6 @@ export const Layout = ({
- {!isAuthenticated && showLoginMenu && ( - - )}
{children}
diff --git a/frontend/src/pages/Layout/Layout_V2_Header.tsx b/frontend/src/pages/Layout/Layout_V2_Header.tsx index b510c62d..3c5b7318 100644 --- a/frontend/src/pages/Layout/Layout_V2_Header.tsx +++ b/frontend/src/pages/Layout/Layout_V2_Header.tsx @@ -1,6 +1,5 @@ import { useState, useEffect } from "react"; import { Link, useLocation } from "react-router-dom"; -import LoginMenuDropDown from "../../components/Header/LoginMenuDropDown.tsx"; import { useAuth } from "./authHooks.ts"; import { useGlobalContext } from "../../../src/contexts/GlobalContext.tsx"; @@ -65,14 +64,6 @@ const Header: React.FC = ({ isAuthenticated }) => { )}
- {!isAuthenticated && showLoginMenu && ( -
- -
- )} ); }; diff --git a/frontend/src/pages/Login/LoginForm.tsx b/frontend/src/pages/Login/LoginForm.tsx index 3e3fd0f1..97bcdbe5 100644 --- a/frontend/src/pages/Login/LoginForm.tsx +++ b/frontend/src/pages/Login/LoginForm.tsx @@ -109,11 +109,6 @@ function LoginForm({ isAuthenticated, loginError }: LoginFormProps) {
- {/* - - */} From c9f3a319dbd514b29c1aa33a7a94e46db624e13c Mon Sep 17 00:00:00 2001 From: Gregg Stubberfield Date: Sun, 24 Aug 2025 19:33:11 -0400 Subject: [PATCH 05/35] feat: #309 - Add new logout page; restyle admin dropdown; restyle logout button; fix css of mobile nav links; remove login sidenav --- .../components/Header/FeatureMenuDropDown.tsx | 10 ++--- frontend/src/components/Header/Header.tsx | 31 ++++---------- frontend/src/components/Header/MdNavBar.tsx | 26 ++++-------- frontend/src/components/Header/header.css | 4 +- frontend/src/pages/Layout/Layout.tsx | 31 ++------------ .../src/pages/Layout/Layout_V2_Header.tsx | 22 +--------- frontend/src/pages/Login/LoginForm.tsx | 2 +- frontend/src/pages/Logout/Logout.tsx | 42 +++++++++++++++++++ frontend/src/routes/routes.tsx | 5 +++ frontend/tailwind.config.js | 9 +++- 10 files changed, 83 insertions(+), 99 deletions(-) create mode 100644 frontend/src/pages/Logout/Logout.tsx diff --git a/frontend/src/components/Header/FeatureMenuDropDown.tsx b/frontend/src/components/Header/FeatureMenuDropDown.tsx index b1bbf03e..36d72792 100644 --- a/frontend/src/components/Header/FeatureMenuDropDown.tsx +++ b/frontend/src/components/Header/FeatureMenuDropDown.tsx @@ -4,13 +4,13 @@ export const FeatureMenuDropDown = () => { const location = useLocation(); const currentPath = location.pathname; return ( -
-
+
+
    Manage files -
    +
    Manage and chat with files
@@ -19,7 +19,7 @@ export const FeatureMenuDropDown = () => {
    Manage rules -
    +
    Manage list of rules
@@ -28,7 +28,7 @@ export const FeatureMenuDropDown = () => {
    Manage meds -
    +
    Manage list of meds
diff --git a/frontend/src/components/Header/Header.tsx b/frontend/src/components/Header/Header.tsx index 50b14091..f8c40028 100644 --- a/frontend/src/components/Header/Header.tsx +++ b/frontend/src/components/Header/Header.tsx @@ -1,15 +1,12 @@ -import { useState, useRef, useEffect, Fragment } from "react"; -// import { useState, Fragment } from "react"; -import accountLogo from "../../assets/account.svg"; +import { useState, useRef, useEffect } from "react"; import { Link, useNavigate, useLocation } from "react-router-dom"; import "../../components/Header/header.css"; import Chat from "./Chat"; import { FeatureMenuDropDown } from "./FeatureMenuDropDown"; import MdNavBar from "./MdNavBar"; -import { connect, useDispatch } from "react-redux"; +import { connect } from "react-redux"; import { RootState } from "../../services/actions/types"; -import { logout, AppDispatch } from "../../services/actions/auth"; -import { HiChevronDown } from "react-icons/hi"; +import { FaChevronDown, FaSignOutAlt } from "react-icons/fa"; import { useGlobalContext } from "../../contexts/GlobalContext.tsx"; interface LoginFormProps { @@ -23,24 +20,14 @@ const Header: React.FC = ({ isAuthenticated, isSuperuser }) => { const dropdownRef = useRef(null); let delayTimeout: number | null = null; const [showChat, setShowChat] = useState(false); - const [redirect, setRedirect] = useState(false); const { setShowSummary, setEnterNewPatient, triggerFormReset, setIsEditing } = useGlobalContext(); - const dispatch = useDispatch(); - - const logout_user = () => { - dispatch(logout()); - setRedirect(false); - }; - const authLinks = () => ( - + + Sign Out + + ); const handleMouseEnter = () => { @@ -201,14 +188,12 @@ const Header: React.FC = ({ isAuthenticated, isSuperuser }) => { : "absolute ml-1.5 " }`} > - + {showFeaturesMenu && }
)} - - {redirect ? navigate("/") : } {isAuthenticated && ( diff --git a/frontend/src/components/Header/MdNavBar.tsx b/frontend/src/components/Header/MdNavBar.tsx index 926794cf..1ed2cd43 100644 --- a/frontend/src/components/Header/MdNavBar.tsx +++ b/frontend/src/components/Header/MdNavBar.tsx @@ -5,8 +5,6 @@ import Chat from "./Chat"; // import logo from "../../assets/balancer.png"; import closeLogo from "../../assets/close.svg"; import hamburgerLogo from "../../assets/hamburger.svg"; -import {useDispatch} from "react-redux"; -import {logout, AppDispatch} from "../../services/actions/auth"; interface LoginFormProps { isAuthenticated: boolean; @@ -22,13 +20,6 @@ const MdNavBar = (props: LoginFormProps) => { setNav(!nav); }; - const dispatch = useDispatch(); - - const logout_user = () => { - dispatch(logout()); - }; - - return (
{
  • Donate
  • {isAuthenticated && -
  • - - Sign Out - -
  • +
  • + Sign Out + +
  • }
    diff --git a/frontend/src/components/Header/header.css b/frontend/src/components/Header/header.css index 4b0f4a2c..c7e807b9 100644 --- a/frontend/src/components/Header/header.css +++ b/frontend/src/components/Header/header.css @@ -23,7 +23,7 @@ } .header-nav-item { - @apply text-black border-transparent border-b-2 hover:border-blue-600 hover:text-blue-600 hover:border-b-2 hover:border-blue-600; + @apply text-black border-transparent border-b-2 hover:cursor-pointer hover:border-blue-600 hover:text-blue-600 hover:border-b-2 hover:border-blue-600; } .header-nav-item.header-nav-item-selected { @@ -31,7 +31,7 @@ } .subheader-nav-item { - @apply cursor-pointer rounded-lg p-3 transition duration-300 hover:bg-gray-100; + @apply cursor-pointer p-3 transition duration-300 hover:bg-gray-200 border-b border-gray-200; } .subheader-nav-item.subheader-nav-item-selected { diff --git a/frontend/src/pages/Layout/Layout.tsx b/frontend/src/pages/Layout/Layout.tsx index afe880b8..84f9c215 100644 --- a/frontend/src/pages/Layout/Layout.tsx +++ b/frontend/src/pages/Layout/Layout.tsx @@ -1,11 +1,10 @@ // Layout.tsx -import {ReactNode, useState, useEffect} from "react"; +import {ReactNode} from "react"; import Header from "../../components/Header/Header"; import Footer from "../../components/Footer/Footer"; import {connect} from "react-redux"; import {useAuth} from "./authHooks.ts"; import {RootState} from "../../services/actions/types"; -import {useLocation} from "react-router-dom"; interface LayoutProps { children: ReactNode; @@ -16,32 +15,8 @@ interface LoginFormProps { } export const Layout = ({ - children, - isAuthenticated, - }: LayoutProps & LoginFormProps): JSX.Element => { - const [showLoginMenu, setShowLoginMenu] = useState(false); - const location = useLocation(); - - - useEffect(() => { - if (!isAuthenticated) { - if ( - location.pathname === "/login" || - location.pathname === "/resetpassword" || - location.pathname.includes("password") || - location.pathname.includes("reset") - ) { - setShowLoginMenu(false); - } else { - setShowLoginMenu(true); - } - } - }, [isAuthenticated, location.pathname]); - - const handleLoginMenu = () => { - setShowLoginMenu(!showLoginMenu); - }; - + children +}: LayoutProps & LoginFormProps): JSX.Element => { useAuth(); return (
    diff --git a/frontend/src/pages/Layout/Layout_V2_Header.tsx b/frontend/src/pages/Layout/Layout_V2_Header.tsx index 3c5b7318..3371cef5 100644 --- a/frontend/src/pages/Layout/Layout_V2_Header.tsx +++ b/frontend/src/pages/Layout/Layout_V2_Header.tsx @@ -1,4 +1,3 @@ -import { useState, useEffect } from "react"; import { Link, useLocation } from "react-router-dom"; import { useAuth } from "./authHooks.ts"; import { useGlobalContext } from "../../../src/contexts/GlobalContext.tsx"; @@ -7,31 +6,12 @@ interface LoginFormProps { isAuthenticated: boolean; } -const Header: React.FC = ({ isAuthenticated }) => { - const [showLoginMenu, setShowLoginMenu] = useState(false); +const Header: React.FC = () => { const location = useLocation(); const { setShowMetaPanel } = useGlobalContext(); const isOnDrugSummaryPage = location.pathname.includes("/drugsummary"); - useEffect(() => { - // only show the login menu on non‑auth pages - if (!isAuthenticated) { - const path = location.pathname; - const isAuthPage = - path === "/login" || - path === "/resetpassword" || - path.includes("password") || - path.includes("reset"); - - setShowLoginMenu(!isAuthPage); - } - }, [isAuthenticated, location.pathname]); - - const handleLoginMenu = () => { - setShowLoginMenu((prev) => !prev); - }; - useAuth(); return ( diff --git a/frontend/src/pages/Login/LoginForm.tsx b/frontend/src/pages/Login/LoginForm.tsx index 97bcdbe5..ce28c62c 100644 --- a/frontend/src/pages/Login/LoginForm.tsx +++ b/frontend/src/pages/Login/LoginForm.tsx @@ -70,7 +70,7 @@ function LoginForm({ isAuthenticated, loginError }: LoginFormProps) {
    -

    This login is for Code for Philly administrators. Providers can use all site features without logging in. Return to Homepage

    +

    This login is for Code for Philly administrators. Providers can use all site features without logging in. Return to Homepage

    diff --git a/frontend/src/pages/Logout/Logout.tsx b/frontend/src/pages/Logout/Logout.tsx new file mode 100644 index 00000000..b09f0ca3 --- /dev/null +++ b/frontend/src/pages/Logout/Logout.tsx @@ -0,0 +1,42 @@ +import { useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useDispatch } from "react-redux"; +import { logout, AppDispatch } from "../../services/actions/auth"; + +const LogoutPage = () => { + const navigate = useNavigate(); + const dispatch = useDispatch(); + + useEffect(() => { + dispatch(logout()); + + const timer = setTimeout(() => { + navigate('/'); + }, 3000); // Redirect after 3 seconds + + // Cleanup the timer on component unmount + return () => clearTimeout(timer); + }, [dispatch, navigate]); + + return ( +
    +
    +

    You’ve been logged out

    +
    +
    +
    +

    + Thank you for using Balancer. You'll be redirected to the homepage shortly. +

    + +
    +
    + ); +}; + +export default LogoutPage; diff --git a/frontend/src/routes/routes.tsx b/frontend/src/routes/routes.tsx index 2e6273d4..f96f2574 100644 --- a/frontend/src/routes/routes.tsx +++ b/frontend/src/routes/routes.tsx @@ -1,6 +1,7 @@ import App from "../App"; import RouteError from "../pages/404/404.tsx"; import LoginForm from "../pages/Login/Login.tsx"; +import Logout from "../pages/Logout/Logout.tsx"; import AdminPortal from "../pages/AdminPortal/AdminPortal.tsx"; import ResetPassword from "../pages/Login/ResetPassword.tsx"; import ResetPasswordConfirm from "../pages/Login/ResetPasswordConfirm.tsx"; @@ -50,6 +51,10 @@ const routes = [ path: "login", element: , }, + { + path: "logout", + element: , + }, { path: "resetPassword", element: , diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js index bcc1e693..4161a741 100644 --- a/frontend/tailwind.config.js +++ b/frontend/tailwind.config.js @@ -10,8 +10,15 @@ export default { lora: "'Lora', serif", 'quicksand': ['Quicksand', 'sans-serif'] }, + keyframes: { + 'loading': { + '0%': { left: '-40%' }, + '100%': { left: '100%' }, + }, + }, animation: { - 'pulse-bounce': 'pulse-bounce 2s infinite', // Adjust duration and iteration as needed + 'pulse-bounce': 'pulse-bounce 2s infinite', + 'loading': 'loading 3s infinite', }, plugins: [], }, From 28834711023046af4d938c6a7c9bd477e77b097f Mon Sep 17 00:00:00 2001 From: Gregg Stubberfield Date: Sun, 24 Aug 2025 20:48:35 -0400 Subject: [PATCH 06/35] feat: #309 - Separate public and admin api calls --- frontend/src/api/apiClient.ts | 28 +++++++++++-------- frontend/src/pages/Files/ListOfFiles.tsx | 8 +++--- .../src/pages/ListMeds/useMedications.tsx | 4 +-- frontend/src/pages/ManageMeds/ManageMeds.tsx | 8 +++--- .../pages/PatientManager/NewPatientForm.tsx | 4 +-- .../src/pages/RulesManager/RulesManager.tsx | 4 +-- 6 files changed, 30 insertions(+), 26 deletions(-) diff --git a/frontend/src/api/apiClient.ts b/frontend/src/api/apiClient.ts index 73b74caf..0b48496b 100644 --- a/frontend/src/api/apiClient.ts +++ b/frontend/src/api/apiClient.ts @@ -3,7 +3,11 @@ import { FormValues } from "../pages/Feedback/FeedbackForm"; import { Conversation } from "../components/Header/Chat"; const baseURL = import.meta.env.VITE_API_BASE_URL; -export const api = axios.create({ +export const publicApi = axios.create({ + baseURL +}); + +export const adminApi = axios.create({ baseURL, headers: { Authorization: `JWT ${localStorage.getItem("access")}`, @@ -11,7 +15,7 @@ export const api = axios.create({ }); // Request interceptor to set the Authorization header -api.interceptors.request.use( +adminApi.interceptors.request.use( (configuration) => { const token = localStorage.getItem("access"); if (token) { @@ -29,7 +33,7 @@ const handleSubmitFeedback = async ( message: FormValues["message"], ) => { try { - const response = await api.post(`/v1/api/feedback/`, { + const response = await publicApi.post(`/v1/api/feedback/`, { feedbacktype: feedbackType, name, email, @@ -45,7 +49,7 @@ const handleSubmitFeedback = async ( const handleSendDrugSummary = async (message: FormValues["message"], guid: string) => { try { const endpoint = guid ? `/v1/api/embeddings/ask_embeddings?guid=${guid}` : '/v1/api/embeddings/ask_embeddings'; - const response = await api.post(endpoint, { + const response = await adminApi.post(endpoint, { message, }); console.log("Response data:", JSON.stringify(response.data, null, 2)); @@ -58,7 +62,7 @@ const handleSendDrugSummary = async (message: FormValues["message"], guid: strin const handleRuleExtraction = async (guid: string) => { try { - const response = await api.get(`/v1/api/rule_extraction_openai?guid=${guid}`); + const response = await adminApi.get(`/v1/api/rule_extraction_openai?guid=${guid}`); // console.log("Rule extraction response:", JSON.stringify(response.data, null, 2)); return response.data; } catch (error) { @@ -69,7 +73,7 @@ const handleRuleExtraction = async (guid: string) => { const fetchRiskDataWithSources = async (medication: string, source: "include" | "diagnosis" = "include") => { try { - const response = await api.post(`/v1/api/riskWithSources`, { + const response = await adminApi.post(`/v1/api/riskWithSources`, { drug: medication, source: source, }); @@ -192,7 +196,7 @@ const handleSendDrugSummaryStreamLegacy = async ( const fetchConversations = async (): Promise => { try { - const response = await api.get(`/chatgpt/conversations/`); + const response = await publicApi.get(`/chatgpt/conversations/`); return response.data; } catch (error) { console.error("Error(s) during getConversations: ", error); @@ -202,7 +206,7 @@ const fetchConversations = async (): Promise => { const fetchConversation = async (id: string): Promise => { try { - const response = await api.get(`/chatgpt/conversations/${id}/`); + const response = await publicApi.get(`/chatgpt/conversations/${id}/`); return response.data; } catch (error) { console.error("Error(s) during getConversation: ", error); @@ -212,7 +216,7 @@ const fetchConversation = async (id: string): Promise => { const newConversation = async (): Promise => { try { - const response = await api.post(`/chatgpt/conversations/`, { + const response = await publicApi.post(`/chatgpt/conversations/`, { messages: [], }); return response.data; @@ -228,7 +232,7 @@ const continueConversation = async ( page_context?: string, ): Promise<{ response: string; title: Conversation["title"] }> => { try { - const response = await api.post( + const response = await publicApi.post( `/chatgpt/conversations/${id}/continue_conversation/`, { message, @@ -244,7 +248,7 @@ const continueConversation = async ( const deleteConversation = async (id: string) => { try { - const response = await api.delete(`/chatgpt/conversations/${id}/`); + const response = await publicApi.delete(`/chatgpt/conversations/${id}/`); return response.data; } catch (error) { console.error("Error(s) during deleteConversation: ", error); @@ -257,7 +261,7 @@ const updateConversationTitle = async ( newTitle: Conversation["title"], ): Promise<{status: string, title: Conversation["title"]} | {error: string}> => { try { - const response = await api.patch(`/chatgpt/conversations/${id}/update_title/`, { + const response = await publicApi.patch(`/chatgpt/conversations/${id}/update_title/`, { title: newTitle, }); return response.data; diff --git a/frontend/src/pages/Files/ListOfFiles.tsx b/frontend/src/pages/Files/ListOfFiles.tsx index b53874bf..2a579bdc 100644 --- a/frontend/src/pages/Files/ListOfFiles.tsx +++ b/frontend/src/pages/Files/ListOfFiles.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect } from "react"; -import { api } from "../../api/apiClient"; +import { adminApi } from "../../api/apiClient"; import Layout from "../Layout/Layout"; import FileRow from "./FileRow"; import Table from "../../components/Table/Table"; @@ -37,7 +37,7 @@ const ListOfFiles: React.FC<{ showTable?: boolean }> = ({ try { const url = `${baseUrl}/v1/api/uploadFile`; - const { data } = await api.get(url); + const { data } = await adminApi.get(url); if (Array.isArray(data)) { setFiles(data); @@ -63,7 +63,7 @@ const ListOfFiles: React.FC<{ showTable?: boolean }> = ({ const handleDownload = async (guid: string, fileName: string) => { try { setDownloading(guid); - const { data } = await api.get(`/v1/api/uploadFile/${guid}`, { responseType: 'blob' }); + const { data } = await adminApi.get(`/v1/api/uploadFile/${guid}`, { responseType: 'blob' }); const url = window.URL.createObjectURL(new Blob([data])); const link = document.createElement("a"); @@ -84,7 +84,7 @@ const ListOfFiles: React.FC<{ showTable?: boolean }> = ({ const handleOpen = async (guid: string) => { try { setOpening(guid); - const { data } = await api.get(`/v1/api/uploadFile/${guid}`, { responseType: 'arraybuffer' }); + const { data } = await adminApi.get(`/v1/api/uploadFile/${guid}`, { responseType: 'arraybuffer' }); const file = new Blob([data], { type: 'application/pdf' }); const fileURL = window.URL.createObjectURL(file); diff --git a/frontend/src/pages/ListMeds/useMedications.tsx b/frontend/src/pages/ListMeds/useMedications.tsx index e15cc758..022eb07a 100644 --- a/frontend/src/pages/ListMeds/useMedications.tsx +++ b/frontend/src/pages/ListMeds/useMedications.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from "react"; -import { api } from "../../api/apiClient"; +import { publicApi } from "../../api/apiClient"; export interface MedData { name: string; @@ -18,7 +18,7 @@ export function useMedications() { try { const url = `${baseUrl}/v1/api/get_full_list_med`; - const { data } = await api.get(url); + const { data } = await publicApi.get(url); data.sort((a: MedData, b: MedData) => { const nameA = a.name.toUpperCase(); diff --git a/frontend/src/pages/ManageMeds/ManageMeds.tsx b/frontend/src/pages/ManageMeds/ManageMeds.tsx index 071a2690..23493f7e 100644 --- a/frontend/src/pages/ManageMeds/ManageMeds.tsx +++ b/frontend/src/pages/ManageMeds/ManageMeds.tsx @@ -2,7 +2,7 @@ import { useState, useEffect } from "react"; import Layout from "../Layout/Layout"; import Welcome from "../../components/Welcome/Welcome"; import ErrorMessage from "../../components/ErrorMessage"; -import { api } from "../../api/apiClient"; +import { adminApi } from "../../api/apiClient"; function ManageMedications() { interface MedData { id: string; @@ -23,7 +23,7 @@ function ManageMedications() { const fetchMedications = async () => { try { const url = `${baseUrl}/v1/api/get_full_list_med`; - const { data } = await api.get(url); + const { data } = await adminApi.get(url); data.sort((a: MedData, b: MedData) => a.name.localeCompare(b.name)); setMedications(data); } catch (e: unknown) { @@ -36,7 +36,7 @@ function ManageMedications() { // Handle Delete Medication const handleDelete = async (name: string) => { try { - await api.delete(`${baseUrl}/v1/api/delete_med`, { data: { name } }); + await adminApi.delete(`${baseUrl}/v1/api/delete_med`, { data: { name } }); setMedications((prev) => prev.filter((med) => med.name !== name)); setConfirmDelete(null); } catch (e: unknown) { @@ -56,7 +56,7 @@ function ManageMedications() { return; } try { - await api.post(`${baseUrl}/v1/api/add_medication`, { + await adminApi.post(`${baseUrl}/v1/api/add_medication`, { name: newMedName, benefits: newMedBenefits, risks: newMedRisks, diff --git a/frontend/src/pages/PatientManager/NewPatientForm.tsx b/frontend/src/pages/PatientManager/NewPatientForm.tsx index 774ebcb3..16143fdb 100644 --- a/frontend/src/pages/PatientManager/NewPatientForm.tsx +++ b/frontend/src/pages/PatientManager/NewPatientForm.tsx @@ -4,7 +4,7 @@ import { PatientInfo, Diagnosis } from "./PatientTypes"; import { useMedications } from "../ListMeds/useMedications"; import ChipsInput from "../../components/ChipsInput/ChipsInput"; import Tooltip from "../../components/Tooltip"; -import { api } from "../../api/apiClient"; +import { publicApi } from "../../api/apiClient"; import { useGlobalContext } from "../../contexts/GlobalContext.tsx"; // import ErrorMessage from "../../components/ErrorMessage"; @@ -155,7 +155,7 @@ const NewPatientForm = ({ const baseUrl = import.meta.env.VITE_API_BASE_URL; const url = `${baseUrl}/v1/api/get_med_recommend`; - const { data } = await api.post(url, payload); + const { data } = await publicApi.post(url, payload); const categorizedMedications = { first: data.first ?? [], diff --git a/frontend/src/pages/RulesManager/RulesManager.tsx b/frontend/src/pages/RulesManager/RulesManager.tsx index be4980d4..0268a4c8 100644 --- a/frontend/src/pages/RulesManager/RulesManager.tsx +++ b/frontend/src/pages/RulesManager/RulesManager.tsx @@ -2,7 +2,7 @@ import React, { useState, useEffect } from "react"; import Layout from "../Layout/Layout"; import Welcome from "../../components/Welcome/Welcome"; import ErrorMessage from "../../components/ErrorMessage"; -import { api } from "../../api/apiClient"; +import { adminApi } from "../../api/apiClient"; import { ChevronDown, ChevronUp } from "lucide-react"; interface Medication { @@ -69,7 +69,7 @@ function RulesManager() { const fetchMedRules = async () => { try { const url = `${baseUrl}/v1/api/medRules`; - const { data } = await api.get(url); + const { data } = await adminApi.get(url); if (!data || !Array.isArray(data.results)) { throw new Error("Invalid response format"); From 8c4036eb3df48d6d30e6e34ac06503d5582a867a Mon Sep 17 00:00:00 2001 From: Gregg Stubberfield Date: Mon, 25 Aug 2025 20:34:02 -0400 Subject: [PATCH 07/35] feat: #309 - Disable JWT auth for newly public endpoints --- frontend/src/api/apiClient.ts | 4 +--- server/api/views/conversations/views.py | 4 ++-- server/api/views/feedback/views.py | 4 +++- server/api/views/listMeds/views.py | 13 +++++++++---- 4 files changed, 15 insertions(+), 10 deletions(-) diff --git a/frontend/src/api/apiClient.ts b/frontend/src/api/apiClient.ts index 0b48496b..5e4a5eb6 100644 --- a/frontend/src/api/apiClient.ts +++ b/frontend/src/api/apiClient.ts @@ -3,9 +3,7 @@ import { FormValues } from "../pages/Feedback/FeedbackForm"; import { Conversation } from "../components/Header/Chat"; const baseURL = import.meta.env.VITE_API_BASE_URL; -export const publicApi = axios.create({ - baseURL -}); +export const publicApi = axios.create({ baseURL }); export const adminApi = axios.create({ baseURL, diff --git a/server/api/views/conversations/views.py b/server/api/views/conversations/views.py index d5921eaf..eeb68809 100644 --- a/server/api/views/conversations/views.py +++ b/server/api/views/conversations/views.py @@ -1,7 +1,7 @@ from rest_framework.response import Response from rest_framework import viewsets, status from rest_framework.decorators import action -from rest_framework.permissions import IsAuthenticated +from rest_framework.permissions import AllowAny from rest_framework.exceptions import APIException from django.http import JsonResponse from bs4 import BeautifulSoup @@ -81,7 +81,7 @@ def __init__(self, detail=None, code=None): class ConversationViewSet(viewsets.ModelViewSet): serializer_class = ConversationSerializer - permission_classes = [IsAuthenticated] + permission_classes = [AllowAny] def get_queryset(self): return Conversation.objects.filter(user=self.request.user) diff --git a/server/api/views/feedback/views.py b/server/api/views/feedback/views.py index dcbef992..d0f0e1da 100644 --- a/server/api/views/feedback/views.py +++ b/server/api/views/feedback/views.py @@ -1,4 +1,4 @@ - +from rest_framework.permissions import AllowAny from rest_framework.views import APIView from rest_framework.response import Response from rest_framework import status @@ -8,6 +8,8 @@ class FeedbackView(APIView): + permission_classes = [AllowAny] + def post(self, request, *args, **kwargs): serializer = FeedbackSerializer(data=request.data) if serializer.is_valid(): diff --git a/server/api/views/listMeds/views.py b/server/api/views/listMeds/views.py index 796d9b17..d10a385a 100644 --- a/server/api/views/listMeds/views.py +++ b/server/api/views/listMeds/views.py @@ -1,4 +1,5 @@ from rest_framework import status +from rest_framework.permissions import AllowAny from rest_framework.response import Response from rest_framework.views import APIView @@ -21,6 +22,8 @@ class GetMedication(APIView): + permission_classes = [AllowAny] + def post(self, request): data = request.data state_query = data.get('state', '') @@ -67,6 +70,8 @@ def post(self, request): class ListOrDetailMedication(APIView): + permission_classes = [AllowAny] + def get(self, request): name_query = request.query_params.get('name', None) if name_query: @@ -95,7 +100,7 @@ def post(self, request): name = data.get('name', '').strip() benefits = data.get('benefits', '').strip() risks = data.get('risks', '').strip() - + # Validate required fields if not name: return Response({'error': 'Medication name is required'}, status=status.HTTP_400_BAD_REQUEST) @@ -103,7 +108,7 @@ def post(self, request): return Response({'error': 'Medication benefits are required'}, status=status.HTTP_400_BAD_REQUEST) if not risks: return Response({'error': 'Medication risks are required'}, status=status.HTTP_400_BAD_REQUEST) - + # Check if medication already exists if Medication.objects.filter(name=name).exists(): return Response({'error': f'Medication "{name}" already exists'}, status=status.HTTP_400_BAD_REQUEST) @@ -123,11 +128,11 @@ class DeleteMedication(APIView): def delete(self, request): data = request.data name = data.get('name', '').strip() - + # Validate required fields if not name: return Response({'error': 'Medication name is required'}, status=status.HTTP_400_BAD_REQUEST) - + # Check if medication exists and delete try: medication = Medication.objects.get(name=name) From 3be42d380baf6a6de26dea9be9ccb028bdf4d3ec Mon Sep 17 00:00:00 2001 From: Gregg Stubberfield Date: Mon, 25 Aug 2025 20:39:54 -0400 Subject: [PATCH 08/35] feat: #309 - Make riskWithSources endpoint public --- frontend/src/api/apiClient.ts | 2 +- server/api/views/risk/views_riskWithSources.py | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/frontend/src/api/apiClient.ts b/frontend/src/api/apiClient.ts index 5e4a5eb6..a1d32318 100644 --- a/frontend/src/api/apiClient.ts +++ b/frontend/src/api/apiClient.ts @@ -71,7 +71,7 @@ const handleRuleExtraction = async (guid: string) => { const fetchRiskDataWithSources = async (medication: string, source: "include" | "diagnosis" = "include") => { try { - const response = await adminApi.post(`/v1/api/riskWithSources`, { + const response = await publicApi.post(`/v1/api/riskWithSources`, { drug: medication, source: source, }); diff --git a/server/api/views/risk/views_riskWithSources.py b/server/api/views/risk/views_riskWithSources.py index d1c01615..94076c5c 100644 --- a/server/api/views/risk/views_riskWithSources.py +++ b/server/api/views/risk/views_riskWithSources.py @@ -1,6 +1,7 @@ from rest_framework.views import APIView from rest_framework.response import Response from rest_framework import status +from rest_framework.permissions import AllowAny from api.views.listMeds.models import Medication from api.models.model_medRule import MedRule, MedRuleSource import openai @@ -8,6 +9,8 @@ class RiskWithSourcesView(APIView): + permission_classes = [AllowAny] + def post(self, request): openai.api_key = os.environ.get("OPENAI_API_KEY") From 789442bc2ae3ff25954a44652e30d299cda62244 Mon Sep 17 00:00:00 2001 From: Gregg Stubberfield Date: Tue, 26 Aug 2025 19:44:58 -0400 Subject: [PATCH 09/35] feat: #309 - Temporarily restrict chatbot to admins only --- frontend/src/api/apiClient.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/src/api/apiClient.ts b/frontend/src/api/apiClient.ts index a1d32318..a594f921 100644 --- a/frontend/src/api/apiClient.ts +++ b/frontend/src/api/apiClient.ts @@ -214,7 +214,7 @@ const fetchConversation = async (id: string): Promise => { const newConversation = async (): Promise => { try { - const response = await publicApi.post(`/chatgpt/conversations/`, { + const response = await adminApi.post(`/chatgpt/conversations/`, { messages: [], }); return response.data; @@ -230,7 +230,7 @@ const continueConversation = async ( page_context?: string, ): Promise<{ response: string; title: Conversation["title"] }> => { try { - const response = await publicApi.post( + const response = await adminApi.post( `/chatgpt/conversations/${id}/continue_conversation/`, { message, @@ -246,7 +246,7 @@ const continueConversation = async ( const deleteConversation = async (id: string) => { try { - const response = await publicApi.delete(`/chatgpt/conversations/${id}/`); + const response = await adminApi.delete(`/chatgpt/conversations/${id}/`); return response.data; } catch (error) { console.error("Error(s) during deleteConversation: ", error); @@ -259,7 +259,7 @@ const updateConversationTitle = async ( newTitle: Conversation["title"], ): Promise<{status: string, title: Conversation["title"]} | {error: string}> => { try { - const response = await publicApi.patch(`/chatgpt/conversations/${id}/update_title/`, { + const response = await adminApi.patch(`/chatgpt/conversations/${id}/update_title/`, { title: newTitle, }); return response.data; From 0986a172df7fc3e0c72573d5edf5cd8384829f69 Mon Sep 17 00:00:00 2001 From: Gregg Stubberfield Date: Tue, 26 Aug 2025 20:43:18 -0400 Subject: [PATCH 10/35] feat: #309 - Allow viewing and downloading of uploads to be public; edits remain admin only --- .../src/pages/DocumentManager/UploadFile.tsx | 3 +-- frontend/src/pages/Files/ListOfFiles.tsx | 8 ++++---- .../src/pages/Layout/Layout_V2_Sidebar.tsx | 6 +----- server/api/views/uploadFile/views.py | 19 ++++++++----------- 4 files changed, 14 insertions(+), 22 deletions(-) diff --git a/frontend/src/pages/DocumentManager/UploadFile.tsx b/frontend/src/pages/DocumentManager/UploadFile.tsx index 35c4b84f..f3d0f477 100644 --- a/frontend/src/pages/DocumentManager/UploadFile.tsx +++ b/frontend/src/pages/DocumentManager/UploadFile.tsx @@ -28,8 +28,7 @@ const UploadFile: React.FC = () => { formData, { headers: { - "Content-Type": "multipart/form-data", - Authorization: `JWT ${localStorage.getItem("access")}`, // Assuming JWT is used for auth + "Content-Type": "multipart/form-data" }, } ); diff --git a/frontend/src/pages/Files/ListOfFiles.tsx b/frontend/src/pages/Files/ListOfFiles.tsx index 2a579bdc..efed19e5 100644 --- a/frontend/src/pages/Files/ListOfFiles.tsx +++ b/frontend/src/pages/Files/ListOfFiles.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect } from "react"; -import { adminApi } from "../../api/apiClient"; +import { publicApi } from "../../api/apiClient"; import Layout from "../Layout/Layout"; import FileRow from "./FileRow"; import Table from "../../components/Table/Table"; @@ -37,7 +37,7 @@ const ListOfFiles: React.FC<{ showTable?: boolean }> = ({ try { const url = `${baseUrl}/v1/api/uploadFile`; - const { data } = await adminApi.get(url); + const { data } = await publicApi.get(url); if (Array.isArray(data)) { setFiles(data); @@ -63,7 +63,7 @@ const ListOfFiles: React.FC<{ showTable?: boolean }> = ({ const handleDownload = async (guid: string, fileName: string) => { try { setDownloading(guid); - const { data } = await adminApi.get(`/v1/api/uploadFile/${guid}`, { responseType: 'blob' }); + const { data } = await publicApi.get(`/v1/api/uploadFile/${guid}`, { responseType: 'blob' }); const url = window.URL.createObjectURL(new Blob([data])); const link = document.createElement("a"); @@ -84,7 +84,7 @@ const ListOfFiles: React.FC<{ showTable?: boolean }> = ({ const handleOpen = async (guid: string) => { try { setOpening(guid); - const { data } = await adminApi.get(`/v1/api/uploadFile/${guid}`, { responseType: 'arraybuffer' }); + const { data } = await publicApi.get(`/v1/api/uploadFile/${guid}`, { responseType: 'arraybuffer' }); const file = new Blob([data], { type: 'application/pdf' }); const fileURL = window.URL.createObjectURL(file); diff --git a/frontend/src/pages/Layout/Layout_V2_Sidebar.tsx b/frontend/src/pages/Layout/Layout_V2_Sidebar.tsx index 19163290..bec32d50 100644 --- a/frontend/src/pages/Layout/Layout_V2_Sidebar.tsx +++ b/frontend/src/pages/Layout/Layout_V2_Sidebar.tsx @@ -25,11 +25,7 @@ const Sidebar: React.FC = () => { const fetchFiles = async () => { try { const baseUrl = import.meta.env.VITE_API_BASE_URL; - const response = await axios.get(`${baseUrl}/v1/api/uploadFile`, { - headers: { - Authorization: `JWT ${localStorage.getItem("access")}`, - }, - }); + const response = await axios.get(`${baseUrl}/v1/api/uploadFile`); if (Array.isArray(response.data)) { setFiles(response.data); } diff --git a/server/api/views/uploadFile/views.py b/server/api/views/uploadFile/views.py index 8989dbc3..003e171e 100644 --- a/server/api/views/uploadFile/views.py +++ b/server/api/views/uploadFile/views.py @@ -1,5 +1,5 @@ from rest_framework.views import APIView -from rest_framework.permissions import IsAuthenticated +from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.response import Response from rest_framework import status from rest_framework.generics import UpdateAPIView @@ -18,17 +18,15 @@ @method_decorator(csrf_exempt, name='dispatch') class UploadFileView(APIView): - permission_classes = [IsAuthenticated] + def get_permissions(self): + if self.request.method == 'GET': + return [AllowAny()] # Public access + return [IsAuthenticated()] # Auth required for other methods def get(self, request, format=None): print("UploadFileView, get list") - # Get the authenticated user - user = request.user - - # Filter the files uploaded by the authenticated user - files = UploadFile.objects.filter(uploaded_by=user.id).defer( - 'file').order_by('-date_of_upload') + files = UploadFile.objects.all().defer('file').order_by('-date_of_upload') serializer = UploadFileSerializer(files, many=True) return Response(serializer.data) @@ -160,12 +158,11 @@ def delete(self, request, format=None): @method_decorator(csrf_exempt, name='dispatch') class RetrieveUploadFileView(APIView): - permission_classes = [IsAuthenticated] + permission_classes = [AllowAny] def get(self, request, guid, format=None): try: - file = UploadFile.objects.get( - guid=guid, uploaded_by=request.user.id) + file = UploadFile.objects.get(guid=guid) response = HttpResponse(file.file, content_type='application/pdf') # print(file.file[:100]) response['Content-Disposition'] = f'attachment; filename="{file.file_name}"' From c8a605c4fe4afcdc2f0763437b8ca721adb64457 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Thu, 30 Oct 2025 17:33:25 -0400 Subject: [PATCH 11/35] WIP --- frontend/src/api/apiClient.ts | 62 ++++++++++++++++++-------- frontend/src/services/actions/auth.tsx | 6 +-- server/api/views/assistant/views.py | 4 +- 3 files changed, 49 insertions(+), 23 deletions(-) diff --git a/frontend/src/api/apiClient.ts b/frontend/src/api/apiClient.ts index 26a6ab8a..857c1520 100644 --- a/frontend/src/api/apiClient.ts +++ b/frontend/src/api/apiClient.ts @@ -3,7 +3,9 @@ import { FormValues } from "../pages/Feedback/FeedbackForm"; import { Conversation } from "../components/Header/Chat"; const baseURL = import.meta.env.VITE_API_BASE_URL; -export const api = axios.create({ +export const publicApi = axios.create({ baseURL }); + +export const adminApi = axios.create({ baseURL, headers: { Authorization: `JWT ${localStorage.getItem("access")}`, @@ -11,7 +13,7 @@ export const api = axios.create({ }); // Request interceptor to set the Authorization header -api.interceptors.request.use( +adminApi.interceptors.request.use( (configuration) => { const token = localStorage.getItem("access"); if (token) { @@ -42,9 +44,14 @@ const handleSubmitFeedback = async ( } }; -const handleSendDrugSummary = async (message: FormValues["message"], guid: string) => { +const handleSendDrugSummary = async ( + message: FormValues["message"], + guid: string, +) => { try { - const endpoint = guid ? `/v1/api/embeddings/ask_embeddings?guid=${guid}` : '/v1/api/embeddings/ask_embeddings'; + const endpoint = guid + ? `/v1/api/embeddings/ask_embeddings?guid=${guid}` + : "/v1/api/embeddings/ask_embeddings"; const response = await api.post(endpoint, { message, }); @@ -58,7 +65,9 @@ const handleSendDrugSummary = async (message: FormValues["message"], guid: strin const handleRuleExtraction = async (guid: string) => { try { - const response = await api.get(`/v1/api/rule_extraction_openai?guid=${guid}`); + const response = await api.get( + `/v1/api/rule_extraction_openai?guid=${guid}`, + ); // console.log("Rule extraction response:", JSON.stringify(response.data, null, 2)); return response.data; } catch (error) { @@ -67,7 +76,10 @@ const handleRuleExtraction = async (guid: string) => { } }; -const fetchRiskDataWithSources = async (medication: string, source: "include" | "diagnosis" | "diagnosis_depressed" = "include") => { +const fetchRiskDataWithSources = async ( + medication: string, + source: "include" | "diagnosis" | "diagnosis_depressed" = "include", +) => { try { const response = await api.post(`/v1/api/riskWithSources`, { drug: medication, @@ -90,7 +102,7 @@ interface StreamCallbacks { const handleSendDrugSummaryStream = async ( message: string, guid: string, - callbacks: StreamCallbacks + callbacks: StreamCallbacks, ): Promise => { const token = localStorage.getItem("access"); const endpoint = `/v1/api/embeddings/ask_embeddings?stream=true${ @@ -165,12 +177,18 @@ const handleSendDrugSummaryStream = async ( } } } catch (parseError) { - console.error("Failed to parse SSE data:", parseError, "Raw line:", line); + console.error( + "Failed to parse SSE data:", + parseError, + "Raw line:", + line, + ); } } } } catch (error) { - const errorMessage = error instanceof Error ? error.message : "Unknown error"; + const errorMessage = + error instanceof Error ? error.message : "Unknown error"; console.error("Error in stream:", errorMessage); callbacks.onError?.(errorMessage); throw error; @@ -186,7 +204,7 @@ const handleSendDrugSummaryStreamLegacy = async ( return handleSendDrugSummaryStream(message, guid, { onContent: onChunk, onError: (error) => console.error("Stream error:", error), - onComplete: () => console.log("Stream completed") + onComplete: () => console.log("Stream completed"), }); }; @@ -255,11 +273,16 @@ const deleteConversation = async (id: string) => { const updateConversationTitle = async ( id: Conversation["id"], newTitle: Conversation["title"], -): Promise<{status: string, title: Conversation["title"]} | {error: string}> => { +): Promise< + { status: string; title: Conversation["title"] } | { error: string } +> => { try { - const response = await api.patch(`/chatgpt/conversations/${id}/update_title/`, { - title: newTitle, - }); + const response = await api.patch( + `/chatgpt/conversations/${id}/update_title/`, + { + title: newTitle, + }, + ); return response.data; } catch (error) { console.error("Error(s) during getConversation: ", error); @@ -268,9 +291,12 @@ const updateConversationTitle = async ( }; // Assistant API functions -const sendAssistantMessage = async (message: string, previousResponseId?: string) => { +const sendAssistantMessage = async ( + message: string, + previousResponseId?: string, +) => { try { - const response = await api.post(`/v1/api/assistant`, { + const response = await publicApi.post(`/v1/api/assistant`, { message, previous_response_id: previousResponseId, }); @@ -294,5 +320,5 @@ export { handleSendDrugSummaryStream, handleSendDrugSummaryStreamLegacy, fetchRiskDataWithSources, - sendAssistantMessage -}; \ No newline at end of file + sendAssistantMessage, +}; diff --git a/frontend/src/services/actions/auth.tsx b/frontend/src/services/actions/auth.tsx index 2573c223..189ca4a4 100644 --- a/frontend/src/services/actions/auth.tsx +++ b/frontend/src/services/actions/auth.tsx @@ -170,8 +170,8 @@ export const login = export const logout = () => async (dispatch: AppDispatch) => { // Clear chat conversation data on logout for security - sessionStorage.removeItem('currentConversation'); - + sessionStorage.removeItem("currentConversation"); + dispatch({ type: LOGOUT, }); @@ -207,7 +207,7 @@ export const reset_password_confirm = uid: string, token: string, new_password: string, - re_new_password: string + re_new_password: string, ): ThunkType => async (dispatch: AppDispatch) => { const config = { diff --git a/server/api/views/assistant/views.py b/server/api/views/assistant/views.py index 32089c58..67ba8a56 100644 --- a/server/api/views/assistant/views.py +++ b/server/api/views/assistant/views.py @@ -7,7 +7,7 @@ from rest_framework.views import APIView from rest_framework.response import Response from rest_framework import status -from rest_framework.permissions import IsAuthenticated +from rest_framework.permissions import AllowAny from django.utils.decorators import method_decorator from django.views.decorators.csrf import csrf_exempt @@ -111,7 +111,7 @@ def invoke_functions_from_response( @method_decorator(csrf_exempt, name="dispatch") class Assistant(APIView): - permission_classes = [IsAuthenticated] + permission_classes = [AllowAny] def post(self, request): try: From df27961063d4772cb460c49f386a1a49c00131db Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Wed, 5 Nov 2025 16:22:41 -0500 Subject: [PATCH 12/35] WIP --- server/api/services/embedding_services.py | 31 +++++++++++++++-------- 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/server/api/services/embedding_services.py b/server/api/services/embedding_services.py index 6fd34d35..0f65b7b1 100644 --- a/server/api/services/embedding_services.py +++ b/server/api/services/embedding_services.py @@ -1,5 +1,4 @@ -# services/embedding_services.py - +from django.db.models import Q from pgvector.django import L2Distance from .sentencetTransformer_model import TransformerModel @@ -39,17 +38,29 @@ def get_closest_embeddings( - file_id: GUID of the source file """ - # transformerModel = TransformerModel.get_instance().model embedding_message = transformerModel.encode(message_data) - # Start building the query based on the message's embedding - closest_embeddings_query = ( - Embeddings.objects.filter(upload_file__uploaded_by=user) - .annotate( - distance=L2Distance("embedding_sentence_transformers", embedding_message) + + if user and user.is_authenticated: + # User sees their own files + files uploaded by superusers + closest_embeddings_query = ( + Embeddings.objects.filter( + Q(upload_file__uploaded_by=user) | Q(upload_file__uploaded_by__is_superuser=True) + ) + .annotate( + distance=L2Distance("embedding_sentence_transformers", embedding_message) + ) + .order_by("distance") + ) + else: + # Unauthenticated users only see superuser-uploaded files + closest_embeddings_query = ( + Embeddings.objects.filter(upload_file__uploaded_by__is_superuser=True) + .annotate( + distance=L2Distance("embedding_sentence_transformers", embedding_message) + ) + .order_by("distance") ) - .order_by("distance") - ) # Filter by GUID if provided, otherwise filter by document name if provided if guid: From d617fc5759062b7f0bfafdc15fdaa0d44965386c Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Thu, 13 Nov 2025 14:49:43 -0500 Subject: [PATCH 13/35] Revert formatting changes automatically made by Zed --- frontend/src/services/actions/auth.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/services/actions/auth.tsx b/frontend/src/services/actions/auth.tsx index 189ca4a4..2573c223 100644 --- a/frontend/src/services/actions/auth.tsx +++ b/frontend/src/services/actions/auth.tsx @@ -170,8 +170,8 @@ export const login = export const logout = () => async (dispatch: AppDispatch) => { // Clear chat conversation data on logout for security - sessionStorage.removeItem("currentConversation"); - + sessionStorage.removeItem('currentConversation'); + dispatch({ type: LOGOUT, }); @@ -207,7 +207,7 @@ export const reset_password_confirm = uid: string, token: string, new_password: string, - re_new_password: string, + re_new_password: string ): ThunkType => async (dispatch: AppDispatch) => { const config = { From ffe1d576e341992277c0db67fced75fd3a4e91e0 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Thu, 13 Nov 2025 15:19:49 -0500 Subject: [PATCH 14/35] Update embedding_services.py --- server/api/services/embedding_services.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/api/services/embedding_services.py b/server/api/services/embedding_services.py index 0f65b7b1..b50dd750 100644 --- a/server/api/services/embedding_services.py +++ b/server/api/services/embedding_services.py @@ -41,7 +41,7 @@ def get_closest_embeddings( transformerModel = TransformerModel.get_instance().model embedding_message = transformerModel.encode(message_data) - if user and user.is_authenticated: + if user.is_authenticated: # User sees their own files + files uploaded by superusers closest_embeddings_query = ( Embeddings.objects.filter( From 456eb4236fd962a699870155734ea40fb38045d5 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Mon, 1 Dec 2025 18:22:25 -0500 Subject: [PATCH 15/35] Remove auth requirement for Chat in both nav files --- frontend/src/components/Header/Header.tsx | 6 +----- frontend/src/components/Header/MdNavBar.tsx | 4 +--- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/frontend/src/components/Header/Header.tsx b/frontend/src/components/Header/Header.tsx index ac39544e..6a983a08 100644 --- a/frontend/src/components/Header/Header.tsx +++ b/frontend/src/components/Header/Header.tsx @@ -193,11 +193,7 @@ const Header: React.FC = ({ isAuthenticated, isSuperuser }) => { )} - {isAuthenticated && ( - <> - - - )} + {isAuthenticated && authLinks()}
    diff --git a/frontend/src/components/Header/MdNavBar.tsx b/frontend/src/components/Header/MdNavBar.tsx index 1ed2cd43..f2cfc67b 100644 --- a/frontend/src/components/Header/MdNavBar.tsx +++ b/frontend/src/components/Header/MdNavBar.tsx @@ -138,9 +138,7 @@ const MdNavBar = (props: LoginFormProps) => { }
    - {isAuthenticated && ( - - )} + ); }; From cd1ce44af083158566d85c84b423b2fd3a29c345 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Tue, 2 Dec 2025 13:58:38 -0500 Subject: [PATCH 16/35] Clear all session storage when auth state changes for chatbot --- frontend/src/services/actions/auth.tsx | 7 +++++-- frontend/src/services/reducers/auth.ts | 2 ++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/frontend/src/services/actions/auth.tsx b/frontend/src/services/actions/auth.tsx index bfbfbe41..3b1cac0e 100644 --- a/frontend/src/services/actions/auth.tsx +++ b/frontend/src/services/actions/auth.tsx @@ -151,6 +151,9 @@ export const login = try { const res = await axios.post(url, body, config); + // Clear all session data from previous unauthenticated session + sessionStorage.clear(); + dispatch({ type: LOGIN_SUCCESS, payload: res.data, @@ -172,8 +175,8 @@ export const login = }; export const logout = () => async (dispatch: AppDispatch) => { - // Clear chat conversation data on logout for security - sessionStorage.removeItem("currentConversation"); + // Clear all session data on logout for privacy + sessionStorage.clear(); dispatch({ type: LOGOUT, diff --git a/frontend/src/services/reducers/auth.ts b/frontend/src/services/reducers/auth.ts index 769f3071..914ddb4c 100644 --- a/frontend/src/services/reducers/auth.ts +++ b/frontend/src/services/reducers/auth.ts @@ -99,6 +99,7 @@ export default function authReducer(state = initialState, action: ActionType): S user: action.payload } case AUTHENTICATED_FAIL: + sessionStorage.clear(); return { ...state, isAuthenticated: false @@ -113,6 +114,7 @@ export default function authReducer(state = initialState, action: ActionType): S case LOGIN_FAIL: localStorage.removeItem('access'); localStorage.removeItem('refresh'); + sessionStorage.clear(); return { ...state, access: null, From 4dc8b39e3a3ba8ccba3183c6f7c6be4ab4963894 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Tue, 2 Dec 2025 14:08:45 -0500 Subject: [PATCH 17/35] Save patient history data to sessionStorage instead of localStorage --- frontend/src/pages/PatientManager/NewPatientForm.tsx | 12 ++++++------ frontend/src/pages/PatientManager/PatientHistory.tsx | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/frontend/src/pages/PatientManager/NewPatientForm.tsx b/frontend/src/pages/PatientManager/NewPatientForm.tsx index 3acdd4dd..b2ff2e01 100644 --- a/frontend/src/pages/PatientManager/NewPatientForm.tsx +++ b/frontend/src/pages/PatientManager/NewPatientForm.tsx @@ -113,14 +113,14 @@ const NewPatientForm = ({ }; useEffect(() => { - const patientInfoFromLocalStorage = JSON.parse( + const patientInfoFromSessionStorage = JSON.parse( // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - localStorage.getItem("patientInfos") + sessionStorage.getItem("patientInfos") ); - if (patientInfoFromLocalStorage) { - setAllPatientInfo(patientInfoFromLocalStorage); + if (patientInfoFromSessionStorage) { + setAllPatientInfo(patientInfoFromSessionStorage); } }, []); @@ -190,11 +190,11 @@ const NewPatientForm = ({ updatedAllPatientInfo = [updatedPatientInfo, ...allPatientInfo]; } - // Update state and localStorage + // Update state and sessionStorage setPatientInfo(updatedPatientInfo); setAllPatientInfo(updatedAllPatientInfo); setShowSummary(true); - localStorage.setItem( + sessionStorage.setItem( "patientInfos", JSON.stringify(updatedAllPatientInfo) ); diff --git a/frontend/src/pages/PatientManager/PatientHistory.tsx b/frontend/src/pages/PatientManager/PatientHistory.tsx index f8dc14a6..0a03eea4 100644 --- a/frontend/src/pages/PatientManager/PatientHistory.tsx +++ b/frontend/src/pages/PatientManager/PatientHistory.tsx @@ -44,7 +44,7 @@ const PatientHistory = ({ (patient) => patient.ID !== patientIDToDelete ); - localStorage.setItem("patientInfos", JSON.stringify(updatedPatientInfo)); + sessionStorage.setItem("patientInfos", JSON.stringify(updatedPatientInfo)); setAllPatientInfo(updatedPatientInfo); onPatientDeleted(patientIDToDelete); From affe31e903e744b499be8b1c8ab6dc81a211f4c0 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Tue, 2 Dec 2025 14:19:17 -0500 Subject: [PATCH 18/35] Fix clearing sessionStorage on every page load for unauth users --- frontend/src/services/reducers/auth.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/src/services/reducers/auth.ts b/frontend/src/services/reducers/auth.ts index 914ddb4c..e353b9cd 100644 --- a/frontend/src/services/reducers/auth.ts +++ b/frontend/src/services/reducers/auth.ts @@ -99,7 +99,8 @@ export default function authReducer(state = initialState, action: ActionType): S user: action.payload } case AUTHENTICATED_FAIL: - sessionStorage.clear(); + // Don't clear sessionStorage here - this is triggered on every page load + // for unauthenticated users who are allowed to use the app return { ...state, isAuthenticated: false From 74a0b8fc6b8e449eb531c8d927895b1c97cd96b1 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Tue, 2 Dec 2025 14:28:00 -0500 Subject: [PATCH 19/35] Clearing session storage on LOGIN_FAIL public access design of app --- frontend/src/services/reducers/auth.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/src/services/reducers/auth.ts b/frontend/src/services/reducers/auth.ts index e353b9cd..bcafccd1 100644 --- a/frontend/src/services/reducers/auth.ts +++ b/frontend/src/services/reducers/auth.ts @@ -113,9 +113,10 @@ export default function authReducer(state = initialState, action: ActionType): S case GOOGLE_AUTH_FAIL: case FACEBOOK_AUTH_FAIL: case LOGIN_FAIL: + // Don't clear sessionStorage on login failure - users may have entered + // patient data while unauthenticated, and a login typo shouldn't delete their work localStorage.removeItem('access'); localStorage.removeItem('refresh'); - sessionStorage.clear(); return { ...state, access: null, From 257b83106b78c02a9c579fe2140edcba05691170 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Tue, 2 Dec 2025 16:27:55 -0500 Subject: [PATCH 20/35] Enforce authentication at the route level for adminportal --- .../ProtectedRoute/ProtectedRoute.tsx | 34 +++++++++++++++++++ frontend/src/routes/routes.tsx | 3 +- 2 files changed, 36 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/ProtectedRoute/ProtectedRoute.tsx diff --git a/frontend/src/components/ProtectedRoute/ProtectedRoute.tsx b/frontend/src/components/ProtectedRoute/ProtectedRoute.tsx new file mode 100644 index 00000000..4e5a4041 --- /dev/null +++ b/frontend/src/components/ProtectedRoute/ProtectedRoute.tsx @@ -0,0 +1,34 @@ +import { ReactElement, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useSelector } from 'react-redux'; +import { RootState } from '../../services/actions/types'; + +interface ProtectedRouteProps { + children: ReactElement; +} + +const ProtectedRoute = ({ children }: ProtectedRouteProps): ReactElement => { + const navigate = useNavigate(); + const { isAuthenticated, loading } = useSelector((state: RootState) => state.auth); + + useEffect(() => { + // Only redirect if we're done loading and user is not authenticated + if (!loading && !isAuthenticated) { + navigate('/login', { replace: true }); + } + }, [isAuthenticated, loading, navigate]); + + // Show loading state while checking authentication + if (loading) { + return ( +
    +
    Loading...
    +
    + ); + } + + // Only render children if authenticated + return isAuthenticated ? children : <>; +}; + +export default ProtectedRoute; diff --git a/frontend/src/routes/routes.tsx b/frontend/src/routes/routes.tsx index f96f2574..d012ed4d 100644 --- a/frontend/src/routes/routes.tsx +++ b/frontend/src/routes/routes.tsx @@ -18,6 +18,7 @@ import UploadFile from "../pages/DocumentManager/UploadFile.tsx"; import ListofFiles from "../pages/Files/ListOfFiles.tsx"; import RulesManager from "../pages/RulesManager/RulesManager.tsx"; import ManageMeds from "../pages/ManageMeds/ManageMeds.tsx"; +import ProtectedRoute from "../components/ProtectedRoute/ProtectedRoute.tsx"; const routes = [ { @@ -85,7 +86,7 @@ const routes = [ }, { path: "adminportal", - element: , + element: , }, { path: "Settings", From 7759377170f7311976d6b6df5a8dd47ec10401d6 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Tue, 2 Dec 2025 17:02:38 -0500 Subject: [PATCH 21/35] Enforce authentication at the route level for non public routes --- frontend/src/routes/routes.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/frontend/src/routes/routes.tsx b/frontend/src/routes/routes.tsx index d012ed4d..dc974e85 100644 --- a/frontend/src/routes/routes.tsx +++ b/frontend/src/routes/routes.tsx @@ -28,21 +28,21 @@ const routes = [ }, { path: "listoffiles", - element: , + element: , errorElement: , }, { path: "rulesmanager", - element: , + element: , errorElement: , }, { path: "uploadfile", - element: , + element: , }, { path: "drugSummary", - element: , + element: , }, { path: "register", @@ -90,7 +90,7 @@ const routes = [ }, { path: "Settings", - element: , + element: , }, { path: "medications", @@ -98,7 +98,7 @@ const routes = [ }, { path: "managemeds", - element: , + element: , }, ]; From f0eeb35beead96f4c81f5b4a59a876fdec1842e8 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Wed, 3 Dec 2025 09:45:52 -0500 Subject: [PATCH 22/35] Make the DrugSummary route and its API endpoints unprotected --- frontend/src/api/apiClient.ts | 16 +++++++++++----- frontend/src/routes/routes.tsx | 2 +- server/api/services/embedding_services.py | 2 +- server/api/views/embeddings/embeddingsView.py | 4 ++-- server/api/views/text_extraction/views.py | 4 ++-- 5 files changed, 17 insertions(+), 11 deletions(-) diff --git a/frontend/src/api/apiClient.ts b/frontend/src/api/apiClient.ts index 915226d6..b4980e3e 100644 --- a/frontend/src/api/apiClient.ts +++ b/frontend/src/api/apiClient.ts @@ -63,7 +63,7 @@ const handleSendDrugSummary = async ( const handleRuleExtraction = async (guid: string) => { try { - const response = await adminApi.get(`/v1/api/rule_extraction_openai?guid=${guid}`); + const response = await publicApi.get(`/v1/api/rule_extraction_openai?guid=${guid}`); // console.log("Rule extraction response:", JSON.stringify(response.data, null, 2)); return response.data; } catch (error) { @@ -105,13 +105,19 @@ const handleSendDrugSummaryStream = async ( guid ? `&guid=${guid}` : "" }`; + const headers: Record = { + "Content-Type": "application/json", + }; + + // Only add Authorization header if token exists + if (token) { + headers.Authorization = `JWT ${token}`; + } + try { const response = await fetch(baseURL + endpoint, { method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `JWT ${token}`, - }, + headers, body: JSON.stringify({ message }), }); diff --git a/frontend/src/routes/routes.tsx b/frontend/src/routes/routes.tsx index dc974e85..acab4f1c 100644 --- a/frontend/src/routes/routes.tsx +++ b/frontend/src/routes/routes.tsx @@ -42,7 +42,7 @@ const routes = [ }, { path: "drugSummary", - element: , + element: , }, { path: "register", diff --git a/server/api/services/embedding_services.py b/server/api/services/embedding_services.py index b50dd750..0f65b7b1 100644 --- a/server/api/services/embedding_services.py +++ b/server/api/services/embedding_services.py @@ -41,7 +41,7 @@ def get_closest_embeddings( transformerModel = TransformerModel.get_instance().model embedding_message = transformerModel.encode(message_data) - if user.is_authenticated: + if user and user.is_authenticated: # User sees their own files + files uploaded by superusers closest_embeddings_query = ( Embeddings.objects.filter( diff --git a/server/api/views/embeddings/embeddingsView.py b/server/api/views/embeddings/embeddingsView.py index d0bdd8ca..13b49cc0 100644 --- a/server/api/views/embeddings/embeddingsView.py +++ b/server/api/views/embeddings/embeddingsView.py @@ -1,5 +1,5 @@ from rest_framework.views import APIView -from rest_framework.permissions import IsAuthenticated +from rest_framework.permissions import AllowAny from rest_framework.response import Response from rest_framework import status from django.http import StreamingHttpResponse @@ -13,7 +13,7 @@ @method_decorator(csrf_exempt, name='dispatch') class AskEmbeddingsAPIView(APIView): - permission_classes = [IsAuthenticated] + permission_classes = [AllowAny] def post(self, request, *args, **kwargs): try: diff --git a/server/api/views/text_extraction/views.py b/server/api/views/text_extraction/views.py index e4122851..6e51d994 100644 --- a/server/api/views/text_extraction/views.py +++ b/server/api/views/text_extraction/views.py @@ -3,7 +3,7 @@ import re from rest_framework.views import APIView -from rest_framework.permissions import IsAuthenticated +from rest_framework.permissions import IsAuthenticated, AllowAny from rest_framework.response import Response from rest_framework import status from django.utils.decorators import method_decorator @@ -139,7 +139,7 @@ def openai_extraction(content_chunks, user_prompt): @method_decorator(csrf_exempt, name='dispatch') class RuleExtractionAPIOpenAIView(APIView): - permission_classes = [IsAuthenticated] + permission_classes = [AllowAny] def get(self, request): try: From 7648a2c916642e170923ce7d6f904daeadd22c6b Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Wed, 3 Dec 2025 10:01:19 -0500 Subject: [PATCH 23/35] Revert "Make the DrugSummary route and its API endpoints unprotected" This reverts commit f0eeb35beead96f4c81f5b4a59a876fdec1842e8. --- frontend/src/api/apiClient.ts | 16 +++++----------- frontend/src/routes/routes.tsx | 2 +- server/api/services/embedding_services.py | 2 +- server/api/views/embeddings/embeddingsView.py | 4 ++-- server/api/views/text_extraction/views.py | 4 ++-- 5 files changed, 11 insertions(+), 17 deletions(-) diff --git a/frontend/src/api/apiClient.ts b/frontend/src/api/apiClient.ts index b4980e3e..915226d6 100644 --- a/frontend/src/api/apiClient.ts +++ b/frontend/src/api/apiClient.ts @@ -63,7 +63,7 @@ const handleSendDrugSummary = async ( const handleRuleExtraction = async (guid: string) => { try { - const response = await publicApi.get(`/v1/api/rule_extraction_openai?guid=${guid}`); + const response = await adminApi.get(`/v1/api/rule_extraction_openai?guid=${guid}`); // console.log("Rule extraction response:", JSON.stringify(response.data, null, 2)); return response.data; } catch (error) { @@ -105,19 +105,13 @@ const handleSendDrugSummaryStream = async ( guid ? `&guid=${guid}` : "" }`; - const headers: Record = { - "Content-Type": "application/json", - }; - - // Only add Authorization header if token exists - if (token) { - headers.Authorization = `JWT ${token}`; - } - try { const response = await fetch(baseURL + endpoint, { method: "POST", - headers, + headers: { + "Content-Type": "application/json", + Authorization: `JWT ${token}`, + }, body: JSON.stringify({ message }), }); diff --git a/frontend/src/routes/routes.tsx b/frontend/src/routes/routes.tsx index acab4f1c..dc974e85 100644 --- a/frontend/src/routes/routes.tsx +++ b/frontend/src/routes/routes.tsx @@ -42,7 +42,7 @@ const routes = [ }, { path: "drugSummary", - element: , + element: , }, { path: "register", diff --git a/server/api/services/embedding_services.py b/server/api/services/embedding_services.py index 0f65b7b1..b50dd750 100644 --- a/server/api/services/embedding_services.py +++ b/server/api/services/embedding_services.py @@ -41,7 +41,7 @@ def get_closest_embeddings( transformerModel = TransformerModel.get_instance().model embedding_message = transformerModel.encode(message_data) - if user and user.is_authenticated: + if user.is_authenticated: # User sees their own files + files uploaded by superusers closest_embeddings_query = ( Embeddings.objects.filter( diff --git a/server/api/views/embeddings/embeddingsView.py b/server/api/views/embeddings/embeddingsView.py index 13b49cc0..d0bdd8ca 100644 --- a/server/api/views/embeddings/embeddingsView.py +++ b/server/api/views/embeddings/embeddingsView.py @@ -1,5 +1,5 @@ from rest_framework.views import APIView -from rest_framework.permissions import AllowAny +from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework import status from django.http import StreamingHttpResponse @@ -13,7 +13,7 @@ @method_decorator(csrf_exempt, name='dispatch') class AskEmbeddingsAPIView(APIView): - permission_classes = [AllowAny] + permission_classes = [IsAuthenticated] def post(self, request, *args, **kwargs): try: diff --git a/server/api/views/text_extraction/views.py b/server/api/views/text_extraction/views.py index 6e51d994..e4122851 100644 --- a/server/api/views/text_extraction/views.py +++ b/server/api/views/text_extraction/views.py @@ -3,7 +3,7 @@ import re from rest_framework.views import APIView -from rest_framework.permissions import IsAuthenticated, AllowAny +from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework import status from django.utils.decorators import method_decorator @@ -139,7 +139,7 @@ def openai_extraction(content_chunks, user_prompt): @method_decorator(csrf_exempt, name='dispatch') class RuleExtractionAPIOpenAIView(APIView): - permission_classes = [AllowAny] + permission_classes = [IsAuthenticated] def get(self, request): try: From c792295f8dc74374e309b9e0030c2dce8be69e06 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Wed, 3 Dec 2025 10:54:49 -0500 Subject: [PATCH 24/35] Remove comments from auth.ts --- frontend/src/services/reducers/auth.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/frontend/src/services/reducers/auth.ts b/frontend/src/services/reducers/auth.ts index bcafccd1..6a18b9eb 100644 --- a/frontend/src/services/reducers/auth.ts +++ b/frontend/src/services/reducers/auth.ts @@ -99,8 +99,6 @@ export default function authReducer(state = initialState, action: ActionType): S user: action.payload } case AUTHENTICATED_FAIL: - // Don't clear sessionStorage here - this is triggered on every page load - // for unauthenticated users who are allowed to use the app return { ...state, isAuthenticated: false @@ -113,8 +111,6 @@ export default function authReducer(state = initialState, action: ActionType): S case GOOGLE_AUTH_FAIL: case FACEBOOK_AUTH_FAIL: case LOGIN_FAIL: - // Don't clear sessionStorage on login failure - users may have entered - // patient data while unauthenticated, and a login typo shouldn't delete their work localStorage.removeItem('access'); localStorage.removeItem('refresh'); return { @@ -159,4 +155,4 @@ export default function authReducer(state = initialState, action: ActionType): S default: return state } -} \ No newline at end of file +} From e994ef0c215bbef1c808045b31ff71ab2ee0ce3e Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Wed, 3 Dec 2025 11:03:34 -0500 Subject: [PATCH 25/35] Remove final newline automatically added by Zed --- frontend/src/services/reducers/auth.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/services/reducers/auth.ts b/frontend/src/services/reducers/auth.ts index 6a18b9eb..769f3071 100644 --- a/frontend/src/services/reducers/auth.ts +++ b/frontend/src/services/reducers/auth.ts @@ -155,4 +155,4 @@ export default function authReducer(state = initialState, action: ActionType): S default: return state } -} +} \ No newline at end of file From 6b97fa8d0e7e3d3cb89d9cabeda29fcd7c998f31 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Wed, 3 Dec 2025 11:38:18 -0500 Subject: [PATCH 26/35] Simplify and make ProtectedRoute more robust --- .../ProtectedRoute/ProtectedRoute.tsx | 28 ++++++++----------- 1 file changed, 12 insertions(+), 16 deletions(-) diff --git a/frontend/src/components/ProtectedRoute/ProtectedRoute.tsx b/frontend/src/components/ProtectedRoute/ProtectedRoute.tsx index 4e5a4041..590e4ddd 100644 --- a/frontend/src/components/ProtectedRoute/ProtectedRoute.tsx +++ b/frontend/src/components/ProtectedRoute/ProtectedRoute.tsx @@ -1,24 +1,16 @@ -import { ReactElement, useEffect } from 'react'; -import { useNavigate } from 'react-router-dom'; +import { ReactNode } from 'react'; +import { Navigate, useLocation } from 'react-router-dom'; import { useSelector } from 'react-redux'; import { RootState } from '../../services/actions/types'; interface ProtectedRouteProps { - children: ReactElement; + children: ReactNode; } -const ProtectedRoute = ({ children }: ProtectedRouteProps): ReactElement => { - const navigate = useNavigate(); +const ProtectedRoute = ({ children }: ProtectedRouteProps) => { + const location = useLocation(); const { isAuthenticated, loading } = useSelector((state: RootState) => state.auth); - useEffect(() => { - // Only redirect if we're done loading and user is not authenticated - if (!loading && !isAuthenticated) { - navigate('/login', { replace: true }); - } - }, [isAuthenticated, loading, navigate]); - - // Show loading state while checking authentication if (loading) { return (
    @@ -27,8 +19,12 @@ const ProtectedRoute = ({ children }: ProtectedRouteProps): ReactElement => { ); } - // Only render children if authenticated - return isAuthenticated ? children : <>; + // If not authenticated, redirect to login and include the original location + if (!isAuthenticated) { + return ; + } + + return <>{children}; }; -export default ProtectedRoute; +export default ProtectedRoute; \ No newline at end of file From bd817ffc39b67b0599ce7869e1f58dbd4919c011 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Wed, 3 Dec 2025 11:57:59 -0500 Subject: [PATCH 27/35] More targeted clearing of sessionStorage --- frontend/src/services/actions/auth.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/frontend/src/services/actions/auth.tsx b/frontend/src/services/actions/auth.tsx index 3b1cac0e..3dcfcac5 100644 --- a/frontend/src/services/actions/auth.tsx +++ b/frontend/src/services/actions/auth.tsx @@ -151,8 +151,9 @@ export const login = try { const res = await axios.post(url, body, config); - // Clear all session data from previous unauthenticated session - sessionStorage.clear(); + // Clear session data from previous unauthenticated session + sessionStorage.removeItem('currentConversation'); + sessionStorage.removeItem('patientInfos'); dispatch({ type: LOGIN_SUCCESS, @@ -175,8 +176,9 @@ export const login = }; export const logout = () => async (dispatch: AppDispatch) => { - // Clear all session data on logout for privacy - sessionStorage.clear(); + // Clear session data on logout for privacy + sessionStorage.removeItem('currentConversation'); + sessionStorage.removeItem('patientInfos'); dispatch({ type: LOGOUT, From 4ebf265b6912ddd9dcac1ca4a9bb8d4096f7a8f6 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Wed, 3 Dec 2025 12:07:10 -0500 Subject: [PATCH 28/35] Simplify ProtectedRoute.tsx --- .../src/components/ProtectedRoute/ProtectedRoute.tsx | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/frontend/src/components/ProtectedRoute/ProtectedRoute.tsx b/frontend/src/components/ProtectedRoute/ProtectedRoute.tsx index 590e4ddd..6d4d05ac 100644 --- a/frontend/src/components/ProtectedRoute/ProtectedRoute.tsx +++ b/frontend/src/components/ProtectedRoute/ProtectedRoute.tsx @@ -9,15 +9,7 @@ interface ProtectedRouteProps { const ProtectedRoute = ({ children }: ProtectedRouteProps) => { const location = useLocation(); - const { isAuthenticated, loading } = useSelector((state: RootState) => state.auth); - - if (loading) { - return ( -
    -
    Loading...
    -
    - ); - } + const { isAuthenticated } = useSelector((state: RootState) => state.auth); // If not authenticated, redirect to login and include the original location if (!isAuthenticated) { From f165aeb388b9cb27a95ad5460aa36168b2bbd7d1 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Tue, 9 Dec 2025 15:21:00 -0500 Subject: [PATCH 29/35] Handle the initial auth check state --- frontend/src/components/ProtectedRoute/ProtectedRoute.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/frontend/src/components/ProtectedRoute/ProtectedRoute.tsx b/frontend/src/components/ProtectedRoute/ProtectedRoute.tsx index 6d4d05ac..182b2b20 100644 --- a/frontend/src/components/ProtectedRoute/ProtectedRoute.tsx +++ b/frontend/src/components/ProtectedRoute/ProtectedRoute.tsx @@ -10,6 +10,11 @@ interface ProtectedRouteProps { const ProtectedRoute = ({ children }: ProtectedRouteProps) => { const location = useLocation(); const { isAuthenticated } = useSelector((state: RootState) => state.auth); + + // Wait for auth check to complete (null means not checked yet) + if (isAuthenticated === null) { + return
    Loading...
    ; // or a spinner component + } // If not authenticated, redirect to login and include the original location if (!isAuthenticated) { From 5df35a87c50a8711e1a40924076c487c4e1ffc61 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Tue, 9 Dec 2025 15:34:54 -0500 Subject: [PATCH 30/35] Replace Loading Text with spinner --- frontend/src/components/ProtectedRoute/ProtectedRoute.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/ProtectedRoute/ProtectedRoute.tsx b/frontend/src/components/ProtectedRoute/ProtectedRoute.tsx index 182b2b20..97f0db6a 100644 --- a/frontend/src/components/ProtectedRoute/ProtectedRoute.tsx +++ b/frontend/src/components/ProtectedRoute/ProtectedRoute.tsx @@ -2,6 +2,7 @@ import { ReactNode } from 'react'; import { Navigate, useLocation } from 'react-router-dom'; import { useSelector } from 'react-redux'; import { RootState } from '../../services/actions/types'; +import Spinner from '../LoadingSpinner/LoadingSpinner'; interface ProtectedRouteProps { children: ReactNode; @@ -13,7 +14,7 @@ const ProtectedRoute = ({ children }: ProtectedRouteProps) => { // Wait for auth check to complete (null means not checked yet) if (isAuthenticated === null) { - return
    Loading...
    ; // or a spinner component + return ; } // If not authenticated, redirect to login and include the original location From 8190390aec707824ab44f760ea4b545da56b5687 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Tue, 9 Dec 2025 15:42:05 -0500 Subject: [PATCH 31/35] Fix the Type mismatch in RootState --- frontend/src/services/actions/types.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/frontend/src/services/actions/types.tsx b/frontend/src/services/actions/types.tsx index add0dad9..d4b12f5e 100644 --- a/frontend/src/services/actions/types.tsx +++ b/frontend/src/services/actions/types.tsx @@ -21,7 +21,9 @@ export const LOGOUT = "LOGOUT"; export interface RootState { auth: { error: any; - isAuthenticated: boolean; + // Catch any code that doesn't handle the null case by + // matching the actual reducer state defined in auth.ts + isAuthenticated: boolean | null; isSuperuser: boolean; }; } From 3b82d634088a928d633c40930fa573e9ccd7f0d7 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Tue, 9 Dec 2025 15:48:32 -0500 Subject: [PATCH 32/35] Add TODO comments for improvements --- frontend/src/components/ProtectedRoute/ProtectedRoute.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/frontend/src/components/ProtectedRoute/ProtectedRoute.tsx b/frontend/src/components/ProtectedRoute/ProtectedRoute.tsx index 97f0db6a..27b55c1d 100644 --- a/frontend/src/components/ProtectedRoute/ProtectedRoute.tsx +++ b/frontend/src/components/ProtectedRoute/ProtectedRoute.tsx @@ -10,10 +10,15 @@ interface ProtectedRouteProps { const ProtectedRoute = ({ children }: ProtectedRouteProps) => { const location = useLocation(); + // TODO: Optimize useSelector to prevent unnecessary re-renders + // Use: const isAuthenticated = useSelector((state: RootState) => state.auth.isAuthenticated); const { isAuthenticated } = useSelector((state: RootState) => state.auth); // Wait for auth check to complete (null means not checked yet) + // TODO: Add error handling for auth check failures if (isAuthenticated === null) { + // TODO: Add accessibility attributes (role="status", aria-live="polite", aria-label) + // TODO: Prevent Loading State Flash by adding 200ms delay before showing spinner return ; } @@ -22,6 +27,7 @@ const ProtectedRoute = ({ children }: ProtectedRouteProps) => { return ; } + // TODO: Remove unnecessary fragment wrapper - just return children directly return <>{children}; }; From 109966584a0f223043db3ef00741e9f34bbc09fb Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Tue, 9 Dec 2025 16:05:35 -0500 Subject: [PATCH 33/35] Remov unecessary fragment --- .../src/components/ProtectedRoute/ProtectedRoute.tsx | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/frontend/src/components/ProtectedRoute/ProtectedRoute.tsx b/frontend/src/components/ProtectedRoute/ProtectedRoute.tsx index 27b55c1d..dc323217 100644 --- a/frontend/src/components/ProtectedRoute/ProtectedRoute.tsx +++ b/frontend/src/components/ProtectedRoute/ProtectedRoute.tsx @@ -10,15 +10,13 @@ interface ProtectedRouteProps { const ProtectedRoute = ({ children }: ProtectedRouteProps) => { const location = useLocation(); - // TODO: Optimize useSelector to prevent unnecessary re-renders - // Use: const isAuthenticated = useSelector((state: RootState) => state.auth.isAuthenticated); const { isAuthenticated } = useSelector((state: RootState) => state.auth); // Wait for auth check to complete (null means not checked yet) - // TODO: Add error handling for auth check failures + // TODO: Consider adding error handling for auth check failures if (isAuthenticated === null) { - // TODO: Add accessibility attributes (role="status", aria-live="polite", aria-label) - // TODO: Prevent Loading State Flash by adding 200ms delay before showing spinner + // TODO: Consider adding accessibility attributes (role="status", aria-live="polite", aria-label) + // TODO: Consider preventing Loading State Flash by adding delay before showing spinner return ; } @@ -27,8 +25,7 @@ const ProtectedRoute = ({ children }: ProtectedRouteProps) => { return ; } - // TODO: Remove unnecessary fragment wrapper - just return children directly - return <>{children}; + return children; }; export default ProtectedRoute; \ No newline at end of file From 412e90bdf24c1368422ef2c8949af431b7815105 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Tue, 9 Dec 2025 16:09:17 -0500 Subject: [PATCH 34/35] STYLE: Format line length of comment --- frontend/src/services/actions/types.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/frontend/src/services/actions/types.tsx b/frontend/src/services/actions/types.tsx index d4b12f5e..c7f73b94 100644 --- a/frontend/src/services/actions/types.tsx +++ b/frontend/src/services/actions/types.tsx @@ -21,8 +21,7 @@ export const LOGOUT = "LOGOUT"; export interface RootState { auth: { error: any; - // Catch any code that doesn't handle the null case by - // matching the actual reducer state defined in auth.ts + // Catch any code that doesn't handle the null case by matching the actual reducer state defined in auth.ts isAuthenticated: boolean | null; isSuperuser: boolean; }; From 8ca3f0f6b141ad829cfac22f53635591a5439f06 Mon Sep 17 00:00:00 2001 From: taichan03 Date: Fri, 12 Dec 2025 09:49:59 -0500 Subject: [PATCH 35/35] this is for switching the button from view pdf to download pdf when the users is not logged in. --- CHANGELOG.md | 165 +++++++++ CLAUDE.md | 318 ++++++++++++++++++ docs/MIGRATION_PDF_AUTH.md | 307 +++++++++++++++++ frontend/src/components/Header/Header.tsx | 35 +- frontend/src/components/Header/MdNavBar.tsx | 2 +- frontend/src/pages/Layout/Layout.tsx | 2 +- .../src/pages/Layout/Layout_V2_Header.tsx | 2 +- frontend/src/pages/Layout/Layout_V2_Main.tsx | 2 +- frontend/src/pages/Login/LoginForm.tsx | 2 +- frontend/src/pages/Login/ResetPassword.tsx | 2 +- .../src/pages/Login/ResetPasswordConfirm.tsx | 2 +- .../pages/PatientManager/PatientManager.tsx | 4 + .../pages/PatientManager/PatientSummary.tsx | 74 +++- 13 files changed, 889 insertions(+), 28 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 CLAUDE.md create mode 100644 docs/MIGRATION_PDF_AUTH.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..d7b1fd77 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,165 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added +- **Conditional PDF Access Based on Authentication** (2025-01-XX) + - Logged-in users see "View PDF" button that opens PDF viewer in new tab + - Non-logged-in users see "Download PDF" button that directly downloads the file + - Backend: Added `upload_file_guid` field to risk/source API responses + - Frontend: Conditional rendering based on Redux authentication state + - Fallback GUID extraction from URL if backend field is missing + + **Backend Changes:** + + *File: `server/api/views/risk/views_riskWithSources.py`* + ```python + # Added to source_info dictionary in 3 locations (lines ~138, ~252, ~359): + source_info = { + 'filename': filename, + 'title': getattr(embedding, 'title', None), + 'publication': getattr(embedding, 'publication', ''), + 'text': getattr(embedding, 'text', ''), + 'rule_type': medrule.rule_type, + 'history_type': medrule.history_type, + 'upload_fileid': getattr(embedding, 'upload_file_id', None), + 'page': getattr(embedding, 'page_num', None), + 'link_url': self._build_pdf_link(embedding), + 'upload_file_guid': str(embedding.upload_file.guid) if embedding.upload_file else None # NEW + } + ``` + + **Frontend Changes:** + + *File: `frontend/src/pages/PatientManager/PatientManager.tsx`* + ```typescript + // Added imports: + import { useSelector } from "react-redux"; + import { RootState } from "../../services/actions/types"; + + // Added hook to get auth state: + const { isAuthenticated } = useSelector((state: RootState) => state.auth); + + // Passed to PatientSummary: + + ``` + + *File: `frontend/src/pages/PatientManager/PatientSummary.tsx`* + ```typescript + // Updated interface: + interface PatientSummaryProps { + // ... existing props + isAuthenticated?: boolean; // NEW + } + + // Updated SourceItem type: + type SourceItem = { + // ... existing fields + upload_file_guid?: string | null; // NEW + }; + + // Added helper function: + const extractGuidFromUrl = (url: string): string | null => { + try { + const urlObj = new URL(url, window.location.origin); + return urlObj.searchParams.get('guid'); + } catch { + return null; + } + }; + + // Updated component: + const PatientSummary = ({ + // ... existing props + isAuthenticated = false, // NEW + }: PatientSummaryProps) => { + const baseURL = import.meta.env.VITE_API_BASE_URL || ''; // NEW + + // Updated MedicationItem props: + const MedicationItem = ({ + // ... existing props + isAuthenticated, // NEW + baseURL, // NEW + }: { + // ... existing types + isAuthenticated: boolean; // NEW + baseURL: string; // NEW + }) => { + + // Updated MedicationTier props: + const MedicationTier = ({ + // ... existing props + isAuthenticated, // NEW + baseURL, // NEW + }: { + // ... existing types + isAuthenticated: boolean; // NEW + baseURL: string; // NEW + }) => ( + // ... passes to MedicationItem + + ); + + // Conditional button rendering: + {s.link_url && (() => { + const guid = s.upload_file_guid || extractGuidFromUrl(s.link_url); + if (!guid) return null; + + return isAuthenticated ? ( + + View PDF + + ) : ( + + Download PDF + + ); + })()} + + // Updated all MedicationTier calls to pass new props: + + ``` + +### Fixed +- **URL Route Case Consistency** (2025-01-XX) + - Fixed case mismatch between backend URL generation (`/drugsummary`) and frontend route (`/drugSummary`) + - Updated all references to use consistent camelCase `/drugSummary` route + - Affected files: `views_riskWithSources.py`, `Layout_V2_Sidebar.tsx`, `Layout_V2_Header.tsx`, `FileRow.tsx` + +- **Protected Route Authentication Flow** (2025-01-XX) + - Fixed blank page issue when opening protected routes in new tab + - `ProtectedRoute` now waits for authentication check to complete before redirecting + - Added `useAuth()` hook to `Layout_V2_Main` to trigger auth verification + +### Changed +- **PatientSummary Component** (2025-01-XX) + - Now receives `isAuthenticated` prop from Redux state + - Props passed through component hierarchy: `PatientManager` → `PatientSummary` → `MedicationTier` → `MedicationItem` + - Added `baseURL` constant for API endpoint construction + +## [Previous versions would go here] diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..8562eb0d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,318 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Balancer is a web application designed to help prescribers choose suitable medications for patients with bipolar disorder. It's a Code for Philly project built with a PostgreSQL + Django REST Framework + React stack, running on Docker. + +Live site: https://balancertestsite.com + +## Development Setup + +### Prerequisites +- Docker Desktop +- Node.js and npm +- API keys for OpenAI and Anthropic (request from team) + +### Initial Setup +```bash +# Clone the repository +git clone + +# Install frontend dependencies +cd frontend +npm install +cd .. + +# Configure environment variables +# Copy config/env/dev.env.example and fill in API keys: +# - OPENAI_API_KEY +# - ANTHROPIC_API_KEY +# - PINECONE_API_KEY (if needed) + +# Start all services +docker compose up --build +``` + +### Services +- **Frontend**: React + Vite dev server at http://localhost:3000 +- **Backend**: Django REST Framework at http://localhost:8000 +- **Database**: PostgreSQL at localhost:5433 +- **pgAdmin**: Commented out by default (port 5050) + +## Common Development Commands + +### Docker Operations +```bash +# Start all services +docker compose up --build + +# Start in detached mode +docker compose up -d + +# View logs +docker compose logs -f [service_name] + +# Stop all services +docker compose down + +# Rebuild a specific service +docker compose build [frontend|backend|db] + +# Access Django shell in backend container +docker compose exec backend python manage.py shell + +# Run Django migrations +docker compose exec backend python manage.py makemigrations +docker compose exec backend python manage.py migrate +``` + +### Frontend Development +```bash +cd frontend + +# Start dev server (outside Docker) +npm run dev + +# Build for production +npm run build + +# Lint TypeScript/TSX files +npm run lint + +# Preview production build +npm run preview +``` + +### Backend Development +```bash +cd server + +# Create Django superuser (credentials in api/management/commands/createsu.py) +docker compose exec backend python manage.py createsuperuser + +# Access Django admin +# Navigate to http://localhost:8000/admin + +# Run database migrations +docker compose exec backend python manage.py makemigrations +docker compose exec backend python manage.py migrate + +# Django shell +docker compose exec backend python manage.py shell +``` + +### Git Workflow +- Main development branch: `develop` +- Production branch: `listOfMed` (used for PRs) +- Create feature branches from `develop` +- PRs should target `listOfMed` branch + +## Architecture + +### Backend Architecture (Django REST Framework) + +#### URL Routing Pattern +Django uses **dynamic URL importing** (see `server/balancer_backend/urls.py`). API endpoints are organized by feature modules in `server/api/views/`: +- `conversations/` - Patient conversation management +- `feedback/` - User feedback +- `listMeds/` - Medication catalog +- `risk/` - Risk assessment endpoints +- `uploadFile/` - PDF document uploads +- `ai_promptStorage/` - AI prompt templates +- `ai_settings/` - AI configuration +- `embeddings/` - Vector embeddings for RAG +- `medRules/` - Medication rules management +- `text_extraction/` - PDF text extraction +- `assistant/` - AI assistant endpoints + +Each module contains: +- `views.py` or `views_*.py` - API endpoints +- `models.py` - Django ORM models +- `urls.py` - URL patterns +- `serializers.py` - DRF serializers (if present) + +#### Authentication +- Uses **JWT authentication** with `rest_framework_simplejwt` +- Default: All endpoints require authentication (`IsAuthenticated`) +- To make an endpoint public, add to the view class: + ```python + from rest_framework.permissions import AllowAny + + class MyView(APIView): + permission_classes = [AllowAny] + authentication_classes = [] # Optional: disable auth entirely + ``` +- Auth endpoints via Djoser: `/auth/` +- JWT token lifetime: 60 minutes (access), 1 day (refresh) + +#### Key Data Models +- **Medication** (`api.views.listMeds.models`) - Medication catalog with benefits/risks +- **MedRule** (`api.models.model_medRule`) - Include/Exclude rules for medications based on patient history +- **MedRuleSource** - Junction table linking MedRules → Embeddings → Medications +- **Embeddings** (`api.models.model_embeddings`) - Vector embeddings from uploaded PDFs for RAG +- **UploadFile** (`api.views.uploadFile.models`) - Uploaded PDF documents with GUID references + +#### RAG (Retrieval Augmented Generation) System +The application uses embeddings from medical literature PDFs to provide evidence-based medication recommendations: +1. PDFs uploaded via `uploadFile` → text extracted → chunked → embedded (OpenAI/Pinecone) +2. MedRules created linking medications to specific evidence (embeddings) +3. API endpoints return recommendations with source citations (filename, page number, text excerpt) + +### Frontend Architecture (React + TypeScript) + +#### Project Structure +- **`src/components/`** - Reusable React components (Header, forms, etc.) +- **`src/pages/`** - Page-level components +- **`src/routes/routes.tsx`** - React Router configuration +- **`src/services/`** - Redux store, actions, reducers, API clients +- **`src/contexts/`** - React Context providers (GlobalContext for app state) +- **`src/api/`** - API client functions using Axios +- **`src/utils/`** - Utility functions + +#### State Management +- **Redux** for auth state and global application data + - Store: `src/services/store.tsx` + - Actions: `src/services/actions/` + - Reducers: `src/services/reducers/` +- **React Context** (`GlobalContext`) for UI state: + - `showSummary` - Display medication summary + - `enterNewPatient` - New patient form state + - `isEditing` - Form edit mode + - `showMetaPanel` - Metadata panel visibility + +#### Routing +Routes defined in `src/routes/routes.tsx`: +- `/` - Medication Suggester (main tool) +- `/medications` - Medication List +- `/about` - About page +- `/help` - Help documentation +- `/feedback` - Feedback form +- `/logout` - Logout handler +- Admin routes (superuser only): + - `/rulesmanager` - Manage medication rules + - `/ManageMeds` - Manage medication database + +#### Styling +- **Tailwind CSS** for utility-first styling +- **PostCSS** with nesting support +- Custom CSS in component directories (e.g., `Header/header.css`) +- Fonts: Quicksand (branding), Satoshi (body text) + +### Database Schema Notes +- **pgvector extension** enabled for vector similarity search +- Custom Dockerfile for PostgreSQL (`db/Dockerfile`) - workaround for ARM64 compatibility +- Database connection: + - Host: `db` (Docker internal) or `localhost:5433` (external) + - Credentials: `balancer/balancer` (dev environment) + - Database: `balancer_dev` + +### Environment Configuration +- **Development**: `config/env/dev.env` (used by Docker Compose) +- **Frontend Production**: `frontend/.env.production` + - Contains `VITE_API_BASE_URL` for production API endpoint +- **Never commit** actual API keys - use `.env.example` as template +- Django `SECRET_KEY` should be a long random string in production (not "foo") + +## Important Development Patterns + +### Adding a New API Endpoint +1. Create view in appropriate `server/api/views/{module}/views.py` +2. Add URL pattern to `server/api/views/{module}/urls.py` +3. If new module, add to `urls` list in `server/balancer_backend/urls.py` +4. Consider authentication requirements (add `permission_classes` if needed) + +### Working with MedRules +MedRules use a many-to-many relationship with medications and embeddings: +- `rule_type`: "INCLUDE" (beneficial) or "EXCLUDE" (contraindicated) +- `history_type`: Patient diagnosis state (e.g., "DIAGNOSIS_DEPRESSED", "DIAGNOSIS_MANIC") +- Access sources via `MedRuleSource` intermediate model +- API returns benefits/risks with source citations (filename, page, text, **upload_file_guid**) + +### PDF Access and Authentication +**Feature**: Conditional PDF viewing/downloading based on authentication state + +**Behavior**: +- **Logged-in users**: See "View PDF" button (blue) that opens `/drugSummary` page in new tab +- **Non-logged-in users**: See "Download PDF" button (green) that directly downloads via `/v1/api/uploadFile/` endpoint + +**Implementation Details**: +- Backend: `upload_file_guid` field added to source_info in `views_riskWithSources.py` (3 locations) +- Frontend: `isAuthenticated` prop passed through component hierarchy: + - `PatientManager` (gets from Redux) → `PatientSummary` → `MedicationTier` → `MedicationItem` +- Download endpoint: `/v1/api/uploadFile/` is **public** (AllowAny permission) +- Fallback: If `upload_file_guid` missing from API, GUID is extracted from `link_url` query parameter +- Route: `/drugSummary` (camelCase) - fixed from inconsistent `/drugsummary` usage + +**Files Modified**: +- Backend: `server/api/views/risk/views_riskWithSources.py` +- Frontend: `frontend/src/pages/PatientManager/PatientManager.tsx`, `PatientSummary.tsx` +- Routes: Multiple files updated for consistent `/drugSummary` casing +- Auth: `ProtectedRoute.tsx` and `Layout_V2_Main.tsx` fixed for proper auth checking + +### Frontend API Calls +- API client functions in `src/api/` +- Use Axios with base URL from environment +- JWT tokens managed by Redux auth state +- Error handling should check for 401 (unauthorized) and redirect to login + +### Docker Networking +Services use a custom network (192.168.0.0/24): +- db: 192.168.0.2 +- backend: 192.168.0.3 +- frontend: 192.168.0.5 +- Services communicate using service names (e.g., `http://backend:8000`) + +## Testing + +### Backend Tests +Limited test coverage currently. Example test: +- `server/api/views/uploadFile/test_title.py` + +To run tests: +```bash +docker compose exec backend python manage.py test +``` + +### Frontend Tests +No test framework currently configured. Consider adding Jest/Vitest for future testing. + +## Deployment + +### Local Kubernetes (using Devbox) +```bash +# Install Devbox first: https://www.jetify.com/devbox + +# Add balancertestsite.com to /etc/hosts +sudo sh -c 'echo "127.0.0.1 balancertestsite.com" >> /etc/hosts' + +# Deploy to local k8s cluster +devbox shell +devbox create:cluster +devbox run deploy:balancer + +# Access at https://balancertestsite.com:30219/ +``` + +### Production +- Manifests: `deploy/manifests/balancer/` +- ConfigMap: `deploy/manifests/balancer/base/configmap.yml` +- Secrets: `deploy/manifests/balancer/base/secret.template.yaml` + +## Key Files Reference + +- `server/balancer_backend/settings.py` - Django configuration (auth, database, CORS) +- `server/balancer_backend/urls.py` - Root URL configuration with dynamic imports +- `frontend/src/routes/routes.tsx` - React Router configuration +- `frontend/src/services/store.tsx` - Redux store setup +- `docker-compose.yml` - Local development environment +- `config/env/dev.env.example` - Environment variables template + +## Project Conventions + +- Python: Follow Django conventions, use class-based views (APIView) +- TypeScript: Use functional components with hooks, avoid default exports except for pages +- CSS: Prefer Tailwind utilities, use custom CSS only when necessary +- Git: Feature branches from `develop`, PRs to `listOfMed` +- Code formatting: Prettier for frontend (with Tailwind plugin) diff --git a/docs/MIGRATION_PDF_AUTH.md b/docs/MIGRATION_PDF_AUTH.md new file mode 100644 index 00000000..d5f7df26 --- /dev/null +++ b/docs/MIGRATION_PDF_AUTH.md @@ -0,0 +1,307 @@ +# Migration Guide: Conditional PDF Access Feature + +**Date**: January 2025 +**Feature**: Authentication-based PDF viewing and downloading +**PR/Issue**: [Link to PR if applicable] + +## Overview + +This migration adds conditional behavior to PDF source buttons based on user authentication status: +- **Authenticated users**: "View PDF" button opens PDF viewer in new tab +- **Unauthenticated users**: "Download PDF" button triggers direct file download + +## How It Works + +### Button Logic Flow + +The button checks the user's authentication state and uses the `upload_file_guid` to determine behavior: + +``` +User clicks medication → Expands to show sources + ↓ + Check: isAuthenticated? + ↓ + ┌─────────────────┴─────────────────┐ + ↓ ↓ + YES (Logged In) NO (Not Logged In) + ↓ ↓ + "View PDF" (Blue Button) "Download PDF" (Green Button) + ↓ ↓ + Opens /drugSummary page Direct download via + with PDF viewer /v1/api/uploadFile/ + (target="_blank") (download attribute) +``` + +### When User is NOT Authenticated: + +```typescript + + Download PDF + +``` + +- Uses `upload_file_guid` to construct download URL: `/v1/api/uploadFile/` +- The `download` attribute forces browser to download instead of opening +- Endpoint is **public** (AllowAny permission) - no authentication required +- File downloads directly with original filename from database + +### When User IS Authenticated: + +```typescript + + View PDF + +``` + +- Uses `link_url` which points to `/drugSummary` page +- Opens in new tab with `target="_blank"` +- The drugSummary page renders a PDF viewer with navigation controls +- User can navigate between pages, zoom, etc. + +### Key Points: + +1. ✅ **Both auth types can access PDFs** - the download endpoint (`/v1/api/uploadFile/`) is public +2. ✅ The difference is **presentation**: + - **Authenticated**: Rich PDF viewer experience with navigation + - **Unauthenticated**: Simple direct download to local machine +3. ✅ The `upload_file_guid` is the primary identifier for fetching files from the database +4. ✅ **Fallback mechanism**: If `upload_file_guid` is missing from API response, it's extracted from the `link_url` query parameter + +### Code Location: + +The conditional logic is in `frontend/src/pages/PatientManager/PatientSummary.tsx` around line 165-180: + +```typescript +{s.link_url && (() => { + // Get GUID from API or extract from URL as fallback + const guid = s.upload_file_guid || extractGuidFromUrl(s.link_url); + if (!guid) return null; + + // Render different button based on authentication + return isAuthenticated ? ( + // Blue "View PDF" button for authenticated users + View PDF + ) : ( + // Green "Download PDF" button for unauthenticated users + Download PDF + ); +})()} +``` + +## Breaking Changes + +⚠️ **None** - This is a backward-compatible enhancement + +## Database Changes + +✅ **None** - No migrations required + +## API Changes + +### Backend: `POST /v1/api/riskWithSources` + +**Response Schema Update**: +```python +# New field added to each item in sources array: +{ + "sources": [ + { + "filename": "example.pdf", + "title": "Example Document", + "publication": "Journal Name", + "text": "...", + "rule_type": "INCLUDE", + "history_type": "DIAGNOSIS_MANIC", + "upload_fileid": 123, + "page": 5, + "link_url": "/drugSummary?guid=xxx&page=5", + "upload_file_guid": "xxx-xxx-xxx" // NEW FIELD + } + ] +} +``` + +**File**: `server/api/views/risk/views_riskWithSources.py` +**Lines Modified**: ~138-149, ~252-263, ~359-370 + +## Frontend Changes + +### 1. Component Prop Changes + +**PatientManager** now retrieves and passes authentication state: +```typescript +// Added imports +import { useSelector } from "react-redux"; +import { RootState } from "../../services/actions/types"; + +// New hook call +const { isAuthenticated } = useSelector((state: RootState) => state.auth); + +// New prop passed + +``` + +**PatientSummary** interface updated: +```typescript +interface PatientSummaryProps { + // ... existing props + isAuthenticated?: boolean; // NEW +} + +type SourceItem = { + // ... existing fields + upload_file_guid?: string | null; // NEW +} +``` + +### 2. New Helper Function + +```typescript +/** + * Fallback to extract GUID from URL if API doesn't provide upload_file_guid + */ +const extractGuidFromUrl = (url: string): string | null => { + try { + const urlObj = new URL(url, window.location.origin); + return urlObj.searchParams.get('guid'); + } catch { + return null; + } +}; +``` + +### 3. Component Hierarchy Updates + +Props now flow through: `PatientManager` → `PatientSummary` → `MedicationTier` → `MedicationItem` + +Each intermediate component needs `isAuthenticated` and `baseURL` props added. + +## Route Changes + +### URL Consistency Fix + +**Old (inconsistent)**: +- Backend: `/drugsummary` (lowercase) +- Frontend route: `/drugSummary` (camelCase) + +**New (consistent)**: +- All references now use: `/drugSummary` (camelCase) + +**Files Updated**: +- `server/api/views/risk/views_riskWithSources.py` +- `frontend/src/pages/Layout/Layout_V2_Sidebar.tsx` +- `frontend/src/pages/Layout/Layout_V2_Header.tsx` +- `frontend/src/pages/Files/FileRow.tsx` + +## Authentication Flow Fixes + +### ProtectedRoute Component + +**Problem**: Opening protected routes in new tab caused immediate redirect to login + +**Solution**: Wait for auth check to complete +```typescript +if (isAuthenticated === null) { + return null; // Wait for auth verification +} +``` + +### Layout_V2_Main Component + +**Added**: `useAuth()` hook to trigger authentication check on mount + +## Testing Checklist + +### Manual Testing Steps + +1. **As unauthenticated user**: + - [ ] Navigate to medication suggester + - [ ] Submit patient information + - [ ] Expand medication to view sources + - [ ] Verify "Download PDF" button appears (green) + - [ ] Click button and verify file downloads + - [ ] Verify no redirect to login occurs + +2. **As authenticated user**: + - [ ] Log in to application + - [ ] Navigate to medication suggester + - [ ] Submit patient information + - [ ] Expand medication to view sources + - [ ] Verify "View PDF" button appears (blue) + - [ ] Click button and verify PDF viewer opens in new tab + - [ ] Verify new tab doesn't redirect to login + +3. **Edge cases**: + - [ ] Test with sources that have no link_url + - [ ] Test with sources that have link_url but no upload_file_guid + - [ ] Test opening protected route directly in new tab + - [ ] Test authentication state persistence across tabs + +### Automated Tests + +**TODO**: Add integration tests for: +- PDF button conditional rendering +- GUID extraction fallback +- Protected route authentication flow + +## Deployment Notes + +### Backend Deployment + +1. Deploy updated Django code +2. **No database migrations required** +3. Restart Django application server +4. Verify API response includes `upload_file_guid` field + +### Frontend Deployment + +1. Build frontend with updated code: `npm run build` +2. Deploy built assets +3. Clear CDN/browser cache if applicable +4. Verify button behavior for both auth states + +### Rollback Plan + +If issues occur: +1. Revert backend to previous version (API still compatible) +2. Frontend will use fallback GUID extraction from URL +3. Feature will degrade gracefully - button may show for all users but behavior remains functional + +## Environment Variables + +No new environment variables required. Uses existing: +- `VITE_API_BASE_URL` - Frontend API base URL + +## Known Issues / Limitations + +1. **GUID Fallback**: If both `upload_file_guid` and `link_url` are missing/invalid, no button appears +2. **Download Naming**: Downloaded files use server-provided filename, not customizable per-user +3. **Public Access**: Download endpoint is public - PDFs accessible to anyone with GUID + +## Future Enhancements + +- [ ] Add loading spinner while PDF downloads +- [ ] Add analytics tracking for PDF views/downloads +- [ ] Implement PDF access permissions/restrictions +- [ ] Add rate limiting to download endpoint + +## Support + +For questions or issues: +- GitHub Issues: [Repository Issues Link] +- Team Contact: balancerteam@codeforphilly.org + +## References + +- CHANGELOG.md - High-level changes +- CLAUDE.md - Updated project documentation +- Code comments in PatientSummary.tsx diff --git a/frontend/src/components/Header/Header.tsx b/frontend/src/components/Header/Header.tsx index 6a983a08..cbbd2c93 100644 --- a/frontend/src/components/Header/Header.tsx +++ b/frontend/src/components/Header/Header.tsx @@ -10,7 +10,7 @@ import { FaChevronDown, FaSignOutAlt } from "react-icons/fa"; import { useGlobalContext } from "../../contexts/GlobalContext.tsx"; interface LoginFormProps { - isAuthenticated: boolean; + isAuthenticated: boolean | null; isSuperuser: boolean; } @@ -24,9 +24,12 @@ const Header: React.FC = ({ isAuthenticated, isSuperuser }) => { useGlobalContext(); const authLinks = () => ( - - Sign Out - + + Sign Out + ); @@ -70,22 +73,29 @@ const Header: React.FC = ({ isAuthenticated, isSuperuser }) => {

    - Welcome to Balancer’s first release! Found a bug or have feedback? Let us know {" "} + Welcome to Balancer’s first release! Found a bug or have feedback? Let + us know{" "} - here {" "} + here{" "} - or email {" "} - + or email{" "} + balancerteam@codeforphilly.org .

    - App is in beta; report issues to {" "} - + App is in beta; report issues to{" "} + balancerteam@codeforphilly.org . @@ -161,7 +171,7 @@ const Header: React.FC = ({ isAuthenticated, isSuperuser }) => { > Donate - {(isAuthenticated && isSuperuser) && ( + {isAuthenticated && isSuperuser && (

    = ({ isAuthenticated, isSuperuser }) => { )} + + Balancer + {isAuthenticated && authLinks()}
    diff --git a/frontend/src/components/Header/MdNavBar.tsx b/frontend/src/components/Header/MdNavBar.tsx index f2cfc67b..5a8d5bce 100644 --- a/frontend/src/components/Header/MdNavBar.tsx +++ b/frontend/src/components/Header/MdNavBar.tsx @@ -7,7 +7,7 @@ import closeLogo from "../../assets/close.svg"; import hamburgerLogo from "../../assets/hamburger.svg"; interface LoginFormProps { - isAuthenticated: boolean; + isAuthenticated: boolean | null; handleForm: () => void; } diff --git a/frontend/src/pages/Layout/Layout.tsx b/frontend/src/pages/Layout/Layout.tsx index 84f9c215..02274b78 100644 --- a/frontend/src/pages/Layout/Layout.tsx +++ b/frontend/src/pages/Layout/Layout.tsx @@ -11,7 +11,7 @@ interface LayoutProps { } interface LoginFormProps { - isAuthenticated: boolean; + isAuthenticated: boolean | null; } export const Layout = ({ diff --git a/frontend/src/pages/Layout/Layout_V2_Header.tsx b/frontend/src/pages/Layout/Layout_V2_Header.tsx index 3371cef5..c896b7b1 100644 --- a/frontend/src/pages/Layout/Layout_V2_Header.tsx +++ b/frontend/src/pages/Layout/Layout_V2_Header.tsx @@ -3,7 +3,7 @@ import { useAuth } from "./authHooks.ts"; import { useGlobalContext } from "../../../src/contexts/GlobalContext.tsx"; interface LoginFormProps { - isAuthenticated: boolean; + isAuthenticated: boolean | null; } const Header: React.FC = () => { diff --git a/frontend/src/pages/Layout/Layout_V2_Main.tsx b/frontend/src/pages/Layout/Layout_V2_Main.tsx index 132482b6..2ebad75c 100644 --- a/frontend/src/pages/Layout/Layout_V2_Main.tsx +++ b/frontend/src/pages/Layout/Layout_V2_Main.tsx @@ -7,7 +7,7 @@ import Sidebar from "./Layout_V2_Sidebar"; interface LayoutProps { children: ReactNode; - isAuthenticated: boolean; + isAuthenticated: boolean | null; } const Layout: React.FC = ({ children, isAuthenticated }) => { diff --git a/frontend/src/pages/Login/LoginForm.tsx b/frontend/src/pages/Login/LoginForm.tsx index ce28c62c..d0d08184 100644 --- a/frontend/src/pages/Login/LoginForm.tsx +++ b/frontend/src/pages/Login/LoginForm.tsx @@ -9,7 +9,7 @@ import LoadingSpinner from "../../components/LoadingSpinner/LoadingSpinner"; import { FaExclamationTriangle } from "react-icons/fa"; interface LoginFormProps { - isAuthenticated: boolean; + isAuthenticated: boolean | null; loginError?: string | null; // Align this with the mapped state } diff --git a/frontend/src/pages/Login/ResetPassword.tsx b/frontend/src/pages/Login/ResetPassword.tsx index ba57f601..61345aa8 100644 --- a/frontend/src/pages/Login/ResetPassword.tsx +++ b/frontend/src/pages/Login/ResetPassword.tsx @@ -7,7 +7,7 @@ import { useEffect, useState } from "react"; import Layout from "../Layout/Layout"; interface ResetPasswordProps { - isAuthenticated: boolean; + isAuthenticated: boolean | null; } function ResetPassword(props: ResetPasswordProps) { diff --git a/frontend/src/pages/Login/ResetPasswordConfirm.tsx b/frontend/src/pages/Login/ResetPasswordConfirm.tsx index 8f497817..533669bb 100644 --- a/frontend/src/pages/Login/ResetPasswordConfirm.tsx +++ b/frontend/src/pages/Login/ResetPasswordConfirm.tsx @@ -10,7 +10,7 @@ import { useEffect, useState } from "react"; import Layout from "../Layout/Layout"; interface ResetPasswordConfirmProps { - isAuthenticated: boolean; + isAuthenticated: boolean | null; } const ResetPasswordConfirm: React.FC = ({ diff --git a/frontend/src/pages/PatientManager/PatientManager.tsx b/frontend/src/pages/PatientManager/PatientManager.tsx index f49dfa48..00b94050 100644 --- a/frontend/src/pages/PatientManager/PatientManager.tsx +++ b/frontend/src/pages/PatientManager/PatientManager.tsx @@ -1,5 +1,7 @@ import { useState } from "react"; import { Link } from "react-router-dom"; +import { useSelector } from "react-redux"; +import { RootState } from "../../services/actions/types"; import NewPatientForm from "./NewPatientForm.tsx"; import PatientHistory from "./PatientHistory.tsx"; // eslint-disable-next-line @typescript-eslint/ban-ts-comment @@ -11,6 +13,7 @@ import Welcome from "../../components/Welcome/Welcome.tsx"; import { useGlobalContext } from "../../contexts/GlobalContext.tsx"; const PatientManager = () => { + const { isAuthenticated } = useSelector((state: RootState) => state.auth); const [patientInfo, setPatientInfo] = useState({ ID: "", Diagnosis: Diagnosis.Manic, @@ -116,6 +119,7 @@ const PatientManager = () => { patientInfo={patientInfo} isPatientDeleted={isPatientDeleted} setPatientInfo={setPatientInfo} + isAuthenticated={isAuthenticated} /> >; + isAuthenticated: boolean | null; } type SourceItem = { @@ -27,6 +28,7 @@ type SourceItem = { guid?: string | null; page?: number | null; link_url?: string | null; + upload_file_guid?: string | null; }; type RiskData = { benefits: string[]; @@ -43,12 +45,29 @@ type MedicationWithSource = { const truncate = (s = "", n = 220) => s.length > n ? s.slice(0, n).trim() + "…" : s; +/** + * Extracts the GUID from a drugSummary URL query parameter + * Used as fallback when upload_file_guid is not provided by the API + * @param url - URL string like "/drugSummary?guid=xxx&page=1" + * @returns GUID string or null if not found + */ +const extractGuidFromUrl = (url: string): string | null => { + try { + const urlObj = new URL(url, window.location.origin); + return urlObj.searchParams.get('guid'); + } catch { + return null; + } +}; + const MedicationItem = ({ medication, isClicked, riskData, loading, onTierClick, + isAuthenticated, + baseURL, }: { medication: string; source: string; @@ -56,6 +75,8 @@ const MedicationItem = ({ riskData: RiskData | null; loading: boolean; onTierClick: () => void; + isAuthenticated: boolean | null; + baseURL: string; }) => { if (medication === "None") { return ( @@ -141,16 +162,35 @@ const MedicationItem = ({
    {s.title || "Untitled source"} - {s.link_url && ( - - View PDF - - )} + {/* + Conditional PDF Button: + - Logged in: "View PDF" (blue) → Opens /drugSummary in new tab + - Not logged in: "Download PDF" (green) → Direct download via /v1/api/uploadFile/ + - Fallback: Extracts GUID from link_url if upload_file_guid is missing + */} + {s.link_url && (() => { + const guid = s.upload_file_guid || extractGuidFromUrl(s.link_url); + if (!guid) return null; + + return isAuthenticated ? ( + + View PDF + + ) : ( + + Download PDF + + ); + })()}
    {s.publication && ( @@ -192,6 +232,8 @@ const MedicationTier = ({ riskData, loading, onTierClick, + isAuthenticated, + baseURL, }: { title: string; tier: string; @@ -200,6 +242,8 @@ const MedicationTier = ({ riskData: RiskData | null; loading: boolean; onTierClick: (medication: MedicationWithSource) => void; + isAuthenticated: boolean | null; + baseURL: string; }) => ( <>
    @@ -216,6 +260,8 @@ const MedicationTier = ({ riskData={riskData} loading={loading} onTierClick={() => onTierClick(medicationObj)} + isAuthenticated={isAuthenticated} + baseURL={baseURL} /> ))} @@ -232,7 +278,9 @@ const PatientSummary = ({ setIsEditing, patientInfo, isPatientDeleted, + isAuthenticated = false, }: PatientSummaryProps) => { + const baseURL = import.meta.env.VITE_API_BASE_URL || ''; const [loading, setLoading] = useState(false); const [riskData, setRiskData] = useState(null); const [clickedMedication, setClickedMedication] = useState( @@ -374,6 +422,8 @@ const PatientSummary = ({ riskData={riskData} loading={loading} onTierClick={handleTierClick} + isAuthenticated={isAuthenticated} + baseURL={baseURL} />
    @@ -395,6 +447,8 @@ const PatientSummary = ({ riskData={riskData} loading={loading} onTierClick={handleTierClick} + isAuthenticated={isAuthenticated} + baseURL={baseURL} />