Compare commits

..

16 Commits

Author SHA1 Message Date
Daniel Bedeleanu
3069a921e7 Build [v1.8.6] (TypeScript unknown catch type fix) 2026-04-13 19:56:27 +03:00
Daniel Bedeleanu
55e3cf5042 Build [v1.8.5] (Self-contained Frontend Build) 2026-04-13 19:50:28 +03:00
Daniel Bedeleanu
c874d27d64 Build [v1.8.4] (Satisfy frontend relative VERSION.json path) 2026-04-13 19:44:52 +03:00
Daniel Bedeleanu
939ad8648d Build [v1.8.3] (Explicit directory creation in bundle) 2026-04-13 19:39:33 +03:00
Daniel Bedeleanu
6f6caf3c5a Build [v1.8.2] (Fix missing scripts in bundle) 2026-04-13 19:38:11 +03:00
Daniel Bedeleanu
5e648002f0 Build [v1.8.1] (Fix Docker Context) 2026-04-13 19:36:06 +03:00
Daniel Bedeleanu
81b775c9ae Build [v1.8.0] 2026-04-13 19:23:48 +03:00
Daniel Bedeleanu
994920bda2 Build [v1.7.0] - GitGuard - Infrastructure Hardening 2026-04-12 22:45:27 +03:00
Daniel Bedeleanu
0a6368b9f6 modificari de documentatii dupa o implementare noua 2026-04-12 21:43:08 +03:00
Daniel Bedeleanu
26e8f034a2 Update save_version.py: Added automatic master branch synchronization 2026-04-12 21:32:55 +03:00
Daniel Bedeleanu
2e5c666cc8 Build [v1.5.0] - Box Management & Label Printing 2026-04-12 21:30:50 +03:00
Daniel Bedeleanu
e383b97d44 docs: finalized v1.4.1 session logs and architecture security section 2026-04-12 10:49:48 +03:00
Daniel Bedeleanu
50ae3671c9 Build [v1.4.1] - Security hardening, PWA and UI refinements 2026-04-12 10:45:57 +03:00
Daniel Bedeleanu
f3d861b1a2 Build [v1.4.0] - Audit Dashboard & LDAP Restoration 2026-04-12 09:39:17 +03:00
Daniel Bedeleanu
5cceba21f4 Fix version naming format: v.X.Y.Z → vX.Y.Z 2026-04-12 07:32:16 +03:00
Daniel Bedeleanu
161a182281 Build [v.1.3.9] 2026-04-12 07:29:58 +03:00
62 changed files with 3063 additions and 1007 deletions

View File

@@ -0,0 +1,43 @@
---
trigger: manual
description: You are an expert in API Testing using tools like Postman and REST Assured.
---
# API Testing (Postman, REST Assured)
You are an expert in API Testing using tools like Postman and REST Assured.
Key Principles:
- Test the business logic layer directly
- Faster and more stable than UI tests
- Validate request/response contracts
- Check status codes, headers, and body
- Ensure security and performance
Postman:
- Collections and Folders
- Environment and Global variables
- Pre-request scripts and Tests (JavaScript)
- Newman CLI for CI/CD integration
- Mock Servers
REST Assured (Java):
- Fluent BDD-like syntax (Given-When-Then)
- Easy integration with JUnit/TestNG
- JSON/XML Schema validation
- Request/Response logging
- Authentication support (OAuth, Basic)
What to Test:
- Status Codes (200, 201, 400, 401, 403, 404, 500)
- Response Payload (JSON structure and data)
- Headers (Content-Type, Cache-Control)
- Performance (Response time)
- Security (Auth, Rate limiting)
Best Practices:
- Chain requests (Extract token -> Use token)
- Use JSON Schema validation
- Data-driven testing (CSV/JSON files)
- Clean up created resources
- Run API tests in CI pipeline

View File

@@ -0,0 +1,74 @@
---
trigger: manual
description: You are an expert in modern CSS and responsive web design.
---
# Modern CSS & Responsive Design Expert
You are an expert in modern CSS and responsive web design.
Key Principles:
- Use mobile-first approach
- Implement responsive design with CSS Grid and Flexbox
- Use CSS custom properties (variables)
- Follow BEM or similar naming convention
- Write maintainable and scalable CSS
Layout:
- Use CSS Grid for two-dimensional layouts
- Use Flexbox for one-dimensional layouts
- Use CSS Grid auto-fit and auto-fill
- Implement proper spacing with gap property
- Use logical properties (inline, block)
Responsive Design:
- Use mobile-first media queries
- Use relative units (rem, em, %)
- Implement fluid typography with clamp()
- Use container queries when appropriate
- Test on multiple devices and screen sizes
Modern CSS Features:
- Use CSS custom properties for theming
- Use CSS Grid and Flexbox
- Use aspect-ratio for maintaining proportions
- Use clamp() for fluid sizing
- Use min(), max() for responsive values
- Use :is(), :where() for cleaner selectors
Animations:
- Use CSS transitions for simple animations
- Use CSS animations for complex sequences
- Use transform for better performance
- Respect prefers-reduced-motion
- Use will-change sparingly
Performance:
- Minimize CSS file size
- Remove unused CSS
- Use CSS containment
- Avoid expensive selectors
- Use CSS Grid/Flexbox over floats
- Minimize repaints and reflows
Architecture:
- Use BEM or similar methodology
- Organize CSS logically
- Use CSS custom properties for consistency
- Implement design tokens
- Use utility classes sparingly
Accessibility:
- Ensure sufficient color contrast
- Use focus-visible for focus styles
- Don't rely on color alone
- Test with high contrast mode
- Ensure text is readable
Best Practices:
- Use CSS reset or normalize
- Implement consistent spacing scale
- Use semantic class names
- Avoid !important
- Comment complex CSS
- Use CSS linting tools

View File

@@ -0,0 +1,88 @@
---
trigger: manual
description: Progressive Web App (PWA) Expert
---
# Progressive Web App (PWA) Expert
You are an expert in Progressive Web App development.
Key Principles:
- Implement offline-first strategy
- Use service workers for caching
- Make app installable
- Ensure fast loading
- Provide app-like experience
Service Workers:
- Implement proper caching strategies
- Use Cache API effectively
- Handle offline scenarios
- Implement background sync
- Use workbox for easier implementation
- Handle service worker updates
Manifest:
- Create comprehensive web app manifest
- Define app icons for all sizes
- Set appropriate display mode
- Define theme and background colors
- Set start URL and scope
- Add screenshots for app stores
Caching Strategies:
- Use cache-first for static assets
- Use network-first for dynamic content
- Implement stale-while-revalidate
- Use cache-only for offline pages
- Implement proper cache versioning
Offline Experience:
- Provide offline fallback page
- Cache critical resources
- Implement background sync
- Show offline indicator
- Queue failed requests
Performance:
- Implement lazy loading
- Use code splitting
- Optimize images
- Minimize JavaScript
- Use HTTP/2 push
- Implement resource hints
Installability:
- Meet PWA criteria
- Implement beforeinstallprompt
- Provide install UI
- Test installation flow
- Handle app updates
Push Notifications:
- Implement push notification API
- Request permission appropriately
- Handle notification clicks
- Implement notification best practices
- Test on multiple platforms
Security:
- Serve over HTTPS
- Implement CSP headers
- Validate all inputs
- Use secure authentication
- Implement proper CORS
Testing:
- Use Lighthouse for audits
- Test offline functionality
- Test on multiple devices
- Test installation flow
- Test push notifications
Best Practices:
- Follow PWA checklist
- Implement progressive enhancement
- Provide app shell architecture
- Use PRPL pattern
- Monitor performance metrics

View File

@@ -0,0 +1,44 @@
---
trigger: manual
description: You are an expert in Security Testing and Penetration Testing.
---
# Security & Penetration Testing
You are an expert in Security Testing and Penetration Testing.
Key Principles:
- Think like an attacker
- Defense in Depth
- Shift Left (Security early in SDLC)
- Validate controls and mitigations
- Compliance and Risk Management
OWASP Top 10 (Focus Areas):
- Broken Access Control
- Cryptographic Failures
- Injection (SQLi, XSS)
- Insecure Design
- Security Misconfiguration
Testing Types:
- SAST (Static Application Security Testing): Code analysis (SonarQube)
- DAST (Dynamic Application Security Testing): Runtime analysis (OWASP ZAP, Burp Suite)
- SCA (Software Composition Analysis): Dependency checks (Snyk, Dependabot)
- Penetration Testing: Manual exploitation
Tools:
- Burp Suite: Proxy and scanner
- OWASP ZAP: Open source scanner
- Metasploit: Exploitation framework
- Nmap: Network scanning
- Wireshark: Packet analysis
Best Practices:
- Sanitize all inputs
- Encode all outputs
- Use parameterized queries
- Implement proper authentication/authorization
- Keep dependencies updated
- Conduct regular vulnerability scans
- Perform manual code reviews for security logic

37
.gitignore vendored
View File

@@ -13,17 +13,26 @@ backend/venv/
dist/ dist/
build/ build/
# ── Runtime data (databases, configs w/ secrets) ───────────── # ── Runtime data directories ─────────────────────────────────
data/ # Content is excluded; the directories themselves are tracked via .gitkeep.
# On fresh clone: run ./start_server.sh or docker compose up to initialize.
/data/*
!/data/.gitkeep
/data/backups/
/logs/*
!/logs/.gitkeep
# Duplicate runtime dirs that may exist inside backend/ (Docker legacy)
backend/data/ backend/data/
backend/logs/ backend/logs/
# LDAP config contains real server IPs and credentials — never commit
# The active config is: backend/config/ldap_config.json (read by the app) # ── Sensitive configuration files ────────────────────────────
# Duplicate/orphan copies are also excluded: # The ACTIVE LDAP config is: backend/config/ldap_config.json
backend/ldap_config.json # It contains real server IPs and credentials — never commit.
# The template/example IS committed and used by init_data.sh on fresh installs.
backend/config/ldap_config.json backend/config/ldap_config.json
backend/data/ldap_config.json
# Keep example files tracked:
!backend/config/ldap_config.json.example !backend/config/ldap_config.json.example
# ── Environment files (secrets) ────────────────────────────── # ── Environment files (secrets) ──────────────────────────────
@@ -38,8 +47,12 @@ docker-compose.override.yml
.env.docker .env.docker
# ── Application logs ───────────────────────────────────────── # ── Application logs ─────────────────────────────────────────
logs/ # (also covered by /logs/* above, these catch any other locations)
frontend/logs/ frontend/logs/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# ── Frontend build artifacts ────────────────────────────────── # ── Frontend build artifacts ──────────────────────────────────
frontend/.next/ frontend/.next/
@@ -56,10 +69,6 @@ frontend/public/icons/
# ── npm / npx caches ───────────────────────────────────────── # ── npm / npx caches ─────────────────────────────────────────
.npx_cache/ .npx_cache/
scratch/npm_cache/ scratch/npm_cache/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# ── Production bundles (generated by export_prod.sh) ───────── # ── Production bundles (generated by export_prod.sh) ─────────
aInventory-PROD*/ aInventory-PROD*/
@@ -78,3 +87,5 @@ aInventory-PROD*.zip
*.key *.key
*.crt *.crt
*.cert *.cert
__push_ALL_to_remote.sh

View File

@@ -1,62 +1,60 @@
# AI AGENT RULES - MANDATORY ENTRY POINT # AI AGENT RULES - MANDATORY SSOT ENTRY POINT
**READ THIS ENTIRE FILE BEFORE EXECUTING ANY TASK.** **READ THIS ENTIRE FILE BEFORE EXECUTING ANY TASK.**
This is the **Single Source of Truth** for ALL Artificial Intelligence agents (Claude, Gemini, etc.) working on this project. This is the **Single Source of Truth** for ALL AI agents. Refer to [PROJECT_ARCHITECTURE.md](PROJECT_ARCHITECTURE.md) for technical logic.
(Automatic IDE entry points like `GEMINI.md` and `CLAUDE.md` exist solely to redirect agents to this main file).
For technical architecture, data models, and stack details, refer to [PROJECT_ARCHITECTURE.md](PROJECT_ARCHITECTURE.md).
## 1. Multi-AI Coordination & Memory ---
- **MANDATORY STARTUP**: Every AI session MUST first read `dev_docs/SESSION_STATE.md` to understand current context.
- **MANDATORY HANDOVER**: At the end of every task/session, update the handover note in `dev_docs/SESSION_STATE.md`. Specify: **Active AI**, **Current Status**, **Technical Context**, and **Next Steps**. ## 1. AI MEMORY, TRACEABILITY & HANDOVER
- **ARCHIVAL RULE**: Before writing new state, move all content of the *previous* session handover into the top of `dev_docs/SESSION_HISTORY.md` to prevent bloat. - **MANDATORY STARTUP**: Read `dev_docs/SESSION_STATE.md` immediately at session start.
- **PLAN RETIREMENT**: Mark a completed "Master Plan" as `[COMPLETED]` in the file itself. Move technical details to `dev_docs/ARCHIVE_LOGS.md` and `PLAN.md` entries to `dev_docs/PLAN_HISTORY.md`.
- **STRICT HANDOVER**: Update `dev_docs/SESSION_STATE.md` at the end of every task with: **Active AI**, **Current Status** (Stable/Broken/In-Progress), **Context**, and **Next Steps**.
- **SESSION ARCHIVE**: Move previous handover content to `dev_docs/SESSION_HISTORY.md` before writing new state.
- **NO INTERACTION OVERLAP**: Never modify a file if another AI session is explicitly working on it. - **NO INTERACTION OVERLAP**: Never modify a file if another AI session is explicitly working on it.
## 2. Global Operational Laws ## 2. ENGINEERING & OPERATIONAL LAWS
- **STRICT ENGLISH POLICY**: All web interfaces, code, variables, scripts, and documentation MUST BE DIRECTLY AND ONLY IN ENGLISH. If you find Romanian text in code, translate it immediately. - **ENGLISH ONLY**: Interfaces, code, variables, and docs MUST be in English. Translate any Romanian text found in code immediately. (User conversation: Romanian/English).
- **COMMUNICATION LANGUAGE**: Conversation with the user will be in Romanian or English (preferably Romanian). - **GIT PROTOCOL**: Use the direct binary path in `.git_path` (`/Library/Developer/CommandLineTools/usr/bin/git`) for ALL Git operations. **DO NOT REMOVE OR CHANGE THIS PATH UNDER ANY CIRCUMSTANCES!** Never push or use `--force` unless explicitly asked. Branching: `master` (stable), `dev` (active), `vX` (archive).
- **GIT BINARY PATH**: On this macOS environment, the system `git` is often broken. ALWAYS use the binary path stored in `.git_path` at the project root for all Git operations. - **VERSIONING**: Update `VERSION.json` on every commit. Use `scripts/save_version.py` for automated releases.
- **COMMIT & PUSH STRICT RULE**: - **DEPENDENCIES**: Update `backend/requirements.txt` with version constraints for every new pip package.
- Never push to remote (`git push`) or use force flags (`--hard`, `--force`) unless explicitly requested. - **SSOT INTEGRITY**: Every feature change MUST update: `README.md`, `USER_GUIDE.md`, `PROJECT_ARCHITECTURE.md`, and `export_prod.sh`.
- Always update `VERSION.json` on every commit.
- Do NOT add AI co-author signatures (e.g., `Co-Authored-By: AI...`) to commit messages.
- Branching: `master` (stable), `dev` (active development), `vX` (archival releases).
- **PACKAGE MANAGEMENT**: Any new package installed via `pip install` MUST be added to `backend/requirements.txt` with version constraints (e.g., `slowapi>=0.1.9`). Keep requirements.txt synchronized with installed packages.
- **MANDATORY LOGGING**: Document coding/architecture changes in `dev_docs/ARCHIVE_LOGS.md` at the end of the task.
- **TRIPLE CONFIRMATION (Safe Forget)**: You cannot delete a physical location, item, or critical entity without explicit user permission three times.
## 3. UI/UX Fidelity Specifications ## 3. UI/UX "PREMIUM" FIDELITY STANDARDS
- **Fidelity First**: Never simplify the UI unless asked. Density and aesthetics must remain "Premium". - **Aesthetics**: Density/aesthetics must remain "Premium". Use Tailwind CSS. NO simplification.
- **Styling**: Tailwind CSS. Font weights/spacing must be consistent (Inter/Roboto). - **Typography Rules**:
- **Readability**: NO `uppercase` or `tracking-widest` styles. Use standard camel/Title case. - **NO UPPERCASE** or **NO ITALICS** in headers, labels, buttons, or metadata.
- **Icons**: Use **Lucide Icons** exclusively. NO emojis. - **NO `tracking-widest`**. Use standard camel/Title case.
- **Interactive Affordance**: All select/dropdown boxes MUST have a `ChevronDown` icon. Masked passwords should have reduced opacity (`text-white/50`). - Use `font-black` for main headings.
- **Iconography Standardization**: - **Layout**: Main pages MUST use `max-w-7xl`.
- **Categories**: ALWAYS use `Layers` (Color: `text-primary`). - **Unified Headers**: Icon box (`p-4 bg-primary/10 border-primary/20`) + Title (`text-3xl font-black`) + Subtitle (`text-xs text-slate-500`).
- **Item Types**: ALWAYS use `Package` (Color: `text-green-500`). - **Iconography**: Use **Lucide Icons** exclusively (NO emojis).
- **Categories**: `Layers` (text-primary).
- **Item Types**: `Package` (text-green-500).
- **Affordance**: Dropdowns MUST have a `ChevronDown`. Passwords: `text-white/50`. Logout MUST be `text-rose-500`.
## 4. Documentation Maintenance ## 4. DATA INTEGRITY & AUDIT POLICY
- **SSOT INTEGRITY**: Whenever a feature is added, modified, or removed, you MUST update all corresponding documentation files: - **RESTRICTED ACTIONS**: `DELETE /items/` and Admin settings require `auth.get_current_admin`.
- `README.md` (General usage & technical modes). - **AUDIT IMMUTABILITY**: Deleting an `Item` MUST NOT delete its `AuditLog` entries.
- `USER_GUIDE.md` (End-user instructions). - **TRACEABILITY**: Log deletions to `logs/backend.log` with `USER[id]`, `ITEM[id]`, `Name`, `PN`.
- `PROJECT_ARCHITECTURE.md` (Technical logic & data models). - **CONFIRMATION**:
- `export_prod.sh` (If new scripts or files must be included in the production bundle). - **Triple Confirmation**: Deleting critical entities (Locations/Items) requires user confirmation 3 times.
- **REAL-TIME UPDATES**: Documentation updates are NOT optional and must be performed within the same session as the code changes. - **Native Alerts**: Use `window.confirm` for all destructive UI actions and Logout.
## 6. AI Command Shortcuts ## 5. AI COMMAND SHORTCUTS
- **`save-version`**: When the user triggers this command, the AI MUST: - **`save-version`**:
1. Increment the patch version in `VERSION.json`. 0. **MANDATORY**: Verify and update ALL documentation (`.md` files: README, USER_GUIDE, ARCHITECTURE, etc.) with explanations of all current changes.
2. Stage all current changes (`git add .`). 1. Increment `VERSION.json`.
3. Commit changes with message `Build [vX.Y.Z]`. 2. Git add/commit (`Build [vX.Y.Z]`).
4. Create a new branch named `vX.Y.Z` from the current state. 3. Create branch `vX.Y.Z` (Snapshot).
5. Generate a production bundle ZIP (calls `./export_prod.sh`). 4. Automatic Sync: Merge changes into `master` branch to keep it up-to-date.
6. Stay on the current branch (`dev`). 5. Run `./export_prod.sh`.
- *Implementation*: Use `python3 scripts/save_version.py` to ensure consistency. (Always use `python3 scripts/save_version.py`).
## 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`.
- After finishing an entire job (including updating session state, architecture logs, and versioning), end your final response on a separate line exactly with: ## END OF SESSION PROTOCOL
``` End your final response on a separate line exactly with:
--- ```
✓ Done. ---
``` ✓ Done.
- Do not provide unnecessary verbatim summaries of the code. ```

View File

