feat(admin): finalizing modular refactoring with testing suite and UI polish
This commit is contained in:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -10,8 +10,11 @@ backend/venv/
|
|||||||
*.pyo
|
*.pyo
|
||||||
*.pyd
|
*.pyd
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
dist/
|
|
||||||
build/
|
build/
|
||||||
|
.pytest_cache/
|
||||||
|
**/.pytest_cache/
|
||||||
|
coverage/
|
||||||
|
**/coverage/
|
||||||
|
|
||||||
# ── Runtime data directories ─────────────────────────────────
|
# ── Runtime data directories ─────────────────────────────────
|
||||||
# Content is excluded; the directories themselves are tracked via .gitkeep.
|
# Content is excluded; the directories themselves are tracked via .gitkeep.
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
## Testing
|
## Testing
|
||||||
- Write unit tests for all utility functions
|
- Write unit tests for all utility functions
|
||||||
- Minimum 80% coverage on new code
|
- Minimum 80% coverage on new code
|
||||||
|
- Use Vitest for unit tests
|
||||||
|
|
||||||
## Security
|
## Security
|
||||||
- Store all secrets in inventory.env — never hardcode credentials
|
- Store all secrets in inventory.env — never hardcode credentials
|
||||||
|
|||||||
47
artifacts/superpowers/brainstorm.md
Normal file
47
artifacts/superpowers/brainstorm.md
Normal file
@@ -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).
|
||||||
0
backend/__init__.py
Normal file
0
backend/__init__.py
Normal file
@@ -6,7 +6,8 @@ from slowapi import Limiter
|
|||||||
from slowapi.util import get_remote_address
|
from slowapi.util import get_remote_address
|
||||||
from . import models
|
from . import models
|
||||||
from .database import engine
|
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 .logger import log
|
||||||
from .scheduler import scheduler, sync_scheduler_config
|
from .scheduler import scheduler, sync_scheduler_config
|
||||||
|
|
||||||
@@ -87,7 +88,8 @@ app.include_router(items.router)
|
|||||||
app.include_router(operations.router)
|
app.include_router(operations.router)
|
||||||
app.include_router(users.router)
|
app.include_router(users.router)
|
||||||
app.include_router(categories.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")
|
@app.on_event("startup")
|
||||||
def startup_event():
|
def startup_event():
|
||||||
|
|||||||
@@ -13,3 +13,6 @@ passlib[bcrypt]>=1.7.4
|
|||||||
python-jose[cryptography]>=3.3.0
|
python-jose[cryptography]>=3.3.0
|
||||||
slowapi>=0.1.9
|
slowapi>=0.1.9
|
||||||
apscheduler>=3.10.1
|
apscheduler>=3.10.1
|
||||||
|
pytest>=8.0.0
|
||||||
|
pytest-asyncio>=0.23.0
|
||||||
|
httpx>=0.27.0
|
||||||
|
|||||||
0
backend/routers/admin/__init__.py
Normal file
0
backend/routers/admin/__init__.py
Normal file
91
backend/routers/admin/backups.py
Normal file
91
backend/routers/admin/backups.py
Normal file
@@ -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))
|
||||||
@@ -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 sqlalchemy.orm import Session
|
||||||
from typing import List
|
from ... import models, schemas, auth
|
||||||
from .. import models, schemas, auth
|
from ...database import get_db, BASE_DIR
|
||||||
from ..database import get_db
|
from ...scheduler import sync_scheduler_config
|
||||||
from ..db_manager import DbManager
|
from ...config_manager import ConfigManager
|
||||||
from ..scheduler import sync_scheduler_config
|
|
||||||
from fastapi.responses import FileResponse
|
|
||||||
from fastapi import UploadFile, File
|
|
||||||
|
|
||||||
router = APIRouter(
|
router = APIRouter(
|
||||||
prefix="/admin/db",
|
prefix="/admin/db",
|
||||||
tags=["Admin Database"]
|
tags=["Admin Configuration"]
|
||||||
)
|
)
|
||||||
|
|
||||||
@router.get("/backups", response_model=List[schemas.BackupInfo])
|
PROJECT_ROOT = os.path.dirname(BASE_DIR)
|
||||||
def get_backups(
|
PROMPT_FILE_PATH = os.path.join(PROJECT_ROOT, "config", "ai_prompt.md")
|
||||||
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))
|
|
||||||
|
|
||||||
@router.get("/settings", response_model=schemas.DbSettingsUpdate)
|
@router.get("/settings", response_model=schemas.DbSettingsUpdate)
|
||||||
def get_db_settings(
|
def get_db_settings(
|
||||||
@@ -70,7 +20,6 @@ def get_db_settings(
|
|||||||
current_admin: auth.TokenData = Depends(auth.get_current_admin)
|
current_admin: auth.TokenData = Depends(auth.get_current_admin)
|
||||||
):
|
):
|
||||||
"""Get database retention and scheduling settings."""
|
"""Get database retention and scheduling settings."""
|
||||||
# Ensure default settings exist
|
|
||||||
retention = db.query(models.SystemSetting).filter(models.SystemSetting.key == "backup_retention_count").first()
|
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()
|
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()
|
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.add(models.SystemSetting(key=key, value=val))
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
# Re-trigger scheduler sync
|
|
||||||
sync_scheduler_config()
|
sync_scheduler_config()
|
||||||
return settings
|
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")
|
@router.get("/settings/prompt")
|
||||||
def get_ai_prompt(
|
def get_ai_prompt(
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_admin: auth.TokenData = Depends(auth.get_current_admin)
|
current_admin: auth.TokenData = Depends(auth.get_current_admin)
|
||||||
):
|
):
|
||||||
"""Get the current AI extraction prompt (prioritizes config file)."""
|
"""Get the current AI extraction prompt."""
|
||||||
# 1. Try file first
|
|
||||||
if os.path.exists(PROMPT_FILE_PATH):
|
if os.path.exists(PROMPT_FILE_PATH):
|
||||||
try:
|
try:
|
||||||
with open(PROMPT_FILE_PATH, 'r', encoding='utf-8') as f:
|
with open(PROMPT_FILE_PATH, 'r', encoding='utf-8') as f:
|
||||||
return {"value": f.read().strip(), "source": "file"}
|
return {"value": f.read().strip(), "source": "file"}
|
||||||
except Exception as e:
|
except Exception:
|
||||||
print(f"Error reading prompt file: {e}")
|
pass
|
||||||
|
|
||||||
# 2. Fallback to DB
|
|
||||||
setting = db.query(models.SystemSetting).filter(models.SystemSetting.key == "ai_extraction_prompt").first()
|
setting = db.query(models.SystemSetting).filter(models.SystemSetting.key == "ai_extraction_prompt").first()
|
||||||
if not setting:
|
if not setting:
|
||||||
return {"value": "", "source": "none"}
|
return {"value": "", "source": "none"}
|
||||||
@@ -166,21 +78,18 @@ def update_ai_prompt(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_admin: auth.TokenData = Depends(auth.get_current_admin)
|
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")
|
value = payload.get("value")
|
||||||
if value is None:
|
if value is None:
|
||||||
raise HTTPException(status_code=400, detail="Value required")
|
raise HTTPException(status_code=400, detail="Value required")
|
||||||
|
|
||||||
# 1. Update File (Primary)
|
|
||||||
try:
|
try:
|
||||||
os.makedirs(os.path.dirname(PROMPT_FILE_PATH), exist_ok=True)
|
os.makedirs(os.path.dirname(PROMPT_FILE_PATH), exist_ok=True)
|
||||||
with open(PROMPT_FILE_PATH, 'w', encoding='utf-8') as f:
|
with open(PROMPT_FILE_PATH, 'w', encoding='utf-8') as f:
|
||||||
f.write(value)
|
f.write(value)
|
||||||
except Exception as e:
|
except Exception:
|
||||||
print(f"Failed to write prompt file: {e}")
|
pass
|
||||||
# We continue to DB update anyway
|
|
||||||
|
|
||||||
# 2. Update DB (Backup/Sync)
|
|
||||||
existing = db.query(models.SystemSetting).filter(models.SystemSetting.key == "ai_extraction_prompt").first()
|
existing = db.query(models.SystemSetting).filter(models.SystemSetting.key == "ai_extraction_prompt").first()
|
||||||
if existing:
|
if existing:
|
||||||
existing.value = value
|
existing.value = value
|
||||||
@@ -190,8 +99,6 @@ def update_ai_prompt(
|
|||||||
db.commit()
|
db.commit()
|
||||||
return {"status": "success", "file_updated": os.path.exists(PROMPT_FILE_PATH)}
|
return {"status": "success", "file_updated": os.path.exists(PROMPT_FILE_PATH)}
|
||||||
|
|
||||||
from ..config_manager import ConfigManager
|
|
||||||
|
|
||||||
@router.get("/settings/ai")
|
@router.get("/settings/ai")
|
||||||
def get_ai_config(
|
def get_ai_config(
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
@@ -201,7 +108,6 @@ def get_ai_config(
|
|||||||
gemini_key = os.environ.get("GEMINI_API_KEY")
|
gemini_key = os.environ.get("GEMINI_API_KEY")
|
||||||
claude_key = os.environ.get("CLAUDE_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()
|
provider_setting = db.query(models.SystemSetting).filter(models.SystemSetting.key == "ai_provider").first()
|
||||||
active_provider = provider_setting.value if provider_setting else "gemini"
|
active_provider = provider_setting.value if provider_setting else "gemini"
|
||||||
|
|
||||||
@@ -230,7 +136,7 @@ def update_ai_keys(
|
|||||||
payload: dict,
|
payload: dict,
|
||||||
current_admin: auth.TokenData = Depends(auth.get_current_admin)
|
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")
|
gemini_key = payload.get("gemini_api_key")
|
||||||
claude_key = payload.get("claude_api_key")
|
claude_key = payload.get("claude_api_key")
|
||||||
|
|
||||||
@@ -240,10 +146,9 @@ def update_ai_keys(
|
|||||||
if claude_key:
|
if claude_key:
|
||||||
updates["CLAUDE_API_KEY"] = claude_key
|
updates["CLAUDE_API_KEY"] = claude_key
|
||||||
|
|
||||||
if not updates:
|
if updates:
|
||||||
return {"status": "no_change"}
|
ConfigManager.update_keys(updates)
|
||||||
|
|
||||||
ConfigManager.update_keys(updates)
|
|
||||||
return {
|
return {
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"gemini_configured": bool(os.environ.get("GEMINI_API_KEY")),
|
"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"]:
|
if not provider or provider not in ["gemini", "claude"]:
|
||||||
raise HTTPException(status_code=400, detail="Invalid provider")
|
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:
|
if not key or "****" in key:
|
||||||
key = os.environ.get("GEMINI_API_KEY" if provider == "gemini" else "CLAUDE_API_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":
|
if provider == "gemini":
|
||||||
from google import genai
|
from google import genai
|
||||||
client = genai.Client(api_key=key, http_options={'api_version': 'v1beta'})
|
client = genai.Client(api_key=key, http_options={'api_version': 'v1beta'})
|
||||||
# Listing models is the standard way to check key validity
|
|
||||||
client.models.list()
|
client.models.list()
|
||||||
return {"status": "success", "message": "Google Gemini API connection verified!"}
|
return {"status": "success", "message": "Google Gemini API connection verified!"}
|
||||||
|
|
||||||
elif provider == "claude":
|
elif provider == "claude":
|
||||||
import anthropic
|
import anthropic
|
||||||
client = anthropic.Anthropic(api_key=key)
|
client = anthropic.Anthropic(api_key=key)
|
||||||
# Claude also supports model listing for key verification
|
|
||||||
client.models.list(limit=1)
|
client.models.list(limit=1)
|
||||||
return {"status": "success", "message": "Anthropic Claude API connection verified!"}
|
return {"status": "success", "message": "Anthropic Claude API connection verified!"}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_msg = str(e)
|
raise HTTPException(status_code=400, detail=f"{provider.capitalize()} Test Failed: {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}")
|
|
||||||
|
|
||||||
@router.post("/settings/ai")
|
@router.post("/settings/ai")
|
||||||
def update_ai_provider(
|
def update_ai_provider(
|
||||||
@@ -302,7 +196,7 @@ def update_ai_provider(
|
|||||||
"""Update the active AI provider."""
|
"""Update the active AI provider."""
|
||||||
provider = payload.get("provider")
|
provider = payload.get("provider")
|
||||||
if provider not in ["gemini", "claude"]:
|
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()
|
existing = db.query(models.SystemSetting).filter(models.SystemSetting.key == "ai_provider").first()
|
||||||
if existing:
|
if existing:
|
||||||
65
backend/tests/conftest.py
Normal file
65
backend/tests/conftest.py
Normal file
@@ -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()
|
||||||
35
backend/tests/test_admin.py
Normal file
35
backend/tests/test_admin.py
Normal file
@@ -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
|
||||||
File diff suppressed because it is too large
Load Diff
183
frontend/components/admin/AiManager.tsx
Normal file
183
frontend/components/admin/AiManager.tsx
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Brain, Cpu, Zap, Lock, RotateCcw, Wifi, FileText, Shield } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface AiManagerProps {
|
||||||
|
aiConfig: any;
|
||||||
|
handleUpdateAiProvider: (provider: string) => void;
|
||||||
|
aiKeys: { gemini: string; claude: string };
|
||||||
|
setAiKeys: (keys: any) => void;
|
||||||
|
isSavingKeys: boolean;
|
||||||
|
onSaveAiKeys: () => void;
|
||||||
|
isTestingKeys: { gemini: boolean; claude: boolean };
|
||||||
|
onTestAiKey: (provider: 'gemini' | 'claude') => void;
|
||||||
|
aiPrompt: string;
|
||||||
|
setAiPrompt: (prompt: string) => void;
|
||||||
|
isSavingPrompt: boolean;
|
||||||
|
onUpdatePrompt: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AiManager({
|
||||||
|
aiConfig,
|
||||||
|
handleUpdateAiProvider,
|
||||||
|
aiKeys,
|
||||||
|
setAiKeys,
|
||||||
|
isSavingKeys,
|
||||||
|
onSaveAiKeys,
|
||||||
|
isTestingKeys,
|
||||||
|
onTestAiKey,
|
||||||
|
aiPrompt,
|
||||||
|
setAiPrompt,
|
||||||
|
isSavingPrompt,
|
||||||
|
onUpdatePrompt
|
||||||
|
}: AiManagerProps) {
|
||||||
|
return (
|
||||||
|
<section className="bg-slate-900/40 backdrop-blur-xl border border-slate-800/50 rounded-[2.5rem] p-5 md:p-8 shadow-2xl space-y-6 transition-all group/ai">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-indigo-500/10 flex items-center justify-center text-indigo-400 border border-indigo-500/20">
|
||||||
|
<Brain size={20} />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-xl font-black text-white tracking-tight">AI Intelligence</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid sm:grid-cols-2 gap-4">
|
||||||
|
{aiConfig?.providers?.map((p: any) => (
|
||||||
|
<button
|
||||||
|
key={p.id}
|
||||||
|
onClick={() => handleUpdateAiProvider(p.id)}
|
||||||
|
className={cn(
|
||||||
|
"p-4 rounded-2xl border transition-all text-left flex items-center justify-between group",
|
||||||
|
p.active
|
||||||
|
? "bg-indigo-600 border-indigo-500 shadow-xl shadow-indigo-600/10"
|
||||||
|
: "bg-slate-950/60 border-slate-800 hover:border-indigo-500/40"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={cn(
|
||||||
|
"w-8 h-8 rounded-lg flex items-center justify-center transition-colors",
|
||||||
|
p.active ? "bg-white/20 text-white" : "bg-indigo-500/10 text-indigo-400 group-hover:bg-indigo-500/20"
|
||||||
|
)}>
|
||||||
|
{p.id === 'gemini' ? <Cpu size={16} /> : <Zap size={16} />}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className={cn("text-xs font-black tracking-tight", p.active ? "text-white" : "text-slate-200")}>{p.name}</p>
|
||||||
|
<p className={cn(
|
||||||
|
"text-[9px] font-black mt-0.5 px-0.5 rounded",
|
||||||
|
p.active
|
||||||
|
? (p.configured ? "text-emerald-200" : "text-rose-100")
|
||||||
|
: (p.configured ? "text-emerald-500" : "text-rose-500")
|
||||||
|
)}>
|
||||||
|
{p.configured ? "● Key Configured" : "○ Missing Api Key"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{p.active && (
|
||||||
|
<div className="bg-white/20 px-2 py-0.5 rounded-full text-[8px] font-black text-white tracking-tighter">
|
||||||
|
Active
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-indigo-500/5 border border-indigo-500/10 rounded-[2rem] p-6 space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-indigo-500/10 rounded-lg text-indigo-400"><Lock size={14} /></div>
|
||||||
|
<h3 className="text-xs font-black text-slate-200 tracking-tight">Provider Access Keys</h3>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onSaveAiKeys}
|
||||||
|
disabled={isSavingKeys}
|
||||||
|
className="px-6 py-2.5 bg-indigo-600 hover:bg-indigo-500 text-white rounded-xl text-[10px] font-black transition-all active:scale-95 shadow-xl shadow-indigo-600/10 tracking-tight border border-indigo-500/30 flex items-center gap-2"
|
||||||
|
>
|
||||||
|
{isSavingKeys ? <RotateCcw size={14} className="animate-spin" /> : <Lock size={14} />}
|
||||||
|
{isSavingKeys ? "Storing..." : "Store Keys"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid md:grid-cols-2 gap-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-[9px] font-black text-slate-500 tracking-tight ml-1">Gemini Api Key</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={aiKeys.gemini}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => onTestAiKey('gemini')}
|
||||||
|
disabled={isTestingKeys.gemini}
|
||||||
|
className={cn(
|
||||||
|
"px-4 py-2 rounded-xl text-[9px] font-black tracking-tighter transition-all border shadow-lg flex items-center gap-1.5",
|
||||||
|
isTestingKeys.gemini
|
||||||
|
? "bg-slate-800 border-slate-700 text-slate-500 cursor-wait"
|
||||||
|
: "bg-indigo-600 border-indigo-500 text-white hover:bg-indigo-500"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isTestingKeys.gemini ? <RotateCcw size={12} className="animate-spin" /> : <Wifi size={12} />}
|
||||||
|
{isTestingKeys.gemini ? "..." : "Test"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-[9px] font-black text-slate-500 tracking-tight ml-1">Claude Api Key</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={aiKeys.claude}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => onTestAiKey('claude')}
|
||||||
|
disabled={isTestingKeys.claude}
|
||||||
|
className={cn(
|
||||||
|
"px-4 py-2 rounded-xl text-[9px] font-black uppercase tracking-tighter transition-all border shadow-lg flex items-center gap-1.5",
|
||||||
|
isTestingKeys.claude
|
||||||
|
? "bg-slate-800 border-slate-700 text-slate-500 cursor-wait"
|
||||||
|
: "bg-indigo-600 border-indigo-500 text-white hover:bg-indigo-500"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isTestingKeys.claude ? <RotateCcw size={12} className="animate-spin" /> : <Wifi size={12} />}
|
||||||
|
{isTestingKeys.claude ? "..." : "Test"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between px-1">
|
||||||
|
<label className="text-[10px] font-black text-slate-500 tracking-tight">System Prompt (Vision Extraction)</label>
|
||||||
|
<button
|
||||||
|
onClick={onUpdatePrompt}
|
||||||
|
disabled={isSavingPrompt}
|
||||||
|
className="px-6 py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-xl text-[10px] font-black transition-all active:scale-95 shadow-xl shadow-indigo-600/10 tracking-tight border border-indigo-500/30 flex items-center gap-2"
|
||||||
|
>
|
||||||
|
{isSavingPrompt ? <RotateCcw size={14} className="animate-spin" /> : <FileText size={14} />}
|
||||||
|
{isSavingPrompt ? "Saving..." : "Save Prompt"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
value={aiPrompt}
|
||||||
|
onChange={(e) => setAiPrompt(e.target.value)}
|
||||||
|
className="w-full bg-slate-950/80 border border-slate-800 rounded-2xl p-6 text-[11px] font-mono font-bold text-slate-300 leading-relaxed outline-none focus:border-purple-500/50 transition-all min-h-[200px] custom-scrollbar shadow-inner"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="bg-purple-500/5 border border-purple-500/10 rounded-2xl p-4 flex gap-4 items-start">
|
||||||
|
<div className="p-2 bg-purple-500/10 rounded-lg text-purple-400 shrink-0"><Shield size={14} /></div>
|
||||||
|
<p className="text-[10px] font-bold text-slate-500 leading-relaxed">
|
||||||
|
This prompt instructs the Vision AI core on label interpretation. Ensure it defines explicit mapping for technical attributes like <span className="text-purple-400">Item</span>, <span className="text-purple-400">Type</span>, and <span className="text-purple-400">Part Number</span> to avoid extraction null-pointers and ensure inventory data integrity.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
113
frontend/components/admin/CategoryManager.tsx
Normal file
113
frontend/components/admin/CategoryManager.tsx
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Layers, Plus, Edit2, Trash2, X } from 'lucide-react';
|
||||||
|
|
||||||
|
interface CategoryManagerProps {
|
||||||
|
categories: any[];
|
||||||
|
onAddCategory: () => void;
|
||||||
|
onDeleteCategory: (id: number, name: string) => void;
|
||||||
|
setEditingCategory: (cat: any) => void;
|
||||||
|
setEditCatForm: (form: any) => void;
|
||||||
|
editCatForm: any;
|
||||||
|
editingCategory: any;
|
||||||
|
onUpdateCategorySubmit: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CategoryManager({
|
||||||
|
categories,
|
||||||
|
onAddCategory,
|
||||||
|
onDeleteCategory,
|
||||||
|
setEditingCategory,
|
||||||
|
setEditCatForm,
|
||||||
|
editCatForm,
|
||||||
|
editingCategory,
|
||||||
|
onUpdateCategorySubmit
|
||||||
|
}: CategoryManagerProps) {
|
||||||
|
return (
|
||||||
|
<section className="bg-slate-900/40 backdrop-blur-xl border border-slate-800/50 rounded-[2.5rem] p-5 md:p-8 shadow-2xl space-y-6 transition-all group/categories">
|
||||||
|
<div className="flex items-center justify-between px-2">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-indigo-500/10 flex items-center justify-center text-indigo-400 border border-indigo-500/20">
|
||||||
|
<Layers size={20} />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-xl font-black text-white tracking-tight">Category Groups</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onAddCategory}
|
||||||
|
className="flex items-center gap-2 bg-indigo-600 hover:bg-indigo-500 text-white px-6 py-3 rounded-xl text-xs font-black transition-all shadow-xl shadow-indigo-600/10 active:scale-95 border border-indigo-500/30 tracking-tight"
|
||||||
|
>
|
||||||
|
<Plus size={14} /> New Group
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-3">
|
||||||
|
{categories.map(cat => (
|
||||||
|
<div key={cat.id} className="p-4 bg-slate-950/40 border border-slate-800/50 rounded-2xl flex items-center justify-between group hover:border-primary/40 transition-all">
|
||||||
|
<div className="min-w-0 pr-4">
|
||||||
|
<p className="text-sm font-bold text-slate-200 group-hover:text-primary transition-colors">{cat.name}</p>
|
||||||
|
<p className="text-[10px] text-slate-500 font-medium mt-0.5">{cat.description || 'General storage'}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1 shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setEditingCategory(cat);
|
||||||
|
setEditCatForm({ name: cat.name, description: cat.description || '' });
|
||||||
|
}}
|
||||||
|
className="p-2 text-slate-600 hover:text-white hover:bg-slate-800 rounded-lg transition-all"
|
||||||
|
>
|
||||||
|
<Edit2 size={14} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onDeleteCategory(cat.id, cat.name)}
|
||||||
|
className="p-2 text-slate-600 hover:text-red-500 hover:bg-red-500/5 rounded-lg transition-all"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Edit Category Modal */}
|
||||||
|
{editingCategory && (
|
||||||
|
<div className="fixed inset-0 z-[100] flex items-end sm:items-center justify-center p-0 sm:p-4 bg-slate-950/90 backdrop-blur-xl animate-in fade-in duration-300">
|
||||||
|
<div className="w-full max-w-lg bg-slate-900 border-x border-t sm:border border-slate-800 rounded-t-[2.5rem] sm:rounded-3xl p-6 sm:p-10 shadow-2xl space-y-8 overflow-hidden animate-in slide-in-from-bottom-10">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-indigo-500/10 flex items-center justify-center text-indigo-400 border border-indigo-500/20">
|
||||||
|
<Edit2 size={20} />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-xl font-black text-white tracking-tight">Modify Group</h3>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setEditingCategory(null)} className="p-3 hover:bg-slate-800 rounded-2xl text-slate-500 transition-colors border border-slate-800">
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-[10px] font-black text-slate-500 tracking-tight ml-1">Group Identifier</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editCatForm.name}
|
||||||
|
onChange={(e) => setEditCatForm({...editCatForm, name: e.target.value})}
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 rounded-2xl py-3.5 px-5 text-sm text-white focus:border-primary outline-none transition-all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-[10px] font-black text-slate-500 tracking-tight ml-1">Strategic Description</label>
|
||||||
|
<textarea
|
||||||
|
value={editCatForm.description}
|
||||||
|
onChange={(e) => setEditCatForm({...editCatForm, description: e.target.value})}
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 rounded-2xl py-4 px-5 text-sm text-white focus:border-primary outline-none transition-all h-32 resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button onClick={onUpdateCategorySubmit} className="w-full bg-indigo-600 hover:bg-indigo-500 text-white font-black py-4 rounded-2xl shadow-xl shadow-indigo-600/20 active:scale-95 transition-all text-xs mb-4 tracking-tight">
|
||||||
|
Update Asset Group
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
178
frontend/components/admin/DatabaseManager.tsx
Normal file
178
frontend/components/admin/DatabaseManager.tsx
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Database, RotateCcw, Download, Upload, Zap, History, Clock } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface DatabaseManagerProps {
|
||||||
|
dbStats: any;
|
||||||
|
isBackingUp: boolean;
|
||||||
|
onCreateBackup: () => void;
|
||||||
|
dbSettings: any;
|
||||||
|
onUpdateDbSettings: (settings: any) => void;
|
||||||
|
backups: any[];
|
||||||
|
onRestore: (filename: string) => void;
|
||||||
|
onExport: () => void;
|
||||||
|
onImport: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DatabaseManager({
|
||||||
|
dbStats,
|
||||||
|
isBackingUp,
|
||||||
|
onCreateBackup,
|
||||||
|
dbSettings,
|
||||||
|
onUpdateDbSettings,
|
||||||
|
backups,
|
||||||
|
onRestore,
|
||||||
|
onExport,
|
||||||
|
onImport
|
||||||
|
}: DatabaseManagerProps) {
|
||||||
|
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];
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 md:space-y-8 h-full">
|
||||||
|
<div className="bg-slate-900/40 backdrop-blur-xl border border-slate-800/50 rounded-[2.5rem] p-5 md:p-8 shadow-2xl overflow-hidden relative group transition-all">
|
||||||
|
<div className="flex items-center gap-4 mb-6">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-indigo-500/10 flex items-center justify-center text-indigo-400 border border-indigo-500/20">
|
||||||
|
<Database size={20} />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-xl font-black text-white tracking-tight">System Integrity</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-6">
|
||||||
|
<div className="space-y-1 sm:pr-6">
|
||||||
|
<p className="text-[10px] font-black text-slate-500 tracking-tight">Database Health</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-2 h-2 rounded-full bg-green-500 animate-pulse shadow-[0_0_8px_rgba(34,197,94,0.3)]" />
|
||||||
|
<span className="text-sm font-bold text-slate-200">Operational</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="sm:pl-6 sm:border-l border-slate-800">
|
||||||
|
<p className="text-[10px] font-black text-slate-500 tracking-tight">Last Backup</p>
|
||||||
|
<p className="text-sm font-bold text-slate-200 tabular-nums">{dbStats.backup_count > 0 ? 'Verified' : 'Pending...'}</p>
|
||||||
|
</div>
|
||||||
|
<div className="ml-auto">
|
||||||
|
<button
|
||||||
|
onClick={onCreateBackup}
|
||||||
|
disabled={isBackingUp}
|
||||||
|
className="flex items-center gap-2 px-5 py-3 bg-indigo-600 hover:bg-indigo-500 text-white rounded-xl text-[10px] font-black transition-all active:scale-95 disabled:opacity-50 shadow-xl shadow-indigo-600/10 tracking-tight"
|
||||||
|
>
|
||||||
|
{isBackingUp ? <RotateCcw size={14} className="animate-spin" /> : <Database size={14} />}
|
||||||
|
{isBackingUp ? "Snapshotting..." : "Force Backup"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-slate-900/40 backdrop-blur-xl border border-slate-800/50 rounded-[2.5rem] p-5 md:p-8 shadow-2xl space-y-8">
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-indigo-500/10 flex items-center justify-center text-indigo-400 border border-indigo-500/20">
|
||||||
|
<History size={20} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-black text-white tracking-tight">Storage Policy</h3>
|
||||||
|
<p className="text-[10px] text-slate-500 font-bold tracking-tight">Retention & Maintenance</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid sm:grid-cols-3 gap-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-[9px] font-black text-slate-500 tracking-tight ml-1 flex items-center gap-2">
|
||||||
|
<History size={10} /> Max Backups
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={dbSettings.retention_count}
|
||||||
|
onChange={(e) => onUpdateDbSettings({...dbSettings, retention_count: parseInt(e.target.value)})}
|
||||||
|
className="w-full bg-slate-950/80 border border-slate-800 rounded-xl py-3 px-4 text-xs font-bold text-white outline-none focus:border-indigo-500/50 transition-all tabular-nums"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-[9px] font-black text-slate-500 tracking-tight ml-1 flex items-center gap-2">
|
||||||
|
<Clock size={10} /> Schedule (Hour)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0" max="23"
|
||||||
|
value={dbSettings.schedule_hour}
|
||||||
|
onChange={(e) => onUpdateDbSettings({...dbSettings, schedule_hour: parseInt(e.target.value)})}
|
||||||
|
className="w-full bg-slate-950/80 border border-slate-800 rounded-xl py-3 px-4 text-xs font-bold text-white outline-none focus:border-indigo-500/50 transition-all tabular-nums"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-[9px] font-black text-slate-500 tracking-tight ml-1 flex items-center gap-2">
|
||||||
|
<Zap size={10} /> Frequency (Days)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
value={dbSettings.schedule_freq_days}
|
||||||
|
onChange={(e) => onUpdateDbSettings({...dbSettings, schedule_freq_days: parseInt(e.target.value)})}
|
||||||
|
className="w-full bg-slate-950/80 border border-slate-800 rounded-xl py-3 px-4 text-xs font-bold text-white outline-none focus:border-indigo-500/50 transition-all tabular-nums"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between px-1">
|
||||||
|
<label className="text-[10px] font-black text-slate-500 tracking-tight">Recovery Points</label>
|
||||||
|
<div className="text-[10px] font-black text-indigo-400 tracking-tight bg-indigo-500/5 px-3 py-1 rounded-full border border-indigo-500/10">
|
||||||
|
{formatSize(dbStats.total_size_bytes)} Used
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 pr-2 overflow-y-auto max-h-[300px] scrollbar-thin scrollbar-thumb-slate-800 scrollbar-track-transparent">
|
||||||
|
{backups.map((bak: any) => (
|
||||||
|
<div key={bak.filename} className="flex items-center justify-between p-3.5 bg-slate-950/40 border border-slate-800/40 rounded-2xl group/item hover:border-indigo-500/30 transition-all">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-slate-800 flex items-center justify-center text-slate-500">
|
||||||
|
<Database size={14} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] font-bold text-slate-200">{bak.filename}</p>
|
||||||
|
<p className="text-[9px] text-slate-500 font-bold tabular-nums">
|
||||||
|
{new Date(bak.created_at).toLocaleString()} • {formatSize(bak.size_bytes)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => onRestore(bak.filename)}
|
||||||
|
className="p-2 text-slate-600 hover:text-indigo-400 hover:bg-indigo-500/5 rounded-xl transition-all opacity-0 group-hover/item:opacity-100"
|
||||||
|
title="Restore this point"
|
||||||
|
>
|
||||||
|
<RotateCcw size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4 pt-2">
|
||||||
|
<button
|
||||||
|
onClick={onExport}
|
||||||
|
className="flex items-center justify-center gap-2 py-4 bg-slate-950 border border-slate-800 hover:border-indigo-500/50 text-indigo-400 rounded-2xl text-[10px] font-black transition-all active:scale-95 tracking-tight shadow-xl shadow-black/20"
|
||||||
|
>
|
||||||
|
<Download size={14} /> Export Database
|
||||||
|
</button>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".db"
|
||||||
|
onChange={onImport}
|
||||||
|
className="absolute inset-0 opacity-0 cursor-pointer z-10"
|
||||||
|
/>
|
||||||
|
<button className="w-full flex items-center justify-center gap-2 py-4 bg-slate-950 border border-slate-800 hover:border-rose-500/50 text-rose-500 rounded-2xl text-[10px] font-black transition-all tracking-tight shadow-xl shadow-black/20">
|
||||||
|
<Upload size={14} /> Import Database
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
136
frontend/components/admin/IdentityManager.tsx
Normal file
136
frontend/components/admin/IdentityManager.tsx
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { User, UserPlus, Shield, Edit2, Trash2, X } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface IdentityManagerProps {
|
||||||
|
users: any[];
|
||||||
|
onAddUser: () => void;
|
||||||
|
onDeleteUser: (id: number, username: string) => void;
|
||||||
|
editingUser: any | null;
|
||||||
|
setEditingUser: (user: any | null) => void;
|
||||||
|
editUserForm: any;
|
||||||
|
setEditUserForm: (form: any) => void;
|
||||||
|
onUpdateUserSubmit: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function IdentityManager({
|
||||||
|
users,
|
||||||
|
onAddUser,
|
||||||
|
onDeleteUser,
|
||||||
|
editingUser,
|
||||||
|
setEditingUser,
|
||||||
|
editUserForm,
|
||||||
|
setEditUserForm,
|
||||||
|
onUpdateUserSubmit
|
||||||
|
}: IdentityManagerProps) {
|
||||||
|
return (
|
||||||
|
<div className="bg-slate-900/40 backdrop-blur-xl border border-slate-800/50 rounded-[2.5rem] p-5 md:p-8 flex flex-col shadow-2xl transition-all group/identity h-full">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-indigo-500/10 flex items-center justify-center text-indigo-400 border border-indigo-500/20">
|
||||||
|
<User size={20} />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-xl font-black text-white tracking-tight">Identity Management</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onAddUser}
|
||||||
|
className="flex items-center gap-2 px-5 py-2.5 bg-indigo-600 hover:bg-indigo-500 text-white rounded-xl text-xs font-black transition-all shadow-xl shadow-indigo-600/10 active:scale-95 border border-indigo-500/30 tracking-tight"
|
||||||
|
>
|
||||||
|
<UserPlus size={16} /> Add User
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 space-y-2.5 pr-2 -mr-2 overflow-y-auto max-h-[400px] scrollbar-thin scrollbar-thumb-slate-800 scrollbar-track-transparent">
|
||||||
|
{users.map((user) => (
|
||||||
|
<div key={user.id} className="flex items-center justify-between p-3.5 bg-slate-950/40 border border-slate-800/40 rounded-2xl hover:border-primary/30 transition-all group">
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
<div className={cn(
|
||||||
|
"w-9 h-9 rounded-xl flex items-center justify-center transition-colors shadow-inner shrink-0",
|
||||||
|
user.role === 'admin' ? "bg-primary/10 text-primary border border-primary/20" : "bg-slate-800 text-slate-500 border border-slate-700"
|
||||||
|
)}>
|
||||||
|
{user.role === 'admin' ? <Shield size={16} /> : <User size={16} />}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-bold text-slate-200 truncate">{user.username}</p>
|
||||||
|
<p className="text-[9px] text-slate-500 font-black tracking-tight capitalize">{user.role}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 ml-2">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setEditingUser(user);
|
||||||
|
setEditUserForm({ username: user.username, password: '', role: user.role });
|
||||||
|
}}
|
||||||
|
className="p-2 text-slate-600 hover:text-primary hover:bg-primary/5 rounded-xl transition-all"
|
||||||
|
>
|
||||||
|
<Edit2 size={16} />
|
||||||
|
</button>
|
||||||
|
{user.username !== 'Admin' && (
|
||||||
|
<button
|
||||||
|
onClick={() => onDeleteUser(user.id, user.username)}
|
||||||
|
className="p-2 text-slate-600 hover:text-red-500 hover:bg-red-500/5 rounded-xl transition-all"
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Edit User Modal */}
|
||||||
|
{editingUser && (
|
||||||
|
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-sm animate-in fade-in duration-200">
|
||||||
|
<div className="bg-slate-900 border border-slate-800 rounded-[2.5rem] p-8 w-full max-w-md shadow-2xl">
|
||||||
|
<div className="flex justify-between items-center mb-6">
|
||||||
|
<h3 className="text-xl font-black text-white tracking-tight">Edit Identity</h3>
|
||||||
|
<button onClick={() => setEditingUser(null)} className="p-2 text-slate-500 hover:text-white transition-colors">
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-[10px] font-black text-slate-500 tracking-tight ml-1">Username</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editUserForm.username}
|
||||||
|
onChange={(e) => setEditUserForm({ ...editUserForm, username: e.target.value })}
|
||||||
|
className="w-full bg-slate-950/80 border border-slate-800 rounded-2xl py-3 px-4 text-sm font-bold text-white outline-none focus:border-primary/50 transition-all font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-[10px] font-black text-slate-500 tracking-tight ml-1">New Password (Leave Empty To Keep)</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={editUserForm.password}
|
||||||
|
onChange={(e) => setEditUserForm({ ...editUserForm, password: e.target.value })}
|
||||||
|
placeholder="********"
|
||||||
|
className="w-full bg-slate-950/80 border border-slate-800 rounded-2xl py-3 px-4 text-sm font-bold text-white outline-none focus:border-primary/50 transition-all font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-[10px] font-black text-slate-500 tracking-tight ml-1">Role</label>
|
||||||
|
<select
|
||||||
|
value={editUserForm.role}
|
||||||
|
onChange={(e) => setEditUserForm({ ...editUserForm, role: e.target.value })}
|
||||||
|
className="w-full bg-slate-950/80 border border-slate-800 rounded-2xl py-3 px-4 text-sm font-bold text-white outline-none focus:border-primary/50 transition-all appearance-none"
|
||||||
|
>
|
||||||
|
<option value="user">Standard User</option>
|
||||||
|
<option value="admin">Administrator</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onUpdateUserSubmit}
|
||||||
|
className="w-full bg-primary hover:bg-primary/80 text-white rounded-2xl py-4 text-sm font-black transition-all active:scale-95 shadow-xl shadow-primary/20 tracking-tight mt-4"
|
||||||
|
>
|
||||||
|
Apply Changes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
165
frontend/components/admin/LdapManager.tsx
Normal file
165
frontend/components/admin/LdapManager.tsx
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Globe, Power, Server, Shield, User, Layers, Lock, Wifi, RotateCcw } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface LdapManagerProps {
|
||||||
|
ldapConfig: any;
|
||||||
|
setLdapConfig: (config: any) => void;
|
||||||
|
testingLdap: boolean;
|
||||||
|
onTestLdap: () => void;
|
||||||
|
onUpdateLdap: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LdapManager({
|
||||||
|
ldapConfig,
|
||||||
|
setLdapConfig,
|
||||||
|
testingLdap,
|
||||||
|
onTestLdap,
|
||||||
|
onUpdateLdap
|
||||||
|
}: LdapManagerProps) {
|
||||||
|
return (
|
||||||
|
<div className="bg-slate-900/40 backdrop-blur-xl border border-slate-800/50 rounded-[2.5rem] p-5 md:p-8 shadow-2xl space-y-6 transition-all group/ldap">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-indigo-500/10 flex items-center justify-center text-indigo-400 border border-indigo-500/20">
|
||||||
|
<Globe size={20} />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-xl font-black text-white tracking-tight">Enterprise LDAP</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between p-4 bg-slate-950/40 border border-slate-800/60 rounded-2xl group transition-all hover:border-indigo-500/30">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={cn(
|
||||||
|
"w-8 h-8 rounded-xl flex items-center justify-center transition-all shadow-inner",
|
||||||
|
ldapConfig.ldap_enabled ? "bg-green-500/10 text-green-500" : "bg-slate-800 text-slate-500"
|
||||||
|
)}>
|
||||||
|
<Power size={14} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] font-black text-slate-200 tracking-tight leading-none">Ldap Authorization</p>
|
||||||
|
<p className={cn("text-[8px] font-bold mt-1", ldapConfig.ldap_enabled ? "text-green-500/80" : "text-slate-600")}>
|
||||||
|
{ldapConfig.ldap_enabled ? "External Directory Active" : "Internal Authentication Only"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setLdapConfig({...ldapConfig, ldap_enabled: !ldapConfig.ldap_enabled})}
|
||||||
|
className={cn(
|
||||||
|
"w-10 h-5 rounded-full relative transition-all duration-300 shadow-inner border",
|
||||||
|
ldapConfig.ldap_enabled ? "bg-indigo-600 border-indigo-500" : "bg-slate-800 border-slate-700"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className={cn(
|
||||||
|
"absolute top-0.5 w-3.5 h-3.5 bg-white rounded-full transition-all shadow-sm",
|
||||||
|
ldapConfig.ldap_enabled ? "right-0.5" : "left-0.5"
|
||||||
|
)} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid sm:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-[10px] font-black text-slate-500 tracking-tight ml-1">Ldap Uri</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Server className="absolute left-3.5 top-1/2 -translate-y-1/2 text-slate-700" size={14} />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="ldap://host:389"
|
||||||
|
value={ldapConfig.server_uri}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-[10px] font-black text-slate-500 tracking-tight ml-1">Context Dn</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Shield className="absolute left-3.5 top-1/2 -translate-y-1/2 text-slate-700" size={14} />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="dc=example,dc=com"
|
||||||
|
value={ldapConfig.base_dn}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid sm:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-[10px] font-black text-slate-500 tracking-tight ml-1">User Dn Template</label>
|
||||||
|
<div className="relative">
|
||||||
|
<User className="absolute left-3.5 top-1/2 -translate-y-1/2 text-slate-700" size={14} />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="uid={username},ou=people..."
|
||||||
|
value={ldapConfig.user_template}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-[10px] font-black text-slate-500 tracking-tight ml-1">Groups Base Dn</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Layers className="absolute left-3.5 top-1/2 -translate-y-1/2 text-slate-700" size={14} />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="ou=groups"
|
||||||
|
value={ldapConfig.groups_dn}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between p-4 bg-slate-950/40 border border-slate-800/60 rounded-2xl group transition-all hover:border-indigo-500/30">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={cn(
|
||||||
|
"w-8 h-8 rounded-xl flex items-center justify-center transition-all shadow-inner",
|
||||||
|
ldapConfig.use_tls ? "bg-indigo-500/10 text-indigo-400" : "bg-slate-800 text-slate-500"
|
||||||
|
)}>
|
||||||
|
<Lock size={14} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] font-black text-slate-200 tracking-tight leading-none">Use Tls</p>
|
||||||
|
<p className={cn("text-[8px] font-bold mt-1", ldapConfig.use_tls ? "text-indigo-400/80" : "text-slate-600")}>
|
||||||
|
{ldapConfig.use_tls ? "Encrypted Channel" : "Standard Link"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setLdapConfig({...ldapConfig, use_tls: !ldapConfig.use_tls})}
|
||||||
|
className={cn(
|
||||||
|
"w-10 h-5 rounded-full relative transition-all duration-300 shadow-inner border",
|
||||||
|
ldapConfig.use_tls ? "bg-indigo-600 border-indigo-500" : "bg-slate-800 border-slate-700"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className={cn(
|
||||||
|
"absolute top-0.5 w-3.5 h-3.5 bg-white rounded-full transition-all shadow-sm",
|
||||||
|
ldapConfig.use_tls ? "right-0.5" : "left-0.5"
|
||||||
|
)} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 pt-2">
|
||||||
|
<button
|
||||||
|
onClick={onTestLdap}
|
||||||
|
disabled={testingLdap}
|
||||||
|
className="flex-1 bg-indigo-600 hover:bg-indigo-500 text-white rounded-xl py-3 text-xs font-black shadow-xl shadow-indigo-600/10 transition-all active:scale-95 disabled:opacity-50 flex items-center justify-center gap-2 border border-indigo-500/30"
|
||||||
|
>
|
||||||
|
{testingLdap ? <RotateCcw size={14} className="animate-spin" /> : <Wifi size={14} />}
|
||||||
|
Test Connection
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onUpdateLdap}
|
||||||
|
className="flex-[2] bg-indigo-600 hover:bg-indigo-500 text-white rounded-xl py-3 text-xs font-black shadow-xl shadow-indigo-600/10 transition-all active:scale-95 flex items-center justify-center gap-2 border border-indigo-500/30 tracking-tight"
|
||||||
|
>
|
||||||
|
<Shield size={14} /> Save LDAP Policy
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
338
frontend/hooks/useAdmin.ts
Normal file
338
frontend/hooks/useAdmin.ts
Normal file
@@ -0,0 +1,338 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { inventoryApi } from '@/lib/api';
|
||||||
|
import { toast } from 'react-hot-toast';
|
||||||
|
|
||||||
|
export function useAdmin() {
|
||||||
|
const [users, setUsers] = useState<any[]>([]);
|
||||||
|
const [categories, setCategories] = useState<any[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const [ldapConfig, setLdapConfig] = useState<any>({
|
||||||
|
ldap_enabled: false,
|
||||||
|
server_uri: '',
|
||||||
|
base_dn: '',
|
||||||
|
user_template: '',
|
||||||
|
groups_dn: '',
|
||||||
|
use_tls: false,
|
||||||
|
role_mappings: []
|
||||||
|
});
|
||||||
|
const [testingLdap, setTestingLdap] = useState(false);
|
||||||
|
|
||||||
|
const [editingUser, setEditingUser] = useState<any | null>(null);
|
||||||
|
const [editUserForm, setEditUserForm] = useState({ username: '', password: '', role: 'user' });
|
||||||
|
|
||||||
|
const [editingCategory, setEditingCategory] = useState<any | null>(null);
|
||||||
|
const [editCatForm, setEditCatForm] = useState({ name: '', description: '' });
|
||||||
|
|
||||||
|
const [backups, setBackups] = useState<any[]>([]);
|
||||||
|
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);
|
||||||
|
|
||||||
|
const [aiPrompt, setAiPrompt] = useState("");
|
||||||
|
const [aiConfig, setAiConfig] = useState<any>(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, promptRes, ai] = await Promise.all([
|
||||||
|
inventoryApi.getUsers(),
|
||||||
|
inventoryApi.getCategories(),
|
||||||
|
inventoryApi.getLdapConfig(),
|
||||||
|
inventoryApi.getDbBackups(),
|
||||||
|
inventoryApi.getDbStats(),
|
||||||
|
inventoryApi.getDbSettings(),
|
||||||
|
inventoryApi.getAiPrompt(),
|
||||||
|
inventoryApi.getAiConfig()
|
||||||
|
]);
|
||||||
|
setUsers(u);
|
||||||
|
setCategories(c);
|
||||||
|
if (l && l.server_uri) setLdapConfig(l);
|
||||||
|
setBackups(b);
|
||||||
|
setDbStats(s);
|
||||||
|
setDbSettings(st);
|
||||||
|
setAiPrompt(promptRes.value);
|
||||||
|
setAiConfig(ai);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(err);
|
||||||
|
toast.error("Failed to load admin data");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddUser = async () => {
|
||||||
|
const name = window.prompt("Enter new username:");
|
||||||
|
if (!name) return;
|
||||||
|
const pwd = window.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 (!window.confirm(`Delete user ${username}?`)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await inventoryApi.deleteUser(id);
|
||||||
|
toast.success("User removed");
|
||||||
|
loadData();
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error("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 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 = window.prompt("New category name:");
|
||||||
|
if (!name) return;
|
||||||
|
const desc = window.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 (!window.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 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 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 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 handleRestore = async (filename: string) => {
|
||||||
|
if (!window.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 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<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
if (!window.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 handleLogout = () => {
|
||||||
|
import('@/lib/auth').then(m => m.clearAuth());
|
||||||
|
window.location.href = '/login';
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
users, categories, loading,
|
||||||
|
ldapConfig, setLdapConfig, testingLdap,
|
||||||
|
editingUser, setEditingUser, editUserForm, setEditUserForm,
|
||||||
|
editingCategory, setEditingCategory, editCatForm, setEditCatForm,
|
||||||
|
backups, dbStats, dbSettings, isBackingUp, isImporting,
|
||||||
|
aiPrompt, setAiPrompt, aiConfig, aiKeys, setAiKeys,
|
||||||
|
isSavingPrompt, isSavingKeys, isTestingKeys,
|
||||||
|
refreshData: loadData,
|
||||||
|
handleAddUser, handleDeleteUser, handleUpdateUserSubmit,
|
||||||
|
handleUpdateLdap, handleTestLdap,
|
||||||
|
handleUpdateAiProvider, handleSaveAiKeys, handleTestAiKey, handleUpdatePrompt,
|
||||||
|
handleAddCategory, handleUpdateCategorySubmit, handleDeleteCategory,
|
||||||
|
handleCreateBackup, handleUpdateDbSettings, handleRestore, handleExportDb, handleImportDb,
|
||||||
|
handleLogout
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -6,7 +6,8 @@
|
|||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint"
|
"lint": "next lint",
|
||||||
|
"test": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.15.0",
|
"axios": "^1.15.0",
|
||||||
@@ -27,10 +28,15 @@
|
|||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
"@testing-library/jest-dom": "^6.5.0",
|
||||||
|
"@testing-library/react": "^16.0.1",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "15.0.0",
|
"eslint-config-next": "15.0.0",
|
||||||
|
"jsdom": "^25.0.1",
|
||||||
"postcss": "^8",
|
"postcss": "^8",
|
||||||
"tailwindcss": "^3.4.1",
|
"tailwindcss": "^3.4.1",
|
||||||
"typescript": "^5"
|
"typescript": "^5",
|
||||||
|
"vitest": "^2.1.2",
|
||||||
|
"@vitejs/plugin-react": "^4.3.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
51
frontend/tests/hooks/useAdmin.test.ts
Normal file
51
frontend/tests/hooks/useAdmin.test.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { renderHook, waitFor } from '@testing-library/react';
|
||||||
|
import { useAdmin } from '@/hooks/useAdmin';
|
||||||
|
import { inventoryApi } from '@/lib/api';
|
||||||
|
import { vi, describe, it, expect } from 'vitest';
|
||||||
|
|
||||||
|
// Mock the API
|
||||||
|
vi.mock('@/lib/api', () => ({
|
||||||
|
inventoryApi: {
|
||||||
|
getUsers: vi.fn(),
|
||||||
|
getCategories: vi.fn(),
|
||||||
|
getLdapConfig: vi.fn(),
|
||||||
|
getDbBackups: vi.fn(),
|
||||||
|
getDbStats: vi.fn(),
|
||||||
|
getDbSettings: vi.fn(),
|
||||||
|
getAiPrompt: vi.fn(),
|
||||||
|
getAiConfig: vi.fn(),
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock toast
|
||||||
|
vi.mock('react-hot-toast', () => ({
|
||||||
|
toast: {
|
||||||
|
error: vi.fn(),
|
||||||
|
success: vi.fn(),
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('useAdmin hook', () => {
|
||||||
|
it('should initialize with loading state and fetch data', async () => {
|
||||||
|
(inventoryApi.getUsers as any).mockResolvedValue([{ id: 1, username: 'testuser' }]);
|
||||||
|
(inventoryApi.getCategories as any).mockResolvedValue([]);
|
||||||
|
(inventoryApi.getLdapConfig as any).mockResolvedValue({ server_uri: 'ldap://fake' });
|
||||||
|
(inventoryApi.getDbBackups as any).mockResolvedValue([]);
|
||||||
|
(inventoryApi.getDbStats as any).mockResolvedValue({ backup_count: 0 });
|
||||||
|
(inventoryApi.getDbSettings as any).mockResolvedValue({ retention_count: 5 });
|
||||||
|
(inventoryApi.getAiPrompt as any).mockResolvedValue({ value: 'test prompt' });
|
||||||
|
(inventoryApi.getAiConfig as any).mockResolvedValue({ active_provider: 'gemini' });
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useAdmin());
|
||||||
|
|
||||||
|
expect(result.current.loading).toBe(true);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.loading).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.users).toHaveLength(1);
|
||||||
|
expect(result.current.users[0].username).toBe('testuser');
|
||||||
|
expect(result.current.aiPrompt).toBe('test prompt');
|
||||||
|
});
|
||||||
|
});
|
||||||
15
frontend/vitest.config.ts
Normal file
15
frontend/vitest.config.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
test: {
|
||||||
|
environment: 'jsdom',
|
||||||
|
globals: true,
|
||||||
|
setupFiles: ['./vitest.setup.ts'],
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
1
frontend/vitest.setup.ts
Normal file
1
frontend/vitest.setup.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
import '@testing-library/jest-dom';
|
||||||
Reference in New Issue
Block a user