diff --git a/.gitignore b/.gitignore index f1509955..b178e03b 100644 --- a/.gitignore +++ b/.gitignore @@ -10,8 +10,11 @@ backend/venv/ *.pyo *.pyd *.egg-info/ -dist/ build/ +.pytest_cache/ +**/.pytest_cache/ +coverage/ +**/coverage/ # ── Runtime data directories ───────────────────────────────── # Content is excluded; the directories themselves are tracked via .gitkeep. diff --git a/AGENTS.md b/AGENTS.md index 998573f0..e7859738 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,6 +16,7 @@ ## Testing - Write unit tests for all utility functions - Minimum 80% coverage on new code +- Use Vitest for unit tests ## Security - Store all secrets in inventory.env — never hardcode credentials diff --git a/artifacts/superpowers/brainstorm.md b/artifacts/superpowers/brainstorm.md new file mode 100644 index 00000000..f613c625 --- /dev/null +++ b/artifacts/superpowers/brainstorm.md @@ -0,0 +1,47 @@ +## Goal +Reduce the technical debt and cognitive load inherited from large source files (some exceeding 1000 lines) by refactoring them into modular, functional units. The target is to align with the project rule of keeping files significantly under 300 lines where possible, improving maintainability and developer velocity. + +## Constraints +- **Maintain Parity**: No functional changes or regressions allowed during refactoring. +- **Stack Consistency**: Maintain Next.js 15 (React 19) and FastAPI patterns. +- **Prop Drilling**: Avoid excessive prop drilling; use Context or efficient state sharing where logical. +- **Type Safety**: Maintain strict TypeScript definitions throughout the refactoring process. + +## Known context +- **Frontend Hotspots**: `admin/page.tsx` (1039 lines), `inventory/page.tsx` (858 lines), and the main `page.tsx` (910 lines) are the primary pain points. +- **Backend Hotspots**: `routers/users.py` and `routers/admin_db.py` consolidate too many distinct administrative tasks. +- **Existing Components**: There is already a `components/` directory, indicating an established pattern for extraction. + +## Risks +- **Context Fragmentation**: Splitting state-heavy components can make tracking data flow harder if not documented. +- **Build Errors**: Next.js App Router is sensitive to client/server component boundaries during refactoring. +- **Refactoring Fatigue**: Large-scale changes across multiple critical files can introduce subtle bugs in event handlers or API sync logic. + +## Options (2–4) + +### Option 1: Functional Component Extraction (UI-First) +Extract major UI sections (e.g., Modals, Tab Panels, Complex List Items) into the `components/` directory. For `admin/page.tsx`, this would mean creating `LDAPSettings.tsx`, `DatabaseSettings.tsx`, and `AISettings.tsx`. +* **Pros**: Immediate reduction in file size; easier to style in isolation. +* **Cons**: Might require passing many props/callbacks. + +### Option 2: Custom Hook Logic Extraction (Logic-First) +Move all `useState`, `useEffect`, and API call logic from the pages into custom hooks (e.g., `useAdminSettings.ts`, `useInventoryData.ts`). +* **Pros**: Separates "How it works" from "How it looks"; reusable logic. +* **Cons**: Requires careful handling of dependency arrays and closure-related state bugs. + +### Option 3: Modular Sub-Routing (Backend) +Split the backend `users.py` and `admin_db.py` into smaller files based on specific functional domains (e.g., `admin_core.py`, `admin_backups.py`, `user_auth.py`). +* **Pros**: Cleaner API paths and documentation; easier unit testing. +* **Cons**: Requires updating `main.py` router inclusions. + +## Recommendation +**Hybrid Approach (Options 1 & 2)**: Focus on the "Big 3" frontend pages first. +1. **Extract Modals and Sections**: Move heavy UI blocks from `admin` and `inventory` into standalone components. +2. **Extract Data Logic**: Create custom hooks for the most complex state-management sections (like the multi-step `AIOnboarding` or the complex sync logic in `inventory`). +3. **Split Backend Routers**: Break down `users.py` and `admin_db.py` into functional sub-modules. + +## Acceptance criteria +- [ ] No single file in the `app/` or `routers/` directory exceeds 400 lines (aiming for 300). +- [ ] The dashboard, inventory manager, and scanner function exactly as in `v1.9.24`. +- [ ] TypeScript compilation passes without errors (`npm run build` equivalent). +- [ ] All new components use the established `StatCard` and CSS patterns (Tailwind). diff --git a/backend/__init__.py b/backend/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/main.py b/backend/main.py index ce27e27a..9e6c40eb 100644 --- a/backend/main.py +++ b/backend/main.py @@ -6,7 +6,8 @@ from slowapi import Limiter from slowapi.util import get_remote_address from . import models from .database import engine -from .routers import items, operations, users, categories, admin_db +from .routers import items, operations, users, categories +from .routers.admin import backups, config from .logger import log from .scheduler import scheduler, sync_scheduler_config @@ -87,7 +88,8 @@ app.include_router(items.router) app.include_router(operations.router) app.include_router(users.router) app.include_router(categories.router) -app.include_router(admin_db.router) +app.include_router(backups.router) +app.include_router(config.router) @app.on_event("startup") def startup_event(): diff --git a/backend/requirements.txt b/backend/requirements.txt index 25775e84..46ee4d85 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -13,3 +13,6 @@ passlib[bcrypt]>=1.7.4 python-jose[cryptography]>=3.3.0 slowapi>=0.1.9 apscheduler>=3.10.1 +pytest>=8.0.0 +pytest-asyncio>=0.23.0 +httpx>=0.27.0 diff --git a/backend/routers/admin/__init__.py b/backend/routers/admin/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/routers/admin/backups.py b/backend/routers/admin/backups.py new file mode 100644 index 00000000..6ba2212d --- /dev/null +++ b/backend/routers/admin/backups.py @@ -0,0 +1,91 @@ +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File +from sqlalchemy.orm import Session +from typing import List +from fastapi.responses import FileResponse +from ... import schemas, auth, models +from ...database import get_db +from ...db_manager import DbManager + +router = APIRouter( + prefix="/admin/db", + tags=["Admin Database Backups"] +) + +@router.get("/backups", response_model=List[schemas.BackupInfo]) +def get_backups( + db: Session = Depends(get_db), + current_admin: auth.TokenData = Depends(auth.get_current_admin) +): + """List available database backups.""" + return DbManager.get_backup_list() + +@router.get("/stats", response_model=schemas.DatabaseStats) +def get_db_stats( + db: Session = Depends(get_db), + current_admin: auth.TokenData = Depends(auth.get_current_admin) +): + """Get database backup storage statistics.""" + return DbManager.get_stats() + +@router.post("/backup", response_model=schemas.BackupInfo) +def trigger_manual_backup( + db: Session = Depends(get_db), + current_admin: auth.TokenData = Depends(auth.get_current_admin) +): + """Trigger a manual database backup.""" + filename = DbManager.create_backup(db, label="manual", user_id=current_admin.sub) + backups = DbManager.get_backup_list() + for b in backups: + if b.filename == filename: + return b + raise HTTPException(status_code=500, detail="Backup created but info not found") + +@router.post("/restore") +def restore_database( + payload: dict, + db: Session = Depends(get_db), + current_admin: auth.TokenData = Depends(auth.get_current_admin) +): + """Restore database from a specific file. DANGEROUS.""" + filename = payload.get("filename") + confirm = payload.get("confirm", False) + + if not filename: + raise HTTPException(status_code=400, detail="Filename required") + if not confirm: + raise HTTPException(status_code=400, detail="Confirmation required") + + try: + DbManager.restore_backup(filename, db, user_id=current_admin.sub) + return {"status": "success", "message": f"Database restored from {filename}"} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@router.get("/export") +def export_database( + current_admin: auth.TokenData = Depends(auth.get_current_admin) +): + """Download the current database file.""" + try: + path = DbManager.export_db() + return FileResponse( + path, + media_type="application/x-sqlite3", + filename="inventory_export.db" + ) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@router.post("/import") +async def import_database( + file: UploadFile = File(...), + db: Session = Depends(get_db), + current_admin: auth.TokenData = Depends(auth.get_current_admin) +): + """Upload and replace the current database. DANGEROUS.""" + contents = await file.read() + try: + DbManager.import_db(contents, db, user_id=current_admin.sub) + return {"status": "success", "message": "Database successfully imported and replaced."} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) diff --git a/backend/routers/admin_db.py b/backend/routers/admin/config.py similarity index 60% rename from backend/routers/admin_db.py rename to backend/routers/admin/config.py index 56405379..5cd2895d 100644 --- a/backend/routers/admin_db.py +++ b/backend/routers/admin/config.py @@ -1,68 +1,18 @@ -from fastapi import APIRouter, Depends, HTTPException, status +import os +from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.orm import Session -from typing import List -from .. import models, schemas, auth -from ..database import get_db -from ..db_manager import DbManager -from ..scheduler import sync_scheduler_config -from fastapi.responses import FileResponse -from fastapi import UploadFile, File +from ... import models, schemas, auth +from ...database import get_db, BASE_DIR +from ...scheduler import sync_scheduler_config +from ...config_manager import ConfigManager router = APIRouter( prefix="/admin/db", - tags=["Admin Database"] + tags=["Admin Configuration"] ) -@router.get("/backups", response_model=List[schemas.BackupInfo]) -def get_backups( - db: Session = Depends(get_db), - current_admin: auth.TokenData = Depends(auth.get_current_admin) -): - """List available database backups.""" - return DbManager.get_backup_list() - -@router.get("/stats", response_model=schemas.DatabaseStats) -def get_db_stats( - db: Session = Depends(get_db), - current_admin: auth.TokenData = Depends(auth.get_current_admin) -): - """Get database backup storage statistics.""" - return DbManager.get_stats() - -@router.post("/backup", response_model=schemas.BackupInfo) -def trigger_manual_backup( - db: Session = Depends(get_db), - current_admin: auth.TokenData = Depends(auth.get_current_admin) -): - """Trigger a manual database backup.""" - filename = DbManager.create_backup(db, label="manual", user_id=current_admin.sub) - # Re-fetch the newly created file info - backups = DbManager.get_backup_list() - for b in backups: - if b.filename == filename: - return b - raise HTTPException(status_code=500, detail="Backup created but info not found") - -@router.post("/restore") -def restore_database( - payload: dict, - db: Session = Depends(get_db), - current_admin: auth.TokenData = Depends(auth.get_current_admin) -): - """Restore database from a specific file. DANGEROUS.""" - filename = payload.get("filename") - confirm = payload.get("confirm", False) - - if not filename: - raise HTTPException(status_code=400, detail="Filename required") - if not confirm: - raise HTTPException(status_code=400, detail="Confirmation required") - - try: - success = DbManager.restore_backup(filename, db, user_id=current_admin.sub) - return {"status": "success", "message": f"Database restored from {filename}"} - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) +PROJECT_ROOT = os.path.dirname(BASE_DIR) +PROMPT_FILE_PATH = os.path.join(PROJECT_ROOT, "config", "ai_prompt.md") @router.get("/settings", response_model=schemas.DbSettingsUpdate) def get_db_settings( @@ -70,7 +20,6 @@ def get_db_settings( current_admin: auth.TokenData = Depends(auth.get_current_admin) ): """Get database retention and scheduling settings.""" - # Ensure default settings exist retention = db.query(models.SystemSetting).filter(models.SystemSetting.key == "backup_retention_count").first() hour = db.query(models.SystemSetting).filter(models.SystemSetting.key == "backup_schedule_hour").first() freq = db.query(models.SystemSetting).filter(models.SystemSetting.key == "backup_schedule_freq_days").first() @@ -102,59 +51,22 @@ def update_db_settings( db.add(models.SystemSetting(key=key, value=val)) db.commit() - # Re-trigger scheduler sync sync_scheduler_config() return settings -@router.get("/export") -def export_database( - current_admin: auth.TokenData = Depends(auth.get_current_admin) -): - """Download the current database file.""" - try: - path = DbManager.export_db() - return FileResponse( - path, - media_type="application/x-sqlite3", - filename="inventory_export.db" - ) - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - -@router.post("/import") -async def import_database( - file: UploadFile = File(...), - db: Session = Depends(get_db), - current_admin: auth.TokenData = Depends(auth.get_current_admin) -): - """Upload and replace the current database. DANGEROUS.""" - contents = await file.read() - try: - DbManager.import_db(contents, db, user_id=current_admin.sub) - return {"status": "success", "message": "Database successfully imported and replaced."} - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - -import os -from ..database import BASE_DIR -PROJECT_ROOT = os.path.dirname(BASE_DIR) -PROMPT_FILE_PATH = os.path.join(PROJECT_ROOT, "config", "ai_prompt.md") - @router.get("/settings/prompt") def get_ai_prompt( db: Session = Depends(get_db), current_admin: auth.TokenData = Depends(auth.get_current_admin) ): - """Get the current AI extraction prompt (prioritizes config file).""" - # 1. Try file first + """Get the current AI extraction prompt.""" if os.path.exists(PROMPT_FILE_PATH): try: with open(PROMPT_FILE_PATH, 'r', encoding='utf-8') as f: return {"value": f.read().strip(), "source": "file"} - except Exception as e: - print(f"Error reading prompt file: {e}") + except Exception: + pass - # 2. Fallback to DB setting = db.query(models.SystemSetting).filter(models.SystemSetting.key == "ai_extraction_prompt").first() if not setting: return {"value": "", "source": "none"} @@ -166,21 +78,18 @@ def update_ai_prompt( db: Session = Depends(get_db), current_admin: auth.TokenData = Depends(auth.get_current_admin) ): - """Update the AI extraction prompt (writes to both file and DB).""" + """Update the AI extraction prompt.""" value = payload.get("value") if value is None: raise HTTPException(status_code=400, detail="Value required") - # 1. Update File (Primary) try: os.makedirs(os.path.dirname(PROMPT_FILE_PATH), exist_ok=True) with open(PROMPT_FILE_PATH, 'w', encoding='utf-8') as f: f.write(value) - except Exception as e: - print(f"Failed to write prompt file: {e}") - # We continue to DB update anyway + except Exception: + pass - # 2. Update DB (Backup/Sync) existing = db.query(models.SystemSetting).filter(models.SystemSetting.key == "ai_extraction_prompt").first() if existing: existing.value = value @@ -190,8 +99,6 @@ def update_ai_prompt( db.commit() return {"status": "success", "file_updated": os.path.exists(PROMPT_FILE_PATH)} -from ..config_manager import ConfigManager - @router.get("/settings/ai") def get_ai_config( db: Session = Depends(get_db), @@ -201,7 +108,6 @@ def get_ai_config( gemini_key = os.environ.get("GEMINI_API_KEY") claude_key = os.environ.get("CLAUDE_API_KEY") - # Get active provider from DB provider_setting = db.query(models.SystemSetting).filter(models.SystemSetting.key == "ai_provider").first() active_provider = provider_setting.value if provider_setting else "gemini" @@ -230,7 +136,7 @@ def update_ai_keys( payload: dict, current_admin: auth.TokenData = Depends(auth.get_current_admin) ): - """Update AI API keys in inventory.env.""" + """Update AI API keys.""" gemini_key = payload.get("gemini_api_key") claude_key = payload.get("claude_api_key") @@ -240,10 +146,9 @@ def update_ai_keys( if claude_key: updates["CLAUDE_API_KEY"] = claude_key - if not updates: - return {"status": "no_change"} + if updates: + ConfigManager.update_keys(updates) - ConfigManager.update_keys(updates) return { "status": "success", "gemini_configured": bool(os.environ.get("GEMINI_API_KEY")), @@ -262,7 +167,6 @@ def test_ai_key( if not provider or provider not in ["gemini", "claude"]: raise HTTPException(status_code=400, detail="Invalid provider") - # If key is masked or empty, use the one from environment if not key or "****" in key: key = os.environ.get("GEMINI_API_KEY" if provider == "gemini" else "CLAUDE_API_KEY") @@ -273,25 +177,15 @@ def test_ai_key( if provider == "gemini": from google import genai client = genai.Client(api_key=key, http_options={'api_version': 'v1beta'}) - # Listing models is the standard way to check key validity client.models.list() return {"status": "success", "message": "Google Gemini API connection verified!"} - elif provider == "claude": import anthropic client = anthropic.Anthropic(api_key=key) - # Claude also supports model listing for key verification client.models.list(limit=1) return {"status": "success", "message": "Anthropic Claude API connection verified!"} - except Exception as e: - error_msg = str(e) - if "API_KEY_INVALID" in error_msg or "invalid_api_key" in error_msg: - error_msg = "The API key provided is invalid." - elif "PERMISSION_DENIED" in error_msg: - error_msg = "Permission denied. Check if the key has correct permissions." - - raise HTTPException(status_code=400, detail=f"{provider.capitalize()} Test Failed: {error_msg}") + raise HTTPException(status_code=400, detail=f"{provider.capitalize()} Test Failed: {str(e)}") @router.post("/settings/ai") def update_ai_provider( @@ -302,7 +196,7 @@ def update_ai_provider( """Update the active AI provider.""" provider = payload.get("provider") if provider not in ["gemini", "claude"]: - raise HTTPException(status_code=400, detail="Invalid provider. Must be 'gemini' or 'claude'.") + raise HTTPException(status_code=400, detail="Invalid provider") existing = db.query(models.SystemSetting).filter(models.SystemSetting.key == "ai_provider").first() if existing: diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 00000000..b8aa2883 --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,65 @@ +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import StaticPool + +from backend.database import Base, get_db +from backend.main import app +from backend.auth import get_current_admin, TokenData + +from datetime import datetime, timezone + +# Use in-memory SQLite for tests +SQLALCHEMY_DATABASE_URL = "sqlite://" + +engine = create_engine( + SQLALCHEMY_DATABASE_URL, + connect_args={"check_same_thread": False}, + poolclass=StaticPool, +) +TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +@pytest.fixture(scope="function", autouse=True) +def mock_scheduler(): + from backend.scheduler import scheduler + if scheduler.running: + scheduler.shutdown() + yield + if scheduler.running: + scheduler.shutdown() + +@pytest.fixture(scope="function") +def db(): + Base.metadata.create_all(bind=engine) + session = TestingSessionLocal() + try: + yield session + finally: + session.close() + Base.metadata.drop_all(bind=engine) + +@pytest.fixture(scope="function") +def client(db): + def override_get_db(): + try: + yield db + finally: + pass + + # Mock admin user with valid TokenData + def override_get_current_admin(): + return TokenData( + sub=1, + username="admin", + role="admin", + exp=datetime.now(timezone.utc) + ) + + app.dependency_overrides[get_db] = override_get_db + app.dependency_overrides[get_current_admin] = override_get_current_admin + + with TestClient(app) as c: + yield c + + app.dependency_overrides.clear() diff --git a/backend/tests/test_admin.py b/backend/tests/test_admin.py new file mode 100644 index 00000000..f042012b --- /dev/null +++ b/backend/tests/test_admin.py @@ -0,0 +1,35 @@ +import pytest + +def test_get_backups(client): + response = client.get("/admin/db/backups") + assert response.status_code == 200 + assert isinstance(response.json(), list) + +def test_db_settings_workflow(client): + # GET settings + response = client.get("/admin/db/settings") + assert response.status_code == 200 + data = response.json() + assert "retention_count" in data + + # PATCH settings + new_settings = { + "retention_count": 25, + "schedule_hour": 5, + "schedule_freq_days": 2 + } + response = client.patch("/admin/db/settings", json=new_settings) + assert response.status_code == 200 + assert response.json()["retention_count"] == 25 + + # Verify persistence + response = client.get("/admin/db/settings") + assert response.json()["retention_count"] == 25 + +def test_ai_config(client): + response = client.get("/admin/db/settings/ai") + assert response.status_code == 200 + data = response.json() + assert "active_provider" in data + assert "providers" in data + assert len(data["providers"]) == 2 diff --git a/frontend/app/admin/page.tsx b/frontend/app/admin/page.tsx index c3114f7e..962d4f87 100644 --- a/frontend/app/admin/page.tsx +++ b/frontend/app/admin/page.tsx @@ -1,377 +1,27 @@ 'use client'; -import { useState, useEffect } from 'react'; -import { inventoryApi } from '@/lib/api'; +import { useAdmin } from '@/hooks/useAdmin'; import PageShell from '@/components/PageShell'; -import StatCard from '@/components/StatCard'; -import { - Shield, - ShieldAlert, - UserPlus, - User, - Trash2, - Tag, - Plus, - AlertTriangle, - LogOut, - HardDrive, - Download, - RotateCcw, - FileText, - Upload, - Brain, - Globe, - Edit2, - Server, - Wifi, - WifiOff, - Clock, - History, - Database, - Layers, - X, - ChevronDown, - Power, - Lock, - Cpu, - Zap -} from 'lucide-react'; -import { toast } from 'react-hot-toast'; -import { cn } from '@/lib/utils'; +import { Shield, LogOut } from 'lucide-react'; + +import IdentityManager from '@/components/admin/IdentityManager'; +import LdapManager from '@/components/admin/LdapManager'; +import AiManager from '@/components/admin/AiManager'; +import DatabaseManager from '@/components/admin/DatabaseManager'; +import CategoryManager from '@/components/admin/CategoryManager'; export default function AdminPage() { - const [users, setUsers] = useState([]); - const [categories, setCategories] = useState([]); - const [loading, setLoading] = useState(true); - - // LDAP Config State - const [ldapConfig, setLdapConfig] = useState({ - ldap_enabled: false, - server_uri: '', - base_dn: '', - user_template: '', - groups_dn: '', - use_tls: false, - role_mappings: [] - }); - const [testingLdap, setTestingLdap] = useState(false); - - // Edit States - const [editingUser, setEditingUser] = useState(null); - const [editUserForm, setEditUserForm] = useState({ username: '', password: '', role: 'user' }); - - const [editingCategory, setEditingCategory] = useState(null); - const [editCatForm, setEditCatForm] = useState({ name: '', description: '' }); + const admin = useAdmin(); - // DB Management State - const [backups, setBackups] = useState([]); - const [dbStats, setDbStats] = useState({ backup_count: 0, total_size_bytes: 0 }); - const [dbSettings, setDbSettings] = useState({ retention_count: 10, schedule_hour: 3, schedule_freq_days: 1 }); - const [isBackingUp, setIsBackingUp] = useState(false); - const [isImporting, setIsImporting] = useState(false); - - // AI Prompt & Config State - const [aiPrompt, setAiPrompt] = useState(""); - const [aiConfig, setAiConfig] = useState(null); - const [aiKeys, setAiKeys] = useState({ gemini: '', claude: '' }); - const [isSavingPrompt, setIsSavingPrompt] = useState(false); - const [isSavingKeys, setIsSavingKeys] = useState(false); - const [isTestingKeys, setIsTestingKeys] = useState<{gemini: boolean, claude: boolean}>({gemini: false, claude: false}); - - useEffect(() => { - loadData(); - }, []); - - const loadData = async () => { - setLoading(true); - try { - const [u, c, l, b, s, st] = await Promise.all([ - inventoryApi.getUsers(), - inventoryApi.getCategories(), - inventoryApi.getLdapConfig(), - inventoryApi.getDbBackups(), - inventoryApi.getDbStats(), - inventoryApi.getDbSettings() - ]); - setUsers(u); - setCategories(c); - if (l && l.server_uri) setLdapConfig(l); - setBackups(b); - setDbStats(s); - setDbSettings(st); - - const [p, ai] = await Promise.all([ - inventoryApi.getAiPrompt(), - inventoryApi.getAiConfig() - ]); - setAiPrompt(p.value); - setAiConfig(ai); - } catch (err: any) { - console.error(err); - toast.error("Failed to load admin data"); - } finally { - setLoading(false); - } - }; - - const handleAddUser = async () => { - const name = prompt("Enter new username:"); - if (!name) return; - const pwd = prompt("Enter password for " + name + ":"); - if (!pwd) return; - - try { - await inventoryApi.createUser({ username: name, password: pwd, role: 'user' }); - toast.success("User created successfully"); - loadData(); - } catch (err: any) { - toast.error("Failed to create user"); - } - }; - - const handleDeleteUser = async (id: number, username: string) => { - if (username === 'Admin') return; - if (!confirm(`Delete user ${username}?`)) return; - - try { - await inventoryApi.deleteUser(id); - toast.success("User removed"); - loadData(); - } catch (err: any) { - toast.error("Delete failed"); - } - }; - - const handleUpdateAiProvider = async (provider: string) => { - try { - await inventoryApi.updateAiProvider(provider); - toast.success(`AI provider switched to ${provider}`); - loadData(); - } catch (err: any) { - toast.error("Failed to switch AI provider"); - } - }; - - const handleSaveAiKeys = async () => { - if (!aiKeys.gemini && !aiKeys.claude) { - toast.error("Enter at least one API key to save"); - return; - } - - setIsSavingKeys(true); - try { - await inventoryApi.updateAiKeys({ - gemini_api_key: aiKeys.gemini || undefined, - claude_api_key: aiKeys.claude || undefined - }); - toast.success("AI Configuration keys updated successfully"); - setAiKeys({ gemini: '', claude: '' }); - loadData(); - } catch (err: any) { - toast.error("Failed to update AI keys"); - } finally { - setIsSavingKeys(false); - } - }; - - const handleTestAiKey = async (provider: 'gemini' | 'claude') => { - const keyToTest = provider === 'gemini' ? aiKeys.gemini : aiKeys.claude; - - setIsTestingKeys(prev => ({ ...prev, [provider]: true })); - try { - const res = await inventoryApi.testAiKey(provider, keyToTest); - toast.success(res.message || `${provider.toUpperCase()} connection successful!`); - } catch (err: any) { - const msg = err.response?.data?.detail || `Failed to test ${provider} key`; - toast.error(msg); - } finally { - setIsTestingKeys(prev => ({ ...prev, [provider]: false })); - } - }; - - const handleAddCategory = async () => { - const name = prompt("New category name:"); - if (!name) return; - const desc = prompt("Description (optional):"); - - try { - await inventoryApi.createCategory({ name, description: desc }); - toast.success("Category added"); - loadData(); - } catch (err: any) { - toast.error("Failed to add category"); - } - }; - - const handleUpdateCategorySubmit = async () => { - if (!editingCategory) return; - try { - await inventoryApi.updateCategory(editingCategory.id, editCatForm); - toast.success("Category updated"); - setEditingCategory(null); - loadData(); - } catch (err: any) { - toast.error("Update failed"); - } - }; - - const handleDeleteCategory = async (id: number, name: string) => { - if (!confirm(`Delete category ${name}?`)) return; - - try { - await inventoryApi.deleteCategory(id); - toast.success("Category removed"); - loadData(); - } catch (err: any) { - toast.error(err.response?.data?.detail || "Delete failed"); - } - }; - - const handleUpdateUserSubmit = async () => { - if (!editingUser) return; - try { - const payload: any = { username: editUserForm.username, role: editUserForm.role }; - if (editUserForm.password) payload.password = editUserForm.password; - - await inventoryApi.updateUser(editingUser.id, payload); - toast.success("User updated successfully"); - setEditingUser(null); - loadData(); - } catch (err: any) { - toast.error("Update failed"); - } - }; - - const handleLogout = () => { - import('@/lib/auth').then(m => m.clearAuth()); - window.location.href = '/login'; - }; - - const handleCreateBackup = async () => { - setIsBackingUp(true); - try { - await inventoryApi.triggerBackup(); - toast.success("Snapshot created successfully"); - loadData(); - } catch (err: any) { - toast.error("Backup failed"); - } finally { - setIsBackingUp(false); - } - }; - - const handleRestore = async (filename: string) => { - if (!confirm(`DANGEROUS: Restore database from ${filename}? Current data will be replaced. A rollback snapshot will be created automatically.`)) return; - - const loadingToast = toast.loading("Restoring database..."); - try { - await inventoryApi.restoreDatabase(filename); - toast.success("Database restored! Reloading system...", { id: loadingToast }); - setTimeout(() => window.location.reload(), 2000); - } catch (err: any) { - toast.error("Restore failed", { id: loadingToast }); - } - }; - - const handleUpdateDbSettings = async (newSettings: any) => { - try { - await inventoryApi.updateDbSettings(newSettings); - toast.success("System policy updated"); - setDbSettings(newSettings); - } catch (err: any) { - toast.error("Failed to update settings"); - } - }; - - const handleUpdatePrompt = async () => { - setIsSavingPrompt(true); - try { - await inventoryApi.updateAiPrompt(aiPrompt); - toast.success("AI Extraction Prompt updated"); - } catch (err: any) { - toast.error("Failed to update AI prompt"); - } finally { - setIsSavingPrompt(false); - } - }; - - const handleExportDb = async () => { - try { - const blob = await inventoryApi.exportDb(); - const url = window.URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `inventory_backup_${new Date().toISOString().split('T')[0]}.db`; - document.body.appendChild(a); - a.click(); - window.URL.revokeObjectURL(url); - toast.success("Database exported successfully"); - } catch (err: any) { - toast.error("Export failed"); - } - }; - - const handleImportDb = async (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (!file) return; - - if (!confirm("DANGEROUS: You are about to REPLACE the entire database with this file. All current data will be lost. Proceed?")) { - e.target.value = ''; - return; - } - - setIsImporting(true); - const formData = new FormData(); - formData.append('file', file); - - const loadingToast = toast.loading("Importing database..."); - try { - await inventoryApi.importDb(formData); - toast.success("Database imported! Reloading system...", { id: loadingToast }); - setTimeout(() => window.location.reload(), 2000); - } catch (err: any) { - toast.error("Import failed: " + (err.response?.data?.detail || err.message), { id: loadingToast }); - } finally { - setIsImporting(false); - e.target.value = ''; - } - }; - - const handleUpdateLdap = async () => { - setLoading(true); - try { - await inventoryApi.updateLdapConfig(ldapConfig); - toast.success("Enterprise configuration updated"); - } catch (err: any) { - toast.error("Failed to update LDAP config"); - } finally { - setLoading(false); - } - }; - - const handleTestLdap = async () => { - setTestingLdap(true); - try { - const res = await inventoryApi.testLdapConnection(ldapConfig); - if (res.status === 'success') { - toast.success(res.message || "LDAP Connection Successful!"); - } else { - toast.error(`LDAP Error: ${res.message || "Unknown error"}`); - } - } catch (err: any) { - toast.error(`Connection failed: ${err.response?.data?.detail || err.message}`); - } finally { - setTestingLdap(false); - } - }; - - const formatSize = (bytes: number) => { - if (bytes === 0) return '0 B'; - const k = 1024; - const sizes = ['B', 'KB', 'MB', 'GB']; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; - }; + if (admin.loading) { + return ( + +
+
+
+ + ); + } return ( @@ -382,10 +32,10 @@ export default function AdminPage() {

Admin Control

-

System Configuration & Security

+

System Configuration & Security

- - - + - {/* Identity Management */} -
-
-
-
- -
-

Identity Management

-
- -
- -
- {users.map((user) => ( -
-
-
- {user.role === 'admin' ? : } -
-
-

{user.username}

-

{user.role}

-
-
-
- - {user.username !== 'Admin' && ( - - )} -
-
- ))} -
-
+ - {/* Right Column - LDAP & AI */} + {/* Right Column: Infrastructure */}
-
-
-
-
- -
-

Enterprise LDAP

-
-
- -
-
-
- -
-
-

LDAP Authorization

-

- {ldapConfig.ldap_enabled ? "External Directory Active" : "Internal Authentication Only"} -

-
-
- -
- -
-
- -
- - setLdapConfig({...ldapConfig, server_uri: e.target.value})} - className="w-full bg-slate-950/80 border border-slate-800 rounded-2xl py-2.5 pl-10 pr-4 text-xs font-bold text-white outline-none focus:border-indigo-500/50 transition-all font-mono" - /> -
-
-
- -
- - setLdapConfig({...ldapConfig, base_dn: e.target.value})} - className="w-full bg-slate-950/80 border border-slate-800 rounded-2xl py-2.5 pl-10 pr-4 text-xs font-bold text-white outline-none focus:border-indigo-500/50 transition-all font-mono" - /> -
-
-
- -
-
- -
- - setLdapConfig({...ldapConfig, user_template: e.target.value})} - className="w-full bg-slate-950/80 border border-slate-800 rounded-2xl py-2.5 pl-10 pr-4 text-xs font-bold text-white outline-none focus:border-indigo-500/50 transition-all font-mono" - /> -
-
-
- -
- - setLdapConfig({...ldapConfig, groups_dn: e.target.value})} - className="w-full bg-slate-950/80 border border-slate-800 rounded-2xl py-2.5 pl-10 pr-4 text-xs font-bold text-white outline-none focus:border-indigo-500/50 transition-all font-mono" - /> -
-
-
- -
-
-
- -
-
-

