Build [v1.4.0] - Audit Dashboard & LDAP Restoration

This commit is contained in:
Daniel Bedeleanu
2026-04-12 09:39:17 +03:00
parent 5cceba21f4
commit f3d861b1a2
23 changed files with 973 additions and 382 deletions

3
.gitignore vendored
View File

@@ -19,6 +19,7 @@ build/
/data/* /data/*
!/data/.gitkeep !/data/.gitkeep
/data/backups/
/logs/* /logs/*
!/logs/.gitkeep !/logs/.gitkeep
@@ -86,3 +87,5 @@ aInventory-PROD*.zip
*.key *.key
*.crt *.crt
*.cert *.cert
__push_ALL_to_remote.sh

View File

@@ -52,6 +52,25 @@ For technical architecture, data models, and stack details, refer to [PROJECT_AR
6. Stay on the current branch (`dev`). 6. Stay on the current branch (`dev`).
- *Implementation*: Use `python3 scripts/save_version.py` to ensure consistency. - *Implementation*: Use `python3 scripts/save_version.py` to ensure consistency.
## 7. UI Fidelity Laws (Strict)
- **Consistent Layout**: All main pages (Inventory, Audit, Admin) MUST use `max-w-7xl` for their primary content container.
- **Unified Headers**: Every functional page MUST have an identical header structure:
- Icon inside a styled box (`p-4 bg-primary/10 rounded-[2rem] border border-primary/20`).
- Title: `text-3xl font-black text-white tracking-tight`.
- Subtitle: `text-xs text-slate-500 font-bold mt-1 tracking-wider`.
- **Typographic Integrity**:
- **NO UPPERCASE** (ALL CAPS) allowed anywhere in the UI (headers, labels, buttons, metadata).
- **NO ITALICS** in headers, titles, or active UI elements.
- Consistent font weight (`font-black`) for main headings.
- **Safety Actions**:
- The **Logout** button MUST be clearly differentiated (e.g., `text-rose-500`) and MUST always require a `window.confirm` before execution.
## 8. Audit & Deletion Policy
- **Immutability of Logs**: Deleting an `Item` MUST NOT delete its corresponding entries in the `AuditLog` table in the database.
- **Disk Traceability**: Every item deletion MUST be logged to the disk file (`logs/backend.log`) with `USER[id]`, `ITEM[id]`, `Name`, and `PN`.
- **Explicit Confirmation**: Destructive actions (Delete Item from catalog) MUST always trigger a browser-native `window.confirm` before proceeding.
## 5. End of Session Protocol ## 5. End of Session Protocol
- Once a phase/task from `PLAN.md` is completed and verified, move that entry into `dev_docs/PLAN_HISTORY.md`. - Once a phase/task from `PLAN.md` is completed and verified, move that entry into `dev_docs/PLAN_HISTORY.md`.
- After finishing an entire job (including updating session state, architecture logs, and versioning), end your final response on a separate line exactly with: - After finishing an entire job (including updating session state, architecture logs, and versioning), end your final response on a separate line exactly with:

View File

@@ -1,8 +1,10 @@
{ {
"version": "1.3.9", "version": "1.4.0",
"last_build": "2026-04-12-0729", "last_build": "2026-04-12-0940",
"commit": "0869ab8c", "commit": "HEAD",
"changelog": [ "changelog": [
"v1.4.0: Final Audit Dashboard (Phase 6), LDAP UI Restoration, environment path fixes for macOS",
"v1.3.9: Maintenance and structural updates",
"v1.3.8: Remove .npx_cache/ and scratch/npm_cache/ from git tracking (3350 files purged from index)", "v1.3.8: Remove .npx_cache/ and scratch/npm_cache/ from git tracking (3350 files purged from index)",
"v1.3.7: Security hardening \u2014 expanded .gitignore, removed ldap_config.json from tracking, added example files", "v1.3.7: Security hardening \u2014 expanded .gitignore, removed ldap_config.json from tracking, added example files",
"v1.3.6: Scanner UI redesign (autonomous OCR, countdown), Item Type datalist, save-version automation" "v1.3.6: Scanner UI redesign (autonomous OCR, countdown), Item Type datalist, save-version automation"

138
backend/db_manager.py Normal file
View File

@@ -0,0 +1,138 @@
import os
import shutil
import datetime
import sqlite3
import logging
from typing import List
from sqlalchemy import text
from sqlalchemy.orm import Session
from .database import DATA_DIR, engine
from . import models, schemas
logger = logging.getLogger("ainventory")
BACKUP_DIR = os.path.join(DATA_DIR, "backups")
os.makedirs(BACKUP_DIR, exist_ok=True)
class DbManager:
@staticmethod
def get_backup_list() -> List[schemas.BackupInfo]:
"""List all available backup files with metadata."""
backups = []
if not os.path.exists(BACKUP_DIR):
return []
for filename in os.listdir(BACKUP_DIR):
if filename.endswith(".db"):
path = os.path.join(BACKUP_DIR, filename)
stats = os.stat(path)
backups.append(schemas.BackupInfo(
filename=filename,
size_bytes=stats.st_size,
created_at=datetime.datetime.fromtimestamp(stats.st_mtime)
))
# Sort by creation time descending
backups.sort(key=lambda x: x.created_at, reverse=True)
return backups
@staticmethod
def get_stats() -> schemas.DatabaseStats:
"""Calculate storage statistics for backups."""
backups = DbManager.get_backup_list()
total_size = sum(b.size_bytes for b in backups)
return schemas.DatabaseStats(
backup_count=len(backups),
total_size_bytes=total_size
)
@staticmethod
def create_backup(db: Session, label: str = "manual", user_id: int = None) -> str:
"""
Creates a consistent snapshot of the active database using VACUUM INTO.
Records the action in AuditLog.
"""
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"inventory_{label}_{timestamp}.db"
target_path = os.path.join(BACKUP_DIR, filename)
try:
# Use SQLite's VACUUM INTO for a safe, non-blocking backup
# We need a clean string path without potential SQL injection (filenames are generated here)
db.execute(text(f"VACUUM INTO '{target_path}'"))
logger.info(f"[DB] Backup created successfully: {filename}")
# Audit Log
audit = models.AuditLog(
user_id=user_id,
action="DB_BACKUP",
details=f"Backup type: {label}. Created file: {filename}"
)
db.add(audit)
db.commit()
# Enforce retention policy
DbManager.enforce_retention(db)
return filename
except Exception as e:
logger.error(f"[DB] Backup failed: {str(e)}")
db.rollback()
raise Exception(f"Backup failed: {str(e)}")
@staticmethod
def enforce_retention(db: Session):
"""Deletes oldest backups if count exceeds retention limit."""
try:
# Get retention limit from settings
limit_setting = db.query(models.SystemSetting).filter(models.SystemSetting.key == "backup_retention_count").first()
limit = int(limit_setting.value) if limit_setting else 10 # Default to 10
backups = DbManager.get_backup_list()
if len(backups) > limit:
to_delete = backups[limit:]
for b in to_delete:
path = os.path.join(BACKUP_DIR, b.filename)
if os.path.exists(path):
os.remove(path)
logger.info(f"[DB] Retention policy: deleted old backup {b.filename}")
except Exception as e:
logger.error(f"[DB] Retention enforcement failed: {str(e)}")
@staticmethod
def restore_backup(filename: str, db: Session, user_id: int) -> bool:
"""
Restores a database from a backup file.
IMPORTANT: This replaces the primary inventory.db file.
"""
source_path = os.path.join(BACKUP_DIR, filename)
active_db_path = os.path.join(DATA_DIR, "inventory.db")
if not os.path.exists(source_path):
raise Exception("Backup file not found")
try:
# 1. Create a safety rollback backup of current state
logger.info("[DB] Creating safety rollback backup before restore...")
DbManager.create_backup(db, label="rollback", user_id=user_id)
# 2. Close all connections (or as many as possible via pool dispose)
# engine.dispose() drops the current connection pool
engine.dispose()
# 3. Physically swap files
# Note: shutil.copy2 handles file metadata preservation
shutil.copy2(source_path, active_db_path)
logger.warning(f"[DB] RESTORE COMPLETED from {filename} by user_id={user_id}")
# Record the restore in the NEW database (since we just swapped it)
# Re-initializing session logic might be needed but usually next request handles it.
# However, for the current request, the 'db' session is still bound to the OLD file mapping possibly?
# Actually, the file on disk is changed. Next commit might fail or work depending on how sqlite handles it.
# It's safest to return success and let the frontend trigger a reload.
return True
except Exception as e:
logger.error(f"[DB] Restore failed: {str(e)}")
raise Exception(f"Restore failed: {str(e)}")

View File

@@ -5,8 +5,9 @@ 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 from .routers import items, operations, users, categories, admin_db
from .logger import log from .logger import log
from .scheduler import scheduler, sync_scheduler_config
# Create the database tables # Create the database tables
from .database import DATA_DIR, db_path from .database import DATA_DIR, db_path
@@ -33,7 +34,7 @@ app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=ALLOWED_ORIGINS, allow_origins=ALLOWED_ORIGINS,
allow_credentials=True, allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"], allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
allow_headers=["*"], allow_headers=["*"],
) )
@@ -45,6 +46,13 @@ 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.on_event("startup")
def startup_event():
log.info("[STARTUP] Starting background scheduler...")
scheduler.start()
sync_scheduler_config()
@app.get("/") @app.get("/")
def read_root(): def read_root():

View File

@@ -48,7 +48,7 @@ class AuditLog(Base):
__tablename__ = "audit_logs" __tablename__ = "audit_logs"
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
timestamp = Column(DateTime, default=datetime.datetime.utcnow) timestamp = Column(DateTime, default=datetime.datetime.now)
user_id = Column(Integer, ForeignKey("users.id")) user_id = Column(Integer, ForeignKey("users.id"))
action = Column(String) # e.g., 'CHECK_IN', 'CHECK_OUT', 'CREATE_ITEM' action = Column(String) # e.g., 'CHECK_IN', 'CHECK_OUT', 'CREATE_ITEM'
target_item_id = Column(Integer, nullable=True) target_item_id = Column(Integer, nullable=True)
@@ -64,7 +64,7 @@ class Intervention(Base):
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
name = Column(String) name = Column(String)
status = Column(String, default="ACTIVE") status = Column(String, default="ACTIVE")
created_at = Column(DateTime, default=datetime.datetime.utcnow) created_at = Column(DateTime, default=datetime.datetime.now)
items = relationship("InterventionItem", back_populates="intervention") items = relationship("InterventionItem", back_populates="intervention")
@@ -78,3 +78,9 @@ class InterventionItem(Base):
checked_out_quantity = Column(Float, default=0.0) checked_out_quantity = Column(Float, default=0.0)
intervention = relationship("Intervention", back_populates="items") intervention = relationship("Intervention", back_populates="items")
class SystemSetting(Base):
__tablename__ = "system_settings"
key = Column(String, primary_key=True, index=True)
value = Column(String)

View File

@@ -12,3 +12,4 @@ ldap3>=2.9.1
passlib[bcrypt]>=1.7.4 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

105
backend/routers/admin_db.py Normal file
View File

@@ -0,0 +1,105 @@
from fastapi import APIRouter, Depends, HTTPException, status
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
router = APIRouter(
prefix="/admin/db",
tags=["Admin Database"]
)
@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))
@router.get("/settings", response_model=schemas.DbSettingsUpdate)
def get_db_settings(
db: Session = Depends(get_db),
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()
return {
"retention_count": int(retention.value) if retention else 10,
"schedule_hour": int(hour.value) if hour else 3,
"schedule_freq_days": int(freq.value) if freq else 1
}
@router.patch("/settings", response_model=schemas.DbSettingsUpdate)
def update_db_settings(
settings: schemas.DbSettingsUpdate,
db: Session = Depends(get_db),
current_admin: auth.TokenData = Depends(auth.get_current_admin)
):
"""Update database settings and re-trigger scheduler sync."""
pairs = {
"backup_retention_count": str(settings.retention_count),
"backup_schedule_hour": str(settings.schedule_hour),
"backup_schedule_freq_days": str(settings.schedule_freq_days)
}
for key, val in pairs.items():
existing = db.query(models.SystemSetting).filter(models.SystemSetting.key == key).first()
if existing:
existing.value = val
else:
db.add(models.SystemSetting(key=key, value=val))
db.commit()
# Re-trigger scheduler sync
sync_scheduler_config()
return settings

View File

@@ -143,11 +143,20 @@ def delete_item(
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: auth.TokenData = Depends(auth.get_current_user) current_user: auth.TokenData = Depends(auth.get_current_user)
): ):
"""[C-01] Delete item — only for authenticated users.""" """[C-01] Delete item — only for authenticated users. InterventionItems are cleared; AuditLogs are KEPT for history."""
db_item = db.query(models.Item).filter(models.Item.id == item_id).first() db_item = db.query(models.Item).filter(models.Item.id == item_id).first()
if not db_item: if not db_item:
raise HTTPException(status_code=404, detail="Item not found") raise HTTPException(status_code=404, detail="Item not found")
# [AUDIT] Log the deletion to disk file via logger.py
from ..logger import log
log.warning(f"USER[{current_user.sub}] DELETING ITEM: ID={item_id}, Name={db_item.name}, PN={db_item.part_number}")
# [CLEANUP] Delete related InterventionItems to prevent foreign key issues
db.query(models.InterventionItem).filter(models.InterventionItem.item_id == item_id).delete()
# Audit Logs in database are NOT deleted here to preserve history of actions
db.delete(db_item) db.delete(db_item)
db.commit() db.commit()
return {"message": "Item deleted successfully"} return {"message": "Item deleted successfully. History logs preserved."}

46
backend/scheduler.py Normal file
View File

@@ -0,0 +1,46 @@
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.cron import CronTrigger
from .database import SessionLocal
from .db_manager import DbManager
from . import models
import logging
log = logging.getLogger("ainventory")
scheduler = BackgroundScheduler()
def scheduled_backup_job():
"""System triggered automated backup."""
db = SessionLocal()
try:
log.info("[SCHEDULER] Starting automated backup job...")
DbManager.create_backup(db, label="auto", user_id=None)
except Exception as e:
log.error(f"[SCHEDULER] Automated backup failed: {str(e)}")
finally:
db.close()
def sync_scheduler_config():
"""Read DB settings and update the scheduler job."""
db = SessionLocal()
try:
hour_s = db.query(models.SystemSetting).filter(models.SystemSetting.key == "backup_schedule_hour").first()
freq_s = db.query(models.SystemSetting).filter(models.SystemSetting.key == "backup_schedule_freq_days").first()
hour = int(hour_s.value) if hour_s else 3
freq = int(freq_s.value) if freq_s else 1
# Remove existing backup jobs to avoid duplicates
for job in scheduler.get_jobs():
if job.id == "auto_backup":
scheduler.remove_job(job.id)
# Add job with cron trigger: trigger at 'hour' every 'freq' days
# Use day='*/freq' for intervals in days
trigger = CronTrigger(hour=hour, minute=0, day=f"*/{freq}")
scheduler.add_job(scheduled_backup_job, trigger, id="auto_backup")
log.info(f"[SCHEDULER] Policy synced: Every {freq} days at {hour:02d}:00")
except Exception as e:
log.error(f"[SCHEDULER] Failed to sync config: {str(e)}")
finally:
db.close()

