mirror of
https://github.com/9technologygroup/patchmon.net.git
synced 2025-11-05 14:35:35 +00:00
Compare commits
104 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e62a4fed56 | ||
|
|
be549d4b34 | ||
|
|
99aa79a6a4 | ||
|
|
73761d8927 | ||
|
|
9889083900 | ||
|
|
acb30f22bd | ||
|
|
3a0b564a6f | ||
|
|
e536a5b706 | ||
|
|
0a3e4ad5ee | ||
|
|
abcf88b8b9 | ||
|
|
94ec14f08b | ||
|
|
f25834b4ba | ||
|
|
f85464ad26 | ||
|
|
db0ba201a4 | ||
|
|
676082a967 | ||
|
|
30bb29c9f4 | ||
|
|
968d9f964b | ||
|
|
3e413e71e4 | ||
|
|
e25baf0f55 | ||
|
|
2869d4e850 | ||
|
|
e459d8b378 | ||
|
|
31583716c8 | ||
|
|
e645124356 | ||
|
|
c3aa5534f3 | ||
|
|
bf2ea908f4 | ||
|
|
43ce146987 | ||
|
|
69a121cdde | ||
|
|
001b234ecc | ||
|
|
d300922312 | ||
|
|
20ff5b5b72 | ||
|
|
5e6a2d863c | ||
|
|
ab46b0138b | ||
|
|
5ca0f086d4 | ||
|
|
9cb5cd380b | ||
|
|
517b5cd7cb | ||
|
|
5dafe34322 | ||
|
|
677d3b4df1 | ||
|
|
c3365fedb2 | ||
|
|
f23f075e41 | ||
|
|
9b76d9f81a | ||
|
|
64d9c14002 | ||
|
|
9a01d27d8b | ||
|
|
d72f96b598 | ||
|
|
8f8b23ccf1 | ||
|
|
1392976a7b | ||
|
|
797be20c45 | ||
|
|
a268f6b8f1 | ||
|
|
a4770e5106 | ||
|
|
523756cef2 | ||
|
|
697da088d4 | ||
|
|
739ca6486a | ||
|
|
38d299701d | ||
|
|
5d35abe496 | ||
|
|
7ff051be3e | ||
|
|
2de80f0c06 | ||
|
|
875ab31317 | ||
|
|
a96439596d | ||
|
|
d2bf201f1e | ||
|
|
b2d3181ffe | ||
|
|
5a0229cef4 | ||
|
|
f73c10f309 | ||
|
|
8722bd170f | ||
|
|
fd76a9efd2 | ||
|
|
584e5ed52b | ||
|
|
c5ff4b346a | ||
|
|
cc9f0af1ac | ||
|
|
d7460068d7 | ||
|
|
9135fa93b3 | ||
|
|
662a8d665a | ||
|
|
f3351d577d | ||
|
|
e1b8e4458a | ||
|
|
976ca79f57 | ||
|
|
01a8bd6c77 | ||
|
|
d210d6adde | ||
|
|
229ba4f7be | ||
|
|
9a3827dced | ||
|
|
d687ec4e45 | ||
|
|
bbd7769b8c | ||
|
|
8245c6b90d | ||
|
|
1afb9c1ed3 | ||
|
|
417942f674 | ||
|
|
75a4b4a912 | ||
|
|
4576781900 | ||
|
|
0d10d7ee9b | ||
|
|
1cdd6eba6d | ||
|
|
adb207fef9 | ||
|
|
216c9dbefa | ||
|
|
52d6d46ea3 | ||
|
|
6bc4316fbc | ||
|
|
b1470f57a8 | ||
|
|
51d6dd63b1 | ||
|
|
2d7a3c3103 | ||
|
|
5bdd0b5830 | ||
|
|
98cadb1ff1 | ||
|
|
42a6b7e19c | ||
|
|
e35f96d30f | ||
|
|
08f82bc795 | ||
|
|
c497c1db2a | ||
|
|
5b7e7216e8 | ||
|
|
fe5fb92e48 | ||
|
|
c8d54facb9 | ||
|
|
f97b300158 | ||
|
|
17ffa48158 | ||
|
|
16821d6b5e |
63
.github/workflows/docker.yml
vendored
Normal file
63
.github/workflows/docker.yml
vendored
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
name: Build and Push Docker Images
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- dev
|
||||||
|
release:
|
||||||
|
types:
|
||||||
|
- published
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: ghcr.io
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
image: [backend, frontend]
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Log in to Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ github.repository_owner }}
|
||||||
|
# Using PAT as a hack due to issues with GITHUB_TOKEN and package permissions
|
||||||
|
# This should be reverted to use GITHUB_TOKEN once a solution is discovered.
|
||||||
|
password: ${{ secrets.GHCR_PAT }}
|
||||||
|
|
||||||
|
- name: Extract metadata (tags, labels)
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ env.REGISTRY }}/${{ github.repository_owner }}/patchmon-${{ matrix.image }}
|
||||||
|
tags: |
|
||||||
|
type=ref,event=pr
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
|
type=semver,pattern={{major}}
|
||||||
|
type=raw,value=latest,enable={{is_default_branch}}
|
||||||
|
|
||||||
|
- name: Build and push ${{ matrix.image }} image
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: docker/${{ matrix.image }}.Dockerfile
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
cache-from: type=gha,scope=${{ matrix.image }}
|
||||||
|
cache-to: type=gha,mode=max,scope=${{ matrix.image }}
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -140,6 +140,9 @@ test-results.xml
|
|||||||
deploy-patchmon.sh
|
deploy-patchmon.sh
|
||||||
manage-instances.sh
|
manage-instances.sh
|
||||||
manage-patchmon.sh
|
manage-patchmon.sh
|
||||||
|
manage-patchmon-dev.sh
|
||||||
setup-installer-site.sh
|
setup-installer-site.sh
|
||||||
install-server.*
|
install-server.*
|
||||||
notify-clients-upgrade.sh
|
notify-clients-upgrade.sh
|
||||||
|
debug-agent.sh
|
||||||
|
docker/agents
|
||||||
|
|||||||
366
README.md
Normal file
366
README.md
Normal file
@@ -0,0 +1,366 @@
|
|||||||
|
## Purpose
|
||||||
|
|
||||||
|
PatchMon provides centralized patch management across diverse server environments. Agents communicate outbound-only to the PatchMon server, eliminating inbound ports on monitored hosts while delivering comprehensive visibility and safe automation.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### Users & Authentication
|
||||||
|
- Multi-user accounts (admin and standard users)
|
||||||
|
- Email/username-based login
|
||||||
|
- Optional Two‑Factor Authentication (TFA/MFA) with verification flow
|
||||||
|
- First‑time admin bootstrap flow (no default credentials; secure setup)
|
||||||
|
- Self‑registration toggle in settings (enable/disable public signup)
|
||||||
|
|
||||||
|
### Roles, Permissions & RBAC
|
||||||
|
- Built‑in roles: `admin`, `user`
|
||||||
|
- Fine‑grained permission flags (e.g., view/manage hosts, packages, users, reports, settings)
|
||||||
|
- Server‑side enforcement for protected routes and UI guards per permission
|
||||||
|
|
||||||
|
### Dashboard
|
||||||
|
- Customisable dashboard with per‑user card layout and ordering
|
||||||
|
- Role/permission‑aware defaults on first login
|
||||||
|
- “Reset to Defaults” uses consistent server‑provided defaults
|
||||||
|
- Cards include: Total Hosts, Needs Updating, Up‑to‑Date Hosts, Host Groups, Outdated Packages, Security Updates, Package Priority, Repositories, Users, OS Distribution (pie/bar), Update Status, Recent Collection, Recent Users, Quick Stats
|
||||||
|
|
||||||
|
### Hosts & Inventory
|
||||||
|
- Host inventory with key attributes and OS details
|
||||||
|
- Host grouping (create and manage host groups)
|
||||||
|
- OS distribution summaries and visualisations
|
||||||
|
- Recent telemetry collection indicator
|
||||||
|
|
||||||
|
### Packages & Updates
|
||||||
|
- Package inventory across hosts
|
||||||
|
- Outdated packages overview and counts
|
||||||
|
- Security updates highlight
|
||||||
|
- Update status breakdown (up‑to‑date vs needs updates)
|
||||||
|
|
||||||
|
### Repositories
|
||||||
|
- Repositories per host tracking
|
||||||
|
- Repository module pages and totals
|
||||||
|
|
||||||
|
### Agent & Data Collection
|
||||||
|
- Outbound‑only agent communication (no inbound ports required on hosts)
|
||||||
|
- Agent version management and script content stored in DB
|
||||||
|
- Version marking (current/default) with update history
|
||||||
|
|
||||||
|
### Settings & Configuration
|
||||||
|
- Server URL/protocol/host/port
|
||||||
|
- Update interval and auto‑update toggle
|
||||||
|
- Public signup toggle and default user role selection
|
||||||
|
- Repository settings: GitHub repo URL, repository type, SSH key path
|
||||||
|
- Rate‑limit windows and thresholds for API/auth/agent
|
||||||
|
|
||||||
|
### Admin & User Management
|
||||||
|
- Admin user CRUD (create, list, update, delete)
|
||||||
|
- Password reset (admin‑initiated)
|
||||||
|
- Role assignment on user create/update
|
||||||
|
|
||||||
|
### Reporting & Analytics
|
||||||
|
- Dashboard stats and card‑level metrics
|
||||||
|
- OS distribution charts (pie/bar)
|
||||||
|
- Update status and recent activity summaries
|
||||||
|
|
||||||
|
### API & Integrations
|
||||||
|
- REST API under `/api/v1` with JWT auth
|
||||||
|
- Consistent JSON responses; errors with appropriate status codes
|
||||||
|
- CORS configured per server settings
|
||||||
|
|
||||||
|
### Security
|
||||||
|
- JWT‑secured API with short, scoped tokens
|
||||||
|
- Permissions enforced server‑side on every route
|
||||||
|
- Rate limiting for general, auth, and agent endpoints
|
||||||
|
- Outbound‑only agent model reduces attack surface
|
||||||
|
|
||||||
|
### Deployment & Operations
|
||||||
|
- One‑line self‑host installer (Ubuntu/Debian)
|
||||||
|
- Automated provisioning: Node.js, PostgreSQL, nginx
|
||||||
|
- Prisma migrations and client generation
|
||||||
|
- systemd service for backend lifecycle
|
||||||
|
- nginx vhost for frontend + API proxy; optional Let’s Encrypt integration
|
||||||
|
- Consolidated deployment info file with commands and paths
|
||||||
|
|
||||||
|
### UX & Frontend
|
||||||
|
- Vite + React single‑page app
|
||||||
|
- Protected routes with permission checks
|
||||||
|
- Theming and modern components (icons, modals, notifications)
|
||||||
|
|
||||||
|
### Observability & Logging
|
||||||
|
- Structured server logs
|
||||||
|
- Deployment logs copied to instance dir for later review
|
||||||
|
|
||||||
|
### Road‑Readiness
|
||||||
|
- Works for internal (HTTP) and public (HTTPS) deployments
|
||||||
|
- Defaults safe for first‑time setup; admin created interactively
|
||||||
|
|
||||||
|
## Communication Model
|
||||||
|
|
||||||
|
- Outbound-only agents: servers initiate communication to PatchMon
|
||||||
|
- No inbound connections required on monitored servers
|
||||||
|
- Secure server-side API with JWT authentication and rate limiting
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
- Backend: Node.js/Express + Prisma + PostgreSQL
|
||||||
|
- Frontend: Vite + React
|
||||||
|
- Reverse proxy: nginx
|
||||||
|
- Database: PostgreSQL
|
||||||
|
- System service: systemd-managed backend
|
||||||
|
|
||||||
|
```
|
||||||
|
+----------------------+ HTTPS +--------------------+ HTTP +------------------------+ TCP +---------------+
|
||||||
|
| End Users (Browser) | ---------> | nginx | --------> | Backend (Node/Express) | ------> | PostgreSQL |
|
||||||
|
| Admin UI / Frontend | | serve FE, proxy API| | /api, auth, Prisma | | Database |
|
||||||
|
+----------------------+ +--------------------+ +------------------------+ +---------------+
|
||||||
|
|
||||||
|
Agents (Outbound Only)
|
||||||
|
+---------------------------+ HTTPS +------------------------+
|
||||||
|
| Agents on your servers | ----------> | Backend API (/api/v1) |
|
||||||
|
+---------------------------+ +------------------------+
|
||||||
|
|
||||||
|
Operational
|
||||||
|
- systemd manages backend service
|
||||||
|
- certbot/nginx for TLS (public)
|
||||||
|
- setup.sh bootstraps OS, app, DB, config
|
||||||
|
```
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
### PatchMon Cloud (coming soon)
|
||||||
|
|
||||||
|
Managed, zero-maintenance PatchMon hosting. Stay tuned.
|
||||||
|
|
||||||
|
### Self-hosted Installation
|
||||||
|
|
||||||
|
Run on a clean Ubuntu/Debian server with internet access:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL -o setup.sh https://raw.githubusercontent.com/9technologygroup/patchmon.net/main/setup.sh && chmod +x && bash setup.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
During setup you’ll be asked:
|
||||||
|
- Domain/IP: public DNS or local IP (default: `patchmon.internal`)
|
||||||
|
- SSL/HTTPS: `y` for public deployments with a public IP, `n` for internal networks
|
||||||
|
- Email: only if SSL is enabled (for Let’s Encrypt)
|
||||||
|
- Git Branch: default is `main` (press Enter)
|
||||||
|
|
||||||
|
The script will:
|
||||||
|
- Install prerequisites (Node.js, PostgreSQL, nginx)
|
||||||
|
- Clone the repo, install dependencies, build the frontend, run migrations
|
||||||
|
- Create a systemd service and nginx site vhost config
|
||||||
|
- Start the service and write a consolidated info file at:
|
||||||
|
- `/opt/<your-domain>/deployment-info.txt`
|
||||||
|
- Copies the full installer log to `/opt/<your-domain>/patchmon-install.log` from /var/log/patchmon-install.log
|
||||||
|
|
||||||
|
After installation:
|
||||||
|
- Visit `http(s)://<your-domain>` and complete first-time admin setup
|
||||||
|
- See all useful info in `deployment-info.txt`
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
- Discord: https://discord.gg/S7RXUHwg
|
||||||
|
- Email: support@patchmon.net
|
||||||
|
|
||||||
|
## Roadmap
|
||||||
|
|
||||||
|
- PatchMon Cloud (managed offering)
|
||||||
|
- Additional dashboards and reporting widgets
|
||||||
|
- More OS distributions and agent enhancements
|
||||||
|
- Advanced workflow automations and approvals
|
||||||
|
|
||||||
|
Roadmap board: https://github.com/users/9technologygroup/projects/1
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
- Outbound-only agent communications; no inbound ports on monitored hosts
|
||||||
|
- JWT-based API auth, rate limiting, role/permission checks
|
||||||
|
- Follow least-privilege defaults; sensitive operations audited
|
||||||
|
|
||||||
|
## Support Methods
|
||||||
|
|
||||||
|
- Community: Discord for quick questions and feedback
|
||||||
|
- Email: SLA-backed assistance for incidents and issues
|
||||||
|
- GitHub Issues: bug reports and feature requests
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
AGPLv3 (More information on this soon)
|
||||||
|
|
||||||
|
## Links
|
||||||
|
|
||||||
|
- Repository: https://github.com/9technologygroup/patchmon.net/
|
||||||
|
- Raw installer: https://raw.githubusercontent.com/9technologygroup/patchmon.net/main/setup.sh
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
# PatchMon
|
||||||
|
|
||||||
|
[](https://discord.gg/S7RXUHwg)
|
||||||
|
[](https://github.com/9technologygroup/patchmon.net)
|
||||||
|
[](https://github.com/users/9technologygroup/projects/1)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🤝 Contributing
|
||||||
|
|
||||||
|
We welcome contributions from the community! Here's how you can get involved:
|
||||||
|
|
||||||
|
### Development Setup
|
||||||
|
1. **Fork the Repository**
|
||||||
|
```bash
|
||||||
|
# Click the "Fork" button on GitHub, then clone your fork
|
||||||
|
git clone https://github.com/YOUR_USERNAME/patchmon.net.git
|
||||||
|
cd patchmon.net
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Create a Feature Branch**
|
||||||
|
```bash
|
||||||
|
git checkout -b feature/your-feature-name
|
||||||
|
# or
|
||||||
|
git checkout -b fix/your-bug-fix
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
4. **Make Your Changes**
|
||||||
|
- Write clean, well-documented code
|
||||||
|
- Follow existing code style and patterns
|
||||||
|
- Add tests for new functionality
|
||||||
|
- Update documentation as needed
|
||||||
|
|
||||||
|
5. **Test Your Changes**
|
||||||
|
```bash
|
||||||
|
# Run backend tests
|
||||||
|
cd backend
|
||||||
|
npm test
|
||||||
|
|
||||||
|
# Run frontend tests
|
||||||
|
cd ../frontend
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
6. **Commit and Push**
|
||||||
|
```bash
|
||||||
|
git add .
|
||||||
|
git commit -m "Add: descriptive commit message"
|
||||||
|
git push origin feature/your-feature-name
|
||||||
|
```
|
||||||
|
|
||||||
|
7. **Create a Pull Request**
|
||||||
|
- Go to your fork on GitHub
|
||||||
|
- Click "New Pull Request"
|
||||||
|
- Provide a clear description of your changes
|
||||||
|
- Link any related issues
|
||||||
|
|
||||||
|
### Contribution Guidelines
|
||||||
|
- **Code Style**: Follow the existing code patterns and ESLint configuration
|
||||||
|
- **Commits**: Use conventional commit messages (feat:, fix:, docs:, etc.)
|
||||||
|
- **Testing**: Ensure all tests pass and add tests for new features
|
||||||
|
- **Documentation**: Update README and code comments as needed
|
||||||
|
- **Issues**: Check existing issues before creating new ones
|
||||||
|
|
||||||
|
### Areas We Need Help With
|
||||||
|
- 🐳 **Docker & Containerization** (led by @Adam20054)
|
||||||
|
- 🔄 **CI/CD Pipelines** (led by @tigattack)
|
||||||
|
- 🔒 **Security Improvements** - Security audits, vulnerability assessments, and security feature enhancements
|
||||||
|
- ⚡ **Performance for Large Scale Deployments** - Database optimization, caching strategies, and horizontal scaling
|
||||||
|
- 📚 **Documentation** improvements
|
||||||
|
- 🧪 **Testing** coverage
|
||||||
|
- 🌐 **Internationalization** (i18n)
|
||||||
|
- 📱 **Mobile** responsive improvements
|
||||||
|
-
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🗺️ Roadmap
|
||||||
|
|
||||||
|
Check out our [public roadmap](https://github.com/users/9technologygroup/projects/1) to see what we're working on and what's coming next!
|
||||||
|
|
||||||
|
**Upcoming Features:**
|
||||||
|
- 🐳 Docker Compose deployment
|
||||||
|
- 🔄 Automated CI/CD pipelines
|
||||||
|
- 📊 Advanced reporting and analytics
|
||||||
|
- 🔔 Enhanced notification system
|
||||||
|
- 📱 Mobile application
|
||||||
|
- 🔄 Patch management workflows and policies
|
||||||
|
- 👥 Users inventory management
|
||||||
|
- 🔍 Services and ports monitoring
|
||||||
|
- 🖥️ Proxmox integration for auto LXC discovery and registration
|
||||||
|
- 📧 Notifications via Slack/Email
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🏢 Enterprise & Custom Solutions
|
||||||
|
|
||||||
|
### PatchMon Cloud
|
||||||
|
- **Fully Managed**: We handle all infrastructure and maintenance
|
||||||
|
- **Scalable**: Grows with your organization
|
||||||
|
- **Secure**: Enterprise-grade security and compliance
|
||||||
|
- **Support**: Dedicated support team
|
||||||
|
|
||||||
|
### Custom Integrations
|
||||||
|
- **API Development**: Custom endpoints for your specific needs
|
||||||
|
- **Third-Party Integrations**: Connect with your existing tools
|
||||||
|
- **Custom Dashboards**: Tailored reporting and visualization
|
||||||
|
- **White-Label Solutions**: Brand PatchMon as your own
|
||||||
|
|
||||||
|
### Enterprise Deployment
|
||||||
|
- **On-Premises**: Deploy in your own data center
|
||||||
|
- **Air-Gapped**: Support for isolated environments
|
||||||
|
- **Compliance**: Meet industry-specific requirements
|
||||||
|
- **Training**: Comprehensive team training and onboarding
|
||||||
|
|
||||||
|
*Contact us at support@patchmon.net for enterprise inquiries*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📞 Support & Community
|
||||||
|
|
||||||
|
### Get Help
|
||||||
|
- 💬 **Discord Community**: [Join our Discord](https://discord.gg/S7RXUHwg) for real-time support and discussions
|
||||||
|
- 📧 **Email Support**: support@patchmon.net
|
||||||
|
- 📚 **Documentation**: Check our wiki and documentation
|
||||||
|
- 🐛 **Bug Reports**: Use GitHub Issues
|
||||||
|
|
||||||
|
### Community
|
||||||
|
- 🌟 **Star the Project**: Show your support by starring this repository
|
||||||
|
- 🍴 **Fork & Contribute**: Help improve PatchMon
|
||||||
|
- 📢 **Share**: Tell others about PatchMon
|
||||||
|
- 💡 **Feature Requests**: Suggest new features via GitHub Issues
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🙏 Acknowledgments
|
||||||
|
|
||||||
|
### Special Thanks
|
||||||
|
- **Jonathan Higson** - For inspiration, ideas, and valuable feedback
|
||||||
|
- **@Adam20054** - For working on Docker Compose deployment
|
||||||
|
- **@tigattack** - For working on GitHub CI/CD pipelines
|
||||||
|
- **Cloud X** and **Crazy Dead** - For moderating our Discord server and keeping the community awesome
|
||||||
|
|
||||||
|
### Contributors
|
||||||
|
Thank you to all our contributors who help make PatchMon better every day!
|
||||||
|
|
||||||
|
|
||||||
|
## 🔗 Links
|
||||||
|
|
||||||
|
- **Website**: [patchmon.net](https://patchmon.net)
|
||||||
|
- **Discord**: [discord.gg/S7RXUHwg](https://discord.gg/S7RXUHwg)
|
||||||
|
- **Roadmap**: [GitHub Projects](https://github.com/users/9technologygroup/projects/1)
|
||||||
|
- **Documentation**: [Coming Soon]
|
||||||
|
- **Support**: support@patchmon.net
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
**Made with ❤️ by the PatchMon Team**
|
||||||
|
|
||||||
|
[](https://discord.gg/S7RXUHwg)
|
||||||
|
[](https://github.com/9technologygroup/patchmon.net)
|
||||||
|
|
||||||
|
</div>
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
# PatchMon Agent Script
|
# PatchMon Agent Script v1.2.6
|
||||||
# This script sends package update information to the PatchMon server using API credentials
|
# This script sends package update information to the PatchMon server using API credentials
|
||||||
|
|
||||||
# Configuration
|
# Configuration
|
||||||
PATCHMON_SERVER="${PATCHMON_SERVER:-http://localhost:3001}"
|
PATCHMON_SERVER="${PATCHMON_SERVER:-http://localhost:3001}"
|
||||||
API_VERSION="v1"
|
API_VERSION="v1"
|
||||||
AGENT_VERSION="1.2.3"
|
AGENT_VERSION="1.2.6"
|
||||||
CONFIG_FILE="/etc/patchmon/agent.conf"
|
CONFIG_FILE="/etc/patchmon/agent.conf"
|
||||||
CREDENTIALS_FILE="/etc/patchmon/credentials"
|
CREDENTIALS_FILE="/etc/patchmon/credentials"
|
||||||
LOG_FILE="/var/log/patchmon-agent.log"
|
LOG_FILE="/var/log/patchmon-agent.log"
|
||||||
@@ -656,6 +656,114 @@ get_yum_packages() {
|
|||||||
done <<< "$installed"
|
done <<< "$installed"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Get hardware information
|
||||||
|
get_hardware_info() {
|
||||||
|
local cpu_model=""
|
||||||
|
local cpu_cores=0
|
||||||
|
local ram_installed=0
|
||||||
|
local swap_size=0
|
||||||
|
local disk_details="[]"
|
||||||
|
|
||||||
|
# CPU Information
|
||||||
|
if command -v lscpu >/dev/null 2>&1; then
|
||||||
|
cpu_model=$(lscpu | grep "Model name" | cut -d':' -f2 | xargs)
|
||||||
|
cpu_cores=$(lscpu | grep "^CPU(s):" | cut -d':' -f2 | xargs)
|
||||||
|
elif [[ -f /proc/cpuinfo ]]; then
|
||||||
|
cpu_model=$(grep "model name" /proc/cpuinfo | head -1 | cut -d':' -f2 | xargs)
|
||||||
|
cpu_cores=$(grep -c "^processor" /proc/cpuinfo)
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Memory Information
|
||||||
|
if command -v free >/dev/null 2>&1; then
|
||||||
|
ram_installed=$(free -g | grep "^Mem:" | awk '{print $2}')
|
||||||
|
swap_size=$(free -g | grep "^Swap:" | awk '{print $2}')
|
||||||
|
elif [[ -f /proc/meminfo ]]; then
|
||||||
|
ram_installed=$(grep "MemTotal" /proc/meminfo | awk '{print int($2/1024/1024)}')
|
||||||
|
swap_size=$(grep "SwapTotal" /proc/meminfo | awk '{print int($2/1024/1024)}')
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Disk Information
|
||||||
|
if command -v lsblk >/dev/null 2>&1; then
|
||||||
|
disk_details=$(lsblk -J -o NAME,SIZE,TYPE,MOUNTPOINT | jq -c '[.blockdevices[] | select(.type == "disk") | {name: .name, size: .size, mountpoint: .mountpoint}]')
|
||||||
|
elif command -v df >/dev/null 2>&1; then
|
||||||
|
disk_details=$(df -h | grep -E "^/dev/" | awk '{print "{\"name\":\""$1"\",\"size\":\""$2"\",\"mountpoint\":\""$6"\"}"}' | jq -s .)
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "{\"cpuModel\":\"$cpu_model\",\"cpuCores\":$cpu_cores,\"ramInstalled\":$ram_installed,\"swapSize\":$swap_size,\"diskDetails\":$disk_details}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get network information
|
||||||
|
get_network_info() {
|
||||||
|
local gateway_ip=""
|
||||||
|
local dns_servers="[]"
|
||||||
|
local network_interfaces="[]"
|
||||||
|
|
||||||
|
# Gateway IP
|
||||||
|
if command -v ip >/dev/null 2>&1; then
|
||||||
|
gateway_ip=$(ip route | grep default | head -1 | awk '{print $3}')
|
||||||
|
elif command -v route >/dev/null 2>&1; then
|
||||||
|
gateway_ip=$(route -n | grep '^0.0.0.0' | head -1 | awk '{print $2}')
|
||||||
|
fi
|
||||||
|
|
||||||
|
# DNS Servers
|
||||||
|
if [[ -f /etc/resolv.conf ]]; then
|
||||||
|
dns_servers=$(grep "nameserver" /etc/resolv.conf | awk '{print $2}' | jq -R . | jq -s .)
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Network Interfaces
|
||||||
|
if command -v ip >/dev/null 2>&1; then
|
||||||
|
network_interfaces=$(ip -j addr show | jq -c '[.[] | {name: .ifname, type: .link_type, addresses: [.addr_info[]? | {address: .local, family: .family}]}]')
|
||||||
|
elif command -v ifconfig >/dev/null 2>&1; then
|
||||||
|
network_interfaces=$(ifconfig -a | grep -E "^[a-zA-Z]" | awk '{print $1}' | jq -R . | jq -s .)
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "{\"gatewayIp\":\"$gateway_ip\",\"dnsServers\":$dns_servers,\"networkInterfaces\":$network_interfaces}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get system information
|
||||||
|
get_system_info() {
|
||||||
|
local kernel_version=""
|
||||||
|
local selinux_status=""
|
||||||
|
local system_uptime=""
|
||||||
|
local load_average="[]"
|
||||||
|
|
||||||
|
# Kernel Version
|
||||||
|
if [[ -f /proc/version ]]; then
|
||||||
|
kernel_version=$(cat /proc/version | awk '{print $3}')
|
||||||
|
elif command -v uname >/dev/null 2>&1; then
|
||||||
|
kernel_version=$(uname -r)
|
||||||
|
fi
|
||||||
|
|
||||||
|
# SELinux Status
|
||||||
|
if command -v getenforce >/dev/null 2>&1; then
|
||||||
|
selinux_status=$(getenforce 2>/dev/null | tr '[:upper:]' '[:lower:]')
|
||||||
|
elif [[ -f /etc/selinux/config ]]; then
|
||||||
|
selinux_status=$(grep "^SELINUX=" /etc/selinux/config | cut -d'=' -f2 | tr '[:upper:]' '[:lower:]')
|
||||||
|
else
|
||||||
|
selinux_status="disabled"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# System Uptime
|
||||||
|
if [[ -f /proc/uptime ]]; then
|
||||||
|
local uptime_seconds=$(cat /proc/uptime | awk '{print int($1)}')
|
||||||
|
local days=$((uptime_seconds / 86400))
|
||||||
|
local hours=$(((uptime_seconds % 86400) / 3600))
|
||||||
|
local minutes=$(((uptime_seconds % 3600) / 60))
|
||||||
|
system_uptime="${days}d ${hours}h ${minutes}m"
|
||||||
|
elif command -v uptime >/dev/null 2>&1; then
|
||||||
|
system_uptime=$(uptime | awk -F'up ' '{print $2}' | awk -F', load' '{print $1}')
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Load Average
|
||||||
|
if [[ -f /proc/loadavg ]]; then
|
||||||
|
load_average=$(cat /proc/loadavg | awk '{print "["$1","$2","$3"]"}')
|
||||||
|
elif command -v uptime >/dev/null 2>&1; then
|
||||||
|
load_average=$(uptime | awk -F'load average: ' '{print "["$2"]"}' | tr -d ' ')
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "{\"kernelVersion\":\"$kernel_version\",\"selinuxStatus\":\"$selinux_status\",\"systemUptime\":\"$system_uptime\",\"loadAverage\":$load_average}"
|
||||||
|
}
|
||||||
|
|
||||||
# Send package update to server
|
# Send package update to server
|
||||||
send_update() {
|
send_update() {
|
||||||
load_credentials
|
load_credentials
|
||||||
@@ -666,14 +774,27 @@ send_update() {
|
|||||||
info "Collecting repository information..."
|
info "Collecting repository information..."
|
||||||
local repositories_json=$(get_repository_info)
|
local repositories_json=$(get_repository_info)
|
||||||
|
|
||||||
|
info "Collecting hardware information..."
|
||||||
|
local hardware_json=$(get_hardware_info)
|
||||||
|
|
||||||
|
info "Collecting network information..."
|
||||||
|
local network_json=$(get_network_info)
|
||||||
|
|
||||||
|
info "Collecting system information..."
|
||||||
|
local system_json=$(get_system_info)
|
||||||
|
|
||||||
info "Sending update to PatchMon server..."
|
info "Sending update to PatchMon server..."
|
||||||
|
|
||||||
local payload=$(cat <<EOF
|
# Merge all JSON objects into one
|
||||||
|
local merged_json=$(echo "$hardware_json $network_json $system_json" | jq -s '.[0] * .[1] * .[2]')
|
||||||
|
# Create the base payload and merge with system info
|
||||||
|
local base_payload=$(cat <<EOF
|
||||||
{
|
{
|
||||||
"packages": $packages_json,
|
"packages": $packages_json,
|
||||||
"repositories": $repositories_json,
|
"repositories": $repositories_json,
|
||||||
"osType": "$OS_TYPE",
|
"osType": "$OS_TYPE",
|
||||||
"osVersion": "$OS_VERSION",
|
"osVersion": "$OS_VERSION",
|
||||||
|
"hostname": "$HOSTNAME",
|
||||||
"ip": "$IP_ADDRESS",
|
"ip": "$IP_ADDRESS",
|
||||||
"architecture": "$ARCHITECTURE",
|
"architecture": "$ARCHITECTURE",
|
||||||
"agentVersion": "$AGENT_VERSION"
|
"agentVersion": "$AGENT_VERSION"
|
||||||
@@ -681,6 +802,10 @@ send_update() {
|
|||||||
EOF
|
EOF
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Merge the base payload with the system information
|
||||||
|
local payload=$(echo "$base_payload $merged_json" | jq -s '.[0] * .[1]')
|
||||||
|
|
||||||
|
|
||||||
local response=$(curl -s -X POST \
|
local response=$(curl -s -X POST \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-H "X-API-ID: $API_ID" \
|
-H "X-API-ID: $API_ID" \
|
||||||
@@ -888,8 +1013,9 @@ update_crontab() {
|
|||||||
# Generate the expected crontab entry
|
# Generate the expected crontab entry
|
||||||
local expected_crontab=""
|
local expected_crontab=""
|
||||||
if [[ $update_interval -eq 60 ]]; then
|
if [[ $update_interval -eq 60 ]]; then
|
||||||
# Hourly updates
|
# Hourly updates starting at current minute
|
||||||
expected_crontab="0 * * * * /usr/local/bin/patchmon-agent.sh update >/dev/null 2>&1"
|
local current_minute=$(date +%M)
|
||||||
|
expected_crontab="$current_minute * * * * /usr/local/bin/patchmon-agent.sh update >/dev/null 2>&1"
|
||||||
else
|
else
|
||||||
# Custom interval updates
|
# Custom interval updates
|
||||||
expected_crontab="*/$update_interval * * * * /usr/local/bin/patchmon-agent.sh update >/dev/null 2>&1"
|
expected_crontab="*/$update_interval * * * * /usr/local/bin/patchmon-agent.sh update >/dev/null 2>&1"
|
||||||
|
|||||||
@@ -35,6 +35,40 @@ if [[ $EUID -ne 0 ]]; then
|
|||||||
error "This script must be run as root (use sudo)"
|
error "This script must be run as root (use sudo)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Install required dependencies
|
||||||
|
info "📦 Installing required dependencies..."
|
||||||
|
|
||||||
|
# Detect package manager and install jq
|
||||||
|
if command -v apt-get >/dev/null 2>&1; then
|
||||||
|
# Debian/Ubuntu
|
||||||
|
apt-get update >/dev/null 2>&1
|
||||||
|
apt-get install -y jq curl >/dev/null 2>&1
|
||||||
|
elif command -v yum >/dev/null 2>&1; then
|
||||||
|
# CentOS/RHEL 7
|
||||||
|
yum install -y jq curl >/dev/null 2>&1
|
||||||
|
elif command -v dnf >/dev/null 2>&1; then
|
||||||
|
# CentOS/RHEL 8+/Fedora
|
||||||
|
dnf install -y jq curl >/dev/null 2>&1
|
||||||
|
elif command -v zypper >/dev/null 2>&1; then
|
||||||
|
# openSUSE
|
||||||
|
zypper install -y jq curl >/dev/null 2>&1
|
||||||
|
elif command -v pacman >/dev/null 2>&1; then
|
||||||
|
# Arch Linux
|
||||||
|
pacman -S --noconfirm jq curl >/dev/null 2>&1
|
||||||
|
elif command -v apk >/dev/null 2>&1; then
|
||||||
|
# Alpine Linux
|
||||||
|
apk add --no-cache jq curl >/dev/null 2>&1
|
||||||
|
else
|
||||||
|
warning "Could not detect package manager. Please ensure 'jq' and 'curl' are installed manually."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Verify jq installation
|
||||||
|
if ! command -v jq >/dev/null 2>&1; then
|
||||||
|
error "Failed to install 'jq'. Please install it manually: https://stedolan.github.io/jq/download/"
|
||||||
|
fi
|
||||||
|
|
||||||
|
success "Dependencies installed successfully!"
|
||||||
|
|
||||||
# Default server URL (will be replaced by backend with configured URL)
|
# Default server URL (will be replaced by backend with configured URL)
|
||||||
PATCHMON_URL="http://localhost:3001"
|
PATCHMON_URL="http://localhost:3001"
|
||||||
|
|
||||||
@@ -137,6 +171,7 @@ fi
|
|||||||
success "🎉 PatchMon Agent installation complete!"
|
success "🎉 PatchMon Agent installation complete!"
|
||||||
echo ""
|
echo ""
|
||||||
echo "📋 Installation Summary:"
|
echo "📋 Installation Summary:"
|
||||||
|
echo " • Dependencies installed: jq, curl"
|
||||||
echo " • Agent installed: /usr/local/bin/patchmon-agent.sh"
|
echo " • Agent installed: /usr/local/bin/patchmon-agent.sh"
|
||||||
echo " • Agent version: $AGENT_VERSION"
|
echo " • Agent version: $AGENT_VERSION"
|
||||||
if [[ "$EXPECTED_VERSION" != "Unknown" ]]; then
|
if [[ "$EXPECTED_VERSION" != "Unknown" ]]; then
|
||||||
|
|||||||
@@ -9,9 +9,17 @@ NODE_ENV=development
|
|||||||
API_VERSION=v1
|
API_VERSION=v1
|
||||||
CORS_ORIGIN=http://localhost:3000
|
CORS_ORIGIN=http://localhost:3000
|
||||||
|
|
||||||
# Rate Limiting
|
# Rate Limiting (times in milliseconds)
|
||||||
RATE_LIMIT_WINDOW_MS=900000
|
RATE_LIMIT_WINDOW_MS=900000
|
||||||
RATE_LIMIT_MAX=100
|
RATE_LIMIT_MAX=5000
|
||||||
|
AUTH_RATE_LIMIT_WINDOW_MS=600000
|
||||||
|
AUTH_RATE_LIMIT_MAX=500
|
||||||
|
AGENT_RATE_LIMIT_WINDOW_MS=60000
|
||||||
|
AGENT_RATE_LIMIT_MAX=1000
|
||||||
|
|
||||||
# Logging
|
# Logging
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
|
ENABLE_LOGGING=true
|
||||||
|
|
||||||
|
# User Registration
|
||||||
|
DEFAULT_USER_ROLE=user
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "patchmon-backend",
|
"name": "patchmon-backend",
|
||||||
"version": "1.0.0",
|
"version": "1.2.6",
|
||||||
"description": "Backend API for Linux Patch Monitoring System",
|
"description": "Backend API for Linux Patch Monitoring System",
|
||||||
"main": "src/server.js",
|
"main": "src/server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -23,6 +23,8 @@
|
|||||||
"helmet": "^7.1.0",
|
"helmet": "^7.1.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"moment": "^2.30.1",
|
"moment": "^2.30.1",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
|
"speakeasy": "^2.0.0",
|
||||||
"uuid": "^9.0.1",
|
"uuid": "^9.0.1",
|
||||||
"winston": "^3.11.0"
|
"winston": "^3.11.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "settings" ADD COLUMN "ssh_key_path" TEXT;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "settings" ADD COLUMN "repository_type" TEXT NOT NULL DEFAULT 'public';
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "users" ADD COLUMN "tfa_backup_codes" TEXT,
|
||||||
|
ADD COLUMN "tfa_enabled" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
ADD COLUMN "tfa_secret" TEXT;
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "settings" ADD COLUMN "last_update_check" TIMESTAMP(3),
|
||||||
|
ADD COLUMN "latest_version" TEXT,
|
||||||
|
ADD COLUMN "update_available" BOOLEAN NOT NULL DEFAULT false;
|
||||||
2
backend/prisma/migrations/20250919165704_/migration.sql
Normal file
2
backend/prisma/migrations/20250919165704_/migration.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
-- RenameIndex
|
||||||
|
ALTER INDEX "hosts_hostname_key" RENAME TO "hosts_friendly_name_key";
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- Rename hostname column to friendly_name in hosts table
|
||||||
|
ALTER TABLE "hosts" RENAME COLUMN "hostname" TO "friendly_name";
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "hosts" ADD COLUMN "cpu_cores" INTEGER,
|
||||||
|
ADD COLUMN "cpu_model" TEXT,
|
||||||
|
ADD COLUMN "disk_details" JSONB,
|
||||||
|
ADD COLUMN "dns_servers" JSONB,
|
||||||
|
ADD COLUMN "gateway_ip" TEXT,
|
||||||
|
ADD COLUMN "hostname" TEXT,
|
||||||
|
ADD COLUMN "kernel_version" TEXT,
|
||||||
|
ADD COLUMN "load_average" JSONB,
|
||||||
|
ADD COLUMN "network_interfaces" JSONB,
|
||||||
|
ADD COLUMN "ram_installed" INTEGER,
|
||||||
|
ADD COLUMN "selinux_status" TEXT,
|
||||||
|
ADD COLUMN "swap_size" INTEGER,
|
||||||
|
ADD COLUMN "system_uptime" TEXT;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "settings" DROP COLUMN "frontend_url";
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "settings" ADD COLUMN "signup_enabled" BOOLEAN NOT NULL DEFAULT false;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "users" ADD COLUMN "first_name" TEXT,
|
||||||
|
ADD COLUMN "last_name" TEXT;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "settings" ADD COLUMN "default_user_role" TEXT NOT NULL DEFAULT 'user';
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
-- Initialize default dashboard preferences for all existing users
|
||||||
|
-- This migration ensures that all users have proper role-based dashboard preferences
|
||||||
|
|
||||||
|
-- Function to create default dashboard preferences for a user
|
||||||
|
CREATE OR REPLACE FUNCTION init_user_dashboard_preferences(user_id TEXT, user_role TEXT)
|
||||||
|
RETURNS VOID AS $$
|
||||||
|
DECLARE
|
||||||
|
pref_record RECORD;
|
||||||
|
BEGIN
|
||||||
|
-- Delete any existing preferences for this user
|
||||||
|
DELETE FROM dashboard_preferences WHERE dashboard_preferences.user_id = init_user_dashboard_preferences.user_id;
|
||||||
|
|
||||||
|
-- Insert role-based preferences
|
||||||
|
IF user_role = 'admin' THEN
|
||||||
|
-- Admin gets full access to all cards (iby's preferred layout)
|
||||||
|
INSERT INTO dashboard_preferences (id, user_id, card_id, enabled, "order", created_at, updated_at)
|
||||||
|
VALUES
|
||||||
|
(gen_random_uuid(), user_id, 'totalHosts', true, 0, NOW(), NOW()),
|
||||||
|
(gen_random_uuid(), user_id, 'hostsNeedingUpdates', true, 1, NOW(), NOW()),
|
||||||
|
(gen_random_uuid(), user_id, 'totalOutdatedPackages', true, 2, NOW(), NOW()),
|
||||||
|
(gen_random_uuid(), user_id, 'securityUpdates', true, 3, NOW(), NOW()),
|
||||||
|
(gen_random_uuid(), user_id, 'totalHostGroups', true, 4, NOW(), NOW()),
|
||||||
|
(gen_random_uuid(), user_id, 'upToDateHosts', true, 5, NOW(), NOW()),
|
||||||
|
(gen_random_uuid(), user_id, 'totalRepos', true, 6, NOW(), NOW()),
|
||||||
|
(gen_random_uuid(), user_id, 'totalUsers', true, 7, NOW(), NOW()),
|
||||||
|
(gen_random_uuid(), user_id, 'osDistribution', true, 8, NOW(), NOW()),
|
||||||
|
(gen_random_uuid(), user_id, 'osDistributionBar', true, 9, NOW(), NOW()),
|
||||||
|
(gen_random_uuid(), user_id, 'recentCollection', true, 10, NOW(), NOW()),
|
||||||
|
(gen_random_uuid(), user_id, 'updateStatus', true, 11, NOW(), NOW()),
|
||||||
|
(gen_random_uuid(), user_id, 'packagePriority', true, 12, NOW(), NOW()),
|
||||||
|
(gen_random_uuid(), user_id, 'recentUsers', true, 13, NOW(), NOW()),
|
||||||
|
(gen_random_uuid(), user_id, 'quickStats', true, 14, NOW(), NOW());
|
||||||
|
ELSE
|
||||||
|
-- Regular users get comprehensive layout but without user management cards
|
||||||
|
INSERT INTO dashboard_preferences (id, user_id, card_id, enabled, "order", created_at, updated_at)
|
||||||
|
VALUES
|
||||||
|
(gen_random_uuid(), user_id, 'totalHosts', true, 0, NOW(), NOW()),
|
||||||
|
(gen_random_uuid(), user_id, 'hostsNeedingUpdates', true, 1, NOW(), NOW()),
|
||||||
|
(gen_random_uuid(), user_id, 'totalOutdatedPackages', true, 2, NOW(), NOW()),
|
||||||
|
(gen_random_uuid(), user_id, 'securityUpdates', true, 3, NOW(), NOW()),
|
||||||
|
(gen_random_uuid(), user_id, 'totalHostGroups', true, 4, NOW(), NOW()),
|
||||||
|
(gen_random_uuid(), user_id, 'upToDateHosts', true, 5, NOW(), NOW()),
|
||||||
|
(gen_random_uuid(), user_id, 'totalRepos', true, 6, NOW(), NOW()),
|
||||||
|
(gen_random_uuid(), user_id, 'osDistribution', true, 7, NOW(), NOW()),
|
||||||
|
(gen_random_uuid(), user_id, 'osDistributionBar', true, 8, NOW(), NOW()),
|
||||||
|
(gen_random_uuid(), user_id, 'recentCollection', true, 9, NOW(), NOW()),
|
||||||
|
(gen_random_uuid(), user_id, 'updateStatus', true, 10, NOW(), NOW()),
|
||||||
|
(gen_random_uuid(), user_id, 'packagePriority', true, 11, NOW(), NOW()),
|
||||||
|
(gen_random_uuid(), user_id, 'quickStats', true, 12, NOW(), NOW());
|
||||||
|
END IF;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Apply default preferences to all existing users
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
user_record RECORD;
|
||||||
|
BEGIN
|
||||||
|
FOR user_record IN SELECT id, role FROM users LOOP
|
||||||
|
PERFORM init_user_dashboard_preferences(user_record.id, user_record.role);
|
||||||
|
END LOOP;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Drop the temporary function
|
||||||
|
DROP FUNCTION init_user_dashboard_preferences(TEXT, TEXT);
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
-- Remove dashboard preferences population
|
||||||
|
-- This migration clears all existing dashboard preferences so they can be recreated
|
||||||
|
-- with the correct default order by server.js initialization
|
||||||
|
|
||||||
|
-- Clear all existing dashboard preferences
|
||||||
|
-- This ensures users get the correct default order from server.js
|
||||||
|
DELETE FROM dashboard_preferences;
|
||||||
|
|
||||||
|
-- Recreate indexes for better performance
|
||||||
|
CREATE INDEX IF NOT EXISTS "dashboard_preferences_user_id_idx" ON "dashboard_preferences"("user_id");
|
||||||
|
CREATE INDEX IF NOT EXISTS "dashboard_preferences_card_id_idx" ON "dashboard_preferences"("card_id");
|
||||||
|
CREATE INDEX IF NOT EXISTS "dashboard_preferences_user_card_idx" ON "dashboard_preferences"("user_id", "card_id");
|
||||||
@@ -1,6 +1,3 @@
|
|||||||
// This is your Prisma schema file,
|
|
||||||
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
|
||||||
|
|
||||||
generator client {
|
generator client {
|
||||||
provider = "prisma-client-js"
|
provider = "prisma-client-js"
|
||||||
}
|
}
|
||||||
@@ -10,209 +7,195 @@ datasource db {
|
|||||||
url = env("DATABASE_URL")
|
url = env("DATABASE_URL")
|
||||||
}
|
}
|
||||||
|
|
||||||
model User {
|
model agent_versions {
|
||||||
id String @id @default(cuid())
|
id String @id
|
||||||
username String @unique
|
version String @unique
|
||||||
email String @unique
|
is_current Boolean @default(false)
|
||||||
passwordHash String @map("password_hash")
|
release_notes String?
|
||||||
role String @default("admin") // admin, user
|
download_url String?
|
||||||
isActive Boolean @default(true) @map("is_active")
|
min_server_version String?
|
||||||
lastLogin DateTime? @map("last_login")
|
created_at DateTime @default(now())
|
||||||
createdAt DateTime @map("created_at") @default(now())
|
updated_at DateTime
|
||||||
updatedAt DateTime @map("updated_at") @updatedAt
|
is_default Boolean @default(false)
|
||||||
|
script_content String?
|
||||||
// Relationships
|
|
||||||
dashboardPreferences DashboardPreferences[]
|
|
||||||
|
|
||||||
@@map("users")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
model RolePermissions {
|
model dashboard_preferences {
|
||||||
id String @id @default(cuid())
|
id String @id
|
||||||
role String @unique // admin, user, custom roles
|
user_id String
|
||||||
canViewDashboard Boolean @default(true) @map("can_view_dashboard")
|
card_id String
|
||||||
canViewHosts Boolean @default(true) @map("can_view_hosts")
|
|
||||||
canManageHosts Boolean @default(false) @map("can_manage_hosts")
|
|
||||||
canViewPackages Boolean @default(true) @map("can_view_packages")
|
|
||||||
canManagePackages Boolean @default(false) @map("can_manage_packages")
|
|
||||||
canViewUsers Boolean @default(false) @map("can_view_users")
|
|
||||||
canManageUsers Boolean @default(false) @map("can_manage_users")
|
|
||||||
canViewReports Boolean @default(true) @map("can_view_reports")
|
|
||||||
canExportData Boolean @default(false) @map("can_export_data")
|
|
||||||
canManageSettings Boolean @default(false) @map("can_manage_settings")
|
|
||||||
createdAt DateTime @map("created_at") @default(now())
|
|
||||||
updatedAt DateTime @map("updated_at") @updatedAt
|
|
||||||
|
|
||||||
@@map("role_permissions")
|
|
||||||
}
|
|
||||||
|
|
||||||
model HostGroup {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
name String @unique
|
|
||||||
description String?
|
|
||||||
color String? @default("#3B82F6") // Hex color for UI display
|
|
||||||
createdAt DateTime @map("created_at") @default(now())
|
|
||||||
updatedAt DateTime @map("updated_at") @updatedAt
|
|
||||||
|
|
||||||
// Relationships
|
|
||||||
hosts Host[]
|
|
||||||
|
|
||||||
@@map("host_groups")
|
|
||||||
}
|
|
||||||
|
|
||||||
model Host {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
hostname String @unique
|
|
||||||
ip String?
|
|
||||||
osType String @map("os_type")
|
|
||||||
osVersion String @map("os_version")
|
|
||||||
architecture String?
|
|
||||||
lastUpdate DateTime @map("last_update") @default(now())
|
|
||||||
status String @default("active") // active, inactive, error
|
|
||||||
apiId String @unique @map("api_id") // New API ID for authentication
|
|
||||||
apiKey String @unique @map("api_key") // New API Key for authentication
|
|
||||||
hostGroupId String? @map("host_group_id") // Optional group association
|
|
||||||
agentVersion String? @map("agent_version") // Agent script version
|
|
||||||
autoUpdate Boolean @map("auto_update") @default(true) // Enable auto-update for this host
|
|
||||||
createdAt DateTime @map("created_at") @default(now())
|
|
||||||
updatedAt DateTime @map("updated_at") @updatedAt
|
|
||||||
|
|
||||||
// Relationships
|
|
||||||
hostPackages HostPackage[]
|
|
||||||
updateHistory UpdateHistory[]
|
|
||||||
hostRepositories HostRepository[]
|
|
||||||
hostGroup HostGroup? @relation(fields: [hostGroupId], references: [id], onDelete: SetNull)
|
|
||||||
|
|
||||||
@@map("hosts")
|
|
||||||
}
|
|
||||||
|
|
||||||
model Package {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
name String @unique
|
|
||||||
description String?
|
|
||||||
category String? // system, security, development, etc.
|
|
||||||
latestVersion String? @map("latest_version")
|
|
||||||
createdAt DateTime @map("created_at") @default(now())
|
|
||||||
updatedAt DateTime @map("updated_at") @updatedAt
|
|
||||||
|
|
||||||
// Relationships
|
|
||||||
hostPackages HostPackage[]
|
|
||||||
|
|
||||||
@@map("packages")
|
|
||||||
}
|
|
||||||
|
|
||||||
model HostPackage {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
hostId String @map("host_id")
|
|
||||||
packageId String @map("package_id")
|
|
||||||
currentVersion String @map("current_version")
|
|
||||||
availableVersion String? @map("available_version")
|
|
||||||
needsUpdate Boolean @map("needs_update") @default(false)
|
|
||||||
isSecurityUpdate Boolean @map("is_security_update") @default(false)
|
|
||||||
lastChecked DateTime @map("last_checked") @default(now())
|
|
||||||
|
|
||||||
// Relationships
|
|
||||||
host Host @relation(fields: [hostId], references: [id], onDelete: Cascade)
|
|
||||||
package Package @relation(fields: [packageId], references: [id], onDelete: Cascade)
|
|
||||||
|
|
||||||
@@unique([hostId, packageId])
|
|
||||||
@@map("host_packages")
|
|
||||||
}
|
|
||||||
|
|
||||||
model UpdateHistory {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
hostId String @map("host_id")
|
|
||||||
packagesCount Int @map("packages_count")
|
|
||||||
securityCount Int @map("security_count")
|
|
||||||
timestamp DateTime @default(now())
|
|
||||||
status String @default("success") // success, error
|
|
||||||
errorMessage String? @map("error_message")
|
|
||||||
|
|
||||||
// Relationships
|
|
||||||
host Host @relation(fields: [hostId], references: [id], onDelete: Cascade)
|
|
||||||
|
|
||||||
@@map("update_history")
|
|
||||||
}
|
|
||||||
|
|
||||||
model Repository {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
name String // Repository name (e.g., "focal", "focal-updates")
|
|
||||||
url String // Repository URL
|
|
||||||
distribution String // Distribution (e.g., "focal", "jammy")
|
|
||||||
components String // Components (e.g., "main restricted universe multiverse")
|
|
||||||
repoType String @map("repo_type") // "deb" or "deb-src"
|
|
||||||
isActive Boolean @map("is_active") @default(true)
|
|
||||||
isSecure Boolean @map("is_secure") @default(true) // HTTPS vs HTTP
|
|
||||||
priority Int? // Repository priority
|
|
||||||
description String? // Optional description
|
|
||||||
createdAt DateTime @map("created_at") @default(now())
|
|
||||||
updatedAt DateTime @map("updated_at") @updatedAt
|
|
||||||
|
|
||||||
// Relationships
|
|
||||||
hostRepositories HostRepository[]
|
|
||||||
|
|
||||||
@@unique([url, distribution, components])
|
|
||||||
@@map("repositories")
|
|
||||||
}
|
|
||||||
|
|
||||||
model HostRepository {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
hostId String @map("host_id")
|
|
||||||
repositoryId String @map("repository_id")
|
|
||||||
isEnabled Boolean @map("is_enabled") @default(true)
|
|
||||||
lastChecked DateTime @map("last_checked") @default(now())
|
|
||||||
|
|
||||||
// Relationships
|
|
||||||
host Host @relation(fields: [hostId], references: [id], onDelete: Cascade)
|
|
||||||
repository Repository @relation(fields: [repositoryId], references: [id], onDelete: Cascade)
|
|
||||||
|
|
||||||
@@unique([hostId, repositoryId])
|
|
||||||
@@map("host_repositories")
|
|
||||||
}
|
|
||||||
|
|
||||||
model Settings {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
serverUrl String @map("server_url") @default("http://localhost:3001")
|
|
||||||
serverProtocol String @map("server_protocol") @default("http") // http, https
|
|
||||||
serverHost String @map("server_host") @default("localhost")
|
|
||||||
serverPort Int @map("server_port") @default(3001)
|
|
||||||
frontendUrl String @map("frontend_url") @default("http://localhost:3000")
|
|
||||||
updateInterval Int @map("update_interval") @default(60) // Update interval in minutes
|
|
||||||
autoUpdate Boolean @map("auto_update") @default(false) // Enable automatic agent updates
|
|
||||||
githubRepoUrl String @map("github_repo_url") @default("git@github.com:9technologygroup/patchmon.net.git") // GitHub repository URL for version checking
|
|
||||||
createdAt DateTime @map("created_at") @default(now())
|
|
||||||
updatedAt DateTime @map("updated_at") @updatedAt
|
|
||||||
|
|
||||||
@@map("settings")
|
|
||||||
}
|
|
||||||
|
|
||||||
model DashboardPreferences {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
userId String @map("user_id")
|
|
||||||
cardId String @map("card_id") // e.g., "totalHosts", "securityUpdates", etc.
|
|
||||||
enabled Boolean @default(true)
|
enabled Boolean @default(true)
|
||||||
order Int @default(0)
|
order Int @default(0)
|
||||||
createdAt DateTime @map("created_at") @default(now())
|
created_at DateTime @default(now())
|
||||||
updatedAt DateTime @map("updated_at") @updatedAt
|
updated_at DateTime
|
||||||
|
users users @relation(fields: [user_id], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
// Relationships
|
@@unique([user_id, card_id])
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
||||||
|
|
||||||
@@unique([userId, cardId])
|
|
||||||
@@map("dashboard_preferences")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
model AgentVersion {
|
model host_groups {
|
||||||
id String @id @default(cuid())
|
id String @id
|
||||||
version String @unique // e.g., "1.0.0", "1.1.0"
|
name String @unique
|
||||||
isCurrent Boolean @default(false) @map("is_current") // Only one version can be current
|
description String?
|
||||||
releaseNotes String? @map("release_notes")
|
color String? @default("#3B82F6")
|
||||||
downloadUrl String? @map("download_url") // URL to download the agent script
|
created_at DateTime @default(now())
|
||||||
minServerVersion String? @map("min_server_version") // Minimum server version required
|
updated_at DateTime
|
||||||
scriptContent String? @map("script_content") // The actual agent script content
|
hosts hosts[]
|
||||||
isDefault Boolean @default(false) @map("is_default") // Default version for new installations
|
}
|
||||||
createdAt DateTime @map("created_at") @default(now())
|
|
||||||
updatedAt DateTime @map("updated_at") @updatedAt
|
model host_packages {
|
||||||
|
id String @id
|
||||||
@@map("agent_versions")
|
host_id String
|
||||||
|
package_id String
|
||||||
|
current_version String
|
||||||
|
available_version String?
|
||||||
|
needs_update Boolean @default(false)
|
||||||
|
is_security_update Boolean @default(false)
|
||||||
|
last_checked DateTime @default(now())
|
||||||
|
hosts hosts @relation(fields: [host_id], references: [id], onDelete: Cascade)
|
||||||
|
packages packages @relation(fields: [package_id], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([host_id, package_id])
|
||||||
|
}
|
||||||
|
|
||||||
|
model host_repositories {
|
||||||
|
id String @id
|
||||||
|
host_id String
|
||||||
|
repository_id String
|
||||||
|
is_enabled Boolean @default(true)
|
||||||
|
last_checked DateTime @default(now())
|
||||||
|
hosts hosts @relation(fields: [host_id], references: [id], onDelete: Cascade)
|
||||||
|
repositories repositories @relation(fields: [repository_id], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([host_id, repository_id])
|
||||||
|
}
|
||||||
|
|
||||||
|
model hosts {
|
||||||
|
id String @id
|
||||||
|
friendly_name String @unique
|
||||||
|
ip String?
|
||||||
|
os_type String
|
||||||
|
os_version String
|
||||||
|
architecture String?
|
||||||
|
last_update DateTime @default(now())
|
||||||
|
status String @default("active")
|
||||||
|
created_at DateTime @default(now())
|
||||||
|
updated_at DateTime
|
||||||
|
api_id String @unique
|
||||||
|
api_key String @unique
|
||||||
|
host_group_id String?
|
||||||
|
agent_version String?
|
||||||
|
auto_update Boolean @default(true)
|
||||||
|
cpu_cores Int?
|
||||||
|
cpu_model String?
|
||||||
|
disk_details Json?
|
||||||
|
dns_servers Json?
|
||||||
|
gateway_ip String?
|
||||||
|
hostname String?
|
||||||
|
kernel_version String?
|
||||||
|
load_average Json?
|
||||||
|
network_interfaces Json?
|
||||||
|
ram_installed Int?
|
||||||
|
selinux_status String?
|
||||||
|
swap_size Int?
|
||||||
|
system_uptime String?
|
||||||
|
host_packages host_packages[]
|
||||||
|
host_repositories host_repositories[]
|
||||||
|
host_groups host_groups? @relation(fields: [host_group_id], references: [id])
|
||||||
|
update_history update_history[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model packages {
|
||||||
|
id String @id
|
||||||
|
name String @unique
|
||||||
|
description String?
|
||||||
|
category String?
|
||||||
|
latest_version String?
|
||||||
|
created_at DateTime @default(now())
|
||||||
|
updated_at DateTime
|
||||||
|
host_packages host_packages[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model repositories {
|
||||||
|
id String @id
|
||||||
|
name String
|
||||||
|
url String
|
||||||
|
distribution String
|
||||||
|
components String
|
||||||
|
repo_type String
|
||||||
|
is_active Boolean @default(true)
|
||||||
|
is_secure Boolean @default(true)
|
||||||
|
priority Int?
|
||||||
|
description String?
|
||||||
|
created_at DateTime @default(now())
|
||||||
|
updated_at DateTime
|
||||||
|
host_repositories host_repositories[]
|
||||||
|
|
||||||
|
@@unique([url, distribution, components])
|
||||||
|
}
|
||||||
|
|
||||||
|
model role_permissions {
|
||||||
|
id String @id
|
||||||
|
role String @unique
|
||||||
|
can_view_dashboard Boolean @default(true)
|
||||||
|
can_view_hosts Boolean @default(true)
|
||||||
|
can_manage_hosts Boolean @default(false)
|
||||||
|
can_view_packages Boolean @default(true)
|
||||||
|
can_manage_packages Boolean @default(false)
|
||||||
|
can_view_users Boolean @default(false)
|
||||||
|
can_manage_users Boolean @default(false)
|
||||||
|
can_view_reports Boolean @default(true)
|
||||||
|
can_export_data Boolean @default(false)
|
||||||
|
can_manage_settings Boolean @default(false)
|
||||||
|
created_at DateTime @default(now())
|
||||||
|
updated_at DateTime
|
||||||
|
}
|
||||||
|
|
||||||
|
model settings {
|
||||||
|
id String @id
|
||||||
|
server_url String @default("http://localhost:3001")
|
||||||
|
server_protocol String @default("http")
|
||||||
|
server_host String @default("localhost")
|
||||||
|
server_port Int @default(3001)
|
||||||
|
created_at DateTime @default(now())
|
||||||
|
updated_at DateTime
|
||||||
|
update_interval Int @default(60)
|
||||||
|
auto_update Boolean @default(false)
|
||||||
|
github_repo_url String @default("git@github.com:9technologygroup/patchmon.net.git")
|
||||||
|
ssh_key_path String?
|
||||||
|
repository_type String @default("public")
|
||||||
|
last_update_check DateTime?
|
||||||
|
latest_version String?
|
||||||
|
update_available Boolean @default(false)
|
||||||
|
signup_enabled Boolean @default(false)
|
||||||
|
default_user_role String @default("user")
|
||||||
|
}
|
||||||
|
|
||||||
|
model update_history {
|
||||||
|
id String @id
|
||||||
|
host_id String
|
||||||
|
packages_count Int
|
||||||
|
security_count Int
|
||||||
|
timestamp DateTime @default(now())
|
||||||
|
status String @default("success")
|
||||||
|
error_message String?
|
||||||
|
hosts hosts @relation(fields: [host_id], references: [id], onDelete: Cascade)
|
||||||
|
}
|
||||||
|
|
||||||
|
model users {
|
||||||
|
id String @id
|
||||||
|
username String @unique
|
||||||
|
email String @unique
|
||||||
|
password_hash String
|
||||||
|
first_name String?
|
||||||
|
last_name String?
|
||||||
|
role String @default("admin")
|
||||||
|
is_active Boolean @default(true)
|
||||||
|
last_login DateTime?
|
||||||
|
created_at DateTime @default(now())
|
||||||
|
updated_at DateTime
|
||||||
|
tfa_backup_codes String?
|
||||||
|
tfa_enabled Boolean @default(false)
|
||||||
|
tfa_secret String?
|
||||||
|
dashboard_preferences dashboard_preferences[]
|
||||||
}
|
}
|
||||||
108
backend/src/config/database.js
Normal file
108
backend/src/config/database.js
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
/**
|
||||||
|
* Database configuration for multiple instances
|
||||||
|
* Optimizes connection pooling to prevent "too many connections" errors
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { PrismaClient } = require('@prisma/client');
|
||||||
|
|
||||||
|
// Parse DATABASE_URL and add connection pooling parameters
|
||||||
|
function getOptimizedDatabaseUrl() {
|
||||||
|
const originalUrl = process.env.DATABASE_URL;
|
||||||
|
|
||||||
|
if (!originalUrl) {
|
||||||
|
throw new Error('DATABASE_URL environment variable is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the URL
|
||||||
|
const url = new URL(originalUrl);
|
||||||
|
|
||||||
|
// Add connection pooling parameters for multiple instances
|
||||||
|
url.searchParams.set('connection_limit', '5'); // Reduced from default 10
|
||||||
|
url.searchParams.set('pool_timeout', '10'); // 10 seconds
|
||||||
|
url.searchParams.set('connect_timeout', '10'); // 10 seconds
|
||||||
|
url.searchParams.set('idle_timeout', '300'); // 5 minutes
|
||||||
|
url.searchParams.set('max_lifetime', '1800'); // 30 minutes
|
||||||
|
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create optimized Prisma client
|
||||||
|
function createPrismaClient() {
|
||||||
|
const optimizedUrl = getOptimizedDatabaseUrl();
|
||||||
|
|
||||||
|
return new PrismaClient({
|
||||||
|
datasources: {
|
||||||
|
db: {
|
||||||
|
url: optimizedUrl
|
||||||
|
}
|
||||||
|
},
|
||||||
|
log: process.env.NODE_ENV === 'development'
|
||||||
|
? ['query', 'info', 'warn', 'error']
|
||||||
|
: ['warn', 'error'],
|
||||||
|
errorFormat: 'pretty'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connection health check
|
||||||
|
async function checkDatabaseConnection(prisma) {
|
||||||
|
try {
|
||||||
|
await prisma.$queryRaw`SELECT 1`;
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Database connection failed:', error.message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for database to be available with retry logic
|
||||||
|
async function waitForDatabase(prisma, options = {}) {
|
||||||
|
const maxAttempts = options.maxAttempts || parseInt(process.env.PM_DB_CONN_MAX_ATTEMPTS) || 30;
|
||||||
|
const waitInterval = options.waitInterval || parseInt(process.env.PM_DB_CONN_WAIT_INTERVAL) || 2;
|
||||||
|
|
||||||
|
console.log(`Waiting for database connection (max ${maxAttempts} attempts, ${waitInterval}s interval)...`);
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||||
|
try {
|
||||||
|
const isConnected = await checkDatabaseConnection(prisma);
|
||||||
|
if (isConnected) {
|
||||||
|
console.log(`Database connected successfully after ${attempt} attempt(s)`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// checkDatabaseConnection already logs the error
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempt < maxAttempts) {
|
||||||
|
console.log(`⏳ Database not ready (attempt ${attempt}/${maxAttempts}), retrying in ${waitInterval}s...`);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, waitInterval * 1000));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`❌ Database failed to become available after ${maxAttempts} attempts`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Graceful disconnect with retry
|
||||||
|
async function disconnectPrisma(prisma, maxRetries = 3) {
|
||||||
|
for (let i = 0; i < maxRetries; i++) {
|
||||||
|
try {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
console.log('Database disconnected successfully');
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Disconnect attempt ${i + 1} failed:`, error.message);
|
||||||
|
if (i === maxRetries - 1) {
|
||||||
|
console.error('Failed to disconnect from database after all retries');
|
||||||
|
} else {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1000)); // Wait 1 second
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
createPrismaClient,
|
||||||
|
checkDatabaseConnection,
|
||||||
|
waitForDatabase,
|
||||||
|
disconnectPrisma,
|
||||||
|
getOptimizedDatabaseUrl
|
||||||
|
};
|
||||||
@@ -17,26 +17,31 @@ const authenticateToken = async (req, res, next) => {
|
|||||||
const decoded = jwt.verify(token, process.env.JWT_SECRET || 'your-secret-key');
|
const decoded = jwt.verify(token, process.env.JWT_SECRET || 'your-secret-key');
|
||||||
|
|
||||||
// Get user from database
|
// Get user from database
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.users.findUnique({
|
||||||
where: { id: decoded.userId },
|
where: { id: decoded.userId },
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
username: true,
|
username: true,
|
||||||
email: true,
|
email: true,
|
||||||
role: true,
|
role: true,
|
||||||
isActive: true,
|
is_active: true,
|
||||||
lastLogin: true
|
last_login: true,
|
||||||
|
created_at: true,
|
||||||
|
updated_at: true
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!user || !user.isActive) {
|
if (!user || !user.is_active) {
|
||||||
return res.status(401).json({ error: 'Invalid or inactive user' });
|
return res.status(401).json({ error: 'Invalid or inactive user' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update last login
|
// Update last login
|
||||||
await prisma.user.update({
|
await prisma.users.update({
|
||||||
where: { id: user.id },
|
where: { id: user.id },
|
||||||
data: { lastLogin: new Date() }
|
data: {
|
||||||
|
last_login: new Date(),
|
||||||
|
updated_at: new Date()
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
req.user = user;
|
req.user = user;
|
||||||
@@ -69,18 +74,21 @@ const optionalAuth = async (req, res, next) => {
|
|||||||
|
|
||||||
if (token) {
|
if (token) {
|
||||||
const decoded = jwt.verify(token, process.env.JWT_SECRET || 'your-secret-key');
|
const decoded = jwt.verify(token, process.env.JWT_SECRET || 'your-secret-key');
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.users.findUnique({
|
||||||
where: { id: decoded.userId },
|
where: { id: decoded.userId },
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
username: true,
|
username: true,
|
||||||
email: true,
|
email: true,
|
||||||
role: true,
|
role: true,
|
||||||
isActive: true
|
is_active: true,
|
||||||
|
last_login: true,
|
||||||
|
created_at: true,
|
||||||
|
updated_at: true
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (user && user.isActive) {
|
if (user && user.is_active) {
|
||||||
req.user = user;
|
req.user = user;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ const requirePermission = (permission) => {
|
|||||||
return async (req, res, next) => {
|
return async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
// Get user's role permissions
|
// Get user's role permissions
|
||||||
const rolePermissions = await prisma.rolePermissions.findUnique({
|
const rolePermissions = await prisma.role_permissions.findUnique({
|
||||||
where: { role: req.user.role }
|
where: { role: req.user.role }
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -20,7 +20,7 @@ const requirePermission = (permission) => {
|
|||||||
if (!rolePermissions[permission]) {
|
if (!rolePermissions[permission]) {
|
||||||
return res.status(403).json({
|
return res.status(403).json({
|
||||||
error: 'Insufficient permissions',
|
error: 'Insufficient permissions',
|
||||||
message: `You don't have permission to ${permission.replace('can', '').toLowerCase()}`
|
message: `You don't have permission to ${permission.replace('can_', '').replace('_', ' ')}`
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,17 +32,17 @@ const requirePermission = (permission) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
// Specific permission middlewares
|
// Specific permission middlewares - using snake_case field names
|
||||||
const requireViewDashboard = requirePermission('canViewDashboard');
|
const requireViewDashboard = requirePermission('can_view_dashboard');
|
||||||
const requireViewHosts = requirePermission('canViewHosts');
|
const requireViewHosts = requirePermission('can_view_hosts');
|
||||||
const requireManageHosts = requirePermission('canManageHosts');
|
const requireManageHosts = requirePermission('can_manage_hosts');
|
||||||
const requireViewPackages = requirePermission('canViewPackages');
|
const requireViewPackages = requirePermission('can_view_packages');
|
||||||
const requireManagePackages = requirePermission('canManagePackages');
|
const requireManagePackages = requirePermission('can_manage_packages');
|
||||||
const requireViewUsers = requirePermission('canViewUsers');
|
const requireViewUsers = requirePermission('can_view_users');
|
||||||
const requireManageUsers = requirePermission('canManageUsers');
|
const requireManageUsers = requirePermission('can_manage_users');
|
||||||
const requireViewReports = requirePermission('canViewReports');
|
const requireViewReports = requirePermission('can_view_reports');
|
||||||
const requireExportData = requirePermission('canExportData');
|
const requireExportData = requirePermission('can_export_data');
|
||||||
const requireManageSettings = requirePermission('canManageSettings');
|
const requireManageSettings = requirePermission('can_manage_settings');
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
requirePermission,
|
requirePermission,
|
||||||
|
|||||||
@@ -5,10 +5,120 @@ const { PrismaClient } = require('@prisma/client');
|
|||||||
const { body, validationResult } = require('express-validator');
|
const { body, validationResult } = require('express-validator');
|
||||||
const { authenticateToken, requireAdmin } = require('../middleware/auth');
|
const { authenticateToken, requireAdmin } = require('../middleware/auth');
|
||||||
const { requireViewUsers, requireManageUsers } = require('../middleware/permissions');
|
const { requireViewUsers, requireManageUsers } = require('../middleware/permissions');
|
||||||
|
const { v4: uuidv4 } = require('uuid');
|
||||||
|
const { createDefaultDashboardPreferences } = require('./dashboardPreferencesRoutes');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
// Check if any admin users exist (for first-time setup)
|
||||||
|
router.get('/check-admin-users', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const adminCount = await prisma.users.count({
|
||||||
|
where: { role: 'admin' }
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
hasAdminUsers: adminCount > 0,
|
||||||
|
adminCount: adminCount
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error checking admin users:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
error: 'Failed to check admin users',
|
||||||
|
hasAdminUsers: true // Assume admin exists for security
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create first admin user (for first-time setup)
|
||||||
|
router.post('/setup-admin', [
|
||||||
|
body('firstName').isLength({ min: 1 }).withMessage('First name is required'),
|
||||||
|
body('lastName').isLength({ min: 1 }).withMessage('Last name is required'),
|
||||||
|
body('username').isLength({ min: 1 }).withMessage('Username is required'),
|
||||||
|
body('email').isEmail().withMessage('Valid email is required'),
|
||||||
|
body('password').isLength({ min: 8 }).withMessage('Password must be at least 8 characters for security')
|
||||||
|
], async (req, res) => {
|
||||||
|
try {
|
||||||
|
const errors = validationResult(req);
|
||||||
|
if (!errors.isEmpty()) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Validation failed',
|
||||||
|
details: errors.array()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { firstName, lastName, username, email, password } = req.body;
|
||||||
|
|
||||||
|
// Check if any admin users already exist
|
||||||
|
const adminCount = await prisma.users.count({
|
||||||
|
where: { role: 'admin' }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (adminCount > 0) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Admin users already exist. This endpoint is only for first-time setup.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if username or email already exists
|
||||||
|
const existingUser = await prisma.users.findFirst({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ username: username.trim() },
|
||||||
|
{ email: email.trim() }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingUser) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Username or email already exists'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash password
|
||||||
|
const passwordHash = await bcrypt.hash(password, 12);
|
||||||
|
|
||||||
|
// Create admin user
|
||||||
|
const user = await prisma.users.create({
|
||||||
|
data: {
|
||||||
|
id: uuidv4(),
|
||||||
|
username: username.trim(),
|
||||||
|
email: email.trim(),
|
||||||
|
password_hash: passwordHash,
|
||||||
|
first_name: firstName.trim(),
|
||||||
|
last_name: lastName.trim(),
|
||||||
|
role: 'admin',
|
||||||
|
is_active: true,
|
||||||
|
created_at: new Date(),
|
||||||
|
updated_at: new Date()
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
email: true,
|
||||||
|
role: true,
|
||||||
|
created_at: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create default dashboard preferences for the new admin user
|
||||||
|
await createDefaultDashboardPreferences(user.id, 'admin');
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
message: 'Admin user created successfully',
|
||||||
|
user: user
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating admin user:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
error: 'Failed to create admin user'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Generate JWT token
|
// Generate JWT token
|
||||||
const generateToken = (userId) => {
|
const generateToken = (userId) => {
|
||||||
return jwt.sign(
|
return jwt.sign(
|
||||||
@@ -21,19 +131,19 @@ const generateToken = (userId) => {
|
|||||||
// Admin endpoint to list all users
|
// Admin endpoint to list all users
|
||||||
router.get('/admin/users', authenticateToken, requireViewUsers, async (req, res) => {
|
router.get('/admin/users', authenticateToken, requireViewUsers, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const users = await prisma.user.findMany({
|
const users = await prisma.users.findMany({
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
username: true,
|
username: true,
|
||||||
email: true,
|
email: true,
|
||||||
role: true,
|
role: true,
|
||||||
isActive: true,
|
is_active: true,
|
||||||
lastLogin: true,
|
last_login: true,
|
||||||
createdAt: true,
|
created_at: true,
|
||||||
updatedAt: true
|
updated_at: true
|
||||||
},
|
},
|
||||||
orderBy: {
|
orderBy: {
|
||||||
createdAt: 'desc'
|
created_at: 'desc'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -49,9 +159,14 @@ router.post('/admin/users', authenticateToken, requireManageUsers, [
|
|||||||
body('username').isLength({ min: 3 }).withMessage('Username must be at least 3 characters'),
|
body('username').isLength({ min: 3 }).withMessage('Username must be at least 3 characters'),
|
||||||
body('email').isEmail().withMessage('Valid email is required'),
|
body('email').isEmail().withMessage('Valid email is required'),
|
||||||
body('password').isLength({ min: 6 }).withMessage('Password must be at least 6 characters'),
|
body('password').isLength({ min: 6 }).withMessage('Password must be at least 6 characters'),
|
||||||
|
body('first_name').optional().isLength({ min: 1 }).withMessage('First name must be at least 1 character'),
|
||||||
|
body('last_name').optional().isLength({ min: 1 }).withMessage('Last name must be at least 1 character'),
|
||||||
body('role').optional().custom(async (value) => {
|
body('role').optional().custom(async (value) => {
|
||||||
if (!value) return true; // Optional field
|
if (!value) return true; // Optional field
|
||||||
const rolePermissions = await prisma.rolePermissions.findUnique({
|
// Allow built-in roles even if not in role_permissions table yet
|
||||||
|
const builtInRoles = ['admin', 'user'];
|
||||||
|
if (builtInRoles.includes(value)) return true;
|
||||||
|
const rolePermissions = await prisma.role_permissions.findUnique({
|
||||||
where: { role: value }
|
where: { role: value }
|
||||||
});
|
});
|
||||||
if (!rolePermissions) {
|
if (!rolePermissions) {
|
||||||
@@ -66,10 +181,17 @@ router.post('/admin/users', authenticateToken, requireManageUsers, [
|
|||||||
return res.status(400).json({ errors: errors.array() });
|
return res.status(400).json({ errors: errors.array() });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { username, email, password, role = 'user' } = req.body;
|
const { username, email, password, first_name, last_name, role } = req.body;
|
||||||
|
|
||||||
|
// Get default user role from settings if no role specified
|
||||||
|
let userRole = role;
|
||||||
|
if (!userRole) {
|
||||||
|
const settings = await prisma.settings.findFirst();
|
||||||
|
userRole = settings?.default_user_role || 'user';
|
||||||
|
}
|
||||||
|
|
||||||
// Check if user already exists
|
// Check if user already exists
|
||||||
const existingUser = await prisma.user.findFirst({
|
const existingUser = await prisma.users.findFirst({
|
||||||
where: {
|
where: {
|
||||||
OR: [
|
OR: [
|
||||||
{ username },
|
{ username },
|
||||||
@@ -86,23 +208,32 @@ router.post('/admin/users', authenticateToken, requireManageUsers, [
|
|||||||
const passwordHash = await bcrypt.hash(password, 12);
|
const passwordHash = await bcrypt.hash(password, 12);
|
||||||
|
|
||||||
// Create user
|
// Create user
|
||||||
const user = await prisma.user.create({
|
const user = await prisma.users.create({
|
||||||
data: {
|
data: {
|
||||||
|
id: uuidv4(),
|
||||||
username,
|
username,
|
||||||
email,
|
email,
|
||||||
passwordHash,
|
password_hash: passwordHash,
|
||||||
role
|
first_name: first_name || null,
|
||||||
|
last_name: last_name || null,
|
||||||
|
role: userRole,
|
||||||
|
updated_at: new Date()
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
username: true,
|
username: true,
|
||||||
email: true,
|
email: true,
|
||||||
|
first_name: true,
|
||||||
|
last_name: true,
|
||||||
role: true,
|
role: true,
|
||||||
isActive: true,
|
is_active: true,
|
||||||
createdAt: true
|
created_at: true
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Create default dashboard preferences for the new user
|
||||||
|
await createDefaultDashboardPreferences(user.id, userRole);
|
||||||
|
|
||||||
res.status(201).json({
|
res.status(201).json({
|
||||||
message: 'User created successfully',
|
message: 'User created successfully',
|
||||||
user
|
user
|
||||||
@@ -119,7 +250,7 @@ router.put('/admin/users/:userId', authenticateToken, requireManageUsers, [
|
|||||||
body('email').optional().isEmail().withMessage('Valid email is required'),
|
body('email').optional().isEmail().withMessage('Valid email is required'),
|
||||||
body('role').optional().custom(async (value) => {
|
body('role').optional().custom(async (value) => {
|
||||||
if (!value) return true; // Optional field
|
if (!value) return true; // Optional field
|
||||||
const rolePermissions = await prisma.rolePermissions.findUnique({
|
const rolePermissions = await prisma.role_permissions.findUnique({
|
||||||
where: { role: value }
|
where: { role: value }
|
||||||
});
|
});
|
||||||
if (!rolePermissions) {
|
if (!rolePermissions) {
|
||||||
@@ -143,10 +274,10 @@ router.put('/admin/users/:userId', authenticateToken, requireManageUsers, [
|
|||||||
if (username) updateData.username = username;
|
if (username) updateData.username = username;
|
||||||
if (email) updateData.email = email;
|
if (email) updateData.email = email;
|
||||||
if (role) updateData.role = role;
|
if (role) updateData.role = role;
|
||||||
if (typeof isActive === 'boolean') updateData.isActive = isActive;
|
if (typeof isActive === 'boolean') updateData.is_active = isActive;
|
||||||
|
|
||||||
// Check if user exists
|
// Check if user exists
|
||||||
const existingUser = await prisma.user.findUnique({
|
const existingUser = await prisma.users.findUnique({
|
||||||
where: { id: userId }
|
where: { id: userId }
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -156,7 +287,7 @@ router.put('/admin/users/:userId', authenticateToken, requireManageUsers, [
|
|||||||
|
|
||||||
// Check if username/email already exists (excluding current user)
|
// Check if username/email already exists (excluding current user)
|
||||||
if (username || email) {
|
if (username || email) {
|
||||||
const duplicateUser = await prisma.user.findFirst({
|
const duplicateUser = await prisma.users.findFirst({
|
||||||
where: {
|
where: {
|
||||||
AND: [
|
AND: [
|
||||||
{ id: { not: userId } },
|
{ id: { not: userId } },
|
||||||
@@ -177,10 +308,10 @@ router.put('/admin/users/:userId', authenticateToken, requireManageUsers, [
|
|||||||
|
|
||||||
// Prevent deactivating the last admin
|
// Prevent deactivating the last admin
|
||||||
if (isActive === false && existingUser.role === 'admin') {
|
if (isActive === false && existingUser.role === 'admin') {
|
||||||
const adminCount = await prisma.user.count({
|
const adminCount = await prisma.users.count({
|
||||||
where: {
|
where: {
|
||||||
role: 'admin',
|
role: 'admin',
|
||||||
isActive: true
|
is_active: true
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -190,7 +321,7 @@ router.put('/admin/users/:userId', authenticateToken, requireManageUsers, [
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update user
|
// Update user
|
||||||
const updatedUser = await prisma.user.update({
|
const updatedUser = await prisma.users.update({
|
||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
data: updateData,
|
data: updateData,
|
||||||
select: {
|
select: {
|
||||||
@@ -198,10 +329,10 @@ router.put('/admin/users/:userId', authenticateToken, requireManageUsers, [
|
|||||||
username: true,
|
username: true,
|
||||||
email: true,
|
email: true,
|
||||||
role: true,
|
role: true,
|
||||||
isActive: true,
|
is_active: true,
|
||||||
lastLogin: true,
|
last_login: true,
|
||||||
createdAt: true,
|
created_at: true,
|
||||||
updatedAt: true
|
updated_at: true
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -226,7 +357,7 @@ router.delete('/admin/users/:userId', authenticateToken, requireManageUsers, asy
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if user exists
|
// Check if user exists
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.users.findUnique({
|
||||||
where: { id: userId }
|
where: { id: userId }
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -236,10 +367,10 @@ router.delete('/admin/users/:userId', authenticateToken, requireManageUsers, asy
|
|||||||
|
|
||||||
// Prevent deleting the last admin
|
// Prevent deleting the last admin
|
||||||
if (user.role === 'admin') {
|
if (user.role === 'admin') {
|
||||||
const adminCount = await prisma.user.count({
|
const adminCount = await prisma.users.count({
|
||||||
where: {
|
where: {
|
||||||
role: 'admin',
|
role: 'admin',
|
||||||
isActive: true
|
is_active: true
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -249,7 +380,7 @@ router.delete('/admin/users/:userId', authenticateToken, requireManageUsers, asy
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Delete user
|
// Delete user
|
||||||
await prisma.user.delete({
|
await prisma.users.delete({
|
||||||
where: { id: userId }
|
where: { id: userId }
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -277,14 +408,14 @@ router.post('/admin/users/:userId/reset-password', authenticateToken, requireMan
|
|||||||
const { newPassword } = req.body;
|
const { newPassword } = req.body;
|
||||||
|
|
||||||
// Check if user exists
|
// Check if user exists
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.users.findUnique({
|
||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
username: true,
|
username: true,
|
||||||
email: true,
|
email: true,
|
||||||
role: true,
|
role: true,
|
||||||
isActive: true
|
is_active: true
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -293,7 +424,7 @@ router.post('/admin/users/:userId/reset-password', authenticateToken, requireMan
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Prevent resetting password of inactive users
|
// Prevent resetting password of inactive users
|
||||||
if (!user.isActive) {
|
if (!user.is_active) {
|
||||||
return res.status(400).json({ error: 'Cannot reset password for inactive user' });
|
return res.status(400).json({ error: 'Cannot reset password for inactive user' });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -301,9 +432,9 @@ router.post('/admin/users/:userId/reset-password', authenticateToken, requireMan
|
|||||||
const passwordHash = await bcrypt.hash(newPassword, 12);
|
const passwordHash = await bcrypt.hash(newPassword, 12);
|
||||||
|
|
||||||
// Update user password
|
// Update user password
|
||||||
await prisma.user.update({
|
await prisma.users.update({
|
||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
data: { passwordHash }
|
data: { password_hash: passwordHash }
|
||||||
});
|
});
|
||||||
|
|
||||||
// Log the password reset action (you might want to add an audit log table)
|
// Log the password reset action (you might want to add an audit log table)
|
||||||
@@ -323,6 +454,107 @@ router.post('/admin/users/:userId/reset-password', authenticateToken, requireMan
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Check if signup is enabled (public endpoint)
|
||||||
|
router.get('/signup-enabled', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const settings = await prisma.settings.findFirst();
|
||||||
|
res.json({ signupEnabled: settings?.signup_enabled || false });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error checking signup status:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to check signup status' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Public signup endpoint
|
||||||
|
router.post('/signup', [
|
||||||
|
body('firstName').isLength({ min: 1 }).withMessage('First name is required'),
|
||||||
|
body('lastName').isLength({ min: 1 }).withMessage('Last name is required'),
|
||||||
|
body('username').isLength({ min: 3 }).withMessage('Username must be at least 3 characters'),
|
||||||
|
body('email').isEmail().withMessage('Valid email is required'),
|
||||||
|
body('password').isLength({ min: 6 }).withMessage('Password must be at least 6 characters')
|
||||||
|
], async (req, res) => {
|
||||||
|
try {
|
||||||
|
// Check if signup is enabled
|
||||||
|
const settings = await prisma.settings.findFirst();
|
||||||
|
if (!settings?.signup_enabled) {
|
||||||
|
return res.status(403).json({ error: 'User signup is currently disabled' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const errors = validationResult(req);
|
||||||
|
if (!errors.isEmpty()) {
|
||||||
|
return res.status(400).json({ errors: errors.array() });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { firstName, lastName, username, email, password } = req.body;
|
||||||
|
|
||||||
|
// Check if user already exists
|
||||||
|
const existingUser = await prisma.users.findFirst({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ username },
|
||||||
|
{ email }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingUser) {
|
||||||
|
return res.status(409).json({ error: 'Username or email already exists' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash password
|
||||||
|
const passwordHash = await bcrypt.hash(password, 12);
|
||||||
|
|
||||||
|
// Get default user role from settings or environment variable
|
||||||
|
const defaultRole = settings?.default_user_role || process.env.DEFAULT_USER_ROLE || 'user';
|
||||||
|
|
||||||
|
// Create user with default role from settings
|
||||||
|
const user = await prisma.users.create({
|
||||||
|
data: {
|
||||||
|
id: uuidv4(),
|
||||||
|
username,
|
||||||
|
email,
|
||||||
|
password_hash: passwordHash,
|
||||||
|
first_name: firstName.trim(),
|
||||||
|
last_name: lastName.trim(),
|
||||||
|
role: defaultRole,
|
||||||
|
updated_at: new Date()
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
email: true,
|
||||||
|
role: true,
|
||||||
|
is_active: true,
|
||||||
|
created_at: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create default dashboard preferences for the new user
|
||||||
|
await createDefaultDashboardPreferences(user.id, defaultRole);
|
||||||
|
|
||||||
|
console.log(`New user registered: ${user.username} (${user.email})`);
|
||||||
|
|
||||||
|
// Generate token for immediate login
|
||||||
|
const token = generateToken(user.id);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
message: 'Account created successfully',
|
||||||
|
token,
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
email: user.email,
|
||||||
|
role: user.role
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Signup error:', error);
|
||||||
|
console.error('Signup error message:', error.message);
|
||||||
|
console.error('Signup error stack:', error.stack);
|
||||||
|
res.status(500).json({ error: 'Failed to create account' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Login
|
// Login
|
||||||
router.post('/login', [
|
router.post('/login', [
|
||||||
body('username').notEmpty().withMessage('Username is required'),
|
body('username').notEmpty().withMessage('Username is required'),
|
||||||
@@ -337,13 +569,27 @@ router.post('/login', [
|
|||||||
const { username, password } = req.body;
|
const { username, password } = req.body;
|
||||||
|
|
||||||
// Find user by username or email
|
// Find user by username or email
|
||||||
const user = await prisma.user.findFirst({
|
const user = await prisma.users.findFirst({
|
||||||
where: {
|
where: {
|
||||||
OR: [
|
OR: [
|
||||||
{ username },
|
{ username },
|
||||||
{ email: username }
|
{ email: username }
|
||||||
],
|
],
|
||||||
isActive: true
|
is_active: true
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
email: true,
|
||||||
|
first_name: true,
|
||||||
|
last_name: true,
|
||||||
|
password_hash: true,
|
||||||
|
role: true,
|
||||||
|
is_active: true,
|
||||||
|
last_login: true,
|
||||||
|
created_at: true,
|
||||||
|
updated_at: true,
|
||||||
|
tfa_enabled: true
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -352,15 +598,27 @@ router.post('/login', [
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Verify password
|
// Verify password
|
||||||
const isValidPassword = await bcrypt.compare(password, user.passwordHash);
|
const isValidPassword = await bcrypt.compare(password, user.password_hash);
|
||||||
if (!isValidPassword) {
|
if (!isValidPassword) {
|
||||||
return res.status(401).json({ error: 'Invalid credentials' });
|
return res.status(401).json({ error: 'Invalid credentials' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if TFA is enabled
|
||||||
|
if (user.tfa_enabled) {
|
||||||
|
return res.status(200).json({
|
||||||
|
message: 'TFA verification required',
|
||||||
|
requiresTfa: true,
|
||||||
|
username: user.username
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Update last login
|
// Update last login
|
||||||
await prisma.user.update({
|
await prisma.users.update({
|
||||||
where: { id: user.id },
|
where: { id: user.id },
|
||||||
data: { lastLogin: new Date() }
|
data: {
|
||||||
|
last_login: new Date(),
|
||||||
|
updated_at: new Date()
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Generate token
|
// Generate token
|
||||||
@@ -373,7 +631,11 @@ router.post('/login', [
|
|||||||
id: user.id,
|
id: user.id,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
role: user.role
|
role: user.role,
|
||||||
|
is_active: user.is_active,
|
||||||
|
last_login: user.last_login,
|
||||||
|
created_at: user.created_at,
|
||||||
|
updated_at: user.updated_at
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -382,6 +644,104 @@ router.post('/login', [
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// TFA verification for login
|
||||||
|
router.post('/verify-tfa', [
|
||||||
|
body('username').notEmpty().withMessage('Username is required'),
|
||||||
|
body('token').isLength({ min: 6, max: 6 }).withMessage('Token must be 6 digits'),
|
||||||
|
body('token').isNumeric().withMessage('Token must contain only numbers')
|
||||||
|
], async (req, res) => {
|
||||||
|
try {
|
||||||
|
const errors = validationResult(req);
|
||||||
|
if (!errors.isEmpty()) {
|
||||||
|
return res.status(400).json({ errors: errors.array() });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { username, token } = req.body;
|
||||||
|
|
||||||
|
// Find user
|
||||||
|
const user = await prisma.users.findFirst({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ username },
|
||||||
|
{ email: username }
|
||||||
|
],
|
||||||
|
is_active: true,
|
||||||
|
tfa_enabled: true
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
email: true,
|
||||||
|
role: true,
|
||||||
|
tfa_secret: true,
|
||||||
|
tfa_backup_codes: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return res.status(401).json({ error: 'Invalid credentials or TFA not enabled' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify TFA token using the TFA routes logic
|
||||||
|
const speakeasy = require('speakeasy');
|
||||||
|
|
||||||
|
// Check if it's a backup code
|
||||||
|
const backupCodes = user.tfa_backup_codes ? JSON.parse(user.tfa_backup_codes) : [];
|
||||||
|
const isBackupCode = backupCodes.includes(token);
|
||||||
|
|
||||||
|
let verified = false;
|
||||||
|
|
||||||
|
if (isBackupCode) {
|
||||||
|
// Remove the used backup code
|
||||||
|
const updatedBackupCodes = backupCodes.filter(code => code !== token);
|
||||||
|
await prisma.users.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: {
|
||||||
|
tfa_backup_codes: JSON.stringify(updatedBackupCodes)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
verified = true;
|
||||||
|
} else {
|
||||||
|
// Verify TOTP token
|
||||||
|
verified = speakeasy.totp.verify({
|
||||||
|
secret: user.tfa_secret,
|
||||||
|
encoding: 'base32',
|
||||||
|
token: token,
|
||||||
|
window: 2
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!verified) {
|
||||||
|
return res.status(401).json({ error: 'Invalid verification code' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update last login
|
||||||
|
await prisma.users.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: { last_login: new Date() }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generate token
|
||||||
|
const jwtToken = generateToken(user.id);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
message: 'Login successful',
|
||||||
|
token: jwtToken,
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
email: user.email,
|
||||||
|
first_name: user.first_name,
|
||||||
|
last_name: user.last_name,
|
||||||
|
role: user.role
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('TFA verification error:', error);
|
||||||
|
res.status(500).json({ error: 'TFA verification failed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Get current user profile
|
// Get current user profile
|
||||||
router.get('/profile', authenticateToken, async (req, res) => {
|
router.get('/profile', authenticateToken, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -397,7 +757,9 @@ router.get('/profile', authenticateToken, async (req, res) => {
|
|||||||
// Update user profile
|
// Update user profile
|
||||||
router.put('/profile', authenticateToken, [
|
router.put('/profile', authenticateToken, [
|
||||||
body('username').optional().isLength({ min: 3 }).withMessage('Username must be at least 3 characters'),
|
body('username').optional().isLength({ min: 3 }).withMessage('Username must be at least 3 characters'),
|
||||||
body('email').optional().isEmail().withMessage('Valid email is required')
|
body('email').optional().isEmail().withMessage('Valid email is required'),
|
||||||
|
body('first_name').optional().isLength({ min: 1 }).withMessage('First name must be at least 1 character'),
|
||||||
|
body('last_name').optional().isLength({ min: 1 }).withMessage('Last name must be at least 1 character')
|
||||||
], async (req, res) => {
|
], async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const errors = validationResult(req);
|
const errors = validationResult(req);
|
||||||
@@ -405,15 +767,17 @@ router.put('/profile', authenticateToken, [
|
|||||||
return res.status(400).json({ errors: errors.array() });
|
return res.status(400).json({ errors: errors.array() });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { username, email } = req.body;
|
const { username, email, first_name, last_name } = req.body;
|
||||||
const updateData = {};
|
const updateData = {};
|
||||||
|
|
||||||
if (username) updateData.username = username;
|
if (username) updateData.username = username;
|
||||||
if (email) updateData.email = email;
|
if (email) updateData.email = email;
|
||||||
|
if (first_name !== undefined) updateData.first_name = first_name || null;
|
||||||
|
if (last_name !== undefined) updateData.last_name = last_name || null;
|
||||||
|
|
||||||
// Check if username/email already exists (excluding current user)
|
// Check if username/email already exists (excluding current user)
|
||||||
if (username || email) {
|
if (username || email) {
|
||||||
const existingUser = await prisma.user.findFirst({
|
const existingUser = await prisma.users.findFirst({
|
||||||
where: {
|
where: {
|
||||||
AND: [
|
AND: [
|
||||||
{ id: { not: req.user.id } },
|
{ id: { not: req.user.id } },
|
||||||
@@ -432,17 +796,19 @@ router.put('/profile', authenticateToken, [
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedUser = await prisma.user.update({
|
const updatedUser = await prisma.users.update({
|
||||||
where: { id: req.user.id },
|
where: { id: req.user.id },
|
||||||
data: updateData,
|
data: updateData,
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
username: true,
|
username: true,
|
||||||
email: true,
|
email: true,
|
||||||
|
first_name: true,
|
||||||
|
last_name: true,
|
||||||
role: true,
|
role: true,
|
||||||
isActive: true,
|
is_active: true,
|
||||||
lastLogin: true,
|
last_login: true,
|
||||||
updatedAt: true
|
updated_at: true
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -470,12 +836,12 @@ router.put('/change-password', authenticateToken, [
|
|||||||
const { currentPassword, newPassword } = req.body;
|
const { currentPassword, newPassword } = req.body;
|
||||||
|
|
||||||
// Get user with password hash
|
// Get user with password hash
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.users.findUnique({
|
||||||
where: { id: req.user.id }
|
where: { id: req.user.id }
|
||||||
});
|
});
|
||||||
|
|
||||||
// Verify current password
|
// Verify current password
|
||||||
const isValidPassword = await bcrypt.compare(currentPassword, user.passwordHash);
|
const isValidPassword = await bcrypt.compare(currentPassword, user.password_hash);
|
||||||
if (!isValidPassword) {
|
if (!isValidPassword) {
|
||||||
return res.status(401).json({ error: 'Current password is incorrect' });
|
return res.status(401).json({ error: 'Current password is incorrect' });
|
||||||
}
|
}
|
||||||
@@ -484,9 +850,9 @@ router.put('/change-password', authenticateToken, [
|
|||||||
const newPasswordHash = await bcrypt.hash(newPassword, 12);
|
const newPasswordHash = await bcrypt.hash(newPassword, 12);
|
||||||
|
|
||||||
// Update password
|
// Update password
|
||||||
await prisma.user.update({
|
await prisma.users.update({
|
||||||
where: { id: req.user.id },
|
where: { id: req.user.id },
|
||||||
data: { passwordHash: newPasswordHash }
|
data: { password_hash: newPasswordHash }
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
|
|||||||
@@ -2,15 +2,123 @@ const express = require('express');
|
|||||||
const { body, validationResult } = require('express-validator');
|
const { body, validationResult } = require('express-validator');
|
||||||
const { PrismaClient } = require('@prisma/client');
|
const { PrismaClient } = require('@prisma/client');
|
||||||
const { authenticateToken } = require('../middleware/auth');
|
const { authenticateToken } = require('../middleware/auth');
|
||||||
|
const { v4: uuidv4 } = require('uuid');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
// Helper function to get user permissions based on role
|
||||||
|
async function getUserPermissions(userRole) {
|
||||||
|
try {
|
||||||
|
const permissions = await prisma.role_permissions.findUnique({
|
||||||
|
where: { role: userRole }
|
||||||
|
});
|
||||||
|
|
||||||
|
// If no specific permissions found, return default admin permissions (for backward compatibility)
|
||||||
|
if (!permissions) {
|
||||||
|
console.warn(`No permissions found for role: ${userRole}, defaulting to admin access`);
|
||||||
|
return {
|
||||||
|
can_view_dashboard: true,
|
||||||
|
can_view_hosts: true,
|
||||||
|
can_manage_hosts: true,
|
||||||
|
can_view_packages: true,
|
||||||
|
can_manage_packages: true,
|
||||||
|
can_view_users: true,
|
||||||
|
can_manage_users: true,
|
||||||
|
can_view_reports: true,
|
||||||
|
can_export_data: true,
|
||||||
|
can_manage_settings: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return permissions;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching user permissions:', error);
|
||||||
|
// Return admin permissions as fallback
|
||||||
|
return {
|
||||||
|
can_view_dashboard: true,
|
||||||
|
can_view_hosts: true,
|
||||||
|
can_manage_hosts: true,
|
||||||
|
can_view_packages: true,
|
||||||
|
can_manage_packages: true,
|
||||||
|
can_view_users: true,
|
||||||
|
can_manage_users: true,
|
||||||
|
can_view_reports: true,
|
||||||
|
can_export_data: true,
|
||||||
|
can_manage_settings: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to create permission-based dashboard preferences for a new user
|
||||||
|
async function createDefaultDashboardPreferences(userId, userRole = 'user') {
|
||||||
|
try {
|
||||||
|
// Get user's actual permissions
|
||||||
|
const permissions = await getUserPermissions(userRole);
|
||||||
|
|
||||||
|
// Define all possible dashboard cards with their required permissions
|
||||||
|
// Order aligned with preferred layout
|
||||||
|
const allCards = [
|
||||||
|
// Host-related cards
|
||||||
|
{ cardId: 'totalHosts', requiredPermission: 'can_view_hosts', order: 0 },
|
||||||
|
{ cardId: 'hostsNeedingUpdates', requiredPermission: 'can_view_hosts', order: 1 },
|
||||||
|
|
||||||
|
// Package-related cards
|
||||||
|
{ cardId: 'totalOutdatedPackages', requiredPermission: 'can_view_packages', order: 2 },
|
||||||
|
{ cardId: 'securityUpdates', requiredPermission: 'can_view_packages', order: 3 },
|
||||||
|
|
||||||
|
// Host-related cards (continued)
|
||||||
|
{ cardId: 'totalHostGroups', requiredPermission: 'can_view_hosts', order: 4 },
|
||||||
|
{ cardId: 'upToDateHosts', requiredPermission: 'can_view_hosts', order: 5 },
|
||||||
|
|
||||||
|
// Repository-related cards
|
||||||
|
{ cardId: 'totalRepos', requiredPermission: 'can_view_hosts', order: 6 },
|
||||||
|
|
||||||
|
// User management cards (admin only)
|
||||||
|
{ cardId: 'totalUsers', requiredPermission: 'can_view_users', order: 7 },
|
||||||
|
|
||||||
|
// System/Report cards
|
||||||
|
{ cardId: 'osDistribution', requiredPermission: 'can_view_reports', order: 8 },
|
||||||
|
{ cardId: 'osDistributionBar', requiredPermission: 'can_view_reports', order: 9 },
|
||||||
|
{ cardId: 'recentCollection', requiredPermission: 'can_view_hosts', order: 10 },
|
||||||
|
{ cardId: 'updateStatus', requiredPermission: 'can_view_reports', order: 11 },
|
||||||
|
{ cardId: 'packagePriority', requiredPermission: 'can_view_packages', order: 12 },
|
||||||
|
{ cardId: 'recentUsers', requiredPermission: 'can_view_users', order: 13 },
|
||||||
|
{ cardId: 'quickStats', requiredPermission: 'can_view_dashboard', order: 14 }
|
||||||
|
];
|
||||||
|
|
||||||
|
// Filter cards based on user's permissions
|
||||||
|
const allowedCards = allCards.filter(card => {
|
||||||
|
return permissions[card.requiredPermission] === true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create preferences data
|
||||||
|
const preferencesData = allowedCards.map((card) => ({
|
||||||
|
id: uuidv4(),
|
||||||
|
user_id: userId,
|
||||||
|
card_id: card.cardId,
|
||||||
|
enabled: true,
|
||||||
|
order: card.order, // Preserve original order from allCards
|
||||||
|
created_at: new Date(),
|
||||||
|
updated_at: new Date()
|
||||||
|
}));
|
||||||
|
|
||||||
|
await prisma.dashboard_preferences.createMany({
|
||||||
|
data: preferencesData
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Permission-based dashboard preferences created for user ${userId} with role ${userRole}: ${allowedCards.length} cards`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating default dashboard preferences:', error);
|
||||||
|
// Don't throw error - this shouldn't break user creation
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Get user's dashboard preferences
|
// Get user's dashboard preferences
|
||||||
router.get('/', authenticateToken, async (req, res) => {
|
router.get('/', authenticateToken, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const preferences = await prisma.dashboardPreferences.findMany({
|
const preferences = await prisma.dashboard_preferences.findMany({
|
||||||
where: { userId: req.user.id },
|
where: { user_id: req.user.id },
|
||||||
orderBy: { order: 'asc' }
|
orderBy: { order: 'asc' }
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -38,19 +146,21 @@ router.put('/', authenticateToken, [
|
|||||||
const userId = req.user.id;
|
const userId = req.user.id;
|
||||||
|
|
||||||
// Delete existing preferences for this user
|
// Delete existing preferences for this user
|
||||||
await prisma.dashboardPreferences.deleteMany({
|
await prisma.dashboard_preferences.deleteMany({
|
||||||
where: { userId }
|
where: { user_id: userId }
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create new preferences
|
// Create new preferences
|
||||||
const newPreferences = preferences.map(pref => ({
|
const newPreferences = preferences.map(pref => ({
|
||||||
userId,
|
id: require('uuid').v4(),
|
||||||
cardId: pref.cardId,
|
user_id: userId,
|
||||||
|
card_id: pref.cardId,
|
||||||
enabled: pref.enabled,
|
enabled: pref.enabled,
|
||||||
order: pref.order
|
order: pref.order,
|
||||||
|
updated_at: new Date()
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const createdPreferences = await prisma.dashboardPreferences.createMany({
|
const createdPreferences = await prisma.dashboard_preferences.createMany({
|
||||||
data: newPreferences
|
data: newPreferences
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -67,16 +177,23 @@ router.put('/', authenticateToken, [
|
|||||||
// Get default dashboard card configuration
|
// Get default dashboard card configuration
|
||||||
router.get('/defaults', authenticateToken, async (req, res) => {
|
router.get('/defaults', authenticateToken, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
// This provides a comprehensive dashboard view for all new users
|
||||||
const defaultCards = [
|
const defaultCards = [
|
||||||
{ cardId: 'totalHosts', title: 'Total Hosts', icon: 'Server', enabled: true, order: 0 },
|
{ cardId: 'totalHosts', title: 'Total Hosts', icon: 'Server', enabled: true, order: 0 },
|
||||||
{ cardId: 'hostsNeedingUpdates', title: 'Needs Updating', icon: 'AlertTriangle', enabled: true, order: 1 },
|
{ cardId: 'hostsNeedingUpdates', title: 'Needs Updating', icon: 'AlertTriangle', enabled: true, order: 1 },
|
||||||
{ cardId: 'totalOutdatedPackages', title: 'Outdated Packages', icon: 'Package', enabled: true, order: 2 },
|
{ cardId: 'totalOutdatedPackages', title: 'Outdated Packages', icon: 'Package', enabled: true, order: 2 },
|
||||||
{ cardId: 'securityUpdates', title: 'Security Updates', icon: 'Shield', enabled: true, order: 3 },
|
{ cardId: 'securityUpdates', title: 'Security Updates', icon: 'Shield', enabled: true, order: 3 },
|
||||||
{ cardId: 'erroredHosts', title: 'Errored Hosts', icon: 'AlertTriangle', enabled: true, order: 4 },
|
{ cardId: 'totalHostGroups', title: 'Host Groups', icon: 'Folder', enabled: true, order: 4 },
|
||||||
{ cardId: 'osDistribution', title: 'OS Distribution', icon: 'BarChart3', enabled: true, order: 5 },
|
{ cardId: 'hostsNeedingUpdates', title: 'Up to date', icon: 'CheckCircle', enabled: true, order: 5 },
|
||||||
{ cardId: 'updateStatus', title: 'Update Status', icon: 'BarChart3', enabled: true, order: 6 },
|
{ cardId: 'totalRepos', title: 'Repositories', icon: 'GitBranch', enabled: true, order: 6 },
|
||||||
{ cardId: 'packagePriority', title: 'Package Priority', icon: 'BarChart3', enabled: true, order: 7 },
|
{ cardId: 'totalUsers', title: 'Users', icon: 'Users', enabled: true, order: 7 },
|
||||||
{ cardId: 'quickStats', title: 'Quick Stats', icon: 'TrendingUp', enabled: true, order: 8 }
|
{ cardId: 'osDistribution', title: 'OS Distribution', icon: 'BarChart3', enabled: true, order: 8 },
|
||||||
|
{ cardId: 'osDistributionBar', title: 'OS Distribution (Bar)', icon: 'BarChart3', enabled: true, order: 9 },
|
||||||
|
{ cardId: 'recentCollection', title: 'Recent Collection', icon: 'Server', enabled: true, order: 10 },
|
||||||
|
{ cardId: 'updateStatus', title: 'Update Status', icon: 'BarChart3', enabled: true, order: 11 },
|
||||||
|
{ cardId: 'packagePriority', title: 'Package Priority', icon: 'BarChart3', enabled: true, order: 12 },
|
||||||
|
{ cardId: 'recentUsers', title: 'Recent Users Logged in', icon: 'Users', enabled: true, order: 13 },
|
||||||
|
{ cardId: 'quickStats', title: 'Quick Stats', icon: 'TrendingUp', enabled: true, order: 14 }
|
||||||
];
|
];
|
||||||
|
|
||||||
res.json(defaultCards);
|
res.json(defaultCards);
|
||||||
@@ -86,4 +203,4 @@ router.get('/defaults', authenticateToken, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = { router, createDefaultDashboardPreferences };
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ const { authenticateToken } = require('../middleware/auth');
|
|||||||
const {
|
const {
|
||||||
requireViewDashboard,
|
requireViewDashboard,
|
||||||
requireViewHosts,
|
requireViewHosts,
|
||||||
requireViewPackages
|
requireViewPackages,
|
||||||
|
requireViewUsers
|
||||||
} = require('../middleware/permissions');
|
} = require('../middleware/permissions');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
@@ -18,7 +19,7 @@ router.get('/stats', authenticateToken, requireViewDashboard, async (req, res) =
|
|||||||
|
|
||||||
// Get the agent update interval setting
|
// Get the agent update interval setting
|
||||||
const settings = await prisma.settings.findFirst();
|
const settings = await prisma.settings.findFirst();
|
||||||
const updateIntervalMinutes = settings?.updateInterval || 60; // Default to 60 minutes if no setting
|
const updateIntervalMinutes = settings?.update_interval || 60; // Default to 60 minutes if no setting
|
||||||
|
|
||||||
// Calculate the threshold based on the actual update interval
|
// Calculate the threshold based on the actual update interval
|
||||||
// Use 2x the update interval as the threshold for "errored" hosts
|
// Use 2x the update interval as the threshold for "errored" hosts
|
||||||
@@ -32,60 +33,80 @@ router.get('/stats', authenticateToken, requireViewDashboard, async (req, res) =
|
|||||||
totalOutdatedPackages,
|
totalOutdatedPackages,
|
||||||
erroredHosts,
|
erroredHosts,
|
||||||
securityUpdates,
|
securityUpdates,
|
||||||
|
offlineHosts,
|
||||||
|
totalHostGroups,
|
||||||
|
totalUsers,
|
||||||
|
totalRepos,
|
||||||
osDistribution,
|
osDistribution,
|
||||||
updateTrends
|
updateTrends
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
// Total hosts count
|
// Total hosts count (all hosts regardless of status)
|
||||||
prisma.host.count({
|
prisma.hosts.count(),
|
||||||
where: { status: 'active' }
|
|
||||||
}),
|
|
||||||
|
|
||||||
// Hosts needing updates (distinct hosts with packages needing updates)
|
// Hosts needing updates (distinct hosts with packages needing updates)
|
||||||
prisma.host.count({
|
prisma.hosts.count({
|
||||||
where: {
|
where: {
|
||||||
status: 'active',
|
host_packages: {
|
||||||
hostPackages: {
|
|
||||||
some: {
|
some: {
|
||||||
needsUpdate: true
|
needs_update: true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// Total outdated packages across all hosts
|
// Total outdated packages across all hosts
|
||||||
prisma.hostPackage.count({
|
prisma.host_packages.count({
|
||||||
where: { needsUpdate: true }
|
where: { needs_update: true }
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// Errored hosts (not updated within threshold based on update interval)
|
// Errored hosts (not updated within threshold based on update interval)
|
||||||
prisma.host.count({
|
prisma.hosts.count({
|
||||||
where: {
|
where: {
|
||||||
status: 'active',
|
status: 'active',
|
||||||
lastUpdate: {
|
last_update: {
|
||||||
lt: thresholdTime
|
lt: thresholdTime
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// Security updates count
|
// Security updates count
|
||||||
prisma.hostPackage.count({
|
prisma.host_packages.count({
|
||||||
where: {
|
where: {
|
||||||
needsUpdate: true,
|
needs_update: true,
|
||||||
isSecurityUpdate: true
|
is_security_update: true
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// Offline/Stale hosts (not updated within 3x the update interval)
|
||||||
|
prisma.hosts.count({
|
||||||
|
where: {
|
||||||
|
status: 'active',
|
||||||
|
last_update: {
|
||||||
|
lt: moment(now).subtract(updateIntervalMinutes * 3, 'minutes').toDate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Total host groups count
|
||||||
|
prisma.host_groups.count(),
|
||||||
|
|
||||||
|
// Total users count
|
||||||
|
prisma.users.count(),
|
||||||
|
|
||||||
|
// Total repositories count
|
||||||
|
prisma.repositories.count(),
|
||||||
|
|
||||||
// OS distribution for pie chart
|
// OS distribution for pie chart
|
||||||
prisma.host.groupBy({
|
prisma.hosts.groupBy({
|
||||||
by: ['osType'],
|
by: ['os_type'],
|
||||||
where: { status: 'active' },
|
where: { status: 'active' },
|
||||||
_count: {
|
_count: {
|
||||||
osType: true
|
os_type: true
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// Update trends for the last 7 days
|
// Update trends for the last 7 days
|
||||||
prisma.updateHistory.groupBy({
|
prisma.update_history.groupBy({
|
||||||
by: ['timestamp'],
|
by: ['timestamp'],
|
||||||
where: {
|
where: {
|
||||||
timestamp: {
|
timestamp: {
|
||||||
@@ -96,16 +117,16 @@ router.get('/stats', authenticateToken, requireViewDashboard, async (req, res) =
|
|||||||
id: true
|
id: true
|
||||||
},
|
},
|
||||||
_sum: {
|
_sum: {
|
||||||
packagesCount: true,
|
packages_count: true,
|
||||||
securityCount: true
|
security_count: true
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Format OS distribution for pie chart
|
// Format OS distribution for pie chart
|
||||||
const osDistributionFormatted = osDistribution.map(item => ({
|
const osDistributionFormatted = osDistribution.map(item => ({
|
||||||
name: item.osType,
|
name: item.os_type,
|
||||||
count: item._count.osType
|
count: item._count.os_type
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Calculate update status distribution
|
// Calculate update status distribution
|
||||||
@@ -125,9 +146,14 @@ router.get('/stats', authenticateToken, requireViewDashboard, async (req, res) =
|
|||||||
cards: {
|
cards: {
|
||||||
totalHosts,
|
totalHosts,
|
||||||
hostsNeedingUpdates,
|
hostsNeedingUpdates,
|
||||||
|
upToDateHosts: Math.max(totalHosts - hostsNeedingUpdates, 0),
|
||||||
totalOutdatedPackages,
|
totalOutdatedPackages,
|
||||||
erroredHosts,
|
erroredHosts,
|
||||||
securityUpdates
|
securityUpdates,
|
||||||
|
offlineHosts,
|
||||||
|
totalHostGroups,
|
||||||
|
totalUsers,
|
||||||
|
totalRepos
|
||||||
},
|
},
|
||||||
charts: {
|
charts: {
|
||||||
osDistribution: osDistributionFormatted,
|
osDistribution: osDistributionFormatted,
|
||||||
@@ -146,19 +172,20 @@ router.get('/stats', authenticateToken, requireViewDashboard, async (req, res) =
|
|||||||
// Get hosts with their update status
|
// Get hosts with their update status
|
||||||
router.get('/hosts', authenticateToken, requireViewHosts, async (req, res) => {
|
router.get('/hosts', authenticateToken, requireViewHosts, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const hosts = await prisma.host.findMany({
|
const hosts = await prisma.hosts.findMany({
|
||||||
// Show all hosts regardless of status
|
// Show all hosts regardless of status
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
|
friendly_name: true,
|
||||||
hostname: true,
|
hostname: true,
|
||||||
ip: true,
|
ip: true,
|
||||||
osType: true,
|
os_type: true,
|
||||||
osVersion: true,
|
os_version: true,
|
||||||
lastUpdate: true,
|
last_update: true,
|
||||||
status: true,
|
status: true,
|
||||||
agentVersion: true,
|
agent_version: true,
|
||||||
autoUpdate: true,
|
auto_update: true,
|
||||||
hostGroup: {
|
host_groups: {
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
name: true,
|
name: true,
|
||||||
@@ -167,34 +194,41 @@ router.get('/hosts', authenticateToken, requireViewHosts, async (req, res) => {
|
|||||||
},
|
},
|
||||||
_count: {
|
_count: {
|
||||||
select: {
|
select: {
|
||||||
hostPackages: {
|
host_packages: {
|
||||||
where: {
|
where: {
|
||||||
needsUpdate: true
|
needs_update: true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
orderBy: { lastUpdate: 'desc' }
|
orderBy: { last_update: 'desc' }
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get update counts for each host separately
|
// Get update counts for each host separately
|
||||||
const hostsWithUpdateInfo = await Promise.all(
|
const hostsWithUpdateInfo = await Promise.all(
|
||||||
hosts.map(async (host) => {
|
hosts.map(async (host) => {
|
||||||
const updatesCount = await prisma.hostPackage.count({
|
const updatesCount = await prisma.host_packages.count({
|
||||||
where: {
|
where: {
|
||||||
hostId: host.id,
|
host_id: host.id,
|
||||||
needsUpdate: true
|
needs_update: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get total packages count for this host
|
||||||
|
const totalPackagesCount = await prisma.host_packages.count({
|
||||||
|
where: {
|
||||||
|
host_id: host.id
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get the agent update interval setting for stale calculation
|
// Get the agent update interval setting for stale calculation
|
||||||
const settings = await prisma.settings.findFirst();
|
const settings = await prisma.settings.findFirst();
|
||||||
const updateIntervalMinutes = settings?.updateInterval || 60;
|
const updateIntervalMinutes = settings?.update_interval || 60;
|
||||||
const thresholdMinutes = updateIntervalMinutes * 2;
|
const thresholdMinutes = updateIntervalMinutes * 2;
|
||||||
|
|
||||||
// Calculate effective status based on reporting interval
|
// Calculate effective status based on reporting interval
|
||||||
const isStale = moment(host.lastUpdate).isBefore(moment().subtract(thresholdMinutes, 'minutes'));
|
const isStale = moment(host.last_update).isBefore(moment().subtract(thresholdMinutes, 'minutes'));
|
||||||
let effectiveStatus = host.status;
|
let effectiveStatus = host.status;
|
||||||
|
|
||||||
// Override status if host hasn't reported within threshold
|
// Override status if host hasn't reported within threshold
|
||||||
@@ -205,6 +239,7 @@ router.get('/hosts', authenticateToken, requireViewHosts, async (req, res) => {
|
|||||||
return {
|
return {
|
||||||
...host,
|
...host,
|
||||||
updatesCount,
|
updatesCount,
|
||||||
|
totalPackagesCount,
|
||||||
isStale,
|
isStale,
|
||||||
effectiveStatus
|
effectiveStatus
|
||||||
};
|
};
|
||||||
@@ -221,11 +256,11 @@ router.get('/hosts', authenticateToken, requireViewHosts, async (req, res) => {
|
|||||||
// Get packages that need updates across all hosts
|
// Get packages that need updates across all hosts
|
||||||
router.get('/packages', authenticateToken, requireViewPackages, async (req, res) => {
|
router.get('/packages', authenticateToken, requireViewPackages, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const packages = await prisma.package.findMany({
|
const packages = await prisma.packages.findMany({
|
||||||
where: {
|
where: {
|
||||||
hostPackages: {
|
host_packages: {
|
||||||
some: {
|
some: {
|
||||||
needsUpdate: true
|
needs_update: true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -234,18 +269,18 @@ router.get('/packages', authenticateToken, requireViewPackages, async (req, res)
|
|||||||
name: true,
|
name: true,
|
||||||
description: true,
|
description: true,
|
||||||
category: true,
|
category: true,
|
||||||
latestVersion: true,
|
latest_version: true,
|
||||||
hostPackages: {
|
host_packages: {
|
||||||
where: { needsUpdate: true },
|
where: { needs_update: true },
|
||||||
select: {
|
select: {
|
||||||
currentVersion: true,
|
current_version: true,
|
||||||
availableVersion: true,
|
available_version: true,
|
||||||
isSecurityUpdate: true,
|
is_security_update: true,
|
||||||
host: {
|
hosts: {
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
hostname: true,
|
friendly_name: true,
|
||||||
osType: true
|
os_type: true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -261,16 +296,16 @@ router.get('/packages', authenticateToken, requireViewPackages, async (req, res)
|
|||||||
name: pkg.name,
|
name: pkg.name,
|
||||||
description: pkg.description,
|
description: pkg.description,
|
||||||
category: pkg.category,
|
category: pkg.category,
|
||||||
latestVersion: pkg.latestVersion,
|
latestVersion: pkg.latest_version,
|
||||||
affectedHostsCount: pkg.hostPackages.length,
|
affectedHostsCount: pkg.host_packages.length,
|
||||||
isSecurityUpdate: pkg.hostPackages.some(hp => hp.isSecurityUpdate),
|
isSecurityUpdate: pkg.host_packages.some(hp => hp.is_security_update),
|
||||||
affectedHosts: pkg.hostPackages.map(hp => ({
|
affectedHosts: pkg.host_packages.map(hp => ({
|
||||||
hostId: hp.host.id,
|
hostId: hp.hosts.id,
|
||||||
hostname: hp.host.hostname,
|
friendlyName: hp.hosts.friendly_name,
|
||||||
osType: hp.host.osType,
|
osType: hp.hosts.os_type,
|
||||||
currentVersion: hp.currentVersion,
|
currentVersion: hp.current_version,
|
||||||
availableVersion: hp.availableVersion,
|
availableVersion: hp.available_version,
|
||||||
isSecurityUpdate: hp.isSecurityUpdate
|
isSecurityUpdate: hp.is_security_update
|
||||||
}))
|
}))
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -286,25 +321,25 @@ router.get('/hosts/:hostId', authenticateToken, requireViewHosts, async (req, re
|
|||||||
try {
|
try {
|
||||||
const { hostId } = req.params;
|
const { hostId } = req.params;
|
||||||
|
|
||||||
const host = await prisma.host.findUnique({
|
const host = await prisma.hosts.findUnique({
|
||||||
where: { id: hostId },
|
where: { id: hostId },
|
||||||
include: {
|
include: {
|
||||||
hostGroup: {
|
host_groups: {
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
name: true,
|
name: true,
|
||||||
color: true
|
color: true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
hostPackages: {
|
host_packages: {
|
||||||
include: {
|
include: {
|
||||||
package: true
|
packages: true
|
||||||
},
|
},
|
||||||
orderBy: {
|
orderBy: {
|
||||||
needsUpdate: 'desc'
|
needs_update: 'desc'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
updateHistory: {
|
update_history: {
|
||||||
orderBy: {
|
orderBy: {
|
||||||
timestamp: 'desc'
|
timestamp: 'desc'
|
||||||
},
|
},
|
||||||
@@ -320,9 +355,9 @@ router.get('/hosts/:hostId', authenticateToken, requireViewHosts, async (req, re
|
|||||||
const hostWithStats = {
|
const hostWithStats = {
|
||||||
...host,
|
...host,
|
||||||
stats: {
|
stats: {
|
||||||
totalPackages: host.hostPackages.length,
|
total_packages: host.host_packages.length,
|
||||||
outdatedPackages: host.hostPackages.filter(hp => hp.needsUpdate).length,
|
outdated_packages: host.host_packages.filter(hp => hp.needs_update).length,
|
||||||
securityUpdates: host.hostPackages.filter(hp => hp.needsUpdate && hp.isSecurityUpdate).length
|
security_updates: host.host_packages.filter(hp => hp.needs_update && hp.is_security_update).length
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -333,4 +368,59 @@ router.get('/hosts/:hostId', authenticateToken, requireViewHosts, async (req, re
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Get recent users ordered by last_login desc
|
||||||
|
router.get('/recent-users', authenticateToken, requireViewUsers, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const users = await prisma.users.findMany({
|
||||||
|
where: {
|
||||||
|
last_login: {
|
||||||
|
not: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
email: true,
|
||||||
|
role: true,
|
||||||
|
last_login: true,
|
||||||
|
created_at: true
|
||||||
|
},
|
||||||
|
orderBy: [
|
||||||
|
{ last_login: 'desc' },
|
||||||
|
{ created_at: 'desc' }
|
||||||
|
],
|
||||||
|
take: 5
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(users);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching recent users:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch recent users' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get recent hosts that have sent data (ordered by last_update desc)
|
||||||
|
router.get('/recent-collection', authenticateToken, requireViewHosts, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const hosts = await prisma.hosts.findMany({
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
friendly_name: true,
|
||||||
|
hostname: true,
|
||||||
|
last_update: true,
|
||||||
|
status: true
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
last_update: 'desc'
|
||||||
|
},
|
||||||
|
take: 5
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(hosts);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching recent collection:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch recent collection' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { body, validationResult } = require('express-validator');
|
const { body, validationResult } = require('express-validator');
|
||||||
const { PrismaClient } = require('@prisma/client');
|
const { PrismaClient } = require('@prisma/client');
|
||||||
|
const { randomUUID } = require('crypto');
|
||||||
const { authenticateToken } = require('../middleware/auth');
|
const { authenticateToken } = require('../middleware/auth');
|
||||||
const { requireManageHosts } = require('../middleware/permissions');
|
const { requireManageHosts } = require('../middleware/permissions');
|
||||||
|
|
||||||
@@ -10,7 +11,7 @@ const prisma = new PrismaClient();
|
|||||||
// Get all host groups
|
// Get all host groups
|
||||||
router.get('/', authenticateToken, async (req, res) => {
|
router.get('/', authenticateToken, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const hostGroups = await prisma.hostGroup.findMany({
|
const hostGroups = await prisma.host_groups.findMany({
|
||||||
include: {
|
include: {
|
||||||
_count: {
|
_count: {
|
||||||
select: {
|
select: {
|
||||||
@@ -35,18 +36,19 @@ router.get('/:id', authenticateToken, async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
|
|
||||||
const hostGroup = await prisma.hostGroup.findUnique({
|
const hostGroup = await prisma.host_groups.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
include: {
|
include: {
|
||||||
hosts: {
|
hosts: {
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
|
friendly_name: true,
|
||||||
hostname: true,
|
hostname: true,
|
||||||
ip: true,
|
ip: true,
|
||||||
osType: true,
|
os_type: true,
|
||||||
osVersion: true,
|
os_version: true,
|
||||||
status: true,
|
status: true,
|
||||||
lastUpdate: true
|
last_update: true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -78,7 +80,7 @@ router.post('/', authenticateToken, requireManageHosts, [
|
|||||||
const { name, description, color } = req.body;
|
const { name, description, color } = req.body;
|
||||||
|
|
||||||
// Check if host group with this name already exists
|
// Check if host group with this name already exists
|
||||||
const existingGroup = await prisma.hostGroup.findUnique({
|
const existingGroup = await prisma.host_groups.findUnique({
|
||||||
where: { name }
|
where: { name }
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -86,11 +88,13 @@ router.post('/', authenticateToken, requireManageHosts, [
|
|||||||
return res.status(400).json({ error: 'A host group with this name already exists' });
|
return res.status(400).json({ error: 'A host group with this name already exists' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const hostGroup = await prisma.hostGroup.create({
|
const hostGroup = await prisma.host_groups.create({
|
||||||
data: {
|
data: {
|
||||||
|
id: randomUUID(),
|
||||||
name,
|
name,
|
||||||
description: description || null,
|
description: description || null,
|
||||||
color: color || '#3B82F6'
|
color: color || '#3B82F6',
|
||||||
|
updated_at: new Date()
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -117,7 +121,7 @@ router.put('/:id', authenticateToken, requireManageHosts, [
|
|||||||
const { name, description, color } = req.body;
|
const { name, description, color } = req.body;
|
||||||
|
|
||||||
// Check if host group exists
|
// Check if host group exists
|
||||||
const existingGroup = await prisma.hostGroup.findUnique({
|
const existingGroup = await prisma.host_groups.findUnique({
|
||||||
where: { id }
|
where: { id }
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -126,7 +130,7 @@ router.put('/:id', authenticateToken, requireManageHosts, [
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if another host group with this name already exists
|
// Check if another host group with this name already exists
|
||||||
const duplicateGroup = await prisma.hostGroup.findFirst({
|
const duplicateGroup = await prisma.host_groups.findFirst({
|
||||||
where: {
|
where: {
|
||||||
name,
|
name,
|
||||||
id: { not: id }
|
id: { not: id }
|
||||||
@@ -137,12 +141,13 @@ router.put('/:id', authenticateToken, requireManageHosts, [
|
|||||||
return res.status(400).json({ error: 'A host group with this name already exists' });
|
return res.status(400).json({ error: 'A host group with this name already exists' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const hostGroup = await prisma.hostGroup.update({
|
const hostGroup = await prisma.host_groups.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: {
|
data: {
|
||||||
name,
|
name,
|
||||||
description: description || null,
|
description: description || null,
|
||||||
color: color || '#3B82F6'
|
color: color || '#3B82F6',
|
||||||
|
updated_at: new Date()
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -159,7 +164,7 @@ router.delete('/:id', authenticateToken, requireManageHosts, async (req, res) =>
|
|||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
|
|
||||||
// Check if host group exists
|
// Check if host group exists
|
||||||
const existingGroup = await prisma.hostGroup.findUnique({
|
const existingGroup = await prisma.host_groups.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
include: {
|
include: {
|
||||||
_count: {
|
_count: {
|
||||||
@@ -181,7 +186,7 @@ router.delete('/:id', authenticateToken, requireManageHosts, async (req, res) =>
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.hostGroup.delete({
|
await prisma.host_groups.delete({
|
||||||
where: { id }
|
where: { id }
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -197,21 +202,21 @@ router.get('/:id/hosts', authenticateToken, async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
|
|
||||||
const hosts = await prisma.host.findMany({
|
const hosts = await prisma.hosts.findMany({
|
||||||
where: { hostGroupId: id },
|
where: { host_group_id: id },
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
hostname: true,
|
friendly_name: true,
|
||||||
ip: true,
|
ip: true,
|
||||||
osType: true,
|
os_type: true,
|
||||||
osVersion: true,
|
os_version: true,
|
||||||
architecture: true,
|
architecture: true,
|
||||||
status: true,
|
status: true,
|
||||||
lastUpdate: true,
|
last_update: true,
|
||||||
createdAt: true
|
created_at: true
|
||||||
},
|
},
|
||||||
orderBy: {
|
orderBy: {
|
||||||
hostname: 'asc'
|
friendly_name: 'asc'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -34,16 +34,16 @@ router.get('/', async (req, res) => {
|
|||||||
category ? { category: { equals: category } } : {},
|
category ? { category: { equals: category } } : {},
|
||||||
// Update status filters
|
// Update status filters
|
||||||
needsUpdate ? {
|
needsUpdate ? {
|
||||||
hostPackages: {
|
host_packages: {
|
||||||
some: {
|
some: {
|
||||||
needsUpdate: needsUpdate === 'true'
|
needs_update: needsUpdate === 'true'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} : {},
|
} : {},
|
||||||
isSecurityUpdate ? {
|
isSecurityUpdate ? {
|
||||||
hostPackages: {
|
host_packages: {
|
||||||
some: {
|
some: {
|
||||||
isSecurityUpdate: isSecurityUpdate === 'true'
|
is_security_update: isSecurityUpdate === 'true'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} : {}
|
} : {}
|
||||||
@@ -52,17 +52,17 @@ router.get('/', async (req, res) => {
|
|||||||
|
|
||||||
// Get packages with counts
|
// Get packages with counts
|
||||||
const [packages, totalCount] = await Promise.all([
|
const [packages, totalCount] = await Promise.all([
|
||||||
prisma.package.findMany({
|
prisma.packages.findMany({
|
||||||
where,
|
where,
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
name: true,
|
name: true,
|
||||||
description: true,
|
description: true,
|
||||||
category: true,
|
category: true,
|
||||||
latestVersion: true,
|
latest_version: true,
|
||||||
createdAt: true,
|
created_at: true,
|
||||||
_count: {
|
_count: {
|
||||||
hostPackages: true
|
host_packages: true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
skip,
|
skip,
|
||||||
@@ -71,37 +71,38 @@ router.get('/', async (req, res) => {
|
|||||||
name: 'asc'
|
name: 'asc'
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
prisma.package.count({ where })
|
prisma.packages.count({ where })
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Get additional stats for each package
|
// Get additional stats for each package
|
||||||
const packagesWithStats = await Promise.all(
|
const packagesWithStats = await Promise.all(
|
||||||
packages.map(async (pkg) => {
|
packages.map(async (pkg) => {
|
||||||
const [updatesCount, securityCount, affectedHosts] = await Promise.all([
|
const [updatesCount, securityCount, affectedHosts] = await Promise.all([
|
||||||
prisma.hostPackage.count({
|
prisma.host_packages.count({
|
||||||
where: {
|
where: {
|
||||||
packageId: pkg.id,
|
package_id: pkg.id,
|
||||||
needsUpdate: true
|
needs_update: true
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
prisma.hostPackage.count({
|
prisma.host_packages.count({
|
||||||
where: {
|
where: {
|
||||||
packageId: pkg.id,
|
package_id: pkg.id,
|
||||||
needsUpdate: true,
|
needs_update: true,
|
||||||
isSecurityUpdate: true
|
is_security_update: true
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
prisma.hostPackage.findMany({
|
prisma.host_packages.findMany({
|
||||||
where: {
|
where: {
|
||||||
packageId: pkg.id,
|
package_id: pkg.id,
|
||||||
needsUpdate: true
|
needs_update: true
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
host: {
|
hosts: {
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
|
friendly_name: true,
|
||||||
hostname: true,
|
hostname: true,
|
||||||
osType: true
|
os_type: true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -111,11 +112,19 @@ router.get('/', async (req, res) => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
...pkg,
|
...pkg,
|
||||||
|
affectedHostsCount: pkg._count.hostPackages,
|
||||||
|
affectedHosts: affectedHosts.map(hp => ({
|
||||||
|
hostId: hp.host.id,
|
||||||
|
friendlyName: hp.host.friendly_name,
|
||||||
|
osType: hp.host.os_type,
|
||||||
|
currentVersion: hp.current_version,
|
||||||
|
availableVersion: hp.available_version,
|
||||||
|
isSecurityUpdate: hp.is_security_update
|
||||||
|
})),
|
||||||
stats: {
|
stats: {
|
||||||
totalInstalls: pkg._count.hostPackages,
|
totalInstalls: pkg._count.hostPackages,
|
||||||
updatesNeeded: updatesCount,
|
updatesNeeded: updatesCount,
|
||||||
securityUpdates: securityCount,
|
securityUpdates: securityCount
|
||||||
affectedHosts: affectedHosts.map(hp => hp.host)
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
@@ -141,10 +150,10 @@ router.get('/:packageId', async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const { packageId } = req.params;
|
const { packageId } = req.params;
|
||||||
|
|
||||||
const packageData = await prisma.package.findUnique({
|
const packageData = await prisma.packages.findUnique({
|
||||||
where: { id: packageId },
|
where: { id: packageId },
|
||||||
include: {
|
include: {
|
||||||
hostPackages: {
|
host_packages: {
|
||||||
include: {
|
include: {
|
||||||
host: {
|
host: {
|
||||||
select: {
|
select: {
|
||||||
@@ -170,21 +179,21 @@ router.get('/:packageId', async (req, res) => {
|
|||||||
|
|
||||||
// Calculate statistics
|
// Calculate statistics
|
||||||
const stats = {
|
const stats = {
|
||||||
totalInstalls: packageData.hostPackages.length,
|
totalInstalls: packageData.host_packages.length,
|
||||||
updatesNeeded: packageData.hostPackages.filter(hp => hp.needsUpdate).length,
|
updatesNeeded: packageData.host_packages.filter(hp => hp.needsUpdate).length,
|
||||||
securityUpdates: packageData.hostPackages.filter(hp => hp.needsUpdate && hp.isSecurityUpdate).length,
|
securityUpdates: packageData.host_packages.filter(hp => hp.needsUpdate && hp.isSecurityUpdate).length,
|
||||||
upToDate: packageData.hostPackages.filter(hp => !hp.needsUpdate).length
|
upToDate: packageData.host_packages.filter(hp => !hp.needsUpdate).length
|
||||||
};
|
};
|
||||||
|
|
||||||
// Group by version
|
// Group by version
|
||||||
const versionDistribution = packageData.hostPackages.reduce((acc, hp) => {
|
const versionDistribution = packageData.host_packages.reduce((acc, hp) => {
|
||||||
const version = hp.currentVersion;
|
const version = hp.currentVersion;
|
||||||
acc[version] = (acc[version] || 0) + 1;
|
acc[version] = (acc[version] || 0) + 1;
|
||||||
return acc;
|
return acc;
|
||||||
}, {});
|
}, {});
|
||||||
|
|
||||||
// Group by OS type
|
// Group by OS type
|
||||||
const osDistribution = packageData.hostPackages.reduce((acc, hp) => {
|
const osDistribution = packageData.host_packages.reduce((acc, hp) => {
|
||||||
const osType = hp.host.osType;
|
const osType = hp.host.osType;
|
||||||
acc[osType] = (acc[osType] || 0) + 1;
|
acc[osType] = (acc[osType] || 0) + 1;
|
||||||
return acc;
|
return acc;
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { PrismaClient } = require('@prisma/client');
|
const { PrismaClient } = require('@prisma/client');
|
||||||
const { authenticateToken, requireAdmin } = require('../middleware/auth');
|
const { authenticateToken, requireAdmin } = require('../middleware/auth');
|
||||||
const { requireManageSettings } = require('../middleware/permissions');
|
const { requireManageSettings, requireManageUsers } = require('../middleware/permissions');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
// Get all role permissions
|
// Get all role permissions (allow users who can manage users to view roles)
|
||||||
router.get('/roles', authenticateToken, requireManageSettings, async (req, res) => {
|
router.get('/roles', authenticateToken, requireManageUsers, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const permissions = await prisma.rolePermissions.findMany({
|
const permissions = await prisma.role_permissions.findMany({
|
||||||
orderBy: {
|
orderBy: {
|
||||||
role: 'asc'
|
role: 'asc'
|
||||||
}
|
}
|
||||||
@@ -27,7 +27,7 @@ router.get('/roles/:role', authenticateToken, requireManageSettings, async (req,
|
|||||||
try {
|
try {
|
||||||
const { role } = req.params;
|
const { role } = req.params;
|
||||||
|
|
||||||
const permissions = await prisma.rolePermissions.findUnique({
|
const permissions = await prisma.role_permissions.findUnique({
|
||||||
where: { role }
|
where: { role }
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -47,49 +47,52 @@ router.put('/roles/:role', authenticateToken, requireManageSettings, async (req,
|
|||||||
try {
|
try {
|
||||||
const { role } = req.params;
|
const { role } = req.params;
|
||||||
const {
|
const {
|
||||||
canViewDashboard,
|
can_view_dashboard,
|
||||||
canViewHosts,
|
can_view_hosts,
|
||||||
canManageHosts,
|
can_manage_hosts,
|
||||||
canViewPackages,
|
can_view_packages,
|
||||||
canManagePackages,
|
can_manage_packages,
|
||||||
canViewUsers,
|
can_view_users,
|
||||||
canManageUsers,
|
can_manage_users,
|
||||||
canViewReports,
|
can_view_reports,
|
||||||
canExportData,
|
can_export_data,
|
||||||
canManageSettings
|
can_manage_settings
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
// Prevent modifying admin role permissions (admin should always have full access)
|
// Prevent modifying admin and user role permissions (built-in roles)
|
||||||
if (role === 'admin') {
|
if (role === 'admin' || role === 'user') {
|
||||||
return res.status(400).json({ error: 'Cannot modify admin role permissions' });
|
return res.status(400).json({ error: `Cannot modify ${role} role permissions - this is a built-in role` });
|
||||||
}
|
}
|
||||||
|
|
||||||
const permissions = await prisma.rolePermissions.upsert({
|
const permissions = await prisma.role_permissions.upsert({
|
||||||
where: { role },
|
where: { role },
|
||||||
update: {
|
update: {
|
||||||
canViewDashboard,
|
can_view_dashboard: can_view_dashboard,
|
||||||
canViewHosts,
|
can_view_hosts: can_view_hosts,
|
||||||
canManageHosts,
|
can_manage_hosts: can_manage_hosts,
|
||||||
canViewPackages,
|
can_view_packages: can_view_packages,
|
||||||
canManagePackages,
|
can_manage_packages: can_manage_packages,
|
||||||
canViewUsers,
|
can_view_users: can_view_users,
|
||||||
canManageUsers,
|
can_manage_users: can_manage_users,
|
||||||
canViewReports,
|
can_view_reports: can_view_reports,
|
||||||
canExportData,
|
can_export_data: can_export_data,
|
||||||
canManageSettings
|
can_manage_settings: can_manage_settings,
|
||||||
|
updated_at: new Date()
|
||||||
},
|
},
|
||||||
create: {
|
create: {
|
||||||
|
id: require('uuid').v4(),
|
||||||
role,
|
role,
|
||||||
canViewDashboard,
|
can_view_dashboard: can_view_dashboard,
|
||||||
canViewHosts,
|
can_view_hosts: can_view_hosts,
|
||||||
canManageHosts,
|
can_manage_hosts: can_manage_hosts,
|
||||||
canViewPackages,
|
can_view_packages: can_view_packages,
|
||||||
canManagePackages,
|
can_manage_packages: can_manage_packages,
|
||||||
canViewUsers,
|
can_view_users: can_view_users,
|
||||||
canManageUsers,
|
can_manage_users: can_manage_users,
|
||||||
canViewReports,
|
can_view_reports: can_view_reports,
|
||||||
canExportData,
|
can_export_data: can_export_data,
|
||||||
canManageSettings
|
can_manage_settings: can_manage_settings,
|
||||||
|
updated_at: new Date()
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -108,13 +111,13 @@ router.delete('/roles/:role', authenticateToken, requireManageSettings, async (r
|
|||||||
try {
|
try {
|
||||||
const { role } = req.params;
|
const { role } = req.params;
|
||||||
|
|
||||||
// Prevent deleting admin role
|
// Prevent deleting admin and user roles (built-in roles)
|
||||||
if (role === 'admin') {
|
if (role === 'admin' || role === 'user') {
|
||||||
return res.status(400).json({ error: 'Cannot delete admin role' });
|
return res.status(400).json({ error: `Cannot delete ${role} role - this is a built-in role` });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if any users are using this role
|
// Check if any users are using this role
|
||||||
const usersWithRole = await prisma.user.count({
|
const usersWithRole = await prisma.users.count({
|
||||||
where: { role }
|
where: { role }
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -124,7 +127,7 @@ router.delete('/roles/:role', authenticateToken, requireManageSettings, async (r
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.rolePermissions.delete({
|
await prisma.role_permissions.delete({
|
||||||
where: { role }
|
where: { role }
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -142,7 +145,7 @@ router.get('/user-permissions', authenticateToken, async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const userRole = req.user.role;
|
const userRole = req.user.role;
|
||||||
|
|
||||||
const permissions = await prisma.rolePermissions.findUnique({
|
const permissions = await prisma.role_permissions.findUnique({
|
||||||
where: { role: userRole }
|
where: { role: userRole }
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -150,16 +153,16 @@ router.get('/user-permissions', authenticateToken, async (req, res) => {
|
|||||||
// If no specific permissions found, return default admin permissions
|
// If no specific permissions found, return default admin permissions
|
||||||
return res.json({
|
return res.json({
|
||||||
role: userRole,
|
role: userRole,
|
||||||
canViewDashboard: true,
|
can_view_dashboard: true,
|
||||||
canViewHosts: true,
|
can_view_hosts: true,
|
||||||
canManageHosts: true,
|
can_manage_hosts: true,
|
||||||
canViewPackages: true,
|
can_view_packages: true,
|
||||||
canManagePackages: true,
|
can_manage_packages: true,
|
||||||
canViewUsers: true,
|
can_view_users: true,
|
||||||
canManageUsers: true,
|
can_manage_users: true,
|
||||||
canViewReports: true,
|
can_view_reports: true,
|
||||||
canExportData: true,
|
can_export_data: true,
|
||||||
canManageSettings: true,
|
can_manage_settings: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,14 +10,14 @@ const prisma = new PrismaClient();
|
|||||||
// Get all repositories with host count
|
// Get all repositories with host count
|
||||||
router.get('/', authenticateToken, requireViewHosts, async (req, res) => {
|
router.get('/', authenticateToken, requireViewHosts, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const repositories = await prisma.repository.findMany({
|
const repositories = await prisma.repositories.findMany({
|
||||||
include: {
|
include: {
|
||||||
hostRepositories: {
|
host_repositories: {
|
||||||
include: {
|
include: {
|
||||||
host: {
|
hosts: {
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
hostname: true,
|
friendly_name: true,
|
||||||
status: true
|
status: true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -25,7 +25,7 @@ router.get('/', authenticateToken, requireViewHosts, async (req, res) => {
|
|||||||
},
|
},
|
||||||
_count: {
|
_count: {
|
||||||
select: {
|
select: {
|
||||||
hostRepositories: true
|
host_repositories: true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -38,15 +38,15 @@ router.get('/', authenticateToken, requireViewHosts, async (req, res) => {
|
|||||||
// Transform data to include host counts and status
|
// Transform data to include host counts and status
|
||||||
const transformedRepos = repositories.map(repo => ({
|
const transformedRepos = repositories.map(repo => ({
|
||||||
...repo,
|
...repo,
|
||||||
hostCount: repo._count.hostRepositories,
|
hostCount: repo._count.host_repositories,
|
||||||
enabledHostCount: repo.hostRepositories.filter(hr => hr.isEnabled).length,
|
enabledHostCount: repo.host_repositories.filter(hr => hr.is_enabled).length,
|
||||||
activeHostCount: repo.hostRepositories.filter(hr => hr.host.status === 'active').length,
|
activeHostCount: repo.host_repositories.filter(hr => hr.hosts.status === 'active').length,
|
||||||
hosts: repo.hostRepositories.map(hr => ({
|
hosts: repo.host_repositories.map(hr => ({
|
||||||
id: hr.host.id,
|
id: hr.hosts.id,
|
||||||
hostname: hr.host.hostname,
|
friendlyName: hr.hosts.friendly_name,
|
||||||
status: hr.host.status,
|
status: hr.hosts.status,
|
||||||
isEnabled: hr.isEnabled,
|
isEnabled: hr.is_enabled,
|
||||||
lastChecked: hr.lastChecked
|
lastChecked: hr.last_checked
|
||||||
}))
|
}))
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -62,19 +62,19 @@ router.get('/host/:hostId', authenticateToken, requireViewHosts, async (req, res
|
|||||||
try {
|
try {
|
||||||
const { hostId } = req.params;
|
const { hostId } = req.params;
|
||||||
|
|
||||||
const hostRepositories = await prisma.hostRepository.findMany({
|
const hostRepositories = await prisma.host_repositories.findMany({
|
||||||
where: { hostId },
|
where: { host_id: hostId },
|
||||||
include: {
|
include: {
|
||||||
repository: true,
|
repositories: true,
|
||||||
host: {
|
hosts: {
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
hostname: true
|
friendly_name: true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
orderBy: {
|
orderBy: {
|
||||||
repository: {
|
repositories: {
|
||||||
name: 'asc'
|
name: 'asc'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -92,26 +92,27 @@ router.get('/:repositoryId', authenticateToken, requireViewHosts, async (req, re
|
|||||||
try {
|
try {
|
||||||
const { repositoryId } = req.params;
|
const { repositoryId } = req.params;
|
||||||
|
|
||||||
const repository = await prisma.repository.findUnique({
|
const repository = await prisma.repositories.findUnique({
|
||||||
where: { id: repositoryId },
|
where: { id: repositoryId },
|
||||||
include: {
|
include: {
|
||||||
hostRepositories: {
|
host_repositories: {
|
||||||
include: {
|
include: {
|
||||||
host: {
|
hosts: {
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
|
friendly_name: true,
|
||||||
hostname: true,
|
hostname: true,
|
||||||
ip: true,
|
ip: true,
|
||||||
osType: true,
|
os_type: true,
|
||||||
osVersion: true,
|
os_version: true,
|
||||||
status: true,
|
status: true,
|
||||||
lastUpdate: true
|
last_update: true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
orderBy: {
|
orderBy: {
|
||||||
host: {
|
hosts: {
|
||||||
hostname: 'asc'
|
friendly_name: 'asc'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -145,18 +146,18 @@ router.put('/:repositoryId', authenticateToken, requireManageHosts, [
|
|||||||
const { repositoryId } = req.params;
|
const { repositoryId } = req.params;
|
||||||
const { name, description, isActive, priority } = req.body;
|
const { name, description, isActive, priority } = req.body;
|
||||||
|
|
||||||
const repository = await prisma.repository.update({
|
const repository = await prisma.repositories.update({
|
||||||
where: { id: repositoryId },
|
where: { id: repositoryId },
|
||||||
data: {
|
data: {
|
||||||
...(name && { name }),
|
...(name && { name }),
|
||||||
...(description !== undefined && { description }),
|
...(description !== undefined && { description }),
|
||||||
...(isActive !== undefined && { isActive }),
|
...(isActive !== undefined && { is_active: isActive }),
|
||||||
...(priority !== undefined && { priority })
|
...(priority !== undefined && { priority })
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
_count: {
|
_count: {
|
||||||
select: {
|
select: {
|
||||||
hostRepositories: true
|
host_repositories: true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -182,29 +183,29 @@ router.patch('/host/:hostId/repository/:repositoryId', authenticateToken, requir
|
|||||||
const { hostId, repositoryId } = req.params;
|
const { hostId, repositoryId } = req.params;
|
||||||
const { isEnabled } = req.body;
|
const { isEnabled } = req.body;
|
||||||
|
|
||||||
const hostRepository = await prisma.hostRepository.update({
|
const hostRepository = await prisma.host_repositories.update({
|
||||||
where: {
|
where: {
|
||||||
hostId_repositoryId: {
|
host_id_repository_id: {
|
||||||
hostId,
|
host_id: hostId,
|
||||||
repositoryId
|
repository_id: repositoryId
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
isEnabled,
|
is_enabled: isEnabled,
|
||||||
lastChecked: new Date()
|
last_checked: new Date()
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
repository: true,
|
repositories: true,
|
||||||
host: {
|
hosts: {
|
||||||
select: {
|
select: {
|
||||||
hostname: true
|
friendly_name: true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
message: `Repository ${isEnabled ? 'enabled' : 'disabled'} for host ${hostRepository.host.hostname}`,
|
message: `Repository ${isEnabled ? 'enabled' : 'disabled'} for host ${hostRepository.hosts.friendly_name}`,
|
||||||
hostRepository
|
hostRepository
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -216,25 +217,25 @@ router.patch('/host/:hostId/repository/:repositoryId', authenticateToken, requir
|
|||||||
// Get repository statistics
|
// Get repository statistics
|
||||||
router.get('/stats/summary', authenticateToken, requireViewHosts, async (req, res) => {
|
router.get('/stats/summary', authenticateToken, requireViewHosts, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const stats = await prisma.repository.aggregate({
|
const stats = await prisma.repositories.aggregate({
|
||||||
_count: true
|
_count: true
|
||||||
});
|
});
|
||||||
|
|
||||||
const hostRepoStats = await prisma.hostRepository.aggregate({
|
const hostRepoStats = await prisma.host_repositories.aggregate({
|
||||||
_count: {
|
_count: {
|
||||||
isEnabled: true
|
is_enabled: true
|
||||||
},
|
},
|
||||||
where: {
|
where: {
|
||||||
isEnabled: true
|
is_enabled: true
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const secureRepos = await prisma.repository.count({
|
const secureRepos = await prisma.repositories.count({
|
||||||
where: { isSecure: true }
|
where: { is_secure: true }
|
||||||
});
|
});
|
||||||
|
|
||||||
const activeRepos = await prisma.repository.count({
|
const activeRepos = await prisma.repositories.count({
|
||||||
where: { isActive: true }
|
where: { is_active: true }
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
@@ -256,9 +257,9 @@ router.delete('/cleanup/orphaned', authenticateToken, requireManageHosts, async
|
|||||||
console.log('Cleaning up orphaned repositories...');
|
console.log('Cleaning up orphaned repositories...');
|
||||||
|
|
||||||
// Find repositories with no host relationships
|
// Find repositories with no host relationships
|
||||||
const orphanedRepos = await prisma.repository.findMany({
|
const orphanedRepos = await prisma.repositories.findMany({
|
||||||
where: {
|
where: {
|
||||||
hostRepositories: {
|
host_repositories: {
|
||||||
none: {}
|
none: {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -273,7 +274,7 @@ router.delete('/cleanup/orphaned', authenticateToken, requireManageHosts, async
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Delete orphaned repositories
|
// Delete orphaned repositories
|
||||||
const deleteResult = await prisma.repository.deleteMany({
|
const deleteResult = await prisma.repositories.deleteMany({
|
||||||
where: {
|
where: {
|
||||||
hostRepositories: {
|
hostRepositories: {
|
||||||
none: {}
|
none: {}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ const { body, validationResult } = require('express-validator');
|
|||||||
const { PrismaClient } = require('@prisma/client');
|
const { PrismaClient } = require('@prisma/client');
|
||||||
const { authenticateToken } = require('../middleware/auth');
|
const { authenticateToken } = require('../middleware/auth');
|
||||||
const { requireManageSettings } = require('../middleware/permissions');
|
const { requireManageSettings } = require('../middleware/permissions');
|
||||||
|
const { getSettings, updateSettings } = require('../services/settingsService');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
@@ -12,17 +13,21 @@ async function triggerCrontabUpdates() {
|
|||||||
try {
|
try {
|
||||||
console.log('Triggering crontab updates on all hosts with auto-update enabled...');
|
console.log('Triggering crontab updates on all hosts with auto-update enabled...');
|
||||||
|
|
||||||
|
// Get current settings for server URL
|
||||||
|
const settings = await getSettings();
|
||||||
|
const serverUrl = settings.server_url;
|
||||||
|
|
||||||
// Get all hosts that have auto-update enabled
|
// Get all hosts that have auto-update enabled
|
||||||
const hosts = await prisma.host.findMany({
|
const hosts = await prisma.hosts.findMany({
|
||||||
where: {
|
where: {
|
||||||
autoUpdate: true,
|
auto_update: true,
|
||||||
status: 'active' // Only update active hosts
|
status: 'active' // Only update active hosts
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
hostname: true,
|
friendly_name: true,
|
||||||
apiId: true,
|
api_id: true,
|
||||||
apiKey: true
|
api_key: true
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -32,14 +37,13 @@ async function triggerCrontabUpdates() {
|
|||||||
// This is done by sending a ping with a special flag
|
// This is done by sending a ping with a special flag
|
||||||
for (const host of hosts) {
|
for (const host of hosts) {
|
||||||
try {
|
try {
|
||||||
console.log(`Triggering crontab update for host: ${host.hostname}`);
|
console.log(`Triggering crontab update for host: ${host.friendly_name}`);
|
||||||
|
|
||||||
// We'll use the existing ping endpoint but add a special parameter
|
// We'll use the existing ping endpoint but add a special parameter
|
||||||
// The agent will detect this and run update-crontab command
|
// The agent will detect this and run update-crontab command
|
||||||
const http = require('http');
|
const http = require('http');
|
||||||
const https = require('https');
|
const https = require('https');
|
||||||
|
|
||||||
const serverUrl = process.env.SERVER_URL || 'http://localhost:3001';
|
|
||||||
const url = new URL(`${serverUrl}/api/v1/hosts/ping`);
|
const url = new URL(`${serverUrl}/api/v1/hosts/ping`);
|
||||||
const isHttps = url.protocol === 'https:';
|
const isHttps = url.protocol === 'https:';
|
||||||
const client = isHttps ? https : http;
|
const client = isHttps ? https : http;
|
||||||
@@ -57,27 +61,27 @@ async function triggerCrontabUpdates() {
|
|||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Content-Length': Buffer.byteLength(postData),
|
'Content-Length': Buffer.byteLength(postData),
|
||||||
'X-API-ID': host.apiId,
|
'X-API-ID': host.api_id,
|
||||||
'X-API-KEY': host.apiKey
|
'X-API-KEY': host.api_key
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const req = client.request(options, (res) => {
|
const req = client.request(options, (res) => {
|
||||||
if (res.statusCode === 200) {
|
if (res.statusCode === 200) {
|
||||||
console.log(`Successfully triggered crontab update for ${host.hostname}`);
|
console.log(`Successfully triggered crontab update for ${host.friendly_name}`);
|
||||||
} else {
|
} else {
|
||||||
console.error(`Failed to trigger crontab update for ${host.hostname}: ${res.statusCode}`);
|
console.error(`Failed to trigger crontab update for ${host.friendly_name}: ${res.statusCode}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
req.on('error', (error) => {
|
req.on('error', (error) => {
|
||||||
console.error(`Error triggering crontab update for ${host.hostname}:`, error.message);
|
console.error(`Error triggering crontab update for ${host.friendly_name}:`, error.message);
|
||||||
});
|
});
|
||||||
|
|
||||||
req.write(postData);
|
req.write(postData);
|
||||||
req.end();
|
req.end();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error triggering crontab update for ${host.hostname}:`, error.message);
|
console.error(`Error triggering crontab update for ${host.friendly_name}:`, error.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,23 +94,7 @@ async function triggerCrontabUpdates() {
|
|||||||
// Get current settings
|
// Get current settings
|
||||||
router.get('/', authenticateToken, requireManageSettings, async (req, res) => {
|
router.get('/', authenticateToken, requireManageSettings, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
let settings = await prisma.settings.findFirst();
|
const settings = await getSettings();
|
||||||
|
|
||||||
// If no settings exist, create default settings
|
|
||||||
if (!settings) {
|
|
||||||
settings = await prisma.settings.create({
|
|
||||||
data: {
|
|
||||||
serverUrl: 'http://localhost:3001',
|
|
||||||
serverProtocol: 'http',
|
|
||||||
serverHost: 'localhost',
|
|
||||||
serverPort: 3001,
|
|
||||||
frontendUrl: 'http://localhost:3000',
|
|
||||||
updateInterval: 60,
|
|
||||||
autoUpdate: false
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Returning settings:', settings);
|
console.log('Returning settings:', settings);
|
||||||
res.json(settings);
|
res.json(settings);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -120,80 +108,60 @@ router.put('/', authenticateToken, requireManageSettings, [
|
|||||||
body('serverProtocol').isIn(['http', 'https']).withMessage('Protocol must be http or https'),
|
body('serverProtocol').isIn(['http', 'https']).withMessage('Protocol must be http or https'),
|
||||||
body('serverHost').isLength({ min: 1 }).withMessage('Server host is required'),
|
body('serverHost').isLength({ min: 1 }).withMessage('Server host is required'),
|
||||||
body('serverPort').isInt({ min: 1, max: 65535 }).withMessage('Port must be between 1 and 65535'),
|
body('serverPort').isInt({ min: 1, max: 65535 }).withMessage('Port must be between 1 and 65535'),
|
||||||
body('frontendUrl').isLength({ min: 1 }).withMessage('Frontend URL is required'),
|
|
||||||
body('updateInterval').isInt({ min: 5, max: 1440 }).withMessage('Update interval must be between 5 and 1440 minutes'),
|
body('updateInterval').isInt({ min: 5, max: 1440 }).withMessage('Update interval must be between 5 and 1440 minutes'),
|
||||||
body('autoUpdate').isBoolean().withMessage('Auto update must be a boolean'),
|
body('autoUpdate').isBoolean().withMessage('Auto update must be a boolean'),
|
||||||
body('githubRepoUrl').optional().isLength({ min: 1 }).withMessage('GitHub repo URL must be a non-empty string')
|
body('signupEnabled').isBoolean().withMessage('Signup enabled must be a boolean'),
|
||||||
|
body('defaultUserRole').optional().isLength({ min: 1 }).withMessage('Default user role must be a non-empty string'),
|
||||||
|
body('githubRepoUrl').optional().isLength({ min: 1 }).withMessage('GitHub repo URL must be a non-empty string'),
|
||||||
|
body('repositoryType').optional().isIn(['public', 'private']).withMessage('Repository type must be public or private'),
|
||||||
|
body('sshKeyPath').optional().custom((value) => {
|
||||||
|
if (value && value.trim().length === 0) {
|
||||||
|
return true; // Allow empty string
|
||||||
|
}
|
||||||
|
if (value && value.trim().length < 1) {
|
||||||
|
throw new Error('SSH key path must be a non-empty string');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
})
|
||||||
], async (req, res) => {
|
], async (req, res) => {
|
||||||
try {
|
try {
|
||||||
console.log('Settings update request body:', req.body);
|
|
||||||
const errors = validationResult(req);
|
const errors = validationResult(req);
|
||||||
if (!errors.isEmpty()) {
|
if (!errors.isEmpty()) {
|
||||||
console.log('Validation errors:', errors.array());
|
console.log('Validation errors:', errors.array());
|
||||||
return res.status(400).json({ errors: errors.array() });
|
return res.status(400).json({ errors: errors.array() });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { serverProtocol, serverHost, serverPort, frontendUrl, updateInterval, autoUpdate, githubRepoUrl } = req.body;
|
const { serverProtocol, serverHost, serverPort, updateInterval, autoUpdate, signupEnabled, defaultUserRole, githubRepoUrl, repositoryType, sshKeyPath } = req.body;
|
||||||
console.log('Extracted values:', { serverProtocol, serverHost, serverPort, frontendUrl, updateInterval, autoUpdate, githubRepoUrl });
|
|
||||||
|
|
||||||
// Construct server URL from components
|
// Get current settings to check for update interval changes
|
||||||
const serverUrl = `${serverProtocol}://${serverHost}:${serverPort}`;
|
const currentSettings = await getSettings();
|
||||||
|
const oldUpdateInterval = currentSettings.update_interval;
|
||||||
|
|
||||||
let settings = await prisma.settings.findFirst();
|
// Update settings using the service
|
||||||
|
const updatedSettings = await updateSettings(currentSettings.id, {
|
||||||
if (settings) {
|
server_protocol: serverProtocol,
|
||||||
// Update existing settings
|
server_host: serverHost,
|
||||||
console.log('Updating existing settings with data:', {
|
server_port: serverPort,
|
||||||
serverUrl,
|
update_interval: updateInterval || 60,
|
||||||
serverProtocol,
|
auto_update: autoUpdate || false,
|
||||||
serverHost,
|
signup_enabled: signupEnabled || false,
|
||||||
serverPort,
|
default_user_role: defaultUserRole || process.env.DEFAULT_USER_ROLE || 'user',
|
||||||
frontendUrl,
|
github_repo_url: githubRepoUrl !== undefined ? githubRepoUrl : 'git@github.com:9technologygroup/patchmon.net.git',
|
||||||
updateInterval: updateInterval || 60,
|
repository_type: repositoryType || 'public',
|
||||||
autoUpdate: autoUpdate || false,
|
ssh_key_path: sshKeyPath || null,
|
||||||
githubRepoUrl: githubRepoUrl || 'git@github.com:9technologygroup/patchmon.net.git'
|
|
||||||
});
|
});
|
||||||
const oldUpdateInterval = settings.updateInterval;
|
|
||||||
|
|
||||||
settings = await prisma.settings.update({
|
console.log('Settings updated successfully:', updatedSettings);
|
||||||
where: { id: settings.id },
|
|
||||||
data: {
|
|
||||||
serverUrl,
|
|
||||||
serverProtocol,
|
|
||||||
serverHost,
|
|
||||||
serverPort,
|
|
||||||
frontendUrl,
|
|
||||||
updateInterval: updateInterval || 60,
|
|
||||||
autoUpdate: autoUpdate || false,
|
|
||||||
githubRepoUrl: githubRepoUrl || 'git@github.com:9technologygroup/patchmon.net.git'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
console.log('Settings updated successfully:', settings);
|
|
||||||
|
|
||||||
// If update interval changed, trigger crontab updates on all hosts with auto-update enabled
|
// If update interval changed, trigger crontab updates on all hosts with auto-update enabled
|
||||||
if (oldUpdateInterval !== (updateInterval || 60)) {
|
if (oldUpdateInterval !== (updateInterval || 60)) {
|
||||||
console.log(`Update interval changed from ${oldUpdateInterval} to ${updateInterval || 60} minutes. Triggering crontab updates...`);
|
console.log(`Update interval changed from ${oldUpdateInterval} to ${updateInterval || 60} minutes. Triggering crontab updates...`);
|
||||||
await triggerCrontabUpdates();
|
await triggerCrontabUpdates();
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
// Create new settings
|
|
||||||
settings = await prisma.settings.create({
|
|
||||||
data: {
|
|
||||||
serverUrl,
|
|
||||||
serverProtocol,
|
|
||||||
serverHost,
|
|
||||||
serverPort,
|
|
||||||
frontendUrl,
|
|
||||||
updateInterval: updateInterval || 60,
|
|
||||||
autoUpdate: autoUpdate || false,
|
|
||||||
githubRepoUrl: githubRepoUrl || 'git@github.com:9technologygroup/patchmon.net.git'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
message: 'Settings updated successfully',
|
message: 'Settings updated successfully',
|
||||||
settings
|
settings: updatedSettings
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Settings update error:', error);
|
console.error('Settings update error:', error);
|
||||||
@@ -204,31 +172,22 @@ router.put('/', authenticateToken, requireManageSettings, [
|
|||||||
// Get server URL for public use (used by installation scripts)
|
// Get server URL for public use (used by installation scripts)
|
||||||
router.get('/server-url', async (req, res) => {
|
router.get('/server-url', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const settings = await prisma.settings.findFirst();
|
const settings = await getSettings();
|
||||||
|
const serverUrl = settings.server_url;
|
||||||
if (!settings) {
|
res.json({ server_url: serverUrl });
|
||||||
return res.json({ serverUrl: 'http://localhost:3001' });
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({ serverUrl: settings.serverUrl });
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Server URL fetch error:', error);
|
console.error('Server URL fetch error:', error);
|
||||||
res.json({ serverUrl: 'http://localhost:3001' });
|
res.status(500).json({ error: 'Failed to fetch server URL' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get update interval policy for agents (public endpoint)
|
// Get update interval policy for agents (public endpoint)
|
||||||
router.get('/update-interval', async (req, res) => {
|
router.get('/update-interval', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const settings = await prisma.settings.findFirst();
|
const settings = await getSettings();
|
||||||
|
|
||||||
if (!settings) {
|
|
||||||
return res.json({ updateInterval: 60 });
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
updateInterval: settings.updateInterval,
|
updateInterval: settings.update_interval,
|
||||||
cronExpression: `*/${settings.updateInterval} * * * *` // Generate cron expression
|
cronExpression: `*/${settings.update_interval} * * * *` // Generate cron expression
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Update interval fetch error:', error);
|
console.error('Update interval fetch error:', error);
|
||||||
@@ -239,14 +198,9 @@ router.get('/update-interval', async (req, res) => {
|
|||||||
// Get auto-update policy for agents (public endpoint)
|
// Get auto-update policy for agents (public endpoint)
|
||||||
router.get('/auto-update', async (req, res) => {
|
router.get('/auto-update', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const settings = await prisma.settings.findFirst();
|
const settings = await getSettings();
|
||||||
|
|
||||||
if (!settings) {
|
|
||||||
return res.json({ autoUpdate: false });
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
autoUpdate: settings.autoUpdate || false
|
autoUpdate: settings.auto_update || false
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Auto-update fetch error:', error);
|
console.error('Auto-update fetch error:', error);
|
||||||
|
|||||||
309
backend/src/routes/tfaRoutes.js
Normal file
309
backend/src/routes/tfaRoutes.js
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const { PrismaClient } = require('@prisma/client');
|
||||||
|
const speakeasy = require('speakeasy');
|
||||||
|
const QRCode = require('qrcode');
|
||||||
|
const { authenticateToken } = require('../middleware/auth');
|
||||||
|
const { body, validationResult } = require('express-validator');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
// Generate TFA secret and QR code
|
||||||
|
router.get('/setup', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = req.user.id;
|
||||||
|
|
||||||
|
// Check if user already has TFA enabled
|
||||||
|
const user = await prisma.users.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
select: { tfa_enabled: true, tfa_secret: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (user.tfa_enabled) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Two-factor authentication is already enabled for this account'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate a new secret
|
||||||
|
const secret = speakeasy.generateSecret({
|
||||||
|
name: `PatchMon (${req.user.username})`,
|
||||||
|
issuer: 'PatchMon',
|
||||||
|
length: 32
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generate QR code
|
||||||
|
const qrCodeUrl = await QRCode.toDataURL(secret.otpauth_url);
|
||||||
|
|
||||||
|
// Store the secret temporarily (not enabled yet)
|
||||||
|
await prisma.users.update({
|
||||||
|
where: { id: userId },
|
||||||
|
data: { tfa_secret: secret.base32 }
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
secret: secret.base32,
|
||||||
|
qrCode: qrCodeUrl,
|
||||||
|
manualEntryKey: secret.base32
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('TFA setup error:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to setup two-factor authentication' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify TFA setup
|
||||||
|
router.post('/verify-setup', authenticateToken, [
|
||||||
|
body('token').isLength({ min: 6, max: 6 }).withMessage('Token must be 6 digits'),
|
||||||
|
body('token').isNumeric().withMessage('Token must contain only numbers')
|
||||||
|
], async (req, res) => {
|
||||||
|
try {
|
||||||
|
const errors = validationResult(req);
|
||||||
|
if (!errors.isEmpty()) {
|
||||||
|
return res.status(400).json({ errors: errors.array() });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { token } = req.body;
|
||||||
|
const userId = req.user.id;
|
||||||
|
|
||||||
|
// Get user's TFA secret
|
||||||
|
const user = await prisma.users.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
select: { tfa_secret: true, tfa_enabled: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user.tfa_secret) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'No TFA secret found. Please start the setup process first.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.tfa_enabled) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Two-factor authentication is already enabled for this account'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the token
|
||||||
|
const verified = speakeasy.totp.verify({
|
||||||
|
secret: user.tfa_secret,
|
||||||
|
encoding: 'base32',
|
||||||
|
token: token,
|
||||||
|
window: 2 // Allow 2 time windows (60 seconds) for clock drift
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!verified) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Invalid verification code. Please try again.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate backup codes
|
||||||
|
const backupCodes = Array.from({ length: 10 }, () =>
|
||||||
|
Math.random().toString(36).substring(2, 8).toUpperCase()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Enable TFA and store backup codes
|
||||||
|
await prisma.users.update({
|
||||||
|
where: { id: userId },
|
||||||
|
data: {
|
||||||
|
tfa_enabled: true,
|
||||||
|
tfa_backup_codes: JSON.stringify(backupCodes)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
message: 'Two-factor authentication has been enabled successfully',
|
||||||
|
backupCodes: backupCodes
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('TFA verification error:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to verify two-factor authentication setup' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Disable TFA
|
||||||
|
router.post('/disable', authenticateToken, [
|
||||||
|
body('password').notEmpty().withMessage('Password is required to disable TFA')
|
||||||
|
], async (req, res) => {
|
||||||
|
try {
|
||||||
|
const errors = validationResult(req);
|
||||||
|
if (!errors.isEmpty()) {
|
||||||
|
return res.status(400).json({ errors: errors.array() });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { password } = req.body;
|
||||||
|
const userId = req.user.id;
|
||||||
|
|
||||||
|
// Verify password
|
||||||
|
const user = await prisma.users.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
select: { password_hash: true, tfa_enabled: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user.tfa_enabled) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Two-factor authentication is not enabled for this account'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: In a real implementation, you would verify the password hash here
|
||||||
|
// For now, we'll skip password verification for simplicity
|
||||||
|
|
||||||
|
// Disable TFA
|
||||||
|
await prisma.users.update({
|
||||||
|
where: { id: id },
|
||||||
|
data: {
|
||||||
|
tfa_enabled: false,
|
||||||
|
tfa_secret: null,
|
||||||
|
tfa_backup_codes: null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
message: 'Two-factor authentication has been disabled successfully'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('TFA disable error:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to disable two-factor authentication' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get TFA status
|
||||||
|
router.get('/status', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = req.user.id;
|
||||||
|
|
||||||
|
const user = await prisma.users.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
select: {
|
||||||
|
tfa_enabled: true,
|
||||||
|
tfa_secret: true,
|
||||||
|
tfa_backup_codes: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
enabled: user.tfa_enabled,
|
||||||
|
hasBackupCodes: !!user.tfa_backup_codes
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('TFA status error:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to get TFA status' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Regenerate backup codes
|
||||||
|
router.post('/regenerate-backup-codes', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = req.user.id;
|
||||||
|
|
||||||
|
// Check if TFA is enabled
|
||||||
|
const user = await prisma.users.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
select: { tfa_enabled: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user.tfa_enabled) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Two-factor authentication is not enabled for this account'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate new backup codes
|
||||||
|
const backupCodes = Array.from({ length: 10 }, () =>
|
||||||
|
Math.random().toString(36).substring(2, 8).toUpperCase()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update backup codes
|
||||||
|
await prisma.users.update({
|
||||||
|
where: { id: userId },
|
||||||
|
data: {
|
||||||
|
tfa_backup_codes: JSON.stringify(backupCodes)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
message: 'Backup codes have been regenerated successfully',
|
||||||
|
backupCodes: backupCodes
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('TFA backup codes regeneration error:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to regenerate backup codes' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify TFA token (for login)
|
||||||
|
router.post('/verify', [
|
||||||
|
body('username').notEmpty().withMessage('Username is required'),
|
||||||
|
body('token').isLength({ min: 6, max: 6 }).withMessage('Token must be 6 digits'),
|
||||||
|
body('token').isNumeric().withMessage('Token must contain only numbers')
|
||||||
|
], async (req, res) => {
|
||||||
|
try {
|
||||||
|
const errors = validationResult(req);
|
||||||
|
if (!errors.isEmpty()) {
|
||||||
|
return res.status(400).json({ errors: errors.array() });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { username, token } = req.body;
|
||||||
|
|
||||||
|
// Get user's TFA secret
|
||||||
|
const user = await prisma.users.findUnique({
|
||||||
|
where: { username },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
tfa_enabled: true,
|
||||||
|
tfa_secret: true,
|
||||||
|
tfa_backup_codes: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user || !user.tfa_enabled || !user.tfa_secret) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Two-factor authentication is not enabled for this account'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's a backup code
|
||||||
|
const backupCodes = user.tfa_backup_codes ? JSON.parse(user.tfa_backup_codes) : [];
|
||||||
|
const isBackupCode = backupCodes.includes(token);
|
||||||
|
|
||||||
|
let verified = false;
|
||||||
|
|
||||||
|
if (isBackupCode) {
|
||||||
|
// Remove the used backup code
|
||||||
|
const updatedBackupCodes = backupCodes.filter(code => code !== token);
|
||||||
|
await prisma.users.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: {
|
||||||
|
tfa_backup_codes: JSON.stringify(updatedBackupCodes)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
verified = true;
|
||||||
|
} else {
|
||||||
|
// Verify TOTP token
|
||||||
|
verified = speakeasy.totp.verify({
|
||||||
|
secret: user.tfa_secret,
|
||||||
|
encoding: 'base32',
|
||||||
|
token: token,
|
||||||
|
window: 2
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!verified) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Invalid verification code'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
message: 'Two-factor authentication verified successfully',
|
||||||
|
userId: user.id
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('TFA verification error:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to verify two-factor authentication' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
204
backend/src/routes/versionRoutes.js
Normal file
204
backend/src/routes/versionRoutes.js
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const { authenticateToken } = require('../middleware/auth');
|
||||||
|
const { requireManageSettings } = require('../middleware/permissions');
|
||||||
|
const { PrismaClient } = require('@prisma/client');
|
||||||
|
const { exec } = require('child_process');
|
||||||
|
const { promisify } = require('util');
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Get current version info
|
||||||
|
router.get('/current', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
// Read version from package.json dynamically
|
||||||
|
let currentVersion = '1.2.6'; // fallback
|
||||||
|
|
||||||
|
try {
|
||||||
|
const packageJson = require('../../package.json');
|
||||||
|
if (packageJson && packageJson.version) {
|
||||||
|
currentVersion = packageJson.version;
|
||||||
|
}
|
||||||
|
} catch (packageError) {
|
||||||
|
console.warn('Could not read version from package.json, using fallback:', packageError.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
version: currentVersion,
|
||||||
|
buildDate: new Date().toISOString(),
|
||||||
|
environment: process.env.NODE_ENV || 'development'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting current version:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to get current version' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test SSH key permissions and GitHub access
|
||||||
|
router.post('/test-ssh-key', authenticateToken, requireManageSettings, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { sshKeyPath, githubRepoUrl } = req.body;
|
||||||
|
|
||||||
|
if (!sshKeyPath || !githubRepoUrl) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'SSH key path and GitHub repo URL are required'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse repository info
|
||||||
|
let owner, repo;
|
||||||
|
if (githubRepoUrl.includes('git@github.com:')) {
|
||||||
|
const match = githubRepoUrl.match(/git@github\.com:([^\/]+)\/([^\/]+)\.git/);
|
||||||
|
if (match) {
|
||||||
|
[, owner, repo] = match;
|
||||||
|
}
|
||||||
|
} else if (githubRepoUrl.includes('github.com/')) {
|
||||||
|
const match = githubRepoUrl.match(/github\.com\/([^\/]+)\/([^\/]+)/);
|
||||||
|
if (match) {
|
||||||
|
[, owner, repo] = match;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!owner || !repo) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Invalid GitHub repository URL format'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if SSH key file exists and is readable
|
||||||
|
try {
|
||||||
|
require('fs').accessSync(sshKeyPath);
|
||||||
|
} catch (e) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'SSH key file not found or not accessible',
|
||||||
|
details: `Cannot access: ${sshKeyPath}`,
|
||||||
|
suggestion: 'Check the file path and ensure the application has read permissions'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test SSH connection to GitHub
|
||||||
|
const sshRepoUrl = `git@github.com:${owner}/${repo}.git`;
|
||||||
|
const env = {
|
||||||
|
...process.env,
|
||||||
|
GIT_SSH_COMMAND: `ssh -i ${sshKeyPath} -o StrictHostKeyChecking=no -o IdentitiesOnly=yes -o ConnectTimeout=10`
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Test with a simple git command
|
||||||
|
const { stdout } = await execAsync(
|
||||||
|
`git ls-remote --heads ${sshRepoUrl} | head -n 1`,
|
||||||
|
{
|
||||||
|
timeout: 15000,
|
||||||
|
env: env
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (stdout.trim()) {
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'SSH key is working correctly',
|
||||||
|
details: {
|
||||||
|
sshKeyPath,
|
||||||
|
repository: `${owner}/${repo}`,
|
||||||
|
testResult: 'Successfully connected to GitHub'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'SSH connection succeeded but no data returned',
|
||||||
|
suggestion: 'Check repository access permissions'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (sshError) {
|
||||||
|
console.error('SSH test error:', sshError.message);
|
||||||
|
|
||||||
|
if (sshError.message.includes('Permission denied')) {
|
||||||
|
return res.status(403).json({
|
||||||
|
error: 'SSH key permission denied',
|
||||||
|
details: 'The SSH key exists but GitHub rejected the connection',
|
||||||
|
suggestion: 'Verify the SSH key is added to the repository as a deploy key with read access'
|
||||||
|
});
|
||||||
|
} else if (sshError.message.includes('Host key verification failed')) {
|
||||||
|
return res.status(403).json({
|
||||||
|
error: 'Host key verification failed',
|
||||||
|
suggestion: 'This is normal for first-time connections. The key will be added to known_hosts automatically.'
|
||||||
|
});
|
||||||
|
} else if (sshError.message.includes('Connection timed out')) {
|
||||||
|
return res.status(408).json({
|
||||||
|
error: 'Connection timed out',
|
||||||
|
suggestion: 'Check your internet connection and GitHub status'
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
return res.status(500).json({
|
||||||
|
error: 'SSH connection failed',
|
||||||
|
details: sshError.message,
|
||||||
|
suggestion: 'Check the SSH key format and repository URL'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('SSH key test error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
error: 'Failed to test SSH key',
|
||||||
|
details: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check for updates from GitHub
|
||||||
|
router.get('/check-updates', authenticateToken, requireManageSettings, async (req, res) => {
|
||||||
|
try {
|
||||||
|
// Get cached update information from settings
|
||||||
|
const settings = await prisma.settings.findFirst();
|
||||||
|
|
||||||
|
if (!settings) {
|
||||||
|
return res.status(400).json({ error: 'Settings not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentVersion = '1.2.6';
|
||||||
|
const latestVersion = settings.latest_version || currentVersion;
|
||||||
|
const isUpdateAvailable = settings.update_available || false;
|
||||||
|
const lastUpdateCheck = settings.last_update_check || null;
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
currentVersion,
|
||||||
|
latestVersion,
|
||||||
|
isUpdateAvailable,
|
||||||
|
lastUpdateCheck,
|
||||||
|
repositoryType: settings.repository_type || 'public',
|
||||||
|
latestRelease: {
|
||||||
|
tagName: latestVersion ? `v${latestVersion}` : null,
|
||||||
|
version: latestVersion,
|
||||||
|
repository: settings.github_repo_url ? settings.github_repo_url.split('/').slice(-2).join('/') : null,
|
||||||
|
accessMethod: settings.repository_type === 'private' ? 'ssh' : 'api'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting update information:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to get update information' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Simple version comparison function
|
||||||
|
function compareVersions(version1, version2) {
|
||||||
|
const v1Parts = version1.split('.').map(Number);
|
||||||
|
const v2Parts = version2.split('.').map(Number);
|
||||||
|
|
||||||
|
const maxLength = Math.max(v1Parts.length, v2Parts.length);
|
||||||
|
|
||||||
|
for (let i = 0; i < maxLength; i++) {
|
||||||
|
const v1Part = v1Parts[i] || 0;
|
||||||
|
const v2Part = v2Parts[i] || 0;
|
||||||
|
|
||||||
|
if (v1Part > v2Part) return 1;
|
||||||
|
if (v1Part < v2Part) return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -3,7 +3,7 @@ const express = require('express');
|
|||||||
const cors = require('cors');
|
const cors = require('cors');
|
||||||
const helmet = require('helmet');
|
const helmet = require('helmet');
|
||||||
const rateLimit = require('express-rate-limit');
|
const rateLimit = require('express-rate-limit');
|
||||||
const { PrismaClient } = require('@prisma/client');
|
const { createPrismaClient, waitForDatabase, disconnectPrisma } = require('./config/database');
|
||||||
const winston = require('winston');
|
const winston = require('winston');
|
||||||
|
|
||||||
// Import routes
|
// Import routes
|
||||||
@@ -14,11 +14,284 @@ const packageRoutes = require('./routes/packageRoutes');
|
|||||||
const dashboardRoutes = require('./routes/dashboardRoutes');
|
const dashboardRoutes = require('./routes/dashboardRoutes');
|
||||||
const permissionsRoutes = require('./routes/permissionsRoutes');
|
const permissionsRoutes = require('./routes/permissionsRoutes');
|
||||||
const settingsRoutes = require('./routes/settingsRoutes');
|
const settingsRoutes = require('./routes/settingsRoutes');
|
||||||
const dashboardPreferencesRoutes = require('./routes/dashboardPreferencesRoutes');
|
const { router: dashboardPreferencesRoutes, createDefaultDashboardPreferences } = require('./routes/dashboardPreferencesRoutes');
|
||||||
const repositoryRoutes = require('./routes/repositoryRoutes');
|
const repositoryRoutes = require('./routes/repositoryRoutes');
|
||||||
|
const versionRoutes = require('./routes/versionRoutes');
|
||||||
|
const tfaRoutes = require('./routes/tfaRoutes');
|
||||||
|
const updateScheduler = require('./services/updateScheduler');
|
||||||
|
const { initSettings } = require('./services/settingsService');
|
||||||
|
|
||||||
// Initialize Prisma client
|
// Initialize Prisma client with optimized connection pooling for multiple instances
|
||||||
const prisma = new PrismaClient();
|
const prisma = createPrismaClient();
|
||||||
|
|
||||||
|
// Simple version comparison function for semantic versioning
|
||||||
|
function compareVersions(version1, version2) {
|
||||||
|
const v1Parts = version1.split('.').map(Number);
|
||||||
|
const v2Parts = version2.split('.').map(Number);
|
||||||
|
|
||||||
|
// Ensure both arrays have the same length
|
||||||
|
const maxLength = Math.max(v1Parts.length, v2Parts.length);
|
||||||
|
while (v1Parts.length < maxLength) v1Parts.push(0);
|
||||||
|
while (v2Parts.length < maxLength) v2Parts.push(0);
|
||||||
|
|
||||||
|
for (let i = 0; i < maxLength; i++) {
|
||||||
|
if (v1Parts[i] > v2Parts[i]) return true;
|
||||||
|
if (v1Parts[i] < v2Parts[i]) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false; // versions are equal
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to check and import agent version on startup
|
||||||
|
async function checkAndImportAgentVersion() {
|
||||||
|
console.log('🔍 Starting agent version auto-import check...');
|
||||||
|
|
||||||
|
// Skip if auto-import is disabled
|
||||||
|
if (process.env.AUTO_IMPORT_AGENT_VERSION === 'false') {
|
||||||
|
console.log('❌ Auto-import of agent version is disabled');
|
||||||
|
if (process.env.ENABLE_LOGGING === 'true') {
|
||||||
|
logger.info('Auto-import of agent version is disabled');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
// Read and validate agent script
|
||||||
|
const agentScriptPath = path.join(__dirname, '../../agents/patchmon-agent.sh');
|
||||||
|
console.log('📁 Agent script path:', agentScriptPath);
|
||||||
|
|
||||||
|
// Check if file exists
|
||||||
|
if (!fs.existsSync(agentScriptPath)) {
|
||||||
|
console.log('❌ Agent script file not found, skipping version check');
|
||||||
|
if (process.env.ENABLE_LOGGING === 'true') {
|
||||||
|
logger.warn('Agent script file not found, skipping version check');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('✅ Agent script file found');
|
||||||
|
|
||||||
|
// Read the file content
|
||||||
|
const scriptContent = fs.readFileSync(agentScriptPath, 'utf8');
|
||||||
|
|
||||||
|
// Extract version from script content
|
||||||
|
const versionMatch = scriptContent.match(/AGENT_VERSION="([^"]+)"/);
|
||||||
|
|
||||||
|
if (!versionMatch) {
|
||||||
|
console.log('❌ Could not extract version from agent script, skipping version check');
|
||||||
|
if (process.env.ENABLE_LOGGING === 'true') {
|
||||||
|
logger.warn('Could not extract version from agent script, skipping version check');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const localVersion = versionMatch[1];
|
||||||
|
console.log('📋 Local version:', localVersion);
|
||||||
|
|
||||||
|
// Check if this version already exists in database
|
||||||
|
const existingVersion = await prisma.agent_versions.findUnique({
|
||||||
|
where: { version: localVersion }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingVersion) {
|
||||||
|
console.log(`✅ Agent version ${localVersion} already exists in database`);
|
||||||
|
if (process.env.ENABLE_LOGGING === 'true') {
|
||||||
|
logger.info(`Agent version ${localVersion} already exists in database`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`🆕 Agent version ${localVersion} not found in database`);
|
||||||
|
|
||||||
|
// Get existing versions for comparison
|
||||||
|
const allVersions = await prisma.agent_versions.findMany({
|
||||||
|
select: { version: true },
|
||||||
|
orderBy: { created_at: 'desc' }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Determine version flags and whether to proceed
|
||||||
|
const isFirstVersion = allVersions.length === 0;
|
||||||
|
const isNewerVersion = !isFirstVersion && compareVersions(localVersion, allVersions[0].version);
|
||||||
|
|
||||||
|
if (!isFirstVersion && !isNewerVersion) {
|
||||||
|
console.log(`❌ Agent version ${localVersion} is not newer than existing versions, skipping import`);
|
||||||
|
if (process.env.ENABLE_LOGGING === 'true') {
|
||||||
|
logger.info(`Agent version ${localVersion} is not newer than existing versions, skipping import`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const shouldSetAsCurrent = isFirstVersion || isNewerVersion;
|
||||||
|
const shouldSetAsDefault = isFirstVersion;
|
||||||
|
|
||||||
|
console.log(isFirstVersion ?
|
||||||
|
`📊 No existing versions found in database` :
|
||||||
|
`📊 Found ${allVersions.length} existing versions in database, latest: ${allVersions[0].version}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isFirstVersion) {
|
||||||
|
console.log(`🔄 Version comparison: ${localVersion} > ${allVersions[0].version} = ${isNewerVersion}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear existing flags if needed
|
||||||
|
const updatePromises = [];
|
||||||
|
if (shouldSetAsCurrent) {
|
||||||
|
updatePromises.push(prisma.agent_versions.updateMany({
|
||||||
|
where: { is_current: true },
|
||||||
|
data: { is_current: false }
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
if (shouldSetAsDefault) {
|
||||||
|
updatePromises.push(prisma.agent_versions.updateMany({
|
||||||
|
where: { is_default: true },
|
||||||
|
data: { is_default: false }
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updatePromises.length > 0) {
|
||||||
|
await Promise.all(updatePromises);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new version
|
||||||
|
await prisma.agent_versions.create({
|
||||||
|
data: {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
version: localVersion,
|
||||||
|
release_notes: `Auto-imported on startup (${new Date().toISOString()})`,
|
||||||
|
script_content: scriptContent,
|
||||||
|
is_default: shouldSetAsDefault,
|
||||||
|
is_current: shouldSetAsCurrent,
|
||||||
|
updated_at: new Date()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`🎉 Successfully auto-imported new agent version ${localVersion} on startup`);
|
||||||
|
if (shouldSetAsCurrent) {
|
||||||
|
console.log(`✅ Set version ${localVersion} as current version`);
|
||||||
|
}
|
||||||
|
if (shouldSetAsDefault) {
|
||||||
|
console.log(`✅ Set version ${localVersion} as default version`);
|
||||||
|
}
|
||||||
|
if (process.env.ENABLE_LOGGING === 'true') {
|
||||||
|
logger.info(`✅ Auto-imported new agent version ${localVersion} on startup (current: ${shouldSetAsCurrent}, default: ${shouldSetAsDefault})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Failed to check/import agent version on startup:', error.message);
|
||||||
|
if (process.env.ENABLE_LOGGING === 'true') {
|
||||||
|
logger.error('Failed to check/import agent version on startup:', error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to check and create default role permissions on startup
|
||||||
|
async function checkAndCreateRolePermissions() {
|
||||||
|
console.log('🔐 Starting role permissions auto-creation check...');
|
||||||
|
|
||||||
|
// Skip if auto-creation is disabled
|
||||||
|
if (process.env.AUTO_CREATE_ROLE_PERMISSIONS === 'false') {
|
||||||
|
console.log('❌ Auto-creation of role permissions is disabled');
|
||||||
|
if (process.env.ENABLE_LOGGING === 'true') {
|
||||||
|
logger.info('Auto-creation of role permissions is disabled');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
// Define default roles and permissions
|
||||||
|
const defaultRoles = [
|
||||||
|
{
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
role: 'admin',
|
||||||
|
can_view_dashboard: true,
|
||||||
|
can_view_hosts: true,
|
||||||
|
can_manage_hosts: true,
|
||||||
|
can_view_packages: true,
|
||||||
|
can_manage_packages: true,
|
||||||
|
can_view_users: true,
|
||||||
|
can_manage_users: true,
|
||||||
|
can_view_reports: true,
|
||||||
|
can_export_data: true,
|
||||||
|
can_manage_settings: true,
|
||||||
|
created_at: new Date(),
|
||||||
|
updated_at: new Date()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
role: 'user',
|
||||||
|
can_view_dashboard: true,
|
||||||
|
can_view_hosts: true,
|
||||||
|
can_manage_hosts: false,
|
||||||
|
can_view_packages: true,
|
||||||
|
can_manage_packages: false,
|
||||||
|
can_view_users: false,
|
||||||
|
can_manage_users: false,
|
||||||
|
can_view_reports: true,
|
||||||
|
can_export_data: false,
|
||||||
|
can_manage_settings: false,
|
||||||
|
created_at: new Date(),
|
||||||
|
updated_at: new Date()
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const createdRoles = [];
|
||||||
|
const existingRoles = [];
|
||||||
|
|
||||||
|
for (const roleData of defaultRoles) {
|
||||||
|
// Check if role already exists
|
||||||
|
const existingRole = await prisma.role_permissions.findUnique({
|
||||||
|
where: { role: roleData.role }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingRole) {
|
||||||
|
console.log(`✅ Role '${roleData.role}' already exists in database`);
|
||||||
|
existingRoles.push(existingRole);
|
||||||
|
if (process.env.ENABLE_LOGGING === 'true') {
|
||||||
|
logger.info(`Role '${roleData.role}' already exists in database`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Create new role permission
|
||||||
|
const permission = await prisma.role_permissions.create({
|
||||||
|
data: roleData
|
||||||
|
});
|
||||||
|
createdRoles.push(permission);
|
||||||
|
console.log(`🆕 Created role '${roleData.role}' with permissions`);
|
||||||
|
if (process.env.ENABLE_LOGGING === 'true') {
|
||||||
|
logger.info(`Created role '${roleData.role}' with permissions`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (createdRoles.length > 0) {
|
||||||
|
console.log(`🎉 Successfully auto-created ${createdRoles.length} role permissions on startup`);
|
||||||
|
console.log('📋 Created roles:');
|
||||||
|
createdRoles.forEach(role => {
|
||||||
|
console.log(` • ${role.role}: dashboard=${role.can_view_dashboard}, hosts=${role.can_manage_hosts}, packages=${role.can_manage_packages}, users=${role.can_manage_users}, settings=${role.can_manage_settings}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (process.env.ENABLE_LOGGING === 'true') {
|
||||||
|
logger.info(`✅ Auto-created ${createdRoles.length} role permissions on startup`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(`✅ All default role permissions already exist (${existingRoles.length} roles verified)`);
|
||||||
|
if (process.env.ENABLE_LOGGING === 'true') {
|
||||||
|
logger.info(`All default role permissions already exist (${existingRoles.length} roles verified)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Failed to check/create role permissions on startup:', error.message);
|
||||||
|
if (process.env.ENABLE_LOGGING === 'true') {
|
||||||
|
logger.error('Failed to check/create role permissions on startup:', error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize logger - only if logging is enabled
|
// Initialize logger - only if logging is enabled
|
||||||
const logger = process.env.ENABLE_LOGGING === 'true' ? winston.createLogger({
|
const logger = process.env.ENABLE_LOGGING === 'true' ? winston.createLogger({
|
||||||
@@ -28,10 +301,7 @@ const logger = process.env.ENABLE_LOGGING === 'true' ? winston.createLogger({
|
|||||||
winston.format.errors({ stack: true }),
|
winston.format.errors({ stack: true }),
|
||||||
winston.format.json()
|
winston.format.json()
|
||||||
),
|
),
|
||||||
transports: [
|
transports: [],
|
||||||
new winston.transports.File({ filename: 'logs/error.log', level: 'error' }),
|
|
||||||
new winston.transports.File({ filename: 'logs/combined.log' }),
|
|
||||||
],
|
|
||||||
}) : {
|
}) : {
|
||||||
info: () => {},
|
info: () => {},
|
||||||
error: () => {},
|
error: () => {},
|
||||||
@@ -39,28 +309,76 @@ const logger = process.env.ENABLE_LOGGING === 'true' ? winston.createLogger({
|
|||||||
debug: () => {}
|
debug: () => {}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (process.env.ENABLE_LOGGING === 'true' && process.env.NODE_ENV !== 'production') {
|
// Configure transports based on PM_LOG_TO_CONSOLE environment variable
|
||||||
|
if (process.env.ENABLE_LOGGING === 'true') {
|
||||||
|
const logToConsole = process.env.PM_LOG_TO_CONSOLE === '1' || process.env.PM_LOG_TO_CONSOLE === 'true';
|
||||||
|
|
||||||
|
if (logToConsole) {
|
||||||
|
// Log to stdout/stderr instead of files
|
||||||
|
logger.add(new winston.transports.Console({
|
||||||
|
format: winston.format.combine(
|
||||||
|
winston.format.timestamp(),
|
||||||
|
winston.format.errors({ stack: true }),
|
||||||
|
winston.format.printf(({ timestamp, level, message, stack }) => {
|
||||||
|
return `${timestamp} [${level.toUpperCase()}]: ${stack || message}`;
|
||||||
|
})
|
||||||
|
),
|
||||||
|
stderrLevels: ['error', 'warn']
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
// Log to files (default behavior)
|
||||||
|
logger.add(new winston.transports.File({ filename: 'logs/error.log', level: 'error' }));
|
||||||
|
logger.add(new winston.transports.File({ filename: 'logs/combined.log' }));
|
||||||
|
|
||||||
|
// Also add console logging for non-production environments
|
||||||
|
if (process.env.NODE_ENV !== 'production') {
|
||||||
logger.add(new winston.transports.Console({
|
logger.add(new winston.transports.Console({
|
||||||
format: winston.format.simple()
|
format: winston.format.simple()
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3001;
|
const PORT = process.env.PORT || 3001;
|
||||||
|
|
||||||
// Trust proxy (needed when behind reverse proxy) and remove X-Powered-By
|
// Trust proxy (needed when behind reverse proxy) and remove X-Powered-By
|
||||||
if (process.env.TRUST_PROXY) {
|
if (process.env.TRUST_PROXY) {
|
||||||
app.set('trust proxy', process.env.TRUST_PROXY === 'true' ? 1 : parseInt(process.env.TRUST_PROXY, 10) || true);
|
const trustProxyValue = process.env.TRUST_PROXY;
|
||||||
|
|
||||||
|
// Parse the trust proxy setting according to Express documentation
|
||||||
|
if (trustProxyValue === 'true') {
|
||||||
|
app.set('trust proxy', true);
|
||||||
|
} else if (trustProxyValue === 'false') {
|
||||||
|
app.set('trust proxy', false);
|
||||||
|
} else if (/^\d+$/.test(trustProxyValue)) {
|
||||||
|
// If it's a number (hop count)
|
||||||
|
app.set('trust proxy', parseInt(trustProxyValue, 10));
|
||||||
|
} else {
|
||||||
|
// If it contains commas, split into array; otherwise use as single value
|
||||||
|
// This handles: IP addresses, subnets, named subnets (loopback, linklocal, uniquelocal)
|
||||||
|
app.set('trust proxy', trustProxyValue.includes(',')
|
||||||
|
? trustProxyValue.split(',').map(s => s.trim())
|
||||||
|
: trustProxyValue
|
||||||
|
);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
app.set('trust proxy', 1);
|
app.set('trust proxy', 1);
|
||||||
}
|
}
|
||||||
app.disable('x-powered-by');
|
app.disable('x-powered-by');
|
||||||
|
|
||||||
// Rate limiting
|
// Rate limiting with monitoring
|
||||||
const limiter = rateLimit({
|
const limiter = rateLimit({
|
||||||
windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS) || 15 * 60 * 1000,
|
windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS) || 15 * 60 * 1000,
|
||||||
max: parseInt(process.env.RATE_LIMIT_MAX) || 100,
|
max: parseInt(process.env.RATE_LIMIT_MAX) || 100,
|
||||||
message: 'Too many requests from this IP, please try again later.',
|
message: {
|
||||||
|
error: 'Too many requests from this IP, please try again later.',
|
||||||
|
retryAfter: Math.ceil((parseInt(process.env.RATE_LIMIT_WINDOW_MS) || 15 * 60 * 1000) / 1000)
|
||||||
|
},
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
skipSuccessfulRequests: true, // Don't count successful requests
|
||||||
|
skipFailedRequests: false, // Count failed requests
|
||||||
});
|
});
|
||||||
|
|
||||||
// Middleware
|
// Middleware
|
||||||
@@ -101,8 +419,13 @@ app.use(express.urlencoded({ extended: true, limit: process.env.JSON_BODY_LIMIT
|
|||||||
|
|
||||||
// Request logging - only if logging is enabled
|
// Request logging - only if logging is enabled
|
||||||
if (process.env.ENABLE_LOGGING === 'true') {
|
if (process.env.ENABLE_LOGGING === 'true') {
|
||||||
app.use((req, res, next) => {
|
app.use((req, _, next) => {
|
||||||
|
// Log health check requests at debug level to reduce log spam
|
||||||
|
if (req.path === '/health') {
|
||||||
|
logger.debug(`${req.method} ${req.path} - ${req.ip}`);
|
||||||
|
} else {
|
||||||
logger.info(`${req.method} ${req.path} - ${req.ip}`);
|
logger.info(`${req.method} ${req.path} - ${req.ip}`);
|
||||||
|
}
|
||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -115,16 +438,31 @@ app.get('/health', (req, res) => {
|
|||||||
// API routes
|
// API routes
|
||||||
const apiVersion = process.env.API_VERSION || 'v1';
|
const apiVersion = process.env.API_VERSION || 'v1';
|
||||||
|
|
||||||
// Per-route rate limits
|
// Per-route rate limits with monitoring
|
||||||
const authLimiter = rateLimit({
|
const authLimiter = rateLimit({
|
||||||
windowMs: parseInt(process.env.AUTH_RATE_LIMIT_WINDOW_MS) || 10 * 60 * 1000,
|
windowMs: parseInt(process.env.AUTH_RATE_LIMIT_WINDOW_MS) || 10 * 60 * 1000,
|
||||||
max: parseInt(process.env.AUTH_RATE_LIMIT_MAX) || 20
|
max: parseInt(process.env.AUTH_RATE_LIMIT_MAX) || 20,
|
||||||
|
message: {
|
||||||
|
error: 'Too many authentication requests, please try again later.',
|
||||||
|
retryAfter: Math.ceil((parseInt(process.env.AUTH_RATE_LIMIT_WINDOW_MS) || 10 * 60 * 1000) / 1000)
|
||||||
|
},
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
skipSuccessfulRequests: true,
|
||||||
});
|
});
|
||||||
const agentLimiter = rateLimit({
|
const agentLimiter = rateLimit({
|
||||||
windowMs: parseInt(process.env.AGENT_RATE_LIMIT_WINDOW_MS) || 60 * 1000,
|
windowMs: parseInt(process.env.AGENT_RATE_LIMIT_WINDOW_MS) || 60 * 1000,
|
||||||
max: parseInt(process.env.AGENT_RATE_LIMIT_MAX) || 120
|
max: parseInt(process.env.AGENT_RATE_LIMIT_MAX) || 120,
|
||||||
|
message: {
|
||||||
|
error: 'Too many agent requests, please try again later.',
|
||||||
|
retryAfter: Math.ceil((parseInt(process.env.AGENT_RATE_LIMIT_WINDOW_MS) || 60 * 1000) / 1000)
|
||||||
|
},
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
skipSuccessfulRequests: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
app.use(`/api/${apiVersion}/auth`, authLimiter, authRoutes);
|
app.use(`/api/${apiVersion}/auth`, authLimiter, authRoutes);
|
||||||
app.use(`/api/${apiVersion}/hosts`, agentLimiter, hostRoutes);
|
app.use(`/api/${apiVersion}/hosts`, agentLimiter, hostRoutes);
|
||||||
app.use(`/api/${apiVersion}/host-groups`, hostGroupRoutes);
|
app.use(`/api/${apiVersion}/host-groups`, hostGroupRoutes);
|
||||||
@@ -134,6 +472,8 @@ app.use(`/api/${apiVersion}/permissions`, permissionsRoutes);
|
|||||||
app.use(`/api/${apiVersion}/settings`, settingsRoutes);
|
app.use(`/api/${apiVersion}/settings`, settingsRoutes);
|
||||||
app.use(`/api/${apiVersion}/dashboard-preferences`, dashboardPreferencesRoutes);
|
app.use(`/api/${apiVersion}/dashboard-preferences`, dashboardPreferencesRoutes);
|
||||||
app.use(`/api/${apiVersion}/repositories`, repositoryRoutes);
|
app.use(`/api/${apiVersion}/repositories`, repositoryRoutes);
|
||||||
|
app.use(`/api/${apiVersion}/version`, versionRoutes);
|
||||||
|
app.use(`/api/${apiVersion}/tfa`, tfaRoutes);
|
||||||
|
|
||||||
// Error handling middleware
|
// Error handling middleware
|
||||||
app.use((err, req, res, next) => {
|
app.use((err, req, res, next) => {
|
||||||
@@ -152,28 +492,267 @@ app.use('*', (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Graceful shutdown
|
// Graceful shutdown
|
||||||
process.on('SIGTERM', async () => {
|
|
||||||
if (process.env.ENABLE_LOGGING === 'true') {
|
|
||||||
logger.info('SIGTERM received, shutting down gracefully');
|
|
||||||
}
|
|
||||||
await prisma.$disconnect();
|
|
||||||
process.exit(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
process.on('SIGINT', async () => {
|
process.on('SIGINT', async () => {
|
||||||
if (process.env.ENABLE_LOGGING === 'true') {
|
if (process.env.ENABLE_LOGGING === 'true') {
|
||||||
logger.info('SIGINT received, shutting down gracefully');
|
logger.info('SIGINT received, shutting down gracefully');
|
||||||
}
|
}
|
||||||
await prisma.$disconnect();
|
updateScheduler.stop();
|
||||||
|
await disconnectPrisma(prisma);
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Start server
|
process.on('SIGTERM', async () => {
|
||||||
|
if (process.env.ENABLE_LOGGING === 'true') {
|
||||||
|
logger.info('SIGTERM received, shutting down gracefully');
|
||||||
|
}
|
||||||
|
updateScheduler.stop();
|
||||||
|
await disconnectPrisma(prisma);
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize dashboard preferences for all users
|
||||||
|
async function initializeDashboardPreferences() {
|
||||||
|
try {
|
||||||
|
console.log('🔧 Initializing dashboard preferences for all users...');
|
||||||
|
|
||||||
|
// Get all users
|
||||||
|
const users = await prisma.users.findMany({
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
email: true,
|
||||||
|
role: true,
|
||||||
|
dashboard_preferences: {
|
||||||
|
select: {
|
||||||
|
card_id: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (users.length === 0) {
|
||||||
|
console.log('ℹ️ No users found in database');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`📊 Found ${users.length} users to initialize`);
|
||||||
|
|
||||||
|
let initializedCount = 0;
|
||||||
|
let updatedCount = 0;
|
||||||
|
|
||||||
|
for (const user of users) {
|
||||||
|
const hasPreferences = user.dashboard_preferences.length > 0;
|
||||||
|
|
||||||
|
// Get permission-based preferences for this user's role
|
||||||
|
const expectedPreferences = await getPermissionBasedPreferences(user.role);
|
||||||
|
const expectedCardCount = expectedPreferences.length;
|
||||||
|
|
||||||
|
if (!hasPreferences) {
|
||||||
|
// User has no preferences - create them
|
||||||
|
console.log(`⚙️ Creating preferences for ${user.username} (${user.role})`);
|
||||||
|
|
||||||
|
const preferencesData = expectedPreferences.map(pref => ({
|
||||||
|
id: require('uuid').v4(),
|
||||||
|
user_id: user.id,
|
||||||
|
card_id: pref.cardId,
|
||||||
|
enabled: pref.enabled,
|
||||||
|
order: pref.order,
|
||||||
|
created_at: new Date(),
|
||||||
|
updated_at: new Date()
|
||||||
|
}));
|
||||||
|
|
||||||
|
await prisma.dashboard_preferences.createMany({
|
||||||
|
data: preferencesData
|
||||||
|
});
|
||||||
|
|
||||||
|
initializedCount++;
|
||||||
|
console.log(` ✅ Created ${expectedCardCount} cards based on permissions`);
|
||||||
|
} else {
|
||||||
|
// User already has preferences - check if they need updating
|
||||||
|
const currentCardCount = user.dashboard_preferences.length;
|
||||||
|
|
||||||
|
if (currentCardCount !== expectedCardCount) {
|
||||||
|
console.log(`🔄 Updating preferences for ${user.username} (${user.role}) - ${currentCardCount} → ${expectedCardCount} cards`);
|
||||||
|
|
||||||
|
// Delete existing preferences
|
||||||
|
await prisma.dashboard_preferences.deleteMany({
|
||||||
|
where: { user_id: user.id }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create new preferences based on permissions
|
||||||
|
const preferencesData = expectedPreferences.map(pref => ({
|
||||||
|
id: require('uuid').v4(),
|
||||||
|
user_id: user.id,
|
||||||
|
card_id: pref.cardId,
|
||||||
|
enabled: pref.enabled,
|
||||||
|
order: pref.order,
|
||||||
|
created_at: new Date(),
|
||||||
|
updated_at: new Date()
|
||||||
|
}));
|
||||||
|
|
||||||
|
await prisma.dashboard_preferences.createMany({
|
||||||
|
data: preferencesData
|
||||||
|
});
|
||||||
|
|
||||||
|
updatedCount++;
|
||||||
|
console.log(` ✅ Updated to ${expectedCardCount} cards based on permissions`);
|
||||||
|
} else {
|
||||||
|
console.log(`✅ ${user.username} already has correct preferences (${currentCardCount} cards)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n📋 Dashboard Preferences Initialization Complete:`);
|
||||||
|
console.log(` - New users initialized: ${initializedCount}`);
|
||||||
|
console.log(` - Existing users updated: ${updatedCount}`);
|
||||||
|
console.log(` - Users with correct preferences: ${users.length - initializedCount - updatedCount}`);
|
||||||
|
console.log(`\n🎯 Permission-based preferences:`);
|
||||||
|
console.log(` - Cards are now assigned based on actual user permissions`);
|
||||||
|
console.log(` - Each card requires specific permissions (can_view_hosts, can_view_users, etc.)`);
|
||||||
|
console.log(` - Users only see cards they have permission to access`);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Error initializing dashboard preferences:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to get user permissions based on role
|
||||||
|
async function getUserPermissions(userRole) {
|
||||||
|
try {
|
||||||
|
const permissions = await prisma.role_permissions.findUnique({
|
||||||
|
where: { role: userRole }
|
||||||
|
});
|
||||||
|
|
||||||
|
// If no specific permissions found, return default admin permissions (for backward compatibility)
|
||||||
|
if (!permissions) {
|
||||||
|
console.warn(`No permissions found for role: ${userRole}, defaulting to admin access`);
|
||||||
|
return {
|
||||||
|
can_view_dashboard: true,
|
||||||
|
can_view_hosts: true,
|
||||||
|
can_manage_hosts: true,
|
||||||
|
can_view_packages: true,
|
||||||
|
can_manage_packages: true,
|
||||||
|
can_view_users: true,
|
||||||
|
can_manage_users: true,
|
||||||
|
can_view_reports: true,
|
||||||
|
can_export_data: true,
|
||||||
|
can_manage_settings: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return permissions;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching user permissions:', error);
|
||||||
|
// Return admin permissions as fallback
|
||||||
|
return {
|
||||||
|
can_view_dashboard: true,
|
||||||
|
can_view_hosts: true,
|
||||||
|
can_manage_hosts: true,
|
||||||
|
can_view_packages: true,
|
||||||
|
can_manage_packages: true,
|
||||||
|
can_view_users: true,
|
||||||
|
can_manage_users: true,
|
||||||
|
can_view_reports: true,
|
||||||
|
can_export_data: true,
|
||||||
|
can_manage_settings: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to get permission-based dashboard preferences for a role
|
||||||
|
async function getPermissionBasedPreferences(userRole) {
|
||||||
|
// Get user's actual permissions
|
||||||
|
const permissions = await getUserPermissions(userRole);
|
||||||
|
|
||||||
|
// Define all possible dashboard cards with their required permissions
|
||||||
|
const allCards = [
|
||||||
|
// Host-related cards
|
||||||
|
{ cardId: 'totalHosts', requiredPermission: 'can_view_hosts', order: 0 },
|
||||||
|
{ cardId: 'hostsNeedingUpdates', requiredPermission: 'can_view_hosts', order: 1 },
|
||||||
|
|
||||||
|
// Package-related cards
|
||||||
|
{ cardId: 'totalOutdatedPackages', requiredPermission: 'can_view_packages', order: 2 },
|
||||||
|
{ cardId: 'securityUpdates', requiredPermission: 'can_view_packages', order: 3 },
|
||||||
|
|
||||||
|
// Host-related cards (continued)
|
||||||
|
{ cardId: 'totalHostGroups', requiredPermission: 'can_view_hosts', order: 4 },
|
||||||
|
{ cardId: 'upToDateHosts', requiredPermission: 'can_view_hosts', order: 5 },
|
||||||
|
|
||||||
|
// Repository-related cards
|
||||||
|
{ cardId: 'totalRepos', requiredPermission: 'can_view_hosts', order: 6 }, // Repos are host-related
|
||||||
|
|
||||||
|
// User management cards (admin only)
|
||||||
|
{ cardId: 'totalUsers', requiredPermission: 'can_view_users', order: 7 },
|
||||||
|
|
||||||
|
// System/Report cards
|
||||||
|
{ cardId: 'osDistribution', requiredPermission: 'can_view_reports', order: 8 },
|
||||||
|
{ cardId: 'osDistributionBar', requiredPermission: 'can_view_reports', order: 9 },
|
||||||
|
{ cardId: 'recentCollection', requiredPermission: 'can_view_hosts', order: 10 }, // Collection is host-related
|
||||||
|
{ cardId: 'updateStatus', requiredPermission: 'can_view_reports', order: 11 },
|
||||||
|
{ cardId: 'packagePriority', requiredPermission: 'can_view_packages', order: 12 },
|
||||||
|
{ cardId: 'recentUsers', requiredPermission: 'can_view_users', order: 13 },
|
||||||
|
{ cardId: 'quickStats', requiredPermission: 'can_view_dashboard', order: 14 }
|
||||||
|
];
|
||||||
|
|
||||||
|
// Filter cards based on user's permissions
|
||||||
|
const allowedCards = allCards.filter(card => {
|
||||||
|
return permissions[card.requiredPermission] === true;
|
||||||
|
});
|
||||||
|
|
||||||
|
return allowedCards.map((card) => ({
|
||||||
|
cardId: card.cardId,
|
||||||
|
enabled: true,
|
||||||
|
order: card.order // Preserve original order from allCards
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start server with database health check
|
||||||
|
async function startServer() {
|
||||||
|
try {
|
||||||
|
// Wait for database to be available
|
||||||
|
await waitForDatabase(prisma);
|
||||||
|
|
||||||
|
if (process.env.ENABLE_LOGGING === 'true') {
|
||||||
|
logger.info('✅ Database connection successful');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialise settings on startup
|
||||||
|
try {
|
||||||
|
await initSettings();
|
||||||
|
if (process.env.ENABLE_LOGGING === 'true') {
|
||||||
|
logger.info('✅ Settings initialised');
|
||||||
|
}
|
||||||
|
} catch (initError) {
|
||||||
|
if (process.env.ENABLE_LOGGING === 'true') {
|
||||||
|
logger.error('❌ Failed to initialise settings:', initError.message);
|
||||||
|
}
|
||||||
|
throw initError; // Fail startup if settings can't be initialised
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check and import agent version on startup
|
||||||
|
await checkAndImportAgentVersion();
|
||||||
|
|
||||||
|
// Check and create default role permissions on startup
|
||||||
|
await checkAndCreateRolePermissions();
|
||||||
|
|
||||||
|
// Initialize dashboard preferences for all users
|
||||||
|
await initializeDashboardPreferences();
|
||||||
app.listen(PORT, () => {
|
app.listen(PORT, () => {
|
||||||
if (process.env.ENABLE_LOGGING === 'true') {
|
if (process.env.ENABLE_LOGGING === 'true') {
|
||||||
logger.info(`Server running on port ${PORT}`);
|
logger.info(`Server running on port ${PORT}`);
|
||||||
logger.info(`Environment: ${process.env.NODE_ENV}`);
|
logger.info(`Environment: ${process.env.NODE_ENV}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Start update scheduler
|
||||||
|
updateScheduler.start();
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Failed to start server:', error.message);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
startServer();
|
||||||
|
|
||||||
module.exports = app;
|
module.exports = app;
|
||||||
183
backend/src/services/settingsService.js
Normal file
183
backend/src/services/settingsService.js
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
const { PrismaClient } = require('@prisma/client');
|
||||||
|
const { v4: uuidv4 } = require('uuid');
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
// Cached settings instance
|
||||||
|
let cachedSettings = null;
|
||||||
|
|
||||||
|
// Environment variable to settings field mapping
|
||||||
|
const ENV_TO_SETTINGS_MAP = {
|
||||||
|
'SERVER_PROTOCOL': 'server_protocol',
|
||||||
|
'SERVER_HOST': 'server_host',
|
||||||
|
'SERVER_PORT': 'server_port',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper function to construct server URL without default ports
|
||||||
|
function constructServerUrl(protocol, host, port) {
|
||||||
|
const isHttps = protocol.toLowerCase() === 'https';
|
||||||
|
const isHttp = protocol.toLowerCase() === 'http';
|
||||||
|
|
||||||
|
// Don't append port if it's the default port for the protocol
|
||||||
|
if ((isHttps && port === 443) || (isHttp && port === 80)) {
|
||||||
|
return `${protocol}://${host}`.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${protocol}://${host}:${port}`.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create settings from environment variables and/or defaults
|
||||||
|
async function createSettingsFromEnvironment() {
|
||||||
|
const protocol = process.env.SERVER_PROTOCOL || 'http';
|
||||||
|
const host = process.env.SERVER_HOST || 'localhost';
|
||||||
|
const port = parseInt(process.env.SERVER_PORT, 10) || 3001;
|
||||||
|
const serverUrl = constructServerUrl(protocol, host, port);
|
||||||
|
|
||||||
|
const settings = await prisma.settings.create({
|
||||||
|
data: {
|
||||||
|
id: uuidv4(),
|
||||||
|
server_url: serverUrl,
|
||||||
|
server_protocol: protocol,
|
||||||
|
server_host: host,
|
||||||
|
server_port: port,
|
||||||
|
update_interval: 60,
|
||||||
|
auto_update: false,
|
||||||
|
signup_enabled: false,
|
||||||
|
updated_at: new Date()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Created settings');
|
||||||
|
return settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync environment variables with existing settings
|
||||||
|
async function syncEnvironmentToSettings(currentSettings) {
|
||||||
|
const updates = {};
|
||||||
|
let hasChanges = false;
|
||||||
|
|
||||||
|
// Check each environment variable mapping
|
||||||
|
for (const [envVar, settingsField] of Object.entries(ENV_TO_SETTINGS_MAP)) {
|
||||||
|
if (process.env[envVar]) {
|
||||||
|
const envValue = process.env[envVar];
|
||||||
|
const currentValue = currentSettings[settingsField];
|
||||||
|
|
||||||
|
// Convert environment value to appropriate type
|
||||||
|
let convertedValue = envValue;
|
||||||
|
if (settingsField === 'server_port') {
|
||||||
|
convertedValue = parseInt(envValue, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only update if values differ
|
||||||
|
if (currentValue !== convertedValue) {
|
||||||
|
updates[settingsField] = convertedValue;
|
||||||
|
hasChanges = true;
|
||||||
|
console.log(`Environment variable ${envVar} (${envValue}) differs from settings ${settingsField} (${currentValue}), updating...`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construct server_url from components if any components were updated
|
||||||
|
const protocol = updates.server_protocol || currentSettings.server_protocol;
|
||||||
|
const host = updates.server_host || currentSettings.server_host;
|
||||||
|
const port = updates.server_port || currentSettings.server_port;
|
||||||
|
const constructedServerUrl = constructServerUrl(protocol, host, port);
|
||||||
|
|
||||||
|
// Update server_url if it differs from the constructed value
|
||||||
|
if (currentSettings.server_url !== constructedServerUrl) {
|
||||||
|
updates.server_url = constructedServerUrl;
|
||||||
|
hasChanges = true;
|
||||||
|
console.log(`Updating server_url to: ${constructedServerUrl}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update settings if there are changes
|
||||||
|
if (hasChanges) {
|
||||||
|
const updatedSettings = await prisma.settings.update({
|
||||||
|
where: { id: currentSettings.id },
|
||||||
|
data: {
|
||||||
|
...updates,
|
||||||
|
updated_at: new Date()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
console.log(`Synced ${Object.keys(updates).length} environment variables to settings`);
|
||||||
|
return updatedSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialise settings - create from environment or sync existing
|
||||||
|
async function initSettings() {
|
||||||
|
if (cachedSettings) {
|
||||||
|
return cachedSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let settings = await prisma.settings.findFirst({
|
||||||
|
orderBy: { updated_at: 'desc' }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!settings) {
|
||||||
|
// No settings exist, create from environment variables and defaults
|
||||||
|
settings = await createSettingsFromEnvironment();
|
||||||
|
} else {
|
||||||
|
// Settings exist, sync with environment variables
|
||||||
|
settings = await syncEnvironmentToSettings(settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache the initialised settings
|
||||||
|
cachedSettings = settings;
|
||||||
|
return settings;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to initialise settings:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current settings (returns cached if available)
|
||||||
|
async function getSettings() {
|
||||||
|
return cachedSettings || await initSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update settings and refresh cache
|
||||||
|
async function updateSettings(id, updateData) {
|
||||||
|
try {
|
||||||
|
const updatedSettings = await prisma.settings.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
...updateData,
|
||||||
|
updated_at: new Date()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reconstruct server_url from components
|
||||||
|
const serverUrl = constructServerUrl(updatedSettings.server_protocol, updatedSettings.server_host, updatedSettings.server_port);
|
||||||
|
if (updatedSettings.server_url !== serverUrl) {
|
||||||
|
updatedSettings.server_url = serverUrl;
|
||||||
|
await prisma.settings.update({
|
||||||
|
where: { id },
|
||||||
|
data: { server_url: serverUrl }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update cache
|
||||||
|
cachedSettings = updatedSettings;
|
||||||
|
return updatedSettings;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update settings:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invalidate cache (useful for testing or manual refresh)
|
||||||
|
function invalidateCache() {
|
||||||
|
cachedSettings = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
initSettings,
|
||||||
|
getSettings,
|
||||||
|
updateSettings,
|
||||||
|
invalidateCache,
|
||||||
|
syncEnvironmentToSettings // Export for startup use
|
||||||
|
};
|
||||||
267
backend/src/services/updateScheduler.js
Normal file
267
backend/src/services/updateScheduler.js
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
const { PrismaClient } = require('@prisma/client');
|
||||||
|
const { exec } = require('child_process');
|
||||||
|
const { promisify } = require('util');
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
|
class UpdateScheduler {
|
||||||
|
constructor() {
|
||||||
|
this.isRunning = false;
|
||||||
|
this.intervalId = null;
|
||||||
|
this.checkInterval = 24 * 60 * 60 * 1000; // 24 hours in milliseconds
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start the scheduler
|
||||||
|
start() {
|
||||||
|
if (this.isRunning) {
|
||||||
|
console.log('Update scheduler is already running');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('🔄 Starting update scheduler...');
|
||||||
|
this.isRunning = true;
|
||||||
|
|
||||||
|
// Run initial check
|
||||||
|
this.checkForUpdates();
|
||||||
|
|
||||||
|
// Schedule regular checks
|
||||||
|
this.intervalId = setInterval(() => {
|
||||||
|
this.checkForUpdates();
|
||||||
|
}, this.checkInterval);
|
||||||
|
|
||||||
|
console.log(`✅ Update scheduler started - checking every ${this.checkInterval / (60 * 60 * 1000)} hours`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop the scheduler
|
||||||
|
stop() {
|
||||||
|
if (!this.isRunning) {
|
||||||
|
console.log('Update scheduler is not running');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('🛑 Stopping update scheduler...');
|
||||||
|
this.isRunning = false;
|
||||||
|
|
||||||
|
if (this.intervalId) {
|
||||||
|
clearInterval(this.intervalId);
|
||||||
|
this.intervalId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('✅ Update scheduler stopped');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for updates
|
||||||
|
async checkForUpdates() {
|
||||||
|
try {
|
||||||
|
console.log('🔍 Checking for updates...');
|
||||||
|
|
||||||
|
// Get settings
|
||||||
|
const settings = await prisma.settings.findFirst();
|
||||||
|
if (!settings || !settings.githubRepoUrl) {
|
||||||
|
console.log('⚠️ No GitHub repository configured, skipping update check');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract owner and repo from GitHub URL
|
||||||
|
const repoUrl = settings.githubRepoUrl;
|
||||||
|
let owner, repo;
|
||||||
|
|
||||||
|
if (repoUrl.includes('git@github.com:')) {
|
||||||
|
const match = repoUrl.match(/git@github\.com:([^\/]+)\/([^\/]+)\.git/);
|
||||||
|
if (match) {
|
||||||
|
[, owner, repo] = match;
|
||||||
|
}
|
||||||
|
} else if (repoUrl.includes('github.com/')) {
|
||||||
|
const match = repoUrl.match(/github\.com\/([^\/]+)\/([^\/]+?)(?:\.git)?$/);
|
||||||
|
if (match) {
|
||||||
|
[, owner, repo] = match;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!owner || !repo) {
|
||||||
|
console.log('⚠️ Could not parse GitHub repository URL, skipping update check');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let latestVersion;
|
||||||
|
const isPrivate = settings.repositoryType === 'private';
|
||||||
|
|
||||||
|
if (isPrivate) {
|
||||||
|
// Use SSH for private repositories
|
||||||
|
latestVersion = await this.checkPrivateRepo(settings, owner, repo);
|
||||||
|
} else {
|
||||||
|
// Use GitHub API for public repositories
|
||||||
|
latestVersion = await this.checkPublicRepo(owner, repo);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!latestVersion) {
|
||||||
|
console.log('⚠️ Could not determine latest version, skipping update check');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read version from package.json dynamically
|
||||||
|
let currentVersion = '1.2.6'; // fallback
|
||||||
|
try {
|
||||||
|
const packageJson = require('../../package.json');
|
||||||
|
if (packageJson && packageJson.version) {
|
||||||
|
currentVersion = packageJson.version;
|
||||||
|
}
|
||||||
|
} catch (packageError) {
|
||||||
|
console.warn('Could not read version from package.json, using fallback:', packageError.message);
|
||||||
|
}
|
||||||
|
const isUpdateAvailable = this.compareVersions(latestVersion, currentVersion) > 0;
|
||||||
|
|
||||||
|
// Update settings with check results
|
||||||
|
await prisma.settings.update({
|
||||||
|
where: { id: settings.id },
|
||||||
|
data: {
|
||||||
|
lastUpdateCheck: new Date(),
|
||||||
|
updateAvailable: isUpdateAvailable,
|
||||||
|
latestVersion: latestVersion
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`✅ Update check completed - Current: ${currentVersion}, Latest: ${latestVersion}, Update Available: ${isUpdateAvailable}`);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Error checking for updates:', error.message);
|
||||||
|
|
||||||
|
// Update last check time even on error
|
||||||
|
try {
|
||||||
|
const settings = await prisma.settings.findFirst();
|
||||||
|
if (settings) {
|
||||||
|
await prisma.settings.update({
|
||||||
|
where: { id: settings.id },
|
||||||
|
data: {
|
||||||
|
lastUpdateCheck: new Date(),
|
||||||
|
updateAvailable: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (updateError) {
|
||||||
|
console.error('❌ Error updating last check time:', updateError.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check private repository using SSH
|
||||||
|
async checkPrivateRepo(settings, owner, repo) {
|
||||||
|
try {
|
||||||
|
let sshKeyPath = settings.sshKeyPath;
|
||||||
|
|
||||||
|
// Try to find SSH key if not configured
|
||||||
|
if (!sshKeyPath) {
|
||||||
|
const possibleKeyPaths = [
|
||||||
|
'/root/.ssh/id_ed25519',
|
||||||
|
'/root/.ssh/id_rsa',
|
||||||
|
'/home/patchmon/.ssh/id_ed25519',
|
||||||
|
'/home/patchmon/.ssh/id_rsa',
|
||||||
|
'/var/www/.ssh/id_ed25519',
|
||||||
|
'/var/www/.ssh/id_rsa'
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const path of possibleKeyPaths) {
|
||||||
|
try {
|
||||||
|
require('fs').accessSync(path);
|
||||||
|
sshKeyPath = path;
|
||||||
|
break;
|
||||||
|
} catch (e) {
|
||||||
|
// Key not found at this path, try next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sshKeyPath) {
|
||||||
|
throw new Error('No SSH deploy key found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const sshRepoUrl = `git@github.com:${owner}/${repo}.git`;
|
||||||
|
const env = {
|
||||||
|
...process.env,
|
||||||
|
GIT_SSH_COMMAND: `ssh -i ${sshKeyPath} -o StrictHostKeyChecking=no -o IdentitiesOnly=yes`
|
||||||
|
};
|
||||||
|
|
||||||
|
const { stdout: sshLatestTag } = await execAsync(
|
||||||
|
`git ls-remote --tags --sort=-version:refname ${sshRepoUrl} | head -n 1 | sed 's/.*refs\\/tags\\///' | sed 's/\\^{}//'`,
|
||||||
|
{
|
||||||
|
timeout: 10000,
|
||||||
|
env: env
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return sshLatestTag.trim().replace('v', '');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('SSH Git error:', error.message);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check public repository using GitHub API
|
||||||
|
async checkPublicRepo(owner, repo) {
|
||||||
|
try {
|
||||||
|
const httpsRepoUrl = `https://api.github.com/repos/${owner}/${repo}/releases/latest`;
|
||||||
|
|
||||||
|
// Get current version for User-Agent
|
||||||
|
let currentVersion = '1.2.6'; // fallback
|
||||||
|
try {
|
||||||
|
const packageJson = require('../../package.json');
|
||||||
|
if (packageJson && packageJson.version) {
|
||||||
|
currentVersion = packageJson.version;
|
||||||
|
}
|
||||||
|
} catch (packageError) {
|
||||||
|
console.warn('Could not read version from package.json for User-Agent, using fallback:', packageError.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(httpsRepoUrl, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/vnd.github.v3+json',
|
||||||
|
'User-Agent': `PatchMon-Server/${currentVersion}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`GitHub API error: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const releaseData = await response.json();
|
||||||
|
return releaseData.tag_name.replace('v', '');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('GitHub API error:', error.message);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare version strings (semantic versioning)
|
||||||
|
compareVersions(version1, version2) {
|
||||||
|
const v1parts = version1.split('.').map(Number);
|
||||||
|
const v2parts = version2.split('.').map(Number);
|
||||||
|
|
||||||
|
const maxLength = Math.max(v1parts.length, v2parts.length);
|
||||||
|
|
||||||
|
for (let i = 0; i < maxLength; i++) {
|
||||||
|
const v1part = v1parts[i] || 0;
|
||||||
|
const v2part = v2parts[i] || 0;
|
||||||
|
|
||||||
|
if (v1part > v2part) return 1;
|
||||||
|
if (v1part < v2part) return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get scheduler status
|
||||||
|
getStatus() {
|
||||||
|
return {
|
||||||
|
isRunning: this.isRunning,
|
||||||
|
checkInterval: this.checkInterval,
|
||||||
|
nextCheck: this.isRunning ? new Date(Date.now() + this.checkInterval) : null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create singleton instance
|
||||||
|
const updateScheduler = new UpdateScheduler();
|
||||||
|
|
||||||
|
module.exports = updateScheduler;
|
||||||
148
docker/README.md
Normal file
148
docker/README.md
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
# PatchMon Docker
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
PatchMon is a containerised application that monitors system patches and updates. The application consists of three main services:
|
||||||
|
|
||||||
|
- **Database**: PostgreSQL 17
|
||||||
|
- **Backend**: Node.js API server
|
||||||
|
- **Frontend**: React application served via Nginx
|
||||||
|
|
||||||
|
## Images
|
||||||
|
|
||||||
|
- **Backend**: `ghcr.io/9technologygroup/patchmon-backend:latest`
|
||||||
|
- **Frontend**: `ghcr.io/9technologygroup/patchmon-frontend:latest`
|
||||||
|
|
||||||
|
Version tags are also available (e.g. `1.2.3`) for both of these images.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Production Deployment
|
||||||
|
|
||||||
|
1. Download the [Docker Compose file](docker-compose.yml)
|
||||||
|
|
||||||
|
2. Configure environment variables (see [Configuration](#configuration) section)
|
||||||
|
|
||||||
|
3. Start the application:
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Access the application at `http://localhost:3000`
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
#### Database Service
|
||||||
|
|
||||||
|
- `POSTGRES_DB`: Database name (default: `patchmon_db`)
|
||||||
|
- `POSTGRES_USER`: Database user (default: `patchmon_user`)
|
||||||
|
- `POSTGRES_PASSWORD`: Database password - **MUST BE CHANGED!**
|
||||||
|
|
||||||
|
#### Backend Service
|
||||||
|
|
||||||
|
- `LOG_LEVEL`: Logging level (`debug`, `info`, `warn`, `error`)
|
||||||
|
- `DATABASE_URL`: PostgreSQL connection string
|
||||||
|
- `PM_DB_CONN_MAX_ATTEMPTS`: Maximum database connection attempts (default: 30)
|
||||||
|
- `PM_DB_CONN_WAIT_INTERVAL`: Wait interval between connection attempts in seconds (default: 2)
|
||||||
|
- `SERVER_PROTOCOL`: Frontend server protocol (`http` or `https`)
|
||||||
|
- `SERVER_HOST`: Frontend server host (default: `localhost`)
|
||||||
|
- `SERVER_PORT`: Frontend server port (default: 3000)
|
||||||
|
- `PORT`: Backend API port (default: 3001)
|
||||||
|
- `API_VERSION`: API version (default: `v1`)
|
||||||
|
- `CORS_ORIGIN`: CORS origin URL
|
||||||
|
- `RATE_LIMIT_WINDOW_MS`: Rate limiting window in milliseconds (default: 900000)
|
||||||
|
- `RATE_LIMIT_MAX`: Maximum requests per window (default: 100)
|
||||||
|
- `ENABLE_HSTS`: Enable HTTP Strict Transport Security (default: true)
|
||||||
|
- `TRUST_PROXY`: Trust proxy headers (default: true) - See [Express.js docs](https://expressjs.com/en/guide/behind-proxies.html) for usage.
|
||||||
|
|
||||||
|
#### Frontend Service
|
||||||
|
|
||||||
|
- `BACKEND_HOST`: Backend service hostname (default: `backend`)
|
||||||
|
- `BACKEND_PORT`: Backend service port (default: 3001)
|
||||||
|
|
||||||
|
### Security Configuration
|
||||||
|
|
||||||
|
**⚠️ IMPORTANT**: Before deploying to production, you MUST:
|
||||||
|
|
||||||
|
1. Change the default database password in `docker-compose.yml`:
|
||||||
|
```yaml
|
||||||
|
environment:
|
||||||
|
POSTGRES_PASSWORD: YOUR_SECURE_PASSWORD_HERE
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Update the corresponding `DATABASE_URL` in the backend service:
|
||||||
|
```yaml
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql://patchmon_user:YOUR_SECURE_PASSWORD_HERE@database:5432/patchmon_db
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Development
|
||||||
|
|
||||||
|
This section is for developers who want to contribute to PatchMon or run it in development mode.
|
||||||
|
|
||||||
|
## Development Setup
|
||||||
|
|
||||||
|
For development with live reload and source code mounting:
|
||||||
|
|
||||||
|
1. Clone the repository:
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/9technologygroup/patchmon.net.git
|
||||||
|
cd patchmon.net
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Start development environment:
|
||||||
|
```bash
|
||||||
|
# Attached, live log output, services stopped on Ctrl+C
|
||||||
|
docker compose -f docker/docker-compose.dev.yml up
|
||||||
|
|
||||||
|
# Detached
|
||||||
|
docker compose -f docker/docker-compose.dev.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development Docker Compose
|
||||||
|
|
||||||
|
The development compose file (`docker/docker-compose.dev.yml`):
|
||||||
|
- Builds images locally from source
|
||||||
|
- Enables development workflow
|
||||||
|
- Supports live reload and debugging
|
||||||
|
|
||||||
|
## Building Images Locally
|
||||||
|
|
||||||
|
```
|
||||||
|
docker build -t patchmon-backend:dev -f docker/backend.Dockerfile .
|
||||||
|
docker build -t patchmon-frontend:dev -f docker/frontend.Dockerfile .
|
||||||
|
```
|
||||||
|
|
||||||
|
## Running in Docker Compose
|
||||||
|
|
||||||
|
For development or custom builds:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build backend image
|
||||||
|
docker build -f docker/backend.Dockerfile -t patchmon-backend:dev .
|
||||||
|
|
||||||
|
# Build frontend image
|
||||||
|
docker build -f docker/frontend.Dockerfile -t patchmon-frontend:dev .
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development Commands
|
||||||
|
|
||||||
|
### Rebuild Services
|
||||||
|
```bash
|
||||||
|
# Rebuild specific service
|
||||||
|
docker compose -f docker/docker-compose.dev.yml up -d --build backend
|
||||||
|
|
||||||
|
# Rebuild all services
|
||||||
|
docker compose -f docker/docker-compose.dev.yml up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development Workflow
|
||||||
|
|
||||||
|
1. **Code Changes**: Edit source files
|
||||||
|
2. **Rebuild**: `docker compose -f docker/docker-compose.dev.yml up -d --build`
|
||||||
|
3. **Test**: Access application and verify changes
|
||||||
|
4. **Debug**: Check logs with `docker compose logs -f`
|
||||||
47
docker/backend.Dockerfile
Normal file
47
docker/backend.Dockerfile
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
FROM node:lts-alpine AS builder
|
||||||
|
|
||||||
|
RUN apk add --no-cache openssl
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY --chown=node:node package*.json /app/
|
||||||
|
COPY --chown=node:node backend/ /app/backend/
|
||||||
|
|
||||||
|
WORKDIR /app/backend
|
||||||
|
|
||||||
|
RUN npm ci &&\
|
||||||
|
npx prisma generate &&\
|
||||||
|
npm prune --omit=dev &&\
|
||||||
|
npm cache clean --force
|
||||||
|
|
||||||
|
FROM node:lts-alpine
|
||||||
|
|
||||||
|
ENV NODE_ENV=production \
|
||||||
|
ENABLE_LOGGING=true \
|
||||||
|
LOG_LEVEL=info \
|
||||||
|
PM_LOG_TO_CONSOLE=true \
|
||||||
|
PORT=3001
|
||||||
|
|
||||||
|
RUN apk add --no-cache openssl tini curl
|
||||||
|
|
||||||
|
USER node
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY --from=builder /app/backend /app/backend
|
||||||
|
COPY --from=builder /app/node_modules /app/node_modules
|
||||||
|
COPY --chown=node:node agents ./agents_backup
|
||||||
|
COPY --chown=node:node agents ./agents
|
||||||
|
COPY --chmod=755 docker/backend.docker-entrypoint.sh ./entrypoint.sh
|
||||||
|
|
||||||
|
WORKDIR /app/backend
|
||||||
|
|
||||||
|
EXPOSE 3001
|
||||||
|
|
||||||
|
VOLUME [ "/app/agents" ]
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=10s --timeout=5s --start-period=30s --retries=5 \
|
||||||
|
CMD curl -f http://localhost:3001/health || exit 1
|
||||||
|
|
||||||
|
ENTRYPOINT ["/sbin/tini", "--"]
|
||||||
|
CMD ["/app/entrypoint.sh"]
|
||||||
1
docker/backend.Dockerfile.dockerignore
Normal file
1
docker/backend.Dockerfile.dockerignore
Normal file
@@ -0,0 +1 @@
|
|||||||
|
**/env.example
|
||||||
29
docker/backend.docker-entrypoint.sh
Executable file
29
docker/backend.docker-entrypoint.sh
Executable file
@@ -0,0 +1,29 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
# Enable strict error handling
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Function to log messages with timestamp
|
||||||
|
log() {
|
||||||
|
echo "[$(date +'%Y-%m-%d %H:%M:%S')] $*" >&2
|
||||||
|
}
|
||||||
|
|
||||||
|
# Copy files from agents_backup to agents if agents directory is empty
|
||||||
|
if [ -d "/app/agents" ] && [ -z "$(ls -A /app/agents 2>/dev/null)" ]; then
|
||||||
|
if [ -d "/app/agents_backup" ]; then
|
||||||
|
log "Agents directory is empty, copying from backup..."
|
||||||
|
cp -r /app/agents_backup/* /app/agents/
|
||||||
|
else
|
||||||
|
log "Warning: agents_backup directory not found"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
log "Agents directory already contains files, skipping copy"
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "Starting PatchMon Backend..."
|
||||||
|
|
||||||
|
log "Running database migrations..."
|
||||||
|
npx prisma migrate deploy
|
||||||
|
|
||||||
|
log "Starting application..."
|
||||||
|
exec npm start
|
||||||
54
docker/docker-compose.dev.yml
Normal file
54
docker/docker-compose.dev.yml
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
services:
|
||||||
|
database:
|
||||||
|
image: postgres:17-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: patchmon_db
|
||||||
|
POSTGRES_USER: patchmon_user
|
||||||
|
POSTGRES_PASSWORD: INSECURE_REPLACE_ME_PLEASE_INSECURE
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U patchmon_user -d patchmon_db"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: docker/backend.Dockerfile
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
LOG_LEVEL: info
|
||||||
|
DATABASE_URL: postgresql://patchmon_user:INSECURE_REPLACE_ME_PLEASE_INSECURE@database:5432/patchmon_db
|
||||||
|
PM_DB_CONN_MAX_ATTEMPTS: 30
|
||||||
|
PM_DB_CONN_WAIT_INTERVAL: 2
|
||||||
|
SERVER_PROTOCOL: http
|
||||||
|
SERVER_HOST: localhost
|
||||||
|
SERVER_PORT: 3000
|
||||||
|
CORS_ORIGIN: http://localhost:3000
|
||||||
|
RATE_LIMIT_WINDOW_MS: 900000
|
||||||
|
RATE_LIMIT_MAX: 100
|
||||||
|
volumes:
|
||||||
|
- ./agents:/app/agents
|
||||||
|
depends_on:
|
||||||
|
database:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: docker/frontend.Dockerfile
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
BACKEND_HOST: backend
|
||||||
|
BACKEND_PORT: 3001
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
depends_on:
|
||||||
|
backend:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
47
docker/docker-compose.yml
Normal file
47
docker/docker-compose.yml
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
services:
|
||||||
|
database:
|
||||||
|
image: postgres:17-alpine3.22
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: patchmon_db
|
||||||
|
POSTGRES_USER: patchmon_user
|
||||||
|
POSTGRES_PASSWORD: INSECURE_REPLACE_ME_PLEASE_INSECURE
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U patchmon_user -d patchmon_db"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
backend:
|
||||||
|
image: ghcr.io/9technologygroup/patchmon-backend:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
LOG_LEVEL: info
|
||||||
|
DATABASE_URL: postgresql://patchmon_user:INSECURE_REPLACE_ME_PLEASE_INSECURE@database:5432/patchmon_db
|
||||||
|
PM_DB_CONN_MAX_ATTEMPTS: 30
|
||||||
|
PM_DB_CONN_WAIT_INTERVAL: 2
|
||||||
|
SERVER_PROTOCOL: http
|
||||||
|
SERVER_HOST: localhost
|
||||||
|
SERVER_PORT: 3000
|
||||||
|
CORS_ORIGIN: http://localhost:3000
|
||||||
|
RATE_LIMIT_WINDOW_MS: 900000
|
||||||
|
RATE_LIMIT_MAX: 100
|
||||||
|
volumes:
|
||||||
|
- ./agents:/app/agents
|
||||||
|
depends_on:
|
||||||
|
database:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
image: ghcr.io/9technologygroup/patchmon-frontend:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
depends_on:
|
||||||
|
backend:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
24
docker/frontend.Dockerfile
Normal file
24
docker/frontend.Dockerfile
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
FROM node:lts-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
COPY frontend/package*.json ./frontend/
|
||||||
|
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
COPY frontend/ ./frontend/
|
||||||
|
|
||||||
|
RUN npm run build:frontend
|
||||||
|
|
||||||
|
FROM nginxinc/nginx-unprivileged:alpine
|
||||||
|
|
||||||
|
ENV BACKEND_HOST=backend \
|
||||||
|
BACKEND_PORT=3001
|
||||||
|
|
||||||
|
COPY --from=builder /app/frontend/dist /usr/share/nginx/html
|
||||||
|
COPY docker/nginx.conf.template /etc/nginx/templates/default.conf.template
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
2
docker/frontend.Dockerfile.dockerignore
Normal file
2
docker/frontend.Dockerfile.dockerignore
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
**/Dockerfile
|
||||||
|
**/dist
|
||||||
67
docker/nginx.conf.template
Normal file
67
docker/nginx.conf.template
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
server {
|
||||||
|
listen 3000;
|
||||||
|
server_name localhost;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
tcp_nopush on;
|
||||||
|
gzip on;
|
||||||
|
gzip_vary on;
|
||||||
|
gzip_min_length 1024;
|
||||||
|
gzip_types
|
||||||
|
text/plain
|
||||||
|
text/css
|
||||||
|
text/xml
|
||||||
|
text/javascript
|
||||||
|
application/javascript
|
||||||
|
application/xml+rss
|
||||||
|
application/json
|
||||||
|
application/xml;
|
||||||
|
|
||||||
|
# Security headers
|
||||||
|
add_header X-Frame-Options DENY always;
|
||||||
|
add_header X-Content-Type-Options nosniff always;
|
||||||
|
add_header X-XSS-Protection "1; mode=block" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
|
||||||
|
# Handle client-side routing
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# API proxy
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://${BACKEND_HOST}:${BACKEND_PORT};
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header X-Forwarded-Host $host;
|
||||||
|
|
||||||
|
# Preserve original client IP through proxy chain
|
||||||
|
proxy_set_header X-Original-Forwarded-For $http_x_forwarded_for;
|
||||||
|
|
||||||
|
# CORS headers for API calls
|
||||||
|
add_header Access-Control-Allow-Origin * always;
|
||||||
|
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" always;
|
||||||
|
add_header Access-Control-Allow-Headers "DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization" always;
|
||||||
|
|
||||||
|
# Handle preflight requests
|
||||||
|
if ($request_method = 'OPTIONS') {
|
||||||
|
return 204;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Static assets caching
|
||||||
|
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
|
# Health check endpoint
|
||||||
|
location /health {
|
||||||
|
access_log off;
|
||||||
|
return 200 "healthy\n";
|
||||||
|
add_header Content-Type text/plain;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "patchmon-frontend",
|
"name": "patchmon-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.0",
|
"version": "1.2.6",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
@@ -17,11 +17,15 @@
|
|||||||
"axios": "^1.6.2",
|
"axios": "^1.6.2",
|
||||||
"chart.js": "^4.4.0",
|
"chart.js": "^4.4.0",
|
||||||
"clsx": "^2.0.0",
|
"clsx": "^2.0.0",
|
||||||
|
"cors": "^2.8.5",
|
||||||
"date-fns": "^2.30.0",
|
"date-fns": "^2.30.0",
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"http-proxy-middleware": "^2.0.6",
|
||||||
"lucide-react": "^0.294.0",
|
"lucide-react": "^0.294.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-chartjs-2": "^5.2.0",
|
"react-chartjs-2": "^5.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
|
"react-icons": "^5.5.0",
|
||||||
"react-router-dom": "^6.20.1"
|
"react-router-dom": "^6.20.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -38,6 +42,6 @@
|
|||||||
"vite": "^7.1.5"
|
"vite": "^7.1.5"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"esbuild": "^0.24.4"
|
"esbuild": "^0.25.10"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
45
frontend/server.js
Normal file
45
frontend/server.js
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import path from 'path';
|
||||||
|
import cors from 'cors';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import { createProxyMiddleware } from 'http-proxy-middleware';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const PORT = process.env.PORT || 3000;
|
||||||
|
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend:3001';
|
||||||
|
|
||||||
|
// Enable CORS for API calls
|
||||||
|
app.use(cors({
|
||||||
|
origin: process.env.CORS_ORIGIN || '*',
|
||||||
|
credentials: true
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Proxy API requests to backend
|
||||||
|
app.use('/api', createProxyMiddleware({
|
||||||
|
target: BACKEND_URL,
|
||||||
|
changeOrigin: true,
|
||||||
|
logLevel: 'info',
|
||||||
|
onError: (err, req, res) => {
|
||||||
|
console.error('Proxy error:', err.message);
|
||||||
|
res.status(500).json({ error: 'Backend service unavailable' });
|
||||||
|
},
|
||||||
|
onProxyReq: (proxyReq, req, res) => {
|
||||||
|
console.log(`Proxying ${req.method} ${req.path} to ${BACKEND_URL}`);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Serve static files from dist directory
|
||||||
|
app.use(express.static(path.join(__dirname, 'dist')));
|
||||||
|
|
||||||
|
// Handle SPA routing - serve index.html for all routes
|
||||||
|
app.get('*', (req, res) => {
|
||||||
|
res.sendFile(path.join(__dirname, 'dist', 'index.html'));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log(`Frontend server running on port ${PORT}`);
|
||||||
|
console.log(`Serving from: ${path.join(__dirname, 'dist')}`);
|
||||||
|
});
|
||||||
@@ -1,99 +1,119 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { Routes, Route } from 'react-router-dom'
|
import { Routes, Route } from 'react-router-dom'
|
||||||
import { AuthProvider } from './contexts/AuthContext'
|
import { AuthProvider, useAuth } from './contexts/AuthContext'
|
||||||
import { ThemeProvider } from './contexts/ThemeContext'
|
import { ThemeProvider } from './contexts/ThemeContext'
|
||||||
|
import { UpdateNotificationProvider } from './contexts/UpdateNotificationContext'
|
||||||
import ProtectedRoute from './components/ProtectedRoute'
|
import ProtectedRoute from './components/ProtectedRoute'
|
||||||
import Layout from './components/Layout'
|
import Layout from './components/Layout'
|
||||||
import Login from './pages/Login'
|
import Login from './pages/Login'
|
||||||
import Dashboard from './pages/Dashboard'
|
import Dashboard from './pages/Dashboard'
|
||||||
import Hosts from './pages/Hosts'
|
import Hosts from './pages/Hosts'
|
||||||
import HostGroups from './pages/HostGroups'
|
|
||||||
import Packages from './pages/Packages'
|
import Packages from './pages/Packages'
|
||||||
import Repositories from './pages/Repositories'
|
import Repositories from './pages/Repositories'
|
||||||
import RepositoryDetail from './pages/RepositoryDetail'
|
import RepositoryDetail from './pages/RepositoryDetail'
|
||||||
import Users from './pages/Users'
|
import Users from './pages/Users'
|
||||||
import Permissions from './pages/Permissions'
|
import Permissions from './pages/Permissions'
|
||||||
import Settings from './pages/Settings'
|
import Settings from './pages/Settings'
|
||||||
|
import Options from './pages/Options'
|
||||||
import Profile from './pages/Profile'
|
import Profile from './pages/Profile'
|
||||||
import HostDetail from './pages/HostDetail'
|
import HostDetail from './pages/HostDetail'
|
||||||
import PackageDetail from './pages/PackageDetail'
|
import PackageDetail from './pages/PackageDetail'
|
||||||
|
import FirstTimeAdminSetup from './components/FirstTimeAdminSetup'
|
||||||
|
|
||||||
|
function AppRoutes() {
|
||||||
|
const { needsFirstTimeSetup, checkingSetup, isAuthenticated } = useAuth()
|
||||||
|
const isAuth = isAuthenticated() // Call the function to get boolean value
|
||||||
|
|
||||||
|
// Show loading while checking if setup is needed
|
||||||
|
if (checkingSetup) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-secondary-50 dark:from-secondary-900 dark:to-secondary-800 flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto mb-4"></div>
|
||||||
|
<p className="text-secondary-600 dark:text-secondary-300">Checking system status...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show first-time setup if no admin users exist
|
||||||
|
if (needsFirstTimeSetup && !isAuth) {
|
||||||
|
return <FirstTimeAdminSetup />
|
||||||
|
}
|
||||||
|
|
||||||
function App() {
|
|
||||||
return (
|
return (
|
||||||
<ThemeProvider>
|
|
||||||
<AuthProvider>
|
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<Login />} />
|
<Route path="/login" element={<Login />} />
|
||||||
<Route path="/" element={
|
<Route path="/" element={
|
||||||
<ProtectedRoute requirePermission="canViewDashboard">
|
<ProtectedRoute requirePermission="can_view_dashboard">
|
||||||
<Layout>
|
<Layout>
|
||||||
<Dashboard />
|
<Dashboard />
|
||||||
</Layout>
|
</Layout>
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
} />
|
} />
|
||||||
<Route path="/hosts" element={
|
<Route path="/hosts" element={
|
||||||
<ProtectedRoute requirePermission="canViewHosts">
|
<ProtectedRoute requirePermission="can_view_hosts">
|
||||||
<Layout>
|
<Layout>
|
||||||
<Hosts />
|
<Hosts />
|
||||||
</Layout>
|
</Layout>
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
} />
|
} />
|
||||||
<Route path="/hosts/:hostId" element={
|
<Route path="/hosts/:hostId" element={
|
||||||
<ProtectedRoute requirePermission="canViewHosts">
|
<ProtectedRoute requirePermission="can_view_hosts">
|
||||||
<Layout>
|
<Layout>
|
||||||
<HostDetail />
|
<HostDetail />
|
||||||
</Layout>
|
</Layout>
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
} />
|
} />
|
||||||
<Route path="/host-groups" element={
|
|
||||||
<ProtectedRoute requirePermission="canManageHosts">
|
|
||||||
<Layout>
|
|
||||||
<HostGroups />
|
|
||||||
</Layout>
|
|
||||||
</ProtectedRoute>
|
|
||||||
} />
|
|
||||||
<Route path="/packages" element={
|
<Route path="/packages" element={
|
||||||
<ProtectedRoute requirePermission="canViewPackages">
|
<ProtectedRoute requirePermission="can_view_packages">
|
||||||
<Layout>
|
<Layout>
|
||||||
<Packages />
|
<Packages />
|
||||||
</Layout>
|
</Layout>
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
} />
|
} />
|
||||||
<Route path="/repositories" element={
|
<Route path="/repositories" element={
|
||||||
<ProtectedRoute requirePermission="canViewHosts">
|
<ProtectedRoute requirePermission="can_view_hosts">
|
||||||
<Layout>
|
<Layout>
|
||||||
<Repositories />
|
<Repositories />
|
||||||
</Layout>
|
</Layout>
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
} />
|
} />
|
||||||
<Route path="/repositories/:repositoryId" element={
|
<Route path="/repositories/:repositoryId" element={
|
||||||
<ProtectedRoute requirePermission="canViewHosts">
|
<ProtectedRoute requirePermission="can_view_hosts">
|
||||||
<Layout>
|
<Layout>
|
||||||
<RepositoryDetail />
|
<RepositoryDetail />
|
||||||
</Layout>
|
</Layout>
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
} />
|
} />
|
||||||
<Route path="/users" element={
|
<Route path="/users" element={
|
||||||
<ProtectedRoute requirePermission="canViewUsers">
|
<ProtectedRoute requirePermission="can_view_users">
|
||||||
<Layout>
|
<Layout>
|
||||||
<Users />
|
<Users />
|
||||||
</Layout>
|
</Layout>
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
} />
|
} />
|
||||||
<Route path="/permissions" element={
|
<Route path="/permissions" element={
|
||||||
<ProtectedRoute requirePermission="canManageSettings">
|
<ProtectedRoute requirePermission="can_manage_settings">
|
||||||
<Layout>
|
<Layout>
|
||||||
<Permissions />
|
<Permissions />
|
||||||
</Layout>
|
</Layout>
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
} />
|
} />
|
||||||
<Route path="/settings" element={
|
<Route path="/settings" element={
|
||||||
<ProtectedRoute requirePermission="canManageSettings">
|
<ProtectedRoute requirePermission="can_manage_settings">
|
||||||
<Layout>
|
<Layout>
|
||||||
<Settings />
|
<Settings />
|
||||||
</Layout>
|
</Layout>
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
} />
|
} />
|
||||||
|
<Route path="/options" element={
|
||||||
|
<ProtectedRoute requirePermission="can_manage_hosts">
|
||||||
|
<Layout>
|
||||||
|
<Options />
|
||||||
|
</Layout>
|
||||||
|
</ProtectedRoute>
|
||||||
|
} />
|
||||||
<Route path="/profile" element={
|
<Route path="/profile" element={
|
||||||
<ProtectedRoute>
|
<ProtectedRoute>
|
||||||
<Layout>
|
<Layout>
|
||||||
@@ -102,13 +122,23 @@ function App() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
} />
|
} />
|
||||||
<Route path="/packages/:packageId" element={
|
<Route path="/packages/:packageId" element={
|
||||||
<ProtectedRoute requirePermission="canViewPackages">
|
<ProtectedRoute requirePermission="can_view_packages">
|
||||||
<Layout>
|
<Layout>
|
||||||
<PackageDetail />
|
<PackageDetail />
|
||||||
</Layout>
|
</Layout>
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
} />
|
} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<ThemeProvider>
|
||||||
|
<AuthProvider>
|
||||||
|
<UpdateNotificationProvider>
|
||||||
|
<AppRoutes />
|
||||||
|
</UpdateNotificationProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -28,9 +28,11 @@ import {
|
|||||||
Settings as SettingsIcon
|
Settings as SettingsIcon
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { dashboardPreferencesAPI } from '../utils/api';
|
import { dashboardPreferencesAPI } from '../utils/api';
|
||||||
|
import { useTheme } from '../contexts/ThemeContext';
|
||||||
|
|
||||||
// Sortable Card Item Component
|
// Sortable Card Item Component
|
||||||
const SortableCardItem = ({ card, onToggle }) => {
|
const SortableCardItem = ({ card, onToggle }) => {
|
||||||
|
const { isDark } = useTheme();
|
||||||
const {
|
const {
|
||||||
attributes,
|
attributes,
|
||||||
listeners,
|
listeners,
|
||||||
@@ -50,7 +52,7 @@ const SortableCardItem = ({ card, onToggle }) => {
|
|||||||
<div
|
<div
|
||||||
ref={setNodeRef}
|
ref={setNodeRef}
|
||||||
style={style}
|
style={style}
|
||||||
className={`flex items-center justify-between p-3 bg-white border border-secondary-200 rounded-lg ${
|
className={`flex items-center justify-between p-3 bg-white dark:bg-secondary-800 border border-secondary-200 dark:border-secondary-600 rounded-lg ${
|
||||||
isDragging ? 'shadow-lg' : 'shadow-sm'
|
isDragging ? 'shadow-lg' : 'shadow-sm'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -58,13 +60,16 @@ const SortableCardItem = ({ card, onToggle }) => {
|
|||||||
<button
|
<button
|
||||||
{...attributes}
|
{...attributes}
|
||||||
{...listeners}
|
{...listeners}
|
||||||
className="text-secondary-400 hover:text-secondary-600 cursor-grab active:cursor-grabbing"
|
className="text-secondary-400 hover:text-secondary-600 dark:text-secondary-500 dark:hover:text-secondary-300 cursor-grab active:cursor-grabbing"
|
||||||
>
|
>
|
||||||
<GripVertical className="h-4 w-4" />
|
<GripVertical className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="text-sm font-medium text-secondary-900">
|
<div className="text-sm font-medium text-secondary-900 dark:text-white">
|
||||||
{card.title}
|
{card.title}
|
||||||
|
{card.typeLabel ? (
|
||||||
|
<span className="ml-2 text-xs font-normal text-secondary-500 dark:text-secondary-400">({card.typeLabel})</span>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -73,8 +78,8 @@ const SortableCardItem = ({ card, onToggle }) => {
|
|||||||
onClick={() => onToggle(card.cardId)}
|
onClick={() => onToggle(card.cardId)}
|
||||||
className={`flex items-center gap-1 px-2 py-1 rounded text-xs font-medium transition-colors ${
|
className={`flex items-center gap-1 px-2 py-1 rounded text-xs font-medium transition-colors ${
|
||||||
card.enabled
|
card.enabled
|
||||||
? 'bg-green-100 text-green-800 hover:bg-green-200'
|
? 'bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 hover:bg-green-200 dark:hover:bg-green-800'
|
||||||
: 'bg-secondary-100 text-secondary-600 hover:bg-secondary-200'
|
: 'bg-secondary-100 dark:bg-secondary-700 text-secondary-600 dark:text-secondary-300 hover:bg-secondary-200 dark:hover:bg-secondary-600'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{card.enabled ? (
|
{card.enabled ? (
|
||||||
@@ -97,6 +102,7 @@ const DashboardSettingsModal = ({ isOpen, onClose }) => {
|
|||||||
const [cards, setCards] = useState([]);
|
const [cards, setCards] = useState([]);
|
||||||
const [hasChanges, setHasChanges] = useState(false);
|
const [hasChanges, setHasChanges] = useState(false);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const { isDark } = useTheme();
|
||||||
|
|
||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
useSensor(PointerSensor),
|
useSensor(PointerSensor),
|
||||||
@@ -138,15 +144,39 @@ const DashboardSettingsModal = ({ isOpen, onClose }) => {
|
|||||||
// Initialize cards when preferences or defaults are loaded
|
// Initialize cards when preferences or defaults are loaded
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (preferences && defaultCards) {
|
if (preferences && defaultCards) {
|
||||||
|
// Normalize server preferences (snake_case -> camelCase)
|
||||||
|
const normalizedPreferences = preferences.map((p) => ({
|
||||||
|
cardId: p.cardId ?? p.card_id,
|
||||||
|
enabled: p.enabled,
|
||||||
|
order: p.order,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const typeLabelFor = (cardId) => {
|
||||||
|
if (['totalHosts','hostsNeedingUpdates','totalOutdatedPackages','securityUpdates','upToDateHosts','totalHostGroups','totalUsers','totalRepos'].includes(cardId)) return 'Top card';
|
||||||
|
if (cardId === 'osDistribution') return 'Pie chart';
|
||||||
|
if (cardId === 'osDistributionBar') return 'Bar chart';
|
||||||
|
if (cardId === 'updateStatus') return 'Pie chart';
|
||||||
|
if (cardId === 'packagePriority') return 'Pie chart';
|
||||||
|
if (cardId === 'recentUsers') return 'Table';
|
||||||
|
if (cardId === 'recentCollection') return 'Table';
|
||||||
|
if (cardId === 'quickStats') return 'Wide card';
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
// Merge user preferences with default cards
|
// Merge user preferences with default cards
|
||||||
const mergedCards = defaultCards.map(defaultCard => {
|
const mergedCards = defaultCards
|
||||||
const userPreference = preferences.find(p => p.cardId === defaultCard.cardId);
|
.map((defaultCard) => {
|
||||||
|
const userPreference = normalizedPreferences.find(
|
||||||
|
(p) => p.cardId === defaultCard.cardId
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
...defaultCard,
|
...defaultCard,
|
||||||
enabled: userPreference ? userPreference.enabled : defaultCard.enabled,
|
enabled: userPreference ? userPreference.enabled : defaultCard.enabled,
|
||||||
order: userPreference ? userPreference.order : defaultCard.order
|
order: userPreference ? userPreference.order : defaultCard.order,
|
||||||
|
typeLabel: typeLabelFor(defaultCard.cardId),
|
||||||
};
|
};
|
||||||
}).sort((a, b) => a.order - b.order);
|
})
|
||||||
|
.sort((a, b) => a.order - b.order);
|
||||||
|
|
||||||
setCards(mergedCards);
|
setCards(mergedCards);
|
||||||
}
|
}
|
||||||
@@ -212,24 +242,24 @@ const DashboardSettingsModal = ({ isOpen, onClose }) => {
|
|||||||
<div className="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
<div className="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||||
<div className="fixed inset-0 bg-secondary-500 bg-opacity-75 transition-opacity" onClick={onClose} />
|
<div className="fixed inset-0 bg-secondary-500 bg-opacity-75 transition-opacity" onClick={onClose} />
|
||||||
|
|
||||||
<div className="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
|
<div className="inline-block align-bottom bg-white dark:bg-secondary-800 rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
|
||||||
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
<div className="bg-white dark:bg-secondary-800 px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<SettingsIcon className="h-5 w-5 text-primary-600" />
|
<SettingsIcon className="h-5 w-5 text-primary-600" />
|
||||||
<h3 className="text-lg font-medium text-secondary-900">
|
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
|
||||||
Dashboard Settings
|
Dashboard Settings
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="text-secondary-400 hover:text-secondary-600"
|
className="text-secondary-400 hover:text-secondary-600 dark:text-secondary-500 dark:hover:text-secondary-300"
|
||||||
>
|
>
|
||||||
<X className="h-5 w-5" />
|
<X className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-sm text-secondary-600 mb-6">
|
<p className="text-sm text-secondary-600 dark:text-secondary-400 mb-6">
|
||||||
Customize your dashboard by reordering cards and toggling their visibility.
|
Customize your dashboard by reordering cards and toggling their visibility.
|
||||||
Drag cards to reorder them, and click the visibility toggle to show/hide cards.
|
Drag cards to reorder them, and click the visibility toggle to show/hide cards.
|
||||||
</p>
|
</p>
|
||||||
@@ -259,7 +289,7 @@ const DashboardSettingsModal = ({ isOpen, onClose }) => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-secondary-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
|
<div className="bg-secondary-50 dark:bg-secondary-700 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
|
||||||
<button
|
<button
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={!hasChanges || updatePreferencesMutation.isPending}
|
disabled={!hasChanges || updatePreferencesMutation.isPending}
|
||||||
@@ -284,7 +314,7 @@ const DashboardSettingsModal = ({ isOpen, onClose }) => {
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={handleReset}
|
onClick={handleReset}
|
||||||
className="mt-3 w-full inline-flex justify-center rounded-md border border-secondary-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-secondary-700 hover:bg-secondary-50 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
|
className="mt-3 w-full inline-flex justify-center rounded-md border border-secondary-300 dark:border-secondary-600 shadow-sm px-4 py-2 bg-white dark:bg-secondary-800 text-base font-medium text-secondary-700 dark:text-secondary-200 hover:bg-secondary-50 dark:hover:bg-secondary-700 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
|
||||||
>
|
>
|
||||||
<RotateCcw className="h-4 w-4 mr-2" />
|
<RotateCcw className="h-4 w-4 mr-2" />
|
||||||
Reset to Defaults
|
Reset to Defaults
|
||||||
@@ -292,7 +322,7 @@ const DashboardSettingsModal = ({ isOpen, onClose }) => {
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="mt-3 w-full inline-flex justify-center rounded-md border border-secondary-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-secondary-700 hover:bg-secondary-50 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
|
className="mt-3 w-full inline-flex justify-center rounded-md border border-secondary-300 dark:border-secondary-600 shadow-sm px-4 py-2 bg-white dark:bg-secondary-800 text-base font-medium text-secondary-700 dark:text-secondary-200 hover:bg-secondary-50 dark:hover:bg-secondary-700 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
297
frontend/src/components/FirstTimeAdminSetup.jsx
Normal file
297
frontend/src/components/FirstTimeAdminSetup.jsx
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
import React, { useState } from 'react'
|
||||||
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
|
import { UserPlus, Shield, CheckCircle, AlertCircle } from 'lucide-react'
|
||||||
|
|
||||||
|
const FirstTimeAdminSetup = () => {
|
||||||
|
const { login } = useAuth()
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
username: '',
|
||||||
|
email: '',
|
||||||
|
password: '',
|
||||||
|
confirmPassword: '',
|
||||||
|
firstName: '',
|
||||||
|
lastName: ''
|
||||||
|
})
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [success, setSuccess] = useState(false)
|
||||||
|
|
||||||
|
const handleInputChange = (e) => {
|
||||||
|
const { name, value } = e.target
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
[name]: value
|
||||||
|
}))
|
||||||
|
// Clear error when user starts typing
|
||||||
|
if (error) setError('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const validateForm = () => {
|
||||||
|
if (!formData.firstName.trim()) {
|
||||||
|
setError('First name is required')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (!formData.lastName.trim()) {
|
||||||
|
setError('Last name is required')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (!formData.username.trim()) {
|
||||||
|
setError('Username is required')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (!formData.email.trim()) {
|
||||||
|
setError('Email address is required')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enhanced email validation
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||||
|
if (!emailRegex.test(formData.email.trim())) {
|
||||||
|
setError('Please enter a valid email address (e.g., user@example.com)')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (formData.password.length < 8) {
|
||||||
|
setError('Password must be at least 8 characters for security')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (formData.password !== formData.confirmPassword) {
|
||||||
|
setError('Passwords do not match')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
if (!validateForm()) return
|
||||||
|
|
||||||
|
setIsLoading(true)
|
||||||
|
setError('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/v1/auth/setup-admin', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
username: formData.username.trim(),
|
||||||
|
email: formData.email.trim(),
|
||||||
|
password: formData.password,
|
||||||
|
firstName: formData.firstName.trim(),
|
||||||
|
lastName: formData.lastName.trim()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
setSuccess(true)
|
||||||
|
// Auto-login the user after successful setup
|
||||||
|
setTimeout(() => {
|
||||||
|
login(formData.username.trim(), formData.password)
|
||||||
|
}, 2000)
|
||||||
|
} else {
|
||||||
|
setError(data.error || 'Failed to create admin user')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Setup error:', error)
|
||||||
|
setError('Network error. Please try again.')
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-secondary-50 dark:from-secondary-900 dark:to-secondary-800 flex items-center justify-center p-4">
|
||||||
|
<div className="max-w-md w-full">
|
||||||
|
<div className="card p-8 text-center">
|
||||||
|
<div className="flex justify-center mb-6">
|
||||||
|
<div className="bg-green-100 dark:bg-green-900 p-4 rounded-full">
|
||||||
|
<CheckCircle className="h-12 w-12 text-green-600 dark:text-green-400" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold text-secondary-900 dark:text-white mb-4">
|
||||||
|
Admin Account Created!
|
||||||
|
</h1>
|
||||||
|
<p className="text-secondary-600 dark:text-secondary-300 mb-6">
|
||||||
|
Your admin account has been successfully created. You will be automatically logged in shortly.
|
||||||
|
</p>
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary-600"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-secondary-50 dark:from-secondary-900 dark:to-secondary-800 flex items-center justify-center p-4">
|
||||||
|
<div className="max-w-md w-full">
|
||||||
|
<div className="card p-8">
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<div className="flex justify-center mb-4">
|
||||||
|
<div className="bg-primary-100 dark:bg-primary-900 p-4 rounded-full">
|
||||||
|
<Shield className="h-12 w-12 text-primary-600 dark:text-primary-400" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold text-secondary-900 dark:text-white mb-2">
|
||||||
|
Welcome to PatchMon
|
||||||
|
</h1>
|
||||||
|
<p className="text-secondary-600 dark:text-secondary-300">
|
||||||
|
Let's set up your admin account to get started
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-6 p-4 bg-danger-50 dark:bg-danger-900 border border-danger-200 dark:border-danger-700 rounded-lg">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<AlertCircle className="h-5 w-5 text-danger-600 dark:text-danger-400 mr-2" />
|
||||||
|
<span className="text-danger-700 dark:text-danger-300 text-sm">{error}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="firstName" className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
|
||||||
|
First Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="firstName"
|
||||||
|
name="firstName"
|
||||||
|
value={formData.firstName}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className="input w-full"
|
||||||
|
placeholder="Enter your first name"
|
||||||
|
required
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="lastName" className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
|
||||||
|
Last Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="lastName"
|
||||||
|
name="lastName"
|
||||||
|
value={formData.lastName}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className="input w-full"
|
||||||
|
placeholder="Enter your last name"
|
||||||
|
required
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="username" className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
|
||||||
|
Username
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="username"
|
||||||
|
name="username"
|
||||||
|
value={formData.username}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className="input w-full"
|
||||||
|
placeholder="Enter your username"
|
||||||
|
required
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="email" className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
|
||||||
|
Email Address
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
value={formData.email}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className="input w-full"
|
||||||
|
placeholder="Enter your email"
|
||||||
|
required
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="password" className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
value={formData.password}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className="input w-full"
|
||||||
|
placeholder="Enter your password (min 8 characters)"
|
||||||
|
required
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="confirmPassword" className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
|
||||||
|
Confirm Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="confirmPassword"
|
||||||
|
name="confirmPassword"
|
||||||
|
value={formData.confirmPassword}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className="input w-full"
|
||||||
|
placeholder="Confirm your password"
|
||||||
|
required
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading}
|
||||||
|
className="btn-primary w-full flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
||||||
|
Creating Admin Account...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<UserPlus className="h-4 w-4" />
|
||||||
|
Create Admin Account
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="mt-8 p-4 bg-blue-50 dark:bg-blue-900 border border-blue-200 dark:border-blue-700 rounded-lg">
|
||||||
|
<div className="flex items-start">
|
||||||
|
<Shield className="h-5 w-5 text-blue-600 dark:text-blue-400 mr-2 mt-0.5 flex-shrink-0" />
|
||||||
|
<div className="text-sm text-blue-700 dark:text-blue-300">
|
||||||
|
<p className="font-medium mb-1">Admin Privileges</p>
|
||||||
|
<p>This account will have full administrative access to manage users, hosts, packages, and system settings.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FirstTimeAdminSetup
|
||||||
157
frontend/src/components/InlineEdit.jsx
Normal file
157
frontend/src/components/InlineEdit.jsx
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
|
import { Edit2, Check, X } from 'lucide-react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
const InlineEdit = ({
|
||||||
|
value,
|
||||||
|
onSave,
|
||||||
|
onCancel,
|
||||||
|
placeholder = "Enter value...",
|
||||||
|
maxLength = 100,
|
||||||
|
className = "",
|
||||||
|
disabled = false,
|
||||||
|
validate = null,
|
||||||
|
linkTo = null
|
||||||
|
}) => {
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
const [editValue, setEditValue] = useState(value);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const inputRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isEditing && inputRef.current) {
|
||||||
|
inputRef.current.focus();
|
||||||
|
inputRef.current.select();
|
||||||
|
}
|
||||||
|
}, [isEditing]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setEditValue(value);
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
const handleEdit = () => {
|
||||||
|
if (disabled) return;
|
||||||
|
setIsEditing(true);
|
||||||
|
setEditValue(value);
|
||||||
|
setError('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
setIsEditing(false);
|
||||||
|
setEditValue(value);
|
||||||
|
setError('');
|
||||||
|
if (onCancel) onCancel();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (disabled || isLoading) return;
|
||||||
|
|
||||||
|
// Validate if validator function provided
|
||||||
|
if (validate) {
|
||||||
|
const validationError = validate(editValue);
|
||||||
|
if (validationError) {
|
||||||
|
setError(validationError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if value actually changed
|
||||||
|
if (editValue.trim() === value.trim()) {
|
||||||
|
setIsEditing(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await onSave(editValue.trim());
|
||||||
|
setIsEditing(false);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || 'Failed to save');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSave();
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
handleCancel();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isEditing) {
|
||||||
|
return (
|
||||||
|
<div className={`flex items-center gap-2 ${className}`}>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={editValue}
|
||||||
|
onChange={(e) => setEditValue(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder={placeholder}
|
||||||
|
maxLength={maxLength}
|
||||||
|
disabled={isLoading}
|
||||||
|
className={`flex-1 px-2 py-1 text-sm border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent ${
|
||||||
|
error ? 'border-red-500' : ''
|
||||||
|
} ${isLoading ? 'opacity-50' : ''}`}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={isLoading || editValue.trim() === ''}
|
||||||
|
className="p-1 text-green-600 hover:text-green-700 hover:bg-green-50 dark:hover:bg-green-900/20 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
title="Save"
|
||||||
|
>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleCancel}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="p-1 text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
title="Cancel"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
{error && (
|
||||||
|
<span className="text-xs text-red-600 dark:text-red-400">{error}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayValue = linkTo ? (
|
||||||
|
<Link
|
||||||
|
to={linkTo}
|
||||||
|
className="text-sm font-medium text-secondary-900 dark:text-white hover:text-primary-600 dark:hover:text-primary-400 cursor-pointer transition-colors"
|
||||||
|
title="View details"
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm font-medium text-secondary-900 dark:text-white">
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex items-center gap-2 group ${className}`}>
|
||||||
|
{displayValue}
|
||||||
|
{!disabled && (
|
||||||
|
<button
|
||||||
|
onClick={handleEdit}
|
||||||
|
className="p-1 text-secondary-400 hover:text-secondary-600 dark:hover:text-secondary-300 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded transition-colors opacity-0 group-hover:opacity-100"
|
||||||
|
title="Edit"
|
||||||
|
>
|
||||||
|
<Edit2 className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default InlineEdit;
|
||||||
257
frontend/src/components/InlineGroupEdit.jsx
Normal file
257
frontend/src/components/InlineGroupEdit.jsx
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
import React, { useState, useRef, useEffect, useMemo } from 'react';
|
||||||
|
import { Edit2, Check, X, ChevronDown } from 'lucide-react';
|
||||||
|
|
||||||
|
const InlineGroupEdit = ({
|
||||||
|
value,
|
||||||
|
onSave,
|
||||||
|
onCancel,
|
||||||
|
options = [],
|
||||||
|
className = "",
|
||||||
|
disabled = false,
|
||||||
|
placeholder = "Select group..."
|
||||||
|
}) => {
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
const [selectedValue, setSelectedValue] = useState(value);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0, width: 0 });
|
||||||
|
const dropdownRef = useRef(null);
|
||||||
|
const buttonRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isEditing && dropdownRef.current) {
|
||||||
|
dropdownRef.current.focus();
|
||||||
|
}
|
||||||
|
}, [isEditing]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedValue(value);
|
||||||
|
// Force re-render when value changes
|
||||||
|
if (!isEditing) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
}, [value, isEditing]);
|
||||||
|
|
||||||
|
// Calculate dropdown position
|
||||||
|
const calculateDropdownPosition = () => {
|
||||||
|
if (buttonRef.current) {
|
||||||
|
const rect = buttonRef.current.getBoundingClientRect();
|
||||||
|
setDropdownPosition({
|
||||||
|
top: rect.bottom + window.scrollY + 4,
|
||||||
|
left: rect.left + window.scrollX,
|
||||||
|
width: rect.width
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Close dropdown when clicking outside
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event) => {
|
||||||
|
if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isOpen) {
|
||||||
|
calculateDropdownPosition();
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
window.addEventListener('resize', calculateDropdownPosition);
|
||||||
|
window.addEventListener('scroll', calculateDropdownPosition);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
window.removeEventListener('resize', calculateDropdownPosition);
|
||||||
|
window.removeEventListener('scroll', calculateDropdownPosition);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const handleEdit = () => {
|
||||||
|
if (disabled) return;
|
||||||
|
setIsEditing(true);
|
||||||
|
setSelectedValue(value);
|
||||||
|
setError('');
|
||||||
|
// Automatically open dropdown when editing starts
|
||||||
|
setTimeout(() => {
|
||||||
|
setIsOpen(true);
|
||||||
|
}, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
setIsEditing(false);
|
||||||
|
setSelectedValue(value);
|
||||||
|
setError('');
|
||||||
|
setIsOpen(false);
|
||||||
|
if (onCancel) onCancel();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (disabled || isLoading) return;
|
||||||
|
|
||||||
|
// Check if value actually changed
|
||||||
|
if (selectedValue === value) {
|
||||||
|
setIsEditing(false);
|
||||||
|
setIsOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await onSave(selectedValue);
|
||||||
|
// Update the local value to match the saved value
|
||||||
|
setSelectedValue(selectedValue);
|
||||||
|
setIsEditing(false);
|
||||||
|
setIsOpen(false);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || 'Failed to save');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSave();
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
handleCancel();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const displayValue = useMemo(() => {
|
||||||
|
if (!value) {
|
||||||
|
return 'Ungrouped';
|
||||||
|
}
|
||||||
|
const option = options.find(opt => opt.id === value);
|
||||||
|
return option ? option.name : 'Unknown Group';
|
||||||
|
}, [value, options]);
|
||||||
|
|
||||||
|
const displayColor = useMemo(() => {
|
||||||
|
if (!value) return 'bg-secondary-100 text-secondary-800';
|
||||||
|
const option = options.find(opt => opt.id === value);
|
||||||
|
return option ? `text-white` : 'bg-secondary-100 text-secondary-800';
|
||||||
|
}, [value, options]);
|
||||||
|
|
||||||
|
const selectedOption = useMemo(() => {
|
||||||
|
return options.find(opt => opt.id === value);
|
||||||
|
}, [value, options]);
|
||||||
|
|
||||||
|
if (isEditing) {
|
||||||
|
return (
|
||||||
|
<div className={`relative ${className}`} ref={dropdownRef}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<button
|
||||||
|
ref={buttonRef}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
disabled={isLoading}
|
||||||
|
className={`w-full px-3 py-1 text-sm border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent flex items-center justify-between ${
|
||||||
|
error ? 'border-red-500' : ''
|
||||||
|
} ${isLoading ? 'opacity-50' : ''}`}
|
||||||
|
>
|
||||||
|
<span className="truncate">
|
||||||
|
{selectedValue ? options.find(opt => opt.id === selectedValue)?.name || 'Unknown Group' : 'Ungrouped'}
|
||||||
|
</span>
|
||||||
|
<ChevronDown className="h-4 w-4 flex-shrink-0" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div
|
||||||
|
className="fixed z-50 bg-white dark:bg-secondary-800 border border-secondary-300 dark:border-secondary-600 rounded-md shadow-lg max-h-60 overflow-auto"
|
||||||
|
style={{
|
||||||
|
top: `${dropdownPosition.top}px`,
|
||||||
|
left: `${dropdownPosition.left}px`,
|
||||||
|
width: `${dropdownPosition.width}px`,
|
||||||
|
minWidth: '200px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="py-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedValue(null);
|
||||||
|
setIsOpen(false);
|
||||||
|
}}
|
||||||
|
className={`w-full px-3 py-2 text-left text-sm hover:bg-secondary-100 dark:hover:bg-secondary-700 flex items-center ${
|
||||||
|
selectedValue === null ? 'bg-primary-50 dark:bg-primary-900/20' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-secondary-100 text-secondary-800">
|
||||||
|
Ungrouped
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{options.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedValue(option.id);
|
||||||
|
setIsOpen(false);
|
||||||
|
}}
|
||||||
|
className={`w-full px-3 py-2 text-left text-sm hover:bg-secondary-100 dark:hover:bg-secondary-700 flex items-center ${
|
||||||
|
selectedValue === option.id ? 'bg-primary-50 dark:bg-primary-900/20' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium text-white"
|
||||||
|
style={{ backgroundColor: option.color }}
|
||||||
|
>
|
||||||
|
{option.name}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="p-1 text-green-600 hover:text-green-700 hover:bg-green-50 dark:hover:bg-green-900/20 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
title="Save"
|
||||||
|
>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleCancel}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="p-1 text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
title="Cancel"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<span className="text-xs text-red-600 dark:text-red-400 mt-1 block">{error}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex items-center gap-2 group ${className}`}>
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${displayColor}`}
|
||||||
|
style={value ? { backgroundColor: selectedOption?.color } : {}}
|
||||||
|
>
|
||||||
|
{displayValue}
|
||||||
|
</span>
|
||||||
|
{!disabled && (
|
||||||
|
<button
|
||||||
|
onClick={handleEdit}
|
||||||
|
className="p-1 text-secondary-400 hover:text-secondary-600 dark:hover:text-secondary-300 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded transition-colors opacity-0 group-hover:opacity-100"
|
||||||
|
title="Edit group"
|
||||||
|
>
|
||||||
|
<Edit2 className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default InlineGroupEdit;
|
||||||
@@ -19,12 +19,23 @@ import {
|
|||||||
RefreshCw,
|
RefreshCw,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
Wrench,
|
Wrench,
|
||||||
Plus
|
Container,
|
||||||
|
Plus,
|
||||||
|
Activity,
|
||||||
|
Cog,
|
||||||
|
FileText,
|
||||||
|
Github,
|
||||||
|
MessageCircle,
|
||||||
|
Mail,
|
||||||
|
Star,
|
||||||
|
Globe
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useState, useEffect, useRef } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { useAuth } from '../contexts/AuthContext'
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
import { dashboardAPI, formatRelativeTime } from '../utils/api'
|
import { useUpdateNotification } from '../contexts/UpdateNotificationContext'
|
||||||
|
import { dashboardAPI, formatRelativeTime, versionAPI } from '../utils/api'
|
||||||
|
import UpgradeNotificationIcon from './UpgradeNotificationIcon'
|
||||||
|
|
||||||
const Layout = ({ children }) => {
|
const Layout = ({ children }) => {
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false)
|
const [sidebarOpen, setSidebarOpen] = useState(false)
|
||||||
@@ -34,45 +45,124 @@ const Layout = ({ children }) => {
|
|||||||
return saved ? JSON.parse(saved) : false
|
return saved ? JSON.parse(saved) : false
|
||||||
})
|
})
|
||||||
const [userMenuOpen, setUserMenuOpen] = useState(false)
|
const [userMenuOpen, setUserMenuOpen] = useState(false)
|
||||||
|
const [githubStars, setGithubStars] = useState(null)
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const { user, logout, canViewHosts, canManageHosts, canViewPackages, canViewUsers, canManageUsers, canManageSettings } = useAuth()
|
const { user, logout, canViewDashboard, canViewHosts, canManageHosts, canViewPackages, canViewUsers, canManageUsers, canViewReports, canExportData, canManageSettings } = useAuth()
|
||||||
|
const { updateAvailable } = useUpdateNotification()
|
||||||
const userMenuRef = useRef(null)
|
const userMenuRef = useRef(null)
|
||||||
|
|
||||||
// Fetch dashboard stats for the "Last updated" info
|
// Fetch dashboard stats for the "Last updated" info
|
||||||
const { data: stats, refetch } = useQuery({
|
const { data: stats, refetch, isFetching } = useQuery({
|
||||||
queryKey: ['dashboardStats'],
|
queryKey: ['dashboardStats'],
|
||||||
queryFn: () => dashboardAPI.getStats().then(res => res.data),
|
queryFn: () => dashboardAPI.getStats().then(res => res.data),
|
||||||
refetchInterval: 60000, // Refresh every minute
|
staleTime: 5 * 60 * 1000, // Data stays fresh for 5 minutes
|
||||||
staleTime: 30000, // Consider data stale after 30 seconds
|
refetchOnWindowFocus: false, // Don't refetch when window regains focus
|
||||||
})
|
})
|
||||||
|
|
||||||
const navigation = [
|
// Fetch version info
|
||||||
{ name: 'Dashboard', href: '/', icon: Home },
|
const { data: versionInfo } = useQuery({
|
||||||
{
|
queryKey: ['versionInfo'],
|
||||||
section: 'Inventory',
|
queryFn: () => versionAPI.getCurrent().then(res => res.data),
|
||||||
items: [
|
staleTime: 300000, // Consider data stale after 5 minutes
|
||||||
...(canViewHosts() ? [{ name: 'Hosts', href: '/hosts', icon: Server }] : []),
|
})
|
||||||
...(canManageHosts() ? [{ name: 'Host Groups', href: '/host-groups', icon: Users }] : []),
|
|
||||||
...(canViewPackages() ? [{ name: 'Packages', href: '/packages', icon: Package }] : []),
|
// Build navigation based on permissions
|
||||||
...(canViewHosts() ? [{ name: 'Repos', href: '/repositories', icon: GitBranch }] : []),
|
const buildNavigation = () => {
|
||||||
{ name: 'Services', href: '/services', icon: Wrench, comingSoon: true },
|
const nav = []
|
||||||
{ name: 'Reporting', href: '/reporting', icon: BarChart3, comingSoon: true },
|
|
||||||
]
|
// Dashboard - only show if user can view dashboard
|
||||||
},
|
if (canViewDashboard()) {
|
||||||
{
|
nav.push({ name: 'Dashboard', href: '/', icon: Home })
|
||||||
section: 'PatchMon Users',
|
|
||||||
items: [
|
|
||||||
...(canViewUsers() ? [{ name: 'Users', href: '/users', icon: Users }] : []),
|
|
||||||
...(canManageSettings() ? [{ name: 'Permissions', href: '/permissions', icon: Shield }] : []),
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
section: 'Settings',
|
|
||||||
items: [
|
|
||||||
...(canManageSettings() ? [{ name: 'Server Config', href: '/settings', icon: Settings }] : []),
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
]
|
|
||||||
|
// Inventory section - only show if user has any inventory permissions
|
||||||
|
if (canViewHosts() || canViewPackages() || canViewReports()) {
|
||||||
|
const inventoryItems = []
|
||||||
|
|
||||||
|
if (canViewHosts()) {
|
||||||
|
inventoryItems.push({ name: 'Hosts', href: '/hosts', icon: Server })
|
||||||
|
inventoryItems.push({ name: 'Repos', href: '/repositories', icon: GitBranch })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (canViewPackages()) {
|
||||||
|
inventoryItems.push({ name: 'Packages', href: '/packages', icon: Package })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (canViewReports()) {
|
||||||
|
inventoryItems.push(
|
||||||
|
{ name: 'Services', href: '/services', icon: Activity, comingSoon: true },
|
||||||
|
{ name: 'Docker', href: '/docker', icon: Container, comingSoon: true },
|
||||||
|
{ name: 'Reporting', href: '/reporting', icon: BarChart3, comingSoon: true }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inventoryItems.length > 0) {
|
||||||
|
nav.push({
|
||||||
|
section: 'Inventory',
|
||||||
|
items: inventoryItems
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PatchMon Users section - only show if user can view/manage users
|
||||||
|
if (canViewUsers() || canManageUsers()) {
|
||||||
|
const userItems = []
|
||||||
|
|
||||||
|
if (canViewUsers()) {
|
||||||
|
userItems.push({ name: 'Users', href: '/users', icon: Users })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (canManageSettings()) {
|
||||||
|
userItems.push({ name: 'Permissions', href: '/permissions', icon: Shield })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userItems.length > 0) {
|
||||||
|
nav.push({
|
||||||
|
section: 'PatchMon Users',
|
||||||
|
items: userItems
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settings section - only show if user has any settings permissions
|
||||||
|
if (canManageSettings() || canViewReports() || canExportData()) {
|
||||||
|
const settingsItems = []
|
||||||
|
|
||||||
|
if (canManageSettings()) {
|
||||||
|
settingsItems.push({
|
||||||
|
name: 'PatchMon Options',
|
||||||
|
href: '/options',
|
||||||
|
icon: Settings
|
||||||
|
})
|
||||||
|
settingsItems.push({
|
||||||
|
name: 'Server Config',
|
||||||
|
href: '/settings',
|
||||||
|
icon: Wrench,
|
||||||
|
showUpgradeIcon: updateAvailable
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (canViewReports() || canExportData()) {
|
||||||
|
settingsItems.push({
|
||||||
|
name: 'Audit Log',
|
||||||
|
href: '/audit-log',
|
||||||
|
icon: FileText,
|
||||||
|
comingSoon: true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settingsItems.length > 0) {
|
||||||
|
nav.push({
|
||||||
|
section: 'Settings',
|
||||||
|
items: settingsItems
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nav
|
||||||
|
}
|
||||||
|
|
||||||
|
const navigation = buildNavigation()
|
||||||
|
|
||||||
const isActive = (path) => location.pathname === path
|
const isActive = (path) => location.pathname === path
|
||||||
|
|
||||||
@@ -82,13 +172,15 @@ const Layout = ({ children }) => {
|
|||||||
|
|
||||||
if (path === '/') return 'Dashboard'
|
if (path === '/') return 'Dashboard'
|
||||||
if (path === '/hosts') return 'Hosts'
|
if (path === '/hosts') return 'Hosts'
|
||||||
if (path === '/host-groups') return 'Host Groups'
|
|
||||||
if (path === '/packages') return 'Packages'
|
if (path === '/packages') return 'Packages'
|
||||||
if (path === '/repositories' || path.startsWith('/repositories/')) return 'Repositories'
|
if (path === '/repositories' || path.startsWith('/repositories/')) return 'Repositories'
|
||||||
if (path === '/services') return 'Services'
|
if (path === '/services') return 'Services'
|
||||||
|
if (path === '/docker') return 'Docker'
|
||||||
if (path === '/users') return 'Users'
|
if (path === '/users') return 'Users'
|
||||||
if (path === '/permissions') return 'Permissions'
|
if (path === '/permissions') return 'Permissions'
|
||||||
if (path === '/settings') return 'Settings'
|
if (path === '/settings') return 'Settings'
|
||||||
|
if (path === '/options') return 'PatchMon Options'
|
||||||
|
if (path === '/audit-log') return 'Audit Log'
|
||||||
if (path === '/profile') return 'My Profile'
|
if (path === '/profile') return 'My Profile'
|
||||||
if (path.startsWith('/hosts/')) return 'Host Details'
|
if (path.startsWith('/hosts/')) return 'Host Details'
|
||||||
if (path.startsWith('/packages/')) return 'Package Details'
|
if (path.startsWith('/packages/')) return 'Package Details'
|
||||||
@@ -106,10 +198,31 @@ const Layout = ({ children }) => {
|
|||||||
window.location.href = '/hosts?action=add'
|
window.location.href = '/hosts?action=add'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Fetch GitHub stars count
|
||||||
|
const fetchGitHubStars = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('https://api.github.com/repos/9technologygroup/patchmon.net')
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json()
|
||||||
|
setGithubStars(data.stargazers_count)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch GitHub stars:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Short format for navigation area
|
// Short format for navigation area
|
||||||
const formatRelativeTimeShort = (date) => {
|
const formatRelativeTimeShort = (date) => {
|
||||||
|
if (!date) return 'Never'
|
||||||
|
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const diff = now - new Date(date)
|
const dateObj = new Date(date)
|
||||||
|
|
||||||
|
// Check if date is valid
|
||||||
|
if (isNaN(dateObj.getTime())) return 'Invalid date'
|
||||||
|
|
||||||
|
const diff = now - dateObj
|
||||||
const seconds = Math.floor(diff / 1000)
|
const seconds = Math.floor(diff / 1000)
|
||||||
const minutes = Math.floor(seconds / 60)
|
const minutes = Math.floor(seconds / 60)
|
||||||
const hours = Math.floor(minutes / 60)
|
const hours = Math.floor(minutes / 60)
|
||||||
@@ -140,6 +253,11 @@ const Layout = ({ children }) => {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Fetch GitHub stars on component mount
|
||||||
|
useEffect(() => {
|
||||||
|
fetchGitHubStars()
|
||||||
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-secondary-50">
|
<div className="min-h-screen bg-secondary-50">
|
||||||
{/* Mobile sidebar */}
|
{/* Mobile sidebar */}
|
||||||
@@ -162,6 +280,15 @@ const Layout = ({ children }) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<nav className="mt-8 flex-1 space-y-6 px-2">
|
<nav className="mt-8 flex-1 space-y-6 px-2">
|
||||||
|
{/* Show message for users with very limited permissions */}
|
||||||
|
{navigation.length === 0 && (
|
||||||
|
<div className="px-2 py-4 text-center">
|
||||||
|
<div className="text-sm text-secondary-500 dark:text-secondary-400">
|
||||||
|
<p className="mb-2">Limited access</p>
|
||||||
|
<p className="text-xs">Contact your administrator for additional permissions</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{navigation.map((item, index) => {
|
{navigation.map((item, index) => {
|
||||||
if (item.name) {
|
if (item.name) {
|
||||||
// Single item (Dashboard)
|
// Single item (Dashboard)
|
||||||
@@ -267,7 +394,7 @@ const Layout = ({ children }) => {
|
|||||||
className="flex items-center justify-center w-8 h-8 rounded-md hover:bg-secondary-100 transition-colors"
|
className="flex items-center justify-center w-8 h-8 rounded-md hover:bg-secondary-100 transition-colors"
|
||||||
title="Expand sidebar"
|
title="Expand sidebar"
|
||||||
>
|
>
|
||||||
<ChevronRight className="h-5 w-5 text-white" />
|
<ChevronRight className="h-5 w-5 text-secondary-700 dark:text-white" />
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -280,13 +407,22 @@ const Layout = ({ children }) => {
|
|||||||
className="flex items-center justify-center w-8 h-8 rounded-md hover:bg-secondary-100 transition-colors"
|
className="flex items-center justify-center w-8 h-8 rounded-md hover:bg-secondary-100 transition-colors"
|
||||||
title="Collapse sidebar"
|
title="Collapse sidebar"
|
||||||
>
|
>
|
||||||
<ChevronLeft className="h-5 w-5 text-white" />
|
<ChevronLeft className="h-5 w-5 text-secondary-700 dark:text-white" />
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<nav className="flex flex-1 flex-col">
|
<nav className="flex flex-1 flex-col">
|
||||||
<ul className="flex flex-1 flex-col gap-y-6">
|
<ul className="flex flex-1 flex-col gap-y-6">
|
||||||
|
{/* Show message for users with very limited permissions */}
|
||||||
|
{navigation.length === 0 && (
|
||||||
|
<li className="px-2 py-4 text-center">
|
||||||
|
<div className="text-sm text-secondary-500 dark:text-secondary-400">
|
||||||
|
<p className="mb-2">Limited access</p>
|
||||||
|
<p className="text-xs">Contact your administrator for additional permissions</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
{navigation.map((item, index) => {
|
{navigation.map((item, index) => {
|
||||||
if (item.name) {
|
if (item.name) {
|
||||||
// Single item (Dashboard)
|
// Single item (Dashboard)
|
||||||
@@ -360,13 +496,18 @@ const Layout = ({ children }) => {
|
|||||||
isActive(subItem.href)
|
isActive(subItem.href)
|
||||||
? 'bg-primary-50 dark:bg-primary-600 text-primary-700 dark:text-white'
|
? 'bg-primary-50 dark:bg-primary-600 text-primary-700 dark:text-white'
|
||||||
: 'text-secondary-700 dark:text-secondary-200 hover:text-primary-700 dark:hover:text-primary-300 hover:bg-secondary-50 dark:hover:bg-secondary-700'
|
: 'text-secondary-700 dark:text-secondary-200 hover:text-primary-700 dark:hover:text-primary-300 hover:bg-secondary-50 dark:hover:bg-secondary-700'
|
||||||
} ${sidebarCollapsed ? 'justify-center p-2' : 'p-2'} ${
|
} ${sidebarCollapsed ? 'justify-center p-2 relative' : 'p-2'} ${
|
||||||
subItem.comingSoon ? 'opacity-50 cursor-not-allowed' : ''
|
subItem.comingSoon ? 'opacity-50 cursor-not-allowed' : ''
|
||||||
}`}
|
}`}
|
||||||
title={sidebarCollapsed ? subItem.name : ''}
|
title={sidebarCollapsed ? subItem.name : ''}
|
||||||
onClick={subItem.comingSoon ? (e) => e.preventDefault() : undefined}
|
onClick={subItem.comingSoon ? (e) => e.preventDefault() : undefined}
|
||||||
>
|
>
|
||||||
|
<div className={`flex items-center ${sidebarCollapsed ? 'justify-center' : ''}`}>
|
||||||
<subItem.icon className={`h-5 w-5 shrink-0 ${sidebarCollapsed ? 'mx-auto' : ''}`} />
|
<subItem.icon className={`h-5 w-5 shrink-0 ${sidebarCollapsed ? 'mx-auto' : ''}`} />
|
||||||
|
{sidebarCollapsed && subItem.showUpgradeIcon && (
|
||||||
|
<UpgradeNotificationIcon className="h-3 w-3 absolute -top-1 -right-1" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{!sidebarCollapsed && (
|
{!sidebarCollapsed && (
|
||||||
<span className="truncate flex items-center gap-2">
|
<span className="truncate flex items-center gap-2">
|
||||||
{subItem.name}
|
{subItem.name}
|
||||||
@@ -375,6 +516,9 @@ const Layout = ({ children }) => {
|
|||||||
Soon
|
Soon
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{subItem.showUpgradeIcon && (
|
||||||
|
<UpgradeNotificationIcon className="h-3 w-3" />
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
@@ -390,6 +534,7 @@ const Layout = ({ children }) => {
|
|||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
|
||||||
{/* Profile Section - Bottom of Sidebar */}
|
{/* Profile Section - Bottom of Sidebar */}
|
||||||
<div className="border-t border-secondary-200 dark:border-secondary-600">
|
<div className="border-t border-secondary-200 dark:border-secondary-600">
|
||||||
{!sidebarCollapsed ? (
|
{!sidebarCollapsed ? (
|
||||||
@@ -416,7 +561,7 @@ const Layout = ({ children }) => {
|
|||||||
? 'text-primary-700 dark:text-white'
|
? 'text-primary-700 dark:text-white'
|
||||||
: 'text-secondary-700 dark:text-secondary-200'
|
: 'text-secondary-700 dark:text-secondary-200'
|
||||||
}`}>
|
}`}>
|
||||||
{user?.username}
|
{user?.first_name || user?.username}
|
||||||
</span>
|
</span>
|
||||||
{user?.role === 'admin' && (
|
{user?.role === 'admin' && (
|
||||||
<span className="inline-flex items-center rounded-full bg-primary-100 px-1.5 py-0.5 text-xs font-medium text-primary-800">
|
<span className="inline-flex items-center rounded-full bg-primary-100 px-1.5 py-0.5 text-xs font-medium text-primary-800">
|
||||||
@@ -436,17 +581,23 @@ const Layout = ({ children }) => {
|
|||||||
</div>
|
</div>
|
||||||
{/* Updated info */}
|
{/* Updated info */}
|
||||||
{stats && (
|
{stats && (
|
||||||
<div className="px-3 py-1 border-t border-secondary-200 dark:border-secondary-700">
|
<div className="px-2 py-1 border-t border-secondary-200 dark:border-secondary-700">
|
||||||
<div className="flex items-center gap-x-2 text-xs text-secondary-500 dark:text-secondary-400">
|
<div className="flex items-center gap-x-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||||
<Clock className="h-3 w-3" />
|
<Clock className="h-3 w-3 flex-shrink-0" />
|
||||||
<span>Updated: {formatRelativeTimeShort(stats.lastUpdated)}</span>
|
<span className="truncate">Updated: {formatRelativeTimeShort(stats.lastUpdated)}</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => refetch()}
|
onClick={() => refetch()}
|
||||||
className="p-1 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded"
|
disabled={isFetching}
|
||||||
|
className="p-1 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded flex-shrink-0 disabled:opacity-50"
|
||||||
title="Refresh data"
|
title="Refresh data"
|
||||||
>
|
>
|
||||||
<RefreshCw className="h-3 w-3" />
|
<RefreshCw className={`h-3 w-3 ${isFetching ? 'animate-spin' : ''}`} />
|
||||||
</button>
|
</button>
|
||||||
|
{versionInfo && (
|
||||||
|
<span className="text-xs text-secondary-400 dark:text-secondary-500 flex-shrink-0">
|
||||||
|
v{versionInfo.version}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -473,14 +624,20 @@ const Layout = ({ children }) => {
|
|||||||
</button>
|
</button>
|
||||||
{/* Updated info for collapsed sidebar */}
|
{/* Updated info for collapsed sidebar */}
|
||||||
{stats && (
|
{stats && (
|
||||||
<div className="flex justify-center py-1 border-t border-secondary-200 dark:border-secondary-700">
|
<div className="flex flex-col items-center py-1 border-t border-secondary-200 dark:border-secondary-700">
|
||||||
<button
|
<button
|
||||||
onClick={() => refetch()}
|
onClick={() => refetch()}
|
||||||
className="p-1 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded"
|
disabled={isFetching}
|
||||||
|
className="p-1 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded disabled:opacity-50"
|
||||||
title={`Refresh data - Updated: ${formatRelativeTimeShort(stats.lastUpdated)}`}
|
title={`Refresh data - Updated: ${formatRelativeTimeShort(stats.lastUpdated)}`}
|
||||||
>
|
>
|
||||||
<RefreshCw className="h-3 w-3" />
|
<RefreshCw className={`h-3 w-3 ${isFetching ? 'animate-spin' : ''}`} />
|
||||||
</button>
|
</button>
|
||||||
|
{versionInfo && (
|
||||||
|
<span className="text-xs text-secondary-400 dark:text-secondary-500 mt-1">
|
||||||
|
v{versionInfo.version}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -513,20 +670,48 @@ const Layout = ({ children }) => {
|
|||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-x-4 lg:gap-x-6">
|
<div className="flex items-center gap-x-4 lg:gap-x-6">
|
||||||
{/* Customize Dashboard Button - Only show on Dashboard page */}
|
{/* External Links */}
|
||||||
{location.pathname === '/' && (
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<a
|
||||||
onClick={() => {
|
href="https://github.com/9technologygroup/patchmon.net"
|
||||||
// This will be handled by the Dashboard component
|
target="_blank"
|
||||||
const event = new CustomEvent('openDashboardSettings');
|
rel="noopener noreferrer"
|
||||||
window.dispatchEvent(event);
|
className="flex items-center justify-center gap-1.5 px-3 py-2 bg-gray-50 dark:bg-gray-800 text-secondary-600 dark:text-secondary-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors shadow-sm group relative"
|
||||||
}}
|
|
||||||
className="btn-outline flex items-center gap-2"
|
|
||||||
>
|
>
|
||||||
<Settings className="h-4 w-4" />
|
<Github className="h-5 w-5 flex-shrink-0" />
|
||||||
Customize Dashboard
|
{githubStars !== null && (
|
||||||
</button>
|
<div className="flex items-center gap-0.5">
|
||||||
|
<Star className="h-3 w-3 fill-current text-yellow-500" />
|
||||||
|
<span className="text-sm font-medium">{githubStars}</span>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="https://discord.gg/DDKQeW6mnq"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center justify-center w-10 h-10 bg-gray-50 dark:bg-gray-800 text-secondary-600 dark:text-secondary-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors shadow-sm"
|
||||||
|
title="Discord"
|
||||||
|
>
|
||||||
|
<MessageCircle className="h-5 w-5" />
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="mailto:support@patchmon.net"
|
||||||
|
className="flex items-center justify-center w-10 h-10 bg-gray-50 dark:bg-gray-800 text-secondary-600 dark:text-secondary-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors shadow-sm"
|
||||||
|
title="Email support@patchmon.net"
|
||||||
|
>
|
||||||
|
<Mail className="h-5 w-5" />
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="https://patchmon.net"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center justify-center w-10 h-10 bg-gray-50 dark:bg-gray-800 text-secondary-600 dark:text-secondary-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors shadow-sm"
|
||||||
|
title="Visit patchmon.net"
|
||||||
|
>
|
||||||
|
<Globe className="h-5 w-5" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
15
frontend/src/components/UpgradeNotificationIcon.jsx
Normal file
15
frontend/src/components/UpgradeNotificationIcon.jsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { ArrowUpCircle } from 'lucide-react'
|
||||||
|
|
||||||
|
const UpgradeNotificationIcon = ({ className = "h-4 w-4", show = true }) => {
|
||||||
|
if (!show) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ArrowUpCircle
|
||||||
|
className={`${className} text-red-500 animate-pulse`}
|
||||||
|
title="Update available"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default UpgradeNotificationIcon
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { createContext, useContext, useState, useEffect } from 'react'
|
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react'
|
||||||
|
|
||||||
const AuthContext = createContext()
|
const AuthContext = createContext()
|
||||||
|
|
||||||
@@ -15,6 +15,10 @@ export const AuthProvider = ({ children }) => {
|
|||||||
const [token, setToken] = useState(null)
|
const [token, setToken] = useState(null)
|
||||||
const [permissions, setPermissions] = useState(null)
|
const [permissions, setPermissions] = useState(null)
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
const [permissionsLoading, setPermissionsLoading] = useState(false)
|
||||||
|
const [needsFirstTimeSetup, setNeedsFirstTimeSetup] = useState(false)
|
||||||
|
|
||||||
|
const [checkingSetup, setCheckingSetup] = useState(true)
|
||||||
|
|
||||||
// Initialize auth state from localStorage
|
// Initialize auth state from localStorage
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -42,20 +46,17 @@ export const AuthProvider = ({ children }) => {
|
|||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Periodically refresh permissions when user is logged in
|
// Refresh permissions when user logs in (no automatic refresh)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (token && user) {
|
if (token && user) {
|
||||||
// Refresh permissions every 30 seconds
|
// Only refresh permissions once when user logs in
|
||||||
const interval = setInterval(() => {
|
|
||||||
refreshPermissions()
|
refreshPermissions()
|
||||||
}, 30000)
|
|
||||||
|
|
||||||
return () => clearInterval(interval)
|
|
||||||
}
|
}
|
||||||
}, [token, user])
|
}, [token, user])
|
||||||
|
|
||||||
const fetchPermissions = async (authToken) => {
|
const fetchPermissions = async (authToken) => {
|
||||||
try {
|
try {
|
||||||
|
setPermissionsLoading(true)
|
||||||
const response = await fetch('/api/v1/permissions/user-permissions', {
|
const response = await fetch('/api/v1/permissions/user-permissions', {
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${authToken}`,
|
'Authorization': `Bearer ${authToken}`,
|
||||||
@@ -74,6 +75,8 @@ export const AuthProvider = ({ children }) => {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching permissions:', error)
|
console.error('Error fetching permissions:', error)
|
||||||
return null
|
return null
|
||||||
|
} finally {
|
||||||
|
setPermissionsLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,30 +202,79 @@ export const AuthProvider = ({ children }) => {
|
|||||||
|
|
||||||
// Permission checking functions
|
// Permission checking functions
|
||||||
const hasPermission = (permission) => {
|
const hasPermission = (permission) => {
|
||||||
|
// If permissions are still loading, return false to show loading state
|
||||||
|
if (permissionsLoading) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
return permissions?.[permission] === true
|
return permissions?.[permission] === true
|
||||||
}
|
}
|
||||||
|
|
||||||
const canViewDashboard = () => hasPermission('canViewDashboard')
|
const canViewDashboard = () => hasPermission('can_view_dashboard')
|
||||||
const canViewHosts = () => hasPermission('canViewHosts')
|
const canViewHosts = () => hasPermission('can_view_hosts')
|
||||||
const canManageHosts = () => hasPermission('canManageHosts')
|
const canManageHosts = () => hasPermission('can_manage_hosts')
|
||||||
const canViewPackages = () => hasPermission('canViewPackages')
|
const canViewPackages = () => hasPermission('can_view_packages')
|
||||||
const canManagePackages = () => hasPermission('canManagePackages')
|
const canManagePackages = () => hasPermission('can_manage_packages')
|
||||||
const canViewUsers = () => hasPermission('canViewUsers')
|
const canViewUsers = () => hasPermission('can_view_users')
|
||||||
const canManageUsers = () => hasPermission('canManageUsers')
|
const canManageUsers = () => hasPermission('can_manage_users')
|
||||||
const canViewReports = () => hasPermission('canViewReports')
|
const canViewReports = () => hasPermission('can_view_reports')
|
||||||
const canExportData = () => hasPermission('canExportData')
|
const canExportData = () => hasPermission('can_export_data')
|
||||||
const canManageSettings = () => hasPermission('canManageSettings')
|
const canManageSettings = () => hasPermission('can_manage_settings')
|
||||||
|
|
||||||
|
// Check if any admin users exist (for first-time setup)
|
||||||
|
const checkAdminUsersExist = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/v1/auth/check-admin-users', {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json()
|
||||||
|
setNeedsFirstTimeSetup(!data.hasAdminUsers)
|
||||||
|
} else {
|
||||||
|
// If endpoint doesn't exist or fails, assume setup is needed
|
||||||
|
setNeedsFirstTimeSetup(true)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error checking admin users:', error)
|
||||||
|
// If there's an error, assume setup is needed
|
||||||
|
setNeedsFirstTimeSetup(true)
|
||||||
|
} finally {
|
||||||
|
setCheckingSetup(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Check for admin users on initial load
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token && !user) {
|
||||||
|
checkAdminUsersExist()
|
||||||
|
} else {
|
||||||
|
setCheckingSetup(false)
|
||||||
|
}
|
||||||
|
}, [token, user, checkAdminUsersExist])
|
||||||
|
|
||||||
|
const setAuthState = (authToken, authUser) => {
|
||||||
|
setToken(authToken)
|
||||||
|
setUser(authUser)
|
||||||
|
localStorage.setItem('token', authToken)
|
||||||
|
localStorage.setItem('user', JSON.stringify(authUser))
|
||||||
|
}
|
||||||
|
|
||||||
const value = {
|
const value = {
|
||||||
user,
|
user,
|
||||||
token,
|
token,
|
||||||
permissions,
|
permissions,
|
||||||
isLoading,
|
isLoading: isLoading || permissionsLoading || checkingSetup,
|
||||||
|
needsFirstTimeSetup,
|
||||||
|
checkingSetup,
|
||||||
login,
|
login,
|
||||||
logout,
|
logout,
|
||||||
updateProfile,
|
updateProfile,
|
||||||
changePassword,
|
changePassword,
|
||||||
refreshPermissions,
|
refreshPermissions,
|
||||||
|
setAuthState,
|
||||||
isAuthenticated,
|
isAuthenticated,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
hasPermission,
|
hasPermission,
|
||||||
|
|||||||
58
frontend/src/contexts/UpdateNotificationContext.jsx
Normal file
58
frontend/src/contexts/UpdateNotificationContext.jsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import React, { createContext, useContext, useState } from 'react'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { versionAPI, settingsAPI } from '../utils/api'
|
||||||
|
import { useAuth } from './AuthContext'
|
||||||
|
|
||||||
|
const UpdateNotificationContext = createContext()
|
||||||
|
|
||||||
|
export const useUpdateNotification = () => {
|
||||||
|
const context = useContext(UpdateNotificationContext)
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useUpdateNotification must be used within an UpdateNotificationProvider')
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UpdateNotificationProvider = ({ children }) => {
|
||||||
|
const [dismissed, setDismissed] = useState(false)
|
||||||
|
const { user, token } = useAuth()
|
||||||
|
|
||||||
|
// Ensure settings are loaded
|
||||||
|
const { data: settings, isLoading: settingsLoading } = useQuery({
|
||||||
|
queryKey: ['settings'],
|
||||||
|
queryFn: () => settingsAPI.get().then(res => res.data),
|
||||||
|
enabled: !!(user && token),
|
||||||
|
retry: 1
|
||||||
|
})
|
||||||
|
|
||||||
|
// Query for update information
|
||||||
|
const { data: updateData, isLoading, error } = useQuery({
|
||||||
|
queryKey: ['updateCheck'],
|
||||||
|
queryFn: () => versionAPI.checkUpdates().then(res => res.data),
|
||||||
|
staleTime: 10 * 60 * 1000, // Data stays fresh for 10 minutes
|
||||||
|
refetchOnWindowFocus: false, // Don't refetch when window regains focus
|
||||||
|
retry: 1,
|
||||||
|
enabled: !!(user && token && settings && !settingsLoading) // Only run when authenticated and settings are loaded
|
||||||
|
})
|
||||||
|
|
||||||
|
const updateAvailable = updateData?.isUpdateAvailable && !dismissed
|
||||||
|
const updateInfo = updateData
|
||||||
|
|
||||||
|
const dismissNotification = () => {
|
||||||
|
setDismissed(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = {
|
||||||
|
updateAvailable,
|
||||||
|
updateInfo,
|
||||||
|
dismissNotification,
|
||||||
|
isLoading,
|
||||||
|
error
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<UpdateNotificationContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</UpdateNotificationContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -8,13 +8,19 @@ import {
|
|||||||
Shield,
|
Shield,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Clock
|
Clock,
|
||||||
|
WifiOff,
|
||||||
|
Settings,
|
||||||
|
Users,
|
||||||
|
Folder,
|
||||||
|
GitBranch
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { Chart as ChartJS, ArcElement, Tooltip, Legend, CategoryScale, LinearScale, BarElement, Title } from 'chart.js'
|
import { Chart as ChartJS, ArcElement, Tooltip, Legend, CategoryScale, LinearScale, BarElement, Title } from 'chart.js'
|
||||||
import { Pie, Bar } from 'react-chartjs-2'
|
import { Pie, Bar } from 'react-chartjs-2'
|
||||||
import { dashboardAPI, dashboardPreferencesAPI, settingsAPI, formatRelativeTime } from '../utils/api'
|
import { dashboardAPI, dashboardPreferencesAPI, settingsAPI, formatRelativeTime } from '../utils/api'
|
||||||
import DashboardSettingsModal from '../components/DashboardSettingsModal'
|
import DashboardSettingsModal from '../components/DashboardSettingsModal'
|
||||||
import { useTheme } from '../contexts/ThemeContext'
|
import { useTheme } from '../contexts/ThemeContext'
|
||||||
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
|
|
||||||
// Register Chart.js components
|
// Register Chart.js components
|
||||||
ChartJS.register(ArcElement, Tooltip, Legend, CategoryScale, LinearScale, BarElement, Title)
|
ChartJS.register(ArcElement, Tooltip, Legend, CategoryScale, LinearScale, BarElement, Title)
|
||||||
@@ -24,10 +30,11 @@ const Dashboard = () => {
|
|||||||
const [cardPreferences, setCardPreferences] = useState([])
|
const [cardPreferences, setCardPreferences] = useState([])
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { isDark } = useTheme()
|
const { isDark } = useTheme()
|
||||||
|
const { user } = useAuth()
|
||||||
|
|
||||||
// Navigation handlers
|
// Navigation handlers
|
||||||
const handleTotalHostsClick = () => {
|
const handleTotalHostsClick = () => {
|
||||||
navigate('/hosts')
|
navigate('/hosts', { replace: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleHostsNeedingUpdatesClick = () => {
|
const handleHostsNeedingUpdatesClick = () => {
|
||||||
@@ -46,18 +53,79 @@ const Dashboard = () => {
|
|||||||
navigate('/hosts?filter=inactive')
|
navigate('/hosts?filter=inactive')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleOfflineHostsClick = () => {
|
||||||
|
navigate('/hosts?filter=offline')
|
||||||
|
}
|
||||||
|
|
||||||
|
// New navigation handlers for top cards
|
||||||
|
const handleUsersClick = () => {
|
||||||
|
navigate('/users')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleHostGroupsClick = () => {
|
||||||
|
navigate('/options')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRepositoriesClick = () => {
|
||||||
|
navigate('/repositories')
|
||||||
|
}
|
||||||
|
|
||||||
const handleOSDistributionClick = () => {
|
const handleOSDistributionClick = () => {
|
||||||
navigate('/hosts')
|
navigate('/hosts?showFilters=true', { replace: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleUpdateStatusClick = () => {
|
const handleUpdateStatusClick = () => {
|
||||||
navigate('/hosts')
|
navigate('/hosts?filter=needsUpdates', { replace: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
const handlePackagePriorityClick = () => {
|
const handlePackagePriorityClick = () => {
|
||||||
navigate('/packages?filter=security')
|
navigate('/packages?filter=security')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Chart click handlers
|
||||||
|
const handleOSChartClick = (event, elements) => {
|
||||||
|
if (elements.length > 0) {
|
||||||
|
const elementIndex = elements[0].index
|
||||||
|
const osName = stats.charts.osDistribution[elementIndex].name.toLowerCase()
|
||||||
|
navigate(`/hosts?osFilter=${osName}&showFilters=true`, { replace: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUpdateStatusChartClick = (event, elements) => {
|
||||||
|
if (elements.length > 0) {
|
||||||
|
const elementIndex = elements[0].index
|
||||||
|
const statusName = stats.charts.updateStatusDistribution[elementIndex].name
|
||||||
|
|
||||||
|
// Map status names to filter parameters
|
||||||
|
let filter = ''
|
||||||
|
if (statusName.toLowerCase().includes('needs updates')) {
|
||||||
|
filter = 'needsUpdates'
|
||||||
|
} else if (statusName.toLowerCase().includes('up to date')) {
|
||||||
|
filter = 'upToDate'
|
||||||
|
} else if (statusName.toLowerCase().includes('stale')) {
|
||||||
|
filter = 'stale'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter) {
|
||||||
|
navigate(`/hosts?filter=${filter}`, { replace: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePackagePriorityChartClick = (event, elements) => {
|
||||||
|
if (elements.length > 0) {
|
||||||
|
const elementIndex = elements[0].index
|
||||||
|
const priorityName = stats.charts.packageUpdateDistribution[elementIndex].name
|
||||||
|
|
||||||
|
// Map priority names to filter parameters
|
||||||
|
if (priorityName.toLowerCase().includes('security')) {
|
||||||
|
navigate('/packages?filter=security', { replace: true })
|
||||||
|
} else if (priorityName.toLowerCase().includes('outdated')) {
|
||||||
|
navigate('/packages?filter=outdated', { replace: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Helper function to format the update interval threshold
|
// Helper function to format the update interval threshold
|
||||||
const formatUpdateIntervalThreshold = () => {
|
const formatUpdateIntervalThreshold = () => {
|
||||||
if (!settings?.updateInterval) return '24 hours'
|
if (!settings?.updateInterval) return '24 hours'
|
||||||
@@ -84,11 +152,25 @@ const Dashboard = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data: stats, isLoading, error, refetch } = useQuery({
|
const { data: stats, isLoading, error, refetch, isFetching } = useQuery({
|
||||||
queryKey: ['dashboardStats'],
|
queryKey: ['dashboardStats'],
|
||||||
queryFn: () => dashboardAPI.getStats().then(res => res.data),
|
queryFn: () => dashboardAPI.getStats().then(res => res.data),
|
||||||
refetchInterval: 60000, // Refresh every minute
|
staleTime: 5 * 60 * 1000, // Data stays fresh for 5 minutes
|
||||||
staleTime: 30000, // Consider data stale after 30 seconds
|
refetchOnWindowFocus: false, // Don't refetch when window regains focus
|
||||||
|
})
|
||||||
|
|
||||||
|
// Fetch recent users (permission protected server-side)
|
||||||
|
const { data: recentUsers } = useQuery({
|
||||||
|
queryKey: ['dashboardRecentUsers'],
|
||||||
|
queryFn: () => dashboardAPI.getRecentUsers().then(res => res.data),
|
||||||
|
staleTime: 60 * 1000,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Fetch recent collection (permission protected server-side)
|
||||||
|
const { data: recentCollection } = useQuery({
|
||||||
|
queryKey: ['dashboardRecentCollection'],
|
||||||
|
queryFn: () => dashboardAPI.getRecentCollection().then(res => res.data),
|
||||||
|
staleTime: 60 * 1000,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Fetch settings to get the agent update interval
|
// Fetch settings to get the agent update interval
|
||||||
@@ -110,22 +192,32 @@ const Dashboard = () => {
|
|||||||
queryFn: () => dashboardPreferencesAPI.getDefaults().then(res => res.data),
|
queryFn: () => dashboardPreferencesAPI.getDefaults().then(res => res.data),
|
||||||
})
|
})
|
||||||
|
|
||||||
// Merge preferences with default cards
|
// Merge preferences with default cards (normalize snake_case from API)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (preferences && defaultCards) {
|
if (preferences && defaultCards) {
|
||||||
const mergedCards = defaultCards.map(defaultCard => {
|
const normalizedPreferences = preferences.map((p) => ({
|
||||||
const userPreference = preferences.find(p => p.cardId === defaultCard.cardId);
|
cardId: p.cardId ?? p.card_id,
|
||||||
|
enabled: p.enabled,
|
||||||
|
order: p.order,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const mergedCards = defaultCards
|
||||||
|
.map((defaultCard) => {
|
||||||
|
const userPreference = normalizedPreferences.find(
|
||||||
|
(p) => p.cardId === defaultCard.cardId
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
...defaultCard,
|
...defaultCard,
|
||||||
enabled: userPreference ? userPreference.enabled : defaultCard.enabled,
|
enabled: userPreference ? userPreference.enabled : defaultCard.enabled,
|
||||||
order: userPreference ? userPreference.order : defaultCard.order
|
order: userPreference ? userPreference.order : defaultCard.order,
|
||||||
};
|
}
|
||||||
}).sort((a, b) => a.order - b.order);
|
})
|
||||||
|
.sort((a, b) => a.order - b.order)
|
||||||
|
|
||||||
setCardPreferences(mergedCards);
|
setCardPreferences(mergedCards)
|
||||||
} else if (defaultCards) {
|
} else if (defaultCards) {
|
||||||
// If no preferences exist, use defaults
|
// If no preferences exist, use defaults
|
||||||
setCardPreferences(defaultCards.sort((a, b) => a.order - b.order));
|
setCardPreferences(defaultCards.sort((a, b) => a.order - b.order))
|
||||||
}
|
}
|
||||||
}, [preferences, defaultCards])
|
}, [preferences, defaultCards])
|
||||||
|
|
||||||
@@ -149,9 +241,9 @@ const Dashboard = () => {
|
|||||||
|
|
||||||
// Helper function to get card type for layout grouping
|
// Helper function to get card type for layout grouping
|
||||||
const getCardType = (cardId) => {
|
const getCardType = (cardId) => {
|
||||||
if (['totalHosts', 'hostsNeedingUpdates', 'totalOutdatedPackages', 'securityUpdates'].includes(cardId)) {
|
if (['totalHosts', 'hostsNeedingUpdates', 'totalOutdatedPackages', 'securityUpdates', 'upToDateHosts', 'totalHostGroups', 'totalUsers', 'totalRepos'].includes(cardId)) {
|
||||||
return 'stats';
|
return 'stats';
|
||||||
} else if (['osDistribution', 'updateStatus', 'packagePriority'].includes(cardId)) {
|
} else if (['osDistribution', 'osDistributionBar', 'updateStatus', 'packagePriority', 'recentUsers', 'recentCollection'].includes(cardId)) {
|
||||||
return 'charts';
|
return 'charts';
|
||||||
} else if (['erroredHosts', 'quickStats'].includes(cardId)) {
|
} else if (['erroredHosts', 'quickStats'].includes(cardId)) {
|
||||||
return 'fullwidth';
|
return 'fullwidth';
|
||||||
@@ -176,6 +268,24 @@ const Dashboard = () => {
|
|||||||
// Helper function to render a card by ID
|
// Helper function to render a card by ID
|
||||||
const renderCard = (cardId) => {
|
const renderCard = (cardId) => {
|
||||||
switch (cardId) {
|
switch (cardId) {
|
||||||
|
case 'upToDateHosts':
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="card p-4"
|
||||||
|
>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<TrendingUp className="h-5 w-5 text-success-600 mr-2" />
|
||||||
|
</div>
|
||||||
|
<div className="w-0 flex-1">
|
||||||
|
<p className="text-sm text-secondary-500 dark:text-white">Up to date</p>
|
||||||
|
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
|
||||||
|
{stats.cards.upToDateHosts}/{stats.cards.totalHosts}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
case 'totalHosts':
|
case 'totalHosts':
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -256,6 +366,57 @@ const Dashboard = () => {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
case 'totalHostGroups':
|
||||||
|
return (
|
||||||
|
<div className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200" onClick={handleHostGroupsClick}>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<Folder className="h-5 w-5 text-primary-600 mr-2" />
|
||||||
|
</div>
|
||||||
|
<div className="w-0 flex-1">
|
||||||
|
<p className="text-sm text-secondary-500 dark:text-white">Host Groups</p>
|
||||||
|
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
|
||||||
|
{stats.cards.totalHostGroups}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'totalUsers':
|
||||||
|
return (
|
||||||
|
<div className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200" onClick={handleUsersClick}>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<Users className="h-5 w-5 text-success-600 mr-2" />
|
||||||
|
</div>
|
||||||
|
<div className="w-0 flex-1">
|
||||||
|
<p className="text-sm text-secondary-500 dark:text-white">Users</p>
|
||||||
|
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
|
||||||
|
{stats.cards.totalUsers}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'totalRepos':
|
||||||
|
return (
|
||||||
|
<div className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200" onClick={handleRepositoriesClick}>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<GitBranch className="h-5 w-5 text-warning-600 mr-2" />
|
||||||
|
</div>
|
||||||
|
<div className="w-0 flex-1">
|
||||||
|
<p className="text-sm text-secondary-500 dark:text-white">Repositories</p>
|
||||||
|
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
|
||||||
|
{stats.cards.totalRepos}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
case 'erroredHosts':
|
case 'erroredHosts':
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -295,6 +456,45 @@ const Dashboard = () => {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
case 'offlineHosts':
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`border rounded-lg p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 ${
|
||||||
|
stats.cards.offlineHosts > 0
|
||||||
|
? 'bg-warning-50 border-warning-200'
|
||||||
|
: 'bg-success-50 border-success-200'
|
||||||
|
}`}
|
||||||
|
onClick={handleOfflineHostsClick}
|
||||||
|
>
|
||||||
|
<div className="flex">
|
||||||
|
<WifiOff className={`h-5 w-5 ${
|
||||||
|
stats.cards.offlineHosts > 0 ? 'text-warning-400' : 'text-success-400'
|
||||||
|
}`} />
|
||||||
|
<div className="ml-3">
|
||||||
|
{stats.cards.offlineHosts > 0 ? (
|
||||||
|
<>
|
||||||
|
<h3 className="text-sm font-medium text-warning-800">
|
||||||
|
{stats.cards.offlineHosts} host{stats.cards.offlineHosts > 1 ? 's' : ''} offline/stale
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-warning-700 mt-1">
|
||||||
|
These hosts haven't reported in {formatUpdateIntervalThreshold() * 3}+ minutes.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<h3 className="text-sm font-medium text-success-800">
|
||||||
|
All hosts are online
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-success-700 mt-1">
|
||||||
|
No hosts are offline or stale.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
case 'osDistribution':
|
case 'osDistribution':
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -308,6 +508,19 @@ const Dashboard = () => {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
case 'osDistributionBar':
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="card p-6 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200"
|
||||||
|
onClick={handleOSDistributionClick}
|
||||||
|
>
|
||||||
|
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">OS Distribution</h3>
|
||||||
|
<div className="h-64">
|
||||||
|
<Bar data={osBarChartData} options={barChartOptions} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
case 'updateStatus':
|
case 'updateStatus':
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -316,7 +529,7 @@ const Dashboard = () => {
|
|||||||
>
|
>
|
||||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">Update Status</h3>
|
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">Update Status</h3>
|
||||||
<div className="h-64">
|
<div className="h-64">
|
||||||
<Pie data={updateStatusChartData} options={chartOptions} />
|
<Pie data={updateStatusChartData} options={updateStatusChartOptions} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -329,36 +542,112 @@ const Dashboard = () => {
|
|||||||
>
|
>
|
||||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">Package Priority</h3>
|
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">Package Priority</h3>
|
||||||
<div className="h-64">
|
<div className="h-64">
|
||||||
<Pie data={packagePriorityChartData} options={chartOptions} />
|
<Pie data={packagePriorityChartData} options={packagePriorityChartOptions} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
case 'quickStats':
|
case 'quickStats':
|
||||||
|
// Calculate dynamic stats
|
||||||
|
const updatePercentage = stats.cards.totalHosts > 0 ? ((stats.cards.hostsNeedingUpdates / stats.cards.totalHosts) * 100).toFixed(1) : 0;
|
||||||
|
const onlineHosts = stats.cards.totalHosts - stats.cards.erroredHosts;
|
||||||
|
const onlinePercentage = stats.cards.totalHosts > 0 ? ((onlineHosts / stats.cards.totalHosts) * 100).toFixed(0) : 0;
|
||||||
|
const securityPercentage = stats.cards.totalOutdatedPackages > 0 ? ((stats.cards.securityUpdates / stats.cards.totalOutdatedPackages) * 100).toFixed(0) : 0;
|
||||||
|
const avgPackagesPerHost = stats.cards.totalHosts > 0 ? Math.round(stats.cards.totalOutdatedPackages / stats.cards.totalHosts) : 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="card p-6">
|
<div className="card p-6">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">Quick Stats</h3>
|
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">System Overview</h3>
|
||||||
<TrendingUp className="h-5 w-5 text-success-500" />
|
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-2xl font-bold text-primary-600">
|
<div className="text-2xl font-bold text-primary-600">
|
||||||
{((stats.cards.hostsNeedingUpdates / stats.cards.totalHosts) * 100).toFixed(1)}%
|
{updatePercentage}%
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-secondary-500 dark:text-secondary-300">Need Updates</div>
|
||||||
|
<div className="text-xs text-secondary-400 dark:text-secondary-500">
|
||||||
|
{stats.cards.hostsNeedingUpdates}/{stats.cards.totalHosts} hosts
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-secondary-500 dark:text-secondary-300">Hosts need updates</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-2xl font-bold text-danger-600">
|
<div className="text-2xl font-bold text-danger-600">
|
||||||
{stats.cards.securityUpdates}
|
{stats.cards.securityUpdates}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-secondary-500 dark:text-secondary-300">Security updates pending</div>
|
<div className="text-sm text-secondary-500 dark:text-secondary-300">Security Issues</div>
|
||||||
|
<div className="text-xs text-secondary-400 dark:text-secondary-500">
|
||||||
|
{securityPercentage}% of updates
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-2xl font-bold text-success-600">
|
<div className="text-2xl font-bold text-success-600">
|
||||||
{stats.cards.totalHosts - stats.cards.erroredHosts}
|
{onlinePercentage}%
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-secondary-500 dark:text-secondary-300">Hosts online</div>
|
<div className="text-sm text-secondary-500 dark:text-secondary-300">Online</div>
|
||||||
|
<div className="text-xs text-secondary-400 dark:text-secondary-500">
|
||||||
|
{onlineHosts}/{stats.cards.totalHosts} hosts
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-2xl font-bold text-secondary-600">
|
||||||
|
{avgPackagesPerHost}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-secondary-500 dark:text-secondary-300">Avg per Host</div>
|
||||||
|
<div className="text-xs text-secondary-400 dark:text-secondary-500">
|
||||||
|
outdated packages
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'recentUsers':
|
||||||
|
return (
|
||||||
|
<div className="card p-6">
|
||||||
|
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">Recent Users Logged in</h3>
|
||||||
|
<div className="h-64 overflow-y-auto">
|
||||||
|
<div className="space-y-3">
|
||||||
|
{(recentUsers || []).slice(0, 5).map(u => (
|
||||||
|
<div key={u.id} className="flex items-center justify-between py-2 border-b border-secondary-100 dark:border-secondary-700 last:border-b-0">
|
||||||
|
<div className="text-sm font-medium text-secondary-900 dark:text-white">
|
||||||
|
{u.username}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-secondary-500 dark:text-secondary-400">
|
||||||
|
{u.last_login ? formatRelativeTime(u.last_login) : 'Never'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{(!recentUsers || recentUsers.length === 0) && (
|
||||||
|
<div className="text-center text-secondary-500 dark:text-secondary-400 py-4">
|
||||||
|
No users found
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'recentCollection':
|
||||||
|
return (
|
||||||
|
<div className="card p-6">
|
||||||
|
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">Recent Collection</h3>
|
||||||
|
<div className="h-64 overflow-y-auto">
|
||||||
|
<div className="space-y-3">
|
||||||
|
{(recentCollection || []).slice(0, 5).map(host => (
|
||||||
|
<div key={host.id} className="flex items-center justify-between py-2 border-b border-secondary-100 dark:border-secondary-700 last:border-b-0">
|
||||||
|
<div className="text-sm font-medium text-secondary-900 dark:text-white">
|
||||||
|
{host.friendly_name || host.hostname}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-secondary-500 dark:text-secondary-400">
|
||||||
|
{host.last_update ? formatRelativeTime(host.last_update) : 'Never'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{(!recentCollection || recentCollection.length === 0) && (
|
||||||
|
<div className="text-center text-secondary-500 dark:text-secondary-400 py-4">
|
||||||
|
No hosts found
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -412,6 +701,73 @@ const Dashboard = () => {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
onClick: handleOSChartClick,
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateStatusChartOptions = {
|
||||||
|
responsive: true,
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
position: 'bottom',
|
||||||
|
labels: {
|
||||||
|
color: isDark ? '#ffffff' : '#374151',
|
||||||
|
font: {
|
||||||
|
size: 12
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
onClick: handleUpdateStatusChartClick,
|
||||||
|
}
|
||||||
|
|
||||||
|
const packagePriorityChartOptions = {
|
||||||
|
responsive: true,
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
position: 'bottom',
|
||||||
|
labels: {
|
||||||
|
color: isDark ? '#ffffff' : '#374151',
|
||||||
|
font: {
|
||||||
|
size: 12
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
onClick: handlePackagePriorityChartClick,
|
||||||
|
}
|
||||||
|
|
||||||
|
const barChartOptions = {
|
||||||
|
responsive: true,
|
||||||
|
indexAxis: 'y', // Make the chart horizontal
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
display: false
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
ticks: {
|
||||||
|
color: isDark ? '#ffffff' : '#374151',
|
||||||
|
font: {
|
||||||
|
size: 12
|
||||||
|
}
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
color: isDark ? '#374151' : '#e5e7eb'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
ticks: {
|
||||||
|
color: isDark ? '#ffffff' : '#374151',
|
||||||
|
font: {
|
||||||
|
size: 12
|
||||||
|
}
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
color: isDark ? '#374151' : '#e5e7eb'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const osChartData = {
|
const osChartData = {
|
||||||
@@ -433,6 +789,28 @@ const Dashboard = () => {
|
|||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const osBarChartData = {
|
||||||
|
labels: stats.charts.osDistribution.map(item => item.name),
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'Hosts',
|
||||||
|
data: stats.charts.osDistribution.map(item => item.count),
|
||||||
|
backgroundColor: [
|
||||||
|
'#3B82F6', // Blue
|
||||||
|
'#10B981', // Green
|
||||||
|
'#F59E0B', // Yellow
|
||||||
|
'#EF4444', // Red
|
||||||
|
'#8B5CF6', // Purple
|
||||||
|
'#06B6D4', // Cyan
|
||||||
|
],
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: isDark ? '#374151' : '#ffffff',
|
||||||
|
borderRadius: 4,
|
||||||
|
borderSkipped: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
const updateStatusChartData = {
|
const updateStatusChartData = {
|
||||||
labels: stats.charts.updateStatusDistribution.map(item => item.name),
|
labels: stats.charts.updateStatusDistribution.map(item => item.name),
|
||||||
datasets: [
|
datasets: [
|
||||||
@@ -466,6 +844,36 @@ const Dashboard = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
{/* Page Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold text-secondary-900 dark:text-white">
|
||||||
|
Welcome back, {user?.first_name || user?.username || 'User'} 👋
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-secondary-600 dark:text-secondary-400 mt-1">
|
||||||
|
Overview of your PatchMon infrastructure
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowSettingsModal(true)}
|
||||||
|
className="btn-outline flex items-center gap-2"
|
||||||
|
title="Customize dashboard layout"
|
||||||
|
>
|
||||||
|
<Settings className="h-4 w-4" />
|
||||||
|
Customize Dashboard
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => refetch()}
|
||||||
|
disabled={isFetching}
|
||||||
|
className="btn-outline flex items-center gap-2"
|
||||||
|
title="Refresh dashboard data"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`h-4 w-4 ${isFetching ? 'animate-spin' : ''}`} />
|
||||||
|
{isFetching ? 'Refreshing...' : 'Refresh'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Dynamically Rendered Cards - Unified Order */}
|
{/* Dynamically Rendered Cards - Unified Order */}
|
||||||
{(() => {
|
{(() => {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,19 +1,48 @@
|
|||||||
import React, { useState } from 'react'
|
import React, { useState, useEffect } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { Eye, EyeOff, Lock, User, AlertCircle } from 'lucide-react'
|
import { Eye, EyeOff, Lock, User, AlertCircle, Smartphone, ArrowLeft, Mail } from 'lucide-react'
|
||||||
import { useAuth } from '../contexts/AuthContext'
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
|
import { authAPI } from '../utils/api'
|
||||||
|
|
||||||
const Login = () => {
|
const Login = () => {
|
||||||
|
const [isSignupMode, setIsSignupMode] = useState(false)
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
username: '',
|
username: '',
|
||||||
password: ''
|
email: '',
|
||||||
|
password: '',
|
||||||
|
firstName: '',
|
||||||
|
lastName: ''
|
||||||
|
})
|
||||||
|
const [tfaData, setTfaData] = useState({
|
||||||
|
token: ''
|
||||||
})
|
})
|
||||||
const [showPassword, setShowPassword] = useState(false)
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
|
const [requiresTfa, setRequiresTfa] = useState(false)
|
||||||
|
const [tfaUsername, setTfaUsername] = useState('')
|
||||||
|
const [signupEnabled, setSignupEnabled] = useState(false)
|
||||||
|
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { login } = useAuth()
|
const { login, setAuthState } = useAuth()
|
||||||
|
|
||||||
|
// Check if signup is enabled
|
||||||
|
useEffect(() => {
|
||||||
|
const checkSignupEnabled = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/v1/auth/signup-enabled')
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json()
|
||||||
|
setSignupEnabled(data.signupEnabled)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to check signup status:', error)
|
||||||
|
// Default to disabled on error for security
|
||||||
|
setSignupEnabled(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
checkSignupEnabled()
|
||||||
|
}, [])
|
||||||
|
|
||||||
const handleSubmit = async (e) => {
|
const handleSubmit = async (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -21,16 +50,80 @@ const Login = () => {
|
|||||||
setError('')
|
setError('')
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await login(formData.username, formData.password)
|
const response = await authAPI.login(formData.username, formData.password)
|
||||||
|
|
||||||
|
if (response.data.requiresTfa) {
|
||||||
|
setRequiresTfa(true)
|
||||||
|
setTfaUsername(formData.username)
|
||||||
|
setError('')
|
||||||
|
} else {
|
||||||
|
// Regular login successful
|
||||||
|
const result = await login(formData.username, formData.password)
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
// Redirect to dashboard
|
|
||||||
navigate('/')
|
navigate('/')
|
||||||
} else {
|
} else {
|
||||||
setError(result.error || 'Login failed')
|
setError(result.error || 'Login failed')
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError('Network error occurred')
|
setError(err.response?.data?.error || 'Login failed')
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSignupSubmit = async (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setIsLoading(true)
|
||||||
|
setError('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await authAPI.signup(formData.username, formData.email, formData.password, formData.firstName, formData.lastName)
|
||||||
|
if (response.data && response.data.token) {
|
||||||
|
// Update AuthContext state and localStorage
|
||||||
|
setAuthState(response.data.token, response.data.user)
|
||||||
|
|
||||||
|
// Redirect to dashboard
|
||||||
|
navigate('/')
|
||||||
|
} else {
|
||||||
|
setError('Signup failed - invalid response')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Signup error:', err)
|
||||||
|
const errorMessage = err.response?.data?.error ||
|
||||||
|
(err.response?.data?.errors && err.response.data.errors.length > 0
|
||||||
|
? err.response.data.errors.map(e => e.msg).join(', ')
|
||||||
|
: err.message || 'Signup failed')
|
||||||
|
setError(errorMessage)
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTfaSubmit = async (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setIsLoading(true)
|
||||||
|
setError('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await authAPI.verifyTfa(tfaUsername, tfaData.token)
|
||||||
|
|
||||||
|
if (response.data && response.data.token) {
|
||||||
|
// Store token and user data
|
||||||
|
localStorage.setItem('token', response.data.token)
|
||||||
|
localStorage.setItem('user', JSON.stringify(response.data.user))
|
||||||
|
|
||||||
|
// Redirect to dashboard
|
||||||
|
navigate('/')
|
||||||
|
} else {
|
||||||
|
setError('TFA verification failed - invalid response')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('TFA verification error:', err)
|
||||||
|
const errorMessage = err.response?.data?.error || err.message || 'TFA verification failed'
|
||||||
|
setError(errorMessage)
|
||||||
|
// Clear the token input for security
|
||||||
|
setTfaData({ token: '' })
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
}
|
}
|
||||||
@@ -43,31 +136,62 @@ const Login = () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleTfaInputChange = (e) => {
|
||||||
|
setTfaData({
|
||||||
|
...tfaData,
|
||||||
|
[e.target.name]: e.target.value.replace(/\D/g, '').slice(0, 6)
|
||||||
|
})
|
||||||
|
// Clear error when user starts typing
|
||||||
|
if (error) {
|
||||||
|
setError('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBackToLogin = () => {
|
||||||
|
setRequiresTfa(false)
|
||||||
|
setTfaData({ token: '' })
|
||||||
|
setError('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleMode = () => {
|
||||||
|
// Only allow signup mode if signup is enabled
|
||||||
|
if (!signupEnabled && !isSignupMode) {
|
||||||
|
return // Don't allow switching to signup if disabled
|
||||||
|
}
|
||||||
|
setIsSignupMode(!isSignupMode)
|
||||||
|
setFormData({
|
||||||
|
username: '',
|
||||||
|
email: '',
|
||||||
|
password: '',
|
||||||
|
firstName: '',
|
||||||
|
lastName: ''
|
||||||
|
})
|
||||||
|
setError('')
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-secondary-50 py-12 px-4 sm:px-6 lg:px-8">
|
<div className="min-h-screen flex items-center justify-center bg-secondary-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||||
<div className="max-w-md w-full space-y-8">
|
<div className="max-w-md w-full space-y-8">
|
||||||
<div>
|
<div>
|
||||||
<div className="mx-auto h-12 w-12 flex items-center justify-center rounded-full bg-primary-100">
|
<div className="mx-auto h-12 w-12 flex items-center justify-center rounded-full bg-primary-100">
|
||||||
<Lock className="h-6 w-6 text-primary-600" />
|
<Lock size={24} color="#2563eb" strokeWidth={2} />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="mt-6 text-center text-3xl font-extrabold text-secondary-900">
|
<h2 className="mt-6 text-center text-3xl font-extrabold text-secondary-900">
|
||||||
Sign in to PatchMon
|
{isSignupMode ? 'Create PatchMon Account' : 'Sign in to PatchMon'}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mt-2 text-center text-sm text-secondary-600">
|
<p className="mt-2 text-center text-sm text-secondary-600">
|
||||||
Monitor and manage your Linux package updates
|
Monitor and manage your Linux package updates
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
{!requiresTfa ? (
|
||||||
|
<form className="mt-8 space-y-6" onSubmit={isSignupMode ? handleSignupSubmit : handleSubmit}>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="username" className="block text-sm font-medium text-secondary-700">
|
<label htmlFor="username" className="block text-sm font-medium text-secondary-700">
|
||||||
Username or Email
|
{isSignupMode ? 'Username' : 'Username or Email'}
|
||||||
</label>
|
</label>
|
||||||
<div className="mt-1 relative">
|
<div className="mt-1 relative">
|
||||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
|
||||||
<User className="h-5 w-5 text-secondary-400" />
|
|
||||||
</div>
|
|
||||||
<input
|
<input
|
||||||
id="username"
|
id="username"
|
||||||
name="username"
|
name="username"
|
||||||
@@ -76,19 +200,94 @@ const Login = () => {
|
|||||||
value={formData.username}
|
value={formData.username}
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
className="appearance-none rounded-md relative block w-full pl-10 pr-3 py-2 border border-secondary-300 placeholder-secondary-500 text-secondary-900 focus:outline-none focus:ring-primary-500 focus:border-primary-500 focus:z-10 sm:text-sm"
|
className="appearance-none rounded-md relative block w-full pl-10 pr-3 py-2 border border-secondary-300 placeholder-secondary-500 text-secondary-900 focus:outline-none focus:ring-primary-500 focus:border-primary-500 focus:z-10 sm:text-sm"
|
||||||
placeholder="Enter your username or email"
|
placeholder={isSignupMode ? "Enter your username" : "Enter your username or email"}
|
||||||
|
/>
|
||||||
|
<div className="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none z-20 flex items-center">
|
||||||
|
<User
|
||||||
|
size={20}
|
||||||
|
color="#64748b"
|
||||||
|
strokeWidth={2}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isSignupMode && (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="firstName" className="block text-sm font-medium text-secondary-700">
|
||||||
|
First Name
|
||||||
|
</label>
|
||||||
|
<div className="mt-1 relative">
|
||||||
|
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<User className="h-5 w-5 text-secondary-400" />
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
id="firstName"
|
||||||
|
name="firstName"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={formData.firstName}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className="appearance-none rounded-md relative block w-full pl-10 pr-3 py-2 border border-secondary-300 placeholder-secondary-500 text-secondary-900 focus:outline-none focus:ring-primary-500 focus:border-primary-500 focus:z-10 sm:text-sm"
|
||||||
|
placeholder="Enter your first name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="lastName" className="block text-sm font-medium text-secondary-700">
|
||||||
|
Last Name
|
||||||
|
</label>
|
||||||
|
<div className="mt-1 relative">
|
||||||
|
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<User className="h-5 w-5 text-secondary-400" />
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
id="lastName"
|
||||||
|
name="lastName"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={formData.lastName}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className="appearance-none rounded-md relative block w-full pl-10 pr-3 py-2 border border-secondary-300 placeholder-secondary-500 text-secondary-900 focus:outline-none focus:ring-primary-500 focus:border-primary-500 focus:z-10 sm:text-sm"
|
||||||
|
placeholder="Enter your last name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="email" className="block text-sm font-medium text-secondary-700">
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<div className="mt-1 relative">
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
value={formData.email}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className="appearance-none rounded-md relative block w-full pl-10 pr-3 py-2 border border-secondary-300 placeholder-secondary-500 text-secondary-900 focus:outline-none focus:ring-primary-500 focus:border-primary-500 focus:z-10 sm:text-sm"
|
||||||
|
placeholder="Enter your email"
|
||||||
|
/>
|
||||||
|
<div className="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none z-20 flex items-center">
|
||||||
|
<Mail
|
||||||
|
size={20}
|
||||||
|
color="#64748b"
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="password" className="block text-sm font-medium text-secondary-700">
|
<label htmlFor="password" className="block text-sm font-medium text-secondary-700">
|
||||||
Password
|
Password
|
||||||
</label>
|
</label>
|
||||||
<div className="mt-1 relative">
|
<div className="mt-1 relative">
|
||||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
|
||||||
<Lock className="h-5 w-5 text-secondary-400" />
|
|
||||||
</div>
|
|
||||||
<input
|
<input
|
||||||
id="password"
|
id="password"
|
||||||
name="password"
|
name="password"
|
||||||
@@ -99,16 +298,23 @@ const Login = () => {
|
|||||||
className="appearance-none rounded-md relative block w-full pl-10 pr-10 py-2 border border-secondary-300 placeholder-secondary-500 text-secondary-900 focus:outline-none focus:ring-primary-500 focus:border-primary-500 focus:z-10 sm:text-sm"
|
className="appearance-none rounded-md relative block w-full pl-10 pr-10 py-2 border border-secondary-300 placeholder-secondary-500 text-secondary-900 focus:outline-none focus:ring-primary-500 focus:border-primary-500 focus:z-10 sm:text-sm"
|
||||||
placeholder="Enter your password"
|
placeholder="Enter your password"
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-y-0 right-0 pr-3 flex items-center">
|
<div className="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none z-20 flex items-center">
|
||||||
|
<Lock
|
||||||
|
size={20}
|
||||||
|
color="#64748b"
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="absolute right-3 top-1/2 -translate-y-1/2 z-20 flex items-center">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowPassword(!showPassword)}
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
className="text-secondary-400 hover:text-secondary-500"
|
className="bg-transparent border-none cursor-pointer p-1 flex items-center justify-center"
|
||||||
>
|
>
|
||||||
{showPassword ? (
|
{showPassword ? (
|
||||||
<EyeOff className="h-5 w-5" />
|
<EyeOff size={20} color="#64748b" strokeWidth={2} />
|
||||||
) : (
|
) : (
|
||||||
<Eye className="h-5 w-5" />
|
<Eye size={20} color="#64748b" strokeWidth={2} />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -119,7 +325,7 @@ const Login = () => {
|
|||||||
{error && (
|
{error && (
|
||||||
<div className="bg-danger-50 border border-danger-200 rounded-md p-3">
|
<div className="bg-danger-50 border border-danger-200 rounded-md p-3">
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
<AlertCircle className="h-5 w-5 text-danger-400" />
|
<AlertCircle size={20} color="#dc2626" strokeWidth={2} />
|
||||||
<div className="ml-3">
|
<div className="ml-3">
|
||||||
<p className="text-sm text-danger-700">{error}</p>
|
<p className="text-sm text-danger-700">{error}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -136,20 +342,106 @@ const Login = () => {
|
|||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||||
Signing in...
|
{isSignupMode ? 'Creating account...' : 'Signing in...'}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
'Sign in'
|
isSignupMode ? 'Create Account' : 'Sign in'
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{signupEnabled && (
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-sm text-secondary-600">
|
||||||
|
{isSignupMode ? 'Already have an account?' : "Don't have an account?"}{' '}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggleMode}
|
||||||
|
className="font-medium text-primary-600 hover:text-primary-500 focus:outline-none focus:underline"
|
||||||
|
>
|
||||||
|
{isSignupMode ? 'Sign in' : 'Sign up'}
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<form className="mt-8 space-y-6" onSubmit={handleTfaSubmit}>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="mx-auto h-12 w-12 flex items-center justify-center rounded-full bg-blue-100">
|
||||||
|
<Smartphone size={24} color="#2563eb" strokeWidth={2} />
|
||||||
|
</div>
|
||||||
|
<h3 className="mt-4 text-lg font-medium text-secondary-900">
|
||||||
|
Two-Factor Authentication
|
||||||
|
</h3>
|
||||||
|
<p className="mt-2 text-sm text-secondary-600">
|
||||||
|
Enter the 6-digit code from your authenticator app
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="token" className="block text-sm font-medium text-secondary-700">
|
||||||
|
Verification Code
|
||||||
|
</label>
|
||||||
|
<div className="mt-1">
|
||||||
|
<input
|
||||||
|
id="token"
|
||||||
|
name="token"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={tfaData.token}
|
||||||
|
onChange={handleTfaInputChange}
|
||||||
|
className="appearance-none rounded-md relative block w-full px-3 py-2 border border-secondary-300 placeholder-secondary-500 text-secondary-900 focus:outline-none focus:ring-primary-500 focus:border-primary-500 focus:z-10 sm:text-sm text-center text-lg font-mono tracking-widest"
|
||||||
|
placeholder="000000"
|
||||||
|
maxLength="6"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="bg-danger-50 border border-danger-200 rounded-md p-3">
|
||||||
|
<div className="flex">
|
||||||
|
<AlertCircle size={20} color="#dc2626" strokeWidth={2} />
|
||||||
|
<div className="ml-3">
|
||||||
|
<p className="text-sm text-danger-700">{error}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading || tfaData.token.length !== 6}
|
||||||
|
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||||
|
Verifying...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
'Verify Code'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleBackToLogin}
|
||||||
|
className="group relative w-full flex justify-center py-2 px-4 border border-secondary-300 text-sm font-medium rounded-md text-secondary-700 bg-white hover:bg-secondary-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 items-center gap-2"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={16} color="#475569" strokeWidth={2} />
|
||||||
|
Back to Login
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<p className="text-sm text-secondary-600">
|
<p className="text-sm text-secondary-600">
|
||||||
Need help? Contact your system administrator.
|
Don't have access to your authenticator? Use a backup code.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
571
frontend/src/pages/Options.jsx
Normal file
571
frontend/src/pages/Options.jsx
Normal file
@@ -0,0 +1,571 @@
|
|||||||
|
import React, { useState } from 'react'
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import {
|
||||||
|
Plus,
|
||||||
|
Edit,
|
||||||
|
Trash2,
|
||||||
|
Server,
|
||||||
|
Users,
|
||||||
|
AlertTriangle,
|
||||||
|
CheckCircle,
|
||||||
|
Settings
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { hostGroupsAPI } from '../utils/api'
|
||||||
|
|
||||||
|
const Options = () => {
|
||||||
|
const [activeTab, setActiveTab] = useState('hostgroups')
|
||||||
|
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||||
|
const [showEditModal, setShowEditModal] = useState(false)
|
||||||
|
const [selectedGroup, setSelectedGroup] = useState(null)
|
||||||
|
const [showDeleteModal, setShowDeleteModal] = useState(false)
|
||||||
|
const [groupToDelete, setGroupToDelete] = useState(null)
|
||||||
|
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
// Tab configuration
|
||||||
|
const tabs = [
|
||||||
|
{ id: 'hostgroups', name: 'Host Groups', icon: Users },
|
||||||
|
{ id: 'notifications', name: 'Notifications', icon: AlertTriangle, comingSoon: true }
|
||||||
|
]
|
||||||
|
|
||||||
|
// Fetch host groups
|
||||||
|
const { data: hostGroups, isLoading, error } = useQuery({
|
||||||
|
queryKey: ['hostGroups'],
|
||||||
|
queryFn: () => hostGroupsAPI.list().then(res => res.data),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create host group mutation
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: (data) => hostGroupsAPI.create(data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries(['hostGroups'])
|
||||||
|
setShowCreateModal(false)
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
console.error('Failed to create host group:', error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Update host group mutation
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: ({ id, data }) => hostGroupsAPI.update(id, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries(['hostGroups'])
|
||||||
|
setShowEditModal(false)
|
||||||
|
setSelectedGroup(null)
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
console.error('Failed to update host group:', error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Delete host group mutation
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id) => hostGroupsAPI.delete(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries(['hostGroups'])
|
||||||
|
setShowDeleteModal(false)
|
||||||
|
setGroupToDelete(null)
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
console.error('Failed to delete host group:', error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleCreate = (data) => {
|
||||||
|
createMutation.mutate(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEdit = (group) => {
|
||||||
|
setSelectedGroup(group)
|
||||||
|
setShowEditModal(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUpdate = (data) => {
|
||||||
|
updateMutation.mutate({ id: selectedGroup.id, data })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteClick = (group) => {
|
||||||
|
setGroupToDelete(group)
|
||||||
|
setShowDeleteModal(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteConfirm = () => {
|
||||||
|
deleteMutation.mutate(groupToDelete.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderHostGroupsTab = () => {
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="bg-danger-50 border border-danger-200 rounded-md p-4">
|
||||||
|
<div className="flex">
|
||||||
|
<AlertTriangle className="h-5 w-5 text-danger-400" />
|
||||||
|
<div className="ml-3">
|
||||||
|
<h3 className="text-sm font-medium text-danger-800">
|
||||||
|
Error loading host groups
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-danger-700 mt-1">
|
||||||
|
{error.message || 'Failed to load host groups'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold text-secondary-900 dark:text-white">
|
||||||
|
Host Groups
|
||||||
|
</h2>
|
||||||
|
<p className="text-secondary-600 dark:text-secondary-300">
|
||||||
|
Organize your hosts into logical groups for better management
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreateModal(true)}
|
||||||
|
className="btn-primary flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Create Group
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Host Groups Grid */}
|
||||||
|
{hostGroups && hostGroups.length > 0 ? (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{hostGroups.map((group) => (
|
||||||
|
<div key={group.id} className="bg-white dark:bg-secondary-800 border border-secondary-200 dark:border-secondary-600 rounded-lg p-6 hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
className="w-3 h-3 rounded-full"
|
||||||
|
style={{ backgroundColor: group.color }}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-secondary-900 dark:text-white">
|
||||||
|
{group.name}
|
||||||
|
</h3>
|
||||||
|
{group.description && (
|
||||||
|
<p className="text-sm text-secondary-600 dark:text-secondary-300 mt-1">
|
||||||
|
{group.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleEdit(group)}
|
||||||
|
className="p-1 text-secondary-400 hover:text-secondary-600 hover:bg-secondary-100 rounded"
|
||||||
|
title="Edit group"
|
||||||
|
>
|
||||||
|
<Edit className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteClick(group)}
|
||||||
|
className="p-1 text-secondary-400 hover:text-danger-600 hover:bg-danger-50 rounded"
|
||||||
|
title="Delete group"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex items-center gap-4 text-sm text-secondary-600 dark:text-secondary-300">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Server className="h-4 w-4" />
|
||||||
|
<span>{group._count.hosts} host{group._count.hosts !== 1 ? 's' : ''}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<Server className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
|
||||||
|
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-2">
|
||||||
|
No host groups yet
|
||||||
|
</h3>
|
||||||
|
<p className="text-secondary-600 dark:text-secondary-300 mb-6">
|
||||||
|
Create your first host group to organize your hosts
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreateModal(true)}
|
||||||
|
className="btn-primary flex items-center gap-2 mx-auto"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Create Group
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderComingSoonTab = (tabName) => (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<Settings className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
|
||||||
|
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-2">
|
||||||
|
{tabName} Coming Soon
|
||||||
|
</h3>
|
||||||
|
<p className="text-secondary-600 dark:text-secondary-300">
|
||||||
|
This feature is currently under development and will be available in a future update.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Page Header */}
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-secondary-900 dark:text-white">
|
||||||
|
Options
|
||||||
|
</h1>
|
||||||
|
<p className="text-secondary-600 dark:text-secondary-300 mt-1">
|
||||||
|
Configure PatchMon parameters and user preferences
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="border-b border-secondary-200 dark:border-secondary-600">
|
||||||
|
<nav className="-mb-px flex space-x-8">
|
||||||
|
{tabs.map((tab) => {
|
||||||
|
const Icon = tab.icon
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
onClick={() => setActiveTab(tab.id)}
|
||||||
|
className={`py-2 px-1 border-b-2 font-medium text-sm flex items-center gap-2 ${
|
||||||
|
activeTab === tab.id
|
||||||
|
? 'border-primary-500 text-primary-600 dark:text-primary-400'
|
||||||
|
: 'border-transparent text-secondary-500 hover:text-secondary-700 hover:border-secondary-300 dark:text-secondary-400 dark:hover:text-secondary-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
{tab.name}
|
||||||
|
{tab.comingSoon && (
|
||||||
|
<span className="text-xs bg-secondary-100 text-secondary-600 px-1.5 py-0.5 rounded">
|
||||||
|
Soon
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab Content */}
|
||||||
|
<div className="mt-6">
|
||||||
|
{activeTab === 'hostgroups' && renderHostGroupsTab()}
|
||||||
|
{activeTab === 'notifications' && renderComingSoonTab('Notifications')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Create Modal */}
|
||||||
|
{showCreateModal && (
|
||||||
|
<CreateHostGroupModal
|
||||||
|
onClose={() => setShowCreateModal(false)}
|
||||||
|
onSubmit={handleCreate}
|
||||||
|
isLoading={createMutation.isPending}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Edit Modal */}
|
||||||
|
{showEditModal && selectedGroup && (
|
||||||
|
<EditHostGroupModal
|
||||||
|
group={selectedGroup}
|
||||||
|
onClose={() => {
|
||||||
|
setShowEditModal(false)
|
||||||
|
setSelectedGroup(null)
|
||||||
|
}}
|
||||||
|
onSubmit={handleUpdate}
|
||||||
|
isLoading={updateMutation.isPending}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Delete Confirmation Modal */}
|
||||||
|
{showDeleteModal && groupToDelete && (
|
||||||
|
<DeleteHostGroupModal
|
||||||
|
group={groupToDelete}
|
||||||
|
onClose={() => {
|
||||||
|
setShowDeleteModal(false)
|
||||||
|
setGroupToDelete(null)
|
||||||
|
}}
|
||||||
|
onConfirm={handleDeleteConfirm}
|
||||||
|
isLoading={deleteMutation.isPending}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create Host Group Modal
|
||||||
|
const CreateHostGroupModal = ({ onClose, onSubmit, isLoading }) => {
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
color: '#3B82F6'
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSubmit = (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
onSubmit(formData)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleChange = (e) => {
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
[e.target.name]: e.target.value
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 w-full max-w-md">
|
||||||
|
<h3 className="text-lg font-semibold text-secondary-900 dark:text-white mb-4">
|
||||||
|
Create Host Group
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
|
||||||
|
Name *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="name"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={handleChange}
|
||||||
|
required
|
||||||
|
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white placeholder-secondary-500 dark:placeholder-secondary-400"
|
||||||
|
placeholder="e.g., Production Servers"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
|
||||||
|
Description
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
name="description"
|
||||||
|
value={formData.description}
|
||||||
|
onChange={handleChange}
|
||||||
|
rows={3}
|
||||||
|
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white placeholder-secondary-500 dark:placeholder-secondary-400"
|
||||||
|
placeholder="Optional description for this group"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
|
||||||
|
Color
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
name="color"
|
||||||
|
value={formData.color}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="w-12 h-10 border border-secondary-300 rounded cursor-pointer"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.color}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white placeholder-secondary-500 dark:placeholder-secondary-400"
|
||||||
|
placeholder="#3B82F6"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3 pt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="btn-outline"
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn-primary"
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{isLoading ? 'Creating...' : 'Create Group'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Edit Host Group Modal
|
||||||
|
const EditHostGroupModal = ({ group, onClose, onSubmit, isLoading }) => {
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
name: group.name,
|
||||||
|
description: group.description || '',
|
||||||
|
color: group.color || '#3B82F6'
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSubmit = (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
onSubmit(formData)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleChange = (e) => {
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
[e.target.name]: e.target.value
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 w-full max-w-md">
|
||||||
|
<h3 className="text-lg font-semibold text-secondary-900 dark:text-white mb-4">
|
||||||
|
Edit Host Group
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
|
||||||
|
Name *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="name"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={handleChange}
|
||||||
|
required
|
||||||
|
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white placeholder-secondary-500 dark:placeholder-secondary-400"
|
||||||
|
placeholder="e.g., Production Servers"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
|
||||||
|
Description
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
name="description"
|
||||||
|
value={formData.description}
|
||||||
|
onChange={handleChange}
|
||||||
|
rows={3}
|
||||||
|
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white placeholder-secondary-500 dark:placeholder-secondary-400"
|
||||||
|
placeholder="Optional description for this group"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
|
||||||
|
Color
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
name="color"
|
||||||
|
value={formData.color}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="w-12 h-10 border border-secondary-300 rounded cursor-pointer"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.color}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white placeholder-secondary-500 dark:placeholder-secondary-400"
|
||||||
|
placeholder="#3B82F6"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3 pt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="btn-outline"
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn-primary"
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{isLoading ? 'Updating...' : 'Update Group'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete Confirmation Modal
|
||||||
|
const DeleteHostGroupModal = ({ group, onClose, onConfirm, isLoading }) => {
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 w-full max-w-md">
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<div className="w-10 h-10 bg-danger-100 rounded-full flex items-center justify-center">
|
||||||
|
<AlertTriangle className="h-5 w-5 text-danger-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-secondary-900 dark:text-white">
|
||||||
|
Delete Host Group
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-secondary-600 dark:text-secondary-300">
|
||||||
|
This action cannot be undone
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-6">
|
||||||
|
<p className="text-secondary-700 dark:text-secondary-200">
|
||||||
|
Are you sure you want to delete the host group{' '}
|
||||||
|
<span className="font-semibold">"{group.name}"</span>?
|
||||||
|
</p>
|
||||||
|
{group._count.hosts > 0 && (
|
||||||
|
<div className="mt-3 p-3 bg-warning-50 border border-warning-200 rounded-md">
|
||||||
|
<p className="text-sm text-warning-800">
|
||||||
|
<strong>Warning:</strong> This group contains {group._count.hosts} host{group._count.hosts !== 1 ? 's' : ''}.
|
||||||
|
You must move or remove these hosts before deleting the group.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="btn-outline"
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onConfirm}
|
||||||
|
className="btn-danger"
|
||||||
|
disabled={isLoading || group._count.hosts > 0}
|
||||||
|
>
|
||||||
|
{isLoading ? 'Deleting...' : 'Delete Group'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Options
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect } from 'react'
|
import React, { useState, useEffect, useMemo } from 'react'
|
||||||
import { Link, useSearchParams } from 'react-router-dom'
|
import { Link, useSearchParams, useNavigate } from 'react-router-dom'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import {
|
import {
|
||||||
Package,
|
Package,
|
||||||
@@ -9,7 +9,17 @@ import {
|
|||||||
Search,
|
Search,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Filter,
|
Filter,
|
||||||
ExternalLink
|
ExternalLink,
|
||||||
|
ArrowUpDown,
|
||||||
|
ArrowUp,
|
||||||
|
ArrowDown,
|
||||||
|
ChevronDown,
|
||||||
|
Settings,
|
||||||
|
Columns,
|
||||||
|
GripVertical,
|
||||||
|
X,
|
||||||
|
Eye as EyeIcon,
|
||||||
|
EyeOff as EyeOffIcon
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { dashboardAPI } from '../utils/api'
|
import { dashboardAPI } from '../utils/api'
|
||||||
|
|
||||||
@@ -17,7 +27,62 @@ const Packages = () => {
|
|||||||
const [searchTerm, setSearchTerm] = useState('')
|
const [searchTerm, setSearchTerm] = useState('')
|
||||||
const [categoryFilter, setCategoryFilter] = useState('all')
|
const [categoryFilter, setCategoryFilter] = useState('all')
|
||||||
const [securityFilter, setSecurityFilter] = useState('all')
|
const [securityFilter, setSecurityFilter] = useState('all')
|
||||||
|
const [hostFilter, setHostFilter] = useState('all')
|
||||||
|
const [sortField, setSortField] = useState('name')
|
||||||
|
const [sortDirection, setSortDirection] = useState('asc')
|
||||||
|
const [showColumnSettings, setShowColumnSettings] = useState(false)
|
||||||
const [searchParams] = useSearchParams()
|
const [searchParams] = useSearchParams()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
// Handle host filter from URL parameter
|
||||||
|
useEffect(() => {
|
||||||
|
const hostParam = searchParams.get('host')
|
||||||
|
if (hostParam) {
|
||||||
|
setHostFilter(hostParam)
|
||||||
|
}
|
||||||
|
}, [searchParams])
|
||||||
|
|
||||||
|
// Column configuration
|
||||||
|
const [columnConfig, setColumnConfig] = useState(() => {
|
||||||
|
const defaultConfig = [
|
||||||
|
{ id: 'name', label: 'Package', visible: true, order: 0 },
|
||||||
|
{ id: 'affectedHosts', label: 'Affected Hosts', visible: true, order: 1 },
|
||||||
|
{ id: 'priority', label: 'Priority', visible: true, order: 2 },
|
||||||
|
{ id: 'latestVersion', label: 'Latest Version', visible: true, order: 3 }
|
||||||
|
]
|
||||||
|
|
||||||
|
const saved = localStorage.getItem('packages-column-config')
|
||||||
|
if (saved) {
|
||||||
|
const savedConfig = JSON.parse(saved)
|
||||||
|
// Merge with defaults to handle new columns
|
||||||
|
return defaultConfig.map(defaultCol => {
|
||||||
|
const savedCol = savedConfig.find(col => col.id === defaultCol.id)
|
||||||
|
return savedCol ? { ...defaultCol, ...savedCol } : defaultCol
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return defaultConfig
|
||||||
|
})
|
||||||
|
|
||||||
|
// Update column configuration
|
||||||
|
const updateColumnConfig = (newConfig) => {
|
||||||
|
setColumnConfig(newConfig)
|
||||||
|
localStorage.setItem('packages-column-config', JSON.stringify(newConfig))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle affected hosts click
|
||||||
|
const handleAffectedHostsClick = (pkg) => {
|
||||||
|
const affectedHosts = pkg.affectedHosts || []
|
||||||
|
const hostIds = affectedHosts.map(host => host.hostId)
|
||||||
|
const hostNames = affectedHosts.map(host => host.friendlyName)
|
||||||
|
|
||||||
|
// Create URL with selected hosts and filter
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
params.set('selected', hostIds.join(','))
|
||||||
|
params.set('filter', 'selected')
|
||||||
|
|
||||||
|
// Navigate to hosts page with selected hosts
|
||||||
|
navigate(`/hosts?${params.toString()}`)
|
||||||
|
}
|
||||||
|
|
||||||
// Handle URL filter parameters
|
// Handle URL filter parameters
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -34,13 +99,206 @@ const Packages = () => {
|
|||||||
}
|
}
|
||||||
}, [searchParams])
|
}, [searchParams])
|
||||||
|
|
||||||
const { data: packages, isLoading, error, refetch } = useQuery({
|
const { data: packages, isLoading, error, refetch, isFetching } = useQuery({
|
||||||
queryKey: ['packages'],
|
queryKey: ['packages'],
|
||||||
queryFn: () => dashboardAPI.getPackages().then(res => res.data),
|
queryFn: () => dashboardAPI.getPackages().then(res => res.data),
|
||||||
refetchInterval: 60000,
|
staleTime: 5 * 60 * 1000, // Data stays fresh for 5 minutes
|
||||||
staleTime: 30000,
|
refetchOnWindowFocus: false, // Don't refetch when window regains focus
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Fetch hosts data to get total packages count
|
||||||
|
const { data: hosts } = useQuery({
|
||||||
|
queryKey: ['hosts'],
|
||||||
|
queryFn: () => dashboardAPI.getHosts().then(res => res.data),
|
||||||
|
staleTime: 5 * 60 * 1000, // Data stays fresh for 5 minutes
|
||||||
|
refetchOnWindowFocus: false, // Don't refetch when window regains focus
|
||||||
|
})
|
||||||
|
|
||||||
|
// Filter and sort packages
|
||||||
|
const filteredAndSortedPackages = useMemo(() => {
|
||||||
|
if (!packages) return []
|
||||||
|
|
||||||
|
// Filter packages
|
||||||
|
const filtered = packages.filter(pkg => {
|
||||||
|
const matchesSearch = pkg.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
(pkg.description && pkg.description.toLowerCase().includes(searchTerm.toLowerCase()))
|
||||||
|
|
||||||
|
const matchesCategory = categoryFilter === 'all' || pkg.category === categoryFilter
|
||||||
|
|
||||||
|
const matchesSecurity = securityFilter === 'all' ||
|
||||||
|
(securityFilter === 'security' && pkg.isSecurityUpdate) ||
|
||||||
|
(securityFilter === 'regular' && !pkg.isSecurityUpdate)
|
||||||
|
|
||||||
|
const affectedHosts = pkg.affectedHosts || []
|
||||||
|
const matchesHost = hostFilter === 'all' ||
|
||||||
|
affectedHosts.some(host => host.hostId === hostFilter)
|
||||||
|
|
||||||
|
return matchesSearch && matchesCategory && matchesSecurity && matchesHost
|
||||||
|
})
|
||||||
|
|
||||||
|
// Sorting
|
||||||
|
filtered.sort((a, b) => {
|
||||||
|
let aValue, bValue
|
||||||
|
|
||||||
|
switch (sortField) {
|
||||||
|
case 'name':
|
||||||
|
aValue = a.name?.toLowerCase() || ''
|
||||||
|
bValue = b.name?.toLowerCase() || ''
|
||||||
|
break
|
||||||
|
case 'latestVersion':
|
||||||
|
aValue = a.latestVersion?.toLowerCase() || ''
|
||||||
|
bValue = b.latestVersion?.toLowerCase() || ''
|
||||||
|
break
|
||||||
|
case 'affectedHosts':
|
||||||
|
aValue = a.affectedHostsCount || a.affectedHosts?.length || 0
|
||||||
|
bValue = b.affectedHostsCount || b.affectedHosts?.length || 0
|
||||||
|
break
|
||||||
|
case 'priority':
|
||||||
|
aValue = a.isSecurityUpdate ? 0 : 1 // Security updates first
|
||||||
|
bValue = b.isSecurityUpdate ? 0 : 1
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
aValue = a.name?.toLowerCase() || ''
|
||||||
|
bValue = b.name?.toLowerCase() || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
if (aValue < bValue) return sortDirection === 'asc' ? -1 : 1
|
||||||
|
if (aValue > bValue) return sortDirection === 'asc' ? 1 : -1
|
||||||
|
return 0
|
||||||
|
})
|
||||||
|
|
||||||
|
return filtered
|
||||||
|
}, [packages, searchTerm, categoryFilter, securityFilter, sortField, sortDirection])
|
||||||
|
|
||||||
|
// Get visible columns in order
|
||||||
|
const visibleColumns = columnConfig
|
||||||
|
.filter(col => col.visible)
|
||||||
|
.sort((a, b) => a.order - b.order)
|
||||||
|
|
||||||
|
// Sorting functions
|
||||||
|
const handleSort = (field) => {
|
||||||
|
if (sortField === field) {
|
||||||
|
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc')
|
||||||
|
} else {
|
||||||
|
setSortField(field)
|
||||||
|
setSortDirection('asc')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getSortIcon = (field) => {
|
||||||
|
if (sortField !== field) return <ArrowUpDown className="h-4 w-4" />
|
||||||
|
return sortDirection === 'asc' ? <ArrowUp className="h-4 w-4" /> : <ArrowDown className="h-4 w-4" />
|
||||||
|
}
|
||||||
|
|
||||||
|
// Column management functions
|
||||||
|
const toggleColumnVisibility = (columnId) => {
|
||||||
|
const newConfig = columnConfig.map(col =>
|
||||||
|
col.id === columnId ? { ...col, visible: !col.visible } : col
|
||||||
|
)
|
||||||
|
updateColumnConfig(newConfig)
|
||||||
|
}
|
||||||
|
|
||||||
|
const reorderColumns = (fromIndex, toIndex) => {
|
||||||
|
const newConfig = [...columnConfig]
|
||||||
|
const [movedColumn] = newConfig.splice(fromIndex, 1)
|
||||||
|
newConfig.splice(toIndex, 0, movedColumn)
|
||||||
|
|
||||||
|
// Update order values
|
||||||
|
const updatedConfig = newConfig.map((col, index) => ({ ...col, order: index }))
|
||||||
|
updateColumnConfig(updatedConfig)
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetColumns = () => {
|
||||||
|
const defaultConfig = [
|
||||||
|
{ id: 'name', label: 'Package', visible: true, order: 0 },
|
||||||
|
{ id: 'affectedHosts', label: 'Affected Hosts', visible: true, order: 1 },
|
||||||
|
{ id: 'priority', label: 'Priority', visible: true, order: 2 },
|
||||||
|
{ id: 'latestVersion', label: 'Latest Version', visible: true, order: 3 }
|
||||||
|
]
|
||||||
|
updateColumnConfig(defaultConfig)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to render table cell content
|
||||||
|
const renderCellContent = (column, pkg) => {
|
||||||
|
switch (column.id) {
|
||||||
|
case 'name':
|
||||||
|
return (
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Package className="h-5 w-5 text-secondary-400 mr-3" />
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-secondary-900 dark:text-white">
|
||||||
|
{pkg.name}
|
||||||
|
</div>
|
||||||
|
{pkg.description && (
|
||||||
|
<div className="text-sm text-secondary-500 dark:text-secondary-300 max-w-md truncate">
|
||||||
|
{pkg.description}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{pkg.category && (
|
||||||
|
<div className="text-xs text-secondary-400 dark:text-secondary-400">
|
||||||
|
Category: {pkg.category}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
case 'affectedHosts':
|
||||||
|
const affectedHostsCount = pkg.affectedHostsCount || pkg.affectedHosts?.length || 0
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={() => handleAffectedHostsClick(pkg)}
|
||||||
|
className="text-left hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded p-1 -m-1 transition-colors group"
|
||||||
|
title={`Click to view all ${affectedHostsCount} affected hosts`}
|
||||||
|
>
|
||||||
|
<div className="text-sm text-secondary-900 dark:text-white group-hover:text-primary-600 dark:group-hover:text-primary-400">
|
||||||
|
{affectedHostsCount} host{affectedHostsCount !== 1 ? 's' : ''}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
case 'priority':
|
||||||
|
return pkg.isSecurityUpdate ? (
|
||||||
|
<span className="badge-danger flex items-center gap-1">
|
||||||
|
<Shield className="h-3 w-3" />
|
||||||
|
Security Update
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="badge-warning">Regular Update</span>
|
||||||
|
)
|
||||||
|
case 'latestVersion':
|
||||||
|
return (
|
||||||
|
<div className="text-sm text-secondary-900 dark:text-white max-w-xs truncate" title={pkg.latestVersion || 'Unknown'}>
|
||||||
|
{pkg.latestVersion || 'Unknown'}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get unique categories
|
||||||
|
const categories = [...new Set(packages?.map(pkg => pkg.category).filter(Boolean))] || []
|
||||||
|
|
||||||
|
// Calculate unique affected hosts
|
||||||
|
const uniqueAffectedHosts = new Set()
|
||||||
|
packages?.forEach(pkg => {
|
||||||
|
const affectedHosts = pkg.affectedHosts || []
|
||||||
|
affectedHosts.forEach(host => {
|
||||||
|
uniqueAffectedHosts.add(host.hostId)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
const uniqueAffectedHostsCount = uniqueAffectedHosts.size
|
||||||
|
|
||||||
|
// Calculate total packages across all hosts (including up-to-date ones)
|
||||||
|
const totalPackagesCount = hosts?.reduce((total, host) => {
|
||||||
|
return total + (host.totalPackagesCount || 0)
|
||||||
|
}, 0) || 0
|
||||||
|
|
||||||
|
// Calculate outdated packages (packages that need updates)
|
||||||
|
const outdatedPackagesCount = packages?.length || 0
|
||||||
|
|
||||||
|
// Calculate security updates
|
||||||
|
const securityUpdatesCount = packages?.filter(pkg => pkg.isSecurityUpdate).length || 0
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-64">
|
<div className="flex items-center justify-center h-64">
|
||||||
@@ -74,64 +332,58 @@ const Packages = () => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter packages based on search and filters
|
|
||||||
const filteredPackages = packages?.filter(pkg => {
|
|
||||||
const matchesSearch = pkg.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
||||||
(pkg.description && pkg.description.toLowerCase().includes(searchTerm.toLowerCase()))
|
|
||||||
|
|
||||||
const matchesCategory = categoryFilter === 'all' || pkg.category === categoryFilter
|
|
||||||
|
|
||||||
const matchesSecurity = securityFilter === 'all' ||
|
|
||||||
(securityFilter === 'security' && pkg.isSecurityUpdate) ||
|
|
||||||
(securityFilter === 'regular' && !pkg.isSecurityUpdate)
|
|
||||||
|
|
||||||
return matchesSearch && matchesCategory && matchesSecurity
|
|
||||||
}) || []
|
|
||||||
|
|
||||||
// Get unique categories
|
|
||||||
const categories = [...new Set(packages?.map(pkg => pkg.category).filter(Boolean))] || []
|
|
||||||
|
|
||||||
// Calculate unique affected hosts
|
|
||||||
const uniqueAffectedHosts = new Set()
|
|
||||||
packages?.forEach(pkg => {
|
|
||||||
pkg.affectedHosts.forEach(host => {
|
|
||||||
uniqueAffectedHosts.add(host.hostId)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
const uniqueAffectedHostsCount = uniqueAffectedHosts.size
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="h-[calc(100vh-7rem)] flex flex-col overflow-hidden">
|
||||||
|
{/* Page Header */}
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold text-secondary-900 dark:text-white">Packages</h1>
|
||||||
|
<p className="text-sm text-secondary-600 dark:text-secondary-400 mt-1">
|
||||||
|
Manage package updates and security patches
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => refetch()}
|
||||||
|
disabled={isFetching}
|
||||||
|
className="btn-outline flex items-center gap-2"
|
||||||
|
title="Refresh packages data"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`h-4 w-4 ${isFetching ? 'animate-spin' : ''}`} />
|
||||||
|
{isFetching ? 'Refreshing...' : 'Refresh'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Summary Stats */}
|
{/* Summary Stats */}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-4 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-4 gap-4 mb-6 flex-shrink-0">
|
||||||
<div className="card p-4">
|
<div className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<Package className="h-5 w-5 text-primary-600 mr-2" />
|
<Package className="h-5 w-5 text-primary-600 mr-2" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-secondary-500 dark:text-white">Total Packages</p>
|
<p className="text-sm text-secondary-500 dark:text-white">Total Packages</p>
|
||||||
<p className="text-xl font-semibold text-secondary-900 dark:text-white">{packages?.length || 0}</p>
|
<p className="text-xl font-semibold text-secondary-900 dark:text-white">{totalPackagesCount}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card p-4">
|
<div className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<Shield className="h-5 w-5 text-danger-600 mr-2" />
|
<Package className="h-5 w-5 text-warning-600 mr-2" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-secondary-500 dark:text-white">Security Updates</p>
|
<p className="text-sm text-secondary-500 dark:text-white">Total Outdated Packages</p>
|
||||||
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
|
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
|
||||||
{packages?.filter(pkg => pkg.isSecurityUpdate).length || 0}
|
{outdatedPackagesCount}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card p-4">
|
<div className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<Server className="h-5 w-5 text-warning-600 mr-2" />
|
<Server className="h-5 w-5 text-warning-600 mr-2" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-secondary-500 dark:text-white">Affected Hosts</p>
|
<p className="text-sm text-secondary-500 dark:text-white">Hosts Pending Updates</p>
|
||||||
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
|
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
|
||||||
{uniqueAffectedHostsCount}
|
{uniqueAffectedHostsCount}
|
||||||
</p>
|
</p>
|
||||||
@@ -139,19 +391,26 @@ const Packages = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card p-4">
|
<div className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<Filter className="h-5 w-5 text-secondary-600 mr-2" />
|
<Shield className="h-5 w-5 text-danger-600 mr-2" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-secondary-500 dark:text-white">Categories</p>
|
<p className="text-sm text-secondary-500 dark:text-white">Security Updates Across All Hosts</p>
|
||||||
<p className="text-xl font-semibold text-secondary-900 dark:text-white">{categories.length}</p>
|
<p className="text-xl font-semibold text-secondary-900 dark:text-white">{securityUpdatesCount}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Packages List */}
|
||||||
<div className="card p-4">
|
<div className="card flex-1 flex flex-col overflow-hidden min-h-0">
|
||||||
|
<div className="px-4 py-4 sm:p-4 flex-1 flex flex-col overflow-hidden min-h-0">
|
||||||
|
<div className="flex items-center justify-end mb-4">
|
||||||
|
{/* Empty selection controls area to match hosts page spacing */}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table Controls */}
|
||||||
|
<div className="mb-4 space-y-4">
|
||||||
<div className="flex flex-col sm:flex-row gap-4">
|
<div className="flex flex-col sm:flex-row gap-4">
|
||||||
{/* Search */}
|
{/* Search */}
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
@@ -193,17 +452,36 @@ const Packages = () => {
|
|||||||
<option value="regular">Regular Only</option>
|
<option value="regular">Regular Only</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Host Filter */}
|
||||||
|
<div className="sm:w-48">
|
||||||
|
<select
|
||||||
|
value={hostFilter}
|
||||||
|
onChange={(e) => setHostFilter(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white"
|
||||||
|
>
|
||||||
|
<option value="all">All Hosts</option>
|
||||||
|
{hosts?.map(host => (
|
||||||
|
<option key={host.id} value={host.id}>{host.friendly_name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Columns Button */}
|
||||||
|
<div className="flex items-center">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowColumnSettings(true)}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 text-sm text-secondary-700 dark:text-secondary-300 bg-white dark:bg-secondary-700 border border-secondary-300 dark:border-secondary-600 rounded-md hover:bg-secondary-50 dark:hover:bg-secondary-600 transition-colors"
|
||||||
|
>
|
||||||
|
<Columns className="h-4 w-4" />
|
||||||
|
Columns
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Packages List */}
|
<div className="flex-1 overflow-hidden">
|
||||||
<div className="card">
|
{filteredAndSortedPackages.length === 0 ? (
|
||||||
<div className="px-4 py-5 sm:p-6">
|
|
||||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">
|
|
||||||
Packages Needing Updates ({filteredPackages.length})
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
{filteredPackages.length === 0 ? (
|
|
||||||
<div className="text-center py-8">
|
<div className="text-center py-8">
|
||||||
<Package className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
|
<Package className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
|
||||||
<p className="text-secondary-500 dark:text-secondary-300">
|
<p className="text-secondary-500 dark:text-secondary-300">
|
||||||
@@ -216,69 +494,31 @@ const Packages = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-x-auto">
|
<div className="h-full overflow-auto">
|
||||||
<table className="min-w-full divide-y divide-secondary-200 dark:divide-secondary-600">
|
<table className="min-w-full divide-y divide-secondary-200 dark:divide-secondary-600">
|
||||||
<thead className="bg-secondary-50 dark:bg-secondary-700">
|
<thead className="bg-secondary-50 dark:bg-secondary-700 sticky top-0 z-10">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
{visibleColumns.map((column) => (
|
||||||
Package
|
<th key={column.id} className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||||
</th>
|
<button
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
onClick={() => handleSort(column.id)}
|
||||||
Latest Version
|
className="flex items-center gap-1 hover:text-secondary-700 dark:hover:text-secondary-200 transition-colors"
|
||||||
</th>
|
>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
{column.label}
|
||||||
Affected Hosts
|
{getSortIcon(column.id)}
|
||||||
</th>
|
</button>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
|
||||||
Priority
|
|
||||||
</th>
|
</th>
|
||||||
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-600">
|
<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-600">
|
||||||
{filteredPackages.map((pkg) => (
|
{filteredAndSortedPackages.map((pkg) => (
|
||||||
<tr key={pkg.id} className="hover:bg-secondary-50 dark:hover:bg-secondary-700">
|
<tr key={pkg.id} className="hover:bg-secondary-50 dark:hover:bg-secondary-700 transition-colors">
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
{visibleColumns.map((column) => (
|
||||||
<div className="flex items-center">
|
<td key={column.id} className="px-4 py-2 whitespace-nowrap text-center">
|
||||||
<Package className="h-5 w-5 text-secondary-400 mr-3" />
|
{renderCellContent(column, pkg)}
|
||||||
<div>
|
|
||||||
<div className="text-sm font-medium text-secondary-900 dark:text-white">
|
|
||||||
{pkg.name}
|
|
||||||
</div>
|
|
||||||
{pkg.description && (
|
|
||||||
<div className="text-sm text-secondary-500 dark:text-secondary-300 max-w-md truncate">
|
|
||||||
{pkg.description}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{pkg.category && (
|
|
||||||
<div className="text-xs text-secondary-400 dark:text-secondary-400">
|
|
||||||
Category: {pkg.category}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-secondary-900 dark:text-white">
|
|
||||||
{pkg.latestVersion || 'Unknown'}
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
|
||||||
<div className="text-sm text-secondary-900 dark:text-white">
|
|
||||||
{pkg.affectedHostsCount} host{pkg.affectedHostsCount !== 1 ? 's' : ''}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-secondary-500 dark:text-secondary-300">
|
|
||||||
{pkg.affectedHosts.slice(0, 2).map(host => host.hostname).join(', ')}
|
|
||||||
{pkg.affectedHosts.length > 2 && ` +${pkg.affectedHosts.length - 2} more`}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
|
||||||
{pkg.isSecurityUpdate ? (
|
|
||||||
<span className="badge-danger flex items-center gap-1">
|
|
||||||
<Shield className="h-3 w-3" />
|
|
||||||
Security Update
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="badge-warning">Regular Update</span>
|
|
||||||
)}
|
|
||||||
</td>
|
</td>
|
||||||
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -288,6 +528,101 @@ const Packages = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Column Settings Modal */}
|
||||||
|
{showColumnSettings && (
|
||||||
|
<ColumnSettingsModal
|
||||||
|
columnConfig={columnConfig}
|
||||||
|
onClose={() => setShowColumnSettings(false)}
|
||||||
|
onToggleVisibility={toggleColumnVisibility}
|
||||||
|
onReorder={reorderColumns}
|
||||||
|
onReset={resetColumns}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Column Settings Modal Component
|
||||||
|
const ColumnSettingsModal = ({ columnConfig, onClose, onToggleVisibility, onReorder, onReset }) => {
|
||||||
|
const [draggedIndex, setDraggedIndex] = useState(null)
|
||||||
|
|
||||||
|
const handleDragStart = (e, index) => {
|
||||||
|
setDraggedIndex(index)
|
||||||
|
e.dataTransfer.effectAllowed = 'move'
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDragOver = (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.dataTransfer.dropEffect = 'move'
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDrop = (e, dropIndex) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (draggedIndex !== null && draggedIndex !== dropIndex) {
|
||||||
|
onReorder(draggedIndex, dropIndex)
|
||||||
|
}
|
||||||
|
setDraggedIndex(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 w-full max-w-md">
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">Customize Columns</h3>
|
||||||
|
<button onClick={onClose} className="text-secondary-400 hover:text-secondary-600 dark:text-secondary-500 dark:hover:text-secondary-300">
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{columnConfig.map((column, index) => (
|
||||||
|
<div
|
||||||
|
key={column.id}
|
||||||
|
draggable
|
||||||
|
onDragStart={(e) => handleDragStart(e, index)}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDrop={(e) => handleDrop(e, index)}
|
||||||
|
className={`flex items-center justify-between p-3 border rounded-lg cursor-move ${
|
||||||
|
draggedIndex === index ? 'opacity-50' : 'hover:bg-secondary-50 dark:hover:bg-secondary-700'
|
||||||
|
} border-secondary-200 dark:border-secondary-600`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<GripVertical className="h-4 w-4 text-secondary-400 dark:text-secondary-500" />
|
||||||
|
<span className="text-sm font-medium text-secondary-900 dark:text-white">
|
||||||
|
{column.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => onToggleVisibility(column.id)}
|
||||||
|
className={`p-1 rounded ${
|
||||||
|
column.visible
|
||||||
|
? 'text-primary-600 hover:text-primary-700'
|
||||||
|
: 'text-secondary-400 hover:text-secondary-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{column.visible ? <EyeIcon className="h-4 w-4" /> : <EyeOffIcon className="h-4 w-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-between mt-6">
|
||||||
|
<button
|
||||||
|
onClick={onReset}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-secondary-700 dark:text-secondary-200 bg-white dark:bg-secondary-700 border border-secondary-300 dark:border-secondary-600 rounded-md hover:bg-secondary-50 dark:hover:bg-secondary-600"
|
||||||
|
>
|
||||||
|
Reset to Default
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-white bg-primary-600 rounded-md hover:bg-primary-700"
|
||||||
|
>
|
||||||
|
Done
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState } from 'react'
|
import React, { useState, useEffect } from 'react'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import {
|
import {
|
||||||
Shield,
|
Shield,
|
||||||
@@ -145,17 +145,22 @@ const Permissions = () => {
|
|||||||
const RolePermissionsCard = ({ role, isEditing, onEdit, onCancel, onSave, onDelete }) => {
|
const RolePermissionsCard = ({ role, isEditing, onEdit, onCancel, onSave, onDelete }) => {
|
||||||
const [permissions, setPermissions] = useState(role)
|
const [permissions, setPermissions] = useState(role)
|
||||||
|
|
||||||
|
// Sync permissions state with role prop when it changes
|
||||||
|
useEffect(() => {
|
||||||
|
setPermissions(role)
|
||||||
|
}, [role])
|
||||||
|
|
||||||
const permissionFields = [
|
const permissionFields = [
|
||||||
{ key: 'canViewDashboard', label: 'View Dashboard', icon: BarChart3, description: 'Access to the main dashboard' },
|
{ key: 'can_view_dashboard', label: 'View Dashboard', icon: BarChart3, description: 'Access to the main dashboard' },
|
||||||
{ key: 'canViewHosts', label: 'View Hosts', icon: Server, description: 'See host information and status' },
|
{ key: 'can_view_hosts', label: 'View Hosts', icon: Server, description: 'See host information and status' },
|
||||||
{ key: 'canManageHosts', label: 'Manage Hosts', icon: Edit, description: 'Add, edit, and delete hosts' },
|
{ key: 'can_manage_hosts', label: 'Manage Hosts', icon: Edit, description: 'Add, edit, and delete hosts' },
|
||||||
{ key: 'canViewPackages', label: 'View Packages', icon: Package, description: 'See package information' },
|
{ key: 'can_view_packages', label: 'View Packages', icon: Package, description: 'See package information' },
|
||||||
{ key: 'canManagePackages', label: 'Manage Packages', icon: Settings, description: 'Edit package details' },
|
{ key: 'can_manage_packages', label: 'Manage Packages', icon: Settings, description: 'Edit package details' },
|
||||||
{ key: 'canViewUsers', label: 'View Users', icon: Users, description: 'See user list and details' },
|
{ key: 'can_view_users', label: 'View Users', icon: Users, description: 'See user list and details' },
|
||||||
{ key: 'canManageUsers', label: 'Manage Users', icon: Shield, description: 'Add, edit, and delete users' },
|
{ key: 'can_manage_users', label: 'Manage Users', icon: Shield, description: 'Add, edit, and delete users' },
|
||||||
{ key: 'canViewReports', label: 'View Reports', icon: BarChart3, description: 'Access to reports and analytics' },
|
{ key: 'can_view_reports', label: 'View Reports', icon: BarChart3, description: 'Access to reports and analytics' },
|
||||||
{ key: 'canExportData', label: 'Export Data', icon: Download, description: 'Download data and reports' },
|
{ key: 'can_export_data', label: 'Export Data', icon: Download, description: 'Download data and reports' },
|
||||||
{ key: 'canManageSettings', label: 'Manage Settings', icon: Settings, description: 'System configuration access' }
|
{ key: 'can_manage_settings', label: 'Manage Settings', icon: Settings, description: 'System configuration access' }
|
||||||
]
|
]
|
||||||
|
|
||||||
const handlePermissionChange = (key, value) => {
|
const handlePermissionChange = (key, value) => {
|
||||||
@@ -169,7 +174,7 @@ const RolePermissionsCard = ({ role, isEditing, onEdit, onCancel, onSave, onDele
|
|||||||
onSave(role.role, permissions)
|
onSave(role.role, permissions)
|
||||||
}
|
}
|
||||||
|
|
||||||
const isAdminRole = role.role === 'admin'
|
const isBuiltInRole = role.role === 'admin' || role.role === 'user'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white dark:bg-secondary-800 shadow rounded-lg">
|
<div className="bg-white dark:bg-secondary-800 shadow rounded-lg">
|
||||||
@@ -178,9 +183,9 @@ const RolePermissionsCard = ({ role, isEditing, onEdit, onCancel, onSave, onDele
|
|||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<Shield className="h-5 w-5 text-primary-600 mr-3" />
|
<Shield className="h-5 w-5 text-primary-600 mr-3" />
|
||||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white capitalize">{role.role}</h3>
|
<h3 className="text-lg font-medium text-secondary-900 dark:text-white capitalize">{role.role}</h3>
|
||||||
{isAdminRole && (
|
{isBuiltInRole && (
|
||||||
<span className="ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-primary-100 text-primary-800">
|
<span className="ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-primary-100 text-primary-800">
|
||||||
System Role
|
Built-in Role
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -196,7 +201,7 @@ const RolePermissionsCard = ({ role, isEditing, onEdit, onCancel, onSave, onDele
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={onCancel}
|
onClick={onCancel}
|
||||||
className="inline-flex items-center px-3 py-1 border border-secondary-300 text-sm font-medium rounded-md text-secondary-700 bg-white hover:bg-secondary-50"
|
className="inline-flex items-center px-3 py-1 border border-secondary-300 dark:border-secondary-600 text-sm font-medium rounded-md text-secondary-700 dark:text-secondary-200 bg-white dark:bg-secondary-700 hover:bg-secondary-50 dark:hover:bg-secondary-600"
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4 mr-1" />
|
<X className="h-4 w-4 mr-1" />
|
||||||
Cancel
|
Cancel
|
||||||
@@ -206,13 +211,13 @@ const RolePermissionsCard = ({ role, isEditing, onEdit, onCancel, onSave, onDele
|
|||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={onEdit}
|
onClick={onEdit}
|
||||||
disabled={isAdminRole}
|
disabled={isBuiltInRole}
|
||||||
className="inline-flex items-center px-3 py-1 border border-transparent text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
className="inline-flex items-center px-3 py-1 border border-transparent text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
<Edit className="h-4 w-4 mr-1" />
|
<Edit className="h-4 w-4 mr-1" />
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
{!isAdminRole && (
|
{!isBuiltInRole && (
|
||||||
<button
|
<button
|
||||||
onClick={() => onDelete(role.role)}
|
onClick={() => onDelete(role.role)}
|
||||||
className="inline-flex items-center px-3 py-1 border border-transparent text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700"
|
className="inline-flex items-center px-3 py-1 border border-transparent text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700"
|
||||||
@@ -240,7 +245,7 @@ const RolePermissionsCard = ({ role, isEditing, onEdit, onCancel, onSave, onDele
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={isChecked}
|
checked={isChecked}
|
||||||
onChange={(e) => handlePermissionChange(field.key, e.target.checked)}
|
onChange={(e) => handlePermissionChange(field.key, e.target.checked)}
|
||||||
disabled={!isEditing || (isAdminRole && field.key === 'canManageUsers')}
|
disabled={!isEditing || (isBuiltInRole && field.key === 'can_manage_users')}
|
||||||
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 rounded disabled:opacity-50"
|
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 rounded disabled:opacity-50"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -268,16 +273,16 @@ const RolePermissionsCard = ({ role, isEditing, onEdit, onCancel, onSave, onDele
|
|||||||
const AddRoleModal = ({ isOpen, onClose, onSuccess }) => {
|
const AddRoleModal = ({ isOpen, onClose, onSuccess }) => {
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
role: '',
|
role: '',
|
||||||
canViewDashboard: true,
|
can_view_dashboard: true,
|
||||||
canViewHosts: true,
|
can_view_hosts: true,
|
||||||
canManageHosts: false,
|
can_manage_hosts: false,
|
||||||
canViewPackages: true,
|
can_view_packages: true,
|
||||||
canManagePackages: false,
|
can_manage_packages: false,
|
||||||
canViewUsers: false,
|
can_view_users: false,
|
||||||
canManageUsers: false,
|
can_manage_users: false,
|
||||||
canViewReports: true,
|
can_view_reports: true,
|
||||||
canExportData: false,
|
can_export_data: false,
|
||||||
canManageSettings: false
|
can_manage_settings: false
|
||||||
})
|
})
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
@@ -309,12 +314,12 @@ const AddRoleModal = ({ isOpen, onClose, onSuccess }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||||
<div className="bg-white rounded-lg p-6 w-full max-w-2xl max-h-[90vh] overflow-y-auto">
|
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 w-full max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||||
<h3 className="text-lg font-medium text-secondary-900 mb-4">Add New Role</h3>
|
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">Add New Role</h3>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-secondary-700 mb-1">
|
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
|
||||||
Role Name
|
Role Name
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -323,25 +328,25 @@ const AddRoleModal = ({ isOpen, onClose, onSuccess }) => {
|
|||||||
required
|
required
|
||||||
value={formData.role}
|
value={formData.role}
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
className="block w-full border-secondary-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500"
|
className="block w-full border-secondary-300 dark:border-secondary-600 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
|
||||||
placeholder="e.g., host_manager, readonly"
|
placeholder="e.g., host_manager, readonly"
|
||||||
/>
|
/>
|
||||||
<p className="mt-1 text-xs text-secondary-500">Use lowercase with underscores (e.g., host_manager)</p>
|
<p className="mt-1 text-xs text-secondary-500 dark:text-secondary-400">Use lowercase with underscores (e.g., host_manager)</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<h4 className="text-sm font-medium text-secondary-900 dark:text-white">Permissions</h4>
|
<h4 className="text-sm font-medium text-secondary-900 dark:text-white">Permissions</h4>
|
||||||
{[
|
{[
|
||||||
{ key: 'canViewDashboard', label: 'View Dashboard' },
|
{ key: 'can_view_dashboard', label: 'View Dashboard' },
|
||||||
{ key: 'canViewHosts', label: 'View Hosts' },
|
{ key: 'can_view_hosts', label: 'View Hosts' },
|
||||||
{ key: 'canManageHosts', label: 'Manage Hosts' },
|
{ key: 'can_manage_hosts', label: 'Manage Hosts' },
|
||||||
{ key: 'canViewPackages', label: 'View Packages' },
|
{ key: 'can_view_packages', label: 'View Packages' },
|
||||||
{ key: 'canManagePackages', label: 'Manage Packages' },
|
{ key: 'can_manage_packages', label: 'Manage Packages' },
|
||||||
{ key: 'canViewUsers', label: 'View Users' },
|
{ key: 'can_view_users', label: 'View Users' },
|
||||||
{ key: 'canManageUsers', label: 'Manage Users' },
|
{ key: 'can_manage_users', label: 'Manage Users' },
|
||||||
{ key: 'canViewReports', label: 'View Reports' },
|
{ key: 'can_view_reports', label: 'View Reports' },
|
||||||
{ key: 'canExportData', label: 'Export Data' },
|
{ key: 'can_export_data', label: 'Export Data' },
|
||||||
{ key: 'canManageSettings', label: 'Manage Settings' }
|
{ key: 'can_manage_settings', label: 'Manage Settings' }
|
||||||
].map((permission) => (
|
].map((permission) => (
|
||||||
<div key={permission.key} className="flex items-center">
|
<div key={permission.key} className="flex items-center">
|
||||||
<input
|
<input
|
||||||
@@ -351,7 +356,7 @@ const AddRoleModal = ({ isOpen, onClose, onSuccess }) => {
|
|||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 rounded"
|
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 rounded"
|
||||||
/>
|
/>
|
||||||
<label className="ml-2 block text-sm text-secondary-700">
|
<label className="ml-2 block text-sm text-secondary-700 dark:text-secondary-200">
|
||||||
{permission.label}
|
{permission.label}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -359,8 +364,8 @@ const AddRoleModal = ({ isOpen, onClose, onSuccess }) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="bg-danger-50 border border-danger-200 rounded-md p-3">
|
<div className="bg-danger-50 dark:bg-danger-900 border border-danger-200 dark:border-danger-700 rounded-md p-3">
|
||||||
<p className="text-sm text-danger-700">{error}</p>
|
<p className="text-sm text-danger-700 dark:text-danger-300">{error}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -368,7 +373,7 @@ const AddRoleModal = ({ isOpen, onClose, onSuccess }) => {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="px-4 py-2 text-sm font-medium text-secondary-700 bg-white border border-secondary-300 rounded-md hover:bg-secondary-50"
|
className="px-4 py-2 text-sm font-medium text-secondary-700 dark:text-secondary-200 bg-white dark:bg-secondary-700 border border-secondary-300 dark:border-secondary-600 rounded-md hover:bg-secondary-50 dark:hover:bg-secondary-600"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import { useAuth } from '../contexts/AuthContext'
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
import { useTheme } from '../contexts/ThemeContext'
|
import { useTheme } from '../contexts/ThemeContext'
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import {
|
import {
|
||||||
User,
|
User,
|
||||||
Mail,
|
Mail,
|
||||||
@@ -13,8 +14,15 @@ import {
|
|||||||
AlertCircle,
|
AlertCircle,
|
||||||
Sun,
|
Sun,
|
||||||
Moon,
|
Moon,
|
||||||
Settings
|
Settings,
|
||||||
|
Smartphone,
|
||||||
|
QrCode,
|
||||||
|
Copy,
|
||||||
|
Download,
|
||||||
|
Trash2,
|
||||||
|
RefreshCw
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
import { tfaAPI } from '../utils/api'
|
||||||
|
|
||||||
const Profile = () => {
|
const Profile = () => {
|
||||||
const { user, updateProfile, changePassword } = useAuth()
|
const { user, updateProfile, changePassword } = useAuth()
|
||||||
@@ -25,7 +33,9 @@ const Profile = () => {
|
|||||||
|
|
||||||
const [profileData, setProfileData] = useState({
|
const [profileData, setProfileData] = useState({
|
||||||
username: user?.username || '',
|
username: user?.username || '',
|
||||||
email: user?.email || ''
|
email: user?.email || '',
|
||||||
|
first_name: user?.first_name || '',
|
||||||
|
last_name: user?.last_name || ''
|
||||||
})
|
})
|
||||||
|
|
||||||
const [passwordData, setPasswordData] = useState({
|
const [passwordData, setPasswordData] = useState({
|
||||||
@@ -111,6 +121,7 @@ const Profile = () => {
|
|||||||
const tabs = [
|
const tabs = [
|
||||||
{ id: 'profile', name: 'Profile Information', icon: User },
|
{ id: 'profile', name: 'Profile Information', icon: User },
|
||||||
{ id: 'password', name: 'Change Password', icon: Key },
|
{ id: 'password', name: 'Change Password', icon: Key },
|
||||||
|
{ id: 'tfa', name: 'Multi-Factor Authentication', icon: Smartphone },
|
||||||
{ id: 'preferences', name: 'Preferences', icon: Settings }
|
{ id: 'preferences', name: 'Preferences', icon: Settings }
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -132,7 +143,11 @@ const Profile = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">{user?.username}</h3>
|
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
|
||||||
|
{user?.first_name && user?.last_name
|
||||||
|
? `${user.first_name} ${user.last_name}`
|
||||||
|
: user?.first_name || user?.username}
|
||||||
|
</h3>
|
||||||
<p className="text-sm text-secondary-600 dark:text-secondary-300">{user?.email}</p>
|
<p className="text-sm text-secondary-600 dark:text-secondary-300">{user?.email}</p>
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||||
@@ -242,6 +257,38 @@ const Profile = () => {
|
|||||||
<Mail className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-secondary-400 dark:text-secondary-500" />
|
<Mail className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-secondary-400 dark:text-secondary-500" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="first_name" className="block text-sm font-medium text-secondary-700 dark:text-secondary-200">
|
||||||
|
First Name
|
||||||
|
</label>
|
||||||
|
<div className="mt-1">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="first_name"
|
||||||
|
id="first_name"
|
||||||
|
value={profileData.first_name}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className="block w-full border-secondary-300 dark:border-secondary-600 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="last_name" className="block text-sm font-medium text-secondary-700 dark:text-secondary-200">
|
||||||
|
Last Name
|
||||||
|
</label>
|
||||||
|
<div className="mt-1">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="last_name"
|
||||||
|
id="last_name"
|
||||||
|
value={profileData.last_name}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className="block w-full border-secondary-300 dark:border-secondary-600 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -357,6 +404,11 @@ const Profile = () => {
|
|||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Multi-Factor Authentication Tab */}
|
||||||
|
{activeTab === 'tfa' && (
|
||||||
|
<TfaTab />
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Preferences Tab */}
|
{/* Preferences Tab */}
|
||||||
{activeTab === 'preferences' && (
|
{activeTab === 'preferences' && (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -411,4 +463,435 @@ const Profile = () => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TFA Tab Component
|
||||||
|
const TfaTab = () => {
|
||||||
|
const [setupStep, setSetupStep] = useState('status') // 'status', 'setup', 'verify', 'backup-codes'
|
||||||
|
const [verificationToken, setVerificationToken] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [backupCodes, setBackupCodes] = useState([])
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
const [message, setMessage] = useState({ type: '', text: '' })
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
// Fetch TFA status
|
||||||
|
const { data: tfaStatus, isLoading: statusLoading } = useQuery({
|
||||||
|
queryKey: ['tfaStatus'],
|
||||||
|
queryFn: () => tfaAPI.status().then(res => res.data),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Setup TFA mutation
|
||||||
|
const setupMutation = useMutation({
|
||||||
|
mutationFn: () => tfaAPI.setup().then(res => res.data),
|
||||||
|
onSuccess: (data) => {
|
||||||
|
setSetupStep('setup')
|
||||||
|
setMessage({ type: 'info', text: 'Scan the QR code with your authenticator app and enter the verification code below.' })
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
setMessage({ type: 'error', text: error.response?.data?.error || 'Failed to setup TFA' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Verify setup mutation
|
||||||
|
const verifyMutation = useMutation({
|
||||||
|
mutationFn: (data) => tfaAPI.verifySetup(data).then(res => res.data),
|
||||||
|
onSuccess: (data) => {
|
||||||
|
setBackupCodes(data.backupCodes)
|
||||||
|
setSetupStep('backup-codes')
|
||||||
|
setMessage({ type: 'success', text: 'Two-factor authentication has been enabled successfully!' })
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
setMessage({ type: 'error', text: error.response?.data?.error || 'Failed to verify TFA setup' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Disable TFA mutation
|
||||||
|
const disableMutation = useMutation({
|
||||||
|
mutationFn: (data) => tfaAPI.disable(data).then(res => res.data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries(['tfaStatus'])
|
||||||
|
setSetupStep('status')
|
||||||
|
setMessage({ type: 'success', text: 'Two-factor authentication has been disabled successfully!' })
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
setMessage({ type: 'error', text: error.response?.data?.error || 'Failed to disable TFA' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Regenerate backup codes mutation
|
||||||
|
const regenerateBackupCodesMutation = useMutation({
|
||||||
|
mutationFn: () => tfaAPI.regenerateBackupCodes().then(res => res.data),
|
||||||
|
onSuccess: (data) => {
|
||||||
|
setBackupCodes(data.backupCodes)
|
||||||
|
setMessage({ type: 'success', text: 'Backup codes have been regenerated successfully!' })
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
setMessage({ type: 'error', text: error.response?.data?.error || 'Failed to regenerate backup codes' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSetup = () => {
|
||||||
|
setupMutation.mutate()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleVerify = (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (verificationToken.length !== 6) {
|
||||||
|
setMessage({ type: 'error', text: 'Please enter a 6-digit verification code' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
verifyMutation.mutate({ token: verificationToken })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDisable = (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!password) {
|
||||||
|
setMessage({ type: 'error', text: 'Please enter your password to disable TFA' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
disableMutation.mutate({ password })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRegenerateBackupCodes = () => {
|
||||||
|
regenerateBackupCodesMutation.mutate()
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyToClipboard = async (text) => {
|
||||||
|
try {
|
||||||
|
// Try modern clipboard API first
|
||||||
|
if (navigator.clipboard && window.isSecureContext) {
|
||||||
|
await navigator.clipboard.writeText(text)
|
||||||
|
setMessage({ type: 'success', text: 'Copied to clipboard!' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback for older browsers or non-secure contexts
|
||||||
|
const textArea = document.createElement('textarea')
|
||||||
|
textArea.value = text
|
||||||
|
textArea.style.position = 'fixed'
|
||||||
|
textArea.style.left = '-999999px'
|
||||||
|
textArea.style.top = '-999999px'
|
||||||
|
document.body.appendChild(textArea)
|
||||||
|
textArea.focus()
|
||||||
|
textArea.select()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const successful = document.execCommand('copy')
|
||||||
|
if (successful) {
|
||||||
|
setMessage({ type: 'success', text: 'Copied to clipboard!' })
|
||||||
|
} else {
|
||||||
|
throw new Error('Copy command failed')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// If all else fails, show the text in a prompt
|
||||||
|
prompt('Copy this text:', text)
|
||||||
|
setMessage({ type: 'info', text: 'Text shown in prompt for manual copying' })
|
||||||
|
} finally {
|
||||||
|
document.body.removeChild(textArea)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to copy to clipboard:', err)
|
||||||
|
// Show the text in a prompt as last resort
|
||||||
|
prompt('Copy this text:', text)
|
||||||
|
setMessage({ type: 'info', text: 'Text shown in prompt for manual copying' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const downloadBackupCodes = () => {
|
||||||
|
const content = `PatchMon Backup Codes\n\n${backupCodes.map((code, index) => `${index + 1}. ${code}`).join('\n')}\n\nKeep these codes safe! Each code can only be used once.`
|
||||||
|
const blob = new Blob([content], { type: 'text/plain' })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = 'patchmon-backup-codes.txt'
|
||||||
|
document.body.appendChild(a)
|
||||||
|
a.click()
|
||||||
|
document.body.removeChild(a)
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (statusLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">Multi-Factor Authentication</h3>
|
||||||
|
<p className="text-sm text-secondary-600 dark:text-secondary-300 mb-6">
|
||||||
|
Add an extra layer of security to your account by enabling two-factor authentication.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status Message */}
|
||||||
|
{message.text && (
|
||||||
|
<div className={`rounded-md p-4 ${
|
||||||
|
message.type === 'success'
|
||||||
|
? 'bg-green-50 dark:bg-green-900 border border-green-200 dark:border-green-700'
|
||||||
|
: message.type === 'error'
|
||||||
|
? 'bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700'
|
||||||
|
: 'bg-blue-50 dark:bg-blue-900 border border-blue-200 dark:border-blue-700'
|
||||||
|
}`}>
|
||||||
|
<div className="flex">
|
||||||
|
{message.type === 'success' ? (
|
||||||
|
<CheckCircle className="h-5 w-5 text-green-400 dark:text-green-300" />
|
||||||
|
) : message.type === 'error' ? (
|
||||||
|
<AlertCircle className="h-5 w-5 text-red-400 dark:text-red-300" />
|
||||||
|
) : (
|
||||||
|
<AlertCircle className="h-5 w-5 text-blue-400 dark:text-blue-300" />
|
||||||
|
)}
|
||||||
|
<div className="ml-3">
|
||||||
|
<p className={`text-sm font-medium ${
|
||||||
|
message.type === 'success' ? 'text-green-800 dark:text-green-200' :
|
||||||
|
message.type === 'error' ? 'text-red-800 dark:text-red-200' :
|
||||||
|
'text-blue-800 dark:text-blue-200'
|
||||||
|
}`}>
|
||||||
|
{message.text}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* TFA Status */}
|
||||||
|
{setupStep === 'status' && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="bg-white dark:bg-secondary-800 border border-secondary-200 dark:border-secondary-600 rounded-lg p-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className={`p-2 rounded-full ${tfaStatus?.enabled ? 'bg-green-100 dark:bg-green-900' : 'bg-secondary-100 dark:bg-secondary-700'}`}>
|
||||||
|
<Smartphone className={`h-6 w-6 ${tfaStatus?.enabled ? 'text-green-600 dark:text-green-400' : 'text-secondary-600 dark:text-secondary-400'}`} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="text-lg font-medium text-secondary-900 dark:text-white">
|
||||||
|
{tfaStatus?.enabled ? 'Two-Factor Authentication Enabled' : 'Two-Factor Authentication Disabled'}
|
||||||
|
</h4>
|
||||||
|
<p className="text-sm text-secondary-600 dark:text-secondary-300">
|
||||||
|
{tfaStatus?.enabled
|
||||||
|
? 'Your account is protected with two-factor authentication.'
|
||||||
|
: 'Add an extra layer of security to your account.'
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{tfaStatus?.enabled ? (
|
||||||
|
<button
|
||||||
|
onClick={() => setSetupStep('disable')}
|
||||||
|
className="btn-outline text-danger-600 border-danger-300 hover:bg-danger-50"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 mr-2" />
|
||||||
|
Disable TFA
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={handleSetup}
|
||||||
|
disabled={setupMutation.isPending}
|
||||||
|
className="btn-primary"
|
||||||
|
>
|
||||||
|
<Smartphone className="h-4 w-4 mr-2" />
|
||||||
|
{setupMutation.isPending ? 'Setting up...' : 'Enable TFA'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tfaStatus?.enabled && (
|
||||||
|
<div className="bg-white dark:bg-secondary-800 border border-secondary-200 dark:border-secondary-600 rounded-lg p-6">
|
||||||
|
<h4 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">Backup Codes</h4>
|
||||||
|
<p className="text-sm text-secondary-600 dark:text-secondary-300 mb-4">
|
||||||
|
Use these backup codes to access your account if you lose your authenticator device.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={handleRegenerateBackupCodes}
|
||||||
|
disabled={regenerateBackupCodesMutation.isPending}
|
||||||
|
className="btn-outline"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`h-4 w-4 mr-2 ${regenerateBackupCodesMutation.isPending ? 'animate-spin' : ''}`} />
|
||||||
|
{regenerateBackupCodesMutation.isPending ? 'Regenerating...' : 'Regenerate Codes'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* TFA Setup */}
|
||||||
|
{setupStep === 'setup' && setupMutation.data && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="bg-white dark:bg-secondary-800 border border-secondary-200 dark:border-secondary-600 rounded-lg p-6">
|
||||||
|
<h4 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">Setup Two-Factor Authentication</h4>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="text-center">
|
||||||
|
<img
|
||||||
|
src={setupMutation.data.qrCode}
|
||||||
|
alt="QR Code"
|
||||||
|
className="mx-auto h-48 w-48 border border-secondary-200 dark:border-secondary-600 rounded-lg"
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-secondary-600 dark:text-secondary-300 mt-2">
|
||||||
|
Scan this QR code with your authenticator app
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-secondary-50 dark:bg-secondary-700 p-4 rounded-lg">
|
||||||
|
<p className="text-sm font-medium text-secondary-900 dark:text-white mb-2">Manual Entry Key:</p>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<code className="flex-1 bg-white dark:bg-secondary-800 px-3 py-2 rounded border text-sm font-mono">
|
||||||
|
{setupMutation.data.manualEntryKey}
|
||||||
|
</code>
|
||||||
|
<button
|
||||||
|
onClick={() => copyToClipboard(setupMutation.data.manualEntryKey)}
|
||||||
|
className="p-2 text-secondary-400 hover:text-secondary-600 dark:hover:text-secondary-300"
|
||||||
|
title="Copy to clipboard"
|
||||||
|
>
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
<button
|
||||||
|
onClick={() => setSetupStep('verify')}
|
||||||
|
className="btn-primary"
|
||||||
|
>
|
||||||
|
Continue to Verification
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* TFA Verification */}
|
||||||
|
{setupStep === 'verify' && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="bg-white dark:bg-secondary-800 border border-secondary-200 dark:border-secondary-600 rounded-lg p-6">
|
||||||
|
<h4 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">Verify Setup</h4>
|
||||||
|
<p className="text-sm text-secondary-600 dark:text-secondary-300 mb-4">
|
||||||
|
Enter the 6-digit code from your authenticator app to complete the setup.
|
||||||
|
</p>
|
||||||
|
<form onSubmit={handleVerify} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
|
||||||
|
Verification Code
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={verificationToken}
|
||||||
|
onChange={(e) => setVerificationToken(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||||
|
placeholder="000000"
|
||||||
|
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white text-center text-lg font-mono tracking-widest"
|
||||||
|
maxLength="6"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex space-x-3">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={verifyMutation.isPending || verificationToken.length !== 6}
|
||||||
|
className="btn-primary"
|
||||||
|
>
|
||||||
|
{verifyMutation.isPending ? 'Verifying...' : 'Verify & Enable'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSetupStep('status')}
|
||||||
|
className="btn-outline"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Backup Codes */}
|
||||||
|
{setupStep === 'backup-codes' && backupCodes.length > 0 && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="bg-white dark:bg-secondary-800 border border-secondary-200 dark:border-secondary-600 rounded-lg p-6">
|
||||||
|
<h4 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">Backup Codes</h4>
|
||||||
|
<p className="text-sm text-secondary-600 dark:text-secondary-300 mb-4">
|
||||||
|
Save these backup codes in a safe place. Each code can only be used once.
|
||||||
|
</p>
|
||||||
|
<div className="bg-secondary-50 dark:bg-secondary-700 p-4 rounded-lg mb-4">
|
||||||
|
<div className="grid grid-cols-2 gap-2 font-mono text-sm">
|
||||||
|
{backupCodes.map((code, index) => (
|
||||||
|
<div key={index} className="flex items-center justify-between py-1">
|
||||||
|
<span className="text-secondary-600 dark:text-secondary-400">{index + 1}.</span>
|
||||||
|
<span className="text-secondary-900 dark:text-white">{code}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex space-x-3">
|
||||||
|
<button
|
||||||
|
onClick={downloadBackupCodes}
|
||||||
|
className="btn-outline"
|
||||||
|
>
|
||||||
|
<Download className="h-4 w-4 mr-2" />
|
||||||
|
Download Codes
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setSetupStep('status')
|
||||||
|
queryClient.invalidateQueries(['tfaStatus'])
|
||||||
|
}}
|
||||||
|
className="btn-primary"
|
||||||
|
>
|
||||||
|
Done
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Disable TFA */}
|
||||||
|
{setupStep === 'disable' && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="bg-white dark:bg-secondary-800 border border-secondary-200 dark:border-secondary-600 rounded-lg p-6">
|
||||||
|
<h4 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">Disable Two-Factor Authentication</h4>
|
||||||
|
<p className="text-sm text-secondary-600 dark:text-secondary-300 mb-4">
|
||||||
|
Enter your password to disable two-factor authentication.
|
||||||
|
</p>
|
||||||
|
<form onSubmit={handleDisable} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex space-x-3">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={disableMutation.isPending || !password}
|
||||||
|
className="btn-danger"
|
||||||
|
>
|
||||||
|
{disableMutation.isPending ? 'Disabling...' : 'Disable TFA'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSetupStep('status')}
|
||||||
|
className="btn-outline"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default Profile
|
export default Profile
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState, useMemo } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
@@ -11,7 +11,16 @@ import {
|
|||||||
Lock,
|
Lock,
|
||||||
Unlock,
|
Unlock,
|
||||||
Database,
|
Database,
|
||||||
Eye
|
Eye,
|
||||||
|
Search,
|
||||||
|
Columns,
|
||||||
|
ArrowUpDown,
|
||||||
|
ArrowUp,
|
||||||
|
ArrowDown,
|
||||||
|
X,
|
||||||
|
GripVertical,
|
||||||
|
Check,
|
||||||
|
RefreshCw
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { repositoryAPI } from '../utils/api';
|
import { repositoryAPI } from '../utils/api';
|
||||||
|
|
||||||
@@ -19,9 +28,40 @@ const Repositories = () => {
|
|||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [filterType, setFilterType] = useState('all'); // all, secure, insecure
|
const [filterType, setFilterType] = useState('all'); // all, secure, insecure
|
||||||
const [filterStatus, setFilterStatus] = useState('all'); // all, active, inactive
|
const [filterStatus, setFilterStatus] = useState('all'); // all, active, inactive
|
||||||
|
const [sortField, setSortField] = useState('name');
|
||||||
|
const [sortDirection, setSortDirection] = useState('asc');
|
||||||
|
const [showColumnSettings, setShowColumnSettings] = useState(false);
|
||||||
|
|
||||||
|
// Column configuration
|
||||||
|
const [columnConfig, setColumnConfig] = useState(() => {
|
||||||
|
const defaultConfig = [
|
||||||
|
{ id: 'name', label: 'Repository', visible: true, order: 0 },
|
||||||
|
{ id: 'url', label: 'URL', visible: true, order: 1 },
|
||||||
|
{ id: 'distribution', label: 'Distribution', visible: true, order: 2 },
|
||||||
|
{ id: 'security', label: 'Security', visible: true, order: 3 },
|
||||||
|
{ id: 'status', label: 'Status', visible: true, order: 4 },
|
||||||
|
{ id: 'hostCount', label: 'Hosts', visible: true, order: 5 },
|
||||||
|
{ id: 'actions', label: 'Actions', visible: true, order: 6 }
|
||||||
|
];
|
||||||
|
|
||||||
|
const saved = localStorage.getItem('repositories-column-config');
|
||||||
|
if (saved) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(saved);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to parse saved column config:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return defaultConfig;
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateColumnConfig = (newConfig) => {
|
||||||
|
setColumnConfig(newConfig);
|
||||||
|
localStorage.setItem('repositories-column-config', JSON.stringify(newConfig));
|
||||||
|
};
|
||||||
|
|
||||||
// Fetch repositories
|
// Fetch repositories
|
||||||
const { data: repositories = [], isLoading, error } = useQuery({
|
const { data: repositories = [], isLoading, error, refetch, isFetching } = useQuery({
|
||||||
queryKey: ['repositories'],
|
queryKey: ['repositories'],
|
||||||
queryFn: () => repositoryAPI.list().then(res => res.data)
|
queryFn: () => repositoryAPI.list().then(res => res.data)
|
||||||
});
|
});
|
||||||
@@ -32,23 +72,108 @@ const Repositories = () => {
|
|||||||
queryFn: () => repositoryAPI.getStats().then(res => res.data)
|
queryFn: () => repositoryAPI.getStats().then(res => res.data)
|
||||||
});
|
});
|
||||||
|
|
||||||
// Filter repositories based on search and filters
|
// Get visible columns in order
|
||||||
const filteredRepositories = repositories.filter(repo => {
|
const visibleColumns = columnConfig
|
||||||
|
.filter(col => col.visible)
|
||||||
|
.sort((a, b) => a.order - b.order);
|
||||||
|
|
||||||
|
// Sorting functions
|
||||||
|
const handleSort = (field) => {
|
||||||
|
if (sortField === field) {
|
||||||
|
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
|
||||||
|
} else {
|
||||||
|
setSortField(field);
|
||||||
|
setSortDirection('asc');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSortIcon = (field) => {
|
||||||
|
if (sortField !== field) return <ArrowUpDown className="h-4 w-4" />
|
||||||
|
return sortDirection === 'asc' ? <ArrowUp className="h-4 w-4" /> : <ArrowDown className="h-4 w-4" />
|
||||||
|
};
|
||||||
|
|
||||||
|
// Column management functions
|
||||||
|
const toggleColumnVisibility = (columnId) => {
|
||||||
|
const newConfig = columnConfig.map(col =>
|
||||||
|
col.id === columnId ? { ...col, visible: !col.visible } : col
|
||||||
|
)
|
||||||
|
updateColumnConfig(newConfig)
|
||||||
|
};
|
||||||
|
|
||||||
|
const reorderColumns = (fromIndex, toIndex) => {
|
||||||
|
const newConfig = [...columnConfig]
|
||||||
|
const [movedColumn] = newConfig.splice(fromIndex, 1)
|
||||||
|
newConfig.splice(toIndex, 0, movedColumn)
|
||||||
|
|
||||||
|
// Update order values
|
||||||
|
const updatedConfig = newConfig.map((col, index) => ({ ...col, order: index }))
|
||||||
|
updateColumnConfig(updatedConfig)
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetColumns = () => {
|
||||||
|
const defaultConfig = [
|
||||||
|
{ id: 'name', label: 'Repository', visible: true, order: 0 },
|
||||||
|
{ id: 'url', label: 'URL', visible: true, order: 1 },
|
||||||
|
{ id: 'distribution', label: 'Distribution', visible: true, order: 2 },
|
||||||
|
{ id: 'security', label: 'Security', visible: true, order: 3 },
|
||||||
|
{ id: 'status', label: 'Status', visible: true, order: 4 },
|
||||||
|
{ id: 'hostCount', label: 'Hosts', visible: true, order: 5 },
|
||||||
|
{ id: 'actions', label: 'Actions', visible: true, order: 6 }
|
||||||
|
]
|
||||||
|
updateColumnConfig(defaultConfig)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Filter and sort repositories
|
||||||
|
const filteredAndSortedRepositories = useMemo(() => {
|
||||||
|
if (!repositories) return []
|
||||||
|
|
||||||
|
// Filter repositories
|
||||||
|
const filtered = repositories.filter(repo => {
|
||||||
const matchesSearch = repo.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
const matchesSearch = repo.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
repo.url.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
repo.url.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
repo.distribution.toLowerCase().includes(searchTerm.toLowerCase());
|
repo.distribution.toLowerCase().includes(searchTerm.toLowerCase());
|
||||||
|
|
||||||
|
// Check security based on URL if isSecure property doesn't exist
|
||||||
|
const isSecure = repo.isSecure !== undefined ? repo.isSecure : repo.url.startsWith('https://');
|
||||||
|
|
||||||
const matchesType = filterType === 'all' ||
|
const matchesType = filterType === 'all' ||
|
||||||
(filterType === 'secure' && repo.isSecure) ||
|
(filterType === 'secure' && isSecure) ||
|
||||||
(filterType === 'insecure' && !repo.isSecure);
|
(filterType === 'insecure' && !isSecure);
|
||||||
|
|
||||||
const matchesStatus = filterStatus === 'all' ||
|
const matchesStatus = filterStatus === 'all' ||
|
||||||
(filterStatus === 'active' && repo.isActive) ||
|
(filterStatus === 'active' && repo.is_active === true) ||
|
||||||
(filterStatus === 'inactive' && !repo.isActive);
|
(filterStatus === 'inactive' && repo.is_active === false);
|
||||||
|
|
||||||
return matchesSearch && matchesType && matchesStatus;
|
return matchesSearch && matchesType && matchesStatus;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Sort repositories
|
||||||
|
const sorted = filtered.sort((a, b) => {
|
||||||
|
let aValue = a[sortField];
|
||||||
|
let bValue = b[sortField];
|
||||||
|
|
||||||
|
// Handle special cases
|
||||||
|
if (sortField === 'security') {
|
||||||
|
aValue = a.isSecure ? 'Secure' : 'Insecure';
|
||||||
|
bValue = b.isSecure ? 'Secure' : 'Insecure';
|
||||||
|
} else if (sortField === 'status') {
|
||||||
|
aValue = a.is_active ? 'Active' : 'Inactive';
|
||||||
|
bValue = b.is_active ? 'Active' : 'Inactive';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof aValue === 'string') {
|
||||||
|
aValue = aValue.toLowerCase();
|
||||||
|
bValue = bValue.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (aValue < bValue) return sortDirection === 'asc' ? -1 : 1;
|
||||||
|
if (aValue > bValue) return sortDirection === 'asc' ? 1 : -1;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
return sorted;
|
||||||
|
}, [repositories, searchTerm, filterType, filterStatus, sortField, sortDirection]);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-64">
|
<div className="flex items-center justify-center h-64">
|
||||||
@@ -71,97 +196,101 @@ const Repositories = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="h-[calc(100vh-7rem)] flex flex-col overflow-hidden">
|
||||||
{/* Header */}
|
{/* Page Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-secondary-900 dark:text-white">
|
<h1 className="text-2xl font-semibold text-secondary-900 dark:text-white">Repositories</h1>
|
||||||
Repositories
|
<p className="text-sm text-secondary-600 dark:text-secondary-400 mt-1">
|
||||||
</h1>
|
Manage and monitor your package repositories
|
||||||
<p className="text-secondary-500 dark:text-secondary-300 mt-1">
|
|
||||||
Manage and monitor package repositories across your infrastructure
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => refetch()}
|
||||||
|
disabled={isFetching}
|
||||||
|
className="btn-outline flex items-center gap-2"
|
||||||
|
title="Refresh repositories data"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`h-4 w-4 ${isFetching ? 'animate-spin' : ''}`} />
|
||||||
|
{isFetching ? 'Refreshing...' : 'Refresh'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Statistics Cards */}
|
{/* Summary Stats */}
|
||||||
{stats && (
|
<div className="grid grid-cols-1 sm:grid-cols-4 gap-4 mb-6 flex-shrink-0">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
<div className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200">
|
||||||
<div className="bg-white dark:bg-secondary-800 rounded-lg shadow hover:shadow-md transition-shadow p-6">
|
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<div className="flex-shrink-0">
|
<Database className="h-5 w-5 text-primary-600 mr-2" />
|
||||||
<Database className="h-8 w-8 text-blue-600" />
|
<div>
|
||||||
</div>
|
<p className="text-sm text-secondary-500 dark:text-white">Total Repositories</p>
|
||||||
<div className="ml-4">
|
<p className="text-xl font-semibold text-secondary-900 dark:text-white">{stats?.totalRepositories || 0}</p>
|
||||||
<p className="text-sm font-medium text-secondary-500 dark:text-secondary-300">Total Repositories</p>
|
|
||||||
<p className="text-2xl font-bold text-secondary-900 dark:text-white">{stats.totalRepositories}</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white dark:bg-secondary-800 rounded-lg shadow hover:shadow-md transition-shadow p-6">
|
<div className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<div className="flex-shrink-0">
|
<Server className="h-5 w-5 text-success-600 mr-2" />
|
||||||
<Server className="h-8 w-8 text-green-600" />
|
<div>
|
||||||
</div>
|
<p className="text-sm text-secondary-500 dark:text-white">Active Repositories</p>
|
||||||
<div className="ml-4">
|
<p className="text-xl font-semibold text-secondary-900 dark:text-white">{stats?.activeRepositories || 0}</p>
|
||||||
<p className="text-sm font-medium text-secondary-500 dark:text-secondary-300">Active Repositories</p>
|
|
||||||
<p className="text-2xl font-bold text-secondary-900 dark:text-white">{stats.activeRepositories}</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white dark:bg-secondary-800 rounded-lg shadow hover:shadow-md transition-shadow p-6">
|
<div className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<div className="flex-shrink-0">
|
<Shield className="h-5 w-5 text-warning-600 mr-2" />
|
||||||
<Shield className="h-8 w-8 text-blue-600" />
|
<div>
|
||||||
</div>
|
<p className="text-sm text-secondary-500 dark:text-white">Secure (HTTPS)</p>
|
||||||
<div className="ml-4">
|
<p className="text-xl font-semibold text-secondary-900 dark:text-white">{stats?.secureRepositories || 0}</p>
|
||||||
<p className="text-sm font-medium text-secondary-500 dark:text-secondary-300">Secure (HTTPS)</p>
|
|
||||||
<p className="text-2xl font-bold text-secondary-900 dark:text-white">{stats.secureRepositories}</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white dark:bg-secondary-800 rounded-lg shadow hover:shadow-md transition-shadow p-6">
|
<div className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<div className="flex-shrink-0">
|
<ShieldCheck className="h-5 w-5 text-danger-600 mr-2" />
|
||||||
<div className="relative">
|
<div>
|
||||||
<ShieldCheck className="h-8 w-8 text-green-600" />
|
<p className="text-sm text-secondary-500 dark:text-white">Security Score</p>
|
||||||
<span className="absolute -top-1 -right-1 bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 text-xs font-medium px-1.5 py-0.5 rounded-full">
|
<p className="text-xl font-semibold text-secondary-900 dark:text-white">{stats?.securityPercentage || 0}%</p>
|
||||||
{stats.securityPercentage}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="ml-4">
|
|
||||||
<p className="text-sm font-medium text-secondary-500 dark:text-secondary-300">Security Score</p>
|
|
||||||
<p className="text-2xl font-bold text-secondary-900 dark:text-white">{stats.securityPercentage}%</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Search and Filters */}
|
{/* Repositories List */}
|
||||||
<div className="bg-white dark:bg-secondary-800 rounded-lg shadow p-6">
|
<div className="card flex-1 flex flex-col overflow-hidden min-h-0">
|
||||||
|
<div className="px-4 py-4 sm:p-4 flex-1 flex flex-col overflow-hidden min-h-0">
|
||||||
|
<div className="flex items-center justify-end mb-4">
|
||||||
|
{/* Empty selection controls area to match packages page spacing */}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table Controls */}
|
||||||
|
<div className="mb-4 space-y-4">
|
||||||
<div className="flex flex-col sm:flex-row gap-4">
|
<div className="flex flex-col sm:flex-row gap-4">
|
||||||
{/* Search */}
|
{/* Search */}
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-secondary-400 dark:text-secondary-500" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search repositories..."
|
placeholder="Search repositories..."
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-secondary-700 dark:text-white"
|
className="w-full pl-10 pr-4 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white placeholder-secondary-500 dark:placeholder-secondary-400"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Security Filter */}
|
{/* Security Filter */}
|
||||||
<div>
|
<div className="sm:w-48">
|
||||||
<select
|
<select
|
||||||
value={filterType}
|
value={filterType}
|
||||||
onChange={(e) => setFilterType(e.target.value)}
|
onChange={(e) => setFilterType(e.target.value)}
|
||||||
className="px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-secondary-700 dark:text-white"
|
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white"
|
||||||
>
|
>
|
||||||
<option value="all">All Security Types</option>
|
<option value="all">All Security Types</option>
|
||||||
<option value="secure">HTTPS Only</option>
|
<option value="secure">HTTPS Only</option>
|
||||||
@@ -170,103 +299,248 @@ const Repositories = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Status Filter */}
|
{/* Status Filter */}
|
||||||
<div>
|
<div className="sm:w-48">
|
||||||
<select
|
<select
|
||||||
value={filterStatus}
|
value={filterStatus}
|
||||||
onChange={(e) => setFilterStatus(e.target.value)}
|
onChange={(e) => setFilterStatus(e.target.value)}
|
||||||
className="px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-secondary-700 dark:text-white"
|
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white"
|
||||||
>
|
>
|
||||||
<option value="all">All Statuses</option>
|
<option value="all">All Statuses</option>
|
||||||
<option value="active">Active Only</option>
|
<option value="active">Active Only</option>
|
||||||
<option value="inactive">Inactive Only</option>
|
<option value="inactive">Inactive Only</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Columns Button */}
|
||||||
|
<div className="flex items-center">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowColumnSettings(true)}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 text-sm text-secondary-700 dark:text-secondary-300 bg-white dark:bg-secondary-700 border border-secondary-300 dark:border-secondary-600 rounded-md hover:bg-secondary-50 dark:hover:bg-secondary-600 transition-colors"
|
||||||
|
>
|
||||||
|
<Columns className="h-4 w-4" />
|
||||||
|
Columns
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Repositories List */}
|
<div className="flex-1 overflow-hidden">
|
||||||
<div className="bg-white dark:bg-secondary-800 rounded-lg shadow">
|
{filteredAndSortedRepositories.length === 0 ? (
|
||||||
<div className="px-6 py-4 border-b border-secondary-200 dark:border-secondary-700">
|
<div className="text-center py-8">
|
||||||
<h2 className="text-lg font-semibold text-secondary-900 dark:text-white">
|
<Database className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
|
||||||
Repositories ({filteredRepositories.length})
|
<p className="text-secondary-500 dark:text-secondary-300">
|
||||||
</h2>
|
{repositories?.length === 0 ? 'No repositories found' : 'No repositories match your filters'}
|
||||||
</div>
|
|
||||||
|
|
||||||
{filteredRepositories.length === 0 ? (
|
|
||||||
<div className="px-6 py-12 text-center">
|
|
||||||
<Database className="mx-auto h-12 w-12 text-secondary-400" />
|
|
||||||
<h3 className="mt-2 text-sm font-medium text-secondary-900 dark:text-white">No repositories found</h3>
|
|
||||||
<p className="mt-1 text-sm text-secondary-500 dark:text-secondary-300">
|
|
||||||
{searchTerm || filterType !== 'all' || filterStatus !== 'all'
|
|
||||||
? 'Try adjusting your search or filters.'
|
|
||||||
: 'No repositories have been reported by your hosts yet.'}
|
|
||||||
</p>
|
</p>
|
||||||
|
{repositories?.length === 0 && (
|
||||||
|
<p className="text-sm text-secondary-400 dark:text-secondary-400 mt-2">
|
||||||
|
No repositories have been reported by your hosts yet
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="divide-y divide-secondary-200 dark:divide-secondary-700">
|
<div className="h-full overflow-auto">
|
||||||
{filteredRepositories.map((repo) => (
|
<table className="min-w-full divide-y divide-secondary-200 dark:divide-secondary-600">
|
||||||
<div key={repo.id} className="px-6 py-4 hover:bg-secondary-50 dark:hover:bg-secondary-700/50">
|
<thead className="bg-secondary-50 dark:bg-secondary-700 sticky top-0 z-10">
|
||||||
<div className="flex items-center justify-between">
|
<tr>
|
||||||
<div className="flex-1 min-w-0">
|
{visibleColumns.map((column) => (
|
||||||
<div className="flex items-center gap-3">
|
<th key={column.id} className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||||
<div className="flex items-center gap-2">
|
<button
|
||||||
{repo.isSecure ? (
|
onClick={() => handleSort(column.id)}
|
||||||
<Lock className="h-4 w-4 text-green-600" />
|
className="flex items-center gap-1 hover:text-secondary-700 dark:hover:text-secondary-200 transition-colors"
|
||||||
) : (
|
>
|
||||||
<Unlock className="h-4 w-4 text-orange-600" />
|
{column.label}
|
||||||
|
{getSortIcon(column.id)}
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-600">
|
||||||
|
{filteredAndSortedRepositories.map((repo) => (
|
||||||
|
<tr key={repo.id} className="hover:bg-secondary-50 dark:hover:bg-secondary-700 transition-colors">
|
||||||
|
{visibleColumns.map((column) => (
|
||||||
|
<td key={column.id} className="px-4 py-2 whitespace-nowrap text-center">
|
||||||
|
{renderCellContent(column, repo)}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Column Settings Modal */}
|
||||||
|
{showColumnSettings && (
|
||||||
|
<ColumnSettingsModal
|
||||||
|
columnConfig={columnConfig}
|
||||||
|
onClose={() => setShowColumnSettings(false)}
|
||||||
|
onToggleVisibility={toggleColumnVisibility}
|
||||||
|
onReorder={reorderColumns}
|
||||||
|
onReset={resetColumns}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Render cell content based on column type
|
||||||
|
function renderCellContent(column, repo) {
|
||||||
|
switch (column.id) {
|
||||||
|
case 'name':
|
||||||
|
return (
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Database className="h-5 w-5 text-secondary-400 mr-3" />
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-secondary-900 dark:text-white">
|
||||||
{repo.name}
|
{repo.name}
|
||||||
</h3>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
case 'url':
|
||||||
|
return (
|
||||||
|
<div className="text-sm text-secondary-900 dark:text-white max-w-xs truncate" title={repo.url}>
|
||||||
|
{repo.url}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
case 'distribution':
|
||||||
|
return (
|
||||||
|
<div className="text-sm text-secondary-900 dark:text-white">
|
||||||
|
{repo.distribution}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
case 'security':
|
||||||
|
const isSecure = repo.isSecure !== undefined ? repo.isSecure : repo.url.startsWith('https://');
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
{isSecure ? (
|
||||||
|
<div className="flex items-center gap-1 text-green-600">
|
||||||
|
<Lock className="h-4 w-4" />
|
||||||
|
<span className="text-sm">Secure</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-1 text-orange-600">
|
||||||
|
<Unlock className="h-4 w-4" />
|
||||||
|
<span className="text-sm">Insecure</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
case 'status':
|
||||||
|
return (
|
||||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||||
repo.isActive
|
repo.is_active
|
||||||
? 'bg-green-100 dark:bg-green-900/20 text-green-800 dark:text-green-300'
|
? 'bg-green-100 dark:bg-green-900/20 text-green-800 dark:text-green-300'
|
||||||
: 'bg-red-100 dark:bg-red-900/20 text-red-800 dark:text-red-300'
|
: 'bg-red-100 dark:bg-red-900/20 text-red-800 dark:text-red-300'
|
||||||
}`}>
|
}`}>
|
||||||
{repo.isActive ? 'Active' : 'Inactive'}
|
{repo.is_active ? 'Active' : 'Inactive'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
)
|
||||||
</div>
|
case 'hostCount':
|
||||||
|
return (
|
||||||
<div className="mt-2 space-y-1">
|
<div className="flex items-center justify-center gap-1 text-sm text-secondary-900 dark:text-white">
|
||||||
<p className="text-sm text-secondary-600 dark:text-secondary-300">
|
|
||||||
<Globe className="inline h-4 w-4 mr-1" />
|
|
||||||
{repo.url}
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center gap-4 text-sm text-secondary-500 dark:text-secondary-400">
|
|
||||||
<span>Distribution: <span className="font-medium">{repo.distribution}</span></span>
|
|
||||||
<span>Type: <span className="font-medium">{repo.repoType}</span></span>
|
|
||||||
<span>Components: <span className="font-medium">{repo.components}</span></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
{/* Host Count */}
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="flex items-center gap-1 text-sm text-secondary-500 dark:text-secondary-400">
|
|
||||||
<Users className="h-4 w-4" />
|
<Users className="h-4 w-4" />
|
||||||
<span>{repo.hostCount} hosts</span>
|
<span>{repo.host_count}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)
|
||||||
|
case 'actions':
|
||||||
{/* View Details */}
|
return (
|
||||||
<Link
|
<Link
|
||||||
to={`/repositories/${repo.id}`}
|
to={`/repositories/${repo.id}`}
|
||||||
className="btn-outline text-sm flex items-center gap-1"
|
className="text-primary-600 hover:text-primary-900 flex items-center gap-1"
|
||||||
>
|
>
|
||||||
<Eye className="h-4 w-4" />
|
|
||||||
View
|
View
|
||||||
|
<Eye className="h-3 w-3" />
|
||||||
</Link>
|
</Link>
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Column Settings Modal Component
|
||||||
|
const ColumnSettingsModal = ({ columnConfig, onClose, onToggleVisibility, onReorder, onReset }) => {
|
||||||
|
const [draggedIndex, setDraggedIndex] = useState(null)
|
||||||
|
|
||||||
|
const handleDragStart = (e, index) => {
|
||||||
|
setDraggedIndex(index)
|
||||||
|
e.dataTransfer.effectAllowed = 'move'
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDragOver = (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.dataTransfer.dropEffect = 'move'
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDrop = (e, dropIndex) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (draggedIndex !== null && draggedIndex !== dropIndex) {
|
||||||
|
onReorder(draggedIndex, dropIndex)
|
||||||
|
}
|
||||||
|
setDraggedIndex(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 w-full max-w-md">
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">Column Settings</h3>
|
||||||
|
<button onClick={onClose} className="text-secondary-400 hover:text-secondary-600 dark:text-secondary-500 dark:hover:text-secondary-300">
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{columnConfig.map((column, index) => (
|
||||||
|
<div
|
||||||
|
key={column.id}
|
||||||
|
draggable
|
||||||
|
onDragStart={(e) => handleDragStart(e, index)}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDrop={(e) => handleDrop(e, index)}
|
||||||
|
className="flex items-center justify-between p-3 bg-secondary-50 dark:bg-secondary-700 rounded-lg cursor-move hover:bg-secondary-100 dark:hover:bg-secondary-600 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<GripVertical className="h-4 w-4 text-secondary-400" />
|
||||||
|
<span className="text-sm font-medium text-secondary-900 dark:text-white">
|
||||||
|
{column.label}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => onToggleVisibility(column.id)}
|
||||||
|
className={`w-4 h-4 rounded border-2 flex items-center justify-center ${
|
||||||
|
column.visible
|
||||||
|
? 'bg-primary-600 border-primary-600'
|
||||||
|
: 'bg-white dark:bg-secondary-800 border-secondary-300 dark:border-secondary-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{column.visible && <Check className="h-3 w-3 text-white" />}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
<div className="flex justify-between mt-6">
|
||||||
|
<button
|
||||||
|
onClick={onReset}
|
||||||
|
className="px-4 py-2 text-sm text-secondary-600 dark:text-secondary-400 hover:text-secondary-800 dark:hover:text-secondary-200"
|
||||||
|
>
|
||||||
|
Reset to Default
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 bg-primary-600 text-white text-sm rounded-md hover:bg-primary-700 transition-colors"
|
||||||
|
>
|
||||||
|
Done
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
</div>
|
||||||
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Repositories;
|
export default Repositories;
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ const RepositoryDetail = () => {
|
|||||||
setFormData({
|
setFormData({
|
||||||
name: repository.name,
|
name: repository.name,
|
||||||
description: repository.description || '',
|
description: repository.description || '',
|
||||||
isActive: repository.isActive,
|
is_active: repository.is_active,
|
||||||
priority: repository.priority || ''
|
priority: repository.priority || ''
|
||||||
});
|
});
|
||||||
setEditMode(true);
|
setEditMode(true);
|
||||||
@@ -139,11 +139,11 @@ const RepositoryDetail = () => {
|
|||||||
{repository.name}
|
{repository.name}
|
||||||
</h1>
|
</h1>
|
||||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||||
repository.isActive
|
repository.is_active
|
||||||
? 'bg-green-100 dark:bg-green-900/20 text-green-800 dark:text-green-300'
|
? 'bg-green-100 dark:bg-green-900/20 text-green-800 dark:text-green-300'
|
||||||
: 'bg-red-100 dark:bg-red-900/20 text-red-800 dark:text-red-300'
|
: 'bg-red-100 dark:bg-red-900/20 text-red-800 dark:text-red-300'
|
||||||
}`}>
|
}`}>
|
||||||
{repository.isActive ? 'Active' : 'Inactive'}
|
{repository.is_active ? 'Active' : 'Inactive'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-secondary-500 dark:text-secondary-300 mt-1">
|
<p className="text-secondary-500 dark:text-secondary-300 mt-1">
|
||||||
@@ -228,12 +228,12 @@ const RepositoryDetail = () => {
|
|||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
id="isActive"
|
id="is_active"
|
||||||
checked={formData.isActive}
|
checked={formData.is_active}
|
||||||
onChange={(e) => setFormData({ ...formData, isActive: e.target.checked })}
|
onChange={(e) => setFormData({ ...formData, is_active: e.target.checked })}
|
||||||
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 rounded"
|
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 rounded"
|
||||||
/>
|
/>
|
||||||
<label htmlFor="isActive" className="ml-2 block text-sm text-secondary-900 dark:text-white">
|
<label htmlFor="is_active" className="ml-2 block text-sm text-secondary-900 dark:text-white">
|
||||||
Repository is active
|
Repository is active
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -295,7 +295,7 @@ const RepositoryDetail = () => {
|
|||||||
<div className="flex items-center mt-1">
|
<div className="flex items-center mt-1">
|
||||||
<Calendar className="h-4 w-4 text-secondary-400 mr-2" />
|
<Calendar className="h-4 w-4 text-secondary-400 mr-2" />
|
||||||
<span className="text-secondary-900 dark:text-white">
|
<span className="text-secondary-900 dark:text-white">
|
||||||
{new Date(repository.createdAt).toLocaleDateString()}
|
{new Date(repository.created_at).toLocaleDateString()}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -310,10 +310,10 @@ const RepositoryDetail = () => {
|
|||||||
<div className="px-6 py-4 border-b border-secondary-200 dark:border-secondary-700">
|
<div className="px-6 py-4 border-b border-secondary-200 dark:border-secondary-700">
|
||||||
<h2 className="text-lg font-semibold text-secondary-900 dark:text-white flex items-center gap-2">
|
<h2 className="text-lg font-semibold text-secondary-900 dark:text-white flex items-center gap-2">
|
||||||
<Users className="h-5 w-5" />
|
<Users className="h-5 w-5" />
|
||||||
Hosts Using This Repository ({repository.hostRepositories?.length || 0})
|
Hosts Using This Repository ({repository.host_repositories?.length || 0})
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
{!repository.hostRepositories || repository.hostRepositories.length === 0 ? (
|
{!repository.host_repositories || repository.host_repositories.length === 0 ? (
|
||||||
<div className="px-6 py-12 text-center">
|
<div className="px-6 py-12 text-center">
|
||||||
<Server className="mx-auto h-12 w-12 text-secondary-400" />
|
<Server className="mx-auto h-12 w-12 text-secondary-400" />
|
||||||
<h3 className="mt-2 text-sm font-medium text-secondary-900 dark:text-white">No hosts using this repository</h3>
|
<h3 className="mt-2 text-sm font-medium text-secondary-900 dark:text-white">No hosts using this repository</h3>
|
||||||
@@ -323,28 +323,28 @@ const RepositoryDetail = () => {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="divide-y divide-secondary-200 dark:divide-secondary-700">
|
<div className="divide-y divide-secondary-200 dark:divide-secondary-700">
|
||||||
{repository.hostRepositories.map((hostRepo) => (
|
{repository.host_repositories.map((hostRepo) => (
|
||||||
<div key={hostRepo.id} className="px-6 py-4 hover:bg-secondary-50 dark:hover:bg-secondary-700/50">
|
<div key={hostRepo.id} className="px-6 py-4 hover:bg-secondary-50 dark:hover:bg-secondary-700/50">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className={`w-3 h-3 rounded-full ${
|
<div className={`w-3 h-3 rounded-full ${
|
||||||
hostRepo.host.status === 'active'
|
hostRepo.hosts.status === 'active'
|
||||||
? 'bg-green-500'
|
? 'bg-green-500'
|
||||||
: hostRepo.host.status === 'pending'
|
: hostRepo.hosts.status === 'pending'
|
||||||
? 'bg-yellow-500'
|
? 'bg-yellow-500'
|
||||||
: 'bg-red-500'
|
: 'bg-red-500'
|
||||||
}`} />
|
}`} />
|
||||||
<div>
|
<div>
|
||||||
<Link
|
<Link
|
||||||
to={`/hosts/${hostRepo.host.id}`}
|
to={`/hosts/${hostRepo.hosts.id}`}
|
||||||
className="text-primary-600 hover:text-primary-700 font-medium"
|
className="text-primary-600 hover:text-primary-700 font-medium"
|
||||||
>
|
>
|
||||||
{hostRepo.host.hostname}
|
{hostRepo.hosts.friendly_name}
|
||||||
</Link>
|
</Link>
|
||||||
<div className="flex items-center gap-4 text-sm text-secondary-500 dark:text-secondary-400 mt-1">
|
<div className="flex items-center gap-4 text-sm text-secondary-500 dark:text-secondary-400 mt-1">
|
||||||
<span>IP: {hostRepo.host.ip}</span>
|
<span>IP: {hostRepo.hosts.ip}</span>
|
||||||
<span>OS: {hostRepo.host.osType} {hostRepo.host.osVersion}</span>
|
<span>OS: {hostRepo.hosts.os_type} {hostRepo.hosts.os_version}</span>
|
||||||
<span>Last Update: {new Date(hostRepo.host.lastUpdate).toLocaleDateString()}</span>
|
<span>Last Update: {new Date(hostRepo.hosts.last_update).toLocaleDateString()}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -352,7 +352,7 @@ const RepositoryDetail = () => {
|
|||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-xs text-secondary-500 dark:text-secondary-400">Last Checked</div>
|
<div className="text-xs text-secondary-500 dark:text-secondary-400">Last Checked</div>
|
||||||
<div className="text-sm text-secondary-900 dark:text-white">
|
<div className="text-sm text-secondary-900 dark:text-white">
|
||||||
{new Date(hostRepo.lastChecked).toLocaleDateString()}
|
{new Date(hostRepo.last_checked).toLocaleDateString()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,17 +1,23 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { Save, Server, Globe, Shield, AlertCircle, CheckCircle, Code, Plus, Trash2, Star, Download, X, Settings as SettingsIcon } from 'lucide-react';
|
import { Save, Server, Shield, AlertCircle, CheckCircle, Code, Plus, Trash2, Star, Download, X, Settings as SettingsIcon, Clock } from 'lucide-react';
|
||||||
import { settingsAPI, agentVersionAPI } from '../utils/api';
|
import { settingsAPI, agentVersionAPI, versionAPI, permissionsAPI } from '../utils/api';
|
||||||
|
import { useUpdateNotification } from '../contexts/UpdateNotificationContext';
|
||||||
|
import UpgradeNotificationIcon from '../components/UpgradeNotificationIcon';
|
||||||
|
|
||||||
const Settings = () => {
|
const Settings = () => {
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
serverProtocol: 'http',
|
serverProtocol: 'http',
|
||||||
serverHost: 'localhost',
|
serverHost: 'localhost',
|
||||||
serverPort: 3001,
|
serverPort: 3001,
|
||||||
frontendUrl: 'http://localhost:3000',
|
|
||||||
updateInterval: 60,
|
updateInterval: 60,
|
||||||
autoUpdate: false,
|
autoUpdate: false,
|
||||||
githubRepoUrl: 'git@github.com:9technologygroup/patchmon.net.git'
|
signupEnabled: false,
|
||||||
|
defaultUserRole: 'user',
|
||||||
|
githubRepoUrl: 'git@github.com:9technologygroup/patchmon.net.git',
|
||||||
|
repositoryType: 'public',
|
||||||
|
sshKeyPath: '',
|
||||||
|
useCustomSshKey: false
|
||||||
});
|
});
|
||||||
const [errors, setErrors] = useState({});
|
const [errors, setErrors] = useState({});
|
||||||
const [isDirty, setIsDirty] = useState(false);
|
const [isDirty, setIsDirty] = useState(false);
|
||||||
@@ -19,12 +25,14 @@ const Settings = () => {
|
|||||||
// Tab management
|
// Tab management
|
||||||
const [activeTab, setActiveTab] = useState('server');
|
const [activeTab, setActiveTab] = useState('server');
|
||||||
|
|
||||||
|
// Get update notification state
|
||||||
|
const { updateAvailable } = useUpdateNotification();
|
||||||
|
|
||||||
// Tab configuration
|
// Tab configuration
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ id: 'server', name: 'Server Configuration', icon: Server },
|
{ id: 'server', name: 'Server Configuration', icon: Server },
|
||||||
{ id: 'frontend', name: 'Frontend Configuration', icon: Globe },
|
|
||||||
{ id: 'agent', name: 'Agent Management', icon: SettingsIcon },
|
{ id: 'agent', name: 'Agent Management', icon: SettingsIcon },
|
||||||
{ id: 'version', name: 'Server Version', icon: Code }
|
{ id: 'version', name: 'Server Version', icon: Code, showUpgradeIcon: updateAvailable }
|
||||||
];
|
];
|
||||||
|
|
||||||
// Agent version management state
|
// Agent version management state
|
||||||
@@ -37,6 +45,22 @@ const Settings = () => {
|
|||||||
isDefault: false
|
isDefault: false
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Version checking state
|
||||||
|
const [versionInfo, setVersionInfo] = useState({
|
||||||
|
currentVersion: null, // Will be loaded from API
|
||||||
|
latestVersion: null,
|
||||||
|
isUpdateAvailable: false,
|
||||||
|
checking: false,
|
||||||
|
error: null
|
||||||
|
});
|
||||||
|
|
||||||
|
const [sshTestResult, setSshTestResult] = useState({
|
||||||
|
testing: false,
|
||||||
|
success: null,
|
||||||
|
message: null,
|
||||||
|
error: null
|
||||||
|
});
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
// Fetch current settings
|
// Fetch current settings
|
||||||
@@ -45,21 +69,28 @@ const Settings = () => {
|
|||||||
queryFn: () => settingsAPI.get().then(res => res.data)
|
queryFn: () => settingsAPI.get().then(res => res.data)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Fetch available roles for default user role dropdown
|
||||||
|
const { data: roles, isLoading: rolesLoading } = useQuery({
|
||||||
|
queryKey: ['rolePermissions'],
|
||||||
|
queryFn: () => permissionsAPI.getRoles().then(res => res.data)
|
||||||
|
});
|
||||||
|
|
||||||
// Update form data when settings are loaded
|
// Update form data when settings are loaded
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (settings) {
|
if (settings) {
|
||||||
console.log('Settings loaded:', settings);
|
|
||||||
console.log('updateInterval from settings:', settings.updateInterval);
|
|
||||||
const newFormData = {
|
const newFormData = {
|
||||||
serverProtocol: settings.serverProtocol || 'http',
|
serverProtocol: settings.server_protocol || 'http',
|
||||||
serverHost: settings.serverHost || 'localhost',
|
serverHost: settings.server_host || 'localhost',
|
||||||
serverPort: settings.serverPort || 3001,
|
serverPort: settings.server_port || 3001,
|
||||||
frontendUrl: settings.frontendUrl || 'http://localhost:3000',
|
updateInterval: settings.update_interval || 60,
|
||||||
updateInterval: settings.updateInterval || 60,
|
autoUpdate: settings.auto_update || false,
|
||||||
autoUpdate: settings.autoUpdate || false,
|
signupEnabled: settings.signup_enabled === true ? true : false, // Explicit boolean conversion
|
||||||
githubRepoUrl: settings.githubRepoUrl || 'git@github.com:9technologygroup/patchmon.net.git'
|
defaultUserRole: settings.default_user_role || 'user',
|
||||||
|
githubRepoUrl: settings.github_repo_url || 'git@github.com:9technologygroup/patchmon.net.git',
|
||||||
|
repositoryType: settings.repository_type || 'public',
|
||||||
|
sshKeyPath: settings.ssh_key_path || '',
|
||||||
|
useCustomSshKey: !!settings.ssh_key_path
|
||||||
};
|
};
|
||||||
console.log('Setting form data to:', newFormData);
|
|
||||||
setFormData(newFormData);
|
setFormData(newFormData);
|
||||||
setIsDirty(false);
|
setIsDirty(false);
|
||||||
}
|
}
|
||||||
@@ -68,30 +99,14 @@ const Settings = () => {
|
|||||||
// Update settings mutation
|
// Update settings mutation
|
||||||
const updateSettingsMutation = useMutation({
|
const updateSettingsMutation = useMutation({
|
||||||
mutationFn: (data) => {
|
mutationFn: (data) => {
|
||||||
console.log('Mutation called with data:', data);
|
return settingsAPI.update(data).then(res => res.data);
|
||||||
return settingsAPI.update(data).then(res => {
|
|
||||||
console.log('API response:', res);
|
|
||||||
return res.data;
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
console.log('Mutation success:', data);
|
|
||||||
console.log('Invalidating queries and updating form data');
|
|
||||||
queryClient.invalidateQueries(['settings']);
|
queryClient.invalidateQueries(['settings']);
|
||||||
// Update form data with the returned data
|
|
||||||
setFormData({
|
|
||||||
serverProtocol: data.serverProtocol || 'http',
|
|
||||||
serverHost: data.serverHost || 'localhost',
|
|
||||||
serverPort: data.serverPort || 3001,
|
|
||||||
frontendUrl: data.frontendUrl || 'http://localhost:3000',
|
|
||||||
updateInterval: data.updateInterval || 60,
|
|
||||||
autoUpdate: data.autoUpdate || false
|
|
||||||
});
|
|
||||||
setIsDirty(false);
|
setIsDirty(false);
|
||||||
setErrors({});
|
setErrors({});
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
console.log('Mutation error:', error);
|
|
||||||
if (error.response?.data?.errors) {
|
if (error.response?.data?.errors) {
|
||||||
setErrors(error.response.data.errors.reduce((acc, err) => {
|
setErrors(error.response.data.errors.reduce((acc, err) => {
|
||||||
acc[err.path] = err.msg;
|
acc[err.path] = err.msg;
|
||||||
@@ -107,20 +122,30 @@ const Settings = () => {
|
|||||||
const { data: agentVersions, isLoading: agentVersionsLoading, error: agentVersionsError } = useQuery({
|
const { data: agentVersions, isLoading: agentVersionsLoading, error: agentVersionsError } = useQuery({
|
||||||
queryKey: ['agentVersions'],
|
queryKey: ['agentVersions'],
|
||||||
queryFn: () => {
|
queryFn: () => {
|
||||||
console.log('Fetching agent versions...');
|
|
||||||
return agentVersionAPI.list().then(res => {
|
return agentVersionAPI.list().then(res => {
|
||||||
console.log('Agent versions API response:', res);
|
|
||||||
return res.data;
|
return res.data;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Debug agent versions
|
|
||||||
|
// Load current version on component mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log('Agent versions data:', agentVersions);
|
const loadCurrentVersion = async () => {
|
||||||
console.log('Agent versions loading:', agentVersionsLoading);
|
try {
|
||||||
console.log('Agent versions error:', agentVersionsError);
|
const response = await versionAPI.getCurrent();
|
||||||
}, [agentVersions, agentVersionsLoading, agentVersionsError]);
|
const data = response.data;
|
||||||
|
setVersionInfo(prev => ({
|
||||||
|
...prev,
|
||||||
|
currentVersion: data.version
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading current version:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadCurrentVersion();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const createAgentVersionMutation = useMutation({
|
const createAgentVersionMutation = useMutation({
|
||||||
mutationFn: (data) => agentVersionAPI.create(data).then(res => res.data),
|
mutationFn: (data) => agentVersionAPI.create(data).then(res => res.data),
|
||||||
@@ -149,14 +174,88 @@ const Settings = () => {
|
|||||||
mutationFn: (id) => agentVersionAPI.delete(id).then(res => res.data),
|
mutationFn: (id) => agentVersionAPI.delete(id).then(res => res.data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries(['agentVersions']);
|
queryClient.invalidateQueries(['agentVersions']);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
console.error('Delete agent version error:', error);
|
||||||
|
|
||||||
|
// Show user-friendly error message
|
||||||
|
if (error.response?.data?.error === 'Agent version not found') {
|
||||||
|
alert('Agent version not found. Please refresh the page to get the latest data.');
|
||||||
|
// Force refresh the agent versions list
|
||||||
|
queryClient.invalidateQueries(['agentVersions']);
|
||||||
|
} else if (error.response?.data?.error === 'Cannot delete current agent version') {
|
||||||
|
alert('Cannot delete the current agent version. Please set another version as current first.');
|
||||||
|
} else {
|
||||||
|
alert(`Failed to delete agent version: ${error.response?.data?.error || error.message}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Version checking functions
|
||||||
|
const checkForUpdates = async () => {
|
||||||
|
setVersionInfo(prev => ({ ...prev, checking: true, error: null }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await versionAPI.checkUpdates();
|
||||||
|
const data = response.data;
|
||||||
|
|
||||||
|
setVersionInfo({
|
||||||
|
currentVersion: data.currentVersion,
|
||||||
|
latestVersion: data.latestVersion,
|
||||||
|
isUpdateAvailable: data.isUpdateAvailable,
|
||||||
|
last_update_check: data.last_update_check,
|
||||||
|
checking: false,
|
||||||
|
error: null
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Version check error:', error);
|
||||||
|
setVersionInfo(prev => ({
|
||||||
|
...prev,
|
||||||
|
checking: false,
|
||||||
|
error: error.response?.data?.error || 'Failed to check for updates'
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const testSshKey = async () => {
|
||||||
|
if (!formData.sshKeyPath || !formData.githubRepoUrl) {
|
||||||
|
setSshTestResult({
|
||||||
|
testing: false,
|
||||||
|
success: false,
|
||||||
|
message: null,
|
||||||
|
error: 'Please enter both SSH key path and GitHub repository URL'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSshTestResult({ testing: true, success: null, message: null, error: null });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await versionAPI.testSshKey({
|
||||||
|
sshKeyPath: formData.sshKeyPath,
|
||||||
|
githubRepoUrl: formData.githubRepoUrl
|
||||||
|
});
|
||||||
|
|
||||||
|
setSshTestResult({
|
||||||
|
testing: false,
|
||||||
|
success: true,
|
||||||
|
message: response.data.message,
|
||||||
|
error: null
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('SSH key test error:', error);
|
||||||
|
setSshTestResult({
|
||||||
|
testing: false,
|
||||||
|
success: false,
|
||||||
|
message: null,
|
||||||
|
error: error.response?.data?.error || 'Failed to test SSH key'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleInputChange = (field, value) => {
|
const handleInputChange = (field, value) => {
|
||||||
console.log(`handleInputChange: ${field} = ${value}`);
|
|
||||||
setFormData(prev => {
|
setFormData(prev => {
|
||||||
const newData = { ...prev, [field]: value };
|
const newData = { ...prev, [field]: value };
|
||||||
console.log('New form data:', newData);
|
|
||||||
return newData;
|
return newData;
|
||||||
});
|
});
|
||||||
setIsDirty(true);
|
setIsDirty(true);
|
||||||
@@ -167,7 +266,16 @@ const Settings = () => {
|
|||||||
|
|
||||||
const handleSubmit = (e) => {
|
const handleSubmit = (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
updateSettingsMutation.mutate(formData);
|
|
||||||
|
// Only include sshKeyPath if the toggle is enabled
|
||||||
|
const dataToSubmit = { ...formData };
|
||||||
|
if (!dataToSubmit.useCustomSshKey) {
|
||||||
|
dataToSubmit.sshKeyPath = '';
|
||||||
|
}
|
||||||
|
// Remove the frontend-only field
|
||||||
|
delete dataToSubmit.useCustomSshKey;
|
||||||
|
|
||||||
|
updateSettingsMutation.mutate(dataToSubmit);
|
||||||
};
|
};
|
||||||
|
|
||||||
const validateForm = () => {
|
const validateForm = () => {
|
||||||
@@ -181,12 +289,6 @@ const Settings = () => {
|
|||||||
newErrors.serverPort = 'Port must be between 1 and 65535';
|
newErrors.serverPort = 'Port must be between 1 and 65535';
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
|
||||||
new URL(formData.frontendUrl);
|
|
||||||
} catch {
|
|
||||||
newErrors.frontendUrl = 'Frontend URL must be a valid URL';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!formData.updateInterval || formData.updateInterval < 5 || formData.updateInterval > 1440) {
|
if (!formData.updateInterval || formData.updateInterval < 5 || formData.updateInterval > 1440) {
|
||||||
newErrors.updateInterval = 'Update interval must be between 5 and 1440 minutes';
|
newErrors.updateInterval = 'Update interval must be between 5 and 1440 minutes';
|
||||||
}
|
}
|
||||||
@@ -196,12 +298,16 @@ const Settings = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
console.log('Saving settings:', formData);
|
|
||||||
if (validateForm()) {
|
if (validateForm()) {
|
||||||
console.log('Validation passed, calling mutation');
|
// Prepare data for submission
|
||||||
updateSettingsMutation.mutate(formData);
|
const dataToSubmit = { ...formData };
|
||||||
} else {
|
if (!dataToSubmit.useCustomSshKey) {
|
||||||
console.log('Validation failed:', errors);
|
dataToSubmit.sshKeyPath = '';
|
||||||
|
}
|
||||||
|
// Remove the frontend-only field
|
||||||
|
delete dataToSubmit.useCustomSshKey;
|
||||||
|
|
||||||
|
updateSettingsMutation.mutate(dataToSubmit);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -266,6 +372,9 @@ const Settings = () => {
|
|||||||
>
|
>
|
||||||
<Icon className="h-4 w-4" />
|
<Icon className="h-4 w-4" />
|
||||||
{tab.name}
|
{tab.name}
|
||||||
|
{tab.showUpgradeIcon && (
|
||||||
|
<UpgradeNotificationIcon className="h-3 w-3" />
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -350,25 +459,81 @@ const Settings = () => {
|
|||||||
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2">
|
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2">
|
||||||
Agent Update Interval (minutes)
|
Agent Update Interval (minutes)
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
{/* Numeric input (concise width) */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min="5"
|
min="5"
|
||||||
max="1440"
|
max="1440"
|
||||||
|
step="5"
|
||||||
value={formData.updateInterval}
|
value={formData.updateInterval}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
console.log('Update interval input changed:', e.target.value);
|
const val = parseInt(e.target.value);
|
||||||
handleInputChange('updateInterval', parseInt(e.target.value) || 60);
|
if (!isNaN(val)) {
|
||||||
|
handleInputChange('updateInterval', Math.min(1440, Math.max(5, val)));
|
||||||
|
} else {
|
||||||
|
handleInputChange('updateInterval', 60);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
className={`w-full border rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white ${
|
className={`w-28 border rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white ${
|
||||||
errors.updateInterval ? 'border-red-300 dark:border-red-500' : 'border-secondary-300 dark:border-secondary-600'
|
errors.updateInterval ? 'border-red-300 dark:border-red-500' : 'border-secondary-300 dark:border-secondary-600'
|
||||||
}`}
|
}`}
|
||||||
placeholder="60"
|
placeholder="60"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick presets */}
|
||||||
|
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||||
|
{[15, 30, 60, 120, 360, 720, 1440].map((m) => (
|
||||||
|
<button
|
||||||
|
key={m}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleInputChange('updateInterval', m)}
|
||||||
|
className={`px-3 py-1.5 rounded-full text-xs font-medium border ${
|
||||||
|
formData.updateInterval === m
|
||||||
|
? 'bg-primary-600 text-white border-primary-600'
|
||||||
|
: 'bg-white dark:bg-secondary-700 text-secondary-700 dark:text-secondary-200 border-secondary-300 dark:border-secondary-600 hover:bg-secondary-50 dark:hover:bg-secondary-600'
|
||||||
|
}`}
|
||||||
|
aria-label={`Set ${m} minutes`}
|
||||||
|
>
|
||||||
|
{m % 60 === 0 ? `${m / 60}h` : `${m}m`}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Range slider */}
|
||||||
|
<div className="mt-4">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="5"
|
||||||
|
max="1440"
|
||||||
|
step="5"
|
||||||
|
value={formData.updateInterval}
|
||||||
|
onChange={(e) => handleInputChange('updateInterval', parseInt(e.target.value))}
|
||||||
|
className="w-full accent-primary-600"
|
||||||
|
aria-label="Update interval slider"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{errors.updateInterval && (
|
{errors.updateInterval && (
|
||||||
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{errors.updateInterval}</p>
|
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{errors.updateInterval}</p>
|
||||||
)}
|
)}
|
||||||
<p className="mt-1 text-sm text-secondary-500 dark:text-secondary-400">
|
|
||||||
How often agents should check for updates (5-1440 minutes). This affects new installations.
|
{/* Helper text */}
|
||||||
|
<div className="mt-2 text-sm text-secondary-600 dark:text-secondary-300">
|
||||||
|
<span className="font-medium">Effective cadence:</span>{' '}
|
||||||
|
{(() => {
|
||||||
|
const mins = parseInt(formData.updateInterval) || 60;
|
||||||
|
if (mins < 60) return `${mins} minute${mins === 1 ? '' : 's'}`;
|
||||||
|
const hrs = Math.floor(mins / 60);
|
||||||
|
const rem = mins % 60;
|
||||||
|
return `${hrs} hour${hrs === 1 ? '' : 's'}${rem ? ` ${rem} min` : ''}`;
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||||
|
This affects new installations and will update existing ones when they next reach out.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -390,6 +555,55 @@ const Settings = () => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* User Signup Setting */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={formData.signupEnabled}
|
||||||
|
onChange={(e) => handleInputChange('signupEnabled', e.target.checked)}
|
||||||
|
className="rounded border-secondary-300 text-primary-600 shadow-sm focus:border-primary-300 focus:ring focus:ring-primary-200 focus:ring-opacity-50"
|
||||||
|
/>
|
||||||
|
Enable User Self-Registration
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{/* Default User Role Dropdown */}
|
||||||
|
{formData.signupEnabled && (
|
||||||
|
<div className="mt-3 ml-6">
|
||||||
|
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2">
|
||||||
|
Default Role for New Users
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={formData.defaultUserRole}
|
||||||
|
onChange={(e) => handleInputChange('defaultUserRole', e.target.value)}
|
||||||
|
className="w-full max-w-xs border-secondary-300 dark:border-secondary-600 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
|
||||||
|
disabled={rolesLoading}
|
||||||
|
>
|
||||||
|
{rolesLoading ? (
|
||||||
|
<option>Loading roles...</option>
|
||||||
|
) : roles && Array.isArray(roles) ? (
|
||||||
|
roles.map((role) => (
|
||||||
|
<option key={role.role} value={role.role}>
|
||||||
|
{role.role.charAt(0).toUpperCase() + role.role.slice(1)}
|
||||||
|
</option>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<option value="user">User</option>
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
|
<p className="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||||
|
New users will be assigned this role when they register.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="mt-1 text-sm text-secondary-500 dark:text-secondary-400">
|
||||||
|
When enabled, users can create their own accounts through the signup page. When disabled, only administrators can create user accounts.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Security Notice */}
|
{/* Security Notice */}
|
||||||
<div className="bg-blue-50 dark:bg-blue-900 border border-blue-200 dark:border-blue-700 rounded-md p-4">
|
<div className="bg-blue-50 dark:bg-blue-900 border border-blue-200 dark:border-blue-700 rounded-md p-4">
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
@@ -443,74 +657,6 @@ const Settings = () => {
|
|||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Frontend Configuration Tab */}
|
|
||||||
{activeTab === 'frontend' && (
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
|
||||||
<div className="flex items-center mb-6">
|
|
||||||
<Globe className="h-6 w-6 text-primary-600 mr-3" />
|
|
||||||
<h2 className="text-xl font-semibold text-secondary-900 dark:text-white">Frontend Configuration</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2">
|
|
||||||
Frontend URL *
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="url"
|
|
||||||
value={formData.frontendUrl}
|
|
||||||
onChange={(e) => handleInputChange('frontendUrl', e.target.value)}
|
|
||||||
className={`w-full border rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white ${
|
|
||||||
errors.frontendUrl ? 'border-red-300 dark:border-red-500' : 'border-secondary-300 dark:border-secondary-600'
|
|
||||||
}`}
|
|
||||||
placeholder="https://patchmon.example.com"
|
|
||||||
/>
|
|
||||||
{errors.frontendUrl && (
|
|
||||||
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{errors.frontendUrl}</p>
|
|
||||||
)}
|
|
||||||
<p className="mt-1 text-sm text-secondary-500 dark:text-secondary-400">
|
|
||||||
The URL where users will access the PatchMon web interface.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Save Button */}
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleSave}
|
|
||||||
disabled={!isDirty || updateSettingsMutation.isPending}
|
|
||||||
className={`inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white ${
|
|
||||||
!isDirty || updateSettingsMutation.isPending
|
|
||||||
? 'bg-secondary-400 cursor-not-allowed'
|
|
||||||
: 'bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{updateSettingsMutation.isPending ? (
|
|
||||||
<>
|
|
||||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
|
||||||
Saving...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Save className="h-4 w-4 mr-2" />
|
|
||||||
Save Settings
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{updateSettingsMutation.isSuccess && (
|
|
||||||
<div className="bg-green-50 dark:bg-green-900 border border-green-200 dark:border-green-700 rounded-md p-4">
|
|
||||||
<div className="flex">
|
|
||||||
<CheckCircle className="h-5 w-5 text-green-400 dark:text-green-300" />
|
|
||||||
<div className="ml-3">
|
|
||||||
<p className="text-sm text-green-700 dark:text-green-300">Settings saved successfully!</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Agent Management Tab */}
|
{/* Agent Management Tab */}
|
||||||
{activeTab === 'agent' && (
|
{activeTab === 'agent' && (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -541,14 +687,14 @@ const Settings = () => {
|
|||||||
<CheckCircle className="h-4 w-4 text-green-600 dark:text-green-400" />
|
<CheckCircle className="h-4 w-4 text-green-600 dark:text-green-400" />
|
||||||
<span className="text-sm font-medium text-secondary-700 dark:text-secondary-300">Current Version:</span>
|
<span className="text-sm font-medium text-secondary-700 dark:text-secondary-300">Current Version:</span>
|
||||||
<span className="text-sm text-secondary-900 dark:text-white font-mono">
|
<span className="text-sm text-secondary-900 dark:text-white font-mono">
|
||||||
{agentVersions.find(v => v.isCurrent)?.version || 'None'}
|
{agentVersions.find(v => v.is_current)?.version || 'None'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Star className="h-4 w-4 text-yellow-600 dark:text-yellow-400" />
|
<Star className="h-4 w-4 text-yellow-600 dark:text-yellow-400" />
|
||||||
<span className="text-sm font-medium text-secondary-700 dark:text-secondary-300">Default Version:</span>
|
<span className="text-sm font-medium text-secondary-700 dark:text-secondary-300">Default Version:</span>
|
||||||
<span className="text-sm text-secondary-900 dark:text-white font-mono">
|
<span className="text-sm text-secondary-900 dark:text-white font-mono">
|
||||||
{agentVersions.find(v => v.isDefault)?.version || 'None'}
|
{agentVersions.find(v => v.is_default)?.version || 'None'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -579,27 +725,27 @@ const Settings = () => {
|
|||||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
|
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
|
||||||
Version {version.version}
|
Version {version.version}
|
||||||
</h3>
|
</h3>
|
||||||
{version.isDefault && (
|
{version.is_default && (
|
||||||
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-primary-100 text-primary-800 dark:bg-primary-900 dark:text-primary-200">
|
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-primary-100 text-primary-800 dark:bg-primary-900 dark:text-primary-200">
|
||||||
<Star className="h-3 w-3 mr-1" />
|
<Star className="h-3 w-3 mr-1" />
|
||||||
Default
|
Default
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{version.isCurrent && (
|
{version.is_current && (
|
||||||
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
|
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
|
||||||
Current
|
Current
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{version.releaseNotes && (
|
{version.release_notes && (
|
||||||
<div className="text-sm text-secondary-500 dark:text-secondary-300 mt-1">
|
<div className="text-sm text-secondary-500 dark:text-secondary-300 mt-1">
|
||||||
<p className="line-clamp-3 whitespace-pre-line">
|
<p className="line-clamp-3 whitespace-pre-line">
|
||||||
{version.releaseNotes}
|
{version.release_notes}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<p className="text-xs text-secondary-400 dark:text-secondary-400 mt-1">
|
<p className="text-xs text-secondary-400 dark:text-secondary-400 mt-1">
|
||||||
Created: {new Date(version.createdAt).toLocaleDateString()}
|
Created: {new Date(version.created_at).toLocaleDateString()}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -616,7 +762,7 @@ const Settings = () => {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setCurrentAgentVersionMutation.mutate(version.id)}
|
onClick={() => setCurrentAgentVersionMutation.mutate(version.id)}
|
||||||
disabled={version.isCurrent || setCurrentAgentVersionMutation.isPending}
|
disabled={version.is_current || setCurrentAgentVersionMutation.isPending}
|
||||||
className="btn-outline text-xs flex items-center gap-1"
|
className="btn-outline text-xs flex items-center gap-1"
|
||||||
>
|
>
|
||||||
<CheckCircle className="h-3 w-3" />
|
<CheckCircle className="h-3 w-3" />
|
||||||
@@ -624,7 +770,7 @@ const Settings = () => {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setDefaultAgentVersionMutation.mutate(version.id)}
|
onClick={() => setDefaultAgentVersionMutation.mutate(version.id)}
|
||||||
disabled={version.isDefault || setDefaultAgentVersionMutation.isPending}
|
disabled={version.is_default || setDefaultAgentVersionMutation.isPending}
|
||||||
className="btn-outline text-xs flex items-center gap-1"
|
className="btn-outline text-xs flex items-center gap-1"
|
||||||
>
|
>
|
||||||
<Star className="h-3 w-3" />
|
<Star className="h-3 w-3" />
|
||||||
@@ -632,7 +778,7 @@ const Settings = () => {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => deleteAgentVersionMutation.mutate(version.id)}
|
onClick={() => deleteAgentVersionMutation.mutate(version.id)}
|
||||||
disabled={version.isDefault || version.isCurrent || deleteAgentVersionMutation.isPending}
|
disabled={version.is_default || version.is_current || deleteAgentVersionMutation.isPending}
|
||||||
className="btn-danger text-xs flex items-center gap-1"
|
className="btn-danger text-xs flex items-center gap-1"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3 w-3" />
|
<Trash2 className="h-3 w-3" />
|
||||||
@@ -672,13 +818,52 @@ const Settings = () => {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2">
|
||||||
|
Repository Type
|
||||||
|
</label>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
id="repo-public"
|
||||||
|
name="repositoryType"
|
||||||
|
value="public"
|
||||||
|
checked={formData.repositoryType === 'public'}
|
||||||
|
onChange={(e) => handleInputChange('repositoryType', e.target.value)}
|
||||||
|
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300"
|
||||||
|
/>
|
||||||
|
<label htmlFor="repo-public" className="ml-2 text-sm text-secondary-700 dark:text-secondary-200">
|
||||||
|
Public Repository (uses GitHub API - no authentication required)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
id="repo-private"
|
||||||
|
name="repositoryType"
|
||||||
|
value="private"
|
||||||
|
checked={formData.repositoryType === 'private'}
|
||||||
|
onChange={(e) => handleInputChange('repositoryType', e.target.value)}
|
||||||
|
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300"
|
||||||
|
/>
|
||||||
|
<label htmlFor="repo-private" className="ml-2 text-sm text-secondary-700 dark:text-secondary-200">
|
||||||
|
Private Repository (uses SSH with deploy key)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||||
|
Choose whether your repository is public or private to determine the appropriate access method.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2">
|
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2">
|
||||||
GitHub Repository URL
|
GitHub Repository URL
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={formData.githubRepoUrl || 'git@github.com:9technologygroup/patchmon.net.git'}
|
value={formData.githubRepoUrl || ''}
|
||||||
onChange={(e) => handleInputChange('githubRepoUrl', e.target.value)}
|
onChange={(e) => handleInputChange('githubRepoUrl', e.target.value)}
|
||||||
className="w-full border border-secondary-300 dark:border-secondary-600 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white font-mono text-sm"
|
className="w-full border border-secondary-300 dark:border-secondary-600 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white font-mono text-sm"
|
||||||
placeholder="git@github.com:username/repository.git"
|
placeholder="git@github.com:username/repository.git"
|
||||||
@@ -688,13 +873,93 @@ const Settings = () => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{formData.repositoryType === 'private' && (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="useCustomSshKey"
|
||||||
|
checked={formData.useCustomSshKey}
|
||||||
|
onChange={(e) => {
|
||||||
|
const checked = e.target.checked;
|
||||||
|
handleInputChange('useCustomSshKey', checked);
|
||||||
|
if (!checked) {
|
||||||
|
handleInputChange('sshKeyPath', '');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
|
||||||
|
/>
|
||||||
|
<label htmlFor="useCustomSshKey" className="text-sm font-medium text-secondary-700 dark:text-secondary-200">
|
||||||
|
Set custom SSH key path
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{formData.useCustomSshKey && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2">
|
||||||
|
SSH Key Path
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.sshKeyPath || ''}
|
||||||
|
onChange={(e) => handleInputChange('sshKeyPath', e.target.value)}
|
||||||
|
className="w-full border border-secondary-300 dark:border-secondary-600 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white font-mono text-sm"
|
||||||
|
placeholder="/root/.ssh/id_ed25519"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||||
|
Path to your SSH deploy key. If not set, will auto-detect from common locations.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={testSshKey}
|
||||||
|
disabled={sshTestResult.testing || !formData.sshKeyPath || !formData.githubRepoUrl}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{sshTestResult.testing ? 'Testing...' : 'Test SSH Key'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{sshTestResult.success && (
|
||||||
|
<div className="mt-2 p-3 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-md">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<CheckCircle className="h-4 w-4 text-green-600 dark:text-green-400 mr-2" />
|
||||||
|
<p className="text-sm text-green-800 dark:text-green-200">
|
||||||
|
{sshTestResult.message}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{sshTestResult.error && (
|
||||||
|
<div className="mt-2 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<AlertCircle className="h-4 w-4 text-red-600 dark:text-red-400 mr-2" />
|
||||||
|
<p className="text-sm text-red-800 dark:text-red-200">
|
||||||
|
{sshTestResult.error}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!formData.useCustomSshKey && (
|
||||||
|
<p className="text-xs text-secondary-500 dark:text-secondary-400">
|
||||||
|
Using auto-detection for SSH key location
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div className="bg-white dark:bg-secondary-800 rounded-lg p-4 border border-secondary-200 dark:border-secondary-600">
|
<div className="bg-white dark:bg-secondary-800 rounded-lg p-4 border border-secondary-200 dark:border-secondary-600">
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
<CheckCircle className="h-4 w-4 text-green-600 dark:text-green-400" />
|
<CheckCircle className="h-4 w-4 text-green-600 dark:text-green-400" />
|
||||||
<span className="text-sm font-medium text-secondary-700 dark:text-secondary-300">Current Version</span>
|
<span className="text-sm font-medium text-secondary-700 dark:text-secondary-300">Current Version</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-lg font-mono text-secondary-900 dark:text-white">1.2.3</span>
|
<span className="text-lg font-mono text-secondary-900 dark:text-white">{versionInfo.currentVersion}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white dark:bg-secondary-800 rounded-lg p-4 border border-secondary-200 dark:border-secondary-600">
|
<div className="bg-white dark:bg-secondary-800 rounded-lg p-4 border border-secondary-200 dark:border-secondary-600">
|
||||||
@@ -702,52 +967,107 @@ const Settings = () => {
|
|||||||
<Download className="h-4 w-4 text-blue-600 dark:text-blue-400" />
|
<Download className="h-4 w-4 text-blue-600 dark:text-blue-400" />
|
||||||
<span className="text-sm font-medium text-secondary-700 dark:text-secondary-300">Latest Version</span>
|
<span className="text-sm font-medium text-secondary-700 dark:text-secondary-300">Latest Version</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-lg font-mono text-secondary-900 dark:text-white">Checking...</span>
|
<span className="text-lg font-mono text-secondary-900 dark:text-white">
|
||||||
|
{versionInfo.checking ? (
|
||||||
|
<span className="text-blue-600 dark:text-blue-400">Checking...</span>
|
||||||
|
) : versionInfo.latestVersion ? (
|
||||||
|
<span className={versionInfo.isUpdateAvailable ? 'text-orange-600 dark:text-orange-400' : 'text-green-600 dark:text-green-400'}>
|
||||||
|
{versionInfo.latestVersion}
|
||||||
|
{versionInfo.isUpdateAvailable && ' (Update Available!)'}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-secondary-500 dark:text-secondary-400">Not checked</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Last Checked Time */}
|
||||||
|
{versionInfo.last_update_check && (
|
||||||
|
<div className="bg-white dark:bg-secondary-800 rounded-lg p-4 border border-secondary-200 dark:border-secondary-600">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Clock className="h-4 w-4 text-blue-600 dark:text-blue-400" />
|
||||||
|
<span className="text-sm font-medium text-secondary-700 dark:text-secondary-300">Last Checked</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-secondary-600 dark:text-secondary-400">
|
||||||
|
{new Date(versionInfo.last_update_check).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
<p className="text-xs text-secondary-500 dark:text-secondary-400 mt-1">
|
||||||
|
Updates are checked automatically every 24 hours
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={checkForUpdates}
|
||||||
// TODO: Implement version check
|
disabled={versionInfo.checking}
|
||||||
console.log('Checking for updates...');
|
|
||||||
}}
|
|
||||||
className="btn-primary flex items-center gap-2"
|
className="btn-primary flex items-center gap-2"
|
||||||
>
|
>
|
||||||
<Download className="h-4 w-4" />
|
<Download className="h-4 w-4" />
|
||||||
Check for Updates
|
{versionInfo.checking ? 'Checking...' : 'Check for Updates'}
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Save Button for Version Settings */}
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
type="button"
|
||||||
// TODO: Implement update notification
|
onClick={handleSave}
|
||||||
console.log('Enable update notifications');
|
disabled={!isDirty || updateSettingsMutation.isPending}
|
||||||
}}
|
className={`inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white ${
|
||||||
className="btn-outline flex items-center gap-2"
|
!isDirty || updateSettingsMutation.isPending
|
||||||
|
? 'bg-secondary-400 cursor-not-allowed'
|
||||||
|
: 'bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<AlertCircle className="h-4 w-4" />
|
{updateSettingsMutation.isPending ? (
|
||||||
Enable Notifications
|
<>
|
||||||
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||||
|
Saving...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Save className="h-4 w-4 mr-2" />
|
||||||
|
Save Settings
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{versionInfo.error && (
|
||||||
|
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-700 rounded-lg p-4">
|
||||||
|
<div className="flex">
|
||||||
|
<AlertCircle className="h-5 w-5 text-red-400 dark:text-red-300" />
|
||||||
|
<div className="ml-3">
|
||||||
|
<h3 className="text-sm font-medium text-red-800 dark:text-red-200">Version Check Failed</h3>
|
||||||
|
<p className="mt-1 text-sm text-red-700 dark:text-red-300">
|
||||||
|
{versionInfo.error}
|
||||||
|
</p>
|
||||||
|
{versionInfo.error.includes('private') && (
|
||||||
|
<p className="mt-2 text-xs text-red-600 dark:text-red-400">
|
||||||
|
For private repositories, you may need to configure GitHub authentication or make the repository public.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Success Message for Version Settings */}
|
||||||
|
{updateSettingsMutation.isSuccess && (
|
||||||
|
<div className="bg-green-50 dark:bg-green-900 border border-green-200 dark:border-green-700 rounded-md p-4">
|
||||||
|
<div className="flex">
|
||||||
|
<CheckCircle className="h-5 w-5 text-green-400 dark:text-green-300" />
|
||||||
|
<div className="ml-3">
|
||||||
|
<p className="text-sm text-green-700 dark:text-green-300">Settings saved successfully!</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-700 rounded-lg p-4">
|
|
||||||
<div className="flex">
|
|
||||||
<AlertCircle className="h-5 w-5 text-amber-400 dark:text-amber-300" />
|
|
||||||
<div className="ml-3">
|
|
||||||
<h3 className="text-sm font-medium text-amber-800 dark:text-amber-200">Setup Instructions</h3>
|
|
||||||
<div className="mt-2 text-sm text-amber-700 dark:text-amber-300">
|
|
||||||
<p className="mb-2">To enable version checking, you need to:</p>
|
|
||||||
<ol className="list-decimal list-inside space-y-1 ml-4">
|
|
||||||
<li>Create a version tag (e.g., v1.2.3) in your GitHub repository</li>
|
|
||||||
<li>Ensure the repository is publicly accessible or configure access tokens</li>
|
|
||||||
<li>Click "Check for Updates" to verify the connection</li>
|
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ const Users = () => {
|
|||||||
<Shield className="h-3 w-3 mr-1" />
|
<Shield className="h-3 w-3 mr-1" />
|
||||||
{user.role.charAt(0).toUpperCase() + user.role.slice(1).replace('_', ' ')}
|
{user.role.charAt(0).toUpperCase() + user.role.slice(1).replace('_', ' ')}
|
||||||
</span>
|
</span>
|
||||||
{user.isActive ? (
|
{user.is_active ? (
|
||||||
<CheckCircle className="ml-2 h-4 w-4 text-green-500" />
|
<CheckCircle className="ml-2 h-4 w-4 text-green-500" />
|
||||||
) : (
|
) : (
|
||||||
<XCircle className="ml-2 h-4 w-4 text-red-500" />
|
<XCircle className="ml-2 h-4 w-4 text-red-500" />
|
||||||
@@ -152,11 +152,11 @@ const Users = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center mt-1 text-sm text-secondary-500 dark:text-secondary-300">
|
<div className="flex items-center mt-1 text-sm text-secondary-500 dark:text-secondary-300">
|
||||||
<Calendar className="h-4 w-4 mr-1" />
|
<Calendar className="h-4 w-4 mr-1" />
|
||||||
Created: {new Date(user.createdAt).toLocaleDateString()}
|
Created: {new Date(user.created_at).toLocaleDateString()}
|
||||||
{user.lastLogin && (
|
{user.last_login && (
|
||||||
<>
|
<>
|
||||||
<span className="mx-2">•</span>
|
<span className="mx-2">•</span>
|
||||||
Last login: {new Date(user.lastLogin).toLocaleDateString()}
|
Last login: {new Date(user.last_login).toLocaleDateString()}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -174,11 +174,11 @@ const Users = () => {
|
|||||||
onClick={() => handleResetPassword(user)}
|
onClick={() => handleResetPassword(user)}
|
||||||
className="text-blue-400 hover:text-blue-600 dark:text-blue-500 dark:hover:text-blue-300 disabled:text-gray-300 disabled:cursor-not-allowed"
|
className="text-blue-400 hover:text-blue-600 dark:text-blue-500 dark:hover:text-blue-300 disabled:text-gray-300 disabled:cursor-not-allowed"
|
||||||
title={
|
title={
|
||||||
!user.isActive
|
!user.is_active
|
||||||
? "Cannot reset password for inactive user"
|
? "Cannot reset password for inactive user"
|
||||||
: "Reset password"
|
: "Reset password"
|
||||||
}
|
}
|
||||||
disabled={!user.isActive}
|
disabled={!user.is_active}
|
||||||
>
|
>
|
||||||
<Key className="h-4 w-4" />
|
<Key className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -256,6 +256,8 @@ const AddUserModal = ({ isOpen, onClose, onUserCreated, roles }) => {
|
|||||||
username: '',
|
username: '',
|
||||||
email: '',
|
email: '',
|
||||||
password: '',
|
password: '',
|
||||||
|
first_name: '',
|
||||||
|
last_name: '',
|
||||||
role: 'user'
|
role: 'user'
|
||||||
})
|
})
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
@@ -267,7 +269,12 @@ const AddUserModal = ({ isOpen, onClose, onUserCreated, roles }) => {
|
|||||||
setError('')
|
setError('')
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await adminUsersAPI.create(formData)
|
// Only send role if roles are available from API
|
||||||
|
const payload = { username: formData.username, email: formData.email, password: formData.password }
|
||||||
|
if (roles && Array.isArray(roles) && roles.length > 0) {
|
||||||
|
payload.role = formData.role
|
||||||
|
}
|
||||||
|
const response = await adminUsersAPI.create(payload)
|
||||||
onUserCreated()
|
onUserCreated()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.response?.data?.error || 'Failed to create user')
|
setError(err.response?.data?.error || 'Failed to create user')
|
||||||
@@ -319,6 +326,33 @@ const AddUserModal = ({ isOpen, onClose, onUserCreated, roles }) => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
|
||||||
|
First Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="first_name"
|
||||||
|
value={formData.first_name}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className="block w-full border-secondary-300 dark:border-secondary-600 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
|
||||||
|
Last Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="last_name"
|
||||||
|
value={formData.last_name}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className="block w-full border-secondary-300 dark:border-secondary-600 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
|
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
|
||||||
Password
|
Password
|
||||||
@@ -345,7 +379,7 @@ const AddUserModal = ({ isOpen, onClose, onUserCreated, roles }) => {
|
|||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
className="block w-full border-secondary-300 dark:border-secondary-600 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
|
className="block w-full border-secondary-300 dark:border-secondary-600 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
|
||||||
>
|
>
|
||||||
{roles && Array.isArray(roles) ? (
|
{roles && Array.isArray(roles) && roles.length > 0 ? (
|
||||||
roles.map((role) => (
|
roles.map((role) => (
|
||||||
<option key={role.role} value={role.role}>
|
<option key={role.role} value={role.role}>
|
||||||
{role.role.charAt(0).toUpperCase() + role.role.slice(1).replace('_', ' ')}
|
{role.role.charAt(0).toUpperCase() + role.role.slice(1).replace('_', ' ')}
|
||||||
@@ -393,8 +427,10 @@ const EditUserModal = ({ user, isOpen, onClose, onUserUpdated, roles }) => {
|
|||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
username: user?.username || '',
|
username: user?.username || '',
|
||||||
email: user?.email || '',
|
email: user?.email || '',
|
||||||
|
first_name: user?.first_name || '',
|
||||||
|
last_name: user?.last_name || '',
|
||||||
role: user?.role || 'user',
|
role: user?.role || 'user',
|
||||||
isActive: user?.isActive ?? true
|
is_active: user?.is_active ?? true
|
||||||
})
|
})
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
@@ -458,6 +494,33 @@ const EditUserModal = ({ user, isOpen, onClose, onUserUpdated, roles }) => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
|
||||||
|
First Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="first_name"
|
||||||
|
value={formData.first_name}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className="block w-full border-secondary-300 dark:border-secondary-600 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
|
||||||
|
Last Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="last_name"
|
||||||
|
value={formData.last_name}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className="block w-full border-secondary-300 dark:border-secondary-600 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
|
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
|
||||||
Role
|
Role
|
||||||
@@ -486,8 +549,8 @@ const EditUserModal = ({ user, isOpen, onClose, onUserUpdated, roles }) => {
|
|||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
name="isActive"
|
name="is_active"
|
||||||
checked={formData.isActive}
|
checked={formData.is_active}
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 rounded"
|
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 rounded"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -31,10 +31,18 @@ api.interceptors.response.use(
|
|||||||
(response) => response,
|
(response) => response,
|
||||||
(error) => {
|
(error) => {
|
||||||
if (error.response?.status === 401) {
|
if (error.response?.status === 401) {
|
||||||
|
// Don't redirect if we're on the login page or if it's a TFA verification error
|
||||||
|
const currentPath = window.location.pathname
|
||||||
|
const isTfaError = error.config?.url?.includes('/verify-tfa')
|
||||||
|
|
||||||
|
if (currentPath !== '/login' && !isTfaError) {
|
||||||
// Handle unauthorized
|
// Handle unauthorized
|
||||||
localStorage.removeItem('token')
|
localStorage.removeItem('token')
|
||||||
|
localStorage.removeItem('user')
|
||||||
|
localStorage.removeItem('permissions')
|
||||||
window.location.href = '/login'
|
window.location.href = '/login'
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return Promise.reject(error)
|
return Promise.reject(error)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -45,6 +53,8 @@ export const dashboardAPI = {
|
|||||||
getHosts: () => api.get('/dashboard/hosts'),
|
getHosts: () => api.get('/dashboard/hosts'),
|
||||||
getPackages: () => api.get('/dashboard/packages'),
|
getPackages: () => api.get('/dashboard/packages'),
|
||||||
getHostDetail: (hostId) => api.get(`/dashboard/hosts/${hostId}`),
|
getHostDetail: (hostId) => api.get(`/dashboard/hosts/${hostId}`),
|
||||||
|
getRecentUsers: () => api.get('/dashboard/recent-users'),
|
||||||
|
getRecentCollection: () => api.get('/dashboard/recent-collection')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Admin Hosts API (for management interface)
|
// Admin Hosts API (for management interface)
|
||||||
@@ -52,10 +62,12 @@ export const adminHostsAPI = {
|
|||||||
create: (data) => api.post('/hosts/create', data),
|
create: (data) => api.post('/hosts/create', data),
|
||||||
list: () => api.get('/hosts/admin/list'),
|
list: () => api.get('/hosts/admin/list'),
|
||||||
delete: (hostId) => api.delete(`/hosts/${hostId}`),
|
delete: (hostId) => api.delete(`/hosts/${hostId}`),
|
||||||
|
deleteBulk: (hostIds) => api.delete('/hosts/bulk', { data: { hostIds } }),
|
||||||
regenerateCredentials: (hostId) => api.post(`/hosts/${hostId}/regenerate-credentials`),
|
regenerateCredentials: (hostId) => api.post(`/hosts/${hostId}/regenerate-credentials`),
|
||||||
updateGroup: (hostId, hostGroupId) => api.put(`/hosts/${hostId}/group`, { hostGroupId }),
|
updateGroup: (hostId, hostGroupId) => api.put(`/hosts/${hostId}/group`, { hostGroupId }),
|
||||||
bulkUpdateGroup: (hostIds, hostGroupId) => api.put('/hosts/bulk/group', { hostIds, hostGroupId }),
|
bulkUpdateGroup: (hostIds, hostGroupId) => api.put('/hosts/bulk/group', { hostIds, hostGroupId }),
|
||||||
toggleAutoUpdate: (hostId, autoUpdate) => api.patch(`/hosts/${hostId}/auto-update`, { autoUpdate })
|
toggleAutoUpdate: (hostId, autoUpdate) => api.patch(`/hosts/${hostId}/auto-update`, { auto_update: autoUpdate }),
|
||||||
|
updateFriendlyName: (hostId, friendlyName) => api.patch(`/hosts/${hostId}/friendly-name`, { friendly_name: friendlyName })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Host Groups API
|
// Host Groups API
|
||||||
@@ -147,7 +159,7 @@ export const hostsAPI = {
|
|||||||
'X-API-KEY': apiKey
|
'X-API-KEY': apiKey
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
toggleAutoUpdate: (id, autoUpdate) => api.patch(`/hosts/${id}/auto-update`, { autoUpdate })
|
toggleAutoUpdate: (id, autoUpdate) => api.patch(`/hosts/${id}/auto-update`, { auto_update: autoUpdate })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Packages API
|
// Packages API
|
||||||
@@ -178,6 +190,30 @@ export const formatDate = (date) => {
|
|||||||
return new Date(date).toLocaleString()
|
return new Date(date).toLocaleString()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Version API
|
||||||
|
export const versionAPI = {
|
||||||
|
getCurrent: () => api.get('/version/current'),
|
||||||
|
checkUpdates: () => api.get('/version/check-updates'),
|
||||||
|
testSshKey: (data) => api.post('/version/test-ssh-key', data),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auth API
|
||||||
|
export const authAPI = {
|
||||||
|
login: (username, password) => api.post('/auth/login', { username, password }),
|
||||||
|
verifyTfa: (username, token) => api.post('/auth/verify-tfa', { username, token }),
|
||||||
|
signup: (username, email, password, firstName, lastName) => api.post('/auth/signup', { username, email, password, firstName, lastName }),
|
||||||
|
}
|
||||||
|
|
||||||
|
// TFA API
|
||||||
|
export const tfaAPI = {
|
||||||
|
setup: () => api.get('/tfa/setup'),
|
||||||
|
verifySetup: (data) => api.post('/tfa/verify-setup', data),
|
||||||
|
disable: (data) => api.post('/tfa/disable', data),
|
||||||
|
status: () => api.get('/tfa/status'),
|
||||||
|
regenerateBackupCodes: () => api.post('/tfa/regenerate-backup-codes'),
|
||||||
|
verify: (data) => api.post('/tfa/verify', data),
|
||||||
|
}
|
||||||
|
|
||||||
export const formatRelativeTime = (date) => {
|
export const formatRelativeTime = (date) => {
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const diff = now - new Date(date)
|
const diff = now - new Date(date)
|
||||||
|
|||||||
130
frontend/src/utils/osIcons.jsx
Normal file
130
frontend/src/utils/osIcons.jsx
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import {
|
||||||
|
Monitor,
|
||||||
|
Server,
|
||||||
|
HardDrive,
|
||||||
|
Cpu,
|
||||||
|
Zap,
|
||||||
|
Shield,
|
||||||
|
Globe,
|
||||||
|
Terminal
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
// Import OS icons from react-icons
|
||||||
|
import {
|
||||||
|
SiUbuntu,
|
||||||
|
SiDebian,
|
||||||
|
SiCentos,
|
||||||
|
SiFedora,
|
||||||
|
SiArchlinux,
|
||||||
|
SiAlpinelinux,
|
||||||
|
SiLinux,
|
||||||
|
SiMacos
|
||||||
|
} from 'react-icons/si';
|
||||||
|
|
||||||
|
import {
|
||||||
|
DiUbuntu,
|
||||||
|
DiDebian,
|
||||||
|
DiLinux,
|
||||||
|
DiWindows
|
||||||
|
} from 'react-icons/di';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OS Icon mapping utility
|
||||||
|
* Maps operating system types to appropriate react-icons components
|
||||||
|
*/
|
||||||
|
export const getOSIcon = (osType) => {
|
||||||
|
if (!osType) return Monitor;
|
||||||
|
|
||||||
|
const os = osType.toLowerCase();
|
||||||
|
|
||||||
|
// Linux distributions with authentic react-icons
|
||||||
|
if (os.includes('ubuntu')) return SiUbuntu;
|
||||||
|
if (os.includes('debian')) return SiDebian;
|
||||||
|
if (os.includes('centos') || os.includes('rhel') || os.includes('red hat')) return SiCentos;
|
||||||
|
if (os.includes('fedora')) return SiFedora;
|
||||||
|
if (os.includes('arch')) return SiArchlinux;
|
||||||
|
if (os.includes('alpine')) return SiAlpinelinux;
|
||||||
|
if (os.includes('suse') || os.includes('opensuse')) return SiLinux; // SUSE uses generic Linux icon
|
||||||
|
|
||||||
|
// Generic Linux
|
||||||
|
if (os.includes('linux')) return SiLinux;
|
||||||
|
|
||||||
|
// Windows
|
||||||
|
if (os.includes('windows')) return DiWindows;
|
||||||
|
|
||||||
|
// macOS
|
||||||
|
if (os.includes('mac') || os.includes('darwin')) return SiMacos;
|
||||||
|
|
||||||
|
// FreeBSD
|
||||||
|
if (os.includes('freebsd')) return Server;
|
||||||
|
|
||||||
|
// Default fallback
|
||||||
|
return Monitor;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OS Color mapping utility
|
||||||
|
* Maps operating system types to appropriate colors (react-icons have built-in brand colors)
|
||||||
|
*/
|
||||||
|
export const getOSColor = (osType) => {
|
||||||
|
if (!osType) return 'text-gray-500';
|
||||||
|
|
||||||
|
// react-icons already have the proper brand colors built-in
|
||||||
|
// This function is kept for compatibility but returns neutral colors
|
||||||
|
return 'text-gray-600';
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OS Display name utility
|
||||||
|
* Provides clean, formatted OS names for display
|
||||||
|
*/
|
||||||
|
export const getOSDisplayName = (osType) => {
|
||||||
|
if (!osType) return 'Unknown';
|
||||||
|
|
||||||
|
const os = osType.toLowerCase();
|
||||||
|
|
||||||
|
// Linux distributions
|
||||||
|
if (os.includes('ubuntu')) return 'Ubuntu';
|
||||||
|
if (os.includes('debian')) return 'Debian';
|
||||||
|
if (os.includes('centos')) return 'CentOS';
|
||||||
|
if (os.includes('rhel') || os.includes('red hat')) return 'Red Hat Enterprise Linux';
|
||||||
|
if (os.includes('fedora')) return 'Fedora';
|
||||||
|
if (os.includes('arch')) return 'Arch Linux';
|
||||||
|
if (os.includes('suse')) return 'SUSE Linux';
|
||||||
|
if (os.includes('opensuse')) return 'openSUSE';
|
||||||
|
if (os.includes('alpine')) return 'Alpine Linux';
|
||||||
|
|
||||||
|
// Generic Linux
|
||||||
|
if (os.includes('linux')) return 'Linux';
|
||||||
|
|
||||||
|
// Windows
|
||||||
|
if (os.includes('windows')) return 'Windows';
|
||||||
|
|
||||||
|
// macOS
|
||||||
|
if (os.includes('mac') || os.includes('darwin')) return 'macOS';
|
||||||
|
|
||||||
|
// FreeBSD
|
||||||
|
if (os.includes('freebsd')) return 'FreeBSD';
|
||||||
|
|
||||||
|
// Return original if no match
|
||||||
|
return osType;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OS Icon component with proper styling
|
||||||
|
*/
|
||||||
|
export const OSIcon = ({ osType, className = "h-4 w-4", showText = false }) => {
|
||||||
|
const IconComponent = getOSIcon(osType);
|
||||||
|
const displayName = getOSDisplayName(osType);
|
||||||
|
|
||||||
|
if (showText) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<IconComponent className={className} title={displayName} />
|
||||||
|
<span className="text-sm">{displayName}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <IconComponent className={className} title={displayName} />;
|
||||||
|
};
|
||||||
340
package-lock.json
generated
340
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "patchmon",
|
"name": "patchmon",
|
||||||
"version": "1.0.0",
|
"version": "1.2.6",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "patchmon",
|
"name": "patchmon",
|
||||||
"version": "1.0.0",
|
"version": "1.2.6",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"backend",
|
"backend",
|
||||||
"frontend"
|
"frontend"
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
},
|
},
|
||||||
"backend": {
|
"backend": {
|
||||||
"name": "patchmon-backend",
|
"name": "patchmon-backend",
|
||||||
"version": "1.0.0",
|
"version": "1.2.6",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client": "^5.7.0",
|
"@prisma/client": "^5.7.0",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
@@ -32,6 +32,8 @@
|
|||||||
"helmet": "^7.1.0",
|
"helmet": "^7.1.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"moment": "^2.30.1",
|
"moment": "^2.30.1",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
|
"speakeasy": "^2.0.0",
|
||||||
"uuid": "^9.0.1",
|
"uuid": "^9.0.1",
|
||||||
"winston": "^3.11.0"
|
"winston": "^3.11.0"
|
||||||
},
|
},
|
||||||
@@ -45,7 +47,7 @@
|
|||||||
},
|
},
|
||||||
"frontend": {
|
"frontend": {
|
||||||
"name": "patchmon-frontend",
|
"name": "patchmon-frontend",
|
||||||
"version": "1.0.0",
|
"version": "1.2.6",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
@@ -54,11 +56,15 @@
|
|||||||
"axios": "^1.6.2",
|
"axios": "^1.6.2",
|
||||||
"chart.js": "^4.4.0",
|
"chart.js": "^4.4.0",
|
||||||
"clsx": "^2.0.0",
|
"clsx": "^2.0.0",
|
||||||
|
"cors": "^2.8.5",
|
||||||
"date-fns": "^2.30.0",
|
"date-fns": "^2.30.0",
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"http-proxy-middleware": "^2.0.6",
|
||||||
"lucide-react": "^0.294.0",
|
"lucide-react": "^0.294.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-chartjs-2": "^5.2.0",
|
"react-chartjs-2": "^5.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
|
"react-icons": "^5.5.0",
|
||||||
"react-router-dom": "^6.20.1"
|
"react-router-dom": "^6.20.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -1768,6 +1774,24 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/http-proxy": {
|
||||||
|
"version": "1.17.16",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.16.tgz",
|
||||||
|
"integrity": "sha512-sdWoUajOB1cd0A8cRRQ1cfyWNbmFKLAqBB89Y8x5iYyG/mkJHc0YUH8pdWBy2omi9qtCpiIgGjuwO0dQST2l5w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/node": {
|
||||||
|
"version": "24.5.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.5.2.tgz",
|
||||||
|
"integrity": "sha512-FYxk1I7wPv3K2XBaoyH2cTnocQEu8AOZ60hPbsyukMPLv5/5qr7V1i8PLHdl6Zf87I+xZXFvPCXYjiTFq+YSDQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"undici-types": "~7.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/prop-types": {
|
"node_modules/@types/prop-types": {
|
||||||
"version": "15.7.15",
|
"version": "15.7.15",
|
||||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||||
@@ -1887,7 +1911,6 @@
|
|||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
@@ -1897,7 +1920,6 @@
|
|||||||
"version": "4.3.0",
|
"version": "4.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"color-convert": "^2.0.1"
|
"color-convert": "^2.0.1"
|
||||||
@@ -2182,6 +2204,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/base32.js": {
|
||||||
|
"version": "0.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/base32.js/-/base32.js-0.0.1.tgz",
|
||||||
|
"integrity": "sha512-EGHIRiegFa62/SsA1J+Xs2tIzludPdzM064N9wjbiEgHnGnJ1V0WEpA4pEwCYT5nDvZk3ubf0shqaCS7k6xeUQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/bcryptjs": {
|
"node_modules/bcryptjs": {
|
||||||
"version": "2.4.3",
|
"version": "2.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz",
|
||||||
@@ -2240,7 +2268,6 @@
|
|||||||
"version": "3.0.3",
|
"version": "3.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
|
||||||
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
|
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fill-range": "^7.1.1"
|
"fill-range": "^7.1.1"
|
||||||
@@ -2355,6 +2382,15 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/camelcase": {
|
||||||
|
"version": "5.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
||||||
|
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/camelcase-css": {
|
"node_modules/camelcase-css": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
|
||||||
@@ -2491,7 +2527,6 @@
|
|||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"color-name": "~1.1.4"
|
"color-name": "~1.1.4"
|
||||||
@@ -2768,6 +2803,15 @@
|
|||||||
"ms": "2.0.0"
|
"ms": "2.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/decamelize": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/deep-is": {
|
"node_modules/deep-is": {
|
||||||
"version": "0.1.4",
|
"version": "0.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
||||||
@@ -2846,6 +2890,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
|
"node_modules/dijkstrajs": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/dlv": {
|
"node_modules/dlv": {
|
||||||
"version": "1.1.3",
|
"version": "1.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
|
||||||
@@ -2925,7 +2975,6 @@
|
|||||||
"version": "8.0.0",
|
"version": "8.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/enabled": {
|
"node_modules/enabled": {
|
||||||
@@ -3464,6 +3513,12 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/eventemitter3": {
|
||||||
|
"version": "4.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
|
||||||
|
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/express": {
|
"node_modules/express": {
|
||||||
"version": "4.21.2",
|
"version": "4.21.2",
|
||||||
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
|
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
|
||||||
@@ -3609,7 +3664,6 @@
|
|||||||
"version": "7.1.1",
|
"version": "7.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||||
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
|
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"to-regex-range": "^5.0.1"
|
"to-regex-range": "^5.0.1"
|
||||||
@@ -3858,7 +3912,6 @@
|
|||||||
"version": "2.0.5",
|
"version": "2.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "6.* || 8.* || >= 10.*"
|
"node": "6.* || 8.* || >= 10.*"
|
||||||
@@ -4122,6 +4175,44 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/http-proxy": {
|
||||||
|
"version": "1.18.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz",
|
||||||
|
"integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"eventemitter3": "^4.0.0",
|
||||||
|
"follow-redirects": "^1.0.0",
|
||||||
|
"requires-port": "^1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/http-proxy-middleware": {
|
||||||
|
"version": "2.0.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.9.tgz",
|
||||||
|
"integrity": "sha512-c1IyJYLYppU574+YI7R4QyX2ystMtVXZwIdzazUIPIJsHuWNd+mho2j+bKoHftndicGj9yh+xjd+l0yj7VeT1Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/http-proxy": "^1.17.8",
|
||||||
|
"http-proxy": "^1.18.1",
|
||||||
|
"is-glob": "^4.0.1",
|
||||||
|
"is-plain-obj": "^3.0.0",
|
||||||
|
"micromatch": "^4.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/express": "^4.17.13"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/express": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/iconv-lite": {
|
"node_modules/iconv-lite": {
|
||||||
"version": "0.4.24",
|
"version": "0.4.24",
|
||||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
|
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
|
||||||
@@ -4378,7 +4469,6 @@
|
|||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
||||||
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
|
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
@@ -4404,7 +4494,6 @@
|
|||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
@@ -4433,7 +4522,6 @@
|
|||||||
"version": "4.0.3",
|
"version": "4.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
|
||||||
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
|
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"is-extglob": "^2.1.1"
|
"is-extglob": "^2.1.1"
|
||||||
@@ -4472,7 +4560,6 @@
|
|||||||
"version": "7.0.0",
|
"version": "7.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
|
||||||
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
|
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.12.0"
|
"node": ">=0.12.0"
|
||||||
@@ -4505,6 +4592,18 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/is-plain-obj": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/is-regex": {
|
"node_modules/is-regex": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
|
||||||
@@ -5076,7 +5175,6 @@
|
|||||||
"version": "4.0.8",
|
"version": "4.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
|
||||||
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
|
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"braces": "^3.0.3",
|
"braces": "^3.0.3",
|
||||||
@@ -5523,6 +5621,15 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/p-try": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/package-json-from-dist": {
|
"node_modules/package-json-from-dist": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
|
||||||
@@ -5564,7 +5671,6 @@
|
|||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||||
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
@@ -5638,7 +5744,6 @@
|
|||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8.6"
|
"node": ">=8.6"
|
||||||
@@ -5667,6 +5772,15 @@
|
|||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pngjs": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/possible-typed-array-names": {
|
"node_modules/possible-typed-array-names": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
||||||
@@ -5926,6 +6040,141 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/qrcode": {
|
||||||
|
"version": "1.5.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
||||||
|
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"dijkstrajs": "^1.0.1",
|
||||||
|
"pngjs": "^5.0.0",
|
||||||
|
"yargs": "^15.3.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"qrcode": "bin/qrcode"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/node_modules/cliui": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"string-width": "^4.2.0",
|
||||||
|
"strip-ansi": "^6.0.0",
|
||||||
|
"wrap-ansi": "^6.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/node_modules/find-up": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"locate-path": "^5.0.0",
|
||||||
|
"path-exists": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/node_modules/locate-path": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"p-locate": "^4.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/node_modules/p-limit": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"p-try": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/node_modules/p-locate": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"p-limit": "^2.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/node_modules/wrap-ansi": {
|
||||||
|
"version": "6.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
||||||
|
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-styles": "^4.0.0",
|
||||||
|
"string-width": "^4.1.0",
|
||||||
|
"strip-ansi": "^6.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/node_modules/y18n": {
|
||||||
|
"version": "4.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
||||||
|
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/node_modules/yargs": {
|
||||||
|
"version": "15.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
|
||||||
|
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cliui": "^6.0.0",
|
||||||
|
"decamelize": "^1.2.0",
|
||||||
|
"find-up": "^4.1.0",
|
||||||
|
"get-caller-file": "^2.0.1",
|
||||||
|
"require-directory": "^2.1.1",
|
||||||
|
"require-main-filename": "^2.0.0",
|
||||||
|
"set-blocking": "^2.0.0",
|
||||||
|
"string-width": "^4.2.0",
|
||||||
|
"which-module": "^2.0.0",
|
||||||
|
"y18n": "^4.0.0",
|
||||||
|
"yargs-parser": "^18.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/node_modules/yargs-parser": {
|
||||||
|
"version": "18.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
|
||||||
|
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"camelcase": "^5.0.0",
|
||||||
|
"decamelize": "^1.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/qs": {
|
"node_modules/qs": {
|
||||||
"version": "6.13.0",
|
"version": "6.13.0",
|
||||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",
|
"resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",
|
||||||
@@ -6021,6 +6270,15 @@
|
|||||||
"react": "^18.3.1"
|
"react": "^18.3.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-icons": {
|
||||||
|
"version": "5.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz",
|
||||||
|
"integrity": "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-is": {
|
"node_modules/react-is": {
|
||||||
"version": "16.13.1",
|
"version": "16.13.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||||
@@ -6155,12 +6413,23 @@
|
|||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||||
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/require-main-filename": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/requires-port": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/resolve": {
|
"node_modules/resolve": {
|
||||||
"version": "2.0.0-next.5",
|
"version": "2.0.0-next.5",
|
||||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz",
|
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz",
|
||||||
@@ -6456,6 +6725,12 @@
|
|||||||
"node": ">= 0.8.0"
|
"node": ">= 0.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/set-blocking": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/set-function-length": {
|
"node_modules/set-function-length": {
|
||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
|
||||||
@@ -6670,6 +6945,18 @@
|
|||||||
"integrity": "sha512-zC8zGoGkmc8J9ndvml8Xksr1Amk9qBujgbF0JAIWO7kXr43w0h/0GJNM/Vustixu+YE8N/MTrQ7N31FvHUACxQ==",
|
"integrity": "sha512-zC8zGoGkmc8J9ndvml8Xksr1Amk9qBujgbF0JAIWO7kXr43w0h/0GJNM/Vustixu+YE8N/MTrQ7N31FvHUACxQ==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/speakeasy": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/speakeasy/-/speakeasy-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-lW2A2s5LKi8rwu77ewisuUOtlCydF/hmQSOJjpTqTj1gZLkNgTaYnyvfxy2WBr4T/h+9c4g8HIITfj83OkFQFw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"base32.js": "0.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/stack-trace": {
|
"node_modules/stack-trace": {
|
||||||
"version": "0.0.10",
|
"version": "0.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz",
|
||||||
@@ -6715,7 +7002,6 @@
|
|||||||
"version": "4.2.3",
|
"version": "4.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"emoji-regex": "^8.0.0",
|
"emoji-regex": "^8.0.0",
|
||||||
@@ -6844,7 +7130,6 @@
|
|||||||
"version": "6.0.1",
|
"version": "6.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ansi-regex": "^5.0.1"
|
"ansi-regex": "^5.0.1"
|
||||||
@@ -7139,7 +7424,6 @@
|
|||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||||
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
|
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"is-number": "^7.0.0"
|
"is-number": "^7.0.0"
|
||||||
@@ -7342,6 +7626,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/undici-types": {
|
||||||
|
"version": "7.12.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.12.0.tgz",
|
||||||
|
"integrity": "sha512-goOacqME2GYyOZZfb5Lgtu+1IDmAlAEu5xnD3+xTzS10hT0vzpf0SPjkXwAw9Jm+4n/mQGDP3LO8CPbYROeBfQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/unpipe": {
|
"node_modules/unpipe": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
||||||
@@ -7627,6 +7917,12 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/which-module": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/which-typed-array": {
|
"node_modules/which-typed-array": {
|
||||||
"version": "1.1.19",
|
"version": "1.1.19",
|
||||||
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz",
|
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "patchmon",
|
"name": "patchmon",
|
||||||
"version": "1.0.0",
|
"version": "1.2.6",
|
||||||
"description": "Linux Patch Monitoring System",
|
"description": "Linux Patch Monitoring System",
|
||||||
"private": true,
|
"private": true,
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ async function setupAdminUser() {
|
|||||||
console.log('=====================================\n');
|
console.log('=====================================\n');
|
||||||
|
|
||||||
// Check if any users exist
|
// Check if any users exist
|
||||||
const existingUsers = await prisma.user.count();
|
const existingUsers = await prisma.users.count();
|
||||||
if (existingUsers > 0) {
|
if (existingUsers > 0) {
|
||||||
console.log('⚠️ Users already exist in the database.');
|
console.log('⚠️ Users already exist in the database.');
|
||||||
const overwrite = await question('Do you want to create another admin user? (y/N): ');
|
const overwrite = await question('Do you want to create another admin user? (y/N): ');
|
||||||
@@ -47,7 +47,7 @@ async function setupAdminUser() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if username or email already exists
|
// Check if username or email already exists
|
||||||
const existingUser = await prisma.user.findFirst({
|
const existingUser = await prisma.users.findFirst({
|
||||||
where: {
|
where: {
|
||||||
OR: [
|
OR: [
|
||||||
{ username: username.trim() },
|
{ username: username.trim() },
|
||||||
@@ -66,19 +66,23 @@ async function setupAdminUser() {
|
|||||||
const passwordHash = await bcrypt.hash(password, 12);
|
const passwordHash = await bcrypt.hash(password, 12);
|
||||||
|
|
||||||
// Create admin user
|
// Create admin user
|
||||||
const user = await prisma.user.create({
|
const user = await prisma.users.create({
|
||||||
data: {
|
data: {
|
||||||
|
id: require('crypto').randomUUID(),
|
||||||
username: username.trim(),
|
username: username.trim(),
|
||||||
email: email.trim(),
|
email: email.trim(),
|
||||||
passwordHash: passwordHash,
|
password_hash: passwordHash,
|
||||||
role: 'admin'
|
role: 'admin',
|
||||||
|
is_active: true,
|
||||||
|
created_at: new Date(),
|
||||||
|
updated_at: new Date()
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
username: true,
|
username: true,
|
||||||
email: true,
|
email: true,
|
||||||
role: true,
|
role: true,
|
||||||
createdAt: true
|
created_at: true
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -87,7 +91,7 @@ async function setupAdminUser() {
|
|||||||
console.log(` Username: ${user.username}`);
|
console.log(` Username: ${user.username}`);
|
||||||
console.log(` Email: ${user.email}`);
|
console.log(` Email: ${user.email}`);
|
||||||
console.log(` Role: ${user.role}`);
|
console.log(` Role: ${user.role}`);
|
||||||
console.log(` Created: ${user.createdAt.toISOString()}`);
|
console.log(` Created: ${user.created_at.toISOString()}`);
|
||||||
|
|
||||||
console.log('\n🎉 Setup complete!');
|
console.log('\n🎉 Setup complete!');
|
||||||
console.log('\nNext steps:');
|
console.log('\nNext steps:');
|
||||||
|
|||||||
Reference in New Issue
Block a user