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/api/apiClient.ts b/frontend/src/api/apiClient.ts index 26a6ab8a..915226d6 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) { @@ -29,7 +31,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, @@ -42,10 +44,13 @@ 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 response = await api.post(endpoint, { + const response = await adminApi.post(endpoint, { message, }); console.log("Response data:", JSON.stringify(response.data, null, 2)); @@ -58,7 +63,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) { @@ -67,9 +72,12 @@ 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`, { + const response = await publicApi.post(`/v1/api/riskWithSources`, { drug: medication, source: source, }); @@ -90,7 +98,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 +173,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,13 +200,13 @@ 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"), }); }; 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 +216,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 +226,7 @@ const fetchConversation = async (id: string): Promise => { const newConversation = async (): Promise => { try { - const response = await api.post(`/chatgpt/conversations/`, { + const response = await adminApi.post(`/chatgpt/conversations/`, { messages: [], }); return response.data; @@ -228,7 +242,7 @@ const continueConversation = async ( page_context?: string, ): Promise<{ response: string; title: Conversation["title"] }> => { try { - const response = await api.post( + const response = await adminApi.post( `/chatgpt/conversations/${id}/continue_conversation/`, { message, @@ -244,7 +258,7 @@ const continueConversation = async ( const deleteConversation = async (id: string) => { try { - const response = await api.delete(`/chatgpt/conversations/${id}/`); + const response = await adminApi.delete(`/chatgpt/conversations/${id}/`); return response.data; } catch (error) { console.error("Error(s) during deleteConversation: ", error); @@ -255,9 +269,11 @@ 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/`, { + const response = await adminApi.patch(`/chatgpt/conversations/${id}/update_title/`, { title: newTitle, }); return response.data; @@ -268,9 +284,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 +313,5 @@ export { handleSendDrugSummaryStream, handleSendDrugSummaryStreamLegacy, fetchRiskDataWithSources, - sendAssistantMessage -}; \ No newline at end of file + sendAssistantMessage, +}; 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 49798c83..cbbd2c93 100644 --- a/frontend/src/components/Header/Header.tsx +++ b/frontend/src/components/Header/Header.tsx @@ -1,20 +1,16 @@ -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 LoginMenuDropDown from "./LoginMenuDropDown"; 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 { - isAuthenticated: boolean; + isAuthenticated: boolean | null; isSuperuser: boolean; } @@ -24,44 +20,19 @@ 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(); - const dispatch = useDispatch(); - - const logout_user = () => { - dispatch(logout()); - setRedirect(false); - }; - - const guestLinks = () => ( - - ); - const authLinks = () => ( - + + Sign Out + + ); - const handleLoginMenu = () => { - setShowLoginMenu(!showLoginMenu); - }; - const handleMouseEnter = () => { if (delayTimeout !== null) { clearTimeout(delayTimeout); @@ -102,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 . @@ -133,7 +111,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/components/Header/MdNavBar.tsx b/frontend/src/components/Header/MdNavBar.tsx index 926794cf..5a8d5bce 100644 --- a/frontend/src/components/Header/MdNavBar.tsx +++ b/frontend/src/components/Header/MdNavBar.tsx @@ -5,11 +5,9 @@ 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; + isAuthenticated: boolean | null; handleForm: () => void; } @@ -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 + +
  • }
    - {isAuthenticated && ( - - )} +
    ); }; 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/components/ProtectedRoute/ProtectedRoute.tsx b/frontend/src/components/ProtectedRoute/ProtectedRoute.tsx new file mode 100644 index 00000000..dc323217 --- /dev/null +++ b/frontend/src/components/ProtectedRoute/ProtectedRoute.tsx @@ -0,0 +1,31 @@ +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; +} + +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) + // TODO: Consider adding error handling for auth check failures + if (isAuthenticated === null) { + // 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 ; + } + + // If not authenticated, redirect to login and include the original location + if (!isAuthenticated) { + return ; + } + + return children; +}; + +export default ProtectedRoute; \ No newline at end of file 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 b53874bf..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 { api } 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 api.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 api.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 api.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.tsx b/frontend/src/pages/Layout/Layout.tsx index 3c12358b..02274b78 100644 --- a/frontend/src/pages/Layout/Layout.tsx +++ b/frontend/src/pages/Layout/Layout.tsx @@ -1,48 +1,22 @@ // 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 LoginMenuDropDown from "../../components/Header/LoginMenuDropDown"; 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; } interface LoginFormProps { - isAuthenticated: boolean; + isAuthenticated: boolean | null; } 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 (
    @@ -50,12 +24,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..c896b7b1 100644 --- a/frontend/src/pages/Layout/Layout_V2_Header.tsx +++ b/frontend/src/pages/Layout/Layout_V2_Header.tsx @@ -1,38 +1,17 @@ -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"; interface LoginFormProps { - isAuthenticated: boolean; + isAuthenticated: boolean | null; } -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 ( @@ -65,14 +44,6 @@ const Header: React.FC = ({ isAuthenticated }) => { )}
    - {!isAuthenticated && showLoginMenu && ( -
    - -
    - )} ); }; 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/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/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/Login/LoginForm.tsx b/frontend/src/pages/Login/LoginForm.tsx index d4579ead..d0d08184 100644 --- a/frontend/src/pages/Login/LoginForm.tsx +++ b/frontend/src/pages/Login/LoginForm.tsx @@ -1,15 +1,15 @@ 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"; 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; + isAuthenticated: boolean | null; loginError?: string | null; // Align this with the mapped state } @@ -59,11 +59,20 @@ 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 Homepage

    +
    +
    @@ -100,11 +109,6 @@ function LoginForm({ isAuthenticated, loginError }: LoginFormProps) {
    - {/* - - */} 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/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/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 232ed296..b2ff2e01 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"; @@ -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); } }, []); @@ -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 ?? [], @@ -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); 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} />
    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"); diff --git a/frontend/src/routes/routes.tsx b/frontend/src/routes/routes.tsx index 2e6273d4..dc974e85 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"; @@ -17,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 = [ { @@ -26,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", @@ -50,6 +52,10 @@ const routes = [ path: "login", element: , }, + { + path: "logout", + element: , + }, { path: "resetPassword", element: , @@ -80,11 +86,11 @@ const routes = [ }, { path: "adminportal", - element: , + element: , }, { path: "Settings", - element: , + element: , }, { path: "medications", @@ -92,7 +98,7 @@ const routes = [ }, { path: "managemeds", - element: , + element: , }, ]; diff --git a/frontend/src/services/actions/auth.tsx b/frontend/src/services/actions/auth.tsx index bfbfbe41..3dcfcac5 100644 --- a/frontend/src/services/actions/auth.tsx +++ b/frontend/src/services/actions/auth.tsx @@ -151,6 +151,10 @@ export const login = try { const res = await axios.post(url, body, config); + // Clear session data from previous unauthenticated session + sessionStorage.removeItem('currentConversation'); + sessionStorage.removeItem('patientInfos'); + dispatch({ type: LOGIN_SUCCESS, payload: res.data, @@ -172,8 +176,9 @@ export const login = }; export const logout = () => async (dispatch: AppDispatch) => { - // Clear chat conversation data on logout for security - sessionStorage.removeItem("currentConversation"); + // Clear session data on logout for privacy + sessionStorage.removeItem('currentConversation'); + sessionStorage.removeItem('patientInfos'); dispatch({ type: LOGOUT, diff --git a/frontend/src/services/actions/types.tsx b/frontend/src/services/actions/types.tsx index add0dad9..c7f73b94 100644 --- a/frontend/src/services/actions/types.tsx +++ b/frontend/src/services/actions/types.tsx @@ -21,7 +21,8 @@ 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; }; } 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: [], }, diff --git a/server/api/services/embedding_services.py b/server/api/services/embedding_services.py index 6fd34d35..b50dd750 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.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: 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: 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 1976458e..fcd0edf2 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', '') @@ -71,6 +74,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: diff --git a/server/api/views/risk/views_riskWithSources.py b/server/api/views/risk/views_riskWithSources.py index 0be43dbb..c02908fc 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") diff --git a/server/api/views/uploadFile/views.py b/server/api/views/uploadFile/views.py index 6904e061..69dfb996 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 @@ -15,16 +15,15 @@ class UploadFileView(APIView): + 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) @@ -156,12 +155,11 @@ def delete(self, request, format=None): 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}"'