Use TLS

-

- {ldapConfig.use_tls ? "Encrypted Channel" : "Standard Link"} -

-
-
- -
- -
- - -
-
- +
- {/* AI Intelligence Section - Full Width */} -
-
-
-
- -
-

AI Intelligence

-
-
- - {/* [NEW] AI Provider Selection */} -
- {aiConfig?.providers?.map((p: any) => ( - - ))} -
+ {/* Full Width: Category Groups */} + - {/* [NEW] AI Key Configuration */} -
-
-
-
-

Provider Access Keys

-
- -
- -
-
- -
- setAiKeys({...aiKeys, gemini: e.target.value})} - placeholder={aiConfig?.providers?.find((p: any) => p.id === 'gemini')?.masked_key || "Enter Gemini Key..."} - className="flex-1 bg-slate-950/80 border border-slate-800 rounded-xl py-3 px-4 text-xs text-white outline-none focus:border-indigo-500/50 transition-all placeholder:text-slate-600" - /> - -
-
-
- -
- setAiKeys({...aiKeys, claude: e.target.value})} - placeholder={aiConfig?.providers?.find((p: any) => p.id === 'claude')?.masked_key || "Enter Claude Key..."} - className="flex-1 bg-slate-950/80 border border-slate-800 rounded-xl py-3 px-4 text-xs text-white outline-none focus:border-indigo-500/50 transition-all placeholder:text-slate-600" - /> - -
-
-
-
- -
-
-
- - -
-