View File

@@ -116,3 +116,27 @@ class AuditLogResponse(BaseModel):
class Config: class Config:
from_attributes = True from_attributes = True
# --- System Settings ---
class SystemSettingBase(BaseModel):
key: str
value: str
class SystemSetting(SystemSettingBase):
class Config:
from_attributes = True
# --- Database Management ---
class BackupInfo(BaseModel):
filename: str
size_bytes: int
created_at: datetime
class DatabaseStats(BaseModel):
backup_count: int
total_size_bytes: int
class DbSettingsUpdate(BaseModel):
retention_count: int
schedule_hour: int
schedule_freq_days: int

View File

@@ -26,6 +26,12 @@ Runtime data (`data/`, `logs/`) is now properly excluded from Git with init-on-f
- **`.gitignore`** — Changed `data/``data/*` with `!/data/.gitkeep` exception; same for `logs/` - **`.gitignore`** — Changed `data/``data/*` with `!/data/.gitkeep` exception; same for `logs/`
- **Git index cleaned** — `data/inventory.db` and `data/ldap_config.json` removed from tracking via `git rm --cached` - **Git index cleaned** — `data/inventory.db` and `data/ldap_config.json` removed from tracking via `git rm --cached`
### Item Deletion Improvement
- **`frontend/app/page.tsx` & `inventory/page.tsx`** — Moved the "Delete" button (Trash icon) to the modal header.
- The button is now always visible in the item details view, not just during edit mode.
- Implementation uses `handleDeleteItem` which enforces user confirmation via `window.confirm` before removing the item from the DB.
- **Layout**: Moved all controls OUT of the camera viewport overlay. Camera feed is now 100% unobstructed. - **Layout**: Moved all controls OUT of the camera viewport overlay. Camera feed is now 100% unobstructed.
- **Automated OCR**: Removed manual "OCR SCAN" button. OCR now runs automatically on a 4-second cycle. - **Automated OCR**: Removed manual "OCR SCAN" button. OCR now runs automatically on a 4-second cycle.

View File

