Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
946f1787c7 | ||
|
|
09be0b401d | ||
|
|
3069a921e7 | ||
|
|
55e3cf5042 | ||
|
|
c874d27d64 | ||
|
|
939ad8648d | ||
|
|
6f6caf3c5a | ||
|
|
5e648002f0 | ||
|
|
81b775c9ae | ||
|
|
994920bda2 | ||
|
|
0a6368b9f6 | ||
|
|
26e8f034a2 | ||
|
|
2e5c666cc8 | ||
|
|
e383b97d44 | ||
|
|
50ae3671c9 | ||
|
|
f3d861b1a2 | ||
|
|
5cceba21f4 | ||
|
|
161a182281 |
43
.agents/rules/api-testing-postman-rest-asured.md
Normal file
43
.agents/rules/api-testing-postman-rest-asured.md
Normal 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
|
||||
74
.agents/rules/modern-css-and-esponsive-design-expert.md
Normal file
74
.agents/rules/modern-css-and-esponsive-design-expert.md
Normal 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
|
||||
88
.agents/rules/progressive-web-app-pwa-expert.md
Normal file
88
.agents/rules/progressive-web-app-pwa-expert.md
Normal 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
|
||||
44
.agents/rules/security-and-penetration-testing.md
Normal file
44
.agents/rules/security-and-penetration-testing.md
Normal 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
37
.gitignore
vendored
@@ -13,17 +13,26 @@ backend/venv/
|
||||
dist/
|
||||
build/
|
||||
|
||||
# ── Runtime data (databases, configs w/ secrets) ─────────────
|
||||
data/
|
||||
# ── Runtime data directories ─────────────────────────────────
|
||||
# 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/logs/
|
||||
# LDAP config contains real server IPs and credentials — never commit
|
||||
# The active config is: backend/config/ldap_config.json (read by the app)
|
||||
# Duplicate/orphan copies are also excluded:
|
||||
backend/ldap_config.json
|
||||
|
||||
# ── Sensitive configuration files ────────────────────────────
|
||||
# The ACTIVE LDAP config is: backend/config/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/data/ldap_config.json
|
||||
# Keep example files tracked:
|
||||
!backend/config/ldap_config.json.example
|
||||
|
||||
# ── Environment files (secrets) ──────────────────────────────
|
||||
@@ -38,8 +47,12 @@ docker-compose.override.yml
|
||||
.env.docker
|
||||
|
||||
# ── Application logs ─────────────────────────────────────────
|
||||
logs/
|
||||
# (also covered by /logs/* above, these catch any other locations)
|
||||
frontend/logs/
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# ── Frontend build artifacts ──────────────────────────────────
|
||||
frontend/.next/
|
||||
@@ -56,10 +69,6 @@ frontend/public/icons/
|
||||
# ── npm / npx caches ─────────────────────────────────────────
|
||||
.npx_cache/
|
||||
scratch/npm_cache/
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# ── Production bundles (generated by export_prod.sh) ─────────
|
||||
aInventory-PROD*/
|
||||
@@ -78,3 +87,5 @@ aInventory-PROD*.zip
|
||||
*.key
|
||||
*.crt
|
||||
*.cert
|
||||
|
||||
__push_ALL_to_remote.sh
|
||||
|
||||
104
AI_RULES.md
104
AI_RULES.md
@@ -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.**
|
||||
This is the **Single Source of Truth** for ALL Artificial Intelligence agents (Claude, Gemini, etc.) working on this project.
|
||||
(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).
|
||||
This is the **Single Source of Truth** for ALL AI agents. Refer to [PROJECT_ARCHITECTURE.md](PROJECT_ARCHITECTURE.md) for technical logic.
|
||||
|
||||
## 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**.
|
||||
- **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.
|
||||
---
|
||||
|
||||
## 1. AI MEMORY, TRACEABILITY & HANDOVER
|
||||
- **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.
|
||||
|
||||
## 2. Global 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.
|
||||
- **COMMUNICATION LANGUAGE**: Conversation with the user will be in Romanian or English (preferably Romanian).
|
||||
- **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.
|
||||
- **COMMIT & PUSH STRICT RULE**:
|
||||
- Never push to remote (`git push`) or use force flags (`--hard`, `--force`) unless explicitly requested.
|
||||
- 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.
|
||||
## 2. ENGINEERING & OPERATIONAL LAWS
|
||||
- **ENGLISH ONLY**: Interfaces, code, variables, and docs MUST be in English. Translate any Romanian text found in code immediately. (User conversation: Romanian/English).
|
||||
- **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).
|
||||
- **VERSIONING**: Update `VERSION.json` on every commit. Use `scripts/save_version.py` for automated releases.
|
||||
- **DEPENDENCIES**: Update `backend/requirements.txt` with version constraints for every new pip package.
|
||||
- **SSOT INTEGRITY**: Every feature change MUST update: `README.md`, `USER_GUIDE.md`, `PROJECT_ARCHITECTURE.md`, and `export_prod.sh`.
|
||||
|
||||
## 3. UI/UX Fidelity Specifications
|
||||
- **Fidelity First**: Never simplify the UI unless asked. Density and aesthetics must remain "Premium".
|
||||
- **Styling**: Tailwind CSS. Font weights/spacing must be consistent (Inter/Roboto).
|
||||
- **Readability**: NO `uppercase` or `tracking-widest` styles. Use standard camel/Title case.
|
||||
- **Icons**: Use **Lucide Icons** exclusively. NO emojis.
|
||||
- **Interactive Affordance**: All select/dropdown boxes MUST have a `ChevronDown` icon. Masked passwords should have reduced opacity (`text-white/50`).
|
||||
- **Iconography Standardization**:
|
||||
- **Categories**: ALWAYS use `Layers` (Color: `text-primary`).
|
||||
- **Item Types**: ALWAYS use `Package` (Color: `text-green-500`).
|
||||
## 3. UI/UX "PREMIUM" FIDELITY STANDARDS
|
||||
- **Aesthetics**: Density/aesthetics must remain "Premium". Use Tailwind CSS. NO simplification.
|
||||
- **Typography Rules**:
|
||||
- **NO UPPERCASE** or **NO ITALICS** in headers, labels, buttons, or metadata.
|
||||
- **NO `tracking-widest`**. Use standard camel/Title case.
|
||||
- Use `font-black` for main headings.
|
||||
- **Layout**: Main pages MUST use `max-w-7xl`.
|
||||
- **Unified Headers**: Icon box (`p-4 bg-primary/10 border-primary/20`) + Title (`text-3xl font-black`) + Subtitle (`text-xs text-slate-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
|
||||
- **SSOT INTEGRITY**: Whenever a feature is added, modified, or removed, you MUST update all corresponding documentation files:
|
||||
- `README.md` (General usage & technical modes).
|
||||
- `USER_GUIDE.md` (End-user instructions).
|
||||
- `PROJECT_ARCHITECTURE.md` (Technical logic & data models).
|
||||
- `export_prod.sh` (If new scripts or files must be included in the production bundle).
|
||||
- **REAL-TIME UPDATES**: Documentation updates are NOT optional and must be performed within the same session as the code changes.
|
||||
## 4. DATA INTEGRITY & AUDIT POLICY
|
||||
- **RESTRICTED ACTIONS**: `DELETE /items/` and Admin settings require `auth.get_current_admin`.
|
||||
- **AUDIT IMMUTABILITY**: Deleting an `Item` MUST NOT delete its `AuditLog` entries.
|
||||
- **TRACEABILITY**: Log deletions to `logs/backend.log` with `USER[id]`, `ITEM[id]`, `Name`, `PN`.
|
||||
- **CONFIRMATION**:
|
||||
- **Triple Confirmation**: Deleting critical entities (Locations/Items) requires user confirmation 3 times.
|
||||
- **Native Alerts**: Use `window.confirm` for all destructive UI actions and Logout.
|
||||
|
||||
## 6. AI Command Shortcuts
|
||||
- **`save-version`**: When the user triggers this command, the AI MUST:
|
||||
1. Increment the patch version in `VERSION.json`.
|
||||
2. Stage all current changes (`git add .`).
|
||||
3. Commit changes with message `Build [vX.Y.Z]`.
|
||||
4. Create a new branch named `vX.Y.Z` from the current state.
|
||||
5. Generate a production bundle ZIP (calls `./export_prod.sh`).
|
||||
6. Stay on the current branch (`dev`).
|
||||
- *Implementation*: Use `python3 scripts/save_version.py` to ensure consistency.
|
||||
## 5. AI COMMAND SHORTCUTS
|
||||
- **`save-version`**:
|
||||
0. **MANDATORY**: Verify and update ALL documentation (`.md` files: README, USER_GUIDE, ARCHITECTURE, etc.) with explanations of all current changes.
|
||||
1. Increment `VERSION.json`.
|
||||
2. Git add/commit (`Build [vX.Y.Z]`).
|
||||
3. Create branch `vX.Y.Z` (Snapshot).
|
||||
4. Automatic Sync: Merge changes into `master` branch to keep it up-to-date.
|
||||
5. Run `./export_prod.sh`.
|
||||
(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:
|
||||
```
|
||||
---
|
||||
✓ Done.
|
||||
```
|
||||
- Do not provide unnecessary verbatim summaries of the code.
|
||||
---
|
||||
|
||||
## END OF SESSION PROTOCOL
|
||||
End your final response on a separate line exactly with:
|
||||
```
|
||||
---
|
||||
✓ Done.
|
||||
```
|
||||
|
||||
8
PLAN.md
8
PLAN.md
@@ -3,11 +3,7 @@
|
||||
This document tracks the immediate implementation checklist. For overarching design and constraints, see [PROJECT_ARCHITECTURE.md](PROJECT_ARCHITECTURE.md).
|
||||
|
||||
## Implementation Status
|
||||
- [x] **Phase 1: Backend Foundation** (FastAPI, SQLite, Models).
|
||||
- [x] **Phase 2: Modular AI Integration** (Gemini support, v2 SDK).
|
||||
- [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).
|
||||
- [ ] **Phase 8: Database Encryption** (Implementing SQLCipher for data-at-rest protection).
|
||||
- [ ] **Phase 9: Multi-Location Support** (Tracking inventory across different physical warehouses).
|
||||
|
||||
*(Note: Completed phases are periodically moved to `dev_docs/PLAN_HISTORY.md` according to AI_RULES)*
|
||||
|
||||
@@ -24,19 +24,21 @@ A unified system to maintain an inventory of "items" and their quantities, inclu
|
||||
|
||||
### 2.3 Operations & Tooling
|
||||
- **PWA Deployment:** `next-pwa` (Service Workers + Manifest.json)
|
||||
- **HTTPS Proxy:** `local-ssl-proxy` (Required for mobile camera access, Port 3003)
|
||||
- **Servers:** Frontend (`npm run dev` on 3000), Backend (`./start_server.sh` on 8000)
|
||||
- **HTTPS Proxy:** `caddy` or `local-ssl-proxy` (Port 8909)
|
||||
- **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
|
||||
- **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.
|
||||
- **Intervention:** Linked to a required items list.
|
||||
- **Audit Log:** Immutable ledger detailing CRUD operations and stock fluctuations.
|
||||
- **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, including point-in-time box associations.
|
||||
|
||||
## 4. Scanning & Optimization Strategy (Crucial)
|
||||
### 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.
|
||||
- **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.
|
||||
|
||||
### 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.
|
||||
- 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.
|
||||
- **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
|
||||
@@ -59,3 +71,27 @@ To prevent data loss in basements or unstable networks:
|
||||
## 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.
|
||||
|
||||
|
||||
## 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.
|
||||
|
||||
16
README.md
16
README.md
@@ -12,21 +12,21 @@ This project supports three distinct operational modes:
|
||||
Ideal for local development on macOS/Linux.
|
||||
* **Command:** `./start_server.sh`
|
||||
* **Details:** Runs FastAPI (backend) and Next.js (frontend) in development mode. Uses `local-ssl-proxy` for HTTPS.
|
||||
* **Backend:** http://localhost:8000
|
||||
* **Frontend:** https://localhost:3003
|
||||
* **Backend:** http://localhost:8906
|
||||
* **Frontend:** https://localhost:8909
|
||||
|
||||
### 2. 🐳 Docker Mode (Recommended for Production)
|
||||
Isolated and portable container stack.
|
||||
* **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`.
|
||||
* **Access:** https://localhost:3003
|
||||
* **Access:** https://localhost:8909
|
||||
|
||||
### 3. 🐧 Standalone Linux Mode (Systemd)
|
||||
Native Linux installation (Alma/Debian/Ubuntu) without Docker dependencies.
|
||||
* **Installation:** `sudo ./install_service.sh`
|
||||
* **Execution:** `sudo systemctl start inventory`
|
||||
* **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:
|
||||
1. Use the AI shortcut command: `save-version`.
|
||||
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.
|
||||
|
||||
---
|
||||
@@ -77,6 +77,12 @@ export ALLOWED_ORIGINS="https://your-domain.com"
|
||||
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).
|
||||
|
||||
---
|
||||
|
||||
@@ -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).
|
||||
- **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.
|
||||
|
||||
---
|
||||
@@ -31,16 +31,24 @@ The application supports two scanning modes:
|
||||
### Manual / Barcode Scanning
|
||||
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.
|
||||
|
||||
### 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
|
||||
@@ -92,6 +100,11 @@ If your organization uses LDAP/Active Directory, administrators can:
|
||||
### Settings
|
||||
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
|
||||
@@ -133,5 +146,5 @@ For detailed technical documentation, see the [Project Architecture](../PROJECT_
|
||||
|
||||
---
|
||||
|
||||
**Version:** v1.3.6
|
||||
**Last Updated:** 2026-04-11
|
||||
**Version:** v1.8.4
|
||||
**Last Updated:** 2026-04-13
|
||||
|
||||
10
VERSION.json
10
VERSION.json
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -19,6 +19,9 @@ RUN adduser --system --group appuser
|
||||
# Copy application files
|
||||
COPY backend ./backend
|
||||
|
||||
# Copy initialization scripts (shared with start_server.sh)
|
||||
COPY scripts ./scripts
|
||||
|
||||
# We define the data dir explicitly for Docker
|
||||
ENV DATA_DIR="/app/data"
|
||||
ENV LOGS_DIR="/app/logs"
|
||||
@@ -27,9 +30,12 @@ ENV LOGS_DIR="/app/logs"
|
||||
# although Docker volumes will handle ownership context.
|
||||
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
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
# Start Uvicorn pointing to the backend module
|
||||
CMD ["python", "-m", "uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
# Entrypoint runs init_data.sh first, then starts uvicorn
|
||||
ENTRYPOINT ["/app/backend/entrypoint.sh"]
|
||||
|
||||
@@ -7,36 +7,55 @@ base_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
dotenv_path = os.path.join(base_dir, ".env")
|
||||
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.
|
||||
Order: Gemini (Flash/Pro) -> Claude (Haiku/Sonnet)
|
||||
"""
|
||||
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
|
||||
}
|
||||
Modes: 'item' (full technical extraction), 'box' (container discovery)
|
||||
"""
|
||||
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
|
||||
result = gemini.extract(image_bytes, prompt)
|
||||
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
|
||||
|
||||
# 2. Try Claude (Fallback)
|
||||
|
||||
@@ -66,8 +66,7 @@ async def get_current_user(credentials = Depends(security)):
|
||||
token = credentials.credentials
|
||||
import logging
|
||||
log = logging.getLogger("ainventory")
|
||||
log.debug(f"[AUTH] Validating token (first 20 chars): {token[:20] if token else 'None'}")
|
||||
log.debug(f"[AUTH] Using SECRET_KEY (first 10 chars): {SECRET_KEY[:10] if SECRET_KEY else 'None'}")
|
||||
log.debug(f"[AUTH] Validating token (first 10 chars): {token[:10] if token else 'None'}")
|
||||
try:
|
||||
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||
user_id: int = int(payload.get("sub")) # sub is stored as string, convert back to int
|
||||
|
||||
138
backend/db_manager.py
Normal file
138
backend/db_manager.py
Normal 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
28
backend/entrypoint.sh
Executable 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
|
||||
@@ -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"}]}
|
||||
@@ -5,8 +5,9 @@ from slowapi import Limiter
|
||||
from slowapi.util import get_remote_address
|
||||
from . import models
|
||||
from .database import engine
|
||||
from .routers import items, operations, users, categories
|
||||
from .routers import items, operations, users, categories, admin_db
|
||||
from .logger import log
|
||||
from .scheduler import scheduler, sync_scheduler_config
|
||||
|
||||
# Create the database tables
|
||||
from .database import DATA_DIR, db_path
|
||||
@@ -23,7 +24,7 @@ log.info("TFM aInventory API process started.")
|
||||
# Secure fallback: localhost only for development.
|
||||
_raw_origins = os.environ.get(
|
||||
"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()]
|
||||
log.info(f"CORS allowed origins: {ALLOWED_ORIGINS}")
|
||||
@@ -33,7 +34,7 @@ app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=ALLOWED_ORIGINS,
|
||||
allow_credentials=True,
|
||||
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
@@ -45,6 +46,13 @@ app.include_router(items.router)
|
||||
app.include_router(operations.router)
|
||||
app.include_router(users.router)
|
||||
app.include_router(categories.router)
|
||||
app.include_router(admin_db.router)
|
||||
|
||||
@app.on_event("startup")
|
||||
def startup_event():
|
||||
log.info("[STARTUP] Starting background scheduler...")
|
||||
scheduler.start()
|
||||
sync_scheduler_config()
|
||||
|
||||
@app.get("/")
|
||||
def read_root():
|
||||
|
||||
@@ -41,6 +41,9 @@ class Item(Base):
|
||||
min_quantity = Column(Float, default=1.0)
|
||||
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
|
||||
labels_data = Column(Text, nullable=True)
|
||||
|
||||
@@ -48,10 +51,14 @@ class AuditLog(Base):
|
||||
__tablename__ = "audit_logs"
|
||||
|
||||
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"))
|
||||
action = Column(String) # e.g., 'CHECK_IN', 'CHECK_OUT', 'CREATE_ITEM'
|
||||
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)
|
||||
uuid = Column(String, unique=True, index=True, nullable=True)
|
||||
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)
|
||||
name = Column(String)
|
||||
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")
|
||||
|
||||
@@ -78,3 +85,9 @@ class InterventionItem(Base):
|
||||
checked_out_quantity = Column(Float, default=0.0)
|
||||
|
||||
intervention = relationship("Intervention", back_populates="items")
|
||||
|
||||
class SystemSetting(Base):
|
||||
__tablename__ = "system_settings"
|
||||
|
||||
key = Column(String, primary_key=True, index=True)
|
||||
value = Column(String)
|
||||
|
||||
@@ -12,3 +12,4 @@ ldap3>=2.9.1
|
||||
passlib[bcrypt]>=1.7.4
|
||||
python-jose[cryptography]>=3.3.0
|
||||
slowapi>=0.1.9
|
||||
apscheduler>=3.10.1
|
||||
|
||||
105
backend/routers/admin_db.py
Normal file
105
backend/routers/admin_db.py
Normal 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
|
||||
@@ -2,6 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File,
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func
|
||||
from typing import List
|
||||
import json
|
||||
from slowapi import Limiter
|
||||
from slowapi.util import get_remote_address
|
||||
from .. import models, schemas, auth
|
||||
@@ -65,6 +66,7 @@ _MAX_IMAGE_SIZE = 10 * 1024 * 1024 # 10 MB
|
||||
async def extract_label(
|
||||
request: Request,
|
||||
file: UploadFile = File(...),
|
||||
mode: str = "item", # 'item' or 'box'
|
||||
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."""
|
||||
@@ -85,7 +87,7 @@ async def extract_label(
|
||||
detail="File exceeds 10MB limit."
|
||||
)
|
||||
|
||||
result = extract_label_info(contents)
|
||||
result = extract_label_info(contents, mode=mode)
|
||||
return result
|
||||
|
||||
@router.post("/", response_model=schemas.Item, status_code=status.HTTP_201_CREATED)
|
||||
@@ -106,10 +108,27 @@ def create_item(
|
||||
db.refresh(db_item)
|
||||
|
||||
# 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(
|
||||
user_id=current_user.sub,
|
||||
action="CREATE_ITEM",
|
||||
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
|
||||
)
|
||||
db.add(audit)
|
||||
@@ -141,13 +160,46 @@ def update_item(
|
||||
def delete_item(
|
||||
item_id: int,
|
||||
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()
|
||||
if not db_item:
|
||||
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.commit()
|
||||
return {"message": "Item deleted successfully"}
|
||||
return {"message": "Item deleted successfully. History logs preserved."}
|
||||
|
||||
@@ -3,6 +3,7 @@ from typing import List
|
||||
from sqlalchemy.orm import Session
|
||||
from .. import models, schemas, auth
|
||||
from ..database import get_db
|
||||
import json
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/operations",
|
||||
@@ -27,10 +28,21 @@ def check_in_item(
|
||||
item.quantity += op.quantity
|
||||
|
||||
# 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(
|
||||
user_id=current_user.sub,
|
||||
action="CHECK_IN",
|
||||
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
|
||||
)
|
||||
|
||||
@@ -60,10 +72,21 @@ def check_out_item(
|
||||
item.quantity -= op.quantity
|
||||
|
||||
# Create Mandatory Audit Log
|
||||
item_snapshot = {
|
||||
"barcode": item.barcode,
|
||||
"name": item.name,
|
||||
"category": item.category,
|
||||
"part_number": item.part_number
|
||||
}
|
||||
|
||||
audit = models.AuditLog(
|
||||
user_id=current_user.sub,
|
||||
action="CHECK_OUT",
|
||||
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
|
||||
)
|
||||
|
||||
@@ -93,10 +116,21 @@ def trash_item(
|
||||
item.quantity -= op.quantity
|
||||
|
||||
# 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(
|
||||
user_id=current_user.sub,
|
||||
action="TRASH",
|
||||
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,
|
||||
details=op.reason
|
||||
)
|
||||
@@ -130,10 +164,21 @@ def bulk_check_out(
|
||||
item.quantity -= op.quantity
|
||||
|
||||
# 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(
|
||||
user_id=current_user.sub,
|
||||
action="BULK_CHECK_OUT",
|
||||
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
|
||||
)
|
||||
db.add(audit)
|
||||
@@ -182,10 +227,21 @@ def bulk_sync(
|
||||
continue
|
||||
|
||||
# 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(
|
||||
user_id=current_user.sub,
|
||||
action=op.type,
|
||||
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,
|
||||
timestamp=op.timestamp,
|
||||
uuid=op.uuid,
|
||||
@@ -215,6 +271,10 @@ def get_logs(
|
||||
models.User.username,
|
||||
models.AuditLog.action,
|
||||
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.details
|
||||
).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,
|
||||
"action": l.action,
|
||||
"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,
|
||||
"details": l.details
|
||||
} for l in logs_with_users
|
||||
|
||||
@@ -1,22 +1,28 @@
|
||||
import secrets
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
from slowapi import Limiter
|
||||
from slowapi.util import get_remote_address
|
||||
from passlib.context import CryptContext
|
||||
import ldap3
|
||||
from ldap3 import Tls
|
||||
from ldap3.utils.conv import escape_filter_chars
|
||||
from ldap3.utils.dn import escape_rdn
|
||||
import ssl
|
||||
import json
|
||||
import os
|
||||
from .. import models, schemas, database, auth
|
||||
from ..logger import log
|
||||
|
||||
router = APIRouter(prefix="/users", tags=["users"])
|
||||
limiter = Limiter(key_func=get_remote_address)
|
||||
pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
|
||||
|
||||
def get_ldap_config():
|
||||
# Read from backend/config/ directory
|
||||
config_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "config")
|
||||
# Read from root /config directory
|
||||
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")
|
||||
if os.path.exists(config_path):
|
||||
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')}")
|
||||
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']}")
|
||||
safe_username_rdn = escape_rdn(username)
|
||||
user_dn = config["user_template"].format(username=safe_username_rdn)
|
||||
@@ -98,10 +119,31 @@ def authenticate_ldap(username, password):
|
||||
|
||||
return assigned_role
|
||||
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
|
||||
log.debug(f"LDAP: Full traceback: {traceback.format_exc()}")
|
||||
return None
|
||||
|
||||
pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
|
||||
|
||||
def get_db():
|
||||
@@ -158,7 +200,8 @@ def create_user(
|
||||
return new_user
|
||||
|
||||
@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.
|
||||
"""
|
||||
@@ -272,7 +315,8 @@ def update_ldap_settings(
|
||||
current_user: auth.TokenData = Depends(auth.get_current_admin)
|
||||
):
|
||||
"""[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)
|
||||
config_path = os.path.join(config_dir, "ldap_config.json")
|
||||
with open(config_path, "w") as f:
|
||||
@@ -307,15 +351,36 @@ def test_ldap_connection(
|
||||
s.close()
|
||||
|
||||
if result == 0:
|
||||
# Socket is open! Now try LDAP library
|
||||
# Socket is open! Now try LDAP library probe
|
||||
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)
|
||||
if conn.open():
|
||||
return {"status": "success", "message": "Connection Successful"}
|
||||
return {"status": "success", "message": "Server reachable (Socket open, but LDAP probe failed)"}
|
||||
except:
|
||||
return {"status": "success", "message": "Server reachable (Socket open)"}
|
||||
return {"status": "success", "message": "LDAP Connection Successful (Server Reachable)"}
|
||||
|
||||
# If open fails, it might just be the server policy.
|
||||
# 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:
|
||||
# Socket failed, let's try calling system 'ldapsearch' as a last resort diagnostic
|
||||
import subprocess
|
||||
@@ -346,6 +411,9 @@ def delete_user(
|
||||
if user.username == "Admin":
|
||||
raise HTTPException(status_code=400, detail="Cannot delete default Admin")
|
||||
|
||||
is_ldap = user.origin == "ldap"
|
||||
db.delete(user)
|
||||
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
46
backend/scheduler.py
Normal 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()
|
||||
@@ -64,6 +64,7 @@ class ItemBase(BaseModel):
|
||||
quantity: float = 0.0
|
||||
min_quantity: float = 1.0
|
||||
image_url: Optional[str] = None
|
||||
box_label: Optional[str] = None
|
||||
labels_data: Optional[str] = None
|
||||
|
||||
class ItemCreate(ItemBase):
|
||||
@@ -111,8 +112,36 @@ class AuditLogResponse(BaseModel):
|
||||
username: Optional[str] = None
|
||||
action: str
|
||||
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]
|
||||
details: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
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
|
||||
|
||||
92
backend/tests/api_bench.py
Normal file
92
backend/tests/api_bench.py
Normal 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()
|
||||
@@ -1,12 +1,12 @@
|
||||
# TFM aInventory - Caddy Self-Signed Internal Proxy
|
||||
# This replaces the need for `local-ssl-proxy` in Node.
|
||||
|
||||
:3003 {
|
||||
:{$FRONTEND_SSL_PORT} {
|
||||
tls internal
|
||||
reverse_proxy frontend:3000
|
||||
}
|
||||
|
||||
:3002 {
|
||||
:{$BACKEND_SSL_PORT} {
|
||||
tls internal
|
||||
reverse_proxy backend:8000
|
||||
}
|
||||
@@ -17,8 +17,8 @@ JWT_SECRET_KEY=change-me-generate-a-secure-random-value
|
||||
# --- CORS ---
|
||||
# Comma-separated list of allowed frontend origins
|
||||
# Example for LAN deployment:
|
||||
# ALLOWED_ORIGINS=http://192.168.1.100:3000,https://192.168.1.100:3003
|
||||
ALLOWED_ORIGINS=http://localhost:3000,https://localhost:3003
|
||||
# ALLOWED_ORIGINS=http://192.168.84.113:8907,https://192.168.84.113:8909
|
||||
ALLOWED_ORIGINS=http://localhost:8907,https://localhost:8909
|
||||
|
||||
# --- Data Paths (overridden by start_server.sh / docker-compose) ---
|
||||
# DATA_DIR=/absolute/path/to/data
|
||||
19
config/ldap_config.json
Normal file
19
config/ldap_config.json
Normal 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
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
"user_template": "cn={username},ou=people,dc=yourdomain,dc=com",
|
||||
"groups_dn": "ou=groups",
|
||||
"use_tls": false,
|
||||
"ignore_cert": false,
|
||||
"role_mappings": [
|
||||
{
|
||||
"group": "inventory_admins",
|
||||
28
config/network_config.env
Normal file
28
config/network_config.env
Normal 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
4
data/.gitkeep
Normal 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.
@@ -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"}]}
|
||||
@@ -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
|
||||
**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:**
|
||||
|
||||
61
dev_docs/BOX_SCANNING_MASTER_PLAN.md
Normal file
61
dev_docs/BOX_SCANNING_MASTER_PLAN.md
Normal 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.
|
||||
@@ -2,6 +2,10 @@
|
||||
This file tracks all completed tasks and phases moved from `PLAN.md`.
|
||||
|
||||
## 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.1.0**: Auth System & LDAP Framework (2026-04-10)
|
||||
- **v1.0.0**: Initial PWA MVP (2026-04-10)
|
||||
|
||||
@@ -1,155 +1,77 @@
|
||||
# CURRENT AI WORKING SESSION — HANDOVER
|
||||
|
||||
**Active AI:** Gemini (Antigravity)
|
||||
**Last Updated:** 2026-04-11
|
||||
**Current Version:** v1.3.5 (pending bump to v1.3.6)
|
||||
**Last Updated:** 2026-04-12
|
||||
**Current Version:** v1.6.0 (BoxMaster)
|
||||
**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
|
||||
|
||||
### 1. Scanner UI Redesign (`frontend/components/Scanner.tsx`)
|
||||
- **Layout**: Moved all controls OUT of the camera viewport overlay. Camera feed is now 100% unobstructed.
|
||||
- **Automated OCR**: Removed manual "OCR SCAN" button. OCR now runs automatically on a 4-second cycle.
|
||||
- **Visual Countdown**: Added a countdown display (4, 3, 2, 1, Scan) with a progress bar so the user can see the next scan timing.
|
||||
- **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).
|
||||
### 1. Box Management Architecture (Backend)
|
||||
- **Database Schema** — Added `box_label` column to the `items` table.
|
||||
- **Audit Integrity** — Updated `AuditLog` snapshots to capture `box_label` at the time of each transaction, ensuring immutable historical traceability even if items are moved.
|
||||
- **API Support** — Exposed `box_label` in Pydantic schemas and item routers.
|
||||
|
||||
### 2. Item Type Datalist (`AIOnboarding.tsx`, `page.tsx`, `inventory/page.tsx`)
|
||||
- Added a searchable `<datalist>` to the Item Type field across all relevant forms.
|
||||
- Dynamically populated with existing unique types from the DB, while still allowing manual free-text input.
|
||||
### 2. Intelligent Scanner Routing (Frontend)
|
||||
- **Box Match Priority** — Rewrote the scanner's `onOCRMatch` logic to prioritize box labels.
|
||||
- **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
|
||||
- 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`.
|
||||
- Registered as an official AI Command Shortcut in `AI_RULES.md` Section 6.
|
||||
### 3. Dependency-Free Label System
|
||||
- **Native Generation** — Built a zero-dependency SVG engine for Code 128 Barcodes and QR Codes (`lib/labels.ts`).
|
||||
- **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
|
||||
|
||||
1. Run `save-version` to finalize version `1.3.6` if not already done.
|
||||
2. Monitor TypeScript warnings as the `Item` interface in `frontend/lib/db.ts` may be missing fields.
|
||||
3. If the Item Type datalist grows too large, consider a dedicated "Category/Type Management" settings page.
|
||||
4. Periodically review `dev_docs/SECURITY_REPORT.md`.
|
||||
1. **Database Encryption** — Consider implementing SQLite encryption at rest (SQLCipher) if requested.
|
||||
2. **Persistent JWT** — If requested, move the `JWT_SECRET_KEY` to a `.env` file for session persistence across server restarts.
|
||||
3. **Advanced Filtering** — Extend the Box Manager to allow bulk movements between boxes.
|
||||
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
|
||||
|
||||
**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"}]}
|
||||
```
|
||||
**LDAP config:** `config/ldap_config.json`
|
||||
**Network config:** `config/network_config.env`
|
||||
**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:**
|
||||
```bash
|
||||
./start_server.sh
|
||||
```
|
||||
- Frontend: `https://<LOCAL_IP>:3003`
|
||||
- Backend: `http://localhost:8000` direct
|
||||
- Frontend: `https://192.168.84.113:8909`
|
||||
- Backend: `https://192.168.84.113:8908`
|
||||
|
||||
**Environment variables set by start_server.sh:**
|
||||
- `ALLOWED_ORIGINS` — auto-detected from local IP
|
||||
- `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
|
||||
|
||||
|
||||
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)
|
||||
- `ALLOWED_ORIGINS` — auto-detected
|
||||
- `DATA_DIR` — absolute path
|
||||
- `JWT_SECRET_KEY` — ephemeral (regenerates on restart)
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
backend:
|
||||
build:
|
||||
@@ -8,27 +6,30 @@ services:
|
||||
networks:
|
||||
- inventory_net
|
||||
ports:
|
||||
- "8000:8000"
|
||||
- ${BACKEND_PORT:-8000}:8000
|
||||
env_file:
|
||||
- ./config/network_config.env
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./logs:/app/logs
|
||||
- ./scripts:/app/scripts:ro
|
||||
environment:
|
||||
- DATA_DIR=/app/data
|
||||
- LOGS_DIR=/app/logs
|
||||
# [M-01] CORS allowed origins — customize for production
|
||||
- ALLOWED_ORIGINS=http://localhost:3000,http://localhost:3002
|
||||
- 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}
|
||||
# [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
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: frontend/Dockerfile
|
||||
context: ./frontend
|
||||
networks:
|
||||
- inventory_net
|
||||
ports:
|
||||
- "3000:3000"
|
||||
- ${FRONTEND_PORT:-3000}:3000
|
||||
env_file:
|
||||
- ./config/network_config.env
|
||||
volumes:
|
||||
- ./logs:/app/logs
|
||||
# Write Next.js logs to both stdout (docker logs) and file (mapped volume)
|
||||
@@ -40,10 +41,12 @@ services:
|
||||
networks:
|
||||
- inventory_net
|
||||
ports:
|
||||
- "3002:3002"
|
||||
- "3003:3003"
|
||||
- ${BACKEND_SSL_PORT:-3002}:${BACKEND_SSL_PORT:-3002}
|
||||
- ${FRONTEND_SSL_PORT:-3003}:${FRONTEND_SSL_PORT:-3003}
|
||||
env_file:
|
||||
- ./config/network_config.env
|
||||
volumes:
|
||||
- ./Caddyfile:/etc/caddy/Caddyfile
|
||||
- ./config/Caddyfile:/etc/caddy/Caddyfile
|
||||
# Persist the internal Caddy certificates so users don't get new certificate warnings constantly
|
||||
- ./data/caddy_data:/data
|
||||
- ./data/caddy_config:/config
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
|
||||
echo "📦 Preparing TFM aInventory Production Bundle..."
|
||||
|
||||
# Extract version from VERSION.json using grep to avoid macOS python/xcode stubs
|
||||
VERSION=$(grep '"version"' VERSION.json | head -n 1 | awk -F '"' '{print $4}')
|
||||
# Extract version from frontend/VERSION.json
|
||||
VERSION=$(grep '"version"' frontend/VERSION.json | head -n 1 | awk -F '"' '{print $4}')
|
||||
PROD_DIR="aInventory-PROD-v${VERSION}"
|
||||
|
||||
# Clean previous run if it exists
|
||||
@@ -16,11 +16,13 @@ mkdir -p "$PROD_DIR"
|
||||
echo "📂 Copying application components (excluding dev artifacts)..."
|
||||
# Core application
|
||||
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 Caddyfile "$PROD_DIR/"
|
||||
rsync -a config/ "$PROD_DIR/config/"
|
||||
rsync -a scripts/ "$PROD_DIR/scripts/"
|
||||
cp start_server.sh "$PROD_DIR/"
|
||||
cp run_standalone.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 README.md "$PROD_DIR/INSTALLATION_GUIDE.md"
|
||||
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
|
||||
mkdir -p "$PROD_DIR/data"
|
||||
@@ -44,7 +47,7 @@ TO RUN VIA DOCKER (Recommended):
|
||||
1. Install Docker Desktop or Docker Engine.
|
||||
2. Run: docker-compose build
|
||||
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):
|
||||
1. sudo ./install_service.sh
|
||||
@@ -54,7 +57,7 @@ TO RUN BARE-METAL (No Docker):
|
||||
1. Install Python 3.12+ and Node.js 20+.
|
||||
2. Ensure you have network access for npm installs.
|
||||
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.
|
||||
EOF
|
||||
|
||||
5
frontend/VERSION.json
Normal file
5
frontend/VERSION.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"version": "1.8.7",
|
||||
"last_build": "2026-04-13-1954",
|
||||
"codename": "TypeFix"
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { inventoryApi } from '@/lib/api';
|
||||
import PageShell from '@/components/PageShell';
|
||||
import {
|
||||
Shield,
|
||||
ShieldAlert,
|
||||
UserPlus,
|
||||
User,
|
||||
Trash2,
|
||||
@@ -22,7 +23,11 @@ import {
|
||||
Wifi,
|
||||
WifiOff,
|
||||
Layers,
|
||||
ChevronDown
|
||||
ChevronDown,
|
||||
Clock,
|
||||
HardDrive,
|
||||
Download,
|
||||
RotateCcw
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -38,7 +43,6 @@ export default function AdminPage() {
|
||||
server_uri: 'ldap://192.168.84.107:3890',
|
||||
base_dn: 'dc=example,dc=com',
|
||||
user_template: 'cn={username},ou=people,dc=example,dc=com',
|
||||
required_group: 'inventory',
|
||||
groups_dn: 'ou=groups',
|
||||
use_tls: false,
|
||||
role_mappings: []
|
||||
@@ -52,6 +56,12 @@ export default function AdminPage() {
|
||||
const [editingCategory, setEditingCategory] = useState<any | null>(null);
|
||||
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(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
@@ -59,15 +69,21 @@ export default function AdminPage() {
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [u, c, l] = await Promise.all([
|
||||
const [u, c, l, b, s, st] = await Promise.all([
|
||||
inventoryApi.getUsers(),
|
||||
inventoryApi.getCategories(),
|
||||
inventoryApi.getLdapConfig()
|
||||
inventoryApi.getLdapConfig(),
|
||||
inventoryApi.getDbBackups(),
|
||||
inventoryApi.getDbStats(),
|
||||
inventoryApi.getDbSettings()
|
||||
]);
|
||||
setUsers(u);
|
||||
setCategories(c);
|
||||
if (l && l.server_uri) setLdapConfig(l);
|
||||
} catch (err) {
|
||||
setBackups(b);
|
||||
setDbStats(s);
|
||||
setDbSettings(st);
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
toast.error("Failed to load admin data");
|
||||
} finally {
|
||||
@@ -85,7 +101,7 @@ export default function AdminPage() {
|
||||
await inventoryApi.createUser({ username: name, password: pwd, role: 'user' });
|
||||
toast.success("User created successfully");
|
||||
loadData();
|
||||
} catch (err) {
|
||||
} catch (err: any) {
|
||||
toast.error("Failed to create user");
|
||||
}
|
||||
};
|
||||
@@ -98,7 +114,7 @@ export default function AdminPage() {
|
||||
await inventoryApi.deleteUser(id);
|
||||
toast.success("User removed");
|
||||
loadData();
|
||||
} catch (err) {
|
||||
} catch (err: any) {
|
||||
toast.error("Delete failed");
|
||||
}
|
||||
};
|
||||
@@ -112,7 +128,7 @@ export default function AdminPage() {
|
||||
await inventoryApi.createCategory({ name, description: desc });
|
||||
toast.success("Category added");
|
||||
loadData();
|
||||
} catch (err) {
|
||||
} catch (err: any) {
|
||||
toast.error("Failed to add category");
|
||||
}
|
||||
};
|
||||
@@ -124,7 +140,7 @@ export default function AdminPage() {
|
||||
toast.success("Category updated");
|
||||
setEditingCategory(null);
|
||||
loadData();
|
||||
} catch (err) {
|
||||
} catch (err: any) {
|
||||
toast.error("Update failed");
|
||||
}
|
||||
};
|
||||
@@ -136,7 +152,7 @@ export default function AdminPage() {
|
||||
await inventoryApi.deleteCategory(id);
|
||||
toast.success("Category removed");
|
||||
loadData();
|
||||
} catch (err) {
|
||||
} catch (err: any) {
|
||||
toast.error(err.response?.data?.detail || "Delete failed");
|
||||
}
|
||||
};
|
||||
@@ -151,7 +167,7 @@ export default function AdminPage() {
|
||||
toast.success("User updated successfully");
|
||||
setEditingUser(null);
|
||||
loadData();
|
||||
} catch (err) {
|
||||
} catch (err: any) {
|
||||
toast.error("Update failed");
|
||||
}
|
||||
};
|
||||
@@ -161,44 +177,119 @@ export default function AdminPage() {
|
||||
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 (
|
||||
<PageShell requireAdmin={true}>
|
||||
<main className="p-4 md:p-8 max-w-6xl mx-auto space-y-16">
|
||||
<header className="flex items-center gap-6">
|
||||
<div className="p-4 bg-primary/10 rounded-3xl text-primary border border-primary/20 shadow-xl shadow-primary/5">
|
||||
<Shield size={40} />
|
||||
<main className="p-4 md:p-8 max-w-7xl mx-auto space-y-16">
|
||||
<header 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">
|
||||
<Shield size={32} />
|
||||
</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>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* 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 gap-3">
|
||||
<User size={20} className="text-primary" />
|
||||
<h2 className="text-lg font-black text-white">User Accounts</h2>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-3 bg-primary/10 rounded-2xl text-primary border border-primary/20">
|
||||
<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>
|
||||
<button
|
||||
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
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-12">
|
||||
<div className="space-y-10">
|
||||
{/* Local Users Group */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between px-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-primary" />
|
||||
<h3 className="text-sm font-black text-white">Local Users</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-1">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-primary" />
|
||||
<h3 className="text-sm font-black text-white">Local Users</h3>
|
||||
</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 ? (
|
||||
<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={cn(
|
||||
"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} />}
|
||||
</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>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 transition-opacity pr-2">
|
||||
<div className="flex items-center gap-1 pr-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingUser(u);
|
||||
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-700/50"
|
||||
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"
|
||||
>
|
||||
<Edit2 size={14} />
|
||||
</button>
|
||||
{u.username !== 'Admin' && (
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
@@ -249,13 +336,11 @@ export default function AdminPage() {
|
||||
{/* enterprise Users Group */}
|
||||
{users.some(u => u.origin === 'ldap') && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between px-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<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>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-1">
|
||||
<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>
|
||||
</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 => (
|
||||
<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">
|
||||
@@ -266,15 +351,24 @@ export default function AdminPage() {
|
||||
<Shield size={16} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<p className="text-sm font-bold text-white">{u.username}</p>
|
||||
<span className="text-[8px] bg-indigo-500/10 text-indigo-400 border border-indigo-500/20 px-1.5 py-0.5 rounded-md font-black">Network Sync</span>
|
||||
<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>
|
||||
<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">Ldap Profile</span>
|
||||
<span className="text-[8px] font-black text-slate-500 opacity-60 font-mono tracking-tighter">{u.role}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs font-black text-indigo-400/50 pr-4 truncate max-w-[150px] italic">
|
||||
Read Only Profile
|
||||
<div className="flex items-center gap-1 pr-2">
|
||||
<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>
|
||||
))}
|
||||
@@ -284,22 +378,214 @@ export default function AdminPage() {
|
||||
</div>
|
||||
</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 */}
|
||||
<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 gap-3">
|
||||
<Layers size={20} className="text-primary" />
|
||||
<h2 className="text-lg font-black text-white">Category Groups</h2>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-3 bg-primary/10 rounded-2xl text-primary border border-primary/20">
|
||||
<Layers size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-black text-white">Category Groups</h2>
|
||||
<p className="text-xs text-slate-500 font-bold mt-1">Classify inventory items into logical clusters</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
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
|
||||
</button>
|
||||
</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 ? (
|
||||
<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 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-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'}
|
||||
</p>
|
||||
</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
|
||||
onClick={() => {
|
||||
setEditingCategory(cat);
|
||||
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-700/50"
|
||||
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"
|
||||
>
|
||||
<Edit2 size={14} />
|
||||
</button>
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
@@ -340,235 +624,172 @@ export default function AdminPage() {
|
||||
))
|
||||
)}
|
||||
{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
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* System Settings Section */}
|
||||
<div className="pt-8 border-t border-slate-900">
|
||||
<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="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">
|
||||
<Database size={32} />
|
||||
</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>
|
||||
{/* System Integrity (Compacted and left-aligned) */}
|
||||
<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">
|
||||
<div className="flex items-center gap-5 min-w-0 flex-1">
|
||||
<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={20} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 bg-slate-950 p-2 rounded-2xl border border-slate-800">
|
||||
<span className="text-xs font-black text-slate-400 px-3">LDAP Status</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
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 className="min-w-0 flex-1">
|
||||
<h3 className="text-sm font-black text-white">System Integrity</h3>
|
||||
<p className="text-[10px] text-slate-500 mt-0.5 max-w-xl truncate">
|
||||
Hybrid storage model active. Real-time synchronization between local memory and cloud-hosted SQLite instance.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid lg:grid-cols-2 gap-8 relative z-10">
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-black text-slate-500 px-1">Server URI</label>
|
||||
<div className="relative group/input">
|
||||
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none text-slate-600 group-focus-within/input:text-indigo-400 transition-colors">
|
||||
<Wifi size={16} />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={ldapConfig.server_uri || ''}
|
||||
onChange={(e) => setLdapConfig({ ...ldapConfig, server_uri: e.target.value })}
|
||||
placeholder="ldap://192.168.84.107:3890"
|
||||
className="w-full bg-slate-950 border border-slate-800 focus:border-indigo-500/50 rounded-2xl py-4 pl-12 pr-5 text-white outline-none transition-all font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-black text-slate-500 px-1">Base DN (Suffix)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={ldapConfig.base_dn || ''}
|
||||
onChange={(e) => setLdapConfig({ ...ldapConfig, base_dn: e.target.value })}
|
||||
placeholder="dc=example,dc=com"
|
||||
className="w-full bg-slate-950 border border-slate-800 focus:border-indigo-500/50 rounded-2xl py-4 px-5 text-white outline-none transition-all font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-black text-slate-500 px-1">User DN Template</label>
|
||||
<input
|
||||
type="text"
|
||||
value={ldapConfig.user_template || ''}
|
||||
onChange={(e) => setLdapConfig({ ...ldapConfig, user_template: e.target.value })}
|
||||
placeholder="uid={username},ou=people,dc=..."
|
||||
className="w-full bg-slate-950 border border-slate-800 focus:border-indigo-500/50 rounded-2xl py-4 px-5 text-white outline-none transition-all font-mono text-sm"
|
||||
/>
|
||||
<div className="flex items-center gap-6 divide-x divide-slate-800/50">
|
||||
<div className="flex flex-col items-end">
|
||||
<span className="text-[8px] font-black text-slate-500 uppercase tracking-widest">Storage Status</span>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse shadow-[0_0_8px_rgba(34,197,94,0.3)]" />
|
||||
<span className="text-[10px] font-black text-green-500 tracking-tight">Online</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between px-1">
|
||||
<label className="text-xs font-black text-slate-500">Role Mappings</label>
|
||||
<button
|
||||
onClick={() => {
|
||||
const newMappings = [...(ldapConfig.role_mappings || []), { group: '', role: 'user' }];
|
||||
setLdapConfig({ ...ldapConfig, role_mappings: newMappings });
|
||||
}}
|
||||
className="text-xs font-black text-indigo-400 hover:text-indigo-300 transition-colors"
|
||||
>
|
||||
+ Add Mapping
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 max-h-[180px] overflow-y-auto pr-2 custom-scrollbar">
|
||||
{(ldapConfig.role_mappings || []).map((mapping: any, idx: number) => (
|
||||
<div key={idx} className="flex gap-2 items-center bg-slate-950/50 p-3 rounded-2xl border border-slate-800/50">
|
||||
<input
|
||||
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 className="flex flex-col items-end pl-6">
|
||||
<span className="text-[8px] font-black text-slate-500 uppercase tracking-widest">Local Archives</span>
|
||||
<div className="flex items-center gap-3 mt-1">
|
||||
<span className="text-[10px] font-black text-white">{dbStats.backup_count} Files</span>
|
||||
<span className="text-[10px] font-black text-primary/60">{formatSize(dbStats.total_size_bytes)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</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 */}
|
||||
{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">
|
||||
|
||||
@@ -5,19 +5,58 @@
|
||||
@import "bootstrap-icons/font/bootstrap-icons.css";
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
}
|
||||
/* slate-950 forced as default to prevent white flash */
|
||||
--background: #020617;
|
||||
--foreground: #f1f5f9;
|
||||
}
|
||||
|
||||
body {
|
||||
color: var(--foreground);
|
||||
background: var(--background);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
background-color: var(--background);
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { db, Item } from '@/lib/db';
|
||||
import { inventoryApi } from '@/lib/api';
|
||||
import PageShell from '@/components/PageShell';
|
||||
import Scanner from '@/components/Scanner';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import {
|
||||
Package,
|
||||
@@ -17,7 +18,8 @@ import {
|
||||
X,
|
||||
AlertTriangle,
|
||||
Tag,
|
||||
Edit2
|
||||
Edit2,
|
||||
Camera
|
||||
} from 'lucide-react';
|
||||
import { clsx, type ClassValue } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
@@ -50,6 +52,10 @@ export default function InventoryPage() {
|
||||
const [catEditedName, setCatEditedName] = useState('');
|
||||
const [catEditedDesc, setCatEditedDesc] = useState('');
|
||||
|
||||
// Scanner state
|
||||
const [showScanner, setShowScanner] = useState(false);
|
||||
const [fieldScanning, setFieldScanning] = useState<{ active: boolean, field: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
const savedUser = localStorage.getItem('inventory_user');
|
||||
@@ -79,7 +85,7 @@ export default function InventoryPage() {
|
||||
// Sync local DB
|
||||
await db.items.clear();
|
||||
await db.items.bulkPut(res);
|
||||
} catch (err) {
|
||||
} catch (err: any) {
|
||||
console.error("Failed to load backend data", err);
|
||||
}
|
||||
};
|
||||
@@ -143,7 +149,7 @@ export default function InventoryPage() {
|
||||
setIsEditing(false);
|
||||
setSelectedItem(updated as Item);
|
||||
await loadData();
|
||||
} catch (err) {
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
toast.error("Failed to update item");
|
||||
}
|
||||
@@ -161,7 +167,7 @@ export default function InventoryPage() {
|
||||
toast.success("Item deleted");
|
||||
setSelectedItem(null);
|
||||
await loadData();
|
||||
} catch (err) {
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
toast.error("Failed to delete item");
|
||||
}
|
||||
@@ -177,11 +183,36 @@ export default function InventoryPage() {
|
||||
toast.success("Category updated");
|
||||
setEditingCategory(null);
|
||||
await loadData();
|
||||
} catch (err) {
|
||||
} catch (err: any) {
|
||||
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
|
||||
const categories = Array.from(new Set(inventory.map(i => i.category)));
|
||||
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()))
|
||||
);
|
||||
|
||||
// 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 existingBoxes = Array.from(new Set(inventory.map(i => i.box_label).filter(Boolean))).sort() as string[];
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
return (
|
||||
<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">
|
||||
{existingTypes.map(t => <option key={t} value={t} />)}
|
||||
</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">
|
||||
<h1 className="text-2xl font-black flex items-center gap-3">
|
||||
<Package className="text-primary" size={28} />
|
||||
Inventory Catalog
|
||||
</h1>
|
||||
<p className="text-sm text-slate-500 mt-1">Detailed view of all stock items by category</p>
|
||||
<header className="flex items-center gap-5 mb-10">
|
||||
<div className="p-4 bg-primary/10 rounded-[2rem] text-primary border border-primary/20 shadow-xl shadow-primary/5">
|
||||
<Package size={32} />
|
||||
</div>
|
||||
<div>
|
||||
<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>
|
||||
|
||||
<div className="max-w-4xl mx-auto w-full space-y-8">
|
||||
<div className="w-full space-y-8">
|
||||
{/* Stats Dashboard */}
|
||||
<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="w-8 h-8 rounded-xl bg-primary/10 text-primary flex items-center justify-center mb-3">
|
||||
<Layers size={18} />
|
||||
</div>
|
||||
<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 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">
|
||||
<Layers size={18} className="text-primary shrink-0 opacity-80" />
|
||||
<p className="text-sm font-bold text-slate-300 whitespace-nowrap">Categories</p>
|
||||
<p className="text-xl font-black text-white tabular-nums ml-auto">{stats?.total_categories || categories.length}</p>
|
||||
</div>
|
||||
<div className="bg-slate-900/50 border border-slate-800 p-4 rounded-3xl">
|
||||
<div className="w-8 h-8 rounded-xl bg-green-500/10 text-green-500 flex items-center justify-center mb-3">
|
||||
<Package size={18} />
|
||||
</div>
|
||||
<p className="text-xs font-black text-slate-500">Item Types</p>
|
||||
<p className="text-2xl font-black mt-1">{stats?.total_items || inventory.length}</p>
|
||||
<div 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">
|
||||
<Package size={18} className="text-green-500 shrink-0 opacity-80" />
|
||||
<p className="text-sm font-bold text-slate-300 whitespace-nowrap">Item Types</p>
|
||||
<p className="text-xl font-black text-white tabular-nums ml-auto">{stats?.total_items || inventory.length}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -256,7 +289,7 @@ export default function InventoryPage() {
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<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
|
||||
</p>
|
||||
</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"
|
||||
/>
|
||||
</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>
|
||||
<label className="text-xs font-black text-slate-500 ml-1">Specs / Comments</label>
|
||||
@@ -544,6 +604,13 @@ export default function InventoryPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showScanner && (
|
||||
<Scanner
|
||||
onScanSuccess={onScanSuccess}
|
||||
onOCRMatch={onOCRMatch}
|
||||
/>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</PageShell>
|
||||
);
|
||||
|
||||
@@ -23,9 +23,14 @@ export default function RootLayout({
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<head>
|
||||
<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>
|
||||
<body className="antialiased">
|
||||
<body className="antialiased bg-slate-950 text-slate-100">
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -65,8 +65,9 @@ export default function LoginPage() {
|
||||
router.push('/');
|
||||
}, 500);
|
||||
|
||||
} catch (error) {
|
||||
toast.error(isEnterprise ? "Login failed. Check credentials or group membership." : "Invalid password");
|
||||
} catch (error: any) {
|
||||
const detail = error.response?.data?.detail || (isEnterprise ? "Login failed. Check credentials or group membership." : "Invalid password");
|
||||
toast.error(detail);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useState, useEffect } from 'react';
|
||||
import { db, Item } from '@/lib/db';
|
||||
import { inventoryApi } from '@/lib/api';
|
||||
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 { fetchAndCacheItems } from '@/lib/sync';
|
||||
|
||||
@@ -23,23 +23,46 @@ export default function LogsPage() {
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// Load inventory for name lookups
|
||||
const cached = await db.items.toArray();
|
||||
setInventory(cached);
|
||||
// 1. First, try to get fresh items to resolve names
|
||||
let freshItems: Item[] = [];
|
||||
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);
|
||||
setAuditLogs(logs);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
// 3. Pre-resolve names to avoid UI flickering/mismatches
|
||||
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 {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredLogs = auditLogs.filter(log => {
|
||||
const itemName = inventory.find(i => i.id === log.target_item_id)?.name || '';
|
||||
const matchesSearch = itemName.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
const matchesSearch = (log.resolved_name || '').toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
log.action.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
(log.username || '').toLowerCase().includes(searchQuery.toLowerCase());
|
||||
|
||||
@@ -55,52 +78,59 @@ export default function LogsPage() {
|
||||
|
||||
const mostActiveUser = auditLogs.length > 0 ?
|
||||
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;
|
||||
}, {})).sort((a: any, b: any) => b[1] - a[1])[0]?.[0] : 'N/A';
|
||||
|
||||
return (
|
||||
<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">
|
||||
<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">
|
||||
<History size={32} />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-black tracking-tight text-white italic">Audit Dashboard</h1>
|
||||
<p className="text-xs text-slate-500 font-bold tracking-widest uppercase mt-1">Real-time Intervention Tracking</p>
|
||||
<h1 className="text-3xl font-black tracking-tight text-white">Audit Dashboard</h1>
|
||||
<p className="text-xs text-slate-500 font-bold mt-1">Real-time Intervention Tracking</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<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">
|
||||
<p className="text-[10px] font-black text-slate-500 uppercase tracking-widest">Total Events</p>
|
||||
<p className="text-3xl font-black text-white tabular-nums">{totalCount}</p>
|
||||
<div className="bg-slate-900/80 border border-slate-700/40 p-2 px-4 rounded-xl flex items-center gap-4 shadow-lg backdrop-blur-sm">
|
||||
<Activity size={18} className="text-primary shrink-0 opacity-80" />
|
||||
<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 className="bg-slate-900/40 border border-slate-800/50 p-6 rounded-[2rem] space-y-2">
|
||||
<p className="text-[10px] font-black text-slate-500 uppercase tracking-widest">Flow In</p>
|
||||
<p className="text-3xl font-black text-green-500 tabular-nums">{inCount}</p>
|
||||
<div className="bg-slate-900/80 border border-slate-700/40 p-2 px-4 rounded-xl flex items-center gap-4 shadow-lg backdrop-blur-sm">
|
||||
<ArrowDownCircle size={18} className="text-green-500 shrink-0 opacity-80" />
|
||||
<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 className="bg-slate-900/40 border border-slate-800/50 p-6 rounded-[2rem] space-y-2">
|
||||
<p className="text-[10px] font-black text-slate-500 uppercase tracking-widest">Flow Out</p>
|
||||
<p className="text-3xl font-black text-rose-500 tabular-nums">{outCount}</p>
|
||||
<div className="bg-slate-900/80 border border-slate-700/40 p-2 px-4 rounded-xl flex items-center gap-4 shadow-lg backdrop-blur-sm">
|
||||
<ArrowUpCircle size={18} className="text-rose-500 shrink-0 opacity-80" />
|
||||
<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 className="bg-slate-900/40 border border-slate-800/50 p-6 rounded-[2rem] space-y-2">
|
||||
<p className="text-[10px] font-black text-slate-500 uppercase tracking-widest">Top Operator</p>
|
||||
<p className="text-xl font-black text-primary truncate" title={mostActiveUser}>{mostActiveUser}</p>
|
||||
<div className="bg-slate-900/80 border border-slate-700/40 p-2 px-4 rounded-xl flex items-center gap-4 shadow-lg backdrop-blur-sm overflow-hidden">
|
||||
<User size={18} className="text-indigo-400 shrink-0 opacity-80" />
|
||||
<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>
|
||||
|
||||
@@ -117,18 +147,25 @@ export default function LogsPage() {
|
||||
</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">
|
||||
{['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
|
||||
key={action}
|
||||
onClick={() => setFilterAction(action)}
|
||||
key={action.value}
|
||||
onClick={() => setFilterAction(action.value)}
|
||||
className={cn(
|
||||
"px-4 py-2.5 rounded-xl text-[10px] font-black transition-all whitespace-nowrap",
|
||||
filterAction === action
|
||||
"px-4 py-2.5 rounded-xl text-xs font-bold transition-all whitespace-nowrap",
|
||||
filterAction === action.value
|
||||
? "bg-primary text-white shadow-lg shadow-primary/20"
|
||||
: "text-slate-500 hover:text-slate-300 hover:bg-slate-800"
|
||||
)}
|
||||
>
|
||||
{action.replace('_', ' ')}
|
||||
{action.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -139,7 +176,7 @@ export default function LogsPage() {
|
||||
{loading ? (
|
||||
<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" />
|
||||
<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>
|
||||
) : 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">
|
||||
@@ -157,52 +194,44 @@ export default function LogsPage() {
|
||||
<button
|
||||
key={log.id}
|
||||
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 items-center gap-3 mb-3">
|
||||
<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="flex-1 min-w-0 z-10 flex items-center gap-4">
|
||||
{/* Compact Action Badge */}
|
||||
<div className={cn(
|
||||
"text-4xl font-black tabular-nums group-hover:scale-110 transition-transform flex items-center justify-end gap-1",
|
||||
log.quantity_change > 0 ? "text-green-500" : (log.quantity_change < 0 ? "text-rose-500" : "text-indigo-400")
|
||||
"text-[9px] font-black px-2 py-0.5 rounded-md border min-w-[70px] text-center",
|
||||
log.action.includes('CHECK_IN') ? "bg-green-500/5 text-green-500 border-green-500/20" :
|
||||
(log.action.includes('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>
|
||||
<p className="text-[10px] font-black text-slate-600 uppercase tracking-widest mt-1">Quantity</p>
|
||||
</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" />
|
||||
</button>
|
||||
))}
|
||||
@@ -224,7 +253,7 @@ export default function LogsPage() {
|
||||
{selectedLog.action}
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
<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="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>
|
||||
</div>
|
||||
<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(
|
||||
"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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<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">
|
||||
{new Date(selectedLog.timestamp).toLocaleString(undefined, { dateStyle: 'full', timeStyle: 'medium' })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{selectedLog.target_snapshot && (() => {
|
||||
try {
|
||||
const snap = JSON.parse(selectedLog.target_snapshot) as Record<string, any>;
|
||||
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>
|
||||
) : null
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} catch (e) { return null; }
|
||||
})()}
|
||||
|
||||
{selectedLog.details && (
|
||||
<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">
|
||||
"{selectedLog.details}"
|
||||
</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>
|
||||
|
||||
<button
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -16,9 +16,11 @@ export default function AIOnboarding({ onCancel, onComplete, categories, invento
|
||||
const [image, setImage] = useState<string | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [extractedData, setExtractedData] = useState<any>(null);
|
||||
const [mode, setMode] = useState<'item' | 'box'>('item');
|
||||
|
||||
// Extract unique item types for suggestions
|
||||
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 fileInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -41,7 +43,7 @@ export default function AIOnboarding({ onCancel, onComplete, categories, invento
|
||||
const formData = new FormData();
|
||||
formData.append('file', blob, 'label.jpg');
|
||||
|
||||
const data = await inventoryApi.analyzeLabel(formData);
|
||||
const data = await inventoryApi.analyzeLabel(formData, mode);
|
||||
|
||||
if (data.error) {
|
||||
toast.error(`AI Error: ${data.error}`);
|
||||
@@ -50,7 +52,11 @@ export default function AIOnboarding({ onCancel, onComplete, categories, invento
|
||||
}
|
||||
|
||||
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) {
|
||||
toast.error("Failed to process image with AI");
|
||||
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()}`),
|
||||
quantity: parseFloat(String(extractedData.quantity || 1)),
|
||||
min_quantity: 1.0,
|
||||
box_label: extractedData.box_label ? String(extractedData.box_label) : null,
|
||||
labels_data: JSON.stringify(extractedData)
|
||||
};
|
||||
onComplete(newItem);
|
||||
@@ -95,13 +102,34 @@ export default function AIOnboarding({ onCancel, onComplete, categories, invento
|
||||
|
||||
{!image ? (
|
||||
<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="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>
|
||||
<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">
|
||||
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>
|
||||
</div>
|
||||
|
||||
@@ -229,6 +257,19 @@ export default function AIOnboarding({ onCancel, onComplete, categories, invento
|
||||
/>
|
||||
</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 className="bg-slate-900/80 p-5 rounded-[1.5rem] border border-slate-800 focus-within:border-primary/50 transition-colors group">
|
||||
|
||||
@@ -25,7 +25,7 @@ export default function BottomNav({
|
||||
const isAdmin = pathname === '/admin';
|
||||
|
||||
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">
|
||||
|
||||
<button
|
||||
@@ -68,10 +68,12 @@ export default function BottomNav({
|
||||
{/* Logout */}
|
||||
<button
|
||||
onClick={() => {
|
||||
import('@/lib/auth').then(m => m.clearAuth());
|
||||
window.location.href = '/login';
|
||||
if (window.confirm("Are you sure you want to logout?")) {
|
||||
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} />
|
||||
<span className="text-xs font-bold transition-all">Logout</span>
|
||||
|
||||
@@ -47,10 +47,14 @@ export default function PageShell({ children, requireAdmin = false }: PageShellP
|
||||
}, [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
|
||||
if (!currentUser && pathname !== '/login') return null;
|
||||
// Prevent flicker by showing dark background if we're redirecting to login
|
||||
if (!currentUser && pathname !== '/login') {
|
||||
return <div className="min-h-screen bg-slate-950" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-950 text-slate-100 flex flex-col">
|
||||
|
||||
@@ -25,6 +25,7 @@ export default function Scanner({ onScanSuccess, onOCRMatch, paused }: ScannerPr
|
||||
const [isSelecting, setIsSelecting] = useState(false);
|
||||
const [capturedImage, setCapturedImage] = useState<string | null>(null);
|
||||
const isBusy = useRef(false);
|
||||
const isTransitioning = useRef(false); // Flag to track start/stop transitions
|
||||
const scannerId = "reader-container-unique";
|
||||
|
||||
useEffect(() => {
|
||||
@@ -33,11 +34,20 @@ export default function Scanner({ onScanSuccess, onOCRMatch, paused }: ScannerPr
|
||||
}
|
||||
|
||||
const startScanner = async () => {
|
||||
if (isBusy.current) return;
|
||||
if (isBusy.current || isTransitioning.current) return;
|
||||
isBusy.current = true;
|
||||
isTransitioning.current = true;
|
||||
|
||||
try {
|
||||
// If already scanning, we MUST stop it first
|
||||
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 = {
|
||||
@@ -50,7 +60,7 @@ export default function Scanner({ onScanSuccess, onOCRMatch, paused }: ScannerPr
|
||||
Html5QrcodeSupportedFormats.CODE_39,
|
||||
Html5QrcodeSupportedFormats.EAN_13,
|
||||
Html5QrcodeSupportedFormats.UPC_A,
|
||||
Html5QrcodeSupportedFormats.DATAMATRIX
|
||||
Html5QrcodeSupportedFormats.DATA_MATRIX
|
||||
],
|
||||
videoConstraints: {
|
||||
facingMode: "environment"
|
||||
@@ -85,6 +95,7 @@ export default function Scanner({ onScanSuccess, onOCRMatch, paused }: ScannerPr
|
||||
}
|
||||
} finally {
|
||||
isBusy.current = false;
|
||||
isTransitioning.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -94,11 +105,15 @@ export default function Scanner({ onScanSuccess, onOCRMatch, paused }: ScannerPr
|
||||
|
||||
return () => {
|
||||
if (html5QrCodeRef.current?.isScanning) {
|
||||
isTransitioning.current = true;
|
||||
html5QrCodeRef.current.stop()
|
||||
.then(() => {
|
||||
setIsStarted(false);
|
||||
})
|
||||
.catch(e => console.error("Stop failed", e));
|
||||
.catch(e => console.error("Unmount stop failed", e))
|
||||
.finally(() => {
|
||||
isTransitioning.current = false;
|
||||
});
|
||||
}
|
||||
};
|
||||
}, [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"
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
|
||||
@@ -310,7 +325,7 @@ export default function Scanner({ onScanSuccess, onOCRMatch, paused }: ScannerPr
|
||||
<>
|
||||
<Search className={cn("text-slate-500", !isStarted && "opacity-20")} size={20} />
|
||||
<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">
|
||||
{countdown === 0 ? "Scanning..." : `Next scan in ${countdown}s`}
|
||||
</span>
|
||||
|
||||
@@ -1,20 +1,48 @@
|
||||
import axios from 'axios';
|
||||
import { getToken, clearAuth } from './auth';
|
||||
|
||||
export const getBackendUrl = () => {
|
||||
if (typeof window === 'undefined') return 'http://localhost:8000';
|
||||
// Cached config to avoid repeated fetches
|
||||
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;
|
||||
|
||||
// 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 (host.includes('.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({});
|
||||
|
||||
axiosInstance.interceptors.request.use((config) => {
|
||||
axiosInstance.interceptors.request.use(async (config) => {
|
||||
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();
|
||||
if (token) {
|
||||
@@ -75,8 +103,8 @@ export const inventoryApi = {
|
||||
}
|
||||
},
|
||||
|
||||
analyzeLabel: async (formData: FormData) => {
|
||||
const res = await axiosInstance.post('/items/extract-label', formData, {
|
||||
analyzeLabel: async (formData: FormData, mode: string = "item") => {
|
||||
const res = await axiosInstance.post(`/items/extract-label?mode=${mode}`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
});
|
||||
return res.data;
|
||||
@@ -110,7 +138,8 @@ export const inventoryApi = {
|
||||
// Users
|
||||
getUsers: async () => {
|
||||
// [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;
|
||||
},
|
||||
|
||||
@@ -121,7 +150,8 @@ export const inventoryApi = {
|
||||
|
||||
login: async (credentials: any) => {
|
||||
// [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;
|
||||
},
|
||||
|
||||
@@ -164,5 +194,31 @@ export const inventoryApi = {
|
||||
deleteCategory: async (id: number) => {
|
||||
const res = await axiosInstance.delete(`/categories/${id}`);
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -11,6 +11,7 @@ export interface Item {
|
||||
quantity: number;
|
||||
min_quantity: number;
|
||||
image_url?: string;
|
||||
box_label?: string;
|
||||
labels_data?: string;
|
||||
serial_number?: string;
|
||||
type?: string;
|
||||
@@ -33,8 +34,8 @@ export class InventoryDatabase extends Dexie {
|
||||
|
||||
constructor() {
|
||||
super('InventoryDatabase');
|
||||
this.version(3).stores({
|
||||
items: '++id, barcode, name, category, part_number, color',
|
||||
this.version(4).stores({
|
||||
items: '++id, barcode, name, category, part_number, color, box_label',
|
||||
pendingOperations: '++id, barcode, timestamp, synced, uuid'
|
||||
});
|
||||
}
|
||||
|
||||
58
frontend/lib/labels.ts
Normal file
58
frontend/lib/labels.ts
Normal 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)}`;
|
||||
}
|
||||
@@ -1,21 +1,38 @@
|
||||
{
|
||||
"name": "Inventory PWA",
|
||||
"short_name": "Inventory",
|
||||
"name": "TFM aInventory",
|
||||
"short_name": "aInventory",
|
||||
"description": "Unified inventory management with offline scanning",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"orientation": "portrait",
|
||||
"background_color": "#0a0a0a",
|
||||
"theme_color": "#3b82f6",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/icon-192x192.png",
|
||||
"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",
|
||||
"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" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
1
frontend/public/sw.js
Normal file
1
frontend/public/sw.js
Normal file
File diff suppressed because one or more lines are too long
1
frontend/public/workbox-4754cb34.js
Normal file
1
frontend/public/workbox-4754cb34.js
Normal file
File diff suppressed because one or more lines are too long
2
logs/.gitkeep
Normal file
2
logs/.gitkeep
Normal 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.
|
||||
@@ -7,17 +7,38 @@ echo "🚀 Starting TFM aInventory in Standalone Mode..."
|
||||
# Trapping termination signals to clean up child processes
|
||||
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
|
||||
FRONTEND_PORT=3001
|
||||
BACKEND_SSL_PORT=3002
|
||||
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
|
||||
if [ -d ".venv" ]; then
|
||||
source .venv/bin/activate
|
||||
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)
|
||||
echo "🔥 Starting Backend (Uvicorn)..."
|
||||
python3 -m uvicorn backend.main:app --host 0.0.0.0 --port $BACKEND_PORT &
|
||||
|
||||
56
scripts/init_data.sh
Executable file
56
scripts/init_data.sh
Executable 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."
|
||||
@@ -4,7 +4,7 @@ import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
|
||||
VERSION_FILE = 'VERSION.json'
|
||||
VERSION_FILE = 'frontend/VERSION.json'
|
||||
GIT_PATH_FILE = '.git_path'
|
||||
|
||||
def get_git_path():
|
||||
@@ -24,7 +24,7 @@ def run_command(cmd):
|
||||
def main():
|
||||
git = get_git_path()
|
||||
|
||||
# 1. Read and Increment Version
|
||||
# 1. Read and Parse Version
|
||||
if not os.path.exists(VERSION_FILE):
|
||||
print(f"Error: {VERSION_FILE} not found.")
|
||||
sys.exit(1)
|
||||
@@ -34,10 +34,20 @@ def main():
|
||||
|
||||
old_version = data.get('version', '1.0.0')
|
||||
parts = old_version.split('.')
|
||||
if len(parts) == 3:
|
||||
parts[2] = str(int(parts[2]) + 1)
|
||||
if len(parts) < 3:
|
||||
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:
|
||||
parts.append('1')
|
||||
# Default: patch increment
|
||||
parts[2] = str(int(parts[2]) + 1)
|
||||
|
||||
new_version = '.'.join(parts)
|
||||
data['version'] = new_version
|
||||
@@ -51,17 +61,24 @@ def main():
|
||||
|
||||
# 2. Git Operations
|
||||
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)
|
||||
branch_name = f"v.{new_version}"
|
||||
branch_name = f"v{new_version}"
|
||||
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...")
|
||||
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.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -1,10 +1,22 @@
|
||||
#!/bin/bash
|
||||
|
||||
# --- CONFIGURATION ---
|
||||
# --- CONFIGURATION (Default values, will be overridden by network_config.env) ---
|
||||
BACKEND_PORT=8000
|
||||
FRONTEND_PORT=3001
|
||||
BACKEND_SSL_PORT=3002
|
||||
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..."
|
||||
|
||||
@@ -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 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 " CORS origins: $ALLOWED_ORIGINS"
|
||||
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 ""
|
||||
echo -e " USE THIS URL ON BOTH DESKTOP & MOBILE:"
|
||||
echo -e " 👉 ${GREEN}${BOLD}https://$LOCAL_IP:3003${NC}"
|
||||
echo -e " (Or ${GREEN}https://localhost:3003${NC} on this Mac)"
|
||||
echo -e " 👉 ${GREEN}${BOLD}https://$LOCAL_IP:$FRONTEND_SSL_PORT${NC}"
|
||||
echo -e " (Or ${GREEN}https://localhost:$FRONTEND_SSL_PORT${NC} on this Mac)"
|
||||
echo ""
|
||||
echo -e " ${YELLOW}${BOLD}NOTE:${NC} If you see a 'Not Private' warning,"
|
||||
echo -e " Click 'Advanced' -> 'Proceed' to continue."
|
||||
|
||||
Reference in New Issue
Block a user