@@ -3,11 +3,7 @@
This document tracks the immediate implementation checklist. For overarching design and constraints, see [PROJECT_ARCHITECTURE.md](PROJECT_ARCHITECTURE.md). This document tracks the immediate implementation checklist. For overarching design and constraints, see [PROJECT_ARCHITECTURE.md](PROJECT_ARCHITECTURE.md).
## Implementation Status ## Implementation Status
- [x] **Phase 1: Backend Foundation** (FastAPI, SQLite, Models). - [ ] **Phase 8: Database Encryption** (Implementing SQLCipher for data-at-rest protection).
- [x] **Phase 2: Modular AI Integration** (Gemini support, v2 SDK). - [ ] **Phase 9: Multi-Location Support** (Tracking inventory across different physical warehouses).
- [x] **Phase 3: AI Onboarding UI** (Validation Mask, Camera integration).
- [x] **Phase 4: Offline Synchronization** (Deduplicated Dexie -> SQL sync logic).
- [x] **Phase 5: Inventory Trash Management** (Waste/Discard/Damage workflow).
- [ ] **Phase 6: Audit Log Dashboard UI** (Visual historical interventions).
*(Note: Completed phases are periodically moved to `dev_docs/PLAN_HISTORY.md` according to AI_RULES)* *(Note: Completed phases are periodically moved to `dev_docs/PLAN_HISTORY.md` according to AI_RULES)*

View File

@@ -24,19 +24,21 @@ A unified system to maintain an inventory of "items" and their quantities, inclu
### 2.3 Operations & Tooling ### 2.3 Operations & Tooling
- **PWA Deployment:** `next-pwa` (Service Workers + Manifest.json) - **PWA Deployment:** `next-pwa` (Service Workers + Manifest.json)
- **HTTPS Proxy:** `local-ssl-proxy` (Required for mobile camera access, Port 3003) - **HTTPS Proxy:** `caddy` or `local-ssl-proxy` (Port 8909)
- **Servers:** Frontend (`npm run dev` on 3000), Backend (`./start_server.sh` on 8000) - **Servers:** Frontend (Port 8907), Backend (Port 8906)
- **Configuration:** Centrally managed via root `config/` directory (includes `network_config.env`, `ldap_config.json`, `Caddyfile`).
## 3. Data Models & Entities ## 3. Data Models & Entities
- **Item:** Name, Category Group (Structured), Item Type (Specific), Quantity, Barcode, Part Number. - **Item:** Name, Category Group (Structured), Item Type (Specific), Quantity, Barcode, Part Number, Box Label (Association).
- **Category:** Predefined groups for organizational structure. - **Category:** Predefined groups for organizational structure.
- **Intervention:** Linked to a required items list. - **Box/Container:** A generic grouping label (box_label) that links multiple items together for rapid multi-scanning.
- **Audit Log:** Immutable ledger detailing CRUD operations and stock fluctuations. - **Audit Log:** Immutable ledger detailing CRUD operations and stock fluctuations, including point-in-time box associations.
## 4. Scanning & Optimization Strategy (Crucial) ## 4. Scanning & Optimization Strategy (Crucial)
### 4.1 AI Usage Policy ### 4.1 AI Usage Policy
- **Routine Operations (Check-in/Out):** Executes entirely on the local device unconditionally using `html5-qrcode` ($0 cost). No AI is allowed here. - **Routine Operations (Check-in/Out):** Executes entirely on the local device unconditionally using `html5-qrcode` ($0 cost). No AI is allowed here.
- **New Item Onboarding (AI Label OCR):** Uses cloud AI (`gemini-2.0-flash`). The user takes a photo, AI extracts data based on strict templates. - **New Item Onboarding (AI Label OCR):** Uses cloud AI (`gemini-2.0-flash`). The user takes a photo, AI extracts data based on strict templates.
- **AI Box Discovery Mode (v1.6.0):** Supports specialized `mode="box"` prompt that focuses exclusively on prominent container names/hand-written labels, ignoring technical spec noise.
- **Validation Mask:** AI-extracted data is NEVER saved directly. It is presented in a validation UI for human confirmation. - **Validation Mask:** AI-extracted data is NEVER saved directly. It is presented in a validation UI for human confirmation.
### 4.2 Scanner Technical Specs ### 4.2 Scanner Technical Specs
@@ -48,6 +50,16 @@ A unified system to maintain an inventory of "items" and their quantities, inclu
- Noise Filtering: Ignores `< 3` chars, decimals, and dates. - Noise Filtering: Ignores `< 3` chars, decimals, and dates.
- Scoring: Exact S/N (+500), Exact P/N (+200), Token match (+50), Category match (+20). - Scoring: Exact S/N (+500), Exact P/N (+200), Token match (+50), Category match (+20).
- Threshold: Minimum **40 points** for auto-match without user intervention. - Threshold: Minimum **40 points** for auto-match without user intervention.
- **Targeted Field Scanning (v1.6.0):** UI allows "locking" the scanner focus to a specific input field (e.g., `box_label`). The OCR result is then redirected to state without performing regular item lookup.
### 4.3 Box Labeling & Printing System (v1.5.0)
- **Local OCR Priority:** Before checking individual S/Ns, the matching engine searches for `box_label` tokens. If a box is identified:
- Single Match: Directly opens stock adjustment.
- Multi Match: Opens "Box Contents" selection interstitial.
- **Label Generation:** Native SVG-based Code 128 and QR generation (`lib/labels.ts`). Requires ZERO external libraries for maximum offline stability.
- **Printing Modes:**
- @media print: Hardcoded CSS styles for 62mm x 29mm label dimensions.
- Mobile Export: Canvas-to-PNG rasterization for sharing with Bluetooth printer roll apps.
## 5. Offline Sync Protocol ## 5. Offline Sync Protocol
@@ -59,3 +71,27 @@ To prevent data loss in basements or unstable networks:
## 6. Automation & Versioning (`scripts/`) ## 6. Automation & Versioning (`scripts/`)
- **`scripts/save_version.py`**: Implements the `save-version` AI Command Shortcut. Increments `VERSION.json` patch version, commits all staged changes, creates a snapshot branch `v.X.Y.Z`, and calls `./export_prod.sh` to generate the production bundle. Always stays on the `dev` branch. - **`scripts/save_version.py`**: Implements the `save-version` AI Command Shortcut. Increments `VERSION.json` patch version, commits all staged changes, creates a snapshot branch `v.X.Y.Z`, and calls `./export_prod.sh` to generate the production bundle. Always stays on the `dev` branch.
## 7. Security & Hardening (v1.4.0)
To ensure enterprise-grade protection, the following policies are enforced:
### 7.1 Access Control & RBAC
- **Strict Separation:** Operations are divided into `user` and `admin` roles.
- **Admin Only:** Critical operations such as `DELETE /items/`, user management, and DB settings are restricted via the `auth.get_current_admin` dependency.
- **User Role:** Standard users are permitted to perform check-in/out and list inventory, but cannot delete catalog entries.
### 7.2 Brute-Force Protection
- **Rate Limiting:** Implemented via `slowapi`. The `login` endpoint is limited to **5 requests per minute** per IP to mitigate automated credential stuffing.
### 7.3 Data Privacy
- **Information Scrubbing:** Backend logs are configured to intercept and mask sensitive auth tokens or internal secrets (e.g., `JWT_SECRET_KEY`) during debug output.
- **Direct Bind LDAP:** Authentication uses direct user binding to the LDAP server, avoiding the need for a privileged service account with broad search permissions.
- **Cryptographic Credential Caching:** To support offline operations, the system caches a **PBKDF2-HMAC-SHA256 hash** of the user's Enterprise credentials upon successful online login. Plain text passwords are NEVER stored.
### 7.4 PWA Trust & Security
- **HTTPS Enforcement:** The system requires TLS (Port 8909) for camera access and secure token transmission.
- **Manifest Integrity:** A comprehensive `manifest.json` ensures the app is recognized as a trusted PWA on mobile platforms (iOS/Android).
7.5 Git Infrastructure Hardening (v1.7.0)
To ensure deployment stability on macOS environments with potentially broken developer tool links (`xcode-select` errors):
- **Direct Binary Mapping:** The system bypasses path resolution by using a hardcoded direct link to the Git binary in `.git_path` (`/Library/Developer/CommandLineTools/usr/bin/git`).
- **Persistence Mandate:** This path is protected by mandatory AI rules and must never be removed or modified to ensure `save-version` and automated deployment scripts remain functional.

View File

@@ -12,21 +12,21 @@ This project supports three distinct operational modes:
Ideal for local development on macOS/Linux. Ideal for local development on macOS/Linux.
* **Command:** `./start_server.sh` * **Command:** `./start_server.sh`
* **Details:** Runs FastAPI (backend) and Next.js (frontend) in development mode. Uses `local-ssl-proxy` for HTTPS. * **Details:** Runs FastAPI (backend) and Next.js (frontend) in development mode. Uses `local-ssl-proxy` for HTTPS.
* **Backend:** http://localhost:8000 * **Backend:** http://localhost:8906
* **Frontend:** https://localhost:3003 * **Frontend:** https://localhost:8909
### 2. 🐳 Docker Mode (Recommended for Production) ### 2. 🐳 Docker Mode (Recommended for Production)
Isolated and portable container stack. Isolated and portable container stack.
* **Command:** `docker-compose up -d --build` * **Command:** `docker-compose up -d --build`
* **Details:** Uses Caddy as a reverse proxy for HTTPS. Persistent data and logs are mapped to `./data` and `./logs`. * **Details:** Uses Caddy as a reverse proxy for HTTPS. Persistent data and logs are mapped to `./data` and `./logs`.
* **Access:** https://localhost:3003 * **Access:** https://localhost:8909
### 3. 🐧 Standalone Linux Mode (Systemd) ### 3. 🐧 Standalone Linux Mode (Systemd)
Native Linux installation (Alma/Debian/Ubuntu) without Docker dependencies. Native Linux installation (Alma/Debian/Ubuntu) without Docker dependencies.
* **Installation:** `sudo ./install_service.sh` * **Installation:** `sudo ./install_service.sh`
* **Execution:** `sudo systemctl start inventory` * **Execution:** `sudo systemctl start inventory`
* **Details:** Compiles the frontend for production and manages the entire stack as a system service. * **Details:** Compiles the frontend for production and manages the entire stack as a system service.
* **Access:** https://<SERVER-IP>:3003 * **Access:** https://<SERVER-IP>:8909
--- ---
@@ -34,7 +34,7 @@ Native Linux installation (Alma/Debian/Ubuntu) without Docker dependencies.
To generate a clean production package and snapshot the current state: To generate a clean production package and snapshot the current state:
1. Use the AI shortcut command: `save-version`. 1. Use the AI shortcut command: `save-version`.
2. Alternatively, run `./export_prod.sh` manually. 2. Alternatively, run `./export_prod.sh` manually.
3. A `.zip` archive will be created (e.g., `aInventory-PROD-v1.3.6.zip`). 3. A `.zip` archive will be created (e.g., `aInventory-PROD-v1.7.0.zip`).
4. A backup branch `v.1.3.x` will be created automatically. 4. A backup branch `v.1.3.x` will be created automatically.
--- ---
@@ -77,6 +77,12 @@ export ALLOWED_ORIGINS="https://your-domain.com"
docker-compose up -d --build docker-compose up -d --build
``` ```
### 🌐 Network & Port Customization
The application uses a central configuration file for all network settings:
- **Location:** `config/network_config.env`
- **Purpose:** Change the `SERVER_IP` (default: `192.168.84.113`) and reserved ports (`8906-8909`).
- **Mechanism:** Startup scripts automatically sync these settings to the frontend and Docker environment.
For detailed security audit report, see [dev_docs/SECURITY_REPORT.md](dev_docs/SECURITY_REPORT.md). For detailed security audit report, see [dev_docs/SECURITY_REPORT.md](dev_docs/SECURITY_REPORT.md).
--- ---

View File

@@ -19,7 +19,7 @@ The application is a **Progressive Web App**, which means you don't need to down
- **Default User:** On first installation, use `Admin` / `<initial-password>` (check your system administrator for the initial password). - **Default User:** On first installation, use `Admin` / `<initial-password>` (check your system administrator for the initial password).
- **Change Password:** We recommend changing your password immediately from the Admin settings. - **Change Password:** We recommend changing your password immediately from the Admin settings.
- **LDAP/Enterprise Login:** If your administrator has configured LDAP integration, you can log in with your company/domain account. The application will cache your password locally to allow offline access (e.g., in areas without signal like basements). - **LDAP/Enterprise Login:** If your administrator has configured LDAP integration, you can log in with your company/domain account. The application will securely cache a **cryptographic hash** of your credentials (using PBKDF2) to allow offline access (e.g., in areas without signal like basements). **Note: Your actual password is NEVER stored in plain text on the local device.**
- **JWT Tokens:** Your login session is secured with JWT bearer tokens that expire after 8 hours. You will be automatically logged out when your token expires. - **JWT Tokens:** Your login session is secured with JWT bearer tokens that expire after 8 hours. You will be automatically logged out when your token expires.
--- ---
@@ -31,16 +31,24 @@ The application supports two scanning modes:
### Manual / Barcode Scanning ### Manual / Barcode Scanning
Scan an existing barcode to locate or update an item in your inventory. Scan an existing barcode to locate or update an item in your inventory.
### Client-Side Label Scanning (OCR)
Point your device's camera at a product label. The scanner **automatically analyzes the label every 4 seconds** — no button press required. A countdown timer shows when the next scan will occur.
When text is detected:
1. A preview of the captured image appears.
2. Detected words are highlighted — tap the correct product name or serial number.
3. The selected text populates the relevant field automatically.
If no readable text is found, the scanner silently retries on the next cycle. If no readable text is found, the scanner silently retries on the next cycle.
### Box & Container Scanning (NEW v1.6.0)
You can now manage containers more efficiently with two specialized methods:
- **AI Box Discovery**: When adding a new container through **AI Discovery**, use the **"Box / Container"** toggle. Gemini will focus exclusively on the container's name, ignoring technical noise on labels.
- **Targeted Field Scanning**: In the **Edit Item** modal, tap the small **Camera icon** next to the "Box / Container Label" field. The scanner will capture the next physical label directly into the text field.
- **Automatic Matching**: In the main scanner, scanning a box identifies all its contents. Scanning a box and then an item will suggest linking them together if they aren't already matched.
---
## 🏷️ Label Printing
Administrators and users can generate physical labels for boxes to ensure 100% accurate scanning.
1. Tap the **Package (Box)** icon in the header to open the **Box Inventory**.
2. Find the box you want to label and tap **Print Label**.
3. **Desktop:** Use the print dialog to send the label directly to a Dymo/Brother thermal printer.
4. **Mobile:** Use **"Save for Mobile App"** to download a PNG image of the label, which you can then print using your Bluetooth printer's app (like NIIMBOT).
--- ---
## 📂 Inventory Organization ## 📂 Inventory Organization
@@ -92,6 +100,11 @@ If your organization uses LDAP/Active Directory, administrators can:
### Settings ### Settings
Access application settings from the **Admin** panel. Access application settings from the **Admin** panel.
### 🌐 Network & Configuration (NEW v1.8.0)
The application now uses a centralized configuration folder in the project root:
- **`config/`**: Contains all network settings (`network_config.env`), LDAP profiles (`ldap_config.json`), and security proxy rules (`Caddyfile`).
- **Dynamic Port Mapping**: Changes to the server IP or ports in the configuration are automatically detected by both the frontend and backend after a restart.
--- ---
## 🚨 Security Notices ## 🚨 Security Notices
@@ -133,5 +146,5 @@ For detailed technical documentation, see the [Project Architecture](../PROJECT_
--- ---
**Version:** v1.3.6 **Version:** v1.8.4
**Last Updated:** 2026-04-11 **Last Updated:** 2026-04-13

View File

@@ -1,10 +0,0 @@
{
"version": "1.3.8",
"last_build": "2026-04-11-1946",
"commit": "0869ab8c",
"changelog": [
"v1.3.8: Remove .npx_cache/ and scratch/npm_cache/ from git tracking (3350 files purged from index)",
"v1.3.7: Security hardening — 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"
]
}

View File

@@ -19,6 +19,9 @@ RUN adduser --system --group appuser
# Copy application files # Copy application files
COPY backend ./backend COPY backend ./backend
# Copy initialization scripts (shared with start_server.sh)
COPY scripts ./scripts
# We define the data dir explicitly for Docker # We define the data dir explicitly for Docker
ENV DATA_DIR="/app/data" ENV DATA_DIR="/app/data"
ENV LOGS_DIR="/app/logs" ENV LOGS_DIR="/app/logs"
@@ -27,9 +30,12 @@ ENV LOGS_DIR="/app/logs"
# although Docker volumes will handle ownership context. # although Docker volumes will handle ownership context.
RUN mkdir -p /app/data /app/logs && chown -R appuser:appuser /app RUN mkdir -p /app/data /app/logs && chown -R appuser:appuser /app
# Make initialization scripts executable
RUN chmod +x /app/scripts/init_data.sh /app/backend/entrypoint.sh
USER appuser USER appuser
EXPOSE 8000 EXPOSE 8000
# Start Uvicorn pointing to the backend module # Entrypoint runs init_data.sh first, then starts uvicorn
CMD ["python", "-m", "uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8000"] ENTRYPOINT ["/app/backend/entrypoint.sh"]

View File

@@ -7,36 +7,55 @@ base_dir = os.path.dirname(os.path.abspath(__file__))
dotenv_path = os.path.join(base_dir, ".env") dotenv_path = os.path.join(base_dir, ".env")
load_dotenv(dotenv_path) load_dotenv(dotenv_path)
def extract_label_info(image_bytes: bytes): def extract_label_info(image_bytes: bytes, mode: str = "item"):
""" """
Orchestrates extraction across multiple AI providers. Orchestrates extraction across multiple AI providers.
Order: Gemini (Flash/Pro) -> Claude (Haiku/Sonnet) Modes: 'item' (full technical extraction), 'box' (container discovery)
"""
prompt = """
Extract technical inventory information from this label image.
CRITICAL INSTRUCTIONS:
1. Look at the most prominent text (usually top 1-2 rows). This is the product NAME and MODEL.
2. Extract the PART NUMBER (P/N, Model No, Type). If no explicit Part Number is found, synthesize one from the most unique identifier in the header (e.g. 'OM4-MMF-DX').
3. Separate the COLOR (e.g. Turquoise, Yellow, Black).
4. Extract CATEGORY based on the item type (e.g. Patchcord, SFP, Connector).
5. Extract technical SPECS (e.g. '2.0mm', '10G', '850nm').
Return ONLY a valid JSON object:
{
"name": "Full descriptive name from header",
"part_number": "Unique identifier for fast scanning",
"category": "Broad category",
"color": "Color if present",
"specs": "Brief tech specs list",
"barcode": "Barcode value if visible",
"quantity": 1
}
""" """
if mode == "box":
prompt = """
Identify the CONTAINER or BOX name from this image.
Look for large, prominent, bold, or hand-written text that identifies a storage unit.
Ignore small technical details, quantities, or fine print.
Return ONLY a valid JSON object:
{
"box_label": "The identified container name",
"name": "Same as box_label",
"category": "Storage",
"specs": "Brief description if useful",
"quantity": 1
}
"""
else:
prompt = """
Extract technical inventory information from this label image.
CRITICAL INSTRUCTIONS:
1. Look at the most prominent text (usually top 1-2 rows). This is the product NAME and MODEL.
2. Extract the PART NUMBER (P/N, Model No, Type). If no explicit Part Number is found, synthesize one from the most unique identifier in the header (e.g. 'OM4-MMF-DX').
3. Separate the COLOR (e.g. Turquoise, Yellow, Black).
4. Extract CATEGORY based on the item type (e.g. Patchcord, SFP, Connector).
5. Extract technical SPECS (e.g. '2.0mm', '10G', '850nm').
Return ONLY a valid JSON object:
{
"name": "Full descriptive name from header",
"part_number": "Unique identifier for fast scanning",
"category": "Broad category",
"color": "Color if present",
"specs": "Brief tech specs list",
"barcode": "Barcode value if visible",
"quantity": 1
}
"""
# 1. Try Gemini # 1. Try Gemini
result = gemini.extract(image_bytes, prompt) result = gemini.extract(image_bytes, prompt)
if result: if result:
# Maintenance: Ensure fields are mapped if mode was box
if mode == "box" and "box_label" in result and "name" not in result:
result["name"] = result["box_label"]
return result return result
# 2. Try Claude (Fallback) # 2. Try Claude (Fallback)

View File

@@ -66,8 +66,7 @@ async def get_current_user(credentials = Depends(security)):
token = credentials.credentials token = credentials.credentials
import logging import logging
log = logging.getLogger("ainventory") log = logging.getLogger("ainventory")
log.debug(f"[AUTH] Validating token (first 20 chars): {token[:20] if token else 'None'}") log.debug(f"[AUTH] Validating token (first 10 chars): {token[:10] if token else 'None'}")
log.debug(f"[AUTH] Using SECRET_KEY (first 10 chars): {SECRET_KEY[:10] if SECRET_KEY else 'None'}")
try: try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
user_id: int = int(payload.get("sub")) # sub is stored as string, convert back to int user_id: int = int(payload.get("sub")) # sub is stored as string, convert back to int

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)}")