@@ -22,7 +22,11 @@ import {
Wifi, Wifi,
WifiOff, WifiOff,
Layers, Layers,
ChevronDown ChevronDown,
Clock,
HardDrive,
Download,
RotateCcw
} from 'lucide-react'; } from 'lucide-react';
import { toast } from 'react-hot-toast'; import { toast } from 'react-hot-toast';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@@ -52,6 +56,12 @@ export default function AdminPage() {
const [editingCategory, setEditingCategory] = useState<any | null>(null); const [editingCategory, setEditingCategory] = useState<any | null>(null);
const [editCatForm, setEditCatForm] = useState({ name: '', description: '' }); const [editCatForm, setEditCatForm] = useState({ name: '', description: '' });
// DB Management State
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);
useEffect(() => { useEffect(() => {
loadData(); loadData();
}, []); }, []);
@@ -59,14 +69,20 @@ export default function AdminPage() {
const loadData = async () => { const loadData = async () => {
setLoading(true); setLoading(true);
try { try {
const [u, c, l] = await Promise.all([ const [u, c, l, b, s, st] = await Promise.all([
inventoryApi.getUsers(), inventoryApi.getUsers(),
inventoryApi.getCategories(), inventoryApi.getCategories(),
inventoryApi.getLdapConfig() inventoryApi.getLdapConfig(),
inventoryApi.getDbBackups(),
inventoryApi.getDbStats(),
inventoryApi.getDbSettings()
]); ]);
setUsers(u); setUsers(u);
setCategories(c); setCategories(c);
if (l && l.server_uri) setLdapConfig(l); if (l && l.server_uri) setLdapConfig(l);
setBackups(b);
setDbStats(s);
setDbSettings(st);
} catch (err) { } catch (err) {
console.error(err); console.error(err);
toast.error("Failed to load admin data"); toast.error("Failed to load admin data");
@@ -161,44 +177,119 @@ export default function AdminPage() {
window.location.href = '/login'; window.location.href = '/login';
}; };
const handleCreateBackup = async () => {
setIsBackingUp(true);
try {
await inventoryApi.triggerBackup();
toast.success("Snapshot created successfully");
loadData();
} catch (err) {
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) {
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) {
toast.error("Failed to update settings");
}
};
const handleUpdateLdap = async () => {
setLoading(true);
try {
await inventoryApi.updateLdapConfig(ldapConfig);
toast.success("Enterprise configuration updated");
} catch (err) {
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];
};
return ( return (
<PageShell requireAdmin={true}> <PageShell requireAdmin={true}>
<main className="p-4 md:p-8 max-w-6xl mx-auto space-y-16"> <main className="p-4 md:p-8 max-w-7xl mx-auto space-y-16">
<header className="flex items-center gap-6"> <header className="flex items-center gap-5">
<div className="p-4 bg-primary/10 rounded-3xl text-primary border border-primary/20 shadow-xl shadow-primary/5"> <div className="p-4 bg-primary/10 rounded-[2rem] text-primary border border-primary/20 shadow-xl shadow-primary/5">
<Shield size={40} /> <Shield size={32} />
</div> </div>
<div> <div>
<h1 className="text-4xl font-black tracking-tight text-white">System Admin</h1> <h1 className="text-3xl font-black tracking-tight text-white">System Admin</h1>
<p className="text-xs text-slate-500 font-bold mt-1">Enterprise Control Center</p> <p className="text-xs text-slate-500 font-bold mt-1">Enterprise Control Center</p>
</div> </div>
</header> </header>
{/* User Management Section */} {/* User Management Section */}
<section className="space-y-6"> <section className="bg-slate-900/40 border border-slate-800/40 p-6 md:p-8 rounded-[2.5rem] md:rounded-[3rem] shadow-2xl space-y-8">
<div className="flex items-center justify-between px-2"> <div className="flex items-center justify-between px-2">
<div className="flex items-center gap-3"> <div className="flex items-center gap-4">
<User size={20} className="text-primary" /> <div className="p-3 bg-primary/10 rounded-2xl text-primary border border-primary/20">
<h2 className="text-lg font-black text-white">User Accounts</h2> <User size={24} />
</div>
<div>
<h2 className="text-xl font-black text-white">User Accounts</h2>
<p className="text-xs text-slate-500 font-bold mt-1">Manage local and network synchronized identities</p>
</div>
</div> </div>
<button <button
onClick={handleAddUser} onClick={handleAddUser}
className="flex items-center justify-center gap-2 bg-primary/10 hover:bg-primary text-primary hover:text-white font-black text-xs px-4 py-2 rounded-xl transition-all border border-primary/20 active:scale-95" className="flex items-center justify-center gap-2 bg-primary/10 hover:bg-primary text-primary hover:text-white font-black text-xs px-5 py-2.5 rounded-xl transition-all border border-primary/20 active:scale-95"
> >
<UserPlus size={14} /> Add Local User <UserPlus size={14} /> Add Local User
</button> </button>
</div> </div>
<div className="space-y-12"> <div className="space-y-10">
{/* Local Users Group */} {/* Local Users Group */}
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between px-4"> <div className="flex items-center gap-2 px-1">
<div className="flex items-center gap-2">
<div className="w-1.5 h-1.5 rounded-full bg-primary" /> <div className="w-1.5 h-1.5 rounded-full bg-primary" />
<h3 className="text-sm font-black text-white">Local Users</h3> <h3 className="text-sm font-black text-white">Local Users</h3>
</div> </div>
</div> <div className="bg-slate-950/40 border border-slate-800/50 rounded-2xl overflow-hidden shadow-xl divide-y divide-slate-800/50">
<div className="bg-slate-900/40 border border-slate-800/50 rounded-3xl overflow-hidden shadow-xl divide-y divide-slate-800/50">
{loading ? ( {loading ? (
<div className="p-8 text-center animate-pulse text-slate-600 text-xs font-black">Loading...</div> <div className="p-8 text-center animate-pulse text-slate-600 text-xs font-black">Loading...</div>
) : ( ) : (
@@ -207,33 +298,29 @@ export default function AdminPage() {
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className={cn( <div className={cn(
"p-2.5 rounded-xl", "p-2.5 rounded-xl",
u.role === 'admin' ? "bg-primary/10 text-primary border border-primary/20" : "bg-slate-800 text-slate-500 border border-slate-700" u.role === 'admin' ? "bg-primary/10 text-primary border border-primary/20" : "bg-slate-800 text-slate-500 border border-slate-800"
)}> )}>
{u.role === 'admin' ? <Shield size={16} /> : <User size={16} />} {u.role === 'admin' ? <Shield size={16} /> : <User size={16} />}
</div> </div>
<div> <div>
<div className="flex items-center gap-2">
<p className="text-sm font-bold text-white group-hover:text-primary transition-colors">{u.username}</p> <p className="text-sm font-bold text-white group-hover:text-primary transition-colors">{u.username}</p>
<span className="text-[8px] font-black text-slate-500 opacity-60 bg-slate-800/50 px-1.5 py-0.5 rounded-md border border-slate-700/50 font-mono tracking-tighter">{u.role}</span> <span className="text-[8px] font-black text-slate-500 opacity-60 font-mono tracking-tighter">{u.role}</span>
</div>
</div> </div>
</div> </div>
<div className="flex items-center gap-1 transition-opacity pr-2"> <div className="flex items-center gap-1 pr-2">
<button <button
onClick={() => { onClick={() => {
setEditingUser(u); setEditingUser(u);
setEditUserForm({ username: u.username, password: '', role: u.role }); setEditUserForm({ username: u.username, password: '', role: u.role });
}} }}
title="Edit User" className="p-2.5 bg-slate-800/50 hover:bg-slate-700 text-slate-400 hover:text-white rounded-lg transition-all border border-slate-800"
className="p-2.5 bg-slate-800/50 hover:bg-slate-700 text-slate-400 hover:text-white rounded-lg transition-all border border-slate-700/50"
> >
<Edit2 size={14} /> <Edit2 size={14} />
</button> </button>
{u.username !== 'Admin' && ( {u.username !== 'Admin' && (
<button <button
onClick={() => handleDeleteUser(u.id, u.username)} onClick={() => handleDeleteUser(u.id, u.username)}
title="Delete User"
className="p-2.5 bg-rose-500/5 hover:bg-rose-500 text-rose-500 hover:text-white rounded-lg transition-all border border-rose-500/10" className="p-2.5 bg-rose-500/5 hover:bg-rose-500 text-rose-500 hover:text-white rounded-lg transition-all border border-rose-500/10"
> >
<Trash2 size={14} /> <Trash2 size={14} />
@@ -249,13 +336,11 @@ export default function AdminPage() {
{/* enterprise Users Group */} {/* enterprise Users Group */}
{users.some(u => u.origin === 'ldap') && ( {users.some(u => u.origin === 'ldap') && (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between px-4"> <div className="flex items-center gap-2 px-1">
<div className="flex items-center gap-2">
<div className="w-1.5 h-1.5 rounded-full bg-indigo-500" /> <div className="w-1.5 h-1.5 rounded-full bg-indigo-500" />
<h3 className="text-sm font-black text-white">Enterprise Users (LDAP)</h3> <h3 className="text-sm font-black text-white">Enterprise Users (LDAP)</h3>
</div> </div>
</div> <div className="bg-indigo-500/5 border border-indigo-500/10 rounded-2xl overflow-hidden shadow-xl divide-y divide-indigo-500/10">
<div className="bg-indigo-500/5 border border-indigo-500/10 rounded-3xl overflow-hidden shadow-xl divide-y divide-indigo-500/10">
{users.filter(u => u.origin === 'ldap').map(u => ( {users.filter(u => u.origin === 'ldap').map(u => (
<div key={u.id} className="flex items-center justify-between p-4 hover:bg-indigo-500/10 transition-all group"> <div key={u.id} className="flex items-center justify-between p-4 hover:bg-indigo-500/10 transition-all group">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
@@ -266,15 +351,15 @@ export default function AdminPage() {
<Shield size={16} /> <Shield size={16} />
</div> </div>
<div> <div>
<div className="flex items-center gap-3">
<p className="text-sm font-bold text-white">{u.username}</p> <p className="text-sm font-bold text-white">{u.username}</p>
<span className="text-[8px] bg-indigo-500/10 text-indigo-400 border border-indigo-500/20 px-1.5 py-0.5 rounded-md font-black">Network Sync</span> <div className="flex items-center gap-2">
<span className="text-[8px] font-black text-slate-500 opacity-60 bg-slate-800/50 px-1.5 py-0.5 rounded-md border border-slate-700/50 font-mono tracking-tighter">{u.role}</span> <span className="text-[8px] bg-indigo-500/10 text-indigo-400 border border-indigo-500/20 px-1.5 py-0.5 rounded-md font-black">Ldap Profile</span>
<span className="text-[8px] font-black text-slate-500 opacity-60 font-mono tracking-tighter">{u.role}</span>
</div> </div>
</div> </div>
</div> </div>
<div className="text-xs font-black text-indigo-400/50 pr-4 truncate max-w-[150px] italic"> <div className="text-[10px] font-black text-indigo-400/50 pr-4 italic">
Read Only Profile Externally Managed
</div> </div>
</div> </div>
))} ))}
@@ -284,22 +369,164 @@ export default function AdminPage() {
</div> </div>
</section> </section>
{/* Category Management Section */} {/* Enterprise Integration (LDAP) Section */}
<section className="space-y-6"> <section className="bg-slate-900/40 border border-slate-800/40 p-6 md:p-8 rounded-[2.5rem] md:rounded-[3rem] shadow-2xl space-y-8">
<div className="flex items-center justify-between px-2"> <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-6 px-2">
<div className="flex items-center gap-4">
<div className="p-3 bg-indigo-500/10 rounded-2xl text-indigo-500 border border-indigo-500/20">
<Globe size={24} />
</div>
<div>
<h2 className="text-xl font-black text-white">Enterprise Integration</h2>
<p className="text-xs text-slate-500 font-bold mt-1">Configure directory services and single sign-on synchronization</p>
</div>
</div>
<div className="flex items-center gap-3 bg-slate-950/50 p-2 rounded-2xl border border-slate-800">
<span className={cn("text-[10px] font-black px-3 transition-colors", ldapConfig.ldap_enabled ? "text-slate-600" : "text-rose-500")}>Offline Only</span>
<button
onClick={() => setLdapConfig({ ...ldapConfig, ldap_enabled: !ldapConfig.ldap_enabled })}
className={cn(
"relative inline-flex h-7 w-12 items-center rounded-full transition-all duration-300 outline-none",
ldapConfig.ldap_enabled ? "bg-indigo-600" : "bg-slate-800"
)}
>
<span className={cn(
"inline-block h-5 w-5 transform rounded-full bg-white transition-transform duration-300",
ldapConfig.ldap_enabled ? "translate-x-6" : "translate-x-1"
)} />
</button>
<span className={cn("text-[10px] font-black px-3 transition-colors", ldapConfig.ldap_enabled ? "text-indigo-400" : "text-slate-600")}>LDAP Active</span>
</div>
</div>
<div className="grid lg:grid-cols-2 gap-8">
<div className="space-y-6">
<div className="grid sm:grid-cols-2 gap-4">
<div className="space-y-1.5">
<label className="text-[10px] font-black text-slate-500 px-1">Server URI</label>
<div className="relative flex items-center">
<Server className="absolute left-4 text-slate-600" size={14} />
<input
type="text"
placeholder="ldap://192.168.1.10:389"
value={ldapConfig.server_uri}
onChange={(e) => setLdapConfig({ ...ldapConfig, server_uri: e.target.value })}
className="w-full bg-slate-950 border border-slate-800 rounded-2xl py-3 pl-10 pr-4 text-sm text-white outline-none focus:border-indigo-500/30 transition-all font-mono"
/>
</div>
</div>
<div className="space-y-1.5">
<label className="text-[10px] font-black text-slate-500 px-1">Base DN</label>
<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 border border-slate-800 rounded-2xl py-3 px-4 text-sm text-white outline-none focus:border-indigo-500/30 transition-all font-mono"
/>
</div>
</div>
<div className="space-y-1.5">
<label className="text-[10px] font-black text-slate-500 px-1">User Template (LDAP query path)</label>
<input
type="text"
placeholder="cn={username},ou=people,dc=example,dc=com"
value={ldapConfig.user_template}
onChange={(e) => setLdapConfig({ ...ldapConfig, user_template: e.target.value })}
className="w-full bg-slate-950 border border-slate-800 rounded-2xl py-3 px-4 text-sm text-white outline-none focus:border-indigo-500/30 transition-all font-mono"
/>
</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 px-1">Groups DN</label>
<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 border border-slate-800 rounded-2xl py-3 px-4 text-sm text-white outline-none focus:border-indigo-500/30 transition-all font-mono"
/>
</div>
<div className="space-y-1.5">
<label className="text-[10px] font-black text-slate-500 px-1">Required Security Group</label>
<input
type="text"
placeholder="inventory_admins"
value={ldapConfig.required_group}
onChange={(e) => setLdapConfig({ ...ldapConfig, required_group: e.target.value })}
className="w-full bg-slate-950 border border-slate-800 rounded-2xl py-3 px-4 text-sm text-white outline-none focus:border-indigo-500/30 transition-all font-mono"
/>
</div>
</div>
</div>
<div className="space-y-6 flex flex-col justify-between">
<div className="bg-indigo-500/5 border border-indigo-500/10 rounded-3xl p-6 space-y-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Layers size={20} className="text-primary" /> <Wifi className={cn("transition-colors", ldapConfig.ldap_enabled ? "text-indigo-400" : "text-slate-700")} size={20} />
<h2 className="text-lg font-black text-white">Category Groups</h2> <h3 className="text-sm font-black text-slate-300">Synchronization Shield</h3>
</div>
<p className="text-xs text-slate-500 leading-relaxed font-bold">
Enabling LDAP will allow users from your network domain to log in using their enterprise credentials.
Local account passwords will still function as a fail-safe backup.
</p>
<div className="flex items-center gap-4 text-[10px] font-black">
<div className="flex items-center gap-1.5">
<div className={cn("w-1.5 h-1.5 rounded-full", ldapConfig.use_tls ? "bg-green-500" : "bg-slate-800")} />
<span className="text-slate-400">LDAPS / TLS</span>
</div>
<button
onClick={() => setLdapConfig({ ...ldapConfig, use_tls: !ldapConfig.use_tls })}
className="text-indigo-500 hover:text-indigo-400 underline decoration-indigo-500/30 font-bold"
>
Toggle Security Layer
</button>
</div>
</div>
<div className="flex gap-3 pt-2">
<button
onClick={handleTestLdap}
disabled={testingLdap}
className="flex-1 bg-slate-950 border border-slate-800 hover:border-indigo-500/50 text-slate-400 hover:text-white font-black text-xs py-4 rounded-2xl transition-all active:scale-95 disabled:opacity-50 flex items-center justify-center gap-2"
>
{testingLdap ? <div className="w-3 h-3 border-2 border-indigo-500 border-t-transparent animate-spin rounded-full" /> : <WifiOff size={14} />}
Test Connection
</button>
<button
onClick={handleUpdateLdap}
className="flex-[1.5] bg-indigo-600 hover:bg-indigo-500 text-white font-black text-xs py-4 rounded-2xl shadow-xl shadow-indigo-500/20 transition-all active:scale-95"
>
Save Enterprise Settings
</button>
</div>
</div>
</div>
</section>
{/* Category Management Section */}
<section className="bg-slate-900/40 border border-slate-800/40 p-6 md:p-8 rounded-[2.5rem] md:rounded-[3rem] shadow-2xl space-y-8">
<div className="flex items-center justify-between px-2">
<div className="flex items-center gap-4">
<div className="p-3 bg-primary/10 rounded-2xl text-primary border border-primary/20">
<Layers size={24} />
</div>
<div>
<h2 className="text-xl font-black text-white">Category Groups</h2>
<p className="text-xs text-slate-500 font-bold mt-1">Classify inventory items into logical clusters</p>
</div>
</div> </div>
<button <button
onClick={handleAddCategory} onClick={handleAddCategory}
className="flex items-center justify-center gap-2 bg-primary/10 hover:bg-primary text-primary hover:text-white font-black text-xs px-4 py-2 rounded-xl transition-all border border-primary/20 active:scale-95" className="flex items-center justify-center gap-2 bg-primary/10 hover:bg-primary text-primary hover:text-white font-black text-xs px-5 py-2.5 rounded-xl transition-all border border-primary/20 active:scale-95"
> >
<Plus size={14} /> Add New Group <Plus size={14} /> Add New Group
</button> </button>
</div> </div>
<div className="bg-slate-900/40 border border-slate-800/50 rounded-3xl overflow-hidden shadow-xl divide-y divide-slate-800/50"> <div className="bg-slate-950/40 border border-slate-800/50 rounded-2xl overflow-hidden shadow-xl divide-y divide-slate-800/50">
{loading ? ( {loading ? (
<div className="p-8 text-center animate-pulse text-slate-600 text-xs font-black">Loading Categories...</div> <div className="p-8 text-center animate-pulse text-slate-600 text-xs font-black">Loading Categories...</div>
) : ( ) : (
@@ -311,26 +538,24 @@ export default function AdminPage() {
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="text-sm font-bold text-white group-hover:text-primary transition-colors truncate">{cat.name}</p> <p className="text-sm font-bold text-white group-hover:text-primary transition-colors truncate">{cat.name}</p>
<p className="text-xs text-slate-500 font-medium truncate opacity-60"> <p className="text-[10px] text-slate-500 font-medium truncate opacity-60">
{cat.description || 'General Purpose Group'} {cat.description || 'General Purpose Group'}
</p> </p>
</div> </div>
</div> </div>
<div className="flex items-center gap-1 transition-opacity ml-4 pr-2"> <div className="flex items-center gap-1 ml-4 pr-2">
<button <button
onClick={() => { onClick={() => {
setEditingCategory(cat); setEditingCategory(cat);
setEditCatForm({ name: cat.name, description: cat.description || '' }); setEditCatForm({ name: cat.name, description: cat.description || '' });
}} }}
title="Edit Category" className="p-2.5 bg-slate-800/50 hover:bg-slate-700 text-slate-400 hover:text-white rounded-lg transition-all border border-slate-800"
className="p-2.5 bg-slate-800/50 hover:bg-slate-700 text-slate-400 hover:text-white rounded-lg transition-all border border-slate-700/50"
> >
<Edit2 size={14} /> <Edit2 size={14} />
</button> </button>
<button <button
onClick={() => handleDeleteCategory(cat.id, cat.name)} onClick={() => handleDeleteCategory(cat.id, cat.name)}
title="Delete Category"
className="p-2.5 bg-rose-500/5 hover:bg-rose-500 text-rose-500 hover:text-white rounded-lg transition-all border border-rose-500/10" className="p-2.5 bg-rose-500/5 hover:bg-rose-500 text-rose-500 hover:text-white rounded-lg transition-all border border-rose-500/10"
> >
<Trash2 size={14} /> <Trash2 size={14} />
@@ -340,235 +565,172 @@ export default function AdminPage() {
)) ))
)} )}
{categories.length === 0 && !loading && ( {categories.length === 0 && !loading && (
<div className="p-8 text-center text-slate-600 text-xs font-black italic"> <div className="p-8 text-center text-slate-600 text-[10px] font-black italic">
No categories defined No categories defined
</div> </div>
)} )}
</div> </div>
</section> </section>
{/* System Settings Section */} {/* System Integrity (Compacted and left-aligned) */}
<div className="pt-8 border-t border-slate-900"> <section className="bg-slate-900/30 border border-slate-800/40 p-5 px-8 rounded-3xl flex items-center justify-between gap-6 shadow-sm">
<section className="p-8 bg-slate-900/50 rounded-[3rem] border border-slate-800 flex flex-col items-center text-center gap-6 shadow-inner"> <div className="flex items-center gap-5 min-w-0 flex-1">
<div className="w-16 h-16 bg-primary/10 rounded-3xl flex items-center justify-center text-primary border border-primary/20 shadow-lg shadow-primary/5"> <div className="w-10 h-10 bg-primary/10 rounded-xl flex items-center justify-center text-primary border border-primary/20 shrink-0">
<Database size={32} /> <Database size={20} />
</div> </div>
<div> <div className="min-w-0 flex-1">
<h3 className="text-xl font-black text-white tracking-tight">System Integrity</h3> <h3 className="text-sm font-black text-white">System Integrity</h3>
<p className="text-xs text-slate-500 mt-3 max-w-[280px] mx-auto leading-relaxed"> <p className="text-[10px] text-slate-500 mt-0.5 max-w-xl truncate">
Hybrid storage model active. Real-time synchronization between local memory and cloud-hosted SQLite. Hybrid storage model active. Real-time synchronization between local memory and cloud-hosted SQLite instance.
</p> </p>
</div> </div>
<div className="flex items-center gap-3 bg-slate-950 px-6 py-2.5 rounded-full border border-slate-800 shadow-2xl"> </div>
<div className="w-2 h-2 rounded-full bg-green-500 animate-pulse shadow-[0_0_8px_rgba(34,197,94,0.5)]" /> <div className="flex items-center gap-6 divide-x divide-slate-800/50">
<span className="text-xs font-black text-green-500">Storage Online</span> <div className="flex flex-col items-end">
<span className="text-[8px] font-black text-slate-500 uppercase tracking-widest">Storage Status</span>
<div className="flex items-center gap-2 mt-1">
<div className="w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse shadow-[0_0_8px_rgba(34,197,94,0.3)]" />
<span className="text-[10px] font-black text-green-500 tracking-tight">Online</span>
</div>
</div>
<div className="flex flex-col items-end pl-6">
<span className="text-[8px] font-black text-slate-500 uppercase tracking-widest">Local Archives</span>
<div className="flex items-center gap-3 mt-1">
<span className="text-[10px] font-black text-white">{dbStats.backup_count} Files</span>
<span className="text-[10px] font-black text-primary/60">{formatSize(dbStats.total_size_bytes)}</span>
</div>
</div>
</div> </div>
</section> </section>
</div>
{/* Enterprise LDAP Integration */} {/* Database & Continuity Card */}
<section className="space-y-8 bg-slate-900/30 border border-slate-800/40 p-8 rounded-[3rem] shadow-2xl relative overflow-hidden group"> <section className="bg-slate-900/40 border border-slate-800/40 p-6 md:p-8 rounded-[2.5rem] md:rounded-[3rem] shadow-2xl space-y-8">
<div className="absolute top-0 right-0 p-8 opacity-5 group-hover:opacity-10 transition-opacity"> <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-6 px-2">
<Globe size={120} />
</div>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-6 px-2 relative z-10">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="p-4 bg-indigo-500/10 rounded-2xl text-indigo-400 border border-indigo-500/20"> <div className="p-3 bg-amber-500/10 rounded-2xl text-amber-500 border border-amber-500/20">
<Server size={24} /> <HardDrive size={24} />
</div> </div>
<div> <div>
<h2 className="text-xl font-black text-white">Enterprise Integration</h2> <h2 className="text-xl font-black text-white">Database & Continuity</h2>
<p className="text-xs text-slate-500 font-bold mt-1">LLDAP / Active Directory Connectivity</p> <p className="text-xs text-slate-500 font-bold mt-1">Snapshot management and disaster recovery tools</p>
</div> </div>
</div> </div>
<div className="flex items-center gap-3 bg-slate-950 p-2 rounded-2xl border border-slate-800">
<span className="text-xs font-black text-slate-400 px-3">LDAP Status</span>
<button <button
onClick={() => { onClick={handleCreateBackup}
const newConfig = { ...ldapConfig, ldap_enabled: !ldapConfig.ldap_enabled }; disabled={isBackingUp}
setLdapConfig(newConfig); className="group flex items-center justify-center gap-2 bg-amber-500/10 hover:bg-amber-500 text-amber-500 hover:text-white font-black text-xs px-6 py-3 rounded-xl transition-all border border-amber-500/20 active:scale-95 disabled:opacity-50"
inventoryApi.updateLdapConfig(newConfig);
toast.success(`LDAP ${newConfig.ldap_enabled ? 'Enabled' : 'Disabled'}`);
}}
className={cn(
"px-6 py-2 rounded-xl text-xs font-black transition-all",
ldapConfig.ldap_enabled ? "bg-green-500/10 text-green-500 border border-green-500/20" : "bg-slate-800 text-slate-500"
)}
> >
{ldapConfig.ldap_enabled ? 'Active' : 'Offline'} {isBackingUp ? (
<div className="w-3 h-3 border-2 border-current border-t-transparent animate-spin rounded-full" />
) : (
<Download size={14} className="group-hover:-translate-y-0.5 transition-transform" />
)}
Create Manual Backup
</button> </button>
</div> </div>
<div className="grid lg:grid-cols-3 gap-6">
{/* Scheduling Configuration */}
<div className="lg:col-span-1 space-y-6">
<div className="p-6 bg-slate-950/40 border border-slate-800/50 rounded-3xl space-y-6">
<div className="flex items-center gap-2 text-amber-500/80">
<Clock size={16} />
<span className="text-xs font-black">Retention & Automation</span>
</div> </div>
<div className="grid lg:grid-cols-2 gap-8 relative z-10">
<div className="space-y-6">
<div className="space-y-2">
<label className="text-xs font-black text-slate-500 px-1">Server URI</label>
<div className="relative group/input">
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none text-slate-600 group-focus-within/input:text-indigo-400 transition-colors">
<Wifi size={16} />
</div>
<input
type="text"
value={ldapConfig.server_uri || ''}
onChange={(e) => setLdapConfig({ ...ldapConfig, server_uri: e.target.value })}
placeholder="ldap://192.168.84.107:3890"
className="w-full bg-slate-950 border border-slate-800 focus:border-indigo-500/50 rounded-2xl py-4 pl-12 pr-5 text-white outline-none transition-all font-mono text-sm"
/>
</div>
</div>
<div className="space-y-2">
<label className="text-xs font-black text-slate-500 px-1">Base DN (Suffix)</label>
<input
type="text"
value={ldapConfig.base_dn || ''}
onChange={(e) => setLdapConfig({ ...ldapConfig, base_dn: e.target.value })}
placeholder="dc=example,dc=com"
className="w-full bg-slate-950 border border-slate-800 focus:border-indigo-500/50 rounded-2xl py-4 px-5 text-white outline-none transition-all font-mono text-sm"
/>
</div>
<div className="space-y-2">
<label className="text-xs font-black text-slate-500 px-1">User DN Template</label>
<input
type="text"
value={ldapConfig.user_template || ''}
onChange={(e) => setLdapConfig({ ...ldapConfig, user_template: e.target.value })}
placeholder="uid={username},ou=people,dc=..."
className="w-full bg-slate-950 border border-slate-800 focus:border-indigo-500/50 rounded-2xl py-4 px-5 text-white outline-none transition-all font-mono text-sm"
/>
</div>
</div>
<div className="space-y-6">
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between px-1"> <div className="space-y-1.5">
<label className="text-xs font-black text-slate-500">Role Mappings</label> <label className="text-[10px] font-black text-slate-500 px-1">Retention Limit (Max Files)</label>
<button
onClick={() => {
const newMappings = [...(ldapConfig.role_mappings || []), { group: '', role: 'user' }];
setLdapConfig({ ...ldapConfig, role_mappings: newMappings });
}}
className="text-xs font-black text-indigo-400 hover:text-indigo-300 transition-colors"
>
+ Add Mapping
</button>
</div>
<div className="space-y-2 max-h-[180px] overflow-y-auto pr-2 custom-scrollbar">
{(ldapConfig.role_mappings || []).map((mapping: any, idx: number) => (
<div key={idx} className="flex gap-2 items-center bg-slate-950/50 p-3 rounded-2xl border border-slate-800/50">
<input <input
type="text" type="number"
value={mapping.group} value={dbSettings.retention_count}
onChange={(e) => { onChange={(e) => handleUpdateDbSettings({ ...dbSettings, retention_count: parseInt(e.target.value) || 1 })}
const newMappings = [...ldapConfig.role_mappings]; className="w-full bg-slate-900 border border-slate-800 rounded-xl py-2 px-4 text-sm text-white outline-none focus:border-amber-500/30 transition-all font-mono"
newMappings[idx].group = e.target.value;
setLdapConfig({ ...ldapConfig, role_mappings: newMappings });
}}
placeholder="Group CN (e.g. admins)"
className="flex-1 bg-transparent text-xs font-mono text-white outline-none"
/> />
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<label className="text-[10px] font-black text-slate-500 px-1">Backup Hour</label>
<select <select
value={mapping.role} value={dbSettings.schedule_hour}
onChange={(e) => { onChange={(e) => handleUpdateDbSettings({ ...dbSettings, schedule_hour: parseInt(e.target.value) })}
const newMappings = [...ldapConfig.role_mappings]; className="w-full bg-slate-900 border border-slate-800 rounded-xl py-2 px-3 text-sm text-white outline-none focus:border-amber-500/30 transition-all appearance-none"
newMappings[idx].role = e.target.value;
setLdapConfig({ ...ldapConfig, role_mappings: newMappings });
}}
className="bg-slate-900 text-xs font-bold text-slate-400 px-2 py-1 rounded-lg border border-slate-700 outline-none"
> >
<option value="user">User</option> {Array.from({ length: 24 }).map((_, i) => (
<option value="admin">Admin</option> <option key={i} value={i}>{String(i).padStart(2, '0')}:00</option>
</select>
<button
onClick={() => {
const newMappings = ldapConfig.role_mappings.filter((_: any, i: number) => i !== idx);
setLdapConfig({ ...ldapConfig, role_mappings: newMappings });
}}
className="text-slate-600 hover:text-red-400 p-1"
>
<X size={14} />
</button>
</div>
))} ))}
{(ldapConfig.role_mappings || []).length === 0 && ( </select>
<div className="text-center py-4 border border-dashed border-slate-800 rounded-2xl text-xs text-slate-600">
No roles mapped
</div> </div>
)} <div className="space-y-1.5">
</div> <label className="text-[10px] font-black text-slate-500 px-1">Frequency (Days)</label>
</div> <select
value={dbSettings.schedule_freq_days}
<div className="p-6 bg-slate-950 rounded-3xl border border-slate-800 space-y-6"> onChange={(e) => handleUpdateDbSettings({ ...dbSettings, schedule_freq_days: parseInt(e.target.value) })}
<div className="flex items-center justify-between"> className="w-full bg-slate-900 border border-slate-800 rounded-xl py-2 px-3 text-sm text-white outline-none focus:border-amber-500/30 transition-all appearance-none"
<div className="flex items-center gap-3">
<Lock size={16} className="text-slate-500" />
<span className="text-sm font-bold text-slate-300">Encrypted Connection (TLS)</span>
</div>
<button
onClick={() => setLdapConfig({ ...ldapConfig, use_tls: !ldapConfig.use_tls })}
className={cn(
"w-12 h-6 rounded-full relative transition-all",
ldapConfig.use_tls ? "bg-indigo-500" : "bg-slate-800"
)}
> >
<div className={cn( <option value={1}>Daily</option>
"absolute top-1 w-4 h-4 bg-white rounded-full transition-all", <option value={2}>Every 2 days</option>
ldapConfig.use_tls ? "right-1" : "left-1" <option value={3}>Every 3 days</option>
)} /> <option value={5}>Every 5 days</option>
</button> <option value={7}>Weekly</option>
<option value={14}>Bi-weekly</option>
<option value={30}>Monthly</option>
</select>
</div> </div>
<div className="pt-4 border-t border-slate-900 flex gap-4">
<button
onClick={async () => {
setTestingLdap(true);
try {
const res = await inventoryApi.testLdapConnection(ldapConfig);
if (res.status === 'success') {
toast.success("Connection Successful!");
} else {
toast.error(res.message);
}
} catch (err) {
toast.error("Network Error during test");
} finally {
setTestingLdap(false);
}
}}
disabled={testingLdap}
className="flex-1 bg-slate-900 hover:bg-slate-800 text-xs font-black py-4 rounded-xl border border-slate-800 transition-all flex items-center justify-center gap-2"
>
{testingLdap ? <div className="w-4 h-4 border-2 border-indigo-400 border-t-transparent animate-spin rounded-full" /> : <Wifi size={14} />}
Test Link
</button>
<button
onClick={async () => {
await inventoryApi.updateLdapConfig(ldapConfig);
toast.success("Settings applied");
}}
className="flex-1 bg-indigo-600 hover:bg-indigo-500 shadow-lg shadow-indigo-900/20 text-white text-xs font-black py-4 rounded-xl transition-all"
>
Save Changes
</button>
</div> </div>
</div> </div>
<div className="flex items-start gap-3 p-4 bg-indigo-500/5 rounded-2xl border border-indigo-500/10"> <div className="pt-2">
<AlertTriangle size={16} className="text-indigo-400 shrink-0 mt-0.5" /> <p className="text-[10px] text-slate-500 italic leading-relaxed px-1">
<p className="text-xs text-indigo-300/60 leading-normal italic"> Automated backups are stored in <code className="text-amber-500/60 font-mono">/data/backups</code>. Restoring data will overwrite the current live primary database.
LLDAP usually serves LDAP on port 3890. Ensure your firewall allows traffic on this port between the inventory server and 192.168.84.107.
</p> </p>
</div> </div>
</div> </div>
</div> </div>
{/* Backup History List */}
<div className="lg:col-span-2">
<div className="bg-slate-950/40 border border-slate-800/50 rounded-3xl overflow-hidden shadow-xl flex flex-col h-full max-h-[400px]">
<div className="px-5 py-3 border-b border-slate-800/50 bg-slate-900/20 flex items-center justify-between">
<span className="text-xs font-black text-slate-400">Available Snapshots</span>
<span className="text-[10px] font-bold text-slate-500">Sorted by newest</span>
</div>
<div className="overflow-y-auto flex-1 divide-y divide-slate-800/50 custom-scrollbar">
{backups.length === 0 ? (
<div className="p-12 text-center text-slate-600 italic text-xs font-bold">No backups available on disk</div>
) : (
backups.map((b) => (
<div key={b.filename} className="flex items-center justify-between p-4 hover:bg-slate-800/20 transition-all group">
<div className="flex items-center gap-4 min-w-0">
<div className="p-2.5 bg-slate-800 rounded-xl text-slate-500 group-hover:text-amber-500 transition-colors shrink-0">
<History size={16} />
</div>
<div className="min-w-0">
<p className="text-sm font-bold text-slate-200 truncate">{b.filename}</p>
<div className="flex items-center gap-3 mt-0.5">
<span className="text-[10px] font-black text-slate-500">{new Date(b.created_at).toLocaleString()}</span>
<span className="text-[10px] font-black text-amber-500/40">{formatSize(b.size_bytes)}</span>
</div>
</div>
</div>
<button
onClick={() => handleRestore(b.filename)}
className="flex items-center gap-2 bg-slate-800 hover:bg-amber-600 text-slate-400 hover:text-white px-3 py-2 rounded-xl text-[10px] font-black transition-all border border-slate-700 hover:border-amber-500"
>
<RotateCcw size={12} />
Restore
</button>
</div>
))
)}
</div>
</div>
</div>
</div>
</section> </section>
{/* Edit User Modal */} {/* Edit User Modal */}
{editingUser && ( {editingUser && (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-slate-950/90 backdrop-blur-xl animate-in fade-in duration-300"> <div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-slate-950/90 backdrop-blur-xl animate-in fade-in duration-300">

View File

@@ -5,19 +5,39 @@
@import "bootstrap-icons/font/bootstrap-icons.css"; @import "bootstrap-icons/font/bootstrap-icons.css";
:root { :root {
--background: #ffffff; /* slate-950 forced as default to prevent white flash */
--foreground: #171717; --background: #020617;
} --foreground: #f1f5f9;
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
} }
body { body {
color: var(--foreground); color: var(--foreground);
background: var(--background); background-color: var(--background);
font-family: Arial, Helvetica, sans-serif; font-family: inherit; /* Use Next.js font if defined, or system default */
}
/* Custom Scrollbar Styling */
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-track {
background: #020617; /* slate-950 */
}
::-webkit-scrollbar-thumb {
background: #1e293b; /* slate-800 */
border-radius: 10px;
border: 2px solid #020617; /* adds padding effect */
}
::-webkit-scrollbar-thumb:hover {
background: #334155; /* slate-700 */
}
/* Support for Firefox (limited) */
* {
scrollbar-width: thin;
scrollbar-color: #1e293b #020617;
} }

View File

@@ -196,35 +196,33 @@ export default function InventoryPage() {
return ( return (
<PageShell> <PageShell>
<div className="p-3 md:p-8 max-w-4xl mx-auto space-y-6"> <div className="p-3 md:p-8 max-w-7xl mx-auto space-y-6">
<datalist id="existing-types"> <datalist id="existing-types">
{existingTypes.map(t => <option key={t} value={t} />)} {existingTypes.map(t => <option key={t} value={t} />)}
</datalist> </datalist>
<header className="max-w-4xl mx-auto w-full mb-8"> <header className="flex items-center gap-5 mb-10">
<h1 className="text-2xl font-black flex items-center gap-3"> <div className="p-4 bg-primary/10 rounded-[2rem] text-primary border border-primary/20 shadow-xl shadow-primary/5">
<Package className="text-primary" size={28} /> <Package size={32} />
Inventory Catalog </div>
</h1> <div>
<p className="text-sm text-slate-500 mt-1">Detailed view of all stock items by category</p> <h1 className="text-3xl font-black tracking-tight text-white">Inventory Catalog</h1>
<p className="text-xs text-slate-500 font-bold mt-1">Enterprise Stock Overview</p>
</div>
</header> </header>
<div className="max-w-4xl mx-auto w-full space-y-8"> <div className="w-full space-y-8">
{/* Stats Dashboard */} {/* Stats Dashboard */}
<section className="grid grid-cols-2 md:grid-cols-4 gap-3"> <section className="grid grid-cols-2 md:grid-cols-4 gap-3">
<div className="bg-slate-900/50 border border-slate-800 p-4 rounded-3xl"> <div className="bg-slate-900/80 border border-slate-700/40 p-2 px-4 rounded-xl flex items-center gap-4 shadow-lg backdrop-blur-sm">
<div className="w-8 h-8 rounded-xl bg-primary/10 text-primary flex items-center justify-center mb-3"> <Layers size={18} className="text-primary shrink-0 opacity-80" />
<Layers size={18} /> <p className="text-sm font-bold text-slate-300 whitespace-nowrap">Categories</p>
<p className="text-xl font-black text-white tabular-nums ml-auto">{stats?.total_categories || categories.length}</p>
</div> </div>
<p className="text-xs font-black text-slate-500">Categories</p> <div className="bg-slate-900/80 border border-slate-700/40 p-2 px-4 rounded-xl flex items-center gap-4 shadow-lg backdrop-blur-sm">
<p className="text-2xl font-black mt-1">{stats?.total_categories || categories.length}</p> <Package size={18} className="text-green-500 shrink-0 opacity-80" />
</div> <p className="text-sm font-bold text-slate-300 whitespace-nowrap">Item Types</p>
<div className="bg-slate-900/50 border border-slate-800 p-4 rounded-3xl"> <p className="text-xl font-black text-white tabular-nums ml-auto">{stats?.total_items || inventory.length}</p>
<div className="w-8 h-8 rounded-xl bg-green-500/10 text-green-500 flex items-center justify-center mb-3">
<Package size={18} />
</div>
<p className="text-xs font-black text-slate-500">Item Types</p>
<p className="text-2xl font-black mt-1">{stats?.total_items || inventory.length}</p>
</div> </div>
</section> </section>
@@ -256,7 +254,7 @@ export default function InventoryPage() {
</div> </div>
<div className="text-left"> <div className="text-left">
<h3 className="font-bold text-lg">{cat}</h3> <h3 className="font-bold text-lg">{cat}</h3>
<p className="text-xs font-black text-slate-500"> <p className="text-[9px] font-black text-slate-400">
{inventory.filter(i => i.category === cat).length} Item types in stock {inventory.filter(i => i.category === cat).length} Item types in stock
</p> </p>
</div> </div>

View File

@@ -25,7 +25,7 @@ export default function RootLayout({
<link rel="manifest" href="/manifest.json" /> <link rel="manifest" href="/manifest.json" />
<link rel="apple-touch-icon" href="/icon-192x192.png" /> <link rel="apple-touch-icon" href="/icon-192x192.png" />
</head> </head>
<body className="antialiased"> <body className="antialiased bg-slate-950 text-slate-100">
{children} {children}
</body> </body>
</html> </html>

View File

@@ -4,7 +4,7 @@ import { useState, useEffect } from 'react';
import { db, Item } from '@/lib/db'; import { db, Item } from '@/lib/db';
import { inventoryApi } from '@/lib/api'; import { inventoryApi } from '@/lib/api';
import PageShell from '@/components/PageShell'; import PageShell from '@/components/PageShell';
import { History, X, Search, Filter } from 'lucide-react'; import { History, X, Search, Filter, Activity, ArrowDownCircle, ArrowUpCircle, User } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { fetchAndCacheItems } from '@/lib/sync'; import { fetchAndCacheItems } from '@/lib/sync';
@@ -61,16 +61,16 @@ export default function LogsPage() {
return ( return (
<PageShell> <PageShell>
<main className="p-4 md:p-8 max-w-5xl mx-auto space-y-12"> <main className="p-4 md:p-8 max-w-7xl mx-auto space-y-12">
<header className="space-y-8"> <header className="space-y-8">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-6"> <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-6">
<div className="flex items-center gap-4"> <div className="flex items-center gap-5">
<div className="p-4 bg-primary/10 rounded-[2rem] text-primary border border-primary/20 shadow-xl shadow-primary/5"> <div className="p-4 bg-primary/10 rounded-[2rem] text-primary border border-primary/20 shadow-xl shadow-primary/5">
<History size={32} /> <History size={32} />
</div> </div>
<div> <div>
<h1 className="text-3xl font-black tracking-tight text-white italic">Audit Dashboard</h1> <h1 className="text-3xl font-black tracking-tight text-white">Audit Dashboard</h1>
<p className="text-xs text-slate-500 font-bold tracking-widest uppercase mt-1">Real-time Intervention Tracking</p> <p className="text-xs text-slate-500 font-bold mt-1">Real-time Intervention Tracking</p>
</div> </div>
</div> </div>
@@ -86,21 +86,25 @@ export default function LogsPage() {
{/* Stats Grid */} {/* Stats Grid */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4"> <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-slate-900/40 border border-slate-800/50 p-6 rounded-[2rem] space-y-2"> <div className="bg-slate-900/80 border border-slate-700/40 p-2 px-4 rounded-xl flex items-center gap-4 shadow-lg backdrop-blur-sm">
<p className="text-[10px] font-black text-slate-500 uppercase tracking-widest">Total Events</p> <Activity size={18} className="text-primary shrink-0 opacity-80" />
<p className="text-3xl font-black text-white tabular-nums">{totalCount}</p> <p className="text-sm font-bold text-slate-300 whitespace-nowrap">Total Events</p>
<p className="text-xl font-black text-white tabular-nums ml-auto">{totalCount}</p>
</div> </div>
<div className="bg-slate-900/40 border border-slate-800/50 p-6 rounded-[2rem] space-y-2"> <div className="bg-slate-900/80 border border-slate-700/40 p-2 px-4 rounded-xl flex items-center gap-4 shadow-lg backdrop-blur-sm">
<p className="text-[10px] font-black text-slate-500 uppercase tracking-widest">Flow In</p> <ArrowDownCircle size={18} className="text-green-500 shrink-0 opacity-80" />
<p className="text-3xl font-black text-green-500 tabular-nums">{inCount}</p> <p className="text-sm font-bold text-slate-300 whitespace-nowrap">Check in</p>
<p className="text-xl font-black text-green-500 tabular-nums ml-auto">{inCount}</p>
</div> </div>
<div className="bg-slate-900/40 border border-slate-800/50 p-6 rounded-[2rem] space-y-2"> <div className="bg-slate-900/80 border border-slate-700/40 p-2 px-4 rounded-xl flex items-center gap-4 shadow-lg backdrop-blur-sm">
<p className="text-[10px] font-black text-slate-500 uppercase tracking-widest">Flow Out</p> <ArrowUpCircle size={18} className="text-rose-500 shrink-0 opacity-80" />
<p className="text-3xl font-black text-rose-500 tabular-nums">{outCount}</p> <p className="text-sm font-bold text-slate-300 whitespace-nowrap">Check out</p>
<p className="text-xl font-black text-rose-500 tabular-nums ml-auto">{outCount}</p>
</div> </div>
<div className="bg-slate-900/40 border border-slate-800/50 p-6 rounded-[2rem] space-y-2"> <div className="bg-slate-900/80 border border-slate-700/40 p-2 px-4 rounded-xl flex items-center gap-4 shadow-lg backdrop-blur-sm overflow-hidden">
<p className="text-[10px] font-black text-slate-500 uppercase tracking-widest">Top Operator</p> <User size={18} className="text-indigo-400 shrink-0 opacity-80" />
<p className="text-xl font-black text-primary truncate" title={mostActiveUser}>{mostActiveUser}</p> <p className="text-sm font-bold text-slate-300 whitespace-nowrap">Top Operator</p>
<p className="text-base font-black text-amber-500 truncate ml-auto" title={mostActiveUser}>{mostActiveUser}</p>
</div> </div>
</div> </div>
@@ -117,18 +121,24 @@ export default function LogsPage() {
</div> </div>
<div className="flex items-center gap-1.5 p-1.5 bg-slate-900/50 border border-slate-900 rounded-[1.5rem] overflow-x-auto no-scrollbar"> <div className="flex items-center gap-1.5 p-1.5 bg-slate-900/50 border border-slate-900 rounded-[1.5rem] overflow-x-auto no-scrollbar">
{['ALL', 'CHECK_IN', 'CHECK_OUT', 'TRASH', 'CREATE'].map(action => ( {[
{ label: 'All', value: 'ALL' },
{ label: 'Check in', value: 'CHECK_IN' },
{ label: 'Check out', value: 'CHECK_OUT' },
{ label: 'Trash', value: 'TRASH' },
{ label: 'Create', value: 'CREATE' }
].map(action => (
<button <button
key={action} key={action.value}
onClick={() => setFilterAction(action)} onClick={() => setFilterAction(action.value)}
className={cn( className={cn(
"px-4 py-2.5 rounded-xl text-[10px] font-black transition-all whitespace-nowrap", "px-4 py-2.5 rounded-xl text-xs font-bold transition-all whitespace-nowrap",
filterAction === action filterAction === action.value
? "bg-primary text-white shadow-lg shadow-primary/20" ? "bg-primary text-white shadow-lg shadow-primary/20"
: "text-slate-500 hover:text-slate-300 hover:bg-slate-800" : "text-slate-500 hover:text-slate-300 hover:bg-slate-800"
)} )}
> >
{action.replace('_', ' ')} {action.label}
</button> </button>
))} ))}
</div> </div>
@@ -139,7 +149,7 @@ export default function LogsPage() {
{loading ? ( {loading ? (
<div className="flex flex-col items-center justify-center py-32 text-slate-600 gap-4 animate-pulse"> <div className="flex flex-col items-center justify-center py-32 text-slate-600 gap-4 animate-pulse">
<div className="w-12 h-12 border-4 border-primary/20 border-t-primary rounded-full animate-spin" /> <div className="w-12 h-12 border-4 border-primary/20 border-t-primary rounded-full animate-spin" />
<p className="text-[10px] uppercase font-black tracking-widest">Securing Audit Stream...</p> <p className="text-[10px] font-black tracking-widest">Securing Audit Stream...</p>
</div> </div>
) : filteredLogs.length === 0 ? ( ) : filteredLogs.length === 0 ? (
<div className="bg-slate-900/20 border border-slate-800/50 border-dashed rounded-[3rem] py-24 flex flex-col items-center justify-center text-center gap-6"> <div className="bg-slate-900/20 border border-slate-800/50 border-dashed rounded-[3rem] py-24 flex flex-col items-center justify-center text-center gap-6">
@@ -157,49 +167,45 @@ export default function LogsPage() {
<button <button
key={log.id} key={log.id}
onClick={() => setSelectedLog(log)} onClick={() => setSelectedLog(log)}
className="w-full text-left bg-slate-900/30 border border-slate-800/40 p-6 rounded-[2.5rem] flex flex-col sm:flex-row sm:items-center justify-between gap-6 hover:bg-slate-900/60 hover:border-slate-700/50 transition-all group active:scale-[0.99] relative overflow-hidden" className="w-full text-left bg-slate-900/30 border border-slate-800/20 p-2 px-4 rounded-xl flex items-center justify-between gap-4 hover:bg-slate-900/60 hover:border-slate-700/50 transition-all group active:scale-[0.99] relative overflow-hidden shadow-sm"
> >
<div className="flex-1 min-w-0 z-10"> <div className="flex-1 min-w-0 z-10 flex items-center gap-4">
<div className="flex items-center gap-3 mb-3"> {/* Compact Action Badge */}
<div className={cn( <div className={cn(
"text-[10px] font-black px-3 py-1 rounded-full border shadow-sm", "text-[9px] font-black px-2 py-0.5 rounded-md border min-w-[70px] text-center",
log.action.includes('CHECK_IN') ? "bg-green-500/5 text-green-500 border-green-500/20" : log.action.includes('CHECK_IN') ? "bg-green-500/5 text-green-500 border-green-500/20" :
(log.action.includes('TRASH') ? "bg-rose-500/5 text-rose-500 border-rose-500/20" : (log.action.includes('TRASH') ? "bg-rose-500/5 text-rose-500 border-rose-500/20" :
(log.action.includes('CREATE') ? "bg-indigo-500/5 text-indigo-400 border-indigo-500/20" : "bg-amber-500/5 text-amber-500 border-amber-500/20")) (log.action.includes('CREATE') ? "bg-indigo-500/5 text-indigo-400 border-indigo-500/20" : "bg-amber-500/5 text-amber-500 border-amber-500/20"))
)}> )}>
{log.action.replace('_', ' ')} {log.action.replace('_', ' ')}
</div> </div>
<div className="flex items-center gap-2">
<div className="w-1 h-1 rounded-full bg-slate-700" />
<span className="text-[10px] font-black text-slate-500 uppercase tracking-tight">{log.username || 'System'}</span>
</div>
</div>
<h3 className="text-lg font-black text-white group-hover:text-primary transition-colors truncate"> <div className="flex-1 min-w-0">
<h3 className="text-sm font-bold text-white group-hover:text-primary transition-colors truncate">
{inventory.find(i => i.id === log.target_item_id)?.name || `Item #${log.target_item_id}`} {inventory.find(i => i.id === log.target_item_id)?.name || `Item #${log.target_item_id}`}
</h3> </h3>
<div className="flex items-center gap-2 opacity-80">
<div className="flex flex-wrap items-center gap-4 mt-4"> <span className="text-[9px] font-black text-amber-500 tracking-tight">{log.username || 'System'}</span>
<div className="text-[10px] text-slate-500 font-mono flex items-center gap-2 bg-slate-950/50 px-3 py-1.5 rounded-xl border border-slate-800/50"> <span className="w-1 h-1 rounded-full bg-slate-700" />
<div className="w-1.5 h-1.5 rounded-full bg-primary/40" /> <span className="text-[9px] text-slate-500 font-mono">
{new Date(log.timestamp).toLocaleDateString()} · {new Date(log.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} {new Date(log.timestamp).toLocaleDateString()} · {new Date(log.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</span>
</div> </div>
{log.details && (
<div className="text-[10px] font-bold text-slate-400 truncate max-w-[200px] italic opacity-60">
"{log.details}"
</div>
)}
</div> </div>
</div> </div>
<div className="shrink-0 text-right z-10"> <div className="shrink-0 flex items-center gap-3 z-10">
{log.details && (
<span className="text-[10px] text-slate-600 italic hidden md:block truncate max-w-[150px]">
"{log.details}"
</span>
)}
<div className={cn( <div className={cn(
"text-4xl font-black tabular-nums group-hover:scale-110 transition-transform flex items-center justify-end gap-1", "text-xl font-black tabular-nums min-w-[40px] text-right",
log.quantity_change > 0 ? "text-green-500" : (log.quantity_change < 0 ? "text-rose-500" : "text-indigo-400") log.quantity_change > 0 ? "text-green-500" : (log.quantity_change < 0 ? "text-rose-500" : "text-indigo-400")
)}> )}>
{log.quantity_change > 0 ? '+' : ''}{log.quantity_change === 0 ? '±' : log.quantity_change} {log.quantity_change > 0 ? '+' : ''}{log.quantity_change === 0 ? '±' : log.quantity_change}
</div> </div>
<p className="text-[10px] font-black text-slate-600 uppercase tracking-widest mt-1">Quantity</p>
</div> </div>
{/* Glass background effect */} {/* Glass background effect */}
@@ -234,11 +240,11 @@ export default function LogsPage() {
<div className="grid grid-cols-2 gap-6"> <div className="grid grid-cols-2 gap-6">
<div className="space-y-1 bg-slate-950/50 p-4 rounded-2xl border border-slate-800"> <div className="space-y-1 bg-slate-950/50 p-4 rounded-2xl border border-slate-800">
<p className="text-[10px] font-black text-slate-600 uppercase">Operator</p> <p className="text-[10px] font-black text-slate-600">Operator</p>
<p className="text-sm font-black text-white">{selectedLog.username || 'System Profile'}</p> <p className="text-sm font-black text-white">{selectedLog.username || 'System Profile'}</p>
</div> </div>
<div className="space-y-1 bg-slate-950/50 p-4 rounded-2xl border border-slate-800"> <div className="space-y-1 bg-slate-950/50 p-4 rounded-2xl border border-slate-800">
<p className="text-[10px] font-black text-slate-600 uppercase">Delta</p> <p className="text-[10px] font-black text-slate-600">Delta</p>
<p className={cn( <p className={cn(
"text-xl font-black", "text-xl font-black",
selectedLog.quantity_change > 0 ? "text-green-500" : "text-rose-500" selectedLog.quantity_change > 0 ? "text-green-500" : "text-rose-500"
@@ -250,7 +256,7 @@ export default function LogsPage() {
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-1"> <div className="space-y-1">
<p className="text-[10px] font-black text-slate-600 uppercase">Timestamp</p> <p className="text-[10px] font-black text-slate-600">Timestamp</p>
<p className="text-sm font-bold text-slate-300 bg-slate-800/30 p-4 rounded-2xl border border-slate-800/50"> <p className="text-sm font-bold text-slate-300 bg-slate-800/30 p-4 rounded-2xl border border-slate-800/50">
{new Date(selectedLog.timestamp).toLocaleString(undefined, { dateStyle: 'full', timeStyle: 'medium' })} {new Date(selectedLog.timestamp).toLocaleString(undefined, { dateStyle: 'full', timeStyle: 'medium' })}
</p> </p>
@@ -258,7 +264,7 @@ export default function LogsPage() {
{selectedLog.details && ( {selectedLog.details && (
<div className="space-y-1"> <div className="space-y-1">
<p className="text-[10px] font-black text-slate-600 uppercase">Intervention Details</p> <p className="text-[10px] font-black text-slate-600">Intervention Details</p>
<div className="bg-primary/5 text-primary/80 p-6 rounded-[2rem] border border-primary/10 text-sm font-bold leading-relaxed italic"> <div className="bg-primary/5 text-primary/80 p-6 rounded-[2rem] border border-primary/10 text-sm font-bold leading-relaxed italic">
"{selectedLog.details}" "{selectedLog.details}"
</div> </div>

View File

@@ -28,6 +28,8 @@ import {
Smartphone, Smartphone,
CheckCircle2, CheckCircle2,
User, User,
ArrowDownCircle,
ArrowUpCircle,
Settings, Settings,
Lock, Lock,
Shield, Shield,
@@ -381,14 +383,14 @@ export default function Home() {
return ( return (
<PageShell> <PageShell>
<div className="p-3 md:p-8 overflow-x-hidden w-full"> <div className="p-3 md:p-8 overflow-x-hidden w-full max-w-7xl mx-auto">
{/* Search datalist for types */} {/* Search datalist for types */}
<datalist id="existing-types"> <datalist id="existing-types">
{existingTypes.map(t => <option key={t} value={t} />)} {existingTypes.map(t => <option key={t} value={t} />)}
</datalist> </datalist>
{/* Header */} {/* Header */}
<header className="flex flex-col sm:flex-row justify-between sm:items-center gap-4 mb-6 w-full max-w-4xl mx-auto px-1"> <header className="flex flex-col sm:flex-row justify-between sm:items-center gap-4 mb-6 w-full px-1">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="p-1"> <div className="p-1">
<img <img
@@ -445,22 +447,23 @@ export default function Home() {
</div> </div>
</header> </header>
<div className="max-w-4xl mx-auto w-full px-1 space-y-6"> <div className="w-full px-1 space-y-6">
{/* Mode Switcher */} {/* Mode Switcher */}
<div className="flex p-1 bg-slate-900/80 backdrop-blur-md rounded-2xl shadow-inner w-full"> <div className="flex p-1 bg-slate-900/80 backdrop-blur-md rounded-2xl shadow-inner w-full">
{[ {[
{ id: 'CHECK_IN', label: 'Check In' }, { id: 'CHECK_IN', label: 'Check in', icon: ArrowDownCircle },
{ id: 'CHECK_OUT', label: 'Check Out' }, { id: 'CHECK_OUT', label: 'Check out', icon: ArrowUpCircle },
{ id: 'TRASH', label: 'Trash' } { id: 'TRASH', label: 'Trash', icon: Trash2 }
].map((m) => ( ].map((m) => (
<button <button
key={m.id} key={m.id}
onClick={() => setMode(m.id as any)} onClick={() => setMode(m.id as any)}
className={cn( className={cn(
"flex-1 py-3 rounded-xl text-sm font-black transition-all", "flex-1 py-3 rounded-xl text-sm font-black transition-all flex items-center justify-center gap-2",
mode === m.id ? "bg-slate-800 text-primary shadow-lg" : "text-slate-500" mode === m.id ? "bg-slate-800 text-primary shadow-lg" : "text-slate-500 hover:text-slate-300"
)} )}
> >
<m.icon size={18} />
{m.label} {m.label}
</button> </button>
))} ))}

View File

@@ -68,10 +68,12 @@ export default function BottomNav({
{/* Logout */} {/* Logout */}
<button <button
onClick={() => { onClick={() => {
if (window.confirm("Are you sure you want to logout?")) {
import('@/lib/auth').then(m => m.clearAuth()); import('@/lib/auth').then(m => m.clearAuth());
window.location.href = '/login'; window.location.href = '/login';
}
}} }}
className="flex flex-col items-center gap-1 hover:text-rose-500 transition-colors" className="flex flex-col items-center gap-1 text-rose-500 hover:text-rose-400 transition-colors"
> >
<LogOut size={20} /> <LogOut size={20} />
<span className="text-xs font-bold transition-all">Logout</span> <span className="text-xs font-bold transition-all">Logout</span>

View File

@@ -47,10 +47,14 @@ export default function PageShell({ children, requireAdmin = false }: PageShellP
}, [requireAdmin, router, pathname]); }, [requireAdmin, router, pathname]);
if (!mounted) return null; if (!mounted) {
return <div className="min-h-screen bg-slate-950" />;
}
// Prevent flicker by not rendering background if we're redirecting to login // Prevent flicker by showing dark background if we're redirecting to login
if (!currentUser && pathname !== '/login') return null; if (!currentUser && pathname !== '/login') {
return <div className="min-h-screen bg-slate-950" />;
}
return ( return (
<div className="min-h-screen bg-slate-950 text-slate-100 flex flex-col"> <div className="min-h-screen bg-slate-950 text-slate-100 flex flex-col">

View File

@@ -296,7 +296,7 @@ export default function Scanner({ onScanSuccess, onOCRMatch, paused }: ScannerPr
className="h-16 px-6 bg-slate-800 hover:bg-slate-700 border border-slate-700 text-white rounded-2xl flex flex-col items-center justify-center shadow-lg transition-all active:scale-95" className="h-16 px-6 bg-slate-800 hover:bg-slate-700 border border-slate-700 text-white rounded-2xl flex flex-col items-center justify-center shadow-lg transition-all active:scale-95"
> >
<span className="text-xs font-black">{zoom.toFixed(1)}x</span> <span className="text-xs font-black">{zoom.toFixed(1)}x</span>
<span className="text-[10px] text-primary font-bold uppercase">Zoom</span> <span className="text-[10px] text-primary font-bold">Zoom</span>
</button> </button>
)} )}
@@ -310,7 +310,7 @@ export default function Scanner({ onScanSuccess, onOCRMatch, paused }: ScannerPr
<> <>
<Search className={cn("text-slate-500", !isStarted && "opacity-20")} size={20} /> <Search className={cn("text-slate-500", !isStarted && "opacity-20")} size={20} />
<div className="flex flex-col"> <div className="flex flex-col">
<span className="text-[10px] text-slate-500 font-bold leading-none uppercase">Label Scanning</span> <span className="text-[10px] text-slate-500 font-bold leading-none">Label Scanning</span>
<span className="text-sm font-black text-primary leading-tight"> <span className="text-sm font-black text-primary leading-tight">
{countdown === 0 ? "Scanning..." : `Next scan in ${countdown}s`} {countdown === 0 ? "Scanning..." : `Next scan in ${countdown}s`}
</span> </span>

View File

@@ -164,5 +164,31 @@ export const inventoryApi = {
deleteCategory: async (id: number) => { deleteCategory: async (id: number) => {
const res = await axiosInstance.delete(`/categories/${id}`); const res = await axiosInstance.delete(`/categories/${id}`);
return res.data; return res.data;
},
// Database Management
getDbBackups: async () => {
const res = await axiosInstance.get('/admin/db/backups');
return res.data;
},
getDbStats: async () => {
const res = await axiosInstance.get('/admin/db/stats');
return res.data;
},
triggerBackup: async () => {
const res = await axiosInstance.post('/admin/db/backup');
return res.data;
},
restoreDatabase: async (filename: string) => {
const res = await axiosInstance.post('/admin/db/restore', { filename, confirm: true });
return res.data;
},
getDbSettings: async () => {
const res = await axiosInstance.get('/admin/db/settings');
return res.data;
},
updateDbSettings: async (settings: any) => {
const res = await axiosInstance.patch('/admin/db/settings', settings);
return res.data;
} }
}; };

View File

@@ -6,6 +6,9 @@ FRONTEND_PORT=3001
BACKEND_SSL_PORT=3002 BACKEND_SSL_PORT=3002
FRONTEND_SSL_PORT=3003 FRONTEND_SSL_PORT=3003
# Add common Mac paths for npm/node
export PATH="/opt/homebrew/bin:/usr/local/bin:$PATH"
echo "🚀 Starting TFM aInventory Stack with Dual Proxy..." echo "🚀 Starting TFM aInventory Stack with Dual Proxy..."
# 1. Kill potentially hanging processes # 1. Kill potentially hanging processes