diff --git a/backend/app/auth.py b/backend/app/auth.py new file mode 100644 index 0000000..e3d4112 --- /dev/null +++ b/backend/app/auth.py @@ -0,0 +1,93 @@ +from datetime import datetime, timedelta +from typing import Optional +from jose import JWTError, jwt +from passlib.context import CryptContext +from fastapi import Depends, HTTPException, status, Header +from sqlalchemy.orm import Session +from .database import get_db +from .models import User + +# Configuration +SECRET_KEY = "your-secret-key-change-in-production" # Change this in production! +ALGORITHM = "HS256" +ACCESS_TOKEN_EXPIRE_MINUTES = 720 + +pwd_context = CryptContext(schemes=["argon2"], deprecated="auto") + + +def hash_password(password: str) -> str: + """Hash a password using bcrypt""" + return pwd_context.hash(password) + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + """Verify a password against its hash""" + return pwd_context.verify(plain_password, hashed_password) + + +def create_access_token(data: dict, expires_delta: Optional[timedelta] = None): + """Create a JWT access token""" + to_encode = data.copy() + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta(minutes=15) + to_encode.update({"exp": expire}) + encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) + return encoded_jwt + + +def get_current_user(authorization: Optional[str] = Header(None), db: Session = Depends(get_db)) -> User: + """Get the current authenticated user from JWT token""" + if not authorization: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Missing authorization header", + headers={"WWW-Authenticate": "Bearer"}, + ) + + try: + scheme, credentials = authorization.split() + if scheme.lower() != "bearer": + raise ValueError("Invalid auth scheme") + except (ValueError, AttributeError): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid authorization header format", + headers={"WWW-Authenticate": "Bearer"}, + ) + + try: + payload = jwt.decode(credentials, SECRET_KEY, algorithms=[ALGORITHM]) + username: str = payload.get("sub") + if username is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid authentication credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + except JWTError: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid authentication credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + + user = db.query(User).filter(User.username == username).first() + if user is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid authentication credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + return user + + +def get_current_admin_user(current_user: User = Depends(get_current_user)) -> User: + """Get the current user and verify they are an admin""" + if not current_user.is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not enough permissions. Admin access required." + ) + return current_user diff --git a/backend/app/main.py b/backend/app/main.py index 95bbc2b..03aee8a 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,10 +1,11 @@ -from fastapi import FastAPI, Depends, HTTPException, APIRouter +from fastapi import FastAPI, Depends, HTTPException, APIRouter, status from fastapi.middleware.cors import CORSMiddleware from sqlalchemy.orm import Session from typing import List, Optional -from datetime import datetime +from datetime import datetime, timedelta from .database import engine, get_db, Base -from .models import Drug, DrugVariant, Dispensing +from .models import Drug, DrugVariant, Dispensing, User +from .auth import hash_password, verify_password, create_access_token, get_current_user, get_current_admin_user, ACCESS_TOKEN_EXPIRE_MINUTES from pydantic import BaseModel # Create tables @@ -25,18 +26,42 @@ app.add_middleware( router = APIRouter(prefix="/api") # Pydantic schemas +class UserCreate(BaseModel): + username: str + password: str + +class PasswordChange(BaseModel): + current_password: str + new_password: str + +class AdminPasswordChange(BaseModel): + new_password: str + +class UserResponse(BaseModel): + id: int + username: str + is_admin: bool + + class Config: + from_attributes = True + +class TokenResponse(BaseModel): + access_token: str + token_type: str + user: UserResponse + class DrugCreate(BaseModel): name: str - description: str = None + description: Optional[str] = None class DrugUpdate(BaseModel): - name: str = None - description: str = None + name: Optional[str] = None + description: Optional[str] = None class DrugResponse(BaseModel): id: int name: str - description: str = None + description: Optional[str] = None class Config: from_attributes = True @@ -67,7 +92,7 @@ class DrugVariantResponse(BaseModel): class DrugWithVariantsResponse(BaseModel): id: int name: str - description: str = None + description: Optional[str] = None variants: List[DrugVariantResponse] = [] class Config: @@ -92,13 +117,153 @@ class DispensingResponse(BaseModel): class Config: from_attributes = True +# Authentication Routes +@router.post("/auth/register", response_model=TokenResponse) +def register(user_data: UserCreate, db: Session = Depends(get_db)): + """Register the first admin user (only allowed if no users exist)""" + # Check if users already exist + user_count = db.query(User).count() + if user_count > 0: + raise HTTPException( + status_code=403, + detail="Registration is disabled. Contact an administrator to create an account." + ) + + # Check if user already exists + existing_user = db.query(User).filter(User.username == user_data.username).first() + if existing_user: + raise HTTPException(status_code=400, detail="Username already registered") + + # First (and only allowed) user is admin + hashed_password = hash_password(user_data.password) + db_user = User( + username=user_data.username, + hashed_password=hashed_password, + is_admin=True + ) + db.add(db_user) + db.commit() + db.refresh(db_user) + + # Create access token + access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + access_token = create_access_token( + data={"sub": db_user.username}, + expires_delta=access_token_expires + ) + + return { + "access_token": access_token, + "token_type": "bearer", + "user": db_user + } + +@router.post("/auth/login", response_model=TokenResponse) +def login(user_data: UserCreate, db: Session = Depends(get_db)): + """Login with username and password""" + user = db.query(User).filter(User.username == user_data.username).first() + if not user or not verify_password(user_data.password, user.hashed_password): + raise HTTPException(status_code=401, detail="Invalid credentials") + + # Create access token + access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + access_token = create_access_token( + data={"sub": user.username}, + expires_delta=access_token_expires + ) + + return { + "access_token": access_token, + "token_type": "bearer", + "user": user + } + +@router.get("/auth/me", response_model=UserResponse) +def get_current_user_info(current_user: User = Depends(get_current_user)): + """Get current user info""" + return current_user + +# User Management Routes (Admin only) +@router.get("/users", response_model=List[UserResponse]) +def list_users(db: Session = Depends(get_db), current_user: User = Depends(get_current_admin_user)): + """List all users (admin only)""" + return db.query(User).all() + +@router.post("/users", response_model=UserResponse) +def create_user(user_data: UserCreate, db: Session = Depends(get_db), current_user: User = Depends(get_current_admin_user)): + """Create a new user (admin only)""" + # Check if user already exists + existing_user = db.query(User).filter(User.username == user_data.username).first() + if existing_user: + raise HTTPException(status_code=400, detail="Username already exists") + + hashed_password = hash_password(user_data.password) + db_user = User( + username=user_data.username, + hashed_password=hashed_password, + is_admin=False + ) + db.add(db_user) + db.commit() + db.refresh(db_user) + return db_user + +@router.delete("/users/{user_id}") +def delete_user(user_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_admin_user)): + """Delete a user (admin only)""" + # Don't allow deleting yourself + if current_user.id == user_id: + raise HTTPException(status_code=400, detail="Cannot delete your own user account") + + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + db.delete(user) + db.commit() + return {"message": "User deleted successfully"} + +@router.post("/auth/change-password") +def change_own_password(password_data: PasswordChange, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): + """Change current user's password""" + user = db.query(User).filter(User.id == current_user.id).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + # Verify current password + if not verify_password(password_data.current_password, user.hashed_password): + raise HTTPException(status_code=401, detail="Current password is incorrect") + + # Update password + user.hashed_password = hash_password(password_data.new_password) + db.commit() + + return {"message": "Password changed successfully"} + +@router.post("/users/{user_id}/change-password") +def admin_change_password(user_id: int, password_data: AdminPasswordChange, db: Session = Depends(get_db), current_user: User = Depends(get_current_admin_user)): + """Change a user's password (admin only)""" + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + # Don't allow changing yourself via this endpoint + if current_user.id == user_id: + raise HTTPException(status_code=400, detail="Use /auth/change-password to change your own password") + + # Update password + user.hashed_password = hash_password(password_data.new_password) + db.commit() + + return {"message": "Password changed successfully"} + # Routes @router.get("/") def read_root(): return {"message": "Drug Inventory API"} @router.get("/drugs", response_model=List[DrugWithVariantsResponse]) -def list_drugs(db: Session = Depends(get_db)): +def list_drugs(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): """Get all drugs with their variants""" drugs = db.query(Drug).all() result = [] @@ -110,7 +275,7 @@ def list_drugs(db: Session = Depends(get_db)): return result @router.get("/drugs/low-stock", response_model=List[DrugWithVariantsResponse]) -def low_stock_drugs(db: Session = Depends(get_db)): +def low_stock_drugs(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): """Get drugs with low stock variants""" # Get variants that are low on stock low_stock_variants = db.query(DrugVariant).filter( @@ -130,7 +295,7 @@ def low_stock_drugs(db: Session = Depends(get_db)): return result @router.get("/drugs/{drug_id}", response_model=DrugWithVariantsResponse) -def get_drug(drug_id: int, db: Session = Depends(get_db)): +def get_drug(drug_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): """Get a specific drug with its variants""" drug = db.query(Drug).filter(Drug.id == drug_id).first() if not drug: @@ -142,7 +307,7 @@ def get_drug(drug_id: int, db: Session = Depends(get_db)): return drug_dict @router.post("/drugs", response_model=DrugWithVariantsResponse) -def create_drug(drug: DrugCreate, db: Session = Depends(get_db)): +def create_drug(drug: DrugCreate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): """Create a new drug""" # Check if drug name already exists existing = db.query(Drug).filter(Drug.name == drug.name).first() @@ -160,7 +325,7 @@ def create_drug(drug: DrugCreate, db: Session = Depends(get_db)): return drug_dict @router.put("/drugs/{drug_id}", response_model=DrugWithVariantsResponse) -def update_drug(drug_id: int, drug_update: DrugUpdate, db: Session = Depends(get_db)): +def update_drug(drug_id: int, drug_update: DrugUpdate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): """Update a drug""" drug = db.query(Drug).filter(Drug.id == drug_id).first() if not drug: @@ -178,7 +343,7 @@ def update_drug(drug_id: int, drug_update: DrugUpdate, db: Session = Depends(get return drug_dict @router.delete("/drugs/{drug_id}") -def delete_drug(drug_id: int, db: Session = Depends(get_db)): +def delete_drug(drug_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): """Delete a drug and all its variants""" drug = db.query(Drug).filter(Drug.id == drug_id).first() if not drug: @@ -194,7 +359,7 @@ def delete_drug(drug_id: int, db: Session = Depends(get_db)): # Drug Variant endpoints @router.post("/drugs/{drug_id}/variants", response_model=DrugVariantResponse) -def create_drug_variant(drug_id: int, variant: DrugVariantCreate, db: Session = Depends(get_db)): +def create_drug_variant(drug_id: int, variant: DrugVariantCreate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): """Create a new variant for a drug""" # Check if drug exists drug = db.query(Drug).filter(Drug.id == drug_id).first() @@ -222,7 +387,7 @@ def create_drug_variant(drug_id: int, variant: DrugVariantCreate, db: Session = return db_variant @router.get("/variants/{variant_id}", response_model=DrugVariantResponse) -def get_drug_variant(variant_id: int, db: Session = Depends(get_db)): +def get_drug_variant(variant_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): """Get a specific drug variant""" variant = db.query(DrugVariant).filter(DrugVariant.id == variant_id).first() if not variant: @@ -230,7 +395,7 @@ def get_drug_variant(variant_id: int, db: Session = Depends(get_db)): return variant @router.put("/variants/{variant_id}", response_model=DrugVariantResponse) -def update_drug_variant(variant_id: int, variant_update: DrugVariantUpdate, db: Session = Depends(get_db)): +def update_drug_variant(variant_id: int, variant_update: DrugVariantUpdate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): """Update a drug variant""" variant = db.query(DrugVariant).filter(DrugVariant.id == variant_id).first() if not variant: @@ -244,7 +409,7 @@ def update_drug_variant(variant_id: int, variant_update: DrugVariantUpdate, db: return variant @router.delete("/variants/{variant_id}") -def delete_drug_variant(variant_id: int, db: Session = Depends(get_db)): +def delete_drug_variant(variant_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): """Delete a drug variant""" variant = db.query(DrugVariant).filter(DrugVariant.id == variant_id).first() if not variant: @@ -257,7 +422,7 @@ def delete_drug_variant(variant_id: int, db: Session = Depends(get_db)): # Dispensing endpoints @router.post("/dispense", response_model=DispensingResponse) -def dispense_drug(dispensing: DispensingCreate, db: Session = Depends(get_db)): +def dispense_drug(dispensing: DispensingCreate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): """Record a drug dispensing and reduce inventory""" # Check if drug variant exists variant = db.query(DrugVariant).filter(DrugVariant.id == dispensing.drug_variant_id).first() @@ -283,12 +448,12 @@ def dispense_drug(dispensing: DispensingCreate, db: Session = Depends(get_db)): return db_dispensing @router.get("/dispense/history", response_model=List[DispensingResponse]) -def list_dispensings(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)): +def list_dispensings(skip: int = 0, limit: int = 100, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): """Get dispensing records (audit log)""" return db.query(Dispensing).order_by(Dispensing.dispensed_at.desc()).offset(skip).limit(limit).all() @router.get("/drugs/{drug_id}/dispense/history", response_model=List[DispensingResponse]) -def get_drug_dispensings(drug_id: int, db: Session = Depends(get_db)): +def get_drug_dispensings(drug_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): """Get dispensing history for a specific drug (all variants)""" # Verify drug exists drug = db.query(Drug).filter(Drug.id == drug_id).first() @@ -301,7 +466,7 @@ def get_drug_dispensings(drug_id: int, db: Session = Depends(get_db)): return db.query(Dispensing).filter(Dispensing.drug_variant_id.in_(variant_ids)).order_by(Dispensing.dispensed_at.desc()).all() @router.get("/variants/{variant_id}/dispense/history", response_model=List[DispensingResponse]) -def get_variant_dispensings(variant_id: int, db: Session = Depends(get_db)): +def get_variant_dispensings(variant_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): """Get dispensing history for a specific drug variant""" # Verify variant exists variant = db.query(DrugVariant).filter(DrugVariant.id == variant_id).first() diff --git a/backend/app/models.py b/backend/app/models.py index 6de779a..efcc519 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -1,7 +1,17 @@ -from sqlalchemy import Column, Integer, String, Float, DateTime, ForeignKey +from sqlalchemy import Column, Integer, String, Float, DateTime, ForeignKey, Boolean from sqlalchemy.sql import func from .database import Base +class User(Base): + __tablename__ = "users" + + id = Column(Integer, primary_key=True, index=True) + username = Column(String, unique=True, index=True, nullable=False) + hashed_password = Column(String, nullable=False) + is_admin = Column(Boolean, default=False) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + class Drug(Base): __tablename__ = "drugs" @@ -31,7 +41,7 @@ class Dispensing(Base): id = Column(Integer, primary_key=True, index=True) drug_variant_id = Column(Integer, ForeignKey("drug_variants.id"), nullable=False) quantity = Column(Float, nullable=False) - animal_name = Column(String, nullable=False) # Name/ID of the animal + animal_name = Column(String, nullable=True) # Name/ID of the animal (optional) user_name = Column(String, nullable=False) # User who dispensed dispensed_at = Column(DateTime(timezone=True), server_default=func.now(), index=True) notes = Column(String, nullable=True) diff --git a/backend/requirements.txt b/backend/requirements.txt index 55acc1c..8c18216 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -3,3 +3,5 @@ uvicorn==0.24.0 sqlalchemy==2.0.23 pydantic==2.5.0 python-multipart==0.0.6 +python-jose[cryptography]==3.3.0 +passlib[argon2]==1.7.4 diff --git a/frontend/app.js b/frontend/app.js index b50773b..c68b3a6 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -4,9 +4,137 @@ let currentDrug = null; let showLowStockOnly = false; let searchTerm = ''; let expandedDrugs = new Set(); +let currentUser = null; +let accessToken = null; -// Initialize +// Initialize on page load document.addEventListener('DOMContentLoaded', () => { + checkAuth(); +}); + +// Check if user is already logged in +function checkAuth() { + const token = localStorage.getItem('accessToken'); + const user = localStorage.getItem('currentUser'); + + if (token && user) { + accessToken = token; + currentUser = JSON.parse(user); + showMainApp(); + } else { + showLoginPage(); + } +} + +// Show login page +function showLoginPage() { + document.getElementById('loginPage').style.display = 'flex'; + document.getElementById('mainApp').style.display = 'none'; + + const loginForm = document.getElementById('loginForm'); + if (loginForm) loginForm.addEventListener('submit', handleLogin); +} + +// Show main app +function showMainApp() { + document.getElementById('loginPage').style.display = 'none'; + document.getElementById('mainApp').style.display = 'block'; + + const userDisplay = document.getElementById('currentUser'); + if (userDisplay) { + userDisplay.textContent = `👤 ${currentUser.username}`; + } + + const adminBtn = document.getElementById('adminBtn'); + if (adminBtn) { + adminBtn.style.display = currentUser.is_admin ? 'block' : 'none'; + } + + setupEventListeners(); + loadDrugs(); +} + +// Handle login +async function handleLogin(e) { + e.preventDefault(); + const username = document.getElementById('loginUsername').value; + const password = document.getElementById('loginPassword').value; + + try { + const response = await fetch(`${API_URL}/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password }) + }); + + if (!response.ok) { + throw new Error('Invalid credentials'); + } + + const data = await response.json(); + accessToken = data.access_token; + currentUser = data.user; + + localStorage.setItem('accessToken', accessToken); + localStorage.setItem('currentUser', JSON.stringify(currentUser)); + + document.getElementById('loginForm').reset(); + const errorDiv = document.getElementById('loginError'); + if (errorDiv) errorDiv.style.display = 'none'; + showMainApp(); + } catch (error) { + const errorDiv = document.getElementById('loginError'); + if (errorDiv) { + errorDiv.textContent = error.message; + errorDiv.style.display = 'block'; + } + } +} + +// Handle register +// Logout +function handleLogout() { + localStorage.removeItem('accessToken'); + localStorage.removeItem('currentUser'); + accessToken = null; + currentUser = null; + const loginForm = document.getElementById('loginForm'); + if (loginForm) loginForm.reset(); + const registerForm = document.getElementById('registerForm'); + if (registerForm) { + registerForm.style.display = 'none'; + } + const form = document.getElementById('loginForm'); + if (form) form.style.display = 'block'; + showLoginPage(); +} + +// API helper with authentication +async function apiCall(endpoint, options = {}) { + const headers = { + 'Content-Type': 'application/json', + ...options.headers + }; + + if (accessToken) { + headers['Authorization'] = `Bearer ${accessToken}`; + } + + const response = await fetch(`${API_URL}${endpoint}`, { + ...options, + headers + }); + + if (response.status === 401) { + handleLogout(); + throw new Error('Authentication expired'); + } + + return response; +} + +// Setup event listeners +function setupEventListeners() { const drugForm = document.getElementById('drugForm'); const variantForm = document.getElementById('variantForm'); const editVariantForm = document.getElementById('editVariantForm'); @@ -18,6 +146,7 @@ document.addEventListener('DOMContentLoaded', () => { const dispenseModal = document.getElementById('dispenseModal'); const editModal = document.getElementById('editModal'); const addDrugBtn = document.getElementById('addDrugBtn'); + const dispenseBtn = document.getElementById('dispenseBtn'); const cancelAddBtn = document.getElementById('cancelAddBtn'); const cancelVariantBtn = document.getElementById('cancelVariantBtn'); const cancelEditVariantBtn = document.getElementById('cancelEditVariantBtn'); @@ -25,41 +154,75 @@ document.addEventListener('DOMContentLoaded', () => { const cancelEditBtn = document.getElementById('cancelEditBtn'); const showAllBtn = document.getElementById('showAllBtn'); const showLowStockBtn = document.getElementById('showLowStockBtn'); + const userMenuBtn = document.getElementById('userMenuBtn'); + const adminBtn = document.getElementById('adminBtn'); + const logoutBtn = document.getElementById('logoutBtn'); + const changePasswordBtn = document.getElementById('changePasswordBtn'); // Modal close buttons const closeButtons = document.querySelectorAll('.close'); - drugForm.addEventListener('submit', handleAddDrug); - variantForm.addEventListener('submit', handleAddVariant); - editVariantForm.addEventListener('submit', handleEditVariant); - dispenseForm.addEventListener('submit', handleDispenseDrug); - editForm.addEventListener('submit', handleEditDrug); + if (drugForm) drugForm.addEventListener('submit', handleAddDrug); + if (variantForm) variantForm.addEventListener('submit', handleAddVariant); + if (editVariantForm) editVariantForm.addEventListener('submit', handleEditVariant); + if (dispenseForm) dispenseForm.addEventListener('submit', handleDispenseDrug); + if (editForm) editForm.addEventListener('submit', handleEditDrug); - addDrugBtn.addEventListener('click', () => openModal(addModal)); + if (addDrugBtn) addDrugBtn.addEventListener('click', () => openModal(addModal)); + if (dispenseBtn) dispenseBtn.addEventListener('click', () => { + updateDispenseDrugSelect(); + openModal(dispenseModal); + }); + + if (cancelAddBtn) cancelAddBtn.addEventListener('click', () => closeModal(addModal)); + if (cancelVariantBtn) cancelVariantBtn.addEventListener('click', () => closeModal(addVariantModal)); + if (cancelEditVariantBtn) cancelEditVariantBtn.addEventListener('click', () => closeModal(editVariantModal)); + if (cancelDispenseBtn) cancelDispenseBtn.addEventListener('click', () => closeModal(dispenseModal)); + if (cancelEditBtn) cancelEditBtn.addEventListener('click', closeEditModal); - cancelAddBtn.addEventListener('click', () => closeModal(addModal)); - cancelVariantBtn.addEventListener('click', () => closeModal(addVariantModal)); - cancelEditVariantBtn.addEventListener('click', () => closeModal(editVariantModal)); - cancelDispenseBtn.addEventListener('click', () => closeModal(dispenseModal)); - cancelEditBtn.addEventListener('click', closeEditModal); const closeHistoryBtn = document.getElementById('closeHistoryBtn'); - closeHistoryBtn.addEventListener('click', () => closeModal(document.getElementById('historyModal'))); + if (closeHistoryBtn) closeHistoryBtn.addEventListener('click', () => closeModal(document.getElementById('historyModal'))); + + const closeUserManagementBtn = document.getElementById('closeUserManagementBtn'); + if (closeUserManagementBtn) closeUserManagementBtn.addEventListener('click', () => closeModal(document.getElementById('userManagementModal'))); + + const changePasswordForm = document.getElementById('changePasswordForm'); + if (changePasswordForm) changePasswordForm.addEventListener('submit', handleChangePassword); + + const cancelChangePasswordBtn = document.getElementById('cancelChangePasswordBtn'); + if (cancelChangePasswordBtn) cancelChangePasswordBtn.addEventListener('click', () => closeModal(document.getElementById('changePasswordModal'))); + + const adminChangePasswordForm = document.getElementById('adminChangePasswordForm'); + if (adminChangePasswordForm) adminChangePasswordForm.addEventListener('submit', handleAdminChangePassword); + + const cancelAdminChangePasswordBtn = document.getElementById('cancelAdminChangePasswordBtn'); + if (cancelAdminChangePasswordBtn) cancelAdminChangePasswordBtn.addEventListener('click', () => closeModal(document.getElementById('adminChangePasswordModal'))); closeButtons.forEach(btn => btn.addEventListener('click', (e) => { const modal = e.target.closest('.modal'); closeModal(modal); })); - showAllBtn.addEventListener('click', () => { + if (showAllBtn) showAllBtn.addEventListener('click', () => { showLowStockOnly = false; updateFilterButtons(); renderDrugs(); }); - showLowStockBtn.addEventListener('click', () => { + if (showLowStockBtn) showLowStockBtn.addEventListener('click', () => { showLowStockOnly = true; updateFilterButtons(); renderDrugs(); }); + + // User menu + if (userMenuBtn) userMenuBtn.addEventListener('click', () => { + const dropdown = document.getElementById('userDropdown'); + if (dropdown) dropdown.style.display = dropdown.style.display === 'none' ? 'block' : 'none'; + }); + + if (changePasswordBtn) changePasswordBtn.addEventListener('click', openChangePasswordModal); + if (adminBtn) adminBtn.addEventListener('click', openUserManagement); + if (logoutBtn) logoutBtn.addEventListener('click', handleLogout); // Search functionality const drugSearch = document.getElementById('drugSearch'); @@ -80,14 +243,12 @@ document.addEventListener('DOMContentLoaded', () => { closeModal(e.target); } }); - - loadDrugs(); -}); +} // Load drugs from API async function loadDrugs() { try { - const response = await fetch(`${API_URL}/drugs`); + const response = await apiCall('/drugs'); if (!response.ok) throw new Error('Failed to load drugs'); allDrugs = await response.json(); @@ -96,18 +257,32 @@ async function loadDrugs() { } catch (error) { console.error('Error loading drugs:', error); document.getElementById('drugsList').innerHTML = - '