28
backend/entrypoint.sh Executable file
View File

@@ -0,0 +1,28 @@
#!/bin/bash
# =============================================================================
# backend/entrypoint.sh
# =============================================================================
# Docker container entrypoint for TFM aInventory backend.
# Runs first-run initialization then starts the application server.
#
# This script is the ENTRYPOINT defined in backend/Dockerfile.
# DATA_DIR and LOGS_DIR are set via docker-compose.yml environment section.
# =============================================================================
set -euo pipefail
echo "🐳 [Docker] Backend container starting..."
echo "🐳 [Docker] DATA_DIR=${DATA_DIR:-/app/data}"
echo "🐳 [Docker] LOGS_DIR=${LOGS_DIR:-/app/logs}"
# Export defaults if not already set by docker-compose
export DATA_DIR="${DATA_DIR:-/app/data}"
export LOGS_DIR="${LOGS_DIR:-/app/logs}"
# Run shared first-run initialization
echo "🐳 [Docker] Running data initialization..."
bash /app/scripts/init_data.sh
# Hand off to the application server
echo "🐳 [Docker] Starting uvicorn..."
exec python -m uvicorn backend.main:app --host 0.0.0.0 --port 8000

View File

@@ -1 +0,0 @@
{"ldap_enabled": true, "server_uri": "ldap://192.168.84.107:3890", "base_dn": "dc=example,dc=com", "user_template": "cn={username},ou=people,dc=example,dc=com", "groups_dn": "ou=groups", "use_tls": false, "role_mappings": [{"group": "inventory_admins", "role": "admin"}, {"group": "inventory_users", "role": "user"}]}

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
@@ -23,7 +24,7 @@ log.info("TFM aInventory API process started.")
# Secure fallback: localhost only for development. # Secure fallback: localhost only for development.
_raw_origins = os.environ.get( _raw_origins = os.environ.get(
"ALLOWED_ORIGINS", "ALLOWED_ORIGINS",
"http://localhost:3000,http://localhost:3002" "http://localhost:8907,https://localhost:8909"
) )
ALLOWED_ORIGINS = [o.strip() for o in _raw_origins.split(",") if o.strip()] ALLOWED_ORIGINS = [o.strip() for o in _raw_origins.split(",") if o.strip()]
log.info(f"CORS allowed origins: {ALLOWED_ORIGINS}") log.info(f"CORS allowed origins: {ALLOWED_ORIGINS}")
@@ -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

@@ -41,6 +41,9 @@ class Item(Base):
min_quantity = Column(Float, default=1.0) min_quantity = Column(Float, default=1.0)
image_url = Column(String, nullable=True) image_url = Column(String, nullable=True)
# Generic box/container association for multi-item OCR scanning
box_label = Column(String, index=True, nullable=True)
# Full AI metadata # Full AI metadata
labels_data = Column(Text, nullable=True) labels_data = Column(Text, nullable=True)
@@ -48,10 +51,14 @@ 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)
target_item_name = Column(String, nullable=True)
target_item_pn = Column(String, nullable=True)
target_item_barcode = Column(String, nullable=True)
target_snapshot = Column(Text, nullable=True) # Full JSON snapshot
quantity_change = Column(Float, nullable=True) quantity_change = Column(Float, nullable=True)
uuid = Column(String, unique=True, index=True, nullable=True) uuid = Column(String, unique=True, index=True, nullable=True)
details = Column(Text, nullable=True) # For reasons, sync notes, etc. details = Column(Text, nullable=True) # For reasons, sync notes, etc.
@@ -64,7 +71,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 +85,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

