mirror of
				https://github.com/9technologygroup/patchmon.net.git
				synced 2025-11-04 05:53:27 +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/
 | 
			
		||||
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
 | 
			
		||||
.out
 | 
			
		||||
.storybook-out
 | 
			
		||||
@@ -130,6 +137,8 @@ agents/*.log
 | 
			
		||||
test-results/
 | 
			
		||||
playwright-report/
 | 
			
		||||
test-results.xml
 | 
			
		||||
test_*.sh
 | 
			
		||||
test-*.sh
 | 
			
		||||
 | 
			
		||||
# Package manager lock files (uncomment if you want to ignore them)
 | 
			
		||||
# package-lock.json
 | 
			
		||||
@@ -140,6 +149,9 @@ test-results.xml
 | 
			
		||||
deploy-patchmon.sh
 | 
			
		||||
manage-instances.sh
 | 
			
		||||
manage-patchmon.sh
 | 
			
		||||
manage-patchmon-dev.sh
 | 
			
		||||
setup-installer-site.sh
 | 
			
		||||
install-server.*
 | 
			
		||||
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
 | 
			
		||||
 | 
			
		||||
# 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
 | 
			
		||||
 | 
			
		||||
# 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
 | 
			
		||||
RED='\033[0;31m'
 | 
			
		||||
GREEN='\033[0;32m'
 | 
			
		||||
@@ -35,69 +40,276 @@ if [[ $EUID -ne 0 ]]; then
 | 
			
		||||
   error "This script must be run as root (use sudo)"
 | 
			
		||||
fi
 | 
			
		||||
 | 
			
		||||
# Default server URL (will be replaced by backend with configured URL)
 | 
			
		||||
PATCHMON_URL="http://localhost:3001"
 | 
			
		||||
 | 
			
		||||
# Parse arguments
 | 
			
		||||
if [[ $# -ne 3 ]]; then
 | 
			
		||||
    echo "Usage: curl -sSL {PATCHMON_URL}/api/v1/hosts/install | bash -s -- {PATCHMON_URL} {API_ID} {API_KEY}"
 | 
			
		||||
# Verify system datetime and timezone
 | 
			
		||||
verify_datetime() {
 | 
			
		||||
    info "🕐 Verifying system datetime and timezone..."
 | 
			
		||||
    
 | 
			
		||||
    # Get current system time
 | 
			
		||||
    local system_time=$(date)
 | 
			
		||||
    local timezone=$(timedatectl show --property=Timezone --value 2>/dev/null || echo "Unknown")
 | 
			
		||||
    
 | 
			
		||||
    # Display current datetime info
 | 
			
		||||
    echo ""
 | 
			
		||||
    echo "Example:"
 | 
			
		||||
    echo "curl -sSL http://patchmon.example.com/api/v1/hosts/install | bash -s -- http://patchmon.example.com patchmon_1a2b3c4d abcd1234567890abcdef1234567890abcdef1234567890abcdef1234567890"
 | 
			
		||||
    echo -e "${BLUE}📅 Current System Date/Time:${NC}"
 | 
			
		||||
    echo "   • Date/Time: $system_time"
 | 
			
		||||
    echo "   • Timezone: $timezone"
 | 
			
		||||
    echo ""
 | 
			
		||||
    echo "Contact your PatchMon administrator to get your API credentials."
 | 
			
		||||
    exit 1
 | 
			
		||||
fi
 | 
			
		||||
 | 
			
		||||
PATCHMON_URL="$1"
 | 
			
		||||
API_ID="$2"
 | 
			
		||||
API_KEY="$3"
 | 
			
		||||
 | 
			
		||||
# Validate inputs
 | 
			
		||||
if [[ ! "$PATCHMON_URL" =~ ^https?:// ]]; then
 | 
			
		||||
    error "Invalid URL format. Must start with http:// or https://"
 | 
			
		||||
fi
 | 
			
		||||
 | 
			
		||||
if [[ ! "$API_ID" =~ ^patchmon_[a-f0-9]{16}$ ]]; then
 | 
			
		||||
    error "Invalid API ID format. API ID should be in format: patchmon_xxxxxxxxxxxxxxxx"
 | 
			
		||||
fi
 | 
			
		||||
 | 
			
		||||
if [[ ! "$API_KEY" =~ ^[a-f0-9]{64}$ ]]; then
 | 
			
		||||
    error "Invalid API Key format. API Key should be 64 hexadecimal characters."
 | 
			
		||||
fi
 | 
			
		||||
 | 
			
		||||
info "🚀 Installing PatchMon Agent..."
 | 
			
		||||
info "   Server: $PATCHMON_URL"
 | 
			
		||||
info "   API ID: $API_ID"
 | 
			
		||||
 | 
			
		||||
# Create patchmon directory
 | 
			
		||||
info "📁 Creating configuration directory..."
 | 
			
		||||
mkdir -p /etc/patchmon
 | 
			
		||||
 | 
			
		||||
# Download the agent script
 | 
			
		||||
info "📥 Downloading PatchMon agent script..."
 | 
			
		||||
curl -sSL "$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)
 | 
			
		||||
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"
 | 
			
		||||
    
 | 
			
		||||
    # Check if we can read from stdin (interactive terminal)
 | 
			
		||||
    if [[ -t 0 ]]; then
 | 
			
		||||
        # Interactive terminal - ask user
 | 
			
		||||
        read -p "Does this date/time look correct to you? (y/N): " -r response
 | 
			
		||||
        if [[ "$response" =~ ^[Yy]$ ]]; then
 | 
			
		||||
            success "✅ Date/time verification passed"
 | 
			
		||||
            echo ""
 | 
			
		||||
            return 0
 | 
			
		||||
        else
 | 
			
		||||
            echo ""
 | 
			
		||||
            echo -e "${RED}❌ Date/time verification failed${NC}"
 | 
			
		||||
            echo ""
 | 
			
		||||
            echo -e "${YELLOW}💡 Please fix the date/time and re-run the installation script:${NC}"
 | 
			
		||||
            echo "   sudo timedatectl set-time 'YYYY-MM-DD HH:MM:SS'"
 | 
			
		||||
            echo "   sudo timedatectl set-timezone 'America/New_York'  # or your timezone"
 | 
			
		||||
            echo "   sudo timedatectl list-timezones  # to see available timezones"
 | 
			
		||||
            echo ""
 | 
			
		||||
            echo -e "${BLUE}ℹ️  After fixing the date/time, re-run this installation script.${NC}"
 | 
			
		||||
            error "Installation cancelled - please fix date/time and re-run"
 | 
			
		||||
        fi
 | 
			
		||||
    else
 | 
			
		||||
        # Non-interactive (piped from curl) - show warning and continue
 | 
			
		||||
        echo -e "${YELLOW}⚠️  Non-interactive installation detected${NC}"
 | 
			
		||||
        echo ""
 | 
			
		||||
        echo "Please verify the date/time shown above is correct."
 | 
			
		||||
        echo "If the date/time is incorrect, it may cause issues with:"
 | 
			
		||||
        echo "   • Logging timestamps"
 | 
			
		||||
        echo "   • Scheduled updates"
 | 
			
		||||
        echo "   • Data synchronization"
 | 
			
		||||
        echo ""
 | 
			
		||||
        echo -e "${GREEN}✅ Continuing with installation...${NC}"
 | 
			
		||||
        success "✅ Date/time verification completed (assumed correct)"
 | 
			
		||||
        echo ""
 | 
			
		||||
    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
 | 
			
		||||
 | 
			
		||||
# Get update interval policy from server
 | 
			
		||||
UPDATE_INTERVAL=$(curl -s "$PATCHMON_URL/api/v1/settings/update-interval" | grep -o '"updateInterval":[0-9]*' | cut -d':' -f2 2>/dev/null || echo "60")
 | 
			
		||||
info "📋 Update interval: $UPDATE_INTERVAL minutes"
 | 
			
		||||
# Check if --force flag is set (for bypassing broken packages)
 | 
			
		||||
FORCE_INSTALL="${FORCE_INSTALL:-false}"
 | 
			
		||||
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
 | 
			
		||||
# PatchMon API Credentials
 | 
			
		||||
# Generated on $(date)
 | 
			
		||||
@@ -105,52 +317,117 @@ PATCHMON_URL="$PATCHMON_URL"
 | 
			
		||||
API_ID="$API_ID"
 | 
			
		||||
API_KEY="$API_KEY"
 | 
			
		||||
EOF
 | 
			
		||||
 | 
			
		||||
chmod 600 /etc/patchmon/credentials
 | 
			
		||||
 | 
			
		||||
# Test the configuration
 | 
			
		||||
info "🧪 Testing configuration..."
 | 
			
		||||
# Step 3: Download the agent script using API credentials
 | 
			
		||||
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
 | 
			
		||||
    success "Configuration test passed!"
 | 
			
		||||
    success "✅ TEST: API credentials are valid and server is reachable"
 | 
			
		||||
else
 | 
			
		||||
    error "Configuration test failed. Please check your credentials."
 | 
			
		||||
    error "❌ Failed to validate API credentials or reach server"
 | 
			
		||||
fi
 | 
			
		||||
 | 
			
		||||
# Send initial update
 | 
			
		||||
info "📊 Sending initial package data..."
 | 
			
		||||
# Step 5: Send initial data and setup automated updates
 | 
			
		||||
info "📊 Sending initial package data to server..."
 | 
			
		||||
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
 | 
			
		||||
    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
 | 
			
		||||
 | 
			
		||||
# Setup crontab for automatic updates
 | 
			
		||||
info "⏰ Setting up automatic updates every $UPDATE_INTERVAL minutes..."
 | 
			
		||||
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!"
 | 
			
		||||
# Installation complete
 | 
			
		||||
success "🎉 PatchMon Agent installation completed successfully!"
 | 
			
		||||
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 version: $AGENT_VERSION"
 | 
			
		||||
if [[ "$EXPECTED_VERSION" != "Unknown" ]]; then
 | 
			
		||||
    echo "   • Expected version: $EXPECTED_VERSION"
 | 
			
		||||
fi
 | 
			
		||||
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!"
 | 
			
		||||
echo "   • Dependencies installed: jq, curl, bc"
 | 
			
		||||
echo "   • Automated updates configured via crontab"
 | 
			
		||||
echo "   • API credentials configured and tested"
 | 
			
		||||
echo "   • Update schedule managed by agent"
 | 
			
		||||
 | 
			
		||||
# 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_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
 | 
			
		||||
PORT=3001
 | 
			
		||||
@@ -9,9 +11,28 @@ NODE_ENV=development
 | 
			
		||||
API_VERSION=v1
 | 
			
		||||
CORS_ORIGIN=http://localhost:3000
 | 
			
		||||
 | 
			
		||||
# Rate Limiting
 | 
			
		||||
# Rate Limiting (times in milliseconds)
 | 
			
		||||
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
 | 
			
		||||
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",
 | 
			
		||||
  "version": "1.2.4",
 | 
			
		||||
  "description": "Backend API for Linux Patch Monitoring System",
 | 
			
		||||
  "main": "src/server.js",
 | 
			
		||||
  "scripts": {
 | 
			
		||||
    "dev": "nodemon src/server.js",
 | 
			
		||||
    "start": "node src/server.js",
 | 
			
		||||
    "build": "echo 'No build step needed for Node.js'",
 | 
			
		||||
    "db:generate": "prisma generate",
 | 
			
		||||
    "db:migrate": "prisma migrate dev",
 | 
			
		||||
    "db:push": "prisma db push",
 | 
			
		||||
    "db:studio": "prisma studio"
 | 
			
		||||
  },
 | 
			
		||||
  "dependencies": {
 | 
			
		||||
    "@prisma/client": "^5.7.0",
 | 
			
		||||
    "bcryptjs": "^2.4.3",
 | 
			
		||||
    "cors": "^2.8.5",
 | 
			
		||||
    "dotenv": "^16.3.1",
 | 
			
		||||
    "express": "^4.18.2",
 | 
			
		||||
    "express-rate-limit": "^7.1.5",
 | 
			
		||||
    "express-validator": "^7.0.1",
 | 
			
		||||
    "helmet": "^7.1.0",
 | 
			
		||||
    "jsonwebtoken": "^9.0.2",
 | 
			
		||||
    "moment": "^2.30.1",
 | 
			
		||||
    "uuid": "^9.0.1",
 | 
			
		||||
    "winston": "^3.11.0"
 | 
			
		||||
  },
 | 
			
		||||
  "devDependencies": {
 | 
			
		||||
    "nodemon": "^3.0.2",
 | 
			
		||||
    "prisma": "^5.7.0"
 | 
			
		||||
  },
 | 
			
		||||
  "engines": {
 | 
			
		||||
    "node": ">=18.0.0"
 | 
			
		||||
  }
 | 
			
		||||
	"name": "patchmon-backend",
 | 
			
		||||
	"version": "1.2.7",
 | 
			
		||||
	"description": "Backend API for Linux Patch Monitoring System",
 | 
			
		||||
	"license": "AGPL-3.0",
 | 
			
		||||
	"main": "src/server.js",
 | 
			
		||||
	"scripts": {
 | 
			
		||||
		"dev": "nodemon src/server.js",
 | 
			
		||||
		"start": "node src/server.js",
 | 
			
		||||
		"build": "echo 'No build step needed for Node.js'",
 | 
			
		||||
		"db:generate": "prisma generate",
 | 
			
		||||
		"db:migrate": "prisma migrate dev",
 | 
			
		||||
		"db:push": "prisma db push",
 | 
			
		||||
		"db:studio": "prisma studio"
 | 
			
		||||
	},
 | 
			
		||||
	"dependencies": {
 | 
			
		||||
		"@prisma/client": "^6.1.0",
 | 
			
		||||
		"bcryptjs": "^2.4.3",
 | 
			
		||||
		"cors": "^2.8.5",
 | 
			
		||||
		"dotenv": "^16.4.7",
 | 
			
		||||
		"express": "^4.21.2",
 | 
			
		||||
		"express-rate-limit": "^7.5.0",
 | 
			
		||||
		"express-validator": "^7.2.0",
 | 
			
		||||
		"helmet": "^8.0.0",
 | 
			
		||||
		"jsonwebtoken": "^9.0.2",
 | 
			
		||||
		"moment": "^2.30.1",
 | 
			
		||||
		"qrcode": "^1.5.4",
 | 
			
		||||
		"speakeasy": "^2.0.0",
 | 
			
		||||
		"uuid": "^11.0.3",
 | 
			
		||||
		"winston": "^3.17.0"
 | 
			
		||||
	},
 | 
			
		||||
	"devDependencies": {
 | 
			
		||||
		"@types/bcryptjs": "^2.4.6",
 | 
			
		||||
		"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 {
 | 
			
		||||
  provider = "prisma-client-js"
 | 
			
		||||
}
 | 
			
		||||
@@ -10,210 +7,258 @@ datasource db {
 | 
			
		||||
  url      = env("DATABASE_URL")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
model User {
 | 
			
		||||
  id            String   @id @default(cuid())
 | 
			
		||||
  username      String   @unique
 | 
			
		||||
  email         String   @unique
 | 
			
		||||
  passwordHash  String   @map("password_hash")
 | 
			
		||||
  role          String   @default("admin") // admin, user
 | 
			
		||||
  isActive      Boolean  @default(true) @map("is_active")
 | 
			
		||||
  lastLogin     DateTime? @map("last_login")
 | 
			
		||||
  createdAt     DateTime @map("created_at") @default(now())
 | 
			
		||||
  updatedAt     DateTime @map("updated_at") @updatedAt
 | 
			
		||||
  
 | 
			
		||||
  // Relationships
 | 
			
		||||
  dashboardPreferences DashboardPreferences[]
 | 
			
		||||
  
 | 
			
		||||
  @@map("users")
 | 
			
		||||
model dashboard_preferences {
 | 
			
		||||
  id         String   @id
 | 
			
		||||
  user_id    String
 | 
			
		||||
  card_id    String
 | 
			
		||||
  enabled    Boolean  @default(true)
 | 
			
		||||
  order      Int      @default(0)
 | 
			
		||||
  created_at DateTime @default(now())
 | 
			
		||||
  updated_at DateTime
 | 
			
		||||
  users      users    @relation(fields: [user_id], references: [id], onDelete: Cascade)
 | 
			
		||||
 | 
			
		||||
  @@unique([user_id, card_id])
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
model RolePermissions {
 | 
			
		||||
  id                    String   @id @default(cuid())
 | 
			
		||||
  role                  String   @unique // admin, user, custom roles
 | 
			
		||||
  canViewDashboard      Boolean  @default(true) @map("can_view_dashboard")
 | 
			
		||||
  canViewHosts          Boolean  @default(true) @map("can_view_hosts")
 | 
			
		||||
  canManageHosts        Boolean  @default(false) @map("can_manage_hosts")
 | 
			
		||||
  canViewPackages       Boolean  @default(true) @map("can_view_packages")
 | 
			
		||||
  canManagePackages     Boolean  @default(false) @map("can_manage_packages")
 | 
			
		||||
  canViewUsers          Boolean  @default(false) @map("can_view_users")
 | 
			
		||||
  canManageUsers        Boolean  @default(false) @map("can_manage_users")
 | 
			
		||||
  canViewReports        Boolean  @default(true) @map("can_view_reports")
 | 
			
		||||
  canExportData         Boolean  @default(false) @map("can_export_data")
 | 
			
		||||
  canManageSettings     Boolean  @default(false) @map("can_manage_settings")
 | 
			
		||||
  createdAt             DateTime @map("created_at") @default(now())
 | 
			
		||||
  updatedAt             DateTime @map("updated_at") @updatedAt
 | 
			
		||||
  
 | 
			
		||||
  @@map("role_permissions")
 | 
			
		||||
model host_groups {
 | 
			
		||||
  id                      String                    @id
 | 
			
		||||
  name                    String                    @unique
 | 
			
		||||
  description             String?
 | 
			
		||||
  color                   String?                   @default("#3B82F6")
 | 
			
		||||
  created_at              DateTime                  @default(now())
 | 
			
		||||
  updated_at              DateTime
 | 
			
		||||
  hosts                   hosts[]
 | 
			
		||||
  auto_enrollment_tokens  auto_enrollment_tokens[]
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
model HostGroup {
 | 
			
		||||
  id            String   @id @default(cuid())
 | 
			
		||||
  name          String   @unique
 | 
			
		||||
  description   String?
 | 
			
		||||
  color         String?  @default("#3B82F6") // Hex color for UI display
 | 
			
		||||
  createdAt     DateTime @map("created_at") @default(now())
 | 
			
		||||
  updatedAt     DateTime @map("updated_at") @updatedAt
 | 
			
		||||
  
 | 
			
		||||
  // Relationships
 | 
			
		||||
  hosts         Host[]
 | 
			
		||||
  
 | 
			
		||||
  @@map("host_groups")
 | 
			
		||||
model host_packages {
 | 
			
		||||
  id                 String   @id
 | 
			
		||||
  host_id            String
 | 
			
		||||
  package_id         String
 | 
			
		||||
  current_version    String
 | 
			
		||||
  available_version  String?
 | 
			
		||||
  needs_update       Boolean  @default(false)
 | 
			
		||||
  is_security_update Boolean  @default(false)
 | 
			
		||||
  last_checked       DateTime @default(now())
 | 
			
		||||
  hosts              hosts    @relation(fields: [host_id], references: [id], onDelete: Cascade)
 | 
			
		||||
  packages           packages @relation(fields: [package_id], references: [id], onDelete: Cascade)
 | 
			
		||||
 | 
			
		||||
  @@unique([host_id, package_id])
 | 
			
		||||
  @@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 {
 | 
			
		||||
  id            String   @id @default(cuid())
 | 
			
		||||
  hostname      String   @unique
 | 
			
		||||
  ip            String?
 | 
			
		||||
  osType        String   @map("os_type")
 | 
			
		||||
  osVersion     String   @map("os_version")
 | 
			
		||||
  architecture  String?
 | 
			
		||||
  lastUpdate    DateTime @map("last_update") @default(now())
 | 
			
		||||
  status        String   @default("active") // active, inactive, error
 | 
			
		||||
  apiId         String   @unique @map("api_id") // New API ID for authentication
 | 
			
		||||
  apiKey        String   @unique @map("api_key") // New API Key for authentication
 | 
			
		||||
  hostGroupId   String?  @map("host_group_id") // Optional group association
 | 
			
		||||
  agentVersion  String?  @map("agent_version") // Agent script version
 | 
			
		||||
  autoUpdate    Boolean  @map("auto_update") @default(true) // Enable auto-update for this host
 | 
			
		||||
  createdAt     DateTime @map("created_at") @default(now())
 | 
			
		||||
  updatedAt     DateTime @map("updated_at") @updatedAt
 | 
			
		||||
  
 | 
			
		||||
  // Relationships
 | 
			
		||||
  hostPackages  HostPackage[]
 | 
			
		||||
  updateHistory UpdateHistory[]
 | 
			
		||||
  hostRepositories HostRepository[]
 | 
			
		||||
  hostGroup     HostGroup? @relation(fields: [hostGroupId], references: [id], onDelete: SetNull)
 | 
			
		||||
  
 | 
			
		||||
  @@map("hosts")
 | 
			
		||||
model host_repositories {
 | 
			
		||||
  id            String       @id
 | 
			
		||||
  host_id       String
 | 
			
		||||
  repository_id String
 | 
			
		||||
  is_enabled    Boolean      @default(true)
 | 
			
		||||
  last_checked  DateTime     @default(now())
 | 
			
		||||
  hosts         hosts        @relation(fields: [host_id], references: [id], onDelete: Cascade)
 | 
			
		||||
  repositories  repositories @relation(fields: [repository_id], references: [id], onDelete: Cascade)
 | 
			
		||||
 | 
			
		||||
  @@unique([host_id, repository_id])
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
model Package {
 | 
			
		||||
  id            String   @id @default(cuid())
 | 
			
		||||
  name          String   @unique
 | 
			
		||||
  description   String?
 | 
			
		||||
  category      String?  // system, security, development, etc.
 | 
			
		||||
  latestVersion String?  @map("latest_version")
 | 
			
		||||
  createdAt     DateTime @map("created_at") @default(now())
 | 
			
		||||
  updatedAt     DateTime @map("updated_at") @updatedAt
 | 
			
		||||
  
 | 
			
		||||
  // Relationships
 | 
			
		||||
  hostPackages  HostPackage[]
 | 
			
		||||
  
 | 
			
		||||
  @@map("packages")
 | 
			
		||||
model hosts {
 | 
			
		||||
  id                 String              @id
 | 
			
		||||
  machine_id         String              @unique
 | 
			
		||||
  friendly_name      String
 | 
			
		||||
  ip                 String?
 | 
			
		||||
  os_type            String
 | 
			
		||||
  os_version         String
 | 
			
		||||
  architecture       String?
 | 
			
		||||
  last_update        DateTime            @default(now())
 | 
			
		||||
  status             String              @default("active")
 | 
			
		||||
  created_at         DateTime            @default(now())
 | 
			
		||||
  updated_at         DateTime
 | 
			
		||||
  api_id             String              @unique
 | 
			
		||||
  api_key            String              @unique
 | 
			
		||||
  host_group_id      String?
 | 
			
		||||
  agent_version      String?
 | 
			
		||||
  auto_update        Boolean             @default(true)
 | 
			
		||||
  cpu_cores          Int?
 | 
			
		||||
  cpu_model          String?
 | 
			
		||||
  disk_details       Json?
 | 
			
		||||
  dns_servers        Json?
 | 
			
		||||
  gateway_ip         String?
 | 
			
		||||
  hostname           String?
 | 
			
		||||
  kernel_version     String?
 | 
			
		||||
  load_average       Json?
 | 
			
		||||
  network_interfaces Json?
 | 
			
		||||
  ram_installed      Int?
 | 
			
		||||
  selinux_status     String?
 | 
			
		||||
  swap_size          Int?
 | 
			
		||||
  system_uptime      String?
 | 
			
		||||
  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 {
 | 
			
		||||
  id               String   @id @default(cuid())
 | 
			
		||||
  hostId           String   @map("host_id")
 | 
			
		||||
  packageId        String   @map("package_id")
 | 
			
		||||
  currentVersion   String   @map("current_version")
 | 
			
		||||
  availableVersion String?  @map("available_version")
 | 
			
		||||
  needsUpdate      Boolean  @map("needs_update") @default(false)
 | 
			
		||||
  isSecurityUpdate Boolean  @map("is_security_update") @default(false)
 | 
			
		||||
  lastChecked      DateTime @map("last_checked") @default(now())
 | 
			
		||||
  
 | 
			
		||||
  // Relationships
 | 
			
		||||
  host             Host     @relation(fields: [hostId], references: [id], onDelete: Cascade)
 | 
			
		||||
  package          Package  @relation(fields: [packageId], references: [id], onDelete: Cascade)
 | 
			
		||||
  
 | 
			
		||||
  @@unique([hostId, packageId])
 | 
			
		||||
  @@map("host_packages")
 | 
			
		||||
model packages {
 | 
			
		||||
  id             String          @id
 | 
			
		||||
  name           String          @unique
 | 
			
		||||
  description    String?
 | 
			
		||||
  category       String?
 | 
			
		||||
  latest_version String?
 | 
			
		||||
  created_at     DateTime        @default(now())
 | 
			
		||||
  updated_at     DateTime
 | 
			
		||||
  host_packages  host_packages[]
 | 
			
		||||
 | 
			
		||||
  @@index([name])
 | 
			
		||||
  @@index([category])
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
model UpdateHistory {
 | 
			
		||||
  id             String   @id @default(cuid())
 | 
			
		||||
  hostId         String   @map("host_id")
 | 
			
		||||
  packagesCount  Int      @map("packages_count")
 | 
			
		||||
  securityCount  Int      @map("security_count")
 | 
			
		||||
  timestamp      DateTime @default(now())
 | 
			
		||||
  status         String   @default("success") // success, error
 | 
			
		||||
  errorMessage   String?  @map("error_message")
 | 
			
		||||
  
 | 
			
		||||
  // Relationships
 | 
			
		||||
  host           Host     @relation(fields: [hostId], references: [id], onDelete: Cascade)
 | 
			
		||||
  
 | 
			
		||||
  @@map("update_history")
 | 
			
		||||
}
 | 
			
		||||
model repositories {
 | 
			
		||||
  id                String              @id
 | 
			
		||||
  name              String
 | 
			
		||||
  url               String
 | 
			
		||||
  distribution      String
 | 
			
		||||
  components        String
 | 
			
		||||
  repo_type         String
 | 
			
		||||
  is_active         Boolean             @default(true)
 | 
			
		||||
  is_secure         Boolean             @default(true)
 | 
			
		||||
  priority          Int?
 | 
			
		||||
  description       String?
 | 
			
		||||
  created_at        DateTime            @default(now())
 | 
			
		||||
  updated_at        DateTime
 | 
			
		||||
  host_repositories host_repositories[]
 | 
			
		||||
 | 
			
		||||
model Repository {
 | 
			
		||||
  id            String   @id @default(cuid())
 | 
			
		||||
  name          String   // Repository name (e.g., "focal", "focal-updates")
 | 
			
		||||
  url           String   // Repository URL
 | 
			
		||||
  distribution  String   // Distribution (e.g., "focal", "jammy")
 | 
			
		||||
  components    String   // Components (e.g., "main restricted universe multiverse")
 | 
			
		||||
  repoType      String   @map("repo_type") // "deb" or "deb-src"
 | 
			
		||||
  isActive      Boolean  @map("is_active") @default(true)
 | 
			
		||||
  isSecure      Boolean  @map("is_secure") @default(true) // HTTPS vs HTTP
 | 
			
		||||
  priority      Int?     // Repository priority
 | 
			
		||||
  description   String?  // Optional description
 | 
			
		||||
  createdAt     DateTime @map("created_at") @default(now())
 | 
			
		||||
  updatedAt     DateTime @map("updated_at") @updatedAt
 | 
			
		||||
  
 | 
			
		||||
  // Relationships
 | 
			
		||||
  hostRepositories HostRepository[]
 | 
			
		||||
  
 | 
			
		||||
  @@unique([url, distribution, components])
 | 
			
		||||
  @@map("repositories")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
model HostRepository {
 | 
			
		||||
  id           String   @id @default(cuid())
 | 
			
		||||
  hostId       String   @map("host_id")
 | 
			
		||||
  repositoryId String   @map("repository_id")
 | 
			
		||||
  isEnabled    Boolean  @map("is_enabled") @default(true)
 | 
			
		||||
  lastChecked  DateTime @map("last_checked") @default(now())
 | 
			
		||||
  
 | 
			
		||||
  // Relationships
 | 
			
		||||
  host         Host       @relation(fields: [hostId], references: [id], onDelete: Cascade)
 | 
			
		||||
  repository   Repository @relation(fields: [repositoryId], references: [id], onDelete: Cascade)
 | 
			
		||||
  
 | 
			
		||||
  @@unique([hostId, repositoryId])
 | 
			
		||||
  @@map("host_repositories")
 | 
			
		||||
model role_permissions {
 | 
			
		||||
  id                  String   @id
 | 
			
		||||
  role                String   @unique
 | 
			
		||||
  can_view_dashboard  Boolean  @default(true)
 | 
			
		||||
  can_view_hosts      Boolean  @default(true)
 | 
			
		||||
  can_manage_hosts    Boolean  @default(false)
 | 
			
		||||
  can_view_packages   Boolean  @default(true)
 | 
			
		||||
  can_manage_packages Boolean  @default(false)
 | 
			
		||||
  can_view_users      Boolean  @default(false)
 | 
			
		||||
  can_manage_users    Boolean  @default(false)
 | 
			
		||||
  can_view_reports    Boolean  @default(true)
 | 
			
		||||
  can_export_data     Boolean  @default(false)
 | 
			
		||||
  can_manage_settings Boolean  @default(false)
 | 
			
		||||
  created_at          DateTime @default(now())
 | 
			
		||||
  updated_at          DateTime
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
model Settings {
 | 
			
		||||
  id              String   @id @default(cuid())
 | 
			
		||||
  serverUrl       String   @map("server_url") @default("http://localhost:3001")
 | 
			
		||||
  serverProtocol  String   @map("server_protocol") @default("http") // http, https
 | 
			
		||||
  serverHost      String   @map("server_host") @default("localhost")
 | 
			
		||||
  serverPort      Int      @map("server_port") @default(3001)
 | 
			
		||||
  frontendUrl     String   @map("frontend_url") @default("http://localhost:3000")
 | 
			
		||||
  updateInterval  Int      @map("update_interval") @default(60) // Update interval in minutes
 | 
			
		||||
  autoUpdate      Boolean  @map("auto_update") @default(false) // Enable automatic agent updates
 | 
			
		||||
  githubRepoUrl   String   @map("github_repo_url") @default("git@github.com:9technologygroup/patchmon.net.git") // GitHub repository URL for version checking
 | 
			
		||||
  sshKeyPath      String?  @map("ssh_key_path") // Optional SSH key path for deploy key authentication
 | 
			
		||||
  createdAt       DateTime @map("created_at") @default(now())
 | 
			
		||||
  updatedAt       DateTime @map("updated_at") @updatedAt
 | 
			
		||||
 | 
			
		||||
  @@map("settings")
 | 
			
		||||
model settings {
 | 
			
		||||
  id                String    @id
 | 
			
		||||
  server_url        String    @default("http://localhost:3001")
 | 
			
		||||
  server_protocol   String    @default("http")
 | 
			
		||||
  server_host       String    @default("localhost")
 | 
			
		||||
  server_port       Int       @default(3001)
 | 
			
		||||
  created_at        DateTime  @default(now())
 | 
			
		||||
  updated_at        DateTime
 | 
			
		||||
  update_interval   Int       @default(60)
 | 
			
		||||
  auto_update       Boolean   @default(false)
 | 
			
		||||
  github_repo_url   String    @default("git@github.com:9technologygroup/patchmon.net.git")
 | 
			
		||||
  ssh_key_path      String?
 | 
			
		||||
  repository_type   String    @default("public")
 | 
			
		||||
  last_update_check DateTime?
 | 
			
		||||
  latest_version    String?
 | 
			
		||||
  update_available  Boolean   @default(false)
 | 
			
		||||
  signup_enabled    Boolean   @default(false)
 | 
			
		||||
  default_user_role String    @default("user")
 | 
			
		||||
  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 {
 | 
			
		||||
  id              String   @id @default(cuid())
 | 
			
		||||
  userId          String   @map("user_id")
 | 
			
		||||
  cardId          String   @map("card_id") // e.g., "totalHosts", "securityUpdates", etc.
 | 
			
		||||
  enabled         Boolean  @default(true)
 | 
			
		||||
  order           Int      @default(0)
 | 
			
		||||
  createdAt       DateTime @map("created_at") @default(now())
 | 
			
		||||
  updatedAt       DateTime @map("updated_at") @updatedAt
 | 
			
		||||
  
 | 
			
		||||
  // Relationships
 | 
			
		||||
  user            User     @relation(fields: [userId], references: [id], onDelete: Cascade)
 | 
			
		||||
  
 | 
			
		||||
  @@unique([userId, cardId])
 | 
			
		||||
  @@map("dashboard_preferences")
 | 
			
		||||
model update_history {
 | 
			
		||||
  id              String   @id
 | 
			
		||||
  host_id         String
 | 
			
		||||
  packages_count  Int
 | 
			
		||||
  security_count  Int
 | 
			
		||||
  total_packages  Int?
 | 
			
		||||
  payload_size_kb Float?
 | 
			
		||||
  execution_time  Float?
 | 
			
		||||
  timestamp       DateTime @default(now())
 | 
			
		||||
  status          String   @default("success")
 | 
			
		||||
  error_message   String?
 | 
			
		||||
  hosts           hosts    @relation(fields: [host_id], references: [id], onDelete: Cascade)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
model AgentVersion {
 | 
			
		||||
  id              String   @id @default(cuid())
 | 
			
		||||
  version         String   @unique // e.g., "1.0.0", "1.1.0"
 | 
			
		||||
  isCurrent       Boolean  @default(false) @map("is_current") // Only one version can be current
 | 
			
		||||
  releaseNotes    String?  @map("release_notes")
 | 
			
		||||
  downloadUrl     String?  @map("download_url") // URL to download the agent script
 | 
			
		||||
  minServerVersion String? @map("min_server_version") // Minimum server version required
 | 
			
		||||
  scriptContent   String?  @map("script_content") // The actual agent script content
 | 
			
		||||
  isDefault       Boolean  @default(false) @map("is_default") // Default version for new installations
 | 
			
		||||
  createdAt       DateTime @map("created_at") @default(now())
 | 
			
		||||
  updatedAt       DateTime @map("updated_at") @updatedAt
 | 
			
		||||
  
 | 
			
		||||
  @@map("agent_versions")
 | 
			
		||||
} 
 | 
			
		||||
model users {
 | 
			
		||||
  id                     String                   @id
 | 
			
		||||
  username               String                   @unique
 | 
			
		||||
  email                  String                   @unique
 | 
			
		||||
  password_hash          String
 | 
			
		||||
  role                   String                   @default("admin")
 | 
			
		||||
  is_active              Boolean                  @default(true)
 | 
			
		||||
  last_login             DateTime?
 | 
			
		||||
  created_at             DateTime                 @default(now())
 | 
			
		||||
  updated_at             DateTime
 | 
			
		||||
  tfa_backup_codes       String?
 | 
			
		||||
  tfa_enabled            Boolean                  @default(false)
 | 
			
		||||
  tfa_secret             String?
 | 
			
		||||
  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 { PrismaClient } = require('@prisma/client');
 | 
			
		||||
const jwt = require("jsonwebtoken");
 | 
			
		||||
const { PrismaClient } = require("@prisma/client");
 | 
			
		||||
const {
 | 
			
		||||
	validate_session,
 | 
			
		||||
	update_session_activity,
 | 
			
		||||
	is_tfa_bypassed,
 | 
			
		||||
} = require("../utils/session_manager");
 | 
			
		||||
 | 
			
		||||
const prisma = new PrismaClient();
 | 
			
		||||
 | 
			
		||||
// Middleware to verify JWT token
 | 
			
		||||
// Middleware to verify JWT token with session validation
 | 
			
		||||
const authenticateToken = async (req, res, next) => {
 | 
			
		||||
  try {
 | 
			
		||||
    const authHeader = req.headers['authorization'];
 | 
			
		||||
    const token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN
 | 
			
		||||
	try {
 | 
			
		||||
		const authHeader = req.headers.authorization;
 | 
			
		||||
		const token = authHeader?.split(" ")[1]; // Bearer TOKEN
 | 
			
		||||
 | 
			
		||||
    if (!token) {
 | 
			
		||||
      return res.status(401).json({ error: 'Access token required' });
 | 
			
		||||
    }
 | 
			
		||||
		if (!token) {
 | 
			
		||||
			return res.status(401).json({ error: "Access token required" });
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
    // Verify token
 | 
			
		||||
    const decoded = jwt.verify(token, process.env.JWT_SECRET || 'your-secret-key');
 | 
			
		||||
    
 | 
			
		||||
    // Get user from database
 | 
			
		||||
    const user = await prisma.user.findUnique({
 | 
			
		||||
      where: { id: decoded.userId },
 | 
			
		||||
      select: {
 | 
			
		||||
        id: true,
 | 
			
		||||
        username: true,
 | 
			
		||||
        email: true,
 | 
			
		||||
        role: true,
 | 
			
		||||
        isActive: true,
 | 
			
		||||
        lastLogin: true
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
		// Verify token
 | 
			
		||||
		if (!process.env.JWT_SECRET) {
 | 
			
		||||
			throw new Error("JWT_SECRET environment variable is required");
 | 
			
		||||
		}
 | 
			
		||||
		const decoded = jwt.verify(token, process.env.JWT_SECRET);
 | 
			
		||||
 | 
			
		||||
    if (!user || !user.isActive) {
 | 
			
		||||
      return res.status(401).json({ error: 'Invalid or inactive user' });
 | 
			
		||||
    }
 | 
			
		||||
		// Validate session and check inactivity timeout
 | 
			
		||||
		const validation = await validate_session(decoded.sessionId, token);
 | 
			
		||||
 | 
			
		||||
    // Update last login
 | 
			
		||||
    await prisma.user.update({
 | 
			
		||||
      where: { id: user.id },
 | 
			
		||||
      data: { lastLogin: new Date() }
 | 
			
		||||
    });
 | 
			
		||||
		if (!validation.valid) {
 | 
			
		||||
			const error_messages = {
 | 
			
		||||
				"Session not found": "Session not found",
 | 
			
		||||
				"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;
 | 
			
		||||
    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' });
 | 
			
		||||
  }
 | 
			
		||||
			return res.status(401).json({
 | 
			
		||||
				error: error_messages[validation.reason] || "Authentication failed",
 | 
			
		||||
				reason: validation.reason,
 | 
			
		||||
			});
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Update session activity timestamp
 | 
			
		||||
		await update_session_activity(decoded.sessionId);
 | 
			
		||||
 | 
			
		||||
		// Check if TFA is bypassed for this session
 | 
			
		||||
		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
 | 
			
		||||
const requireAdmin = (req, res, next) => {
 | 
			
		||||
  if (req.user.role !== 'admin') {
 | 
			
		||||
    return res.status(403).json({ error: 'Admin access required' });
 | 
			
		||||
  }
 | 
			
		||||
  next();
 | 
			
		||||
	if (req.user.role !== "admin") {
 | 
			
		||||
		return res.status(403).json({ error: "Admin access required" });
 | 
			
		||||
	}
 | 
			
		||||
	next();
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
// Middleware to check if user is authenticated (optional)
 | 
			
		||||
const optionalAuth = async (req, res, next) => {
 | 
			
		||||
  try {
 | 
			
		||||
    const authHeader = req.headers['authorization'];
 | 
			
		||||
    const token = authHeader && authHeader.split(' ')[1];
 | 
			
		||||
const optionalAuth = async (req, _res, next) => {
 | 
			
		||||
	try {
 | 
			
		||||
		const authHeader = req.headers.authorization;
 | 
			
		||||
		const token = authHeader?.split(" ")[1];
 | 
			
		||||
 | 
			
		||||
    if (token) {
 | 
			
		||||
      const decoded = jwt.verify(token, process.env.JWT_SECRET || 'your-secret-key');
 | 
			
		||||
      const user = await prisma.user.findUnique({
 | 
			
		||||
        where: { id: decoded.userId },
 | 
			
		||||
        select: {
 | 
			
		||||
          id: true,
 | 
			
		||||
          username: true,
 | 
			
		||||
          email: true,
 | 
			
		||||
          role: true,
 | 
			
		||||
          isActive: true
 | 
			
		||||
        }
 | 
			
		||||
      });
 | 
			
		||||
		if (token) {
 | 
			
		||||
			if (!process.env.JWT_SECRET) {
 | 
			
		||||
				throw new Error("JWT_SECRET environment variable is required");
 | 
			
		||||
			}
 | 
			
		||||
			const decoded = jwt.verify(token, process.env.JWT_SECRET);
 | 
			
		||||
			const user = await prisma.users.findUnique({
 | 
			
		||||
				where: { id: decoded.userId },
 | 
			
		||||
				select: {
 | 
			
		||||
					id: true,
 | 
			
		||||
					username: true,
 | 
			
		||||
					email: true,
 | 
			
		||||
					role: true,
 | 
			
		||||
					is_active: true,
 | 
			
		||||
					last_login: true,
 | 
			
		||||
					created_at: true,
 | 
			
		||||
					updated_at: true,
 | 
			
		||||
				},
 | 
			
		||||
			});
 | 
			
		||||
 | 
			
		||||
      if (user && user.isActive) {
 | 
			
		||||
        req.user = user;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    next();
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    // Continue without authentication for optional auth
 | 
			
		||||
    next();
 | 
			
		||||
  }
 | 
			
		||||
			if (user?.is_active) {
 | 
			
		||||
				req.user = user;
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
		next();
 | 
			
		||||
	} catch {
 | 
			
		||||
		// Continue without authentication for optional auth
 | 
			
		||||
		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 = {
 | 
			
		||||
  authenticateToken,
 | 
			
		||||
  requireAdmin,
 | 
			
		||||
  optionalAuth
 | 
			
		||||
	authenticateToken,
 | 
			
		||||
	requireAdmin,
 | 
			
		||||
	optionalAuth,
 | 
			
		||||
	requireTfaIfEnabled,
 | 
			
		||||
};
 | 
			
		||||
 
 | 
			
		||||
@@ -1,59 +1,61 @@
 | 
			
		||||
const { PrismaClient } = require('@prisma/client');
 | 
			
		||||
const { PrismaClient } = require("@prisma/client");
 | 
			
		||||
const prisma = new PrismaClient();
 | 
			
		||||
 | 
			
		||||
// Permission middleware factory
 | 
			
		||||
const requirePermission = (permission) => {
 | 
			
		||||
  return async (req, res, next) => {
 | 
			
		||||
    try {
 | 
			
		||||
      // Get user's role permissions
 | 
			
		||||
      const rolePermissions = await prisma.rolePermissions.findUnique({
 | 
			
		||||
        where: { role: req.user.role }
 | 
			
		||||
      });
 | 
			
		||||
	return async (req, res, next) => {
 | 
			
		||||
		try {
 | 
			
		||||
			// Get user's role permissions
 | 
			
		||||
			const rolePermissions = await prisma.role_permissions.findUnique({
 | 
			
		||||
				where: { role: req.user.role },
 | 
			
		||||
			});
 | 
			
		||||
 | 
			
		||||
      // If no specific permissions found, default to admin permissions (for backward compatibility)
 | 
			
		||||
      if (!rolePermissions) {
 | 
			
		||||
        console.warn(`No permissions found for role: ${req.user.role}, defaulting to admin access`);
 | 
			
		||||
        return next();
 | 
			
		||||
      }
 | 
			
		||||
			// If no specific permissions found, default to admin permissions (for backward compatibility)
 | 
			
		||||
			if (!rolePermissions) {
 | 
			
		||||
				console.warn(
 | 
			
		||||
					`No permissions found for role: ${req.user.role}, defaulting to admin access`,
 | 
			
		||||
				);
 | 
			
		||||
				return next();
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
      // Check if user has the required permission
 | 
			
		||||
      if (!rolePermissions[permission]) {
 | 
			
		||||
        return res.status(403).json({ 
 | 
			
		||||
          error: 'Insufficient permissions',
 | 
			
		||||
          message: `You don't have permission to ${permission.replace('can', '').toLowerCase()}`
 | 
			
		||||
        });
 | 
			
		||||
      }
 | 
			
		||||
			// Check if user has the required permission
 | 
			
		||||
			if (!rolePermissions[permission]) {
 | 
			
		||||
				return res.status(403).json({
 | 
			
		||||
					error: "Insufficient permissions",
 | 
			
		||||
					message: `You don't have permission to ${permission.replace("can_", "").replace("_", " ")}`,
 | 
			
		||||
				});
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
      next();
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      console.error('Permission check error:', error);
 | 
			
		||||
      res.status(500).json({ error: 'Permission check failed' });
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
			next();
 | 
			
		||||
		} catch (error) {
 | 
			
		||||
			console.error("Permission check error:", error);
 | 
			
		||||
			res.status(500).json({ error: "Permission check failed" });
 | 
			
		||||
		}
 | 
			
		||||
	};
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
// Specific permission middlewares
 | 
			
		||||
const requireViewDashboard = requirePermission('canViewDashboard');
 | 
			
		||||
const requireViewHosts = requirePermission('canViewHosts');
 | 
			
		||||
const requireManageHosts = requirePermission('canManageHosts');
 | 
			
		||||
const requireViewPackages = requirePermission('canViewPackages');
 | 
			
		||||
const requireManagePackages = requirePermission('canManagePackages');
 | 
			
		||||
const requireViewUsers = requirePermission('canViewUsers');
 | 
			
		||||
const requireManageUsers = requirePermission('canManageUsers');
 | 
			
		||||
const requireViewReports = requirePermission('canViewReports');
 | 
			
		||||
const requireExportData = requirePermission('canExportData');
 | 
			
		||||
const requireManageSettings = requirePermission('canManageSettings');
 | 
			
		||||
// Specific permission middlewares - using snake_case field names
 | 
			
		||||
const requireViewDashboard = requirePermission("can_view_dashboard");
 | 
			
		||||
const requireViewHosts = requirePermission("can_view_hosts");
 | 
			
		||||
const requireManageHosts = requirePermission("can_manage_hosts");
 | 
			
		||||
const requireViewPackages = requirePermission("can_view_packages");
 | 
			
		||||
const requireManagePackages = requirePermission("can_manage_packages");
 | 
			
		||||
const requireViewUsers = requirePermission("can_view_users");
 | 
			
		||||
const requireManageUsers = requirePermission("can_manage_users");
 | 
			
		||||
const requireViewReports = requirePermission("can_view_reports");
 | 
			
		||||
const requireExportData = requirePermission("can_export_data");
 | 
			
		||||
const requireManageSettings = requirePermission("can_manage_settings");
 | 
			
		||||
 | 
			
		||||
module.exports = {
 | 
			
		||||
  requirePermission,
 | 
			
		||||
  requireViewDashboard,
 | 
			
		||||
  requireViewHosts,
 | 
			
		||||
  requireManageHosts,
 | 
			
		||||
  requireViewPackages,
 | 
			
		||||
  requireManagePackages,
 | 
			
		||||
  requireViewUsers,
 | 
			
		||||
  requireManageUsers,
 | 
			
		||||
  requireViewReports,
 | 
			
		||||
  requireExportData,
 | 
			
		||||
  requireManageSettings
 | 
			
		||||
	requirePermission,
 | 
			
		||||
	requireViewDashboard,
 | 
			
		||||
	requireViewHosts,
 | 
			
		||||
	requireManageHosts,
 | 
			
		||||
	requireViewPackages,
 | 
			
		||||
	requireManagePackages,
 | 
			
		||||
	requireViewUsers,
 | 
			
		||||
	requireManageUsers,
 | 
			
		||||
	requireViewReports,
 | 
			
		||||
	requireExportData,
 | 
			
		||||
	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 { body, validationResult } = require('express-validator');
 | 
			
		||||
const { PrismaClient } = require('@prisma/client');
 | 
			
		||||
const { authenticateToken } = require('../middleware/auth');
 | 
			
		||||
const express = require("express");
 | 
			
		||||
const { body, validationResult } = require("express-validator");
 | 
			
		||||
const { PrismaClient } = require("@prisma/client");
 | 
			
		||||
const { authenticateToken } = require("../middleware/auth");
 | 
			
		||||
const { v4: uuidv4 } = require("uuid");
 | 
			
		||||
 | 
			
		||||
const router = express.Router();
 | 
			
		||||
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
 | 
			
		||||
router.get('/', authenticateToken, async (req, res) => {
 | 
			
		||||
  try {
 | 
			
		||||
    const preferences = await prisma.dashboardPreferences.findMany({
 | 
			
		||||
      where: { userId: req.user.id },
 | 
			
		||||
      orderBy: { order: 'asc' }
 | 
			
		||||
    });
 | 
			
		||||
    
 | 
			
		||||
    res.json(preferences);
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.error('Dashboard preferences fetch error:', error);
 | 
			
		||||
    res.status(500).json({ error: 'Failed to fetch dashboard preferences' });
 | 
			
		||||
  }
 | 
			
		||||
router.get("/", authenticateToken, async (req, res) => {
 | 
			
		||||
	try {
 | 
			
		||||
		const preferences = await prisma.dashboard_preferences.findMany({
 | 
			
		||||
			where: { user_id: req.user.id },
 | 
			
		||||
			orderBy: { order: "asc" },
 | 
			
		||||
		});
 | 
			
		||||
 | 
			
		||||
		res.json(preferences);
 | 
			
		||||
	} catch (error) {
 | 
			
		||||
		console.error("Dashboard preferences fetch error:", error);
 | 
			
		||||
		res.status(500).json({ error: "Failed to fetch dashboard preferences" });
 | 
			
		||||
	}
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
// Update dashboard preferences (bulk update)
 | 
			
		||||
router.put('/', authenticateToken, [
 | 
			
		||||
  body('preferences').isArray().withMessage('Preferences must be an array'),
 | 
			
		||||
  body('preferences.*.cardId').isString().withMessage('Card ID is required'),
 | 
			
		||||
  body('preferences.*.enabled').isBoolean().withMessage('Enabled must be boolean'),
 | 
			
		||||
  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() });
 | 
			
		||||
    }
 | 
			
		||||
router.put(
 | 
			
		||||
	"/",
 | 
			
		||||
	authenticateToken,
 | 
			
		||||
	[
 | 
			
		||||
		body("preferences").isArray().withMessage("Preferences must be an array"),
 | 
			
		||||
		body("preferences.*.cardId").isString().withMessage("Card ID is required"),
 | 
			
		||||
		body("preferences.*.enabled")
 | 
			
		||||
			.isBoolean()
 | 
			
		||||
			.withMessage("Enabled must be boolean"),
 | 
			
		||||
		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 userId = req.user.id;
 | 
			
		||||
			const { preferences } = req.body;
 | 
			
		||||
			const userId = req.user.id;
 | 
			
		||||
 | 
			
		||||
    // Delete existing preferences for this user
 | 
			
		||||
    await prisma.dashboardPreferences.deleteMany({
 | 
			
		||||
      where: { userId }
 | 
			
		||||
    });
 | 
			
		||||
			// Delete existing preferences for this user
 | 
			
		||||
			await prisma.dashboard_preferences.deleteMany({
 | 
			
		||||
				where: { user_id: userId },
 | 
			
		||||
			});
 | 
			
		||||
 | 
			
		||||
    // Create new preferences
 | 
			
		||||
    const newPreferences = preferences.map(pref => ({
 | 
			
		||||
      userId,
 | 
			
		||||
      cardId: pref.cardId,
 | 
			
		||||
      enabled: pref.enabled,
 | 
			
		||||
      order: pref.order
 | 
			
		||||
    }));
 | 
			
		||||
			// Create new preferences
 | 
			
		||||
			const newPreferences = preferences.map((pref) => ({
 | 
			
		||||
				id: require("uuid").v4(),
 | 
			
		||||
				user_id: userId,
 | 
			
		||||
				card_id: pref.cardId,
 | 
			
		||||
				enabled: pref.enabled,
 | 
			
		||||
				order: pref.order,
 | 
			
		||||
				updated_at: new Date(),
 | 
			
		||||
			}));
 | 
			
		||||
 | 
			
		||||
    const createdPreferences = await prisma.dashboardPreferences.createMany({
 | 
			
		||||
      data: newPreferences
 | 
			
		||||
    });
 | 
			
		||||
			await prisma.dashboard_preferences.createMany({
 | 
			
		||||
				data: newPreferences,
 | 
			
		||||
			});
 | 
			
		||||
 | 
			
		||||
    res.json({
 | 
			
		||||
      message: 'Dashboard preferences updated successfully',
 | 
			
		||||
      preferences: newPreferences
 | 
			
		||||
    });
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.error('Dashboard preferences update error:', error);
 | 
			
		||||
    res.status(500).json({ error: 'Failed to update dashboard preferences' });
 | 
			
		||||
  }
 | 
			
		||||
});
 | 
			
		||||
			res.json({
 | 
			
		||||
				message: "Dashboard preferences updated successfully",
 | 
			
		||||
				preferences: newPreferences,
 | 
			
		||||
			});
 | 
			
		||||
		} catch (error) {
 | 
			
		||||
			console.error("Dashboard preferences update error:", error);
 | 
			
		||||
			res.status(500).json({ error: "Failed to update dashboard preferences" });
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
// Get default dashboard card configuration
 | 
			
		||||
router.get('/defaults', authenticateToken, async (req, res) => {
 | 
			
		||||
  try {
 | 
			
		||||
    const defaultCards = [
 | 
			
		||||
      { cardId: 'totalHosts', title: 'Total Hosts', icon: 'Server', enabled: true, order: 0 },
 | 
			
		||||
      { 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: 'erroredHosts', title: 'Errored Hosts', icon: 'AlertTriangle', enabled: true, order: 4 },
 | 
			
		||||
      { cardId: 'osDistribution', title: 'OS Distribution', icon: 'BarChart3', enabled: true, order: 5 },
 | 
			
		||||
      { cardId: 'updateStatus', title: 'Update Status', icon: 'BarChart3', enabled: true, order: 6 },
 | 
			
		||||
      { cardId: 'packagePriority', title: 'Package Priority', icon: 'BarChart3', enabled: true, order: 7 },
 | 
			
		||||
      { cardId: 'quickStats', title: 'Quick Stats', icon: 'TrendingUp', enabled: true, order: 8 }
 | 
			
		||||
    ];
 | 
			
		||||
router.get("/defaults", authenticateToken, async (_req, res) => {
 | 
			
		||||
	try {
 | 
			
		||||
		// This provides a comprehensive dashboard view for all new users
 | 
			
		||||
		const defaultCards = [
 | 
			
		||||
			{
 | 
			
		||||
				cardId: "totalHosts",
 | 
			
		||||
				title: "Total Hosts",
 | 
			
		||||
				icon: "Server",
 | 
			
		||||
				enabled: true,
 | 
			
		||||
				order: 0,
 | 
			
		||||
			},
 | 
			
		||||
			{
 | 
			
		||||
				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);
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.error('Default dashboard cards error:', error);
 | 
			
		||||
    res.status(500).json({ error: 'Failed to fetch default dashboard cards' });
 | 
			
		||||
  }
 | 
			
		||||
		res.json(defaultCards);
 | 
			
		||||
	} catch (error) {
 | 
			
		||||
		console.error("Default dashboard cards error:", error);
 | 
			
		||||
		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 { PrismaClient } = require('@prisma/client');
 | 
			
		||||
const moment = require('moment');
 | 
			
		||||
const { authenticateToken } = require('../middleware/auth');
 | 
			
		||||
const { 
 | 
			
		||||
  requireViewDashboard, 
 | 
			
		||||
  requireViewHosts, 
 | 
			
		||||
  requireViewPackages 
 | 
			
		||||
} = require('../middleware/permissions');
 | 
			
		||||
const express = require("express");
 | 
			
		||||
const { PrismaClient } = require("@prisma/client");
 | 
			
		||||
const moment = require("moment");
 | 
			
		||||
const { authenticateToken } = require("../middleware/auth");
 | 
			
		||||
const {
 | 
			
		||||
	requireViewDashboard,
 | 
			
		||||
	requireViewHosts,
 | 
			
		||||
	requireViewPackages,
 | 
			
		||||
	requireViewUsers,
 | 
			
		||||
} = require("../middleware/permissions");
 | 
			
		||||
 | 
			
		||||
const router = express.Router();
 | 
			
		||||
const prisma = new PrismaClient();
 | 
			
		||||
 | 
			
		||||
// Get dashboard statistics
 | 
			
		||||
router.get('/stats', authenticateToken, requireViewDashboard, async (req, res) => {
 | 
			
		||||
  try {
 | 
			
		||||
    const now = new Date();
 | 
			
		||||
    
 | 
			
		||||
    // Get the agent update interval setting
 | 
			
		||||
    const settings = await prisma.settings.findFirst();
 | 
			
		||||
    const updateIntervalMinutes = settings?.updateInterval || 60; // Default to 60 minutes if no setting
 | 
			
		||||
    
 | 
			
		||||
    // 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();
 | 
			
		||||
router.get(
 | 
			
		||||
	"/stats",
 | 
			
		||||
	authenticateToken,
 | 
			
		||||
	requireViewDashboard,
 | 
			
		||||
	async (_req, res) => {
 | 
			
		||||
		try {
 | 
			
		||||
			const now = new Date();
 | 
			
		||||
 | 
			
		||||
    // Get all statistics in parallel for better performance
 | 
			
		||||
    const [
 | 
			
		||||
      totalHosts,
 | 
			
		||||
      hostsNeedingUpdates,
 | 
			
		||||
      totalOutdatedPackages,
 | 
			
		||||
      erroredHosts,
 | 
			
		||||
      securityUpdates,
 | 
			
		||||
      osDistribution,
 | 
			
		||||
      updateTrends
 | 
			
		||||
    ] = await Promise.all([
 | 
			
		||||
      // Total hosts count
 | 
			
		||||
      prisma.host.count({
 | 
			
		||||
        where: { status: 'active' }
 | 
			
		||||
      }),
 | 
			
		||||
			// Get the agent update interval setting
 | 
			
		||||
			const settings = await prisma.settings.findFirst();
 | 
			
		||||
			const updateIntervalMinutes = settings?.update_interval || 60; // Default to 60 minutes if no setting
 | 
			
		||||
 | 
			
		||||
      // Hosts needing updates (distinct hosts with packages needing updates)
 | 
			
		||||
      prisma.host.count({
 | 
			
		||||
        where: {
 | 
			
		||||
          status: 'active',
 | 
			
		||||
          hostPackages: {
 | 
			
		||||
            some: {
 | 
			
		||||
              needsUpdate: true
 | 
			
		||||
            }
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
      }),
 | 
			
		||||
			// 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();
 | 
			
		||||
 | 
			
		||||
      // Total outdated packages across all hosts
 | 
			
		||||
      prisma.hostPackage.count({
 | 
			
		||||
        where: { needsUpdate: true }
 | 
			
		||||
      }),
 | 
			
		||||
			// Get all statistics in parallel for better performance
 | 
			
		||||
			const [
 | 
			
		||||
				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)
 | 
			
		||||
      prisma.host.count({
 | 
			
		||||
        where: {
 | 
			
		||||
          status: 'active',
 | 
			
		||||
          lastUpdate: {
 | 
			
		||||
            lt: thresholdTime
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
      }),
 | 
			
		||||
				// Hosts needing updates (distinct hosts with packages needing updates)
 | 
			
		||||
				prisma.hosts.count({
 | 
			
		||||
					where: {
 | 
			
		||||
						host_packages: {
 | 
			
		||||
							some: {
 | 
			
		||||
								needs_update: true,
 | 
			
		||||
							},
 | 
			
		||||
						},
 | 
			
		||||
					},
 | 
			
		||||
				}),
 | 
			
		||||
 | 
			
		||||
      // Security updates count
 | 
			
		||||
      prisma.hostPackage.count({
 | 
			
		||||
        where: {
 | 
			
		||||
          needsUpdate: true,
 | 
			
		||||
          isSecurityUpdate: true
 | 
			
		||||
        }
 | 
			
		||||
      }),
 | 
			
		||||
				// Total outdated packages across all hosts
 | 
			
		||||
				prisma.host_packages.count({
 | 
			
		||||
					where: { needs_update: true },
 | 
			
		||||
				}),
 | 
			
		||||
 | 
			
		||||
      // OS distribution for pie chart
 | 
			
		||||
      prisma.host.groupBy({
 | 
			
		||||
        by: ['osType'],
 | 
			
		||||
        where: { status: 'active' },
 | 
			
		||||
        _count: {
 | 
			
		||||
          osType: true
 | 
			
		||||
        }
 | 
			
		||||
      }),
 | 
			
		||||
				// Errored hosts (not updated within threshold based on update interval)
 | 
			
		||||
				prisma.hosts.count({
 | 
			
		||||
					where: {
 | 
			
		||||
						status: "active",
 | 
			
		||||
						last_update: {
 | 
			
		||||
							lt: thresholdTime,
 | 
			
		||||
						},
 | 
			
		||||
					},
 | 
			
		||||
				}),
 | 
			
		||||
 | 
			
		||||
      // Update trends for the last 7 days
 | 
			
		||||
      prisma.updateHistory.groupBy({
 | 
			
		||||
        by: ['timestamp'],
 | 
			
		||||
        where: {
 | 
			
		||||
          timestamp: {
 | 
			
		||||
            gte: moment(now).subtract(7, 'days').toDate()
 | 
			
		||||
          }
 | 
			
		||||
        },
 | 
			
		||||
        _count: {
 | 
			
		||||
          id: true
 | 
			
		||||
        },
 | 
			
		||||
        _sum: {
 | 
			
		||||
          packagesCount: true,
 | 
			
		||||
          securityCount: true
 | 
			
		||||
        }
 | 
			
		||||
      })
 | 
			
		||||
    ]);
 | 
			
		||||
				// Security updates count
 | 
			
		||||
				prisma.host_packages.count({
 | 
			
		||||
					where: {
 | 
			
		||||
						needs_update: true,
 | 
			
		||||
						is_security_update: true,
 | 
			
		||||
					},
 | 
			
		||||
				}),
 | 
			
		||||
 | 
			
		||||
    // Format OS distribution for pie chart
 | 
			
		||||
    const osDistributionFormatted = osDistribution.map(item => ({
 | 
			
		||||
      name: item.osType,
 | 
			
		||||
      count: item._count.osType
 | 
			
		||||
    }));
 | 
			
		||||
				// Offline/Stale hosts (not updated within 3x the update interval)
 | 
			
		||||
				prisma.hosts.count({
 | 
			
		||||
					where: {
 | 
			
		||||
						status: "active",
 | 
			
		||||
						last_update: {
 | 
			
		||||
							lt: moment(now)
 | 
			
		||||
								.subtract(updateIntervalMinutes * 3, "minutes")
 | 
			
		||||
								.toDate(),
 | 
			
		||||
						},
 | 
			
		||||
					},
 | 
			
		||||
				}),
 | 
			
		||||
 | 
			
		||||
    // Calculate update status distribution
 | 
			
		||||
    const updateStatusDistribution = [
 | 
			
		||||
      { name: 'Up to date', count: totalHosts - hostsNeedingUpdates },
 | 
			
		||||
      { name: 'Needs updates', count: hostsNeedingUpdates },
 | 
			
		||||
      { name: 'Errored', count: erroredHosts }
 | 
			
		||||
    ];
 | 
			
		||||
				// Total host groups count
 | 
			
		||||
				prisma.host_groups.count(),
 | 
			
		||||
 | 
			
		||||
    // Package update priority distribution
 | 
			
		||||
    const packageUpdateDistribution = [
 | 
			
		||||
      { name: 'Security', count: securityUpdates },
 | 
			
		||||
      { name: 'Regular', count: totalOutdatedPackages - securityUpdates }
 | 
			
		||||
    ];
 | 
			
		||||
				// Total users count
 | 
			
		||||
				prisma.users.count(),
 | 
			
		||||
 | 
			
		||||
    res.json({
 | 
			
		||||
      cards: {
 | 
			
		||||
        totalHosts,
 | 
			
		||||
        hostsNeedingUpdates,
 | 
			
		||||
        totalOutdatedPackages,
 | 
			
		||||
        erroredHosts,
 | 
			
		||||
        securityUpdates
 | 
			
		||||
      },
 | 
			
		||||
      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' });
 | 
			
		||||
  }
 | 
			
		||||
});
 | 
			
		||||
				// Total repositories count
 | 
			
		||||
				prisma.repositories.count(),
 | 
			
		||||
 | 
			
		||||
				// OS distribution for pie chart
 | 
			
		||||
				prisma.hosts.groupBy({
 | 
			
		||||
					by: ["os_type"],
 | 
			
		||||
					where: { status: "active" },
 | 
			
		||||
					_count: {
 | 
			
		||||
						os_type: true,
 | 
			
		||||
					},
 | 
			
		||||
				}),
 | 
			
		||||
 | 
			
		||||
				// Update trends for the last 7 days
 | 
			
		||||
				prisma.update_history.groupBy({
 | 
			
		||||
					by: ["timestamp"],
 | 
			
		||||
					where: {
 | 
			
		||||
						timestamp: {
 | 
			
		||||
							gte: moment(now).subtract(7, "days").toDate(),
 | 
			
		||||
						},
 | 
			
		||||
					},
 | 
			
		||||
					_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
 | 
			
		||||
router.get('/hosts', authenticateToken, requireViewHosts, async (req, res) => {
 | 
			
		||||
  try {
 | 
			
		||||
    const hosts = await prisma.host.findMany({
 | 
			
		||||
      // Show all hosts regardless of status
 | 
			
		||||
      select: {
 | 
			
		||||
        id: true,
 | 
			
		||||
        hostname: true,
 | 
			
		||||
        ip: true,
 | 
			
		||||
        osType: true,
 | 
			
		||||
        osVersion: true,
 | 
			
		||||
        lastUpdate: true,
 | 
			
		||||
        status: true,
 | 
			
		||||
        agentVersion: true,
 | 
			
		||||
        autoUpdate: true,
 | 
			
		||||
        hostGroup: {
 | 
			
		||||
          select: {
 | 
			
		||||
            id: true,
 | 
			
		||||
            name: true,
 | 
			
		||||
            color: true
 | 
			
		||||
          }
 | 
			
		||||
        },
 | 
			
		||||
        _count: {
 | 
			
		||||
          select: {
 | 
			
		||||
            hostPackages: {
 | 
			
		||||
              where: {
 | 
			
		||||
                needsUpdate: true
 | 
			
		||||
              }
 | 
			
		||||
            }
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
      },
 | 
			
		||||
      orderBy: { lastUpdate: 'desc' }
 | 
			
		||||
    });
 | 
			
		||||
router.get("/hosts", authenticateToken, requireViewHosts, async (_req, res) => {
 | 
			
		||||
	try {
 | 
			
		||||
		const hosts = await prisma.hosts.findMany({
 | 
			
		||||
			// Show all hosts regardless of status
 | 
			
		||||
			select: {
 | 
			
		||||
				id: true,
 | 
			
		||||
				machine_id: true,
 | 
			
		||||
				friendly_name: true,
 | 
			
		||||
				hostname: true,
 | 
			
		||||
				ip: true,
 | 
			
		||||
				os_type: true,
 | 
			
		||||
				os_version: true,
 | 
			
		||||
				last_update: true,
 | 
			
		||||
				status: true,
 | 
			
		||||
				agent_version: true,
 | 
			
		||||
				auto_update: true,
 | 
			
		||||
				notes: true,
 | 
			
		||||
				host_groups: {
 | 
			
		||||
					select: {
 | 
			
		||||
						id: true,
 | 
			
		||||
						name: true,
 | 
			
		||||
						color: true,
 | 
			
		||||
					},
 | 
			
		||||
				},
 | 
			
		||||
				_count: {
 | 
			
		||||
					select: {
 | 
			
		||||
						host_packages: {
 | 
			
		||||
							where: {
 | 
			
		||||
								needs_update: true,
 | 
			
		||||
							},
 | 
			
		||||
						},
 | 
			
		||||
					},
 | 
			
		||||
				},
 | 
			
		||||
			},
 | 
			
		||||
			orderBy: { last_update: "desc" },
 | 
			
		||||
		});
 | 
			
		||||
 | 
			
		||||
    // Get update counts for each host separately
 | 
			
		||||
    const hostsWithUpdateInfo = await Promise.all(
 | 
			
		||||
      hosts.map(async (host) => {
 | 
			
		||||
        const updatesCount = await prisma.hostPackage.count({
 | 
			
		||||
          where: {
 | 
			
		||||
            hostId: host.id,
 | 
			
		||||
            needsUpdate: true
 | 
			
		||||
          }
 | 
			
		||||
        });
 | 
			
		||||
		// Get update counts for each host separately
 | 
			
		||||
		const hostsWithUpdateInfo = await Promise.all(
 | 
			
		||||
			hosts.map(async (host) => {
 | 
			
		||||
				const updatesCount = await prisma.host_packages.count({
 | 
			
		||||
					where: {
 | 
			
		||||
						host_id: host.id,
 | 
			
		||||
						needs_update: true,
 | 
			
		||||
					},
 | 
			
		||||
				});
 | 
			
		||||
 | 
			
		||||
        // Get the agent update interval setting for stale calculation
 | 
			
		||||
        const settings = await prisma.settings.findFirst();
 | 
			
		||||
        const updateIntervalMinutes = settings?.updateInterval || 60;
 | 
			
		||||
        const thresholdMinutes = updateIntervalMinutes * 2;
 | 
			
		||||
				// Get total packages count for this host
 | 
			
		||||
				const totalPackagesCount = await prisma.host_packages.count({
 | 
			
		||||
					where: {
 | 
			
		||||
						host_id: host.id,
 | 
			
		||||
					},
 | 
			
		||||
				});
 | 
			
		||||
 | 
			
		||||
        // Calculate effective status based on reporting interval
 | 
			
		||||
        const isStale = moment(host.lastUpdate).isBefore(moment().subtract(thresholdMinutes, 'minutes'));
 | 
			
		||||
        let effectiveStatus = host.status;
 | 
			
		||||
        
 | 
			
		||||
        // Override status if host hasn't reported within threshold
 | 
			
		||||
        if (isStale && host.status === 'active') {
 | 
			
		||||
          effectiveStatus = 'inactive';
 | 
			
		||||
        }
 | 
			
		||||
				// Get the agent update interval setting for stale calculation
 | 
			
		||||
				const settings = await prisma.settings.findFirst();
 | 
			
		||||
				const updateIntervalMinutes = settings?.update_interval || 60;
 | 
			
		||||
				const thresholdMinutes = updateIntervalMinutes * 2;
 | 
			
		||||
 | 
			
		||||
        return {
 | 
			
		||||
          ...host,
 | 
			
		||||
          updatesCount,
 | 
			
		||||
          isStale,
 | 
			
		||||
          effectiveStatus
 | 
			
		||||
        };
 | 
			
		||||
      })
 | 
			
		||||
    );
 | 
			
		||||
				// Calculate effective status based on reporting interval
 | 
			
		||||
				const isStale = moment(host.last_update).isBefore(
 | 
			
		||||
					moment().subtract(thresholdMinutes, "minutes"),
 | 
			
		||||
				);
 | 
			
		||||
				let effectiveStatus = host.status;
 | 
			
		||||
 | 
			
		||||
    res.json(hostsWithUpdateInfo);
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.error('Error fetching hosts:', error);
 | 
			
		||||
    res.status(500).json({ error: 'Failed to fetch hosts' });
 | 
			
		||||
  }
 | 
			
		||||
				// Override status if host hasn't reported within threshold
 | 
			
		||||
				if (isStale && host.status === "active") {
 | 
			
		||||
					effectiveStatus = "inactive";
 | 
			
		||||
				}
 | 
			
		||||
 | 
			
		||||
				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
 | 
			
		||||
router.get('/packages', authenticateToken, requireViewPackages, async (req, res) => {
 | 
			
		||||
  try {
 | 
			
		||||
    const packages = await prisma.package.findMany({
 | 
			
		||||
      where: {
 | 
			
		||||
        hostPackages: {
 | 
			
		||||
          some: {
 | 
			
		||||
            needsUpdate: true
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
      },
 | 
			
		||||
      select: {
 | 
			
		||||
        id: true,
 | 
			
		||||
        name: true,
 | 
			
		||||
        description: true,
 | 
			
		||||
        category: true,
 | 
			
		||||
        latestVersion: true,
 | 
			
		||||
        hostPackages: {
 | 
			
		||||
          where: { needsUpdate: true },
 | 
			
		||||
          select: {
 | 
			
		||||
            currentVersion: true,
 | 
			
		||||
            availableVersion: true,
 | 
			
		||||
            isSecurityUpdate: true,
 | 
			
		||||
            host: {
 | 
			
		||||
              select: {
 | 
			
		||||
                id: true,
 | 
			
		||||
                hostname: true,
 | 
			
		||||
                osType: true
 | 
			
		||||
              }
 | 
			
		||||
            }
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
      },
 | 
			
		||||
      orderBy: {
 | 
			
		||||
        name: 'asc'
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
router.get(
 | 
			
		||||
	"/packages",
 | 
			
		||||
	authenticateToken,
 | 
			
		||||
	requireViewPackages,
 | 
			
		||||
	async (_req, res) => {
 | 
			
		||||
		try {
 | 
			
		||||
			const packages = await prisma.packages.findMany({
 | 
			
		||||
				where: {
 | 
			
		||||
					host_packages: {
 | 
			
		||||
						some: {
 | 
			
		||||
							needs_update: true,
 | 
			
		||||
						},
 | 
			
		||||
					},
 | 
			
		||||
				},
 | 
			
		||||
				select: {
 | 
			
		||||
					id: true,
 | 
			
		||||
					name: true,
 | 
			
		||||
					description: true,
 | 
			
		||||
					category: true,
 | 
			
		||||
					latest_version: true,
 | 
			
		||||
					host_packages: {
 | 
			
		||||
						where: { needs_update: true },
 | 
			
		||||
						select: {
 | 
			
		||||
							current_version: true,
 | 
			
		||||
							available_version: true,
 | 
			
		||||
							is_security_update: true,
 | 
			
		||||
							hosts: {
 | 
			
		||||
								select: {
 | 
			
		||||
									id: true,
 | 
			
		||||
									friendly_name: true,
 | 
			
		||||
									os_type: true,
 | 
			
		||||
								},
 | 
			
		||||
							},
 | 
			
		||||
						},
 | 
			
		||||
					},
 | 
			
		||||
				},
 | 
			
		||||
				orderBy: {
 | 
			
		||||
					name: "asc",
 | 
			
		||||
				},
 | 
			
		||||
			});
 | 
			
		||||
 | 
			
		||||
    const packagesWithHostInfo = packages.map(pkg => ({
 | 
			
		||||
      id: pkg.id,
 | 
			
		||||
      name: pkg.name,
 | 
			
		||||
      description: pkg.description,
 | 
			
		||||
      category: pkg.category,
 | 
			
		||||
      latestVersion: pkg.latestVersion,
 | 
			
		||||
      affectedHostsCount: pkg.hostPackages.length,
 | 
			
		||||
      isSecurityUpdate: pkg.hostPackages.some(hp => hp.isSecurityUpdate),
 | 
			
		||||
      affectedHosts: pkg.hostPackages.map(hp => ({
 | 
			
		||||
        hostId: hp.host.id,
 | 
			
		||||
        hostname: hp.host.hostname,
 | 
			
		||||
        osType: hp.host.osType,
 | 
			
		||||
        currentVersion: hp.currentVersion,
 | 
			
		||||
        availableVersion: hp.availableVersion,
 | 
			
		||||
        isSecurityUpdate: hp.isSecurityUpdate
 | 
			
		||||
      }))
 | 
			
		||||
    }));
 | 
			
		||||
			const packagesWithHostInfo = packages.map((pkg) => ({
 | 
			
		||||
				id: pkg.id,
 | 
			
		||||
				name: pkg.name,
 | 
			
		||||
				description: pkg.description,
 | 
			
		||||
				category: pkg.category,
 | 
			
		||||
				latestVersion: pkg.latest_version,
 | 
			
		||||
				affectedHostsCount: pkg.host_packages.length,
 | 
			
		||||
				isSecurityUpdate: pkg.host_packages.some((hp) => hp.is_security_update),
 | 
			
		||||
				affectedHosts: pkg.host_packages.map((hp) => ({
 | 
			
		||||
					hostId: hp.hosts.id,
 | 
			
		||||
					friendlyName: hp.hosts.friendly_name,
 | 
			
		||||
					osType: hp.hosts.os_type,
 | 
			
		||||
					currentVersion: hp.current_version,
 | 
			
		||||
					availableVersion: hp.available_version,
 | 
			
		||||
					isSecurityUpdate: hp.is_security_update,
 | 
			
		||||
				})),
 | 
			
		||||
			}));
 | 
			
		||||
 | 
			
		||||
    res.json(packagesWithHostInfo);
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.error('Error fetching packages:', error);
 | 
			
		||||
    res.status(500).json({ error: 'Failed to fetch packages' });
 | 
			
		||||
  }
 | 
			
		||||
});
 | 
			
		||||
			res.json(packagesWithHostInfo);
 | 
			
		||||
		} catch (error) {
 | 
			
		||||
			console.error("Error fetching packages:", error);
 | 
			
		||||
			res.status(500).json({ error: "Failed to fetch packages" });
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
// Get detailed host information
 | 
			
		||||
router.get('/hosts/:hostId', authenticateToken, requireViewHosts, async (req, res) => {
 | 
			
		||||
  try {
 | 
			
		||||
    const { hostId } = req.params;
 | 
			
		||||
    
 | 
			
		||||
    const host = await prisma.host.findUnique({
 | 
			
		||||
      where: { id: hostId },
 | 
			
		||||
      include: {
 | 
			
		||||
        hostGroup: {
 | 
			
		||||
          select: {
 | 
			
		||||
            id: true,
 | 
			
		||||
            name: true,
 | 
			
		||||
            color: true
 | 
			
		||||
          }
 | 
			
		||||
        },
 | 
			
		||||
        hostPackages: {
 | 
			
		||||
          include: {
 | 
			
		||||
            package: true
 | 
			
		||||
          },
 | 
			
		||||
          orderBy: {
 | 
			
		||||
            needsUpdate: 'desc'
 | 
			
		||||
          }
 | 
			
		||||
        },
 | 
			
		||||
        updateHistory: {
 | 
			
		||||
          orderBy: {
 | 
			
		||||
            timestamp: 'desc'
 | 
			
		||||
          },
 | 
			
		||||
          take: 10
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
router.get(
 | 
			
		||||
	"/hosts/:hostId",
 | 
			
		||||
	authenticateToken,
 | 
			
		||||
	requireViewHosts,
 | 
			
		||||
	async (req, res) => {
 | 
			
		||||
		try {
 | 
			
		||||
			const { hostId } = req.params;
 | 
			
		||||
 | 
			
		||||
    if (!host) {
 | 
			
		||||
      return res.status(404).json({ error: 'Host not found' });
 | 
			
		||||
    }
 | 
			
		||||
			const limit = parseInt(req.query.limit, 10) || 10;
 | 
			
		||||
			const offset = parseInt(req.query.offset, 10) || 0;
 | 
			
		||||
 | 
			
		||||
    const hostWithStats = {
 | 
			
		||||
      ...host,
 | 
			
		||||
      stats: {
 | 
			
		||||
        totalPackages: host.hostPackages.length,
 | 
			
		||||
        outdatedPackages: host.hostPackages.filter(hp => hp.needsUpdate).length,
 | 
			
		||||
        securityUpdates: host.hostPackages.filter(hp => hp.needsUpdate && hp.isSecurityUpdate).length
 | 
			
		||||
      }
 | 
			
		||||
    };
 | 
			
		||||
			const [host, totalHistoryCount] = await Promise.all([
 | 
			
		||||
				prisma.hosts.findUnique({
 | 
			
		||||
					where: { id: hostId },
 | 
			
		||||
					include: {
 | 
			
		||||
						host_groups: {
 | 
			
		||||
							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);
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.error('Error fetching host details:', error);
 | 
			
		||||
    res.status(500).json({ error: 'Failed to fetch host details' });
 | 
			
		||||
  }
 | 
			
		||||
});
 | 
			
		||||
			if (!host) {
 | 
			
		||||
				return res.status(404).json({ error: "Host not found" });
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
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 { body, validationResult } = require('express-validator');
 | 
			
		||||
const { PrismaClient } = require('@prisma/client');
 | 
			
		||||
const { authenticateToken } = require('../middleware/auth');
 | 
			
		||||
const { requireManageHosts } = require('../middleware/permissions');
 | 
			
		||||
const express = require("express");
 | 
			
		||||
const { body, validationResult } = require("express-validator");
 | 
			
		||||
const { PrismaClient } = require("@prisma/client");
 | 
			
		||||
const { randomUUID } = require("node:crypto");
 | 
			
		||||
const { authenticateToken } = require("../middleware/auth");
 | 
			
		||||
const { requireManageHosts } = require("../middleware/permissions");
 | 
			
		||||
 | 
			
		||||
const router = express.Router();
 | 
			
		||||
const prisma = new PrismaClient();
 | 
			
		||||
 | 
			
		||||
// Get all host groups
 | 
			
		||||
router.get('/', authenticateToken, async (req, res) => {
 | 
			
		||||
  try {
 | 
			
		||||
    const hostGroups = await prisma.hostGroup.findMany({
 | 
			
		||||
      include: {
 | 
			
		||||
        _count: {
 | 
			
		||||
          select: {
 | 
			
		||||
            hosts: true
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
      },
 | 
			
		||||
      orderBy: {
 | 
			
		||||
        name: 'asc'
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
router.get("/", authenticateToken, async (_req, res) => {
 | 
			
		||||
	try {
 | 
			
		||||
		const hostGroups = await prisma.host_groups.findMany({
 | 
			
		||||
			include: {
 | 
			
		||||
				_count: {
 | 
			
		||||
					select: {
 | 
			
		||||
						hosts: true,
 | 
			
		||||
					},
 | 
			
		||||
				},
 | 
			
		||||
			},
 | 
			
		||||
			orderBy: {
 | 
			
		||||
				name: "asc",
 | 
			
		||||
			},
 | 
			
		||||
		});
 | 
			
		||||
 | 
			
		||||
    res.json(hostGroups);
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.error('Error fetching host groups:', error);
 | 
			
		||||
    res.status(500).json({ error: 'Failed to fetch host groups' });
 | 
			
		||||
  }
 | 
			
		||||
		res.json(hostGroups);
 | 
			
		||||
	} catch (error) {
 | 
			
		||||
		console.error("Error fetching host groups:", error);
 | 
			
		||||
		res.status(500).json({ error: "Failed to fetch host groups" });
 | 
			
		||||
	}
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
// Get a specific host group by ID
 | 
			
		||||
router.get('/:id', authenticateToken, async (req, res) => {
 | 
			
		||||
  try {
 | 
			
		||||
    const { id } = req.params;
 | 
			
		||||
router.get("/:id", authenticateToken, async (req, res) => {
 | 
			
		||||
	try {
 | 
			
		||||
		const { id } = req.params;
 | 
			
		||||
 | 
			
		||||
    const hostGroup = await prisma.hostGroup.findUnique({
 | 
			
		||||
      where: { id },
 | 
			
		||||
      include: {
 | 
			
		||||
        hosts: {
 | 
			
		||||
          select: {
 | 
			
		||||
            id: true,
 | 
			
		||||
            hostname: true,
 | 
			
		||||
            ip: true,
 | 
			
		||||
            osType: true,
 | 
			
		||||
            osVersion: true,
 | 
			
		||||
            status: true,
 | 
			
		||||
            lastUpdate: true
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
		const hostGroup = await prisma.host_groups.findUnique({
 | 
			
		||||
			where: { id },
 | 
			
		||||
			include: {
 | 
			
		||||
				hosts: {
 | 
			
		||||
					select: {
 | 
			
		||||
						id: true,
 | 
			
		||||
						friendly_name: true,
 | 
			
		||||
						hostname: true,
 | 
			
		||||
						ip: true,
 | 
			
		||||
						os_type: true,
 | 
			
		||||
						os_version: true,
 | 
			
		||||
						status: true,
 | 
			
		||||
						last_update: true,
 | 
			
		||||
					},
 | 
			
		||||
				},
 | 
			
		||||
			},
 | 
			
		||||
		});
 | 
			
		||||
 | 
			
		||||
    if (!hostGroup) {
 | 
			
		||||
      return res.status(404).json({ error: 'Host group not found' });
 | 
			
		||||
    }
 | 
			
		||||
		if (!hostGroup) {
 | 
			
		||||
			return res.status(404).json({ error: "Host group not found" });
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
    res.json(hostGroup);
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.error('Error fetching host group:', error);
 | 
			
		||||
    res.status(500).json({ error: 'Failed to fetch host group' });
 | 
			
		||||
  }
 | 
			
		||||
		res.json(hostGroup);
 | 
			
		||||
	} catch (error) {
 | 
			
		||||
		console.error("Error fetching host group:", error);
 | 
			
		||||
		res.status(500).json({ error: "Failed to fetch host group" });
 | 
			
		||||
	}
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
// Create a new host group
 | 
			
		||||
router.post('/', authenticateToken, requireManageHosts, [
 | 
			
		||||
  body('name').trim().isLength({ min: 1 }).withMessage('Name is required'),
 | 
			
		||||
  body('description').optional().trim(),
 | 
			
		||||
  body('color').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() });
 | 
			
		||||
    }
 | 
			
		||||
router.post(
 | 
			
		||||
	"/",
 | 
			
		||||
	authenticateToken,
 | 
			
		||||
	requireManageHosts,
 | 
			
		||||
	[
 | 
			
		||||
		body("name").trim().isLength({ min: 1 }).withMessage("Name is required"),
 | 
			
		||||
		body("description").optional().trim(),
 | 
			
		||||
		body("color")
 | 
			
		||||
			.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
 | 
			
		||||
    const existingGroup = await prisma.hostGroup.findUnique({
 | 
			
		||||
      where: { name }
 | 
			
		||||
    });
 | 
			
		||||
			// Check if host group with this name already exists
 | 
			
		||||
			const existingGroup = await prisma.host_groups.findUnique({
 | 
			
		||||
				where: { name },
 | 
			
		||||
			});
 | 
			
		||||
 | 
			
		||||
    if (existingGroup) {
 | 
			
		||||
      return res.status(400).json({ error: 'A host group with this name already exists' });
 | 
			
		||||
    }
 | 
			
		||||
			if (existingGroup) {
 | 
			
		||||
				return res
 | 
			
		||||
					.status(400)
 | 
			
		||||
					.json({ error: "A host group with this name already exists" });
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
    const hostGroup = await prisma.hostGroup.create({
 | 
			
		||||
      data: {
 | 
			
		||||
        name,
 | 
			
		||||
        description: description || null,
 | 
			
		||||
        color: color || '#3B82F6'
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
			const hostGroup = await prisma.host_groups.create({
 | 
			
		||||
				data: {
 | 
			
		||||
					id: randomUUID(),
 | 
			
		||||
					name,
 | 
			
		||||
					description: description || null,
 | 
			
		||||
					color: color || "#3B82F6",
 | 
			
		||||
					updated_at: new Date(),
 | 
			
		||||
				},
 | 
			
		||||
			});
 | 
			
		||||
 | 
			
		||||
    res.status(201).json(hostGroup);
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.error('Error creating host group:', error);
 | 
			
		||||
    res.status(500).json({ error: 'Failed to create host group' });
 | 
			
		||||
  }
 | 
			
		||||
});
 | 
			
		||||
			res.status(201).json(hostGroup);
 | 
			
		||||
		} catch (error) {
 | 
			
		||||
			console.error("Error creating host group:", error);
 | 
			
		||||
			res.status(500).json({ error: "Failed to create host group" });
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
// Update a host group
 | 
			
		||||
router.put('/:id', authenticateToken, requireManageHosts, [
 | 
			
		||||
  body('name').trim().isLength({ min: 1 }).withMessage('Name is required'),
 | 
			
		||||
  body('description').optional().trim(),
 | 
			
		||||
  body('color').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() });
 | 
			
		||||
    }
 | 
			
		||||
router.put(
 | 
			
		||||
	"/:id",
 | 
			
		||||
	authenticateToken,
 | 
			
		||||
	requireManageHosts,
 | 
			
		||||
	[
 | 
			
		||||
		body("name").trim().isLength({ min: 1 }).withMessage("Name is required"),
 | 
			
		||||
		body("description").optional().trim(),
 | 
			
		||||
		body("color")
 | 
			
		||||
			.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 { name, description, color } = req.body;
 | 
			
		||||
			const { id } = req.params;
 | 
			
		||||
			const { name, description, color } = req.body;
 | 
			
		||||
 | 
			
		||||
    // Check if host group exists
 | 
			
		||||
    const existingGroup = await prisma.hostGroup.findUnique({
 | 
			
		||||
      where: { id }
 | 
			
		||||
    });
 | 
			
		||||
			// Check if host group exists
 | 
			
		||||
			const existingGroup = await prisma.host_groups.findUnique({
 | 
			
		||||
				where: { id },
 | 
			
		||||
			});
 | 
			
		||||
 | 
			
		||||
    if (!existingGroup) {
 | 
			
		||||
      return res.status(404).json({ error: 'Host group not found' });
 | 
			
		||||
    }
 | 
			
		||||
			if (!existingGroup) {
 | 
			
		||||
				return res.status(404).json({ error: "Host group not found" });
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
    // Check if another host group with this name already exists
 | 
			
		||||
    const duplicateGroup = await prisma.hostGroup.findFirst({
 | 
			
		||||
      where: {
 | 
			
		||||
        name,
 | 
			
		||||
        id: { not: id }
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
			// Check if another host group with this name already exists
 | 
			
		||||
			const duplicateGroup = await prisma.host_groups.findFirst({
 | 
			
		||||
				where: {
 | 
			
		||||
					name,
 | 
			
		||||
					id: { not: id },
 | 
			
		||||
				},
 | 
			
		||||
			});
 | 
			
		||||
 | 
			
		||||
    if (duplicateGroup) {
 | 
			
		||||
      return res.status(400).json({ error: 'A host group with this name already exists' });
 | 
			
		||||
    }
 | 
			
		||||
			if (duplicateGroup) {
 | 
			
		||||
				return res
 | 
			
		||||
					.status(400)
 | 
			
		||||
					.json({ error: "A host group with this name already exists" });
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
    const hostGroup = await prisma.hostGroup.update({
 | 
			
		||||
      where: { id },
 | 
			
		||||
      data: {
 | 
			
		||||
        name,
 | 
			
		||||
        description: description || null,
 | 
			
		||||
        color: color || '#3B82F6'
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
			const hostGroup = await prisma.host_groups.update({
 | 
			
		||||
				where: { id },
 | 
			
		||||
				data: {
 | 
			
		||||
					name,
 | 
			
		||||
					description: description || null,
 | 
			
		||||
					color: color || "#3B82F6",
 | 
			
		||||
					updated_at: new Date(),
 | 
			
		||||
				},
 | 
			
		||||
			});
 | 
			
		||||
 | 
			
		||||
    res.json(hostGroup);
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.error('Error updating host group:', error);
 | 
			
		||||
    res.status(500).json({ error: 'Failed to update host group' });
 | 
			
		||||
  }
 | 
			
		||||
});
 | 
			
		||||
			res.json(hostGroup);
 | 
			
		||||
		} catch (error) {
 | 
			
		||||
			console.error("Error updating host group:", error);
 | 
			
		||||
			res.status(500).json({ error: "Failed to update host group" });
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
// Delete a host group
 | 
			
		||||
router.delete('/:id', authenticateToken, requireManageHosts, async (req, res) => {
 | 
			
		||||
  try {
 | 
			
		||||
    const { id } = req.params;
 | 
			
		||||
router.delete(
 | 
			
		||||
	"/:id",
 | 
			
		||||
	authenticateToken,
 | 
			
		||||
	requireManageHosts,
 | 
			
		||||
	async (req, res) => {
 | 
			
		||||
		try {
 | 
			
		||||
			const { id } = req.params;
 | 
			
		||||
 | 
			
		||||
    // Check if host group exists
 | 
			
		||||
    const existingGroup = await prisma.hostGroup.findUnique({
 | 
			
		||||
      where: { id },
 | 
			
		||||
      include: {
 | 
			
		||||
        _count: {
 | 
			
		||||
          select: {
 | 
			
		||||
            hosts: true
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
			// Check if host group exists
 | 
			
		||||
			const existingGroup = await prisma.host_groups.findUnique({
 | 
			
		||||
				where: { id },
 | 
			
		||||
				include: {
 | 
			
		||||
					_count: {
 | 
			
		||||
						select: {
 | 
			
		||||
							hosts: true,
 | 
			
		||||
						},
 | 
			
		||||
					},
 | 
			
		||||
				},
 | 
			
		||||
			});
 | 
			
		||||
 | 
			
		||||
    if (!existingGroup) {
 | 
			
		||||
      return res.status(404).json({ error: 'Host group not found' });
 | 
			
		||||
    }
 | 
			
		||||
			if (!existingGroup) {
 | 
			
		||||
				return res.status(404).json({ error: "Host group not found" });
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
    // Check if host group has hosts
 | 
			
		||||
    if (existingGroup._count.hosts > 0) {
 | 
			
		||||
      return res.status(400).json({ 
 | 
			
		||||
        error: 'Cannot delete host group that contains hosts. Please move or remove hosts first.' 
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
			// If host group has hosts, ungroup them first
 | 
			
		||||
			if (existingGroup._count.hosts > 0) {
 | 
			
		||||
				await prisma.hosts.updateMany({
 | 
			
		||||
					where: { host_group_id: id },
 | 
			
		||||
					data: { host_group_id: null },
 | 
			
		||||
				});
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
    await prisma.hostGroup.delete({
 | 
			
		||||
      where: { id }
 | 
			
		||||
    });
 | 
			
		||||
			await prisma.host_groups.delete({
 | 
			
		||||
				where: { id },
 | 
			
		||||
			});
 | 
			
		||||
 | 
			
		||||
    res.json({ message: 'Host group deleted successfully' });
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.error('Error deleting host group:', error);
 | 
			
		||||
    res.status(500).json({ error: 'Failed to delete host group' });
 | 
			
		||||
  }
 | 
			
		||||
});
 | 
			
		||||
			res.json({ message: "Host group deleted successfully" });
 | 
			
		||||
		} catch (error) {
 | 
			
		||||
			console.error("Error deleting host group:", error);
 | 
			
		||||
			res.status(500).json({ error: "Failed to delete host group" });
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
// Get hosts in a specific group
 | 
			
		||||
router.get('/:id/hosts', authenticateToken, async (req, res) => {
 | 
			
		||||
  try {
 | 
			
		||||
    const { id } = req.params;
 | 
			
		||||
router.get("/:id/hosts", authenticateToken, async (req, res) => {
 | 
			
		||||
	try {
 | 
			
		||||
		const { id } = req.params;
 | 
			
		||||
 | 
			
		||||
    const hosts = await prisma.host.findMany({
 | 
			
		||||
      where: { hostGroupId: id },
 | 
			
		||||
      select: {
 | 
			
		||||
        id: true,
 | 
			
		||||
        hostname: true,
 | 
			
		||||
        ip: true,
 | 
			
		||||
        osType: true,
 | 
			
		||||
        osVersion: true,
 | 
			
		||||
        architecture: true,
 | 
			
		||||
        status: true,
 | 
			
		||||
        lastUpdate: true,
 | 
			
		||||
        createdAt: true
 | 
			
		||||
      },
 | 
			
		||||
      orderBy: {
 | 
			
		||||
        hostname: 'asc'
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
		const hosts = await prisma.hosts.findMany({
 | 
			
		||||
			where: { host_group_id: id },
 | 
			
		||||
			select: {
 | 
			
		||||
				id: true,
 | 
			
		||||
				friendly_name: true,
 | 
			
		||||
				ip: true,
 | 
			
		||||
				os_type: true,
 | 
			
		||||
				os_version: true,
 | 
			
		||||
				architecture: true,
 | 
			
		||||
				status: true,
 | 
			
		||||
				last_update: true,
 | 
			
		||||
				created_at: true,
 | 
			
		||||
			},
 | 
			
		||||
			orderBy: {
 | 
			
		||||
				friendly_name: "asc",
 | 
			
		||||
			},
 | 
			
		||||
		});
 | 
			
		||||
 | 
			
		||||
    res.json(hosts);
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.error('Error fetching hosts in group:', error);
 | 
			
		||||
    res.status(500).json({ error: 'Failed to fetch hosts in group' });
 | 
			
		||||
  }
 | 
			
		||||
		res.json(hosts);
 | 
			
		||||
	} catch (error) {
 | 
			
		||||
		console.error("Error fetching hosts in group:", error);
 | 
			
		||||
		res.status(500).json({ error: "Failed to fetch hosts in group" });
 | 
			
		||||
	}
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
module.exports = router;
 | 
			
		||||
 
 | 
			
		||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							@@ -1,213 +1,373 @@
 | 
			
		||||
const express = require('express');
 | 
			
		||||
const { PrismaClient } = require('@prisma/client');
 | 
			
		||||
const { body, validationResult } = require('express-validator');
 | 
			
		||||
const express = require("express");
 | 
			
		||||
const { PrismaClient } = require("@prisma/client");
 | 
			
		||||
 | 
			
		||||
const router = express.Router();
 | 
			
		||||
const prisma = new PrismaClient();
 | 
			
		||||
 | 
			
		||||
// Get all packages with their update status
 | 
			
		||||
router.get('/', async (req, res) => {
 | 
			
		||||
  try {
 | 
			
		||||
    const { 
 | 
			
		||||
      page = 1, 
 | 
			
		||||
      limit = 50, 
 | 
			
		||||
      search = '', 
 | 
			
		||||
      category = '', 
 | 
			
		||||
      needsUpdate = '', 
 | 
			
		||||
      isSecurityUpdate = '' 
 | 
			
		||||
    } = req.query;
 | 
			
		||||
router.get("/", async (req, res) => {
 | 
			
		||||
	try {
 | 
			
		||||
		const {
 | 
			
		||||
			page = 1,
 | 
			
		||||
			limit = 50,
 | 
			
		||||
			search = "",
 | 
			
		||||
			category = "",
 | 
			
		||||
			needsUpdate = "",
 | 
			
		||||
			isSecurityUpdate = "",
 | 
			
		||||
			host = "",
 | 
			
		||||
		} = req.query;
 | 
			
		||||
 | 
			
		||||
    const skip = (parseInt(page) - 1) * parseInt(limit);
 | 
			
		||||
    const take = parseInt(limit);
 | 
			
		||||
		const skip = (parseInt(page, 10) - 1) * parseInt(limit, 10);
 | 
			
		||||
		const take = parseInt(limit, 10);
 | 
			
		||||
 | 
			
		||||
    // Build where clause
 | 
			
		||||
    const where = {
 | 
			
		||||
      AND: [
 | 
			
		||||
        // Search filter
 | 
			
		||||
        search ? {
 | 
			
		||||
          OR: [
 | 
			
		||||
            { name: { contains: search, mode: 'insensitive' } },
 | 
			
		||||
            { description: { contains: search, mode: 'insensitive' } }
 | 
			
		||||
          ]
 | 
			
		||||
        } : {},
 | 
			
		||||
        // Category filter
 | 
			
		||||
        category ? { category: { equals: category } } : {},
 | 
			
		||||
        // Update status filters
 | 
			
		||||
        needsUpdate ? {
 | 
			
		||||
          hostPackages: {
 | 
			
		||||
            some: {
 | 
			
		||||
              needsUpdate: needsUpdate === 'true'
 | 
			
		||||
            }
 | 
			
		||||
          }
 | 
			
		||||
        } : {},
 | 
			
		||||
        isSecurityUpdate ? {
 | 
			
		||||
          hostPackages: {
 | 
			
		||||
            some: {
 | 
			
		||||
              isSecurityUpdate: isSecurityUpdate === 'true'
 | 
			
		||||
            }
 | 
			
		||||
          }
 | 
			
		||||
        } : {}
 | 
			
		||||
      ]
 | 
			
		||||
    };
 | 
			
		||||
		// Build where clause
 | 
			
		||||
		const where = {
 | 
			
		||||
			AND: [
 | 
			
		||||
				// Search filter
 | 
			
		||||
				search
 | 
			
		||||
					? {
 | 
			
		||||
							OR: [
 | 
			
		||||
								{ name: { contains: search, mode: "insensitive" } },
 | 
			
		||||
								{ description: { contains: search, mode: "insensitive" } },
 | 
			
		||||
							],
 | 
			
		||||
						}
 | 
			
		||||
					: {},
 | 
			
		||||
				// Category filter
 | 
			
		||||
				category ? { category: { equals: category } } : {},
 | 
			
		||||
				// Host filter - only return packages installed on the specified host
 | 
			
		||||
				// Combined with update status filters if both are present
 | 
			
		||||
				host
 | 
			
		||||
					? {
 | 
			
		||||
							host_packages: {
 | 
			
		||||
								some: {
 | 
			
		||||
									host_id: host,
 | 
			
		||||
									// If needsUpdate or isSecurityUpdate filters are present, apply them here
 | 
			
		||||
									...(needsUpdate
 | 
			
		||||
										? { 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
 | 
			
		||||
    const [packages, totalCount] = await Promise.all([
 | 
			
		||||
      prisma.package.findMany({
 | 
			
		||||
        where,
 | 
			
		||||
        select: {
 | 
			
		||||
          id: true,
 | 
			
		||||
          name: true,
 | 
			
		||||
          description: true,
 | 
			
		||||
          category: true,
 | 
			
		||||
          latestVersion: true,
 | 
			
		||||
          createdAt: true,
 | 
			
		||||
          _count: {
 | 
			
		||||
            hostPackages: true
 | 
			
		||||
          }
 | 
			
		||||
        },
 | 
			
		||||
        skip,
 | 
			
		||||
        take,
 | 
			
		||||
        orderBy: {
 | 
			
		||||
          name: 'asc'
 | 
			
		||||
        }
 | 
			
		||||
      }),
 | 
			
		||||
      prisma.package.count({ where })
 | 
			
		||||
    ]);
 | 
			
		||||
		// Get packages with counts
 | 
			
		||||
		const [packages, totalCount] = await Promise.all([
 | 
			
		||||
			prisma.packages.findMany({
 | 
			
		||||
				where,
 | 
			
		||||
				select: {
 | 
			
		||||
					id: true,
 | 
			
		||||
					name: true,
 | 
			
		||||
					description: true,
 | 
			
		||||
					category: true,
 | 
			
		||||
					latest_version: true,
 | 
			
		||||
					created_at: true,
 | 
			
		||||
					_count: {
 | 
			
		||||
						select: {
 | 
			
		||||
							host_packages: true,
 | 
			
		||||
						},
 | 
			
		||||
					},
 | 
			
		||||
				},
 | 
			
		||||
				skip,
 | 
			
		||||
				take,
 | 
			
		||||
				orderBy: {
 | 
			
		||||
					name: "asc",
 | 
			
		||||
				},
 | 
			
		||||
			}),
 | 
			
		||||
			prisma.packages.count({ where }),
 | 
			
		||||
		]);
 | 
			
		||||
 | 
			
		||||
    // Get additional stats for each package
 | 
			
		||||
    const packagesWithStats = await Promise.all(
 | 
			
		||||
      packages.map(async (pkg) => {
 | 
			
		||||
        const [updatesCount, securityCount, affectedHosts] = await Promise.all([
 | 
			
		||||
          prisma.hostPackage.count({
 | 
			
		||||
            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
 | 
			
		||||
          })
 | 
			
		||||
        ]);
 | 
			
		||||
		// Get additional stats for each package
 | 
			
		||||
		const packagesWithStats = await Promise.all(
 | 
			
		||||
			packages.map(async (pkg) => {
 | 
			
		||||
				// Build base where clause for this package
 | 
			
		||||
				const baseWhere = { package_id: pkg.id };
 | 
			
		||||
 | 
			
		||||
        return {
 | 
			
		||||
          ...pkg,
 | 
			
		||||
          stats: {
 | 
			
		||||
            totalInstalls: pkg._count.hostPackages,
 | 
			
		||||
            updatesNeeded: updatesCount,
 | 
			
		||||
            securityUpdates: securityCount,
 | 
			
		||||
            affectedHosts: affectedHosts.map(hp => hp.host)
 | 
			
		||||
          }
 | 
			
		||||
        };
 | 
			
		||||
      })
 | 
			
		||||
    );
 | 
			
		||||
				// If host filter is specified, add host filter to all queries
 | 
			
		||||
				const hostWhere = host ? { ...baseWhere, host_id: host } : baseWhere;
 | 
			
		||||
 | 
			
		||||
    res.json({
 | 
			
		||||
      packages: packagesWithStats,
 | 
			
		||||
      pagination: {
 | 
			
		||||
        page: parseInt(page),
 | 
			
		||||
        limit: parseInt(limit),
 | 
			
		||||
        total: totalCount,
 | 
			
		||||
        pages: Math.ceil(totalCount / parseInt(limit))
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.error('Error fetching packages:', error);
 | 
			
		||||
    res.status(500).json({ error: 'Failed to fetch packages' });
 | 
			
		||||
  }
 | 
			
		||||
				const [updatesCount, securityCount, packageHosts] = await Promise.all([
 | 
			
		||||
					prisma.host_packages.count({
 | 
			
		||||
						where: {
 | 
			
		||||
							...hostWhere,
 | 
			
		||||
							needs_update: true,
 | 
			
		||||
						},
 | 
			
		||||
					}),
 | 
			
		||||
					prisma.host_packages.count({
 | 
			
		||||
						where: {
 | 
			
		||||
							...hostWhere,
 | 
			
		||||
							needs_update: true,
 | 
			
		||||
							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
 | 
			
		||||
router.get('/:packageId', async (req, res) => {
 | 
			
		||||
  try {
 | 
			
		||||
    const { packageId } = req.params;
 | 
			
		||||
router.get("/:packageId", async (req, res) => {
 | 
			
		||||
	try {
 | 
			
		||||
		const { packageId } = req.params;
 | 
			
		||||
 | 
			
		||||
    const packageData = await prisma.package.findUnique({
 | 
			
		||||
      where: { id: packageId },
 | 
			
		||||
      include: {
 | 
			
		||||
        hostPackages: {
 | 
			
		||||
          include: {
 | 
			
		||||
            host: {
 | 
			
		||||
              select: {
 | 
			
		||||
                id: true,
 | 
			
		||||
                hostname: true,
 | 
			
		||||
                ip: true,
 | 
			
		||||
                osType: true,
 | 
			
		||||
                osVersion: true,
 | 
			
		||||
                lastUpdate: true
 | 
			
		||||
              }
 | 
			
		||||
            }
 | 
			
		||||
          },
 | 
			
		||||
          orderBy: {
 | 
			
		||||
            needsUpdate: 'desc'
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
		const packageData = await prisma.packages.findUnique({
 | 
			
		||||
			where: { id: packageId },
 | 
			
		||||
			include: {
 | 
			
		||||
				host_packages: {
 | 
			
		||||
					include: {
 | 
			
		||||
						hosts: {
 | 
			
		||||
							select: {
 | 
			
		||||
								id: true,
 | 
			
		||||
								hostname: true,
 | 
			
		||||
								ip: true,
 | 
			
		||||
								os_type: true,
 | 
			
		||||
								os_version: true,
 | 
			
		||||
								last_update: true,
 | 
			
		||||
							},
 | 
			
		||||
						},
 | 
			
		||||
					},
 | 
			
		||||
					orderBy: {
 | 
			
		||||
						needs_update: "desc",
 | 
			
		||||
					},
 | 
			
		||||
				},
 | 
			
		||||
			},
 | 
			
		||||
		});
 | 
			
		||||
 | 
			
		||||
    if (!packageData) {
 | 
			
		||||
      return res.status(404).json({ error: 'Package not found' });
 | 
			
		||||
    }
 | 
			
		||||
		if (!packageData) {
 | 
			
		||||
			return res.status(404).json({ error: "Package not found" });
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
    // Calculate statistics
 | 
			
		||||
    const stats = {
 | 
			
		||||
      totalInstalls: packageData.hostPackages.length,
 | 
			
		||||
      updatesNeeded: packageData.hostPackages.filter(hp => hp.needsUpdate).length,
 | 
			
		||||
      securityUpdates: packageData.hostPackages.filter(hp => hp.needsUpdate && hp.isSecurityUpdate).length,
 | 
			
		||||
      upToDate: packageData.hostPackages.filter(hp => !hp.needsUpdate).length
 | 
			
		||||
    };
 | 
			
		||||
		// Calculate statistics
 | 
			
		||||
		const stats = {
 | 
			
		||||
			totalInstalls: packageData.host_packages.length,
 | 
			
		||||
			updatesNeeded: packageData.host_packages.filter((hp) => hp.needs_update)
 | 
			
		||||
				.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
 | 
			
		||||
    const versionDistribution = packageData.hostPackages.reduce((acc, hp) => {
 | 
			
		||||
      const version = hp.currentVersion;
 | 
			
		||||
      acc[version] = (acc[version] || 0) + 1;
 | 
			
		||||
      return acc;
 | 
			
		||||
    }, {});
 | 
			
		||||
		// Group by version
 | 
			
		||||
		const versionDistribution = packageData.host_packages.reduce((acc, hp) => {
 | 
			
		||||
			const version = hp.current_version;
 | 
			
		||||
			acc[version] = (acc[version] || 0) + 1;
 | 
			
		||||
			return acc;
 | 
			
		||||
		}, {});
 | 
			
		||||
 | 
			
		||||
    // Group by OS type
 | 
			
		||||
    const osDistribution = packageData.hostPackages.reduce((acc, hp) => {
 | 
			
		||||
      const osType = hp.host.osType;
 | 
			
		||||
      acc[osType] = (acc[osType] || 0) + 1;
 | 
			
		||||
      return acc;
 | 
			
		||||
    }, {});
 | 
			
		||||
		// Group by OS type
 | 
			
		||||
		const osDistribution = packageData.host_packages.reduce((acc, hp) => {
 | 
			
		||||
			const osType = hp.hosts.os_type;
 | 
			
		||||
			acc[osType] = (acc[osType] || 0) + 1;
 | 
			
		||||
			return acc;
 | 
			
		||||
		}, {});
 | 
			
		||||
 | 
			
		||||
    res.json({
 | 
			
		||||
      ...packageData,
 | 
			
		||||
      stats,
 | 
			
		||||
      distributions: {
 | 
			
		||||
        versions: Object.entries(versionDistribution).map(([version, count]) => ({
 | 
			
		||||
          version,
 | 
			
		||||
          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' });
 | 
			
		||||
  }
 | 
			
		||||
		res.json({
 | 
			
		||||
			...packageData,
 | 
			
		||||
			stats,
 | 
			
		||||
			distributions: {
 | 
			
		||||
				versions: Object.entries(versionDistribution).map(
 | 
			
		||||
					([version, count]) => ({
 | 
			
		||||
						version,
 | 
			
		||||
						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" });
 | 
			
		||||
	}
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
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 { PrismaClient } = require('@prisma/client');
 | 
			
		||||
const { authenticateToken, requireAdmin } = require('../middleware/auth');
 | 
			
		||||
const { requireManageSettings } = require('../middleware/permissions');
 | 
			
		||||
const express = require("express");
 | 
			
		||||
const { PrismaClient } = require("@prisma/client");
 | 
			
		||||
const { authenticateToken } = require("../middleware/auth");
 | 
			
		||||
const {
 | 
			
		||||
	requireManageSettings,
 | 
			
		||||
	requireManageUsers,
 | 
			
		||||
} = require("../middleware/permissions");
 | 
			
		||||
 | 
			
		||||
const router = express.Router();
 | 
			
		||||
const prisma = new PrismaClient();
 | 
			
		||||
 | 
			
		||||
// Get all role permissions
 | 
			
		||||
router.get('/roles', authenticateToken, requireManageSettings, async (req, res) => {
 | 
			
		||||
  try {
 | 
			
		||||
    const permissions = await prisma.rolePermissions.findMany({
 | 
			
		||||
      orderBy: {
 | 
			
		||||
        role: 'asc'
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
// Get all role permissions (allow users who can manage users to view roles)
 | 
			
		||||
router.get(
 | 
			
		||||
	"/roles",
 | 
			
		||||
	authenticateToken,
 | 
			
		||||
	requireManageUsers,
 | 
			
		||||
	async (_req, res) => {
 | 
			
		||||
		try {
 | 
			
		||||
			const permissions = await prisma.role_permissions.findMany({
 | 
			
		||||
				orderBy: {
 | 
			
		||||
					role: "asc",
 | 
			
		||||
				},
 | 
			
		||||
			});
 | 
			
		||||
 | 
			
		||||
    res.json(permissions);
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.error('Get role permissions error:', error);
 | 
			
		||||
    res.status(500).json({ error: 'Failed to fetch role permissions' });
 | 
			
		||||
  }
 | 
			
		||||
});
 | 
			
		||||
			res.json(permissions);
 | 
			
		||||
		} catch (error) {
 | 
			
		||||
			console.error("Get role permissions error:", error);
 | 
			
		||||
			res.status(500).json({ error: "Failed to fetch role permissions" });
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
// Get permissions for a specific role
 | 
			
		||||
router.get('/roles/:role', authenticateToken, requireManageSettings, async (req, res) => {
 | 
			
		||||
  try {
 | 
			
		||||
    const { role } = req.params;
 | 
			
		||||
    
 | 
			
		||||
    const permissions = await prisma.rolePermissions.findUnique({
 | 
			
		||||
      where: { role }
 | 
			
		||||
    });
 | 
			
		||||
router.get(
 | 
			
		||||
	"/roles/:role",
 | 
			
		||||
	authenticateToken,
 | 
			
		||||
	requireManageSettings,
 | 
			
		||||
	async (req, res) => {
 | 
			
		||||
		try {
 | 
			
		||||
			const { role } = req.params;
 | 
			
		||||
 | 
			
		||||
    if (!permissions) {
 | 
			
		||||
      return res.status(404).json({ error: 'Role not found' });
 | 
			
		||||
    }
 | 
			
		||||
			const permissions = await prisma.role_permissions.findUnique({
 | 
			
		||||
				where: { role },
 | 
			
		||||
			});
 | 
			
		||||
 | 
			
		||||
    res.json(permissions);
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.error('Get role permission error:', error);
 | 
			
		||||
    res.status(500).json({ error: 'Failed to fetch role permission' });
 | 
			
		||||
  }
 | 
			
		||||
});
 | 
			
		||||
			if (!permissions) {
 | 
			
		||||
				return res.status(404).json({ error: "Role not found" });
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			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
 | 
			
		||||
router.put('/roles/:role', authenticateToken, requireManageSettings, async (req, res) => {
 | 
			
		||||
  try {
 | 
			
		||||
    const { role } = req.params;
 | 
			
		||||
    const {
 | 
			
		||||
      canViewDashboard,
 | 
			
		||||
      canViewHosts,
 | 
			
		||||
      canManageHosts,
 | 
			
		||||
      canViewPackages,
 | 
			
		||||
      canManagePackages,
 | 
			
		||||
      canViewUsers,
 | 
			
		||||
      canManageUsers,
 | 
			
		||||
      canViewReports,
 | 
			
		||||
      canExportData,
 | 
			
		||||
      canManageSettings
 | 
			
		||||
    } = req.body;
 | 
			
		||||
router.put(
 | 
			
		||||
	"/roles/:role",
 | 
			
		||||
	authenticateToken,
 | 
			
		||||
	requireManageSettings,
 | 
			
		||||
	async (req, res) => {
 | 
			
		||||
		try {
 | 
			
		||||
			const { role } = req.params;
 | 
			
		||||
			const {
 | 
			
		||||
				can_view_dashboard,
 | 
			
		||||
				can_view_hosts,
 | 
			
		||||
				can_manage_hosts,
 | 
			
		||||
				can_view_packages,
 | 
			
		||||
				can_manage_packages,
 | 
			
		||||
				can_view_users,
 | 
			
		||||
				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)
 | 
			
		||||
    if (role === 'admin') {
 | 
			
		||||
      return res.status(400).json({ error: 'Cannot modify admin role permissions' });
 | 
			
		||||
    }
 | 
			
		||||
			// Prevent modifying admin and user role permissions (built-in roles)
 | 
			
		||||
			if (role === "admin" || role === "user") {
 | 
			
		||||
				return res.status(400).json({
 | 
			
		||||
					error: `Cannot modify ${role} role permissions - this is a built-in role`,
 | 
			
		||||
				});
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
    const permissions = await prisma.rolePermissions.upsert({
 | 
			
		||||
      where: { role },
 | 
			
		||||
      update: {
 | 
			
		||||
        canViewDashboard,
 | 
			
		||||
        canViewHosts,
 | 
			
		||||
        canManageHosts,
 | 
			
		||||
        canViewPackages,
 | 
			
		||||
        canManagePackages,
 | 
			
		||||
        canViewUsers,
 | 
			
		||||
        canManageUsers,
 | 
			
		||||
        canViewReports,
 | 
			
		||||
        canExportData,
 | 
			
		||||
        canManageSettings
 | 
			
		||||
      },
 | 
			
		||||
      create: {
 | 
			
		||||
        role,
 | 
			
		||||
        canViewDashboard,
 | 
			
		||||
        canViewHosts,
 | 
			
		||||
        canManageHosts,
 | 
			
		||||
        canViewPackages,
 | 
			
		||||
        canManagePackages,
 | 
			
		||||
        canViewUsers,
 | 
			
		||||
        canManageUsers,
 | 
			
		||||
        canViewReports,
 | 
			
		||||
        canExportData,
 | 
			
		||||
        canManageSettings
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
			const permissions = await prisma.role_permissions.upsert({
 | 
			
		||||
				where: { role },
 | 
			
		||||
				update: {
 | 
			
		||||
					can_view_dashboard: can_view_dashboard,
 | 
			
		||||
					can_view_hosts: can_view_hosts,
 | 
			
		||||
					can_manage_hosts: can_manage_hosts,
 | 
			
		||||
					can_view_packages: can_view_packages,
 | 
			
		||||
					can_manage_packages: can_manage_packages,
 | 
			
		||||
					can_view_users: can_view_users,
 | 
			
		||||
					can_manage_users: can_manage_users,
 | 
			
		||||
					can_view_reports: can_view_reports,
 | 
			
		||||
					can_export_data: can_export_data,
 | 
			
		||||
					can_manage_settings: can_manage_settings,
 | 
			
		||||
					updated_at: new Date(),
 | 
			
		||||
				},
 | 
			
		||||
				create: {
 | 
			
		||||
					id: require("uuid").v4(),
 | 
			
		||||
					role,
 | 
			
		||||
					can_view_dashboard: can_view_dashboard,
 | 
			
		||||
					can_view_hosts: can_view_hosts,
 | 
			
		||||
					can_manage_hosts: can_manage_hosts,
 | 
			
		||||
					can_view_packages: can_view_packages,
 | 
			
		||||
					can_manage_packages: can_manage_packages,
 | 
			
		||||
					can_view_users: can_view_users,
 | 
			
		||||
					can_manage_users: can_manage_users,
 | 
			
		||||
					can_view_reports: can_view_reports,
 | 
			
		||||
					can_export_data: can_export_data,
 | 
			
		||||
					can_manage_settings: can_manage_settings,
 | 
			
		||||
					updated_at: new Date(),
 | 
			
		||||
				},
 | 
			
		||||
			});
 | 
			
		||||
 | 
			
		||||
    res.json({
 | 
			
		||||
      message: 'Role permissions updated successfully',
 | 
			
		||||
      permissions
 | 
			
		||||
    });
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.error('Update role permissions error:', error);
 | 
			
		||||
    res.status(500).json({ error: 'Failed to update role permissions' });
 | 
			
		||||
  }
 | 
			
		||||
});
 | 
			
		||||
			res.json({
 | 
			
		||||
				message: "Role permissions updated successfully",
 | 
			
		||||
				permissions,
 | 
			
		||||
			});
 | 
			
		||||
		} catch (error) {
 | 
			
		||||
			console.error("Update role permissions error:", error);
 | 
			
		||||
			res.status(500).json({ error: "Failed to update role permissions" });
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
// Delete a role (and its permissions)
 | 
			
		||||
router.delete('/roles/:role', authenticateToken, requireManageSettings, async (req, res) => {
 | 
			
		||||
  try {
 | 
			
		||||
    const { role } = req.params;
 | 
			
		||||
router.delete(
 | 
			
		||||
	"/roles/:role",
 | 
			
		||||
	authenticateToken,
 | 
			
		||||
	requireManageSettings,
 | 
			
		||||
	async (req, res) => {
 | 
			
		||||
		try {
 | 
			
		||||
			const { role } = req.params;
 | 
			
		||||
 | 
			
		||||
    // Prevent deleting admin role
 | 
			
		||||
    if (role === 'admin') {
 | 
			
		||||
      return res.status(400).json({ error: 'Cannot delete admin role' });
 | 
			
		||||
    }
 | 
			
		||||
			// Prevent deleting admin and user roles (built-in roles)
 | 
			
		||||
			if (role === "admin" || role === "user") {
 | 
			
		||||
				return res.status(400).json({
 | 
			
		||||
					error: `Cannot delete ${role} role - this is a built-in role`,
 | 
			
		||||
				});
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
    // Check if any users are using this role
 | 
			
		||||
    const usersWithRole = await prisma.user.count({
 | 
			
		||||
      where: { role }
 | 
			
		||||
    });
 | 
			
		||||
			// Check if any users are using this role
 | 
			
		||||
			const usersWithRole = await prisma.users.count({
 | 
			
		||||
				where: { role },
 | 
			
		||||
			});
 | 
			
		||||
 | 
			
		||||
    if (usersWithRole > 0) {
 | 
			
		||||
      return res.status(400).json({ 
 | 
			
		||||
        error: `Cannot delete role "${role}" because ${usersWithRole} user(s) are currently using it` 
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
			if (usersWithRole > 0) {
 | 
			
		||||
				return res.status(400).json({
 | 
			
		||||
					error: `Cannot delete role "${role}" because ${usersWithRole} user(s) are currently using it`,
 | 
			
		||||
				});
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
    await prisma.rolePermissions.delete({
 | 
			
		||||
      where: { role }
 | 
			
		||||
    });
 | 
			
		||||
			await prisma.role_permissions.delete({
 | 
			
		||||
				where: { role },
 | 
			
		||||
			});
 | 
			
		||||
 | 
			
		||||
    res.json({
 | 
			
		||||
      message: `Role "${role}" deleted successfully`
 | 
			
		||||
    });
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.error('Delete role error:', error);
 | 
			
		||||
    res.status(500).json({ error: 'Failed to delete role' });
 | 
			
		||||
  }
 | 
			
		||||
});
 | 
			
		||||
			res.json({
 | 
			
		||||
				message: `Role "${role}" deleted successfully`,
 | 
			
		||||
			});
 | 
			
		||||
		} catch (error) {
 | 
			
		||||
			console.error("Delete role error:", error);
 | 
			
		||||
			res.status(500).json({ error: "Failed to delete role" });
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
// Get user's permissions based on their role
 | 
			
		||||
router.get('/user-permissions', authenticateToken, async (req, res) => {
 | 
			
		||||
  try {
 | 
			
		||||
    const userRole = req.user.role;
 | 
			
		||||
    
 | 
			
		||||
    const permissions = await prisma.rolePermissions.findUnique({
 | 
			
		||||
      where: { role: userRole }
 | 
			
		||||
    });
 | 
			
		||||
router.get("/user-permissions", authenticateToken, async (req, res) => {
 | 
			
		||||
	try {
 | 
			
		||||
		const userRole = req.user.role;
 | 
			
		||||
 | 
			
		||||
    if (!permissions) {
 | 
			
		||||
      // If no specific permissions found, return default admin permissions
 | 
			
		||||
      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,
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
		const permissions = await prisma.role_permissions.findUnique({
 | 
			
		||||
			where: { role: userRole },
 | 
			
		||||
		});
 | 
			
		||||
 | 
			
		||||
    res.json(permissions);
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.error('Get user permissions error:', error);
 | 
			
		||||
    res.status(500).json({ error: 'Failed to fetch user permissions' });
 | 
			
		||||
  }
 | 
			
		||||
		if (!permissions) {
 | 
			
		||||
			// If no specific permissions found, return default admin permissions
 | 
			
		||||
			return res.json({
 | 
			
		||||
				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;
 | 
			
		||||
 
 | 
			
		||||
@@ -1,301 +1,418 @@
 | 
			
		||||
const express = require('express');
 | 
			
		||||
const { body, validationResult } = require('express-validator');
 | 
			
		||||
const { PrismaClient } = require('@prisma/client');
 | 
			
		||||
const { authenticateToken } = require('../middleware/auth');
 | 
			
		||||
const { requireViewHosts, requireManageHosts } = require('../middleware/permissions');
 | 
			
		||||
const express = require("express");
 | 
			
		||||
const { body, validationResult } = require("express-validator");
 | 
			
		||||
const { PrismaClient } = require("@prisma/client");
 | 
			
		||||
const { authenticateToken } = require("../middleware/auth");
 | 
			
		||||
const {
 | 
			
		||||
	requireViewHosts,
 | 
			
		||||
	requireManageHosts,
 | 
			
		||||
} = require("../middleware/permissions");
 | 
			
		||||
 | 
			
		||||
const router = express.Router();
 | 
			
		||||
const prisma = new PrismaClient();
 | 
			
		||||
 | 
			
		||||
// Get all repositories with host count
 | 
			
		||||
router.get('/', authenticateToken, requireViewHosts, async (req, res) => {
 | 
			
		||||
  try {
 | 
			
		||||
    const repositories = await prisma.repository.findMany({
 | 
			
		||||
      include: {
 | 
			
		||||
        hostRepositories: {
 | 
			
		||||
          include: {
 | 
			
		||||
            host: {
 | 
			
		||||
              select: {
 | 
			
		||||
                id: true,
 | 
			
		||||
                hostname: true,
 | 
			
		||||
                status: true
 | 
			
		||||
              }
 | 
			
		||||
            }
 | 
			
		||||
          }
 | 
			
		||||
        },
 | 
			
		||||
        _count: {
 | 
			
		||||
          select: {
 | 
			
		||||
            hostRepositories: true
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
      },
 | 
			
		||||
      orderBy: [
 | 
			
		||||
        { name: 'asc' },
 | 
			
		||||
        { url: 'asc' }
 | 
			
		||||
      ]
 | 
			
		||||
    });
 | 
			
		||||
router.get("/", authenticateToken, requireViewHosts, async (_req, res) => {
 | 
			
		||||
	try {
 | 
			
		||||
		const repositories = await prisma.repositories.findMany({
 | 
			
		||||
			include: {
 | 
			
		||||
				host_repositories: {
 | 
			
		||||
					include: {
 | 
			
		||||
						hosts: {
 | 
			
		||||
							select: {
 | 
			
		||||
								id: true,
 | 
			
		||||
								friendly_name: true,
 | 
			
		||||
								status: true,
 | 
			
		||||
							},
 | 
			
		||||
						},
 | 
			
		||||
					},
 | 
			
		||||
				},
 | 
			
		||||
				_count: {
 | 
			
		||||
					select: {
 | 
			
		||||
						host_repositories: true,
 | 
			
		||||
					},
 | 
			
		||||
				},
 | 
			
		||||
			},
 | 
			
		||||
			orderBy: [{ name: "asc" }, { url: "asc" }],
 | 
			
		||||
		});
 | 
			
		||||
 | 
			
		||||
    // Transform data to include host counts and status
 | 
			
		||||
    const transformedRepos = repositories.map(repo => ({
 | 
			
		||||
      ...repo,
 | 
			
		||||
      hostCount: repo._count.hostRepositories,
 | 
			
		||||
      enabledHostCount: repo.hostRepositories.filter(hr => hr.isEnabled).length,
 | 
			
		||||
      activeHostCount: repo.hostRepositories.filter(hr => hr.host.status === 'active').length,
 | 
			
		||||
      hosts: repo.hostRepositories.map(hr => ({
 | 
			
		||||
        id: hr.host.id,
 | 
			
		||||
        hostname: hr.host.hostname,
 | 
			
		||||
        status: hr.host.status,
 | 
			
		||||
        isEnabled: hr.isEnabled,
 | 
			
		||||
        lastChecked: hr.lastChecked
 | 
			
		||||
      }))
 | 
			
		||||
    }));
 | 
			
		||||
		// Transform data to include host counts and status
 | 
			
		||||
		const transformedRepos = repositories.map((repo) => ({
 | 
			
		||||
			...repo,
 | 
			
		||||
			hostCount: repo._count.host_repositories,
 | 
			
		||||
			enabledHostCount: repo.host_repositories.filter((hr) => hr.is_enabled)
 | 
			
		||||
				.length,
 | 
			
		||||
			activeHostCount: repo.host_repositories.filter(
 | 
			
		||||
				(hr) => hr.hosts.status === "active",
 | 
			
		||||
			).length,
 | 
			
		||||
			hosts: repo.host_repositories.map((hr) => ({
 | 
			
		||||
				id: hr.hosts.id,
 | 
			
		||||
				friendlyName: hr.hosts.friendly_name,
 | 
			
		||||
				status: hr.hosts.status,
 | 
			
		||||
				isEnabled: hr.is_enabled,
 | 
			
		||||
				lastChecked: hr.last_checked,
 | 
			
		||||
			})),
 | 
			
		||||
		}));
 | 
			
		||||
 | 
			
		||||
    res.json(transformedRepos);
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.error('Repository list error:', error);
 | 
			
		||||
    res.status(500).json({ error: 'Failed to fetch repositories' });
 | 
			
		||||
  }
 | 
			
		||||
		res.json(transformedRepos);
 | 
			
		||||
	} catch (error) {
 | 
			
		||||
		console.error("Repository list error:", error);
 | 
			
		||||
		res.status(500).json({ error: "Failed to fetch repositories" });
 | 
			
		||||
	}
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
// Get repositories for a specific host
 | 
			
		||||
router.get('/host/:hostId', authenticateToken, requireViewHosts, async (req, res) => {
 | 
			
		||||
  try {
 | 
			
		||||
    const { hostId } = req.params;
 | 
			
		||||
router.get(
 | 
			
		||||
	"/host/:hostId",
 | 
			
		||||
	authenticateToken,
 | 
			
		||||
	requireViewHosts,
 | 
			
		||||
	async (req, res) => {
 | 
			
		||||
		try {
 | 
			
		||||
			const { hostId } = req.params;
 | 
			
		||||
 | 
			
		||||
    const hostRepositories = await prisma.hostRepository.findMany({
 | 
			
		||||
      where: { hostId },
 | 
			
		||||
      include: {
 | 
			
		||||
        repository: true,
 | 
			
		||||
        host: {
 | 
			
		||||
          select: {
 | 
			
		||||
            id: true,
 | 
			
		||||
            hostname: true
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
      },
 | 
			
		||||
      orderBy: {
 | 
			
		||||
        repository: {
 | 
			
		||||
          name: 'asc'
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
			const hostRepositories = await prisma.host_repositories.findMany({
 | 
			
		||||
				where: { host_id: hostId },
 | 
			
		||||
				include: {
 | 
			
		||||
					repositories: true,
 | 
			
		||||
					hosts: {
 | 
			
		||||
						select: {
 | 
			
		||||
							id: true,
 | 
			
		||||
							friendly_name: true,
 | 
			
		||||
						},
 | 
			
		||||
					},
 | 
			
		||||
				},
 | 
			
		||||
				orderBy: {
 | 
			
		||||
					repositories: {
 | 
			
		||||
						name: "asc",
 | 
			
		||||
					},
 | 
			
		||||
				},
 | 
			
		||||
			});
 | 
			
		||||
 | 
			
		||||
    res.json(hostRepositories);
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.error('Host repositories error:', error);
 | 
			
		||||
    res.status(500).json({ error: 'Failed to fetch host repositories' });
 | 
			
		||||
  }
 | 
			
		||||
});
 | 
			
		||||
			res.json(hostRepositories);
 | 
			
		||||
		} catch (error) {
 | 
			
		||||
			console.error("Host repositories error:", error);
 | 
			
		||||
			res.status(500).json({ error: "Failed to fetch host repositories" });
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
// Get repository details with all hosts
 | 
			
		||||
router.get('/:repositoryId', authenticateToken, requireViewHosts, async (req, res) => {
 | 
			
		||||
  try {
 | 
			
		||||
    const { repositoryId } = req.params;
 | 
			
		||||
router.get(
 | 
			
		||||
	"/:repositoryId",
 | 
			
		||||
	authenticateToken,
 | 
			
		||||
	requireViewHosts,
 | 
			
		||||
	async (req, res) => {
 | 
			
		||||
		try {
 | 
			
		||||
			const { repositoryId } = req.params;
 | 
			
		||||
 | 
			
		||||
    const repository = await prisma.repository.findUnique({
 | 
			
		||||
      where: { id: repositoryId },
 | 
			
		||||
      include: {
 | 
			
		||||
        hostRepositories: {
 | 
			
		||||
          include: {
 | 
			
		||||
            host: {
 | 
			
		||||
              select: {
 | 
			
		||||
                id: true,
 | 
			
		||||
                hostname: true,
 | 
			
		||||
                ip: true,
 | 
			
		||||
                osType: true,
 | 
			
		||||
                osVersion: true,
 | 
			
		||||
                status: true,
 | 
			
		||||
                lastUpdate: true
 | 
			
		||||
              }
 | 
			
		||||
            }
 | 
			
		||||
          },
 | 
			
		||||
          orderBy: {
 | 
			
		||||
            host: {
 | 
			
		||||
              hostname: 'asc'
 | 
			
		||||
            }
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
			const repository = await prisma.repositories.findUnique({
 | 
			
		||||
				where: { id: repositoryId },
 | 
			
		||||
				include: {
 | 
			
		||||
					host_repositories: {
 | 
			
		||||
						include: {
 | 
			
		||||
							hosts: {
 | 
			
		||||
								select: {
 | 
			
		||||
									id: true,
 | 
			
		||||
									friendly_name: true,
 | 
			
		||||
									hostname: true,
 | 
			
		||||
									ip: true,
 | 
			
		||||
									os_type: true,
 | 
			
		||||
									os_version: true,
 | 
			
		||||
									status: true,
 | 
			
		||||
									last_update: true,
 | 
			
		||||
								},
 | 
			
		||||
							},
 | 
			
		||||
						},
 | 
			
		||||
						orderBy: {
 | 
			
		||||
							hosts: {
 | 
			
		||||
								friendly_name: "asc",
 | 
			
		||||
							},
 | 
			
		||||
						},
 | 
			
		||||
					},
 | 
			
		||||
				},
 | 
			
		||||
			});
 | 
			
		||||
 | 
			
		||||
    if (!repository) {
 | 
			
		||||
      return res.status(404).json({ error: 'Repository not found' });
 | 
			
		||||
    }
 | 
			
		||||
			if (!repository) {
 | 
			
		||||
				return res.status(404).json({ error: "Repository not found" });
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
    res.json(repository);
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.error('Repository detail error:', error);
 | 
			
		||||
    res.status(500).json({ error: 'Failed to fetch repository details' });
 | 
			
		||||
  }
 | 
			
		||||
});
 | 
			
		||||
			res.json(repository);
 | 
			
		||||
		} catch (error) {
 | 
			
		||||
			console.error("Repository detail error:", error);
 | 
			
		||||
			res.status(500).json({ error: "Failed to fetch repository details" });
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
// Update repository information (admin only)
 | 
			
		||||
router.put('/:repositoryId', authenticateToken, requireManageHosts, [
 | 
			
		||||
  body('name').optional().isLength({ min: 1 }).withMessage('Name is required'),
 | 
			
		||||
  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() });
 | 
			
		||||
    }
 | 
			
		||||
router.put(
 | 
			
		||||
	"/:repositoryId",
 | 
			
		||||
	authenticateToken,
 | 
			
		||||
	requireManageHosts,
 | 
			
		||||
	[
 | 
			
		||||
		body("name")
 | 
			
		||||
			.optional()
 | 
			
		||||
			.isLength({ min: 1 })
 | 
			
		||||
			.withMessage("Name is required"),
 | 
			
		||||
		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 { name, description, isActive, priority } = req.body;
 | 
			
		||||
			const { repositoryId } = req.params;
 | 
			
		||||
			const { name, description, isActive, priority } = req.body;
 | 
			
		||||
 | 
			
		||||
    const repository = await prisma.repository.update({
 | 
			
		||||
      where: { id: repositoryId },
 | 
			
		||||
      data: {
 | 
			
		||||
        ...(name && { name }),
 | 
			
		||||
        ...(description !== undefined && { description }),
 | 
			
		||||
        ...(isActive !== undefined && { isActive }),
 | 
			
		||||
        ...(priority !== undefined && { priority })
 | 
			
		||||
      },
 | 
			
		||||
      include: {
 | 
			
		||||
        _count: {
 | 
			
		||||
          select: {
 | 
			
		||||
            hostRepositories: true
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
			const repository = await prisma.repositories.update({
 | 
			
		||||
				where: { id: repositoryId },
 | 
			
		||||
				data: {
 | 
			
		||||
					...(name && { name }),
 | 
			
		||||
					...(description !== undefined && { description }),
 | 
			
		||||
					...(isActive !== undefined && { is_active: isActive }),
 | 
			
		||||
					...(priority !== undefined && { priority }),
 | 
			
		||||
				},
 | 
			
		||||
				include: {
 | 
			
		||||
					_count: {
 | 
			
		||||
						select: {
 | 
			
		||||
							host_repositories: true,
 | 
			
		||||
						},
 | 
			
		||||
					},
 | 
			
		||||
				},
 | 
			
		||||
			});
 | 
			
		||||
 | 
			
		||||
    res.json(repository);
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.error('Repository update error:', error);
 | 
			
		||||
    res.status(500).json({ error: 'Failed to update repository' });
 | 
			
		||||
  }
 | 
			
		||||
});
 | 
			
		||||
			res.json(repository);
 | 
			
		||||
		} catch (error) {
 | 
			
		||||
			console.error("Repository update error:", error);
 | 
			
		||||
			res.status(500).json({ error: "Failed to update repository" });
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
// Toggle repository status for a specific host
 | 
			
		||||
router.patch('/host/:hostId/repository/:repositoryId', authenticateToken, requireManageHosts, [
 | 
			
		||||
  body('isEnabled').isBoolean().withMessage('isEnabled must be a boolean')
 | 
			
		||||
], async (req, res) => {
 | 
			
		||||
  try {
 | 
			
		||||
    const errors = validationResult(req);
 | 
			
		||||
    if (!errors.isEmpty()) {
 | 
			
		||||
      return res.status(400).json({ errors: errors.array() });
 | 
			
		||||
    }
 | 
			
		||||
router.patch(
 | 
			
		||||
	"/host/:hostId/repository/:repositoryId",
 | 
			
		||||
	authenticateToken,
 | 
			
		||||
	requireManageHosts,
 | 
			
		||||
	[body("isEnabled").isBoolean().withMessage("isEnabled must be a boolean")],
 | 
			
		||||
	async (req, res) => {
 | 
			
		||||
		try {
 | 
			
		||||
			const errors = validationResult(req);
 | 
			
		||||
			if (!errors.isEmpty()) {
 | 
			
		||||
				return res.status(400).json({ errors: errors.array() });
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
    const { hostId, repositoryId } = req.params;
 | 
			
		||||
    const { isEnabled } = req.body;
 | 
			
		||||
			const { hostId, repositoryId } = req.params;
 | 
			
		||||
			const { isEnabled } = req.body;
 | 
			
		||||
 | 
			
		||||
    const hostRepository = await prisma.hostRepository.update({
 | 
			
		||||
      where: {
 | 
			
		||||
        hostId_repositoryId: {
 | 
			
		||||
          hostId,
 | 
			
		||||
          repositoryId
 | 
			
		||||
        }
 | 
			
		||||
      },
 | 
			
		||||
      data: {
 | 
			
		||||
        isEnabled,
 | 
			
		||||
        lastChecked: new Date()
 | 
			
		||||
      },
 | 
			
		||||
      include: {
 | 
			
		||||
        repository: true,
 | 
			
		||||
        host: {
 | 
			
		||||
          select: {
 | 
			
		||||
            hostname: true
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
			const hostRepository = await prisma.host_repositories.update({
 | 
			
		||||
				where: {
 | 
			
		||||
					host_id_repository_id: {
 | 
			
		||||
						host_id: hostId,
 | 
			
		||||
						repository_id: repositoryId,
 | 
			
		||||
					},
 | 
			
		||||
				},
 | 
			
		||||
				data: {
 | 
			
		||||
					is_enabled: isEnabled,
 | 
			
		||||
					last_checked: new Date(),
 | 
			
		||||
				},
 | 
			
		||||
				include: {
 | 
			
		||||
					repositories: true,
 | 
			
		||||
					hosts: {
 | 
			
		||||
						select: {
 | 
			
		||||
							friendly_name: true,
 | 
			
		||||
						},
 | 
			
		||||
					},
 | 
			
		||||
				},
 | 
			
		||||
			});
 | 
			
		||||
 | 
			
		||||
    res.json({
 | 
			
		||||
      message: `Repository ${isEnabled ? 'enabled' : 'disabled'} for host ${hostRepository.host.hostname}`,
 | 
			
		||||
      hostRepository
 | 
			
		||||
    });
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.error('Host repository toggle error:', error);
 | 
			
		||||
    res.status(500).json({ error: 'Failed to toggle repository status' });
 | 
			
		||||
  }
 | 
			
		||||
});
 | 
			
		||||
			res.json({
 | 
			
		||||
				message: `Repository ${isEnabled ? "enabled" : "disabled"} for host ${hostRepository.hosts.friendly_name}`,
 | 
			
		||||
				hostRepository,
 | 
			
		||||
			});
 | 
			
		||||
		} catch (error) {
 | 
			
		||||
			console.error("Host repository toggle error:", error);
 | 
			
		||||
			res.status(500).json({ error: "Failed to toggle repository status" });
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
// Get repository statistics
 | 
			
		||||
router.get('/stats/summary', authenticateToken, requireViewHosts, async (req, res) => {
 | 
			
		||||
  try {
 | 
			
		||||
    const stats = await prisma.repository.aggregate({
 | 
			
		||||
      _count: true
 | 
			
		||||
    });
 | 
			
		||||
router.get(
 | 
			
		||||
	"/stats/summary",
 | 
			
		||||
	authenticateToken,
 | 
			
		||||
	requireViewHosts,
 | 
			
		||||
	async (_req, res) => {
 | 
			
		||||
		try {
 | 
			
		||||
			const stats = await prisma.repositories.aggregate({
 | 
			
		||||
				_count: true,
 | 
			
		||||
			});
 | 
			
		||||
 | 
			
		||||
    const hostRepoStats = await prisma.hostRepository.aggregate({
 | 
			
		||||
      _count: {
 | 
			
		||||
        isEnabled: true
 | 
			
		||||
      },
 | 
			
		||||
      where: {
 | 
			
		||||
        isEnabled: true
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
			const hostRepoStats = await prisma.host_repositories.aggregate({
 | 
			
		||||
				_count: {
 | 
			
		||||
					is_enabled: true,
 | 
			
		||||
				},
 | 
			
		||||
				where: {
 | 
			
		||||
					is_enabled: true,
 | 
			
		||||
				},
 | 
			
		||||
			});
 | 
			
		||||
 | 
			
		||||
    const secureRepos = await prisma.repository.count({
 | 
			
		||||
      where: { isSecure: true }
 | 
			
		||||
    });
 | 
			
		||||
			const secureRepos = await prisma.repositories.count({
 | 
			
		||||
				where: { is_secure: true },
 | 
			
		||||
			});
 | 
			
		||||
 | 
			
		||||
    const activeRepos = await prisma.repository.count({
 | 
			
		||||
      where: { isActive: true }
 | 
			
		||||
    });
 | 
			
		||||
			const activeRepos = await prisma.repositories.count({
 | 
			
		||||
				where: { is_active: true },
 | 
			
		||||
			});
 | 
			
		||||
 | 
			
		||||
    res.json({
 | 
			
		||||
      totalRepositories: stats._count,
 | 
			
		||||
      activeRepositories: activeRepos,
 | 
			
		||||
      secureRepositories: secureRepos,
 | 
			
		||||
      enabledHostRepositories: hostRepoStats._count.isEnabled,
 | 
			
		||||
      securityPercentage: stats._count > 0 ? Math.round((secureRepos / stats._count) * 100) : 0
 | 
			
		||||
    });
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.error('Repository stats error:', error);
 | 
			
		||||
    res.status(500).json({ error: 'Failed to fetch repository statistics' });
 | 
			
		||||
  }
 | 
			
		||||
});
 | 
			
		||||
			res.json({
 | 
			
		||||
				totalRepositories: stats._count,
 | 
			
		||||
				activeRepositories: activeRepos,
 | 
			
		||||
				secureRepositories: secureRepos,
 | 
			
		||||
				enabledHostRepositories: hostRepoStats._count.isEnabled,
 | 
			
		||||
				securityPercentage:
 | 
			
		||||
					stats._count > 0 ? Math.round((secureRepos / stats._count) * 100) : 0,
 | 
			
		||||
			});
 | 
			
		||||
		} catch (error) {
 | 
			
		||||
			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)
 | 
			
		||||
router.delete('/cleanup/orphaned', authenticateToken, requireManageHosts, async (req, res) => {
 | 
			
		||||
  try {
 | 
			
		||||
    console.log('Cleaning up orphaned repositories...');
 | 
			
		||||
    
 | 
			
		||||
    // Find repositories with no host relationships
 | 
			
		||||
    const orphanedRepos = await prisma.repository.findMany({
 | 
			
		||||
      where: {
 | 
			
		||||
        hostRepositories: {
 | 
			
		||||
          none: {}
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
router.delete(
 | 
			
		||||
	"/cleanup/orphaned",
 | 
			
		||||
	authenticateToken,
 | 
			
		||||
	requireManageHosts,
 | 
			
		||||
	async (_req, res) => {
 | 
			
		||||
		try {
 | 
			
		||||
			console.log("Cleaning up orphaned repositories...");
 | 
			
		||||
 | 
			
		||||
    if (orphanedRepos.length === 0) {
 | 
			
		||||
      return res.json({
 | 
			
		||||
        message: 'No orphaned repositories found',
 | 
			
		||||
        deletedCount: 0,
 | 
			
		||||
        deletedRepositories: []
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
			// Find repositories with no host relationships
 | 
			
		||||
			const orphanedRepos = await prisma.repositories.findMany({
 | 
			
		||||
				where: {
 | 
			
		||||
					host_repositories: {
 | 
			
		||||
						none: {},
 | 
			
		||||
					},
 | 
			
		||||
				},
 | 
			
		||||
			});
 | 
			
		||||
 | 
			
		||||
    // Delete orphaned repositories
 | 
			
		||||
    const deleteResult = await prisma.repository.deleteMany({
 | 
			
		||||
      where: {
 | 
			
		||||
        hostRepositories: {
 | 
			
		||||
          none: {}
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
			if (orphanedRepos.length === 0) {
 | 
			
		||||
				return res.json({
 | 
			
		||||
					message: "No orphaned repositories found",
 | 
			
		||||
					deletedCount: 0,
 | 
			
		||||
					deletedRepositories: [],
 | 
			
		||||
				});
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
    console.log(`Deleted ${deleteResult.count} orphaned repositories`);
 | 
			
		||||
			// Delete orphaned repositories
 | 
			
		||||
			const deleteResult = await prisma.repositories.deleteMany({
 | 
			
		||||
				where: {
 | 
			
		||||
					hostRepositories: {
 | 
			
		||||
						none: {},
 | 
			
		||||
					},
 | 
			
		||||
				},
 | 
			
		||||
			});
 | 
			
		||||
 | 
			
		||||
    res.json({
 | 
			
		||||
      message: `Successfully deleted ${deleteResult.count} orphaned repositories`,
 | 
			
		||||
      deletedCount: deleteResult.count,
 | 
			
		||||
      deletedRepositories: orphanedRepos.map(repo => ({
 | 
			
		||||
        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' });
 | 
			
		||||
  }
 | 
			
		||||
});
 | 
			
		||||
			console.log(`Deleted ${deleteResult.count} orphaned repositories`);
 | 
			
		||||
 | 
			
		||||
			res.json({
 | 
			
		||||
				message: `Successfully deleted ${deleteResult.count} orphaned repositories`,
 | 
			
		||||
				deletedCount: deleteResult.count,
 | 
			
		||||
				deletedRepositories: orphanedRepos.map((repo) => ({
 | 
			
		||||
					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" });
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
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 { body, validationResult } = require('express-validator');
 | 
			
		||||
const { PrismaClient } = require('@prisma/client');
 | 
			
		||||
const { authenticateToken } = require('../middleware/auth');
 | 
			
		||||
const { requireManageSettings } = require('../middleware/permissions');
 | 
			
		||||
const express = require("express");
 | 
			
		||||
const { body, validationResult } = require("express-validator");
 | 
			
		||||
const { PrismaClient } = require("@prisma/client");
 | 
			
		||||
const { authenticateToken } = require("../middleware/auth");
 | 
			
		||||
const { requireManageSettings } = require("../middleware/permissions");
 | 
			
		||||
const { getSettings, updateSettings } = require("../services/settingsService");
 | 
			
		||||
 | 
			
		||||
const router = express.Router();
 | 
			
		||||
const prisma = new PrismaClient();
 | 
			
		||||
 | 
			
		||||
// Function to trigger crontab updates on all hosts with auto-update enabled
 | 
			
		||||
async function triggerCrontabUpdates() {
 | 
			
		||||
  try {
 | 
			
		||||
    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: { 
 | 
			
		||||
        autoUpdate: true,
 | 
			
		||||
        status: 'active' // Only update active hosts
 | 
			
		||||
      },
 | 
			
		||||
      select: {
 | 
			
		||||
        id: true,
 | 
			
		||||
        hostname: true,
 | 
			
		||||
        apiId: true,
 | 
			
		||||
        apiKey: true
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
    
 | 
			
		||||
    console.log(`Found ${hosts.length} hosts with auto-update enabled`);
 | 
			
		||||
    
 | 
			
		||||
    // For each host, we'll send a special update command that triggers crontab update
 | 
			
		||||
    // 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}`);
 | 
			
		||||
        
 | 
			
		||||
        // We'll use the existing ping endpoint but add a special parameter
 | 
			
		||||
        // The agent will detect this and run update-crontab command
 | 
			
		||||
        const http = require('http');
 | 
			
		||||
        const https = require('https');
 | 
			
		||||
        
 | 
			
		||||
        const serverUrl = process.env.SERVER_URL || 'http://localhost:3001';
 | 
			
		||||
        const url = new URL(`${serverUrl}/api/v1/hosts/ping`);
 | 
			
		||||
        const isHttps = url.protocol === 'https:';
 | 
			
		||||
        const client = isHttps ? https : http;
 | 
			
		||||
        
 | 
			
		||||
        const postData = JSON.stringify({
 | 
			
		||||
          triggerCrontabUpdate: true,
 | 
			
		||||
          message: 'Update interval changed, please update your crontab'
 | 
			
		||||
        });
 | 
			
		||||
        
 | 
			
		||||
        const options = {
 | 
			
		||||
          hostname: url.hostname,
 | 
			
		||||
          port: url.port || (isHttps ? 443 : 80),
 | 
			
		||||
          path: url.pathname,
 | 
			
		||||
          method: 'POST',
 | 
			
		||||
          headers: {
 | 
			
		||||
            'Content-Type': 'application/json',
 | 
			
		||||
            'Content-Length': Buffer.byteLength(postData),
 | 
			
		||||
            'X-API-ID': host.apiId,
 | 
			
		||||
            'X-API-KEY': host.apiKey
 | 
			
		||||
          }
 | 
			
		||||
        };
 | 
			
		||||
        
 | 
			
		||||
        const req = client.request(options, (res) => {
 | 
			
		||||
          if (res.statusCode === 200) {
 | 
			
		||||
            console.log(`Successfully triggered crontab update for ${host.hostname}`);
 | 
			
		||||
          } else {
 | 
			
		||||
            console.error(`Failed to trigger crontab update for ${host.hostname}: ${res.statusCode}`);
 | 
			
		||||
          }
 | 
			
		||||
        });
 | 
			
		||||
        
 | 
			
		||||
        req.on('error', (error) => {
 | 
			
		||||
          console.error(`Error triggering crontab update for ${host.hostname}:`, error.message);
 | 
			
		||||
        });
 | 
			
		||||
        
 | 
			
		||||
        req.write(postData);
 | 
			
		||||
        req.end();
 | 
			
		||||
      } catch (error) {
 | 
			
		||||
        console.error(`Error triggering crontab update for ${host.hostname}:`, error.message);
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    console.log('Crontab update trigger completed');
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.error('Error in triggerCrontabUpdates:', error);
 | 
			
		||||
  }
 | 
			
		||||
	try {
 | 
			
		||||
		console.log(
 | 
			
		||||
			"Triggering crontab updates on all hosts with auto-update enabled...",
 | 
			
		||||
		);
 | 
			
		||||
 | 
			
		||||
		// Get current settings for server URL
 | 
			
		||||
		const settings = await getSettings();
 | 
			
		||||
		const serverUrl = settings.server_url;
 | 
			
		||||
 | 
			
		||||
		// Get all hosts that have auto-update enabled
 | 
			
		||||
		const hosts = await prisma.hosts.findMany({
 | 
			
		||||
			where: {
 | 
			
		||||
				auto_update: true,
 | 
			
		||||
				status: "active", // Only update active hosts
 | 
			
		||||
			},
 | 
			
		||||
			select: {
 | 
			
		||||
				id: true,
 | 
			
		||||
				friendly_name: true,
 | 
			
		||||
				api_id: true,
 | 
			
		||||
				api_key: true,
 | 
			
		||||
			},
 | 
			
		||||
		});
 | 
			
		||||
 | 
			
		||||
		console.log(`Found ${hosts.length} hosts with auto-update enabled`);
 | 
			
		||||
 | 
			
		||||
		// For each host, we'll send a special update command that triggers crontab update
 | 
			
		||||
		// 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.friendly_name}`,
 | 
			
		||||
				);
 | 
			
		||||
 | 
			
		||||
				// We'll use the existing ping endpoint but add a special parameter
 | 
			
		||||
				// The agent will detect this and run update-crontab command
 | 
			
		||||
				const http = require("node:http");
 | 
			
		||||
				const https = require("node:https");
 | 
			
		||||
 | 
			
		||||
				const url = new URL(`${serverUrl}/api/v1/hosts/ping`);
 | 
			
		||||
				const isHttps = url.protocol === "https:";
 | 
			
		||||
				const client = isHttps ? https : http;
 | 
			
		||||
 | 
			
		||||
				const postData = JSON.stringify({
 | 
			
		||||
					triggerCrontabUpdate: true,
 | 
			
		||||
					message: "Update interval changed, please update your crontab",
 | 
			
		||||
				});
 | 
			
		||||
 | 
			
		||||
				const options = {
 | 
			
		||||
					hostname: url.hostname,
 | 
			
		||||
					port: url.port || (isHttps ? 443 : 80),
 | 
			
		||||
					path: url.pathname,
 | 
			
		||||
					method: "POST",
 | 
			
		||||
					headers: {
 | 
			
		||||
						"Content-Type": "application/json",
 | 
			
		||||
						"Content-Length": Buffer.byteLength(postData),
 | 
			
		||||
						"X-API-ID": host.api_id,
 | 
			
		||||
						"X-API-KEY": host.api_key,
 | 
			
		||||
					},
 | 
			
		||||
				};
 | 
			
		||||
 | 
			
		||||
				const req = client.request(options, (res) => {
 | 
			
		||||
					if (res.statusCode === 200) {
 | 
			
		||||
						console.log(
 | 
			
		||||
							`Successfully triggered crontab update for ${host.friendly_name}`,
 | 
			
		||||
						);
 | 
			
		||||
					} else {
 | 
			
		||||
						console.error(
 | 
			
		||||
							`Failed to trigger crontab update for ${host.friendly_name}: ${res.statusCode}`,
 | 
			
		||||
						);
 | 
			
		||||
					}
 | 
			
		||||
				});
 | 
			
		||||
 | 
			
		||||
				req.on("error", (error) => {
 | 
			
		||||
					console.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
 | 
			
		||||
router.get('/', authenticateToken, requireManageSettings, async (req, res) => {
 | 
			
		||||
  try {
 | 
			
		||||
    let settings = await prisma.settings.findFirst();
 | 
			
		||||
    
 | 
			
		||||
    // If no settings exist, create default settings
 | 
			
		||||
    if (!settings) {
 | 
			
		||||
      settings = await prisma.settings.create({
 | 
			
		||||
        data: {
 | 
			
		||||
          serverUrl: 'http://localhost:3001',
 | 
			
		||||
          serverProtocol: 'http',
 | 
			
		||||
          serverHost: 'localhost',
 | 
			
		||||
          serverPort: 3001,
 | 
			
		||||
          frontendUrl: 'http://localhost:3000',
 | 
			
		||||
          updateInterval: 60,
 | 
			
		||||
          autoUpdate: false
 | 
			
		||||
        }
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    console.log('Returning settings:', settings);
 | 
			
		||||
    res.json(settings);
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.error('Settings fetch error:', error);
 | 
			
		||||
    res.status(500).json({ error: 'Failed to fetch settings' });
 | 
			
		||||
  }
 | 
			
		||||
router.get("/", authenticateToken, requireManageSettings, async (_req, res) => {
 | 
			
		||||
	try {
 | 
			
		||||
		const settings = await getSettings();
 | 
			
		||||
		if (process.env.ENABLE_LOGGING === "true") {
 | 
			
		||||
			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
 | 
			
		||||
router.put('/', authenticateToken, requireManageSettings, [
 | 
			
		||||
  body('serverProtocol').isIn(['http', 'https']).withMessage('Protocol must be http or https'),
 | 
			
		||||
  body('serverHost').isLength({ min: 1 }).withMessage('Server host is required'),
 | 
			
		||||
  body('serverPort').isInt({ min: 1, max: 65535 }).withMessage('Port must be between 1 and 65535'),
 | 
			
		||||
  body('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('autoUpdate').isBoolean().withMessage('Auto update must be a boolean'),
 | 
			
		||||
  body('githubRepoUrl').optional().isLength({ min: 1 }).withMessage('GitHub repo URL must be a non-empty string'),
 | 
			
		||||
  body('sshKeyPath').optional().custom((value) => {
 | 
			
		||||
    if (value && value.trim().length === 0) {
 | 
			
		||||
      return true; // Allow empty string
 | 
			
		||||
    }
 | 
			
		||||
    if (value && value.trim().length < 1) {
 | 
			
		||||
      throw new Error('SSH key path must be a non-empty string');
 | 
			
		||||
    }
 | 
			
		||||
    return true;
 | 
			
		||||
  })
 | 
			
		||||
], async (req, res) => {
 | 
			
		||||
  try {
 | 
			
		||||
    console.log('Settings update request body:', req.body);
 | 
			
		||||
    const errors = validationResult(req);
 | 
			
		||||
    if (!errors.isEmpty()) {
 | 
			
		||||
      console.log('Validation errors:', errors.array());
 | 
			
		||||
      return res.status(400).json({ errors: errors.array() });
 | 
			
		||||
    }
 | 
			
		||||
router.put(
 | 
			
		||||
	"/",
 | 
			
		||||
	authenticateToken,
 | 
			
		||||
	requireManageSettings,
 | 
			
		||||
	[
 | 
			
		||||
		body("serverProtocol")
 | 
			
		||||
			.optional()
 | 
			
		||||
			.isIn(["http", "https"])
 | 
			
		||||
			.withMessage("Protocol must be http or https"),
 | 
			
		||||
		body("serverHost")
 | 
			
		||||
			.optional()
 | 
			
		||||
			.isLength({ min: 1 })
 | 
			
		||||
			.withMessage("Server host is required"),
 | 
			
		||||
		body("serverPort")
 | 
			
		||||
			.optional()
 | 
			
		||||
			.isInt({ min: 1, max: 65535 })
 | 
			
		||||
			.withMessage("Port must be between 1 and 65535"),
 | 
			
		||||
		body("updateInterval")
 | 
			
		||||
			.optional()
 | 
			
		||||
			.isInt({ min: 5, max: 1440 })
 | 
			
		||||
			.withMessage("Update interval must be between 5 and 1440 minutes"),
 | 
			
		||||
		body("autoUpdate")
 | 
			
		||||
			.optional()
 | 
			
		||||
			.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;
 | 
			
		||||
    console.log('Extracted values:', { serverProtocol, serverHost, serverPort, frontendUrl, updateInterval, autoUpdate, githubRepoUrl, sshKeyPath });
 | 
			
		||||
    
 | 
			
		||||
    // Construct server URL from components
 | 
			
		||||
    const serverUrl = `${serverProtocol}://${serverHost}:${serverPort}`;
 | 
			
		||||
    
 | 
			
		||||
    let settings = await prisma.settings.findFirst();
 | 
			
		||||
    
 | 
			
		||||
    if (settings) {
 | 
			
		||||
      // Update existing settings
 | 
			
		||||
      console.log('Updating existing settings with data:', {
 | 
			
		||||
        serverUrl,
 | 
			
		||||
        serverProtocol,
 | 
			
		||||
        serverHost,
 | 
			
		||||
        serverPort,
 | 
			
		||||
        frontendUrl,
 | 
			
		||||
        updateInterval: updateInterval || 60,
 | 
			
		||||
        autoUpdate: autoUpdate || false,
 | 
			
		||||
        githubRepoUrl: githubRepoUrl || 'git@github.com:9technologygroup/patchmon.net.git'
 | 
			
		||||
      });
 | 
			
		||||
      const oldUpdateInterval = settings.updateInterval;
 | 
			
		||||
      
 | 
			
		||||
      settings = await prisma.settings.update({
 | 
			
		||||
        where: { id: settings.id },
 | 
			
		||||
        data: {
 | 
			
		||||
          serverUrl,
 | 
			
		||||
          serverProtocol,
 | 
			
		||||
          serverHost,
 | 
			
		||||
          serverPort,
 | 
			
		||||
          frontendUrl,
 | 
			
		||||
          updateInterval: updateInterval || 60,
 | 
			
		||||
          autoUpdate: autoUpdate || false,
 | 
			
		||||
          githubRepoUrl: githubRepoUrl || 'git@github.com:9technologygroup/patchmon.net.git',
 | 
			
		||||
          sshKeyPath: sshKeyPath || null
 | 
			
		||||
        }
 | 
			
		||||
      });
 | 
			
		||||
      console.log('Settings updated successfully:', settings);
 | 
			
		||||
      
 | 
			
		||||
      // If update interval changed, trigger crontab updates on all hosts with auto-update enabled
 | 
			
		||||
      if (oldUpdateInterval !== (updateInterval || 60)) {
 | 
			
		||||
        console.log(`Update interval changed from ${oldUpdateInterval} to ${updateInterval || 60} minutes. Triggering crontab updates...`);
 | 
			
		||||
        await triggerCrontabUpdates();
 | 
			
		||||
      }
 | 
			
		||||
    } else {
 | 
			
		||||
      // Create new settings
 | 
			
		||||
      settings = await prisma.settings.create({
 | 
			
		||||
        data: {
 | 
			
		||||
          serverUrl,
 | 
			
		||||
          serverProtocol,
 | 
			
		||||
          serverHost,
 | 
			
		||||
          serverPort,
 | 
			
		||||
          frontendUrl,
 | 
			
		||||
          updateInterval: updateInterval || 60,
 | 
			
		||||
          autoUpdate: autoUpdate || false,
 | 
			
		||||
          githubRepoUrl: githubRepoUrl || 'git@github.com:9technologygroup/patchmon.net.git',
 | 
			
		||||
          sshKeyPath: sshKeyPath || null
 | 
			
		||||
        }
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    res.json({
 | 
			
		||||
      message: 'Settings updated successfully',
 | 
			
		||||
      settings
 | 
			
		||||
    });
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.error('Settings update error:', error);
 | 
			
		||||
    res.status(500).json({ error: 'Failed to update settings' });
 | 
			
		||||
  }
 | 
			
		||||
});
 | 
			
		||||
			const {
 | 
			
		||||
				serverProtocol,
 | 
			
		||||
				serverHost,
 | 
			
		||||
				serverPort,
 | 
			
		||||
				updateInterval,
 | 
			
		||||
				autoUpdate,
 | 
			
		||||
				ignoreSslSelfSigned,
 | 
			
		||||
				signupEnabled,
 | 
			
		||||
				defaultUserRole,
 | 
			
		||||
				githubRepoUrl,
 | 
			
		||||
				repositoryType,
 | 
			
		||||
				sshKeyPath,
 | 
			
		||||
				logoDark,
 | 
			
		||||
				logoLight,
 | 
			
		||||
				favicon,
 | 
			
		||||
			} = req.body;
 | 
			
		||||
 | 
			
		||||
			// Get current settings to check for update interval changes
 | 
			
		||||
			const currentSettings = await getSettings();
 | 
			
		||||
			const oldUpdateInterval = currentSettings.update_interval;
 | 
			
		||||
 | 
			
		||||
			// Build update object with only provided fields
 | 
			
		||||
			const updateData = {};
 | 
			
		||||
 | 
			
		||||
			if (serverProtocol !== undefined)
 | 
			
		||||
				updateData.server_protocol = serverProtocol;
 | 
			
		||||
			if (serverHost !== undefined) updateData.server_host = serverHost;
 | 
			
		||||
			if (serverPort !== undefined) updateData.server_port = serverPort;
 | 
			
		||||
			if (updateInterval !== undefined) {
 | 
			
		||||
				updateData.update_interval = normalizeUpdateInterval(updateInterval);
 | 
			
		||||
			}
 | 
			
		||||
			if (autoUpdate !== undefined) updateData.auto_update = autoUpdate;
 | 
			
		||||
			if (ignoreSslSelfSigned !== undefined)
 | 
			
		||||
				updateData.ignore_ssl_self_signed = ignoreSslSelfSigned;
 | 
			
		||||
			if (signupEnabled !== undefined)
 | 
			
		||||
				updateData.signup_enabled = signupEnabled;
 | 
			
		||||
			if (defaultUserRole !== undefined)
 | 
			
		||||
				updateData.default_user_role = defaultUserRole;
 | 
			
		||||
			if (githubRepoUrl !== undefined)
 | 
			
		||||
				updateData.github_repo_url = githubRepoUrl;
 | 
			
		||||
			if (repositoryType !== undefined)
 | 
			
		||||
				updateData.repository_type = repositoryType;
 | 
			
		||||
			if (sshKeyPath !== undefined) updateData.ssh_key_path = sshKeyPath;
 | 
			
		||||
			if (logoDark !== undefined) updateData.logo_dark = logoDark;
 | 
			
		||||
			if (logoLight !== undefined) updateData.logo_light = logoLight;
 | 
			
		||||
			if (favicon !== undefined) updateData.favicon = favicon;
 | 
			
		||||
 | 
			
		||||
			const updatedSettings = await updateSettings(
 | 
			
		||||
				currentSettings.id,
 | 
			
		||||
				updateData,
 | 
			
		||||
			);
 | 
			
		||||
 | 
			
		||||
			console.log("Settings updated successfully:", updatedSettings);
 | 
			
		||||
 | 
			
		||||
			// If update interval changed, trigger crontab updates on all hosts with auto-update enabled
 | 
			
		||||
			if (
 | 
			
		||||
				updateInterval !== undefined &&
 | 
			
		||||
				oldUpdateInterval !== updateData.update_interval
 | 
			
		||||
			) {
 | 
			
		||||
				console.log(
 | 
			
		||||
					`Update interval changed from ${oldUpdateInterval} to ${updateData.update_interval} minutes. Triggering crontab updates...`,
 | 
			
		||||
				);
 | 
			
		||||
				await triggerCrontabUpdates();
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			res.json({
 | 
			
		||||
				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)
 | 
			
		||||
router.get('/server-url', async (req, res) => {
 | 
			
		||||
  try {
 | 
			
		||||
    const settings = await prisma.settings.findFirst();
 | 
			
		||||
    
 | 
			
		||||
    if (!settings) {
 | 
			
		||||
      return res.json({ serverUrl: 'http://localhost:3001' });
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    res.json({ serverUrl: settings.serverUrl });
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.error('Server URL fetch error:', error);
 | 
			
		||||
    res.json({ serverUrl: 'http://localhost:3001' });
 | 
			
		||||
  }
 | 
			
		||||
router.get("/server-url", async (_req, res) => {
 | 
			
		||||
	try {
 | 
			
		||||
		const settings = await getSettings();
 | 
			
		||||
		const serverUrl = settings.server_url;
 | 
			
		||||
		res.json({ server_url: serverUrl });
 | 
			
		||||
	} catch (error) {
 | 
			
		||||
		console.error("Server URL fetch error:", error);
 | 
			
		||||
		res.status(500).json({ error: "Failed to fetch server URL" });
 | 
			
		||||
	}
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
// Get update interval policy for agents (public endpoint)
 | 
			
		||||
router.get('/update-interval', async (req, res) => {
 | 
			
		||||
  try {
 | 
			
		||||
    const settings = await prisma.settings.findFirst();
 | 
			
		||||
    
 | 
			
		||||
    if (!settings) {
 | 
			
		||||
      return res.json({ updateInterval: 60 });
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    res.json({ 
 | 
			
		||||
      updateInterval: settings.updateInterval,
 | 
			
		||||
      cronExpression: `*/${settings.updateInterval} * * * *` // Generate cron expression
 | 
			
		||||
    });
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.error('Update interval fetch error:', error);
 | 
			
		||||
    res.json({ updateInterval: 60, cronExpression: '0 * * * *' });
 | 
			
		||||
  }
 | 
			
		||||
// Get update interval policy for agents (requires API authentication)
 | 
			
		||||
router.get("/update-interval", async (req, res) => {
 | 
			
		||||
	try {
 | 
			
		||||
		// Verify API credentials
 | 
			
		||||
		const apiId = req.headers["x-api-id"];
 | 
			
		||||
		const apiKey = req.headers["x-api-key"];
 | 
			
		||||
 | 
			
		||||
		if (!apiId || !apiKey) {
 | 
			
		||||
			return res.status(401).json({ error: "API credentials required" });
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Validate API credentials
 | 
			
		||||
		const host = await prisma.hosts.findUnique({
 | 
			
		||||
			where: { api_id: apiId },
 | 
			
		||||
		});
 | 
			
		||||
 | 
			
		||||
		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)
 | 
			
		||||
router.get('/auto-update', async (req, res) => {
 | 
			
		||||
  try {
 | 
			
		||||
    const settings = await prisma.settings.findFirst();
 | 
			
		||||
    
 | 
			
		||||
    if (!settings) {
 | 
			
		||||
      return res.json({ autoUpdate: false });
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    res.json({ 
 | 
			
		||||
      autoUpdate: settings.autoUpdate || false
 | 
			
		||||
    });
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.error('Auto-update fetch error:', error);
 | 
			
		||||
    res.json({ autoUpdate: false });
 | 
			
		||||
  }
 | 
			
		||||
router.get("/auto-update", async (_req, res) => {
 | 
			
		||||
	try {
 | 
			
		||||
		const settings = await getSettings();
 | 
			
		||||
		res.json({
 | 
			
		||||
			autoUpdate: settings.auto_update || 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;
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										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 { authenticateToken } = require('../middleware/auth');
 | 
			
		||||
const { requireManageSettings } = require('../middleware/permissions');
 | 
			
		||||
const { PrismaClient } = require('@prisma/client');
 | 
			
		||||
const { exec } = require('child_process');
 | 
			
		||||
const { promisify } = require('util');
 | 
			
		||||
const express = require("express");
 | 
			
		||||
const { authenticateToken } = require("../middleware/auth");
 | 
			
		||||
const { requireManageSettings } = require("../middleware/permissions");
 | 
			
		||||
const { PrismaClient } = require("@prisma/client");
 | 
			
		||||
 | 
			
		||||
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();
 | 
			
		||||
 | 
			
		||||
// 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
 | 
			
		||||
router.get('/current', authenticateToken, async (req, res) => {
 | 
			
		||||
  try {
 | 
			
		||||
    // For now, return hardcoded version - this should match your agent version
 | 
			
		||||
    const currentVersion = '1.2.4';
 | 
			
		||||
    
 | 
			
		||||
    res.json({
 | 
			
		||||
      version: currentVersion,
 | 
			
		||||
      buildDate: new Date().toISOString(),
 | 
			
		||||
      environment: process.env.NODE_ENV || 'development'
 | 
			
		||||
    });
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.error('Error getting current version:', error);
 | 
			
		||||
    res.status(500).json({ error: 'Failed to get current version' });
 | 
			
		||||
  }
 | 
			
		||||
router.get("/current", authenticateToken, async (_req, res) => {
 | 
			
		||||
	try {
 | 
			
		||||
		const currentVersion = getCurrentVersion();
 | 
			
		||||
 | 
			
		||||
		// Get settings with cached update info (no GitHub API calls)
 | 
			
		||||
		const settings = await prisma.settings.findFirst();
 | 
			
		||||
		const githubRepoUrl = settings?.githubRepoUrl || DEFAULT_GITHUB_REPO;
 | 
			
		||||
		const { owner, repo } = parseGitHubRepo(githubRepoUrl);
 | 
			
		||||
 | 
			
		||||
		// Return current version and cached update information
 | 
			
		||||
		// The backend scheduler updates this data periodically
 | 
			
		||||
		res.json({
 | 
			
		||||
			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
 | 
			
		||||
router.post('/test-ssh-key', authenticateToken, requireManageSettings, async (req, res) => {
 | 
			
		||||
  try {
 | 
			
		||||
    const { sshKeyPath, githubRepoUrl } = req.body;
 | 
			
		||||
    
 | 
			
		||||
    if (!sshKeyPath || !githubRepoUrl) {
 | 
			
		||||
      return res.status(400).json({ 
 | 
			
		||||
        error: 'SSH key path and GitHub repo URL are required' 
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    // Parse repository info
 | 
			
		||||
    let owner, repo;
 | 
			
		||||
    if (githubRepoUrl.includes('git@github.com:')) {
 | 
			
		||||
      const match = githubRepoUrl.match(/git@github\.com:([^\/]+)\/([^\/]+)\.git/);
 | 
			
		||||
      if (match) {
 | 
			
		||||
        [, owner, repo] = match;
 | 
			
		||||
      }
 | 
			
		||||
    } else if (githubRepoUrl.includes('github.com/')) {
 | 
			
		||||
      const match = githubRepoUrl.match(/github\.com\/([^\/]+)\/([^\/]+)/);
 | 
			
		||||
      if (match) {
 | 
			
		||||
        [, owner, repo] = match;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    if (!owner || !repo) {
 | 
			
		||||
      return res.status(400).json({ 
 | 
			
		||||
        error: 'Invalid GitHub repository URL format' 
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    // Check if SSH key file exists and is readable
 | 
			
		||||
    try {
 | 
			
		||||
      require('fs').accessSync(sshKeyPath);
 | 
			
		||||
    } catch (e) {
 | 
			
		||||
      return res.status(400).json({
 | 
			
		||||
        error: 'SSH key file not found or not accessible',
 | 
			
		||||
        details: `Cannot access: ${sshKeyPath}`,
 | 
			
		||||
        suggestion: 'Check the file path and ensure the application has read permissions'
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    // Test SSH connection to GitHub
 | 
			
		||||
    const sshRepoUrl = `git@github.com:${owner}/${repo}.git`;
 | 
			
		||||
    const env = {
 | 
			
		||||
      ...process.env,
 | 
			
		||||
      GIT_SSH_COMMAND: `ssh -i ${sshKeyPath} -o StrictHostKeyChecking=no -o IdentitiesOnly=yes -o ConnectTimeout=10`
 | 
			
		||||
    };
 | 
			
		||||
    
 | 
			
		||||
    try {
 | 
			
		||||
      // Test with a simple git command
 | 
			
		||||
      const { stdout } = await execAsync(
 | 
			
		||||
        `git ls-remote --heads ${sshRepoUrl} | head -n 1`,
 | 
			
		||||
        { 
 | 
			
		||||
          timeout: 15000,
 | 
			
		||||
          env: env
 | 
			
		||||
        }
 | 
			
		||||
      );
 | 
			
		||||
      
 | 
			
		||||
      if (stdout.trim()) {
 | 
			
		||||
        return res.json({
 | 
			
		||||
          success: true,
 | 
			
		||||
          message: 'SSH key is working correctly',
 | 
			
		||||
          details: {
 | 
			
		||||
            sshKeyPath,
 | 
			
		||||
            repository: `${owner}/${repo}`,
 | 
			
		||||
            testResult: 'Successfully connected to GitHub'
 | 
			
		||||
          }
 | 
			
		||||
        });
 | 
			
		||||
      } else {
 | 
			
		||||
        return res.status(400).json({
 | 
			
		||||
          error: 'SSH connection succeeded but no data returned',
 | 
			
		||||
          suggestion: 'Check repository access permissions'
 | 
			
		||||
        });
 | 
			
		||||
      }
 | 
			
		||||
    } catch (sshError) {
 | 
			
		||||
      console.error('SSH test error:', sshError.message);
 | 
			
		||||
      
 | 
			
		||||
      if (sshError.message.includes('Permission denied')) {
 | 
			
		||||
        return res.status(403).json({
 | 
			
		||||
          error: 'SSH key permission denied',
 | 
			
		||||
          details: 'The SSH key exists but GitHub rejected the connection',
 | 
			
		||||
          suggestion: 'Verify the SSH key is added to the repository as a deploy key with read access'
 | 
			
		||||
        });
 | 
			
		||||
      } else if (sshError.message.includes('Host key verification failed')) {
 | 
			
		||||
        return res.status(403).json({
 | 
			
		||||
          error: 'Host key verification failed',
 | 
			
		||||
          suggestion: 'This is normal for first-time connections. The key will be added to known_hosts automatically.'
 | 
			
		||||
        });
 | 
			
		||||
      } else if (sshError.message.includes('Connection timed out')) {
 | 
			
		||||
        return res.status(408).json({
 | 
			
		||||
          error: 'Connection timed out',
 | 
			
		||||
          suggestion: 'Check your internet connection and GitHub status'
 | 
			
		||||
        });
 | 
			
		||||
      } else {
 | 
			
		||||
        return res.status(500).json({
 | 
			
		||||
          error: 'SSH connection failed',
 | 
			
		||||
          details: sshError.message,
 | 
			
		||||
          suggestion: 'Check the SSH key format and repository URL'
 | 
			
		||||
        });
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.error('SSH key test error:', error);
 | 
			
		||||
    res.status(500).json({
 | 
			
		||||
      error: 'Failed to test SSH key',
 | 
			
		||||
      details: error.message
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
});
 | 
			
		||||
router.post(
 | 
			
		||||
	"/test-ssh-key",
 | 
			
		||||
	authenticateToken,
 | 
			
		||||
	requireManageSettings,
 | 
			
		||||
	async (_req, res) => {
 | 
			
		||||
		res.status(410).json({
 | 
			
		||||
			error:
 | 
			
		||||
				"SSH key testing has been removed. Using default public repository.",
 | 
			
		||||
		});
 | 
			
		||||
	},
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
// Check for updates from GitHub
 | 
			
		||||
router.get('/check-updates', authenticateToken, requireManageSettings, async (req, res) => {
 | 
			
		||||
  try {
 | 
			
		||||
    // Get GitHub repo URL from settings
 | 
			
		||||
    const settings = await prisma.settings.findFirst();
 | 
			
		||||
    if (!settings || !settings.githubRepoUrl) {
 | 
			
		||||
      return res.status(400).json({ error: 'GitHub repository URL not configured' });
 | 
			
		||||
    }
 | 
			
		||||
router.get(
 | 
			
		||||
	"/check-updates",
 | 
			
		||||
	authenticateToken,
 | 
			
		||||
	requireManageSettings,
 | 
			
		||||
	async (_req, res) => {
 | 
			
		||||
		try {
 | 
			
		||||
			// Get cached update information from settings
 | 
			
		||||
			const settings = await prisma.settings.findFirst();
 | 
			
		||||
 | 
			
		||||
    // Extract owner and repo from GitHub URL
 | 
			
		||||
    // Support both SSH and HTTPS formats:
 | 
			
		||||
    // 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 (!settings) {
 | 
			
		||||
				return res.status(400).json({ error: "Settings not found" });
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
    if (!owner || !repo) {
 | 
			
		||||
      return res.status(400).json({ error: 'Invalid GitHub repository URL format' });
 | 
			
		||||
    }
 | 
			
		||||
			const currentVersion = getCurrentVersion();
 | 
			
		||||
			const githubRepoUrl = settings.githubRepoUrl || DEFAULT_GITHUB_REPO;
 | 
			
		||||
			const { owner, repo } = parseGitHubRepo(githubRepoUrl);
 | 
			
		||||
 | 
			
		||||
    // Use SSH with deploy keys (secure approach)
 | 
			
		||||
    const sshRepoUrl = `git@github.com:${owner}/${repo}.git`;
 | 
			
		||||
    
 | 
			
		||||
    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`
 | 
			
		||||
      };
 | 
			
		||||
			let latestRelease = null;
 | 
			
		||||
			let latestCommit = null;
 | 
			
		||||
			let commitDifference = null;
 | 
			
		||||
 | 
			
		||||
      // Fetch the latest tag using SSH with deploy key
 | 
			
		||||
      const { stdout: latestTag } = await execAsync(
 | 
			
		||||
        `git ls-remote --tags --sort=-version:refname ${sshRepoUrl} | head -n 1 | sed 's/.*refs\\/tags\\///' | sed 's/\\^{}//'`,
 | 
			
		||||
        { 
 | 
			
		||||
          timeout: 10000,
 | 
			
		||||
          env: env
 | 
			
		||||
        }
 | 
			
		||||
      );
 | 
			
		||||
			// Fetch fresh GitHub data if we have valid owner/repo
 | 
			
		||||
			if (owner && repo) {
 | 
			
		||||
				try {
 | 
			
		||||
					const [releaseData, commitData, differenceData] = await Promise.all([
 | 
			
		||||
						getLatestRelease(owner, repo),
 | 
			
		||||
						getLatestCommit(owner, repo),
 | 
			
		||||
						getCommitDifference(owner, repo, currentVersion),
 | 
			
		||||
					]);
 | 
			
		||||
 | 
			
		||||
      const latestVersion = latestTag.trim().replace('v', ''); // Remove 'v' prefix
 | 
			
		||||
      const currentVersion = '1.2.4';
 | 
			
		||||
					latestRelease = releaseData;
 | 
			
		||||
					latestCommit = commitData;
 | 
			
		||||
					commitDifference = differenceData;
 | 
			
		||||
				} catch (githubError) {
 | 
			
		||||
					console.warn(
 | 
			
		||||
						"Failed to fetch fresh GitHub data:",
 | 
			
		||||
						githubError.message,
 | 
			
		||||
					);
 | 
			
		||||
 | 
			
		||||
      // Simple version comparison (assumes semantic versioning)
 | 
			
		||||
      const isUpdateAvailable = compareVersions(latestVersion, currentVersion) > 0;
 | 
			
		||||
					// Provide fallback data when GitHub API is rate-limited
 | 
			
		||||
					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({
 | 
			
		||||
        currentVersion,
 | 
			
		||||
        latestVersion,
 | 
			
		||||
        isUpdateAvailable,
 | 
			
		||||
        latestRelease: {
 | 
			
		||||
          tagName: latestTag.trim(),
 | 
			
		||||
          version: latestVersion,
 | 
			
		||||
          repository: `${owner}/${repo}`,
 | 
			
		||||
          sshUrl: sshRepoUrl,
 | 
			
		||||
          sshKeyUsed: sshKeyPath
 | 
			
		||||
        }
 | 
			
		||||
      });
 | 
			
		||||
			const latestVersion =
 | 
			
		||||
				latestRelease?.version || settings.latest_version || currentVersion;
 | 
			
		||||
			const isUpdateAvailable = latestRelease
 | 
			
		||||
				? compareVersions(latestVersion, currentVersion) > 0
 | 
			
		||||
				: settings.update_available || false;
 | 
			
		||||
 | 
			
		||||
    } catch (sshError) {
 | 
			
		||||
      console.error('SSH Git error:', sshError.message);
 | 
			
		||||
 | 
			
		||||
      if (sshError.message.includes('Permission denied') || sshError.message.includes('Host key verification failed')) {
 | 
			
		||||
        return res.status(403).json({
 | 
			
		||||
          error: 'SSH access denied to repository',
 | 
			
		||||
          suggestion: 'Ensure your deploy key is properly configured and has access to the repository. Check that the key has read access to the repository.'
 | 
			
		||||
        });
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      if (sshError.message.includes('not found') || sshError.message.includes('does not exist')) {
 | 
			
		||||
        return res.status(404).json({
 | 
			
		||||
          error: 'Repository not found',
 | 
			
		||||
          suggestion: 'Check that the repository URL is correct and accessible with the deploy key.'
 | 
			
		||||
        });
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      if (sshError.message.includes('No SSH deploy key found')) {
 | 
			
		||||
        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;
 | 
			
		||||
}
 | 
			
		||||
			res.json({
 | 
			
		||||
				currentVersion,
 | 
			
		||||
				latestVersion,
 | 
			
		||||
				isUpdateAvailable,
 | 
			
		||||
				lastUpdateCheck: settings.last_update_check || null,
 | 
			
		||||
				repositoryType: settings.repository_type || "public",
 | 
			
		||||
				github: {
 | 
			
		||||
					repository: githubRepoUrl,
 | 
			
		||||
					owner: owner,
 | 
			
		||||
					repo: repo,
 | 
			
		||||
					latestRelease: latestRelease,
 | 
			
		||||
					latestCommit: latestCommit,
 | 
			
		||||
					commitDifference: commitDifference,
 | 
			
		||||
				},
 | 
			
		||||
			});
 | 
			
		||||
		} catch (error) {
 | 
			
		||||
			console.error("Error getting update information:", error);
 | 
			
		||||
			res.status(500).json({ error: "Failed to get update information" });
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
module.exports = router;
 | 
			
		||||
 
 | 
			
		||||
@@ -1,49 +1,252 @@
 | 
			
		||||
require('dotenv').config();
 | 
			
		||||
const express = require('express');
 | 
			
		||||
const cors = require('cors');
 | 
			
		||||
const helmet = require('helmet');
 | 
			
		||||
const rateLimit = require('express-rate-limit');
 | 
			
		||||
const { PrismaClient } = require('@prisma/client');
 | 
			
		||||
const winston = require('winston');
 | 
			
		||||
require("dotenv").config();
 | 
			
		||||
 | 
			
		||||
// Validate required environment variables on startup
 | 
			
		||||
function validateEnvironmentVariables() {
 | 
			
		||||
	const requiredVars = {
 | 
			
		||||
		JWT_SECRET: "Required for secure authentication token generation",
 | 
			
		||||
		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
 | 
			
		||||
const authRoutes = require('./routes/authRoutes');
 | 
			
		||||
const hostRoutes = require('./routes/hostRoutes');
 | 
			
		||||
const hostGroupRoutes = require('./routes/hostGroupRoutes');
 | 
			
		||||
const packageRoutes = require('./routes/packageRoutes');
 | 
			
		||||
const dashboardRoutes = require('./routes/dashboardRoutes');
 | 
			
		||||
const permissionsRoutes = require('./routes/permissionsRoutes');
 | 
			
		||||
const settingsRoutes = require('./routes/settingsRoutes');
 | 
			
		||||
const dashboardPreferencesRoutes = require('./routes/dashboardPreferencesRoutes');
 | 
			
		||||
const repositoryRoutes = require('./routes/repositoryRoutes');
 | 
			
		||||
const versionRoutes = require('./routes/versionRoutes');
 | 
			
		||||
const authRoutes = require("./routes/authRoutes");
 | 
			
		||||
const hostRoutes = require("./routes/hostRoutes");
 | 
			
		||||
const hostGroupRoutes = require("./routes/hostGroupRoutes");
 | 
			
		||||
const packageRoutes = require("./routes/packageRoutes");
 | 
			
		||||
const dashboardRoutes = require("./routes/dashboardRoutes");
 | 
			
		||||
const permissionsRoutes = require("./routes/permissionsRoutes");
 | 
			
		||||
const settingsRoutes = require("./routes/settingsRoutes");
 | 
			
		||||
const {
 | 
			
		||||
	router: dashboardPreferencesRoutes,
 | 
			
		||||
} = 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
 | 
			
		||||
const prisma = new PrismaClient();
 | 
			
		||||
// Initialize Prisma client with optimized connection pooling for multiple instances
 | 
			
		||||
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
 | 
			
		||||
const logger = process.env.ENABLE_LOGGING === 'true' ? winston.createLogger({
 | 
			
		||||
  level: process.env.LOG_LEVEL || 'info',
 | 
			
		||||
  format: winston.format.combine(
 | 
			
		||||
    winston.format.timestamp(),
 | 
			
		||||
    winston.format.errors({ stack: true }),
 | 
			
		||||
    winston.format.json()
 | 
			
		||||
  ),
 | 
			
		||||
  transports: [
 | 
			
		||||
    new winston.transports.File({ filename: 'logs/error.log', level: 'error' }),
 | 
			
		||||
    new winston.transports.File({ filename: 'logs/combined.log' }),
 | 
			
		||||
  ],
 | 
			
		||||
}) : {
 | 
			
		||||
  info: () => {},
 | 
			
		||||
  error: () => {},
 | 
			
		||||
  warn: () => {},
 | 
			
		||||
  debug: () => {}
 | 
			
		||||
};
 | 
			
		||||
const logger =
 | 
			
		||||
	process.env.ENABLE_LOGGING === "true"
 | 
			
		||||
		? winston.createLogger({
 | 
			
		||||
				level: process.env.LOG_LEVEL || "info",
 | 
			
		||||
				format: winston.format.combine(
 | 
			
		||||
					winston.format.timestamp(),
 | 
			
		||||
					winston.format.errors({ stack: true }),
 | 
			
		||||
					winston.format.json(),
 | 
			
		||||
				),
 | 
			
		||||
				transports: [],
 | 
			
		||||
			})
 | 
			
		||||
		: {
 | 
			
		||||
				info: () => {},
 | 
			
		||||
				error: () => {},
 | 
			
		||||
				warn: () => {},
 | 
			
		||||
				debug: () => {},
 | 
			
		||||
			};
 | 
			
		||||
 | 
			
		||||
if (process.env.ENABLE_LOGGING === 'true' && process.env.NODE_ENV !== 'production') {
 | 
			
		||||
  logger.add(new winston.transports.Console({
 | 
			
		||||
    format: winston.format.simple()
 | 
			
		||||
  }));
 | 
			
		||||
// Configure transports based on PM_LOG_TO_CONSOLE environment variable
 | 
			
		||||
if (process.env.ENABLE_LOGGING === "true") {
 | 
			
		||||
	const logToConsole =
 | 
			
		||||
		process.env.PM_LOG_TO_CONSOLE === "1" ||
 | 
			
		||||
		process.env.PM_LOG_TO_CONSOLE === "true";
 | 
			
		||||
 | 
			
		||||
	if (logToConsole) {
 | 
			
		||||
		// Log to stdout/stderr instead of files
 | 
			
		||||
		logger.add(
 | 
			
		||||
			new winston.transports.Console({
 | 
			
		||||
				format: winston.format.combine(
 | 
			
		||||
					winston.format.timestamp(),
 | 
			
		||||
					winston.format.errors({ stack: true }),
 | 
			
		||||
					winston.format.printf(({ timestamp, level, message, stack }) => {
 | 
			
		||||
						return `${timestamp} [${level.toUpperCase()}]: ${stack || message}`;
 | 
			
		||||
					}),
 | 
			
		||||
				),
 | 
			
		||||
				stderrLevels: ["error", "warn"],
 | 
			
		||||
			}),
 | 
			
		||||
		);
 | 
			
		||||
	} else {
 | 
			
		||||
		// Log to files (default behavior)
 | 
			
		||||
		logger.add(
 | 
			
		||||
			new winston.transports.File({
 | 
			
		||||
				filename: "logs/error.log",
 | 
			
		||||
				level: "error",
 | 
			
		||||
			}),
 | 
			
		||||
		);
 | 
			
		||||
		logger.add(new winston.transports.File({ filename: "logs/combined.log" }));
 | 
			
		||||
 | 
			
		||||
		// Also add console logging for non-production environments
 | 
			
		||||
		if (process.env.NODE_ENV !== "production") {
 | 
			
		||||
			logger.add(
 | 
			
		||||
				new winston.transports.Console({
 | 
			
		||||
					format: winston.format.simple(),
 | 
			
		||||
				}),
 | 
			
		||||
			);
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
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
 | 
			
		||||
if (process.env.TRUST_PROXY) {
 | 
			
		||||
  app.set('trust proxy', process.env.TRUST_PROXY === 'true' ? 1 : parseInt(process.env.TRUST_PROXY, 10) || true);
 | 
			
		||||
} else {
 | 
			
		||||
  app.set('trust proxy', 1);
 | 
			
		||||
}
 | 
			
		||||
app.disable('x-powered-by');
 | 
			
		||||
	const trustProxyValue = process.env.TRUST_PROXY;
 | 
			
		||||
 | 
			
		||||
// 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({
 | 
			
		||||
  windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS) || 15 * 60 * 1000,
 | 
			
		||||
  max: parseInt(process.env.RATE_LIMIT_MAX) || 100,
 | 
			
		||||
  message: 'Too many requests from this IP, please try again later.',
 | 
			
		||||
	windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS, 10) || 15 * 60 * 1000,
 | 
			
		||||
	max: parseInt(process.env.RATE_LIMIT_MAX, 10) || 100,
 | 
			
		||||
	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
 | 
			
		||||
// Helmet with stricter defaults (CSP/HSTS only in production)
 | 
			
		||||
app.use(helmet({
 | 
			
		||||
  contentSecurityPolicy: process.env.NODE_ENV === 'production' ? {
 | 
			
		||||
    useDefaults: true,
 | 
			
		||||
    directives: {
 | 
			
		||||
      defaultSrc: ["'self'"],
 | 
			
		||||
      scriptSrc: ["'self'"],
 | 
			
		||||
      styleSrc: ["'self'", "'unsafe-inline'"],
 | 
			
		||||
      imgSrc: ["'self'", 'data:'],
 | 
			
		||||
      fontSrc: ["'self'", 'data:'],
 | 
			
		||||
      connectSrc: ["'self'"],
 | 
			
		||||
      frameAncestors: ["'none'"],
 | 
			
		||||
      objectSrc: ["'none'"]
 | 
			
		||||
    }
 | 
			
		||||
  } : false,
 | 
			
		||||
  hsts: process.env.ENABLE_HSTS === 'true' || process.env.NODE_ENV === 'production'
 | 
			
		||||
}));
 | 
			
		||||
app.use(
 | 
			
		||||
	helmet({
 | 
			
		||||
		contentSecurityPolicy:
 | 
			
		||||
			process.env.NODE_ENV === "production"
 | 
			
		||||
				? {
 | 
			
		||||
						useDefaults: true,
 | 
			
		||||
						directives: {
 | 
			
		||||
							defaultSrc: ["'self'"],
 | 
			
		||||
							scriptSrc: ["'self'"],
 | 
			
		||||
							styleSrc: ["'self'", "'unsafe-inline'"],
 | 
			
		||||
							imgSrc: ["'self'", "data:"],
 | 
			
		||||
							fontSrc: ["'self'", "data:"],
 | 
			
		||||
							connectSrc: ["'self'"],
 | 
			
		||||
							frameAncestors: ["'none'"],
 | 
			
		||||
							objectSrc: ["'none'"],
 | 
			
		||||
						},
 | 
			
		||||
					}
 | 
			
		||||
				: false,
 | 
			
		||||
		hsts:
 | 
			
		||||
			process.env.ENABLE_HSTS === "true" ||
 | 
			
		||||
			process.env.NODE_ENV === "production",
 | 
			
		||||
	}),
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
// CORS allowlist from comma-separated env
 | 
			
		||||
const parseOrigins = (val) => (val || '').split(',').map(s => s.trim()).filter(Boolean);
 | 
			
		||||
const allowedOrigins = parseOrigins(process.env.CORS_ORIGINS || process.env.CORS_ORIGIN || 'http://localhost:3000');
 | 
			
		||||
app.use(cors({
 | 
			
		||||
  origin: function(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
 | 
			
		||||
}));
 | 
			
		||||
const parseOrigins = (val) =>
 | 
			
		||||
	(val || "")
 | 
			
		||||
		.split(",")
 | 
			
		||||
		.map((s) => s.trim())
 | 
			
		||||
		.filter(Boolean);
 | 
			
		||||
const allowedOrigins = parseOrigins(
 | 
			
		||||
	process.env.CORS_ORIGINS ||
 | 
			
		||||
		process.env.CORS_ORIGIN ||
 | 
			
		||||
		"http://localhost:3000",
 | 
			
		||||
);
 | 
			
		||||
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);
 | 
			
		||||
// Reduce body size limits to reasonable defaults
 | 
			
		||||
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.json({ 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
 | 
			
		||||
if (process.env.ENABLE_LOGGING === 'true') {
 | 
			
		||||
  app.use((req, res, next) => {
 | 
			
		||||
    logger.info(`${req.method} ${req.path} - ${req.ip}`);
 | 
			
		||||
    next();
 | 
			
		||||
  });
 | 
			
		||||
if (process.env.ENABLE_LOGGING === "true") {
 | 
			
		||||
	app.use((req, _, next) => {
 | 
			
		||||
		// Log health check requests at debug level to reduce log spam
 | 
			
		||||
		if (req.path === "/health") {
 | 
			
		||||
			logger.debug(`${req.method} ${req.path} - ${req.ip}`);
 | 
			
		||||
		} else {
 | 
			
		||||
			logger.info(`${req.method} ${req.path} - ${req.ip}`);
 | 
			
		||||
		}
 | 
			
		||||
		next();
 | 
			
		||||
	});
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Health check endpoint
 | 
			
		||||
app.get('/health', (req, res) => {
 | 
			
		||||
  res.json({ status: 'ok', timestamp: new Date().toISOString() });
 | 
			
		||||
app.get("/health", (_req, res) => {
 | 
			
		||||
	res.json({ status: "ok", timestamp: new Date().toISOString() });
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
// 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({
 | 
			
		||||
  windowMs: parseInt(process.env.AUTH_RATE_LIMIT_WINDOW_MS) || 10 * 60 * 1000,
 | 
			
		||||
  max: parseInt(process.env.AUTH_RATE_LIMIT_MAX) || 20
 | 
			
		||||
	windowMs:
 | 
			
		||||
		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({
 | 
			
		||||
  windowMs: parseInt(process.env.AGENT_RATE_LIMIT_WINDOW_MS) || 60 * 1000,
 | 
			
		||||
  max: parseInt(process.env.AGENT_RATE_LIMIT_MAX) || 120
 | 
			
		||||
	windowMs: parseInt(process.env.AGENT_RATE_LIMIT_WINDOW_MS, 10) || 60 * 1000,
 | 
			
		||||
	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);
 | 
			
		||||
@@ -136,46 +415,353 @@ app.use(`/api/${apiVersion}/settings`, settingsRoutes);
 | 
			
		||||
app.use(`/api/${apiVersion}/dashboard-preferences`, dashboardPreferencesRoutes);
 | 
			
		||||
app.use(`/api/${apiVersion}/repositories`, repositoryRoutes);
 | 
			
		||||
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
 | 
			
		||||
app.use((err, req, res, next) => {
 | 
			
		||||
  if (process.env.ENABLE_LOGGING === 'true') {
 | 
			
		||||
    logger.error(err.stack);
 | 
			
		||||
  }
 | 
			
		||||
  res.status(500).json({ 
 | 
			
		||||
    error: 'Something went wrong!', 
 | 
			
		||||
    message: process.env.NODE_ENV === 'development' ? err.message : undefined 
 | 
			
		||||
  });
 | 
			
		||||
app.use((err, _req, res, _next) => {
 | 
			
		||||
	if (process.env.ENABLE_LOGGING === "true") {
 | 
			
		||||
		logger.error(err.stack);
 | 
			
		||||
	}
 | 
			
		||||
	res.status(500).json({
 | 
			
		||||
		error: "Something went wrong!",
 | 
			
		||||
		message: process.env.NODE_ENV === "development" ? err.message : undefined,
 | 
			
		||||
	});
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
// 404 handler
 | 
			
		||||
app.use('*', (req, res) => {
 | 
			
		||||
  res.status(404).json({ error: 'Route not found' });
 | 
			
		||||
app.use("*", (_req, res) => {
 | 
			
		||||
	res.status(404).json({ error: "Route not found" });
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
// Graceful shutdown
 | 
			
		||||
process.on('SIGTERM', async () => {
 | 
			
		||||
  if (process.env.ENABLE_LOGGING === 'true') {
 | 
			
		||||
    logger.info('SIGTERM received, shutting down gracefully');
 | 
			
		||||
  }
 | 
			
		||||
  await prisma.$disconnect();
 | 
			
		||||
  process.exit(0);
 | 
			
		||||
process.on("SIGINT", async () => {
 | 
			
		||||
	if (process.env.ENABLE_LOGGING === "true") {
 | 
			
		||||
		logger.info("SIGINT received, shutting down gracefully");
 | 
			
		||||
	}
 | 
			
		||||
	if (app.locals.session_cleanup_interval) {
 | 
			
		||||
		clearInterval(app.locals.session_cleanup_interval);
 | 
			
		||||
	}
 | 
			
		||||
	updateScheduler.stop();
 | 
			
		||||
	await disconnectPrisma(prisma);
 | 
			
		||||
	process.exit(0);
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
process.on('SIGINT', async () => {
 | 
			
		||||
  if (process.env.ENABLE_LOGGING === 'true') {
 | 
			
		||||
    logger.info('SIGINT received, shutting down gracefully');
 | 
			
		||||
  }
 | 
			
		||||
  await prisma.$disconnect();
 | 
			
		||||
  process.exit(0);
 | 
			
		||||
process.on("SIGTERM", async () => {
 | 
			
		||||
	if (process.env.ENABLE_LOGGING === "true") {
 | 
			
		||||
		logger.info("SIGTERM received, shutting down gracefully");
 | 
			
		||||
	}
 | 
			
		||||
	if (app.locals.session_cleanup_interval) {
 | 
			
		||||
		clearInterval(app.locals.session_cleanup_interval);
 | 
			
		||||
	}
 | 
			
		||||
	updateScheduler.stop();
 | 
			
		||||
	await disconnectPrisma(prisma);
 | 
			
		||||
	process.exit(0);
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
// Start server
 | 
			
		||||
app.listen(PORT, () => {
 | 
			
		||||
  if (process.env.ENABLE_LOGGING === 'true') {
 | 
			
		||||
    logger.info(`Server running on port ${PORT}`);
 | 
			
		||||
    logger.info(`Environment: ${process.env.NODE_ENV}`);
 | 
			
		||||
  }
 | 
			
		||||
});
 | 
			
		||||
// Initialize dashboard preferences for all users
 | 
			
		||||
async function initializeDashboardPreferences() {
 | 
			
		||||
	try {
 | 
			
		||||
		// Get all users
 | 
			
		||||
		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">
 | 
			
		||||
  <head>
 | 
			
		||||
    <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" />
 | 
			
		||||
    <title>PatchMon - Linux Patch Monitoring Dashboard</title>
 | 
			
		||||
    <link rel="preconnect" href="https://fonts.googleapis.com">
 | 
			
		||||
 
 | 
			
		||||
@@ -1,43 +1,44 @@
 | 
			
		||||
{
 | 
			
		||||
  "name": "patchmon-frontend",
 | 
			
		||||
  "private": true,
 | 
			
		||||
  "version": "1.2.4",
 | 
			
		||||
  "type": "module",
 | 
			
		||||
  "scripts": {
 | 
			
		||||
    "dev": "vite",
 | 
			
		||||
    "build": "vite build",
 | 
			
		||||
    "lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0",
 | 
			
		||||
    "preview": "vite preview"
 | 
			
		||||
  },
 | 
			
		||||
  "dependencies": {
 | 
			
		||||
    "@dnd-kit/core": "^6.3.1",
 | 
			
		||||
    "@dnd-kit/sortable": "^10.0.0",
 | 
			
		||||
    "@dnd-kit/utilities": "^3.2.2",
 | 
			
		||||
    "@tanstack/react-query": "^5.87.4",
 | 
			
		||||
    "axios": "^1.6.2",
 | 
			
		||||
    "chart.js": "^4.4.0",
 | 
			
		||||
    "clsx": "^2.0.0",
 | 
			
		||||
    "date-fns": "^2.30.0",
 | 
			
		||||
    "lucide-react": "^0.294.0",
 | 
			
		||||
    "react": "^18.2.0",
 | 
			
		||||
    "react-chartjs-2": "^5.2.0",
 | 
			
		||||
    "react-dom": "^18.2.0",
 | 
			
		||||
    "react-router-dom": "^6.20.1"
 | 
			
		||||
  },
 | 
			
		||||
  "devDependencies": {
 | 
			
		||||
    "@types/react": "^18.2.37",
 | 
			
		||||
    "@types/react-dom": "^18.2.15",
 | 
			
		||||
    "@vitejs/plugin-react": "^4.3.3",
 | 
			
		||||
    "autoprefixer": "^10.4.16",
 | 
			
		||||
    "eslint": "^8.53.0",
 | 
			
		||||
    "eslint-plugin-react": "^7.33.2",
 | 
			
		||||
    "eslint-plugin-react-hooks": "^4.6.0",
 | 
			
		||||
    "eslint-plugin-react-refresh": "^0.4.4",
 | 
			
		||||
    "postcss": "^8.4.32",
 | 
			
		||||
    "tailwindcss": "^3.3.6",
 | 
			
		||||
    "vite": "^7.1.5"
 | 
			
		||||
  },
 | 
			
		||||
  "overrides": {
 | 
			
		||||
    "esbuild": "^0.24.4"
 | 
			
		||||
  }
 | 
			
		||||
	"name": "patchmon-frontend",
 | 
			
		||||
	"private": true,
 | 
			
		||||
	"version": "1.2.7",
 | 
			
		||||
	"license": "AGPL-3.0",
 | 
			
		||||
	"type": "module",
 | 
			
		||||
	"scripts": {
 | 
			
		||||
		"dev": "vite",
 | 
			
		||||
		"build": "vite build",
 | 
			
		||||
		"lint": "biome check .",
 | 
			
		||||
		"preview": "vite preview"
 | 
			
		||||
	},
 | 
			
		||||
	"dependencies": {
 | 
			
		||||
		"@dnd-kit/core": "^6.3.1",
 | 
			
		||||
		"@dnd-kit/sortable": "^10.0.0",
 | 
			
		||||
		"@dnd-kit/utilities": "^3.2.2",
 | 
			
		||||
		"@tanstack/react-query": "^5.87.4",
 | 
			
		||||
		"axios": "^1.7.9",
 | 
			
		||||
		"chart.js": "^4.4.7",
 | 
			
		||||
		"clsx": "^2.1.1",
 | 
			
		||||
		"cors": "^2.8.5",
 | 
			
		||||
		"date-fns": "^4.1.0",
 | 
			
		||||
		"express": "^4.21.2",
 | 
			
		||||
		"http-proxy-middleware": "^3.0.3",
 | 
			
		||||
		"lucide-react": "^0.468.0",
 | 
			
		||||
		"react": "^18.3.1",
 | 
			
		||||
		"react-chartjs-2": "^5.2.0",
 | 
			
		||||
		"react-dom": "^18.3.1",
 | 
			
		||||
		"react-icons": "^5.5.0",
 | 
			
		||||
		"react-router-dom": "^6.30.1"
 | 
			
		||||
	},
 | 
			
		||||
	"devDependencies": {
 | 
			
		||||
		"@types/react": "^18.3.14",
 | 
			
		||||
		"@types/react-dom": "^18.3.1",
 | 
			
		||||
		"@vitejs/plugin-react": "^4.3.4",
 | 
			
		||||
		"autoprefixer": "^10.4.20",
 | 
			
		||||
		"postcss": "^8.5.6",
 | 
			
		||||
		"tailwindcss": "^3.4.17",
 | 
			
		||||
		"vite": "^7.1.5"
 | 
			
		||||
	},
 | 
			
		||||
	"overrides": {
 | 
			
		||||
		"esbuild": "^0.25.10"
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,6 +1,6 @@
 | 
			
		||||
export default {
 | 
			
		||||
  plugins: {
 | 
			
		||||
    tailwindcss: {},
 | 
			
		||||
    autoprefixer: {},
 | 
			
		||||
  },
 | 
			
		||||
} 
 | 
			
		||||
	plugins: {
 | 
			
		||||
		tailwindcss: {},
 | 
			
		||||
		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 { Routes, Route } from 'react-router-dom'
 | 
			
		||||
import { AuthProvider } from './contexts/AuthContext'
 | 
			
		||||
import { ThemeProvider } from './contexts/ThemeContext'
 | 
			
		||||
import ProtectedRoute from './components/ProtectedRoute'
 | 
			
		||||
import Layout from './components/Layout'
 | 
			
		||||
import Login from './pages/Login'
 | 
			
		||||
import Dashboard from './pages/Dashboard'
 | 
			
		||||
import Hosts from './pages/Hosts'
 | 
			
		||||
import HostGroups from './pages/HostGroups'
 | 
			
		||||
import Packages from './pages/Packages'
 | 
			
		||||
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'
 | 
			
		||||
import { lazy, Suspense } from "react";
 | 
			
		||||
import { Route, Routes } from "react-router-dom";
 | 
			
		||||
import FirstTimeAdminSetup from "./components/FirstTimeAdminSetup";
 | 
			
		||||
import Layout from "./components/Layout";
 | 
			
		||||
import LogoProvider from "./components/LogoProvider";
 | 
			
		||||
import ProtectedRoute from "./components/ProtectedRoute";
 | 
			
		||||
import SettingsLayout from "./components/SettingsLayout";
 | 
			
		||||
import { isAuthPhase } from "./constants/authPhases";
 | 
			
		||||
import { AuthProvider, useAuth } from "./contexts/AuthContext";
 | 
			
		||||
import { ThemeProvider } from "./contexts/ThemeContext";
 | 
			
		||||
import { UpdateNotificationProvider } from "./contexts/UpdateNotificationContext";
 | 
			
		||||
 | 
			
		||||
function App() {
 | 
			
		||||
  return (
 | 
			
		||||
    <ThemeProvider>
 | 
			
		||||
      <AuthProvider>
 | 
			
		||||
        <Routes>
 | 
			
		||||
        <Route path="/login" element={<Login />} />
 | 
			
		||||
        <Route path="/" element={
 | 
			
		||||
          <ProtectedRoute requirePermission="canViewDashboard">
 | 
			
		||||
            <Layout>
 | 
			
		||||
              <Dashboard />
 | 
			
		||||
            </Layout>
 | 
			
		||||
          </ProtectedRoute>
 | 
			
		||||
        } />
 | 
			
		||||
        <Route path="/hosts" element={
 | 
			
		||||
          <ProtectedRoute requirePermission="canViewHosts">
 | 
			
		||||
            <Layout>
 | 
			
		||||
              <Hosts />
 | 
			
		||||
            </Layout>
 | 
			
		||||
          </ProtectedRoute>
 | 
			
		||||
        } />
 | 
			
		||||
        <Route path="/hosts/:hostId" element={
 | 
			
		||||
          <ProtectedRoute requirePermission="canViewHosts">
 | 
			
		||||
            <Layout>
 | 
			
		||||
              <HostDetail />
 | 
			
		||||
            </Layout>
 | 
			
		||||
          </ProtectedRoute>
 | 
			
		||||
        } />
 | 
			
		||||
        <Route path="/host-groups" element={
 | 
			
		||||
          <ProtectedRoute requirePermission="canManageHosts">
 | 
			
		||||
            <Layout>
 | 
			
		||||
              <HostGroups />
 | 
			
		||||
            </Layout>
 | 
			
		||||
          </ProtectedRoute>
 | 
			
		||||
        } />
 | 
			
		||||
        <Route path="/packages" element={
 | 
			
		||||
          <ProtectedRoute requirePermission="canViewPackages">
 | 
			
		||||
            <Layout>
 | 
			
		||||
              <Packages />
 | 
			
		||||
            </Layout>
 | 
			
		||||
          </ProtectedRoute>
 | 
			
		||||
        } />
 | 
			
		||||
        <Route path="/repositories" element={
 | 
			
		||||
          <ProtectedRoute requirePermission="canViewHosts">
 | 
			
		||||
            <Layout>
 | 
			
		||||
              <Repositories />
 | 
			
		||||
            </Layout>
 | 
			
		||||
          </ProtectedRoute>
 | 
			
		||||
        } />
 | 
			
		||||
        <Route path="/repositories/:repositoryId" element={
 | 
			
		||||
          <ProtectedRoute requirePermission="canViewHosts">
 | 
			
		||||
            <Layout>
 | 
			
		||||
              <RepositoryDetail />
 | 
			
		||||
            </Layout>
 | 
			
		||||
          </ProtectedRoute>
 | 
			
		||||
        } />
 | 
			
		||||
        <Route path="/users" element={
 | 
			
		||||
          <ProtectedRoute requirePermission="canViewUsers">
 | 
			
		||||
            <Layout>
 | 
			
		||||
              <Users />
 | 
			
		||||
            </Layout>
 | 
			
		||||
          </ProtectedRoute>
 | 
			
		||||
        } />
 | 
			
		||||
        <Route path="/permissions" element={
 | 
			
		||||
          <ProtectedRoute requirePermission="canManageSettings">
 | 
			
		||||
            <Layout>
 | 
			
		||||
              <Permissions />
 | 
			
		||||
            </Layout>
 | 
			
		||||
          </ProtectedRoute>
 | 
			
		||||
        } />
 | 
			
		||||
        <Route path="/settings" element={
 | 
			
		||||
          <ProtectedRoute requirePermission="canManageSettings">
 | 
			
		||||
            <Layout>
 | 
			
		||||
              <Settings />
 | 
			
		||||
            </Layout>
 | 
			
		||||
          </ProtectedRoute>
 | 
			
		||||
        } />
 | 
			
		||||
        <Route path="/profile" element={
 | 
			
		||||
          <ProtectedRoute>
 | 
			
		||||
            <Layout>
 | 
			
		||||
              <Profile />
 | 
			
		||||
            </Layout>
 | 
			
		||||
          </ProtectedRoute>
 | 
			
		||||
        } />
 | 
			
		||||
        <Route path="/packages/:packageId" element={
 | 
			
		||||
          <ProtectedRoute requirePermission="canViewPackages">
 | 
			
		||||
            <Layout>
 | 
			
		||||
              <PackageDetail />
 | 
			
		||||
            </Layout>
 | 
			
		||||
          </ProtectedRoute>
 | 
			
		||||
        } />
 | 
			
		||||
        </Routes>
 | 
			
		||||
      </AuthProvider>
 | 
			
		||||
    </ThemeProvider>
 | 
			
		||||
  )
 | 
			
		||||
// Lazy load pages
 | 
			
		||||
const Dashboard = lazy(() => import("./pages/Dashboard"));
 | 
			
		||||
const HostDetail = lazy(() => import("./pages/HostDetail"));
 | 
			
		||||
const Hosts = lazy(() => import("./pages/Hosts"));
 | 
			
		||||
const Login = lazy(() => import("./pages/Login"));
 | 
			
		||||
const PackageDetail = lazy(() => import("./pages/PackageDetail"));
 | 
			
		||||
const Packages = lazy(() => import("./pages/Packages"));
 | 
			
		||||
const Profile = lazy(() => import("./pages/Profile"));
 | 
			
		||||
const Queue = lazy(() => import("./pages/Queue"));
 | 
			
		||||
const Repositories = lazy(() => import("./pages/Repositories"));
 | 
			
		||||
const RepositoryDetail = lazy(() => import("./pages/RepositoryDetail"));
 | 
			
		||||
const AlertChannels = lazy(() => import("./pages/settings/AlertChannels"));
 | 
			
		||||
const Integrations = lazy(() => import("./pages/settings/Integrations"));
 | 
			
		||||
const Notifications = lazy(() => import("./pages/settings/Notifications"));
 | 
			
		||||
const PatchManagement = lazy(() => import("./pages/settings/PatchManagement"));
 | 
			
		||||
const SettingsAgentConfig = lazy(
 | 
			
		||||
	() => import("./pages/settings/SettingsAgentConfig"),
 | 
			
		||||
);
 | 
			
		||||
const SettingsHostGroups = lazy(
 | 
			
		||||
	() => import("./pages/settings/SettingsHostGroups"),
 | 
			
		||||
);
 | 
			
		||||
const SettingsServerConfig = lazy(
 | 
			
		||||
	() => import("./pages/settings/SettingsServerConfig"),
 | 
			
		||||
);
 | 
			
		||||
const SettingsUsers = lazy(() => import("./pages/settings/SettingsUsers"));
 | 
			
		||||
 | 
			
		||||
// Loading fallback component
 | 
			
		||||
const LoadingFallback = () => (
 | 
			
		||||
	<div className="min-h-screen bg-gradient-to-br from-primary-50 to-secondary-50 dark:from-secondary-900 dark:to-secondary-800 flex items-center justify-center">
 | 
			
		||||
		<div className="text-center">
 | 
			
		||||
			<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto mb-4"></div>
 | 
			
		||||
			<p className="text-secondary-600 dark:text-secondary-300">Loading...</p>
 | 
			
		||||
		</div>
 | 
			
		||||
	</div>
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
function AppRoutes() {
 | 
			
		||||
	const { needsFirstTimeSetup, authPhase, isAuthenticated } = useAuth();
 | 
			
		||||
	const isAuth = isAuthenticated(); // Call the function to get boolean value
 | 
			
		||||
 | 
			
		||||
	// Show loading while checking setup or initialising
 | 
			
		||||
	if (
 | 
			
		||||
		isAuthPhase.initialising(authPhase) ||
 | 
			
		||||
		isAuthPhase.checkingSetup(authPhase)
 | 
			
		||||
	) {
 | 
			
		||||
		return (
 | 
			
		||||
			<div className="min-h-screen bg-gradient-to-br from-primary-50 to-secondary-50 dark:from-secondary-900 dark:to-secondary-800 flex items-center justify-center">
 | 
			
		||||
				<div className="text-center">
 | 
			
		||||
					<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto mb-4"></div>
 | 
			
		||||
					<p className="text-secondary-600 dark:text-secondary-300">
 | 
			
		||||
						Checking system status...
 | 
			
		||||
					</p>
 | 
			
		||||
				</div>
 | 
			
		||||
			</div>
 | 
			
		||||
		);
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Show first-time setup if no admin users exist
 | 
			
		||||
	if (needsFirstTimeSetup && !isAuth) {
 | 
			
		||||
		return <FirstTimeAdminSetup />;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return (
 | 
			
		||||
		<Suspense fallback={<LoadingFallback />}>
 | 
			
		||||
			<Routes>
 | 
			
		||||
				<Route path="/login" element={<Login />} />
 | 
			
		||||
				<Route
 | 
			
		||||
					path="/"
 | 
			
		||||
					element={
 | 
			
		||||
						<ProtectedRoute requirePermission="can_view_dashboard">
 | 
			
		||||
							<Layout>
 | 
			
		||||
								<Dashboard />
 | 
			
		||||
							</Layout>
 | 
			
		||||
						</ProtectedRoute>
 | 
			
		||||
					}
 | 
			
		||||
				/>
 | 
			
		||||
				<Route
 | 
			
		||||
					path="/hosts"
 | 
			
		||||
					element={
 | 
			
		||||
						<ProtectedRoute requirePermission="can_view_hosts">
 | 
			
		||||
							<Layout>
 | 
			
		||||
								<Hosts />
 | 
			
		||||
							</Layout>
 | 
			
		||||
						</ProtectedRoute>
 | 
			
		||||
					}
 | 
			
		||||
				/>
 | 
			
		||||
				<Route
 | 
			
		||||
					path="/hosts/:hostId"
 | 
			
		||||
					element={
 | 
			
		||||
						<ProtectedRoute requirePermission="can_view_hosts">
 | 
			
		||||
							<Layout>
 | 
			
		||||
								<HostDetail />
 | 
			
		||||
							</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 {
 | 
			
		||||
  arrayMove,
 | 
			
		||||
  SortableContext,
 | 
			
		||||
  sortableKeyboardCoordinates,
 | 
			
		||||
  verticalListSortingStrategy,
 | 
			
		||||
} from '@dnd-kit/sortable';
 | 
			
		||||
	closestCenter,
 | 
			
		||||
	DndContext,
 | 
			
		||||
	KeyboardSensor,
 | 
			
		||||
	PointerSensor,
 | 
			
		||||
	useSensor,
 | 
			
		||||
	useSensors,
 | 
			
		||||
} from "@dnd-kit/core";
 | 
			
		||||
import {
 | 
			
		||||
  useSortable,
 | 
			
		||||
} from '@dnd-kit/sortable';
 | 
			
		||||
import { CSS } from '@dnd-kit/utilities';
 | 
			
		||||
import { 
 | 
			
		||||
  X, 
 | 
			
		||||
  GripVertical, 
 | 
			
		||||
  Eye, 
 | 
			
		||||
  EyeOff, 
 | 
			
		||||
  Save, 
 | 
			
		||||
  RotateCcw,
 | 
			
		||||
  Settings as SettingsIcon
 | 
			
		||||
} from 'lucide-react';
 | 
			
		||||
import { dashboardPreferencesAPI } from '../utils/api';
 | 
			
		||||
	arrayMove,
 | 
			
		||||
	SortableContext,
 | 
			
		||||
	sortableKeyboardCoordinates,
 | 
			
		||||
	useSortable,
 | 
			
		||||
	verticalListSortingStrategy,
 | 
			
		||||
} from "@dnd-kit/sortable";
 | 
			
		||||
import { CSS } from "@dnd-kit/utilities";
 | 
			
		||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
 | 
			
		||||
import {
 | 
			
		||||
	Eye,
 | 
			
		||||
	EyeOff,
 | 
			
		||||
	GripVertical,
 | 
			
		||||
	RotateCcw,
 | 
			
		||||
	Save,
 | 
			
		||||
	Settings as SettingsIcon,
 | 
			
		||||
	X,
 | 
			
		||||
} from "lucide-react";
 | 
			
		||||
import { useEffect, useState } from "react";
 | 
			
		||||
import { dashboardPreferencesAPI } from "../utils/api";
 | 
			
		||||
 | 
			
		||||
// Sortable Card Item Component
 | 
			
		||||
const SortableCardItem = ({ card, onToggle }) => {
 | 
			
		||||
  const {
 | 
			
		||||
    attributes,
 | 
			
		||||
    listeners,
 | 
			
		||||
    setNodeRef,
 | 
			
		||||
    transform,
 | 
			
		||||
    transition,
 | 
			
		||||
    isDragging,
 | 
			
		||||
  } = useSortable({ id: card.cardId });
 | 
			
		||||
	const {
 | 
			
		||||
		attributes,
 | 
			
		||||
		listeners,
 | 
			
		||||
		setNodeRef,
 | 
			
		||||
		transform,
 | 
			
		||||
		transition,
 | 
			
		||||
		isDragging,
 | 
			
		||||
	} = useSortable({
 | 
			
		||||
		id: card.cardId,
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
  const style = {
 | 
			
		||||
    transform: CSS.Transform.toString(transform),
 | 
			
		||||
    transition,
 | 
			
		||||
    opacity: isDragging ? 0.5 : 1,
 | 
			
		||||
  };
 | 
			
		||||
	const style = {
 | 
			
		||||
		transform: CSS.Transform.toString(transform),
 | 
			
		||||
		transition,
 | 
			
		||||
		opacity: isDragging ? 0.5 : 1,
 | 
			
		||||
	};
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div
 | 
			
		||||
      ref={setNodeRef}
 | 
			
		||||
      style={style}
 | 
			
		||||
      className={`flex items-center justify-between p-3 bg-white border border-secondary-200 rounded-lg ${
 | 
			
		||||
        isDragging ? 'shadow-lg' : 'shadow-sm'
 | 
			
		||||
      }`}
 | 
			
		||||
    >
 | 
			
		||||
      <div className="flex items-center gap-3">
 | 
			
		||||
        <button
 | 
			
		||||
          {...attributes}
 | 
			
		||||
          {...listeners}
 | 
			
		||||
          className="text-secondary-400 hover:text-secondary-600 cursor-grab active:cursor-grabbing"
 | 
			
		||||
        >
 | 
			
		||||
          <GripVertical className="h-4 w-4" />
 | 
			
		||||
        </button>
 | 
			
		||||
        <div className="flex items-center gap-2">
 | 
			
		||||
          <div className="text-sm font-medium text-secondary-900">
 | 
			
		||||
            {card.title}
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
      
 | 
			
		||||
      <button
 | 
			
		||||
        onClick={() => onToggle(card.cardId)}
 | 
			
		||||
        className={`flex items-center gap-1 px-2 py-1 rounded text-xs font-medium transition-colors ${
 | 
			
		||||
          card.enabled
 | 
			
		||||
            ? 'bg-green-100 text-green-800 hover:bg-green-200'
 | 
			
		||||
            : 'bg-secondary-100 text-secondary-600 hover:bg-secondary-200'
 | 
			
		||||
        }`}
 | 
			
		||||
      >
 | 
			
		||||
        {card.enabled ? (
 | 
			
		||||
          <>
 | 
			
		||||
            <Eye className="h-3 w-3" />
 | 
			
		||||
            Visible
 | 
			
		||||
          </>
 | 
			
		||||
        ) : (
 | 
			
		||||
          <>
 | 
			
		||||
            <EyeOff className="h-3 w-3" />
 | 
			
		||||
            Hidden
 | 
			
		||||
          </>
 | 
			
		||||
        )}
 | 
			
		||||
      </button>
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
	return (
 | 
			
		||||
		<div
 | 
			
		||||
			ref={setNodeRef}
 | 
			
		||||
			style={style}
 | 
			
		||||
			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"
 | 
			
		||||
			}`}
 | 
			
		||||
		>
 | 
			
		||||
			<div className="flex items-center gap-3">
 | 
			
		||||
				<button
 | 
			
		||||
					{...attributes}
 | 
			
		||||
					{...listeners}
 | 
			
		||||
					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" />
 | 
			
		||||
				</button>
 | 
			
		||||
				<div className="flex items-center gap-2">
 | 
			
		||||
					<div className="text-sm font-medium text-secondary-900 dark:text-white">
 | 
			
		||||
						{card.title}
 | 
			
		||||
						{card.typeLabel ? (
 | 
			
		||||
							<span className="ml-2 text-xs font-normal text-secondary-500 dark:text-secondary-400">
 | 
			
		||||
								({card.typeLabel})
 | 
			
		||||
							</span>
 | 
			
		||||
						) : null}
 | 
			
		||||
					</div>
 | 
			
		||||
				</div>
 | 
			
		||||
			</div>
 | 
			
		||||
 | 
			
		||||
			<button
 | 
			
		||||
				type="button"
 | 
			
		||||
				onClick={() => onToggle(card.cardId)}
 | 
			
		||||
				className={`flex items-center gap-1 px-2 py-1 rounded text-xs font-medium transition-colors ${
 | 
			
		||||
					card.enabled
 | 
			
		||||
						? "bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 hover:bg-green-200 dark:hover:bg-green-800"
 | 
			
		||||
						: "bg-secondary-100 dark:bg-secondary-700 text-secondary-600 dark:text-secondary-300 hover:bg-secondary-200 dark:hover:bg-secondary-600"
 | 
			
		||||
				}`}
 | 
			
		||||
			>
 | 
			
		||||
				{card.enabled ? (
 | 
			
		||||
					<>
 | 
			
		||||
						<Eye className="h-3 w-3" />
 | 
			
		||||
						Visible
 | 
			
		||||
					</>
 | 
			
		||||
				) : (
 | 
			
		||||
					<>
 | 
			
		||||
						<EyeOff className="h-3 w-3" />
 | 
			
		||||
						Hidden
 | 
			
		||||
					</>
 | 
			
		||||
				)}
 | 
			
		||||
			</button>
 | 
			
		||||
		</div>
 | 
			
		||||
	);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const DashboardSettingsModal = ({ isOpen, onClose }) => {
 | 
			
		||||
  const [cards, setCards] = useState([]);
 | 
			
		||||
  const [hasChanges, setHasChanges] = useState(false);
 | 
			
		||||
  const queryClient = useQueryClient();
 | 
			
		||||
	const [cards, setCards] = useState([]);
 | 
			
		||||
	const [hasChanges, setHasChanges] = useState(false);
 | 
			
		||||
	const queryClient = useQueryClient();
 | 
			
		||||
 | 
			
		||||
  const sensors = useSensors(
 | 
			
		||||
    useSensor(PointerSensor),
 | 
			
		||||
    useSensor(KeyboardSensor, {
 | 
			
		||||
      coordinateGetter: sortableKeyboardCoordinates,
 | 
			
		||||
    })
 | 
			
		||||
  );
 | 
			
		||||
	const sensors = useSensors(
 | 
			
		||||
		useSensor(PointerSensor),
 | 
			
		||||
		useSensor(KeyboardSensor, {
 | 
			
		||||
			coordinateGetter: sortableKeyboardCoordinates,
 | 
			
		||||
		}),
 | 
			
		||||
	);
 | 
			
		||||
 | 
			
		||||
  // Fetch user's dashboard preferences
 | 
			
		||||
  const { data: preferences, isLoading } = useQuery({
 | 
			
		||||
    queryKey: ['dashboardPreferences'],
 | 
			
		||||
    queryFn: () => dashboardPreferencesAPI.get().then(res => res.data),
 | 
			
		||||
    enabled: isOpen
 | 
			
		||||
  });
 | 
			
		||||
	// Fetch user's dashboard preferences
 | 
			
		||||
	const { data: preferences, isLoading } = useQuery({
 | 
			
		||||
		queryKey: ["dashboardPreferences"],
 | 
			
		||||
		queryFn: () => dashboardPreferencesAPI.get().then((res) => res.data),
 | 
			
		||||
		enabled: isOpen,
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
  // Fetch default card configuration
 | 
			
		||||
  const { data: defaultCards } = useQuery({
 | 
			
		||||
    queryKey: ['dashboardDefaultCards'],
 | 
			
		||||
    queryFn: () => dashboardPreferencesAPI.getDefaults().then(res => res.data),
 | 
			
		||||
    enabled: isOpen
 | 
			
		||||
  });
 | 
			
		||||
	// Fetch default card configuration
 | 
			
		||||
	const { data: defaultCards } = useQuery({
 | 
			
		||||
		queryKey: ["dashboardDefaultCards"],
 | 
			
		||||
		queryFn: () =>
 | 
			
		||||
			dashboardPreferencesAPI.getDefaults().then((res) => res.data),
 | 
			
		||||
		enabled: isOpen,
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
  // Update preferences mutation
 | 
			
		||||
  const updatePreferencesMutation = useMutation({
 | 
			
		||||
    mutationFn: (preferences) => dashboardPreferencesAPI.update(preferences),
 | 
			
		||||
    onSuccess: (response) => {
 | 
			
		||||
      // Optimistically update the query cache with the correct data structure
 | 
			
		||||
      queryClient.setQueryData(['dashboardPreferences'], response.data.preferences);
 | 
			
		||||
      // Also invalidate to ensure fresh data
 | 
			
		||||
      queryClient.invalidateQueries(['dashboardPreferences']);
 | 
			
		||||
      setHasChanges(false);
 | 
			
		||||
      onClose();
 | 
			
		||||
    },
 | 
			
		||||
    onError: (error) => {
 | 
			
		||||
      console.error('Failed to update dashboard preferences:', error);
 | 
			
		||||
    }
 | 
			
		||||
  });
 | 
			
		||||
	// Update preferences mutation
 | 
			
		||||
	const updatePreferencesMutation = useMutation({
 | 
			
		||||
		mutationFn: (preferences) => dashboardPreferencesAPI.update(preferences),
 | 
			
		||||
		onSuccess: (response) => {
 | 
			
		||||
			// Optimistically update the query cache with the correct data structure
 | 
			
		||||
			queryClient.setQueryData(
 | 
			
		||||
				["dashboardPreferences"],
 | 
			
		||||
				response.data.preferences,
 | 
			
		||||
			);
 | 
			
		||||
			// Also invalidate to ensure fresh data
 | 
			
		||||
			queryClient.invalidateQueries(["dashboardPreferences"]);
 | 
			
		||||
			setHasChanges(false);
 | 
			
		||||
			onClose();
 | 
			
		||||
		},
 | 
			
		||||
		onError: (error) => {
 | 
			
		||||
			console.error("Failed to update dashboard preferences:", error);
 | 
			
		||||
		},
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
  // Initialize cards when preferences or defaults are loaded
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    if (preferences && defaultCards) {
 | 
			
		||||
      // Merge user preferences with default cards
 | 
			
		||||
      const mergedCards = defaultCards.map(defaultCard => {
 | 
			
		||||
        const userPreference = preferences.find(p => p.cardId === defaultCard.cardId);
 | 
			
		||||
        return {
 | 
			
		||||
          ...defaultCard,
 | 
			
		||||
          enabled: userPreference ? userPreference.enabled : defaultCard.enabled,
 | 
			
		||||
          order: userPreference ? userPreference.order : defaultCard.order
 | 
			
		||||
        };
 | 
			
		||||
      }).sort((a, b) => a.order - b.order);
 | 
			
		||||
      
 | 
			
		||||
      setCards(mergedCards);
 | 
			
		||||
    }
 | 
			
		||||
  }, [preferences, defaultCards]);
 | 
			
		||||
	// Initialize cards when preferences or defaults are loaded
 | 
			
		||||
	useEffect(() => {
 | 
			
		||||
		if (preferences && defaultCards) {
 | 
			
		||||
			// Normalize server preferences (snake_case -> camelCase)
 | 
			
		||||
			const normalizedPreferences = preferences.map((p) => ({
 | 
			
		||||
				cardId: p.cardId ?? p.card_id,
 | 
			
		||||
				enabled: p.enabled,
 | 
			
		||||
				order: p.order,
 | 
			
		||||
			}));
 | 
			
		||||
 | 
			
		||||
  const handleDragEnd = (event) => {
 | 
			
		||||
    const { active, over } = event;
 | 
			
		||||
			const typeLabelFor = (cardId) => {
 | 
			
		||||
				if (
 | 
			
		||||
					[
 | 
			
		||||
						"totalHosts",
 | 
			
		||||
						"hostsNeedingUpdates",
 | 
			
		||||
						"totalOutdatedPackages",
 | 
			
		||||
						"securityUpdates",
 | 
			
		||||
						"upToDateHosts",
 | 
			
		||||
						"totalHostGroups",
 | 
			
		||||
						"totalUsers",
 | 
			
		||||
						"totalRepos",
 | 
			
		||||
					].includes(cardId)
 | 
			
		||||
				)
 | 
			
		||||
					return "Top card";
 | 
			
		||||
				if (cardId === "osDistribution") return "Pie chart";
 | 
			
		||||
				if (cardId === "osDistributionBar") return "Bar chart";
 | 
			
		||||
				if (cardId === "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) {
 | 
			
		||||
      setCards((items) => {
 | 
			
		||||
        const oldIndex = items.findIndex(item => item.cardId === active.id);
 | 
			
		||||
        const newIndex = items.findIndex(item => item.cardId === over.id);
 | 
			
		||||
        
 | 
			
		||||
        const newItems = arrayMove(items, oldIndex, newIndex);
 | 
			
		||||
        
 | 
			
		||||
        // Update order values
 | 
			
		||||
        return newItems.map((item, index) => ({
 | 
			
		||||
          ...item,
 | 
			
		||||
          order: index
 | 
			
		||||
        }));
 | 
			
		||||
      });
 | 
			
		||||
      setHasChanges(true);
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
			// Merge user preferences with default cards
 | 
			
		||||
			const mergedCards = defaultCards
 | 
			
		||||
				.map((defaultCard) => {
 | 
			
		||||
					const userPreference = normalizedPreferences.find(
 | 
			
		||||
						(p) => p.cardId === defaultCard.cardId,
 | 
			
		||||
					);
 | 
			
		||||
					return {
 | 
			
		||||
						...defaultCard,
 | 
			
		||||
						enabled: userPreference
 | 
			
		||||
							? userPreference.enabled
 | 
			
		||||
							: defaultCard.enabled,
 | 
			
		||||
						order: userPreference ? userPreference.order : defaultCard.order,
 | 
			
		||||
						typeLabel: typeLabelFor(defaultCard.cardId),
 | 
			
		||||
					};
 | 
			
		||||
				})
 | 
			
		||||
				.sort((a, b) => a.order - b.order);
 | 
			
		||||
 | 
			
		||||
  const handleToggle = (cardId) => {
 | 
			
		||||
    setCards(prevCards => 
 | 
			
		||||
      prevCards.map(card => 
 | 
			
		||||
        card.cardId === cardId 
 | 
			
		||||
          ? { ...card, enabled: !card.enabled }
 | 
			
		||||
          : card
 | 
			
		||||
      )
 | 
			
		||||
    );
 | 
			
		||||
    setHasChanges(true);
 | 
			
		||||
  };
 | 
			
		||||
			setCards(mergedCards);
 | 
			
		||||
		}
 | 
			
		||||
	}, [preferences, defaultCards]);
 | 
			
		||||
 | 
			
		||||
  const handleSave = () => {
 | 
			
		||||
    const preferences = cards.map(card => ({
 | 
			
		||||
      cardId: card.cardId,
 | 
			
		||||
      enabled: card.enabled,
 | 
			
		||||
      order: card.order
 | 
			
		||||
    }));
 | 
			
		||||
    
 | 
			
		||||
    updatePreferencesMutation.mutate(preferences);
 | 
			
		||||
  };
 | 
			
		||||
	const handleDragEnd = (event) => {
 | 
			
		||||
		const { active, over } = event;
 | 
			
		||||
 | 
			
		||||
  const handleReset = () => {
 | 
			
		||||
    if (defaultCards) {
 | 
			
		||||
      const resetCards = defaultCards.map(card => ({
 | 
			
		||||
        ...card,
 | 
			
		||||
        enabled: true,
 | 
			
		||||
        order: card.order
 | 
			
		||||
      }));
 | 
			
		||||
      setCards(resetCards);
 | 
			
		||||
      setHasChanges(true);
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
		if (active.id !== over.id) {
 | 
			
		||||
			setCards((items) => {
 | 
			
		||||
				const oldIndex = items.findIndex((item) => item.cardId === active.id);
 | 
			
		||||
				const newIndex = items.findIndex((item) => item.cardId === over.id);
 | 
			
		||||
 | 
			
		||||
  if (!isOpen) return null;
 | 
			
		||||
				const newItems = arrayMove(items, oldIndex, newIndex);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="fixed inset-0 z-50 overflow-y-auto">
 | 
			
		||||
      <div className="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
 | 
			
		||||
        <div className="fixed inset-0 bg-secondary-500 bg-opacity-75 transition-opacity" onClick={onClose} />
 | 
			
		||||
        
 | 
			
		||||
        <div className="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">
 | 
			
		||||
            <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>
 | 
			
		||||
				// Update order values
 | 
			
		||||
				return newItems.map((item, index) => ({
 | 
			
		||||
					...item,
 | 
			
		||||
					order: index,
 | 
			
		||||
				}));
 | 
			
		||||
			});
 | 
			
		||||
			setHasChanges(true);
 | 
			
		||||
		}
 | 
			
		||||
	};
 | 
			
		||||
 | 
			
		||||
            {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 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
 | 
			
		||||
            <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
 | 
			
		||||
              onClick={handleReset}
 | 
			
		||||
              className="mt-3 w-full inline-flex justify-center rounded-md border border-secondary-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-secondary-700 hover:bg-secondary-50 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
 | 
			
		||||
            >
 | 
			
		||||
              <RotateCcw className="h-4 w-4 mr-2" />
 | 
			
		||||
              Reset to Defaults
 | 
			
		||||
            </button>
 | 
			
		||||
            
 | 
			
		||||
            <button
 | 
			
		||||
              onClick={onClose}
 | 
			
		||||
              className="mt-3 w-full inline-flex justify-center rounded-md border border-secondary-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-secondary-700 hover:bg-secondary-50 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
 | 
			
		||||
            >
 | 
			
		||||
              Cancel
 | 
			
		||||
            </button>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
	const handleToggle = (cardId) => {
 | 
			
		||||
		setCards((prevCards) =>
 | 
			
		||||
			prevCards.map((card) =>
 | 
			
		||||
				card.cardId === cardId ? { ...card, enabled: !card.enabled } : card,
 | 
			
		||||
			),
 | 
			
		||||
		);
 | 
			
		||||
		setHasChanges(true);
 | 
			
		||||
	};
 | 
			
		||||
 | 
			
		||||
	const handleSave = () => {
 | 
			
		||||
		const preferences = cards.map((card) => ({
 | 
			
		||||
			cardId: card.cardId,
 | 
			
		||||
			enabled: card.enabled,
 | 
			
		||||
			order: card.order,
 | 
			
		||||
		}));
 | 
			
		||||
 | 
			
		||||
		updatePreferencesMutation.mutate(preferences);
 | 
			
		||||
	};
 | 
			
		||||
 | 
			
		||||
	const handleReset = () => {
 | 
			
		||||
		if (defaultCards) {
 | 
			
		||||
			const resetCards = defaultCards.map((card) => ({
 | 
			
		||||
				...card,
 | 
			
		||||
				enabled: true,
 | 
			
		||||
				order: card.order,
 | 
			
		||||
			}));
 | 
			
		||||
			setCards(resetCards);
 | 
			
		||||
			setHasChanges(true);
 | 
			
		||||
		}
 | 
			
		||||
	};
 | 
			
		||||
 | 
			
		||||
	if (!isOpen) return null;
 | 
			
		||||
 | 
			
		||||
	return (
 | 
			
		||||
		<div className="fixed inset-0 z-50 overflow-y-auto">
 | 
			
		||||
			<div className="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
 | 
			
		||||
				<button
 | 
			
		||||
					type="button"
 | 
			
		||||
					className="fixed inset-0 bg-secondary-500 bg-opacity-75 transition-opacity cursor-default"
 | 
			
		||||
					onClick={onClose}
 | 
			
		||||
					aria-label="Close modal"
 | 
			
		||||
				/>
 | 
			
		||||
 | 
			
		||||
				<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">
 | 
			
		||||
							<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 dark:text-white">
 | 
			
		||||
									Dashboard Settings
 | 
			
		||||
								</h3>
 | 
			
		||||
							</div>
 | 
			
		||||
							<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>
 | 
			
		||||
 | 
			
		||||
						<p className="text-sm text-secondary-600 dark:text-secondary-400 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 ? (
 | 
			
		||||
							<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;
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										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 { useAuth } from '../contexts/AuthContext'
 | 
			
		||||
import { Navigate } from "react-router-dom";
 | 
			
		||||
import { useAuth } from "../contexts/AuthContext";
 | 
			
		||||
 | 
			
		||||
const ProtectedRoute = ({ children, requireAdmin = false, requirePermission = null }) => {
 | 
			
		||||
  const { isAuthenticated, isAdmin, isLoading, hasPermission } = useAuth()
 | 
			
		||||
const ProtectedRoute = ({
 | 
			
		||||
	children,
 | 
			
		||||
	requireAdmin = false,
 | 
			
		||||
	requirePermission = null,
 | 
			
		||||
}) => {
 | 
			
		||||
	const { isAuthenticated, isAdmin, isLoading, hasPermission } = useAuth();
 | 
			
		||||
 | 
			
		||||
  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 (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 (!isAuthenticated()) {
 | 
			
		||||
    return <Navigate to="/login" replace />
 | 
			
		||||
  }
 | 
			
		||||
	if (!isAuthenticated()) {
 | 
			
		||||
		return <Navigate to="/login" replace />;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
  // Check admin requirement
 | 
			
		||||
  if (requireAdmin && !isAdmin()) {
 | 
			
		||||
    return (
 | 
			
		||||
      <div className="flex items-center justify-center h-64">
 | 
			
		||||
        <div className="text-center">
 | 
			
		||||
          <h2 className="text-xl font-semibold text-secondary-900 mb-2">Access Denied</h2>
 | 
			
		||||
          <p className="text-secondary-600">You don't have permission to access this page.</p>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    )
 | 
			
		||||
  }
 | 
			
		||||
	// Check admin requirement
 | 
			
		||||
	if (requireAdmin && !isAdmin()) {
 | 
			
		||||
		return (
 | 
			
		||||
			<div className="flex items-center justify-center h-64">
 | 
			
		||||
				<div className="text-center">
 | 
			
		||||
					<h2 className="text-xl font-semibold text-secondary-900 mb-2">
 | 
			
		||||
						Access Denied
 | 
			
		||||
					</h2>
 | 
			
		||||
					<p className="text-secondary-600">
 | 
			
		||||
						You don't have permission to access this page.
 | 
			
		||||
					</p>
 | 
			
		||||
				</div>
 | 
			
		||||
			</div>
 | 
			
		||||
		);
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
  // Check specific permission requirement
 | 
			
		||||
  if (requirePermission && !hasPermission(requirePermission)) {
 | 
			
		||||
    return (
 | 
			
		||||
      <div className="flex items-center justify-center h-64">
 | 
			
		||||
        <div className="text-center">
 | 
			
		||||
          <h2 className="text-xl font-semibold text-secondary-900 mb-2">Access Denied</h2>
 | 
			
		||||
          <p className="text-secondary-600">You don't have permission to access this page.</p>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    )
 | 
			
		||||
  }
 | 
			
		||||
	// Check specific permission requirement
 | 
			
		||||
	if (requirePermission && !hasPermission(requirePermission)) {
 | 
			
		||||
		return (
 | 
			
		||||
			<div className="flex items-center justify-center h-64">
 | 
			
		||||
				<div className="text-center">
 | 
			
		||||
					<h2 className="text-xl font-semibold text-secondary-900 mb-2">
 | 
			
		||||
						Access Denied
 | 
			
		||||
					</h2>
 | 
			
		||||
					<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 = () => {
 | 
			
		||||
  const context = useContext(AuthContext)
 | 
			
		||||
  if (!context) {
 | 
			
		||||
    throw new Error('useAuth must be used within an AuthProvider')
 | 
			
		||||
  }
 | 
			
		||||
  return context
 | 
			
		||||
}
 | 
			
		||||
	const context = useContext(AuthContext);
 | 
			
		||||
	if (!context) {
 | 
			
		||||
		throw new Error("useAuth must be used within an AuthProvider");
 | 
			
		||||
	}
 | 
			
		||||
	return context;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const AuthProvider = ({ children }) => {
 | 
			
		||||
  const [user, setUser] = useState(null)
 | 
			
		||||
  const [token, setToken] = useState(null)
 | 
			
		||||
  const [permissions, setPermissions] = useState(null)
 | 
			
		||||
  const [isLoading, setIsLoading] = useState(true)
 | 
			
		||||
	const [user, setUser] = useState(null);
 | 
			
		||||
	const [token, setToken] = useState(null);
 | 
			
		||||
	const [permissions, setPermissions] = useState(null);
 | 
			
		||||
	const [needsFirstTimeSetup, setNeedsFirstTimeSetup] = useState(false);
 | 
			
		||||
 | 
			
		||||
  // Initialize auth state from localStorage
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    const storedToken = localStorage.getItem('token')
 | 
			
		||||
    const storedUser = localStorage.getItem('user')
 | 
			
		||||
    const storedPermissions = localStorage.getItem('permissions')
 | 
			
		||||
	// Authentication state machine phases
 | 
			
		||||
	const [authPhase, setAuthPhase] = useState(AUTH_PHASES.INITIALISING);
 | 
			
		||||
	const [permissionsLoading, setPermissionsLoading] = useState(false);
 | 
			
		||||
 | 
			
		||||
    if (storedToken && storedUser) {
 | 
			
		||||
      try {
 | 
			
		||||
        setToken(storedToken)
 | 
			
		||||
        setUser(JSON.parse(storedUser))
 | 
			
		||||
        if (storedPermissions) {
 | 
			
		||||
          setPermissions(JSON.parse(storedPermissions))
 | 
			
		||||
        } else {
 | 
			
		||||
          // 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)
 | 
			
		||||
  }, [])
 | 
			
		||||
	// Define functions first
 | 
			
		||||
	const fetchPermissions = useCallback(async (authToken) => {
 | 
			
		||||
		try {
 | 
			
		||||
			setPermissionsLoading(true);
 | 
			
		||||
			const response = await fetch("/api/v1/permissions/user-permissions", {
 | 
			
		||||
				headers: {
 | 
			
		||||
					Authorization: `Bearer ${authToken}`,
 | 
			
		||||
				},
 | 
			
		||||
			});
 | 
			
		||||
 | 
			
		||||
  // Periodically refresh permissions when user is logged in
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    if (token && user) {
 | 
			
		||||
      // Refresh permissions every 30 seconds
 | 
			
		||||
      const interval = setInterval(() => {
 | 
			
		||||
        refreshPermissions()
 | 
			
		||||
      }, 30000)
 | 
			
		||||
			if (response.ok) {
 | 
			
		||||
				const data = await response.json();
 | 
			
		||||
				setPermissions(data);
 | 
			
		||||
				return data;
 | 
			
		||||
			} else {
 | 
			
		||||
				console.error("Failed to fetch permissions");
 | 
			
		||||
				return null;
 | 
			
		||||
			}
 | 
			
		||||
		} catch (error) {
 | 
			
		||||
			console.error("Error fetching permissions:", error);
 | 
			
		||||
			return null;
 | 
			
		||||
		} finally {
 | 
			
		||||
			setPermissionsLoading(false);
 | 
			
		||||
		}
 | 
			
		||||
	}, []);
 | 
			
		||||
 | 
			
		||||
      return () => clearInterval(interval)
 | 
			
		||||
    }
 | 
			
		||||
  }, [token, user])
 | 
			
		||||
	const refreshPermissions = useCallback(async () => {
 | 
			
		||||
		if (token) {
 | 
			
		||||
			const updatedPermissions = await fetchPermissions(token);
 | 
			
		||||
			return updatedPermissions;
 | 
			
		||||
		}
 | 
			
		||||
		return null;
 | 
			
		||||
	}, [token, fetchPermissions]);
 | 
			
		||||
 | 
			
		||||
  const fetchPermissions = async (authToken) => {
 | 
			
		||||
    try {
 | 
			
		||||
      const response = await fetch('/api/v1/permissions/user-permissions', {
 | 
			
		||||
        headers: {
 | 
			
		||||
          'Authorization': `Bearer ${authToken}`,
 | 
			
		||||
        },
 | 
			
		||||
      })
 | 
			
		||||
	// Initialize auth state from localStorage
 | 
			
		||||
	useEffect(() => {
 | 
			
		||||
		const storedToken = localStorage.getItem("token");
 | 
			
		||||
		const storedUser = localStorage.getItem("user");
 | 
			
		||||
 | 
			
		||||
      if (response.ok) {
 | 
			
		||||
        const data = await response.json()
 | 
			
		||||
        setPermissions(data)
 | 
			
		||||
        localStorage.setItem('permissions', JSON.stringify(data))
 | 
			
		||||
        return data
 | 
			
		||||
      } else {
 | 
			
		||||
        console.error('Failed to fetch permissions')
 | 
			
		||||
        return null
 | 
			
		||||
      }
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      console.error('Error fetching permissions:', error)
 | 
			
		||||
      return null
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
		if (storedToken && storedUser) {
 | 
			
		||||
			try {
 | 
			
		||||
				setToken(storedToken);
 | 
			
		||||
				setUser(JSON.parse(storedUser));
 | 
			
		||||
				// Fetch permissions from backend
 | 
			
		||||
				fetchPermissions(storedToken);
 | 
			
		||||
				// User is authenticated, skip setup check
 | 
			
		||||
				setAuthPhase(AUTH_PHASES.READY);
 | 
			
		||||
			} catch (error) {
 | 
			
		||||
				console.error("Error parsing stored user data:", error);
 | 
			
		||||
				localStorage.removeItem("token");
 | 
			
		||||
				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 () => {
 | 
			
		||||
    if (token) {
 | 
			
		||||
      const updatedPermissions = await fetchPermissions(token)
 | 
			
		||||
      return updatedPermissions
 | 
			
		||||
    }
 | 
			
		||||
    return null
 | 
			
		||||
  }
 | 
			
		||||
	const login = async (username, password) => {
 | 
			
		||||
		try {
 | 
			
		||||
			const response = await fetch("/api/v1/auth/login", {
 | 
			
		||||
				method: "POST",
 | 
			
		||||
				headers: {
 | 
			
		||||
					"Content-Type": "application/json",
 | 
			
		||||
				},
 | 
			
		||||
				body: JSON.stringify({ username, password }),
 | 
			
		||||
			});
 | 
			
		||||
 | 
			
		||||
  const login = async (username, password) => {
 | 
			
		||||
    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();
 | 
			
		||||
 | 
			
		||||
      const data = await response.json()
 | 
			
		||||
			if (response.ok) {
 | 
			
		||||
				// Check if TFA is required
 | 
			
		||||
				if (data.requiresTfa) {
 | 
			
		||||
					return { success: true, requiresTfa: true };
 | 
			
		||||
				}
 | 
			
		||||
 | 
			
		||||
      if (response.ok) {
 | 
			
		||||
        setToken(data.token)
 | 
			
		||||
        setUser(data.user)
 | 
			
		||||
        localStorage.setItem('token', data.token)
 | 
			
		||||
        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' }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
				// Regular successful login
 | 
			
		||||
				setToken(data.token);
 | 
			
		||||
				setUser(data.user);
 | 
			
		||||
				localStorage.setItem("token", data.token);
 | 
			
		||||
				localStorage.setItem("user", JSON.stringify(data.user));
 | 
			
		||||
 | 
			
		||||
  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')
 | 
			
		||||
      localStorage.removeItem('permissions')
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
				// Fetch user permissions after successful login
 | 
			
		||||
				const userPermissions = await fetchPermissions(data.token);
 | 
			
		||||
				if (userPermissions) {
 | 
			
		||||
					setPermissions(userPermissions);
 | 
			
		||||
				}
 | 
			
		||||
 | 
			
		||||
  const updateProfile = async (profileData) => {
 | 
			
		||||
    try {
 | 
			
		||||
      const response = await fetch('/api/v1/auth/profile', {
 | 
			
		||||
        method: 'PUT',
 | 
			
		||||
        headers: {
 | 
			
		||||
          'Authorization': `Bearer ${token}`,
 | 
			
		||||
          'Content-Type': 'application/json',
 | 
			
		||||
        },
 | 
			
		||||
        body: JSON.stringify(profileData),
 | 
			
		||||
      })
 | 
			
		||||
				return { success: true };
 | 
			
		||||
			} else {
 | 
			
		||||
				return { success: false, error: data.error || "Login failed" };
 | 
			
		||||
			}
 | 
			
		||||
		} catch {
 | 
			
		||||
			return { success: false, error: "Network error occurred" };
 | 
			
		||||
		}
 | 
			
		||||
	};
 | 
			
		||||
 | 
			
		||||
      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) {
 | 
			
		||||
        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 (error) {
 | 
			
		||||
      return { success: false, error: 'Network error occurred' }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
	const updateProfile = async (profileData) => {
 | 
			
		||||
		try {
 | 
			
		||||
			const response = await fetch("/api/v1/auth/profile", {
 | 
			
		||||
				method: "PUT",
 | 
			
		||||
				headers: {
 | 
			
		||||
					Authorization: `Bearer ${token}`,
 | 
			
		||||
					"Content-Type": "application/json",
 | 
			
		||||
				},
 | 
			
		||||
				body: JSON.stringify(profileData),
 | 
			
		||||
			});
 | 
			
		||||
 | 
			
		||||
  const changePassword = async (currentPassword, newPassword) => {
 | 
			
		||||
    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();
 | 
			
		||||
 | 
			
		||||
      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) {
 | 
			
		||||
        return { success: true }
 | 
			
		||||
      } else {
 | 
			
		||||
        return { success: false, error: data.error || 'Password change failed' }
 | 
			
		||||
      }
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      return { success: false, error: 'Network error occurred' }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
	const changePassword = async (currentPassword, newPassword) => {
 | 
			
		||||
		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 isAuthenticated = () => {
 | 
			
		||||
    return !!(token && user)
 | 
			
		||||
  }
 | 
			
		||||
			const data = await response.json();
 | 
			
		||||
 | 
			
		||||
  const isAdmin = () => {
 | 
			
		||||
    return user?.role === 'admin'
 | 
			
		||||
  }
 | 
			
		||||
			if (response.ok) {
 | 
			
		||||
				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 hasPermission = (permission) => {
 | 
			
		||||
    return permissions?.[permission] === true
 | 
			
		||||
  }
 | 
			
		||||
	const isAdmin = () => {
 | 
			
		||||
		return user?.role === "admin";
 | 
			
		||||
	};
 | 
			
		||||
 | 
			
		||||
  const canViewDashboard = () => hasPermission('canViewDashboard')
 | 
			
		||||
  const canViewHosts = () => hasPermission('canViewHosts')
 | 
			
		||||
  const canManageHosts = () => hasPermission('canManageHosts')
 | 
			
		||||
  const canViewPackages = () => hasPermission('canViewPackages')
 | 
			
		||||
  const canManagePackages = () => hasPermission('canManagePackages')
 | 
			
		||||
  const canViewUsers = () => hasPermission('canViewUsers')
 | 
			
		||||
  const canManageUsers = () => hasPermission('canManageUsers')
 | 
			
		||||
  const canViewReports = () => hasPermission('canViewReports')
 | 
			
		||||
  const canExportData = () => hasPermission('canExportData')
 | 
			
		||||
  const canManageSettings = () => hasPermission('canManageSettings')
 | 
			
		||||
	// Permission checking functions
 | 
			
		||||
	const hasPermission = (permission) => {
 | 
			
		||||
		// If permissions are still loading, return false to show loading state
 | 
			
		||||
		if (permissionsLoading) {
 | 
			
		||||
			return false;
 | 
			
		||||
		}
 | 
			
		||||
		return permissions?.[permission] === true;
 | 
			
		||||
	};
 | 
			
		||||
 | 
			
		||||
  const value = {
 | 
			
		||||
    user,
 | 
			
		||||
    token,
 | 
			
		||||
    permissions,
 | 
			
		||||
    isLoading,
 | 
			
		||||
    login,
 | 
			
		||||
    logout,
 | 
			
		||||
    updateProfile,
 | 
			
		||||
    changePassword,
 | 
			
		||||
    refreshPermissions,
 | 
			
		||||
    isAuthenticated,
 | 
			
		||||
    isAdmin,
 | 
			
		||||
    hasPermission,
 | 
			
		||||
    canViewDashboard,
 | 
			
		||||
    canViewHosts,
 | 
			
		||||
    canManageHosts,
 | 
			
		||||
    canViewPackages,
 | 
			
		||||
    canManagePackages,
 | 
			
		||||
    canViewUsers,
 | 
			
		||||
    canManageUsers,
 | 
			
		||||
    canViewReports,
 | 
			
		||||
    canExportData,
 | 
			
		||||
    canManageSettings
 | 
			
		||||
  }
 | 
			
		||||
	const canViewDashboard = () => hasPermission("can_view_dashboard");
 | 
			
		||||
	const canViewHosts = () => hasPermission("can_view_hosts");
 | 
			
		||||
	const canManageHosts = () => hasPermission("can_manage_hosts");
 | 
			
		||||
	const canViewPackages = () => hasPermission("can_view_packages");
 | 
			
		||||
	const canManagePackages = () => hasPermission("can_manage_packages");
 | 
			
		||||
	const canViewUsers = () => hasPermission("can_view_users");
 | 
			
		||||
	const canManageUsers = () => hasPermission("can_manage_users");
 | 
			
		||||
	const canViewReports = () => hasPermission("can_view_reports");
 | 
			
		||||
	const canExportData = () => hasPermission("can_export_data");
 | 
			
		||||
	const canManageSettings = () => hasPermission("can_manage_settings");
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <AuthContext.Provider value={value}>
 | 
			
		||||
      {children}
 | 
			
		||||
    </AuthContext.Provider>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
	// Check if any admin users exist (for first-time setup)
 | 
			
		||||
	const checkAdminUsersExist = useCallback(async () => {
 | 
			
		||||
		try {
 | 
			
		||||
			const response = await fetch("/api/v1/auth/check-admin-users", {
 | 
			
		||||
				method: "GET",
 | 
			
		||||
				headers: {
 | 
			
		||||
					"Content-Type": "application/json",
 | 
			
		||||
				},
 | 
			
		||||
			});
 | 
			
		||||
 | 
			
		||||
			if (response.ok) {
 | 
			
		||||
				const data = await response.json();
 | 
			
		||||
				setNeedsFirstTimeSetup(!data.hasAdminUsers);
 | 
			
		||||
				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 = () => {
 | 
			
		||||
  const context = useContext(ThemeContext)
 | 
			
		||||
  if (!context) {
 | 
			
		||||
    throw new Error('useTheme must be used within a ThemeProvider')
 | 
			
		||||
  }
 | 
			
		||||
  return context
 | 
			
		||||
}
 | 
			
		||||
	const context = useContext(ThemeContext);
 | 
			
		||||
	if (!context) {
 | 
			
		||||
		throw new Error("useTheme must be used within a ThemeProvider");
 | 
			
		||||
	}
 | 
			
		||||
	return context;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const ThemeProvider = ({ children }) => {
 | 
			
		||||
  const [theme, setTheme] = useState(() => {
 | 
			
		||||
    // Check localStorage first, then system preference
 | 
			
		||||
    const savedTheme = localStorage.getItem('theme')
 | 
			
		||||
    if (savedTheme) {
 | 
			
		||||
      return savedTheme
 | 
			
		||||
    }
 | 
			
		||||
    // Check system preference
 | 
			
		||||
    if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
 | 
			
		||||
      return 'dark'
 | 
			
		||||
    }
 | 
			
		||||
    return 'light'
 | 
			
		||||
  })
 | 
			
		||||
	const [theme, setTheme] = useState(() => {
 | 
			
		||||
		// Check localStorage first, then system preference
 | 
			
		||||
		const savedTheme = localStorage.getItem("theme");
 | 
			
		||||
		if (savedTheme) {
 | 
			
		||||
			return savedTheme;
 | 
			
		||||
		}
 | 
			
		||||
		// Check system preference
 | 
			
		||||
		if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
 | 
			
		||||
			return "dark";
 | 
			
		||||
		}
 | 
			
		||||
		return "light";
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    // Apply theme to document
 | 
			
		||||
    if (theme === 'dark') {
 | 
			
		||||
      document.documentElement.classList.add('dark')
 | 
			
		||||
    } else {
 | 
			
		||||
      document.documentElement.classList.remove('dark')
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    // Save to localStorage
 | 
			
		||||
    localStorage.setItem('theme', theme)
 | 
			
		||||
  }, [theme])
 | 
			
		||||
	useEffect(() => {
 | 
			
		||||
		// Apply theme to document
 | 
			
		||||
		if (theme === "dark") {
 | 
			
		||||
			document.documentElement.classList.add("dark");
 | 
			
		||||
		} else {
 | 
			
		||||
			document.documentElement.classList.remove("dark");
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
  const toggleTheme = () => {
 | 
			
		||||
    setTheme(prevTheme => prevTheme === 'light' ? 'dark' : 'light')
 | 
			
		||||
  }
 | 
			
		||||
		// Save to localStorage
 | 
			
		||||
		localStorage.setItem("theme", theme);
 | 
			
		||||
	}, [theme]);
 | 
			
		||||
 | 
			
		||||
  const value = {
 | 
			
		||||
    theme,
 | 
			
		||||
    toggleTheme,
 | 
			
		||||
    isDark: theme === 'dark'
 | 
			
		||||
  }
 | 
			
		||||
	const toggleTheme = () => {
 | 
			
		||||
		setTheme((prevTheme) => (prevTheme === "light" ? "dark" : "light"));
 | 
			
		||||
	};
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <ThemeContext.Provider value={value}>
 | 
			
		||||
      {children}
 | 
			
		||||
    </ThemeContext.Provider>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
	const value = {
 | 
			
		||||
		theme,
 | 
			
		||||
		toggleTheme,
 | 
			
		||||
		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