Error loading drugs. Make sure the backend is running on http://localhost:8000

'; + '

Error loading drugs. Make sure the backend is running.

'; } } // Modal utility functions function openModal(modal) { + // Find the highest z-index among currently visible modals + const visibleModals = document.querySelectorAll('.modal.show'); + let maxZIndex = 1000; + + visibleModals.forEach(m => { + const zIndex = parseInt(window.getComputedStyle(m).zIndex, 10) || 1000; + if (zIndex > maxZIndex) { + maxZIndex = zIndex; + } + }); + + // Set the new modal's z-index higher than any existing modal + modal.style.zIndex = (maxZIndex + 100).toString(); modal.classList.add('show'); document.body.style.overflow = 'hidden'; } function closeModal(modal) { modal.classList.remove('show'); + modal.style.zIndex = '1000'; document.body.style.overflow = 'auto'; } @@ -224,9 +399,8 @@ async function handleAddDrug(e) { try { // Create the drug first - const drugResponse = await fetch(`${API_URL}/drugs`, { + const drugResponse = await apiCall('/drugs', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(drugData) }); @@ -243,9 +417,8 @@ async function handleAddDrug(e) { low_stock_threshold: parseFloat(document.getElementById('initialVariantThreshold').value) || 10 }; - const variantResponse = await fetch(`${API_URL}/drugs/${createdDrug.id}/variants`, { + const variantResponse = await apiCall(`/drugs/${createdDrug.id}/variants`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(variantData) }); @@ -282,15 +455,14 @@ async function handleDispenseDrug(e) { const dispensingData = { drug_variant_id: variantId, quantity: quantity, - animal_name: animalName, + animal_name: animalName || null, user_name: userName, notes: notes || null }; try { - const response = await fetch(`${API_URL}/dispense`, { + const response = await apiCall('/dispense', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(dispensingData) }); @@ -360,9 +532,8 @@ async function handleAddVariant(e) { }; try { - const response = await fetch(`${API_URL}/drugs/${drugId}/variants`, { + const response = await apiCall(`/drugs/${drugId}/variants`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(variantData) }); @@ -412,9 +583,8 @@ async function handleEditVariant(e) { }; try { - const response = await fetch(`${API_URL}/variants/${variantId}`, { + const response = await apiCall(`/variants/${variantId}`, { method: 'PUT', - headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(variantData) }); @@ -448,7 +618,7 @@ async function deleteVariant(variantId) { if (!confirm('Are you sure you want to delete this variant?')) return; try { - const response = await fetch(`${API_URL}/variants/${variantId}`, { + const response = await apiCall(`/variants/${variantId}`, { method: 'DELETE' }); @@ -476,7 +646,7 @@ async function showDrugHistory(drugId) { openModal(historyModal); try { - const response = await fetch(`${API_URL}/dispense/history`); + const response = await apiCall(`/dispense/history`); if (!response.ok) throw new Error('Failed to fetch history'); const allHistory = await response.json(); @@ -546,9 +716,8 @@ async function handleEditDrug(e) { }; try { - const response = await fetch(`${API_URL}/drugs/${drugId}`, { + const response = await apiCall(`/drugs/${drugId}`, { method: 'PUT', - headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(drugData) }); @@ -568,7 +737,7 @@ async function deleteDrug(drugId) { if (!confirm('Are you sure you want to delete this drug?')) return; try { - const response = await fetch(`${API_URL}/drugs/${drugId}`, { + const response = await apiCall(`/drugs/${drugId}`, { method: 'DELETE' }); @@ -582,6 +751,104 @@ async function deleteDrug(drugId) { } } +// Password Management +function openChangePasswordModal() { + const modal = document.getElementById('changePasswordModal'); + document.getElementById('changePasswordForm').reset(); + + // Close dropdown + const dropdown = document.getElementById('userDropdown'); + if (dropdown) dropdown.style.display = 'none'; + + openModal(modal); +} + +async function handleChangePassword(e) { + e.preventDefault(); + + const currentPassword = document.getElementById('currentPassword').value; + const newPassword = document.getElementById('newPassword').value; + const confirmPassword = document.getElementById('confirmNewPassword').value; + + if (newPassword !== confirmPassword) { + alert('New passwords do not match!'); + return; + } + + if (newPassword.length < 1) { + alert('New password cannot be empty!'); + return; + } + + try { + const response = await apiCall('/auth/change-password', { + method: 'POST', + body: JSON.stringify({ + current_password: currentPassword, + new_password: newPassword + }) + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.detail || 'Failed to change password'); + } + + alert('Password changed successfully!'); + closeModal(document.getElementById('changePasswordModal')); + } catch (error) { + console.error('Error changing password:', error); + alert('Failed to change password: ' + error.message); + } +} + +async function openAdminChangePasswordModal(userId, username) { + const modal = document.getElementById('adminChangePasswordModal'); + document.getElementById('adminChangePasswordForm').reset(); + document.getElementById('adminChangePasswordUserId').value = userId; + document.getElementById('adminChangePasswordUsername').value = username; + openModal(modal); +} + +async function handleAdminChangePassword(e) { + e.preventDefault(); + + const userId = document.getElementById('adminChangePasswordUserId').value; + const newPassword = document.getElementById('adminChangePasswordNewPassword').value; + const confirmPassword = document.getElementById('adminChangePasswordConfirm').value; + + if (newPassword !== confirmPassword) { + alert('Passwords do not match!'); + return; + } + + if (newPassword.length < 1) { + alert('Password cannot be empty!'); + return; + } + + try { + const response = await apiCall(`/users/${userId}/change-password`, { + method: 'POST', + body: JSON.stringify({ + new_password: newPassword + }) + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.detail || 'Failed to change password'); + } + + alert('Password changed successfully!'); + closeModal(document.getElementById('adminChangePasswordModal')); + openUserManagement(); + } catch (error) { + console.error('Error changing password:', error); + alert('Failed to change password: ' + error.message); + } +} + // Update filter button states function updateFilterButtons() { document.getElementById('showAllBtn').classList.toggle('active', !showLowStockOnly); @@ -594,3 +861,93 @@ function escapeHtml(text) { div.textContent = text; return div.innerHTML; } + +// User Management +async function openUserManagement() { + const modal = document.getElementById('userManagementModal'); + document.getElementById('newUsername').value = ''; + document.getElementById('newUserPassword').value = ''; + + const usersList = document.getElementById('usersList'); + usersList.innerHTML = '

Users

Loading users...

'; + + try { + const response = await apiCall('/users'); + if (!response.ok) throw new Error('Failed to load users'); + + const users = await response.json(); + + const usersHtml = ` +

Users

+
+ ${users.map(user => ` +
+ ${user.username} + ${user.is_admin ? '👑 Admin' : 'User'} + + ${user.id !== currentUser.id ? ` + + ` : ''} +
+ `).join('')} +
+ `; + + usersList.innerHTML = usersHtml; + } catch (error) { + console.error('Error loading users:', error); + usersList.innerHTML = '

Users

Error loading users

'; + } + + const createUserForm = document.getElementById('createUserForm'); + if (createUserForm) { + createUserForm.onsubmit = createUser; + } + + openModal(modal); +} + +// Create user +async function createUser(e) { + e.preventDefault(); + + const username = document.getElementById('newUsername').value; + const password = document.getElementById('newUserPassword').value; + + try { + const response = await apiCall('/users', { + method: 'POST', + body: JSON.stringify({ username, password }) + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.detail || 'Failed to create user'); + } + + document.getElementById('newUsername').value = ''; + document.getElementById('newUserPassword').value = ''; + alert('User created successfully!'); + openUserManagement(); + } catch (error) { + console.error('Error creating user:', error); + alert('Failed to create user: ' + error.message); + } +} + +// Delete user +async function deleteUser(userId) { + if (!confirm('Are you sure you want to delete this user?')) return; + + try { + const response = await apiCall(`/users/${userId}`, { method: 'DELETE' }); + + if (!response.ok) throw new Error('Failed to delete user'); + + alert('User deleted successfully!'); + openUserManagement(); + } catch (error) { + console.error('Error deleting user:', error); + alert('Failed to delete user: ' + error.message); + } +} diff --git a/frontend/index.html b/frontend/index.html index 9b88c98..32da6c1 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -7,19 +7,53 @@ -
-
+ +
+
+

Login

+
+ + +
+
+ + +
+ + +
+ +
+ -
- -
-
-

Current Inventory

-
- + +