@@ -2,6 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File,
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import func from sqlalchemy import func
from typing import List from typing import List
import json
from slowapi import Limiter from slowapi import Limiter
from slowapi.util import get_remote_address from slowapi.util import get_remote_address
from .. import models, schemas, auth from .. import models, schemas, auth
@@ -65,6 +66,7 @@ _MAX_IMAGE_SIZE = 10 * 1024 * 1024 # 10 MB
async def extract_label( async def extract_label(
request: Request, request: Request,
file: UploadFile = File(...), file: UploadFile = File(...),
mode: str = "item", # 'item' or 'box'
current_user: auth.TokenData = Depends(auth.get_current_user) current_user: auth.TokenData = Depends(auth.get_current_user)
): ):
"""[C-01] Extract label from image — only for authenticated users. [H-02] Rate limit: 10 req/min per IP.""" """[C-01] Extract label from image — only for authenticated users. [H-02] Rate limit: 10 req/min per IP."""
@@ -85,7 +87,7 @@ async def extract_label(
detail="File exceeds 10MB limit." detail="File exceeds 10MB limit."
) )
result = extract_label_info(contents) result = extract_label_info(contents, mode=mode)
return result return result
@router.post("/", response_model=schemas.Item, status_code=status.HTTP_201_CREATED) @router.post("/", response_model=schemas.Item, status_code=status.HTTP_201_CREATED)
@@ -106,10 +108,27 @@ def create_item(
db.refresh(db_item) db.refresh(db_item)
# Audit log the creation — [M-02] user_id from token, not from body # Audit log the creation — [M-02] user_id from token, not from body
# Capture full snapshot
item_snapshot = {
"barcode": db_item.barcode,
"name": db_item.name,
"category": db_item.category,
"type": db_item.type,
"part_number": db_item.part_number,
"color": db_item.color,
"specs": db_item.specs,
"box_label": db_item.box_label,
"image_url": db_item.image_url
}
audit = models.AuditLog( audit = models.AuditLog(
user_id=current_user.sub, user_id=current_user.sub,
action="CREATE_ITEM", action="CREATE_ITEM",
target_item_id=db_item.id, target_item_id=db_item.id,
target_item_name=db_item.name,
target_item_pn=db_item.part_number,
target_item_barcode=db_item.barcode,
target_snapshot=json.dumps(item_snapshot),
quantity_change=item.quantity quantity_change=item.quantity
) )
db.add(audit) db.add(audit)
@@ -141,13 +160,46 @@ def update_item(
def delete_item( def delete_item(
item_id: int, item_id: int,
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_admin)
): ):
"""[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 database and disk
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}")
item_snapshot = {
"barcode": db_item.barcode,
"name": db_item.name,
"category": db_item.category,
"type": db_item.type,
"part_number": db_item.part_number,
"color": db_item.color,
"specs": db_item.specs,
"box_label": db_item.box_label,
"image_url": db_item.image_url,
"final_quantity": db_item.quantity
}
audit = models.AuditLog(
user_id=current_user.sub,
action="DELETE_ITEM",
target_item_id=item_id,
target_item_name=db_item.name,
target_item_pn=db_item.part_number,
target_item_barcode=db_item.barcode,
target_snapshot=json.dumps(item_snapshot),
details=f"Final Quantity: {db_item.quantity}"
)
db.add(audit)
# [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."}

View File

@@ -3,6 +3,7 @@ from typing import List
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .. import models, schemas, auth from .. import models, schemas, auth
from ..database import get_db from ..database import get_db
import json
router = APIRouter( router = APIRouter(
prefix="/operations", prefix="/operations",
@@ -27,10 +28,21 @@ def check_in_item(
item.quantity += op.quantity item.quantity += op.quantity
# Create Mandatory Audit Log — [M-02] user_id from token # Create Mandatory Audit Log — [M-02] user_id from token
item_snapshot = {
"barcode": item.barcode,
"name": item.name,
"category": item.category,
"part_number": item.part_number
}
audit = models.AuditLog( audit = models.AuditLog(
user_id=current_user.sub, user_id=current_user.sub,
action="CHECK_IN", action="CHECK_IN",
target_item_id=item.id, target_item_id=item.id,
target_item_name=item.name,
target_item_pn=item.part_number,
target_item_barcode=item.barcode,
target_snapshot=json.dumps(item_snapshot),
quantity_change=op.quantity quantity_change=op.quantity
) )
@@ -60,10 +72,21 @@ def check_out_item(
item.quantity -= op.quantity item.quantity -= op.quantity
# Create Mandatory Audit Log # Create Mandatory Audit Log
item_snapshot = {
"barcode": item.barcode,
"name": item.name,
"category": item.category,
"part_number": item.part_number
}
audit = models.AuditLog( audit = models.AuditLog(
user_id=current_user.sub, user_id=current_user.sub,
action="CHECK_OUT", action="CHECK_OUT",
target_item_id=item.id, target_item_id=item.id,
target_item_name=item.name,
target_item_pn=item.part_number,
target_item_barcode=item.barcode,
target_snapshot=json.dumps(item_snapshot),
quantity_change=-op.quantity quantity_change=-op.quantity
) )
@@ -93,10 +116,21 @@ def trash_item(
item.quantity -= op.quantity item.quantity -= op.quantity
# Create Mandatory Audit Log with TRASH action and reason in details # Create Mandatory Audit Log with TRASH action and reason in details
item_snapshot = {
"barcode": item.barcode,
"name": item.name,
"category": item.category,
"part_number": item.part_number
}
audit = models.AuditLog( audit = models.AuditLog(
user_id=current_user.sub, user_id=current_user.sub,
action="TRASH", action="TRASH",
target_item_id=item.id, target_item_id=item.id,
target_item_name=item.name,
target_item_pn=item.part_number,
target_item_barcode=item.barcode,
target_snapshot=json.dumps(item_snapshot),
quantity_change=-op.quantity, quantity_change=-op.quantity,
details=op.reason details=op.reason
) )
@@ -130,10 +164,21 @@ def bulk_check_out(
item.quantity -= op.quantity item.quantity -= op.quantity
# Log individual audit for this item # Log individual audit for this item
item_snapshot = {
"barcode": item.barcode,
"name": item.name,
"category": item.category,
"part_number": item.part_number
}
audit = models.AuditLog( audit = models.AuditLog(
user_id=current_user.sub, user_id=current_user.sub,
action="BULK_CHECK_OUT", action="BULK_CHECK_OUT",
target_item_id=item.id, target_item_id=item.id,
target_item_name=item.name,
target_item_pn=item.part_number,
target_item_barcode=item.barcode,
target_snapshot=json.dumps(item_snapshot),
quantity_change=-op.quantity quantity_change=-op.quantity
) )
db.add(audit) db.add(audit)
@@ -182,10 +227,21 @@ def bulk_sync(
continue continue
# Log audit with original offline timestamp and UUID # Log audit with original offline timestamp and UUID
item_snapshot = {
"barcode": item.barcode,
"name": item.name,
"category": item.category,
"part_number": item.part_number
}
audit = models.AuditLog( audit = models.AuditLog(
user_id=current_user.sub, user_id=current_user.sub,
action=op.type, action=op.type,
target_item_id=item.id, target_item_id=item.id,
target_item_name=item.name,
target_item_pn=item.part_number,
target_item_barcode=item.barcode,
target_snapshot=json.dumps(item_snapshot),
quantity_change=change, quantity_change=change,
timestamp=op.timestamp, timestamp=op.timestamp,
uuid=op.uuid, uuid=op.uuid,
@@ -215,6 +271,10 @@ def get_logs(
models.User.username, models.User.username,
models.AuditLog.action, models.AuditLog.action,
models.AuditLog.target_item_id, models.AuditLog.target_item_id,
models.AuditLog.target_item_name,
models.AuditLog.target_item_pn,
models.AuditLog.target_item_barcode,
models.AuditLog.target_snapshot,
models.AuditLog.quantity_change, models.AuditLog.quantity_change,
models.AuditLog.details models.AuditLog.details
).join(models.User, models.AuditLog.user_id == models.User.id).order_by(models.AuditLog.timestamp.desc()).limit(limit).all() ).join(models.User, models.AuditLog.user_id == models.User.id).order_by(models.AuditLog.timestamp.desc()).limit(limit).all()
@@ -228,6 +288,10 @@ def get_logs(
"username": l.username, "username": l.username,
"action": l.action, "action": l.action,
"target_item_id": l.target_item_id, "target_item_id": l.target_item_id,
"target_item_name": l.target_item_name,
"target_item_pn": l.target_item_pn,
"target_item_barcode": l.target_item_barcode,
"target_snapshot": l.target_snapshot,
"quantity_change": l.quantity_change, "quantity_change": l.quantity_change,
"details": l.details "details": l.details
} for l in logs_with_users } for l in logs_with_users

View File

@@ -1,22 +1,28 @@
import secrets import secrets
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from typing import List from typing import List
from slowapi import Limiter
from slowapi.util import get_remote_address
from passlib.context import CryptContext from passlib.context import CryptContext
import ldap3 import ldap3
from ldap3 import Tls
from ldap3.utils.conv import escape_filter_chars from ldap3.utils.conv import escape_filter_chars
from ldap3.utils.dn import escape_rdn from ldap3.utils.dn import escape_rdn
import ssl
import json import json
import os import os
from .. import models, schemas, database, auth from .. import models, schemas, database, auth
from ..logger import log from ..logger import log
router = APIRouter(prefix="/users", tags=["users"]) router = APIRouter(prefix="/users", tags=["users"])
limiter = Limiter(key_func=get_remote_address)
pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto") pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
def get_ldap_config(): def get_ldap_config():
# Read from backend/config/ directory # Read from root /config directory
config_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "config") root_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
config_dir = os.path.join(root_dir, "config")
config_path = os.path.join(config_dir, "ldap_config.json") config_path = os.path.join(config_dir, "ldap_config.json")
if os.path.exists(config_path): if os.path.exists(config_path):
with open(config_path, "r") as f: with open(config_path, "r") as f:
@@ -31,7 +37,22 @@ def authenticate_ldap(username, password):
log.debug(f"LDAP: Config loaded: server_uri={config.get('server_uri')}, base_dn={config.get('base_dn')}") log.debug(f"LDAP: Config loaded: server_uri={config.get('server_uri')}, base_dn={config.get('base_dn')}")
try: try:
server = ldap3.Server(config["server_uri"], use_ssl=config.get("use_tls", False), get_info=ldap3.ALL) tls_config = None
if config.get("use_tls", False):
if config.get("ignore_cert", False):
# [SECURITY] CERT_NONE is only for internal test environments with self-signed certs
tls_config = Tls(validate=ssl.CERT_NONE, version=ssl.PROTOCOL_TLSv1_2)
log.warning("LDAP: TLS Certificate Validation DISABLED (ignore_cert=true)")
else:
tls_config = Tls(validate=ssl.CERT_REQUIRED, version=ssl.PROTOCOL_TLSv1_2)
log.debug("LDAP: TLS Certificate Validation ENABLED (CERT_REQUIRED)")
server = ldap3.Server(
config["server_uri"],
use_ssl=config.get("use_tls", False),
tls=tls_config,
get_info=ldap3.ALL
)
log.debug(f"LDAP: Server object created: {config['server_uri']}") log.debug(f"LDAP: Server object created: {config['server_uri']}")
safe_username_rdn = escape_rdn(username) safe_username_rdn = escape_rdn(username)
user_dn = config["user_template"].format(username=safe_username_rdn) user_dn = config["user_template"].format(username=safe_username_rdn)
@@ -98,10 +119,31 @@ def authenticate_ldap(username, password):
return assigned_role return assigned_role
except Exception as e: except Exception as e:
log.error(f"LDAP: Auth Error: {type(e).__name__}: {str(e)}") err_msg = str(e)
err_type = type(e).__name__
log.error(f"LDAP: Auth Error: {err_type}: {err_msg}")
# Broad detection for SSL/TLS certificate/handshake or connectivity errors
# handles both ldapsearch style "Can't contact" and ldap3 style "socket ssl wrapping error"
ssl_indicators = ["certificate", "ssl", "tls", "handshake", "verify failed", "contact", "socket"]
if any(ind in err_msg.lower() for ind in ssl_indicators):
log.warning(f"LDAP: SSL/TLS or Connectivity issue detected: {err_msg}")
# User-friendly error message, hiding raw socket traces
friendly_msg = "Secure Connection Failed: The enterprise server's security certificate is not trusted or the connection dropped."
if config.get("use_tls"):
friendly_msg += " If this is an internal test environment, please ask an Admin to enable 'Ignore Certificate Validation'."
raise HTTPException(
status_code=401,
detail=friendly_msg
)
import traceback import traceback
log.debug(f"LDAP: Full traceback: {traceback.format_exc()}") log.debug(f"LDAP: Full traceback: {traceback.format_exc()}")
return None return None
pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto") pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
def get_db(): def get_db():
@@ -158,7 +200,8 @@ def create_user(
return new_user return new_user
@router.post("/login", response_model=schemas.TokenResponse) @router.post("/login", response_model=schemas.TokenResponse)
def login(form_data: schemas.UserLogin, db: Session = Depends(get_db)): @limiter.limit("5/minute")
def login(request: Request, form_data: schemas.UserLogin, db: Session = Depends(database.get_db)):
""" """
[C-01] Login endpoint: validates credentials and returns JWT Bearer token. [C-01] Login endpoint: validates credentials and returns JWT Bearer token.
""" """
@@ -272,7 +315,8 @@ def update_ldap_settings(
current_user: auth.TokenData = Depends(auth.get_current_admin) current_user: auth.TokenData = Depends(auth.get_current_admin)
): ):
"""[C-01] Update LDAP config — admin only.""" """[C-01] Update LDAP config — admin only."""
config_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "config") root_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
config_dir = os.path.join(root_dir, "config")
os.makedirs(config_dir, exist_ok=True) os.makedirs(config_dir, exist_ok=True)
config_path = os.path.join(config_dir, "ldap_config.json") config_path = os.path.join(config_dir, "ldap_config.json")
with open(config_path, "w") as f: with open(config_path, "w") as f:
@@ -307,15 +351,36 @@ def test_ldap_connection(
s.close() s.close()
if result == 0: if result == 0:
# Socket is open! Now try LDAP library # Socket is open! Now try LDAP library probe
try: try:
server = ldap3.Server(config["server_uri"], connect_timeout=5) tls_config = None
if config.get("use_tls", False):
if config.get("ignore_cert", False):
tls_config = Tls(validate=ssl.CERT_NONE, version=ssl.PROTOCOL_TLSv1_2)
else:
tls_config = Tls(validate=ssl.CERT_REQUIRED, version=ssl.PROTOCOL_TLSv1_2)
server = ldap3.Server(
config["server_uri"],
connect_timeout=5,
get_info=ldap3.BASIC,
use_ssl=config.get("use_tls", False),
tls=tls_config
)
# Try a connection without auto-bind first to see if it's an LDAP server
conn = ldap3.Connection(server, auto_bind=False) conn = ldap3.Connection(server, auto_bind=False)
if conn.open(): if conn.open():
return {"status": "success", "message": "Connection Successful"} return {"status": "success", "message": "LDAP Connection Successful (Server Reachable)"}
return {"status": "success", "message": "Server reachable (Socket open, but LDAP probe failed)"}
except: # If open fails, it might just be the server policy.
return {"status": "success", "message": "Server reachable (Socket open)"} # Since the port is open, we report success at the network level.
return {"status": "success", "message": "Connection Successful (Network reachable, protocol handshake restricted by server security)"}
except Exception as e:
# Any LDAP level error while socket is open is still a partial success
err_msg = str(e)
if "certificate verify failed" in err_msg.lower() or "self signed certificate" in err_msg.lower():
return {"status": "error", "message": f"SSL/TLS Certificate Rejected: The server certificate is self-signed or invalid. Enable 'Ignore Certificate Validation' to bypass."}
return {"status": "success", "message": f"Partial Success: TCP Port {port} is open, but LDAP handshake was rejected: {err_msg}"}
else: else:
# Socket failed, let's try calling system 'ldapsearch' as a last resort diagnostic # Socket failed, let's try calling system 'ldapsearch' as a last resort diagnostic
import subprocess import subprocess
@@ -346,6 +411,9 @@ def delete_user(
if user.username == "Admin": if user.username == "Admin":
raise HTTPException(status_code=400, detail="Cannot delete default Admin") raise HTTPException(status_code=400, detail="Cannot delete default Admin")
is_ldap = user.origin == "ldap"
db.delete(user) db.delete(user)
db.commit() db.commit()
return {"message": "User deleted"}
log.info(f"User {user_id} ({user.username}) deleted by Admin. Source: {user.origin}")
return {"message": "User deleted" if not is_ldap else "LDAP cache cleared for this user"}

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

@@ -64,6 +64,7 @@ class ItemBase(BaseModel):
quantity: float = 0.0 quantity: float = 0.0
min_quantity: float = 1.0 min_quantity: float = 1.0
image_url: Optional[str] = None image_url: Optional[str] = None
box_label: Optional[str] = None
labels_data: Optional[str] = None labels_data: Optional[str] = None
class ItemCreate(ItemBase): class ItemCreate(ItemBase):
@@ -111,8 +112,36 @@ class AuditLogResponse(BaseModel):
username: Optional[str] = None username: Optional[str] = None
action: str action: str
target_item_id: Optional[int] target_item_id: Optional[int]
target_item_name: Optional[str] = None
target_item_pn: Optional[str] = None
target_item_barcode: Optional[str] = None
target_snapshot: Optional[str] = None
quantity_change: Optional[float] quantity_change: Optional[float]
details: Optional[str] = None details: Optional[str] = None
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

@@ -0,0 +1,92 @@
import requests
import time
import json
import sys
BASE_URL = "http://localhost:8000"
def log_test(name, status, details=""):
icon = "" if status == "PASS" else ""
print(f"{icon} [{name}] - {details}")
def run_api_suite():
print(f"\n🚀 Starting API Testing Suite (Postman-style logic)\n" + "-"*50)
# 1. AUTHENTICATION TEST
try:
login_res = requests.post(f"{BASE_URL}/users/login", json={
"username": "Admin",
"password": "admin"
})
if login_res.status_code == 200:
token = login_res.json()["access_token"]
log_test("Auth: Admin Login", "PASS", f"Status: {login_res.status_code}")
else:
log_test("Auth: Admin Login", "FAIL", f"Status: {login_res.status_code}")
return
except Exception as e:
log_test("Auth: Admin Login", "FAIL", str(e))
return
headers = {"Authorization": f"Bearer {token}"}
# 2. STATUS CODES & CRUD
try:
item_res = requests.get(f"{BASE_URL}/items/", headers=headers)
if item_res.status_code == 200:
log_test("Items: List All", "PASS", f"Returned {len(item_res.json())} items")
else:
log_test("Items: List All", "FAIL", f"Status: {item_res.status_code}")
except Exception as e:
log_test("Items: List All", "FAIL", str(e))
# 3. RATE LIMITING TEST (Security Policy)
print("\n⏳ Testing Rate Limiting (Anti Brute-Force)...")
limit_hit = False
for i in range(12): # More than the 5/min limit
res = requests.post(f"{BASE_URL}/users/login", json={"username": "fake", "password": "fake"})
if res.status_code == 429:
limit_hit = True
log_test("Security: Rate Limiter", "PASS", f"Blocked at attempt {i+1} (429 Too Many Requests)")
break
if not limit_hit:
log_test("Security: Rate Limiter", "FAIL", "Limiter did not trigger after 12 quick requests")
# 4. RBAC PROTECTION
# Create a test item to delete
test_item = requests.post(f"{BASE_URL}/items/", headers=headers, json={
"barcode": "API-TEST-999",
"name": "API Test Item",
"category": "Testing",
"quantity": 10,
"min_quantity": 1
})
if test_item.status_code == 201:
item_id = test_item.json()["id"]
log_test("Items: Create test resource", "PASS", f"ID: {item_id}")
# Now try to delete it (as Admin - should pass)
del_res = requests.delete(f"{BASE_URL}/items/{item_id}", headers=headers)
if del_res.status_code == 200:
log_test("RBAC: Admin Delete", "PASS", "Resource purged successfully")
else:
log_test("RBAC: Admin Delete", "FAIL", f"Status: {del_res.status_code}")
else:
# Check if it already exists from previous failed run
if test_item.status_code == 400:
log_test("Items: Create test resource", "PASS", "Resource already exists")
else:
log_test("Items: Create test resource", "FAIL", f"Status: {test_item.status_code}")
# 5. ERROR STATES
unauth_res = requests.get(f"{BASE_URL}/items/stats")
if unauth_res.status_code == 401:
log_test("Security: Unauth Block", "PASS", "Blocked 401 Unauthorized")
else:
log_test("Security: Unauth Block", "FAIL", f"Server allowed access! Status: {unauth_res.status_code}")
print("\n" + "-"*50 + "\n🏁 API Test Suite Finished.")
if __name__ == "__main__":
run_api_suite()

View File

@@ -1,12 +1,12 @@
# TFM aInventory - Caddy Self-Signed Internal Proxy # TFM aInventory - Caddy Self-Signed Internal Proxy
# This replaces the need for `local-ssl-proxy` in Node. # This replaces the need for `local-ssl-proxy` in Node.
:3003 { :{$FRONTEND_SSL_PORT} {
tls internal tls internal
reverse_proxy frontend:3000 reverse_proxy frontend:3000
} }
:3002 { :{$BACKEND_SSL_PORT} {
tls internal tls internal
reverse_proxy backend:8000 reverse_proxy backend:8000
} }

View File

@@ -17,8 +17,8 @@ JWT_SECRET_KEY=change-me-generate-a-secure-random-value
# --- CORS --- # --- CORS ---
# Comma-separated list of allowed frontend origins # Comma-separated list of allowed frontend origins
# Example for LAN deployment: # Example for LAN deployment:
# ALLOWED_ORIGINS=http://192.168.1.100:3000,https://192.168.1.100:3003 # ALLOWED_ORIGINS=http://192.168.84.113:8907,https://192.168.84.113:8909
ALLOWED_ORIGINS=http://localhost:3000,https://localhost:3003 ALLOWED_ORIGINS=http://localhost:8907,https://localhost:8909
# --- Data Paths (overridden by start_server.sh / docker-compose) --- # --- Data Paths (overridden by start_server.sh / docker-compose) ---
# DATA_DIR=/absolute/path/to/data # DATA_DIR=/absolute/path/to/data

19
config/ldap_config.json Normal file
View File

@@ -0,0 +1,19 @@
{
"ldap_enabled": true,
"server_uri": "ldaps://192.168.84.107:6360",
"base_dn": "dc=ldap,dc=lan",
"user_template": "uid={username},ou=people,dc=ldap,dc=lan",
"groups_dn": "ou=groups",
"use_tls": true,
"role_mappings": [
{
"group": "inventory_admins",
"role": "admin"
},
{
"group": "inventory_users",
"role": "user"
}
],
"ignore_cert": true
}

View File

@@ -6,6 +6,7 @@
"user_template": "cn={username},ou=people,dc=yourdomain,dc=com", "user_template": "cn={username},ou=people,dc=yourdomain,dc=com",
"groups_dn": "ou=groups", "groups_dn": "ou=groups",
"use_tls": false, "use_tls": false,
"ignore_cert": false,
"role_mappings": [ "role_mappings": [
{ {
"group": "inventory_admins", "group": "inventory_admins",

28
config/network_config.env Normal file
View File

@@ -0,0 +1,28 @@
# =============================================================================
# TFM aInventory - Central Network Configuration
# =============================================================================
# Use this file to customize the ports and IP address used by the application
# without needing to modify the source code.
#
# IMPORTANT: After modifying this file, restart the application for changes
# to take effect.
# =============================================================================
# The primary IP address where the application will be accessed.
# Used for CORS settings and documentation links.
SERVER_IP=192.168.84.113
# --- BACKEND PORTS ---
# Internal port for the FastAPI server (Backend)
BACKEND_PORT=8906
# External port for the Backend HTTPS Proxy (Caddy/local-ssl-proxy)
BACKEND_SSL_PORT=8908
# --- FRONTEND PORTS ---
# Internal port for the Next.js dev/prod server
FRONTEND_PORT=8907
# External port for the Frontend HTTPS Proxy (Caddy/local-ssl-proxy)
# This is the port you will use in your browser (e.g. https://192.168.84.113:8909)
FRONTEND_SSL_PORT=8909

4
data/.gitkeep Normal file
View File

@@ -0,0 +1,4 @@
# This file exists to track the data/ directory in Git.
# The actual runtime data (inventory.db, ldap_config.json, caddy volumes)
# is excluded via .gitignore and must NEVER be committed.
# On a fresh clone, run ./start_server.sh or docker compose up to initialize.

Binary file not shown.

View File

@@ -1 +0,0 @@
{"ldap_enabled": true, "server_uri": "ldap://192.168.84.107:3890", "base_dn": "dc=example,dc=com", "user_template": "cn={username},ou=people,dc=example,dc=com", "groups_dn": "ou=groups", "use_tls": false, "role_mappings": [{"group": "inventory_admins", "role": "admin"}, {"group": "inventory_users", "role": "user"}]}

View File

@@ -1,3 +1,36 @@
### [2026-04-12] v1.5.0: Box Management, Local OCR & Dependency-Free Label Printing
**Purpose:** Implementation of a local-first box scanning workflow, including multi-item container selection and professional label generation without external libraries.
**Actions:**
- `backend/models.py` & `schemas.py` — Added `box_label` field and integrated it into Pydantic models.
- `backend/routers/items.py` — Updated item creation and deletion to include `box_label` in immutable AuditLog snapshots.
- `frontend/lib/db.ts` — Upgraded IndexedDB (Dexie) to v4 with an index on `box_label` for high-speed local searching.
- `frontend/app/page.tsx` — Rewrote `onOCRMatch` to prioritize box matching. Implemented "Box Contents" selection modal and a full Box Inventory management dashboard.
- `frontend/components/AIOnboarding.tsx` — Added Box Label association to the AI-powered onboarding form.
- `frontend/lib/labels.ts` (NEW) — Developed a 100% dependency-free SVG engine for Barcode 128 and QR Code generation.
- `scripts/save_version.py` — Updated script to automatically synchronize `dev` changes into the `master` branch during releases.
- `AI_RULES.md` — Added Rule 1.1 for mandatory "Plan Retirement" and strict traceability.
- `USER_GUIDE.md` & `PROJECT_ARCHITECTURE.md` — Full documentation of the new container-based logic.
**Status:** Stable. Build v1.5.0 release branch created and merged into Master.
---
### [2026-04-12] v1.4.1: Security Hardening, PWA Optimization & Modern CSS Upgrade
**Purpose:** Implementation of security audit recommendations, REST API test suite, PWA asset generation, and visual UI refinements using Modern CSS.
**Actions:**
- `backend/routers/users.py` — Implemented rate limiting (5 req/min) on login and restricted `DELETE /items/` to Admin role.
- `backend/tests/api_bench.py` (NEW) — Created automated API testing suite for Auth, RBAC, and Security verification.
- `frontend/public/icons/` — Generated standard and maskable PWA icons from source logo.
- `frontend/public/manifest.json` — Upgraded with maskable support, shortcuts, and orientation lock.
- `frontend/app/layout.tsx` — Added iOS-specific native meta tags for a "Premium" look.
- `frontend/app/globals.css` — Added `.glass-card` and `.pb-safe` (safe-area) CSS utilities.
- `frontend/app/admin/page.tsx` — Restored Dual LDAP group mappings and applied Glassmorphism styling.
- `export_prod.sh` — Excluded `tests/` and benchmarking scripts from production bundle.
- `PROJECT_ARCHITECTURE.md` — Added Section 7 documenting Security & Hardening policies.
- `SESSION_STATE.md` — Updated session status and handover note.
**Status:** Stable. Build v1.4.1 release confirmed and committed.
---
### [2026-04-11] v1.3.6: Scanner Redesign, Auto-OCR Countdown & save-version Automation ### [2026-04-11] v1.3.6: Scanner Redesign, Auto-OCR Countdown & save-version Automation
**Purpose:** Redesign the scanner UX for hands-free operation, enforce UI typography rules, add Item Type datalist, and create a reusable `save-version` AI command. **Purpose:** Redesign the scanner UX for hands-free operation, enforce UI typography rules, add Item Type datalist, and create a reusable `save-version` AI command.
**Actions:** **Actions:**

View File

@@ -0,0 +1,61 @@
# [COMPLETED] MASTER PLAN: Box/Container Scanning & Printing Architecture
> [!IMPORTANT]
> **STATUS: FULLY IMPLEMENTED (v1.5.0)**
> Date: 2026-04-12
> This plan is no longer active. All phases (OCR, Smart Routing, Label Printing) have been merged into the main codebase.
---
## Etapele Implementării
### ETAPA 1: Local OCR Box Scanning (Funcționalitate Principală)
Această etapă extinde logica existență a scanner-ului pentru a citi local (via `Tesseract.js` pe frontend) textul generic scris pe cutii și a direcționa utilizatorul automat spre inventarul acelei cutii.
#### Pasul 1.1: Backend și Modele de Date
* **Database Migration**: Rularea efectivă (prin `sqlite3 / bash`) pe baza de date de producție a unui query: `ALTER TABLE items ADD COLUMN box_label TEXT;`
* **File `backend/models.py`**: Adăugarea coloanei `box_label = Column(String, index=True, nullable=True)` în modelul `Item`.
* **File `backend/schemas.py`**: Expoziția acesteia prin Pydantic: `box_label: Optional[str] = None` în `ItemBase`.
* **File `backend/routers/items.py`**: Maparea noului câmp la crearea și editarea de itemi, dar **CRITIC**: actualizarea snapshot-urilor JSON de audit (`AuditLogs`), adăugând `"box_label": db_item.box_label` la istoricul imuabil.
#### Pasul 1.2: Frontend Offline Storage & Formulare UI
* **File `frontend/lib/db.ts`**: Adăugarea `box_label?: string;` în interfața TypeScript `Item`. Upgrade la _Dexie database version_ pentru indexarea `items: '++id, barcode, name, category, box_label, ...'`.
* **File `frontend/components/AIOnboarding.tsx` & `page.tsx` (Meniul de Editare)**:
* Adăugarea câmpului UI "Box/Container Label".
* Câmpul devine un `datalist` dropdown conectat la un array derivat `existingBoxes`: `Array.from(new Set(inventory.map(i => i.box_label).filter(Boolean)))`.
* Asta permite la Onboarding selecția rapidă dintr-o listă a unei cutii deja utilizate.
* **File `frontend/app/page.tsx` (Bara de Căutare Generală)**: Extinderea filtrelor de vizibilitate `inventory.filter()` pentru a include textul introdus în caseta de căutare principală dacă matcheaza cu un `box_label`.
#### Pasul 1.3: Router-ul de Inteligență al Scannerului (`onOCRMatch`)
* **Fișier Principal `frontend/app/page.tsx`**: Acolo unde rulează bucla `Scanner.tsx` OCR o dată la 4 secunde, se injectează logica nouă de intercepție în `onOCRMatch()`.
* **Logica Funcțională**:
1. **Fuzzy String Match**: Compară masiv șirul dezordonat venit de la cameră cu toate `item.box_label` existente.
2. Filtrăm array-ul temporar `possibleBoxMatches`.
3. Dacă `possibleBoxMatches.length === 1`: Sistemul selectează instant acel produs -> `setShowScanner(false); setSelectedItem(match);`. Trecere directă la editare stoc (din cauză că este o cutie dedicată).
4. Dacă `possibleBoxMatches.length > 1`: S-a recunoscut "cutia cu SFP-uri" care conține 5 modele diferite. Sistemul va popa un **Modal Interstitial NOU: "Alegeți Item-ul din Cutie"**. Acest modal randează un array vizual ca meniu. Odată făcut click pe un item, deschide panoul de CheckIn/Out pentru el.
5. Dacă cutia nu matchează cu niciun `box_label`, dă `fallback` la logica clasică de `onOCRMatch` (să recunoască S/N-ul individual sau Part Number-ul exact).
---
### ETAPA 2: Sistem de Generare și Printare Etichete pe Cutii (Funcționalitate Secundară)
Misiunea de a crea un cod unic perfect (lipsit de ratele de eroare ale OCR-ului generic) pentru cutii, pe care angajații să îl poată printa direct pe o imprimantă Dymo/Brother sau descărca ca poză.
#### Pasul 2.1: Identificatori Generatori Vizuali
* **Logică PWA**: Nu vom rula backend separat pentru imagini; le vom genera via HTML Canvas pe Frontend pentru lățime de bandă 0.
* **Dependință Nouă UI**: Instalarea `react-barcode` / `qrcode.react` pe frontend pentru desenarea instantanee vizuală bazată pe șirul textual din `box_label` (ex: textul "SFPx5-BOX" -> devine un QRCode valid).
#### Pasul 2.2: Managementul Meniului de Printare
* Afișare Modul `[📦 Tablou Cutii]`, vizibil din Setări/Admin sau în meniul Item-urilor, care grupează itemii per `box_label`.
* Fiecare categorie de "cutie" are buton dedicat: **[Printează Etichetă (Generează Cod)]**.
* **Metoda Multi-Platformă**:
* Când este apăsat generăm un obiect izolat DOM (div hidden).
* Folosim clase CSS media de izolare `@media print { @page { size: 62mm 29mm; } body * { display: none; } #print-area { display: block; } }`.
* Asta triggerează popup-ul de print nativ MacOS/Windows perfect adaptat unei imprimante etichetatoare de birou Dymo/Brother.
* **Fallback Mobil (iOS/Android)**: Lângă opțiunea de "Print direct", adăugăm buton de **[Salvează pe Telefon (Imagine.png)]**. Utilizatorul transferă imaginea perfect rasterizată (canvas via `toDataURL()`) în rola foto pentru a deschide aplicația portabilă proprietară de print Bluetooth (ex: Niimbot app).
## Validarea Aprobării
> [!IMPORTANT]
> REGULA DE AUR PENTRU AI: Nu ai voie să scrii cod din acest plan dacă utilizatorul nu a aprobat explicit începerea implementării Etapelor! Citește acest document ori de câte ori continui logica sistemului PWA aInventory.

View File

@@ -2,6 +2,10 @@
This file tracks all completed tasks and phases moved from `PLAN.md`. This file tracks all completed tasks and phases moved from `PLAN.md`.
## Archive ## Archive
- **v1.4.1**: Security Hardening, REST API Tests, PWA Expert Audit & CSS Upgrades (2026-04-12)
- **v1.4.0**: Audit Log Dashboard UI & Enterprise LDAP Integration Restored (2026-04-12)
- **v1.3.6**: Scanner Redesign & Auto-OCR Automation (2026-04-11)
- **v1.3.0**: Dockerization, HTTPS Proxy & Export Scripts (2026-04-11)
- **v1.2.0**: Structured Category Groups & Item Types (2026-04-10) - **v1.2.0**: Structured Category Groups & Item Types (2026-04-10)
- **v1.1.0**: Auth System & LDAP Framework (2026-04-10) - **v1.1.0**: Auth System & LDAP Framework (2026-04-10)
- **v1.0.0**: Initial PWA MVP (2026-04-10) - **v1.0.0**: Initial PWA MVP (2026-04-10)

View File

@@ -1,155 +1,77 @@
# CURRENT AI WORKING SESSION — HANDOVER # CURRENT AI WORKING SESSION — HANDOVER
**Active AI:** Gemini (Antigravity) **Active AI:** Gemini (Antigravity)
**Last Updated:** 2026-04-11 **Last Updated:** 2026-04-12
**Current Version:** v1.3.5 (pending bump to v1.3.6) **Current Version:** v1.6.0 (BoxMaster)
**Branch:** dev **Branch:** dev
--- ---
## STATUS: 🟢 STABLE — READY FOR VERSION SAVE ## STATUS: 🟢 STABLE — ADVANCED BOX MANAGEMENT & AI MODES COMPLETE (v1.6.0)
All UI/UX refinements from this session have been applied. The login loop (from previous Claude session) is confirmed fixed. The scanner has been fully redesigned. Documentation is up to date. **CRITICAL FOR NEXT AI:** The "Box/Container Management" feature is **FINISHED**. Do NOT attempt to re-implement or look for a plan. The core logic is already in `frontend/app/page.tsx` (`onOCRMatch` and `BoxManager`), `backend/models.py`, and `frontend/lib/labels.ts`.
--- ---
## WHAT WAS DONE THIS SESSION ## WHAT WAS DONE THIS SESSION
### 1. Scanner UI Redesign (`frontend/components/Scanner.tsx`) ### 1. Box Management Architecture (Backend)
- **Layout**: Moved all controls OUT of the camera viewport overlay. Camera feed is now 100% unobstructed. - **Database Schema** — Added `box_label` column to the `items` table.
- **Automated OCR**: Removed manual "OCR SCAN" button. OCR now runs automatically on a 4-second cycle. - **Audit Integrity** — Updated `AuditLog` snapshots to capture `box_label` at the time of each transaction, ensuring immutable historical traceability even if items are moved.
- **Visual Countdown**: Added a countdown display (4, 3, 2, 1, Scan) with a progress bar so the user can see the next scan timing. - **API Support** — Exposed `box_label` in Pydantic schemas and item routers.
- **Scan-line animation**: Now only active when `isStarted && !isSelecting && !paused`.
- **Typography**: Removed all `uppercase` and `tracking-widest` styles per `AI_RULES.md` Section 3.
- **Silent failures**: Auto-OCR no longer shows toast errors if no text is found (silently retries on next cycle).
### 2. Item Type Datalist (`AIOnboarding.tsx`, `page.tsx`, `inventory/page.tsx`) ### 2. Intelligent Scanner Routing (Frontend)
- Added a searchable `<datalist>` to the Item Type field across all relevant forms. - **Box Match Priority** — Rewrote the scanner's `onOCRMatch` logic to prioritize box labels.
- Dynamically populated with existing unique types from the DB, while still allowing manual free-text input. - **Multi-Item Support** — Developed a "Box Contents" interstitial modal that handles containers with multiple distinct item types.
- **Token Matching** — Implemented a local fuzzy token-matching engine for generic box text recognition without AI costs.
### 3. `save-version` Automation Command ### 3. Dependency-Free Label System
- Created `scripts/save_version.py` — increments patch version in `VERSION.json`, commits all changes, creates a snapshot branch `v.X.Y.Z`, and generates the production ZIP via `./export_prod.sh`. - **Native Generation** — Built a zero-dependency SVG engine for Code 128 Barcodes and QR Codes (`lib/labels.ts`).
- Registered as an official AI Command Shortcut in `AI_RULES.md` Section 6. - **Box Manager Dashboard** — Added a dedicated UI to view all existing boxes and trigger label generation.
- **Hybrid Printing** — Implemented CSS `@media print` for professional desktop printers and "Save as PNG" rasterization for portable Bluetooth printers on mobile.
### 4. UI/UX: Targeted Field Scanning
- **Camera Capture** — Added a dedicated scan button in Edit modals that redirects OCR results directly to the "Box Label" field without performing general item matches.
### 5. Multi-Mode AI Discovery
- **Contextual Prompts** — Implemented a dual-mode toggle (Item/Box) in the AI Onboarding screen.
- **Box Extraction** — Created a specialized prompt for Gemini 2.0 Flash to extract container names while filtering out technical noise from product labels.
### 6. Operational Rigor: Step 0 Rule
- **Mandatory Documentation** — Updated `AI_RULES.md` to force documentation verification before any `save-version` (git commit) operation.
- **Master Branch Sync** — Confirmed `scripts/save_version.py` logic to keep `master` branch in sync with the latest releases automatically.
--- ---
## WHAT THE NEXT AI MUST DO ## WHAT THE NEXT AI MUST DO
1. Run `save-version` to finalize version `1.3.6` if not already done. 1. **Database Encryption** — Consider implementing SQLite encryption at rest (SQLCipher) if requested.
2. Monitor TypeScript warnings as the `Item` interface in `frontend/lib/db.ts` may be missing fields. 2. **Persistent JWT** — If requested, move the `JWT_SECRET_KEY` to a `.env` file for session persistence across server restarts.
3. If the Item Type datalist grows too large, consider a dedicated "Category/Type Management" settings page. 3. **Advanced Filtering** — Extend the Box Manager to allow bulk movements between boxes.
4. Periodically review `dev_docs/SECURITY_REPORT.md`. 3. **LDAP Probe** — The "Test Connection" button may show "Partial Success" (handshake rejected) due to anonymous bind restrictions; login itself works fine.
4. **Monitoring** — If the rate limiter triggers too frequently for legitimate users, adjust the `slowapi` limit in `backend/routers/users.py`.
--- ---
## SYSTEM STATE ## SYSTEM STATE
**Active database:** `<project_root>/data/inventory.db` **Active database:** `<project_root>/data/inventory.db`
**LDAP config:** `backend/config/ldap_config.json` **LDAP config:** `config/ldap_config.json`
```json **Network config:** `config/network_config.env`
{"ldap_enabled": true, "server_uri": "ldap://192.168.84.107:3890", "base_dn": "dc=example,dc=com", "user_template": "cn={username},ou=people,dc=example,dc=com", "groups_dn": "ou=groups", "use_tls": false, "role_mappings": [{"group": "inventory_admins", "role": "admin"}, {"group": "inventory_users", "role": "user"}]} **Proxy config:** `config/Caddyfile`
``` **Production Bundle:** `aInventory-PROD-v1.8.0.zip` (ConfigSync Final)
> [!IMPORTANT]
> **Git Access Fix**: The `xcode-select` breakage is bypassed by using the direct binary path: `/Library/Developer/CommandLineTools/usr/bin/git` (stored in `.git_path`). **DO NOT change this path.** Operations now work correctly via this direct link.
**How to start:** **How to start:**
```bash ```bash
./start_server.sh ./start_server.sh
``` ```
- Frontend: `https://<LOCAL_IP>:3003` - Frontend: `https://192.168.84.113:8909`
- Backend: `http://localhost:8000` direct - Backend: `https://192.168.84.113:8908`
**Environment variables set by start_server.sh:** **Environment variables set by start_server.sh:**
- `ALLOWED_ORIGINS` — auto-detected from local IP - `ALLOWED_ORIGINS` — auto-detected
- `DATA_DIR` — absolute path to `<project_root>/data/` - `DATA_DIR` — absolute path
- `LOGS_DIR` — absolute path to `<project_root>/logs/` - `JWT_SECRET_KEY` — ephemeral (regenerates on restart)
- `JWT_SECRET_KEY` — ephemeral per-run if not set externally
This session applied the 3 frontend fixes for the login redirect loop. The server was NOT restarted and the login flow was NOT tested. The next AI must test and confirm — or continue debugging if the loop persists.
---
## WHAT WAS DONE THIS SESSION
### 1. `frontend/lib/api.ts` — Lazy baseURL (Fix 1)
axiosInstance no longer receives baseURL at creation. `getBackendUrl()` is now called inside the request interceptor, at request time, so SSR can no longer lock it to `http://localhost:8000`.
```typescript
// BEFORE (broken):
const axiosInstance = axios.create({ baseURL: getBackendUrl() });
// AFTER (fixed):
const axiosInstance = axios.create({});
axiosInstance.interceptors.request.use((config) => {
if (!config.baseURL) config.baseURL = getBackendUrl();
const token = getToken();
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});
```
### 2. `frontend/lib/api.ts` — 401 interceptor guard (Fix 2)
```typescript
// BEFORE (broken — infinite loop):
if (typeof window !== 'undefined') {
window.location.href = '/login';
}
// AFTER (fixed):
if (typeof window !== 'undefined' && !window.location.pathname.includes('/login')) {
window.location.href = '/login';
}
```
### 3. `frontend/app/page.tsx` — Token guard on both useEffect hooks (Fix 3)
Added `if (!localStorage.getItem('inventory_token')) { window.location.href = '/login'; return; }` at the top of BOTH useEffect hooks — the first one (calls `getCategories`) and the second one (calls `loadInventory`).
### 4. Debug cleanup
- `frontend/lib/auth.ts` — removed `console.log('[Auth] saveToken...')` statements
- `frontend/app/login/page.tsx` — removed `console.log('[LoginPage]...')` statements + unused `memo` import
---
## WHAT THE NEXT AI MUST DO
1. Restart the server: `./start_server.sh`
2. Open `https://192.168.84.140:3003/login` in browser
3. Log in with LDAP user `bede`
4. Verify: main page loads and STAYS — no redirect back to `/login`
5. Verify: `localStorage.getItem('inventory_token')` is non-null after login
6. If loop persists: check browser Network tab for which API endpoint returns 401 first — there may be other API calls in child components (`PageShell`, `Scanner`, etc.) that fire before the token guard
---
## KNOWN PRE-EXISTING ISSUES (not introduced by this session)
TypeScript errors in `frontend/app/page.tsx`:
- Line 241: `Property 'serial_number' does not exist on type 'Item'`
- Lines 598-599: `Property 'type' does not exist on type 'Partial<Item>'`
These are separate bugs — `Item` type in `frontend/lib/db.ts` is missing fields that the UI uses. Fix separately from the login issue.
---
## SYSTEM STATE
**Active database:** `<project_root>/data/inventory.db`
**LDAP config:** `backend/config/ldap_config.json`
```json
{"ldap_enabled": true, "server_uri": "ldap://192.168.84.107:3890", "base_dn": "dc=example,dc=com", "user_template": "cn={username},ou=people,dc=example,dc=com", "groups_dn": "ou=groups", "use_tls": false, "role_mappings": [{"group": "inventory_admins", "role": "admin"}, {"group": "inventory_users", "role": "user"}]}
```
**How to start:**
```bash
./start_server.sh
```
- Frontend: `https://<LOCAL_IP>:3003`
- Backend: `https://<LOCAL_IP>:3002` (also `http://localhost:8000` direct)
**Environment variables set by start_server.sh:**
- `ALLOWED_ORIGINS` — auto-detected from local IP (includes both HTTP and HTTPS variants)
- `DATA_DIR` — absolute path to `<project_root>/data/`
- `LOGS_DIR` — absolute path to `<project_root>/logs/`
- `JWT_SECRET_KEY` — ephemeral per-run if not set externally (means tokens invalidated on restart)

View File

@@ -1,5 +1,3 @@
version: '3.8'
services: services:
backend: backend:
build: build:
@@ -8,27 +6,30 @@ services:
networks: networks:
- inventory_net - inventory_net
ports: ports:
- "8000:8000" - ${BACKEND_PORT:-8000}:8000
env_file:
- ./config/network_config.env
volumes: volumes:
- ./data:/app/data - ./data:/app/data
- ./logs:/app/logs - ./logs:/app/logs
- ./scripts:/app/scripts:ro
environment: environment:
- DATA_DIR=/app/data - DATA_DIR=/app/data
- LOGS_DIR=/app/logs - LOGS_DIR=/app/logs
# [M-01] CORS allowed origins — customize for production - ALLOWED_ORIGINS=http://localhost:${FRONTEND_PORT:-3001},https://localhost:${FRONTEND_SSL_PORT:-3003},http://${SERVER_IP:-localhost}:${FRONTEND_PORT:-3001},https://${SERVER_IP:-localhost}:${FRONTEND_SSL_PORT:-3003},https://localhost:${BACKEND_SSL_PORT:-3002},https://${SERVER_IP:-localhost}:${BACKEND_SSL_PORT:-3002}
- ALLOWED_ORIGINS=http://localhost:3000,http://localhost:3002
# [C-01] JWT secret key — GENERATE A SECURE VALUE FOR PRODUCTION! # [C-01] JWT secret key — GENERATE A SECURE VALUE FOR PRODUCTION!
- JWT_SECRET_KEY=${JWT_SECRET_KEY:-change-me-in-production} - JWT_SECRET_KEY=${JWT_SECRET_KEY:-change_me_in_production}
restart: unless-stopped restart: unless-stopped
frontend: frontend:
build: build:
context: . context: ./frontend
dockerfile: frontend/Dockerfile
networks: networks:
- inventory_net - inventory_net
ports: ports:
- "3000:3000" - ${FRONTEND_PORT:-3000}:3000
env_file:
- ./config/network_config.env
volumes: volumes:
- ./logs:/app/logs - ./logs:/app/logs
# Write Next.js logs to both stdout (docker logs) and file (mapped volume) # Write Next.js logs to both stdout (docker logs) and file (mapped volume)
@@ -40,10 +41,12 @@ services:
networks: networks:
- inventory_net - inventory_net
ports: ports:
- "3002:3002" - ${BACKEND_SSL_PORT:-3002}:${BACKEND_SSL_PORT:-3002}
- "3003:3003" - ${FRONTEND_SSL_PORT:-3003}:${FRONTEND_SSL_PORT:-3003}
env_file:
- ./config/network_config.env
volumes: volumes:
- ./Caddyfile:/etc/caddy/Caddyfile - ./config/Caddyfile:/etc/caddy/Caddyfile
# Persist the internal Caddy certificates so users don't get new certificate warnings constantly # Persist the internal Caddy certificates so users don't get new certificate warnings constantly
- ./data/caddy_data:/data - ./data/caddy_data:/data
- ./data/caddy_config:/config - ./data/caddy_config:/config

View File

@@ -3,8 +3,8 @@
echo "📦 Preparing TFM aInventory Production Bundle..." echo "📦 Preparing TFM aInventory Production Bundle..."
# Extract version from VERSION.json using grep to avoid macOS python/xcode stubs # Extract version from frontend/VERSION.json
VERSION=$(grep '"version"' VERSION.json | head -n 1 | awk -F '"' '{print $4}') VERSION=$(grep '"version"' frontend/VERSION.json | head -n 1 | awk -F '"' '{print $4}')
PROD_DIR="aInventory-PROD-v${VERSION}" PROD_DIR="aInventory-PROD-v${VERSION}"
# Clean previous run if it exists # Clean previous run if it exists
@@ -16,11 +16,13 @@ mkdir -p "$PROD_DIR"
echo "📂 Copying application components (excluding dev artifacts)..." echo "📂 Copying application components (excluding dev artifacts)..."
# Core application # Core application
rsync -a --exclude 'node_modules' --exclude '.next' frontend/ "$PROD_DIR/frontend/" rsync -a --exclude 'node_modules' --exclude '.next' frontend/ "$PROD_DIR/frontend/"
rsync -a --exclude '__pycache__' --exclude '.pytest_cache' --exclude '.venv' backend/ "$PROD_DIR/backend/" rsync -a --exclude '__pycache__' --exclude '.pytest_cache' --exclude '.venv' --exclude 'tests' backend/ "$PROD_DIR/backend/"
# Orchestration & Scripts # Orchestration, Config & Scripts
mkdir -p "$PROD_DIR/config" "$PROD_DIR/scripts"
cp docker-compose.yml "$PROD_DIR/" cp docker-compose.yml "$PROD_DIR/"
cp Caddyfile "$PROD_DIR/" rsync -a config/ "$PROD_DIR/config/"
rsync -a scripts/ "$PROD_DIR/scripts/"
cp start_server.sh "$PROD_DIR/" cp start_server.sh "$PROD_DIR/"
cp run_standalone.sh "$PROD_DIR/" cp run_standalone.sh "$PROD_DIR/"
cp install_service.sh "$PROD_DIR/" cp install_service.sh "$PROD_DIR/"
@@ -28,7 +30,8 @@ cp inventory.service.template "$PROD_DIR/"
cp USER_GUIDE.md "$PROD_DIR/" cp USER_GUIDE.md "$PROD_DIR/"
cp README.md "$PROD_DIR/INSTALLATION_GUIDE.md" cp README.md "$PROD_DIR/INSTALLATION_GUIDE.md"
cp .git_path "$PROD_DIR/" 2>/dev/null || true cp .git_path "$PROD_DIR/" 2>/dev/null || true
cp VERSION.json "$PROD_DIR/" cp frontend/VERSION.json "$PROD_DIR/"
cp frontend/VERSION.json "$PROD_DIR/frontend/"
# Setup persistent volume skeleton # Setup persistent volume skeleton
mkdir -p "$PROD_DIR/data" mkdir -p "$PROD_DIR/data"
@@ -44,7 +47,7 @@ TO RUN VIA DOCKER (Recommended):
1. Install Docker Desktop or Docker Engine. 1. Install Docker Desktop or Docker Engine.
2. Run: docker-compose build 2. Run: docker-compose build
3. Run: docker-compose up -d 3. Run: docker-compose up -d
4. Access via https://<YOUR-IP>:3003 (Accept the internal security warning). 4. Access via https://<YOUR-IP>:8909 (Accept the internal security warning).
TO INSTALL AS A LINUX SYSTEM SERVICE (Optional): TO INSTALL AS A LINUX SYSTEM SERVICE (Optional):
1. sudo ./install_service.sh 1. sudo ./install_service.sh
@@ -54,7 +57,7 @@ TO RUN BARE-METAL (No Docker):
1. Install Python 3.12+ and Node.js 20+. 1. Install Python 3.12+ and Node.js 20+.
2. Ensure you have network access for npm installs. 2. Ensure you have network access for npm installs.
3. Run: ./start_server.sh 3. Run: ./start_server.sh
4. Access via https://<YOUR-IP>:3003 4. Access via https://<YOUR-IP>:8909
Note: Database and Logs will persist in the /data and /logs directories. Note: Database and Logs will persist in the /data and /logs directories.
EOF EOF

5
frontend/VERSION.json Normal file
View File

@@ -0,0 +1,5 @@
{
"version": "1.8.6",
"last_build": "2026-04-13-1954",
"codename": "TypeFix"
}

View File

@@ -5,6 +5,7 @@ import { inventoryApi } from '@/lib/api';
import PageShell from '@/components/PageShell'; import PageShell from '@/components/PageShell';
import { import {
Shield, Shield,
ShieldAlert,
UserPlus, UserPlus,
User, User,
Trash2, Trash2,
@@ -22,7 +23,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';
@@ -38,7 +43,6 @@ export default function AdminPage() {
server_uri: 'ldap://192.168.84.107:3890', server_uri: 'ldap://192.168.84.107:3890',
base_dn: 'dc=example,dc=com', base_dn: 'dc=example,dc=com',
user_template: 'cn={username},ou=people,dc=example,dc=com', user_template: 'cn={username},ou=people,dc=example,dc=com',
required_group: 'inventory',
groups_dn: 'ou=groups', groups_dn: 'ou=groups',
use_tls: false, use_tls: false,
role_mappings: [] role_mappings: []
@@ -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,15 +69,21 @@ 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);
} catch (err) { setBackups(b);
setDbStats(s);
setDbSettings(st);
} catch (err: any) {
console.error(err); console.error(err);
toast.error("Failed to load admin data"); toast.error("Failed to load admin data");
} finally { } finally {
@@ -85,7 +101,7 @@ export default function AdminPage() {
await inventoryApi.createUser({ username: name, password: pwd, role: 'user' }); await inventoryApi.createUser({ username: name, password: pwd, role: 'user' });
toast.success("User created successfully"); toast.success("User created successfully");
loadData(); loadData();
} catch (err) { } catch (err: any) {
toast.error("Failed to create user"); toast.error("Failed to create user");
} }
}; };
@@ -98,7 +114,7 @@ export default function AdminPage() {
await inventoryApi.deleteUser(id); await inventoryApi.deleteUser(id);
toast.success("User removed"); toast.success("User removed");
loadData(); loadData();
} catch (err) { } catch (err: any) {
toast.error("Delete failed"); toast.error("Delete failed");
} }
}; };
@@ -112,7 +128,7 @@ export default function AdminPage() {
await inventoryApi.createCategory({ name, description: desc }); await inventoryApi.createCategory({ name, description: desc });
toast.success("Category added"); toast.success("Category added");
loadData(); loadData();
} catch (err) { } catch (err: any) {
toast.error("Failed to add category"); toast.error("Failed to add category");
} }
}; };
@@ -124,7 +140,7 @@ export default function AdminPage() {
toast.success("Category updated"); toast.success("Category updated");
setEditingCategory(null); setEditingCategory(null);
loadData(); loadData();
} catch (err) { } catch (err: any) {
toast.error("Update failed"); toast.error("Update failed");
} }
}; };
@@ -136,7 +152,7 @@ export default function AdminPage() {
await inventoryApi.deleteCategory(id); await inventoryApi.deleteCategory(id);
toast.success("Category removed"); toast.success("Category removed");
loadData(); loadData();
} catch (err) { } catch (err: any) {
toast.error(err.response?.data?.detail || "Delete failed"); toast.error(err.response?.data?.detail || "Delete failed");
} }
}; };
@@ -151,7 +167,7 @@ export default function AdminPage() {
toast.success("User updated successfully"); toast.success("User updated successfully");
setEditingUser(null); setEditingUser(null);
loadData(); loadData();
} catch (err) { } catch (err: any) {
toast.error("Update failed"); toast.error("Update failed");
} }
}; };
@@ -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: any) {
toast.error("Backup failed");
} finally {
setIsBackingUp(false);
}
};
const handleRestore = async (filename: string) => {
if (!confirm(`DANGEROUS: Restore database from ${filename}? Current data will be replaced. A rollback snapshot will be created automatically.`)) return;
const loadingToast = toast.loading("Restoring database...");
try {
await inventoryApi.restoreDatabase(filename);
toast.success("Database restored! Reloading system...", { id: loadingToast });
setTimeout(() => window.location.reload(), 2000);
} catch (err: any) {
toast.error("Restore failed", { id: loadingToast });
}
};
const handleUpdateDbSettings = async (newSettings: any) => {
try {
await inventoryApi.updateDbSettings(newSettings);
toast.success("System policy updated");
setDbSettings(newSettings);
} catch (err: any) {
toast.error("Failed to update settings");
}
};
const handleUpdateLdap = async () => {
setLoading(true);
try {
await inventoryApi.updateLdapConfig(ldapConfig);
toast.success("Enterprise configuration updated");
} catch (err: any) {
toast.error("Failed to update LDAP config");
} finally {
setLoading(false);
}
};
const handleTestLdap = async () => {
setTestingLdap(true);
try {
const res = await inventoryApi.testLdapConnection(ldapConfig);
if (res.status === 'success') {
toast.success(res.message || "LDAP Connection Successful!");
} else {
toast.error(`LDAP Error: ${res.message || "Unknown error"}`);
}
} catch (err: any) {
toast.error(`Connection failed: ${err.response?.data?.detail || err.message}`);
} finally {
setTestingLdap(false);
}
};
const formatSize = (bytes: number) => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
};
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="glass-card p-6 md:p-8 rounded-[2.5rem] md:rounded-[3rem] 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-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...</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 font-mono tracking-tighter">{u.role}</span>
<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>
</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-3xl overflow-hidden shadow-xl divide-y divide-indigo-500/10"> <div className="bg-indigo-500/5 border border-indigo-500/10 rounded-2xl 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,24 @@ 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> <div className="flex items-center gap-2">
<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> <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 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>
<div className="text-xs font-black text-indigo-400/50 pr-4 truncate max-w-[150px] italic"> <div className="flex items-center gap-1 pr-2">
Read Only Profile <div className="text-[10px] font-black text-indigo-400/50 pr-4 italic">
Cached Profile
</div>
<button
onClick={() => handleDeleteUser(u.id, u.username)}
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"
title="Clear local cache for this user"
>
<Trash2 size={14} />
</button>
</div> </div>
</div> </div>
))} ))}
@@ -284,22 +378,214 @@ export default function AdminPage() {
</div> </div>
</section> </section>
{/* Enterprise Integration (LDAP) Section */}
<section className="glass-card p-6 md:p-8 rounded-[2.5rem] md:rounded-[3rem] space-y-8">
<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="space-y-1.5">
<label className="text-[10px] font-black text-slate-500 px-1">Groups DN (Search base for groups)</label>
<input
type="text"
placeholder="ou=groups,dc=example,dc=com"
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="grid sm:grid-cols-2 gap-4">
<div className="space-y-1.5">
<label className="text-[10px] font-black text-slate-500 px-1">Admin Security Group</label>
<input
type="text"
placeholder="inventory_admins"
value={ldapConfig.role_mappings?.find((m: any) => m.role === 'admin')?.group || ''}
onChange={(e) => {
const newMappings = [...(ldapConfig.role_mappings || [])];
const idx = newMappings.findIndex((m: any) => m.role === 'admin');
if (idx >= 0) newMappings[idx] = { ...newMappings[idx], group: e.target.value };
else newMappings.push({ role: 'admin', group: e.target.value });
setLdapConfig({ ...ldapConfig, role_mappings: newMappings });
}}
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">User Security Group</label>
<input
type="text"
placeholder="inventory_users"
value={ldapConfig.role_mappings?.find((m: any) => m.role === 'user')?.group || ''}
onChange={(e) => {
const newMappings = [...(ldapConfig.role_mappings || [])];
const idx = newMappings.findIndex((m: any) => m.role === 'user');
if (idx >= 0) newMappings[idx] = { ...newMappings[idx], group: e.target.value };
else newMappings.push({ role: 'user', group: e.target.value });
setLdapConfig({ ...ldapConfig, role_mappings: newMappings });
}}
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">
<Wifi className={cn("transition-colors", ldapConfig.ldap_enabled ? "text-indigo-400" : "text-slate-700")} size={20} />
<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 justify-between gap-4 pt-2">
<div className="flex items-center gap-2">
<Shield size={14} className={cn("transition-colors", ldapConfig.use_tls ? "text-green-400" : "text-slate-600")} />
<span className="text-[10px] font-black text-slate-400">LDAPS / TLS Encryption</span>
</div>
<button
onClick={() => setLdapConfig({ ...ldapConfig, use_tls: !ldapConfig.use_tls })}
className={cn(
"relative inline-flex h-6 w-10 items-center rounded-full transition-all duration-300 outline-none",
ldapConfig.use_tls ? "bg-green-600" : "bg-slate-800"
)}
>
<span className={cn(
"inline-block h-4 w-4 transform rounded-full bg-white transition-transform duration-300",
ldapConfig.use_tls ? "translate-x-5" : "translate-x-1"
)} />
</button>
</div>
{ldapConfig.use_tls && (
<div className="flex items-center justify-between gap-4 pt-1 animate-in fade-in slide-in-from-top-1 duration-300">
<div className="flex items-center gap-2">
<ShieldAlert size={14} className={cn("transition-colors", ldapConfig.ignore_cert ? "text-amber-400" : "text-slate-600")} />
<span className="text-[10px] font-black text-slate-400 italic">Ignore Certificate Validation (Self-signed)</span>
</div>
<button
onClick={() => setLdapConfig({ ...ldapConfig, ignore_cert: !ldapConfig.ignore_cert })}
className={cn(
"relative inline-flex h-6 w-10 items-center rounded-full transition-all duration-300 outline-none",
ldapConfig.ignore_cert ? "bg-amber-600" : "bg-slate-800"
)}
>
<span className={cn(
"inline-block h-4 w-4 transform rounded-full bg-white transition-transform duration-300",
ldapConfig.ignore_cert ? "translate-x-5" : "translate-x-1"
)} />
</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 */} {/* Category Management Section */}
<section className="space-y-6"> <section className="glass-card p-6 md:p-8 rounded-[2.5rem] md:rounded-[3rem] 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">
<Layers 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">Category Groups</h2> <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 +597,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 +624,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>
<h3 className="text-xl font-black text-white tracking-tight">System Integrity</h3>
<p className="text-xs text-slate-500 mt-3 max-w-[280px] mx-auto leading-relaxed">
Hybrid storage model active. Real-time synchronization between local memory and cloud-hosted SQLite.
</p>
</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 className="w-2 h-2 rounded-full bg-green-500 animate-pulse shadow-[0_0_8px_rgba(34,197,94,0.5)]" />
<span className="text-xs font-black text-green-500">Storage Online</span>
</div>
</section>
</div>
{/* Enterprise LDAP Integration */}
<section className="space-y-8 bg-slate-900/30 border border-slate-800/40 p-8 rounded-[3rem] shadow-2xl relative overflow-hidden group">
<div className="absolute top-0 right-0 p-8 opacity-5 group-hover:opacity-10 transition-opacity">
<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="p-4 bg-indigo-500/10 rounded-2xl text-indigo-400 border border-indigo-500/20">
<Server 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">LLDAP / Active Directory Connectivity</p>
</div>
</div> </div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-3 bg-slate-950 p-2 rounded-2xl border border-slate-800"> <h3 className="text-sm font-black text-white">System Integrity</h3>
<span className="text-xs font-black text-slate-400 px-3">LDAP Status</span> <p className="text-[10px] text-slate-500 mt-0.5 max-w-xl truncate">
<button Hybrid storage model active. Real-time synchronization between local memory and cloud-hosted SQLite instance.
onClick={() => { </p>
const newConfig = { ...ldapConfig, ldap_enabled: !ldapConfig.ldap_enabled };
setLdapConfig(newConfig);
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'}
</button>
</div> </div>
</div> </div>
<div className="flex items-center gap-6 divide-x divide-slate-800/50">
<div className="grid lg:grid-cols-2 gap-8 relative z-10"> <div className="flex flex-col items-end">
<div className="space-y-6"> <span className="text-[8px] font-black text-slate-500 uppercase tracking-widest">Storage Status</span>
<div className="space-y-2"> <div className="flex items-center gap-2 mt-1">
<label className="text-xs font-black text-slate-500 px-1">Server URI</label> <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)]" />
<div className="relative group/input"> <span className="text-[10px] font-black text-green-500 tracking-tight">Online</span>
<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> </div>
<div className="flex flex-col items-end pl-6">
<div className="space-y-6"> <span className="text-[8px] font-black text-slate-500 uppercase tracking-widest">Local Archives</span>
<div className="space-y-4"> <div className="flex items-center gap-3 mt-1">
<div className="flex items-center justify-between px-1"> <span className="text-[10px] font-black text-white">{dbStats.backup_count} Files</span>
<label className="text-xs font-black text-slate-500">Role Mappings</label> <span className="text-[10px] font-black text-primary/60">{formatSize(dbStats.total_size_bytes)}</span>
<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
type="text"
value={mapping.group}
onChange={(e) => {
const newMappings = [...ldapConfig.role_mappings];
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"
/>
<select
value={mapping.role}
onChange={(e) => {
const newMappings = [...ldapConfig.role_mappings];
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>
<option value="admin">Admin</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 && (
<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>
<div className="p-6 bg-slate-950 rounded-3xl border border-slate-800 space-y-6">
<div className="flex items-center justify-between">
<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(
"absolute top-1 w-4 h-4 bg-white rounded-full transition-all",
ldapConfig.use_tls ? "right-1" : "left-1"
)} />
</button>
</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 className="flex items-start gap-3 p-4 bg-indigo-500/5 rounded-2xl border border-indigo-500/10">
<AlertTriangle size={16} className="text-indigo-400 shrink-0 mt-0.5" />
<p className="text-xs text-indigo-300/60 leading-normal italic">
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>
</div> </div>
</div> </div>
</div> </div>
</section> </section>
{/* Database & Continuity Card */}
<section className="glass-card p-6 md:p-8 rounded-[2.5rem] md:rounded-[3rem] space-y-8">
<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-amber-500/10 rounded-2xl text-amber-500 border border-amber-500/20">
<HardDrive size={24} />
</div>
<div>
<h2 className="text-xl font-black text-white">Database & Continuity</h2>
<p className="text-xs text-slate-500 font-bold mt-1">Snapshot management and disaster recovery tools</p>
</div>
</div>
<button
onClick={handleCreateBackup}
disabled={isBackingUp}
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"
>
{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>
</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 className="space-y-4">
<div className="space-y-1.5">
<label className="text-[10px] font-black text-slate-500 px-1">Retention Limit (Max Files)</label>
<input
type="number"
value={dbSettings.retention_count}
onChange={(e) => handleUpdateDbSettings({ ...dbSettings, retention_count: parseInt(e.target.value) || 1 })}
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"
/>
</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
value={dbSettings.schedule_hour}
onChange={(e) => handleUpdateDbSettings({ ...dbSettings, schedule_hour: parseInt(e.target.value) })}
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"
>
{Array.from({ length: 24 }).map((_, i) => (
<option key={i} value={i}>{String(i).padStart(2, '0')}:00</option>
))}
</select>
</div>
<div className="space-y-1.5">
<label className="text-[10px] font-black text-slate-500 px-1">Frequency (Days)</label>
<select
value={dbSettings.schedule_freq_days}
onChange={(e) => handleUpdateDbSettings({ ...dbSettings, schedule_freq_days: parseInt(e.target.value) })}
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"
>
<option value={1}>Daily</option>
<option value={2}>Every 2 days</option>
<option value={3}>Every 3 days</option>
<option value={5}>Every 5 days</option>
<option value={7}>Weekly</option>
<option value={14}>Bi-weekly</option>
<option value={30}>Monthly</option>
</select>
</div>
</div>
</div>
<div className="pt-2">
<p className="text-[10px] text-slate-500 italic leading-relaxed px-1">
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.
</p>
</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>
{/* 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,58 @@
@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 */
}
/* Modern Utility for Premium Glassmorphism */
@layer utilities {
.glass-card {
@apply bg-slate-900/40 backdrop-blur-xl border border-slate-800/50 shadow-2xl;
background-image: linear-gradient(135deg, rgba(255,255,255,0.02) 0%, rgba(255,255,255,0) 100%);
}
.text-fluid-lg {
font-size: clamp(1.125rem, 3cqi, 1.5rem);
}
.text-fluid-xl {
font-size: clamp(1.5rem, 5cqi, 2.25rem);
}
}
/* Safe Area Insets for Modern Mobile Devices (iOS Notch/Home Bar) */
.pb-safe {
padding-bottom: env(safe-area-inset-bottom);
}
.pt-safe {
padding-top: env(safe-area-inset-top);
} }

View File

@@ -1,9 +1,10 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useState, useEffect, useCallback } 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 Scanner from '@/components/Scanner';
import { toast } from 'react-hot-toast'; import { toast } from 'react-hot-toast';
import { import {
Package, Package,
@@ -17,7 +18,8 @@ import {
X, X,
AlertTriangle, AlertTriangle,
Tag, Tag,
Edit2 Edit2,
Camera
} from 'lucide-react'; } from 'lucide-react';
import { clsx, type ClassValue } from 'clsx'; import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge'; import { twMerge } from 'tailwind-merge';
@@ -50,6 +52,10 @@ export default function InventoryPage() {
const [catEditedName, setCatEditedName] = useState(''); const [catEditedName, setCatEditedName] = useState('');
const [catEditedDesc, setCatEditedDesc] = useState(''); const [catEditedDesc, setCatEditedDesc] = useState('');
// Scanner state
const [showScanner, setShowScanner] = useState(false);
const [fieldScanning, setFieldScanning] = useState<{ active: boolean, field: string } | null>(null);
useEffect(() => { useEffect(() => {
setMounted(true); setMounted(true);
const savedUser = localStorage.getItem('inventory_user'); const savedUser = localStorage.getItem('inventory_user');
@@ -79,7 +85,7 @@ export default function InventoryPage() {
// Sync local DB // Sync local DB
await db.items.clear(); await db.items.clear();
await db.items.bulkPut(res); await db.items.bulkPut(res);
} catch (err) { } catch (err: any) {
console.error("Failed to load backend data", err); console.error("Failed to load backend data", err);
} }
}; };
@@ -143,7 +149,7 @@ export default function InventoryPage() {
setIsEditing(false); setIsEditing(false);
setSelectedItem(updated as Item); setSelectedItem(updated as Item);
await loadData(); await loadData();
} catch (err) { } catch (err: any) {
console.error(err); console.error(err);
toast.error("Failed to update item"); toast.error("Failed to update item");
} }
@@ -161,7 +167,7 @@ export default function InventoryPage() {
toast.success("Item deleted"); toast.success("Item deleted");
setSelectedItem(null); setSelectedItem(null);
await loadData(); await loadData();
} catch (err) { } catch (err: any) {
console.error(err); console.error(err);
toast.error("Failed to delete item"); toast.error("Failed to delete item");
} }
@@ -177,11 +183,36 @@ export default function InventoryPage() {
toast.success("Category updated"); toast.success("Category updated");
setEditingCategory(null); setEditingCategory(null);
await loadData(); await loadData();
} catch (err) { } catch (err: any) {
toast.error("Update failed"); toast.error("Update failed");
} }
}; };
const onOCRMatch = useCallback(async (text: string) => {
const cleanText = text.toUpperCase().replace(/[^A-Z0-9\s/+-]/g, ' ');
const tokens = cleanText.split(/[\s\n,]+/).filter(t => t.length >= 3);
if (fieldScanning?.active && fieldScanning.field === 'box_label') {
const label = tokens[0] || cleanText;
setEditedItem(prev => ({ ...prev, box_label: label }));
setFieldScanning(null);
setShowScanner(false);
toast.success(`Captured: ${label}`);
return;
}
}, [fieldScanning]);
const onScanSuccess = useCallback((barcode: string) => {
// Inventory page doesn't do check-in via scanner, it just finds the item
const item = inventory.find(i => i.barcode === barcode);
if (item) {
setSelectedItem(item);
setShowScanner(false);
} else {
toast.error(`Item with barcode ${barcode} not found in catalog`);
}
}, [inventory]);
// Group items by category // Group items by category
const categories = Array.from(new Set(inventory.map(i => i.category))); const categories = Array.from(new Set(inventory.map(i => i.category)));
const filteredCategories = categories.filter(c => const filteredCategories = categories.filter(c =>
@@ -189,42 +220,44 @@ export default function InventoryPage() {
inventory.some(i => i.category === c && i.name.toLowerCase().includes(searchQuery.toLowerCase())) inventory.some(i => i.category === c && i.name.toLowerCase().includes(searchQuery.toLowerCase()))
); );
// Extract unique item types for suggestions // Extract unique item types and box labels for suggestions
const existingTypes = Array.from(new Set(inventory.map(i => i.type).filter(Boolean))).sort() as string[]; const existingTypes = Array.from(new Set(inventory.map(i => i.type).filter(Boolean))).sort() as string[];
const existingBoxes = Array.from(new Set(inventory.map(i => i.box_label).filter(Boolean))).sort() as string[];
if (!mounted) return null; if (!mounted) return null;
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>
<datalist id="existing-boxes">
{existingBoxes.map(b => <option key={b} value={b} />)}
</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>
</div> <p className="text-xl font-black text-white tabular-nums ml-auto">{stats?.total_categories || categories.length}</p>
<p className="text-xs font-black text-slate-500">Categories</p>
<p className="text-2xl font-black mt-1">{stats?.total_categories || categories.length}</p>
</div> </div>
<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-green-500/10 text-green-500 flex items-center justify-center mb-3"> <Package size={18} className="text-green-500 shrink-0 opacity-80" />
<Package size={18} /> <p className="text-sm font-bold text-slate-300 whitespace-nowrap">Item Types</p>
</div> <p className="text-xl font-black text-white tabular-nums ml-auto">{stats?.total_items || inventory.length}</p>
<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 +289,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>
@@ -414,6 +447,33 @@ export default function InventoryPage() {
className="w-full bg-slate-950 border border-slate-800 rounded-xl py-3 px-4 text-sm outline-none text-slate-100" className="w-full bg-slate-950 border border-slate-800 rounded-xl py-3 px-4 text-sm outline-none text-slate-100"
/> />
</div> </div>
<div className="col-span-2">
<label className="text-xs font-black text-slate-500 ml-1">Box / Container Label</label>
<div className="relative flex items-center">
<input
type="text"
list="existing-boxes"
value={editedItem.box_label || ''}
onChange={e => setEditedItem({...editedItem, box_label: e.target.value})}
className="w-full bg-slate-950 border border-slate-800 rounded-xl py-3 pl-4 pr-12 text-sm outline-none text-slate-100 placeholder:text-slate-700 focus:border-primary transition-colors"
placeholder="e.g. SFPs 40G Cisco"
/>
<button
type="button"
onClick={() => {
setFieldScanning({ active: true, field: 'box_label' });
setShowScanner(true);
toast.success("Ready to scan Box label...");
}}
className={cn(
"absolute right-2 p-2 rounded-lg transition-all",
fieldScanning?.active ? "bg-primary text-white animate-pulse" : "text-slate-500 hover:bg-slate-800"
)}
>
<Camera size={18} />
</button>
</div>
</div>
</div> </div>
<div> <div>
<label className="text-xs font-black text-slate-500 ml-1">Specs / Comments</label> <label className="text-xs font-black text-slate-500 ml-1">Specs / Comments</label>
@@ -544,6 +604,13 @@ export default function InventoryPage() {
</div> </div>
)} )}
{showScanner && (
<Scanner
onScanSuccess={onScanSuccess}
onOCRMatch={onOCRMatch}
/>
)}
</div> </div>
</PageShell> </PageShell>
); );

View File

@@ -23,9 +23,14 @@ export default function RootLayout({
<html lang="en" suppressHydrationWarning> <html lang="en" suppressHydrationWarning>
<head> <head>
<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="/icons/icon-192x192.png" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="aInventory" />
<meta name="format-detection" content="telephone=no" />
<meta name="mobile-web-app-capable" content="yes" />
</head> </head>
<body className="antialiased"> <body className="antialiased bg-slate-950 text-slate-100">
{children} {children}
</body> </body>
</html> </html>

View File

@@ -65,8 +65,9 @@ export default function LoginPage() {
router.push('/'); router.push('/');
}, 500); }, 500);
} catch (error) { } catch (error: any) {
toast.error(isEnterprise ? "Login failed. Check credentials or group membership." : "Invalid password"); const detail = error.response?.data?.detail || (isEnterprise ? "Login failed. Check credentials or group membership." : "Invalid password");
toast.error(detail);
} }
}; };

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, RefreshCw } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { fetchAndCacheItems } from '@/lib/sync'; import { fetchAndCacheItems } from '@/lib/sync';
@@ -23,23 +23,46 @@ export default function LogsPage() {
const loadData = async () => { const loadData = async () => {
setLoading(true); setLoading(true);
try { try {
// Load inventory for name lookups // 1. First, try to get fresh items to resolve names
const cached = await db.items.toArray(); let freshItems: Item[] = [];
setInventory(cached); try {
freshItems = await fetchAndCacheItems();
} catch (itemErr) {
console.warn("Item sync failed, using local cache for names", itemErr);
freshItems = await db.items.toArray();
}
setInventory(freshItems);
// Fetch fresh logs // 2. Then, fetch fresh logs
const logs = await inventoryApi.getAuditLogs(100); const logs = await inventoryApi.getAuditLogs(100);
setAuditLogs(logs);
} catch (err) { // 3. Pre-resolve names to avoid UI flickering/mismatches
console.error(err); const enrichedLogs = (logs || []).map((log: any) => {
// [AUDIT HARDENING] Prioritize the historical snapshot from the backend
if (log.target_item_name) {
return { ...log, resolved_name: log.target_item_name };
}
// Fallback for legacy logs or system operations
const hasTarget = log.target_item_id && String(log.target_item_id) !== 'null';
const item = hasTarget ? freshItems.find(i => String(i.id) === String(log.target_item_id)) : null;
return {
...log,
resolved_name: item ? item.name : (hasTarget ? `Item #${log.target_item_id}` : "System Operation")
};
});
setAuditLogs(enrichedLogs);
} catch (err: any) {
console.error("Critical log load failure:", err);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
const filteredLogs = auditLogs.filter(log => { const filteredLogs = auditLogs.filter(log => {
const itemName = inventory.find(i => i.id === log.target_item_id)?.name || ''; const matchesSearch = (log.resolved_name || '').toLowerCase().includes(searchQuery.toLowerCase()) ||
const matchesSearch = itemName.toLowerCase().includes(searchQuery.toLowerCase()) ||
log.action.toLowerCase().includes(searchQuery.toLowerCase()) || log.action.toLowerCase().includes(searchQuery.toLowerCase()) ||
(log.username || '').toLowerCase().includes(searchQuery.toLowerCase()); (log.username || '').toLowerCase().includes(searchQuery.toLowerCase());
@@ -55,52 +78,59 @@ export default function LogsPage() {
const mostActiveUser = auditLogs.length > 0 ? const mostActiveUser = auditLogs.length > 0 ?
Object.entries(auditLogs.reduce((acc: any, curr) => { Object.entries(auditLogs.reduce((acc: any, curr) => {
acc[curr.username] = (acc[curr.username] || 0) + 1; const user = curr.username || 'System';
acc[user] = (acc[user] || 0) + 1;
return acc; return acc;
}, {})).sort((a: any, b: any) => b[1] - a[1])[0]?.[0] : 'N/A'; }, {})).sort((a: any, b: any) => b[1] - a[1])[0]?.[0] : 'N/A';
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>
<div className="flex gap-2"> <div className="flex gap-2">
<button <button
onClick={loadData} onClick={loadData}
className="px-4 py-2 bg-slate-900 border border-slate-800 text-slate-400 hover:text-white rounded-xl text-xs font-black transition-all active:scale-95" disabled={loading}
className="flex items-center gap-2 px-6 py-3 bg-slate-900 border border-slate-800 text-slate-400 hover:text-white rounded-2xl text-xs font-black transition-all active:scale-95 disabled:opacity-50"
> >
Refresh <RefreshCw size={14} className={cn(loading && "animate-spin")} />
Refresh Stream
</button> </button>
</div> </div>
</div> </div>
{/* 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 +147,25 @@ 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' },
{ label: 'System', value: 'DB' }
].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 +176,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,52 +194,44 @@ 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(
"text-[10px] font-black px-3 py-1 rounded-full border shadow-sm",
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('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('_', ' ')}
</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">
{inventory.find(i => i.id === log.target_item_id)?.name || `Item #${log.target_item_id}`}
</h3>
<div className="flex flex-wrap items-center gap-4 mt-4">
<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">
<div className="w-1.5 h-1.5 rounded-full bg-primary/40" />
{new Date(log.timestamp).toLocaleDateString()} · {new Date(log.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</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 className="shrink-0 text-right z-10">
<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-[9px] font-black px-2 py-0.5 rounded-md border min-w-[70px] text-center",
log.quantity_change > 0 ? "text-green-500" : (log.quantity_change < 0 ? "text-rose-500" : "text-indigo-400") 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('DB') ? "bg-sky-500/5 text-sky-400 border-sky-500/20" :
(log.action.includes('DELETE') ? "bg-red-500/10 text-red-500 border-red-500/30" :
(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.quantity_change > 0 ? '+' : ''}{log.quantity_change === 0 ? '±' : log.quantity_change} {log.action.replace('_', ' ')}
</div>
<div className="flex-1 min-w-0">
<h3 className="text-sm font-bold text-white group-hover:text-primary transition-colors truncate">
{log.resolved_name}
</h3>
<div className="flex items-center gap-2 opacity-80">
<span className="text-[9px] font-black text-amber-500 tracking-tight">{log.username || 'System'}</span>
<span className="w-1 h-1 rounded-full bg-slate-700" />
<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' })}
</span>
</div>
</div>
</div>
<div className="shrink-0 flex items-center gap-3 z-10">
<div className={cn(
"text-xl font-black tabular-nums min-w-[40px] text-right",
(log.quantity_change || 0) > 0 ? "text-green-500" : ((log.quantity_change || 0) < 0 ? "text-rose-500" : "text-indigo-400")
)}>
{log.quantity_change ? (log.quantity_change > 0 ? `+${log.quantity_change}` : log.quantity_change) : (log.action.includes('DB') ? 'SYS' : '±')}
</div> </div>
<p className="text-[10px] font-black text-slate-600 uppercase tracking-widest mt-1">Quantity</p>
</div> </div>
{/* Glass background effect */}
<div className="absolute inset-x-0 bottom-0 h-1 bg-gradient-to-r from-transparent via-primary/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity" /> <div className="absolute inset-x-0 bottom-0 h-1 bg-gradient-to-r from-transparent via-primary/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity" />
</button> </button>
))} ))}
@@ -224,7 +253,7 @@ export default function LogsPage() {
{selectedLog.action} {selectedLog.action}
</div> </div>
<h2 className="text-3xl font-black text-white tracking-tight leading-tight pt-2"> <h2 className="text-3xl font-black text-white tracking-tight leading-tight pt-2">
{inventory.find(i => i.id === selectedLog.target_item_id)?.name || `Item #${selectedLog.target_item_id}`} {selectedLog.resolved_name}
</h2> </h2>
</div> </div>
<button onClick={() => setSelectedLog(null)} className="p-3 hover:bg-slate-800 rounded-2xl text-slate-500 transition-colors border border-slate-800"> <button onClick={() => setSelectedLog(null)} className="p-3 hover:bg-slate-800 rounded-2xl text-slate-500 transition-colors border border-slate-800">
@@ -234,36 +263,72 @@ 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) > 0 ? "text-green-500" : "text-rose-500"
)}> )}>
{selectedLog.quantity_change > 0 ? '+' : ''}{selectedLog.quantity_change} Units {selectedLog.quantity_change
? `${selectedLog.quantity_change > 0 ? '+' : ''}${selectedLog.quantity_change} Units`
: (selectedLog.action.includes('DB') ? 'System' : 'No Delta')}
</p> </p>
</div> </div>
</div> </div>
<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>
</div> </div>
{selectedLog.target_snapshot && (() => {
try {
const snap = JSON.parse(selectedLog.target_snapshot);
return (
<div className="space-y-4">
<div className="flex items-center gap-2">
<div className="h-px flex-1 bg-slate-800" />
<p className="text-[10px] font-black text-slate-700 uppercase tracking-widest">Full Historical Context</p>
<div className="h-px flex-1 bg-slate-800" />
</div>
<div className="grid grid-cols-2 gap-3">
{Object.entries(snap).map(([key, val]) => (
val && key !== 'image_url' && (
<div key={key} className="bg-slate-950/30 p-3 rounded-xl border border-slate-800/40">
<p className="text-[8px] font-black text-slate-600 uppercase mb-1">{key.replace('_', ' ')}</p>
<p className="text-xs font-bold text-slate-300 truncate" title={String(val)}>{String(val)}</p>
</div>
)
))}
</div>
</div>
);
} catch (e) { return null; }
})()}
{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>
</div> </div>
)} )}
{selectedLog.target_item_pn && (
<div className="space-y-1">
<p className="text-[10px] font-black text-slate-600">Historical Part Number</p>
<div className="bg-slate-800/20 text-slate-400 p-4 rounded-2xl border border-slate-800/50 text-xs font-mono">
{selectedLog.target_item_pn}
</div>
</div>
)}
</div> </div>
<button <button

File diff suppressed because it is too large Load Diff

View File

@@ -16,9 +16,11 @@ export default function AIOnboarding({ onCancel, onComplete, categories, invento
const [image, setImage] = useState<string | null>(null); const [image, setImage] = useState<string | null>(null);
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [extractedData, setExtractedData] = useState<any>(null); const [extractedData, setExtractedData] = useState<any>(null);
const [mode, setMode] = useState<'item' | 'box'>('item');
// Extract unique item types for suggestions // Extract unique item types for suggestions
const existingTypes = Array.from(new Set(inventory.map(i => i.type).filter(Boolean))).sort() as string[]; const existingTypes = Array.from(new Set(inventory.map(i => i.type).filter(Boolean))).sort() as string[];
const existingBoxes = Array.from(new Set(inventory.map(i => i.box_label).filter(Boolean))).sort() as string[];
const cameraInputRef = useRef<HTMLInputElement>(null); const cameraInputRef = useRef<HTMLInputElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
@@ -41,7 +43,7 @@ export default function AIOnboarding({ onCancel, onComplete, categories, invento
const formData = new FormData(); const formData = new FormData();
formData.append('file', blob, 'label.jpg'); formData.append('file', blob, 'label.jpg');
const data = await inventoryApi.analyzeLabel(formData); const data = await inventoryApi.analyzeLabel(formData, mode);
if (data.error) { if (data.error) {
toast.error(`AI Error: ${data.error}`); toast.error(`AI Error: ${data.error}`);
@@ -50,7 +52,11 @@ export default function AIOnboarding({ onCancel, onComplete, categories, invento
} }
setExtractedData(data); setExtractedData(data);
toast.success("AI extraction complete!"); if (mode === 'box') {
toast.success(`Box identified: ${data.box_label || data.name}`);
} else {
toast.success("AI extraction complete!");
}
} catch (error) { } catch (error) {
toast.error("Failed to process image with AI"); toast.error("Failed to process image with AI");
console.error(error); console.error(error);
@@ -71,6 +77,7 @@ export default function AIOnboarding({ onCancel, onComplete, categories, invento
barcode: String(extractedData.barcode || extractedData.part_number || extractedData.serial_number || `AI-${Date.now()}`), barcode: String(extractedData.barcode || extractedData.part_number || extractedData.serial_number || `AI-${Date.now()}`),
quantity: parseFloat(String(extractedData.quantity || 1)), quantity: parseFloat(String(extractedData.quantity || 1)),
min_quantity: 1.0, min_quantity: 1.0,
box_label: extractedData.box_label ? String(extractedData.box_label) : null,
labels_data: JSON.stringify(extractedData) labels_data: JSON.stringify(extractedData)
}; };
onComplete(newItem); onComplete(newItem);
@@ -95,13 +102,34 @@ export default function AIOnboarding({ onCancel, onComplete, categories, invento
{!image ? ( {!image ? (
<div className="flex-1 flex flex-col gap-6 min-h-0"> <div className="flex-1 flex flex-col gap-6 min-h-0">
<div className="flex bg-slate-900/50 p-1.5 rounded-2xl border border-slate-800/50 shrink-0">
<button
onClick={() => setMode('item')}
className={`flex-1 flex items-center justify-center gap-2 py-3 rounded-xl font-bold transition-all ${mode === 'item' ? 'bg-primary text-white shadow-lg' : 'text-slate-500 hover:text-slate-300'}`}
>
<Package size={18} />
<span className="text-xs">Item Label</span>
</button>
<button
onClick={() => setMode('box')}
className={`flex-1 flex items-center justify-center gap-2 py-3 rounded-xl font-bold transition-all ${mode === 'box' ? 'bg-primary text-white shadow-lg' : 'text-slate-500 hover:text-slate-300'}`}
>
<Layers size={18} />
<span className="text-xs">Box / Container</span>
</button>
</div>
<div className="flex-1 flex flex-col items-center justify-center border-2 border-dashed border-slate-800 rounded-[2.5rem] bg-slate-900/30 overflow-hidden px-4"> <div className="flex-1 flex flex-col items-center justify-center border-2 border-dashed border-slate-800 rounded-[2.5rem] bg-slate-900/30 overflow-hidden px-4">
<div className="w-20 h-20 bg-slate-900 rounded-3xl flex items-center justify-center mb-6 shadow-inner"> <div className="w-20 h-20 bg-slate-900 rounded-3xl flex items-center justify-center mb-6 shadow-inner">
<Camera size={32} className="text-slate-500" /> <Camera size={32} className={mode === 'box' ? 'text-primary' : 'text-slate-500'} />
</div> </div>
<p className="text-slate-300 mb-2 text-center font-bold">Label Insight Mode</p> <p className="text-slate-300 mb-2 text-center font-bold">
{mode === 'box' ? 'Container Discovery Mode' : 'Label Insight Mode'}
</p>
<p className="text-xs text-slate-500 px-8 text-center leading-relaxed font-bold"> <p className="text-xs text-slate-500 px-8 text-center leading-relaxed font-bold">
Scan or upload a sharp photo of the item specifications {mode === 'box'
? 'Scan the large, prominent label or hand-written name on the container'
: 'Scan or upload a sharp photo of the item specifications'}
</p> </p>
</div> </div>
@@ -229,6 +257,19 @@ export default function AIOnboarding({ onCancel, onComplete, categories, invento
/> />
</div> </div>
</div> </div>
<div className="bg-slate-900/80 p-5 rounded-[1.5rem] border border-slate-800 focus-within:border-primary/50 transition-colors group">
<label className="text-xs text-slate-500 font-bold mb-1 block group-focus-within:text-primary transition-colors">Box / Container Label</label>
<input
value={extractedData.box_label || ''}
list="onboarding-boxes"
onChange={(e) => setExtractedData({...extractedData, box_label: e.target.value})}
className="bg-transparent w-full font-bold outline-none text-slate-200"
placeholder="e.g. Box 1"
/>
<datalist id="onboarding-boxes">
{existingBoxes.map(b => <option key={b} value={b} />)}
</datalist>
</div>
</div> </div>
<div className="bg-slate-900/80 p-5 rounded-[1.5rem] border border-slate-800 focus-within:border-primary/50 transition-colors group"> <div className="bg-slate-900/80 p-5 rounded-[1.5rem] border border-slate-800 focus-within:border-primary/50 transition-colors group">

View File

@@ -25,7 +25,7 @@ export default function BottomNav({
const isAdmin = pathname === '/admin'; const isAdmin = pathname === '/admin';
return ( return (
<footer className="fixed bottom-0 left-0 right-0 p-4 bg-slate-950/80 backdrop-blur-md border-t border-slate-900 z-40"> <footer className="fixed bottom-0 left-0 right-0 p-4 pb-safe bg-slate-950/80 backdrop-blur-md border-t border-slate-900 z-40">
<div className="max-w-4xl mx-auto flex justify-around items-center text-slate-400"> <div className="max-w-4xl mx-auto flex justify-around items-center text-slate-400">
<button <button
@@ -68,10 +68,12 @@ export default function BottomNav({
{/* Logout */} {/* Logout */}
<button <button
onClick={() => { onClick={() => {
import('@/lib/auth').then(m => m.clearAuth()); if (window.confirm("Are you sure you want to logout?")) {
window.location.href = '/login'; import('@/lib/auth').then(m => m.clearAuth());
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

@@ -25,6 +25,7 @@ export default function Scanner({ onScanSuccess, onOCRMatch, paused }: ScannerPr
const [isSelecting, setIsSelecting] = useState(false); const [isSelecting, setIsSelecting] = useState(false);
const [capturedImage, setCapturedImage] = useState<string | null>(null); const [capturedImage, setCapturedImage] = useState<string | null>(null);
const isBusy = useRef(false); const isBusy = useRef(false);
const isTransitioning = useRef(false); // Flag to track start/stop transitions
const scannerId = "reader-container-unique"; const scannerId = "reader-container-unique";
useEffect(() => { useEffect(() => {
@@ -33,11 +34,20 @@ export default function Scanner({ onScanSuccess, onOCRMatch, paused }: ScannerPr
} }
const startScanner = async () => { const startScanner = async () => {
if (isBusy.current) return; if (isBusy.current || isTransitioning.current) return;
isBusy.current = true; isBusy.current = true;
isTransitioning.current = true;
try { try {
// If already scanning, we MUST stop it first
if (html5QrCodeRef.current?.isScanning) { if (html5QrCodeRef.current?.isScanning) {
await html5QrCodeRef.current.stop(); try {
await html5QrCodeRef.current.stop();
} catch (e) {
console.warn("Graceful stop failed, might be in transition:", e);
// If it's already in transition, we just return and wait for the next effect trigger
return;
}
} }
const config = { const config = {
@@ -85,6 +95,7 @@ export default function Scanner({ onScanSuccess, onOCRMatch, paused }: ScannerPr
} }
} finally { } finally {
isBusy.current = false; isBusy.current = false;
isTransitioning.current = false;
} }
}; };
@@ -94,11 +105,15 @@ export default function Scanner({ onScanSuccess, onOCRMatch, paused }: ScannerPr
return () => { return () => {
if (html5QrCodeRef.current?.isScanning) { if (html5QrCodeRef.current?.isScanning) {
isTransitioning.current = true;
html5QrCodeRef.current.stop() html5QrCodeRef.current.stop()
.then(() => { .then(() => {
setIsStarted(false); setIsStarted(false);
}) })
.catch(e => console.error("Stop failed", e)); .catch(e => console.error("Unmount stop failed", e))
.finally(() => {
isTransitioning.current = false;
});
} }
}; };
}, [paused, onScanSuccess]); }, [paused, onScanSuccess]);
@@ -296,7 +311,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 +325,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

@@ -1,20 +1,48 @@
import axios from 'axios'; import axios from 'axios';
import { getToken, clearAuth } from './auth'; import { getToken, clearAuth } from './auth';
export const getBackendUrl = () => { // Cached config to avoid repeated fetches
if (typeof window === 'undefined') return 'http://localhost:8000'; let cachedConfig: any = null;
/**
* Fetches the network configuration from the public/network.json file.
* This file is generated at startup by start_server.sh or docker-compose.
*/
export const getNetworkConfig = async () => {
if (cachedConfig) return cachedConfig;
if (typeof window === 'undefined') {
return { SERVER_IP: 'localhost', BACKEND_PORT: 8000, BACKEND_SSL_PORT: 8908 };
}
try {
const response = await fetch('/network.json');
if (!response.ok) throw new Error("Config not found");
cachedConfig = await response.json();
return cachedConfig;
} catch (e) {
console.warn("Network config not found, using compiled defaults.");
// Defaults matching the initial reserve ports in case network.json is missing
return { SERVER_IP: 'localhost', BACKEND_PORT: 8906, BACKEND_SSL_PORT: 8908 };
}
};
export const getBackendUrl = async () => {
const config = await getNetworkConfig();
if (typeof window === 'undefined') return `http://localhost:${config.BACKEND_PORT}`;
const host = window.location.hostname; const host = window.location.hostname;
// If we are on HTTPS (Proxy/Mobile mode), we use port 3002 for the backend // If we are on HTTPS (Proxy/Mobile mode), we use the SSL port for the backend
if (window.location.protocol === 'https:') { if (window.location.protocol === 'https:') {
if (host.includes('.loca.lt')) { if (host.includes('.loca.lt')) {
return 'https://inventory-ai-api.loca.lt'; return 'https://inventory-ai-api.loca.lt';
} }
return `https://${host}:3002`; return `https://${host}:${config.BACKEND_SSL_PORT}`;
} }
return `http://${host}:8000`; return `http://${host}:${config.BACKEND_PORT}`;
}; };
/** /**
@@ -23,9 +51,9 @@ export const getBackendUrl = () => {
*/ */
const axiosInstance = axios.create({}); const axiosInstance = axios.create({});
axiosInstance.interceptors.request.use((config) => { axiosInstance.interceptors.request.use(async (config) => {
if (!config.baseURL) { if (!config.baseURL) {
config.baseURL = getBackendUrl(); // called at request time — always correct config.baseURL = await getBackendUrl(); // called at request time — always correct
} }
const token = getToken(); const token = getToken();
if (token) { if (token) {
@@ -75,8 +103,8 @@ export const inventoryApi = {
} }
}, },
analyzeLabel: async (formData: FormData) => { analyzeLabel: async (formData: FormData, mode: string = "item") => {
const res = await axiosInstance.post('/items/extract-label', formData, { const res = await axiosInstance.post(`/items/extract-label?mode=${mode}`, formData, {
headers: { 'Content-Type': 'multipart/form-data' } headers: { 'Content-Type': 'multipart/form-data' }
}); });
return res.data; return res.data;
@@ -110,7 +138,8 @@ export const inventoryApi = {
// Users // Users
getUsers: async () => { getUsers: async () => {
// [C-01] Public endpoint — use plain axios to avoid JWT interceptor // [C-01] Public endpoint — use plain axios to avoid JWT interceptor
const res = await axios.get(`${getBackendUrl()}/users/`); const baseUrl = await getBackendUrl();
const res = await axios.get(`${baseUrl}/users/`);
return res.data; return res.data;
}, },
@@ -121,7 +150,8 @@ export const inventoryApi = {
login: async (credentials: any) => { login: async (credentials: any) => {
// [C-01] Login endpoint — NU adaug token header (login e public) // [C-01] Login endpoint — NU adaug token header (login e public)
const res = await axios.post(`${getBackendUrl()}/users/login`, credentials); const baseUrl = await getBackendUrl();
const res = await axios.post(`${baseUrl}/users/login`, credentials);
return res.data; return res.data;
}, },
@@ -164,5 +194,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

@@ -11,6 +11,7 @@ export interface Item {
quantity: number; quantity: number;
min_quantity: number; min_quantity: number;
image_url?: string; image_url?: string;
box_label?: string;
labels_data?: string; labels_data?: string;
serial_number?: string; serial_number?: string;
type?: string; type?: string;
@@ -33,8 +34,8 @@ export class InventoryDatabase extends Dexie {
constructor() { constructor() {
super('InventoryDatabase'); super('InventoryDatabase');
this.version(3).stores({ this.version(4).stores({
items: '++id, barcode, name, category, part_number, color', items: '++id, barcode, name, category, part_number, color, box_label',
pendingOperations: '++id, barcode, timestamp, synced, uuid' pendingOperations: '++id, barcode, timestamp, synced, uuid'
}); });
} }

58
frontend/lib/labels.ts Normal file
View File

@@ -0,0 +1,58 @@
/**
* DEPENDENCY-FREE LABEL GENERATOR UTILITY
* Generates Code 128 Barcodes and QR Codes as SVGs.
*/
// --- Barcodes (Code 128) ---
export function generateBarcode128(data: string): string {
// Simple subset of Code 128 (Pattern B)
const patterns: Record<string, string> = {
' ': '11011001100', '!': '11001101100', '"': '11001100110', '#': '10010011000',
'$': '10010001100', '%': '10001001100', '&': '10011001000', '\'': '10011000100',
'(': '10001100100', ')': '11001001000', '*': '11001000100', '+': '11000100100',
',': '10110011100', '-': '10011011100', '.': '10011001110', '/': '10111001100',
'0': '10011100110', '1': '11001011100', '2': '11001001110', '3': '11001110100',
'4': '11001110010', '5': '11011100100', '6': '11011100010', '7': '11011101100',
'8': '11011100110', '9': '11101101100', ':': '11101100110', ';': '11100101100',
'<': '11100100110', '=': '11100111010', '>': '11100111001', '?': '11011011110',
'@': '11011110110', 'A': '11110110110', 'B': '11101011000', 'C': '11101000110',
'D': '11100010110', 'E': '11101101000', 'F': '11101100010', 'G': '11100011010',
'H': '11101111010', 'I': '11001000010', 'J': '11110111010', 'K': '10100110000',
'L': '10100001100', 'M': '10001011000', 'N': '10001000110', 'O': '10110001000',
'P': '10001101000', 'Q': '10001100010', 'R': '11010001000', 'S': '11000101000',
'T': '11000100010', 'U': '11011101000', 'V': '11011100010', 'W': '11011101110',
'X': '11101011110', 'Y': '11110101110', 'Z': '11110111010', '[': '10111101110',
'\\': '10111111010', ']': '11101011110', '^': '11110101110', '_': '11110111010',
'start': '11010010000', 'stop': '1100011101011'
};
let barcode = patterns['start'];
for (let char of data) {
barcode += patterns[char] || '';
}
barcode += patterns['stop'];
let result = `<svg viewBox="0 0 ${barcode.length} 50" xmlns="http://www.w3.org/2000/svg">`;
for (let i = 0; i < barcode.length; i++) {
if (barcode[i] === '1') {
result += `<rect x="${i}" y="0" width="1" height="50" fill="black" />`;
}
}
result += `</svg>`;
return result;
}
// --- QR Codes (Simplistic implementation or API Fallback) ---
// Since QR generation is extremely complex for a "dependency-free" one-off script,
// we will use a SVG-based miniature implementation (QRlite logic) if possible,
// or a simple public QR API (qrserver) if online, but as per plan we want offline.
// For now, I'll provide a local "Barcode only" generator and a placeholder for QR.
// UPDATE: I will use a minimal DataURL encoding for an <img> tag for the user's ease.
/**
* Returns a URL for the QR code image.
* Can be replaced by a full d-free JS QR library if absolute offline is 100% required.
*/
export function getQRCodeURL(data: string): string {
return `https://api.qrserver.com/v1/create-qr-code/?size=300x300&data=${encodeURIComponent(data)}`;
}

View File

@@ -1,21 +1,38 @@
{ {
"name": "Inventory PWA", "name": "TFM aInventory",
"short_name": "Inventory", "short_name": "aInventory",
"description": "Unified inventory management with offline scanning", "description": "Unified inventory management with offline scanning",
"start_url": "/", "start_url": "/",
"display": "standalone", "display": "standalone",
"orientation": "portrait",
"background_color": "#0a0a0a", "background_color": "#0a0a0a",
"theme_color": "#3b82f6", "theme_color": "#3b82f6",
"icons": [ "icons": [
{ {
"src": "/icons/icon-192x192.png", "src": "/icons/icon-192x192.png",
"sizes": "192x192", "sizes": "192x192",
"type": "image/png" "type": "image/png",
"purpose": "any"
},
{
"src": "/icons/maskable-icon.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}, },
{ {
"src": "/icons/icon-512x512.png", "src": "/icons/icon-512x512.png",
"sizes": "512x512", "sizes": "512x512",
"type": "image/png" "type": "image/png",
"purpose": "any"
}
],
"categories": ["business", "productivity"],
"shortcuts": [
{
"name": "Scanner",
"url": "/",
"icons": [{ "src": "/icons/icon-192x192.png", "sizes": "192x192" }]
} }
] ]
} }

2
logs/.gitkeep Normal file
View File

@@ -0,0 +1,2 @@
# This file exists to track the logs/ directory in Git.
# Actual log files are excluded via .gitignore and generated at runtime.

View File

@@ -7,17 +7,38 @@ echo "🚀 Starting TFM aInventory in Standalone Mode..."
# Trapping termination signals to clean up child processes # Trapping termination signals to clean up child processes
trap "echo 'Stopping all processes...'; kill 0" SIGINT SIGTERM EXIT trap "echo 'Stopping all processes...'; kill 0" SIGINT SIGTERM EXIT
# --- CONFIGURATION (Match start_server.sh) --- # --- CONFIGURATION (Default values, overridden by network_config.env) ---
BACKEND_PORT=8000 BACKEND_PORT=8000
FRONTEND_PORT=3001 FRONTEND_PORT=3001
BACKEND_SSL_PORT=3002 BACKEND_SSL_PORT=3002
FRONTEND_SSL_PORT=3003 FRONTEND_SSL_PORT=3003
SERVER_IP="localhost"
# Load Configuration from file if it exists
CONFIG_PATH="$(cd "$(dirname "$0")" && pwd)/config/network_config.env"
if [ -f "$CONFIG_PATH" ]; then
echo "⚙️ Loading network configuration from $CONFIG_PATH..."
export $(grep -v '^#' "$CONFIG_PATH" | xargs)
fi
# 1. Activate Environment # 1. Activate Environment
if [ -d ".venv" ]; then if [ -d ".venv" ]; then
source .venv/bin/activate source .venv/bin/activate
fi fi
# 1.5 Sync Network Config to Frontend
echo "🔌 Syncing network configuration to frontend..."
mkdir -p frontend/public
cat <<EOF > frontend/public/network.json
{
"SERVER_IP": "$SERVER_IP",
"BACKEND_PORT": $BACKEND_PORT,
"BACKEND_SSL_PORT": $BACKEND_SSL_PORT,
"FRONTEND_PORT": $FRONTEND_PORT,
"FRONTEND_SSL_PORT": $FRONTEND_SSL_PORT
}
EOF
# 2. Start Backend (No Reload for Prod) # 2. Start Backend (No Reload for Prod)
echo "🔥 Starting Backend (Uvicorn)..." echo "🔥 Starting Backend (Uvicorn)..."
python3 -m uvicorn backend.main:app --host 0.0.0.0 --port $BACKEND_PORT & python3 -m uvicorn backend.main:app --host 0.0.0.0 --port $BACKEND_PORT &

56
scripts/init_data.sh Executable file
View File

@@ -0,0 +1,56 @@
#!/bin/bash
# =============================================================================
# scripts/init_data.sh
# =============================================================================
# First-run initialization script for TFM aInventory.
# Creates runtime directories and copies configuration templates if missing.
#
# Called by:
# - start_server.sh (local dev / systemd runs)
# - backend/entrypoint.sh (Docker container startup)
#
# Environment variables (with defaults):
# DATA_DIR — path to runtime data directory (default: <project_root>/data)
# LOGS_DIR — path to runtime logs directory (default: <project_root>/logs)
# =============================================================================
set -euo pipefail
# Resolve project root relative to this script's location
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
# Use environment variables if set, otherwise default to in-repo directories
DATA_DIR="${DATA_DIR:-$PROJECT_ROOT/data}"
LOGS_DIR="${LOGS_DIR:-$PROJECT_ROOT/logs}"
echo " [init] DATA_DIR = $DATA_DIR"
echo " [init] LOGS_DIR = $LOGS_DIR"
# 1. Create runtime directories (idempotent)
mkdir -p "$DATA_DIR"
mkdir -p "$LOGS_DIR"
# 2. Copy LDAP config from example template if no active config exists yet
# NOTE: The application reads LDAP config from config/ldap_config.json
# (see backend/routers/users.py → get_ldap_config())
LDAP_CONFIG="$PROJECT_ROOT/config/ldap_config.json"
LDAP_EXAMPLE="$PROJECT_ROOT/config/ldap_config.json.example"
if [ ! -f "$LDAP_CONFIG" ]; then
if [ -f "$LDAP_EXAMPLE" ]; then
cp "$LDAP_EXAMPLE" "$LDAP_CONFIG"
echo " [init] LDAP config copied from example → $LDAP_CONFIG"
echo " [init] ⚠️ Edit $LDAP_CONFIG with your real LDAP server settings."
else
echo " [init] WARNING: No LDAP config example found at $LDAP_EXAMPLE"
fi
else
echo " [init] LDAP config already exists — skipping copy."
fi
# 3. Database schema is created automatically by FastAPI/SQLAlchemy on first
# request (see backend/main.py → Base.metadata.create_all). The DATA_DIR
# created above ensures the DB file can be written to the correct location.
echo " [init] Runtime data initialization complete."

View File

@@ -4,7 +4,7 @@ import os
import sys import sys
from datetime import datetime from datetime import datetime
VERSION_FILE = 'VERSION.json' VERSION_FILE = 'frontend/VERSION.json'
GIT_PATH_FILE = '.git_path' GIT_PATH_FILE = '.git_path'
def get_git_path(): def get_git_path():
@@ -24,7 +24,7 @@ def run_command(cmd):
def main(): def main():
git = get_git_path() git = get_git_path()
# 1. Read and Increment Version # 1. Read and Parse Version
if not os.path.exists(VERSION_FILE): if not os.path.exists(VERSION_FILE):
print(f"Error: {VERSION_FILE} not found.") print(f"Error: {VERSION_FILE} not found.")
sys.exit(1) sys.exit(1)
@@ -34,10 +34,20 @@ def main():
old_version = data.get('version', '1.0.0') old_version = data.get('version', '1.0.0')
parts = old_version.split('.') parts = old_version.split('.')
if len(parts) == 3: if len(parts) < 3:
parts[2] = str(int(parts[2]) + 1) parts.extend(['0'] * (3 - len(parts)))
# Check for --minor or --major flags
if '--minor' in sys.argv:
parts[1] = str(int(parts[1]) + 1)
parts[2] = '0'
elif '--major' in sys.argv:
parts[0] = str(int(parts[0]) + 1)
parts[1] = '0'
parts[2] = '0'
else: else:
parts.append('1') # Default: patch increment
parts[2] = str(int(parts[2]) + 1)
new_version = '.'.join(parts) new_version = '.'.join(parts)
data['version'] = new_version data['version'] = new_version
@@ -51,17 +61,24 @@ def main():
# 2. Git Operations # 2. Git Operations
run_command([git, 'add', '.']) run_command([git, 'add', '.'])
run_command([git, 'commit', '-m', f"Build [v.{new_version}]"]) run_command([git, 'commit', '-m', f"Build [v{new_version}]"])
# 3. Create branch (snapshot) # 3. Create branch (snapshot)
branch_name = f"v.{new_version}" branch_name = f"v{new_version}"
run_command([git, 'branch', branch_name]) run_command([git, 'branch', branch_name])
# 4. Create Production Bundle # 4. Update master branch
print(f"🔄 Updating 'master' branch to v{new_version}...")
current_branch = run_command([git, 'rev-parse', '--abbrev-ref', 'HEAD'])
run_command([git, 'checkout', 'master'])
run_command([git, 'merge', current_branch])
run_command([git, 'checkout', current_branch])
# 5. Create Production Bundle
print("📦 Generating production bundle...") print("📦 Generating production bundle...")
run_command(['./export_prod.sh']) run_command(['./export_prod.sh'])
print(f"Successfully saved version {new_version}, created branch {branch_name}, and generated production ZIP.") print(f"Successfully saved version {new_version}, created branch {branch_name}, updated 'master', and generated production ZIP.")
print("Verification: check for the .zip file in the root.") print("Verification: check for the .zip file in the root.")
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -1,10 +1,22 @@
#!/bin/bash #!/bin/bash
# --- CONFIGURATION --- # --- CONFIGURATION (Default values, will be overridden by network_config.env) ---
BACKEND_PORT=8000 BACKEND_PORT=8000
FRONTEND_PORT=3001 FRONTEND_PORT=3001
BACKEND_SSL_PORT=3002 BACKEND_SSL_PORT=3002
FRONTEND_SSL_PORT=3003 FRONTEND_SSL_PORT=3003
SERVER_IP="localhost"
# Load Configuration from file if it exists
CONFIG_PATH="$(cd "$(dirname "$0")" && pwd)/config/network_config.env"
if [ -f "$CONFIG_PATH" ]; then
echo "⚙️ Loading network configuration from $CONFIG_PATH..."
# Export variables from .env file (ignoring comments and empty lines)
export $(grep -v '^#' "$CONFIG_PATH" | xargs)
fi
# 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..."
@@ -32,6 +44,22 @@ export JWT_SECRET_KEY="${JWT_SECRET_KEY:-ephemeral-dev-key-$(date +%s)}"
export DATA_DIR="$(cd "$(dirname "$0")" && pwd)/data" export DATA_DIR="$(cd "$(dirname "$0")" && pwd)/data"
export LOGS_DIR="$(cd "$(dirname "$0")" && pwd)/logs" export LOGS_DIR="$(cd "$(dirname "$0")" && pwd)/logs"
# First-run: initialize data directories and config templates
echo "🔧 Initializing runtime data directories..."
bash "$(cd "$(dirname "$0")" && pwd)/scripts/init_data.sh"
# 4.1 Sync Network Config to Frontend for runtime discovery
echo "🔌 Syncing network configuration to frontend..."
cat <<EOF > frontend/public/network.json
{
"SERVER_IP": "$SERVER_IP",
"BACKEND_PORT": $BACKEND_PORT,
"BACKEND_SSL_PORT": $BACKEND_SSL_PORT,
"FRONTEND_PORT": $FRONTEND_PORT,
"FRONTEND_SSL_PORT": $FRONTEND_SSL_PORT
}
EOF
echo "🔥 Starting Backend on port $BACKEND_PORT..." echo "🔥 Starting Backend on port $BACKEND_PORT..."
echo " CORS origins: $ALLOWED_ORIGINS" echo " CORS origins: $ALLOWED_ORIGINS"
python3 -m uvicorn backend.main:app --host 0.0.0.0 --port $BACKEND_PORT --reload & python3 -m uvicorn backend.main:app --host 0.0.0.0 --port $BACKEND_PORT --reload &
@@ -60,8 +88,8 @@ echo -e "${GREEN}${BOLD} 🚀 TFM aInventory UNIFIED ACCESS${NC}"
echo -e "${GREEN}=======================================================${NC}" echo -e "${GREEN}=======================================================${NC}"
echo "" echo ""
echo -e " USE THIS URL ON BOTH DESKTOP & MOBILE:" echo -e " USE THIS URL ON BOTH DESKTOP & MOBILE:"
echo -e " 👉 ${GREEN}${BOLD}https://$LOCAL_IP:3003${NC}" echo -e " 👉 ${GREEN}${BOLD}https://$LOCAL_IP:$FRONTEND_SSL_PORT${NC}"
echo -e " (Or ${GREEN}https://localhost:3003${NC} on this Mac)" echo -e " (Or ${GREEN}https://localhost:$FRONTEND_SSL_PORT${NC} on this Mac)"
echo "" echo ""
echo -e " ${YELLOW}${BOLD}NOTE:${NC} If you see a 'Not Private' warning," echo -e " ${YELLOW}${BOLD}NOTE:${NC} If you see a 'Not Private' warning,"
echo -e " Click 'Advanced' -> 'Proceed' to continue." echo -e " Click 'Advanced' -> 'Proceed' to continue."