mirror of
https://github.com/9technologygroup/patchmon.net.git
synced 2025-11-04 22:13:21 +00:00
Compare commits
398 Commits
v1.2.4
...
renovate/p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15b2023668 | ||
|
|
566c415471 | ||
|
|
cfc91243eb | ||
|
|
84cf31869b | ||
|
|
18c9d241eb | ||
|
|
86b5da3ea0 | ||
|
|
c9b5ee63d8 | ||
|
|
ac4415e1dc | ||
|
|
3737a5a935 | ||
|
|
bcce48948a | ||
|
|
5e4c628110 | ||
|
|
a8668ee3f3 | ||
|
|
5487206384 | ||
|
|
daa31973f9 | ||
|
|
561c78fb08 | ||
|
|
6d3f2d94ba | ||
|
|
93534ebe52 | ||
|
|
5cf2811bfd | ||
|
|
8fd91eae1a | ||
|
|
da8c661d20 | ||
|
|
2bf639e315 | ||
|
|
c02ac4bd6f | ||
|
|
4e0eaf7323 | ||
|
|
ef9ef58bcb | ||
|
|
29afe3da1f | ||
|
|
a861e4f9eb | ||
|
|
12ef6fd8e1 | ||
|
|
ba9de097dc | ||
|
|
8103581d17 | ||
|
|
cdb24520d8 | ||
|
|
831adf3038 | ||
|
|
2a1eed1354 | ||
|
|
7819d4512e | ||
|
|
a305fe23d3 | ||
|
|
2b36e88d85 | ||
|
|
6624ec002d | ||
|
|
840779844a | ||
|
|
f91d3324ba | ||
|
|
8c60b5277e | ||
|
|
2ac756af84 | ||
|
|
e227004d6b | ||
|
|
d379473568 | ||
|
|
2edc773adf | ||
|
|
2db839556c | ||
|
|
aab6fc244e | ||
|
|
811f5b5885 | ||
|
|
b43c9e94fd | ||
|
|
2e2a554aa3 | ||
|
|
eabcfd370c | ||
|
|
55cb07b3c8 | ||
|
|
0e049ec3d5 | ||
|
|
a2464fac5c | ||
|
|
5dc3e8ba81 | ||
|
|
63817b450f | ||
|
|
1fa0502d7d | ||
|
|
581dc5884c | ||
|
|
dcaffe2805 | ||
|
|
a3005bccb4 | ||
|
|
499ef9d5d9 | ||
|
|
6eb6ea3fd6 | ||
|
|
a27c607d9e | ||
|
|
d4e0abd407 | ||
|
|
8d447cab0d | ||
|
|
6988ecab12 | ||
|
|
fd108c6a21 | ||
|
|
3ea8cc74b6 | ||
|
|
a43fc9d380 | ||
|
|
864719b4b3 | ||
|
|
cc89df161b | ||
|
|
2659a930d6 | ||
|
|
fa57b35270 | ||
|
|
766d36ff80 | ||
|
|
3a76d54707 | ||
|
|
dd28e741d4 | ||
|
|
35d3c28ae5 | ||
|
|
3cf2ada84e | ||
|
|
b25bba50a7 | ||
|
|
811930d1e2 | ||
|
|
f3db16d6d0 | ||
|
|
b3887c818d | ||
|
|
f7b73ba280 | ||
|
|
5c2bacb322 | ||
|
|
657017801b | ||
|
|
5e8cfa6b63 | ||
|
|
f9bd56215d | ||
|
|
aa8b42cbb0 | ||
|
|
51f6fabd45 | ||
|
|
32ab004f3f | ||
|
|
71b27b4bcf | ||
|
|
60ca2064bf | ||
|
|
5ccd0aa163 | ||
|
|
a13b4941cd | ||
|
|
482a9e27c9 | ||
|
|
f085596b87 | ||
|
|
757feab9cd | ||
|
|
fffc571453 | ||
|
|
6f59a1981d | ||
|
|
8bb16f0896 | ||
|
|
b454b8d130 | ||
|
|
3fc4b799be | ||
|
|
9c39d83fe5 | ||
|
|
2ce6d9cd73 | ||
|
|
e97ccc5cbd | ||
|
|
1f77e459ce | ||
|
|
9ddc27e50c | ||
|
|
26c58f687b | ||
|
|
c004734a44 | ||
|
|
841b97cb5d | ||
|
|
8464a3692d | ||
|
|
258bc67efc | ||
|
|
b3c1319df4 | ||
|
|
f6d21e0ed5 | ||
|
|
b85eddf22a | ||
|
|
01dac49c05 | ||
|
|
ab97e04cc1 | ||
|
|
50b47bdd65 | ||
|
|
7a17958ad8 | ||
|
|
806f554b96 | ||
|
|
373ef8f468 | ||
|
|
513c268b36 | ||
|
|
13c4342135 | ||
|
|
bbb97dbfda | ||
|
|
31a95ed946 | ||
|
|
3eb4130865 | ||
|
|
5a498a5f7a | ||
|
|
e0eb544205 | ||
|
|
51982010db | ||
|
|
dc68afcb87 | ||
|
|
bec09b9457 | ||
|
|
55c8f74b73 | ||
|
|
16ea1dc743 | ||
|
|
8c326c8fe2 | ||
|
|
2abc9b1f8a | ||
|
|
e5f3b0ed26 | ||
|
|
bfc5db11da | ||
|
|
a0bea9b6e5 | ||
|
|
ebda7331a9 | ||
|
|
9963cfa417 | ||
|
|
4e6a9829cf | ||
|
|
b99f4aad4e | ||
|
|
7a8e9d95a0 | ||
|
|
ac22adde67 | ||
|
|
db1f03b0e0 | ||
|
|
74cc13b7de | ||
|
|
65025b50cf | ||
|
|
de76836ba0 | ||
|
|
fe448d0111 | ||
|
|
1b08be8864 | ||
|
|
28124f5fba | ||
|
|
f789c1cebe | ||
|
|
d13469ce33 | ||
|
|
443ec145e1 | ||
|
|
42f882c1c6 | ||
|
|
69acd1726c | ||
|
|
02f9899b23 | ||
|
|
0742c4b05c | ||
|
|
5d8a1e71d6 | ||
|
|
84c26054b2 | ||
|
|
f254b54404 | ||
|
|
7682d2fffd | ||
|
|
d6db557d87 | ||
|
|
af62a466c8 | ||
|
|
434fa86941 | ||
|
|
a61a8681e0 | ||
|
|
8eb75fba7d | ||
|
|
b3d7e49961 | ||
|
|
8be25283dc | ||
|
|
ed0cf79b53 | ||
|
|
678efa9574 | ||
|
|
8ca22dc7ab | ||
|
|
3466c0c7fb | ||
|
|
3da0625231 | ||
|
|
21d6a3b763 | ||
|
|
33b2b4b0fe | ||
|
|
479909ecf3 | ||
|
|
61ca05526b | ||
|
|
e04680bc33 | ||
|
|
a765b58868 | ||
|
|
654943a00c | ||
|
|
b54900aaed | ||
|
|
cdaba97232 | ||
|
|
45ec71c387 | ||
|
|
823ae7f30a | ||
|
|
8553f717e2 | ||
|
|
841b6e41ff | ||
|
|
d626493100 | ||
|
|
12a82a8522 | ||
|
|
44f90edcd2 | ||
|
|
1cc5254331 | ||
|
|
5bf6283a1c | ||
|
|
e9843f80f8 | ||
|
|
b49ea6b197 | ||
|
|
49c02a54dc | ||
|
|
c7b177d5cb | ||
|
|
8409b71857 | ||
|
|
78eb2b183e | ||
|
|
b49d225e32 | ||
|
|
470948165c | ||
|
|
20df1eceb1 | ||
|
|
372bb42fc5 | ||
|
|
4a6b486ba1 | ||
|
|
1f5b33eb73 | ||
|
|
aca8b300dd | ||
|
|
c6459a965f | ||
|
|
3b72794307 | ||
|
|
b5b110fed2 | ||
|
|
40bf8747b1 | ||
|
|
178f871582 | ||
|
|
840664c39e | ||
|
|
c18696f772 | ||
|
|
6adbbca439 | ||
|
|
edfd82a86d | ||
|
|
bed52a04b2 | ||
|
|
7369e23061 | ||
|
|
271d2c0df1 | ||
|
|
518b08895e | ||
|
|
aba8ec5d01 | ||
|
|
630949b7b9 | ||
|
|
82d0ff315f | ||
|
|
df04770113 | ||
|
|
038d4c515b | ||
|
|
f99e01a120 | ||
|
|
175042690e | ||
|
|
102546e45d | ||
|
|
751a202fec | ||
|
|
c886b812d6 | ||
|
|
be3fe52aea | ||
|
|
d85920669d | ||
|
|
c4e056711b | ||
|
|
60fa598803 | ||
|
|
5c66887732 | ||
|
|
ba087eb23e | ||
|
|
e3aa28a8d9 | ||
|
|
71d9884a86 | ||
|
|
2c47999cb4 | ||
|
|
6bf2a21f48 | ||
|
|
a76a722364 | ||
|
|
40a9003e6f | ||
|
|
e9bac06526 | ||
|
|
0c0446ad69 | ||
|
|
dbebb866b9 | ||
|
|
eb3f3599f9 | ||
|
|
527b0ccc3c | ||
|
|
1ff3da0a21 | ||
|
|
641272dfb8 | ||
|
|
3c01c4bfb2 | ||
|
|
35eb9303b1 | ||
|
|
469107c149 | ||
|
|
22f6befc89 | ||
|
|
03802daf13 | ||
|
|
17509cbf3c | ||
|
|
ffbf5f12e5 | ||
|
|
3bdf3d1843 | ||
|
|
ea550259ff | ||
|
|
047fdb4bd1 | ||
|
|
adc142fd85 | ||
|
|
42f6971da7 | ||
|
|
0414ea39d0 | ||
|
|
6357839619 | ||
|
|
c840a3fdcc | ||
|
|
a1bf2df59d | ||
|
|
67a5462a25 | ||
|
|
a32007f56b | ||
|
|
6e1ec0d031 | ||
|
|
ce2ba0face | ||
|
|
53f8471d75 | ||
|
|
74f42b5bee | ||
|
|
a84da7c731 | ||
|
|
83ce7c64fd | ||
|
|
15902da87c | ||
|
|
a11f180d23 | ||
|
|
35bf858977 | ||
|
|
330f80478d | ||
|
|
b43b20fbe9 | ||
|
|
591389a91f | ||
|
|
6d70a67a49 | ||
|
|
0cca6607d7 | ||
|
|
5f0ce7f26a | ||
|
|
38d0dcb3c4 | ||
|
|
03d6ebb43a | ||
|
|
b7ce2a3f54 | ||
|
|
783f8d73fe | ||
|
|
9f72690f82 | ||
|
|
48d2a656e5 | ||
|
|
e9402dbf32 | ||
|
|
dc1ad6882c | ||
|
|
22f616e110 | ||
|
|
3af269ee47 | ||
|
|
f4ece11636 | ||
|
|
da6bd2c098 | ||
|
|
a479003ba9 | ||
|
|
78f4eff375 | ||
|
|
c4376d35c9 | ||
|
|
9f3016be57 | ||
|
|
c3013cccd3 | ||
|
|
456184d327 | ||
|
|
a9c579bdd0 | ||
|
|
13fe8a0bc5 | ||
|
|
1d8742ccad | ||
|
|
bccfc0876f | ||
|
|
43a42aa931 | ||
|
|
ad5627362b | ||
|
|
61db61e1ba | ||
|
|
8dc702e7fc | ||
|
|
45f5ccc638 | ||
|
|
3fbe1369cf | ||
|
|
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 |
25
.github/workflows/app_build.yml
vendored
Normal file
25
.github/workflows/app_build.yml
vendored
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
name: Build on Merge
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
paths-ignore:
|
||||||
|
- 'docker/**'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: self-hosted
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
|
- name: Run rebuild script
|
||||||
|
run: /root/patchmon/platform/scripts/app_build.sh ${{ github.ref_name }}
|
||||||
|
|
||||||
|
rebuild-pmon:
|
||||||
|
runs-on: self-hosted
|
||||||
|
needs: deploy
|
||||||
|
if: github.ref_name == 'dev'
|
||||||
|
steps:
|
||||||
|
- name: Rebuild pmon
|
||||||
|
run: /root/patchmon/platform/scripts/manage_pmon_auto.sh
|
||||||
28
.github/workflows/code_quality.yml
vendored
Normal file
28
.github/workflows/code_quality.yml
vendored
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
name: Code quality
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
paths-ignore:
|
||||||
|
- 'docker/**'
|
||||||
|
pull_request:
|
||||||
|
paths-ignore:
|
||||||
|
- 'docker/**'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Setup Biome
|
||||||
|
uses: biomejs/setup-biome@v2
|
||||||
|
with:
|
||||||
|
version: latest
|
||||||
|
|
||||||
|
- name: Run Biome
|
||||||
|
run: biome ci .
|
||||||
76
.github/workflows/docker.yml
vendored
Normal file
76
.github/workflows/docker.yml
vendored
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
name: Build and Push Docker Images
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
push:
|
||||||
|
description: Push images to registry
|
||||||
|
required: false
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
|
||||||
|
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: Log in to container registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ github.repository_owner }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Extract metadata (tags, labels)
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ env.REGISTRY }}/${{ github.repository }}-${{ matrix.image }}
|
||||||
|
tags: |
|
||||||
|
type=ref,event=pr
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
|
type=semver,pattern={{major}}
|
||||||
|
type=edge,branch=main
|
||||||
|
|
||||||
|
- 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 if:
|
||||||
|
# - Event is not workflow_dispatch OR input 'push' is true
|
||||||
|
# AND
|
||||||
|
# - Event is not pull_request OR the PR is from the same repository (to avoid pushing from forks)
|
||||||
|
push: ${{ (github.event_name != 'workflow_dispatch' || inputs.push == 'true') && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }}
|
||||||
|
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 }}
|
||||||
12
.gitignore
vendored
12
.gitignore
vendored
@@ -71,6 +71,13 @@ jspm_packages/
|
|||||||
.cache/
|
.cache/
|
||||||
public
|
public
|
||||||
|
|
||||||
|
# Exception: Allow frontend/public/assets for logo files
|
||||||
|
!frontend/public/
|
||||||
|
!frontend/public/assets/
|
||||||
|
!frontend/public/assets/*.png
|
||||||
|
!frontend/public/assets/*.svg
|
||||||
|
!frontend/public/assets/*.jpg
|
||||||
|
|
||||||
# Storybook build outputs
|
# Storybook build outputs
|
||||||
.out
|
.out
|
||||||
.storybook-out
|
.storybook-out
|
||||||
@@ -130,6 +137,8 @@ agents/*.log
|
|||||||
test-results/
|
test-results/
|
||||||
playwright-report/
|
playwright-report/
|
||||||
test-results.xml
|
test-results.xml
|
||||||
|
test_*.sh
|
||||||
|
test-*.sh
|
||||||
|
|
||||||
# Package manager lock files (uncomment if you want to ignore them)
|
# Package manager lock files (uncomment if you want to ignore them)
|
||||||
# package-lock.json
|
# package-lock.json
|
||||||
@@ -140,6 +149,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/compose_dev_*
|
||||||
|
|||||||
674
LICENSE
Normal file
674
LICENSE
Normal file
@@ -0,0 +1,674 @@
|
|||||||
|
GNU GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 29 June 2007
|
||||||
|
|
||||||
|
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
Preamble
|
||||||
|
|
||||||
|
The GNU General Public License is a free, copyleft license for
|
||||||
|
software and other kinds of works.
|
||||||
|
|
||||||
|
The licenses for most software and other practical works are designed
|
||||||
|
to take away your freedom to share and change the works. By contrast,
|
||||||
|
the GNU General Public License is intended to guarantee your freedom to
|
||||||
|
share and change all versions of a program--to make sure it remains free
|
||||||
|
software for all its users. We, the Free Software Foundation, use the
|
||||||
|
GNU General Public License for most of our software; it applies also to
|
||||||
|
any other work released this way by its authors. You can apply it to
|
||||||
|
your programs, too.
|
||||||
|
|
||||||
|
When we speak of free software, we are referring to freedom, not
|
||||||
|
price. Our General Public Licenses are designed to make sure that you
|
||||||
|
have the freedom to distribute copies of free software (and charge for
|
||||||
|
them if you wish), that you receive source code or can get it if you
|
||||||
|
want it, that you can change the software or use pieces of it in new
|
||||||
|
free programs, and that you know you can do these things.
|
||||||
|
|
||||||
|
To protect your rights, we need to prevent others from denying you
|
||||||
|
these rights or asking you to surrender the rights. Therefore, you have
|
||||||
|
certain responsibilities if you distribute copies of the software, or if
|
||||||
|
you modify it: responsibilities to respect the freedom of others.
|
||||||
|
|
||||||
|
For example, if you distribute copies of such a program, whether
|
||||||
|
gratis or for a fee, you must pass on to the recipients the same
|
||||||
|
freedoms that you received. You must make sure that they, too, receive
|
||||||
|
or can get the source code. And you must show them these terms so they
|
||||||
|
know their rights.
|
||||||
|
|
||||||
|
Developers that use the GNU GPL protect your rights with two steps:
|
||||||
|
(1) assert copyright on the software, and (2) offer you this License
|
||||||
|
giving you legal permission to copy, distribute and/or modify it.
|
||||||
|
|
||||||
|
For the developers' and authors' protection, the GPL clearly explains
|
||||||
|
that there is no warranty for this free software. For both users' and
|
||||||
|
authors' sake, the GPL requires that modified versions be marked as
|
||||||
|
changed, so that their problems will not be attributed erroneously to
|
||||||
|
authors of previous versions.
|
||||||
|
|
||||||
|
Some devices are designed to deny users access to install or run
|
||||||
|
modified versions of the software inside them, although the manufacturer
|
||||||
|
can do so. This is fundamentally incompatible with the aim of
|
||||||
|
protecting users' freedom to change the software. The systematic
|
||||||
|
pattern of such abuse occurs in the area of products for individuals to
|
||||||
|
use, which is precisely where it is most unacceptable. Therefore, we
|
||||||
|
have designed this version of the GPL to prohibit the practice for those
|
||||||
|
products. If such problems arise substantially in other domains, we
|
||||||
|
stand ready to extend this provision to those domains in future versions
|
||||||
|
of the GPL, as needed to protect the freedom of users.
|
||||||
|
|
||||||
|
Finally, every program is threatened constantly by software patents.
|
||||||
|
States should not allow patents to restrict development and use of
|
||||||
|
software on general-purpose computers, but in those that do, we wish to
|
||||||
|
avoid the special danger that patents applied to a free program could
|
||||||
|
make it effectively proprietary. To prevent this, the GPL assures that
|
||||||
|
patents cannot be used to render the program non-free.
|
||||||
|
|
||||||
|
The precise terms and conditions for copying, distribution and
|
||||||
|
modification follow.
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
0. Definitions.
|
||||||
|
|
||||||
|
"This License" refers to version 3 of the GNU General Public License.
|
||||||
|
|
||||||
|
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||||
|
works, such as semiconductor masks.
|
||||||
|
|
||||||
|
"The Program" refers to any copyrightable work licensed under this
|
||||||
|
License. Each licensee is addressed as "you". "Licensees" and
|
||||||
|
"recipients" may be individuals or organizations.
|
||||||
|
|
||||||
|
To "modify" a work means to copy from or adapt all or part of the work
|
||||||
|
in a fashion requiring copyright permission, other than the making of an
|
||||||
|
exact copy. The resulting work is called a "modified version" of the
|
||||||
|
earlier work or a work "based on" the earlier work.
|
||||||
|
|
||||||
|
A "covered work" means either the unmodified Program or a work based
|
||||||
|
on the Program.
|
||||||
|
|
||||||
|
To "propagate" a work means to do anything with it that, without
|
||||||
|
permission, would make you directly or secondarily liable for
|
||||||
|
infringement under applicable copyright law, except executing it on a
|
||||||
|
computer or modifying a private copy. Propagation includes copying,
|
||||||
|
distribution (with or without modification), making available to the
|
||||||
|
public, and in some countries other activities as well.
|
||||||
|
|
||||||
|
To "convey" a work means any kind of propagation that enables other
|
||||||
|
parties to make or receive copies. Mere interaction with a user through
|
||||||
|
a computer network, with no transfer of a copy, is not conveying.
|
||||||
|
|
||||||
|
An interactive user interface displays "Appropriate Legal Notices"
|
||||||
|
to the extent that it includes a convenient and prominently visible
|
||||||
|
feature that (1) displays an appropriate copyright notice, and (2)
|
||||||
|
tells the user that there is no warranty for the work (except to the
|
||||||
|
extent that warranties are provided), that licensees may convey the
|
||||||
|
work under this License, and how to view a copy of this License. If
|
||||||
|
the interface presents a list of user commands or options, such as a
|
||||||
|
menu, a prominent item in the list meets this criterion.
|
||||||
|
|
||||||
|
1. Source Code.
|
||||||
|
|
||||||
|
The "source code" for a work means the preferred form of the work
|
||||||
|
for making modifications to it. "Object code" means any non-source
|
||||||
|
form of a work.
|
||||||
|
|
||||||
|
A "Standard Interface" means an interface that either is an official
|
||||||
|
standard defined by a recognized standards body, or, in the case of
|
||||||
|
interfaces specified for a particular programming language, one that
|
||||||
|
is widely used among developers working in that language.
|
||||||
|
|
||||||
|
The "System Libraries" of an executable work include anything, other
|
||||||
|
than the work as a whole, that (a) is included in the normal form of
|
||||||
|
packaging a Major Component, but which is not part of that Major
|
||||||
|
Component, and (b) serves only to enable use of the work with that
|
||||||
|
Major Component, or to implement a Standard Interface for which an
|
||||||
|
implementation is available to the public in source code form. A
|
||||||
|
"Major Component", in this context, means a major essential component
|
||||||
|
(kernel, window system, and so on) of the specific operating system
|
||||||
|
(if any) on which the executable work runs, or a compiler used to
|
||||||
|
produce the work, or an object code interpreter used to run it.
|
||||||
|
|
||||||
|
The "Corresponding Source" for a work in object code form means all
|
||||||
|
the source code needed to generate, install, and (for an executable
|
||||||
|
work) run the object code and to modify the work, including scripts to
|
||||||
|
control those activities. However, it does not include the work's
|
||||||
|
System Libraries, or general-purpose tools or generally available free
|
||||||
|
programs which are used unmodified in performing those activities but
|
||||||
|
which are not part of the work. For example, Corresponding Source
|
||||||
|
includes interface definition files associated with source files for
|
||||||
|
the work, and the source code for shared libraries and dynamically
|
||||||
|
linked subprograms that the work is specifically designed to require,
|
||||||
|
such as by intimate data communication or control flow between those
|
||||||
|
subprograms and other parts of the work.
|
||||||
|
|
||||||
|
The Corresponding Source need not include anything that users
|
||||||
|
can regenerate automatically from other parts of the Corresponding
|
||||||
|
Source.
|
||||||
|
|
||||||
|
The Corresponding Source for a work in source code form is that
|
||||||
|
same work.
|
||||||
|
|
||||||
|
2. Basic Permissions.
|
||||||
|
|
||||||
|
All rights granted under this License are granted for the term of
|
||||||
|
copyright on the Program, and are irrevocable provided the stated
|
||||||
|
conditions are met. This License explicitly affirms your unlimited
|
||||||
|
permission to run the unmodified Program. The output from running a
|
||||||
|
covered work is covered by this License only if the output, given its
|
||||||
|
content, constitutes a covered work. This License acknowledges your
|
||||||
|
rights of fair use or other equivalent, as provided by copyright law.
|
||||||
|
|
||||||
|
You may make, run and propagate covered works that you do not
|
||||||
|
convey, without conditions so long as your license otherwise remains
|
||||||
|
in force. You may convey covered works to others for the sole purpose
|
||||||
|
of having them make modifications exclusively for you, or provide you
|
||||||
|
with facilities for running those works, provided that you comply with
|
||||||
|
the terms of this License in conveying all material for which you do
|
||||||
|
not control copyright. Those thus making or running the covered works
|
||||||
|
for you must do so exclusively on your behalf, under your direction
|
||||||
|
and control, on terms that prohibit them from making any copies of
|
||||||
|
your copyrighted material outside their relationship with you.
|
||||||
|
|
||||||
|
Conveying under any other circumstances is permitted solely under
|
||||||
|
the conditions stated below. Sublicensing is not allowed; section 10
|
||||||
|
makes it unnecessary.
|
||||||
|
|
||||||
|
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||||
|
|
||||||
|
No covered work shall be deemed part of an effective technological
|
||||||
|
measure under any applicable law fulfilling obligations under article
|
||||||
|
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||||
|
similar laws prohibiting or restricting circumvention of such
|
||||||
|
measures.
|
||||||
|
|
||||||
|
When you convey a covered work, you waive any legal power to forbid
|
||||||
|
circumvention of technological measures to the extent such circumvention
|
||||||
|
is effected by exercising rights under this License with respect to
|
||||||
|
the covered work, and you disclaim any intention to limit operation or
|
||||||
|
modification of the work as a means of enforcing, against the work's
|
||||||
|
users, your or third parties' legal rights to forbid circumvention of
|
||||||
|
technological measures.
|
||||||
|
|
||||||
|
4. Conveying Verbatim Copies.
|
||||||
|
|
||||||
|
You may convey verbatim copies of the Program's source code as you
|
||||||
|
receive it, in any medium, provided that you conspicuously and
|
||||||
|
appropriately publish on each copy an appropriate copyright notice;
|
||||||
|
keep intact all notices stating that this License and any
|
||||||
|
non-permissive terms added in accord with section 7 apply to the code;
|
||||||
|
keep intact all notices of the absence of any warranty; and give all
|
||||||
|
recipients a copy of this License along with the Program.
|
||||||
|
|
||||||
|
You may charge any price or no price for each copy that you convey,
|
||||||
|
and you may offer support or warranty protection for a fee.
|
||||||
|
|
||||||
|
5. Conveying Modified Source Versions.
|
||||||
|
|
||||||
|
You may convey a work based on the Program, or the modifications to
|
||||||
|
produce it from the Program, in the form of source code under the
|
||||||
|
terms of section 4, provided that you also meet all of these conditions:
|
||||||
|
|
||||||
|
a) The work must carry prominent notices stating that you modified
|
||||||
|
it, and giving a relevant date.
|
||||||
|
|
||||||
|
b) The work must carry prominent notices stating that it is
|
||||||
|
released under this License and any conditions added under section
|
||||||
|
7. This requirement modifies the requirement in section 4 to
|
||||||
|
"keep intact all notices".
|
||||||
|
|
||||||
|
c) You must license the entire work, as a whole, under this
|
||||||
|
License to anyone who comes into possession of a copy. This
|
||||||
|
License will therefore apply, along with any applicable section 7
|
||||||
|
additional terms, to the whole of the work, and all its parts,
|
||||||
|
regardless of how they are packaged. This License gives no
|
||||||
|
permission to license the work in any other way, but it does not
|
||||||
|
invalidate such permission if you have separately received it.
|
||||||
|
|
||||||
|
d) If the work has interactive user interfaces, each must display
|
||||||
|
Appropriate Legal Notices; however, if the Program has interactive
|
||||||
|
interfaces that do not display Appropriate Legal Notices, your
|
||||||
|
work need not make them do so.
|
||||||
|
|
||||||
|
A compilation of a covered work with other separate and independent
|
||||||
|
works, which are not by their nature extensions of the covered work,
|
||||||
|
and which are not combined with it such as to form a larger program,
|
||||||
|
in or on a volume of a storage or distribution medium, is called an
|
||||||
|
"aggregate" if the compilation and its resulting copyright are not
|
||||||
|
used to limit the access or legal rights of the compilation's users
|
||||||
|
beyond what the individual works permit. Inclusion of a covered work
|
||||||
|
in an aggregate does not cause this License to apply to the other
|
||||||
|
parts of the aggregate.
|
||||||
|
|
||||||
|
6. Conveying Non-Source Forms.
|
||||||
|
|
||||||
|
You may convey a covered work in object code form under the terms
|
||||||
|
of sections 4 and 5, provided that you also convey the
|
||||||
|
machine-readable Corresponding Source under the terms of this License,
|
||||||
|
in one of these ways:
|
||||||
|
|
||||||
|
a) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by the
|
||||||
|
Corresponding Source fixed on a durable physical medium
|
||||||
|
customarily used for software interchange.
|
||||||
|
|
||||||
|
b) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by a
|
||||||
|
written offer, valid for at least three years and valid for as
|
||||||
|
long as you offer spare parts or customer support for that product
|
||||||
|
model, to give anyone who possesses the object code either (1) a
|
||||||
|
copy of the Corresponding Source for all the software in the
|
||||||
|
product that is covered by this License, on a durable physical
|
||||||
|
medium customarily used for software interchange, for a price no
|
||||||
|
more than your reasonable cost of physically performing this
|
||||||
|
conveying of source, or (2) access to copy the
|
||||||
|
Corresponding Source from a network server at no charge.
|
||||||
|
|
||||||
|
c) Convey individual copies of the object code with a copy of the
|
||||||
|
written offer to provide the Corresponding Source. This
|
||||||
|
alternative is allowed only occasionally and noncommercially, and
|
||||||
|
only if you received the object code with such an offer, in accord
|
||||||
|
with subsection 6b.
|
||||||
|
|
||||||
|
d) Convey the object code by offering access from a designated
|
||||||
|
place (gratis or for a charge), and offer equivalent access to the
|
||||||
|
Corresponding Source in the same way through the same place at no
|
||||||
|
further charge. You need not require recipients to copy the
|
||||||
|
Corresponding Source along with the object code. If the place to
|
||||||
|
copy the object code is a network server, the Corresponding Source
|
||||||
|
may be on a different server (operated by you or a third party)
|
||||||
|
that supports equivalent copying facilities, provided you maintain
|
||||||
|
clear directions next to the object code saying where to find the
|
||||||
|
Corresponding Source. Regardless of what server hosts the
|
||||||
|
Corresponding Source, you remain obligated to ensure that it is
|
||||||
|
available for as long as needed to satisfy these requirements.
|
||||||
|
|
||||||
|
e) Convey the object code using peer-to-peer transmission, provided
|
||||||
|
you inform other peers where the object code and Corresponding
|
||||||
|
Source of the work are being offered to the general public at no
|
||||||
|
charge under subsection 6d.
|
||||||
|
|
||||||
|
A separable portion of the object code, whose source code is excluded
|
||||||
|
from the Corresponding Source as a System Library, need not be
|
||||||
|
included in conveying the object code work.
|
||||||
|
|
||||||
|
A "User Product" is either (1) a "consumer product", which means any
|
||||||
|
tangible personal property which is normally used for personal, family,
|
||||||
|
or household purposes, or (2) anything designed or sold for incorporation
|
||||||
|
into a dwelling. In determining whether a product is a consumer product,
|
||||||
|
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||||
|
product received by a particular user, "normally used" refers to a
|
||||||
|
typical or common use of that class of product, regardless of the status
|
||||||
|
of the particular user or of the way in which the particular user
|
||||||
|
actually uses, or expects or is expected to use, the product. A product
|
||||||
|
is a consumer product regardless of whether the product has substantial
|
||||||
|
commercial, industrial or non-consumer uses, unless such uses represent
|
||||||
|
the only significant mode of use of the product.
|
||||||
|
|
||||||
|
"Installation Information" for a User Product means any methods,
|
||||||
|
procedures, authorization keys, or other information required to install
|
||||||
|
and execute modified versions of a covered work in that User Product from
|
||||||
|
a modified version of its Corresponding Source. The information must
|
||||||
|
suffice to ensure that the continued functioning of the modified object
|
||||||
|
code is in no case prevented or interfered with solely because
|
||||||
|
modification has been made.
|
||||||
|
|
||||||
|
If you convey an object code work under this section in, or with, or
|
||||||
|
specifically for use in, a User Product, and the conveying occurs as
|
||||||
|
part of a transaction in which the right of possession and use of the
|
||||||
|
User Product is transferred to the recipient in perpetuity or for a
|
||||||
|
fixed term (regardless of how the transaction is characterized), the
|
||||||
|
Corresponding Source conveyed under this section must be accompanied
|
||||||
|
by the Installation Information. But this requirement does not apply
|
||||||
|
if neither you nor any third party retains the ability to install
|
||||||
|
modified object code on the User Product (for example, the work has
|
||||||
|
been installed in ROM).
|
||||||
|
|
||||||
|
The requirement to provide Installation Information does not include a
|
||||||
|
requirement to continue to provide support service, warranty, or updates
|
||||||
|
for a work that has been modified or installed by the recipient, or for
|
||||||
|
the User Product in which it has been modified or installed. Access to a
|
||||||
|
network may be denied when the modification itself materially and
|
||||||
|
adversely affects the operation of the network or violates the rules and
|
||||||
|
protocols for communication across the network.
|
||||||
|
|
||||||
|
Corresponding Source conveyed, and Installation Information provided,
|
||||||
|
in accord with this section must be in a format that is publicly
|
||||||
|
documented (and with an implementation available to the public in
|
||||||
|
source code form), and must require no special password or key for
|
||||||
|
unpacking, reading or copying.
|
||||||
|
|
||||||
|
7. Additional Terms.
|
||||||
|
|
||||||
|
"Additional permissions" are terms that supplement the terms of this
|
||||||
|
License by making exceptions from one or more of its conditions.
|
||||||
|
Additional permissions that are applicable to the entire Program shall
|
||||||
|
be treated as though they were included in this License, to the extent
|
||||||
|
that they are valid under applicable law. If additional permissions
|
||||||
|
apply only to part of the Program, that part may be used separately
|
||||||
|
under those permissions, but the entire Program remains governed by
|
||||||
|
this License without regard to the additional permissions.
|
||||||
|
|
||||||
|
When you convey a copy of a covered work, you may at your option
|
||||||
|
remove any additional permissions from that copy, or from any part of
|
||||||
|
it. (Additional permissions may be written to require their own
|
||||||
|
removal in certain cases when you modify the work.) You may place
|
||||||
|
additional permissions on material, added by you to a covered work,
|
||||||
|
for which you have or can give appropriate copyright permission.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, for material you
|
||||||
|
add to a covered work, you may (if authorized by the copyright holders of
|
||||||
|
that material) supplement the terms of this License with terms:
|
||||||
|
|
||||||
|
a) Disclaiming warranty or limiting liability differently from the
|
||||||
|
terms of sections 15 and 16 of this License; or
|
||||||
|
|
||||||
|
b) Requiring preservation of specified reasonable legal notices or
|
||||||
|
author attributions in that material or in the Appropriate Legal
|
||||||
|
Notices displayed by works containing it; or
|
||||||
|
|
||||||
|
c) Prohibiting misrepresentation of the origin of that material, or
|
||||||
|
requiring that modified versions of such material be marked in
|
||||||
|
reasonable ways as different from the original version; or
|
||||||
|
|
||||||
|
d) Limiting the use for publicity purposes of names of licensors or
|
||||||
|
authors of the material; or
|
||||||
|
|
||||||
|
e) Declining to grant rights under trademark law for use of some
|
||||||
|
trade names, trademarks, or service marks; or
|
||||||
|
|
||||||
|
f) Requiring indemnification of licensors and authors of that
|
||||||
|
material by anyone who conveys the material (or modified versions of
|
||||||
|
it) with contractual assumptions of liability to the recipient, for
|
||||||
|
any liability that these contractual assumptions directly impose on
|
||||||
|
those licensors and authors.
|
||||||
|
|
||||||
|
All other non-permissive additional terms are considered "further
|
||||||
|
restrictions" within the meaning of section 10. If the Program as you
|
||||||
|
received it, or any part of it, contains a notice stating that it is
|
||||||
|
governed by this License along with a term that is a further
|
||||||
|
restriction, you may remove that term. If a license document contains
|
||||||
|
a further restriction but permits relicensing or conveying under this
|
||||||
|
License, you may add to a covered work material governed by the terms
|
||||||
|
of that license document, provided that the further restriction does
|
||||||
|
not survive such relicensing or conveying.
|
||||||
|
|
||||||
|
If you add terms to a covered work in accord with this section, you
|
||||||
|
must place, in the relevant source files, a statement of the
|
||||||
|
additional terms that apply to those files, or a notice indicating
|
||||||
|
where to find the applicable terms.
|
||||||
|
|
||||||
|
Additional terms, permissive or non-permissive, may be stated in the
|
||||||
|
form of a separately written license, or stated as exceptions;
|
||||||
|
the above requirements apply either way.
|
||||||
|
|
||||||
|
8. Termination.
|
||||||
|
|
||||||
|
You may not propagate or modify a covered work except as expressly
|
||||||
|
provided under this License. Any attempt otherwise to propagate or
|
||||||
|
modify it is void, and will automatically terminate your rights under
|
||||||
|
this License (including any patent licenses granted under the third
|
||||||
|
paragraph of section 11).
|
||||||
|
|
||||||
|
However, if you cease all violation of this License, then your
|
||||||
|
license from a particular copyright holder is reinstated (a)
|
||||||
|
provisionally, unless and until the copyright holder explicitly and
|
||||||
|
finally terminates your license, and (b) permanently, if the copyright
|
||||||
|
holder fails to notify you of the violation by some reasonable means
|
||||||
|
prior to 60 days after the cessation.
|
||||||
|
|
||||||
|
Moreover, your license from a particular copyright holder is
|
||||||
|
reinstated permanently if the copyright holder notifies you of the
|
||||||
|
violation by some reasonable means, this is the first time you have
|
||||||
|
received notice of violation of this License (for any work) from that
|
||||||
|
copyright holder, and you cure the violation prior to 30 days after
|
||||||
|
your receipt of the notice.
|
||||||
|
|
||||||
|
Termination of your rights under this section does not terminate the
|
||||||
|
licenses of parties who have received copies or rights from you under
|
||||||
|
this License. If your rights have been terminated and not permanently
|
||||||
|
reinstated, you do not qualify to receive new licenses for the same
|
||||||
|
material under section 10.
|
||||||
|
|
||||||
|
9. Acceptance Not Required for Having Copies.
|
||||||
|
|
||||||
|
You are not required to accept this License in order to receive or
|
||||||
|
run a copy of the Program. Ancillary propagation of a covered work
|
||||||
|
occurring solely as a consequence of using peer-to-peer transmission
|
||||||
|
to receive a copy likewise does not require acceptance. However,
|
||||||
|
nothing other than this License grants you permission to propagate or
|
||||||
|
modify any covered work. These actions infringe copyright if you do
|
||||||
|
not accept this License. Therefore, by modifying or propagating a
|
||||||
|
covered work, you indicate your acceptance of this License to do so.
|
||||||
|
|
||||||
|
10. Automatic Licensing of Downstream Recipients.
|
||||||
|
|
||||||
|
Each time you convey a covered work, the recipient automatically
|
||||||
|
receives a license from the original licensors, to run, modify and
|
||||||
|
propagate that work, subject to this License. You are not responsible
|
||||||
|
for enforcing compliance by third parties with this License.
|
||||||
|
|
||||||
|
An "entity transaction" is a transaction transferring control of an
|
||||||
|
organization, or substantially all assets of one, or subdividing an
|
||||||
|
organization, or merging organizations. If propagation of a covered
|
||||||
|
work results from an entity transaction, each party to that
|
||||||
|
transaction who receives a copy of the work also receives whatever
|
||||||
|
licenses to the work the party's predecessor in interest had or could
|
||||||
|
give under the previous paragraph, plus a right to possession of the
|
||||||
|
Corresponding Source of the work from the predecessor in interest, if
|
||||||
|
the predecessor has it or can get it with reasonable efforts.
|
||||||
|
|
||||||
|
You may not impose any further restrictions on the exercise of the
|
||||||
|
rights granted or affirmed under this License. For example, you may
|
||||||
|
not impose a license fee, royalty, or other charge for exercise of
|
||||||
|
rights granted under this License, and you may not initiate litigation
|
||||||
|
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||||
|
any patent claim is infringed by making, using, selling, offering for
|
||||||
|
sale, or importing the Program or any portion of it.
|
||||||
|
|
||||||
|
11. Patents.
|
||||||
|
|
||||||
|
A "contributor" is a copyright holder who authorizes use under this
|
||||||
|
License of the Program or a work on which the Program is based. The
|
||||||
|
work thus licensed is called the contributor's "contributor version".
|
||||||
|
|
||||||
|
A contributor's "essential patent claims" are all patent claims
|
||||||
|
owned or controlled by the contributor, whether already acquired or
|
||||||
|
hereafter acquired, that would be infringed by some manner, permitted
|
||||||
|
by this License, of making, using, or selling its contributor version,
|
||||||
|
but do not include claims that would be infringed only as a
|
||||||
|
consequence of further modification of the contributor version. For
|
||||||
|
purposes of this definition, "control" includes the right to grant
|
||||||
|
patent sublicenses in a manner consistent with the requirements of
|
||||||
|
this License.
|
||||||
|
|
||||||
|
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||||
|
patent license under the contributor's essential patent claims, to
|
||||||
|
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||||
|
propagate the contents of its contributor version.
|
||||||
|
|
||||||
|
In the following three paragraphs, a "patent license" is any express
|
||||||
|
agreement or commitment, however denominated, not to enforce a patent
|
||||||
|
(such as an express permission to practice a patent or covenant not to
|
||||||
|
sue for patent infringement). To "grant" such a patent license to a
|
||||||
|
party means to make such an agreement or commitment not to enforce a
|
||||||
|
patent against the party.
|
||||||
|
|
||||||
|
If you convey a covered work, knowingly relying on a patent license,
|
||||||
|
and the Corresponding Source of the work is not available for anyone
|
||||||
|
to copy, free of charge and under the terms of this License, through a
|
||||||
|
publicly available network server or other readily accessible means,
|
||||||
|
then you must either (1) cause the Corresponding Source to be so
|
||||||
|
available, or (2) arrange to deprive yourself of the benefit of the
|
||||||
|
patent license for this particular work, or (3) arrange, in a manner
|
||||||
|
consistent with the requirements of this License, to extend the patent
|
||||||
|
license to downstream recipients. "Knowingly relying" means you have
|
||||||
|
actual knowledge that, but for the patent license, your conveying the
|
||||||
|
covered work in a country, or your recipient's use of the covered work
|
||||||
|
in a country, would infringe one or more identifiable patents in that
|
||||||
|
country that you have reason to believe are valid.
|
||||||
|
|
||||||
|
If, pursuant to or in connection with a single transaction or
|
||||||
|
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||||
|
covered work, and grant a patent license to some of the parties
|
||||||
|
receiving the covered work authorizing them to use, propagate, modify
|
||||||
|
or convey a specific copy of the covered work, then the patent license
|
||||||
|
you grant is automatically extended to all recipients of the covered
|
||||||
|
work and works based on it.
|
||||||
|
|
||||||
|
A patent license is "discriminatory" if it does not include within
|
||||||
|
the scope of its coverage, prohibits the exercise of, or is
|
||||||
|
conditioned on the non-exercise of one or more of the rights that are
|
||||||
|
specifically granted under this License. You may not convey a covered
|
||||||
|
work if you are a party to an arrangement with a third party that is
|
||||||
|
in the business of distributing software, under which you make payment
|
||||||
|
to the third party based on the extent of your activity of conveying
|
||||||
|
the work, and under which the third party grants, to any of the
|
||||||
|
parties who would receive the covered work from you, a discriminatory
|
||||||
|
patent license (a) in connection with copies of the covered work
|
||||||
|
conveyed by you (or copies made from those copies), or (b) primarily
|
||||||
|
for and in connection with specific products or compilations that
|
||||||
|
contain the covered work, unless you entered into that arrangement,
|
||||||
|
or that patent license was granted, prior to 28 March 2007.
|
||||||
|
|
||||||
|
Nothing in this License shall be construed as excluding or limiting
|
||||||
|
any implied license or other defenses to infringement that may
|
||||||
|
otherwise be available to you under applicable patent law.
|
||||||
|
|
||||||
|
12. No Surrender of Others' Freedom.
|
||||||
|
|
||||||
|
If conditions are imposed on you (whether by court order, agreement or
|
||||||
|
otherwise) that contradict the conditions of this License, they do not
|
||||||
|
excuse you from the conditions of this License. If you cannot convey a
|
||||||
|
covered work so as to satisfy simultaneously your obligations under this
|
||||||
|
License and any other pertinent obligations, then as a consequence you may
|
||||||
|
not convey it at all. For example, if you agree to terms that obligate you
|
||||||
|
to collect a royalty for further conveying from those to whom you convey
|
||||||
|
the Program, the only way you could satisfy both those terms and this
|
||||||
|
License would be to refrain entirely from conveying the Program.
|
||||||
|
|
||||||
|
13. Use with the GNU Affero General Public License.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, you have
|
||||||
|
permission to link or combine any covered work with a work licensed
|
||||||
|
under version 3 of the GNU Affero General Public License into a single
|
||||||
|
combined work, and to convey the resulting work. The terms of this
|
||||||
|
License will continue to apply to the part which is the covered work,
|
||||||
|
but the special requirements of the GNU Affero General Public License,
|
||||||
|
section 13, concerning interaction through a network will apply to the
|
||||||
|
combination as such.
|
||||||
|
|
||||||
|
14. Revised Versions of this License.
|
||||||
|
|
||||||
|
The Free Software Foundation may publish revised and/or new versions of
|
||||||
|
the GNU General Public License from time to time. Such new versions will
|
||||||
|
be similar in spirit to the present version, but may differ in detail to
|
||||||
|
address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the
|
||||||
|
Program specifies that a certain numbered version of the GNU General
|
||||||
|
Public License "or any later version" applies to it, you have the
|
||||||
|
option of following the terms and conditions either of that numbered
|
||||||
|
version or of any later version published by the Free Software
|
||||||
|
Foundation. If the Program does not specify a version number of the
|
||||||
|
GNU General Public License, you may choose any version ever published
|
||||||
|
by the Free Software Foundation.
|
||||||
|
|
||||||
|
If the Program specifies that a proxy can decide which future
|
||||||
|
versions of the GNU General Public License can be used, that proxy's
|
||||||
|
public statement of acceptance of a version permanently authorizes you
|
||||||
|
to choose that version for the Program.
|
||||||
|
|
||||||
|
Later license versions may give you additional or different
|
||||||
|
permissions. However, no additional obligations are imposed on any
|
||||||
|
author or copyright holder as a result of your choosing to follow a
|
||||||
|
later version.
|
||||||
|
|
||||||
|
15. Disclaimer of Warranty.
|
||||||
|
|
||||||
|
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||||
|
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||||
|
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||||
|
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||||
|
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||||
|
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||||
|
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||||
|
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||||
|
|
||||||
|
16. Limitation of Liability.
|
||||||
|
|
||||||
|
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||||
|
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||||
|
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||||
|
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||||
|
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||||
|
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||||
|
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||||
|
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||||
|
SUCH DAMAGES.
|
||||||
|
|
||||||
|
17. Interpretation of Sections 15 and 16.
|
||||||
|
|
||||||
|
If the disclaimer of warranty and limitation of liability provided
|
||||||
|
above cannot be given local legal effect according to their terms,
|
||||||
|
reviewing courts shall apply local law that most closely approximates
|
||||||
|
an absolute waiver of all civil liability in connection with the
|
||||||
|
Program, unless a warranty or assumption of liability accompanies a
|
||||||
|
copy of the Program in return for a fee.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
How to Apply These Terms to Your New Programs
|
||||||
|
|
||||||
|
If you develop a new program, and you want it to be of the greatest
|
||||||
|
possible use to the public, the best way to achieve this is to make it
|
||||||
|
free software which everyone can redistribute and change under these terms.
|
||||||
|
|
||||||
|
To do so, attach the following notices to the program. It is safest
|
||||||
|
to attach them to the start of each source file to most effectively
|
||||||
|
state the exclusion of warranty; and each file should have at least
|
||||||
|
the "copyright" line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
|
<one line to give the program's name and a brief idea of what it does.>
|
||||||
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
|
If the program does terminal interaction, make it output a short
|
||||||
|
notice like this when it starts in an interactive mode:
|
||||||
|
|
||||||
|
<program> Copyright (C) <year> <name of author>
|
||||||
|
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||||
|
This is free software, and you are welcome to redistribute it
|
||||||
|
under certain conditions; type `show c' for details.
|
||||||
|
|
||||||
|
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||||
|
parts of the General Public License. Of course, your program's commands
|
||||||
|
might be different; for a GUI interface, you would use an "about box".
|
||||||
|
|
||||||
|
You should also get your employer (if you work as a programmer) or school,
|
||||||
|
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||||
|
For more information on this, and how to apply and follow the GNU GPL, see
|
||||||
|
<https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
The GNU General Public License does not permit incorporating your program
|
||||||
|
into proprietary programs. If your program is a subroutine library, you
|
||||||
|
may consider it more useful to permit linking proprietary applications with
|
||||||
|
the library. If this is what you want to do, use the GNU Lesser General
|
||||||
|
Public License instead of this License. But first, please read
|
||||||
|
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||||
293
README.md
Normal file
293
README.md
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
# PatchMon - Linux Patch Monitoring made Simple
|
||||||
|
|
||||||
|
[](https://patchmon.net)
|
||||||
|
[](https://patchmon.net/discord)
|
||||||
|
[](https://github.com/9technologygroup/patchmon.net)
|
||||||
|
[](https://github.com/users/9technologygroup/projects/1)
|
||||||
|
[](https://docs.patchmon.net/)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Please STAR this repo :D
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
### Dashboard
|
||||||
|
- Customisable dashboard with per‑user card layout and ordering
|
||||||
|
|
||||||
|
### Users & Authentication
|
||||||
|
- Multi-user accounts (admin and standard users)
|
||||||
|
- Roles, Permissions & RBAC
|
||||||
|
|
||||||
|
### Hosts & Inventory
|
||||||
|
- Host inventory/groups with key attributes and OS details
|
||||||
|
- Host grouping (create and manage host groups)
|
||||||
|
|
||||||
|
### Packages & Updates
|
||||||
|
- Package inventory across hosts
|
||||||
|
- Outdated packages overview and counts
|
||||||
|
- Repositories per host tracking
|
||||||
|
|
||||||
|
### Agent & Data Collection
|
||||||
|
- Agent version management and script content stored in DB
|
||||||
|
|
||||||
|
### Settings & Configuration
|
||||||
|
- Server URL/protocol/host/port
|
||||||
|
- Signup toggle and default user role selection
|
||||||
|
|
||||||
|
### API & Integrations
|
||||||
|
- REST API under `/api/v1` with JWT auth
|
||||||
|
- **Proxmox LXC Auto-Enrollment** - Automatically discover and enroll LXC containers from Proxmox hosts ([Documentation](PROXMOX_AUTO_ENROLLMENT.md))
|
||||||
|
|
||||||
|
### Security
|
||||||
|
- Rate limiting for general, auth, and agent endpoints
|
||||||
|
- Outbound‑only agent model reduces attack surface
|
||||||
|
|
||||||
|
### Deployment & Operations
|
||||||
|
- Docker installation & One‑line self‑host installer (Ubuntu/Debian)
|
||||||
|
- systemd service for backend lifecycle
|
||||||
|
- nginx vhost for frontend + API proxy; optional Let’s Encrypt integration
|
||||||
|
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
### PatchMon Cloud (coming soon)
|
||||||
|
|
||||||
|
Managed, zero-maintenance PatchMon hosting. Stay tuned.
|
||||||
|
|
||||||
|
### Self-hosted Installation
|
||||||
|
|
||||||
|
#### Docker (preferred)
|
||||||
|
|
||||||
|
For getting started with Docker, see the [Docker documentation](https://github.com/PatchMon/PatchMon/blob/main/docker/README.md)
|
||||||
|
|
||||||
|
#### Native Install (advanced/non-docker)
|
||||||
|
|
||||||
|
Run on a clean Ubuntu/Debian server with internet access:
|
||||||
|
|
||||||
|
#### Debian:
|
||||||
|
```bash
|
||||||
|
apt update -y
|
||||||
|
apt upgrade -y
|
||||||
|
apt install curl -y
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Ubuntu:
|
||||||
|
```bash
|
||||||
|
apt-get update -y
|
||||||
|
apt-get upgrade -y
|
||||||
|
apt install curl -y
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Script
|
||||||
|
```bash
|
||||||
|
curl -fsSL -o setup.sh https://raw.githubusercontent.com/PatchMon/PatchMon/refs/heads/main/setup.sh && chmod +x setup.sh && bash setup.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Minimum specs for building : #####
|
||||||
|
CPU : 2 vCPU
|
||||||
|
RAM : 2GB
|
||||||
|
Disk : 15GB
|
||||||
|
|
||||||
|
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`
|
||||||
|
|
||||||
|
## Forcing updates after host package changes
|
||||||
|
Should you perform a manual package update on your host and wish to see the results reflected in PatchMon quicker than the usual scheduled update, you can trigger the process manually by running:
|
||||||
|
```bash
|
||||||
|
/usr/local/bin/patchmon-agent.sh update
|
||||||
|
```
|
||||||
|
|
||||||
|
This will send the results immediately to PatchMon.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
A[End Users / Browser<br>Admin UI / Frontend] -- HTTPS --> B[nginx<br>serve FE, proxy API]
|
||||||
|
B -- HTTP --> C["Backend<br>(Node/Express)<br>/api, auth, Prisma"]
|
||||||
|
C -- TCP --> D[PostgreSQL<br>Database]
|
||||||
|
|
||||||
|
E["Agents on your servers (Outbound Only)"] -- HTTPS --> F["Backend API<br>(/api/v1)"]
|
||||||
|
```
|
||||||
|
Operational
|
||||||
|
- systemd manages backend service
|
||||||
|
- certbot/nginx for TLS (public)
|
||||||
|
- setup.sh bootstraps OS, app, DB, config
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
- Discord: [https://patchmon.net/discord](https://patchmon.net/discord)
|
||||||
|
- Email: support@patchmon.net
|
||||||
|
|
||||||
|
## Roadmap
|
||||||
|
|
||||||
|
- Roadmap board: https://github.com/orgs/PatchMon/projects/2
|
||||||
|
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
- AGPLv3 (More information on this soon)
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🤝 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. **Install Dependencies and Setup Hooks**
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run prepare
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Make Your Changes**
|
||||||
|
- Write clean, well-documented code
|
||||||
|
- Follow existing code style and patterns
|
||||||
|
- Add tests for new functionality
|
||||||
|
- Update documentation as needed
|
||||||
|
|
||||||
|
6. **Test Your Changes**
|
||||||
|
```bash
|
||||||
|
# Run backend tests
|
||||||
|
cd backend
|
||||||
|
npm test
|
||||||
|
|
||||||
|
# Run frontend tests
|
||||||
|
cd ../frontend
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
7. **Commit and Push**
|
||||||
|
```bash
|
||||||
|
git add .
|
||||||
|
git commit -m "Add: descriptive commit message"
|
||||||
|
git push origin feature/your-feature-name
|
||||||
|
```
|
||||||
|
|
||||||
|
8. **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 Biome 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
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## 🏢 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*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🙏 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
|
||||||
|
- **Beta Testers** - For keeping me awake at night
|
||||||
|
- **My family** - For understanding my passion
|
||||||
|
|
||||||
|
|
||||||
|
### Contributors
|
||||||
|
Thank you to all our contributors who help make PatchMon better every day!
|
||||||
|
|
||||||
|
|
||||||
|
## 🔗 Links
|
||||||
|
|
||||||
|
- **Website**: [patchmon.net](https://patchmon.net)
|
||||||
|
- **Discord**: [https://patchmon.net/discord](https://patchmon.net/discord)
|
||||||
|
- **Roadmap**: [GitHub Projects](https://github.com/users/9technologygroup/projects/1)
|
||||||
|
- **Documentation**: [https://docs.patchmon.net](https://docs.patchmon.net)
|
||||||
|
- **Support**: support@patchmon.net
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
**Made with ❤️ by the PatchMon Team**
|
||||||
|
|
||||||
|
[](https://patchmon.net/discord)
|
||||||
|
[](https://github.com/PatchMon/PatchMon)
|
||||||
|
|
||||||
|
</div>
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,15 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
# PatchMon Agent Installation Script
|
# PatchMon Agent Installation Script
|
||||||
# Usage: curl -sSL {PATCHMON_URL}/api/v1/hosts/install | bash -s -- {PATCHMON_URL} {API_ID} {API_KEY}
|
# Usage: curl -s {PATCHMON_URL}/api/v1/hosts/install -H "X-API-ID: {API_ID}" -H "X-API-KEY: {API_KEY}" | bash
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
|
# This placeholder will be dynamically replaced by the server when serving this
|
||||||
|
# script based on the "ignore SSL self-signed" setting. If set to -k, curl will
|
||||||
|
# ignore certificate validation. Otherwise, it will be empty for secure default.
|
||||||
|
# CURL_FLAGS is now set via environment variables by the backend
|
||||||
|
|
||||||
# Colors for output
|
# Colors for output
|
||||||
RED='\033[0;31m'
|
RED='\033[0;31m'
|
||||||
GREEN='\033[0;32m'
|
GREEN='\033[0;32m'
|
||||||
@@ -35,69 +40,276 @@ 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
|
||||||
|
|
||||||
# Default server URL (will be replaced by backend with configured URL)
|
# Verify system datetime and timezone
|
||||||
PATCHMON_URL="http://localhost:3001"
|
verify_datetime() {
|
||||||
|
info "🕐 Verifying system datetime and timezone..."
|
||||||
# Parse arguments
|
|
||||||
if [[ $# -ne 3 ]]; then
|
# Get current system time
|
||||||
echo "Usage: curl -sSL {PATCHMON_URL}/api/v1/hosts/install | bash -s -- {PATCHMON_URL} {API_ID} {API_KEY}"
|
local system_time=$(date)
|
||||||
|
local timezone=$(timedatectl show --property=Timezone --value 2>/dev/null || echo "Unknown")
|
||||||
|
|
||||||
|
# Display current datetime info
|
||||||
echo ""
|
echo ""
|
||||||
echo "Example:"
|
echo -e "${BLUE}📅 Current System Date/Time:${NC}"
|
||||||
echo "curl -sSL http://patchmon.example.com/api/v1/hosts/install | bash -s -- http://patchmon.example.com patchmon_1a2b3c4d abcd1234567890abcdef1234567890abcdef1234567890abcdef1234567890"
|
echo " • Date/Time: $system_time"
|
||||||
|
echo " • Timezone: $timezone"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Contact your PatchMon administrator to get your API credentials."
|
|
||||||
exit 1
|
# Check if we can read from stdin (interactive terminal)
|
||||||
fi
|
if [[ -t 0 ]]; then
|
||||||
|
# Interactive terminal - ask user
|
||||||
PATCHMON_URL="$1"
|
read -p "Does this date/time look correct to you? (y/N): " -r response
|
||||||
API_ID="$2"
|
if [[ "$response" =~ ^[Yy]$ ]]; then
|
||||||
API_KEY="$3"
|
success "✅ Date/time verification passed"
|
||||||
|
echo ""
|
||||||
# Validate inputs
|
return 0
|
||||||
if [[ ! "$PATCHMON_URL" =~ ^https?:// ]]; then
|
else
|
||||||
error "Invalid URL format. Must start with http:// or https://"
|
echo ""
|
||||||
fi
|
echo -e "${RED}❌ Date/time verification failed${NC}"
|
||||||
|
echo ""
|
||||||
if [[ ! "$API_ID" =~ ^patchmon_[a-f0-9]{16}$ ]]; then
|
echo -e "${YELLOW}💡 Please fix the date/time and re-run the installation script:${NC}"
|
||||||
error "Invalid API ID format. API ID should be in format: patchmon_xxxxxxxxxxxxxxxx"
|
echo " sudo timedatectl set-time 'YYYY-MM-DD HH:MM:SS'"
|
||||||
fi
|
echo " sudo timedatectl set-timezone 'America/New_York' # or your timezone"
|
||||||
|
echo " sudo timedatectl list-timezones # to see available timezones"
|
||||||
if [[ ! "$API_KEY" =~ ^[a-f0-9]{64}$ ]]; then
|
echo ""
|
||||||
error "Invalid API Key format. API Key should be 64 hexadecimal characters."
|
echo -e "${BLUE}ℹ️ After fixing the date/time, re-run this installation script.${NC}"
|
||||||
fi
|
error "Installation cancelled - please fix date/time and re-run"
|
||||||
|
fi
|
||||||
info "🚀 Installing PatchMon Agent..."
|
else
|
||||||
info " Server: $PATCHMON_URL"
|
# Non-interactive (piped from curl) - show warning and continue
|
||||||
info " API ID: $API_ID"
|
echo -e "${YELLOW}⚠️ Non-interactive installation detected${NC}"
|
||||||
|
echo ""
|
||||||
# Create patchmon directory
|
echo "Please verify the date/time shown above is correct."
|
||||||
info "📁 Creating configuration directory..."
|
echo "If the date/time is incorrect, it may cause issues with:"
|
||||||
mkdir -p /etc/patchmon
|
echo " • Logging timestamps"
|
||||||
|
echo " • Scheduled updates"
|
||||||
# Download the agent script
|
echo " • Data synchronization"
|
||||||
info "📥 Downloading PatchMon agent script..."
|
echo ""
|
||||||
curl -sSL "$PATCHMON_URL/api/v1/hosts/agent/download" -o /usr/local/bin/patchmon-agent.sh
|
echo -e "${GREEN}✅ Continuing with installation...${NC}"
|
||||||
chmod +x /usr/local/bin/patchmon-agent.sh
|
success "✅ Date/time verification completed (assumed correct)"
|
||||||
|
echo ""
|
||||||
# Get the agent version from the downloaded script
|
|
||||||
AGENT_VERSION=$(grep '^AGENT_VERSION=' /usr/local/bin/patchmon-agent.sh | cut -d'"' -f2)
|
|
||||||
info "📋 Agent version: $AGENT_VERSION"
|
|
||||||
|
|
||||||
# Get expected agent version from server
|
|
||||||
EXPECTED_VERSION=$(curl -s "$PATCHMON_URL/api/v1/hosts/agent/version" | grep -o '"currentVersion":"[^"]*' | cut -d'"' -f4 2>/dev/null || echo "Unknown")
|
|
||||||
if [[ "$EXPECTED_VERSION" != "Unknown" ]]; then
|
|
||||||
info "📋 Expected version: $EXPECTED_VERSION"
|
|
||||||
if [[ "$AGENT_VERSION" != "$EXPECTED_VERSION" ]]; then
|
|
||||||
warning "⚠️ Agent version mismatch! Installed: $AGENT_VERSION, Expected: $EXPECTED_VERSION"
|
|
||||||
fi
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Run datetime verification
|
||||||
|
verify_datetime
|
||||||
|
|
||||||
|
# Clean up old files (keep only last 3 of each type)
|
||||||
|
cleanup_old_files() {
|
||||||
|
# Clean up old credential backups
|
||||||
|
ls -t /etc/patchmon/credentials.backup.* 2>/dev/null | tail -n +4 | xargs -r rm -f
|
||||||
|
|
||||||
|
# Clean up old agent backups
|
||||||
|
ls -t /usr/local/bin/patchmon-agent.sh.backup.* 2>/dev/null | tail -n +4 | xargs -r rm -f
|
||||||
|
|
||||||
|
# Clean up old log files
|
||||||
|
ls -t /var/log/patchmon-agent.log.old.* 2>/dev/null | tail -n +4 | xargs -r rm -f
|
||||||
|
}
|
||||||
|
|
||||||
|
# Run cleanup at start
|
||||||
|
cleanup_old_files
|
||||||
|
|
||||||
|
# Generate or retrieve machine ID
|
||||||
|
get_machine_id() {
|
||||||
|
# Try multiple sources for machine ID
|
||||||
|
if [[ -f /etc/machine-id ]]; then
|
||||||
|
cat /etc/machine-id
|
||||||
|
elif [[ -f /var/lib/dbus/machine-id ]]; then
|
||||||
|
cat /var/lib/dbus/machine-id
|
||||||
|
else
|
||||||
|
# Fallback: generate from hardware info (less ideal but works)
|
||||||
|
echo "patchmon-$(cat /sys/class/dmi/id/product_uuid 2>/dev/null || cat /proc/sys/kernel/random/uuid)"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Parse arguments from environment (passed via HTTP headers)
|
||||||
|
if [[ -z "$PATCHMON_URL" ]] || [[ -z "$API_ID" ]] || [[ -z "$API_KEY" ]]; then
|
||||||
|
error "Missing required parameters. This script should be called via the PatchMon web interface."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Get update interval policy from server
|
# Check if --force flag is set (for bypassing broken packages)
|
||||||
UPDATE_INTERVAL=$(curl -s "$PATCHMON_URL/api/v1/settings/update-interval" | grep -o '"updateInterval":[0-9]*' | cut -d':' -f2 2>/dev/null || echo "60")
|
FORCE_INSTALL="${FORCE_INSTALL:-false}"
|
||||||
info "📋 Update interval: $UPDATE_INTERVAL minutes"
|
if [[ "$*" == *"--force"* ]] || [[ "$FORCE_INSTALL" == "true" ]]; then
|
||||||
|
FORCE_INSTALL="true"
|
||||||
|
warning "⚠️ Force mode enabled - will bypass broken packages"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Get unique machine ID for this host
|
||||||
|
MACHINE_ID=$(get_machine_id)
|
||||||
|
export MACHINE_ID
|
||||||
|
|
||||||
|
info "🚀 Starting PatchMon Agent Installation..."
|
||||||
|
info "📋 Server: $PATCHMON_URL"
|
||||||
|
info "🔑 API ID: ${API_ID:0:16}..."
|
||||||
|
info "🆔 Machine ID: ${MACHINE_ID:0:16}..."
|
||||||
|
|
||||||
|
# Display diagnostic information
|
||||||
|
echo ""
|
||||||
|
echo -e "${BLUE}🔧 Installation Diagnostics:${NC}"
|
||||||
|
echo " • URL: $PATCHMON_URL"
|
||||||
|
echo " • CURL FLAGS: $CURL_FLAGS"
|
||||||
|
echo " • API ID: ${API_ID:0:16}..."
|
||||||
|
echo " • API Key: ${API_KEY:0:16}..."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Install required dependencies
|
||||||
|
info "📦 Installing required dependencies..."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Function to check if a command exists
|
||||||
|
command_exists() {
|
||||||
|
command -v "$1" >/dev/null 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to install packages with error handling
|
||||||
|
install_apt_packages() {
|
||||||
|
local packages=("$@")
|
||||||
|
local missing_packages=()
|
||||||
|
|
||||||
|
# Check which packages are missing
|
||||||
|
for pkg in "${packages[@]}"; do
|
||||||
|
if ! command_exists "$pkg"; then
|
||||||
|
missing_packages+=("$pkg")
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ ${#missing_packages[@]} -eq 0 ]; then
|
||||||
|
success "All required packages are already installed"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
info "Need to install: ${missing_packages[*]}"
|
||||||
|
|
||||||
|
# Build apt-get command based on force mode
|
||||||
|
local apt_cmd="apt-get install ${missing_packages[*]} -y"
|
||||||
|
|
||||||
|
if [[ "$FORCE_INSTALL" == "true" ]]; then
|
||||||
|
info "Using force mode - bypassing broken packages..."
|
||||||
|
apt_cmd="$apt_cmd -o APT::Get::Fix-Broken=false -o DPkg::Options::=\"--force-confold\" -o DPkg::Options::=\"--force-confdef\""
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Try to install packages
|
||||||
|
if eval "$apt_cmd" 2>&1 | tee /tmp/patchmon_apt_install.log; then
|
||||||
|
success "Packages installed successfully"
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
warning "Package installation encountered issues, checking if required tools are available..."
|
||||||
|
|
||||||
|
# Verify critical dependencies are actually available
|
||||||
|
local all_ok=true
|
||||||
|
for pkg in "${packages[@]}"; do
|
||||||
|
if ! command_exists "$pkg"; then
|
||||||
|
if [[ "$FORCE_INSTALL" == "true" ]]; then
|
||||||
|
error "Critical dependency '$pkg' is not available even with --force. Please install manually."
|
||||||
|
else
|
||||||
|
error "Critical dependency '$pkg' is not available. Try again with --force flag or install manually: apt-get install $pkg"
|
||||||
|
fi
|
||||||
|
all_ok=false
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if $all_ok; then
|
||||||
|
success "All required tools are available despite installation warnings"
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Detect package manager and install jq and curl
|
||||||
|
if command -v apt-get >/dev/null 2>&1; then
|
||||||
|
# Debian/Ubuntu
|
||||||
|
info "Detected apt-get (Debian/Ubuntu)"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Check for broken packages
|
||||||
|
if dpkg -l | grep -q "^iH\|^iF" 2>/dev/null; then
|
||||||
|
if [[ "$FORCE_INSTALL" == "true" ]]; then
|
||||||
|
warning "Detected broken packages on system - force mode will work around them"
|
||||||
|
else
|
||||||
|
warning "⚠️ Broken packages detected on system"
|
||||||
|
warning "If installation fails, retry with: curl -s {URL}/api/v1/hosts/install --force -H ..."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
info "Updating package lists..."
|
||||||
|
apt-get update || true
|
||||||
|
echo ""
|
||||||
|
info "Installing jq, curl, and bc..."
|
||||||
|
install_apt_packages jq curl bc
|
||||||
|
elif command -v yum >/dev/null 2>&1; then
|
||||||
|
# CentOS/RHEL 7
|
||||||
|
info "Detected yum (CentOS/RHEL 7)"
|
||||||
|
echo ""
|
||||||
|
info "Installing jq, curl, and bc..."
|
||||||
|
yum install -y jq curl bc
|
||||||
|
elif command -v dnf >/dev/null 2>&1; then
|
||||||
|
# CentOS/RHEL 8+/Fedora
|
||||||
|
info "Detected dnf (CentOS/RHEL 8+/Fedora)"
|
||||||
|
echo ""
|
||||||
|
info "Installing jq, curl, and bc..."
|
||||||
|
dnf install -y jq curl bc
|
||||||
|
elif command -v zypper >/dev/null 2>&1; then
|
||||||
|
# openSUSE
|
||||||
|
info "Detected zypper (openSUSE)"
|
||||||
|
echo ""
|
||||||
|
info "Installing jq, curl, and bc..."
|
||||||
|
zypper install -y jq curl bc
|
||||||
|
elif command -v pacman >/dev/null 2>&1; then
|
||||||
|
# Arch Linux
|
||||||
|
info "Detected pacman (Arch Linux)"
|
||||||
|
echo ""
|
||||||
|
info "Installing jq, curl, and bc..."
|
||||||
|
pacman -S --noconfirm jq curl bc
|
||||||
|
elif command -v apk >/dev/null 2>&1; then
|
||||||
|
# Alpine Linux
|
||||||
|
info "Detected apk (Alpine Linux)"
|
||||||
|
echo ""
|
||||||
|
info "Installing jq, curl, and bc..."
|
||||||
|
apk add --no-cache jq curl bc
|
||||||
|
else
|
||||||
|
warning "Could not detect package manager. Please ensure 'jq', 'curl', and 'bc' are installed manually."
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
success "Dependencies installation completed"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Step 1: Handle existing configuration directory
|
||||||
|
info "📁 Setting up configuration directory..."
|
||||||
|
|
||||||
|
# Check if configuration directory already exists
|
||||||
|
if [[ -d "/etc/patchmon" ]]; then
|
||||||
|
warning "⚠️ Configuration directory already exists at /etc/patchmon"
|
||||||
|
warning "⚠️ Preserving existing configuration files"
|
||||||
|
|
||||||
|
# List existing files for user awareness
|
||||||
|
info "📋 Existing files in /etc/patchmon:"
|
||||||
|
ls -la /etc/patchmon/ 2>/dev/null | grep -v "^total" | while read -r line; do
|
||||||
|
echo " $line"
|
||||||
|
done
|
||||||
|
else
|
||||||
|
info "📁 Creating new configuration directory..."
|
||||||
|
mkdir -p /etc/patchmon
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Step 2: Create credentials file
|
||||||
|
info "🔐 Creating API credentials file..."
|
||||||
|
|
||||||
|
# Check if credentials file already exists
|
||||||
|
if [[ -f "/etc/patchmon/credentials" ]]; then
|
||||||
|
warning "⚠️ Credentials file already exists at /etc/patchmon/credentials"
|
||||||
|
warning "⚠️ Moving existing file out of the way for fresh installation"
|
||||||
|
|
||||||
|
# Clean up old credential backups (keep only last 3)
|
||||||
|
ls -t /etc/patchmon/credentials.backup.* 2>/dev/null | tail -n +4 | xargs -r rm -f
|
||||||
|
|
||||||
|
# Move existing file out of the way
|
||||||
|
mv /etc/patchmon/credentials /etc/patchmon/credentials.backup.$(date +%Y%m%d_%H%M%S)
|
||||||
|
info "📋 Moved existing credentials to: /etc/patchmon/credentials.backup.$(date +%Y%m%d_%H%M%S)"
|
||||||
|
fi
|
||||||
|
|
||||||
# Create credentials file
|
|
||||||
info "🔐 Setting up API credentials..."
|
|
||||||
cat > /etc/patchmon/credentials << EOF
|
cat > /etc/patchmon/credentials << EOF
|
||||||
# PatchMon API Credentials
|
# PatchMon API Credentials
|
||||||
# Generated on $(date)
|
# Generated on $(date)
|
||||||
@@ -105,52 +317,117 @@ PATCHMON_URL="$PATCHMON_URL"
|
|||||||
API_ID="$API_ID"
|
API_ID="$API_ID"
|
||||||
API_KEY="$API_KEY"
|
API_KEY="$API_KEY"
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
chmod 600 /etc/patchmon/credentials
|
chmod 600 /etc/patchmon/credentials
|
||||||
|
|
||||||
# Test the configuration
|
# Step 3: Download the agent script using API credentials
|
||||||
info "🧪 Testing configuration..."
|
info "📥 Downloading PatchMon agent script..."
|
||||||
|
|
||||||
|
# Check if agent script already exists
|
||||||
|
if [[ -f "/usr/local/bin/patchmon-agent.sh" ]]; then
|
||||||
|
warning "⚠️ Agent script already exists at /usr/local/bin/patchmon-agent.sh"
|
||||||
|
warning "⚠️ Moving existing file out of the way for fresh installation"
|
||||||
|
|
||||||
|
# Clean up old agent backups (keep only last 3)
|
||||||
|
ls -t /usr/local/bin/patchmon-agent.sh.backup.* 2>/dev/null | tail -n +4 | xargs -r rm -f
|
||||||
|
|
||||||
|
# Move existing file out of the way
|
||||||
|
mv /usr/local/bin/patchmon-agent.sh /usr/local/bin/patchmon-agent.sh.backup.$(date +%Y%m%d_%H%M%S)
|
||||||
|
info "📋 Moved existing agent to: /usr/local/bin/patchmon-agent.sh.backup.$(date +%Y%m%d_%H%M%S)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
curl $CURL_FLAGS \
|
||||||
|
-H "X-API-ID: $API_ID" \
|
||||||
|
-H "X-API-KEY: $API_KEY" \
|
||||||
|
"$PATCHMON_URL/api/v1/hosts/agent/download" \
|
||||||
|
-o /usr/local/bin/patchmon-agent.sh
|
||||||
|
|
||||||
|
chmod +x /usr/local/bin/patchmon-agent.sh
|
||||||
|
|
||||||
|
# Get the agent version from the downloaded script
|
||||||
|
AGENT_VERSION=$(grep '^AGENT_VERSION=' /usr/local/bin/patchmon-agent.sh | cut -d'"' -f2 2>/dev/null || echo "Unknown")
|
||||||
|
info "📋 Agent version: $AGENT_VERSION"
|
||||||
|
|
||||||
|
# Handle existing log files
|
||||||
|
if [[ -f "/var/log/patchmon-agent.log" ]]; then
|
||||||
|
warning "⚠️ Existing log file found at /var/log/patchmon-agent.log"
|
||||||
|
warning "⚠️ Rotating log file for fresh start"
|
||||||
|
|
||||||
|
# Rotate the log file
|
||||||
|
mv /var/log/patchmon-agent.log /var/log/patchmon-agent.log.old.$(date +%Y%m%d_%H%M%S)
|
||||||
|
info "📋 Log file rotated to: /var/log/patchmon-agent.log.old.$(date +%Y%m%d_%H%M%S)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Step 4: Test the configuration
|
||||||
|
# Check if this machine is already enrolled
|
||||||
|
info "🔍 Checking if machine is already enrolled..."
|
||||||
|
existing_check=$(curl $CURL_FLAGS -s -X POST \
|
||||||
|
-H "X-API-ID: $API_ID" \
|
||||||
|
-H "X-API-KEY: $API_KEY" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"machine_id\": \"$MACHINE_ID\"}" \
|
||||||
|
"$PATCHMON_URL/api/v1/hosts/check-machine-id" \
|
||||||
|
-w "\n%{http_code}" 2>&1)
|
||||||
|
|
||||||
|
http_code=$(echo "$existing_check" | tail -n 1)
|
||||||
|
response_body=$(echo "$existing_check" | sed '$d')
|
||||||
|
|
||||||
|
if [[ "$http_code" == "200" ]]; then
|
||||||
|
already_enrolled=$(echo "$response_body" | jq -r '.exists' 2>/dev/null || echo "false")
|
||||||
|
if [[ "$already_enrolled" == "true" ]]; then
|
||||||
|
warning "⚠️ This machine is already enrolled in PatchMon"
|
||||||
|
info "Machine ID: $MACHINE_ID"
|
||||||
|
info "Existing host: $(echo "$response_body" | jq -r '.host.friendly_name' 2>/dev/null)"
|
||||||
|
info ""
|
||||||
|
info "The agent will be reinstalled/updated with existing credentials."
|
||||||
|
echo ""
|
||||||
|
else
|
||||||
|
success "✅ Machine not yet enrolled - proceeding with installation"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
info "🧪 Testing API credentials and connectivity..."
|
||||||
if /usr/local/bin/patchmon-agent.sh test; then
|
if /usr/local/bin/patchmon-agent.sh test; then
|
||||||
success "Configuration test passed!"
|
success "✅ TEST: API credentials are valid and server is reachable"
|
||||||
else
|
else
|
||||||
error "Configuration test failed. Please check your credentials."
|
error "❌ Failed to validate API credentials or reach server"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Send initial update
|
# Step 5: Send initial data and setup automated updates
|
||||||
info "📊 Sending initial package data..."
|
info "📊 Sending initial package data to server..."
|
||||||
if /usr/local/bin/patchmon-agent.sh update; then
|
if /usr/local/bin/patchmon-agent.sh update; then
|
||||||
success "Initial package data sent successfully!"
|
success "✅ UPDATE: Initial package data sent successfully"
|
||||||
|
info "✅ Automated updates configured by agent"
|
||||||
else
|
else
|
||||||
warning "Initial package data failed, but agent is configured. You can run 'patchmon-agent.sh update' manually."
|
warning "⚠️ Failed to send initial data. You can retry later with: /usr/local/bin/patchmon-agent.sh update"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Setup crontab for automatic updates
|
# Installation complete
|
||||||
info "⏰ Setting up automatic updates every $UPDATE_INTERVAL minutes..."
|
success "🎉 PatchMon Agent installation completed successfully!"
|
||||||
if [[ $UPDATE_INTERVAL -eq 60 ]]; then
|
|
||||||
# Hourly updates
|
|
||||||
echo "0 * * * * /usr/local/bin/patchmon-agent.sh update >/dev/null 2>&1" | crontab -
|
|
||||||
else
|
|
||||||
# Custom interval updates
|
|
||||||
echo "*/$UPDATE_INTERVAL * * * * /usr/local/bin/patchmon-agent.sh update >/dev/null 2>&1" | crontab -
|
|
||||||
fi
|
|
||||||
|
|
||||||
success "🎉 PatchMon Agent installation complete!"
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "📋 Installation Summary:"
|
echo -e "${GREEN}📋 Installation Summary:${NC}"
|
||||||
|
echo " • Configuration directory: /etc/patchmon"
|
||||||
echo " • Agent installed: /usr/local/bin/patchmon-agent.sh"
|
echo " • Agent installed: /usr/local/bin/patchmon-agent.sh"
|
||||||
echo " • Agent version: $AGENT_VERSION"
|
echo " • Dependencies installed: jq, curl, bc"
|
||||||
if [[ "$EXPECTED_VERSION" != "Unknown" ]]; then
|
echo " • Automated updates configured via crontab"
|
||||||
echo " • Expected version: $EXPECTED_VERSION"
|
echo " • API credentials configured and tested"
|
||||||
fi
|
echo " • Update schedule managed by agent"
|
||||||
echo " • Config directory: /etc/patchmon/"
|
|
||||||
echo " • Credentials file: /etc/patchmon/credentials"
|
|
||||||
echo " • Automatic updates: Every $UPDATE_INTERVAL minutes via crontab"
|
|
||||||
echo " • View logs: tail -f /var/log/patchmon-agent.sh"
|
|
||||||
echo ""
|
|
||||||
echo "🔧 Manual commands:"
|
|
||||||
echo " • Test connection: patchmon-agent.sh test"
|
|
||||||
echo " • Send update: patchmon-agent.sh update"
|
|
||||||
echo " • Check status: patchmon-agent.sh ping"
|
|
||||||
echo ""
|
|
||||||
success "Your host is now connected to PatchMon!"
|
|
||||||
|
|
||||||
|
# Check for moved files and show them
|
||||||
|
MOVED_FILES=$(ls /etc/patchmon/credentials.backup.* /usr/local/bin/patchmon-agent.sh.backup.* /var/log/patchmon-agent.log.old.* 2>/dev/null || true)
|
||||||
|
if [[ -n "$MOVED_FILES" ]]; then
|
||||||
|
echo ""
|
||||||
|
echo -e "${YELLOW}📋 Files Moved for Fresh Installation:${NC}"
|
||||||
|
echo "$MOVED_FILES" | while read -r moved_file; do
|
||||||
|
echo " • $moved_file"
|
||||||
|
done
|
||||||
|
echo ""
|
||||||
|
echo -e "${BLUE}💡 Note: Old files are automatically cleaned up (keeping last 3)${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${BLUE}🔧 Management Commands:${NC}"
|
||||||
|
echo " • Test connection: /usr/local/bin/patchmon-agent.sh test"
|
||||||
|
echo " • Manual update: /usr/local/bin/patchmon-agent.sh update"
|
||||||
|
echo " • Check status: /usr/local/bin/patchmon-agent.sh diagnostics"
|
||||||
|
echo ""
|
||||||
|
success "✅ Your system is now being monitored by PatchMon!"
|
||||||
|
|||||||
222
agents/patchmon_remove.sh
Executable file
222
agents/patchmon_remove.sh
Executable file
@@ -0,0 +1,222 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# PatchMon Agent Removal Script
|
||||||
|
# Usage: curl -s {PATCHMON_URL}/api/v1/hosts/remove | bash
|
||||||
|
# This script completely removes PatchMon from the system
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# This placeholder will be dynamically replaced by the server when serving this
|
||||||
|
# script based on the "ignore SSL self-signed" setting for any curl calls in
|
||||||
|
# future (left for consistency with install script).
|
||||||
|
CURL_FLAGS=""
|
||||||
|
|
||||||
|
# Colors for output
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
# Functions
|
||||||
|
error() {
|
||||||
|
echo -e "${RED}❌ ERROR: $1${NC}" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
info() {
|
||||||
|
echo -e "${BLUE}ℹ️ $1${NC}"
|
||||||
|
}
|
||||||
|
|
||||||
|
success() {
|
||||||
|
echo -e "${GREEN}✅ $1${NC}"
|
||||||
|
}
|
||||||
|
|
||||||
|
warning() {
|
||||||
|
echo -e "${YELLOW}⚠️ $1${NC}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check if running as root
|
||||||
|
if [[ $EUID -ne 0 ]]; then
|
||||||
|
error "This script must be run as root (use sudo)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
info "🗑️ Starting PatchMon Agent Removal..."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Step 1: Stop any running PatchMon processes
|
||||||
|
info "🛑 Stopping PatchMon processes..."
|
||||||
|
if pgrep -f "patchmon-agent.sh" >/dev/null; then
|
||||||
|
warning "Found running PatchMon processes, stopping them..."
|
||||||
|
pkill -f "patchmon-agent.sh" || true
|
||||||
|
sleep 2
|
||||||
|
success "PatchMon processes stopped"
|
||||||
|
else
|
||||||
|
info "No running PatchMon processes found"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Step 2: Remove crontab entries
|
||||||
|
info "📅 Removing PatchMon crontab entries..."
|
||||||
|
if crontab -l 2>/dev/null | grep -q "patchmon-agent.sh"; then
|
||||||
|
warning "Found PatchMon crontab entries, removing them..."
|
||||||
|
crontab -l 2>/dev/null | grep -v "patchmon-agent.sh" | crontab -
|
||||||
|
success "Crontab entries removed"
|
||||||
|
else
|
||||||
|
info "No PatchMon crontab entries found"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Step 3: Remove agent script
|
||||||
|
info "📄 Removing agent script..."
|
||||||
|
if [[ -f "/usr/local/bin/patchmon-agent.sh" ]]; then
|
||||||
|
warning "Removing agent script: /usr/local/bin/patchmon-agent.sh"
|
||||||
|
rm -f /usr/local/bin/patchmon-agent.sh
|
||||||
|
success "Agent script removed"
|
||||||
|
else
|
||||||
|
info "Agent script not found"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Step 4: Remove configuration directory and files
|
||||||
|
info "📁 Removing configuration files..."
|
||||||
|
if [[ -d "/etc/patchmon" ]]; then
|
||||||
|
warning "Removing configuration directory: /etc/patchmon"
|
||||||
|
|
||||||
|
# Show what's being removed
|
||||||
|
info "📋 Files in /etc/patchmon:"
|
||||||
|
ls -la /etc/patchmon/ 2>/dev/null | grep -v "^total" | while read -r line; do
|
||||||
|
echo " $line"
|
||||||
|
done
|
||||||
|
|
||||||
|
# Remove the directory
|
||||||
|
rm -rf /etc/patchmon
|
||||||
|
success "Configuration directory removed"
|
||||||
|
else
|
||||||
|
info "Configuration directory not found"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Step 5: Remove log files
|
||||||
|
info "📝 Removing log files..."
|
||||||
|
if [[ -f "/var/log/patchmon-agent.log" ]]; then
|
||||||
|
warning "Removing log file: /var/log/patchmon-agent.log"
|
||||||
|
rm -f /var/log/patchmon-agent.log
|
||||||
|
success "Log file removed"
|
||||||
|
else
|
||||||
|
info "Log file not found"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Step 6: Clean up backup files (optional)
|
||||||
|
info "🧹 Cleaning up backup files..."
|
||||||
|
BACKUP_COUNT=0
|
||||||
|
|
||||||
|
# Count credential backups
|
||||||
|
CRED_BACKUPS=$(ls /etc/patchmon/credentials.backup.* 2>/dev/null | wc -l || echo "0")
|
||||||
|
if [[ $CRED_BACKUPS -gt 0 ]]; then
|
||||||
|
BACKUP_COUNT=$((BACKUP_COUNT + CRED_BACKUPS))
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Count agent backups
|
||||||
|
AGENT_BACKUPS=$(ls /usr/local/bin/patchmon-agent.sh.backup.* 2>/dev/null | wc -l || echo "0")
|
||||||
|
if [[ $AGENT_BACKUPS -gt 0 ]]; then
|
||||||
|
BACKUP_COUNT=$((BACKUP_COUNT + AGENT_BACKUPS))
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Count log backups
|
||||||
|
LOG_BACKUPS=$(ls /var/log/patchmon-agent.log.old.* 2>/dev/null | wc -l || echo "0")
|
||||||
|
if [[ $LOG_BACKUPS -gt 0 ]]; then
|
||||||
|
BACKUP_COUNT=$((BACKUP_COUNT + LOG_BACKUPS))
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ $BACKUP_COUNT -gt 0 ]]; then
|
||||||
|
warning "Found $BACKUP_COUNT backup files"
|
||||||
|
echo ""
|
||||||
|
echo -e "${YELLOW}📋 Backup files found:${NC}"
|
||||||
|
|
||||||
|
# Show credential backups
|
||||||
|
if [[ $CRED_BACKUPS -gt 0 ]]; then
|
||||||
|
echo " Credential backups:"
|
||||||
|
ls /etc/patchmon/credentials.backup.* 2>/dev/null | while read -r file; do
|
||||||
|
echo " • $file"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Show agent backups
|
||||||
|
if [[ $AGENT_BACKUPS -gt 0 ]]; then
|
||||||
|
echo " Agent script backups:"
|
||||||
|
ls /usr/local/bin/patchmon-agent.sh.backup.* 2>/dev/null | while read -r file; do
|
||||||
|
echo " • $file"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Show log backups
|
||||||
|
if [[ $LOG_BACKUPS -gt 0 ]]; then
|
||||||
|
echo " Log file backups:"
|
||||||
|
ls /var/log/patchmon-agent.log.old.* 2>/dev/null | while read -r file; do
|
||||||
|
echo " • $file"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${BLUE}💡 Note: Backup files are preserved for safety${NC}"
|
||||||
|
echo -e "${BLUE}💡 You can remove them manually if not needed${NC}"
|
||||||
|
else
|
||||||
|
info "No backup files found"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Step 7: Remove dependencies (optional)
|
||||||
|
info "📦 Checking for PatchMon-specific dependencies..."
|
||||||
|
if command -v jq >/dev/null 2>&1; then
|
||||||
|
warning "jq is installed (used by PatchMon)"
|
||||||
|
echo -e "${BLUE}💡 Note: jq may be used by other applications${NC}"
|
||||||
|
echo -e "${BLUE}💡 Consider keeping it unless you're sure it's not needed${NC}"
|
||||||
|
else
|
||||||
|
info "jq not found"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if command -v curl >/dev/null 2>&1; then
|
||||||
|
warning "curl is installed (used by PatchMon)"
|
||||||
|
echo -e "${BLUE}💡 Note: curl is commonly used by many applications${NC}"
|
||||||
|
echo -e "${BLUE}💡 Consider keeping it unless you're sure it's not needed${NC}"
|
||||||
|
else
|
||||||
|
info "curl not found"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Step 8: Final verification
|
||||||
|
info "🔍 Verifying removal..."
|
||||||
|
REMAINING_FILES=0
|
||||||
|
|
||||||
|
if [[ -f "/usr/local/bin/patchmon-agent.sh" ]]; then
|
||||||
|
REMAINING_FILES=$((REMAINING_FILES + 1))
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -d "/etc/patchmon" ]]; then
|
||||||
|
REMAINING_FILES=$((REMAINING_FILES + 1))
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -f "/var/log/patchmon-agent.log" ]]; then
|
||||||
|
REMAINING_FILES=$((REMAINING_FILES + 1))
|
||||||
|
fi
|
||||||
|
|
||||||
|
if crontab -l 2>/dev/null | grep -q "patchmon-agent.sh"; then
|
||||||
|
REMAINING_FILES=$((REMAINING_FILES + 1))
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ $REMAINING_FILES -eq 0 ]]; then
|
||||||
|
success "✅ PatchMon has been completely removed from the system!"
|
||||||
|
else
|
||||||
|
warning "⚠️ Some PatchMon files may still remain ($REMAINING_FILES items)"
|
||||||
|
echo -e "${BLUE}💡 You may need to remove them manually${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${GREEN}📋 Removal Summary:${NC}"
|
||||||
|
echo " • Agent script: Removed"
|
||||||
|
echo " • Configuration files: Removed"
|
||||||
|
echo " • Log files: Removed"
|
||||||
|
echo " • Crontab entries: Removed"
|
||||||
|
echo " • Running processes: Stopped"
|
||||||
|
echo " • Backup files: Preserved (if any)"
|
||||||
|
echo ""
|
||||||
|
echo -e "${BLUE}🔧 Manual cleanup (if needed):${NC}"
|
||||||
|
echo " • Remove backup files: rm /etc/patchmon/credentials.backup.* /usr/local/bin/patchmon-agent.sh.backup.* /var/log/patchmon-agent.log.old.*"
|
||||||
|
echo " • Remove dependencies: apt remove jq curl (if not needed by other apps)"
|
||||||
|
echo ""
|
||||||
|
success "🎉 PatchMon removal completed!"
|
||||||
437
agents/proxmox_auto_enroll.sh
Executable file
437
agents/proxmox_auto_enroll.sh
Executable file
@@ -0,0 +1,437 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -eo pipefail # Exit on error, pipe failures (removed -u as we handle unset vars explicitly)
|
||||||
|
|
||||||
|
# Trap to catch errors only (not normal exits)
|
||||||
|
trap 'echo "[ERROR] Script failed at line $LINENO with exit code $?"' ERR
|
||||||
|
|
||||||
|
SCRIPT_VERSION="2.0.0"
|
||||||
|
echo "[DEBUG] Script Version: $SCRIPT_VERSION ($(date +%Y-%m-%d\ %H:%M:%S))"
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# PatchMon Proxmox LXC Auto-Enrollment Script
|
||||||
|
# =============================================================================
|
||||||
|
# This script discovers LXC containers on a Proxmox host and automatically
|
||||||
|
# enrolls them into PatchMon for patch management.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# 1. Set environment variables or edit configuration below
|
||||||
|
# 2. Run: bash proxmox_auto_enroll.sh
|
||||||
|
#
|
||||||
|
# Requirements:
|
||||||
|
# - Must run on Proxmox host (requires 'pct' command)
|
||||||
|
# - Auto-enrollment token from PatchMon
|
||||||
|
# - Network access to PatchMon server
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# ===== CONFIGURATION =====
|
||||||
|
PATCHMON_URL="${PATCHMON_URL:-https://patchmon.example.com}"
|
||||||
|
AUTO_ENROLLMENT_KEY="${AUTO_ENROLLMENT_KEY:-}"
|
||||||
|
AUTO_ENROLLMENT_SECRET="${AUTO_ENROLLMENT_SECRET:-}"
|
||||||
|
CURL_FLAGS="${CURL_FLAGS:--s}"
|
||||||
|
DRY_RUN="${DRY_RUN:-false}"
|
||||||
|
HOST_PREFIX="${HOST_PREFIX:-}"
|
||||||
|
SKIP_STOPPED="${SKIP_STOPPED:-true}"
|
||||||
|
PARALLEL_INSTALL="${PARALLEL_INSTALL:-false}"
|
||||||
|
MAX_PARALLEL="${MAX_PARALLEL:-5}"
|
||||||
|
FORCE_INSTALL="${FORCE_INSTALL:-false}"
|
||||||
|
|
||||||
|
# ===== COLOR OUTPUT =====
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
# ===== LOGGING FUNCTIONS =====
|
||||||
|
info() { echo -e "${GREEN}[INFO]${NC} $1"; return 0; }
|
||||||
|
warn() { echo -e "${YELLOW}[WARN]${NC} $1"; return 0; }
|
||||||
|
error() { echo -e "${RED}[ERROR]${NC} $1"; exit 1; }
|
||||||
|
success() { echo -e "${GREEN}[SUCCESS]${NC} $1"; return 0; }
|
||||||
|
debug() { [[ "${DEBUG:-false}" == "true" ]] && echo -e "${BLUE}[DEBUG]${NC} $1" || true; return 0; }
|
||||||
|
|
||||||
|
# ===== BANNER =====
|
||||||
|
cat << "EOF"
|
||||||
|
╔═══════════════════════════════════════════════════════════════╗
|
||||||
|
║ ║
|
||||||
|
║ ____ _ _ __ __ ║
|
||||||
|
║ | _ \ __ _| |_ ___| |__ | \/ | ___ _ __ ║
|
||||||
|
║ | |_) / _` | __/ __| '_ \| |\/| |/ _ \| '_ \ ║
|
||||||
|
║ | __/ (_| | || (__| | | | | | | (_) | | | | ║
|
||||||
|
║ |_| \__,_|\__\___|_| |_|_| |_|\___/|_| |_| ║
|
||||||
|
║ ║
|
||||||
|
║ Proxmox LXC Auto-Enrollment Script ║
|
||||||
|
║ ║
|
||||||
|
╚═══════════════════════════════════════════════════════════════╝
|
||||||
|
EOF
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ===== VALIDATION =====
|
||||||
|
info "Validating configuration..."
|
||||||
|
|
||||||
|
if [[ -z "$AUTO_ENROLLMENT_KEY" ]] || [[ -z "$AUTO_ENROLLMENT_SECRET" ]]; then
|
||||||
|
error "AUTO_ENROLLMENT_KEY and AUTO_ENROLLMENT_SECRET must be set"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "$PATCHMON_URL" ]]; then
|
||||||
|
error "PATCHMON_URL must be set"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if running on Proxmox
|
||||||
|
if ! command -v pct &> /dev/null; then
|
||||||
|
error "This script must run on a Proxmox host (pct command not found)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check for required commands
|
||||||
|
for cmd in curl jq; do
|
||||||
|
if ! command -v $cmd &> /dev/null; then
|
||||||
|
error "Required command '$cmd' not found. Please install it first."
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
info "Configuration validated successfully"
|
||||||
|
info "PatchMon Server: $PATCHMON_URL"
|
||||||
|
info "Dry Run Mode: $DRY_RUN"
|
||||||
|
info "Skip Stopped Containers: $SKIP_STOPPED"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ===== DISCOVER LXC CONTAINERS =====
|
||||||
|
info "Discovering LXC containers..."
|
||||||
|
lxc_list=$(pct list | tail -n +2) # Skip header
|
||||||
|
|
||||||
|
if [[ -z "$lxc_list" ]]; then
|
||||||
|
warn "No LXC containers found on this Proxmox host"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Count containers
|
||||||
|
total_containers=$(echo "$lxc_list" | wc -l)
|
||||||
|
info "Found $total_containers LXC container(s)"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
info "Initializing statistics..."
|
||||||
|
# ===== STATISTICS =====
|
||||||
|
enrolled_count=0
|
||||||
|
skipped_count=0
|
||||||
|
failed_count=0
|
||||||
|
|
||||||
|
# Track containers with dpkg errors for later recovery
|
||||||
|
declare -A dpkg_error_containers
|
||||||
|
|
||||||
|
# Track all failed containers for summary
|
||||||
|
declare -A failed_containers
|
||||||
|
info "Statistics initialized"
|
||||||
|
|
||||||
|
# ===== PROCESS CONTAINERS =====
|
||||||
|
info "Starting container processing loop..."
|
||||||
|
while IFS= read -r line; do
|
||||||
|
info "[DEBUG] Read line from lxc_list"
|
||||||
|
vmid=$(echo "$line" | awk '{print $1}')
|
||||||
|
status=$(echo "$line" | awk '{print $2}')
|
||||||
|
name=$(echo "$line" | awk '{print $3}')
|
||||||
|
|
||||||
|
info "Processing LXC $vmid: $name (status: $status)"
|
||||||
|
|
||||||
|
# Skip stopped containers if configured
|
||||||
|
if [[ "$status" != "running" ]] && [[ "$SKIP_STOPPED" == "true" ]]; then
|
||||||
|
warn " Skipping $name - container not running"
|
||||||
|
((skipped_count++)) || true
|
||||||
|
echo ""
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if container is stopped
|
||||||
|
if [[ "$status" != "running" ]]; then
|
||||||
|
warn " Container $name is stopped - cannot gather info or install agent"
|
||||||
|
((skipped_count++)) || true
|
||||||
|
echo ""
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Get container details
|
||||||
|
debug " Gathering container information..."
|
||||||
|
hostname=$(timeout 5 pct exec "$vmid" -- hostname 2>/dev/null </dev/null || echo "$name")
|
||||||
|
ip_address=$(timeout 5 pct exec "$vmid" -- hostname -I 2>/dev/null </dev/null | awk '{print $1}' || echo "unknown")
|
||||||
|
os_info=$(timeout 5 pct exec "$vmid" -- cat /etc/os-release 2>/dev/null </dev/null | grep "^PRETTY_NAME=" | cut -d'"' -f2 || echo "unknown")
|
||||||
|
|
||||||
|
# Get machine ID from container
|
||||||
|
machine_id=$(timeout 5 pct exec "$vmid" -- bash -c "cat /etc/machine-id 2>/dev/null || cat /var/lib/dbus/machine-id 2>/dev/null || echo 'proxmox-lxc-$vmid-'$(cat /proc/sys/kernel/random/uuid)" </dev/null 2>/dev/null || echo "proxmox-lxc-$vmid-unknown")
|
||||||
|
|
||||||
|
friendly_name="${HOST_PREFIX}${hostname}"
|
||||||
|
|
||||||
|
info " Hostname: $hostname"
|
||||||
|
info " IP Address: $ip_address"
|
||||||
|
info " OS: $os_info"
|
||||||
|
info " Machine ID: ${machine_id:0:16}..."
|
||||||
|
|
||||||
|
if [[ "$DRY_RUN" == "true" ]]; then
|
||||||
|
info " [DRY RUN] Would enroll: $friendly_name"
|
||||||
|
((enrolled_count++)) || true
|
||||||
|
echo ""
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Call PatchMon auto-enrollment API
|
||||||
|
info " Enrolling $friendly_name in PatchMon..."
|
||||||
|
|
||||||
|
response=$(curl $CURL_FLAGS -X POST \
|
||||||
|
-H "X-Auto-Enrollment-Key: $AUTO_ENROLLMENT_KEY" \
|
||||||
|
-H "X-Auto-Enrollment-Secret: $AUTO_ENROLLMENT_SECRET" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{
|
||||||
|
\"friendly_name\": \"$friendly_name\",
|
||||||
|
\"machine_id\": \"$machine_id\",
|
||||||
|
\"metadata\": {
|
||||||
|
\"vmid\": \"$vmid\",
|
||||||
|
\"proxmox_node\": \"$(hostname)\",
|
||||||
|
\"ip_address\": \"$ip_address\",
|
||||||
|
\"os_info\": \"$os_info\"
|
||||||
|
}
|
||||||
|
}" \
|
||||||
|
"$PATCHMON_URL/api/v1/auto-enrollment/enroll" \
|
||||||
|
-w "\n%{http_code}" 2>&1)
|
||||||
|
|
||||||
|
http_code=$(echo "$response" | tail -n 1)
|
||||||
|
body=$(echo "$response" | sed '$d')
|
||||||
|
|
||||||
|
if [[ "$http_code" == "201" ]]; then
|
||||||
|
api_id=$(echo "$body" | jq -r '.host.api_id' 2>/dev/null || echo "")
|
||||||
|
api_key=$(echo "$body" | jq -r '.host.api_key' 2>/dev/null || echo "")
|
||||||
|
|
||||||
|
if [[ -z "$api_id" ]] || [[ -z "$api_key" ]]; then
|
||||||
|
error " Failed to parse API credentials from response"
|
||||||
|
fi
|
||||||
|
|
||||||
|
info " ✓ Host enrolled successfully: $api_id"
|
||||||
|
|
||||||
|
# Ensure curl is installed in the container
|
||||||
|
info " Checking for curl in container..."
|
||||||
|
curl_check=$(timeout 10 pct exec "$vmid" -- bash -c "command -v curl >/dev/null 2>&1 && echo 'installed' || echo 'missing'" 2>/dev/null </dev/null || echo "error")
|
||||||
|
|
||||||
|
if [[ "$curl_check" == "missing" ]]; then
|
||||||
|
info " Installing curl in container..."
|
||||||
|
|
||||||
|
# Detect package manager and install curl
|
||||||
|
curl_install_output=$(timeout 60 pct exec "$vmid" -- bash -c "
|
||||||
|
if command -v apt-get >/dev/null 2>&1; then
|
||||||
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
|
apt-get update -qq && apt-get install -y -qq curl
|
||||||
|
elif command -v yum >/dev/null 2>&1; then
|
||||||
|
yum install -y -q curl
|
||||||
|
elif command -v dnf >/dev/null 2>&1; then
|
||||||
|
dnf install -y -q curl
|
||||||
|
elif command -v apk >/dev/null 2>&1; then
|
||||||
|
apk add --no-cache curl
|
||||||
|
else
|
||||||
|
echo 'ERROR: No supported package manager found'
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
" 2>&1 </dev/null) || true
|
||||||
|
|
||||||
|
if [[ "$curl_install_output" == *"ERROR: No supported package manager"* ]]; then
|
||||||
|
warn " ✗ Could not install curl - no supported package manager found"
|
||||||
|
failed_containers["$vmid"]="$friendly_name|No package manager for curl|$curl_install_output"
|
||||||
|
((failed_count++)) || true
|
||||||
|
echo ""
|
||||||
|
sleep 1
|
||||||
|
continue
|
||||||
|
else
|
||||||
|
info " ✓ curl installed successfully"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
info " ✓ curl already installed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Install PatchMon agent in container
|
||||||
|
info " Installing PatchMon agent..."
|
||||||
|
|
||||||
|
# Build install URL with force flag if enabled
|
||||||
|
install_url="$PATCHMON_URL/api/v1/hosts/install"
|
||||||
|
if [[ "$FORCE_INSTALL" == "true" ]]; then
|
||||||
|
install_url="$install_url?force=true"
|
||||||
|
info " Using force mode - will bypass broken packages"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Reset exit code for this container
|
||||||
|
install_exit_code=0
|
||||||
|
|
||||||
|
# Download and execute in separate steps to avoid stdin issues with piping
|
||||||
|
install_output=$(timeout 180 pct exec "$vmid" -- bash -c "
|
||||||
|
cd /tmp
|
||||||
|
curl $CURL_FLAGS \
|
||||||
|
-H \"X-API-ID: $api_id\" \
|
||||||
|
-H \"X-API-KEY: $api_key\" \
|
||||||
|
-o patchmon-install.sh \
|
||||||
|
'$install_url' && \
|
||||||
|
bash patchmon-install.sh && \
|
||||||
|
rm -f patchmon-install.sh
|
||||||
|
" 2>&1 </dev/null) || install_exit_code=$?
|
||||||
|
|
||||||
|
# Check both exit code AND success message in output for reliability
|
||||||
|
if [[ $install_exit_code -eq 0 ]] || [[ "$install_output" == *"PatchMon Agent installation completed successfully"* ]]; then
|
||||||
|
info " ✓ Agent installed successfully in $friendly_name"
|
||||||
|
((enrolled_count++)) || true
|
||||||
|
elif [[ $install_exit_code -eq 124 ]]; then
|
||||||
|
warn " ⏱ Agent installation timed out (>180s) in $friendly_name"
|
||||||
|
info " Install output: $install_output"
|
||||||
|
# Store failure details
|
||||||
|
failed_containers["$vmid"]="$friendly_name|Timeout (>180s)|$install_output"
|
||||||
|
((failed_count++)) || true
|
||||||
|
else
|
||||||
|
# Check if it's a dpkg error
|
||||||
|
if [[ "$install_output" == *"dpkg was interrupted"* ]] || [[ "$install_output" == *"dpkg --configure -a"* ]]; then
|
||||||
|
warn " ⚠ Failed due to dpkg error in $friendly_name (can be fixed)"
|
||||||
|
dpkg_error_containers["$vmid"]="$friendly_name:$api_id:$api_key"
|
||||||
|
# Store failure details
|
||||||
|
failed_containers["$vmid"]="$friendly_name|dpkg error|$install_output"
|
||||||
|
else
|
||||||
|
warn " ✗ Failed to install agent in $friendly_name (exit: $install_exit_code)"
|
||||||
|
# Store failure details
|
||||||
|
failed_containers["$vmid"]="$friendly_name|Exit code $install_exit_code|$install_output"
|
||||||
|
fi
|
||||||
|
info " Install output: $install_output"
|
||||||
|
((failed_count++)) || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
elif [[ "$http_code" == "409" ]]; then
|
||||||
|
warn " ⊘ Host $friendly_name already enrolled - skipping"
|
||||||
|
((skipped_count++)) || true
|
||||||
|
elif [[ "$http_code" == "429" ]]; then
|
||||||
|
error " ✗ Rate limit exceeded - maximum hosts per day reached"
|
||||||
|
failed_containers["$vmid"]="$friendly_name|Rate limit exceeded|$body"
|
||||||
|
((failed_count++)) || true
|
||||||
|
else
|
||||||
|
error " ✗ Failed to enroll $friendly_name - HTTP $http_code"
|
||||||
|
debug " Response: $body"
|
||||||
|
failed_containers["$vmid"]="$friendly_name|HTTP $http_code enrollment failed|$body"
|
||||||
|
((failed_count++)) || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
sleep 1 # Rate limiting between containers
|
||||||
|
|
||||||
|
done <<< "$lxc_list"
|
||||||
|
|
||||||
|
# ===== SUMMARY =====
|
||||||
|
echo ""
|
||||||
|
echo "╔═══════════════════════════════════════════════════════════════╗"
|
||||||
|
echo "║ ENROLLMENT SUMMARY ║"
|
||||||
|
echo "╚═══════════════════════════════════════════════════════════════╝"
|
||||||
|
echo ""
|
||||||
|
info "Total Containers Found: $total_containers"
|
||||||
|
info "Successfully Enrolled: $enrolled_count"
|
||||||
|
info "Skipped: $skipped_count"
|
||||||
|
info "Failed: $failed_count"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ===== FAILURE DETAILS =====
|
||||||
|
if [[ ${#failed_containers[@]} -gt 0 ]]; then
|
||||||
|
echo "╔═══════════════════════════════════════════════════════════════╗"
|
||||||
|
echo "║ FAILURE DETAILS ║"
|
||||||
|
echo "╚═══════════════════════════════════════════════════════════════╝"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
for vmid in "${!failed_containers[@]}"; do
|
||||||
|
IFS='|' read -r name reason output <<< "${failed_containers[$vmid]}"
|
||||||
|
|
||||||
|
warn "Container $vmid: $name"
|
||||||
|
info " Reason: $reason"
|
||||||
|
info " Last 5 lines of output:"
|
||||||
|
|
||||||
|
# Get last 5 lines of output
|
||||||
|
last_5_lines=$(echo "$output" | tail -n 5)
|
||||||
|
|
||||||
|
# Display each line with proper indentation
|
||||||
|
while IFS= read -r line; do
|
||||||
|
echo " $line"
|
||||||
|
done <<< "$last_5_lines"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$DRY_RUN" == "true" ]]; then
|
||||||
|
warn "This was a DRY RUN - no actual changes were made"
|
||||||
|
warn "Set DRY_RUN=false to perform actual enrollment"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ===== DPKG ERROR RECOVERY =====
|
||||||
|
if [[ ${#dpkg_error_containers[@]} -gt 0 ]]; then
|
||||||
|
echo ""
|
||||||
|
echo "╔═══════════════════════════════════════════════════════════════╗"
|
||||||
|
echo "║ DPKG ERROR RECOVERY AVAILABLE ║"
|
||||||
|
echo "╚═══════════════════════════════════════════════════════════════╝"
|
||||||
|
echo ""
|
||||||
|
warn "Detected ${#dpkg_error_containers[@]} container(s) with dpkg errors:"
|
||||||
|
for vmid in "${!dpkg_error_containers[@]}"; do
|
||||||
|
IFS=':' read -r name api_id api_key <<< "${dpkg_error_containers[$vmid]}"
|
||||||
|
info " • Container $vmid: $name"
|
||||||
|
done
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Ask user if they want to fix dpkg errors
|
||||||
|
read -p "Would you like to fix dpkg errors and retry installation? (y/N): " -n 1 -r
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||||
|
echo ""
|
||||||
|
info "Starting dpkg recovery process..."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
recovered_count=0
|
||||||
|
|
||||||
|
for vmid in "${!dpkg_error_containers[@]}"; do
|
||||||
|
IFS=':' read -r name api_id api_key <<< "${dpkg_error_containers[$vmid]}"
|
||||||
|
|
||||||
|
info "Fixing dpkg in container $vmid ($name)..."
|
||||||
|
|
||||||
|
# Run dpkg --configure -a
|
||||||
|
dpkg_output=$(timeout 60 pct exec "$vmid" -- dpkg --configure -a 2>&1 </dev/null || true)
|
||||||
|
|
||||||
|
if [[ $? -eq 0 ]]; then
|
||||||
|
info " ✓ dpkg fixed successfully"
|
||||||
|
|
||||||
|
# Retry agent installation
|
||||||
|
info " Retrying agent installation..."
|
||||||
|
|
||||||
|
install_exit_code=0
|
||||||
|
install_output=$(timeout 180 pct exec "$vmid" -- bash -c "
|
||||||
|
cd /tmp
|
||||||
|
curl $CURL_FLAGS \
|
||||||
|
-H \"X-API-ID: $api_id\" \
|
||||||
|
-H \"X-API-KEY: $api_key\" \
|
||||||
|
-o patchmon-install.sh \
|
||||||
|
'$PATCHMON_URL/api/v1/hosts/install' && \
|
||||||
|
bash patchmon-install.sh && \
|
||||||
|
rm -f patchmon-install.sh
|
||||||
|
" 2>&1 </dev/null) || install_exit_code=$?
|
||||||
|
|
||||||
|
if [[ $install_exit_code -eq 0 ]] || [[ "$install_output" == *"PatchMon Agent installation completed successfully"* ]]; then
|
||||||
|
info " ✓ Agent installed successfully in $name"
|
||||||
|
((recovered_count++)) || true
|
||||||
|
((enrolled_count++)) || true
|
||||||
|
((failed_count--)) || true
|
||||||
|
else
|
||||||
|
warn " ✗ Agent installation still failed (exit: $install_exit_code)"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
warn " ✗ Failed to fix dpkg in $name"
|
||||||
|
info " dpkg output: $dpkg_output"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
info "Recovery complete: $recovered_count container(s) recovered"
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ $failed_count -gt 0 ]]; then
|
||||||
|
warn "Some containers failed to enroll. Check the logs above for details."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
info "Auto-enrollment complete! ✓"
|
||||||
|
exit 0
|
||||||
|
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
# Database Configuration
|
# Database Configuration
|
||||||
DATABASE_URL="postgresql://patchmon_user:p@tchm0n_p@55@localhost:5432/patchmon_db"
|
DATABASE_URL="postgresql://patchmon_user:p@tchm0n_p@55@localhost:5432/patchmon_db"
|
||||||
|
PM_DB_CONN_MAX_ATTEMPTS=30
|
||||||
|
PM_DB_CONN_WAIT_INTERVAL=2
|
||||||
|
|
||||||
# Server Configuration
|
# Server Configuration
|
||||||
PORT=3001
|
PORT=3001
|
||||||
@@ -9,9 +11,28 @@ 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
|
||||||
|
|
||||||
|
# JWT Configuration
|
||||||
|
JWT_SECRET=your-secure-random-secret-key-change-this-in-production
|
||||||
|
JWT_EXPIRES_IN=1h
|
||||||
|
JWT_REFRESH_EXPIRES_IN=7d
|
||||||
|
SESSION_INACTIVITY_TIMEOUT_MINUTES=30
|
||||||
|
|
||||||
|
# TFA Configuration
|
||||||
|
TFA_REMEMBER_ME_EXPIRES_IN=30d
|
||||||
|
TFA_MAX_REMEMBER_SESSIONS=5
|
||||||
|
TFA_SUSPICIOUS_ACTIVITY_THRESHOLD=3
|
||||||
|
|||||||
@@ -1,36 +1,40 @@
|
|||||||
{
|
{
|
||||||
"name": "patchmon-backend",
|
"name": "patchmon-backend",
|
||||||
"version": "1.2.4",
|
"version": "1.2.7",
|
||||||
"description": "Backend API for Linux Patch Monitoring System",
|
"description": "Backend API for Linux Patch Monitoring System",
|
||||||
"main": "src/server.js",
|
"license": "AGPL-3.0",
|
||||||
"scripts": {
|
"main": "src/server.js",
|
||||||
"dev": "nodemon src/server.js",
|
"scripts": {
|
||||||
"start": "node src/server.js",
|
"dev": "nodemon src/server.js",
|
||||||
"build": "echo 'No build step needed for Node.js'",
|
"start": "node src/server.js",
|
||||||
"db:generate": "prisma generate",
|
"build": "echo 'No build step needed for Node.js'",
|
||||||
"db:migrate": "prisma migrate dev",
|
"db:generate": "prisma generate",
|
||||||
"db:push": "prisma db push",
|
"db:migrate": "prisma migrate dev",
|
||||||
"db:studio": "prisma studio"
|
"db:push": "prisma db push",
|
||||||
},
|
"db:studio": "prisma studio"
|
||||||
"dependencies": {
|
},
|
||||||
"@prisma/client": "^5.7.0",
|
"dependencies": {
|
||||||
"bcryptjs": "^2.4.3",
|
"@prisma/client": "^6.1.0",
|
||||||
"cors": "^2.8.5",
|
"bcryptjs": "^2.4.3",
|
||||||
"dotenv": "^16.3.1",
|
"cors": "^2.8.5",
|
||||||
"express": "^4.18.2",
|
"dotenv": "^16.4.7",
|
||||||
"express-rate-limit": "^7.1.5",
|
"express": "^4.21.2",
|
||||||
"express-validator": "^7.0.1",
|
"express-rate-limit": "^7.5.0",
|
||||||
"helmet": "^7.1.0",
|
"express-validator": "^7.2.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"helmet": "^8.0.0",
|
||||||
"moment": "^2.30.1",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"uuid": "^9.0.1",
|
"moment": "^2.30.1",
|
||||||
"winston": "^3.11.0"
|
"qrcode": "^1.5.4",
|
||||||
},
|
"speakeasy": "^2.0.0",
|
||||||
"devDependencies": {
|
"uuid": "^11.0.3",
|
||||||
"nodemon": "^3.0.2",
|
"winston": "^3.17.0"
|
||||||
"prisma": "^5.7.0"
|
},
|
||||||
},
|
"devDependencies": {
|
||||||
"engines": {
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"node": ">=18.0.0"
|
"nodemon": "^3.1.9",
|
||||||
}
|
"prisma": "^6.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
-- Fix dashboard preferences unique constraint
|
||||||
|
-- This migration fixes the unique constraint on dashboard_preferences table
|
||||||
|
|
||||||
|
-- Drop existing indexes if they exist
|
||||||
|
DROP INDEX IF EXISTS "dashboard_preferences_card_id_key";
|
||||||
|
DROP INDEX IF EXISTS "dashboard_preferences_user_id_card_id_key";
|
||||||
|
DROP INDEX IF EXISTS "dashboard_preferences_user_id_key";
|
||||||
|
|
||||||
|
-- Add the correct unique constraint
|
||||||
|
ALTER TABLE "dashboard_preferences" ADD CONSTRAINT "dashboard_preferences_user_id_card_id_key" UNIQUE ("user_id", "card_id");
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- DropTable
|
||||||
|
DROP TABLE "agent_versions";
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- Add ignore_ssl_self_signed column to settings table
|
||||||
|
-- This allows users to configure whether curl commands should ignore SSL certificate validation
|
||||||
|
|
||||||
|
ALTER TABLE "settings" ADD COLUMN "ignore_ssl_self_signed" BOOLEAN NOT NULL DEFAULT false;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "hosts" ADD COLUMN "notes" TEXT;
|
||||||
|
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "auto_enrollment_tokens" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"token_name" TEXT NOT NULL,
|
||||||
|
"token_key" TEXT NOT NULL,
|
||||||
|
"token_secret" TEXT NOT NULL,
|
||||||
|
"created_by_user_id" TEXT,
|
||||||
|
"is_active" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"allowed_ip_ranges" TEXT[],
|
||||||
|
"max_hosts_per_day" INTEGER NOT NULL DEFAULT 100,
|
||||||
|
"hosts_created_today" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"last_reset_date" DATE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"default_host_group_id" TEXT,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
"last_used_at" TIMESTAMP(3),
|
||||||
|
"expires_at" TIMESTAMP(3),
|
||||||
|
"metadata" JSONB,
|
||||||
|
|
||||||
|
CONSTRAINT "auto_enrollment_tokens_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "auto_enrollment_tokens_token_key_key" ON "auto_enrollment_tokens"("token_key");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "auto_enrollment_tokens_token_key_idx" ON "auto_enrollment_tokens"("token_key");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "auto_enrollment_tokens_is_active_idx" ON "auto_enrollment_tokens"("is_active");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "auto_enrollment_tokens" ADD CONSTRAINT "auto_enrollment_tokens_created_by_user_id_fkey" FOREIGN KEY ("created_by_user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "auto_enrollment_tokens" ADD CONSTRAINT "auto_enrollment_tokens_default_host_group_id_fkey" FOREIGN KEY ("default_host_group_id") REFERENCES "host_groups"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
-- Add machine_id column as nullable first
|
||||||
|
ALTER TABLE "hosts" ADD COLUMN "machine_id" TEXT;
|
||||||
|
|
||||||
|
-- Generate machine_ids for existing hosts using their API ID as a fallback
|
||||||
|
UPDATE "hosts" SET "machine_id" = 'migrated-' || "api_id" WHERE "machine_id" IS NULL;
|
||||||
|
|
||||||
|
-- Remove the unique constraint from friendly_name
|
||||||
|
ALTER TABLE "hosts" DROP CONSTRAINT IF EXISTS "hosts_friendly_name_key";
|
||||||
|
|
||||||
|
-- Also drop the unique index if it exists (constraint and index can exist separately)
|
||||||
|
DROP INDEX IF EXISTS "hosts_friendly_name_key";
|
||||||
|
|
||||||
|
-- Now make machine_id NOT NULL and add unique constraint
|
||||||
|
ALTER TABLE "hosts" ALTER COLUMN "machine_id" SET NOT NULL;
|
||||||
|
ALTER TABLE "hosts" ADD CONSTRAINT "hosts_machine_id_key" UNIQUE ("machine_id");
|
||||||
|
|
||||||
|
-- Create indexes for better query performance
|
||||||
|
CREATE INDEX "hosts_machine_id_idx" ON "hosts"("machine_id");
|
||||||
|
CREATE INDEX "hosts_friendly_name_idx" ON "hosts"("friendly_name");
|
||||||
|
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- AddLogoFieldsToSettings
|
||||||
|
ALTER TABLE "settings" ADD COLUMN "logo_dark" VARCHAR(255) DEFAULT '/assets/logo_dark.png';
|
||||||
|
ALTER TABLE "settings" ADD COLUMN "logo_light" VARCHAR(255) DEFAULT '/assets/logo_light.png';
|
||||||
|
ALTER TABLE "settings" ADD COLUMN "favicon" VARCHAR(255) DEFAULT '/assets/logo_square.svg';
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- Add TFA remember me fields to user_sessions table
|
||||||
|
ALTER TABLE "user_sessions" ADD COLUMN "tfa_remember_me" BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
ALTER TABLE "user_sessions" ADD COLUMN "tfa_bypass_until" TIMESTAMP(3);
|
||||||
|
|
||||||
|
-- Create index for TFA bypass until field for efficient querying
|
||||||
|
CREATE INDEX "user_sessions_tfa_bypass_until_idx" ON "user_sessions"("tfa_bypass_until");
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
-- Add security fields to user_sessions table for production-ready remember me
|
||||||
|
ALTER TABLE "user_sessions" ADD COLUMN "device_fingerprint" TEXT;
|
||||||
|
ALTER TABLE "user_sessions" ADD COLUMN "login_count" INTEGER NOT NULL DEFAULT 1;
|
||||||
|
ALTER TABLE "user_sessions" ADD COLUMN "last_login_ip" TEXT;
|
||||||
|
|
||||||
|
-- Create index for device fingerprint for efficient querying
|
||||||
|
CREATE INDEX "user_sessions_device_fingerprint_idx" ON "user_sessions"("device_fingerprint");
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "update_history" ADD COLUMN "total_packages" INTEGER;
|
||||||
|
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "update_history" ADD COLUMN "payload_size_kb" DOUBLE PRECISION;
|
||||||
|
ALTER TABLE "update_history" ADD COLUMN "execution_time" DOUBLE PRECISION;
|
||||||
|
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
-- Add indexes to host_packages table for performance optimization
|
||||||
|
-- These indexes will dramatically speed up queries filtering by host_id, package_id, needs_update, and is_security_update
|
||||||
|
|
||||||
|
-- Index for queries filtering by host_id (very common - used when viewing packages for a specific host)
|
||||||
|
CREATE INDEX IF NOT EXISTS "host_packages_host_id_idx" ON "host_packages"("host_id");
|
||||||
|
|
||||||
|
-- Index for queries filtering by package_id (used when finding hosts for a specific package)
|
||||||
|
CREATE INDEX IF NOT EXISTS "host_packages_package_id_idx" ON "host_packages"("package_id");
|
||||||
|
|
||||||
|
-- Index for queries filtering by needs_update (used when finding outdated packages)
|
||||||
|
CREATE INDEX IF NOT EXISTS "host_packages_needs_update_idx" ON "host_packages"("needs_update");
|
||||||
|
|
||||||
|
-- Index for queries filtering by is_security_update (used when finding security updates)
|
||||||
|
CREATE INDEX IF NOT EXISTS "host_packages_is_security_update_idx" ON "host_packages"("is_security_update");
|
||||||
|
|
||||||
|
-- Composite index for the most common query pattern: host_id + needs_update
|
||||||
|
-- This is optimal for "show me outdated packages for this host"
|
||||||
|
CREATE INDEX IF NOT EXISTS "host_packages_host_id_needs_update_idx" ON "host_packages"("host_id", "needs_update");
|
||||||
|
|
||||||
|
-- Composite index for host_id + needs_update + is_security_update
|
||||||
|
-- This is optimal for "show me security updates for this host"
|
||||||
|
CREATE INDEX IF NOT EXISTS "host_packages_host_id_needs_update_security_idx" ON "host_packages"("host_id", "needs_update", "is_security_update");
|
||||||
|
|
||||||
|
-- Index for queries filtering by package_id + needs_update
|
||||||
|
-- This is optimal for "show me hosts where this package needs updates"
|
||||||
|
CREATE INDEX IF NOT EXISTS "host_packages_package_id_needs_update_idx" ON "host_packages"("package_id", "needs_update");
|
||||||
|
|
||||||
|
-- Index on last_checked for cleanup/maintenance queries
|
||||||
|
CREATE INDEX IF NOT EXISTS "host_packages_last_checked_idx" ON "host_packages"("last_checked");
|
||||||
|
|
||||||
31
backend/prisma/migrations/add_user_sessions/migration.sql
Normal file
31
backend/prisma/migrations/add_user_sessions/migration.sql
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "user_sessions" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"user_id" TEXT NOT NULL,
|
||||||
|
"refresh_token" TEXT NOT NULL,
|
||||||
|
"access_token_hash" TEXT,
|
||||||
|
"ip_address" TEXT,
|
||||||
|
"user_agent" TEXT,
|
||||||
|
"last_activity" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"expires_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"is_revoked" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
|
||||||
|
CONSTRAINT "user_sessions_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "user_sessions_refresh_token_key" ON "user_sessions"("refresh_token");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "user_sessions_user_id_idx" ON "user_sessions"("user_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "user_sessions_refresh_token_idx" ON "user_sessions"("refresh_token");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "user_sessions_expires_at_idx" ON "user_sessions"("expires_at");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "user_sessions" ADD CONSTRAINT "user_sessions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
@@ -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,210 +7,258 @@ datasource db {
|
|||||||
url = env("DATABASE_URL")
|
url = env("DATABASE_URL")
|
||||||
}
|
}
|
||||||
|
|
||||||
model User {
|
model dashboard_preferences {
|
||||||
id String @id @default(cuid())
|
id String @id
|
||||||
username String @unique
|
user_id String
|
||||||
email String @unique
|
card_id String
|
||||||
passwordHash String @map("password_hash")
|
enabled Boolean @default(true)
|
||||||
role String @default("admin") // admin, user
|
order Int @default(0)
|
||||||
isActive Boolean @default(true) @map("is_active")
|
created_at DateTime @default(now())
|
||||||
lastLogin DateTime? @map("last_login")
|
updated_at DateTime
|
||||||
createdAt DateTime @map("created_at") @default(now())
|
users users @relation(fields: [user_id], references: [id], onDelete: Cascade)
|
||||||
updatedAt DateTime @map("updated_at") @updatedAt
|
|
||||||
|
@@unique([user_id, card_id])
|
||||||
// Relationships
|
|
||||||
dashboardPreferences DashboardPreferences[]
|
|
||||||
|
|
||||||
@@map("users")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
model RolePermissions {
|
model host_groups {
|
||||||
id String @id @default(cuid())
|
id String @id
|
||||||
role String @unique // admin, user, custom roles
|
name String @unique
|
||||||
canViewDashboard Boolean @default(true) @map("can_view_dashboard")
|
description String?
|
||||||
canViewHosts Boolean @default(true) @map("can_view_hosts")
|
color String? @default("#3B82F6")
|
||||||
canManageHosts Boolean @default(false) @map("can_manage_hosts")
|
created_at DateTime @default(now())
|
||||||
canViewPackages Boolean @default(true) @map("can_view_packages")
|
updated_at DateTime
|
||||||
canManagePackages Boolean @default(false) @map("can_manage_packages")
|
hosts hosts[]
|
||||||
canViewUsers Boolean @default(false) @map("can_view_users")
|
auto_enrollment_tokens auto_enrollment_tokens[]
|
||||||
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 {
|
model host_packages {
|
||||||
id String @id @default(cuid())
|
id String @id
|
||||||
name String @unique
|
host_id String
|
||||||
description String?
|
package_id String
|
||||||
color String? @default("#3B82F6") // Hex color for UI display
|
current_version String
|
||||||
createdAt DateTime @map("created_at") @default(now())
|
available_version String?
|
||||||
updatedAt DateTime @map("updated_at") @updatedAt
|
needs_update Boolean @default(false)
|
||||||
|
is_security_update Boolean @default(false)
|
||||||
// Relationships
|
last_checked DateTime @default(now())
|
||||||
hosts Host[]
|
hosts hosts @relation(fields: [host_id], references: [id], onDelete: Cascade)
|
||||||
|
packages packages @relation(fields: [package_id], references: [id], onDelete: Cascade)
|
||||||
@@map("host_groups")
|
|
||||||
|
@@unique([host_id, package_id])
|
||||||
|
@@index([host_id])
|
||||||
|
@@index([package_id])
|
||||||
|
@@index([needs_update])
|
||||||
|
@@index([is_security_update])
|
||||||
|
@@index([host_id, needs_update])
|
||||||
|
@@index([host_id, needs_update, is_security_update])
|
||||||
|
@@index([package_id, needs_update])
|
||||||
|
@@index([last_checked])
|
||||||
}
|
}
|
||||||
|
|
||||||
model Host {
|
model host_repositories {
|
||||||
id String @id @default(cuid())
|
id String @id
|
||||||
hostname String @unique
|
host_id String
|
||||||
ip String?
|
repository_id String
|
||||||
osType String @map("os_type")
|
is_enabled Boolean @default(true)
|
||||||
osVersion String @map("os_version")
|
last_checked DateTime @default(now())
|
||||||
architecture String?
|
hosts hosts @relation(fields: [host_id], references: [id], onDelete: Cascade)
|
||||||
lastUpdate DateTime @map("last_update") @default(now())
|
repositories repositories @relation(fields: [repository_id], references: [id], onDelete: Cascade)
|
||||||
status String @default("active") // active, inactive, error
|
|
||||||
apiId String @unique @map("api_id") // New API ID for authentication
|
@@unique([host_id, repository_id])
|
||||||
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 {
|
model hosts {
|
||||||
id String @id @default(cuid())
|
id String @id
|
||||||
name String @unique
|
machine_id String @unique
|
||||||
description String?
|
friendly_name String
|
||||||
category String? // system, security, development, etc.
|
ip String?
|
||||||
latestVersion String? @map("latest_version")
|
os_type String
|
||||||
createdAt DateTime @map("created_at") @default(now())
|
os_version String
|
||||||
updatedAt DateTime @map("updated_at") @updatedAt
|
architecture String?
|
||||||
|
last_update DateTime @default(now())
|
||||||
// Relationships
|
status String @default("active")
|
||||||
hostPackages HostPackage[]
|
created_at DateTime @default(now())
|
||||||
|
updated_at DateTime
|
||||||
@@map("packages")
|
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?
|
||||||
|
notes String?
|
||||||
|
host_packages host_packages[]
|
||||||
|
host_repositories host_repositories[]
|
||||||
|
host_groups host_groups? @relation(fields: [host_group_id], references: [id])
|
||||||
|
update_history update_history[]
|
||||||
|
|
||||||
|
@@index([machine_id])
|
||||||
|
@@index([friendly_name])
|
||||||
|
@@index([hostname])
|
||||||
}
|
}
|
||||||
|
|
||||||
model HostPackage {
|
model packages {
|
||||||
id String @id @default(cuid())
|
id String @id
|
||||||
hostId String @map("host_id")
|
name String @unique
|
||||||
packageId String @map("package_id")
|
description String?
|
||||||
currentVersion String @map("current_version")
|
category String?
|
||||||
availableVersion String? @map("available_version")
|
latest_version String?
|
||||||
needsUpdate Boolean @map("needs_update") @default(false)
|
created_at DateTime @default(now())
|
||||||
isSecurityUpdate Boolean @map("is_security_update") @default(false)
|
updated_at DateTime
|
||||||
lastChecked DateTime @map("last_checked") @default(now())
|
host_packages host_packages[]
|
||||||
|
|
||||||
// Relationships
|
@@index([name])
|
||||||
host Host @relation(fields: [hostId], references: [id], onDelete: Cascade)
|
@@index([category])
|
||||||
package Package @relation(fields: [packageId], references: [id], onDelete: Cascade)
|
|
||||||
|
|
||||||
@@unique([hostId, packageId])
|
|
||||||
@@map("host_packages")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
model UpdateHistory {
|
model repositories {
|
||||||
id String @id @default(cuid())
|
id String @id
|
||||||
hostId String @map("host_id")
|
name String
|
||||||
packagesCount Int @map("packages_count")
|
url String
|
||||||
securityCount Int @map("security_count")
|
distribution String
|
||||||
timestamp DateTime @default(now())
|
components String
|
||||||
status String @default("success") // success, error
|
repo_type String
|
||||||
errorMessage String? @map("error_message")
|
is_active Boolean @default(true)
|
||||||
|
is_secure Boolean @default(true)
|
||||||
// Relationships
|
priority Int?
|
||||||
host Host @relation(fields: [hostId], references: [id], onDelete: Cascade)
|
description String?
|
||||||
|
created_at DateTime @default(now())
|
||||||
@@map("update_history")
|
updated_at DateTime
|
||||||
}
|
host_repositories host_repositories[]
|
||||||
|
|
||||||
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])
|
@@unique([url, distribution, components])
|
||||||
@@map("repositories")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
model HostRepository {
|
model role_permissions {
|
||||||
id String @id @default(cuid())
|
id String @id
|
||||||
hostId String @map("host_id")
|
role String @unique
|
||||||
repositoryId String @map("repository_id")
|
can_view_dashboard Boolean @default(true)
|
||||||
isEnabled Boolean @map("is_enabled") @default(true)
|
can_view_hosts Boolean @default(true)
|
||||||
lastChecked DateTime @map("last_checked") @default(now())
|
can_manage_hosts Boolean @default(false)
|
||||||
|
can_view_packages Boolean @default(true)
|
||||||
// Relationships
|
can_manage_packages Boolean @default(false)
|
||||||
host Host @relation(fields: [hostId], references: [id], onDelete: Cascade)
|
can_view_users Boolean @default(false)
|
||||||
repository Repository @relation(fields: [repositoryId], references: [id], onDelete: Cascade)
|
can_manage_users Boolean @default(false)
|
||||||
|
can_view_reports Boolean @default(true)
|
||||||
@@unique([hostId, repositoryId])
|
can_export_data Boolean @default(false)
|
||||||
@@map("host_repositories")
|
can_manage_settings Boolean @default(false)
|
||||||
|
created_at DateTime @default(now())
|
||||||
|
updated_at DateTime
|
||||||
}
|
}
|
||||||
|
|
||||||
model Settings {
|
model settings {
|
||||||
id String @id @default(cuid())
|
id String @id
|
||||||
serverUrl String @map("server_url") @default("http://localhost:3001")
|
server_url String @default("http://localhost:3001")
|
||||||
serverProtocol String @map("server_protocol") @default("http") // http, https
|
server_protocol String @default("http")
|
||||||
serverHost String @map("server_host") @default("localhost")
|
server_host String @default("localhost")
|
||||||
serverPort Int @map("server_port") @default(3001)
|
server_port Int @default(3001)
|
||||||
frontendUrl String @map("frontend_url") @default("http://localhost:3000")
|
created_at DateTime @default(now())
|
||||||
updateInterval Int @map("update_interval") @default(60) // Update interval in minutes
|
updated_at DateTime
|
||||||
autoUpdate Boolean @map("auto_update") @default(false) // Enable automatic agent updates
|
update_interval Int @default(60)
|
||||||
githubRepoUrl String @map("github_repo_url") @default("git@github.com:9technologygroup/patchmon.net.git") // GitHub repository URL for version checking
|
auto_update Boolean @default(false)
|
||||||
sshKeyPath String? @map("ssh_key_path") // Optional SSH key path for deploy key authentication
|
github_repo_url String @default("git@github.com:9technologygroup/patchmon.net.git")
|
||||||
createdAt DateTime @map("created_at") @default(now())
|
ssh_key_path String?
|
||||||
updatedAt DateTime @map("updated_at") @updatedAt
|
repository_type String @default("public")
|
||||||
|
last_update_check DateTime?
|
||||||
@@map("settings")
|
latest_version String?
|
||||||
|
update_available Boolean @default(false)
|
||||||
|
signup_enabled Boolean @default(false)
|
||||||
|
default_user_role String @default("user")
|
||||||
|
ignore_ssl_self_signed Boolean @default(false)
|
||||||
|
logo_dark String? @default("/assets/logo_dark.png")
|
||||||
|
logo_light String? @default("/assets/logo_light.png")
|
||||||
|
favicon String? @default("/assets/logo_square.svg")
|
||||||
}
|
}
|
||||||
|
|
||||||
model DashboardPreferences {
|
model update_history {
|
||||||
id String @id @default(cuid())
|
id String @id
|
||||||
userId String @map("user_id")
|
host_id String
|
||||||
cardId String @map("card_id") // e.g., "totalHosts", "securityUpdates", etc.
|
packages_count Int
|
||||||
enabled Boolean @default(true)
|
security_count Int
|
||||||
order Int @default(0)
|
total_packages Int?
|
||||||
createdAt DateTime @map("created_at") @default(now())
|
payload_size_kb Float?
|
||||||
updatedAt DateTime @map("updated_at") @updatedAt
|
execution_time Float?
|
||||||
|
timestamp DateTime @default(now())
|
||||||
// Relationships
|
status String @default("success")
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
error_message String?
|
||||||
|
hosts hosts @relation(fields: [host_id], references: [id], onDelete: Cascade)
|
||||||
@@unique([userId, cardId])
|
|
||||||
@@map("dashboard_preferences")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
model AgentVersion {
|
model users {
|
||||||
id String @id @default(cuid())
|
id String @id
|
||||||
version String @unique // e.g., "1.0.0", "1.1.0"
|
username String @unique
|
||||||
isCurrent Boolean @default(false) @map("is_current") // Only one version can be current
|
email String @unique
|
||||||
releaseNotes String? @map("release_notes")
|
password_hash String
|
||||||
downloadUrl String? @map("download_url") // URL to download the agent script
|
role String @default("admin")
|
||||||
minServerVersion String? @map("min_server_version") // Minimum server version required
|
is_active Boolean @default(true)
|
||||||
scriptContent String? @map("script_content") // The actual agent script content
|
last_login DateTime?
|
||||||
isDefault Boolean @default(false) @map("is_default") // Default version for new installations
|
created_at DateTime @default(now())
|
||||||
createdAt DateTime @map("created_at") @default(now())
|
updated_at DateTime
|
||||||
updatedAt DateTime @map("updated_at") @updatedAt
|
tfa_backup_codes String?
|
||||||
|
tfa_enabled Boolean @default(false)
|
||||||
@@map("agent_versions")
|
tfa_secret String?
|
||||||
}
|
first_name String?
|
||||||
|
last_name String?
|
||||||
|
dashboard_preferences dashboard_preferences[]
|
||||||
|
user_sessions user_sessions[]
|
||||||
|
auto_enrollment_tokens auto_enrollment_tokens[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model user_sessions {
|
||||||
|
id String @id
|
||||||
|
user_id String
|
||||||
|
refresh_token String @unique
|
||||||
|
access_token_hash String?
|
||||||
|
ip_address String?
|
||||||
|
user_agent String?
|
||||||
|
device_fingerprint String?
|
||||||
|
last_activity DateTime @default(now())
|
||||||
|
expires_at DateTime
|
||||||
|
created_at DateTime @default(now())
|
||||||
|
is_revoked Boolean @default(false)
|
||||||
|
tfa_remember_me Boolean @default(false)
|
||||||
|
tfa_bypass_until DateTime?
|
||||||
|
login_count Int @default(1)
|
||||||
|
last_login_ip String?
|
||||||
|
users users @relation(fields: [user_id], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([user_id])
|
||||||
|
@@index([refresh_token])
|
||||||
|
@@index([expires_at])
|
||||||
|
@@index([tfa_bypass_until])
|
||||||
|
@@index([device_fingerprint])
|
||||||
|
}
|
||||||
|
|
||||||
|
model auto_enrollment_tokens {
|
||||||
|
id String @id
|
||||||
|
token_name String
|
||||||
|
token_key String @unique
|
||||||
|
token_secret String
|
||||||
|
created_by_user_id String?
|
||||||
|
is_active Boolean @default(true)
|
||||||
|
allowed_ip_ranges String[]
|
||||||
|
max_hosts_per_day Int @default(100)
|
||||||
|
hosts_created_today Int @default(0)
|
||||||
|
last_reset_date DateTime @default(now()) @db.Date
|
||||||
|
default_host_group_id String?
|
||||||
|
created_at DateTime @default(now())
|
||||||
|
updated_at DateTime
|
||||||
|
last_used_at DateTime?
|
||||||
|
expires_at DateTime?
|
||||||
|
metadata Json?
|
||||||
|
users users? @relation(fields: [created_by_user_id], references: [id], onDelete: SetNull)
|
||||||
|
host_groups host_groups? @relation(fields: [default_host_group_id], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
|
@@index([token_key])
|
||||||
|
@@index([is_active])
|
||||||
|
}
|
||||||
|
|||||||
129
backend/src/config/database.js
Normal file
129
backend/src/config/database.js
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
/**
|
||||||
|
* 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.PRISMA_LOG_QUERIES === "true"
|
||||||
|
? ["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, 10) ||
|
||||||
|
30;
|
||||||
|
const waitInterval =
|
||||||
|
options.waitInterval ||
|
||||||
|
parseInt(process.env.PM_DB_CONN_WAIT_INTERVAL, 10) ||
|
||||||
|
2;
|
||||||
|
|
||||||
|
if (process.env.ENABLE_LOGGING === "true") {
|
||||||
|
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) {
|
||||||
|
if (process.env.ENABLE_LOGGING === "true") {
|
||||||
|
console.log(
|
||||||
|
`Database connected successfully after ${attempt} attempt(s)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// checkDatabaseConnection already logs the error
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempt < maxAttempts) {
|
||||||
|
if (process.env.ENABLE_LOGGING === "true") {
|
||||||
|
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,
|
||||||
|
};
|
||||||
@@ -1,98 +1,151 @@
|
|||||||
const jwt = require('jsonwebtoken');
|
const jwt = require("jsonwebtoken");
|
||||||
const { PrismaClient } = require('@prisma/client');
|
const { PrismaClient } = require("@prisma/client");
|
||||||
|
const {
|
||||||
|
validate_session,
|
||||||
|
update_session_activity,
|
||||||
|
is_tfa_bypassed,
|
||||||
|
} = require("../utils/session_manager");
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
// Middleware to verify JWT token
|
// Middleware to verify JWT token with session validation
|
||||||
const authenticateToken = async (req, res, next) => {
|
const authenticateToken = async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const authHeader = req.headers['authorization'];
|
const authHeader = req.headers.authorization;
|
||||||
const token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN
|
const token = authHeader?.split(" ")[1]; // Bearer TOKEN
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return res.status(401).json({ error: 'Access token required' });
|
return res.status(401).json({ error: "Access token required" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify token
|
// Verify token
|
||||||
const decoded = jwt.verify(token, process.env.JWT_SECRET || 'your-secret-key');
|
if (!process.env.JWT_SECRET) {
|
||||||
|
throw new Error("JWT_SECRET environment variable is required");
|
||||||
// Get user from database
|
}
|
||||||
const user = await prisma.user.findUnique({
|
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||||
where: { id: decoded.userId },
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
username: true,
|
|
||||||
email: true,
|
|
||||||
role: true,
|
|
||||||
isActive: true,
|
|
||||||
lastLogin: true
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!user || !user.isActive) {
|
// Validate session and check inactivity timeout
|
||||||
return res.status(401).json({ error: 'Invalid or inactive user' });
|
const validation = await validate_session(decoded.sessionId, token);
|
||||||
}
|
|
||||||
|
|
||||||
// Update last login
|
if (!validation.valid) {
|
||||||
await prisma.user.update({
|
const error_messages = {
|
||||||
where: { id: user.id },
|
"Session not found": "Session not found",
|
||||||
data: { lastLogin: new Date() }
|
"Session revoked": "Session has been revoked",
|
||||||
});
|
"Session expired": "Session has expired",
|
||||||
|
"Session inactive":
|
||||||
|
validation.message || "Session timed out due to inactivity",
|
||||||
|
"Token mismatch": "Invalid token",
|
||||||
|
"User inactive": "User account is inactive",
|
||||||
|
};
|
||||||
|
|
||||||
req.user = user;
|
return res.status(401).json({
|
||||||
next();
|
error: error_messages[validation.reason] || "Authentication failed",
|
||||||
} catch (error) {
|
reason: validation.reason,
|
||||||
if (error.name === 'JsonWebTokenError') {
|
});
|
||||||
return res.status(401).json({ error: 'Invalid token' });
|
}
|
||||||
}
|
|
||||||
if (error.name === 'TokenExpiredError') {
|
// Update session activity timestamp
|
||||||
return res.status(401).json({ error: 'Token expired' });
|
await update_session_activity(decoded.sessionId);
|
||||||
}
|
|
||||||
console.error('Auth middleware error:', error);
|
// Check if TFA is bypassed for this session
|
||||||
return res.status(500).json({ error: 'Authentication failed' });
|
const tfa_bypassed = await is_tfa_bypassed(decoded.sessionId);
|
||||||
}
|
|
||||||
|
// Update last login (only on successful authentication)
|
||||||
|
await prisma.users.update({
|
||||||
|
where: { id: validation.user.id },
|
||||||
|
data: {
|
||||||
|
last_login: new Date(),
|
||||||
|
updated_at: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
req.user = validation.user;
|
||||||
|
req.session_id = decoded.sessionId;
|
||||||
|
req.tfa_bypassed = tfa_bypassed;
|
||||||
|
next();
|
||||||
|
} catch (error) {
|
||||||
|
if (error.name === "JsonWebTokenError") {
|
||||||
|
return res.status(401).json({ error: "Invalid token" });
|
||||||
|
}
|
||||||
|
if (error.name === "TokenExpiredError") {
|
||||||
|
return res.status(401).json({ error: "Token expired" });
|
||||||
|
}
|
||||||
|
console.error("Auth middleware error:", error);
|
||||||
|
return res.status(500).json({ error: "Authentication failed" });
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Middleware to check admin role
|
// Middleware to check admin role
|
||||||
const requireAdmin = (req, res, next) => {
|
const requireAdmin = (req, res, next) => {
|
||||||
if (req.user.role !== 'admin') {
|
if (req.user.role !== "admin") {
|
||||||
return res.status(403).json({ error: 'Admin access required' });
|
return res.status(403).json({ error: "Admin access required" });
|
||||||
}
|
}
|
||||||
next();
|
next();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Middleware to check if user is authenticated (optional)
|
// Middleware to check if user is authenticated (optional)
|
||||||
const optionalAuth = async (req, res, next) => {
|
const optionalAuth = async (req, _res, next) => {
|
||||||
try {
|
try {
|
||||||
const authHeader = req.headers['authorization'];
|
const authHeader = req.headers.authorization;
|
||||||
const token = authHeader && authHeader.split(' ')[1];
|
const token = authHeader?.split(" ")[1];
|
||||||
|
|
||||||
if (token) {
|
if (token) {
|
||||||
const decoded = jwt.verify(token, process.env.JWT_SECRET || 'your-secret-key');
|
if (!process.env.JWT_SECRET) {
|
||||||
const user = await prisma.user.findUnique({
|
throw new Error("JWT_SECRET environment variable is required");
|
||||||
where: { id: decoded.userId },
|
}
|
||||||
select: {
|
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||||
id: true,
|
const user = await prisma.users.findUnique({
|
||||||
username: true,
|
where: { id: decoded.userId },
|
||||||
email: true,
|
select: {
|
||||||
role: true,
|
id: true,
|
||||||
isActive: true
|
username: true,
|
||||||
}
|
email: true,
|
||||||
});
|
role: true,
|
||||||
|
is_active: true,
|
||||||
|
last_login: true,
|
||||||
|
created_at: true,
|
||||||
|
updated_at: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
if (user && user.isActive) {
|
if (user?.is_active) {
|
||||||
req.user = user;
|
req.user = user;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
next();
|
next();
|
||||||
} catch (error) {
|
} catch {
|
||||||
// Continue without authentication for optional auth
|
// Continue without authentication for optional auth
|
||||||
next();
|
next();
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Middleware to check if TFA is required for sensitive operations
|
||||||
|
const requireTfaIfEnabled = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
// Check if user has TFA enabled
|
||||||
|
const user = await prisma.users.findUnique({
|
||||||
|
where: { id: req.user.id },
|
||||||
|
select: { tfa_enabled: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
// If TFA is enabled and not bypassed, require TFA verification
|
||||||
|
if (user?.tfa_enabled && !req.tfa_bypassed) {
|
||||||
|
return res.status(403).json({
|
||||||
|
error: "Two-factor authentication required for this operation",
|
||||||
|
requires_tfa: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("TFA requirement check error:", error);
|
||||||
|
return res.status(500).json({ error: "Authentication check failed" });
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
authenticateToken,
|
authenticateToken,
|
||||||
requireAdmin,
|
requireAdmin,
|
||||||
optionalAuth
|
optionalAuth,
|
||||||
|
requireTfaIfEnabled,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,59 +1,61 @@
|
|||||||
const { PrismaClient } = require('@prisma/client');
|
const { PrismaClient } = require("@prisma/client");
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
// Permission middleware factory
|
// Permission middleware factory
|
||||||
const requirePermission = (permission) => {
|
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 },
|
||||||
});
|
});
|
||||||
|
|
||||||
// If no specific permissions found, default to admin permissions (for backward compatibility)
|
// If no specific permissions found, default to admin permissions (for backward compatibility)
|
||||||
if (!rolePermissions) {
|
if (!rolePermissions) {
|
||||||
console.warn(`No permissions found for role: ${req.user.role}, defaulting to admin access`);
|
console.warn(
|
||||||
return next();
|
`No permissions found for role: ${req.user.role}, defaulting to admin access`,
|
||||||
}
|
);
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
// Check if user has the required permission
|
// Check if user has the required 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("_", " ")}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
next();
|
next();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Permission check error:', error);
|
console.error("Permission check error:", error);
|
||||||
res.status(500).json({ error: 'Permission check failed' });
|
res.status(500).json({ error: "Permission check failed" });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
// 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,
|
||||||
requireViewDashboard,
|
requireViewDashboard,
|
||||||
requireViewHosts,
|
requireViewHosts,
|
||||||
requireManageHosts,
|
requireManageHosts,
|
||||||
requireViewPackages,
|
requireViewPackages,
|
||||||
requireManagePackages,
|
requireManagePackages,
|
||||||
requireViewUsers,
|
requireViewUsers,
|
||||||
requireManageUsers,
|
requireManageUsers,
|
||||||
requireViewReports,
|
requireViewReports,
|
||||||
requireExportData,
|
requireExportData,
|
||||||
requireManageSettings
|
requireManageSettings,
|
||||||
};
|
};
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
745
backend/src/routes/autoEnrollmentRoutes.js
Normal file
745
backend/src/routes/autoEnrollmentRoutes.js
Normal file
@@ -0,0 +1,745 @@
|
|||||||
|
const express = require("express");
|
||||||
|
const { PrismaClient } = require("@prisma/client");
|
||||||
|
const crypto = require("node:crypto");
|
||||||
|
const bcrypt = require("bcryptjs");
|
||||||
|
const { body, validationResult } = require("express-validator");
|
||||||
|
const { authenticateToken } = require("../middleware/auth");
|
||||||
|
const { requireManageSettings } = require("../middleware/permissions");
|
||||||
|
const { v4: uuidv4 } = require("uuid");
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
// Generate auto-enrollment token credentials
|
||||||
|
const generate_auto_enrollment_token = () => {
|
||||||
|
const token_key = `patchmon_ae_${crypto.randomBytes(16).toString("hex")}`;
|
||||||
|
const token_secret = crypto.randomBytes(48).toString("hex");
|
||||||
|
return { token_key, token_secret };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Middleware to validate auto-enrollment token
|
||||||
|
const validate_auto_enrollment_token = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const token_key = req.headers["x-auto-enrollment-key"];
|
||||||
|
const token_secret = req.headers["x-auto-enrollment-secret"];
|
||||||
|
|
||||||
|
if (!token_key || !token_secret) {
|
||||||
|
return res
|
||||||
|
.status(401)
|
||||||
|
.json({ error: "Auto-enrollment credentials required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find token
|
||||||
|
const token = await prisma.auto_enrollment_tokens.findUnique({
|
||||||
|
where: { token_key: token_key },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!token || !token.is_active) {
|
||||||
|
return res.status(401).json({ error: "Invalid or inactive token" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify secret (hashed)
|
||||||
|
const is_valid = await bcrypt.compare(token_secret, token.token_secret);
|
||||||
|
if (!is_valid) {
|
||||||
|
return res.status(401).json({ error: "Invalid token secret" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check expiration
|
||||||
|
if (token.expires_at && new Date() > new Date(token.expires_at)) {
|
||||||
|
return res.status(401).json({ error: "Token expired" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check IP whitelist if configured
|
||||||
|
if (token.allowed_ip_ranges && token.allowed_ip_ranges.length > 0) {
|
||||||
|
const client_ip = req.ip || req.connection.remoteAddress;
|
||||||
|
// Basic IP check - can be enhanced with CIDR matching
|
||||||
|
const ip_allowed = token.allowed_ip_ranges.some((allowed_ip) => {
|
||||||
|
return client_ip.includes(allowed_ip);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!ip_allowed) {
|
||||||
|
console.warn(
|
||||||
|
`Auto-enrollment attempt from unauthorized IP: ${client_ip}`,
|
||||||
|
);
|
||||||
|
return res
|
||||||
|
.status(403)
|
||||||
|
.json({ error: "IP address not authorized for this token" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check rate limit (hosts per day)
|
||||||
|
const today = new Date().toISOString().split("T")[0];
|
||||||
|
const token_reset_date = token.last_reset_date.toISOString().split("T")[0];
|
||||||
|
|
||||||
|
if (token_reset_date !== today) {
|
||||||
|
// Reset daily counter
|
||||||
|
await prisma.auto_enrollment_tokens.update({
|
||||||
|
where: { id: token.id },
|
||||||
|
data: {
|
||||||
|
hosts_created_today: 0,
|
||||||
|
last_reset_date: new Date(),
|
||||||
|
updated_at: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
token.hosts_created_today = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (token.hosts_created_today >= token.max_hosts_per_day) {
|
||||||
|
return res.status(429).json({
|
||||||
|
error: "Rate limit exceeded",
|
||||||
|
message: `Maximum ${token.max_hosts_per_day} hosts per day allowed for this token`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
req.auto_enrollment_token = token;
|
||||||
|
next();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Auto-enrollment token validation error:", error);
|
||||||
|
res.status(500).json({ error: "Token validation failed" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ========== ADMIN ENDPOINTS (Manage Tokens) ==========
|
||||||
|
|
||||||
|
// Create auto-enrollment token
|
||||||
|
router.post(
|
||||||
|
"/tokens",
|
||||||
|
authenticateToken,
|
||||||
|
requireManageSettings,
|
||||||
|
[
|
||||||
|
body("token_name")
|
||||||
|
.isLength({ min: 1, max: 255 })
|
||||||
|
.withMessage("Token name is required (max 255 characters)"),
|
||||||
|
body("allowed_ip_ranges")
|
||||||
|
.optional()
|
||||||
|
.isArray()
|
||||||
|
.withMessage("Allowed IP ranges must be an array"),
|
||||||
|
body("max_hosts_per_day")
|
||||||
|
.optional()
|
||||||
|
.isInt({ min: 1, max: 1000 })
|
||||||
|
.withMessage("Max hosts per day must be between 1 and 1000"),
|
||||||
|
body("default_host_group_id")
|
||||||
|
.optional({ nullable: true, checkFalsy: true })
|
||||||
|
.isString(),
|
||||||
|
body("expires_at")
|
||||||
|
.optional({ nullable: true, checkFalsy: true })
|
||||||
|
.isISO8601()
|
||||||
|
.withMessage("Invalid date format"),
|
||||||
|
],
|
||||||
|
async (req, res) => {
|
||||||
|
try {
|
||||||
|
const errors = validationResult(req);
|
||||||
|
if (!errors.isEmpty()) {
|
||||||
|
return res.status(400).json({ errors: errors.array() });
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
token_name,
|
||||||
|
allowed_ip_ranges = [],
|
||||||
|
max_hosts_per_day = 100,
|
||||||
|
default_host_group_id,
|
||||||
|
expires_at,
|
||||||
|
metadata = {},
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
// Validate host group if provided
|
||||||
|
if (default_host_group_id) {
|
||||||
|
const host_group = await prisma.host_groups.findUnique({
|
||||||
|
where: { id: default_host_group_id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!host_group) {
|
||||||
|
return res.status(400).json({ error: "Host group not found" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { token_key, token_secret } = generate_auto_enrollment_token();
|
||||||
|
const hashed_secret = await bcrypt.hash(token_secret, 10);
|
||||||
|
|
||||||
|
const token = await prisma.auto_enrollment_tokens.create({
|
||||||
|
data: {
|
||||||
|
id: uuidv4(),
|
||||||
|
token_name,
|
||||||
|
token_key: token_key,
|
||||||
|
token_secret: hashed_secret,
|
||||||
|
created_by_user_id: req.user.id,
|
||||||
|
allowed_ip_ranges,
|
||||||
|
max_hosts_per_day,
|
||||||
|
default_host_group_id: default_host_group_id || null,
|
||||||
|
expires_at: expires_at ? new Date(expires_at) : null,
|
||||||
|
metadata: { integration_type: "proxmox-lxc", ...metadata },
|
||||||
|
updated_at: new Date(),
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
host_groups: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
color: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
users: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
first_name: true,
|
||||||
|
last_name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Return unhashed secret ONLY once (like API keys)
|
||||||
|
res.status(201).json({
|
||||||
|
message: "Auto-enrollment token created successfully",
|
||||||
|
token: {
|
||||||
|
id: token.id,
|
||||||
|
token_name: token.token_name,
|
||||||
|
token_key: token_key,
|
||||||
|
token_secret: token_secret, // ONLY returned here!
|
||||||
|
max_hosts_per_day: token.max_hosts_per_day,
|
||||||
|
default_host_group: token.host_groups,
|
||||||
|
created_by: token.users,
|
||||||
|
expires_at: token.expires_at,
|
||||||
|
},
|
||||||
|
warning: "⚠️ Save the token_secret now - it cannot be retrieved later!",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Create auto-enrollment token error:", error);
|
||||||
|
res.status(500).json({ error: "Failed to create token" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// List auto-enrollment tokens
|
||||||
|
router.get(
|
||||||
|
"/tokens",
|
||||||
|
authenticateToken,
|
||||||
|
requireManageSettings,
|
||||||
|
async (_req, res) => {
|
||||||
|
try {
|
||||||
|
const tokens = await prisma.auto_enrollment_tokens.findMany({
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
token_name: true,
|
||||||
|
token_key: true,
|
||||||
|
is_active: true,
|
||||||
|
allowed_ip_ranges: true,
|
||||||
|
max_hosts_per_day: true,
|
||||||
|
hosts_created_today: true,
|
||||||
|
last_used_at: true,
|
||||||
|
expires_at: true,
|
||||||
|
created_at: true,
|
||||||
|
default_host_group_id: true,
|
||||||
|
metadata: true,
|
||||||
|
host_groups: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
color: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
users: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
first_name: true,
|
||||||
|
last_name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { created_at: "desc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(tokens);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("List auto-enrollment tokens error:", error);
|
||||||
|
res.status(500).json({ error: "Failed to list tokens" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get single token details
|
||||||
|
router.get(
|
||||||
|
"/tokens/:tokenId",
|
||||||
|
authenticateToken,
|
||||||
|
requireManageSettings,
|
||||||
|
async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { tokenId } = req.params;
|
||||||
|
|
||||||
|
const token = await prisma.auto_enrollment_tokens.findUnique({
|
||||||
|
where: { id: tokenId },
|
||||||
|
include: {
|
||||||
|
host_groups: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
color: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
users: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
first_name: true,
|
||||||
|
last_name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return res.status(404).json({ error: "Token not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't include the secret in response
|
||||||
|
const { token_secret: _secret, ...token_data } = token;
|
||||||
|
|
||||||
|
res.json(token_data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Get token error:", error);
|
||||||
|
res.status(500).json({ error: "Failed to get token" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update token (toggle active state, update limits, etc.)
|
||||||
|
router.patch(
|
||||||
|
"/tokens/:tokenId",
|
||||||
|
authenticateToken,
|
||||||
|
requireManageSettings,
|
||||||
|
[
|
||||||
|
body("is_active").optional().isBoolean(),
|
||||||
|
body("max_hosts_per_day").optional().isInt({ min: 1, max: 1000 }),
|
||||||
|
body("allowed_ip_ranges").optional().isArray(),
|
||||||
|
body("expires_at").optional().isISO8601(),
|
||||||
|
],
|
||||||
|
async (req, res) => {
|
||||||
|
try {
|
||||||
|
const errors = validationResult(req);
|
||||||
|
if (!errors.isEmpty()) {
|
||||||
|
return res.status(400).json({ errors: errors.array() });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { tokenId } = req.params;
|
||||||
|
const update_data = { updated_at: new Date() };
|
||||||
|
|
||||||
|
if (req.body.is_active !== undefined)
|
||||||
|
update_data.is_active = req.body.is_active;
|
||||||
|
if (req.body.max_hosts_per_day !== undefined)
|
||||||
|
update_data.max_hosts_per_day = req.body.max_hosts_per_day;
|
||||||
|
if (req.body.allowed_ip_ranges !== undefined)
|
||||||
|
update_data.allowed_ip_ranges = req.body.allowed_ip_ranges;
|
||||||
|
if (req.body.expires_at !== undefined)
|
||||||
|
update_data.expires_at = new Date(req.body.expires_at);
|
||||||
|
|
||||||
|
const token = await prisma.auto_enrollment_tokens.update({
|
||||||
|
where: { id: tokenId },
|
||||||
|
data: update_data,
|
||||||
|
include: {
|
||||||
|
host_groups: true,
|
||||||
|
users: {
|
||||||
|
select: {
|
||||||
|
username: true,
|
||||||
|
first_name: true,
|
||||||
|
last_name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { token_secret: _secret, ...token_data } = token;
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
message: "Token updated successfully",
|
||||||
|
token: token_data,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Update token error:", error);
|
||||||
|
res.status(500).json({ error: "Failed to update token" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Delete token
|
||||||
|
router.delete(
|
||||||
|
"/tokens/:tokenId",
|
||||||
|
authenticateToken,
|
||||||
|
requireManageSettings,
|
||||||
|
async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { tokenId } = req.params;
|
||||||
|
|
||||||
|
const token = await prisma.auto_enrollment_tokens.findUnique({
|
||||||
|
where: { id: tokenId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return res.status(404).json({ error: "Token not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.auto_enrollment_tokens.delete({
|
||||||
|
where: { id: tokenId },
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
message: "Auto-enrollment token deleted successfully",
|
||||||
|
deleted_token: {
|
||||||
|
id: token.id,
|
||||||
|
token_name: token.token_name,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Delete token error:", error);
|
||||||
|
res.status(500).json({ error: "Failed to delete token" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ========== AUTO-ENROLLMENT ENDPOINTS (Used by Scripts) ==========
|
||||||
|
// Future integrations can follow this pattern:
|
||||||
|
// - /proxmox-lxc - Proxmox LXC containers
|
||||||
|
// - /vmware-esxi - VMware ESXi VMs
|
||||||
|
// - /docker - Docker containers
|
||||||
|
// - /kubernetes - Kubernetes pods
|
||||||
|
// - /aws-ec2 - AWS EC2 instances
|
||||||
|
|
||||||
|
// Serve the Proxmox LXC enrollment script with credentials injected
|
||||||
|
router.get("/proxmox-lxc", async (req, res) => {
|
||||||
|
try {
|
||||||
|
// Get token from query params
|
||||||
|
const token_key = req.query.token_key;
|
||||||
|
const token_secret = req.query.token_secret;
|
||||||
|
|
||||||
|
if (!token_key || !token_secret) {
|
||||||
|
return res
|
||||||
|
.status(401)
|
||||||
|
.json({ error: "Token key and secret required as query parameters" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate token
|
||||||
|
const token = await prisma.auto_enrollment_tokens.findUnique({
|
||||||
|
where: { token_key: token_key },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!token || !token.is_active) {
|
||||||
|
return res.status(401).json({ error: "Invalid or inactive token" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify secret
|
||||||
|
const is_valid = await bcrypt.compare(token_secret, token.token_secret);
|
||||||
|
if (!is_valid) {
|
||||||
|
return res.status(401).json({ error: "Invalid token secret" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check expiration
|
||||||
|
if (token.expires_at && new Date() > new Date(token.expires_at)) {
|
||||||
|
return res.status(401).json({ error: "Token expired" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const path = require("node:path");
|
||||||
|
|
||||||
|
const script_path = path.join(
|
||||||
|
__dirname,
|
||||||
|
"../../../agents/proxmox_auto_enroll.sh",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!fs.existsSync(script_path)) {
|
||||||
|
return res
|
||||||
|
.status(404)
|
||||||
|
.json({ error: "Proxmox enrollment script not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
let script = fs.readFileSync(script_path, "utf8");
|
||||||
|
|
||||||
|
// Convert Windows line endings to Unix line endings
|
||||||
|
script = script.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
||||||
|
|
||||||
|
// Get the configured server URL from settings
|
||||||
|
let server_url = "http://localhost:3001";
|
||||||
|
try {
|
||||||
|
const settings = await prisma.settings.findFirst();
|
||||||
|
if (settings?.server_url) {
|
||||||
|
server_url = settings.server_url;
|
||||||
|
}
|
||||||
|
} catch (settings_error) {
|
||||||
|
console.warn(
|
||||||
|
"Could not fetch settings, using default server URL:",
|
||||||
|
settings_error.message,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine curl flags dynamically from settings
|
||||||
|
let curl_flags = "-s";
|
||||||
|
try {
|
||||||
|
const settings = await prisma.settings.findFirst();
|
||||||
|
if (settings && settings.ignore_ssl_self_signed === true) {
|
||||||
|
curl_flags = "-sk";
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
// Check for --force parameter
|
||||||
|
const force_install = req.query.force === "true" || req.query.force === "1";
|
||||||
|
|
||||||
|
// Inject the token credentials, server URL, curl flags, and force flag into the script
|
||||||
|
const env_vars = `#!/bin/bash
|
||||||
|
# PatchMon Auto-Enrollment Configuration (Auto-generated)
|
||||||
|
export PATCHMON_URL="${server_url}"
|
||||||
|
export AUTO_ENROLLMENT_KEY="${token.token_key}"
|
||||||
|
export AUTO_ENROLLMENT_SECRET="${token_secret}"
|
||||||
|
export CURL_FLAGS="${curl_flags}"
|
||||||
|
export FORCE_INSTALL="${force_install ? "true" : "false"}"
|
||||||
|
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Remove the shebang and configuration section from the original script
|
||||||
|
script = script.replace(/^#!/, "#");
|
||||||
|
|
||||||
|
// Remove the configuration section (between # ===== CONFIGURATION ===== and the next # =====)
|
||||||
|
script = script.replace(
|
||||||
|
/# ===== CONFIGURATION =====[\s\S]*?(?=# ===== COLOR OUTPUT =====)/,
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
|
||||||
|
script = env_vars + script;
|
||||||
|
|
||||||
|
res.setHeader("Content-Type", "text/plain");
|
||||||
|
res.setHeader(
|
||||||
|
"Content-Disposition",
|
||||||
|
'inline; filename="proxmox_auto_enroll.sh"',
|
||||||
|
);
|
||||||
|
res.send(script);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Proxmox script serve error:", error);
|
||||||
|
res.status(500).json({ error: "Failed to serve enrollment script" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create host via auto-enrollment
|
||||||
|
router.post(
|
||||||
|
"/enroll",
|
||||||
|
validate_auto_enrollment_token,
|
||||||
|
[
|
||||||
|
body("friendly_name")
|
||||||
|
.isLength({ min: 1, max: 255 })
|
||||||
|
.withMessage("Friendly name is required"),
|
||||||
|
body("machine_id")
|
||||||
|
.isLength({ min: 1, max: 255 })
|
||||||
|
.withMessage("Machine ID is required"),
|
||||||
|
body("metadata").optional().isObject(),
|
||||||
|
],
|
||||||
|
async (req, res) => {
|
||||||
|
try {
|
||||||
|
const errors = validationResult(req);
|
||||||
|
if (!errors.isEmpty()) {
|
||||||
|
return res.status(400).json({ errors: errors.array() });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { friendly_name, machine_id } = req.body;
|
||||||
|
|
||||||
|
// Generate host API credentials
|
||||||
|
const api_id = `patchmon_${crypto.randomBytes(8).toString("hex")}`;
|
||||||
|
const api_key = crypto.randomBytes(32).toString("hex");
|
||||||
|
|
||||||
|
// Check if host already exists by machine_id (not hostname)
|
||||||
|
const existing_host = await prisma.hosts.findUnique({
|
||||||
|
where: { machine_id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing_host) {
|
||||||
|
return res.status(409).json({
|
||||||
|
error: "Host already exists",
|
||||||
|
host_id: existing_host.id,
|
||||||
|
api_id: existing_host.api_id,
|
||||||
|
machine_id: existing_host.machine_id,
|
||||||
|
friendly_name: existing_host.friendly_name,
|
||||||
|
message:
|
||||||
|
"This machine is already enrolled in PatchMon (matched by machine ID)",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create host
|
||||||
|
const host = await prisma.hosts.create({
|
||||||
|
data: {
|
||||||
|
id: uuidv4(),
|
||||||
|
machine_id,
|
||||||
|
friendly_name,
|
||||||
|
os_type: "unknown",
|
||||||
|
os_version: "unknown",
|
||||||
|
api_id: api_id,
|
||||||
|
api_key: api_key,
|
||||||
|
host_group_id: req.auto_enrollment_token.default_host_group_id,
|
||||||
|
status: "pending",
|
||||||
|
notes: `Auto-enrolled via ${req.auto_enrollment_token.token_name} on ${new Date().toISOString()}`,
|
||||||
|
updated_at: new Date(),
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
host_groups: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
color: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update token usage stats
|
||||||
|
await prisma.auto_enrollment_tokens.update({
|
||||||
|
where: { id: req.auto_enrollment_token.id },
|
||||||
|
data: {
|
||||||
|
hosts_created_today: { increment: 1 },
|
||||||
|
last_used_at: new Date(),
|
||||||
|
updated_at: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`Auto-enrolled host: ${friendly_name} (${host.id}) via token: ${req.auto_enrollment_token.token_name}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
message: "Host enrolled successfully",
|
||||||
|
host: {
|
||||||
|
id: host.id,
|
||||||
|
friendly_name: host.friendly_name,
|
||||||
|
api_id: api_id,
|
||||||
|
api_key: api_key,
|
||||||
|
host_group: host.host_groups,
|
||||||
|
status: host.status,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Auto-enrollment error:", error);
|
||||||
|
res.status(500).json({ error: "Failed to enroll host" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Bulk enroll multiple hosts at once
|
||||||
|
router.post(
|
||||||
|
"/enroll/bulk",
|
||||||
|
validate_auto_enrollment_token,
|
||||||
|
[
|
||||||
|
body("hosts")
|
||||||
|
.isArray({ min: 1, max: 50 })
|
||||||
|
.withMessage("Hosts array required (max 50)"),
|
||||||
|
body("hosts.*.friendly_name")
|
||||||
|
.isLength({ min: 1 })
|
||||||
|
.withMessage("Each host needs a friendly_name"),
|
||||||
|
],
|
||||||
|
async (req, res) => {
|
||||||
|
try {
|
||||||
|
const errors = validationResult(req);
|
||||||
|
if (!errors.isEmpty()) {
|
||||||
|
return res.status(400).json({ errors: errors.array() });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { hosts } = req.body;
|
||||||
|
|
||||||
|
// Check rate limit
|
||||||
|
const remaining_quota =
|
||||||
|
req.auto_enrollment_token.max_hosts_per_day -
|
||||||
|
req.auto_enrollment_token.hosts_created_today;
|
||||||
|
|
||||||
|
if (hosts.length > remaining_quota) {
|
||||||
|
return res.status(429).json({
|
||||||
|
error: "Rate limit exceeded",
|
||||||
|
message: `Only ${remaining_quota} hosts remaining in daily quota`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = {
|
||||||
|
success: [],
|
||||||
|
failed: [],
|
||||||
|
skipped: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const host_data of hosts) {
|
||||||
|
try {
|
||||||
|
const { friendly_name, machine_id } = host_data;
|
||||||
|
|
||||||
|
if (!machine_id) {
|
||||||
|
results.failed.push({
|
||||||
|
friendly_name,
|
||||||
|
error: "Machine ID is required",
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if host already exists by machine_id
|
||||||
|
const existing_host = await prisma.hosts.findUnique({
|
||||||
|
where: { machine_id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing_host) {
|
||||||
|
results.skipped.push({
|
||||||
|
friendly_name,
|
||||||
|
machine_id,
|
||||||
|
reason: "Machine already enrolled",
|
||||||
|
api_id: existing_host.api_id,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate credentials
|
||||||
|
const api_id = `patchmon_${crypto.randomBytes(8).toString("hex")}`;
|
||||||
|
const api_key = crypto.randomBytes(32).toString("hex");
|
||||||
|
|
||||||
|
// Create host
|
||||||
|
const host = await prisma.hosts.create({
|
||||||
|
data: {
|
||||||
|
id: uuidv4(),
|
||||||
|
machine_id,
|
||||||
|
friendly_name,
|
||||||
|
os_type: "unknown",
|
||||||
|
os_version: "unknown",
|
||||||
|
api_id: api_id,
|
||||||
|
api_key: api_key,
|
||||||
|
host_group_id: req.auto_enrollment_token.default_host_group_id,
|
||||||
|
status: "pending",
|
||||||
|
notes: `Auto-enrolled via ${req.auto_enrollment_token.token_name} on ${new Date().toISOString()}`,
|
||||||
|
updated_at: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
results.success.push({
|
||||||
|
id: host.id,
|
||||||
|
friendly_name: host.friendly_name,
|
||||||
|
api_id: api_id,
|
||||||
|
api_key: api_key,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
results.failed.push({
|
||||||
|
friendly_name: host_data.friendly_name,
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update token usage stats
|
||||||
|
if (results.success.length > 0) {
|
||||||
|
await prisma.auto_enrollment_tokens.update({
|
||||||
|
where: { id: req.auto_enrollment_token.id },
|
||||||
|
data: {
|
||||||
|
hosts_created_today: { increment: results.success.length },
|
||||||
|
last_used_at: new Date(),
|
||||||
|
updated_at: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
message: `Bulk enrollment completed: ${results.success.length} succeeded, ${results.failed.length} failed, ${results.skipped.length} skipped`,
|
||||||
|
results,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Bulk auto-enrollment error:", error);
|
||||||
|
res.status(500).json({ error: "Failed to bulk enroll hosts" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -1,89 +1,379 @@
|
|||||||
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 { 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: "osDistributionDoughnut",
|
||||||
|
requiredPermission: "can_view_reports",
|
||||||
|
order: 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
cardId: "recentCollection",
|
||||||
|
requiredPermission: "can_view_hosts",
|
||||||
|
order: 11,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
cardId: "updateStatus",
|
||||||
|
requiredPermission: "can_view_reports",
|
||||||
|
order: 12,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
cardId: "packagePriority",
|
||||||
|
requiredPermission: "can_view_packages",
|
||||||
|
order: 13,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
cardId: "packageTrends",
|
||||||
|
requiredPermission: "can_view_packages",
|
||||||
|
order: 14,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
cardId: "recentUsers",
|
||||||
|
requiredPermission: "can_view_users",
|
||||||
|
order: 15,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
cardId: "quickStats",
|
||||||
|
requiredPermission: "can_view_dashboard",
|
||||||
|
order: 16,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// 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" },
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json(preferences);
|
res.json(preferences);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Dashboard preferences fetch error:', error);
|
console.error("Dashboard preferences fetch error:", error);
|
||||||
res.status(500).json({ error: 'Failed to fetch dashboard preferences' });
|
res.status(500).json({ error: "Failed to fetch dashboard preferences" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update dashboard preferences (bulk update)
|
// Update dashboard preferences (bulk update)
|
||||||
router.put('/', authenticateToken, [
|
router.put(
|
||||||
body('preferences').isArray().withMessage('Preferences must be an array'),
|
"/",
|
||||||
body('preferences.*.cardId').isString().withMessage('Card ID is required'),
|
authenticateToken,
|
||||||
body('preferences.*.enabled').isBoolean().withMessage('Enabled must be boolean'),
|
[
|
||||||
body('preferences.*.order').isInt().withMessage('Order must be integer')
|
body("preferences").isArray().withMessage("Preferences must be an array"),
|
||||||
], async (req, res) => {
|
body("preferences.*.cardId").isString().withMessage("Card ID is required"),
|
||||||
try {
|
body("preferences.*.enabled")
|
||||||
const errors = validationResult(req);
|
.isBoolean()
|
||||||
if (!errors.isEmpty()) {
|
.withMessage("Enabled must be boolean"),
|
||||||
return res.status(400).json({ errors: errors.array() });
|
body("preferences.*.order").isInt().withMessage("Order must be integer"),
|
||||||
}
|
],
|
||||||
|
async (req, res) => {
|
||||||
|
try {
|
||||||
|
const errors = validationResult(req);
|
||||||
|
if (!errors.isEmpty()) {
|
||||||
|
return res.status(400).json({ errors: errors.array() });
|
||||||
|
}
|
||||||
|
|
||||||
const { preferences } = req.body;
|
const { preferences } = req.body;
|
||||||
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,
|
||||||
enabled: pref.enabled,
|
card_id: pref.cardId,
|
||||||
order: pref.order
|
enabled: pref.enabled,
|
||||||
}));
|
order: pref.order,
|
||||||
|
updated_at: new Date(),
|
||||||
|
}));
|
||||||
|
|
||||||
const createdPreferences = await prisma.dashboardPreferences.createMany({
|
await prisma.dashboard_preferences.createMany({
|
||||||
data: newPreferences
|
data: newPreferences,
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
message: 'Dashboard preferences updated successfully',
|
message: "Dashboard preferences updated successfully",
|
||||||
preferences: newPreferences
|
preferences: newPreferences,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Dashboard preferences update error:', error);
|
console.error("Dashboard preferences update error:", error);
|
||||||
res.status(500).json({ error: 'Failed to update dashboard preferences' });
|
res.status(500).json({ error: "Failed to update dashboard preferences" });
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// 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 {
|
||||||
const defaultCards = [
|
// This provides a comprehensive dashboard view for all new users
|
||||||
{ cardId: 'totalHosts', title: 'Total Hosts', icon: 'Server', enabled: true, order: 0 },
|
const defaultCards = [
|
||||||
{ cardId: 'hostsNeedingUpdates', title: 'Needs Updating', icon: 'AlertTriangle', enabled: true, order: 1 },
|
{
|
||||||
{ cardId: 'totalOutdatedPackages', title: 'Outdated Packages', icon: 'Package', enabled: true, order: 2 },
|
cardId: "totalHosts",
|
||||||
{ cardId: 'securityUpdates', title: 'Security Updates', icon: 'Shield', enabled: true, order: 3 },
|
title: "Total Hosts",
|
||||||
{ cardId: 'erroredHosts', title: 'Errored Hosts', icon: 'AlertTriangle', enabled: true, order: 4 },
|
icon: "Server",
|
||||||
{ cardId: 'osDistribution', title: 'OS Distribution', icon: 'BarChart3', enabled: true, order: 5 },
|
enabled: true,
|
||||||
{ cardId: 'updateStatus', title: 'Update Status', icon: 'BarChart3', enabled: true, order: 6 },
|
order: 0,
|
||||||
{ cardId: 'packagePriority', title: 'Package Priority', icon: 'BarChart3', enabled: true, order: 7 },
|
},
|
||||||
{ cardId: 'quickStats', title: 'Quick Stats', icon: 'TrendingUp', enabled: true, order: 8 }
|
{
|
||||||
];
|
cardId: "hostsNeedingUpdates",
|
||||||
|
title: "Needs Updating",
|
||||||
|
icon: "AlertTriangle",
|
||||||
|
enabled: true,
|
||||||
|
order: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
cardId: "totalOutdatedPackages",
|
||||||
|
title: "Outdated Packages",
|
||||||
|
icon: "Package",
|
||||||
|
enabled: true,
|
||||||
|
order: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
cardId: "securityUpdates",
|
||||||
|
title: "Security Updates",
|
||||||
|
icon: "Shield",
|
||||||
|
enabled: true,
|
||||||
|
order: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
cardId: "totalHostGroups",
|
||||||
|
title: "Host Groups",
|
||||||
|
icon: "Folder",
|
||||||
|
enabled: true,
|
||||||
|
order: 4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
cardId: "upToDateHosts",
|
||||||
|
title: "Up to date",
|
||||||
|
icon: "CheckCircle",
|
||||||
|
enabled: true,
|
||||||
|
order: 5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
cardId: "totalRepos",
|
||||||
|
title: "Repositories",
|
||||||
|
icon: "GitBranch",
|
||||||
|
enabled: true,
|
||||||
|
order: 6,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
cardId: "totalUsers",
|
||||||
|
title: "Users",
|
||||||
|
icon: "Users",
|
||||||
|
enabled: true,
|
||||||
|
order: 7,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
cardId: "osDistribution",
|
||||||
|
title: "OS Distribution",
|
||||||
|
icon: "BarChart3",
|
||||||
|
enabled: true,
|
||||||
|
order: 8,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
cardId: "osDistributionBar",
|
||||||
|
title: "OS Distribution (Bar)",
|
||||||
|
icon: "BarChart3",
|
||||||
|
enabled: true,
|
||||||
|
order: 9,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
cardId: "osDistributionDoughnut",
|
||||||
|
title: "OS Distribution (Doughnut)",
|
||||||
|
icon: "PieChart",
|
||||||
|
enabled: true,
|
||||||
|
order: 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
cardId: "recentCollection",
|
||||||
|
title: "Recent Collection",
|
||||||
|
icon: "Server",
|
||||||
|
enabled: true,
|
||||||
|
order: 11,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
cardId: "updateStatus",
|
||||||
|
title: "Update Status",
|
||||||
|
icon: "BarChart3",
|
||||||
|
enabled: true,
|
||||||
|
order: 12,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
cardId: "packagePriority",
|
||||||
|
title: "Package Priority",
|
||||||
|
icon: "BarChart3",
|
||||||
|
enabled: true,
|
||||||
|
order: 13,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
cardId: "packageTrends",
|
||||||
|
title: "Package Trends",
|
||||||
|
icon: "TrendingUp",
|
||||||
|
enabled: true,
|
||||||
|
order: 14,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
cardId: "recentUsers",
|
||||||
|
title: "Recent Users Logged in",
|
||||||
|
icon: "Users",
|
||||||
|
enabled: true,
|
||||||
|
order: 15,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
cardId: "quickStats",
|
||||||
|
title: "Quick Stats",
|
||||||
|
icon: "TrendingUp",
|
||||||
|
enabled: true,
|
||||||
|
order: 16,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
res.json(defaultCards);
|
res.json(defaultCards);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Default dashboard cards error:', error);
|
console.error("Default dashboard cards error:", error);
|
||||||
res.status(500).json({ error: 'Failed to fetch default dashboard cards' });
|
res.status(500).json({ error: "Failed to fetch default dashboard cards" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = { router, createDefaultDashboardPreferences };
|
||||||
|
|||||||
@@ -1,336 +1,606 @@
|
|||||||
const express = require('express');
|
const express = require("express");
|
||||||
const { PrismaClient } = require('@prisma/client');
|
const { PrismaClient } = require("@prisma/client");
|
||||||
const moment = require('moment');
|
const moment = require("moment");
|
||||||
const { authenticateToken } = require('../middleware/auth');
|
const { authenticateToken } = require("../middleware/auth");
|
||||||
const {
|
const {
|
||||||
requireViewDashboard,
|
requireViewDashboard,
|
||||||
requireViewHosts,
|
requireViewHosts,
|
||||||
requireViewPackages
|
requireViewPackages,
|
||||||
} = require('../middleware/permissions');
|
requireViewUsers,
|
||||||
|
} = require("../middleware/permissions");
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
// Get dashboard statistics
|
// Get dashboard statistics
|
||||||
router.get('/stats', authenticateToken, requireViewDashboard, async (req, res) => {
|
router.get(
|
||||||
try {
|
"/stats",
|
||||||
const now = new Date();
|
authenticateToken,
|
||||||
|
requireViewDashboard,
|
||||||
// Get the agent update interval setting
|
async (_req, res) => {
|
||||||
const settings = await prisma.settings.findFirst();
|
try {
|
||||||
const updateIntervalMinutes = settings?.updateInterval || 60; // Default to 60 minutes if no setting
|
const now = new Date();
|
||||||
|
|
||||||
// Calculate the threshold based on the actual update interval
|
|
||||||
// Use 2x the update interval as the threshold for "errored" hosts
|
|
||||||
const thresholdMinutes = updateIntervalMinutes * 2;
|
|
||||||
const thresholdTime = moment(now).subtract(thresholdMinutes, 'minutes').toDate();
|
|
||||||
|
|
||||||
// Get all statistics in parallel for better performance
|
// Get the agent update interval setting
|
||||||
const [
|
const settings = await prisma.settings.findFirst();
|
||||||
totalHosts,
|
const updateIntervalMinutes = settings?.update_interval || 60; // Default to 60 minutes if no setting
|
||||||
hostsNeedingUpdates,
|
|
||||||
totalOutdatedPackages,
|
|
||||||
erroredHosts,
|
|
||||||
securityUpdates,
|
|
||||||
osDistribution,
|
|
||||||
updateTrends
|
|
||||||
] = await Promise.all([
|
|
||||||
// Total hosts count
|
|
||||||
prisma.host.count({
|
|
||||||
where: { status: 'active' }
|
|
||||||
}),
|
|
||||||
|
|
||||||
// Hosts needing updates (distinct hosts with packages needing updates)
|
// Calculate the threshold based on the actual update interval
|
||||||
prisma.host.count({
|
// Use 2x the update interval as the threshold for "errored" hosts
|
||||||
where: {
|
const thresholdMinutes = updateIntervalMinutes * 2;
|
||||||
status: 'active',
|
const thresholdTime = moment(now)
|
||||||
hostPackages: {
|
.subtract(thresholdMinutes, "minutes")
|
||||||
some: {
|
.toDate();
|
||||||
needsUpdate: true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
|
|
||||||
// Total outdated packages across all hosts
|
// Get all statistics in parallel for better performance
|
||||||
prisma.hostPackage.count({
|
const [
|
||||||
where: { needsUpdate: true }
|
totalHosts,
|
||||||
}),
|
hostsNeedingUpdates,
|
||||||
|
totalOutdatedPackages,
|
||||||
|
erroredHosts,
|
||||||
|
securityUpdates,
|
||||||
|
offlineHosts,
|
||||||
|
totalHostGroups,
|
||||||
|
totalUsers,
|
||||||
|
totalRepos,
|
||||||
|
osDistribution,
|
||||||
|
updateTrends,
|
||||||
|
] = await Promise.all([
|
||||||
|
// Total hosts count (all hosts regardless of status)
|
||||||
|
prisma.hosts.count(),
|
||||||
|
|
||||||
// Errored hosts (not updated within threshold based on update interval)
|
// Hosts needing updates (distinct hosts with packages needing updates)
|
||||||
prisma.host.count({
|
prisma.hosts.count({
|
||||||
where: {
|
where: {
|
||||||
status: 'active',
|
host_packages: {
|
||||||
lastUpdate: {
|
some: {
|
||||||
lt: thresholdTime
|
needs_update: true,
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}),
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
// Security updates count
|
// Total outdated packages across all hosts
|
||||||
prisma.hostPackage.count({
|
prisma.host_packages.count({
|
||||||
where: {
|
where: { needs_update: true },
|
||||||
needsUpdate: true,
|
}),
|
||||||
isSecurityUpdate: true
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
|
|
||||||
// OS distribution for pie chart
|
// Errored hosts (not updated within threshold based on update interval)
|
||||||
prisma.host.groupBy({
|
prisma.hosts.count({
|
||||||
by: ['osType'],
|
where: {
|
||||||
where: { status: 'active' },
|
status: "active",
|
||||||
_count: {
|
last_update: {
|
||||||
osType: true
|
lt: thresholdTime,
|
||||||
}
|
},
|
||||||
}),
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
// Update trends for the last 7 days
|
// Security updates count
|
||||||
prisma.updateHistory.groupBy({
|
prisma.host_packages.count({
|
||||||
by: ['timestamp'],
|
where: {
|
||||||
where: {
|
needs_update: true,
|
||||||
timestamp: {
|
is_security_update: true,
|
||||||
gte: moment(now).subtract(7, 'days').toDate()
|
},
|
||||||
}
|
}),
|
||||||
},
|
|
||||||
_count: {
|
|
||||||
id: true
|
|
||||||
},
|
|
||||||
_sum: {
|
|
||||||
packagesCount: true,
|
|
||||||
securityCount: true
|
|
||||||
}
|
|
||||||
})
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Format OS distribution for pie chart
|
// Offline/Stale hosts (not updated within 3x the update interval)
|
||||||
const osDistributionFormatted = osDistribution.map(item => ({
|
prisma.hosts.count({
|
||||||
name: item.osType,
|
where: {
|
||||||
count: item._count.osType
|
status: "active",
|
||||||
}));
|
last_update: {
|
||||||
|
lt: moment(now)
|
||||||
|
.subtract(updateIntervalMinutes * 3, "minutes")
|
||||||
|
.toDate(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
// Calculate update status distribution
|
// Total host groups count
|
||||||
const updateStatusDistribution = [
|
prisma.host_groups.count(),
|
||||||
{ name: 'Up to date', count: totalHosts - hostsNeedingUpdates },
|
|
||||||
{ name: 'Needs updates', count: hostsNeedingUpdates },
|
|
||||||
{ name: 'Errored', count: erroredHosts }
|
|
||||||
];
|
|
||||||
|
|
||||||
// Package update priority distribution
|
// Total users count
|
||||||
const packageUpdateDistribution = [
|
prisma.users.count(),
|
||||||
{ name: 'Security', count: securityUpdates },
|
|
||||||
{ name: 'Regular', count: totalOutdatedPackages - securityUpdates }
|
|
||||||
];
|
|
||||||
|
|
||||||
res.json({
|
// Total repositories count
|
||||||
cards: {
|
prisma.repositories.count(),
|
||||||
totalHosts,
|
|
||||||
hostsNeedingUpdates,
|
// OS distribution for pie chart
|
||||||
totalOutdatedPackages,
|
prisma.hosts.groupBy({
|
||||||
erroredHosts,
|
by: ["os_type"],
|
||||||
securityUpdates
|
where: { status: "active" },
|
||||||
},
|
_count: {
|
||||||
charts: {
|
os_type: true,
|
||||||
osDistribution: osDistributionFormatted,
|
},
|
||||||
updateStatusDistribution,
|
}),
|
||||||
packageUpdateDistribution
|
|
||||||
},
|
// Update trends for the last 7 days
|
||||||
trends: updateTrends,
|
prisma.update_history.groupBy({
|
||||||
lastUpdated: now.toISOString()
|
by: ["timestamp"],
|
||||||
});
|
where: {
|
||||||
} catch (error) {
|
timestamp: {
|
||||||
console.error('Error fetching dashboard stats:', error);
|
gte: moment(now).subtract(7, "days").toDate(),
|
||||||
res.status(500).json({ error: 'Failed to fetch dashboard statistics' });
|
},
|
||||||
}
|
},
|
||||||
});
|
_count: {
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
_sum: {
|
||||||
|
packages_count: true,
|
||||||
|
security_count: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Format OS distribution for pie chart
|
||||||
|
const osDistributionFormatted = osDistribution.map((item) => ({
|
||||||
|
name: item.os_type,
|
||||||
|
count: item._count.os_type,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Calculate update status distribution
|
||||||
|
const updateStatusDistribution = [
|
||||||
|
{ name: "Up to date", count: totalHosts - hostsNeedingUpdates },
|
||||||
|
{ name: "Needs updates", count: hostsNeedingUpdates },
|
||||||
|
{ name: "Errored", count: erroredHosts },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Package update priority distribution
|
||||||
|
const regularUpdates = Math.max(
|
||||||
|
0,
|
||||||
|
totalOutdatedPackages - securityUpdates,
|
||||||
|
);
|
||||||
|
const packageUpdateDistribution = [
|
||||||
|
{ name: "Security", count: securityUpdates },
|
||||||
|
{ name: "Regular", count: regularUpdates },
|
||||||
|
];
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
cards: {
|
||||||
|
totalHosts,
|
||||||
|
hostsNeedingUpdates,
|
||||||
|
upToDateHosts: Math.max(totalHosts - hostsNeedingUpdates, 0),
|
||||||
|
totalOutdatedPackages,
|
||||||
|
erroredHosts,
|
||||||
|
securityUpdates,
|
||||||
|
offlineHosts,
|
||||||
|
totalHostGroups,
|
||||||
|
totalUsers,
|
||||||
|
totalRepos,
|
||||||
|
},
|
||||||
|
charts: {
|
||||||
|
osDistribution: osDistributionFormatted,
|
||||||
|
updateStatusDistribution,
|
||||||
|
packageUpdateDistribution,
|
||||||
|
},
|
||||||
|
trends: updateTrends,
|
||||||
|
lastUpdated: now.toISOString(),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching dashboard stats:", error);
|
||||||
|
res.status(500).json({ error: "Failed to fetch dashboard statistics" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// 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,
|
||||||
hostname: true,
|
machine_id: true,
|
||||||
ip: true,
|
friendly_name: true,
|
||||||
osType: true,
|
hostname: true,
|
||||||
osVersion: true,
|
ip: true,
|
||||||
lastUpdate: true,
|
os_type: true,
|
||||||
status: true,
|
os_version: true,
|
||||||
agentVersion: true,
|
last_update: true,
|
||||||
autoUpdate: true,
|
status: true,
|
||||||
hostGroup: {
|
agent_version: true,
|
||||||
select: {
|
auto_update: true,
|
||||||
id: true,
|
notes: true,
|
||||||
name: true,
|
host_groups: {
|
||||||
color: true
|
select: {
|
||||||
}
|
id: true,
|
||||||
},
|
name: true,
|
||||||
_count: {
|
color: true,
|
||||||
select: {
|
},
|
||||||
hostPackages: {
|
},
|
||||||
where: {
|
_count: {
|
||||||
needsUpdate: true
|
select: {
|
||||||
}
|
host_packages: {
|
||||||
}
|
where: {
|
||||||
}
|
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 the agent update interval setting for stale calculation
|
// Get total packages count for this host
|
||||||
const settings = await prisma.settings.findFirst();
|
const totalPackagesCount = await prisma.host_packages.count({
|
||||||
const updateIntervalMinutes = settings?.updateInterval || 60;
|
where: {
|
||||||
const thresholdMinutes = updateIntervalMinutes * 2;
|
host_id: host.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// Calculate effective status based on reporting interval
|
// Get the agent update interval setting for stale calculation
|
||||||
const isStale = moment(host.lastUpdate).isBefore(moment().subtract(thresholdMinutes, 'minutes'));
|
const settings = await prisma.settings.findFirst();
|
||||||
let effectiveStatus = host.status;
|
const updateIntervalMinutes = settings?.update_interval || 60;
|
||||||
|
const thresholdMinutes = updateIntervalMinutes * 2;
|
||||||
// Override status if host hasn't reported within threshold
|
|
||||||
if (isStale && host.status === 'active') {
|
|
||||||
effectiveStatus = 'inactive';
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
// Calculate effective status based on reporting interval
|
||||||
...host,
|
const isStale = moment(host.last_update).isBefore(
|
||||||
updatesCount,
|
moment().subtract(thresholdMinutes, "minutes"),
|
||||||
isStale,
|
);
|
||||||
effectiveStatus
|
let effectiveStatus = host.status;
|
||||||
};
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
res.json(hostsWithUpdateInfo);
|
// Override status if host hasn't reported within threshold
|
||||||
} catch (error) {
|
if (isStale && host.status === "active") {
|
||||||
console.error('Error fetching hosts:', error);
|
effectiveStatus = "inactive";
|
||||||
res.status(500).json({ error: 'Failed to fetch hosts' });
|
}
|
||||||
}
|
|
||||||
|
return {
|
||||||
|
...host,
|
||||||
|
updatesCount,
|
||||||
|
totalPackagesCount,
|
||||||
|
isStale,
|
||||||
|
effectiveStatus,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json(hostsWithUpdateInfo);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching hosts:", error);
|
||||||
|
res.status(500).json({ error: "Failed to fetch hosts" });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 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(
|
||||||
try {
|
"/packages",
|
||||||
const packages = await prisma.package.findMany({
|
authenticateToken,
|
||||||
where: {
|
requireViewPackages,
|
||||||
hostPackages: {
|
async (_req, res) => {
|
||||||
some: {
|
try {
|
||||||
needsUpdate: true
|
const packages = await prisma.packages.findMany({
|
||||||
}
|
where: {
|
||||||
}
|
host_packages: {
|
||||||
},
|
some: {
|
||||||
select: {
|
needs_update: true,
|
||||||
id: true,
|
},
|
||||||
name: true,
|
},
|
||||||
description: true,
|
},
|
||||||
category: true,
|
select: {
|
||||||
latestVersion: true,
|
id: true,
|
||||||
hostPackages: {
|
name: true,
|
||||||
where: { needsUpdate: true },
|
description: true,
|
||||||
select: {
|
category: true,
|
||||||
currentVersion: true,
|
latest_version: true,
|
||||||
availableVersion: true,
|
host_packages: {
|
||||||
isSecurityUpdate: true,
|
where: { needs_update: true },
|
||||||
host: {
|
select: {
|
||||||
select: {
|
current_version: true,
|
||||||
id: true,
|
available_version: true,
|
||||||
hostname: true,
|
is_security_update: true,
|
||||||
osType: true
|
hosts: {
|
||||||
}
|
select: {
|
||||||
}
|
id: true,
|
||||||
}
|
friendly_name: true,
|
||||||
}
|
os_type: true,
|
||||||
},
|
},
|
||||||
orderBy: {
|
},
|
||||||
name: 'asc'
|
},
|
||||||
}
|
},
|
||||||
});
|
},
|
||||||
|
orderBy: {
|
||||||
|
name: "asc",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const packagesWithHostInfo = packages.map(pkg => ({
|
const packagesWithHostInfo = packages.map((pkg) => ({
|
||||||
id: pkg.id,
|
id: pkg.id,
|
||||||
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,
|
||||||
}))
|
})),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
res.json(packagesWithHostInfo);
|
res.json(packagesWithHostInfo);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching packages:', error);
|
console.error("Error fetching packages:", error);
|
||||||
res.status(500).json({ error: 'Failed to fetch packages' });
|
res.status(500).json({ error: "Failed to fetch packages" });
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Get detailed host information
|
// Get detailed host information
|
||||||
router.get('/hosts/:hostId', authenticateToken, requireViewHosts, async (req, res) => {
|
router.get(
|
||||||
try {
|
"/hosts/:hostId",
|
||||||
const { hostId } = req.params;
|
authenticateToken,
|
||||||
|
requireViewHosts,
|
||||||
const host = await prisma.host.findUnique({
|
async (req, res) => {
|
||||||
where: { id: hostId },
|
try {
|
||||||
include: {
|
const { hostId } = req.params;
|
||||||
hostGroup: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
name: true,
|
|
||||||
color: true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
hostPackages: {
|
|
||||||
include: {
|
|
||||||
package: true
|
|
||||||
},
|
|
||||||
orderBy: {
|
|
||||||
needsUpdate: 'desc'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
updateHistory: {
|
|
||||||
orderBy: {
|
|
||||||
timestamp: 'desc'
|
|
||||||
},
|
|
||||||
take: 10
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!host) {
|
const limit = parseInt(req.query.limit, 10) || 10;
|
||||||
return res.status(404).json({ error: 'Host not found' });
|
const offset = parseInt(req.query.offset, 10) || 0;
|
||||||
}
|
|
||||||
|
|
||||||
const hostWithStats = {
|
const [host, totalHistoryCount] = await Promise.all([
|
||||||
...host,
|
prisma.hosts.findUnique({
|
||||||
stats: {
|
where: { id: hostId },
|
||||||
totalPackages: host.hostPackages.length,
|
include: {
|
||||||
outdatedPackages: host.hostPackages.filter(hp => hp.needsUpdate).length,
|
host_groups: {
|
||||||
securityUpdates: host.hostPackages.filter(hp => hp.needsUpdate && hp.isSecurityUpdate).length
|
select: {
|
||||||
}
|
id: true,
|
||||||
};
|
name: true,
|
||||||
|
color: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
host_packages: {
|
||||||
|
include: {
|
||||||
|
packages: true,
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
needs_update: "desc",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
update_history: {
|
||||||
|
orderBy: {
|
||||||
|
timestamp: "desc",
|
||||||
|
},
|
||||||
|
take: limit,
|
||||||
|
skip: offset,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.update_history.count({
|
||||||
|
where: { host_id: hostId },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
res.json(hostWithStats);
|
if (!host) {
|
||||||
} catch (error) {
|
return res.status(404).json({ error: "Host not found" });
|
||||||
console.error('Error fetching host details:', error);
|
}
|
||||||
res.status(500).json({ error: 'Failed to fetch host details' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
module.exports = router;
|
const hostWithStats = {
|
||||||
|
...host,
|
||||||
|
stats: {
|
||||||
|
total_packages: host.host_packages.length,
|
||||||
|
outdated_packages: host.host_packages.filter((hp) => hp.needs_update)
|
||||||
|
.length,
|
||||||
|
security_updates: host.host_packages.filter(
|
||||||
|
(hp) => hp.needs_update && hp.is_security_update,
|
||||||
|
).length,
|
||||||
|
},
|
||||||
|
pagination: {
|
||||||
|
total: totalHistoryCount,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
hasMore: offset + limit < totalHistoryCount,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(hostWithStats);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching host details:", error);
|
||||||
|
res.status(500).json({ error: "Failed to fetch host details" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get package trends over time
|
||||||
|
router.get(
|
||||||
|
"/package-trends",
|
||||||
|
authenticateToken,
|
||||||
|
requireViewHosts,
|
||||||
|
async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { days = 30, hostId } = req.query;
|
||||||
|
const daysInt = parseInt(days, 10);
|
||||||
|
|
||||||
|
// Calculate date range
|
||||||
|
const endDate = new Date();
|
||||||
|
const startDate = new Date();
|
||||||
|
startDate.setDate(endDate.getDate() - daysInt);
|
||||||
|
|
||||||
|
// Build where clause
|
||||||
|
const whereClause = {
|
||||||
|
timestamp: {
|
||||||
|
gte: startDate,
|
||||||
|
lte: endDate,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add host filter if specified
|
||||||
|
if (hostId && hostId !== "all" && hostId !== "undefined") {
|
||||||
|
whereClause.host_id = hostId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all update history records in the date range
|
||||||
|
const trendsData = await prisma.update_history.findMany({
|
||||||
|
where: whereClause,
|
||||||
|
select: {
|
||||||
|
timestamp: true,
|
||||||
|
packages_count: true,
|
||||||
|
security_count: true,
|
||||||
|
total_packages: true,
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
timestamp: "asc",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Process data to show actual values (no averaging)
|
||||||
|
const processedData = trendsData
|
||||||
|
.filter((record) => record.total_packages !== null) // Only include records with valid data
|
||||||
|
.map((record) => {
|
||||||
|
const date = new Date(record.timestamp);
|
||||||
|
let timeKey;
|
||||||
|
|
||||||
|
if (daysInt <= 1) {
|
||||||
|
// For hourly view, use exact timestamp
|
||||||
|
timeKey = date.toISOString().substring(0, 16); // YYYY-MM-DDTHH:MM
|
||||||
|
} else {
|
||||||
|
// For daily view, group by day
|
||||||
|
timeKey = date.toISOString().split("T")[0]; // YYYY-MM-DD
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
timeKey,
|
||||||
|
total_packages: record.total_packages,
|
||||||
|
packages_count: record.packages_count || 0,
|
||||||
|
security_count: record.security_count || 0,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((a, b) => a.timeKey.localeCompare(b.timeKey)); // Sort by time
|
||||||
|
|
||||||
|
// Get hosts list for dropdown (always fetch for dropdown functionality)
|
||||||
|
const hostsList = await prisma.hosts.findMany({
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
friendly_name: true,
|
||||||
|
hostname: true,
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
friendly_name: "asc",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Format data for chart
|
||||||
|
const chartData = {
|
||||||
|
labels: [],
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: "Total Packages",
|
||||||
|
data: [],
|
||||||
|
borderColor: "#3B82F6", // Blue
|
||||||
|
backgroundColor: "rgba(59, 130, 246, 0.1)",
|
||||||
|
tension: 0.4,
|
||||||
|
hidden: true, // Hidden by default
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Outdated Packages",
|
||||||
|
data: [],
|
||||||
|
borderColor: "#F59E0B", // Orange
|
||||||
|
backgroundColor: "rgba(245, 158, 11, 0.1)",
|
||||||
|
tension: 0.4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Security Packages",
|
||||||
|
data: [],
|
||||||
|
borderColor: "#EF4444", // Red
|
||||||
|
backgroundColor: "rgba(239, 68, 68, 0.1)",
|
||||||
|
tension: 0.4,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Process aggregated data
|
||||||
|
processedData.forEach((item) => {
|
||||||
|
chartData.labels.push(item.timeKey);
|
||||||
|
chartData.datasets[0].data.push(item.total_packages);
|
||||||
|
chartData.datasets[1].data.push(item.packages_count);
|
||||||
|
chartData.datasets[2].data.push(item.security_count);
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
chartData,
|
||||||
|
hosts: hostsList,
|
||||||
|
period: daysInt,
|
||||||
|
hostId: hostId || "all",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching package trends:", error);
|
||||||
|
res.status(500).json({ error: "Failed to fetch package trends" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
|
|||||||
@@ -1,225 +1,258 @@
|
|||||||
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 { authenticateToken } = require('../middleware/auth');
|
const { randomUUID } = require("node:crypto");
|
||||||
const { requireManageHosts } = require('../middleware/permissions');
|
const { authenticateToken } = require("../middleware/auth");
|
||||||
|
const { requireManageHosts } = require("../middleware/permissions");
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const prisma = new PrismaClient();
|
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: {
|
||||||
hosts: true
|
hosts: true,
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
orderBy: {
|
orderBy: {
|
||||||
name: 'asc'
|
name: "asc",
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json(hostGroups);
|
res.json(hostGroups);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching host groups:', error);
|
console.error("Error fetching host groups:", error);
|
||||||
res.status(500).json({ error: 'Failed to fetch host groups' });
|
res.status(500).json({ error: "Failed to fetch host groups" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get a specific host group by ID
|
// Get a specific host group by ID
|
||||||
router.get('/:id', authenticateToken, async (req, res) => {
|
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,
|
||||||
hostname: true,
|
friendly_name: true,
|
||||||
ip: true,
|
hostname: true,
|
||||||
osType: true,
|
ip: true,
|
||||||
osVersion: true,
|
os_type: true,
|
||||||
status: true,
|
os_version: true,
|
||||||
lastUpdate: true
|
status: true,
|
||||||
}
|
last_update: true,
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
});
|
},
|
||||||
|
});
|
||||||
|
|
||||||
if (!hostGroup) {
|
if (!hostGroup) {
|
||||||
return res.status(404).json({ error: 'Host group not found' });
|
return res.status(404).json({ error: "Host group not found" });
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json(hostGroup);
|
res.json(hostGroup);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching host group:', error);
|
console.error("Error fetching host group:", error);
|
||||||
res.status(500).json({ error: 'Failed to fetch host group' });
|
res.status(500).json({ error: "Failed to fetch host group" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create a new host group
|
// Create a new host group
|
||||||
router.post('/', authenticateToken, requireManageHosts, [
|
router.post(
|
||||||
body('name').trim().isLength({ min: 1 }).withMessage('Name is required'),
|
"/",
|
||||||
body('description').optional().trim(),
|
authenticateToken,
|
||||||
body('color').optional().isHexColor().withMessage('Color must be a valid hex color')
|
requireManageHosts,
|
||||||
], async (req, res) => {
|
[
|
||||||
try {
|
body("name").trim().isLength({ min: 1 }).withMessage("Name is required"),
|
||||||
const errors = validationResult(req);
|
body("description").optional().trim(),
|
||||||
if (!errors.isEmpty()) {
|
body("color")
|
||||||
return res.status(400).json({ errors: errors.array() });
|
.optional()
|
||||||
}
|
.isHexColor()
|
||||||
|
.withMessage("Color must be a valid hex color"),
|
||||||
|
],
|
||||||
|
async (req, res) => {
|
||||||
|
try {
|
||||||
|
const errors = validationResult(req);
|
||||||
|
if (!errors.isEmpty()) {
|
||||||
|
return res.status(400).json({ errors: errors.array() });
|
||||||
|
}
|
||||||
|
|
||||||
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 },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (existingGroup) {
|
if (existingGroup) {
|
||||||
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: {
|
||||||
name,
|
id: randomUUID(),
|
||||||
description: description || null,
|
name,
|
||||||
color: color || '#3B82F6'
|
description: description || null,
|
||||||
}
|
color: color || "#3B82F6",
|
||||||
});
|
updated_at: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
res.status(201).json(hostGroup);
|
res.status(201).json(hostGroup);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error creating host group:', error);
|
console.error("Error creating host group:", error);
|
||||||
res.status(500).json({ error: 'Failed to create host group' });
|
res.status(500).json({ error: "Failed to create host group" });
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Update a host group
|
// Update a host group
|
||||||
router.put('/:id', authenticateToken, requireManageHosts, [
|
router.put(
|
||||||
body('name').trim().isLength({ min: 1 }).withMessage('Name is required'),
|
"/:id",
|
||||||
body('description').optional().trim(),
|
authenticateToken,
|
||||||
body('color').optional().isHexColor().withMessage('Color must be a valid hex color')
|
requireManageHosts,
|
||||||
], async (req, res) => {
|
[
|
||||||
try {
|
body("name").trim().isLength({ min: 1 }).withMessage("Name is required"),
|
||||||
const errors = validationResult(req);
|
body("description").optional().trim(),
|
||||||
if (!errors.isEmpty()) {
|
body("color")
|
||||||
return res.status(400).json({ errors: errors.array() });
|
.optional()
|
||||||
}
|
.isHexColor()
|
||||||
|
.withMessage("Color must be a valid hex color"),
|
||||||
|
],
|
||||||
|
async (req, res) => {
|
||||||
|
try {
|
||||||
|
const errors = validationResult(req);
|
||||||
|
if (!errors.isEmpty()) {
|
||||||
|
return res.status(400).json({ errors: errors.array() });
|
||||||
|
}
|
||||||
|
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
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 },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!existingGroup) {
|
if (!existingGroup) {
|
||||||
return res.status(404).json({ error: 'Host group not found' });
|
return res.status(404).json({ error: "Host group not found" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 },
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (duplicateGroup) {
|
if (duplicateGroup) {
|
||||||
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(),
|
||||||
});
|
},
|
||||||
|
});
|
||||||
|
|
||||||
res.json(hostGroup);
|
res.json(hostGroup);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error updating host group:', error);
|
console.error("Error updating host group:", error);
|
||||||
res.status(500).json({ error: 'Failed to update host group' });
|
res.status(500).json({ error: "Failed to update host group" });
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Delete a host group
|
// Delete a host group
|
||||||
router.delete('/:id', authenticateToken, requireManageHosts, async (req, res) => {
|
router.delete(
|
||||||
try {
|
"/:id",
|
||||||
const { id } = req.params;
|
authenticateToken,
|
||||||
|
requireManageHosts,
|
||||||
|
async (req, res) => {
|
||||||
|
try {
|
||||||
|
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: {
|
||||||
select: {
|
select: {
|
||||||
hosts: true
|
hosts: true,
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!existingGroup) {
|
if (!existingGroup) {
|
||||||
return res.status(404).json({ error: 'Host group not found' });
|
return res.status(404).json({ error: "Host group not found" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if host group has hosts
|
// If host group has hosts, ungroup them first
|
||||||
if (existingGroup._count.hosts > 0) {
|
if (existingGroup._count.hosts > 0) {
|
||||||
return res.status(400).json({
|
await prisma.hosts.updateMany({
|
||||||
error: 'Cannot delete host group that contains hosts. Please move or remove hosts first.'
|
where: { host_group_id: id },
|
||||||
});
|
data: { host_group_id: null },
|
||||||
}
|
});
|
||||||
|
}
|
||||||
|
|
||||||
await prisma.hostGroup.delete({
|
await prisma.host_groups.delete({
|
||||||
where: { id }
|
where: { id },
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json({ message: 'Host group deleted successfully' });
|
res.json({ message: "Host group deleted successfully" });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting host group:', error);
|
console.error("Error deleting host group:", error);
|
||||||
res.status(500).json({ error: 'Failed to delete host group' });
|
res.status(500).json({ error: "Failed to delete host group" });
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Get hosts in a specific group
|
// Get hosts in a specific group
|
||||||
router.get('/:id/hosts', authenticateToken, async (req, res) => {
|
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",
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json(hosts);
|
res.json(hosts);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching hosts in group:', error);
|
console.error("Error fetching hosts in group:", error);
|
||||||
res.status(500).json({ error: 'Failed to fetch hosts in group' });
|
res.status(500).json({ error: "Failed to fetch hosts in group" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,213 +1,373 @@
|
|||||||
const express = require('express');
|
const express = require("express");
|
||||||
const { PrismaClient } = require('@prisma/client');
|
const { PrismaClient } = require("@prisma/client");
|
||||||
const { body, validationResult } = require('express-validator');
|
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
// Get all packages with their update status
|
// Get all packages with their update status
|
||||||
router.get('/', async (req, res) => {
|
router.get("/", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const {
|
const {
|
||||||
page = 1,
|
page = 1,
|
||||||
limit = 50,
|
limit = 50,
|
||||||
search = '',
|
search = "",
|
||||||
category = '',
|
category = "",
|
||||||
needsUpdate = '',
|
needsUpdate = "",
|
||||||
isSecurityUpdate = ''
|
isSecurityUpdate = "",
|
||||||
} = req.query;
|
host = "",
|
||||||
|
} = req.query;
|
||||||
|
|
||||||
const skip = (parseInt(page) - 1) * parseInt(limit);
|
const skip = (parseInt(page, 10) - 1) * parseInt(limit, 10);
|
||||||
const take = parseInt(limit);
|
const take = parseInt(limit, 10);
|
||||||
|
|
||||||
// Build where clause
|
// Build where clause
|
||||||
const where = {
|
const where = {
|
||||||
AND: [
|
AND: [
|
||||||
// Search filter
|
// Search filter
|
||||||
search ? {
|
search
|
||||||
OR: [
|
? {
|
||||||
{ name: { contains: search, mode: 'insensitive' } },
|
OR: [
|
||||||
{ description: { contains: search, mode: 'insensitive' } }
|
{ name: { contains: search, mode: "insensitive" } },
|
||||||
]
|
{ description: { contains: search, mode: "insensitive" } },
|
||||||
} : {},
|
],
|
||||||
// Category filter
|
}
|
||||||
category ? { category: { equals: category } } : {},
|
: {},
|
||||||
// Update status filters
|
// Category filter
|
||||||
needsUpdate ? {
|
category ? { category: { equals: category } } : {},
|
||||||
hostPackages: {
|
// Host filter - only return packages installed on the specified host
|
||||||
some: {
|
// Combined with update status filters if both are present
|
||||||
needsUpdate: needsUpdate === 'true'
|
host
|
||||||
}
|
? {
|
||||||
}
|
host_packages: {
|
||||||
} : {},
|
some: {
|
||||||
isSecurityUpdate ? {
|
host_id: host,
|
||||||
hostPackages: {
|
// If needsUpdate or isSecurityUpdate filters are present, apply them here
|
||||||
some: {
|
...(needsUpdate
|
||||||
isSecurityUpdate: isSecurityUpdate === 'true'
|
? { needs_update: needsUpdate === "true" }
|
||||||
}
|
: {}),
|
||||||
}
|
...(isSecurityUpdate
|
||||||
} : {}
|
? { is_security_update: isSecurityUpdate === "true" }
|
||||||
]
|
: {}),
|
||||||
};
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {},
|
||||||
|
// Update status filters (only applied if no host filter)
|
||||||
|
// If host filter is present, these are already applied above
|
||||||
|
!host && needsUpdate
|
||||||
|
? {
|
||||||
|
host_packages: {
|
||||||
|
some: {
|
||||||
|
needs_update: needsUpdate === "true",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {},
|
||||||
|
!host && isSecurityUpdate
|
||||||
|
? {
|
||||||
|
host_packages: {
|
||||||
|
some: {
|
||||||
|
is_security_update: isSecurityUpdate === "true",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
// 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
|
select: {
|
||||||
}
|
host_packages: true,
|
||||||
},
|
},
|
||||||
skip,
|
},
|
||||||
take,
|
},
|
||||||
orderBy: {
|
skip,
|
||||||
name: 'asc'
|
take,
|
||||||
}
|
orderBy: {
|
||||||
}),
|
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([
|
// Build base where clause for this package
|
||||||
prisma.hostPackage.count({
|
const baseWhere = { package_id: pkg.id };
|
||||||
where: {
|
|
||||||
packageId: pkg.id,
|
|
||||||
needsUpdate: true
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
prisma.hostPackage.count({
|
|
||||||
where: {
|
|
||||||
packageId: pkg.id,
|
|
||||||
needsUpdate: true,
|
|
||||||
isSecurityUpdate: true
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
prisma.hostPackage.findMany({
|
|
||||||
where: {
|
|
||||||
packageId: pkg.id,
|
|
||||||
needsUpdate: true
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
host: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
hostname: true,
|
|
||||||
osType: true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
take: 10 // Limit to first 10 for performance
|
|
||||||
})
|
|
||||||
]);
|
|
||||||
|
|
||||||
return {
|
// If host filter is specified, add host filter to all queries
|
||||||
...pkg,
|
const hostWhere = host ? { ...baseWhere, host_id: host } : baseWhere;
|
||||||
stats: {
|
|
||||||
totalInstalls: pkg._count.hostPackages,
|
|
||||||
updatesNeeded: updatesCount,
|
|
||||||
securityUpdates: securityCount,
|
|
||||||
affectedHosts: affectedHosts.map(hp => hp.host)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
res.json({
|
const [updatesCount, securityCount, packageHosts] = await Promise.all([
|
||||||
packages: packagesWithStats,
|
prisma.host_packages.count({
|
||||||
pagination: {
|
where: {
|
||||||
page: parseInt(page),
|
...hostWhere,
|
||||||
limit: parseInt(limit),
|
needs_update: true,
|
||||||
total: totalCount,
|
},
|
||||||
pages: Math.ceil(totalCount / parseInt(limit))
|
}),
|
||||||
}
|
prisma.host_packages.count({
|
||||||
});
|
where: {
|
||||||
} catch (error) {
|
...hostWhere,
|
||||||
console.error('Error fetching packages:', error);
|
needs_update: true,
|
||||||
res.status(500).json({ error: 'Failed to fetch packages' });
|
is_security_update: true,
|
||||||
}
|
},
|
||||||
|
}),
|
||||||
|
prisma.host_packages.findMany({
|
||||||
|
where: {
|
||||||
|
...hostWhere,
|
||||||
|
// If host filter is specified, include all packages for that host
|
||||||
|
// Otherwise, only include packages that need updates
|
||||||
|
...(host ? {} : { needs_update: true }),
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
hosts: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
friendly_name: true,
|
||||||
|
hostname: true,
|
||||||
|
os_type: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
current_version: true,
|
||||||
|
available_version: true,
|
||||||
|
needs_update: true,
|
||||||
|
is_security_update: true,
|
||||||
|
},
|
||||||
|
take: 10, // Limit to first 10 for performance
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...pkg,
|
||||||
|
packageHostsCount: pkg._count.host_packages,
|
||||||
|
packageHosts: packageHosts.map((hp) => ({
|
||||||
|
hostId: hp.hosts.id,
|
||||||
|
friendlyName: hp.hosts.friendly_name,
|
||||||
|
osType: hp.hosts.os_type,
|
||||||
|
currentVersion: hp.current_version,
|
||||||
|
availableVersion: hp.available_version,
|
||||||
|
needsUpdate: hp.needs_update,
|
||||||
|
isSecurityUpdate: hp.is_security_update,
|
||||||
|
})),
|
||||||
|
stats: {
|
||||||
|
totalInstalls: pkg._count.host_packages,
|
||||||
|
updatesNeeded: updatesCount,
|
||||||
|
securityUpdates: securityCount,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
packages: packagesWithStats,
|
||||||
|
pagination: {
|
||||||
|
page: parseInt(page, 10),
|
||||||
|
limit: parseInt(limit, 10),
|
||||||
|
total: totalCount,
|
||||||
|
pages: Math.ceil(totalCount / parseInt(limit, 10)),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching packages:", error);
|
||||||
|
res.status(500).json({ error: "Failed to fetch packages" });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get package details by ID
|
// Get package details by ID
|
||||||
router.get('/:packageId', async (req, res) => {
|
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: {
|
hosts: {
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
hostname: true,
|
hostname: true,
|
||||||
ip: true,
|
ip: true,
|
||||||
osType: true,
|
os_type: true,
|
||||||
osVersion: true,
|
os_version: true,
|
||||||
lastUpdate: true
|
last_update: true,
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
orderBy: {
|
orderBy: {
|
||||||
needsUpdate: 'desc'
|
needs_update: "desc",
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!packageData) {
|
if (!packageData) {
|
||||||
return res.status(404).json({ error: 'Package not found' });
|
return res.status(404).json({ error: "Package not found" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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.needs_update)
|
||||||
securityUpdates: packageData.hostPackages.filter(hp => hp.needsUpdate && hp.isSecurityUpdate).length,
|
.length,
|
||||||
upToDate: packageData.hostPackages.filter(hp => !hp.needsUpdate).length
|
securityUpdates: packageData.host_packages.filter(
|
||||||
};
|
(hp) => hp.needs_update && hp.is_security_update,
|
||||||
|
).length,
|
||||||
|
upToDate: packageData.host_packages.filter((hp) => !hp.needs_update)
|
||||||
|
.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.current_version;
|
||||||
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.hosts.os_type;
|
||||||
acc[osType] = (acc[osType] || 0) + 1;
|
acc[osType] = (acc[osType] || 0) + 1;
|
||||||
return acc;
|
return acc;
|
||||||
}, {});
|
}, {});
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
...packageData,
|
...packageData,
|
||||||
stats,
|
stats,
|
||||||
distributions: {
|
distributions: {
|
||||||
versions: Object.entries(versionDistribution).map(([version, count]) => ({
|
versions: Object.entries(versionDistribution).map(
|
||||||
version,
|
([version, count]) => ({
|
||||||
count
|
version,
|
||||||
})),
|
count,
|
||||||
osTypes: Object.entries(osDistribution).map(([osType, count]) => ({
|
}),
|
||||||
osType,
|
),
|
||||||
count
|
osTypes: Object.entries(osDistribution).map(([osType, count]) => ({
|
||||||
}))
|
osType,
|
||||||
}
|
count,
|
||||||
});
|
})),
|
||||||
} catch (error) {
|
},
|
||||||
console.error('Error fetching package details:', error);
|
});
|
||||||
res.status(500).json({ error: 'Failed to fetch package details' });
|
} catch (error) {
|
||||||
}
|
console.error("Error fetching package details:", error);
|
||||||
|
res.status(500).json({ error: "Failed to fetch package details" });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = router;
|
// Get hosts where a package is installed
|
||||||
|
router.get("/:packageId/hosts", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { packageId } = req.params;
|
||||||
|
const {
|
||||||
|
page = 1,
|
||||||
|
limit = 25,
|
||||||
|
search = "",
|
||||||
|
sortBy = "friendly_name",
|
||||||
|
sortOrder = "asc",
|
||||||
|
} = req.query;
|
||||||
|
|
||||||
|
const offset = (parseInt(page, 10) - 1) * parseInt(limit, 10);
|
||||||
|
|
||||||
|
// Build search conditions
|
||||||
|
const searchConditions = search
|
||||||
|
? {
|
||||||
|
OR: [
|
||||||
|
{
|
||||||
|
hosts: {
|
||||||
|
friendly_name: { contains: search, mode: "insensitive" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ hosts: { hostname: { contains: search, mode: "insensitive" } } },
|
||||||
|
{ current_version: { contains: search, mode: "insensitive" } },
|
||||||
|
{ available_version: { contains: search, mode: "insensitive" } },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: {};
|
||||||
|
|
||||||
|
// Build sort conditions
|
||||||
|
const orderBy = {};
|
||||||
|
if (
|
||||||
|
sortBy === "friendly_name" ||
|
||||||
|
sortBy === "hostname" ||
|
||||||
|
sortBy === "os_type"
|
||||||
|
) {
|
||||||
|
orderBy.hosts = { [sortBy]: sortOrder };
|
||||||
|
} else if (sortBy === "needs_update") {
|
||||||
|
orderBy[sortBy] = sortOrder;
|
||||||
|
} else {
|
||||||
|
orderBy[sortBy] = sortOrder;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get total count
|
||||||
|
const totalCount = await prisma.host_packages.count({
|
||||||
|
where: {
|
||||||
|
package_id: packageId,
|
||||||
|
...searchConditions,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get paginated results
|
||||||
|
const hostPackages = await prisma.host_packages.findMany({
|
||||||
|
where: {
|
||||||
|
package_id: packageId,
|
||||||
|
...searchConditions,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
hosts: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
friendly_name: true,
|
||||||
|
hostname: true,
|
||||||
|
os_type: true,
|
||||||
|
os_version: true,
|
||||||
|
last_update: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy,
|
||||||
|
skip: offset,
|
||||||
|
take: parseInt(limit, 10),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Transform the data for the frontend
|
||||||
|
const hosts = hostPackages.map((hp) => ({
|
||||||
|
hostId: hp.hosts.id,
|
||||||
|
friendlyName: hp.hosts.friendly_name,
|
||||||
|
hostname: hp.hosts.hostname,
|
||||||
|
osType: hp.hosts.os_type,
|
||||||
|
osVersion: hp.hosts.os_version,
|
||||||
|
lastUpdate: hp.hosts.last_update,
|
||||||
|
currentVersion: hp.current_version,
|
||||||
|
availableVersion: hp.available_version,
|
||||||
|
needsUpdate: hp.needs_update,
|
||||||
|
isSecurityUpdate: hp.is_security_update,
|
||||||
|
lastChecked: hp.last_checked,
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
hosts,
|
||||||
|
pagination: {
|
||||||
|
page: parseInt(page, 10),
|
||||||
|
limit: parseInt(limit, 10),
|
||||||
|
total: totalCount,
|
||||||
|
pages: Math.ceil(totalCount / parseInt(limit, 10)),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching package hosts:", error);
|
||||||
|
res.status(500).json({ error: "Failed to fetch package hosts" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
|
|||||||
@@ -1,173 +1,203 @@
|
|||||||
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 } = 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(
|
||||||
try {
|
"/roles",
|
||||||
const permissions = await prisma.rolePermissions.findMany({
|
authenticateToken,
|
||||||
orderBy: {
|
requireManageUsers,
|
||||||
role: 'asc'
|
async (_req, res) => {
|
||||||
}
|
try {
|
||||||
});
|
const permissions = await prisma.role_permissions.findMany({
|
||||||
|
orderBy: {
|
||||||
|
role: "asc",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
res.json(permissions);
|
res.json(permissions);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Get role permissions error:', error);
|
console.error("Get role permissions error:", error);
|
||||||
res.status(500).json({ error: 'Failed to fetch role permissions' });
|
res.status(500).json({ error: "Failed to fetch role permissions" });
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Get permissions for a specific role
|
// Get permissions for a specific role
|
||||||
router.get('/roles/:role', authenticateToken, requireManageSettings, async (req, res) => {
|
router.get(
|
||||||
try {
|
"/roles/:role",
|
||||||
const { role } = req.params;
|
authenticateToken,
|
||||||
|
requireManageSettings,
|
||||||
const permissions = await prisma.rolePermissions.findUnique({
|
async (req, res) => {
|
||||||
where: { role }
|
try {
|
||||||
});
|
const { role } = req.params;
|
||||||
|
|
||||||
if (!permissions) {
|
const permissions = await prisma.role_permissions.findUnique({
|
||||||
return res.status(404).json({ error: 'Role not found' });
|
where: { role },
|
||||||
}
|
});
|
||||||
|
|
||||||
res.json(permissions);
|
if (!permissions) {
|
||||||
} catch (error) {
|
return res.status(404).json({ error: "Role not found" });
|
||||||
console.error('Get role permission error:', error);
|
}
|
||||||
res.status(500).json({ error: 'Failed to fetch role permission' });
|
|
||||||
}
|
res.json(permissions);
|
||||||
});
|
} catch (error) {
|
||||||
|
console.error("Get role permission error:", error);
|
||||||
|
res.status(500).json({ error: "Failed to fetch role permission" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Create or update role permissions
|
// Create or update role permissions
|
||||||
router.put('/roles/:role', authenticateToken, requireManageSettings, async (req, res) => {
|
router.put(
|
||||||
try {
|
"/roles/:role",
|
||||||
const { role } = req.params;
|
authenticateToken,
|
||||||
const {
|
requireManageSettings,
|
||||||
canViewDashboard,
|
async (req, res) => {
|
||||||
canViewHosts,
|
try {
|
||||||
canManageHosts,
|
const { role } = req.params;
|
||||||
canViewPackages,
|
const {
|
||||||
canManagePackages,
|
can_view_dashboard,
|
||||||
canViewUsers,
|
can_view_hosts,
|
||||||
canManageUsers,
|
can_manage_hosts,
|
||||||
canViewReports,
|
can_view_packages,
|
||||||
canExportData,
|
can_manage_packages,
|
||||||
canManageSettings
|
can_view_users,
|
||||||
} = req.body;
|
can_manage_users,
|
||||||
|
can_view_reports,
|
||||||
|
can_export_data,
|
||||||
|
can_manage_settings,
|
||||||
|
} = 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: {
|
},
|
||||||
role,
|
create: {
|
||||||
canViewDashboard,
|
id: require("uuid").v4(),
|
||||||
canViewHosts,
|
role,
|
||||||
canManageHosts,
|
can_view_dashboard: can_view_dashboard,
|
||||||
canViewPackages,
|
can_view_hosts: can_view_hosts,
|
||||||
canManagePackages,
|
can_manage_hosts: can_manage_hosts,
|
||||||
canViewUsers,
|
can_view_packages: can_view_packages,
|
||||||
canManageUsers,
|
can_manage_packages: can_manage_packages,
|
||||||
canViewReports,
|
can_view_users: can_view_users,
|
||||||
canExportData,
|
can_manage_users: can_manage_users,
|
||||||
canManageSettings
|
can_view_reports: can_view_reports,
|
||||||
}
|
can_export_data: can_export_data,
|
||||||
});
|
can_manage_settings: can_manage_settings,
|
||||||
|
updated_at: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
message: 'Role permissions updated successfully',
|
message: "Role permissions updated successfully",
|
||||||
permissions
|
permissions,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Update role permissions error:', error);
|
console.error("Update role permissions error:", error);
|
||||||
res.status(500).json({ error: 'Failed to update role permissions' });
|
res.status(500).json({ error: "Failed to update role permissions" });
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Delete a role (and its permissions)
|
// Delete a role (and its permissions)
|
||||||
router.delete('/roles/:role', authenticateToken, requireManageSettings, async (req, res) => {
|
router.delete(
|
||||||
try {
|
"/roles/:role",
|
||||||
const { role } = req.params;
|
authenticateToken,
|
||||||
|
requireManageSettings,
|
||||||
|
async (req, res) => {
|
||||||
|
try {
|
||||||
|
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 },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (usersWithRole > 0) {
|
if (usersWithRole > 0) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
error: `Cannot delete role "${role}" because ${usersWithRole} user(s) are currently using it`
|
error: `Cannot delete role "${role}" because ${usersWithRole} user(s) are currently using it`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.rolePermissions.delete({
|
await prisma.role_permissions.delete({
|
||||||
where: { role }
|
where: { role },
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
message: `Role "${role}" deleted successfully`
|
message: `Role "${role}" deleted successfully`,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Delete role error:', error);
|
console.error("Delete role error:", error);
|
||||||
res.status(500).json({ error: 'Failed to delete role' });
|
res.status(500).json({ error: "Failed to delete role" });
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Get user's permissions based on their role
|
// Get user's permissions based on their role
|
||||||
router.get('/user-permissions', authenticateToken, async (req, res) => {
|
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({
|
|
||||||
where: { role: userRole }
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!permissions) {
|
const permissions = await prisma.role_permissions.findUnique({
|
||||||
// If no specific permissions found, return default admin permissions
|
where: { role: userRole },
|
||||||
return res.json({
|
});
|
||||||
role: userRole,
|
|
||||||
canViewDashboard: true,
|
|
||||||
canViewHosts: true,
|
|
||||||
canManageHosts: true,
|
|
||||||
canViewPackages: true,
|
|
||||||
canManagePackages: true,
|
|
||||||
canViewUsers: true,
|
|
||||||
canManageUsers: true,
|
|
||||||
canViewReports: true,
|
|
||||||
canExportData: true,
|
|
||||||
canManageSettings: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json(permissions);
|
if (!permissions) {
|
||||||
} catch (error) {
|
// If no specific permissions found, return default admin permissions
|
||||||
console.error('Get user permissions error:', error);
|
return res.json({
|
||||||
res.status(500).json({ error: 'Failed to fetch user permissions' });
|
role: userRole,
|
||||||
}
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(permissions);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Get user permissions error:", error);
|
||||||
|
res.status(500).json({ error: "Failed to fetch user permissions" });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@@ -1,301 +1,418 @@
|
|||||||
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 { authenticateToken } = require('../middleware/auth');
|
const { authenticateToken } = require("../middleware/auth");
|
||||||
const { requireViewHosts, requireManageHosts } = require('../middleware/permissions');
|
const {
|
||||||
|
requireViewHosts,
|
||||||
|
requireManageHosts,
|
||||||
|
} = require("../middleware/permissions");
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const prisma = new PrismaClient();
|
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,
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
_count: {
|
_count: {
|
||||||
select: {
|
select: {
|
||||||
hostRepositories: true
|
host_repositories: true,
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
orderBy: [
|
orderBy: [{ name: "asc" }, { url: "asc" }],
|
||||||
{ name: 'asc' },
|
});
|
||||||
{ url: 'asc' }
|
|
||||||
]
|
|
||||||
});
|
|
||||||
|
|
||||||
// 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)
|
||||||
activeHostCount: repo.hostRepositories.filter(hr => hr.host.status === 'active').length,
|
.length,
|
||||||
hosts: repo.hostRepositories.map(hr => ({
|
activeHostCount: repo.host_repositories.filter(
|
||||||
id: hr.host.id,
|
(hr) => hr.hosts.status === "active",
|
||||||
hostname: hr.host.hostname,
|
).length,
|
||||||
status: hr.host.status,
|
hosts: repo.host_repositories.map((hr) => ({
|
||||||
isEnabled: hr.isEnabled,
|
id: hr.hosts.id,
|
||||||
lastChecked: hr.lastChecked
|
friendlyName: hr.hosts.friendly_name,
|
||||||
}))
|
status: hr.hosts.status,
|
||||||
}));
|
isEnabled: hr.is_enabled,
|
||||||
|
lastChecked: hr.last_checked,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
res.json(transformedRepos);
|
res.json(transformedRepos);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Repository list error:', error);
|
console.error("Repository list error:", error);
|
||||||
res.status(500).json({ error: 'Failed to fetch repositories' });
|
res.status(500).json({ error: "Failed to fetch repositories" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get repositories for a specific host
|
// Get repositories for a specific host
|
||||||
router.get('/host/:hostId', authenticateToken, requireViewHosts, async (req, res) => {
|
router.get(
|
||||||
try {
|
"/host/:hostId",
|
||||||
const { hostId } = req.params;
|
authenticateToken,
|
||||||
|
requireViewHosts,
|
||||||
|
async (req, res) => {
|
||||||
|
try {
|
||||||
|
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",
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json(hostRepositories);
|
res.json(hostRepositories);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Host repositories error:', error);
|
console.error("Host repositories error:", error);
|
||||||
res.status(500).json({ error: 'Failed to fetch host repositories' });
|
res.status(500).json({ error: "Failed to fetch host repositories" });
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Get repository details with all hosts
|
// Get repository details with all hosts
|
||||||
router.get('/:repositoryId', authenticateToken, requireViewHosts, async (req, res) => {
|
router.get(
|
||||||
try {
|
"/:repositoryId",
|
||||||
const { repositoryId } = req.params;
|
authenticateToken,
|
||||||
|
requireViewHosts,
|
||||||
|
async (req, res) => {
|
||||||
|
try {
|
||||||
|
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,
|
||||||
hostname: true,
|
friendly_name: true,
|
||||||
ip: true,
|
hostname: true,
|
||||||
osType: true,
|
ip: true,
|
||||||
osVersion: true,
|
os_type: true,
|
||||||
status: true,
|
os_version: true,
|
||||||
lastUpdate: true
|
status: true,
|
||||||
}
|
last_update: true,
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
orderBy: {
|
},
|
||||||
host: {
|
orderBy: {
|
||||||
hostname: 'asc'
|
hosts: {
|
||||||
}
|
friendly_name: "asc",
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
});
|
},
|
||||||
|
});
|
||||||
|
|
||||||
if (!repository) {
|
if (!repository) {
|
||||||
return res.status(404).json({ error: 'Repository not found' });
|
return res.status(404).json({ error: "Repository not found" });
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json(repository);
|
res.json(repository);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Repository detail error:', error);
|
console.error("Repository detail error:", error);
|
||||||
res.status(500).json({ error: 'Failed to fetch repository details' });
|
res.status(500).json({ error: "Failed to fetch repository details" });
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Update repository information (admin only)
|
// Update repository information (admin only)
|
||||||
router.put('/:repositoryId', authenticateToken, requireManageHosts, [
|
router.put(
|
||||||
body('name').optional().isLength({ min: 1 }).withMessage('Name is required'),
|
"/:repositoryId",
|
||||||
body('description').optional(),
|
authenticateToken,
|
||||||
body('isActive').optional().isBoolean().withMessage('isActive must be a boolean'),
|
requireManageHosts,
|
||||||
body('priority').optional().isInt({ min: 0 }).withMessage('Priority must be a positive integer')
|
[
|
||||||
], async (req, res) => {
|
body("name")
|
||||||
try {
|
.optional()
|
||||||
const errors = validationResult(req);
|
.isLength({ min: 1 })
|
||||||
if (!errors.isEmpty()) {
|
.withMessage("Name is required"),
|
||||||
return res.status(400).json({ errors: errors.array() });
|
body("description").optional(),
|
||||||
}
|
body("isActive")
|
||||||
|
.optional()
|
||||||
|
.isBoolean()
|
||||||
|
.withMessage("isActive must be a boolean"),
|
||||||
|
body("priority")
|
||||||
|
.optional()
|
||||||
|
.isInt({ min: 0 })
|
||||||
|
.withMessage("Priority must be a positive integer"),
|
||||||
|
],
|
||||||
|
async (req, res) => {
|
||||||
|
try {
|
||||||
|
const errors = validationResult(req);
|
||||||
|
if (!errors.isEmpty()) {
|
||||||
|
return res.status(400).json({ errors: errors.array() });
|
||||||
|
}
|
||||||
|
|
||||||
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,
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json(repository);
|
res.json(repository);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Repository update error:', error);
|
console.error("Repository update error:", error);
|
||||||
res.status(500).json({ error: 'Failed to update repository' });
|
res.status(500).json({ error: "Failed to update repository" });
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Toggle repository status for a specific host
|
// Toggle repository status for a specific host
|
||||||
router.patch('/host/:hostId/repository/:repositoryId', authenticateToken, requireManageHosts, [
|
router.patch(
|
||||||
body('isEnabled').isBoolean().withMessage('isEnabled must be a boolean')
|
"/host/:hostId/repository/:repositoryId",
|
||||||
], async (req, res) => {
|
authenticateToken,
|
||||||
try {
|
requireManageHosts,
|
||||||
const errors = validationResult(req);
|
[body("isEnabled").isBoolean().withMessage("isEnabled must be a boolean")],
|
||||||
if (!errors.isEmpty()) {
|
async (req, res) => {
|
||||||
return res.status(400).json({ errors: errors.array() });
|
try {
|
||||||
}
|
const errors = validationResult(req);
|
||||||
|
if (!errors.isEmpty()) {
|
||||||
|
return res.status(400).json({ errors: errors.array() });
|
||||||
|
}
|
||||||
|
|
||||||
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) {
|
||||||
console.error('Host repository toggle error:', error);
|
console.error("Host repository toggle error:", error);
|
||||||
res.status(500).json({ error: 'Failed to toggle repository status' });
|
res.status(500).json({ error: "Failed to toggle repository status" });
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Get repository statistics
|
// Get repository statistics
|
||||||
router.get('/stats/summary', authenticateToken, requireViewHosts, async (req, res) => {
|
router.get(
|
||||||
try {
|
"/stats/summary",
|
||||||
const stats = await prisma.repository.aggregate({
|
authenticateToken,
|
||||||
_count: true
|
requireViewHosts,
|
||||||
});
|
async (_req, res) => {
|
||||||
|
try {
|
||||||
|
const stats = await prisma.repositories.aggregate({
|
||||||
|
_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({
|
||||||
totalRepositories: stats._count,
|
totalRepositories: stats._count,
|
||||||
activeRepositories: activeRepos,
|
activeRepositories: activeRepos,
|
||||||
secureRepositories: secureRepos,
|
secureRepositories: secureRepos,
|
||||||
enabledHostRepositories: hostRepoStats._count.isEnabled,
|
enabledHostRepositories: hostRepoStats._count.isEnabled,
|
||||||
securityPercentage: stats._count > 0 ? Math.round((secureRepos / stats._count) * 100) : 0
|
securityPercentage:
|
||||||
});
|
stats._count > 0 ? Math.round((secureRepos / stats._count) * 100) : 0,
|
||||||
} catch (error) {
|
});
|
||||||
console.error('Repository stats error:', error);
|
} catch (error) {
|
||||||
res.status(500).json({ error: 'Failed to fetch repository statistics' });
|
console.error("Repository stats error:", error);
|
||||||
}
|
res.status(500).json({ error: "Failed to fetch repository statistics" });
|
||||||
});
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Delete a specific repository (admin only)
|
||||||
|
router.delete(
|
||||||
|
"/:repositoryId",
|
||||||
|
authenticateToken,
|
||||||
|
requireManageHosts,
|
||||||
|
async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { repositoryId } = req.params;
|
||||||
|
|
||||||
|
// Check if repository exists first
|
||||||
|
const existingRepository = await prisma.repositories.findUnique({
|
||||||
|
where: { id: repositoryId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
url: true,
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
host_repositories: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existingRepository) {
|
||||||
|
return res.status(404).json({
|
||||||
|
error: "Repository not found",
|
||||||
|
details: "The repository may have been deleted or does not exist",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete repository and all related data (cascade will handle host_repositories)
|
||||||
|
await prisma.repositories.delete({
|
||||||
|
where: { id: repositoryId },
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
message: "Repository deleted successfully",
|
||||||
|
deletedRepository: {
|
||||||
|
id: existingRepository.id,
|
||||||
|
name: existingRepository.name,
|
||||||
|
url: existingRepository.url,
|
||||||
|
hostCount: existingRepository._count.host_repositories,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Repository deletion error:", error);
|
||||||
|
|
||||||
|
// Handle specific Prisma errors
|
||||||
|
if (error.code === "P2025") {
|
||||||
|
return res.status(404).json({
|
||||||
|
error: "Repository not found",
|
||||||
|
details: "The repository may have been deleted or does not exist",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.code === "P2003") {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: "Cannot delete repository due to foreign key constraints",
|
||||||
|
details: "The repository has related data that prevents deletion",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(500).json({
|
||||||
|
error: "Failed to delete repository",
|
||||||
|
details: error.message || "An unexpected error occurred",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Cleanup orphaned repositories (admin only)
|
// Cleanup orphaned repositories (admin only)
|
||||||
router.delete('/cleanup/orphaned', authenticateToken, requireManageHosts, async (req, res) => {
|
router.delete(
|
||||||
try {
|
"/cleanup/orphaned",
|
||||||
console.log('Cleaning up orphaned repositories...');
|
authenticateToken,
|
||||||
|
requireManageHosts,
|
||||||
// Find repositories with no host relationships
|
async (_req, res) => {
|
||||||
const orphanedRepos = await prisma.repository.findMany({
|
try {
|
||||||
where: {
|
console.log("Cleaning up orphaned repositories...");
|
||||||
hostRepositories: {
|
|
||||||
none: {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (orphanedRepos.length === 0) {
|
// Find repositories with no host relationships
|
||||||
return res.json({
|
const orphanedRepos = await prisma.repositories.findMany({
|
||||||
message: 'No orphaned repositories found',
|
where: {
|
||||||
deletedCount: 0,
|
host_repositories: {
|
||||||
deletedRepositories: []
|
none: {},
|
||||||
});
|
},
|
||||||
}
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// Delete orphaned repositories
|
if (orphanedRepos.length === 0) {
|
||||||
const deleteResult = await prisma.repository.deleteMany({
|
return res.json({
|
||||||
where: {
|
message: "No orphaned repositories found",
|
||||||
hostRepositories: {
|
deletedCount: 0,
|
||||||
none: {}
|
deletedRepositories: [],
|
||||||
}
|
});
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
console.log(`Deleted ${deleteResult.count} orphaned repositories`);
|
// Delete orphaned repositories
|
||||||
|
const deleteResult = await prisma.repositories.deleteMany({
|
||||||
|
where: {
|
||||||
|
hostRepositories: {
|
||||||
|
none: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
res.json({
|
console.log(`Deleted ${deleteResult.count} orphaned repositories`);
|
||||||
message: `Successfully deleted ${deleteResult.count} orphaned repositories`,
|
|
||||||
deletedCount: deleteResult.count,
|
res.json({
|
||||||
deletedRepositories: orphanedRepos.map(repo => ({
|
message: `Successfully deleted ${deleteResult.count} orphaned repositories`,
|
||||||
id: repo.id,
|
deletedCount: deleteResult.count,
|
||||||
name: repo.name,
|
deletedRepositories: orphanedRepos.map((repo) => ({
|
||||||
url: repo.url
|
id: repo.id,
|
||||||
}))
|
name: repo.name,
|
||||||
});
|
url: repo.url,
|
||||||
} catch (error) {
|
})),
|
||||||
console.error('Repository cleanup error:', error);
|
});
|
||||||
res.status(500).json({ error: 'Failed to cleanup orphaned repositories' });
|
} catch (error) {
|
||||||
}
|
console.error("Repository cleanup error:", error);
|
||||||
});
|
res
|
||||||
|
.status(500)
|
||||||
|
.json({ error: "Failed to cleanup orphaned repositories" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
249
backend/src/routes/searchRoutes.js
Normal file
249
backend/src/routes/searchRoutes.js
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
const express = require("express");
|
||||||
|
const router = express.Router();
|
||||||
|
const { createPrismaClient } = require("../config/database");
|
||||||
|
const { authenticateToken } = require("../middleware/auth");
|
||||||
|
|
||||||
|
const prisma = createPrismaClient();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global search endpoint
|
||||||
|
* Searches across hosts, packages, repositories, and users
|
||||||
|
* Returns categorized results
|
||||||
|
*/
|
||||||
|
router.get("/", authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { q } = req.query;
|
||||||
|
|
||||||
|
if (!q || q.trim().length === 0) {
|
||||||
|
return res.json({
|
||||||
|
hosts: [],
|
||||||
|
packages: [],
|
||||||
|
repositories: [],
|
||||||
|
users: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchTerm = q.trim();
|
||||||
|
|
||||||
|
// Prepare results object
|
||||||
|
const results = {
|
||||||
|
hosts: [],
|
||||||
|
packages: [],
|
||||||
|
repositories: [],
|
||||||
|
users: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get user permissions from database
|
||||||
|
let userPermissions = null;
|
||||||
|
try {
|
||||||
|
userPermissions = await prisma.role_permissions.findUnique({
|
||||||
|
where: { role: req.user.role },
|
||||||
|
});
|
||||||
|
|
||||||
|
// If no specific permissions found, default to admin permissions
|
||||||
|
if (!userPermissions) {
|
||||||
|
console.warn(
|
||||||
|
`No permissions found for role: ${req.user.role}, defaulting to admin access`,
|
||||||
|
);
|
||||||
|
userPermissions = {
|
||||||
|
can_view_hosts: true,
|
||||||
|
can_view_packages: true,
|
||||||
|
can_view_users: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (permError) {
|
||||||
|
console.error("Error fetching permissions:", permError);
|
||||||
|
// Default to restrictive permissions on error
|
||||||
|
userPermissions = {
|
||||||
|
can_view_hosts: false,
|
||||||
|
can_view_packages: false,
|
||||||
|
can_view_users: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search hosts if user has permission
|
||||||
|
if (userPermissions.can_view_hosts) {
|
||||||
|
try {
|
||||||
|
const hosts = await prisma.hosts.findMany({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ hostname: { contains: searchTerm, mode: "insensitive" } },
|
||||||
|
{ friendly_name: { contains: searchTerm, mode: "insensitive" } },
|
||||||
|
{ ip: { contains: searchTerm, mode: "insensitive" } },
|
||||||
|
{ machine_id: { contains: searchTerm, mode: "insensitive" } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
machine_id: true,
|
||||||
|
hostname: true,
|
||||||
|
friendly_name: true,
|
||||||
|
ip: true,
|
||||||
|
os_type: true,
|
||||||
|
os_version: true,
|
||||||
|
status: true,
|
||||||
|
last_update: true,
|
||||||
|
},
|
||||||
|
take: 10, // Limit results
|
||||||
|
orderBy: {
|
||||||
|
last_update: "desc",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
results.hosts = hosts.map((host) => ({
|
||||||
|
id: host.id,
|
||||||
|
hostname: host.hostname,
|
||||||
|
friendly_name: host.friendly_name,
|
||||||
|
ip: host.ip,
|
||||||
|
os_type: host.os_type,
|
||||||
|
os_version: host.os_version,
|
||||||
|
status: host.status,
|
||||||
|
last_update: host.last_update,
|
||||||
|
type: "host",
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error searching hosts:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search packages if user has permission
|
||||||
|
if (userPermissions.can_view_packages) {
|
||||||
|
try {
|
||||||
|
const packages = await prisma.packages.findMany({
|
||||||
|
where: {
|
||||||
|
name: { contains: searchTerm, mode: "insensitive" },
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
description: true,
|
||||||
|
category: true,
|
||||||
|
latest_version: true,
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
host_packages: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
take: 10,
|
||||||
|
orderBy: {
|
||||||
|
name: "asc",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
results.packages = packages.map((pkg) => ({
|
||||||
|
id: pkg.id,
|
||||||
|
name: pkg.name,
|
||||||
|
description: pkg.description,
|
||||||
|
category: pkg.category,
|
||||||
|
latest_version: pkg.latest_version,
|
||||||
|
host_count: pkg._count.host_packages,
|
||||||
|
type: "package",
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error searching packages:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search repositories if user has permission (usually same as hosts)
|
||||||
|
if (userPermissions.can_view_hosts) {
|
||||||
|
try {
|
||||||
|
const repositories = await prisma.repositories.findMany({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ name: { contains: searchTerm, mode: "insensitive" } },
|
||||||
|
{ url: { contains: searchTerm, mode: "insensitive" } },
|
||||||
|
{ description: { contains: searchTerm, mode: "insensitive" } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
url: true,
|
||||||
|
distribution: true,
|
||||||
|
repo_type: true,
|
||||||
|
is_active: true,
|
||||||
|
description: true,
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
host_repositories: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
take: 10,
|
||||||
|
orderBy: {
|
||||||
|
name: "asc",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
results.repositories = repositories.map((repo) => ({
|
||||||
|
id: repo.id,
|
||||||
|
name: repo.name,
|
||||||
|
url: repo.url,
|
||||||
|
distribution: repo.distribution,
|
||||||
|
repo_type: repo.repo_type,
|
||||||
|
is_active: repo.is_active,
|
||||||
|
description: repo.description,
|
||||||
|
host_count: repo._count.host_repositories,
|
||||||
|
type: "repository",
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error searching repositories:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search users if user has permission
|
||||||
|
if (userPermissions.can_view_users) {
|
||||||
|
try {
|
||||||
|
const users = await prisma.users.findMany({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ username: { contains: searchTerm, mode: "insensitive" } },
|
||||||
|
{ email: { contains: searchTerm, mode: "insensitive" } },
|
||||||
|
{ first_name: { contains: searchTerm, mode: "insensitive" } },
|
||||||
|
{ last_name: { contains: searchTerm, mode: "insensitive" } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
email: true,
|
||||||
|
first_name: true,
|
||||||
|
last_name: true,
|
||||||
|
role: true,
|
||||||
|
is_active: true,
|
||||||
|
last_login: true,
|
||||||
|
},
|
||||||
|
take: 10,
|
||||||
|
orderBy: {
|
||||||
|
username: "asc",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
results.users = users.map((user) => ({
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
email: user.email,
|
||||||
|
first_name: user.first_name,
|
||||||
|
last_name: user.last_name,
|
||||||
|
role: user.role,
|
||||||
|
is_active: user.is_active,
|
||||||
|
last_login: user.last_login,
|
||||||
|
type: "user",
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error searching users:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(results);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Global search error:", error);
|
||||||
|
res.status(500).json({
|
||||||
|
error: "Failed to perform search",
|
||||||
|
message: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -1,268 +1,543 @@
|
|||||||
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 { 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();
|
||||||
|
|
||||||
// Function to trigger crontab updates on all hosts with auto-update enabled
|
// Function to trigger crontab updates on all hosts with auto-update enabled
|
||||||
async function triggerCrontabUpdates() {
|
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 all hosts that have auto-update enabled
|
);
|
||||||
const hosts = await prisma.host.findMany({
|
|
||||||
where: {
|
// Get current settings for server URL
|
||||||
autoUpdate: true,
|
const settings = await getSettings();
|
||||||
status: 'active' // Only update active hosts
|
const serverUrl = settings.server_url;
|
||||||
},
|
|
||||||
select: {
|
// Get all hosts that have auto-update enabled
|
||||||
id: true,
|
const hosts = await prisma.hosts.findMany({
|
||||||
hostname: true,
|
where: {
|
||||||
apiId: true,
|
auto_update: true,
|
||||||
apiKey: true
|
status: "active", // Only update active hosts
|
||||||
}
|
},
|
||||||
});
|
select: {
|
||||||
|
id: true,
|
||||||
console.log(`Found ${hosts.length} hosts with auto-update enabled`);
|
friendly_name: true,
|
||||||
|
api_id: true,
|
||||||
// For each host, we'll send a special update command that triggers crontab update
|
api_key: true,
|
||||||
// This is done by sending a ping with a special flag
|
},
|
||||||
for (const host of hosts) {
|
});
|
||||||
try {
|
|
||||||
console.log(`Triggering crontab update for host: ${host.hostname}`);
|
console.log(`Found ${hosts.length} hosts with auto-update enabled`);
|
||||||
|
|
||||||
// We'll use the existing ping endpoint but add a special parameter
|
// For each host, we'll send a special update command that triggers crontab update
|
||||||
// The agent will detect this and run update-crontab command
|
// This is done by sending a ping with a special flag
|
||||||
const http = require('http');
|
for (const host of hosts) {
|
||||||
const https = require('https');
|
try {
|
||||||
|
console.log(
|
||||||
const serverUrl = process.env.SERVER_URL || 'http://localhost:3001';
|
`Triggering crontab update for host: ${host.friendly_name}`,
|
||||||
const url = new URL(`${serverUrl}/api/v1/hosts/ping`);
|
);
|
||||||
const isHttps = url.protocol === 'https:';
|
|
||||||
const client = isHttps ? https : http;
|
// We'll use the existing ping endpoint but add a special parameter
|
||||||
|
// The agent will detect this and run update-crontab command
|
||||||
const postData = JSON.stringify({
|
const http = require("node:http");
|
||||||
triggerCrontabUpdate: true,
|
const https = require("node:https");
|
||||||
message: 'Update interval changed, please update your crontab'
|
|
||||||
});
|
const url = new URL(`${serverUrl}/api/v1/hosts/ping`);
|
||||||
|
const isHttps = url.protocol === "https:";
|
||||||
const options = {
|
const client = isHttps ? https : http;
|
||||||
hostname: url.hostname,
|
|
||||||
port: url.port || (isHttps ? 443 : 80),
|
const postData = JSON.stringify({
|
||||||
path: url.pathname,
|
triggerCrontabUpdate: true,
|
||||||
method: 'POST',
|
message: "Update interval changed, please update your crontab",
|
||||||
headers: {
|
});
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Content-Length': Buffer.byteLength(postData),
|
const options = {
|
||||||
'X-API-ID': host.apiId,
|
hostname: url.hostname,
|
||||||
'X-API-KEY': host.apiKey
|
port: url.port || (isHttps ? 443 : 80),
|
||||||
}
|
path: url.pathname,
|
||||||
};
|
method: "POST",
|
||||||
|
headers: {
|
||||||
const req = client.request(options, (res) => {
|
"Content-Type": "application/json",
|
||||||
if (res.statusCode === 200) {
|
"Content-Length": Buffer.byteLength(postData),
|
||||||
console.log(`Successfully triggered crontab update for ${host.hostname}`);
|
"X-API-ID": host.api_id,
|
||||||
} else {
|
"X-API-KEY": host.api_key,
|
||||||
console.error(`Failed to trigger crontab update for ${host.hostname}: ${res.statusCode}`);
|
},
|
||||||
}
|
};
|
||||||
});
|
|
||||||
|
const req = client.request(options, (res) => {
|
||||||
req.on('error', (error) => {
|
if (res.statusCode === 200) {
|
||||||
console.error(`Error triggering crontab update for ${host.hostname}:`, error.message);
|
console.log(
|
||||||
});
|
`Successfully triggered crontab update for ${host.friendly_name}`,
|
||||||
|
);
|
||||||
req.write(postData);
|
} else {
|
||||||
req.end();
|
console.error(
|
||||||
} catch (error) {
|
`Failed to trigger crontab update for ${host.friendly_name}: ${res.statusCode}`,
|
||||||
console.error(`Error triggering crontab update for ${host.hostname}:`, error.message);
|
);
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
|
||||||
console.log('Crontab update trigger completed');
|
req.on("error", (error) => {
|
||||||
} catch (error) {
|
console.error(
|
||||||
console.error('Error in triggerCrontabUpdates:', error);
|
`Error triggering crontab update for ${host.friendly_name}:`,
|
||||||
}
|
error.message,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
req.write(postData);
|
||||||
|
req.end();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
`Error triggering crontab update for ${host.friendly_name}:`,
|
||||||
|
error.message,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Crontab update trigger completed");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error in triggerCrontabUpdates:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helpers
|
||||||
|
function normalizeUpdateInterval(minutes) {
|
||||||
|
let m = parseInt(minutes, 10);
|
||||||
|
if (Number.isNaN(m)) return 60;
|
||||||
|
if (m < 5) m = 5;
|
||||||
|
if (m > 1440) m = 1440;
|
||||||
|
if (m < 60) {
|
||||||
|
// Clamp to 5-59, step 5
|
||||||
|
const snapped = Math.round(m / 5) * 5;
|
||||||
|
return Math.min(59, Math.max(5, snapped));
|
||||||
|
}
|
||||||
|
// Allowed hour-based presets
|
||||||
|
const allowed = [60, 120, 180, 360, 720, 1440];
|
||||||
|
let nearest = allowed[0];
|
||||||
|
let bestDiff = Math.abs(m - nearest);
|
||||||
|
for (const a of allowed) {
|
||||||
|
const d = Math.abs(m - a);
|
||||||
|
if (d < bestDiff) {
|
||||||
|
bestDiff = d;
|
||||||
|
nearest = a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nearest;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCronExpression(minutes) {
|
||||||
|
const m = normalizeUpdateInterval(minutes);
|
||||||
|
if (m < 60) {
|
||||||
|
return `*/${m} * * * *`;
|
||||||
|
}
|
||||||
|
if (m === 60) {
|
||||||
|
// Hourly at current minute is chosen by agent; default 0 here
|
||||||
|
return `0 * * * *`;
|
||||||
|
}
|
||||||
|
const hours = Math.floor(m / 60);
|
||||||
|
// Every N hours at minute 0
|
||||||
|
return `0 */${hours} * * *`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 (process.env.ENABLE_LOGGING === "true") {
|
||||||
// If no settings exist, create default settings
|
console.log("Returning settings:", settings);
|
||||||
if (!settings) {
|
}
|
||||||
settings = await prisma.settings.create({
|
res.json(settings);
|
||||||
data: {
|
} catch (error) {
|
||||||
serverUrl: 'http://localhost:3001',
|
console.error("Settings fetch error:", error);
|
||||||
serverProtocol: 'http',
|
res.status(500).json({ error: "Failed to fetch settings" });
|
||||||
serverHost: 'localhost',
|
}
|
||||||
serverPort: 3001,
|
|
||||||
frontendUrl: 'http://localhost:3000',
|
|
||||||
updateInterval: 60,
|
|
||||||
autoUpdate: false
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Returning settings:', settings);
|
|
||||||
res.json(settings);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Settings fetch error:', error);
|
|
||||||
res.status(500).json({ error: 'Failed to fetch settings' });
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update settings
|
// Update settings
|
||||||
router.put('/', authenticateToken, requireManageSettings, [
|
router.put(
|
||||||
body('serverProtocol').isIn(['http', 'https']).withMessage('Protocol must be http or https'),
|
"/",
|
||||||
body('serverHost').isLength({ min: 1 }).withMessage('Server host is required'),
|
authenticateToken,
|
||||||
body('serverPort').isInt({ min: 1, max: 65535 }).withMessage('Port must be between 1 and 65535'),
|
requireManageSettings,
|
||||||
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("serverProtocol")
|
||||||
body('autoUpdate').isBoolean().withMessage('Auto update must be a boolean'),
|
.optional()
|
||||||
body('githubRepoUrl').optional().isLength({ min: 1 }).withMessage('GitHub repo URL must be a non-empty string'),
|
.isIn(["http", "https"])
|
||||||
body('sshKeyPath').optional().custom((value) => {
|
.withMessage("Protocol must be http or https"),
|
||||||
if (value && value.trim().length === 0) {
|
body("serverHost")
|
||||||
return true; // Allow empty string
|
.optional()
|
||||||
}
|
.isLength({ min: 1 })
|
||||||
if (value && value.trim().length < 1) {
|
.withMessage("Server host is required"),
|
||||||
throw new Error('SSH key path must be a non-empty string');
|
body("serverPort")
|
||||||
}
|
.optional()
|
||||||
return true;
|
.isInt({ min: 1, max: 65535 })
|
||||||
})
|
.withMessage("Port must be between 1 and 65535"),
|
||||||
], async (req, res) => {
|
body("updateInterval")
|
||||||
try {
|
.optional()
|
||||||
console.log('Settings update request body:', req.body);
|
.isInt({ min: 5, max: 1440 })
|
||||||
const errors = validationResult(req);
|
.withMessage("Update interval must be between 5 and 1440 minutes"),
|
||||||
if (!errors.isEmpty()) {
|
body("autoUpdate")
|
||||||
console.log('Validation errors:', errors.array());
|
.optional()
|
||||||
return res.status(400).json({ errors: errors.array() });
|
.isBoolean()
|
||||||
}
|
.withMessage("Auto update must be a boolean"),
|
||||||
|
body("ignoreSslSelfSigned")
|
||||||
|
.optional()
|
||||||
|
.isBoolean()
|
||||||
|
.withMessage("Ignore SSL self-signed must be a boolean"),
|
||||||
|
body("signupEnabled")
|
||||||
|
.optional()
|
||||||
|
.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;
|
||||||
|
}),
|
||||||
|
body("logoDark")
|
||||||
|
.optional()
|
||||||
|
.isLength({ min: 1 })
|
||||||
|
.withMessage("Logo dark path must be a non-empty string"),
|
||||||
|
body("logoLight")
|
||||||
|
.optional()
|
||||||
|
.isLength({ min: 1 })
|
||||||
|
.withMessage("Logo light path must be a non-empty string"),
|
||||||
|
body("favicon")
|
||||||
|
.optional()
|
||||||
|
.isLength({ min: 1 })
|
||||||
|
.withMessage("Favicon path must be a non-empty string"),
|
||||||
|
],
|
||||||
|
async (req, res) => {
|
||||||
|
try {
|
||||||
|
const errors = validationResult(req);
|
||||||
|
if (!errors.isEmpty()) {
|
||||||
|
console.log("Validation errors:", errors.array());
|
||||||
|
return res.status(400).json({ errors: errors.array() });
|
||||||
|
}
|
||||||
|
|
||||||
const { serverProtocol, serverHost, serverPort, frontendUrl, updateInterval, autoUpdate, githubRepoUrl, sshKeyPath } = req.body;
|
const {
|
||||||
console.log('Extracted values:', { serverProtocol, serverHost, serverPort, frontendUrl, updateInterval, autoUpdate, githubRepoUrl, sshKeyPath });
|
serverProtocol,
|
||||||
|
serverHost,
|
||||||
// Construct server URL from components
|
serverPort,
|
||||||
const serverUrl = `${serverProtocol}://${serverHost}:${serverPort}`;
|
updateInterval,
|
||||||
|
autoUpdate,
|
||||||
let settings = await prisma.settings.findFirst();
|
ignoreSslSelfSigned,
|
||||||
|
signupEnabled,
|
||||||
if (settings) {
|
defaultUserRole,
|
||||||
// Update existing settings
|
githubRepoUrl,
|
||||||
console.log('Updating existing settings with data:', {
|
repositoryType,
|
||||||
serverUrl,
|
sshKeyPath,
|
||||||
serverProtocol,
|
logoDark,
|
||||||
serverHost,
|
logoLight,
|
||||||
serverPort,
|
favicon,
|
||||||
frontendUrl,
|
} = req.body;
|
||||||
updateInterval: updateInterval || 60,
|
|
||||||
autoUpdate: autoUpdate || false,
|
// Get current settings to check for update interval changes
|
||||||
githubRepoUrl: githubRepoUrl || 'git@github.com:9technologygroup/patchmon.net.git'
|
const currentSettings = await getSettings();
|
||||||
});
|
const oldUpdateInterval = currentSettings.update_interval;
|
||||||
const oldUpdateInterval = settings.updateInterval;
|
|
||||||
|
// Build update object with only provided fields
|
||||||
settings = await prisma.settings.update({
|
const updateData = {};
|
||||||
where: { id: settings.id },
|
|
||||||
data: {
|
if (serverProtocol !== undefined)
|
||||||
serverUrl,
|
updateData.server_protocol = serverProtocol;
|
||||||
serverProtocol,
|
if (serverHost !== undefined) updateData.server_host = serverHost;
|
||||||
serverHost,
|
if (serverPort !== undefined) updateData.server_port = serverPort;
|
||||||
serverPort,
|
if (updateInterval !== undefined) {
|
||||||
frontendUrl,
|
updateData.update_interval = normalizeUpdateInterval(updateInterval);
|
||||||
updateInterval: updateInterval || 60,
|
}
|
||||||
autoUpdate: autoUpdate || false,
|
if (autoUpdate !== undefined) updateData.auto_update = autoUpdate;
|
||||||
githubRepoUrl: githubRepoUrl || 'git@github.com:9technologygroup/patchmon.net.git',
|
if (ignoreSslSelfSigned !== undefined)
|
||||||
sshKeyPath: sshKeyPath || null
|
updateData.ignore_ssl_self_signed = ignoreSslSelfSigned;
|
||||||
}
|
if (signupEnabled !== undefined)
|
||||||
});
|
updateData.signup_enabled = signupEnabled;
|
||||||
console.log('Settings updated successfully:', settings);
|
if (defaultUserRole !== undefined)
|
||||||
|
updateData.default_user_role = defaultUserRole;
|
||||||
// If update interval changed, trigger crontab updates on all hosts with auto-update enabled
|
if (githubRepoUrl !== undefined)
|
||||||
if (oldUpdateInterval !== (updateInterval || 60)) {
|
updateData.github_repo_url = githubRepoUrl;
|
||||||
console.log(`Update interval changed from ${oldUpdateInterval} to ${updateInterval || 60} minutes. Triggering crontab updates...`);
|
if (repositoryType !== undefined)
|
||||||
await triggerCrontabUpdates();
|
updateData.repository_type = repositoryType;
|
||||||
}
|
if (sshKeyPath !== undefined) updateData.ssh_key_path = sshKeyPath;
|
||||||
} else {
|
if (logoDark !== undefined) updateData.logo_dark = logoDark;
|
||||||
// Create new settings
|
if (logoLight !== undefined) updateData.logo_light = logoLight;
|
||||||
settings = await prisma.settings.create({
|
if (favicon !== undefined) updateData.favicon = favicon;
|
||||||
data: {
|
|
||||||
serverUrl,
|
const updatedSettings = await updateSettings(
|
||||||
serverProtocol,
|
currentSettings.id,
|
||||||
serverHost,
|
updateData,
|
||||||
serverPort,
|
);
|
||||||
frontendUrl,
|
|
||||||
updateInterval: updateInterval || 60,
|
console.log("Settings updated successfully:", updatedSettings);
|
||||||
autoUpdate: autoUpdate || false,
|
|
||||||
githubRepoUrl: githubRepoUrl || 'git@github.com:9technologygroup/patchmon.net.git',
|
// If update interval changed, trigger crontab updates on all hosts with auto-update enabled
|
||||||
sshKeyPath: sshKeyPath || null
|
if (
|
||||||
}
|
updateInterval !== undefined &&
|
||||||
});
|
oldUpdateInterval !== updateData.update_interval
|
||||||
}
|
) {
|
||||||
|
console.log(
|
||||||
res.json({
|
`Update interval changed from ${oldUpdateInterval} to ${updateData.update_interval} minutes. Triggering crontab updates...`,
|
||||||
message: 'Settings updated successfully',
|
);
|
||||||
settings
|
await triggerCrontabUpdates();
|
||||||
});
|
}
|
||||||
} catch (error) {
|
|
||||||
console.error('Settings update error:', error);
|
res.json({
|
||||||
res.status(500).json({ error: 'Failed to update settings' });
|
message: "Settings updated successfully",
|
||||||
}
|
settings: updatedSettings,
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Settings update error:", error);
|
||||||
|
res.status(500).json({ error: "Failed to update settings" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// 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' });
|
} catch (error) {
|
||||||
}
|
console.error("Server URL fetch error:", error);
|
||||||
|
res.status(500).json({ error: "Failed to fetch server URL" });
|
||||||
res.json({ serverUrl: settings.serverUrl });
|
}
|
||||||
} catch (error) {
|
|
||||||
console.error('Server URL fetch error:', error);
|
|
||||||
res.json({ serverUrl: 'http://localhost:3001' });
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get update interval policy for agents (public endpoint)
|
// Get update interval policy for agents (requires API authentication)
|
||||||
router.get('/update-interval', async (req, res) => {
|
router.get("/update-interval", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const settings = await prisma.settings.findFirst();
|
// Verify API credentials
|
||||||
|
const apiId = req.headers["x-api-id"];
|
||||||
if (!settings) {
|
const apiKey = req.headers["x-api-key"];
|
||||||
return res.json({ updateInterval: 60 });
|
|
||||||
}
|
if (!apiId || !apiKey) {
|
||||||
|
return res.status(401).json({ error: "API credentials required" });
|
||||||
res.json({
|
}
|
||||||
updateInterval: settings.updateInterval,
|
|
||||||
cronExpression: `*/${settings.updateInterval} * * * *` // Generate cron expression
|
// Validate API credentials
|
||||||
});
|
const host = await prisma.hosts.findUnique({
|
||||||
} catch (error) {
|
where: { api_id: apiId },
|
||||||
console.error('Update interval fetch error:', error);
|
});
|
||||||
res.json({ updateInterval: 60, cronExpression: '0 * * * *' });
|
|
||||||
}
|
if (!host || host.api_key !== apiKey) {
|
||||||
|
return res.status(401).json({ error: "Invalid API credentials" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const settings = await getSettings();
|
||||||
|
const interval = normalizeUpdateInterval(settings.update_interval || 60);
|
||||||
|
res.json({
|
||||||
|
updateInterval: interval,
|
||||||
|
cronExpression: buildCronExpression(interval),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Update interval fetch error:", error);
|
||||||
|
res.json({ updateInterval: 60, cronExpression: "0 * * * *" });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 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();
|
||||||
|
res.json({
|
||||||
if (!settings) {
|
autoUpdate: settings.auto_update || false,
|
||||||
return res.json({ autoUpdate: false });
|
});
|
||||||
}
|
} catch (error) {
|
||||||
|
console.error("Auto-update fetch error:", error);
|
||||||
res.json({
|
res.json({ autoUpdate: false });
|
||||||
autoUpdate: settings.autoUpdate || false
|
}
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Auto-update fetch error:', error);
|
|
||||||
res.json({ autoUpdate: false });
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Upload logo files
|
||||||
|
router.post(
|
||||||
|
"/logos/upload",
|
||||||
|
authenticateToken,
|
||||||
|
requireManageSettings,
|
||||||
|
async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { logoType, fileContent, fileName } = req.body;
|
||||||
|
|
||||||
|
if (!logoType || !fileContent) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: "Logo type and file content are required",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!["dark", "light", "favicon"].includes(logoType)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: "Logo type must be 'dark', 'light', or 'favicon'",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate file content (basic checks)
|
||||||
|
if (typeof fileContent !== "string") {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: "File content must be a base64 string",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const fs = require("node:fs").promises;
|
||||||
|
const path = require("node:path");
|
||||||
|
const _crypto = require("node:crypto");
|
||||||
|
|
||||||
|
// Create assets directory if it doesn't exist
|
||||||
|
// In development: save to public/assets (served by Vite)
|
||||||
|
// In production: save to dist/assets (served by built app)
|
||||||
|
const isDevelopment = process.env.NODE_ENV !== "production";
|
||||||
|
const assetsDir = isDevelopment
|
||||||
|
? path.join(__dirname, "../../../frontend/public/assets")
|
||||||
|
: path.join(__dirname, "../../../frontend/dist/assets");
|
||||||
|
await fs.mkdir(assetsDir, { recursive: true });
|
||||||
|
|
||||||
|
// Determine file extension and path
|
||||||
|
let fileExtension;
|
||||||
|
let fileName_final;
|
||||||
|
|
||||||
|
if (logoType === "favicon") {
|
||||||
|
fileExtension = ".svg";
|
||||||
|
fileName_final = fileName || "logo_square.svg";
|
||||||
|
} else {
|
||||||
|
// Determine extension from file content or use default
|
||||||
|
if (fileContent.startsWith("data:image/png")) {
|
||||||
|
fileExtension = ".png";
|
||||||
|
} else if (fileContent.startsWith("data:image/svg")) {
|
||||||
|
fileExtension = ".svg";
|
||||||
|
} else if (
|
||||||
|
fileContent.startsWith("data:image/jpeg") ||
|
||||||
|
fileContent.startsWith("data:image/jpg")
|
||||||
|
) {
|
||||||
|
fileExtension = ".jpg";
|
||||||
|
} else {
|
||||||
|
fileExtension = ".png"; // Default to PNG
|
||||||
|
}
|
||||||
|
fileName_final = fileName || `logo_${logoType}${fileExtension}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = path.join(assetsDir, fileName_final);
|
||||||
|
|
||||||
|
// Handle base64 data URLs
|
||||||
|
let fileBuffer;
|
||||||
|
if (fileContent.startsWith("data:")) {
|
||||||
|
const base64Data = fileContent.split(",")[1];
|
||||||
|
fileBuffer = Buffer.from(base64Data, "base64");
|
||||||
|
} else {
|
||||||
|
// Assume it's already base64
|
||||||
|
fileBuffer = Buffer.from(fileContent, "base64");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create backup of existing file
|
||||||
|
try {
|
||||||
|
const backupPath = `${filePath}.backup.${Date.now()}`;
|
||||||
|
await fs.copyFile(filePath, backupPath);
|
||||||
|
console.log(`Created backup: ${backupPath}`);
|
||||||
|
} catch (error) {
|
||||||
|
// Ignore if original doesn't exist
|
||||||
|
if (error.code !== "ENOENT") {
|
||||||
|
console.warn("Failed to create backup:", error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write new logo file
|
||||||
|
await fs.writeFile(filePath, fileBuffer);
|
||||||
|
|
||||||
|
// Update settings with new logo path
|
||||||
|
const settings = await getSettings();
|
||||||
|
const logoPath = `/assets/${fileName_final}`;
|
||||||
|
|
||||||
|
const updateData = {};
|
||||||
|
if (logoType === "dark") {
|
||||||
|
updateData.logo_dark = logoPath;
|
||||||
|
} else if (logoType === "light") {
|
||||||
|
updateData.logo_light = logoPath;
|
||||||
|
} else if (logoType === "favicon") {
|
||||||
|
updateData.favicon = logoPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateSettings(settings.id, updateData);
|
||||||
|
|
||||||
|
// Get file stats
|
||||||
|
const stats = await fs.stat(filePath);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
message: `${logoType} logo uploaded successfully`,
|
||||||
|
fileName: fileName_final,
|
||||||
|
path: logoPath,
|
||||||
|
size: stats.size,
|
||||||
|
sizeFormatted: `${(stats.size / 1024).toFixed(1)} KB`,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Upload logo error:", error);
|
||||||
|
res.status(500).json({ error: "Failed to upload logo" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Reset logo to default
|
||||||
|
router.post(
|
||||||
|
"/logos/reset",
|
||||||
|
authenticateToken,
|
||||||
|
requireManageSettings,
|
||||||
|
async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { logoType } = req.body;
|
||||||
|
|
||||||
|
if (!logoType) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: "Logo type is required",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!["dark", "light", "favicon"].includes(logoType)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: "Logo type must be 'dark', 'light', or 'favicon'",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current settings
|
||||||
|
const settings = await getSettings();
|
||||||
|
|
||||||
|
// Clear the custom logo path to revert to default
|
||||||
|
const updateData = {};
|
||||||
|
if (logoType === "dark") {
|
||||||
|
updateData.logo_dark = null;
|
||||||
|
} else if (logoType === "light") {
|
||||||
|
updateData.logo_light = null;
|
||||||
|
} else if (logoType === "favicon") {
|
||||||
|
updateData.favicon = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateSettings(settings.id, updateData);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
message: `${logoType} logo reset to default successfully`,
|
||||||
|
logoType,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Reset logo error:", error);
|
||||||
|
res.status(500).json({ error: "Failed to reset logo" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
340
backend/src/routes/tfaRoutes.js
Normal file
340
backend/src/routes/tfaRoutes.js
Normal file
@@ -0,0 +1,340 @@
|
|||||||
|
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: _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",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// FIXME: 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: userId },
|
||||||
|
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;
|
||||||
@@ -1,309 +1,337 @@
|
|||||||
const express = require('express');
|
const express = require("express");
|
||||||
const { authenticateToken } = require('../middleware/auth');
|
const { authenticateToken } = require("../middleware/auth");
|
||||||
const { requireManageSettings } = require('../middleware/permissions');
|
const { requireManageSettings } = require("../middleware/permissions");
|
||||||
const { PrismaClient } = require('@prisma/client');
|
const { PrismaClient } = require("@prisma/client");
|
||||||
const { exec } = require('child_process');
|
|
||||||
const { promisify } = require('util');
|
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
const execAsync = promisify(exec);
|
|
||||||
|
// Default GitHub repository URL
|
||||||
|
const DEFAULT_GITHUB_REPO = "https://github.com/patchMon/patchmon";
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Helper function to get current version from package.json
|
||||||
|
function getCurrentVersion() {
|
||||||
|
try {
|
||||||
|
const packageJson = require("../../package.json");
|
||||||
|
return packageJson?.version || "1.2.7";
|
||||||
|
} catch (packageError) {
|
||||||
|
console.warn(
|
||||||
|
"Could not read version from package.json, using fallback:",
|
||||||
|
packageError.message,
|
||||||
|
);
|
||||||
|
return "1.2.7";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to parse GitHub repository URL
|
||||||
|
function parseGitHubRepo(repoUrl) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { owner, repo };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to get latest release from GitHub API
|
||||||
|
async function getLatestRelease(owner, repo) {
|
||||||
|
try {
|
||||||
|
const currentVersion = getCurrentVersion();
|
||||||
|
const apiUrl = `https://api.github.com/repos/${owner}/${repo}/releases/latest`;
|
||||||
|
|
||||||
|
const response = await fetch(apiUrl, {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
Accept: "application/vnd.github.v3+json",
|
||||||
|
"User-Agent": `PatchMon-Server/${currentVersion}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
if (
|
||||||
|
errorText.includes("rate limit") ||
|
||||||
|
errorText.includes("API rate limit")
|
||||||
|
) {
|
||||||
|
throw new Error("GitHub API rate limit exceeded");
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
`GitHub API error: ${response.status} ${response.statusText}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const releaseData = await response.json();
|
||||||
|
return {
|
||||||
|
tagName: releaseData.tag_name,
|
||||||
|
version: releaseData.tag_name.replace("v", ""),
|
||||||
|
publishedAt: releaseData.published_at,
|
||||||
|
htmlUrl: releaseData.html_url,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching latest release:", error.message);
|
||||||
|
throw error; // Re-throw to be caught by the calling function
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to get latest commit from main branch
|
||||||
|
async function getLatestCommit(owner, repo) {
|
||||||
|
try {
|
||||||
|
const currentVersion = getCurrentVersion();
|
||||||
|
const apiUrl = `https://api.github.com/repos/${owner}/${repo}/commits/main`;
|
||||||
|
|
||||||
|
const response = await fetch(apiUrl, {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
Accept: "application/vnd.github.v3+json",
|
||||||
|
"User-Agent": `PatchMon-Server/${currentVersion}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
if (
|
||||||
|
errorText.includes("rate limit") ||
|
||||||
|
errorText.includes("API rate limit")
|
||||||
|
) {
|
||||||
|
throw new Error("GitHub API rate limit exceeded");
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
`GitHub API error: ${response.status} ${response.statusText}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const commitData = await response.json();
|
||||||
|
return {
|
||||||
|
sha: commitData.sha,
|
||||||
|
message: commitData.commit.message,
|
||||||
|
author: commitData.commit.author.name,
|
||||||
|
date: commitData.commit.author.date,
|
||||||
|
htmlUrl: commitData.html_url,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching latest commit:", error.message);
|
||||||
|
throw error; // Re-throw to be caught by the calling function
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to get commit count difference
|
||||||
|
async function getCommitDifference(owner, repo, currentVersion) {
|
||||||
|
try {
|
||||||
|
const currentVersionTag = `v${currentVersion}`;
|
||||||
|
// Compare main branch with the released version tag
|
||||||
|
const apiUrl = `https://api.github.com/repos/${owner}/${repo}/compare/${currentVersionTag}...main`;
|
||||||
|
|
||||||
|
const response = await fetch(apiUrl, {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
Accept: "application/vnd.github.v3+json",
|
||||||
|
"User-Agent": `PatchMon-Server/${getCurrentVersion()}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
if (
|
||||||
|
errorText.includes("rate limit") ||
|
||||||
|
errorText.includes("API rate limit")
|
||||||
|
) {
|
||||||
|
throw new Error("GitHub API rate limit exceeded");
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
`GitHub API error: ${response.status} ${response.statusText}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const compareData = await response.json();
|
||||||
|
return {
|
||||||
|
commitsBehind: compareData.behind_by || 0, // How many commits main is behind release
|
||||||
|
commitsAhead: compareData.ahead_by || 0, // How many commits main is ahead of release
|
||||||
|
totalCommits: compareData.total_commits || 0,
|
||||||
|
branchInfo: "main branch vs release",
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching commit difference:", error.message);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to compare version strings (semantic versioning)
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
// Get current version info
|
// Get current version info
|
||||||
router.get('/current', authenticateToken, async (req, res) => {
|
router.get("/current", authenticateToken, async (_req, res) => {
|
||||||
try {
|
try {
|
||||||
// For now, return hardcoded version - this should match your agent version
|
const currentVersion = getCurrentVersion();
|
||||||
const currentVersion = '1.2.4';
|
|
||||||
|
// Get settings with cached update info (no GitHub API calls)
|
||||||
res.json({
|
const settings = await prisma.settings.findFirst();
|
||||||
version: currentVersion,
|
const githubRepoUrl = settings?.githubRepoUrl || DEFAULT_GITHUB_REPO;
|
||||||
buildDate: new Date().toISOString(),
|
const { owner, repo } = parseGitHubRepo(githubRepoUrl);
|
||||||
environment: process.env.NODE_ENV || 'development'
|
|
||||||
});
|
// Return current version and cached update information
|
||||||
} catch (error) {
|
// The backend scheduler updates this data periodically
|
||||||
console.error('Error getting current version:', error);
|
res.json({
|
||||||
res.status(500).json({ error: 'Failed to get current version' });
|
version: currentVersion,
|
||||||
}
|
latest_version: settings?.latest_version || null,
|
||||||
|
is_update_available: settings?.is_update_available || false,
|
||||||
|
last_update_check: settings?.last_update_check || null,
|
||||||
|
buildDate: new Date().toISOString(),
|
||||||
|
environment: process.env.NODE_ENV || "development",
|
||||||
|
github: {
|
||||||
|
repository: githubRepoUrl,
|
||||||
|
owner: owner,
|
||||||
|
repo: repo,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} 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
|
// Test SSH key permissions and GitHub access
|
||||||
router.post('/test-ssh-key', authenticateToken, requireManageSettings, async (req, res) => {
|
router.post(
|
||||||
try {
|
"/test-ssh-key",
|
||||||
const { sshKeyPath, githubRepoUrl } = req.body;
|
authenticateToken,
|
||||||
|
requireManageSettings,
|
||||||
if (!sshKeyPath || !githubRepoUrl) {
|
async (_req, res) => {
|
||||||
return res.status(400).json({
|
res.status(410).json({
|
||||||
error: 'SSH key path and GitHub repo URL are required'
|
error:
|
||||||
});
|
"SSH key testing has been removed. Using default public repository.",
|
||||||
}
|
});
|
||||||
|
},
|
||||||
// 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
|
// Check for updates from GitHub
|
||||||
router.get('/check-updates', authenticateToken, requireManageSettings, async (req, res) => {
|
router.get(
|
||||||
try {
|
"/check-updates",
|
||||||
// Get GitHub repo URL from settings
|
authenticateToken,
|
||||||
const settings = await prisma.settings.findFirst();
|
requireManageSettings,
|
||||||
if (!settings || !settings.githubRepoUrl) {
|
async (_req, res) => {
|
||||||
return res.status(400).json({ error: 'GitHub repository URL not configured' });
|
try {
|
||||||
}
|
// Get cached update information from settings
|
||||||
|
const settings = await prisma.settings.findFirst();
|
||||||
|
|
||||||
// Extract owner and repo from GitHub URL
|
if (!settings) {
|
||||||
// Support both SSH and HTTPS formats:
|
return res.status(400).json({ error: "Settings not found" });
|
||||||
// git@github.com:owner/repo.git
|
}
|
||||||
// https://github.com/owner/repo.git
|
|
||||||
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\/([^\/]+)\/([^\/]+)/);
|
|
||||||
if (match) {
|
|
||||||
[, owner, repo] = match;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!owner || !repo) {
|
const currentVersion = getCurrentVersion();
|
||||||
return res.status(400).json({ error: 'Invalid GitHub repository URL format' });
|
const githubRepoUrl = settings.githubRepoUrl || DEFAULT_GITHUB_REPO;
|
||||||
}
|
const { owner, repo } = parseGitHubRepo(githubRepoUrl);
|
||||||
|
|
||||||
// Use SSH with deploy keys (secure approach)
|
let latestRelease = null;
|
||||||
const sshRepoUrl = `git@github.com:${owner}/${repo}.git`;
|
let latestCommit = null;
|
||||||
|
let commitDifference = null;
|
||||||
try {
|
|
||||||
let sshKeyPath = null;
|
|
||||||
|
|
||||||
// First, try to use the configured SSH key path from settings
|
|
||||||
if (settings.sshKeyPath) {
|
|
||||||
try {
|
|
||||||
require('fs').accessSync(settings.sshKeyPath);
|
|
||||||
sshKeyPath = settings.sshKeyPath;
|
|
||||||
console.log(`Using configured SSH key at: ${sshKeyPath}`);
|
|
||||||
} catch (e) {
|
|
||||||
console.warn(`Configured SSH key path not accessible: ${settings.sshKeyPath}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If no configured path or it's not accessible, try common locations
|
|
||||||
if (!sshKeyPath) {
|
|
||||||
const possibleKeyPaths = [
|
|
||||||
'/root/.ssh/id_ed25519', // Root user (if service runs as root)
|
|
||||||
'/root/.ssh/id_rsa', // Root user RSA key
|
|
||||||
'/home/patchmon/.ssh/id_ed25519', // PatchMon user
|
|
||||||
'/home/patchmon/.ssh/id_rsa', // PatchMon user RSA key
|
|
||||||
'/var/www/.ssh/id_ed25519', // Web user
|
|
||||||
'/var/www/.ssh/id_rsa' // Web user RSA key
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const path of possibleKeyPaths) {
|
|
||||||
try {
|
|
||||||
require('fs').accessSync(path);
|
|
||||||
sshKeyPath = path;
|
|
||||||
console.log(`Found SSH key at: ${path}`);
|
|
||||||
break;
|
|
||||||
} catch (e) {
|
|
||||||
// Key not found at this path, try next
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!sshKeyPath) {
|
|
||||||
throw new Error('No SSH deploy key found. Please configure the SSH key path in settings or ensure a deploy key is installed in one of the expected locations.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const env = {
|
|
||||||
...process.env,
|
|
||||||
GIT_SSH_COMMAND: `ssh -i ${sshKeyPath} -o StrictHostKeyChecking=no -o IdentitiesOnly=yes`
|
|
||||||
};
|
|
||||||
|
|
||||||
// Fetch the latest tag using SSH with deploy key
|
// Fetch fresh GitHub data if we have valid owner/repo
|
||||||
const { stdout: latestTag } = await execAsync(
|
if (owner && repo) {
|
||||||
`git ls-remote --tags --sort=-version:refname ${sshRepoUrl} | head -n 1 | sed 's/.*refs\\/tags\\///' | sed 's/\\^{}//'`,
|
try {
|
||||||
{
|
const [releaseData, commitData, differenceData] = await Promise.all([
|
||||||
timeout: 10000,
|
getLatestRelease(owner, repo),
|
||||||
env: env
|
getLatestCommit(owner, repo),
|
||||||
}
|
getCommitDifference(owner, repo, currentVersion),
|
||||||
);
|
]);
|
||||||
|
|
||||||
const latestVersion = latestTag.trim().replace('v', ''); // Remove 'v' prefix
|
latestRelease = releaseData;
|
||||||
const currentVersion = '1.2.4';
|
latestCommit = commitData;
|
||||||
|
commitDifference = differenceData;
|
||||||
|
} catch (githubError) {
|
||||||
|
console.warn(
|
||||||
|
"Failed to fetch fresh GitHub data:",
|
||||||
|
githubError.message,
|
||||||
|
);
|
||||||
|
|
||||||
// Simple version comparison (assumes semantic versioning)
|
// Provide fallback data when GitHub API is rate-limited
|
||||||
const isUpdateAvailable = compareVersions(latestVersion, currentVersion) > 0;
|
if (
|
||||||
|
githubError.message.includes("rate limit") ||
|
||||||
|
githubError.message.includes("API rate limit")
|
||||||
|
) {
|
||||||
|
console.log("GitHub API rate limited, providing fallback data");
|
||||||
|
latestRelease = {
|
||||||
|
tagName: "v1.2.7",
|
||||||
|
version: "1.2.7",
|
||||||
|
publishedAt: "2025-10-02T17:12:53Z",
|
||||||
|
htmlUrl:
|
||||||
|
"https://github.com/PatchMon/PatchMon/releases/tag/v1.2.7",
|
||||||
|
};
|
||||||
|
latestCommit = {
|
||||||
|
sha: "cc89df161b8ea5d48ff95b0eb405fe69042052cd",
|
||||||
|
message: "Update README.md\n\nAdded Documentation Links",
|
||||||
|
author: "9 Technology Group LTD",
|
||||||
|
date: "2025-10-04T18:38:09Z",
|
||||||
|
htmlUrl:
|
||||||
|
"https://github.com/PatchMon/PatchMon/commit/cc89df161b8ea5d48ff95b0eb405fe69042052cd",
|
||||||
|
};
|
||||||
|
commitDifference = {
|
||||||
|
commitsBehind: 0,
|
||||||
|
commitsAhead: 3, // Main branch is ahead of release
|
||||||
|
totalCommits: 3,
|
||||||
|
branchInfo: "main branch vs release",
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// Fall back to cached data for other errors
|
||||||
|
latestRelease = settings.latest_version
|
||||||
|
? {
|
||||||
|
version: settings.latest_version,
|
||||||
|
tagName: `v${settings.latest_version}`,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
res.json({
|
const latestVersion =
|
||||||
currentVersion,
|
latestRelease?.version || settings.latest_version || currentVersion;
|
||||||
latestVersion,
|
const isUpdateAvailable = latestRelease
|
||||||
isUpdateAvailable,
|
? compareVersions(latestVersion, currentVersion) > 0
|
||||||
latestRelease: {
|
: settings.update_available || false;
|
||||||
tagName: latestTag.trim(),
|
|
||||||
version: latestVersion,
|
|
||||||
repository: `${owner}/${repo}`,
|
|
||||||
sshUrl: sshRepoUrl,
|
|
||||||
sshKeyUsed: sshKeyPath
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (sshError) {
|
res.json({
|
||||||
console.error('SSH Git error:', sshError.message);
|
currentVersion,
|
||||||
|
latestVersion,
|
||||||
if (sshError.message.includes('Permission denied') || sshError.message.includes('Host key verification failed')) {
|
isUpdateAvailable,
|
||||||
return res.status(403).json({
|
lastUpdateCheck: settings.last_update_check || null,
|
||||||
error: 'SSH access denied to repository',
|
repositoryType: settings.repository_type || "public",
|
||||||
suggestion: 'Ensure your deploy key is properly configured and has access to the repository. Check that the key has read access to the repository.'
|
github: {
|
||||||
});
|
repository: githubRepoUrl,
|
||||||
}
|
owner: owner,
|
||||||
|
repo: repo,
|
||||||
if (sshError.message.includes('not found') || sshError.message.includes('does not exist')) {
|
latestRelease: latestRelease,
|
||||||
return res.status(404).json({
|
latestCommit: latestCommit,
|
||||||
error: 'Repository not found',
|
commitDifference: commitDifference,
|
||||||
suggestion: 'Check that the repository URL is correct and accessible with the deploy key.'
|
},
|
||||||
});
|
});
|
||||||
}
|
} catch (error) {
|
||||||
|
console.error("Error getting update information:", error);
|
||||||
if (sshError.message.includes('No SSH deploy key found')) {
|
res.status(500).json({ error: "Failed to get update information" });
|
||||||
return res.status(400).json({
|
}
|
||||||
error: 'No SSH deploy key found',
|
},
|
||||||
suggestion: 'Please install a deploy key in one of the expected locations: /root/.ssh/, /home/patchmon/.ssh/, or /var/www/.ssh/'
|
);
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.status(500).json({
|
|
||||||
error: 'Failed to fetch repository information',
|
|
||||||
details: sshError.message,
|
|
||||||
suggestion: 'Check deploy key configuration and repository access permissions.'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error checking for updates:', error);
|
|
||||||
res.status(500).json({
|
|
||||||
error: 'Failed to check for updates',
|
|
||||||
details: error.message
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 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;
|
module.exports = router;
|
||||||
|
|||||||
@@ -1,49 +1,252 @@
|
|||||||
require('dotenv').config();
|
require("dotenv").config();
|
||||||
const express = require('express');
|
|
||||||
const cors = require('cors');
|
// Validate required environment variables on startup
|
||||||
const helmet = require('helmet');
|
function validateEnvironmentVariables() {
|
||||||
const rateLimit = require('express-rate-limit');
|
const requiredVars = {
|
||||||
const { PrismaClient } = require('@prisma/client');
|
JWT_SECRET: "Required for secure authentication token generation",
|
||||||
const winston = require('winston');
|
DATABASE_URL: "Required for database connection",
|
||||||
|
};
|
||||||
|
|
||||||
|
const missing = [];
|
||||||
|
|
||||||
|
// Check required variables
|
||||||
|
for (const [varName, description] of Object.entries(requiredVars)) {
|
||||||
|
if (!process.env[varName]) {
|
||||||
|
missing.push(`${varName}: ${description}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fail if required variables are missing
|
||||||
|
if (missing.length > 0) {
|
||||||
|
console.error("❌ Missing required environment variables:");
|
||||||
|
for (const error of missing) {
|
||||||
|
console.error(` - ${error}`);
|
||||||
|
}
|
||||||
|
console.error("");
|
||||||
|
console.error(
|
||||||
|
"Please set these environment variables and restart the application.",
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("✅ Environment variable validation passed");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate environment variables before importing any modules that depend on them
|
||||||
|
validateEnvironmentVariables();
|
||||||
|
|
||||||
|
const express = require("express");
|
||||||
|
const cors = require("cors");
|
||||||
|
const helmet = require("helmet");
|
||||||
|
const rateLimit = require("express-rate-limit");
|
||||||
|
const {
|
||||||
|
createPrismaClient,
|
||||||
|
waitForDatabase,
|
||||||
|
disconnectPrisma,
|
||||||
|
} = require("./config/database");
|
||||||
|
const winston = require("winston");
|
||||||
|
|
||||||
// Import routes
|
// Import routes
|
||||||
const authRoutes = require('./routes/authRoutes');
|
const authRoutes = require("./routes/authRoutes");
|
||||||
const hostRoutes = require('./routes/hostRoutes');
|
const hostRoutes = require("./routes/hostRoutes");
|
||||||
const hostGroupRoutes = require('./routes/hostGroupRoutes');
|
const hostGroupRoutes = require("./routes/hostGroupRoutes");
|
||||||
const packageRoutes = require('./routes/packageRoutes');
|
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 {
|
||||||
const repositoryRoutes = require('./routes/repositoryRoutes');
|
router: dashboardPreferencesRoutes,
|
||||||
const versionRoutes = require('./routes/versionRoutes');
|
} = require("./routes/dashboardPreferencesRoutes");
|
||||||
|
const repositoryRoutes = require("./routes/repositoryRoutes");
|
||||||
|
const versionRoutes = require("./routes/versionRoutes");
|
||||||
|
const tfaRoutes = require("./routes/tfaRoutes");
|
||||||
|
const searchRoutes = require("./routes/searchRoutes");
|
||||||
|
const autoEnrollmentRoutes = require("./routes/autoEnrollmentRoutes");
|
||||||
|
const updateScheduler = require("./services/updateScheduler");
|
||||||
|
const { initSettings } = require("./services/settingsService");
|
||||||
|
const { cleanup_expired_sessions } = require("./utils/session_manager");
|
||||||
|
|
||||||
// Initialize Prisma client
|
// Initialize Prisma client with optimized connection pooling for multiple instances
|
||||||
const prisma = new PrismaClient();
|
const prisma = createPrismaClient();
|
||||||
|
|
||||||
|
// 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("node: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 =
|
||||||
level: process.env.LOG_LEVEL || 'info',
|
process.env.ENABLE_LOGGING === "true"
|
||||||
format: winston.format.combine(
|
? winston.createLogger({
|
||||||
winston.format.timestamp(),
|
level: process.env.LOG_LEVEL || "info",
|
||||||
winston.format.errors({ stack: true }),
|
format: winston.format.combine(
|
||||||
winston.format.json()
|
winston.format.timestamp(),
|
||||||
),
|
winston.format.errors({ stack: true }),
|
||||||
transports: [
|
winston.format.json(),
|
||||||
new winston.transports.File({ filename: 'logs/error.log', level: 'error' }),
|
),
|
||||||
new winston.transports.File({ filename: 'logs/combined.log' }),
|
transports: [],
|
||||||
],
|
})
|
||||||
}) : {
|
: {
|
||||||
info: () => {},
|
info: () => {},
|
||||||
error: () => {},
|
error: () => {},
|
||||||
warn: () => {},
|
warn: () => {},
|
||||||
debug: () => {}
|
debug: () => {},
|
||||||
};
|
};
|
||||||
|
|
||||||
if (process.env.ENABLE_LOGGING === 'true' && process.env.NODE_ENV !== 'production') {
|
// Configure transports based on PM_LOG_TO_CONSOLE environment variable
|
||||||
logger.add(new winston.transports.Console({
|
if (process.env.ENABLE_LOGGING === "true") {
|
||||||
format: winston.format.simple()
|
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({
|
||||||
|
format: winston.format.simple(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
@@ -51,79 +254,155 @@ 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;
|
||||||
} else {
|
|
||||||
app.set('trust proxy', 1);
|
|
||||||
}
|
|
||||||
app.disable('x-powered-by');
|
|
||||||
|
|
||||||
// Rate limiting
|
// 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 {
|
||||||
|
app.set("trust proxy", 1);
|
||||||
|
}
|
||||||
|
app.disable("x-powered-by");
|
||||||
|
|
||||||
|
// 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, 10) || 15 * 60 * 1000,
|
||||||
max: parseInt(process.env.RATE_LIMIT_MAX) || 100,
|
max: parseInt(process.env.RATE_LIMIT_MAX, 10) || 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, 10) || 15 * 60 * 1000) / 1000,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
skipSuccessfulRequests: true, // Don't count successful requests
|
||||||
|
skipFailedRequests: false, // Count failed requests
|
||||||
});
|
});
|
||||||
|
|
||||||
// Middleware
|
// Middleware
|
||||||
// Helmet with stricter defaults (CSP/HSTS only in production)
|
// Helmet with stricter defaults (CSP/HSTS only in production)
|
||||||
app.use(helmet({
|
app.use(
|
||||||
contentSecurityPolicy: process.env.NODE_ENV === 'production' ? {
|
helmet({
|
||||||
useDefaults: true,
|
contentSecurityPolicy:
|
||||||
directives: {
|
process.env.NODE_ENV === "production"
|
||||||
defaultSrc: ["'self'"],
|
? {
|
||||||
scriptSrc: ["'self'"],
|
useDefaults: true,
|
||||||
styleSrc: ["'self'", "'unsafe-inline'"],
|
directives: {
|
||||||
imgSrc: ["'self'", 'data:'],
|
defaultSrc: ["'self'"],
|
||||||
fontSrc: ["'self'", 'data:'],
|
scriptSrc: ["'self'"],
|
||||||
connectSrc: ["'self'"],
|
styleSrc: ["'self'", "'unsafe-inline'"],
|
||||||
frameAncestors: ["'none'"],
|
imgSrc: ["'self'", "data:"],
|
||||||
objectSrc: ["'none'"]
|
fontSrc: ["'self'", "data:"],
|
||||||
}
|
connectSrc: ["'self'"],
|
||||||
} : false,
|
frameAncestors: ["'none'"],
|
||||||
hsts: process.env.ENABLE_HSTS === 'true' || process.env.NODE_ENV === 'production'
|
objectSrc: ["'none'"],
|
||||||
}));
|
},
|
||||||
|
}
|
||||||
|
: false,
|
||||||
|
hsts:
|
||||||
|
process.env.ENABLE_HSTS === "true" ||
|
||||||
|
process.env.NODE_ENV === "production",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
// CORS allowlist from comma-separated env
|
// CORS allowlist from comma-separated env
|
||||||
const parseOrigins = (val) => (val || '').split(',').map(s => s.trim()).filter(Boolean);
|
const parseOrigins = (val) =>
|
||||||
const allowedOrigins = parseOrigins(process.env.CORS_ORIGINS || process.env.CORS_ORIGIN || 'http://localhost:3000');
|
(val || "")
|
||||||
app.use(cors({
|
.split(",")
|
||||||
origin: function(origin, callback) {
|
.map((s) => s.trim())
|
||||||
// Allow non-browser/SSR tools with no origin
|
.filter(Boolean);
|
||||||
if (!origin) return callback(null, true);
|
const allowedOrigins = parseOrigins(
|
||||||
if (allowedOrigins.includes(origin)) return callback(null, true);
|
process.env.CORS_ORIGINS ||
|
||||||
return callback(new Error('Not allowed by CORS'));
|
process.env.CORS_ORIGIN ||
|
||||||
},
|
"http://localhost:3000",
|
||||||
credentials: true
|
);
|
||||||
}));
|
app.use(
|
||||||
|
cors({
|
||||||
|
origin: (origin, callback) => {
|
||||||
|
// Allow non-browser/SSR tools with no origin
|
||||||
|
if (!origin) return callback(null, true);
|
||||||
|
if (allowedOrigins.includes(origin)) return callback(null, true);
|
||||||
|
return callback(new Error("Not allowed by CORS"));
|
||||||
|
},
|
||||||
|
credentials: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
app.use(limiter);
|
app.use(limiter);
|
||||||
// Reduce body size limits to reasonable defaults
|
// Reduce body size limits to reasonable defaults
|
||||||
app.use(express.json({ limit: process.env.JSON_BODY_LIMIT || '5mb' }));
|
app.use(express.json({ limit: process.env.JSON_BODY_LIMIT || "5mb" }));
|
||||||
app.use(express.urlencoded({ extended: true, limit: process.env.JSON_BODY_LIMIT || '5mb' }));
|
app.use(
|
||||||
|
express.urlencoded({
|
||||||
|
extended: true,
|
||||||
|
limit: process.env.JSON_BODY_LIMIT || "5mb",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
// 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) => {
|
||||||
logger.info(`${req.method} ${req.path} - ${req.ip}`);
|
// Log health check requests at debug level to reduce log spam
|
||||||
next();
|
if (req.path === "/health") {
|
||||||
});
|
logger.debug(`${req.method} ${req.path} - ${req.ip}`);
|
||||||
|
} else {
|
||||||
|
logger.info(`${req.method} ${req.path} - ${req.ip}`);
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Health check endpoint
|
// Health check endpoint
|
||||||
app.get('/health', (req, res) => {
|
app.get("/health", (_req, res) => {
|
||||||
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
res.json({ status: "ok", timestamp: new Date().toISOString() });
|
||||||
});
|
});
|
||||||
|
|
||||||
// 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:
|
||||||
max: parseInt(process.env.AUTH_RATE_LIMIT_MAX) || 20
|
parseInt(process.env.AUTH_RATE_LIMIT_WINDOW_MS, 10) || 10 * 60 * 1000,
|
||||||
|
max: parseInt(process.env.AUTH_RATE_LIMIT_MAX, 10) || 20,
|
||||||
|
message: {
|
||||||
|
error: "Too many authentication requests, please try again later.",
|
||||||
|
retryAfter: Math.ceil(
|
||||||
|
(parseInt(process.env.AUTH_RATE_LIMIT_WINDOW_MS, 10) || 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, 10) || 60 * 1000,
|
||||||
max: parseInt(process.env.AGENT_RATE_LIMIT_MAX) || 120
|
max: parseInt(process.env.AGENT_RATE_LIMIT_MAX, 10) || 120,
|
||||||
|
message: {
|
||||||
|
error: "Too many agent requests, please try again later.",
|
||||||
|
retryAfter: Math.ceil(
|
||||||
|
(parseInt(process.env.AGENT_RATE_LIMIT_WINDOW_MS, 10) || 60 * 1000) /
|
||||||
|
1000,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
skipSuccessfulRequests: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
app.use(`/api/${apiVersion}/auth`, authLimiter, authRoutes);
|
app.use(`/api/${apiVersion}/auth`, authLimiter, authRoutes);
|
||||||
@@ -136,46 +415,353 @@ 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}/version`, versionRoutes);
|
||||||
|
app.use(`/api/${apiVersion}/tfa`, tfaRoutes);
|
||||||
|
app.use(`/api/${apiVersion}/search`, searchRoutes);
|
||||||
|
app.use(
|
||||||
|
`/api/${apiVersion}/auto-enrollment`,
|
||||||
|
authLimiter,
|
||||||
|
autoEnrollmentRoutes,
|
||||||
|
);
|
||||||
|
|
||||||
// Error handling middleware
|
// Error handling middleware
|
||||||
app.use((err, req, res, next) => {
|
app.use((err, _req, res, _next) => {
|
||||||
if (process.env.ENABLE_LOGGING === 'true') {
|
if (process.env.ENABLE_LOGGING === "true") {
|
||||||
logger.error(err.stack);
|
logger.error(err.stack);
|
||||||
}
|
}
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
error: 'Something went wrong!',
|
error: "Something went wrong!",
|
||||||
message: process.env.NODE_ENV === 'development' ? err.message : undefined
|
message: process.env.NODE_ENV === "development" ? err.message : undefined,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// 404 handler
|
// 404 handler
|
||||||
app.use('*', (req, res) => {
|
app.use("*", (_req, res) => {
|
||||||
res.status(404).json({ error: 'Route not found' });
|
res.status(404).json({ error: "Route not found" });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Graceful shutdown
|
// Graceful shutdown
|
||||||
process.on('SIGTERM', async () => {
|
process.on("SIGINT", async () => {
|
||||||
if (process.env.ENABLE_LOGGING === 'true') {
|
if (process.env.ENABLE_LOGGING === "true") {
|
||||||
logger.info('SIGTERM received, shutting down gracefully');
|
logger.info("SIGINT received, shutting down gracefully");
|
||||||
}
|
}
|
||||||
await prisma.$disconnect();
|
if (app.locals.session_cleanup_interval) {
|
||||||
process.exit(0);
|
clearInterval(app.locals.session_cleanup_interval);
|
||||||
|
}
|
||||||
|
updateScheduler.stop();
|
||||||
|
await disconnectPrisma(prisma);
|
||||||
|
process.exit(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
process.on('SIGINT', async () => {
|
process.on("SIGTERM", async () => {
|
||||||
if (process.env.ENABLE_LOGGING === 'true') {
|
if (process.env.ENABLE_LOGGING === "true") {
|
||||||
logger.info('SIGINT received, shutting down gracefully');
|
logger.info("SIGTERM received, shutting down gracefully");
|
||||||
}
|
}
|
||||||
await prisma.$disconnect();
|
if (app.locals.session_cleanup_interval) {
|
||||||
process.exit(0);
|
clearInterval(app.locals.session_cleanup_interval);
|
||||||
|
}
|
||||||
|
updateScheduler.stop();
|
||||||
|
await disconnectPrisma(prisma);
|
||||||
|
process.exit(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Start server
|
// Initialize dashboard preferences for all users
|
||||||
app.listen(PORT, () => {
|
async function initializeDashboardPreferences() {
|
||||||
if (process.env.ENABLE_LOGGING === 'true') {
|
try {
|
||||||
logger.info(`Server running on port ${PORT}`);
|
// Get all users
|
||||||
logger.info(`Environment: ${process.env.NODE_ENV}`);
|
const users = await prisma.users.findMany({
|
||||||
}
|
select: {
|
||||||
});
|
id: true,
|
||||||
|
username: true,
|
||||||
|
email: true,
|
||||||
|
role: true,
|
||||||
|
dashboard_preferences: {
|
||||||
|
select: {
|
||||||
|
card_id: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = app;
|
if (users.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
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++;
|
||||||
|
} else {
|
||||||
|
// User already has preferences - check if they need updating
|
||||||
|
const currentCardCount = user.dashboard_preferences.length;
|
||||||
|
|
||||||
|
if (currentCardCount !== expectedCardCount) {
|
||||||
|
// 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++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only show summary if there were changes
|
||||||
|
if (initializedCount > 0 || updatedCount > 0) {
|
||||||
|
console.log(
|
||||||
|
`✅ Dashboard preferences: ${initializedCount} initialized, ${updatedCount} updated`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} 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: "osDistributionDoughnut",
|
||||||
|
requiredPermission: "can_view_reports",
|
||||||
|
order: 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
cardId: "recentCollection",
|
||||||
|
requiredPermission: "can_view_hosts",
|
||||||
|
order: 11,
|
||||||
|
}, // Collection is host-related
|
||||||
|
{
|
||||||
|
cardId: "updateStatus",
|
||||||
|
requiredPermission: "can_view_reports",
|
||||||
|
order: 12,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
cardId: "packagePriority",
|
||||||
|
requiredPermission: "can_view_packages",
|
||||||
|
order: 13,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
cardId: "packageTrends",
|
||||||
|
requiredPermission: "can_view_packages",
|
||||||
|
order: 14,
|
||||||
|
},
|
||||||
|
{ cardId: "recentUsers", requiredPermission: "can_view_users", order: 15 },
|
||||||
|
{
|
||||||
|
cardId: "quickStats",
|
||||||
|
requiredPermission: "can_view_dashboard",
|
||||||
|
order: 16,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// 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 create default role permissions on startup
|
||||||
|
await checkAndCreateRolePermissions();
|
||||||
|
|
||||||
|
// Initialize dashboard preferences for all users
|
||||||
|
await initializeDashboardPreferences();
|
||||||
|
|
||||||
|
// Initial session cleanup
|
||||||
|
await cleanup_expired_sessions();
|
||||||
|
|
||||||
|
// Schedule session cleanup every hour
|
||||||
|
const session_cleanup_interval = setInterval(
|
||||||
|
async () => {
|
||||||
|
try {
|
||||||
|
await cleanup_expired_sessions();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Session cleanup error:", error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
60 * 60 * 1000,
|
||||||
|
); // Every hour
|
||||||
|
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
if (process.env.ENABLE_LOGGING === "true") {
|
||||||
|
logger.info(`Server running on port ${PORT}`);
|
||||||
|
logger.info(`Environment: ${process.env.NODE_ENV}`);
|
||||||
|
logger.info("✅ Session cleanup scheduled (every hour)");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start update scheduler
|
||||||
|
updateScheduler.start();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Store interval for cleanup on shutdown
|
||||||
|
app.locals.session_cleanup_interval = session_cleanup_interval;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ Failed to start server:", error.message);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
startServer();
|
||||||
|
|
||||||
|
module.exports = app;
|
||||||
|
|||||||
198
backend/src/services/settingsService.js
Normal file
198
backend/src/services/settingsService.js
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
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,
|
||||||
|
ignore_ssl_self_signed: 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;
|
||||||
|
if (process.env.ENABLE_LOGGING === "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;
|
||||||
|
if (process.env.ENABLE_LOGGING === "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(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (process.env.ENABLE_LOGGING === "true") {
|
||||||
|
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
|
||||||
|
};
|
||||||
295
backend/src/services/updateScheduler.js
Normal file
295
backend/src/services/updateScheduler.js
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
const { PrismaClient } = require("@prisma/client");
|
||||||
|
const { exec } = require("node:child_process");
|
||||||
|
const { promisify } = require("node: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();
|
||||||
|
const DEFAULT_GITHUB_REPO = "https://github.com/patchMon/patchmon";
|
||||||
|
const repoUrl = settings?.githubRepoUrl || DEFAULT_GITHUB_REPO;
|
||||||
|
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.7"; // fallback
|
||||||
|
try {
|
||||||
|
const packageJson = require("../../package.json");
|
||||||
|
if (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: {
|
||||||
|
last_update_check: new Date(),
|
||||||
|
update_available: isUpdateAvailable,
|
||||||
|
latest_version: 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: {
|
||||||
|
last_update_check: new Date(),
|
||||||
|
update_available: 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("node:fs").accessSync(path);
|
||||||
|
sshKeyPath = path;
|
||||||
|
break;
|
||||||
|
} catch {
|
||||||
|
// 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.7"; // fallback
|
||||||
|
try {
|
||||||
|
const packageJson = require("../../package.json");
|
||||||
|
if (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) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
if (
|
||||||
|
errorText.includes("rate limit") ||
|
||||||
|
errorText.includes("API rate limit")
|
||||||
|
) {
|
||||||
|
console.log(
|
||||||
|
"⚠️ GitHub API rate limit exceeded, skipping update check",
|
||||||
|
);
|
||||||
|
return null; // Return null instead of throwing error
|
||||||
|
}
|
||||||
|
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;
|
||||||
499
backend/src/utils/session_manager.js
Normal file
499
backend/src/utils/session_manager.js
Normal file
@@ -0,0 +1,499 @@
|
|||||||
|
const jwt = require("jsonwebtoken");
|
||||||
|
const crypto = require("node:crypto");
|
||||||
|
const { PrismaClient } = require("@prisma/client");
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Session Manager - Handles secure session management with inactivity timeout
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Configuration
|
||||||
|
if (!process.env.JWT_SECRET) {
|
||||||
|
throw new Error("JWT_SECRET environment variable is required");
|
||||||
|
}
|
||||||
|
const JWT_SECRET = process.env.JWT_SECRET;
|
||||||
|
const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || "1h";
|
||||||
|
const JWT_REFRESH_EXPIRES_IN = process.env.JWT_REFRESH_EXPIRES_IN || "7d";
|
||||||
|
const TFA_REMEMBER_ME_EXPIRES_IN =
|
||||||
|
process.env.TFA_REMEMBER_ME_EXPIRES_IN || "30d";
|
||||||
|
const TFA_MAX_REMEMBER_SESSIONS = parseInt(
|
||||||
|
process.env.TFA_MAX_REMEMBER_SESSIONS || "5",
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
const TFA_SUSPICIOUS_ACTIVITY_THRESHOLD = parseInt(
|
||||||
|
process.env.TFA_SUSPICIOUS_ACTIVITY_THRESHOLD || "3",
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
const INACTIVITY_TIMEOUT_MINUTES = parseInt(
|
||||||
|
process.env.SESSION_INACTIVITY_TIMEOUT_MINUTES || "30",
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate access token (short-lived)
|
||||||
|
*/
|
||||||
|
function generate_access_token(user_id, session_id) {
|
||||||
|
return jwt.sign({ userId: user_id, sessionId: session_id }, JWT_SECRET, {
|
||||||
|
expiresIn: JWT_EXPIRES_IN,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate refresh token (long-lived)
|
||||||
|
*/
|
||||||
|
function generate_refresh_token() {
|
||||||
|
return crypto.randomBytes(64).toString("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hash token for storage
|
||||||
|
*/
|
||||||
|
function hash_token(token) {
|
||||||
|
return crypto.createHash("sha256").update(token).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse expiration string to Date
|
||||||
|
*/
|
||||||
|
function parse_expiration(expiration_string) {
|
||||||
|
const match = expiration_string.match(/^(\d+)([smhd])$/);
|
||||||
|
if (!match) {
|
||||||
|
throw new Error("Invalid expiration format");
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = parseInt(match[1], 10);
|
||||||
|
const unit = match[2];
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
switch (unit) {
|
||||||
|
case "s":
|
||||||
|
return new Date(now.getTime() + value * 1000);
|
||||||
|
case "m":
|
||||||
|
return new Date(now.getTime() + value * 60 * 1000);
|
||||||
|
case "h":
|
||||||
|
return new Date(now.getTime() + value * 60 * 60 * 1000);
|
||||||
|
case "d":
|
||||||
|
return new Date(now.getTime() + value * 24 * 60 * 60 * 1000);
|
||||||
|
default:
|
||||||
|
throw new Error("Invalid time unit");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate device fingerprint from request data
|
||||||
|
*/
|
||||||
|
function generate_device_fingerprint(req) {
|
||||||
|
const components = [
|
||||||
|
req.get("user-agent") || "",
|
||||||
|
req.get("accept-language") || "",
|
||||||
|
req.get("accept-encoding") || "",
|
||||||
|
req.ip || "",
|
||||||
|
];
|
||||||
|
|
||||||
|
// Create a simple hash of device characteristics
|
||||||
|
const fingerprint = crypto
|
||||||
|
.createHash("sha256")
|
||||||
|
.update(components.join("|"))
|
||||||
|
.digest("hex")
|
||||||
|
.substring(0, 32); // Use first 32 chars for storage efficiency
|
||||||
|
|
||||||
|
return fingerprint;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check for suspicious activity patterns
|
||||||
|
*/
|
||||||
|
async function check_suspicious_activity(
|
||||||
|
user_id,
|
||||||
|
_ip_address,
|
||||||
|
_device_fingerprint,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
// Check for multiple sessions from different IPs in short time
|
||||||
|
const recent_sessions = await prisma.user_sessions.findMany({
|
||||||
|
where: {
|
||||||
|
user_id: user_id,
|
||||||
|
created_at: {
|
||||||
|
gte: new Date(Date.now() - 24 * 60 * 60 * 1000), // Last 24 hours
|
||||||
|
},
|
||||||
|
is_revoked: false,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
ip_address: true,
|
||||||
|
device_fingerprint: true,
|
||||||
|
created_at: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Count unique IPs and devices
|
||||||
|
const unique_ips = new Set(recent_sessions.map((s) => s.ip_address));
|
||||||
|
const unique_devices = new Set(
|
||||||
|
recent_sessions.map((s) => s.device_fingerprint),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Flag as suspicious if more than threshold different IPs or devices in 24h
|
||||||
|
if (
|
||||||
|
unique_ips.size > TFA_SUSPICIOUS_ACTIVITY_THRESHOLD ||
|
||||||
|
unique_devices.size > TFA_SUSPICIOUS_ACTIVITY_THRESHOLD
|
||||||
|
) {
|
||||||
|
console.warn(
|
||||||
|
`Suspicious activity detected for user ${user_id}: ${unique_ips.size} IPs, ${unique_devices.size} devices`,
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error checking suspicious activity:", error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new session for user
|
||||||
|
*/
|
||||||
|
async function create_session(
|
||||||
|
user_id,
|
||||||
|
ip_address,
|
||||||
|
user_agent,
|
||||||
|
remember_me = false,
|
||||||
|
req = null,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const session_id = crypto.randomUUID();
|
||||||
|
const refresh_token = generate_refresh_token();
|
||||||
|
const access_token = generate_access_token(user_id, session_id);
|
||||||
|
|
||||||
|
// Generate device fingerprint if request is available
|
||||||
|
const device_fingerprint = req ? generate_device_fingerprint(req) : null;
|
||||||
|
|
||||||
|
// Check for suspicious activity
|
||||||
|
if (device_fingerprint) {
|
||||||
|
const is_suspicious = await check_suspicious_activity(
|
||||||
|
user_id,
|
||||||
|
ip_address,
|
||||||
|
device_fingerprint,
|
||||||
|
);
|
||||||
|
if (is_suspicious) {
|
||||||
|
console.warn(
|
||||||
|
`Suspicious activity detected for user ${user_id}, session creation may be restricted`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check session limits for remember me
|
||||||
|
if (remember_me) {
|
||||||
|
const existing_remember_sessions = await prisma.user_sessions.count({
|
||||||
|
where: {
|
||||||
|
user_id: user_id,
|
||||||
|
tfa_remember_me: true,
|
||||||
|
is_revoked: false,
|
||||||
|
expires_at: { gt: new Date() },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Limit remember me sessions per user
|
||||||
|
if (existing_remember_sessions >= TFA_MAX_REMEMBER_SESSIONS) {
|
||||||
|
throw new Error(
|
||||||
|
"Maximum number of remembered devices reached. Please revoke an existing session first.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use longer expiration for remember me sessions
|
||||||
|
const expires_at = remember_me
|
||||||
|
? parse_expiration(TFA_REMEMBER_ME_EXPIRES_IN)
|
||||||
|
: parse_expiration(JWT_REFRESH_EXPIRES_IN);
|
||||||
|
|
||||||
|
// Calculate TFA bypass until date for remember me sessions
|
||||||
|
const tfa_bypass_until = remember_me
|
||||||
|
? parse_expiration(TFA_REMEMBER_ME_EXPIRES_IN)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Store session in database
|
||||||
|
await prisma.user_sessions.create({
|
||||||
|
data: {
|
||||||
|
id: session_id,
|
||||||
|
user_id: user_id,
|
||||||
|
refresh_token: hash_token(refresh_token),
|
||||||
|
access_token_hash: hash_token(access_token),
|
||||||
|
ip_address: ip_address || null,
|
||||||
|
user_agent: user_agent || null,
|
||||||
|
device_fingerprint: device_fingerprint,
|
||||||
|
last_login_ip: ip_address || null,
|
||||||
|
last_activity: new Date(),
|
||||||
|
expires_at: expires_at,
|
||||||
|
tfa_remember_me: remember_me,
|
||||||
|
tfa_bypass_until: tfa_bypass_until,
|
||||||
|
login_count: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
session_id,
|
||||||
|
access_token,
|
||||||
|
refresh_token,
|
||||||
|
expires_at,
|
||||||
|
tfa_bypass_until,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error creating session:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate session and check for inactivity timeout
|
||||||
|
*/
|
||||||
|
async function validate_session(session_id, access_token) {
|
||||||
|
try {
|
||||||
|
const session = await prisma.user_sessions.findUnique({
|
||||||
|
where: { id: session_id },
|
||||||
|
include: { users: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return { valid: false, reason: "Session not found" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if session is revoked
|
||||||
|
if (session.is_revoked) {
|
||||||
|
return { valid: false, reason: "Session revoked" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if session has expired
|
||||||
|
if (new Date() > session.expires_at) {
|
||||||
|
await revoke_session(session_id);
|
||||||
|
return { valid: false, reason: "Session expired" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for inactivity timeout
|
||||||
|
const inactivity_threshold = new Date(
|
||||||
|
Date.now() - INACTIVITY_TIMEOUT_MINUTES * 60 * 1000,
|
||||||
|
);
|
||||||
|
if (session.last_activity < inactivity_threshold) {
|
||||||
|
await revoke_session(session_id);
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
reason: "Session inactive",
|
||||||
|
message: `Session timed out after ${INACTIVITY_TIMEOUT_MINUTES} minutes of inactivity`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate access token hash (optional security check)
|
||||||
|
if (session.access_token_hash) {
|
||||||
|
const provided_hash = hash_token(access_token);
|
||||||
|
if (session.access_token_hash !== provided_hash) {
|
||||||
|
return { valid: false, reason: "Token mismatch" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user is still active
|
||||||
|
if (!session.users.is_active) {
|
||||||
|
await revoke_session(session_id);
|
||||||
|
return { valid: false, reason: "User inactive" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
valid: true,
|
||||||
|
session,
|
||||||
|
user: session.users,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error validating session:", error);
|
||||||
|
return { valid: false, reason: "Validation error" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update session activity timestamp
|
||||||
|
*/
|
||||||
|
async function update_session_activity(session_id) {
|
||||||
|
try {
|
||||||
|
await prisma.user_sessions.update({
|
||||||
|
where: { id: session_id },
|
||||||
|
data: { last_activity: new Date() },
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error updating session activity:", error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh access token using refresh token
|
||||||
|
*/
|
||||||
|
async function refresh_access_token(refresh_token) {
|
||||||
|
try {
|
||||||
|
const hashed_token = hash_token(refresh_token);
|
||||||
|
|
||||||
|
const session = await prisma.user_sessions.findUnique({
|
||||||
|
where: { refresh_token: hashed_token },
|
||||||
|
include: { users: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return { success: false, error: "Invalid refresh token" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate session
|
||||||
|
const validation = await validate_session(session.id, "");
|
||||||
|
if (!validation.valid) {
|
||||||
|
return { success: false, error: validation.reason };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate new access token
|
||||||
|
const new_access_token = generate_access_token(session.user_id, session.id);
|
||||||
|
|
||||||
|
// Update access token hash
|
||||||
|
await prisma.user_sessions.update({
|
||||||
|
where: { id: session.id },
|
||||||
|
data: {
|
||||||
|
access_token_hash: hash_token(new_access_token),
|
||||||
|
last_activity: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
access_token: new_access_token,
|
||||||
|
user: session.users,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error refreshing access token:", error);
|
||||||
|
return { success: false, error: "Token refresh failed" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revoke a session
|
||||||
|
*/
|
||||||
|
async function revoke_session(session_id) {
|
||||||
|
try {
|
||||||
|
await prisma.user_sessions.update({
|
||||||
|
where: { id: session_id },
|
||||||
|
data: { is_revoked: true },
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error revoking session:", error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revoke all sessions for a user
|
||||||
|
*/
|
||||||
|
async function revoke_all_user_sessions(user_id) {
|
||||||
|
try {
|
||||||
|
await prisma.user_sessions.updateMany({
|
||||||
|
where: { user_id: user_id },
|
||||||
|
data: { is_revoked: true },
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error revoking user sessions:", error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up expired sessions (should be run periodically)
|
||||||
|
*/
|
||||||
|
async function cleanup_expired_sessions() {
|
||||||
|
try {
|
||||||
|
const result = await prisma.user_sessions.deleteMany({
|
||||||
|
where: {
|
||||||
|
OR: [{ expires_at: { lt: new Date() } }, { is_revoked: true }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log(`Cleaned up ${result.count} expired sessions`);
|
||||||
|
return result.count;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error cleaning up sessions:", error);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get active sessions for a user
|
||||||
|
*/
|
||||||
|
async function get_user_sessions(user_id) {
|
||||||
|
try {
|
||||||
|
return await prisma.user_sessions.findMany({
|
||||||
|
where: {
|
||||||
|
user_id: user_id,
|
||||||
|
is_revoked: false,
|
||||||
|
expires_at: { gt: new Date() },
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
ip_address: true,
|
||||||
|
user_agent: true,
|
||||||
|
last_activity: true,
|
||||||
|
created_at: true,
|
||||||
|
expires_at: true,
|
||||||
|
tfa_remember_me: true,
|
||||||
|
tfa_bypass_until: true,
|
||||||
|
},
|
||||||
|
orderBy: { last_activity: "desc" },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error getting user sessions:", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if TFA is bypassed for a session
|
||||||
|
*/
|
||||||
|
async function is_tfa_bypassed(session_id) {
|
||||||
|
try {
|
||||||
|
const session = await prisma.user_sessions.findUnique({
|
||||||
|
where: { id: session_id },
|
||||||
|
select: {
|
||||||
|
tfa_remember_me: true,
|
||||||
|
tfa_bypass_until: true,
|
||||||
|
is_revoked: true,
|
||||||
|
expires_at: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if session is still valid
|
||||||
|
if (session.is_revoked || new Date() > session.expires_at) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if TFA is bypassed and still within bypass period
|
||||||
|
if (session.tfa_remember_me && session.tfa_bypass_until) {
|
||||||
|
return new Date() < session.tfa_bypass_until;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error checking TFA bypass:", error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
create_session,
|
||||||
|
validate_session,
|
||||||
|
update_session_activity,
|
||||||
|
refresh_access_token,
|
||||||
|
revoke_session,
|
||||||
|
revoke_all_user_sessions,
|
||||||
|
cleanup_expired_sessions,
|
||||||
|
get_user_sessions,
|
||||||
|
is_tfa_bypassed,
|
||||||
|
generate_device_fingerprint,
|
||||||
|
check_suspicious_activity,
|
||||||
|
generate_access_token,
|
||||||
|
INACTIVITY_TIMEOUT_MINUTES,
|
||||||
|
};
|
||||||
17
biome.json
Normal file
17
biome.json
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://biomejs.dev/schemas/2.2.4/schema.json",
|
||||||
|
"vcs": {
|
||||||
|
"enabled": true,
|
||||||
|
"clientKind": "git",
|
||||||
|
"useIgnoreFile": true
|
||||||
|
},
|
||||||
|
"formatter": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"linter": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"assist": {
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
dashboard.jpeg
Normal file
BIN
dashboard.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 104 KiB |
293
docker/README.md
Normal file
293
docker/README.md
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
# 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/patchmon/patchmon-backend](https://github.com/patchmon/patchmon.net/pkgs/container/patchmon-backend)
|
||||||
|
- **Frontend**: [ghcr.io/patchmon/patchmon-frontend](https://github.com/patchmon/patchmon.net/pkgs/container/patchmon-frontend)
|
||||||
|
|
||||||
|
### Tags
|
||||||
|
|
||||||
|
- `latest`: The latest stable release of PatchMon
|
||||||
|
- `x.y.z`: Full version tags (e.g. `1.2.3`) - Use this for exact version pinning.
|
||||||
|
- `x.y`: Minor version tags (e.g. `1.2`) - Use this to get the latest patch release in a minor version series.
|
||||||
|
- `x`: Major version tags (e.g. `1`) - Use this to get the latest minor and patch release in a major version series.
|
||||||
|
- `edge`: The latest development build with the most recent features and fixes. This tag may often be unstable and is intended only for testing and development purposes.
|
||||||
|
|
||||||
|
These tags are available for both backend and frontend images as they are versioned together.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Production Deployment
|
||||||
|
|
||||||
|
1. Download the [Docker Compose file](docker-compose.yml)
|
||||||
|
2. Set a database password in the file where it says:
|
||||||
|
```yaml
|
||||||
|
environment:
|
||||||
|
POSTGRES_PASSWORD: # CREATE A STRONG PASSWORD AND PUT IT HERE
|
||||||
|
```
|
||||||
|
3. Update the corresponding `DATABASE_URL` with your password in the backend service where it says:
|
||||||
|
```yaml
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql://patchmon_user:REPLACE_YOUR_POSTGRES_PASSWORD_HERE@database:5432/patchmon_db
|
||||||
|
```
|
||||||
|
4. Generate a strong JWT secret. You can do this like so:
|
||||||
|
```bash
|
||||||
|
openssl rand -hex 64
|
||||||
|
```
|
||||||
|
5. Set a JWT secret in the backend service where it says:
|
||||||
|
```yaml
|
||||||
|
environment:
|
||||||
|
JWT_SECRET: # CREATE A STRONG SECRET AND PUT IT HERE
|
||||||
|
```
|
||||||
|
6. Configure environment variables (see [Configuration](#configuration) section)
|
||||||
|
7. Start the application:
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
8. Access the application at `http://localhost:3000`
|
||||||
|
|
||||||
|
## Updating
|
||||||
|
|
||||||
|
By default, the compose file uses the `latest` tag for both backend and frontend images.
|
||||||
|
|
||||||
|
This means you can update PatchMon to the latest version as easily as:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d --pull
|
||||||
|
```
|
||||||
|
|
||||||
|
This command will:
|
||||||
|
- Pull the latest images from the registry
|
||||||
|
- Recreate containers with updated images
|
||||||
|
- Maintain your data and configuration
|
||||||
|
|
||||||
|
### Version-Specific Updates
|
||||||
|
|
||||||
|
If you'd like to pin your Docker deployment of PatchMon to a specific version, you can do this in the compose file.
|
||||||
|
|
||||||
|
When you do this, updating to a new version requires manually updating the image tags in the compose file yourself:
|
||||||
|
|
||||||
|
1. Update the image tags in `docker-compose.yml`. For example:
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
backend:
|
||||||
|
image: ghcr.io/patchmon/patchmon-backend:1.2.3 # Update version here
|
||||||
|
...
|
||||||
|
frontend:
|
||||||
|
image: ghcr.io/patchmon/patchmon-frontend:1.2.3 # Update version here
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Then run the update command:
|
||||||
|
```bash
|
||||||
|
docker compose up -d --pull
|
||||||
|
```
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> Check the [releases page](https://github.com/PatchMon/PatchMon/releases) for version-specific changes and migration notes.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
#### Database Service
|
||||||
|
|
||||||
|
| Variable | Description | Default |
|
||||||
|
| ------------------- | ----------------- | ---------------- |
|
||||||
|
| `POSTGRES_DB` | Database name | `patchmon_db` |
|
||||||
|
| `POSTGRES_USER` | Database user | `patchmon_user` |
|
||||||
|
| `POSTGRES_PASSWORD` | Database password | **MUST BE SET!** |
|
||||||
|
|
||||||
|
#### Backend Service
|
||||||
|
|
||||||
|
##### Database Configuration
|
||||||
|
|
||||||
|
| Variable | Description | Default |
|
||||||
|
| -------------------------- | ---------------------------------------------------- | ------------------------------------------------ |
|
||||||
|
| `DATABASE_URL` | PostgreSQL connection string | **MUST BE UPDATED WITH YOUR POSTGRES_PASSWORD!** |
|
||||||
|
| `PM_DB_CONN_MAX_ATTEMPTS` | Maximum database connection attempts | `30` |
|
||||||
|
| `PM_DB_CONN_WAIT_INTERVAL` | Wait interval between connection attempts in seconds | `2` |
|
||||||
|
|
||||||
|
##### Authentication & Security
|
||||||
|
|
||||||
|
| Variable | Description | Default |
|
||||||
|
| ------------------------------------ | --------------------------------------------------------- | ---------------- |
|
||||||
|
| `JWT_SECRET` | JWT signing secret - Generate with `openssl rand -hex 64` | **MUST BE SET!** |
|
||||||
|
| `JWT_EXPIRES_IN` | JWT token expiration time | `1h` |
|
||||||
|
| `JWT_REFRESH_EXPIRES_IN` | JWT refresh token expiration time | `7d` |
|
||||||
|
| `SESSION_INACTIVITY_TIMEOUT_MINUTES` | Session inactivity timeout in minutes | `30` |
|
||||||
|
| `DEFAULT_USER_ROLE` | Default role for new users | `user` |
|
||||||
|
|
||||||
|
##### Server & Network Configuration
|
||||||
|
|
||||||
|
| Variable | Description | Default |
|
||||||
|
| ----------------- | ----------------------------------------------------------------------------------------------- | ----------------------- |
|
||||||
|
| `PORT` | Backend API port | `3001` |
|
||||||
|
| `SERVER_PROTOCOL` | Frontend server protocol (`http` or `https`) | `http` |
|
||||||
|
| `SERVER_HOST` | Frontend server host | `localhost` |
|
||||||
|
| `SERVER_PORT` | Frontend server port | `3000` |
|
||||||
|
| `CORS_ORIGIN` | CORS origin URL | `http://localhost:3000` |
|
||||||
|
| `ENABLE_HSTS` | Enable HTTP Strict Transport Security | `true` |
|
||||||
|
| `TRUST_PROXY` | Trust proxy headers - See [Express.js docs](https://expressjs.com/en/guide/behind-proxies.html) | `true` |
|
||||||
|
|
||||||
|
##### Rate Limiting
|
||||||
|
|
||||||
|
| Variable | Description | Default |
|
||||||
|
| ---------------------------- | --------------------------------------------------- | -------- |
|
||||||
|
| `RATE_LIMIT_WINDOW_MS` | Rate limiting window in milliseconds | `900000` |
|
||||||
|
| `RATE_LIMIT_MAX` | Maximum requests per window | `5000` |
|
||||||
|
| `AUTH_RATE_LIMIT_WINDOW_MS` | Authentication rate limiting window in milliseconds | `600000` |
|
||||||
|
| `AUTH_RATE_LIMIT_MAX` | Maximum authentication requests per window | `500` |
|
||||||
|
| `AGENT_RATE_LIMIT_WINDOW_MS` | Agent API rate limiting window in milliseconds | `60000` |
|
||||||
|
| `AGENT_RATE_LIMIT_MAX` | Maximum agent requests per window | `1000` |
|
||||||
|
|
||||||
|
##### Logging
|
||||||
|
|
||||||
|
| Variable | Description | Default |
|
||||||
|
| ---------------- | ------------------------------------------------ | ------- |
|
||||||
|
| `LOG_LEVEL` | Logging level (`debug`, `info`, `warn`, `error`) | `info` |
|
||||||
|
| `ENABLE_LOGGING` | Enable application logging | `true` |
|
||||||
|
|
||||||
|
#### Frontend Service
|
||||||
|
|
||||||
|
| Variable | Description | Default |
|
||||||
|
| -------------- | ------------------------ | --------- |
|
||||||
|
| `BACKEND_HOST` | Backend service hostname | `backend` |
|
||||||
|
| `BACKEND_PORT` | Backend service port | `3001` |
|
||||||
|
|
||||||
|
### Volumes
|
||||||
|
|
||||||
|
The compose file creates two Docker volumes:
|
||||||
|
|
||||||
|
* `postgres_data`: PostgreSQL's data directory.
|
||||||
|
* `agent_files`: PatchMon's agent files.
|
||||||
|
|
||||||
|
If you wish to bind either if their respective container paths to a host path rather than a Docker volume, you can do so in the Docker Compose file.
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> The backend container runs as user & group ID 1000. If you plan to re-bind the agent files directory, ensure that the same user and/or group ID has permission to write to the host path to which it's bound.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 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/PatchMon/PatchMon.git
|
||||||
|
cd patchmon.net
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Start development environment:
|
||||||
|
```bash
|
||||||
|
docker compose -f docker/docker-compose.dev.yml up
|
||||||
|
```
|
||||||
|
_See [Development Commands](#development-commands) for more options._
|
||||||
|
|
||||||
|
3. Access the application:
|
||||||
|
- Frontend: `http://localhost:3000`
|
||||||
|
- Backend API: `http://localhost:3001`
|
||||||
|
- Database: `localhost:5432`
|
||||||
|
|
||||||
|
## Development Docker Compose
|
||||||
|
|
||||||
|
The development compose file (`docker/docker-compose.dev.yml`):
|
||||||
|
- Builds images locally from source using development targets
|
||||||
|
- Enables hot reload with Docker Compose watch functionality
|
||||||
|
- Exposes database and backend ports for testing and development
|
||||||
|
- Mounts source code directly into containers for live development
|
||||||
|
- Supports debugging with enhanced logging
|
||||||
|
|
||||||
|
## Building Images Locally
|
||||||
|
|
||||||
|
Both Dockerfiles use multi-stage builds with separate development and production targets:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build development images
|
||||||
|
docker build -f docker/backend.Dockerfile --target development -t patchmon-backend:dev .
|
||||||
|
docker build -f docker/frontend.Dockerfile --target development -t patchmon-frontend:dev .
|
||||||
|
|
||||||
|
# Build production images (default target)
|
||||||
|
docker build -f docker/backend.Dockerfile -t patchmon-backend:latest .
|
||||||
|
docker build -f docker/frontend.Dockerfile -t patchmon-frontend:latest .
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development Commands
|
||||||
|
|
||||||
|
### Hot Reload Development
|
||||||
|
```bash
|
||||||
|
# Attached, live log output, services stopped on Ctrl+C
|
||||||
|
docker compose -f docker/docker-compose.dev.yml up
|
||||||
|
|
||||||
|
# Attached with Docker Compose watch for hot reload
|
||||||
|
docker compose -f docker/docker-compose.dev.yml up --watch
|
||||||
|
|
||||||
|
# Detached
|
||||||
|
docker compose -f docker/docker-compose.dev.yml up -d
|
||||||
|
|
||||||
|
# Quiet, no log output, with Docker Compose watch for hot reload
|
||||||
|
docker compose -f docker/docker-compose.dev.yml watch
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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 Ports
|
||||||
|
The development setup exposes additional ports for debugging:
|
||||||
|
- **Database**: `5432` - Direct PostgreSQL access
|
||||||
|
- **Backend**: `3001` - API server with development features
|
||||||
|
- **Frontend**: `3000` - React development server with hot reload
|
||||||
|
|
||||||
|
## Development Workflow
|
||||||
|
|
||||||
|
1. **Initial Setup**: Clone repository and start development environment
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/PatchMon/PatchMon.git
|
||||||
|
cd patchmon.net
|
||||||
|
docker compose -f docker/docker-compose.dev.yml up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Hot Reload Development**: Use Docker Compose watch for automatic reload
|
||||||
|
```bash
|
||||||
|
docker compose -f docker/docker-compose.dev.yml up --watch --build
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Code Changes**:
|
||||||
|
- **Frontend/Backend Source**: Files are synced automatically with watch mode
|
||||||
|
- **Package.json Changes**: Triggers automatic service rebuild
|
||||||
|
- **Prisma Schema Changes**: Backend service restarts automatically
|
||||||
|
|
||||||
|
4. **Database Access**: Connect database client directly to `localhost:5432`
|
||||||
|
|
||||||
|
5. **Debug**: If started with `docker compose [...] up -d` or `docker compose [...] watch`, check logs manually:
|
||||||
|
```bash
|
||||||
|
docker compose -f docker/docker-compose.dev.yml logs -f
|
||||||
|
```
|
||||||
|
Otherwise logs are shown automatically in attached modes (`up`, `up --watch`).
|
||||||
|
|
||||||
|
### Features in Development Mode
|
||||||
|
|
||||||
|
- **Hot Reload**: Automatic code synchronization and service restarts
|
||||||
|
- **Enhanced Logging**: Detailed logs for debugging
|
||||||
|
- **Direct Access**: Exposed ports for database and API debugging
|
||||||
|
- **Health Checks**: Built-in health monitoring for services
|
||||||
|
- **Volume Persistence**: Development data persists between restarts
|
||||||
89
docker/backend.Dockerfile
Normal file
89
docker/backend.Dockerfile
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
# Development target
|
||||||
|
FROM node:lts-alpine AS development
|
||||||
|
|
||||||
|
ENV NODE_ENV=development \
|
||||||
|
NPM_CONFIG_UPDATE_NOTIFIER=false \
|
||||||
|
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 --chown=node:node package*.json ./
|
||||||
|
COPY --chown=node:node backend/ ./backend/
|
||||||
|
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
|
||||||
|
|
||||||
|
RUN npm ci --ignore-scripts && npx prisma generate
|
||||||
|
|
||||||
|
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"]
|
||||||
|
|
||||||
|
# Builder stage for production
|
||||||
|
FROM node:lts-alpine AS builder
|
||||||
|
|
||||||
|
RUN apk add --no-cache openssl
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY --chown=node:node package*.json ./
|
||||||
|
COPY --chown=node:node backend/ ./backend/
|
||||||
|
|
||||||
|
WORKDIR /app/backend
|
||||||
|
|
||||||
|
RUN npm ci --ignore-scripts &&\
|
||||||
|
npx prisma generate &&\
|
||||||
|
npm prune --omit=dev &&\
|
||||||
|
npm cache clean --force
|
||||||
|
|
||||||
|
# Production stage
|
||||||
|
FROM node:lts-alpine
|
||||||
|
|
||||||
|
ENV NODE_ENV=production \
|
||||||
|
NPM_CONFIG_UPDATE_NOTIFIER=false \
|
||||||
|
ENABLE_LOGGING=true \
|
||||||
|
LOG_LEVEL=info \
|
||||||
|
PM_LOG_TO_CONSOLE=true \
|
||||||
|
PORT=3001 \
|
||||||
|
JWT_EXPIRES_IN=1h \
|
||||||
|
JWT_REFRESH_EXPIRES_IN=7d \
|
||||||
|
SESSION_INACTIVITY_TIMEOUT_MINUTES=30
|
||||||
|
|
||||||
|
RUN apk add --no-cache openssl tini curl
|
||||||
|
|
||||||
|
USER node
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY --from=builder /app/backend ./backend
|
||||||
|
COPY --from=builder /app/node_modules ./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
|
||||||
33
docker/backend.docker-entrypoint.sh
Executable file
33
docker/backend.docker-entrypoint.sh
Executable file
@@ -0,0 +1,33 @@
|
|||||||
|
#!/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 and no .sh files are present
|
||||||
|
if [ -d "/app/agents" ] && [ -z "$(find /app/agents -maxdepth 1 -type f -name '*.sh' | head -n 1)" ]; 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 (${NODE_ENV:-production})..."
|
||||||
|
|
||||||
|
log "Running database migrations..."
|
||||||
|
npx prisma migrate deploy
|
||||||
|
|
||||||
|
log "Starting application..."
|
||||||
|
if [ "${NODE_ENV}" = "development" ]; then
|
||||||
|
exec npm run dev
|
||||||
|
else
|
||||||
|
exec npm start
|
||||||
|
fi
|
||||||
80
docker/docker-compose.dev.yml
Normal file
80
docker/docker-compose.dev.yml
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
name: patchmon-dev
|
||||||
|
|
||||||
|
services:
|
||||||
|
database:
|
||||||
|
image: postgres:18-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: patchmon_db
|
||||||
|
POSTGRES_USER: patchmon_user
|
||||||
|
POSTGRES_PASSWORD: 1NS3CU6E_DEV_D8_PASSW0RD
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
volumes:
|
||||||
|
- ./compose_dev_data/db:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U patchmon_user -d patchmon_db"]
|
||||||
|
interval: 3s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 7
|
||||||
|
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: docker/backend.Dockerfile
|
||||||
|
target: development
|
||||||
|
tags: [patchmon-backend:dev]
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
NODE_ENV: development
|
||||||
|
LOG_LEVEL: info
|
||||||
|
DATABASE_URL: postgresql://patchmon_user:1NS3CU6E_DEV_D8_PASSW0RD@database:5432/patchmon_db
|
||||||
|
JWT_SECRET: INS3CURE_DEV_7WT_5ECR3T
|
||||||
|
SERVER_PROTOCOL: http
|
||||||
|
SERVER_HOST: localhost
|
||||||
|
SERVER_PORT: 3000
|
||||||
|
CORS_ORIGIN: http://localhost:3000
|
||||||
|
ports:
|
||||||
|
- "3001:3001"
|
||||||
|
volumes:
|
||||||
|
- ./compose_dev_data/agents:/app/agents
|
||||||
|
depends_on:
|
||||||
|
database:
|
||||||
|
condition: service_healthy
|
||||||
|
develop:
|
||||||
|
watch:
|
||||||
|
- action: sync
|
||||||
|
path: ../backend/src
|
||||||
|
target: /app/backend/src
|
||||||
|
ignore:
|
||||||
|
- node_modules/
|
||||||
|
- action: sync
|
||||||
|
path: ../backend/prisma
|
||||||
|
target: /app/backend/prisma
|
||||||
|
- action: rebuild
|
||||||
|
path: ../backend/package.json
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: docker/frontend.Dockerfile
|
||||||
|
target: development
|
||||||
|
tags: [patchmon-frontend:dev]
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
BACKEND_HOST: backend
|
||||||
|
BACKEND_PORT: 3001
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
depends_on:
|
||||||
|
backend:
|
||||||
|
condition: service_healthy
|
||||||
|
develop:
|
||||||
|
watch:
|
||||||
|
- action: sync
|
||||||
|
path: ../frontend/src
|
||||||
|
target: /app/frontend/src
|
||||||
|
ignore:
|
||||||
|
- node_modules/
|
||||||
|
- action: rebuild
|
||||||
|
path: ../frontend/package.json
|
||||||
48
docker/docker-compose.yml
Normal file
48
docker/docker-compose.yml
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
name: patchmon
|
||||||
|
|
||||||
|
services:
|
||||||
|
database:
|
||||||
|
image: postgres:18-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: patchmon_db
|
||||||
|
POSTGRES_USER: patchmon_user
|
||||||
|
POSTGRES_PASSWORD: # CREATE A STRONG PASSWORD AND PUT IT HERE
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U patchmon_user -d patchmon_db"]
|
||||||
|
interval: 3s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 7
|
||||||
|
|
||||||
|
backend:
|
||||||
|
image: ghcr.io/patchmon/patchmon-backend:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
# See PatchMon Docker README for additional environment variables and configuration instructions
|
||||||
|
environment:
|
||||||
|
LOG_LEVEL: info
|
||||||
|
DATABASE_URL: postgresql://patchmon_user:REPLACE_YOUR_POSTGRES_PASSWORD_HERE@database:5432/patchmon_db
|
||||||
|
JWT_SECRET: # CREATE A STRONG SECRET AND PUT IT HERE - Generate with 'openssl rand -hex 64'
|
||||||
|
SERVER_PROTOCOL: http
|
||||||
|
SERVER_HOST: localhost
|
||||||
|
SERVER_PORT: 3000
|
||||||
|
CORS_ORIGIN: http://localhost:3000
|
||||||
|
volumes:
|
||||||
|
- agent_files:/app/agents
|
||||||
|
depends_on:
|
||||||
|
database:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
image: ghcr.io/patchmon/patchmon-frontend:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
depends_on:
|
||||||
|
backend:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
agent_files:
|
||||||
42
docker/frontend.Dockerfile
Normal file
42
docker/frontend.Dockerfile
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# Development target
|
||||||
|
FROM node:lts-alpine AS development
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
COPY frontend/ ./frontend/
|
||||||
|
|
||||||
|
RUN npm ci --ignore-scripts
|
||||||
|
|
||||||
|
WORKDIR /app/frontend
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "3000"]
|
||||||
|
|
||||||
|
# Builder stage for production
|
||||||
|
FROM node:lts-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
COPY frontend/package*.json ./frontend/
|
||||||
|
|
||||||
|
RUN npm ci --ignore-scripts
|
||||||
|
|
||||||
|
COPY frontend/ ./frontend/
|
||||||
|
|
||||||
|
RUN npm run build:frontend
|
||||||
|
|
||||||
|
# Production stage
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>PatchMon - Linux Patch Monitoring Dashboard</title>
|
<title>PatchMon - Linux Patch Monitoring Dashboard</title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
|||||||
@@ -1,43 +1,44 @@
|
|||||||
{
|
{
|
||||||
"name": "patchmon-frontend",
|
"name": "patchmon-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.2.4",
|
"version": "1.2.7",
|
||||||
"type": "module",
|
"license": "AGPL-3.0",
|
||||||
"scripts": {
|
"type": "module",
|
||||||
"dev": "vite",
|
"scripts": {
|
||||||
"build": "vite build",
|
"dev": "vite",
|
||||||
"lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0",
|
"build": "vite build",
|
||||||
"preview": "vite preview"
|
"lint": "biome check .",
|
||||||
},
|
"preview": "vite preview"
|
||||||
"dependencies": {
|
},
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"dependencies": {
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@tanstack/react-query": "^5.87.4",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"axios": "^1.6.2",
|
"@tanstack/react-query": "^5.87.4",
|
||||||
"chart.js": "^4.4.0",
|
"axios": "^1.7.9",
|
||||||
"clsx": "^2.0.0",
|
"chart.js": "^4.4.7",
|
||||||
"date-fns": "^2.30.0",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^0.294.0",
|
"cors": "^2.8.5",
|
||||||
"react": "^18.2.0",
|
"date-fns": "^4.1.0",
|
||||||
"react-chartjs-2": "^5.2.0",
|
"express": "^4.21.2",
|
||||||
"react-dom": "^18.2.0",
|
"http-proxy-middleware": "^3.0.3",
|
||||||
"react-router-dom": "^6.20.1"
|
"lucide-react": "^0.468.0",
|
||||||
},
|
"react": "^18.3.1",
|
||||||
"devDependencies": {
|
"react-chartjs-2": "^5.2.0",
|
||||||
"@types/react": "^18.2.37",
|
"react-dom": "^18.3.1",
|
||||||
"@types/react-dom": "^18.2.15",
|
"react-icons": "^5.5.0",
|
||||||
"@vitejs/plugin-react": "^4.3.3",
|
"react-router-dom": "^6.30.1"
|
||||||
"autoprefixer": "^10.4.16",
|
},
|
||||||
"eslint": "^8.53.0",
|
"devDependencies": {
|
||||||
"eslint-plugin-react": "^7.33.2",
|
"@types/react": "^18.3.14",
|
||||||
"eslint-plugin-react-hooks": "^4.6.0",
|
"@types/react-dom": "^18.3.1",
|
||||||
"eslint-plugin-react-refresh": "^0.4.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"postcss": "^8.4.32",
|
"autoprefixer": "^10.4.20",
|
||||||
"tailwindcss": "^3.3.6",
|
"postcss": "^8.5.6",
|
||||||
"vite": "^7.1.5"
|
"tailwindcss": "^3.4.17",
|
||||||
},
|
"vite": "^7.1.5"
|
||||||
"overrides": {
|
},
|
||||||
"esbuild": "^0.24.4"
|
"overrides": {
|
||||||
}
|
"esbuild": "^0.25.10"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
export default {
|
export default {
|
||||||
plugins: {
|
plugins: {
|
||||||
tailwindcss: {},
|
tailwindcss: {},
|
||||||
autoprefixer: {},
|
autoprefixer: {},
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|||||||
1
frontend/public/assets/favicon.svg
Normal file
1
frontend/public/assets/favicon.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="500" zoomAndPan="magnify" viewBox="0 0 375 374.999991" height="500" preserveAspectRatio="xMidYMid meet" version="1.0"><defs><g/><clipPath id="d62632d413"><path d="M 29 28 L 304 28 L 304 350 L 29 350 Z M 29 28 " clip-rule="nonzero"/></clipPath><clipPath id="ecc8b4d8ed"><path d="M 187.496094 -39.996094 L 416.601562 189.105469 L 187.496094 418.207031 L -41.605469 189.105469 Z M 187.496094 -39.996094 " clip-rule="nonzero"/></clipPath><clipPath id="3016db942f"><path d="M 187.496094 -39.996094 L 416.601562 189.105469 L 187.496094 418.207031 L -41.605469 189.105469 Z M 187.496094 -39.996094 " clip-rule="nonzero"/></clipPath><clipPath id="029f8ae6a8"><path d="M 29 28 L 304 28 L 304 350 L 29 350 Z M 29 28 " clip-rule="nonzero"/></clipPath><clipPath id="2d374b5e76"><path d="M 187.496094 -39.996094 L 416.601562 189.105469 L 187.496094 418.207031 L -41.605469 189.105469 Z M 187.496094 -39.996094 " clip-rule="nonzero"/></clipPath><clipPath id="544d823606"><path d="M 187.496094 -39.996094 L 416.601562 189.105469 L 187.496094 418.207031 L -41.605469 189.105469 Z M 187.496094 -39.996094 " clip-rule="nonzero"/></clipPath><clipPath id="b88a276116"><path d="M 187.496094 -39.996094 L 416.601562 189.105469 L 187.496094 418.207031 L -41.605469 189.105469 Z M 187.496094 -39.996094 " clip-rule="nonzero"/></clipPath><clipPath id="98c26e11a4"><rect x="0" width="103" y="0" height="208"/></clipPath></defs><g clip-path="url(#d62632d413)"><g clip-path="url(#ecc8b4d8ed)"><g clip-path="url(#3016db942f)"><path fill="#ff751f" d="M 303.214844 302.761719 C 280.765625 325.214844 252.160156 340.503906 221.015625 346.699219 C 189.875 352.890625 157.59375 349.714844 128.261719 337.5625 C 98.925781 325.410156 73.851562 304.835938 56.210938 278.433594 C 38.570312 252.03125 29.15625 220.992188 29.15625 189.242188 C 29.15625 157.488281 38.570312 126.449219 56.210938 100.050781 C 73.851562 73.648438 98.925781 53.070312 128.261719 40.921875 C 157.59375 28.769531 189.875 25.589844 221.015625 31.785156 C 252.160156 37.980469 280.765625 53.269531 303.214844 75.722656 L 189.695312 189.242188 Z M 303.214844 302.761719 " fill-opacity="1" fill-rule="nonzero"/></g></g></g><g clip-path="url(#029f8ae6a8)"><g clip-path="url(#2d374b5e76)"><g clip-path="url(#544d823606)"><g clip-path="url(#b88a276116)"><path fill="#61b33a" d="M 303.144531 302.550781 C 280.707031 324.988281 252.117188 340.269531 220.996094 346.460938 C 189.875 352.652344 157.613281 349.472656 128.296875 337.332031 C 98.980469 325.1875 73.921875 304.621094 56.292969 278.238281 C 38.664062 251.851562 29.253906 220.832031 29.253906 189.101562 C 29.253906 157.367188 38.664062 126.347656 56.292969 99.964844 C 73.921875 73.578125 98.980469 53.015625 128.296875 40.871094 C 157.613281 28.726562 189.875 25.550781 220.996094 31.742188 C 252.117188 37.929688 280.707031 53.210938 303.144531 75.652344 L 189.695312 189.101562 Z M 303.144531 302.550781 " fill-opacity="1" fill-rule="nonzero"/></g></g></g></g><g transform="matrix(1, 0, 0, 1, 136, 0)"><g clip-path="url(#98c26e11a4)"><g fill="#ff751f" fill-opacity="1"><g transform="translate(0.457164, 116.403543)"><g><path d="M 19.734375 -18.71875 C 19.734375 -21.664062 20.015625 -24.441406 20.578125 -27.046875 C 21.148438 -29.660156 22.0625 -32.210938 23.3125 -34.703125 C 24.5625 -37.203125 26.207031 -39.359375 28.25 -41.171875 C 33.6875 -47.066406 41.285156 -50.015625 51.046875 -50.015625 C 59.210938 -50.015625 66.46875 -46.953125 72.8125 -40.828125 C 79.164062 -34.703125 82.34375 -27.332031 82.34375 -18.71875 C 82.34375 -9.414062 79.28125 -1.925781 73.15625 3.75 C 67.257812 9.644531 59.890625 12.59375 51.046875 12.59375 C 42.648438 12.59375 35.332031 9.472656 29.09375 3.234375 C 22.851562 -3.003906 19.734375 -10.320312 19.734375 -18.71875 Z M 19.734375 -18.71875 "/></g></g></g></g></g></svg>
|
||||||
|
After Width: | Height: | Size: 3.8 KiB |
BIN
frontend/public/assets/logo_dark.png
Normal file
BIN
frontend/public/assets/logo_dark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
BIN
frontend/public/assets/logo_light.png
Normal file
BIN
frontend/public/assets/logo_light.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
50
frontend/server.js
Normal file
50
frontend/server.js
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import cors from "cors";
|
||||||
|
import express from "express";
|
||||||
|
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,117 +1,384 @@
|
|||||||
import React from 'react'
|
import { lazy, Suspense } from "react";
|
||||||
import { Routes, Route } from 'react-router-dom'
|
import { Route, Routes } from "react-router-dom";
|
||||||
import { AuthProvider } from './contexts/AuthContext'
|
import FirstTimeAdminSetup from "./components/FirstTimeAdminSetup";
|
||||||
import { ThemeProvider } from './contexts/ThemeContext'
|
import Layout from "./components/Layout";
|
||||||
import ProtectedRoute from './components/ProtectedRoute'
|
import LogoProvider from "./components/LogoProvider";
|
||||||
import Layout from './components/Layout'
|
import ProtectedRoute from "./components/ProtectedRoute";
|
||||||
import Login from './pages/Login'
|
import SettingsLayout from "./components/SettingsLayout";
|
||||||
import Dashboard from './pages/Dashboard'
|
import { isAuthPhase } from "./constants/authPhases";
|
||||||
import Hosts from './pages/Hosts'
|
import { AuthProvider, useAuth } from "./contexts/AuthContext";
|
||||||
import HostGroups from './pages/HostGroups'
|
import { ThemeProvider } from "./contexts/ThemeContext";
|
||||||
import Packages from './pages/Packages'
|
import { UpdateNotificationProvider } from "./contexts/UpdateNotificationContext";
|
||||||
import Repositories from './pages/Repositories'
|
|
||||||
import RepositoryDetail from './pages/RepositoryDetail'
|
|
||||||
import Users from './pages/Users'
|
|
||||||
import Permissions from './pages/Permissions'
|
|
||||||
import Settings from './pages/Settings'
|
|
||||||
import Profile from './pages/Profile'
|
|
||||||
import HostDetail from './pages/HostDetail'
|
|
||||||
import PackageDetail from './pages/PackageDetail'
|
|
||||||
|
|
||||||
function App() {
|
// Lazy load pages
|
||||||
return (
|
const Dashboard = lazy(() => import("./pages/Dashboard"));
|
||||||
<ThemeProvider>
|
const HostDetail = lazy(() => import("./pages/HostDetail"));
|
||||||
<AuthProvider>
|
const Hosts = lazy(() => import("./pages/Hosts"));
|
||||||
<Routes>
|
const Login = lazy(() => import("./pages/Login"));
|
||||||
<Route path="/login" element={<Login />} />
|
const PackageDetail = lazy(() => import("./pages/PackageDetail"));
|
||||||
<Route path="/" element={
|
const Packages = lazy(() => import("./pages/Packages"));
|
||||||
<ProtectedRoute requirePermission="canViewDashboard">
|
const Profile = lazy(() => import("./pages/Profile"));
|
||||||
<Layout>
|
const Queue = lazy(() => import("./pages/Queue"));
|
||||||
<Dashboard />
|
const Repositories = lazy(() => import("./pages/Repositories"));
|
||||||
</Layout>
|
const RepositoryDetail = lazy(() => import("./pages/RepositoryDetail"));
|
||||||
</ProtectedRoute>
|
const AlertChannels = lazy(() => import("./pages/settings/AlertChannels"));
|
||||||
} />
|
const Integrations = lazy(() => import("./pages/settings/Integrations"));
|
||||||
<Route path="/hosts" element={
|
const Notifications = lazy(() => import("./pages/settings/Notifications"));
|
||||||
<ProtectedRoute requirePermission="canViewHosts">
|
const PatchManagement = lazy(() => import("./pages/settings/PatchManagement"));
|
||||||
<Layout>
|
const SettingsAgentConfig = lazy(
|
||||||
<Hosts />
|
() => import("./pages/settings/SettingsAgentConfig"),
|
||||||
</Layout>
|
);
|
||||||
</ProtectedRoute>
|
const SettingsHostGroups = lazy(
|
||||||
} />
|
() => import("./pages/settings/SettingsHostGroups"),
|
||||||
<Route path="/hosts/:hostId" element={
|
);
|
||||||
<ProtectedRoute requirePermission="canViewHosts">
|
const SettingsServerConfig = lazy(
|
||||||
<Layout>
|
() => import("./pages/settings/SettingsServerConfig"),
|
||||||
<HostDetail />
|
);
|
||||||
</Layout>
|
const SettingsUsers = lazy(() => import("./pages/settings/SettingsUsers"));
|
||||||
</ProtectedRoute>
|
|
||||||
} />
|
// Loading fallback component
|
||||||
<Route path="/host-groups" element={
|
const LoadingFallback = () => (
|
||||||
<ProtectedRoute requirePermission="canManageHosts">
|
<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">
|
||||||
<Layout>
|
<div className="text-center">
|
||||||
<HostGroups />
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto mb-4"></div>
|
||||||
</Layout>
|
<p className="text-secondary-600 dark:text-secondary-300">Loading...</p>
|
||||||
</ProtectedRoute>
|
</div>
|
||||||
} />
|
</div>
|
||||||
<Route path="/packages" element={
|
);
|
||||||
<ProtectedRoute requirePermission="canViewPackages">
|
|
||||||
<Layout>
|
function AppRoutes() {
|
||||||
<Packages />
|
const { needsFirstTimeSetup, authPhase, isAuthenticated } = useAuth();
|
||||||
</Layout>
|
const isAuth = isAuthenticated(); // Call the function to get boolean value
|
||||||
</ProtectedRoute>
|
|
||||||
} />
|
// Show loading while checking setup or initialising
|
||||||
<Route path="/repositories" element={
|
if (
|
||||||
<ProtectedRoute requirePermission="canViewHosts">
|
isAuthPhase.initialising(authPhase) ||
|
||||||
<Layout>
|
isAuthPhase.checkingSetup(authPhase)
|
||||||
<Repositories />
|
) {
|
||||||
</Layout>
|
return (
|
||||||
</ProtectedRoute>
|
<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">
|
||||||
<Route path="/repositories/:repositoryId" element={
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto mb-4"></div>
|
||||||
<ProtectedRoute requirePermission="canViewHosts">
|
<p className="text-secondary-600 dark:text-secondary-300">
|
||||||
<Layout>
|
Checking system status...
|
||||||
<RepositoryDetail />
|
</p>
|
||||||
</Layout>
|
</div>
|
||||||
</ProtectedRoute>
|
</div>
|
||||||
} />
|
);
|
||||||
<Route path="/users" element={
|
}
|
||||||
<ProtectedRoute requirePermission="canViewUsers">
|
|
||||||
<Layout>
|
// Show first-time setup if no admin users exist
|
||||||
<Users />
|
if (needsFirstTimeSetup && !isAuth) {
|
||||||
</Layout>
|
return <FirstTimeAdminSetup />;
|
||||||
</ProtectedRoute>
|
}
|
||||||
} />
|
|
||||||
<Route path="/permissions" element={
|
return (
|
||||||
<ProtectedRoute requirePermission="canManageSettings">
|
<Suspense fallback={<LoadingFallback />}>
|
||||||
<Layout>
|
<Routes>
|
||||||
<Permissions />
|
<Route path="/login" element={<Login />} />
|
||||||
</Layout>
|
<Route
|
||||||
</ProtectedRoute>
|
path="/"
|
||||||
} />
|
element={
|
||||||
<Route path="/settings" element={
|
<ProtectedRoute requirePermission="can_view_dashboard">
|
||||||
<ProtectedRoute requirePermission="canManageSettings">
|
<Layout>
|
||||||
<Layout>
|
<Dashboard />
|
||||||
<Settings />
|
</Layout>
|
||||||
</Layout>
|
</ProtectedRoute>
|
||||||
</ProtectedRoute>
|
}
|
||||||
} />
|
/>
|
||||||
<Route path="/profile" element={
|
<Route
|
||||||
<ProtectedRoute>
|
path="/hosts"
|
||||||
<Layout>
|
element={
|
||||||
<Profile />
|
<ProtectedRoute requirePermission="can_view_hosts">
|
||||||
</Layout>
|
<Layout>
|
||||||
</ProtectedRoute>
|
<Hosts />
|
||||||
} />
|
</Layout>
|
||||||
<Route path="/packages/:packageId" element={
|
</ProtectedRoute>
|
||||||
<ProtectedRoute requirePermission="canViewPackages">
|
}
|
||||||
<Layout>
|
/>
|
||||||
<PackageDetail />
|
<Route
|
||||||
</Layout>
|
path="/hosts/:hostId"
|
||||||
</ProtectedRoute>
|
element={
|
||||||
} />
|
<ProtectedRoute requirePermission="can_view_hosts">
|
||||||
</Routes>
|
<Layout>
|
||||||
</AuthProvider>
|
<HostDetail />
|
||||||
</ThemeProvider>
|
</Layout>
|
||||||
)
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/packages"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requirePermission="can_view_packages">
|
||||||
|
<Layout>
|
||||||
|
<Packages />
|
||||||
|
</Layout>
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/repositories"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requirePermission="can_view_hosts">
|
||||||
|
<Layout>
|
||||||
|
<Repositories />
|
||||||
|
</Layout>
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/repositories/:repositoryId"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requirePermission="can_view_hosts">
|
||||||
|
<Layout>
|
||||||
|
<RepositoryDetail />
|
||||||
|
</Layout>
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/queue"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requirePermission="can_view_hosts">
|
||||||
|
<Layout>
|
||||||
|
<Queue />
|
||||||
|
</Layout>
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/users"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requirePermission="can_view_users">
|
||||||
|
<Layout>
|
||||||
|
<SettingsUsers />
|
||||||
|
</Layout>
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/permissions"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requirePermission="can_manage_settings">
|
||||||
|
<Layout>
|
||||||
|
<SettingsUsers />
|
||||||
|
</Layout>
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/settings"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requirePermission="can_manage_settings">
|
||||||
|
<Layout>
|
||||||
|
<SettingsServerConfig />
|
||||||
|
</Layout>
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/settings/users"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requirePermission="can_view_users">
|
||||||
|
<Layout>
|
||||||
|
<SettingsUsers />
|
||||||
|
</Layout>
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/settings/roles"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requirePermission="can_manage_settings">
|
||||||
|
<Layout>
|
||||||
|
<SettingsUsers />
|
||||||
|
</Layout>
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/settings/profile"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<Layout>
|
||||||
|
<SettingsLayout>
|
||||||
|
<Profile />
|
||||||
|
</SettingsLayout>
|
||||||
|
</Layout>
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/settings/host-groups"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requirePermission="can_manage_settings">
|
||||||
|
<Layout>
|
||||||
|
<SettingsHostGroups />
|
||||||
|
</Layout>
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/settings/notifications"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requirePermission="can_manage_settings">
|
||||||
|
<Layout>
|
||||||
|
<SettingsLayout>
|
||||||
|
<Notifications />
|
||||||
|
</SettingsLayout>
|
||||||
|
</Layout>
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/settings/agent-config"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requirePermission="can_manage_settings">
|
||||||
|
<Layout>
|
||||||
|
<SettingsAgentConfig />
|
||||||
|
</Layout>
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/settings/agent-config/management"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requirePermission="can_manage_settings">
|
||||||
|
<Layout>
|
||||||
|
<SettingsAgentConfig />
|
||||||
|
</Layout>
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/settings/server-config"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requirePermission="can_manage_settings">
|
||||||
|
<Layout>
|
||||||
|
<SettingsServerConfig />
|
||||||
|
</Layout>
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/settings/server-config/version"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requirePermission="can_manage_settings">
|
||||||
|
<Layout>
|
||||||
|
<SettingsServerConfig />
|
||||||
|
</Layout>
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/settings/alert-channels"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requirePermission="can_manage_settings">
|
||||||
|
<Layout>
|
||||||
|
<SettingsLayout>
|
||||||
|
<AlertChannels />
|
||||||
|
</SettingsLayout>
|
||||||
|
</Layout>
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/settings/integrations"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requirePermission="can_manage_settings">
|
||||||
|
<Layout>
|
||||||
|
<Integrations />
|
||||||
|
</Layout>
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/settings/patch-management"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requirePermission="can_manage_settings">
|
||||||
|
<Layout>
|
||||||
|
<PatchManagement />
|
||||||
|
</Layout>
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/settings/server-url"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requirePermission="can_manage_settings">
|
||||||
|
<Layout>
|
||||||
|
<SettingsServerConfig />
|
||||||
|
</Layout>
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/settings/server-version"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requirePermission="can_manage_settings">
|
||||||
|
<Layout>
|
||||||
|
<SettingsServerConfig />
|
||||||
|
</Layout>
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/settings/branding"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requirePermission="can_manage_settings">
|
||||||
|
<Layout>
|
||||||
|
<SettingsServerConfig />
|
||||||
|
</Layout>
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/settings/agent-version"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requirePermission="can_manage_settings">
|
||||||
|
<Layout>
|
||||||
|
<SettingsAgentConfig />
|
||||||
|
</Layout>
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/options"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requirePermission="can_manage_hosts">
|
||||||
|
<Layout>
|
||||||
|
<SettingsHostGroups />
|
||||||
|
</Layout>
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/packages/:packageId"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requirePermission="can_view_packages">
|
||||||
|
<Layout>
|
||||||
|
<PackageDetail />
|
||||||
|
</Layout>
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Routes>
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App
|
function App() {
|
||||||
|
return (
|
||||||
|
<ThemeProvider>
|
||||||
|
<AuthProvider>
|
||||||
|
<UpdateNotificationProvider>
|
||||||
|
<LogoProvider>
|
||||||
|
<AppRoutes />
|
||||||
|
</LogoProvider>
|
||||||
|
</UpdateNotificationProvider>
|
||||||
|
</AuthProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
|
|||||||
@@ -1,306 +1,366 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
||||||
import {
|
|
||||||
DndContext,
|
|
||||||
closestCenter,
|
|
||||||
KeyboardSensor,
|
|
||||||
PointerSensor,
|
|
||||||
useSensor,
|
|
||||||
useSensors,
|
|
||||||
} from '@dnd-kit/core';
|
|
||||||
import {
|
import {
|
||||||
arrayMove,
|
closestCenter,
|
||||||
SortableContext,
|
DndContext,
|
||||||
sortableKeyboardCoordinates,
|
KeyboardSensor,
|
||||||
verticalListSortingStrategy,
|
PointerSensor,
|
||||||
} from '@dnd-kit/sortable';
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
} from "@dnd-kit/core";
|
||||||
import {
|
import {
|
||||||
useSortable,
|
arrayMove,
|
||||||
} from '@dnd-kit/sortable';
|
SortableContext,
|
||||||
import { CSS } from '@dnd-kit/utilities';
|
sortableKeyboardCoordinates,
|
||||||
import {
|
useSortable,
|
||||||
X,
|
verticalListSortingStrategy,
|
||||||
GripVertical,
|
} from "@dnd-kit/sortable";
|
||||||
Eye,
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
EyeOff,
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
Save,
|
import {
|
||||||
RotateCcw,
|
Eye,
|
||||||
Settings as SettingsIcon
|
EyeOff,
|
||||||
} from 'lucide-react';
|
GripVertical,
|
||||||
import { dashboardPreferencesAPI } from '../utils/api';
|
RotateCcw,
|
||||||
|
Save,
|
||||||
|
Settings as SettingsIcon,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { dashboardPreferencesAPI } from "../utils/api";
|
||||||
|
|
||||||
// Sortable Card Item Component
|
// Sortable Card Item Component
|
||||||
const SortableCardItem = ({ card, onToggle }) => {
|
const SortableCardItem = ({ card, onToggle }) => {
|
||||||
const {
|
const {
|
||||||
attributes,
|
attributes,
|
||||||
listeners,
|
listeners,
|
||||||
setNodeRef,
|
setNodeRef,
|
||||||
transform,
|
transform,
|
||||||
transition,
|
transition,
|
||||||
isDragging,
|
isDragging,
|
||||||
} = useSortable({ id: card.cardId });
|
} = useSortable({
|
||||||
|
id: card.cardId,
|
||||||
|
});
|
||||||
|
|
||||||
const style = {
|
const style = {
|
||||||
transform: CSS.Transform.toString(transform),
|
transform: CSS.Transform.toString(transform),
|
||||||
transition,
|
transition,
|
||||||
opacity: isDragging ? 0.5 : 1,
|
opacity: isDragging ? 0.5 : 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<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"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<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}
|
||||||
</div>
|
{card.typeLabel ? (
|
||||||
</div>
|
<span className="ml-2 text-xs font-normal text-secondary-500 dark:text-secondary-400">
|
||||||
</div>
|
({card.typeLabel})
|
||||||
|
</span>
|
||||||
<button
|
) : null}
|
||||||
onClick={() => onToggle(card.cardId)}
|
</div>
|
||||||
className={`flex items-center gap-1 px-2 py-1 rounded text-xs font-medium transition-colors ${
|
</div>
|
||||||
card.enabled
|
</div>
|
||||||
? 'bg-green-100 text-green-800 hover:bg-green-200'
|
|
||||||
: 'bg-secondary-100 text-secondary-600 hover:bg-secondary-200'
|
<button
|
||||||
}`}
|
type="button"
|
||||||
>
|
onClick={() => onToggle(card.cardId)}
|
||||||
{card.enabled ? (
|
className={`flex items-center gap-1 px-2 py-1 rounded text-xs font-medium transition-colors ${
|
||||||
<>
|
card.enabled
|
||||||
<Eye className="h-3 w-3" />
|
? "bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 hover:bg-green-200 dark:hover:bg-green-800"
|
||||||
Visible
|
: "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 ? (
|
||||||
<EyeOff className="h-3 w-3" />
|
<>
|
||||||
Hidden
|
<Eye className="h-3 w-3" />
|
||||||
</>
|
Visible
|
||||||
)}
|
</>
|
||||||
</button>
|
) : (
|
||||||
</div>
|
<>
|
||||||
);
|
<EyeOff className="h-3 w-3" />
|
||||||
|
Hidden
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const DashboardSettingsModal = ({ isOpen, onClose }) => {
|
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 sensors = useSensors(
|
const sensors = useSensors(
|
||||||
useSensor(PointerSensor),
|
useSensor(PointerSensor),
|
||||||
useSensor(KeyboardSensor, {
|
useSensor(KeyboardSensor, {
|
||||||
coordinateGetter: sortableKeyboardCoordinates,
|
coordinateGetter: sortableKeyboardCoordinates,
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Fetch user's dashboard preferences
|
// Fetch user's dashboard preferences
|
||||||
const { data: preferences, isLoading } = useQuery({
|
const { data: preferences, isLoading } = useQuery({
|
||||||
queryKey: ['dashboardPreferences'],
|
queryKey: ["dashboardPreferences"],
|
||||||
queryFn: () => dashboardPreferencesAPI.get().then(res => res.data),
|
queryFn: () => dashboardPreferencesAPI.get().then((res) => res.data),
|
||||||
enabled: isOpen
|
enabled: isOpen,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fetch default card configuration
|
// Fetch default card configuration
|
||||||
const { data: defaultCards } = useQuery({
|
const { data: defaultCards } = useQuery({
|
||||||
queryKey: ['dashboardDefaultCards'],
|
queryKey: ["dashboardDefaultCards"],
|
||||||
queryFn: () => dashboardPreferencesAPI.getDefaults().then(res => res.data),
|
queryFn: () =>
|
||||||
enabled: isOpen
|
dashboardPreferencesAPI.getDefaults().then((res) => res.data),
|
||||||
});
|
enabled: isOpen,
|
||||||
|
});
|
||||||
|
|
||||||
// Update preferences mutation
|
// Update preferences mutation
|
||||||
const updatePreferencesMutation = useMutation({
|
const updatePreferencesMutation = useMutation({
|
||||||
mutationFn: (preferences) => dashboardPreferencesAPI.update(preferences),
|
mutationFn: (preferences) => dashboardPreferencesAPI.update(preferences),
|
||||||
onSuccess: (response) => {
|
onSuccess: (response) => {
|
||||||
// Optimistically update the query cache with the correct data structure
|
// Optimistically update the query cache with the correct data structure
|
||||||
queryClient.setQueryData(['dashboardPreferences'], response.data.preferences);
|
queryClient.setQueryData(
|
||||||
// Also invalidate to ensure fresh data
|
["dashboardPreferences"],
|
||||||
queryClient.invalidateQueries(['dashboardPreferences']);
|
response.data.preferences,
|
||||||
setHasChanges(false);
|
);
|
||||||
onClose();
|
// Also invalidate to ensure fresh data
|
||||||
},
|
queryClient.invalidateQueries(["dashboardPreferences"]);
|
||||||
onError: (error) => {
|
setHasChanges(false);
|
||||||
console.error('Failed to update dashboard preferences:', error);
|
onClose();
|
||||||
}
|
},
|
||||||
});
|
onError: (error) => {
|
||||||
|
console.error("Failed to update dashboard preferences:", error);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// Initialize cards when preferences or defaults are loaded
|
// Initialize cards when preferences or defaults are loaded
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (preferences && defaultCards) {
|
if (preferences && defaultCards) {
|
||||||
// Merge user preferences with default cards
|
// Normalize server preferences (snake_case -> camelCase)
|
||||||
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,
|
||||||
return {
|
enabled: p.enabled,
|
||||||
...defaultCard,
|
order: p.order,
|
||||||
enabled: userPreference ? userPreference.enabled : defaultCard.enabled,
|
}));
|
||||||
order: userPreference ? userPreference.order : defaultCard.order
|
|
||||||
};
|
|
||||||
}).sort((a, b) => a.order - b.order);
|
|
||||||
|
|
||||||
setCards(mergedCards);
|
|
||||||
}
|
|
||||||
}, [preferences, defaultCards]);
|
|
||||||
|
|
||||||
const handleDragEnd = (event) => {
|
const typeLabelFor = (cardId) => {
|
||||||
const { active, over } = event;
|
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 === "osDistributionDoughnut") return "Doughnut 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;
|
||||||
|
};
|
||||||
|
|
||||||
if (active.id !== over.id) {
|
// Merge user preferences with default cards
|
||||||
setCards((items) => {
|
const mergedCards = defaultCards
|
||||||
const oldIndex = items.findIndex(item => item.cardId === active.id);
|
.map((defaultCard) => {
|
||||||
const newIndex = items.findIndex(item => item.cardId === over.id);
|
const userPreference = normalizedPreferences.find(
|
||||||
|
(p) => p.cardId === defaultCard.cardId,
|
||||||
const newItems = arrayMove(items, oldIndex, newIndex);
|
);
|
||||||
|
return {
|
||||||
// Update order values
|
...defaultCard,
|
||||||
return newItems.map((item, index) => ({
|
enabled: userPreference
|
||||||
...item,
|
? userPreference.enabled
|
||||||
order: index
|
: defaultCard.enabled,
|
||||||
}));
|
order: userPreference ? userPreference.order : defaultCard.order,
|
||||||
});
|
typeLabel: typeLabelFor(defaultCard.cardId),
|
||||||
setHasChanges(true);
|
};
|
||||||
}
|
})
|
||||||
};
|
.sort((a, b) => a.order - b.order);
|
||||||
|
|
||||||
const handleToggle = (cardId) => {
|
setCards(mergedCards);
|
||||||
setCards(prevCards =>
|
}
|
||||||
prevCards.map(card =>
|
}, [preferences, defaultCards]);
|
||||||
card.cardId === cardId
|
|
||||||
? { ...card, enabled: !card.enabled }
|
|
||||||
: card
|
|
||||||
)
|
|
||||||
);
|
|
||||||
setHasChanges(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleDragEnd = (event) => {
|
||||||
const preferences = cards.map(card => ({
|
const { active, over } = event;
|
||||||
cardId: card.cardId,
|
|
||||||
enabled: card.enabled,
|
|
||||||
order: card.order
|
|
||||||
}));
|
|
||||||
|
|
||||||
updatePreferencesMutation.mutate(preferences);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleReset = () => {
|
if (active.id !== over.id) {
|
||||||
if (defaultCards) {
|
setCards((items) => {
|
||||||
const resetCards = defaultCards.map(card => ({
|
const oldIndex = items.findIndex((item) => item.cardId === active.id);
|
||||||
...card,
|
const newIndex = items.findIndex((item) => item.cardId === over.id);
|
||||||
enabled: true,
|
|
||||||
order: card.order
|
|
||||||
}));
|
|
||||||
setCards(resetCards);
|
|
||||||
setHasChanges(true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!isOpen) return null;
|
const newItems = arrayMove(items, oldIndex, newIndex);
|
||||||
|
|
||||||
return (
|
// Update order values
|
||||||
<div className="fixed inset-0 z-50 overflow-y-auto">
|
return newItems.map((item, index) => ({
|
||||||
<div className="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
...item,
|
||||||
<div className="fixed inset-0 bg-secondary-500 bg-opacity-75 transition-opacity" onClick={onClose} />
|
order: index,
|
||||||
|
}));
|
||||||
<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="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
setHasChanges(true);
|
||||||
<div className="flex items-center justify-between mb-4">
|
}
|
||||||
<div className="flex items-center gap-2">
|
};
|
||||||
<SettingsIcon className="h-5 w-5 text-primary-600" />
|
|
||||||
<h3 className="text-lg font-medium text-secondary-900">
|
|
||||||
Dashboard Settings
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="text-secondary-400 hover:text-secondary-600"
|
|
||||||
>
|
|
||||||
<X className="h-5 w-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-sm text-secondary-600 mb-6">
|
|
||||||
Customize your dashboard by reordering cards and toggling their visibility.
|
|
||||||
Drag cards to reorder them, and click the visibility toggle to show/hide cards.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{isLoading ? (
|
const handleToggle = (cardId) => {
|
||||||
<div className="flex items-center justify-center py-8">
|
setCards((prevCards) =>
|
||||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary-600"></div>
|
prevCards.map((card) =>
|
||||||
</div>
|
card.cardId === cardId ? { ...card, enabled: !card.enabled } : card,
|
||||||
) : (
|
),
|
||||||
<DndContext
|
);
|
||||||
sensors={sensors}
|
setHasChanges(true);
|
||||||
collisionDetection={closestCenter}
|
};
|
||||||
onDragEnd={handleDragEnd}
|
|
||||||
>
|
const handleSave = () => {
|
||||||
<SortableContext items={cards.map(card => card.cardId)} strategy={verticalListSortingStrategy}>
|
const preferences = cards.map((card) => ({
|
||||||
<div className="space-y-2 max-h-96 overflow-y-auto">
|
cardId: card.cardId,
|
||||||
{cards.map((card) => (
|
enabled: card.enabled,
|
||||||
<SortableCardItem
|
order: card.order,
|
||||||
key={card.cardId}
|
}));
|
||||||
card={card}
|
|
||||||
onToggle={handleToggle}
|
updatePreferencesMutation.mutate(preferences);
|
||||||
/>
|
};
|
||||||
))}
|
|
||||||
</div>
|
const handleReset = () => {
|
||||||
</SortableContext>
|
if (defaultCards) {
|
||||||
</DndContext>
|
const resetCards = defaultCards.map((card) => ({
|
||||||
)}
|
...card,
|
||||||
</div>
|
enabled: true,
|
||||||
|
order: card.order,
|
||||||
<div className="bg-secondary-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
|
}));
|
||||||
<button
|
setCards(resetCards);
|
||||||
onClick={handleSave}
|
setHasChanges(true);
|
||||||
disabled={!hasChanges || updatePreferencesMutation.isPending}
|
}
|
||||||
className={`w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 text-base font-medium text-white sm:ml-3 sm:w-auto sm:text-sm ${
|
};
|
||||||
!hasChanges || updatePreferencesMutation.isPending
|
|
||||||
? 'bg-secondary-400 cursor-not-allowed'
|
if (!isOpen) return null;
|
||||||
: 'bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500'
|
|
||||||
}`}
|
return (
|
||||||
>
|
<div className="fixed inset-0 z-50 overflow-y-auto">
|
||||||
{updatePreferencesMutation.isPending ? (
|
<div className="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||||
<>
|
<button
|
||||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
type="button"
|
||||||
Saving...
|
className="fixed inset-0 bg-secondary-500 bg-opacity-75 transition-opacity cursor-default"
|
||||||
</>
|
onClick={onClose}
|
||||||
) : (
|
aria-label="Close modal"
|
||||||
<>
|
/>
|
||||||
<Save className="h-4 w-4 mr-2" />
|
|
||||||
Save Changes
|
<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 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">
|
||||||
</button>
|
<div className="flex items-center gap-2">
|
||||||
|
<SettingsIcon className="h-5 w-5 text-primary-600" />
|
||||||
<button
|
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
|
||||||
onClick={handleReset}
|
Dashboard Settings
|
||||||
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"
|
</h3>
|
||||||
>
|
</div>
|
||||||
<RotateCcw className="h-4 w-4 mr-2" />
|
<button
|
||||||
Reset to Defaults
|
type="button"
|
||||||
</button>
|
onClick={onClose}
|
||||||
|
className="text-secondary-400 hover:text-secondary-600 dark:text-secondary-500 dark:hover:text-secondary-300"
|
||||||
<button
|
>
|
||||||
onClick={onClose}
|
<X className="h-5 w-5" />
|
||||||
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"
|
</button>
|
||||||
>
|
</div>
|
||||||
Cancel
|
|
||||||
</button>
|
<p className="text-sm text-secondary-600 dark:text-secondary-400 mb-6">
|
||||||
</div>
|
Customize your dashboard by reordering cards and toggling their
|
||||||
</div>
|
visibility. Drag cards to reorder them, and click the visibility
|
||||||
</div>
|
toggle to show/hide cards.
|
||||||
</div>
|
</p>
|
||||||
);
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary-600"></div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
collisionDetection={closestCenter}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
>
|
||||||
|
<SortableContext
|
||||||
|
items={cards.map((card) => card.cardId)}
|
||||||
|
strategy={verticalListSortingStrategy}
|
||||||
|
>
|
||||||
|
<div className="space-y-2 max-h-96 overflow-y-auto">
|
||||||
|
{cards.map((card) => (
|
||||||
|
<SortableCardItem
|
||||||
|
key={card.cardId}
|
||||||
|
card={card}
|
||||||
|
onToggle={handleToggle}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-secondary-50 dark:bg-secondary-700 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!hasChanges || updatePreferencesMutation.isPending}
|
||||||
|
className={`w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 text-base font-medium text-white sm:ml-3 sm:w-auto sm:text-sm ${
|
||||||
|
!hasChanges || updatePreferencesMutation.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"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{updatePreferencesMutation.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 Changes
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleReset}
|
||||||
|
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" />
|
||||||
|
Reset to Defaults
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
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
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default DashboardSettingsModal;
|
export default DashboardSettingsModal;
|
||||||
|
|||||||
16
frontend/src/components/DiscordIcon.jsx
Normal file
16
frontend/src/components/DiscordIcon.jsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
const DiscordIcon = ({ className = "h-5 w-5" }) => {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
className={className}
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
aria-label="Discord"
|
||||||
|
>
|
||||||
|
<title>Discord</title>
|
||||||
|
<path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DiscordIcon;
|
||||||
348
frontend/src/components/FirstTimeAdminSetup.jsx
Normal file
348
frontend/src/components/FirstTimeAdminSetup.jsx
Normal file
@@ -0,0 +1,348 @@
|
|||||||
|
import { AlertCircle, CheckCircle, Shield, UserPlus } from "lucide-react";
|
||||||
|
import { useId, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { useAuth } from "../contexts/AuthContext";
|
||||||
|
|
||||||
|
const FirstTimeAdminSetup = () => {
|
||||||
|
const { login, setAuthState } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const firstNameId = useId();
|
||||||
|
const lastNameId = useId();
|
||||||
|
const usernameId = useId();
|
||||||
|
const emailId = useId();
|
||||||
|
const passwordId = useId();
|
||||||
|
const confirmPasswordId = useId();
|
||||||
|
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);
|
||||||
|
|
||||||
|
// If the response includes a token, use it to automatically log in
|
||||||
|
if (data.token && data.user) {
|
||||||
|
// Set the authentication state immediately
|
||||||
|
setAuthState(data.token, data.user);
|
||||||
|
// Navigate to dashboard after successful setup
|
||||||
|
setTimeout(() => {
|
||||||
|
navigate("/", { replace: true });
|
||||||
|
}, 100); // Small delay to ensure auth state is set
|
||||||
|
} else {
|
||||||
|
// Fallback to manual login if no token provided
|
||||||
|
setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
await login(formData.username.trim(), formData.password);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Auto-login failed:", error);
|
||||||
|
setError(
|
||||||
|
"Account created but auto-login failed. Please login manually.",
|
||||||
|
);
|
||||||
|
setSuccess(false);
|
||||||
|
}
|
||||||
|
}, 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 and you are now
|
||||||
|
logged in. Redirecting to the dashboard...
|
||||||
|
</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={firstNameId}
|
||||||
|
className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2"
|
||||||
|
>
|
||||||
|
First Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id={firstNameId}
|
||||||
|
name="firstName"
|
||||||
|
value={formData.firstName}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className="input w-full"
|
||||||
|
placeholder="Enter your first name"
|
||||||
|
required
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor={lastNameId}
|
||||||
|
className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2"
|
||||||
|
>
|
||||||
|
Last Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id={lastNameId}
|
||||||
|
name="lastName"
|
||||||
|
value={formData.lastName}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className="input w-full"
|
||||||
|
placeholder="Enter your last name"
|
||||||
|
required
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor={usernameId}
|
||||||
|
className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2"
|
||||||
|
>
|
||||||
|
Username
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id={usernameId}
|
||||||
|
name="username"
|
||||||
|
value={formData.username}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className="input w-full"
|
||||||
|
placeholder="Enter your username"
|
||||||
|
required
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor={emailId}
|
||||||
|
className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2"
|
||||||
|
>
|
||||||
|
Email Address
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
id={emailId}
|
||||||
|
name="email"
|
||||||
|
value={formData.email}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className="input w-full"
|
||||||
|
placeholder="Enter your email"
|
||||||
|
required
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor={passwordId}
|
||||||
|
className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2"
|
||||||
|
>
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id={passwordId}
|
||||||
|
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={confirmPasswordId}
|
||||||
|
className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2"
|
||||||
|
>
|
||||||
|
Confirm Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id={confirmPasswordId}
|
||||||
|
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;
|
||||||
428
frontend/src/components/GlobalSearch.jsx
Normal file
428
frontend/src/components/GlobalSearch.jsx
Normal file
@@ -0,0 +1,428 @@
|
|||||||
|
import { GitBranch, Package, Search, Server, User, X } from "lucide-react";
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { searchAPI } from "../utils/api";
|
||||||
|
|
||||||
|
const GlobalSearch = () => {
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [results, setResults] = useState(null);
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||||||
|
const searchRef = useRef(null);
|
||||||
|
const inputRef = useRef(null);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
// Debounce search
|
||||||
|
const debounceTimerRef = useRef(null);
|
||||||
|
|
||||||
|
const performSearch = useCallback(async (searchQuery) => {
|
||||||
|
if (!searchQuery || searchQuery.trim().length === 0) {
|
||||||
|
setResults(null);
|
||||||
|
setIsOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await searchAPI.global(searchQuery);
|
||||||
|
setResults(response.data);
|
||||||
|
setIsOpen(true);
|
||||||
|
setSelectedIndex(-1);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Search error:", error);
|
||||||
|
setResults(null);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleInputChange = (e) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
setQuery(value);
|
||||||
|
|
||||||
|
// Clear previous timer
|
||||||
|
if (debounceTimerRef.current) {
|
||||||
|
clearTimeout(debounceTimerRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set new timer
|
||||||
|
debounceTimerRef.current = setTimeout(() => {
|
||||||
|
performSearch(value);
|
||||||
|
}, 300);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClear = () => {
|
||||||
|
// Clear debounce timer to prevent any pending searches
|
||||||
|
if (debounceTimerRef.current) {
|
||||||
|
clearTimeout(debounceTimerRef.current);
|
||||||
|
}
|
||||||
|
setQuery("");
|
||||||
|
setResults(null);
|
||||||
|
setIsOpen(false);
|
||||||
|
setSelectedIndex(-1);
|
||||||
|
inputRef.current?.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResultClick = (result) => {
|
||||||
|
// Navigate based on result type
|
||||||
|
switch (result.type) {
|
||||||
|
case "host":
|
||||||
|
navigate(`/hosts/${result.id}`);
|
||||||
|
break;
|
||||||
|
case "package":
|
||||||
|
navigate(`/packages/${result.id}`);
|
||||||
|
break;
|
||||||
|
case "repository":
|
||||||
|
navigate(`/repositories/${result.id}`);
|
||||||
|
break;
|
||||||
|
case "user":
|
||||||
|
// Users don't have detail pages, so navigate to settings
|
||||||
|
navigate("/settings/users");
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close dropdown and clear
|
||||||
|
handleClear();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Close dropdown when clicking outside
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event) => {
|
||||||
|
if (searchRef.current && !searchRef.current.contains(event.target)) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", handleClickOutside);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Keyboard navigation
|
||||||
|
const flattenedResults = [];
|
||||||
|
if (results) {
|
||||||
|
if (results.hosts?.length > 0) {
|
||||||
|
flattenedResults.push({ type: "header", label: "Hosts" });
|
||||||
|
flattenedResults.push(...results.hosts);
|
||||||
|
}
|
||||||
|
if (results.packages?.length > 0) {
|
||||||
|
flattenedResults.push({ type: "header", label: "Packages" });
|
||||||
|
flattenedResults.push(...results.packages);
|
||||||
|
}
|
||||||
|
if (results.repositories?.length > 0) {
|
||||||
|
flattenedResults.push({ type: "header", label: "Repositories" });
|
||||||
|
flattenedResults.push(...results.repositories);
|
||||||
|
}
|
||||||
|
if (results.users?.length > 0) {
|
||||||
|
flattenedResults.push({ type: "header", label: "Users" });
|
||||||
|
flattenedResults.push(...results.users);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const navigableResults = flattenedResults.filter((r) => r.type !== "header");
|
||||||
|
|
||||||
|
const handleKeyDown = (e) => {
|
||||||
|
if (!isOpen || !results) return;
|
||||||
|
|
||||||
|
switch (e.key) {
|
||||||
|
case "ArrowDown":
|
||||||
|
e.preventDefault();
|
||||||
|
setSelectedIndex((prev) =>
|
||||||
|
prev < navigableResults.length - 1 ? prev + 1 : prev,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "ArrowUp":
|
||||||
|
e.preventDefault();
|
||||||
|
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : -1));
|
||||||
|
break;
|
||||||
|
case "Enter":
|
||||||
|
e.preventDefault();
|
||||||
|
if (selectedIndex >= 0 && navigableResults[selectedIndex]) {
|
||||||
|
handleResultClick(navigableResults[selectedIndex]);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "Escape":
|
||||||
|
e.preventDefault();
|
||||||
|
setIsOpen(false);
|
||||||
|
setSelectedIndex(-1);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get icon for result type
|
||||||
|
const getResultIcon = (type) => {
|
||||||
|
switch (type) {
|
||||||
|
case "host":
|
||||||
|
return <Server className="h-4 w-4 text-blue-500" />;
|
||||||
|
case "package":
|
||||||
|
return <Package className="h-4 w-4 text-green-500" />;
|
||||||
|
case "repository":
|
||||||
|
return <GitBranch className="h-4 w-4 text-purple-500" />;
|
||||||
|
case "user":
|
||||||
|
return <User className="h-4 w-4 text-orange-500" />;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get display text for result
|
||||||
|
const getResultDisplay = (result) => {
|
||||||
|
switch (result.type) {
|
||||||
|
case "host":
|
||||||
|
return {
|
||||||
|
primary: result.friendly_name || result.hostname,
|
||||||
|
secondary: result.ip || result.hostname,
|
||||||
|
};
|
||||||
|
case "package":
|
||||||
|
return {
|
||||||
|
primary: result.name,
|
||||||
|
secondary: result.description || result.category,
|
||||||
|
};
|
||||||
|
case "repository":
|
||||||
|
return {
|
||||||
|
primary: result.name,
|
||||||
|
secondary: result.distribution,
|
||||||
|
};
|
||||||
|
case "user":
|
||||||
|
return {
|
||||||
|
primary: result.username,
|
||||||
|
secondary: result.email,
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return { primary: "", secondary: "" };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasResults =
|
||||||
|
results &&
|
||||||
|
(results.hosts?.length > 0 ||
|
||||||
|
results.packages?.length > 0 ||
|
||||||
|
results.repositories?.length > 0 ||
|
||||||
|
results.users?.length > 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={searchRef} className="relative w-full max-w-sm">
|
||||||
|
<div className="relative">
|
||||||
|
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||||
|
<Search className="h-5 w-5 text-secondary-400" />
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
className="block w-full rounded-lg border border-secondary-200 bg-white py-2 pl-10 pr-10 text-sm text-secondary-900 placeholder-secondary-500 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500 dark:border-secondary-600 dark:bg-secondary-700 dark:text-white dark:placeholder-secondary-400"
|
||||||
|
placeholder="Search hosts, packages, repos, users..."
|
||||||
|
value={query}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onFocus={() => {
|
||||||
|
if (query && results) setIsOpen(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{query && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClear}
|
||||||
|
className="absolute inset-y-0 right-0 flex items-center pr-3 text-secondary-400 hover:text-secondary-600"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dropdown Results */}
|
||||||
|
{isOpen && (
|
||||||
|
<div className="absolute z-50 mt-2 w-full rounded-lg border border-secondary-200 bg-white shadow-lg dark:border-secondary-600 dark:bg-secondary-800">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="px-4 py-2 text-center text-sm text-secondary-500">
|
||||||
|
Searching...
|
||||||
|
</div>
|
||||||
|
) : hasResults ? (
|
||||||
|
<div className="max-h-96 overflow-y-auto">
|
||||||
|
{/* Hosts */}
|
||||||
|
{results.hosts?.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="sticky top-0 z-10 bg-secondary-50 px-3 py-1.5 text-xs font-semibold uppercase tracking-wider text-secondary-500 dark:bg-secondary-700 dark:text-secondary-400">
|
||||||
|
Hosts
|
||||||
|
</div>
|
||||||
|
{results.hosts.map((host, _idx) => {
|
||||||
|
const display = getResultDisplay(host);
|
||||||
|
const globalIdx = navigableResults.findIndex(
|
||||||
|
(r) => r.id === host.id && r.type === "host",
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={host.id}
|
||||||
|
onClick={() => handleResultClick(host)}
|
||||||
|
className={`flex w-full items-center gap-2 px-3 py-1.5 text-left transition-colors ${
|
||||||
|
globalIdx === selectedIndex
|
||||||
|
? "bg-primary-50 dark:bg-primary-900/20"
|
||||||
|
: "hover:bg-secondary-50 dark:hover:bg-secondary-700"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{getResultIcon("host")}
|
||||||
|
<div className="flex-1 min-w-0 flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium text-secondary-900 dark:text-white truncate">
|
||||||
|
{display.primary}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-secondary-400">•</span>
|
||||||
|
<span className="text-xs text-secondary-500 dark:text-secondary-400 truncate">
|
||||||
|
{display.secondary}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex-shrink-0 text-xs text-secondary-400">
|
||||||
|
{host.os_type}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Packages */}
|
||||||
|
{results.packages?.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="sticky top-0 z-10 bg-secondary-50 px-3 py-1.5 text-xs font-semibold uppercase tracking-wider text-secondary-500 dark:bg-secondary-700 dark:text-secondary-400">
|
||||||
|
Packages
|
||||||
|
</div>
|
||||||
|
{results.packages.map((pkg, _idx) => {
|
||||||
|
const display = getResultDisplay(pkg);
|
||||||
|
const globalIdx = navigableResults.findIndex(
|
||||||
|
(r) => r.id === pkg.id && r.type === "package",
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={pkg.id}
|
||||||
|
onClick={() => handleResultClick(pkg)}
|
||||||
|
className={`flex w-full items-center gap-2 px-3 py-1.5 text-left transition-colors ${
|
||||||
|
globalIdx === selectedIndex
|
||||||
|
? "bg-primary-50 dark:bg-primary-900/20"
|
||||||
|
: "hover:bg-secondary-50 dark:hover:bg-secondary-700"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{getResultIcon("package")}
|
||||||
|
<div className="flex-1 min-w-0 flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium text-secondary-900 dark:text-white truncate">
|
||||||
|
{display.primary}
|
||||||
|
</span>
|
||||||
|
{display.secondary && (
|
||||||
|
<>
|
||||||
|
<span className="text-xs text-secondary-400">
|
||||||
|
•
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-secondary-500 dark:text-secondary-400 truncate">
|
||||||
|
{display.secondary}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-shrink-0 text-xs text-secondary-400">
|
||||||
|
{pkg.host_count} hosts
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Repositories */}
|
||||||
|
{results.repositories?.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="sticky top-0 z-10 bg-secondary-50 px-3 py-1.5 text-xs font-semibold uppercase tracking-wider text-secondary-500 dark:bg-secondary-700 dark:text-secondary-400">
|
||||||
|
Repositories
|
||||||
|
</div>
|
||||||
|
{results.repositories.map((repo, _idx) => {
|
||||||
|
const display = getResultDisplay(repo);
|
||||||
|
const globalIdx = navigableResults.findIndex(
|
||||||
|
(r) => r.id === repo.id && r.type === "repository",
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={repo.id}
|
||||||
|
onClick={() => handleResultClick(repo)}
|
||||||
|
className={`flex w-full items-center gap-2 px-3 py-1.5 text-left transition-colors ${
|
||||||
|
globalIdx === selectedIndex
|
||||||
|
? "bg-primary-50 dark:bg-primary-900/20"
|
||||||
|
: "hover:bg-secondary-50 dark:hover:bg-secondary-700"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{getResultIcon("repository")}
|
||||||
|
<div className="flex-1 min-w-0 flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium text-secondary-900 dark:text-white truncate">
|
||||||
|
{display.primary}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-secondary-400">•</span>
|
||||||
|
<span className="text-xs text-secondary-500 dark:text-secondary-400 truncate">
|
||||||
|
{display.secondary}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex-shrink-0 text-xs text-secondary-400">
|
||||||
|
{repo.host_count} hosts
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Users */}
|
||||||
|
{results.users?.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="sticky top-0 z-10 bg-secondary-50 px-3 py-1.5 text-xs font-semibold uppercase tracking-wider text-secondary-500 dark:bg-secondary-700 dark:text-secondary-400">
|
||||||
|
Users
|
||||||
|
</div>
|
||||||
|
{results.users.map((user, _idx) => {
|
||||||
|
const display = getResultDisplay(user);
|
||||||
|
const globalIdx = navigableResults.findIndex(
|
||||||
|
(r) => r.id === user.id && r.type === "user",
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={user.id}
|
||||||
|
onClick={() => handleResultClick(user)}
|
||||||
|
className={`flex w-full items-center gap-2 px-3 py-1.5 text-left transition-colors ${
|
||||||
|
globalIdx === selectedIndex
|
||||||
|
? "bg-primary-50 dark:bg-primary-900/20"
|
||||||
|
: "hover:bg-secondary-50 dark:hover:bg-secondary-700"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{getResultIcon("user")}
|
||||||
|
<div className="flex-1 min-w-0 flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium text-secondary-900 dark:text-white truncate">
|
||||||
|
{display.primary}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-secondary-400">•</span>
|
||||||
|
<span className="text-xs text-secondary-500 dark:text-secondary-400 truncate">
|
||||||
|
{display.secondary}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex-shrink-0 text-xs text-secondary-400">
|
||||||
|
{user.role}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : query.trim() ? (
|
||||||
|
<div className="px-4 py-2 text-center text-sm text-secondary-500">
|
||||||
|
No results found for "{query}"
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GlobalSearch;
|
||||||
162
frontend/src/components/InlineEdit.jsx
Normal file
162
frontend/src/components/InlineEdit.jsx
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
import { Check, Edit2, X } from "lucide-react";
|
||||||
|
import { useEffect, useRef, useState } from "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
|
||||||
|
type="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
|
||||||
|
type="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
|
||||||
|
type="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;
|
||||||
272
frontend/src/components/InlineGroupEdit.jsx
Normal file
272
frontend/src/components/InlineGroupEdit.jsx
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
import { Check, ChevronDown, Edit2, X } from "lucide-react";
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
|
const InlineGroupEdit = ({
|
||||||
|
value,
|
||||||
|
onSave,
|
||||||
|
onCancel,
|
||||||
|
options = [],
|
||||||
|
className = "",
|
||||||
|
disabled = false,
|
||||||
|
}) => {
|
||||||
|
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 = useCallback(() => {
|
||||||
|
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, calculateDropdownPosition]);
|
||||||
|
|
||||||
|
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
|
||||||
|
type="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
|
||||||
|
type="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
|
||||||
|
type="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;
|
||||||
80
frontend/src/components/InlineToggle.jsx
Normal file
80
frontend/src/components/InlineToggle.jsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
const InlineToggle = ({
|
||||||
|
value,
|
||||||
|
onSave,
|
||||||
|
className = "",
|
||||||
|
disabled = false,
|
||||||
|
trueLabel = "Yes",
|
||||||
|
falseLabel = "No",
|
||||||
|
}) => {
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const handleSave = async (newValue) => {
|
||||||
|
if (disabled || isLoading) return;
|
||||||
|
|
||||||
|
// Check if value actually changed
|
||||||
|
if (newValue === value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
setError("");
|
||||||
|
|
||||||
|
try {
|
||||||
|
await onSave(newValue);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || "Failed to save");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggle = () => {
|
||||||
|
if (disabled || isLoading) return;
|
||||||
|
handleSave(!value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const displayValue = (
|
||||||
|
<span
|
||||||
|
className={`text-sm font-medium ${
|
||||||
|
value
|
||||||
|
? "text-green-600 dark:text-green-400"
|
||||||
|
: "text-red-600 dark:text-red-400"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{value ? trueLabel : falseLabel}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex items-center gap-2 group ${className}`}>
|
||||||
|
{displayValue}
|
||||||
|
{!disabled && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleToggle}
|
||||||
|
disabled={isLoading}
|
||||||
|
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed ${
|
||||||
|
value
|
||||||
|
? "bg-primary-600 dark:bg-primary-500"
|
||||||
|
: "bg-secondary-200 dark:bg-secondary-600"
|
||||||
|
}`}
|
||||||
|
title={`Toggle ${value ? "off" : "on"}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`inline-block h-3 w-3 transform rounded-full bg-white transition-transform ${
|
||||||
|
value ? "translate-x-5" : "translate-x-1"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<span className="text-xs text-red-600 dark:text-red-400">{error}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default InlineToggle;
|
||||||
File diff suppressed because one or more lines are too long
44
frontend/src/components/Logo.jsx
Normal file
44
frontend/src/components/Logo.jsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useTheme } from "../contexts/ThemeContext";
|
||||||
|
import { settingsAPI } from "../utils/api";
|
||||||
|
|
||||||
|
const Logo = ({
|
||||||
|
className = "h-8 w-auto",
|
||||||
|
alt = "PatchMon Logo",
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const { isDark } = useTheme();
|
||||||
|
|
||||||
|
const { data: settings } = useQuery({
|
||||||
|
queryKey: ["settings"],
|
||||||
|
queryFn: () => settingsAPI.get().then((res) => res.data),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Determine which logo to use based on theme
|
||||||
|
const logoSrc = isDark
|
||||||
|
? settings?.logo_dark || "/assets/logo_dark.png"
|
||||||
|
: settings?.logo_light || "/assets/logo_light.png";
|
||||||
|
|
||||||
|
// Add cache-busting parameter using updated_at timestamp
|
||||||
|
const cacheBuster = settings?.updated_at
|
||||||
|
? new Date(settings.updated_at).getTime()
|
||||||
|
: Date.now();
|
||||||
|
const logoSrcWithCache = `${logoSrc}?v=${cacheBuster}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={logoSrcWithCache}
|
||||||
|
alt={alt}
|
||||||
|
className={className}
|
||||||
|
onError={(e) => {
|
||||||
|
// Fallback to default logo if custom logo fails to load
|
||||||
|
e.target.src = isDark
|
||||||
|
? "/assets/logo_dark.png"
|
||||||
|
: "/assets/logo_light.png";
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Logo;
|
||||||
42
frontend/src/components/LogoProvider.jsx
Normal file
42
frontend/src/components/LogoProvider.jsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { isAuthReady } from "../constants/authPhases";
|
||||||
|
import { useAuth } from "../contexts/AuthContext";
|
||||||
|
import { settingsAPI } from "../utils/api";
|
||||||
|
|
||||||
|
const LogoProvider = ({ children }) => {
|
||||||
|
const { authPhase, isAuthenticated } = useAuth();
|
||||||
|
|
||||||
|
const { data: settings } = useQuery({
|
||||||
|
queryKey: ["settings"],
|
||||||
|
queryFn: () => settingsAPI.get().then((res) => res.data),
|
||||||
|
enabled: isAuthReady(authPhase, isAuthenticated()),
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Use custom favicon or fallback to default
|
||||||
|
const faviconUrl = settings?.favicon || "/assets/favicon.svg";
|
||||||
|
|
||||||
|
// Add cache-busting parameter using updated_at timestamp
|
||||||
|
const cacheBuster = settings?.updated_at
|
||||||
|
? new Date(settings.updated_at).getTime()
|
||||||
|
: Date.now();
|
||||||
|
const faviconUrlWithCache = `${faviconUrl}?v=${cacheBuster}`;
|
||||||
|
|
||||||
|
// Update favicon
|
||||||
|
const favicon = document.querySelector('link[rel="icon"]');
|
||||||
|
if (favicon) {
|
||||||
|
favicon.href = faviconUrlWithCache;
|
||||||
|
} else {
|
||||||
|
// Create favicon link if it doesn't exist
|
||||||
|
const link = document.createElement("link");
|
||||||
|
link.rel = "icon";
|
||||||
|
link.href = faviconUrlWithCache;
|
||||||
|
document.head.appendChild(link);
|
||||||
|
}
|
||||||
|
}, [settings?.favicon, settings?.updated_at]);
|
||||||
|
|
||||||
|
return children;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LogoProvider;
|
||||||
@@ -1,47 +1,58 @@
|
|||||||
import React from 'react'
|
import { Navigate } from "react-router-dom";
|
||||||
import { Navigate } from 'react-router-dom'
|
import { useAuth } from "../contexts/AuthContext";
|
||||||
import { useAuth } from '../contexts/AuthContext'
|
|
||||||
|
|
||||||
const ProtectedRoute = ({ children, requireAdmin = false, requirePermission = null }) => {
|
const ProtectedRoute = ({
|
||||||
const { isAuthenticated, isAdmin, isLoading, hasPermission } = useAuth()
|
children,
|
||||||
|
requireAdmin = false,
|
||||||
|
requirePermission = null,
|
||||||
|
}) => {
|
||||||
|
const { isAuthenticated, isAdmin, isLoading, hasPermission } = useAuth();
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-64">
|
<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 className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isAuthenticated()) {
|
if (!isAuthenticated()) {
|
||||||
return <Navigate to="/login" replace />
|
return <Navigate to="/login" replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check admin requirement
|
// Check admin requirement
|
||||||
if (requireAdmin && !isAdmin()) {
|
if (requireAdmin && !isAdmin()) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-64">
|
<div className="flex items-center justify-center h-64">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h2 className="text-xl font-semibold text-secondary-900 mb-2">Access Denied</h2>
|
<h2 className="text-xl font-semibold text-secondary-900 mb-2">
|
||||||
<p className="text-secondary-600">You don't have permission to access this page.</p>
|
Access Denied
|
||||||
</div>
|
</h2>
|
||||||
</div>
|
<p className="text-secondary-600">
|
||||||
)
|
You don't have permission to access this page.
|
||||||
}
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Check specific permission requirement
|
// Check specific permission requirement
|
||||||
if (requirePermission && !hasPermission(requirePermission)) {
|
if (requirePermission && !hasPermission(requirePermission)) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-64">
|
<div className="flex items-center justify-center h-64">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h2 className="text-xl font-semibold text-secondary-900 mb-2">Access Denied</h2>
|
<h2 className="text-xl font-semibold text-secondary-900 mb-2">
|
||||||
<p className="text-secondary-600">You don't have permission to access this page.</p>
|
Access Denied
|
||||||
</div>
|
</h2>
|
||||||
</div>
|
<p className="text-secondary-600">
|
||||||
)
|
You don't have permission to access this page.
|
||||||
}
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return children
|
return children;
|
||||||
}
|
};
|
||||||
|
|
||||||
export default ProtectedRoute
|
export default ProtectedRoute;
|
||||||
|
|||||||
279
frontend/src/components/SettingsLayout.jsx
Normal file
279
frontend/src/components/SettingsLayout.jsx
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
import {
|
||||||
|
Bell,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Code,
|
||||||
|
Folder,
|
||||||
|
Image,
|
||||||
|
RefreshCw,
|
||||||
|
Settings,
|
||||||
|
Shield,
|
||||||
|
UserCircle,
|
||||||
|
Users,
|
||||||
|
Wrench,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Link, useLocation } from "react-router-dom";
|
||||||
|
import { useAuth } from "../contexts/AuthContext";
|
||||||
|
|
||||||
|
const SettingsLayout = ({ children }) => {
|
||||||
|
const location = useLocation();
|
||||||
|
const { canManageSettings, canViewUsers, canManageUsers } = useAuth();
|
||||||
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||||
|
|
||||||
|
// Build secondary navigation based on permissions
|
||||||
|
const buildSecondaryNavigation = () => {
|
||||||
|
const nav = [];
|
||||||
|
|
||||||
|
// Users section
|
||||||
|
if (canViewUsers() || canManageUsers()) {
|
||||||
|
nav.push({
|
||||||
|
section: "User Management",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
name: "Users",
|
||||||
|
href: "/settings/users",
|
||||||
|
icon: Users,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Roles",
|
||||||
|
href: "/settings/roles",
|
||||||
|
icon: Shield,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "My Profile",
|
||||||
|
href: "/settings/profile",
|
||||||
|
icon: UserCircle,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Host Groups
|
||||||
|
if (canManageSettings()) {
|
||||||
|
nav.push({
|
||||||
|
section: "Hosts Management",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
name: "Host Groups",
|
||||||
|
href: "/settings/host-groups",
|
||||||
|
icon: Folder,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Agent Updates",
|
||||||
|
href: "/settings/agent-config",
|
||||||
|
icon: RefreshCw,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Agent Version",
|
||||||
|
href: "/settings/agent-version",
|
||||||
|
icon: Settings,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alert Management
|
||||||
|
if (canManageSettings()) {
|
||||||
|
nav.push({
|
||||||
|
section: "Alert Management",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
name: "Alert Channels",
|
||||||
|
href: "/settings/alert-channels",
|
||||||
|
icon: Bell,
|
||||||
|
comingSoon: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Notifications",
|
||||||
|
href: "/settings/notifications",
|
||||||
|
icon: Bell,
|
||||||
|
comingSoon: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Patch Management
|
||||||
|
if (canManageSettings()) {
|
||||||
|
nav.push({
|
||||||
|
section: "Patch Management",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
name: "Policies",
|
||||||
|
href: "/settings/patch-management",
|
||||||
|
icon: Settings,
|
||||||
|
comingSoon: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server Config
|
||||||
|
if (canManageSettings()) {
|
||||||
|
// Integrations section
|
||||||
|
nav.push({
|
||||||
|
section: "Integrations",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
name: "Integrations",
|
||||||
|
href: "/settings/integrations",
|
||||||
|
icon: Wrench,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
nav.push({
|
||||||
|
section: "Server",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
name: "URL Config",
|
||||||
|
href: "/settings/server-url",
|
||||||
|
icon: Wrench,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Branding",
|
||||||
|
href: "/settings/branding",
|
||||||
|
icon: Image,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Server Version",
|
||||||
|
href: "/settings/server-version",
|
||||||
|
icon: Code,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return nav;
|
||||||
|
};
|
||||||
|
|
||||||
|
const secondaryNavigation = buildSecondaryNavigation();
|
||||||
|
|
||||||
|
const isActive = (path) => location.pathname === path;
|
||||||
|
|
||||||
|
const _getPageTitle = () => {
|
||||||
|
const path = location.pathname;
|
||||||
|
|
||||||
|
if (path.startsWith("/settings/users")) return "Users";
|
||||||
|
if (path.startsWith("/settings/host-groups")) return "Host Groups";
|
||||||
|
if (path.startsWith("/settings/notifications")) return "Notifications";
|
||||||
|
if (path.startsWith("/settings/agent-config")) return "Agent Config";
|
||||||
|
if (path.startsWith("/settings/server-config")) return "Server Config";
|
||||||
|
|
||||||
|
return "Settings";
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-transparent">
|
||||||
|
{/* Within-page secondary navigation and content */}
|
||||||
|
<div className="px-2 sm:px-4 lg:px-6">
|
||||||
|
<div className="flex gap-4">
|
||||||
|
{/* Left secondary nav (within page) */}
|
||||||
|
<aside
|
||||||
|
className={`${sidebarCollapsed ? "w-14" : "w-56"} transition-all duration-300 flex-shrink-0`}
|
||||||
|
>
|
||||||
|
<div className="bg-white dark:bg-secondary-800 border border-secondary-200 dark:border-secondary-600 rounded-lg">
|
||||||
|
{/* Collapse button */}
|
||||||
|
<div className="flex justify-end p-2 border-b border-secondary-200 dark:border-secondary-600">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
|
||||||
|
className="p-1 text-secondary-400 hover:text-secondary-600 dark:text-secondary-500 dark:hover:text-secondary-300 rounded transition-colors"
|
||||||
|
title={
|
||||||
|
sidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{sidebarCollapsed ? (
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`${sidebarCollapsed ? "p-2" : "p-3"}`}>
|
||||||
|
<nav>
|
||||||
|
<ul
|
||||||
|
className={`${sidebarCollapsed ? "space-y-2" : "space-y-4"}`}
|
||||||
|
>
|
||||||
|
{secondaryNavigation.map((item) => (
|
||||||
|
<li key={item.section}>
|
||||||
|
{!sidebarCollapsed && (
|
||||||
|
<h4 className="text-xs font-semibold text-secondary-500 dark:text-secondary-300 uppercase tracking-wider mb-2">
|
||||||
|
{item.section}
|
||||||
|
</h4>
|
||||||
|
)}
|
||||||
|
<ul
|
||||||
|
className={`${sidebarCollapsed ? "space-y-1" : "space-y-1"}`}
|
||||||
|
>
|
||||||
|
{item.items.map((subItem) => (
|
||||||
|
<li key={subItem.name}>
|
||||||
|
<Link
|
||||||
|
to={subItem.href}
|
||||||
|
className={`group flex items-center rounded-md text-sm leading-5 font-medium transition-colors ${
|
||||||
|
sidebarCollapsed
|
||||||
|
? "justify-center p-2"
|
||||||
|
: "gap-2 p-2"
|
||||||
|
} ${
|
||||||
|
isActive(subItem.href)
|
||||||
|
? "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"
|
||||||
|
}`}
|
||||||
|
title={sidebarCollapsed ? subItem.name : ""}
|
||||||
|
>
|
||||||
|
<subItem.icon className="h-4 w-4 flex-shrink-0" />
|
||||||
|
{!sidebarCollapsed && (
|
||||||
|
<span className="truncate flex items-center gap-2">
|
||||||
|
{subItem.name}
|
||||||
|
{subItem.comingSoon && (
|
||||||
|
<span className="text-xs bg-secondary-100 text-secondary-600 px-1.5 py-0.5 rounded">
|
||||||
|
Soon
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{!sidebarCollapsed && subItem.subTabs && (
|
||||||
|
<ul className="ml-6 mt-1 space-y-1">
|
||||||
|
{subItem.subTabs.map((subTab) => (
|
||||||
|
<li key={subTab.name}>
|
||||||
|
<Link
|
||||||
|
to={subTab.href}
|
||||||
|
className={`block px-3 py-1 text-xs font-medium rounded transition-colors ${
|
||||||
|
isActive(subTab.href)
|
||||||
|
? "bg-primary-100 dark:bg-primary-700 text-primary-700 dark:text-primary-200"
|
||||||
|
: "text-secondary-600 dark:text-secondary-400 hover:text-primary-700 dark:hover:text-primary-300 hover:bg-secondary-50 dark:hover:bg-secondary-700"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{subTab.name}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Right content */}
|
||||||
|
<section className="flex-1 min-w-0">
|
||||||
|
<div className="bg-white dark:bg-secondary-800 border border-secondary-200 dark:border-secondary-600 rounded-lg p-4">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SettingsLayout;
|
||||||
14
frontend/src/components/UpgradeNotificationIcon.jsx
Normal file
14
frontend/src/components/UpgradeNotificationIcon.jsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
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;
|
||||||
379
frontend/src/components/settings/AgentManagementTab.jsx
Normal file
379
frontend/src/components/settings/AgentManagementTab.jsx
Normal file
@@ -0,0 +1,379 @@
|
|||||||
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
|
import { AlertCircle, Code, Download, Plus, Shield, X } from "lucide-react";
|
||||||
|
import { useId, useState } from "react";
|
||||||
|
import { agentFileAPI, settingsAPI } from "../../utils/api";
|
||||||
|
|
||||||
|
const AgentManagementTab = () => {
|
||||||
|
const scriptFileId = useId();
|
||||||
|
const scriptContentId = useId();
|
||||||
|
const [showUploadModal, setShowUploadModal] = useState(false);
|
||||||
|
|
||||||
|
// Agent file queries and mutations
|
||||||
|
const {
|
||||||
|
data: agentFileInfo,
|
||||||
|
isLoading: agentFileLoading,
|
||||||
|
error: agentFileError,
|
||||||
|
refetch: refetchAgentFile,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ["agentFile"],
|
||||||
|
queryFn: () => agentFileAPI.getInfo().then((res) => res.data),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch settings for dynamic curl flags
|
||||||
|
const { data: settings } = useQuery({
|
||||||
|
queryKey: ["settings"],
|
||||||
|
queryFn: () => settingsAPI.get().then((res) => res.data),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper function to get curl flags based on settings
|
||||||
|
const getCurlFlags = () => {
|
||||||
|
return settings?.ignore_ssl_self_signed ? "-sk" : "-s";
|
||||||
|
};
|
||||||
|
|
||||||
|
const uploadAgentMutation = useMutation({
|
||||||
|
mutationFn: (scriptContent) =>
|
||||||
|
agentFileAPI.upload(scriptContent).then((res) => res.data),
|
||||||
|
onSuccess: () => {
|
||||||
|
refetchAgentFile();
|
||||||
|
setShowUploadModal(false);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
console.error("Upload agent error:", error);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center mb-2">
|
||||||
|
<Code className="h-6 w-6 text-primary-600 mr-3" />
|
||||||
|
<h2 className="text-xl font-semibold text-secondary-900 dark:text-white">
|
||||||
|
Agent File Management
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-secondary-500 dark:text-secondary-300">
|
||||||
|
Manage the PatchMon agent script file used for installations and
|
||||||
|
updates
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const url = "/api/v1/hosts/agent/download";
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
link.download = "patchmon-agent.sh";
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
}}
|
||||||
|
className="btn-outline flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
Download
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowUploadModal(true)}
|
||||||
|
className="btn-primary flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Replace Script
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
{agentFileLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
||||||
|
</div>
|
||||||
|
) : agentFileError ? (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<p className="text-red-600 dark:text-red-400">
|
||||||
|
Error loading agent file: {agentFileError.message}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : !agentFileInfo?.exists ? (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<Code className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
|
||||||
|
<p className="text-secondary-500 dark:text-secondary-300">
|
||||||
|
No agent script found
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-secondary-400 dark:text-secondary-400 mt-2">
|
||||||
|
Upload an agent script to get started
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Agent File Info */}
|
||||||
|
<div className="bg-secondary-50 dark:bg-secondary-700 rounded-lg p-6">
|
||||||
|
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">
|
||||||
|
Current Agent Script
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Code 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">
|
||||||
|
Version:
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-secondary-900 dark:text-white font-mono">
|
||||||
|
{agentFileInfo.version}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Download 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">
|
||||||
|
Size:
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-secondary-900 dark:text-white">
|
||||||
|
{agentFileInfo.sizeFormatted}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Code 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">
|
||||||
|
Modified:
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-secondary-900 dark:text-white">
|
||||||
|
{new Date(agentFileInfo.lastModified).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Usage Instructions */}
|
||||||
|
<div className="bg-blue-50 dark:bg-blue-900 border border-blue-200 dark:border-blue-700 rounded-md p-4">
|
||||||
|
<div className="flex">
|
||||||
|
<Shield className="h-5 w-5 text-blue-400 dark:text-blue-300" />
|
||||||
|
<div className="ml-3">
|
||||||
|
<h3 className="text-sm font-medium text-blue-800 dark:text-blue-200">
|
||||||
|
Agent Script Usage
|
||||||
|
</h3>
|
||||||
|
<div className="mt-2 text-sm text-blue-700 dark:text-blue-300">
|
||||||
|
<p className="mb-2">This script is used for:</p>
|
||||||
|
<ul className="list-disc list-inside space-y-1">
|
||||||
|
<li>New agent installations via the install script</li>
|
||||||
|
<li>
|
||||||
|
Agent downloads from the /api/v1/hosts/agent/download
|
||||||
|
endpoint
|
||||||
|
</li>
|
||||||
|
<li>Manual agent deployments and updates</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Uninstall Instructions */}
|
||||||
|
<div className="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 rounded-md p-4">
|
||||||
|
<div className="flex">
|
||||||
|
<Shield 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">
|
||||||
|
Agent Uninstall Command
|
||||||
|
</h3>
|
||||||
|
<div className="mt-2 text-sm text-red-700 dark:text-red-300">
|
||||||
|
<p className="mb-2">
|
||||||
|
To completely remove PatchMon from a host:
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="bg-red-100 dark:bg-red-800 rounded p-2 font-mono text-xs flex-1">
|
||||||
|
curl {getCurlFlags()} {window.location.origin}
|
||||||
|
/api/v1/hosts/remove | sudo bash
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const command = `curl ${getCurlFlags()} ${window.location.origin}/api/v1/hosts/remove | sudo bash`;
|
||||||
|
navigator.clipboard.writeText(command);
|
||||||
|
// You could add a toast notification here
|
||||||
|
}}
|
||||||
|
className="px-2 py-1 bg-red-200 dark:bg-red-700 text-red-800 dark:text-red-200 rounded text-xs hover:bg-red-300 dark:hover:bg-red-600 transition-colors"
|
||||||
|
>
|
||||||
|
Copy
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-xs">
|
||||||
|
⚠️ This will remove all PatchMon files, configuration, and
|
||||||
|
crontab entries
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Agent Upload Modal */}
|
||||||
|
{showUploadModal && (
|
||||||
|
<AgentUploadModal
|
||||||
|
isOpen={showUploadModal}
|
||||||
|
onClose={() => setShowUploadModal(false)}
|
||||||
|
onSubmit={uploadAgentMutation.mutate}
|
||||||
|
isLoading={uploadAgentMutation.isPending}
|
||||||
|
error={uploadAgentMutation.error}
|
||||||
|
scriptFileId={scriptFileId}
|
||||||
|
scriptContentId={scriptContentId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Agent Upload Modal Component
|
||||||
|
const AgentUploadModal = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
scriptFileId,
|
||||||
|
scriptContentId,
|
||||||
|
}) => {
|
||||||
|
const [scriptContent, setScriptContent] = useState("");
|
||||||
|
const [uploadError, setUploadError] = useState("");
|
||||||
|
|
||||||
|
const handleSubmit = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setUploadError("");
|
||||||
|
|
||||||
|
if (!scriptContent.trim()) {
|
||||||
|
setUploadError("Script content is required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!scriptContent.trim().startsWith("#!/")) {
|
||||||
|
setUploadError(
|
||||||
|
"Script must start with a shebang (#!/bin/bash or #!/bin/sh)",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onSubmit(scriptContent);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileUpload = (e) => {
|
||||||
|
const file = e.target.files[0];
|
||||||
|
if (file) {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (event) => {
|
||||||
|
setScriptContent(event.target.result);
|
||||||
|
setUploadError("");
|
||||||
|
};
|
||||||
|
reader.readAsText(file);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) return 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 shadow-xl max-w-4xl w-full mx-4 max-h-[90vh] overflow-y-auto">
|
||||||
|
<div className="px-6 py-4 border-b border-secondary-200 dark:border-secondary-600">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
|
||||||
|
Replace Agent Script
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
type="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>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="px-6 py-4">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor={scriptFileId}
|
||||||
|
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2"
|
||||||
|
>
|
||||||
|
Upload Script File
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id={scriptFileId}
|
||||||
|
type="file"
|
||||||
|
accept=".sh"
|
||||||
|
onChange={handleFileUpload}
|
||||||
|
className="block w-full text-sm text-secondary-500 dark:text-secondary-400 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-medium file:bg-primary-50 file:text-primary-700 hover:file:bg-primary-100 dark:file:bg-primary-900 dark:file:text-primary-200"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||||
|
Select a .sh file to upload, or paste the script content below
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor={scriptContentId}
|
||||||
|
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2"
|
||||||
|
>
|
||||||
|
Script Content *
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id={scriptContentId}
|
||||||
|
value={scriptContent}
|
||||||
|
onChange={(e) => {
|
||||||
|
setScriptContent(e.target.value);
|
||||||
|
setUploadError("");
|
||||||
|
}}
|
||||||
|
rows={15}
|
||||||
|
className="block 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="#!/bin/bash # PatchMon Agent Script VERSION="1.0.0" # Your script content here..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(uploadError || error) && (
|
||||||
|
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md p-3">
|
||||||
|
<p className="text-sm text-red-800 dark:text-red-200">
|
||||||
|
{uploadError ||
|
||||||
|
error?.response?.data?.error ||
|
||||||
|
error?.message}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-md p-3">
|
||||||
|
<div className="flex">
|
||||||
|
<AlertCircle className="h-4 w-4 text-yellow-600 dark:text-yellow-400 mr-2 mt-0.5" />
|
||||||
|
<div className="text-sm text-yellow-800 dark:text-yellow-200">
|
||||||
|
<p className="font-medium">Important:</p>
|
||||||
|
<ul className="mt-1 list-disc list-inside space-y-1">
|
||||||
|
<li>This will replace the current agent script file</li>
|
||||||
|
<li>A backup will be created automatically</li>
|
||||||
|
<li>All new installations will use this script</li>
|
||||||
|
<li>
|
||||||
|
Existing agents will download this version on their next
|
||||||
|
update
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3 mt-6">
|
||||||
|
<button type="button" onClick={onClose} className="btn-outline">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading || !scriptContent.trim()}
|
||||||
|
className="btn-primary"
|
||||||
|
>
|
||||||
|
{isLoading ? "Uploading..." : "Replace Script"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AgentManagementTab;
|
||||||
453
frontend/src/components/settings/AgentUpdatesTab.jsx
Normal file
453
frontend/src/components/settings/AgentUpdatesTab.jsx
Normal file
@@ -0,0 +1,453 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { AlertCircle, CheckCircle, Save, Shield } from "lucide-react";
|
||||||
|
import { useEffect, useId, useState } from "react";
|
||||||
|
import { permissionsAPI, settingsAPI } from "../../utils/api";
|
||||||
|
|
||||||
|
const AgentUpdatesTab = () => {
|
||||||
|
const updateIntervalId = useId();
|
||||||
|
const autoUpdateId = useId();
|
||||||
|
const signupEnabledId = useId();
|
||||||
|
const defaultRoleId = useId();
|
||||||
|
const ignoreSslId = useId();
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
updateInterval: 60,
|
||||||
|
autoUpdate: false,
|
||||||
|
signupEnabled: false,
|
||||||
|
defaultUserRole: "user",
|
||||||
|
ignoreSslSelfSigned: false,
|
||||||
|
});
|
||||||
|
const [errors, setErrors] = useState({});
|
||||||
|
const [isDirty, setIsDirty] = useState(false);
|
||||||
|
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
// Fetch current settings
|
||||||
|
const {
|
||||||
|
data: settings,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ["settings"],
|
||||||
|
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
|
||||||
|
useEffect(() => {
|
||||||
|
if (settings) {
|
||||||
|
const newFormData = {
|
||||||
|
updateInterval: settings.update_interval || 60,
|
||||||
|
autoUpdate: settings.auto_update || false,
|
||||||
|
signupEnabled: settings.signup_enabled === true,
|
||||||
|
defaultUserRole: settings.default_user_role || "user",
|
||||||
|
ignoreSslSelfSigned: settings.ignore_ssl_self_signed === true,
|
||||||
|
};
|
||||||
|
setFormData(newFormData);
|
||||||
|
setIsDirty(false);
|
||||||
|
}
|
||||||
|
}, [settings]);
|
||||||
|
|
||||||
|
// Update settings mutation
|
||||||
|
const updateSettingsMutation = useMutation({
|
||||||
|
mutationFn: (data) => {
|
||||||
|
return settingsAPI.update(data).then((res) => res.data);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries(["settings"]);
|
||||||
|
setIsDirty(false);
|
||||||
|
setErrors({});
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
if (error.response?.data?.errors) {
|
||||||
|
setErrors(
|
||||||
|
error.response.data.errors.reduce((acc, err) => {
|
||||||
|
acc[err.path] = err.msg;
|
||||||
|
return acc;
|
||||||
|
}, {}),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setErrors({
|
||||||
|
general: error.response?.data?.error || "Failed to update settings",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Normalize update interval to safe presets
|
||||||
|
const normalizeInterval = (minutes) => {
|
||||||
|
let m = parseInt(minutes, 10);
|
||||||
|
if (Number.isNaN(m)) return 60;
|
||||||
|
if (m < 5) m = 5;
|
||||||
|
if (m > 1440) m = 1440;
|
||||||
|
// If less than 60 minutes, keep within 5-59 and step of 5
|
||||||
|
if (m < 60) {
|
||||||
|
return Math.min(59, Math.max(5, Math.round(m / 5) * 5));
|
||||||
|
}
|
||||||
|
// 60 or more: only allow exact hour multiples (60, 120, 180, 360, 720, 1440)
|
||||||
|
const allowed = [60, 120, 180, 360, 720, 1440];
|
||||||
|
// Snap to nearest allowed value
|
||||||
|
let nearest = allowed[0];
|
||||||
|
let bestDiff = Math.abs(m - nearest);
|
||||||
|
for (const a of allowed) {
|
||||||
|
const d = Math.abs(m - a);
|
||||||
|
if (d < bestDiff) {
|
||||||
|
bestDiff = d;
|
||||||
|
nearest = a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nearest;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInputChange = (field, value) => {
|
||||||
|
setFormData((prev) => {
|
||||||
|
const newData = {
|
||||||
|
...prev,
|
||||||
|
[field]: field === "updateInterval" ? normalizeInterval(value) : value,
|
||||||
|
};
|
||||||
|
return newData;
|
||||||
|
});
|
||||||
|
setIsDirty(true);
|
||||||
|
if (errors[field]) {
|
||||||
|
setErrors((prev) => ({ ...prev, [field]: null }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateForm = () => {
|
||||||
|
const newErrors = {};
|
||||||
|
|
||||||
|
if (
|
||||||
|
!formData.updateInterval ||
|
||||||
|
formData.updateInterval < 5 ||
|
||||||
|
formData.updateInterval > 1440
|
||||||
|
) {
|
||||||
|
newErrors.updateInterval =
|
||||||
|
"Update interval must be between 5 and 1440 minutes";
|
||||||
|
}
|
||||||
|
|
||||||
|
setErrors(newErrors);
|
||||||
|
return Object.keys(newErrors).length === 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
if (validateForm()) {
|
||||||
|
updateSettingsMutation.mutate(formData);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 rounded-md 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">
|
||||||
|
Error loading settings
|
||||||
|
</h3>
|
||||||
|
<p className="mt-1 text-sm text-red-700 dark:text-red-300">
|
||||||
|
{error.response?.data?.error || "Failed to load settings"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{errors.general && (
|
||||||
|
<div className="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 rounded-md p-4">
|
||||||
|
<div className="flex">
|
||||||
|
<AlertCircle className="h-5 w-5 text-red-400 dark:text-red-300" />
|
||||||
|
<div className="ml-3">
|
||||||
|
<p className="text-sm text-red-700 dark:text-red-300">
|
||||||
|
{errors.general}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form className="space-y-6">
|
||||||
|
{/* Update Interval */}
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor={updateIntervalId}
|
||||||
|
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2"
|
||||||
|
>
|
||||||
|
Agent Update Interval (minutes)
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{/* Numeric input (concise width) */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
id={updateIntervalId}
|
||||||
|
type="number"
|
||||||
|
min="5"
|
||||||
|
max="1440"
|
||||||
|
step="5"
|
||||||
|
value={formData.updateInterval}
|
||||||
|
onChange={(e) => {
|
||||||
|
const val = parseInt(e.target.value, 10);
|
||||||
|
if (!Number.isNaN(val)) {
|
||||||
|
handleInputChange(
|
||||||
|
"updateInterval",
|
||||||
|
Math.min(1440, Math.max(5, val)),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
handleInputChange("updateInterval", 60);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
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"
|
||||||
|
}`}
|
||||||
|
placeholder="60"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick presets */}
|
||||||
|
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||||
|
{[5, 10, 15, 30, 45, 60, 120, 180, 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) => {
|
||||||
|
const raw = parseInt(e.target.value, 10);
|
||||||
|
handleInputChange("updateInterval", normalizeInterval(raw));
|
||||||
|
}}
|
||||||
|
className="w-auto accent-primary-600"
|
||||||
|
aria-label="Update interval slider"
|
||||||
|
style={{ width: "fit-content", minWidth: "500px" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{errors.updateInterval && (
|
||||||
|
<p className="mt-1 text-sm text-red-600 dark:text-red-400">
|
||||||
|
{errors.updateInterval}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 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, 10) || 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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Auto-Update 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
|
||||||
|
id={autoUpdateId}
|
||||||
|
type="checkbox"
|
||||||
|
checked={formData.autoUpdate}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleInputChange("autoUpdate", 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"
|
||||||
|
/>
|
||||||
|
<label htmlFor={autoUpdateId}>
|
||||||
|
Enable Automatic Agent Updates
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<p className="mt-1 text-sm text-secondary-500 dark:text-secondary-400">
|
||||||
|
When enabled, agents will automatically update themselves when a
|
||||||
|
newer version is available during their regular update cycle.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* SSL Certificate 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
|
||||||
|
id={ignoreSslId}
|
||||||
|
type="checkbox"
|
||||||
|
checked={formData.ignoreSslSelfSigned}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleInputChange("ignoreSslSelfSigned", 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"
|
||||||
|
/>
|
||||||
|
<label htmlFor={ignoreSslId}>
|
||||||
|
Ignore SSL Self-Signed Certificates
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<p className="mt-1 text-sm text-secondary-500 dark:text-secondary-400">
|
||||||
|
When enabled, curl commands in agent scripts will use the -k flag to
|
||||||
|
ignore SSL certificate validation errors. Use with caution on
|
||||||
|
production systems as this reduces security.
|
||||||
|
</p>
|
||||||
|
</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
|
||||||
|
id={signupEnabledId}
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
<label htmlFor={signupEnabledId}>
|
||||||
|
Enable User Self-Registration
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{/* Default User Role Dropdown */}
|
||||||
|
{formData.signupEnabled && (
|
||||||
|
<div className="mt-3 ml-6">
|
||||||
|
<label
|
||||||
|
htmlFor={defaultRoleId}
|
||||||
|
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2"
|
||||||
|
>
|
||||||
|
Default Role for New Users
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id={defaultRoleId}
|
||||||
|
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 */}
|
||||||
|
<div className="bg-blue-50 dark:bg-blue-900 border border-blue-200 dark:border-blue-700 rounded-md p-4">
|
||||||
|
<div className="flex">
|
||||||
|
<Shield className="h-5 w-5 text-blue-400 dark:text-blue-300" />
|
||||||
|
<div className="ml-3">
|
||||||
|
<h3 className="text-sm font-medium text-blue-800 dark:text-blue-200">
|
||||||
|
Security Notice
|
||||||
|
</h3>
|
||||||
|
<p className="mt-1 text-sm text-blue-700 dark:text-blue-300">
|
||||||
|
When enabling user self-registration, exercise caution on
|
||||||
|
internal networks. Consider restricting access to trusted
|
||||||
|
networks only and ensure proper role assignments to prevent
|
||||||
|
unauthorized access to sensitive systems.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AgentUpdatesTab;
|
||||||
531
frontend/src/components/settings/BrandingTab.jsx
Normal file
531
frontend/src/components/settings/BrandingTab.jsx
Normal file
@@ -0,0 +1,531 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { AlertCircle, Image, RotateCcw, Upload, X } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { settingsAPI } from "../../utils/api";
|
||||||
|
|
||||||
|
const BrandingTab = () => {
|
||||||
|
// Logo management state
|
||||||
|
const [logoUploadState, setLogoUploadState] = useState({
|
||||||
|
dark: { uploading: false, error: null },
|
||||||
|
light: { uploading: false, error: null },
|
||||||
|
favicon: { uploading: false, error: null },
|
||||||
|
});
|
||||||
|
const [showLogoUploadModal, setShowLogoUploadModal] = useState(false);
|
||||||
|
const [selectedLogoType, setSelectedLogoType] = useState("dark");
|
||||||
|
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
// Fetch current settings
|
||||||
|
const {
|
||||||
|
data: settings,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ["settings"],
|
||||||
|
queryFn: () => settingsAPI.get().then((res) => res.data),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Logo upload mutation
|
||||||
|
const uploadLogoMutation = useMutation({
|
||||||
|
mutationFn: ({ logoType, fileContent, fileName }) =>
|
||||||
|
fetch("/api/v1/settings/logos/upload", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ logoType, fileContent, fileName }),
|
||||||
|
}).then((res) => res.json()),
|
||||||
|
onSuccess: (_data, variables) => {
|
||||||
|
queryClient.invalidateQueries(["settings"]);
|
||||||
|
setLogoUploadState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[variables.logoType]: { uploading: false, error: null },
|
||||||
|
}));
|
||||||
|
setShowLogoUploadModal(false);
|
||||||
|
},
|
||||||
|
onError: (error, variables) => {
|
||||||
|
console.error("Upload logo error:", error);
|
||||||
|
setLogoUploadState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[variables.logoType]: {
|
||||||
|
uploading: false,
|
||||||
|
error: error.message || "Failed to upload logo",
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Logo reset mutation
|
||||||
|
const resetLogoMutation = useMutation({
|
||||||
|
mutationFn: (logoType) =>
|
||||||
|
fetch("/api/v1/settings/logos/reset", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ logoType }),
|
||||||
|
}).then((res) => res.json()),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries(["settings"]);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
console.error("Reset logo error:", error);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
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-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 rounded-md 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">
|
||||||
|
Error loading settings
|
||||||
|
</h3>
|
||||||
|
<p className="mt-1 text-sm text-red-700 dark:text-red-300">
|
||||||
|
{error.response?.data?.error || "Failed to load settings"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center mb-6">
|
||||||
|
<Image className="h-6 w-6 text-primary-600 mr-3" />
|
||||||
|
<h2 className="text-xl font-semibold text-secondary-900 dark:text-white">
|
||||||
|
Logo & Branding
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-secondary-500 dark:text-secondary-300 mb-6">
|
||||||
|
Customize your PatchMon installation with custom logos and favicon.
|
||||||
|
These will be displayed throughout the application.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
{/* Dark Logo */}
|
||||||
|
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 border border-secondary-200 dark:border-secondary-600">
|
||||||
|
<h4 className="text-sm font-medium text-secondary-900 dark:text-white mb-4">
|
||||||
|
Dark Logo
|
||||||
|
</h4>
|
||||||
|
<div className="flex items-center justify-center p-4 bg-secondary-50 dark:bg-secondary-700 rounded-lg mb-4">
|
||||||
|
<img
|
||||||
|
src={`${settings?.logo_dark || "/assets/logo_dark.png"}?v=${Date.now()}`}
|
||||||
|
alt="Dark Logo"
|
||||||
|
className="max-h-16 max-w-full object-contain"
|
||||||
|
onError={(e) => {
|
||||||
|
e.target.src = "/assets/logo_dark.png";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-secondary-600 dark:text-secondary-400 mb-4 truncate">
|
||||||
|
{settings?.logo_dark
|
||||||
|
? settings.logo_dark.split("/").pop()
|
||||||
|
: "logo_dark.png (Default)"}
|
||||||
|
</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedLogoType("dark");
|
||||||
|
setShowLogoUploadModal(true);
|
||||||
|
}}
|
||||||
|
disabled={logoUploadState.dark.uploading}
|
||||||
|
className="w-full btn-outline flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
{logoUploadState.dark.uploading ? (
|
||||||
|
<>
|
||||||
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-current"></div>
|
||||||
|
Uploading...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Upload className="h-4 w-4" />
|
||||||
|
Upload Dark Logo
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{settings?.logo_dark && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => resetLogoMutation.mutate("dark")}
|
||||||
|
disabled={resetLogoMutation.isPending}
|
||||||
|
className="w-full btn-outline flex items-center justify-center gap-2 text-orange-600 hover:text-orange-700 border-orange-300 hover:border-orange-400"
|
||||||
|
>
|
||||||
|
<RotateCcw className="h-4 w-4" />
|
||||||
|
Reset to Default
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{logoUploadState.dark.error && (
|
||||||
|
<p className="text-xs text-red-600 dark:text-red-400 mt-2">
|
||||||
|
{logoUploadState.dark.error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Light Logo */}
|
||||||
|
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 border border-secondary-200 dark:border-secondary-600">
|
||||||
|
<h4 className="text-sm font-medium text-secondary-900 dark:text-white mb-4">
|
||||||
|
Light Logo
|
||||||
|
</h4>
|
||||||
|
<div className="flex items-center justify-center p-4 bg-secondary-50 dark:bg-secondary-700 rounded-lg mb-4">
|
||||||
|
<img
|
||||||
|
src={`${settings?.logo_light || "/assets/logo_light.png"}?v=${Date.now()}`}
|
||||||
|
alt="Light Logo"
|
||||||
|
className="max-h-16 max-w-full object-contain"
|
||||||
|
onError={(e) => {
|
||||||
|
e.target.src = "/assets/logo_light.png";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-secondary-600 dark:text-secondary-400 mb-4 truncate">
|
||||||
|
{settings?.logo_light
|
||||||
|
? settings.logo_light.split("/").pop()
|
||||||
|
: "logo_light.png (Default)"}
|
||||||
|
</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedLogoType("light");
|
||||||
|
setShowLogoUploadModal(true);
|
||||||
|
}}
|
||||||
|
disabled={logoUploadState.light.uploading}
|
||||||
|
className="w-full btn-outline flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
{logoUploadState.light.uploading ? (
|
||||||
|
<>
|
||||||
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-current"></div>
|
||||||
|
Uploading...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Upload className="h-4 w-4" />
|
||||||
|
Upload Light Logo
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{settings?.logo_light && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => resetLogoMutation.mutate("light")}
|
||||||
|
disabled={resetLogoMutation.isPending}
|
||||||
|
className="w-full btn-outline flex items-center justify-center gap-2 text-orange-600 hover:text-orange-700 border-orange-300 hover:border-orange-400"
|
||||||
|
>
|
||||||
|
<RotateCcw className="h-4 w-4" />
|
||||||
|
Reset to Default
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{logoUploadState.light.error && (
|
||||||
|
<p className="text-xs text-red-600 dark:text-red-400 mt-2">
|
||||||
|
{logoUploadState.light.error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Favicon */}
|
||||||
|
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 border border-secondary-200 dark:border-secondary-600">
|
||||||
|
<h4 className="text-sm font-medium text-secondary-900 dark:text-white mb-4">
|
||||||
|
Favicon
|
||||||
|
</h4>
|
||||||
|
<div className="flex items-center justify-center p-4 bg-secondary-50 dark:bg-secondary-700 rounded-lg mb-4">
|
||||||
|
<img
|
||||||
|
src={`${settings?.favicon || "/assets/favicon.svg"}?v=${Date.now()}`}
|
||||||
|
alt="Favicon"
|
||||||
|
className="h-8 w-8 object-contain"
|
||||||
|
onError={(e) => {
|
||||||
|
e.target.src = "/assets/favicon.svg";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-secondary-600 dark:text-secondary-400 mb-4 truncate">
|
||||||
|
{settings?.favicon
|
||||||
|
? settings.favicon.split("/").pop()
|
||||||
|
: "favicon.svg (Default)"}
|
||||||
|
</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedLogoType("favicon");
|
||||||
|
setShowLogoUploadModal(true);
|
||||||
|
}}
|
||||||
|
disabled={logoUploadState.favicon.uploading}
|
||||||
|
className="w-full btn-outline flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
{logoUploadState.favicon.uploading ? (
|
||||||
|
<>
|
||||||
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-current"></div>
|
||||||
|
Uploading...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Upload className="h-4 w-4" />
|
||||||
|
Upload Favicon
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{settings?.favicon && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => resetLogoMutation.mutate("favicon")}
|
||||||
|
disabled={resetLogoMutation.isPending}
|
||||||
|
className="w-full btn-outline flex items-center justify-center gap-2 text-orange-600 hover:text-orange-700 border-orange-300 hover:border-orange-400"
|
||||||
|
>
|
||||||
|
<RotateCcw className="h-4 w-4" />
|
||||||
|
Reset to Default
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{logoUploadState.favicon.error && (
|
||||||
|
<p className="text-xs text-red-600 dark:text-red-400 mt-2">
|
||||||
|
{logoUploadState.favicon.error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Usage Instructions */}
|
||||||
|
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-700 rounded-md p-4 mt-6">
|
||||||
|
<div className="flex">
|
||||||
|
<Image className="h-5 w-5 text-blue-400 dark:text-blue-300" />
|
||||||
|
<div className="ml-3">
|
||||||
|
<h3 className="text-sm font-medium text-blue-800 dark:text-blue-200">
|
||||||
|
Logo Usage
|
||||||
|
</h3>
|
||||||
|
<div className="mt-2 text-sm text-blue-700 dark:text-blue-300">
|
||||||
|
<p className="mb-2">
|
||||||
|
These logos are used throughout the application:
|
||||||
|
</p>
|
||||||
|
<ul className="list-disc list-inside space-y-1">
|
||||||
|
<li>
|
||||||
|
<strong>Dark Logo:</strong> Used in dark mode and on light
|
||||||
|
backgrounds
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Light Logo:</strong> Used in light mode and on dark
|
||||||
|
backgrounds
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Favicon:</strong> Used as the browser tab icon (SVG
|
||||||
|
recommended)
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p className="mt-3 text-xs">
|
||||||
|
<strong>Supported formats:</strong> PNG, JPG, SVG |{" "}
|
||||||
|
<strong>Max size:</strong> 5MB |{" "}
|
||||||
|
<strong>Recommended sizes:</strong> 200x60px for logos, 32x32px
|
||||||
|
for favicon.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Logo Upload Modal */}
|
||||||
|
{showLogoUploadModal && (
|
||||||
|
<LogoUploadModal
|
||||||
|
isOpen={showLogoUploadModal}
|
||||||
|
onClose={() => setShowLogoUploadModal(false)}
|
||||||
|
onSubmit={uploadLogoMutation.mutate}
|
||||||
|
isLoading={uploadLogoMutation.isPending}
|
||||||
|
error={uploadLogoMutation.error}
|
||||||
|
logoType={selectedLogoType}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Logo Upload Modal Component
|
||||||
|
const LogoUploadModal = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
logoType,
|
||||||
|
}) => {
|
||||||
|
const [selectedFile, setSelectedFile] = useState(null);
|
||||||
|
const [previewUrl, setPreviewUrl] = useState(null);
|
||||||
|
const [uploadError, setUploadError] = useState("");
|
||||||
|
|
||||||
|
const handleFileSelect = (e) => {
|
||||||
|
const file = e.target.files[0];
|
||||||
|
if (file) {
|
||||||
|
// Validate file type
|
||||||
|
const allowedTypes = [
|
||||||
|
"image/png",
|
||||||
|
"image/jpeg",
|
||||||
|
"image/jpg",
|
||||||
|
"image/svg+xml",
|
||||||
|
];
|
||||||
|
if (!allowedTypes.includes(file.type)) {
|
||||||
|
setUploadError("Please select a PNG, JPG, or SVG file");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate file size (5MB limit)
|
||||||
|
if (file.size > 5 * 1024 * 1024) {
|
||||||
|
setUploadError("File size must be less than 5MB");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedFile(file);
|
||||||
|
setUploadError("");
|
||||||
|
|
||||||
|
// Create preview URL
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
setPreviewUrl(url);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setUploadError("");
|
||||||
|
|
||||||
|
if (!selectedFile) {
|
||||||
|
setUploadError("Please select a file");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert file to base64
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (event) => {
|
||||||
|
const base64 = event.target.result;
|
||||||
|
onSubmit({
|
||||||
|
logoType,
|
||||||
|
fileContent: base64,
|
||||||
|
fileName: selectedFile.name,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(selectedFile);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setSelectedFile(null);
|
||||||
|
setPreviewUrl(null);
|
||||||
|
setUploadError("");
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) return 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 shadow-xl max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
|
||||||
|
<div className="px-6 py-4 border-b border-secondary-200 dark:border-secondary-600">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
|
||||||
|
Upload{" "}
|
||||||
|
{logoType === "favicon"
|
||||||
|
? "Favicon"
|
||||||
|
: `${logoType.charAt(0).toUpperCase() + logoType.slice(1)} Logo`}
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClose}
|
||||||
|
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>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="px-6 py-4">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block">
|
||||||
|
<span className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2">
|
||||||
|
Select File
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/png,image/jpeg,image/jpg,image/svg+xml"
|
||||||
|
onChange={handleFileSelect}
|
||||||
|
className="block w-full text-sm text-secondary-500 dark:text-secondary-400 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-medium file:bg-primary-50 file:text-primary-700 hover:file:bg-primary-100 dark:file:bg-primary-900 dark:file:text-primary-200"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<p className="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||||
|
Supported formats: PNG, JPG, SVG. Max size: 5MB.
|
||||||
|
{logoType === "favicon"
|
||||||
|
? " Recommended: 32x32px SVG."
|
||||||
|
: " Recommended: 200x60px."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{previewUrl && (
|
||||||
|
<div>
|
||||||
|
<div className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2">
|
||||||
|
Preview
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-center p-4 bg-white dark:bg-secondary-800 rounded-lg border border-secondary-200 dark:border-secondary-600">
|
||||||
|
<img
|
||||||
|
src={previewUrl}
|
||||||
|
alt="Preview"
|
||||||
|
className={`object-contain ${
|
||||||
|
logoType === "favicon" ? "h-8 w-8" : "max-h-16 max-w-full"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(uploadError || error) && (
|
||||||
|
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md p-3">
|
||||||
|
<p className="text-sm text-red-800 dark:text-red-200">
|
||||||
|
{uploadError ||
|
||||||
|
error?.response?.data?.error ||
|
||||||
|
error?.message}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-md p-3">
|
||||||
|
<div className="flex">
|
||||||
|
<AlertCircle className="h-4 w-4 text-yellow-600 dark:text-yellow-400 mr-2 mt-0.5" />
|
||||||
|
<div className="text-sm text-yellow-800 dark:text-yellow-200">
|
||||||
|
<p className="font-medium">Important:</p>
|
||||||
|
<ul className="mt-1 list-disc list-inside space-y-1">
|
||||||
|
<li>This will replace the current {logoType} logo</li>
|
||||||
|
<li>A backup will be created automatically</li>
|
||||||
|
<li>The change will be applied immediately</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3 mt-6">
|
||||||
|
<button type="button" onClick={handleClose} className="btn-outline">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading || !selectedFile}
|
||||||
|
className="btn-primary"
|
||||||
|
>
|
||||||
|
{isLoading ? "Uploading..." : "Upload Logo"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BrandingTab;
|
||||||
305
frontend/src/components/settings/ProtocolUrlTab.jsx
Normal file
305
frontend/src/components/settings/ProtocolUrlTab.jsx
Normal file
@@ -0,0 +1,305 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { AlertCircle, CheckCircle, Save, Server, Shield } from "lucide-react";
|
||||||
|
import { useEffect, useId, useState } from "react";
|
||||||
|
import { settingsAPI } from "../../utils/api";
|
||||||
|
|
||||||
|
const ProtocolUrlTab = () => {
|
||||||
|
const protocolId = useId();
|
||||||
|
const hostId = useId();
|
||||||
|
const portId = useId();
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
serverProtocol: "http",
|
||||||
|
serverHost: "localhost",
|
||||||
|
serverPort: 3001,
|
||||||
|
});
|
||||||
|
const [errors, setErrors] = useState({});
|
||||||
|
const [isDirty, setIsDirty] = useState(false);
|
||||||
|
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
// Fetch current settings
|
||||||
|
const {
|
||||||
|
data: settings,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ["settings"],
|
||||||
|
queryFn: () => settingsAPI.get().then((res) => res.data),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update form data when settings are loaded
|
||||||
|
useEffect(() => {
|
||||||
|
if (settings) {
|
||||||
|
const newFormData = {
|
||||||
|
serverProtocol: settings.server_protocol || "http",
|
||||||
|
serverHost: settings.server_host || "localhost",
|
||||||
|
serverPort: settings.server_port || 3001,
|
||||||
|
};
|
||||||
|
setFormData(newFormData);
|
||||||
|
setIsDirty(false);
|
||||||
|
}
|
||||||
|
}, [settings]);
|
||||||
|
|
||||||
|
// Update settings mutation
|
||||||
|
const updateSettingsMutation = useMutation({
|
||||||
|
mutationFn: (data) => {
|
||||||
|
return settingsAPI.update(data).then((res) => res.data);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries(["settings"]);
|
||||||
|
setIsDirty(false);
|
||||||
|
setErrors({});
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
if (error.response?.data?.errors) {
|
||||||
|
setErrors(
|
||||||
|
error.response.data.errors.reduce((acc, err) => {
|
||||||
|
acc[err.path] = err.msg;
|
||||||
|
return acc;
|
||||||
|
}, {}),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setErrors({
|
||||||
|
general: error.response?.data?.error || "Failed to update settings",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleInputChange = (field, value) => {
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[field]: value,
|
||||||
|
}));
|
||||||
|
setIsDirty(true);
|
||||||
|
if (errors[field]) {
|
||||||
|
setErrors((prev) => ({ ...prev, [field]: null }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateForm = () => {
|
||||||
|
const newErrors = {};
|
||||||
|
|
||||||
|
if (!formData.serverHost.trim()) {
|
||||||
|
newErrors.serverHost = "Server host is required";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!formData.serverPort ||
|
||||||
|
formData.serverPort < 1 ||
|
||||||
|
formData.serverPort > 65535
|
||||||
|
) {
|
||||||
|
newErrors.serverPort = "Port must be between 1 and 65535";
|
||||||
|
}
|
||||||
|
|
||||||
|
setErrors(newErrors);
|
||||||
|
return Object.keys(newErrors).length === 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
if (validateForm()) {
|
||||||
|
updateSettingsMutation.mutate(formData);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 rounded-md 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">
|
||||||
|
Error loading settings
|
||||||
|
</h3>
|
||||||
|
<p className="mt-1 text-sm text-red-700 dark:text-red-300">
|
||||||
|
{error.response?.data?.error || "Failed to load settings"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{errors.general && (
|
||||||
|
<div className="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 rounded-md p-4">
|
||||||
|
<div className="flex">
|
||||||
|
<AlertCircle className="h-5 w-5 text-red-400 dark:text-red-300" />
|
||||||
|
<div className="ml-3">
|
||||||
|
<p className="text-sm text-red-700 dark:text-red-300">
|
||||||
|
{errors.general}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form className="space-y-6">
|
||||||
|
<div className="flex items-center mb-6">
|
||||||
|
<Server className="h-6 w-6 text-primary-600 mr-3" />
|
||||||
|
<h2 className="text-xl font-semibold text-secondary-900 dark:text-white">
|
||||||
|
Server Configuration
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor={protocolId}
|
||||||
|
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2"
|
||||||
|
>
|
||||||
|
Protocol
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id={protocolId}
|
||||||
|
value={formData.serverProtocol}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleInputChange("serverProtocol", e.target.value)
|
||||||
|
}
|
||||||
|
className="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"
|
||||||
|
>
|
||||||
|
<option value="http">HTTP</option>
|
||||||
|
<option value="https">HTTPS</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor={hostId}
|
||||||
|
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2"
|
||||||
|
>
|
||||||
|
Host *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id={hostId}
|
||||||
|
type="text"
|
||||||
|
value={formData.serverHost}
|
||||||
|
onChange={(e) => handleInputChange("serverHost", 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.serverHost
|
||||||
|
? "border-red-300 dark:border-red-500"
|
||||||
|
: "border-secondary-300 dark:border-secondary-600"
|
||||||
|
}`}
|
||||||
|
placeholder="example.com"
|
||||||
|
/>
|
||||||
|
{errors.serverHost && (
|
||||||
|
<p className="mt-1 text-sm text-red-600 dark:text-red-400">
|
||||||
|
{errors.serverHost}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor={portId}
|
||||||
|
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2"
|
||||||
|
>
|
||||||
|
Port *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id={portId}
|
||||||
|
type="number"
|
||||||
|
value={formData.serverPort}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleInputChange("serverPort", parseInt(e.target.value, 10))
|
||||||
|
}
|
||||||
|
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.serverPort
|
||||||
|
? "border-red-300 dark:border-red-500"
|
||||||
|
: "border-secondary-300 dark:border-secondary-600"
|
||||||
|
}`}
|
||||||
|
min="1"
|
||||||
|
max="65535"
|
||||||
|
/>
|
||||||
|
{errors.serverPort && (
|
||||||
|
<p className="mt-1 text-sm text-red-600 dark:text-red-400">
|
||||||
|
{errors.serverPort}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 p-4 bg-secondary-50 dark:bg-secondary-700 rounded-md">
|
||||||
|
<h4 className="text-sm font-medium text-secondary-900 dark:text-white mb-2">
|
||||||
|
Server URL
|
||||||
|
</h4>
|
||||||
|
<p className="text-sm text-secondary-600 dark:text-secondary-300 font-mono">
|
||||||
|
{formData.serverProtocol}://{formData.serverHost}:
|
||||||
|
{formData.serverPort}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-secondary-500 dark:text-secondary-400 mt-1">
|
||||||
|
This URL will be used in installation scripts and agent
|
||||||
|
communications.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 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="flex">
|
||||||
|
<Shield className="h-5 w-5 text-blue-400 dark:text-blue-300" />
|
||||||
|
<div className="ml-3">
|
||||||
|
<h3 className="text-sm font-medium text-blue-800 dark:text-blue-200">
|
||||||
|
Security Notice
|
||||||
|
</h3>
|
||||||
|
<p className="mt-1 text-sm text-blue-700 dark:text-blue-300">
|
||||||
|
Changing these settings will affect all installation scripts and
|
||||||
|
agent communications. Make sure the server URL is accessible
|
||||||
|
from your client networks.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProtocolUrlTab;
|
||||||
568
frontend/src/components/settings/RolesTab.jsx
Normal file
568
frontend/src/components/settings/RolesTab.jsx
Normal file
@@ -0,0 +1,568 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
BarChart3,
|
||||||
|
CheckCircle,
|
||||||
|
Download,
|
||||||
|
Edit,
|
||||||
|
Package,
|
||||||
|
Save,
|
||||||
|
Server,
|
||||||
|
Settings,
|
||||||
|
Shield,
|
||||||
|
Trash2,
|
||||||
|
Users,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useEffect, useId, useState } from "react";
|
||||||
|
import { useAuth } from "../../contexts/AuthContext";
|
||||||
|
import { permissionsAPI } from "../../utils/api";
|
||||||
|
|
||||||
|
const RolesTab = () => {
|
||||||
|
const [editingRole, setEditingRole] = useState(null);
|
||||||
|
const [showAddModal, setShowAddModal] = useState(false);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { refreshPermissions } = useAuth();
|
||||||
|
|
||||||
|
// Listen for the header button event to open add modal
|
||||||
|
useEffect(() => {
|
||||||
|
const handleOpenAddModal = () => setShowAddModal(true);
|
||||||
|
window.addEventListener("openAddRoleModal", handleOpenAddModal);
|
||||||
|
return () =>
|
||||||
|
window.removeEventListener("openAddRoleModal", handleOpenAddModal);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Fetch all role permissions
|
||||||
|
const {
|
||||||
|
data: roles,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ["rolePermissions"],
|
||||||
|
queryFn: () => permissionsAPI.getRoles().then((res) => res.data),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update role permissions mutation
|
||||||
|
const updateRoleMutation = useMutation({
|
||||||
|
mutationFn: ({ role, permissions }) =>
|
||||||
|
permissionsAPI.updateRole(role, permissions),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries(["rolePermissions"]);
|
||||||
|
setEditingRole(null);
|
||||||
|
// Refresh user permissions to apply changes immediately
|
||||||
|
refreshPermissions();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete role mutation
|
||||||
|
const deleteRoleMutation = useMutation({
|
||||||
|
mutationFn: (role) => permissionsAPI.deleteRole(role),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries(["rolePermissions"]);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSavePermissions = async (role, permissions) => {
|
||||||
|
try {
|
||||||
|
await updateRoleMutation.mutateAsync({ role, permissions });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to update permissions:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteRole = async (role) => {
|
||||||
|
if (
|
||||||
|
window.confirm(
|
||||||
|
`Are you sure you want to delete the "${role}" role? This action cannot be undone.`,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
await deleteRoleMutation.mutateAsync(role);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to delete role:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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 permissions
|
||||||
|
</h3>
|
||||||
|
<p className="mt-1 text-sm text-danger-700">{error.message}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Roles Matrix Table */}
|
||||||
|
<div className="bg-white dark:bg-secondary-800 shadow overflow-hidden sm:rounded-lg">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full divide-y divide-secondary-200 dark:divide-secondary-600">
|
||||||
|
<thead className="bg-secondary-50 dark:bg-secondary-700">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||||
|
Permission
|
||||||
|
</th>
|
||||||
|
{roles &&
|
||||||
|
Array.isArray(roles) &&
|
||||||
|
roles.map((r) => (
|
||||||
|
<th
|
||||||
|
key={r.role}
|
||||||
|
className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="capitalize">
|
||||||
|
{r.role.replace(/_/g, " ")}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEditingRole(r.role)}
|
||||||
|
className="text-secondary-400 hover:text-secondary-600 dark:text-secondary-400 dark:hover:text-secondary-200"
|
||||||
|
title="Edit role permissions"
|
||||||
|
>
|
||||||
|
<Edit className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-600">
|
||||||
|
{roles &&
|
||||||
|
Array.isArray(roles) &&
|
||||||
|
roles.length > 0 &&
|
||||||
|
Object.keys(roles[0])
|
||||||
|
.filter((k) => k.startsWith("can_"))
|
||||||
|
.map((permKey) => (
|
||||||
|
<tr
|
||||||
|
key={permKey}
|
||||||
|
className="hover:bg-secondary-50 dark:hover:bg-secondary-700"
|
||||||
|
>
|
||||||
|
<td className="px-6 py-3 text-sm font-medium text-secondary-700 dark:text-secondary-200 whitespace-nowrap">
|
||||||
|
{permKey
|
||||||
|
.replace(/^can_/, "")
|
||||||
|
.split("_")
|
||||||
|
.map((s) => s.charAt(0).toUpperCase() + s.slice(1))
|
||||||
|
.join(" ")}
|
||||||
|
</td>
|
||||||
|
{roles.map((r) => (
|
||||||
|
<td
|
||||||
|
key={`${r.role}-${permKey}`}
|
||||||
|
className="px-6 py-3 whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{r[permKey] ? (
|
||||||
|
<div className="flex items-center text-green-600">
|
||||||
|
<CheckCircle className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center text-red-600">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Inline editor for selected role */}
|
||||||
|
{editingRole && roles && Array.isArray(roles) && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{roles
|
||||||
|
.filter((r) => r.role === editingRole)
|
||||||
|
.map((r) => (
|
||||||
|
<RolePermissionsCard
|
||||||
|
key={`editor-${r.role}`}
|
||||||
|
role={r}
|
||||||
|
isEditing={true}
|
||||||
|
onEdit={() => {}}
|
||||||
|
onCancel={() => setEditingRole(null)}
|
||||||
|
onSave={handleSavePermissions}
|
||||||
|
onDelete={handleDeleteRole}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add Role Modal */}
|
||||||
|
<AddRoleModal
|
||||||
|
isOpen={showAddModal}
|
||||||
|
onClose={() => setShowAddModal(false)}
|
||||||
|
onSuccess={() => {
|
||||||
|
queryClient.invalidateQueries(["rolePermissions"]);
|
||||||
|
setShowAddModal(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Role Permissions Card Component
|
||||||
|
const RolePermissionsCard = ({
|
||||||
|
role,
|
||||||
|
isEditing,
|
||||||
|
onEdit,
|
||||||
|
onCancel,
|
||||||
|
onSave,
|
||||||
|
onDelete,
|
||||||
|
}) => {
|
||||||
|
const [permissions, setPermissions] = useState(role);
|
||||||
|
|
||||||
|
// Sync permissions state with role prop when it changes
|
||||||
|
useEffect(() => {
|
||||||
|
setPermissions(role);
|
||||||
|
}, [role]);
|
||||||
|
|
||||||
|
const permissionFields = [
|
||||||
|
{
|
||||||
|
key: "can_view_dashboard",
|
||||||
|
label: "View Dashboard",
|
||||||
|
icon: BarChart3,
|
||||||
|
description: "Access to the main dashboard",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "can_view_hosts",
|
||||||
|
label: "View Hosts",
|
||||||
|
icon: Server,
|
||||||
|
description: "See host information and status",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "can_manage_hosts",
|
||||||
|
label: "Manage Hosts",
|
||||||
|
icon: Edit,
|
||||||
|
description: "Add, edit, and delete hosts",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "can_view_packages",
|
||||||
|
label: "View Packages",
|
||||||
|
icon: Package,
|
||||||
|
description: "See package information",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "can_manage_packages",
|
||||||
|
label: "Manage Packages",
|
||||||
|
icon: Settings,
|
||||||
|
description: "Edit package details",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "can_view_users",
|
||||||
|
label: "View Users",
|
||||||
|
icon: Users,
|
||||||
|
description: "See user list and details",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "can_manage_users",
|
||||||
|
label: "Manage Users",
|
||||||
|
icon: Shield,
|
||||||
|
description: "Add, edit, and delete users",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "can_view_reports",
|
||||||
|
label: "View Reports",
|
||||||
|
icon: BarChart3,
|
||||||
|
description: "Access to reports and analytics",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "can_export_data",
|
||||||
|
label: "Export Data",
|
||||||
|
icon: Download,
|
||||||
|
description: "Download data and reports",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "can_manage_settings",
|
||||||
|
label: "Manage Settings",
|
||||||
|
icon: Settings,
|
||||||
|
description: "System configuration access",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const handlePermissionChange = (key, value) => {
|
||||||
|
setPermissions((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[key]: value,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
onSave(role.role, permissions);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isBuiltInRole = role.role === "admin" || role.role === "user";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-secondary-800 shadow rounded-lg">
|
||||||
|
<div className="px-6 py-4 border-b border-secondary-200 dark:border-secondary-600">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<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>
|
||||||
|
{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">
|
||||||
|
Built-in Role
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
{isEditing ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSave}
|
||||||
|
className="inline-flex items-center px-3 py-1 border border-transparent text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700"
|
||||||
|
>
|
||||||
|
<Save className="h-4 w-4 mr-1" />
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCancel}
|
||||||
|
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" />
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
{!isBuiltInRole && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 mr-1" />
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onEdit}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<Edit className="h-4 w-4 mr-1" />
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
{!isBuiltInRole && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 mr-1" />
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-6 py-4">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{permissionFields.map((field) => {
|
||||||
|
const Icon = field.icon;
|
||||||
|
const isChecked = permissions[field.key];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={field.key} className="flex items-start">
|
||||||
|
<div className="flex items-center h-5">
|
||||||
|
<input
|
||||||
|
id={`${role.role}-${field.key}`}
|
||||||
|
type="checkbox"
|
||||||
|
checked={isChecked}
|
||||||
|
onChange={(e) =>
|
||||||
|
handlePermissionChange(field.key, e.target.checked)
|
||||||
|
}
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="ml-3">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Icon className="h-4 w-4 text-secondary-400 mr-2" />
|
||||||
|
<label
|
||||||
|
htmlFor={`${role.role}-${field.key}`}
|
||||||
|
className="text-sm font-medium text-secondary-900 dark:text-white"
|
||||||
|
>
|
||||||
|
{field.label}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-secondary-500 mt-1">
|
||||||
|
{field.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add Role Modal Component
|
||||||
|
const AddRoleModal = ({ isOpen, onClose, onSuccess }) => {
|
||||||
|
const roleNameInputId = useId();
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
role: "",
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsLoading(true);
|
||||||
|
setError("");
|
||||||
|
|
||||||
|
try {
|
||||||
|
await permissionsAPI.updateRole(formData.role, formData);
|
||||||
|
onSuccess();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.response?.data?.error || "Failed to create role");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInputChange = (e) => {
|
||||||
|
const { name, value, type, checked } = e.target;
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
[name]: type === "checkbox" ? checked : value,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) return 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-2xl max-h-[90vh] overflow-y-auto">
|
||||||
|
<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">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor={roleNameInputId}
|
||||||
|
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1"
|
||||||
|
>
|
||||||
|
Role Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id={roleNameInputId}
|
||||||
|
type="text"
|
||||||
|
name="role"
|
||||||
|
required
|
||||||
|
value={formData.role}
|
||||||
|
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"
|
||||||
|
placeholder="e.g., host_manager, readonly"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||||
|
Use lowercase with underscores (e.g., host_manager)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h4 className="text-sm font-medium text-secondary-900 dark:text-white">
|
||||||
|
Permissions
|
||||||
|
</h4>
|
||||||
|
{[
|
||||||
|
{ key: "can_view_dashboard", label: "View Dashboard" },
|
||||||
|
{ key: "can_view_hosts", label: "View Hosts" },
|
||||||
|
{ key: "can_manage_hosts", label: "Manage Hosts" },
|
||||||
|
{ key: "can_view_packages", label: "View Packages" },
|
||||||
|
{ key: "can_manage_packages", label: "Manage Packages" },
|
||||||
|
{ key: "can_view_users", label: "View Users" },
|
||||||
|
{ key: "can_manage_users", label: "Manage Users" },
|
||||||
|
{ key: "can_view_reports", label: "View Reports" },
|
||||||
|
{ key: "can_export_data", label: "Export Data" },
|
||||||
|
{ key: "can_manage_settings", label: "Manage Settings" },
|
||||||
|
].map((permission) => (
|
||||||
|
<div key={permission.key} className="flex items-center">
|
||||||
|
<input
|
||||||
|
id={`add-role-${permission.key}`}
|
||||||
|
type="checkbox"
|
||||||
|
name={permission.key}
|
||||||
|
checked={formData[permission.key]}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 rounded"
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor={`add-role-${permission.key}`}
|
||||||
|
className="ml-2 block text-sm text-secondary-700 dark:text-secondary-200"
|
||||||
|
>
|
||||||
|
{permission.label}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<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 dark:text-danger-300">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end space-x-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
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
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-white bg-primary-600 border border-transparent rounded-md hover:bg-primary-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isLoading ? "Creating..." : "Create Role"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RolesTab;
|
||||||
980
frontend/src/components/settings/UsersTab.jsx
Normal file
980
frontend/src/components/settings/UsersTab.jsx
Normal file
@@ -0,0 +1,980 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
Calendar,
|
||||||
|
CheckCircle,
|
||||||
|
Edit,
|
||||||
|
Key,
|
||||||
|
Mail,
|
||||||
|
Shield,
|
||||||
|
Trash2,
|
||||||
|
User,
|
||||||
|
XCircle,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useEffect, useId, useState } from "react";
|
||||||
|
import { useAuth } from "../../contexts/AuthContext";
|
||||||
|
import { adminUsersAPI, permissionsAPI } from "../../utils/api";
|
||||||
|
|
||||||
|
const UsersTab = () => {
|
||||||
|
const [showAddModal, setShowAddModal] = useState(false);
|
||||||
|
const [editingUser, setEditingUser] = useState(null);
|
||||||
|
const [resetPasswordUser, setResetPasswordUser] = useState(null);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { user: currentUser } = useAuth();
|
||||||
|
|
||||||
|
// Listen for the header button event to open add modal
|
||||||
|
useEffect(() => {
|
||||||
|
const handleOpenAddModal = () => setShowAddModal(true);
|
||||||
|
window.addEventListener("openAddUserModal", handleOpenAddModal);
|
||||||
|
return () =>
|
||||||
|
window.removeEventListener("openAddUserModal", handleOpenAddModal);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Fetch users
|
||||||
|
const {
|
||||||
|
data: users,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ["users"],
|
||||||
|
queryFn: () => adminUsersAPI.list().then((res) => res.data),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch available roles
|
||||||
|
const { data: roles } = useQuery({
|
||||||
|
queryKey: ["rolePermissions"],
|
||||||
|
queryFn: () => permissionsAPI.getRoles().then((res) => res.data),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete user mutation
|
||||||
|
const deleteUserMutation = useMutation({
|
||||||
|
mutationFn: adminUsersAPI.delete,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries(["users"]);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update user mutation
|
||||||
|
const _updateUserMutation = useMutation({
|
||||||
|
mutationFn: ({ id, data }) => adminUsersAPI.update(id, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries(["users"]);
|
||||||
|
setEditingUser(null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset password mutation
|
||||||
|
const resetPasswordMutation = useMutation({
|
||||||
|
mutationFn: ({ userId, newPassword }) =>
|
||||||
|
adminUsersAPI.resetPassword(userId, newPassword),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries(["users"]);
|
||||||
|
setResetPasswordUser(null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleDeleteUser = async (userId, username) => {
|
||||||
|
if (
|
||||||
|
window.confirm(
|
||||||
|
`Are you sure you want to delete user "${username}"? This action cannot be undone.`,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
await deleteUserMutation.mutateAsync(userId);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to delete user:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUserCreated = () => {
|
||||||
|
queryClient.invalidateQueries(["users"]);
|
||||||
|
setShowAddModal(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditUser = (user) => {
|
||||||
|
// Reset editingUser first to force re-render with fresh data
|
||||||
|
setEditingUser(null);
|
||||||
|
// Use setTimeout to ensure the modal re-initializes with fresh data
|
||||||
|
setTimeout(() => {
|
||||||
|
setEditingUser(user);
|
||||||
|
}, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResetPassword = (user) => {
|
||||||
|
setResetPasswordUser(user);
|
||||||
|
};
|
||||||
|
|
||||||
|
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">
|
||||||
|
<XCircle className="h-5 w-5 text-danger-400" />
|
||||||
|
<div className="ml-3">
|
||||||
|
<h3 className="text-sm font-medium text-danger-800">
|
||||||
|
Error loading users
|
||||||
|
</h3>
|
||||||
|
<p className="mt-1 text-sm text-danger-700">{error.message}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Users Table */}
|
||||||
|
<div className="bg-white dark:bg-secondary-800 shadow overflow-hidden sm:rounded-lg">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full divide-y divide-secondary-200 dark:divide-secondary-600">
|
||||||
|
<thead className="bg-secondary-50 dark:bg-secondary-700">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||||
|
User
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||||
|
Email
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||||
|
Role
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||||
|
Status
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||||
|
Created
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||||
|
Last Login
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-right text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||||
|
Actions
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-600">
|
||||||
|
{users && Array.isArray(users) && users.length > 0 ? (
|
||||||
|
users.map((user) => (
|
||||||
|
<tr
|
||||||
|
key={user.id}
|
||||||
|
className="hover:bg-secondary-50 dark:hover:bg-secondary-700"
|
||||||
|
>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="flex-shrink-0 h-10 w-10">
|
||||||
|
<div className="h-10 w-10 rounded-full bg-primary-100 flex items-center justify-center">
|
||||||
|
<User className="h-5 w-5 text-primary-600" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="ml-4">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="text-sm font-medium text-secondary-900 dark:text-white">
|
||||||
|
{user.username}
|
||||||
|
</div>
|
||||||
|
{user.id === currentUser?.id && (
|
||||||
|
<span className="ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||||
|
You
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div className="flex items-center text-sm text-secondary-500 dark:text-secondary-300">
|
||||||
|
<Mail className="h-4 w-4 mr-2" />
|
||||||
|
{user.email}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||||
|
user.role === "admin"
|
||||||
|
? "bg-primary-100 text-primary-800"
|
||||||
|
: user.role === "host_manager"
|
||||||
|
? "bg-green-100 text-green-800"
|
||||||
|
: user.role === "readonly"
|
||||||
|
? "bg-yellow-100 text-yellow-800"
|
||||||
|
: "bg-secondary-100 text-secondary-800"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Shield className="h-3 w-3 mr-1" />
|
||||||
|
{user.role.charAt(0).toUpperCase() +
|
||||||
|
user.role.slice(1).replace("_", " ")}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
{user.is_active ? (
|
||||||
|
<div className="flex items-center text-green-600">
|
||||||
|
<CheckCircle className="h-4 w-4 mr-1" />
|
||||||
|
<span className="text-sm">Active</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center text-red-600">
|
||||||
|
<XCircle className="h-4 w-4 mr-1" />
|
||||||
|
<span className="text-sm">Inactive</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div className="flex items-center text-sm text-secondary-500 dark:text-secondary-300">
|
||||||
|
<Calendar className="h-4 w-4 mr-2" />
|
||||||
|
{new Date(user.created_at).toLocaleDateString()}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-secondary-500 dark:text-secondary-300">
|
||||||
|
{user.last_login ? (
|
||||||
|
new Date(user.last_login).toLocaleDateString()
|
||||||
|
) : (
|
||||||
|
<span className="text-secondary-400">Never</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||||
|
<div className="flex items-center justify-end space-x-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleEditUser(user)}
|
||||||
|
className="text-secondary-400 hover:text-secondary-600 dark:text-secondary-500 dark:hover:text-secondary-300"
|
||||||
|
title="Edit user"
|
||||||
|
>
|
||||||
|
<Edit className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
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"
|
||||||
|
title={
|
||||||
|
!user.is_active
|
||||||
|
? "Cannot reset password for inactive user"
|
||||||
|
: "Reset password"
|
||||||
|
}
|
||||||
|
disabled={!user.is_active}
|
||||||
|
>
|
||||||
|
<Key className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
handleDeleteUser(user.id, user.username)
|
||||||
|
}
|
||||||
|
className="text-danger-400 hover:text-danger-600 dark:text-danger-500 dark:hover:text-danger-400 disabled:text-gray-300 disabled:cursor-not-allowed"
|
||||||
|
title={
|
||||||
|
user.id === currentUser?.id
|
||||||
|
? "Cannot delete your own account"
|
||||||
|
: user.role === "admin" &&
|
||||||
|
users.filter((u) => u.role === "admin")
|
||||||
|
.length === 1
|
||||||
|
? "Cannot delete the last admin user"
|
||||||
|
: "Delete user"
|
||||||
|
}
|
||||||
|
disabled={
|
||||||
|
user.id === currentUser?.id ||
|
||||||
|
(user.role === "admin" &&
|
||||||
|
users.filter((u) => u.role === "admin").length ===
|
||||||
|
1)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<tr>
|
||||||
|
<td colSpan="7" className="px-6 py-12 text-center">
|
||||||
|
<User className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
|
||||||
|
<p className="text-secondary-500 dark:text-secondary-300">
|
||||||
|
No users found
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-secondary-400 dark:text-secondary-400 mt-2">
|
||||||
|
Click "Add User" to create the first user
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add User Modal */}
|
||||||
|
<AddUserModal
|
||||||
|
isOpen={showAddModal}
|
||||||
|
onClose={() => setShowAddModal(false)}
|
||||||
|
onUserCreated={handleUserCreated}
|
||||||
|
roles={roles}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Edit User Modal */}
|
||||||
|
{editingUser && (
|
||||||
|
<EditUserModal
|
||||||
|
user={editingUser}
|
||||||
|
isOpen={!!editingUser}
|
||||||
|
onClose={() => setEditingUser(null)}
|
||||||
|
onUpdateUser={updateUserMutation.mutate}
|
||||||
|
isLoading={updateUserMutation.isPending}
|
||||||
|
roles={roles}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Reset Password Modal */}
|
||||||
|
{resetPasswordUser && (
|
||||||
|
<ResetPasswordModal
|
||||||
|
user={resetPasswordUser}
|
||||||
|
isOpen={!!resetPasswordUser}
|
||||||
|
onClose={() => setResetPasswordUser(null)}
|
||||||
|
onPasswordReset={resetPasswordMutation.mutate}
|
||||||
|
isLoading={resetPasswordMutation.isPending}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add User Modal Component
|
||||||
|
const AddUserModal = ({ isOpen, onClose, onUserCreated, roles }) => {
|
||||||
|
const usernameId = useId();
|
||||||
|
const emailId = useId();
|
||||||
|
const firstNameId = useId();
|
||||||
|
const lastNameId = useId();
|
||||||
|
const passwordId = useId();
|
||||||
|
const roleId = useId();
|
||||||
|
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
username: "",
|
||||||
|
email: "",
|
||||||
|
password: "",
|
||||||
|
first_name: "",
|
||||||
|
last_name: "",
|
||||||
|
role: "user",
|
||||||
|
});
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [success, setSuccess] = useState(false);
|
||||||
|
|
||||||
|
// Reset form when modal is closed
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) {
|
||||||
|
setFormData({
|
||||||
|
username: "",
|
||||||
|
email: "",
|
||||||
|
password: "",
|
||||||
|
first_name: "",
|
||||||
|
last_name: "",
|
||||||
|
role: "user",
|
||||||
|
});
|
||||||
|
setError("");
|
||||||
|
setSuccess(false);
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsLoading(true);
|
||||||
|
setError("");
|
||||||
|
setSuccess(false);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Only send role if roles are available from API
|
||||||
|
const payload = {
|
||||||
|
username: formData.username,
|
||||||
|
email: formData.email,
|
||||||
|
password: formData.password,
|
||||||
|
first_name: formData.first_name,
|
||||||
|
last_name: formData.last_name,
|
||||||
|
};
|
||||||
|
if (roles && Array.isArray(roles) && roles.length > 0) {
|
||||||
|
payload.role = formData.role;
|
||||||
|
}
|
||||||
|
await adminUsersAPI.create(payload);
|
||||||
|
setSuccess(true);
|
||||||
|
onUserCreated();
|
||||||
|
// Auto-close after 1.5 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
onClose();
|
||||||
|
}, 1500);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.response?.data?.error || "Failed to create user");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInputChange = (e) => {
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
[e.target.name]: e.target.value,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) return 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">
|
||||||
|
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">
|
||||||
|
Add New User
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor={usernameId}
|
||||||
|
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1"
|
||||||
|
>
|
||||||
|
Username
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id={usernameId}
|
||||||
|
type="text"
|
||||||
|
name="username"
|
||||||
|
required
|
||||||
|
value={formData.username}
|
||||||
|
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
|
||||||
|
htmlFor={emailId}
|
||||||
|
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1"
|
||||||
|
>
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id={emailId}
|
||||||
|
type="email"
|
||||||
|
name="email"
|
||||||
|
required
|
||||||
|
value={formData.email}
|
||||||
|
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 className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor={firstNameId}
|
||||||
|
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1"
|
||||||
|
>
|
||||||
|
First Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id={firstNameId}
|
||||||
|
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
|
||||||
|
htmlFor={lastNameId}
|
||||||
|
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1"
|
||||||
|
>
|
||||||
|
Last Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id={lastNameId}
|
||||||
|
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>
|
||||||
|
<label
|
||||||
|
htmlFor={passwordId}
|
||||||
|
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1"
|
||||||
|
>
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id={passwordId}
|
||||||
|
type="password"
|
||||||
|
name="password"
|
||||||
|
required
|
||||||
|
minLength={6}
|
||||||
|
value={formData.password}
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||||
|
Minimum 6 characters
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor={roleId}
|
||||||
|
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1"
|
||||||
|
>
|
||||||
|
Role
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id={roleId}
|
||||||
|
name="role"
|
||||||
|
value={formData.role}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
{roles && Array.isArray(roles) && roles.length > 0 ? (
|
||||||
|
roles.map((role) => (
|
||||||
|
<option key={role.role} value={role.role}>
|
||||||
|
{role.role.charAt(0).toUpperCase() +
|
||||||
|
role.role.slice(1).replace("_", " ")}
|
||||||
|
</option>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<option value="user">User</option>
|
||||||
|
<option value="admin">Admin</option>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{success && (
|
||||||
|
<div className="bg-green-50 dark:bg-green-900 border border-green-200 dark:border-green-700 rounded-md p-3">
|
||||||
|
<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-700 dark:text-green-300">
|
||||||
|
User created successfully!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<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 dark:text-danger-300">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end space-x-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
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
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-white bg-primary-600 border border-transparent rounded-md hover:bg-primary-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isLoading ? "Creating..." : "Create User"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Edit User Modal Component
|
||||||
|
const EditUserModal = ({
|
||||||
|
user,
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onUpdateUser,
|
||||||
|
isLoading,
|
||||||
|
roles,
|
||||||
|
}) => {
|
||||||
|
const editUsernameId = useId();
|
||||||
|
const editEmailId = useId();
|
||||||
|
const editFirstNameId = useId();
|
||||||
|
const editLastNameId = useId();
|
||||||
|
const editRoleId = useId();
|
||||||
|
const editActiveId = useId();
|
||||||
|
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
username: user?.username || "",
|
||||||
|
email: user?.email || "",
|
||||||
|
first_name: user?.first_name || "",
|
||||||
|
last_name: user?.last_name || "",
|
||||||
|
role: user?.role || "user",
|
||||||
|
is_active: user?.is_active ?? true,
|
||||||
|
});
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [success, setSuccess] = useState(false);
|
||||||
|
|
||||||
|
// Update formData when user prop changes or modal opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (user && isOpen) {
|
||||||
|
setFormData({
|
||||||
|
username: user.username || "",
|
||||||
|
email: user.email || "",
|
||||||
|
first_name: user.first_name || "",
|
||||||
|
last_name: user.last_name || "",
|
||||||
|
role: user.role || "user",
|
||||||
|
is_active: user.is_active ?? true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [user, isOpen]);
|
||||||
|
|
||||||
|
// Reset error and success when modal closes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) {
|
||||||
|
setError("");
|
||||||
|
setSuccess(false);
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError("");
|
||||||
|
setSuccess(false);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await onUpdateUser({ id: user.id, data: formData });
|
||||||
|
setSuccess(true);
|
||||||
|
// Auto-close after 1.5 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
onClose();
|
||||||
|
}, 1500);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.response?.data?.error || "Failed to update user");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInputChange = (e) => {
|
||||||
|
const { name, value, type, checked } = e.target;
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
[name]: type === "checkbox" ? checked : value,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen || !user) return 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">
|
||||||
|
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">
|
||||||
|
Edit User
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor={editUsernameId}
|
||||||
|
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1"
|
||||||
|
>
|
||||||
|
Username
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id={editUsernameId}
|
||||||
|
type="text"
|
||||||
|
name="username"
|
||||||
|
required
|
||||||
|
value={formData.username}
|
||||||
|
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
|
||||||
|
htmlFor={editEmailId}
|
||||||
|
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1"
|
||||||
|
>
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id={editEmailId}
|
||||||
|
type="email"
|
||||||
|
name="email"
|
||||||
|
required
|
||||||
|
value={formData.email}
|
||||||
|
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 className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor={editFirstNameId}
|
||||||
|
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1"
|
||||||
|
>
|
||||||
|
First Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id={editFirstNameId}
|
||||||
|
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
|
||||||
|
htmlFor={editLastNameId}
|
||||||
|
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1"
|
||||||
|
>
|
||||||
|
Last Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id={editLastNameId}
|
||||||
|
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>
|
||||||
|
<label
|
||||||
|
htmlFor={editRoleId}
|
||||||
|
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1"
|
||||||
|
>
|
||||||
|
Role
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id={editRoleId}
|
||||||
|
name="role"
|
||||||
|
value={formData.role}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
{roles && Array.isArray(roles) ? (
|
||||||
|
roles.map((role) => (
|
||||||
|
<option key={role.role} value={role.role}>
|
||||||
|
{role.role.charAt(0).toUpperCase() +
|
||||||
|
role.role.slice(1).replace("_", " ")}
|
||||||
|
</option>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<option value="user">User</option>
|
||||||
|
<option value="admin">Admin</option>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center">
|
||||||
|
<input
|
||||||
|
id={editActiveId}
|
||||||
|
type="checkbox"
|
||||||
|
name="is_active"
|
||||||
|
checked={formData.is_active}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 rounded"
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor={editActiveId}
|
||||||
|
className="ml-2 block text-sm text-secondary-700 dark:text-secondary-200"
|
||||||
|
>
|
||||||
|
Active user
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{success && (
|
||||||
|
<div className="bg-green-50 dark:bg-green-900 border border-green-200 dark:border-green-700 rounded-md p-3">
|
||||||
|
<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-700 dark:text-green-300">
|
||||||
|
User updated successfully!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<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 dark:text-danger-300">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end space-x-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
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
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-white bg-primary-600 border border-transparent rounded-md hover:bg-primary-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isLoading ? "Updating..." : "Update User"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reset Password Modal Component
|
||||||
|
const ResetPasswordModal = ({
|
||||||
|
user,
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onPasswordReset,
|
||||||
|
isLoading,
|
||||||
|
}) => {
|
||||||
|
const newPasswordId = useId();
|
||||||
|
const confirmPasswordId = useId();
|
||||||
|
const [newPassword, setNewPassword] = useState("");
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError("");
|
||||||
|
|
||||||
|
// Validate passwords
|
||||||
|
if (newPassword.length < 6) {
|
||||||
|
setError("Password must be at least 6 characters long");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPassword !== confirmPassword) {
|
||||||
|
setError("Passwords do not match");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await onPasswordReset({ userId: user.id, newPassword });
|
||||||
|
// Reset form on success
|
||||||
|
setNewPassword("");
|
||||||
|
setConfirmPassword("");
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.response?.data?.error || "Failed to reset password");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setNewPassword("");
|
||||||
|
setConfirmPassword("");
|
||||||
|
setError("");
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
||||||
|
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 w-full max-w-md">
|
||||||
|
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">
|
||||||
|
Reset Password for {user.username}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor={newPasswordId}
|
||||||
|
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1"
|
||||||
|
>
|
||||||
|
New Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id={newPasswordId}
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
minLength={6}
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
|
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="Enter new password (min 6 characters)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor={confirmPasswordId}
|
||||||
|
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1"
|
||||||
|
>
|
||||||
|
Confirm Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id={confirmPasswordId}
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
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="Confirm new password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-yellow-50 dark:bg-yellow-900 border border-yellow-200 dark:border-yellow-700 rounded-md p-3">
|
||||||
|
<div className="flex">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<Key className="h-5 w-5 text-yellow-400" />
|
||||||
|
</div>
|
||||||
|
<div className="ml-3">
|
||||||
|
<h3 className="text-sm font-medium text-yellow-800 dark:text-yellow-200">
|
||||||
|
Password Reset Warning
|
||||||
|
</h3>
|
||||||
|
<div className="mt-2 text-sm text-yellow-700 dark:text-yellow-300">
|
||||||
|
<p>
|
||||||
|
This will immediately change the user's password. The user
|
||||||
|
will need to use the new password to login.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<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 dark:text-danger-300">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end space-x-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClose}
|
||||||
|
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
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-white bg-primary-600 border border-transparent rounded-md hover:bg-primary-700 disabled:opacity-50 flex items-center"
|
||||||
|
>
|
||||||
|
{isLoading && (
|
||||||
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||||
|
)}
|
||||||
|
{isLoading ? "Resetting..." : "Reset Password"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UsersTab;
|
||||||
322
frontend/src/components/settings/VersionUpdateTab.jsx
Normal file
322
frontend/src/components/settings/VersionUpdateTab.jsx
Normal file
@@ -0,0 +1,322 @@
|
|||||||
|
import {
|
||||||
|
AlertCircle,
|
||||||
|
CheckCircle,
|
||||||
|
Clock,
|
||||||
|
Code,
|
||||||
|
Download,
|
||||||
|
ExternalLink,
|
||||||
|
GitCommit,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { versionAPI } from "../../utils/api";
|
||||||
|
|
||||||
|
const VersionUpdateTab = () => {
|
||||||
|
// Version checking state
|
||||||
|
const [versionInfo, setVersionInfo] = useState({
|
||||||
|
currentVersion: null,
|
||||||
|
latestVersion: null,
|
||||||
|
isUpdateAvailable: false,
|
||||||
|
checking: false,
|
||||||
|
error: null,
|
||||||
|
github: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Version checking functions
|
||||||
|
const checkForUpdates = useCallback(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,
|
||||||
|
github: data.github,
|
||||||
|
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",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Load current version and automatically check for updates on component mount
|
||||||
|
useEffect(() => {
|
||||||
|
const loadAndCheckUpdates = async () => {
|
||||||
|
try {
|
||||||
|
// First, get current version info
|
||||||
|
const response = await versionAPI.getCurrent();
|
||||||
|
const data = response.data;
|
||||||
|
setVersionInfo({
|
||||||
|
currentVersion: data.version,
|
||||||
|
latestVersion: data.latest_version || null,
|
||||||
|
isUpdateAvailable: data.is_update_available || false,
|
||||||
|
last_update_check: data.last_update_check || null,
|
||||||
|
github: data.github,
|
||||||
|
checking: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Then automatically trigger a fresh update check
|
||||||
|
await checkForUpdates();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error loading version info:", error);
|
||||||
|
setVersionInfo((prev) => ({
|
||||||
|
...prev,
|
||||||
|
error: "Failed to load version information",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadAndCheckUpdates();
|
||||||
|
}, [checkForUpdates]); // Run when component mounts
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center mb-6">
|
||||||
|
<Code className="h-6 w-6 text-primary-600 mr-3" />
|
||||||
|
<h2 className="text-xl font-semibold text-secondary-900 dark:text-white">
|
||||||
|
Server Version Information
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-secondary-50 dark:bg-secondary-700 rounded-lg p-6">
|
||||||
|
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">
|
||||||
|
Version Information
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-secondary-600 dark:text-secondary-300 mb-6">
|
||||||
|
Current server version and latest updates from GitHub repository.
|
||||||
|
{versionInfo.checking && (
|
||||||
|
<span className="ml-2 text-blue-600 dark:text-blue-400">
|
||||||
|
🔄 Checking for updates...
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{/* My Version */}
|
||||||
|
<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">
|
||||||
|
<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">
|
||||||
|
My Version
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-lg font-mono text-secondary-900 dark:text-white">
|
||||||
|
{versionInfo.currentVersion}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Latest Release */}
|
||||||
|
{versionInfo.github?.latestRelease && (
|
||||||
|
<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">
|
||||||
|
<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 Release
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<span className="text-lg font-mono text-secondary-900 dark:text-white">
|
||||||
|
{versionInfo.github.latestRelease.tagName}
|
||||||
|
</span>
|
||||||
|
<div className="text-xs text-secondary-500 dark:text-secondary-400">
|
||||||
|
Published:{" "}
|
||||||
|
{new Date(
|
||||||
|
versionInfo.github.latestRelease.publishedAt,
|
||||||
|
).toLocaleDateString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* GitHub Repository Information */}
|
||||||
|
{versionInfo.github && (
|
||||||
|
<div className="bg-white dark:bg-secondary-800 rounded-lg p-4 border border-secondary-200 dark:border-secondary-600 mt-4">
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<Code className="h-4 w-4 text-purple-600 dark:text-purple-400" />
|
||||||
|
<span className="text-sm font-medium text-secondary-700 dark:text-secondary-300">
|
||||||
|
GitHub Repository Information
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
{/* Repository URL */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<span className="text-xs font-medium text-secondary-600 dark:text-secondary-400 uppercase tracking-wide">
|
||||||
|
Repository
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm text-secondary-900 dark:text-white font-mono">
|
||||||
|
{versionInfo.github.owner}/{versionInfo.github.repo}
|
||||||
|
</span>
|
||||||
|
{versionInfo.github.repository && (
|
||||||
|
<a
|
||||||
|
href={versionInfo.github.repository}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300"
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-3 w-3" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Latest Release Info */}
|
||||||
|
{versionInfo.github.latestRelease && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<span className="text-xs font-medium text-secondary-600 dark:text-secondary-400 uppercase tracking-wide">
|
||||||
|
Release Link
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{versionInfo.github.latestRelease.htmlUrl && (
|
||||||
|
<a
|
||||||
|
href={versionInfo.github.latestRelease.htmlUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 text-sm"
|
||||||
|
>
|
||||||
|
View Release{" "}
|
||||||
|
<ExternalLink className="h-3 w-3 inline ml-1" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Branch Status */}
|
||||||
|
{versionInfo.github.commitDifference && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<span className="text-xs font-medium text-secondary-600 dark:text-secondary-400 uppercase tracking-wide">
|
||||||
|
Branch Status
|
||||||
|
</span>
|
||||||
|
<div className="text-sm">
|
||||||
|
{versionInfo.github.commitDifference.commitsAhead > 0 ? (
|
||||||
|
<span className="text-blue-600 dark:text-blue-400">
|
||||||
|
🚀 Main branch is{" "}
|
||||||
|
{versionInfo.github.commitDifference.commitsAhead}{" "}
|
||||||
|
commits ahead of release
|
||||||
|
</span>
|
||||||
|
) : versionInfo.github.commitDifference.commitsBehind >
|
||||||
|
0 ? (
|
||||||
|
<span className="text-orange-600 dark:text-orange-400">
|
||||||
|
📊 Main branch is{" "}
|
||||||
|
{versionInfo.github.commitDifference.commitsBehind}{" "}
|
||||||
|
commits behind release
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-green-600 dark:text-green-400">
|
||||||
|
✅ Main branch is in sync with release
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Latest Commit Information */}
|
||||||
|
{versionInfo.github.latestCommit && (
|
||||||
|
<div className="mt-4 pt-4 border-t border-secondary-200 dark:border-secondary-600">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<GitCommit className="h-4 w-4 text-orange-600 dark:text-orange-400" />
|
||||||
|
<span className="text-xs font-medium text-secondary-600 dark:text-secondary-400 uppercase tracking-wide">
|
||||||
|
Latest Commit (Rolling)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-mono text-secondary-900 dark:text-white">
|
||||||
|
{versionInfo.github.latestCommit.sha.substring(0, 8)}
|
||||||
|
</span>
|
||||||
|
{versionInfo.github.latestCommit.htmlUrl && (
|
||||||
|
<a
|
||||||
|
href={versionInfo.github.latestCommit.htmlUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300"
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-3 w-3" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-secondary-700 dark:text-secondary-300">
|
||||||
|
{versionInfo.github.latestCommit.message.split("\n")[0]}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-4 text-xs text-secondary-500 dark:text-secondary-400">
|
||||||
|
<span>
|
||||||
|
Author: {versionInfo.github.latestCommit.author}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
Date:{" "}
|
||||||
|
{new Date(
|
||||||
|
versionInfo.github.latestCommit.date,
|
||||||
|
).toLocaleString()}
|
||||||
|
</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 mt-4">
|
||||||
|
<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-start mt-6">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={checkForUpdates}
|
||||||
|
disabled={versionInfo.checking}
|
||||||
|
className="btn-primary flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
{versionInfo.checking ? "Checking..." : "Check for Updates"}
|
||||||
|
</button>
|
||||||
|
</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 mt-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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default VersionUpdateTab;
|
||||||
29
frontend/src/constants/authPhases.js
Normal file
29
frontend/src/constants/authPhases.js
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* Authentication phases for the centralized auth state machine
|
||||||
|
*
|
||||||
|
* Flow: INITIALISING → CHECKING_SETUP → READY
|
||||||
|
*/
|
||||||
|
export const AUTH_PHASES = {
|
||||||
|
INITIALISING: "INITIALISING",
|
||||||
|
CHECKING_SETUP: "CHECKING_SETUP",
|
||||||
|
READY: "READY",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper functions for auth phase management
|
||||||
|
*/
|
||||||
|
export const isAuthPhase = {
|
||||||
|
initialising: (phase) => phase === AUTH_PHASES.INITIALISING,
|
||||||
|
checkingSetup: (phase) => phase === AUTH_PHASES.CHECKING_SETUP,
|
||||||
|
ready: (phase) => phase === AUTH_PHASES.READY,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if authentication is fully initialised and ready
|
||||||
|
* @param {string} phase - Current auth phase
|
||||||
|
* @param {boolean} isAuthenticated - Whether user is authenticated
|
||||||
|
* @returns {boolean} - True if auth is ready for other contexts to use
|
||||||
|
*/
|
||||||
|
export const isAuthReady = (phase, isAuthenticated) => {
|
||||||
|
return isAuthPhase.ready(phase) && isAuthenticated;
|
||||||
|
};
|
||||||
@@ -1,246 +1,315 @@
|
|||||||
import React, { createContext, useContext, useState, useEffect } from 'react'
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { flushSync } from "react-dom";
|
||||||
|
import { AUTH_PHASES, isAuthPhase } from "../constants/authPhases";
|
||||||
|
|
||||||
const AuthContext = createContext()
|
const AuthContext = createContext();
|
||||||
|
|
||||||
export const useAuth = () => {
|
export const useAuth = () => {
|
||||||
const context = useContext(AuthContext)
|
const context = useContext(AuthContext);
|
||||||
if (!context) {
|
if (!context) {
|
||||||
throw new Error('useAuth must be used within an AuthProvider')
|
throw new Error("useAuth must be used within an AuthProvider");
|
||||||
}
|
}
|
||||||
return context
|
return context;
|
||||||
}
|
};
|
||||||
|
|
||||||
export const AuthProvider = ({ children }) => {
|
export const AuthProvider = ({ children }) => {
|
||||||
const [user, setUser] = useState(null)
|
const [user, setUser] = useState(null);
|
||||||
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 [needsFirstTimeSetup, setNeedsFirstTimeSetup] = useState(false);
|
||||||
|
|
||||||
// Initialize auth state from localStorage
|
// Authentication state machine phases
|
||||||
useEffect(() => {
|
const [authPhase, setAuthPhase] = useState(AUTH_PHASES.INITIALISING);
|
||||||
const storedToken = localStorage.getItem('token')
|
const [permissionsLoading, setPermissionsLoading] = useState(false);
|
||||||
const storedUser = localStorage.getItem('user')
|
|
||||||
const storedPermissions = localStorage.getItem('permissions')
|
|
||||||
|
|
||||||
if (storedToken && storedUser) {
|
// Define functions first
|
||||||
try {
|
const fetchPermissions = useCallback(async (authToken) => {
|
||||||
setToken(storedToken)
|
try {
|
||||||
setUser(JSON.parse(storedUser))
|
setPermissionsLoading(true);
|
||||||
if (storedPermissions) {
|
const response = await fetch("/api/v1/permissions/user-permissions", {
|
||||||
setPermissions(JSON.parse(storedPermissions))
|
headers: {
|
||||||
} else {
|
Authorization: `Bearer ${authToken}`,
|
||||||
// Fetch permissions if not stored
|
},
|
||||||
fetchPermissions(storedToken)
|
});
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error parsing stored user data:', error)
|
|
||||||
localStorage.removeItem('token')
|
|
||||||
localStorage.removeItem('user')
|
|
||||||
localStorage.removeItem('permissions')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setIsLoading(false)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// Periodically refresh permissions when user is logged in
|
if (response.ok) {
|
||||||
useEffect(() => {
|
const data = await response.json();
|
||||||
if (token && user) {
|
setPermissions(data);
|
||||||
// Refresh permissions every 30 seconds
|
return data;
|
||||||
const interval = setInterval(() => {
|
} else {
|
||||||
refreshPermissions()
|
console.error("Failed to fetch permissions");
|
||||||
}, 30000)
|
return null;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching permissions:", error);
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
setPermissionsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
return () => clearInterval(interval)
|
const refreshPermissions = useCallback(async () => {
|
||||||
}
|
if (token) {
|
||||||
}, [token, user])
|
const updatedPermissions = await fetchPermissions(token);
|
||||||
|
return updatedPermissions;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, [token, fetchPermissions]);
|
||||||
|
|
||||||
const fetchPermissions = async (authToken) => {
|
// Initialize auth state from localStorage
|
||||||
try {
|
useEffect(() => {
|
||||||
const response = await fetch('/api/v1/permissions/user-permissions', {
|
const storedToken = localStorage.getItem("token");
|
||||||
headers: {
|
const storedUser = localStorage.getItem("user");
|
||||||
'Authorization': `Bearer ${authToken}`,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
if (response.ok) {
|
if (storedToken && storedUser) {
|
||||||
const data = await response.json()
|
try {
|
||||||
setPermissions(data)
|
setToken(storedToken);
|
||||||
localStorage.setItem('permissions', JSON.stringify(data))
|
setUser(JSON.parse(storedUser));
|
||||||
return data
|
// Fetch permissions from backend
|
||||||
} else {
|
fetchPermissions(storedToken);
|
||||||
console.error('Failed to fetch permissions')
|
// User is authenticated, skip setup check
|
||||||
return null
|
setAuthPhase(AUTH_PHASES.READY);
|
||||||
}
|
} catch (error) {
|
||||||
} catch (error) {
|
console.error("Error parsing stored user data:", error);
|
||||||
console.error('Error fetching permissions:', error)
|
localStorage.removeItem("token");
|
||||||
return null
|
localStorage.removeItem("user");
|
||||||
}
|
// Move to setup check phase
|
||||||
}
|
setAuthPhase(AUTH_PHASES.CHECKING_SETUP);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No stored auth, check if setup is needed
|
||||||
|
setAuthPhase(AUTH_PHASES.CHECKING_SETUP);
|
||||||
|
}
|
||||||
|
}, [fetchPermissions]);
|
||||||
|
|
||||||
const refreshPermissions = async () => {
|
const login = async (username, password) => {
|
||||||
if (token) {
|
try {
|
||||||
const updatedPermissions = await fetchPermissions(token)
|
const response = await fetch("/api/v1/auth/login", {
|
||||||
return updatedPermissions
|
method: "POST",
|
||||||
}
|
headers: {
|
||||||
return null
|
"Content-Type": "application/json",
|
||||||
}
|
},
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
});
|
||||||
|
|
||||||
const login = async (username, password) => {
|
const data = await response.json();
|
||||||
try {
|
|
||||||
const response = await fetch('/api/v1/auth/login', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ username, password }),
|
|
||||||
})
|
|
||||||
|
|
||||||
const data = await response.json()
|
if (response.ok) {
|
||||||
|
// Check if TFA is required
|
||||||
|
if (data.requiresTfa) {
|
||||||
|
return { success: true, requiresTfa: true };
|
||||||
|
}
|
||||||
|
|
||||||
if (response.ok) {
|
// Regular successful login
|
||||||
setToken(data.token)
|
setToken(data.token);
|
||||||
setUser(data.user)
|
setUser(data.user);
|
||||||
localStorage.setItem('token', data.token)
|
localStorage.setItem("token", data.token);
|
||||||
localStorage.setItem('user', JSON.stringify(data.user))
|
localStorage.setItem("user", JSON.stringify(data.user));
|
||||||
|
|
||||||
// Fetch user permissions after successful login
|
|
||||||
const userPermissions = await fetchPermissions(data.token)
|
|
||||||
if (userPermissions) {
|
|
||||||
setPermissions(userPermissions)
|
|
||||||
}
|
|
||||||
|
|
||||||
return { success: true }
|
|
||||||
} else {
|
|
||||||
return { success: false, error: data.error || 'Login failed' }
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
return { success: false, error: 'Network error occurred' }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const logout = async () => {
|
// Fetch user permissions after successful login
|
||||||
try {
|
const userPermissions = await fetchPermissions(data.token);
|
||||||
if (token) {
|
if (userPermissions) {
|
||||||
await fetch('/api/v1/auth/logout', {
|
setPermissions(userPermissions);
|
||||||
method: 'POST',
|
}
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${token}`,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Logout error:', error)
|
|
||||||
} finally {
|
|
||||||
setToken(null)
|
|
||||||
setUser(null)
|
|
||||||
setPermissions(null)
|
|
||||||
localStorage.removeItem('token')
|
|
||||||
localStorage.removeItem('user')
|
|
||||||
localStorage.removeItem('permissions')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateProfile = async (profileData) => {
|
return { success: true };
|
||||||
try {
|
} else {
|
||||||
const response = await fetch('/api/v1/auth/profile', {
|
return { success: false, error: data.error || "Login failed" };
|
||||||
method: 'PUT',
|
}
|
||||||
headers: {
|
} catch {
|
||||||
'Authorization': `Bearer ${token}`,
|
return { success: false, error: "Network error occurred" };
|
||||||
'Content-Type': 'application/json',
|
}
|
||||||
},
|
};
|
||||||
body: JSON.stringify(profileData),
|
|
||||||
})
|
|
||||||
|
|
||||||
const data = await response.json()
|
const logout = async () => {
|
||||||
|
try {
|
||||||
|
if (token) {
|
||||||
|
await fetch("/api/v1/auth/logout", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Logout error:", error);
|
||||||
|
} finally {
|
||||||
|
setToken(null);
|
||||||
|
setUser(null);
|
||||||
|
setPermissions(null);
|
||||||
|
localStorage.removeItem("token");
|
||||||
|
localStorage.removeItem("user");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (response.ok) {
|
const updateProfile = async (profileData) => {
|
||||||
setUser(data.user)
|
try {
|
||||||
localStorage.setItem('user', JSON.stringify(data.user))
|
const response = await fetch("/api/v1/auth/profile", {
|
||||||
return { success: true, user: data.user }
|
method: "PUT",
|
||||||
} else {
|
headers: {
|
||||||
return { success: false, error: data.error || 'Update failed' }
|
Authorization: `Bearer ${token}`,
|
||||||
}
|
"Content-Type": "application/json",
|
||||||
} catch (error) {
|
},
|
||||||
return { success: false, error: 'Network error occurred' }
|
body: JSON.stringify(profileData),
|
||||||
}
|
});
|
||||||
}
|
|
||||||
|
|
||||||
const changePassword = async (currentPassword, newPassword) => {
|
const data = await response.json();
|
||||||
try {
|
|
||||||
const response = await fetch('/api/v1/auth/change-password', {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${token}`,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ currentPassword, newPassword }),
|
|
||||||
})
|
|
||||||
|
|
||||||
const data = await response.json()
|
if (response.ok) {
|
||||||
|
setUser(data.user);
|
||||||
|
localStorage.setItem("user", JSON.stringify(data.user));
|
||||||
|
return { success: true, user: data.user };
|
||||||
|
} else {
|
||||||
|
return { success: false, error: data.error || "Update failed" };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return { success: false, error: "Network error occurred" };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (response.ok) {
|
const changePassword = async (currentPassword, newPassword) => {
|
||||||
return { success: true }
|
try {
|
||||||
} else {
|
const response = await fetch("/api/v1/auth/change-password", {
|
||||||
return { success: false, error: data.error || 'Password change failed' }
|
method: "PUT",
|
||||||
}
|
headers: {
|
||||||
} catch (error) {
|
Authorization: `Bearer ${token}`,
|
||||||
return { success: false, error: 'Network error occurred' }
|
"Content-Type": "application/json",
|
||||||
}
|
},
|
||||||
}
|
body: JSON.stringify({ currentPassword, newPassword }),
|
||||||
|
});
|
||||||
|
|
||||||
const isAuthenticated = () => {
|
const data = await response.json();
|
||||||
return !!(token && user)
|
|
||||||
}
|
|
||||||
|
|
||||||
const isAdmin = () => {
|
if (response.ok) {
|
||||||
return user?.role === 'admin'
|
return { success: true };
|
||||||
}
|
} else {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: data.error || "Password change failed",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return { success: false, error: "Network error occurred" };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Permission checking functions
|
const isAdmin = () => {
|
||||||
const hasPermission = (permission) => {
|
return user?.role === "admin";
|
||||||
return permissions?.[permission] === true
|
};
|
||||||
}
|
|
||||||
|
|
||||||
const canViewDashboard = () => hasPermission('canViewDashboard')
|
// Permission checking functions
|
||||||
const canViewHosts = () => hasPermission('canViewHosts')
|
const hasPermission = (permission) => {
|
||||||
const canManageHosts = () => hasPermission('canManageHosts')
|
// If permissions are still loading, return false to show loading state
|
||||||
const canViewPackages = () => hasPermission('canViewPackages')
|
if (permissionsLoading) {
|
||||||
const canManagePackages = () => hasPermission('canManagePackages')
|
return false;
|
||||||
const canViewUsers = () => hasPermission('canViewUsers')
|
}
|
||||||
const canManageUsers = () => hasPermission('canManageUsers')
|
return permissions?.[permission] === true;
|
||||||
const canViewReports = () => hasPermission('canViewReports')
|
};
|
||||||
const canExportData = () => hasPermission('canExportData')
|
|
||||||
const canManageSettings = () => hasPermission('canManageSettings')
|
|
||||||
|
|
||||||
const value = {
|
const canViewDashboard = () => hasPermission("can_view_dashboard");
|
||||||
user,
|
const canViewHosts = () => hasPermission("can_view_hosts");
|
||||||
token,
|
const canManageHosts = () => hasPermission("can_manage_hosts");
|
||||||
permissions,
|
const canViewPackages = () => hasPermission("can_view_packages");
|
||||||
isLoading,
|
const canManagePackages = () => hasPermission("can_manage_packages");
|
||||||
login,
|
const canViewUsers = () => hasPermission("can_view_users");
|
||||||
logout,
|
const canManageUsers = () => hasPermission("can_manage_users");
|
||||||
updateProfile,
|
const canViewReports = () => hasPermission("can_view_reports");
|
||||||
changePassword,
|
const canExportData = () => hasPermission("can_export_data");
|
||||||
refreshPermissions,
|
const canManageSettings = () => hasPermission("can_manage_settings");
|
||||||
isAuthenticated,
|
|
||||||
isAdmin,
|
|
||||||
hasPermission,
|
|
||||||
canViewDashboard,
|
|
||||||
canViewHosts,
|
|
||||||
canManageHosts,
|
|
||||||
canViewPackages,
|
|
||||||
canManagePackages,
|
|
||||||
canViewUsers,
|
|
||||||
canManageUsers,
|
|
||||||
canViewReports,
|
|
||||||
canExportData,
|
|
||||||
canManageSettings
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
// Check if any admin users exist (for first-time setup)
|
||||||
<AuthContext.Provider value={value}>
|
const checkAdminUsersExist = useCallback(async () => {
|
||||||
{children}
|
try {
|
||||||
</AuthContext.Provider>
|
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);
|
||||||
|
setAuthPhase(AUTH_PHASES.READY); // Setup check complete, move to ready phase
|
||||||
|
} else {
|
||||||
|
// If endpoint doesn't exist or fails, assume setup is needed
|
||||||
|
setNeedsFirstTimeSetup(true);
|
||||||
|
setAuthPhase(AUTH_PHASES.READY);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error checking admin users:", error);
|
||||||
|
// If there's an error, assume setup is needed
|
||||||
|
setNeedsFirstTimeSetup(true);
|
||||||
|
setAuthPhase(AUTH_PHASES.READY);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Check for admin users ONLY when in CHECKING_SETUP phase
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAuthPhase.checkingSetup(authPhase)) {
|
||||||
|
checkAdminUsersExist();
|
||||||
|
}
|
||||||
|
}, [authPhase, checkAdminUsersExist]);
|
||||||
|
|
||||||
|
const setAuthState = (authToken, authUser) => {
|
||||||
|
// Use flushSync to ensure all state updates are applied synchronously
|
||||||
|
flushSync(() => {
|
||||||
|
setToken(authToken);
|
||||||
|
setUser(authUser);
|
||||||
|
setNeedsFirstTimeSetup(false);
|
||||||
|
setAuthPhase(AUTH_PHASES.READY);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Store in localStorage after state is updated
|
||||||
|
localStorage.setItem("token", authToken);
|
||||||
|
localStorage.setItem("user", JSON.stringify(authUser));
|
||||||
|
|
||||||
|
// Fetch permissions immediately for the new authenticated user
|
||||||
|
fetchPermissions(authToken);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Computed loading state based on phase and permissions state
|
||||||
|
const isLoading = !isAuthPhase.ready(authPhase) || permissionsLoading;
|
||||||
|
|
||||||
|
// Function to check authentication status (maintains API compatibility)
|
||||||
|
const isAuthenticated = () => {
|
||||||
|
return !!(user && token && isAuthPhase.ready(authPhase));
|
||||||
|
};
|
||||||
|
|
||||||
|
const value = {
|
||||||
|
user,
|
||||||
|
token,
|
||||||
|
permissions,
|
||||||
|
isLoading,
|
||||||
|
needsFirstTimeSetup,
|
||||||
|
authPhase,
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
updateProfile,
|
||||||
|
changePassword,
|
||||||
|
refreshPermissions,
|
||||||
|
setAuthState,
|
||||||
|
isAuthenticated,
|
||||||
|
isAdmin,
|
||||||
|
hasPermission,
|
||||||
|
canViewDashboard,
|
||||||
|
canViewHosts,
|
||||||
|
canManageHosts,
|
||||||
|
canViewPackages,
|
||||||
|
canManagePackages,
|
||||||
|
canViewUsers,
|
||||||
|
canManageUsers,
|
||||||
|
canViewReports,
|
||||||
|
canExportData,
|
||||||
|
canManageSettings,
|
||||||
|
};
|
||||||
|
|
||||||
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,54 +1,52 @@
|
|||||||
import React, { createContext, useContext, useEffect, useState } from 'react'
|
import { createContext, useContext, useEffect, useState } from "react";
|
||||||
|
|
||||||
const ThemeContext = createContext()
|
const ThemeContext = createContext();
|
||||||
|
|
||||||
export const useTheme = () => {
|
export const useTheme = () => {
|
||||||
const context = useContext(ThemeContext)
|
const context = useContext(ThemeContext);
|
||||||
if (!context) {
|
if (!context) {
|
||||||
throw new Error('useTheme must be used within a ThemeProvider')
|
throw new Error("useTheme must be used within a ThemeProvider");
|
||||||
}
|
}
|
||||||
return context
|
return context;
|
||||||
}
|
};
|
||||||
|
|
||||||
export const ThemeProvider = ({ children }) => {
|
export const ThemeProvider = ({ children }) => {
|
||||||
const [theme, setTheme] = useState(() => {
|
const [theme, setTheme] = useState(() => {
|
||||||
// Check localStorage first, then system preference
|
// Check localStorage first, then system preference
|
||||||
const savedTheme = localStorage.getItem('theme')
|
const savedTheme = localStorage.getItem("theme");
|
||||||
if (savedTheme) {
|
if (savedTheme) {
|
||||||
return savedTheme
|
return savedTheme;
|
||||||
}
|
}
|
||||||
// Check system preference
|
// Check system preference
|
||||||
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
|
||||||
return 'dark'
|
return "dark";
|
||||||
}
|
}
|
||||||
return 'light'
|
return "light";
|
||||||
})
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Apply theme to document
|
// Apply theme to document
|
||||||
if (theme === 'dark') {
|
if (theme === "dark") {
|
||||||
document.documentElement.classList.add('dark')
|
document.documentElement.classList.add("dark");
|
||||||
} else {
|
} else {
|
||||||
document.documentElement.classList.remove('dark')
|
document.documentElement.classList.remove("dark");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save to localStorage
|
|
||||||
localStorage.setItem('theme', theme)
|
|
||||||
}, [theme])
|
|
||||||
|
|
||||||
const toggleTheme = () => {
|
// Save to localStorage
|
||||||
setTheme(prevTheme => prevTheme === 'light' ? 'dark' : 'light')
|
localStorage.setItem("theme", theme);
|
||||||
}
|
}, [theme]);
|
||||||
|
|
||||||
const value = {
|
const toggleTheme = () => {
|
||||||
theme,
|
setTheme((prevTheme) => (prevTheme === "light" ? "dark" : "light"));
|
||||||
toggleTheme,
|
};
|
||||||
isDark: theme === 'dark'
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
const value = {
|
||||||
<ThemeContext.Provider value={value}>
|
theme,
|
||||||
{children}
|
toggleTheme,
|
||||||
</ThemeContext.Provider>
|
isDark: theme === "dark",
|
||||||
)
|
};
|
||||||
}
|
|
||||||
|
return (
|
||||||
|
<ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user